尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

排序算法深度解析:从比较模型到工程实践与面试考点

排序算法深度解析:从比较模型到工程实践与面试考点 面试里有个问题特别能暴露一个人的底子“你讲一下排序。”看似谁都能说两句但大多数人只能说出冒泡和快排再往下就开始含糊。排序是数据结构课程里少有的、既有严格数学下界、又和工程实现深度绑定的主题从408考研到日常写SQL、调前端表格它一直都在。这篇文章我会从分类体系讲起把七大经典比较排序、快排的进阶优化、三种线性非比较排序、工程库里的选型逻辑以及考研面试常考的证明方法全部串一遍适合正在复习数据结构的学生、准备算法面试的开发者以及那些“天天用sort却不知道sort背后在做什么”的工程实践者。1. 排序的底层逻辑比较模型的天花板、稳定性与被忽略的“原地性”在讲具体算法之前先建立一个坐标系。很多人学排序是“逐个背代码”背完就忘因为脑子里没有那张地图。其实排序算法一共就分两大类比较排序和非比较排序。这个分类不是凭空的它决定了算法复杂度的天花板也决定了你为什么有时候能做出O(n)的排序、有时候再努力也只能是O(n log n)。1.1 比较排序为什么有O(n log n)的天花板比较排序指的是那种“通过两个元素之间的比较来决定先后顺序”的算法冒泡、插入、选择、快排、堆排、归并都属于这一类。这里有一个很震撼的结论任何基于比较的排序在最坏情况下都不可能低于O(n log n)。这个结论不是经验之谈是信息论给的硬约束。n个互不相同的元素可能的排列方式是n!种。每一次比较相当于在两个可能的排列之间做一次二选一也就是把问题空间砍掉一半。如果有n!种排列最坏情况下至少需要log2(n!)次比较才能唯一确定真实的顺序。用斯特林公式展开一下log2(n!)约等于n log2 n。所以所有基于比较的排序算法平均复杂度和最坏复杂度的下界就被钉死在这个位置。这个结论有什么实际意义它告诉你快排、归并、堆排的O(n log n)已经是比较排序里的“满级”了不需要再妄想一个O(n)的比较排序存在。也因此“非比较排序”才有了存在的必要和价值。1.2 稳定性不是理论洁癖是工程刚需稳定性说的是如果两个元素的值相等排序之后它们的相对顺序会不会变。会变就是不稳定的不会变就是稳定的。有人觉得这个性质无所谓反正值相等谁在前谁在后不都一样吗不是。真实的排序场景里“相等”往往是针对某一个字段而言的但记录本身还有很多别的信息。经典的例子是你有一张学生表先按学号排好序再按成绩排序。如果第二次排序不稳定那么同分的学生内部学号顺序就会乱掉而你原本是想让同分的人按学号升序展示的。这个需求不是假的多关键字排序到处都在用。SQL里的ORDER BY score DESC, id ASC本质上就是依赖每趟排序的稳定性。基数排序能成立更是因为内部用的排序必须是稳定的这个点到第4章还会再讲。1.3 原地性与外部排序排序不只是内存里的事还有一个维度常被忽略“原地性”也就是额外空间使用量。插入、冒泡、选择、堆排、快排普通版本都是原地排序额外空间是O(1)或者O(log n)。归并排序就不一样它需要O(n)的辅助空间。这个差异在某些场景下是致命的如果待排序数据有100GB而内存只有8GB任何需要O(n)辅助空间的算法都直接出局。“外部排序”这个词可能很多人只在考试里见过。它就是为超大数据量设计的核心思路是把大文件拆成若干能放进内存的小块每块内部排序后写回磁盘再用归并的方式逐步合并。你会发现归并排序不只是考试里的分治标本它还是外部排序的基石。数据量一大合并有序序列这个操作就是一切。2. 逐一拆解经典比较排序插入类、交换类、选择类与归并范式建立好坐标系之后该面对具体算法了。我按“思考逻辑”而不是“热度”来分组插入类靠“增量”交换类靠“相邻/双向交换”选择类靠“挑最小”归并靠“分治合流”。然后把快排单独放进下一章因为它值得更大篇幅。2.1 插入排序打扑克牌里诞生的增量算法插入排序的思路一句话把新元素插入到已经有序的序列里。你打扑克抓牌时把新抓的牌插到手里正确的位置就是这个过程。void insertion_sort(int a[], int n) { for (int i 1; i n; i) { int key a[i]; int j i - 1; while (j 0 a[j] key) { a[j 1] a[j]; j--; } a[j 1] key; } }这段代码里有几个值得琢磨的地方。第一它把“后移”和“最终插入”拆开了先用key保存当前元素前面比key大的全部后移一位最后把key放进空出来的位置。第二它的最好情况是O(n)——如果序列已经有序每次while循环第一轮就失败只做n-1次比较。第三它的比较次数就是逆序对数量级别的最坏情况下输入完全逆序比较和移动次数都是n(n-1)/2。关于循环不变量这个算法的证明非常经典外层循环第i轮结束时子数组a[0..i]中元素已排好序且保持它们在原数组中的相对顺序。初始化时i0单个元素当然有序保持时因为每次把key插入到正确位置a[0..i]有序终止时in-1全数组有序。插入排序的真实价值在于“几乎有序”的数据。你维护一个在线排行榜新数据不断加入但老数据基本有序插入排序就是最合适的因为它可以边到达边插入不需要等全部数据齐了再开始。2.2 希尔排序给插入排序先做“粗调”希尔排序是插入排序的升级版核心直觉是插入排序在数据接近有序时很快但“把远距离的逆序元素一步步挪过去”很慢。所以希尔排序先用大间隔gap把元素分组组内做插入排序让元素能够“跨大步”地接近它该在的位置然后逐步缩小gap最后gap1时整个数组已经“基本有序”再做一次普通插入排序收尾。void shell_sort(int a[], int n) { for (int gap n / 2; gap 0; gap / 2) { for (int i gap; i n; i) { int key a[i], j i - gap; while (j 0 a[j] key) { a[j gap] a[j]; j - gap; } a[j gap] key; } } }注意里层循环不是只对gap个独立子序列做插入排序而是从gap开始逐个元素往前走相当于把所有子序列的插入排序“交织”在一起这样代码干净且缓存更友好。希尔排序的时间复杂度跟gap序列强相关。gap每次除以2这种简单策略最坏是O(n^2)用Knuth序列1, 4, 13, 40, 121...可以把最坏降到O(n^(3/2))更复杂的Sedgewick序列能到O(n^(4/3))左右。这说明一个道理同一个算法框架参数设计错了性能天差地别。2.3 冒泡排序教学价值大于工程价值冒泡排序人人都会从头到尾相邻比较大的往后冒每轮至少把一个最大值送到最终位置。void bubble_sort(int a[], int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { swap(a[j], a[j 1]); swapped 1; } } if (!swapped) break; } }很多教材没强调那个swapped标志。有了它如果某轮没有任何交换说明数组已经有序可以直接终止最好情况变成O(n)。这也是冒泡排序唯一值得被记住的优化点。但说实话它的交换次数太多每轮都可能进行大量无效的相邻交换工程里基本没人用。它存在的意义是培养“比较-交换”的直觉尤其适合初学者理解稳定排序的含义冒泡只和相邻元素交换相等元素永不跨过对方所以它是稳定的。2.4 简单选择排序最慢但最好理解的“挑最小”选择排序的思路就是每一轮从剩余元素里挑出最小的放到当前正在填充的位置。void selection_sort(int a[], int n) { for (int i 0; i n - 1; i) { int min_idx i; for (int j i 1; j n; j) { if (a[j] a[min_idx]) min_idx j; } if (min_idx ! i) swap(a[i], a[min_idx]); } }它的优点是交换次数少最多只有n-1次交换适用于那种“一次交换代价极高”的场景。缺点是无论数据是什么样子比较次数都固定为n(n-1)/2不给任何好脸色。还有一个容易踩的坑它不稳定。看这行代码如果最小的元素在某个相等元素的后面swap会把后面那个元素换到前面破坏相对顺序。所以选择排序的“简单”是有代价的多关键字排序时用它会出问题。2.5 堆排序用完全二叉树做选择排序的加速器前面说选择排序慢就慢在每轮都要扫描剩余全部元素找最小值。堆排序就是为了解决“扫描太慢”这个问题用一个堆来维护剩余元素的最小值每次取堆顶O(1)删除堆顶后调整O(log n)总复杂度O(n log n)。堆可以完全存在数组里不需要真的建一棵树。关键操作是shiftDown。建堆不需要一个个插入而是从最后一个非叶子节点开始向下调整整体建堆复杂度是O(n)。这个结论可能反直觉但计算一下就知道高度为h的节点最多n/2^(h1)个每个节点的向下调整代价是O(h)求和结果是O(n)。void heapify(int a[], int n, int i) { int largest i; int l 2 * i 1, r 2 * i 2; if (l n a[l] a[largest]) largest l; if (r n a[r] a[largest]) largest r; if (largest ! i) { swap(a[largest], a[i]); heapify(a, n, largest); } } void heap_sort(int a[], int n) { for (int i n / 2 - 1; i 0; i--) heapify(a, n, i); for (int i n - 1; i 0; i--) { swap(a[0], a[i]); heapify(a, i, 0); } }堆排序的工程地位很微妙它最坏也是O(n log n)不像快排有退化风险但它对缓存非常不友好数组访问是跳跃式的而且不稳定。它真正的舞台是“你需要一个稳定的最坏情况O(n log n)又不想开O(n)辅助空间”的时候以及Top-K问题里“维护大小为K的堆”几乎是标准操作。2.6 归并排序分治与合并的教科书模板归并排序的思路简单到让人怀疑把数组对半拆拆到只剩一个元素然后再两两合并有序序列。void merge(int a[], int l, int mid, int r) { int n1 mid - l 1, n2 r - mid; int L[n1], R[n2]; for (int i 0; i n1; i) L[i] a[l i]; for (int i 0; i n2; i) R[i] a[mid 1 i]; int i 0, j 0, k l; while (i n1 j n2) { if (L[i] R[j]) a[k] L[i]; else a[k] R[j]; } while (i n1) a[k] L[i]; while (j n2) a[k] R[j]; }它的时间复杂度稳定在O(n log n)而且稳定。代价是需要O(n)的辅助空间这在高负载场景里可能成为瓶颈。归并排序的另一个价值是它“自上而下拆、自下而上合”的思想启发了外部排序以及后面要讲的TimSort。写归并排序时最容易错的是边界mid (l r) / 2时左半是[l, mid]右半是[mid1, r]左右区间不能重叠也不能漏。这个“区间划分”的细节几乎每本教材的勘误表里都有读者踩坑记录。3. 快速排序的进阶战场枢纽选择、重复元素与混合策略快排单独占一章不是因为它的基础版本有多难而是因为它在工程和面试里的地位和其他算法完全不在一个量级。理解了快排你就理解了大部分sort函数的内部世界。3.1 为什么所有默认排序函数都围绕快排转先看一个基础版快排用的Lomuto分区法代码很直观int partition(int a[], int l, int r) { int pivot a[r]; int i l - 1; for (int j l; j r; j) { if (a[j] pivot) { i; swap(a[i], a[j]); } } swap(a[i 1], a[r]); return i 1; } void quick_sort(int a[], int l, int r) { if (l r) return; int p partition(a, l, r); quick_sort(a, l, p - 1); quick_sort(a, p 1, r); }快排的平均复杂度是O(n log n)但它被工程界青睐的核心原因不是这个——归并和堆也有这个复杂度。真正的原因是快排的平均常数特别小而且它操作数据时是顺序扫描的局部访问CPU缓存命中率比堆排高很多。堆排序虽然理论复杂度好看但“理论上最快”和“实际上最快”从来不是一回事。3.2 枢纽pivot选不好快排会变“慢排”快排最怕的是“每次分区只分掉一个元素”。如果待排序数组已经有序你每次都选最右端元素当pivot那么每次partition都把一个元素放到正确位置剩下O(n-1)个继续递归递归深度O(n)总时间直接O(n^2)。攻击者甚至可以利用这个特性来构造数据集把排序库拖慢——历史上确实有C标准库某版本因此被DoS的例子。常规解法有两个。第一是随机选pivot让最坏情况的输入变得极难构造。第二是三数取中从区间的左端、中间、右端取三个元素用它们的中位数当pivot。三数取中的效果不只是防退化它还能明显改善随机数据的平衡度。我在实际项目中很少写裸快排最少也是“三数取中递归深度限制”的组合。3.3 双路和三路快排如何拯救满是重复元素的数组如果数组里有大量重复元素比如一亿个元素里九成都是同一个值普通快排会退化得很厉害和pivot相等的元素被随机分到左边或右边分区依然不平衡。这时候需要三路快排。三路快排把数组分成三块小于pivot、等于pivot、大于pivot递归只处理小于和大于的部分等于的部分直接跳过。一个经典写法是循环不变量驱动的void quick3(int a[], int l, int r) { if (l r) return; int lt l, gt r, i l 1; int pivot a[l]; while (i gt) { if (a[i] pivot) swap(a[lt], a[i]); else if (a[i] pivot) swap(a[i], a[gt--]); else i; } quick3(a, l, lt - 1); quick3(a, gt 1, r); }这里lt表示“小于区的右边界”gt表示“大于区的左边界”i是当前扫描位置。写完这段我自己调试过很久核心要领是指针移动的三种情况里只有“当前元素小于pivot”时i才前进等于时i前进但lt不动大于时i不动但要交换到gt。这个函数还有个衍生考点就是著名的荷兰国旗问题——给你红白蓝三种球乱序排列用一次O(n)扫描排成红白蓝顺序本质就是三路分区的简化版。3.4 introsort量子速读C sort的兜底策略工程排序库不会只靠快排。C标准库里的std::sort主流实现是introsort它用快排为主但在递归深度超过某个阈值时切换成堆排序因为递归太深说明分区质量很差继续快速排序极可能退化成O(n^2)而堆排序能保证O(n log n)。同时当分区后的小区间长度小于某个阈值比如16时直接改用插入排序。这个“混合策略”是排序工程化的教科书级示范用快排的平均高性能用堆排弥补最坏情况用插入排序处理小区间减少递归调用。学排序如果只学单算法、不理解这种组合思路很难说真正理解了工程排序。3.5 小区间插入排序与递归深度控制为什么小区间要用插入排序因为快排的递归是函数调用每次递归都有栈帧开销而插入排序在小数组上的常数非常小。递归到16个元素时剩余的“几乎有序”的小碎片用插入排序几下就排完了。实验数据表明cutoff取10到20之间通常效果最好取太大了反而会因为插入排序的O(n^2)特性拖慢整体。还有一点容易忽略手动栈还是递归。如果你在嵌入式环境或者面试时被要求迭代实现快排你要用显式栈保存待排序区间本质是把递归转成循环。这个写法的难点同样是区间边界管理每次弹出区间后分区再按顺序把子区间压栈。想清楚“先压哪个、后压哪个”不影响正确性只影响处理顺序这个思想对理解递归和栈的关系很有帮助。4. 线性时间的非比较排序计数、基数与桶排的应用边界比较排序有O(n log n)的天花板但如果数据满足特定条件我们可以绕过比较模型直接利用数据的结构信息做到O(n)。这个“绕过”的思路是整章的核心。4.1 计数排序用桶换掉比较计数排序的前提非常严格数据必须是非负整数且值域k不能太大。方法是先数一遍每个值出现了多少次再算前缀和得到每个元素的最终位置最后倒序扫描原数组放回结果数组。def counting_sort(arr, k): n len(arr) count [0] * (k 1) out [0] * n for x in arr: count[x] 1 for i in range(1, k 1): count[i] count[i - 1] for x in reversed(arr): count[x] - 1 out[count[x]] x return out为什么最后要倒序扫描这是计数排序保持稳定性最关键的一步。前缀和之后count[x]表示“小于等于x的元素数量”也就是x应该占据的最后一个位置。倒序扫描时相同值的元素会从后往前依次填入它们的相对顺序就被保住了。如果正序扫稳定性就丢了。时间复杂度O(nk)空间O(k)。典型应用是年龄排序、成绩排序这种值域几十上百的场景。如果k10^9你不可能开一个十亿的数组所以计数值域必须可控。4.2 基数排序稳定排序的连环接力基数排序的思想是“多趟稳定的低位优先排序”。对非负整数先按个位做一次稳定排序再按十位做一次稳定排序依此类推最高位完成后整个数组就有序了。为什么每趟必须稳定因为个位排完后十位相同的元素之间需要保持个位的相对顺序如果某趟排序不稳定前面所有趟的成果都被冲掉了。实现时每趟内部通常用计数排序来保证线性时间。总复杂度是O(d(nr))d是位数r是基数的大小。如果你用256做基数一次处理8位一个32位整数只需要4趟。这就是字符串排序、日期排序、长整数排序的底层方案之一。关于r的选择r越大每趟的计数数组越大但趟数d越小。权衡点在于内存带宽和cache实践中取256常常是个不错的折中。4.3 桶排序均匀数据下的线性排序桶排序更适合“浮点数”这类值域连续的数据。思路是把值域切成k个区间把数据分到k个桶里每个桶内部用插入排序或其他排序最后按桶顺序输出。它的时间复杂度分析依赖数据分布数据均匀分布时每个桶内的数据量大约是n/k总复杂度接近O(n)数据集中分布时所有数据挤进一个桶复杂度退化为内部排序的复杂度可能变O(n^2)。一个很自然的应用是考试成绩0到100分你切成10个桶每个桶内部排一下比全局快排快不少。前端做柱状图排序、数据分析里对浮点数组预分桶思路也是一样的。但桶排序有个麻烦点桶内元素数量不能预先确定需要动态扩容或用链表实现比计数排序脏一些。4.4 什么时候才该用非比较排序三个实用判断一数据类型是否可离散化整数、定长字符串、日期可以任意浮点数先要分桶。二值域是否可控排序100万个0到99的整数计数排序秒杀快排排序100万个0到10^18的整数计数排序直接内存爆炸。三是否存在大规模重复大量重复元素时三路快排已经能到接近线性非比较排序的“线性”优势会被缩小。很多教材把非比较排序讲成“快排的替代品”这是误导。实际工程里它更像“特定数据集下的特种部队”知道何时用、何时别用比会写更重要。5. 工程代码库里的排序真相从sort函数到SQL排序的暗坑真正写业务代码时很少有人自己手写排序但这不代表排序和你无关。复杂的是“哪个sort在跑我的数据”和“为什么数据库排序这么慢”。5.1 C的qsort与C的sort函数指针与模板内联的分水岭C语言的qsort是快排的标准库实现但它接受一个函数指针作为比较器。每次比较都是一次间接函数调用这个开销在数据量大时非常可观。我记得有人在百万级数据上测过qsort比手写比较逻辑还要慢不少。C的std::sort则不同它是模板函数比较器在编译期就内联展开不存在运行时函数指针跳转。加上introsort的混合策略同样是“标准库快排”真实性能能差出一个数量级。所以如果你是C用户别自己手写快排直接用std::sort就好如果你必须用Cqsort能用但要意识到它的性能上限。5.2 Java和Python为什么默认用TimSortJava的Arrays.sort对不同类型采取了完全不同的策略基本类型用双基准快排因为基本类型没有稳定性需求对象类型用TimSort因为对象排序通常依赖多个字段稳定性是硬需求。Python内置排序也是TimSort。TimSort是归并排序的工程化改良版。它先把数组拆分成一个个天然有序的run短的run用插入排序拉长然后按规则合并这些run。它的优势极其明显如果数组本来就有部分有序结构TimSort的复杂度可能远低于O(n log n)甚至接近O(n)。这对真实世界的数据来说价值巨大因为业务数据几乎从不随机通常都存在明显的局部有序性。我后来才想明白一件事为什么很多面试题强调“排序算法要稳定”因为实际生产中对象排序的稳定性就是多字段排序正确性的一道保险。Java官方替你想好了Python也替你想好了反而是不少自以为自己懂排序的人在项目里自己实现选择排序把数据顺序排乱了。5.3 SQL ORDER BY与ORM别名排序索引才是亲爹数据库里的排序最核心的一条规则是能走索引就尽量走索引。B树索引的叶子节点本身就是有序的MySQL如果发现ORDER BY字段正好有可用索引直接按索引顺序扫描返回不需要任何排序。而一旦没有索引引擎就要用filesort先把匹配的行放入sort buffer在内存或磁盘上做排序数据量超过sort buffer大小时要写临时文件再做归并慢得肉眼可见。我在业务系统里处理过一个慢查询ORDER BY created_at DESC LIMIT 50表里有几百万行每次查询将近一秒。加上(user_id, created_at)的联合索引后直接秒回。原因就是索引让MySQL连排序都不做了。所以SQL排序优化第一优先级永远是索引设计而不是去换什么高级排序算法。ORM层面的坑也不能忽视。拿Sequelize举例如果你在include里的模型上用别名排序直接写order: [[alias, ASC]]框架可能会把这个名字当成模型的字段名去转义四川排序结果完全不对。常用的办法是order: sequelize.literal(COALESCE(alias, 0) ASC)或者用fn和col组合。核心原则是当排序字段不是简单的模型属性而是表达式、别名或嵌套属性时要把“排序表达式”明确交给SQL引擎处理而不是让ORM猜测。5.4 表格头排序、pandas与字符串排序数据处理场景的同款问题前端点击表头排序是另一个翻车高发区。最常见的是默认的Array.prototype.sort()把数字字符串按字典序排10排在2前面因为在字符串比较中“1”小于“2”。解决方法是显式用(a, b) Number(a) - Number(b)。还有一个容易被忽视的点多列排序时需要序列中的每一步排序都是稳定的。前端很多排序方法是稳定的但pandas默认不是。df.sort_values默认使用quicksort它不是稳定排序。如果你需要按A列排完再按B列排并且希望A的优先级体现在最终结果里应该明确指定kindmergesort或使用stable参数否则第二次排序可能打乱第一次相同组的顺序。字符串排序就更隐蔽了。你以为是“按字符集的码点排”其实数据库是按校对规则collation排的不同collation对中文、大小写、重音字符的处理完全不一样。两个看起来一样的中文排序需求在utf8mb4_general_ci和utf8mb4_unicode_ci下结果可能不同。做多语言平台时字符串排序规则最好明确写入配置不要靠默认值猜。5.5 数据库索引与B树为什么有序结构能白送排序为什么数据库一提到排序就绕不开索引因为B树的叶子节点用链表串起来天然有序。插入时维护有序性的开销由索引自己承担查询时你可以免费享受顺序遍历。这也是数据结构知识面最“出圈”的时刻学排序不只是会写几行算法而是要明白“一旦数据维护成有序结构读的时候就省掉排序这一步”。数据结构的各种树、堆、跳表、链表的本质之一就是在写的时候多费一点工读的时候省一大笔钱。理解了这一点你再看各种“排序优化最佳实践”本质都不是什么高深的算法而是在恰当的场景里选择了恰当的有序结构。6. 考研与面试视角复杂度速查、不变量证明与我的学习建议这一章是给正在备考和准备面试的人准备的也是全文最后一块拼图。前面的内容如果说是“要把排序讲明白”这里就是“怎么把学到的讲给别人听、写在卷子上”。6.1 一张表终结复杂度与稳定性记忆先放总表。这张表背下来不难难的是理解为什么是这些数。排序算法最好时间平均时间最坏时间辅助空间稳定性插入排序O(n)O(n^2)O(n^2)O(1)稳定希尔排序O(n log n)附近与步长序列有关O(n^2)或O(n^(3/2))O(1)不稳定冒泡排序O(n)O(n^2)O(n^2)O(1)稳定选择排序O(n^2)O(n^2)O(n^2)O(1)不稳定快排O(n log n)O(n log n)O(n^2)O(log n)不稳定堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定归并排序O(n log n)O(n log n)O(n log n)O(n)稳定计数排序O(nk)O(nk)O(nk)O(k)稳定基数排序O(d(nr))O(d(nr))O(d(nr))O(nr)稳定桶排序O(nk)O(nk)O(n^2)O(nk)看内部排序记忆技巧不稳定家族就是“选择、希尔、快排、堆排”这四个口诀可以是“选块堆奇”。其余的插入、冒泡、归并都是稳定的。为什么它们稳定因为它们都只调整相邻元素的顺序不会跨过相等元素。6.2 循环不变量证明示范以选择排序为例这部分直接回应很多考研资料里“clrs选择排序循环不变量证明”的高频考点。循环不变量证明分三步初始化、保持、终止。以选择排序为例循环不变量的形式是在外层循环每次迭代开始时子数组a[0..i-1]已经排好序并且其中所有元素都不大于剩余子数组a[i..n-1]中的任何元素。初始化i0时a[0..-1]是空数组条件自然成立。保持假设迭代开始时条件成立循环体会在a[i..n-1]中找出最小值minIdx并与a[i]交换。于是a[0..i]中a[i]是剩余部分的最小值整段仍然有序且不大于后面剩余元素。下一次迭代前条件对新的i成立。终止in时a[0..n-1]已经全部排好算法正确结束。这套证明模板能用在很多地方。归并排序的分治正确性可以用主定理和循环不变量组合证明插入排序的不变量和选择排序类似但表述是“前j个已排序”。面试时不用整段背能现场讲出“初始化、保持、终止”这个三段式就足够让面试官放心了。6.3 排序问题的高频考法手撕快排、Top-K、荷兰国旗、外部排序简单列一下我在笔试和面试里见过最多的问题类型。手撕快排是最高频的最好能做到闭着眼睛写出无bug的Lomuto分区或Hoare分区。Top-K问题经典解法是“堆维护”和“快排partition剪枝”后者是平均O(n)的quickselect思想。荷兰国旗问题就是三路快排的简化。外部排序考的是归并分阶段思路和磁盘IO优化。408考纲里图和数组部分也会和排序联动比如最小生成树算法要先把边按权重排序稀疏数组排序后做二分查找等。对于备考的人我的建议是不要只背代码。把“为什么外层循环是n-1次”“为什么内层循环的右边界是递减的”想清楚哪怕题目换一种语言、换一种数据结构你也照样能写出来。写实验报告的时候复杂度推导、边界条件测试用例有序、逆序、随机、大量重复这三样必须齐否则实验报告就是空壳。6.4 关于学习路线和踩坑的一些实在话如果只让我推荐一个顺序我会这样排先学插入排序和选择排序因为它们最贴近人的直觉再学冒泡和归并前者是稳定性的标本后者是分治的入门然后重点学快排和堆排这一对是面试的主力最后扩展希尔排序和三种非比较排序。我自己踩过最大的坑是快排partition的边界条件。写出的代码有时在数组长度为2时崩溃有时在重复元素上死循环。后来我给自己定了一条规矩分区区间一律用闭区间[l, r]移动指针时先处理右指针再处理左指针循环退出后记得判断指针越界。这套习惯帮我避免了很多脏调试。如果想再进阶可以看一眼并行排序领域比如Batcher的双调排序网络那是把排序大规模并行化的硬件友好方案。排序算法学的不是那几段代码而是“如何利用数据特征、结构、分治、并行”来组织信息。把这条线想通了后面学图算法、学数据库、学分布式框架都会顺很多。最后想说的是不要嫌弃那些“太简单”的排序。插入排序在近乎有序的数据上就是比快排快归并排序在稳定性需求面前是不可替代的选择排序让初学者第一次看到循环不变量是什么。每种排序都是一类思想在特定条件下的最优解。你真正掌握了它们各自“在什么条件下、为什么最优”才算是把排序这个主题吃透了。以后不管是在群里讨论算法题还是在代码评审时指出排序方案的隐患你都比当年那个只会冒泡的自己强了很多。
返回列表