1. 项目概述:为什么初阶必须死磕排序算法
排序算法,说它是数据结构与算法这门课里最“承上启下”的一块内容,一点都不夸张。你在牛客、LeetCode上刷题,十道题里至少有四道跟排序沾边;你写业务代码,订单列表要按时间倒序,商品要按销量排列,后台还要做TopN统计,这些全都离不开排序。而“七大排序算法”这个说法,基本就是国内教材和面试题库的默认共识:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序。这七个算法背下来不难,难的是真正理解它们背后的“初阶思想”——也就是那些最朴素的、从0到1的排序思路是怎么长出来的。
这篇文章的定位很明确:给正在学数据结构的学生、准备校招的应届生、以及工作了几年但基础不牢想回炉的开发者。我不打算堆一堆高大上的术语,而是用最直白的方式把这七个算法的思想脉络拆开,配C语言实现,讲清楚每个算法“为什么这么设计”“它解决了前一个算法的什么问题”“它又留下了什么缺陷”。这个过程走一遍,你会发现排序算法不是七个孤立的代码片段,而是一条从“简单粗暴”到“精巧分治”的演进路线。
先说一句我的个人观点,也是带过很多新人后得出的结论:初阶阶段不要追求背代码,要追求“手里有笔、能画过程”。面试官问你快排时间复杂度为什么是O(nlogn),你如果能用“每层分治扫一遍n个元素,一共logn层”来解释,比默写代码有用得多。下面我按这个思路把七种算法逐个拆开。
2. 七大排序的整体设计思路与分类逻辑
2.1 先搞清楚七种排序分别解决了什么问题
学习排序算法最忌讳的方式,就是今天背一个冒泡、明天背一个快排,背完就忘。我在带新人时,永远先让他们看一张“问题演进图”——不是那种花里胡哨的架构图,而是把七个算法看成对应七种不同困境的解决方案:
- 冒泡排序解决的是:新手能想到的最直观的排序方式是什么?答案是“相邻两个比较,大的往后挪”。
- 选择排序解决的是:能不能不搞那么多交换动作,每次都直接找到最小的放前面?于是有了“选择”。
- 插入排序解决的是:那打扑克牌时一张张插入到已排序手中的方法,能不能用到数组上?这就是“插入”。
- 希尔排序解决的是:插入排序在数组接近有序时很快,但整体乱序时慢,能不能先粗调再精调?于是发明了“分组+缩减增量”。
- 归并排序解决的是:两个有序数组怎么合并很简单,那我们能不能“分而治之”,先把大数组切成小段排好再合起来?这是分治思想第一次在排序里大放异彩。
- 快速排序解决的是:归并需要额外空间,能不能在原数组上做分治?于是有了“选基准、分区、递归”三板斧。
- 堆排序解决的是:能不能利用二叉树这种数据结构来排序?于是用堆来维护最大值,每次取走再调整。
这个角度看下来,七个算法不是在背“代码模板”,而是一个问题接一个问题地“打怪升级”。理解了这一点,你学后面的算法就有了抓手。
2.2 从时间复杂度和稳定性看七种算法的“性格”
在真正动手写代码前,先建立起一个宏观坐标系很重要。我一般会让新手把下面这张表抄在笔记第一页,每次写某个排序前先看一遍它属于哪个象限:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n^1.3~2) | O(n²) | O(1) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 快速排序 | O(nlogn) | O(n²) | O(logn) | 不稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
这里面需要特别强调的是“稳定性”这个概念。初学的人最容易忽略它,但它恰恰是实际业务里极其重要的指标。稳定性的意思是:如果数组中有两个相等的元素,排序后它们的相对顺序会不会改变。假设你有一个学生列表,先按学号排好,现在又要按成绩排序,如果排序算法不稳定,那么相同成绩的学生内部,学号顺序就可能被打乱。这就是为什么Java的Collections.sort对对象使用稳定的归并排序,而对基本类型使用不稳定的快排——基本类型无所谓“相对顺序”,对象有。
稳定性的记忆口诀我也分享一个:“插帽龟,归并稳”。插(插入)、帽(冒泡)、龟(归并)是稳定的,其余四个不稳定。记不住的朋友直接背这句口诀,至少面试时不会被问倒。
2.3 为什么初阶阶段要选这七个而不是别的
有人会问:不是还有桶排序、基数排序、计数排序吗?为什么不一起讲了?
我的回答是:这七个算法全部是基于“比较”的排序,它们的下界是O(nlogn),它们构成了一条从简单到复杂、从暴力到优雅的完整技术链。而桶排序、基数排序、计数排序属于“非比较排序”,它们另起炉灶,用空间换时间,在特定数据范围下可以达到O(n),思想和比较排序完全不同。初阶阶段如果混在一起学,很容易思路混乱。我建议先把七个比较排序吃透——因为它们考察的是“怎么排序”的核心思维,而非比较排序考察的更多是“怎么映射数据”,侧重点不一样,留到进阶再学更合适。
3. 入门三件套:冒泡、选择、插入
3.1 冒泡排序:最直观的“相邻交换”
冒泡排序的思路是:从头到尾遍历数组,依次比较相邻的两个元素,如果前一个比后一个大,就交换它们。一趟下来,最大的元素就像气泡一样“浮”到了数组末尾。然后缩小遍历范围,重复这个过程。
C语言实现很简洁:
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { // 每趟冒泡,末尾i个元素已经是排好序的 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } } }这里有个很实用的优化点:如果某一趟遍历中没有发生任何交换,说明数组已经有序,可以提前终止。加一个标志位就行:
void bubbleSortOptimized(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } if (!swapped) break; // 数组已经有序,提前结束 } }注意:这个优化在数组完全有序时效果显著,一趟扫描O(n)就结束了。但数据乱序时该O(n²)还是O(n²),别指望它会逆天改命。
冒泡排序的“性格”是稳定、原地、好写,但效率低。它唯一的教学价值就是让你理解“交换排序”这个概念,实际项目中基本不会用它。如果有人面试时主动提出给冒泡加分标志位优化,这算是一个小小的加分项,能看出候选人是否写过代码、想过优化。
3.2 选择排序:每次挑最小的放前面
选择排序的思路更“贪心”:第一趟扫描整个数组,找到最小值,把它和数组第一个元素交换;第二趟扫描从第二个元素开始的子数组,找到最小值,和第二个元素交换;依此类推。
void selectionSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIdx]) { minIdx = j; } } if (minIdx != i) { int tmp = arr[i]; arr[i] = arr[minIdx]; arr[minIdx] = tmp; } } }这里要提醒一个初学者容易被坑的点:选择排序是不稳定的。举个例子,数组[5a, 5b, 3](a和b表示两个相等的5,用于区分),第一趟找到最小值3,与第一个元素5a交换,变成[3, 5b, 5a]。你看,两个5的相对顺序从“a在前”变成了“b在前”,不稳定了。
为什么会这样?因为选择排序是“跨越式交换”——它挑出最小值后是和远处的某个位置交换,不是相邻交换,这一跳就可能把相同元素的相对顺序打乱。这个例子写出来,面试官如果追问为什么不稳定,你直接说“因为交换可能跨越相等元素”,比背结论强很多。
选择排序比冒泡好的地方在于交换次数少,每趟最多一次交换,总共最多n-1次交换。如果交换一个元素的开销很大(比如元素是复杂结构体),选择排序可能比冒泡更实用。
3.3 插入排序:像整理手里的扑克牌
插入排序的思路和打扑克牌时理牌一模一样:你手里的牌是从左到右有序的,新摸一张牌,从右往左找到它该插入的位置,然后把它插进去。
在数组上的实现方式是:从第二个元素开始,把当前元素记为key,往前扫描,凡是比key大的元素都往后挪一位,直到找到key该待的位置,放进去。
void insertionSort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; // 把比key大的元素往后移动 while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }插入排序的“初阶思想”价值极高,因为它是后面希尔排序的基础,也是很多高级算法(比如TimSort)在小区间排序时的首选。为什么?因为当数据规模小或者数组“接近有序”时,插入排序的效率非常惊人,基本可以做到接近O(n)的表现。很多生产环境的排序库,比如Python的Timsort、Java的DualPivotQuickSort,在小数组区间都会切到插入排序。
这里我提供一个简单验证:给一个已经基本有序的数组,比如只有个别元素错位,跑一次插入排序,你会发现内层while循环很少执行,整体几乎只做了n-1次比较就结束了。这就是“自适应排序”特性的力量。
实操心得:如果你自己实现排序库,务必要重视这个特性。我在写某些定制排序工具时,就曾因为直接快排处理小数组而性能不佳,后来改成“小数组用插入,大数组用快排”的混合策略,性能直接翻倍。
4. 进阶三剑客:希尔、归并、快速
4.1 希尔排序:插入排序的“跨步”升级
希尔排序的发明人是Donald Shell,它的核心洞察是:插入排序在“数组整体有序度较高”时效率极高,但在完全乱序时,元素要一步一步挪,效率很惨。那能不能先让数组“大致有序”,再精细地插入排序?
做法是:先选择一个增量gap,把数组按gap间隔分成若干组,对每组做插入排序;然后缩小gap,再分组排序;直到gap=1,做最后一次完整插入排序。
比较经典的增量序列是希尔原始版本:gap从n/2开始,每次gap/=2。下面我用n=8举个具体过程,方便理解:
初始数组:[9, 1, 5, 3, 7, 2, 8, 6]
- gap=4,分组:
[9,7],[1,2],[5,8],[3,6],组内排序后:[7,1,5,3,9,2,8,6] - gap=2,分组:
[7,5,9,8],[1,3,2,6],组内排序后:[5,1,7,3,8,2,9,6] - gap=1,全数组插入排序:
[1,2,3,5,6,7,8,9]
C语言实现:
void shellSort(int arr[], int n) { for (int gap = n / 2; gap > 0; gap /= 2) { // 对每个分组做插入排序,但步长是gap for (int i = gap; i < n; i++) { int key = arr[i]; int j = i - gap; while (j >= 0 && arr[j] > key) { arr[j + gap] = arr[j]; j -= gap; } arr[j + gap] = key; } } }注意看:内层的结构和插入排序几乎一模一样,只是把“步长1”换成了“步长gap”。所以希尔排序本质上就是插入排序的“跨步”版本。
关于增量序列,网上说法很多,我实际用的经验是:n/2这种折半缩减简单够用,面试和一般项目都不会出问题。但希尔排序的时间复杂度分析很复杂,平均大概是O(n^1.3)左右,最坏可以到O(n²)。如果面试官问“为什么希尔排序比普通插入排序快”,你要能说出“因为大gap让元素可以跨越式移动,很快就消除了大量逆序对,后面小gap阶段的插入排序成本大幅降低”。
注意:希尔排序不稳定,因为分组跨越可能让相同元素的相对位置变化。另外,gap的选取会影响性能,有些论文研究过最优增量序列,但初阶阶段不必死磕。
4.2 归并排序:最典型的分治思想
归并排序是“分治思想”在排序里的最佳教学案例。分治思想就是三步:分解(Divide)、解决(Conquer)、合并(Combine)。归并排序把数组从中间一分为二,递归地把左右两半分别排好序,然后再把两个有序数组合并成一个有序数组。
合并两个有序数组是归并排序的核心操作,思路也很朴素:两个指针分别指向两个数组的开头,谁小谁先放入结果数组,指针后移。这个操作的时间复杂度是O(n),因为每个元素只被比较一次就放进了结果数组。
// 合并arr[l..m]和arr[m+1..r] void merge(int arr[], int l, int m, int r) { int n1 = m - l + 1; int n2 = r - m; int L[n1], R[n2]; for (int i = 0; i < n1; i++) L[i] = arr[l + i]; for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j]; int i = 0, j = 0, k = l; while (i < n1 && j < n2) { if (L[i] <= R[j]) { arr[k++] = L[i++]; } else { arr[k++] = R[j++]; } } // 把剩余元素拷过去 while (i < n1) arr[k++] = L[i++]; while (j < n2) arr[k++] = R[j++]; } void mergeSort(int arr[], int l, int r) { if (l >= r) return; int m = l + (r - l) / 2; // 防溢出的写法,等价于(l+r)/2 mergeSort(arr, l, m); mergeSort(arr, m + 1, r); merge(arr, l, m, r); }我重点解释三个初学者常见的坑:
mid的计算:一定要写
l + (r - l) / 2,而不是(l + r) / 2。当l和r都很大的时候,l+r可能溢出整型范围,尤其是l和r接近INT_MAX时。这个细节在刷题时踩过坑的人应该深有体会。边界条件:递归退出的条件是
l >= r,不是l == r。虽然实际上递归过程中最多出现l==r,但写>=更健壮,防止某些边界调用出错。合并时的稳定性:归并排序是七大排序里最“稳”的稳定排序。关键在于合并时比较要用
L[i] <= R[j],而不是<。这样当左右相等时,优先取左边的元素,保证了相同元素的相对顺序不变。
归并排序的优点是稳定、时间稳定在O(nlogn);缺点是空间复杂度O(n),因为它需要额外的临时数组来合并。在内存敏感的嵌入式场景,这个缺点可能很致命。
实操心得:网上经常有人问“如何利用分治思想修改合并排序算法”,比如做链表排序、求逆序数对、外部排序(大文件排序)。这些题目的核心能力,本质上就是你有没有真正理解“分治”在归并里的作用——先把问题拆小,解决完再组合。我建议初学者先拿归并排序当“分治思想”的标准模板练手,背熟之后,很多分治类的题都能往这个框架上靠。
4.3 快速排序:最实用的原地分治方案
快排是七大排序里实战出场率最高的,也是面试最常考的一个。它的核心思想是:选定一个基准元素(pivot),把数组分成左右两部分,左边都小于等于基准,右边都大于等于基准;然后对左右两部分递归地做同样的事情。
这里要注意:快排的“分”和归并的“分”不一样。归并是先递归切分,在“合”的阶段做排序;快排是在“分”的阶段就做了核心排序动作,递归返回时,数组已经有序了。
经典的实现方式有两种:Lomuto分区和Hoare分区。Lomuto分区逻辑更简单,适合教学;Hoare分区性能更好,但实现细节更容易出错。我先给出Lomuto版:
int partition(int arr[], int low, int high) { int pivot = arr[high]; // 选最后一个元素为基准 int i = low - 1; // i指向小于pivot区域的最后一个位置 for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; swap(&arr[i], &arr[j]); } } // 把pivot放到正确位置 swap(&arr[i + 1], &arr[high]); return i + 1; } void quickSort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } }理解Lomuto分区的关键,是看那个i和j的关系:j负责从左到右扫描整个区间,凡是发现小于等于pivot的元素,就把它换到左边已分区区域的末尾;i记录“已经归位的小元素区的边界”。扫描结束后,把pivot放到i+1的位置,这样所有小于pivot的元素在左,大于的在右。
快排的平均时间复杂度是O(nlogn),但最坏情况是O(n²)。最坏情况什么时候发生?当数组已经有序或逆序,且我们始终选第一个或最后一个元素当pivot时,每次分区都极不均匀,一边空一边全,递归深度变成了n,复杂度退化到O(n²)。解决办法是“三数取中法”(取首、中、尾三个元素的中位数做pivot),或者随机选pivot。我在实际写代码时更倾向随机选,简单有效:
int randomPartition(int arr[], int low, int high) { int randomIdx = low + rand() % (high - low + 1); swap(&arr[randomIdx], &arr[high]); return partition(arr, low, high); }注意:随机选pivot可以让快排的期望时间复杂度稳定在O(nlogn),但不能彻底消除最坏情况。不过对于常规面试和实际项目来说,随机化已经够用。真要保证最坏情况也是O(nlogn),那就得用“BFPRT算法”(中位数的中位数),但那是进阶内容,初阶不需要碰。
快排是不稳定的。它分区过程中用交换把元素左右拨动,相同元素的相对顺序很容易被打乱。所以如果业务上需要稳定排序,别选快排,选归并。
4.4 快排与归并的分治对比,怎么选
这是我带新人时必问的问题,也是理解分治思想的最好切入点。两者的区别可以概括为三个字:前分后合 vs 边分边排。
归并排序是“切到最细再往上合并”:先不断对半切分,切到只有一个元素,然后从底层开始两两合并,合并过程中比较大小并产生有序段。它的排序动作发生在“回溯”阶段。
快速排序是“分的时候就把基准放到了最终位置”:每次partition确定一个元素的最终位置,然后递归处理基准左右两侧。它的排序动作发生在“递推”阶段,递归结束时就全部有序了。
实际选型上:
- 数据量很大且要求稳定,选归并;不要求稳定且想要省内存,选快排。
- 数组接近有序时,快排如果不用随机化会退化到O(n²),归并则稳定在O(nlogn)。
- 需要O(1)空间且不在乎稳定性,堆排可能是更好的选择。这个下面就会讲。
5. 堆排序:用二叉树结构玩出O(nlogn)
5.1 堆是什么,为什么它能排序
堆(Heap)是一种特殊的完全二叉树。大顶堆的意思是:每个节点的值都大于等于其左右子节点的值。所以堆顶元素一定是整个数组的最大值。堆排序的思路就变成了:把数组构建成一个大顶堆,然后把堆顶(最大值)和数组末尾元素交换,堆大小减一,再对新的堆顶做“下沉调整”,让它重新满足大顶堆性质。重复n-1次,数组就排好序了。
用数组表示堆的时候,如果根节点的下标是0,那么任意节点下标i的:
- 左孩子下标 = 2*i + 1
- 右孩子下标 = 2*i + 2
- 父节点下标 = (i-1)/2
这个“用数组存树”的技巧本身就是个非常经典的初阶思想——用连续内存表示逻辑上的树结构,省去了指针。
5.2 建堆与堆排序的C语言实现
建堆有两种方式:自顶向下插入建堆,和自底向上下沉建堆。初阶一般学的是“下沉建堆”,效率更高。核心是下滤函数heapify:
void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; } // 对以i为根的子树做堆化,n是堆大小 void heapify(int arr[], int n, int i) { int largest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { swap(&arr[i], &arr[largest]); heapify(arr, n, largest); // 递归调整被换下去的子树 } } void heapSort(int arr[], int n) { // 从最后一个非叶子节点开始,向前做堆化 for (int i = n / 2 - 1; i >= 0; i--) { heapify(arr, n, i); } // 逐个把堆顶元素交换到末尾 for (int i = n - 1; i > 0; i--) { swap(&arr[0], &arr[i]); heapify(arr, i, 0); } }关键点解释:
为什么建堆从
n/2 - 1开始?因为完全二叉树中,下标大于等于n/2的节点都是叶子节点,叶子节点本身已经满足堆的性质,不需要调整。从最后一个非叶子节点往前调整,可以保证每个子树都是堆。为什么交换堆顶后要对新的堆顶做heapify?因为堆顶和末尾交换后,堆顶元素可能不满足大顶堆性质,但它左右子树仍然是合法的堆,所以只需要对堆顶做一次下沉,整棵树就恢复堆性质。这个过程的时间复杂度是O(logn),因为堆的深度约等于log2(n)。
堆排序的空间复杂度是O(1),这是它最大的优势,也是它在嵌入式、实时系统里被看中的原因。不需要额外的临时数组,直接原地排序。
堆排序的时间复杂度在任何情况下都是O(nlogn),这一点比快排稳定。它的缺点是:不稳定,而且实际运行速度通常比快排慢。原因在于堆排序的“跳跃式”访问内存模式,Cache命中率不如快排的线性扫描高。另外,堆排序的常数因子比快排大,所以虽然都是O(nlogn),但堆排的实际开销更高。
实操心得:堆排序适合“求前K个最大/最小元素”这类场景,因为不需要全排序,维护一个大小为K的堆就够了。这个思路在面试里出现频率非常高,比如“海量数据中找TopK”,直接用堆解决,时间复杂度O(nlogK),比全排序快好几个数量级。
6. 七大排序的实战对比与验证技巧
6.1 如何科学地测试排序结果是否可靠
很多初学者写完排序代码,跑一遍发现“诶好像排对了”,就直接过了。但实际写排序代码,有几个隐藏得很深的bug:
边界条件:n=0或n=1时是否崩溃?冒泡、选择、插入的循环条件是否越界?快排的low、high边界是否递归正确?
重复元素:数组里全是相同元素时,代码还能不能跑完?很多人的快排在处理“全是重复元素”的数组时会退化成O(n²),因为分区严重不均衡。
浮点或负数:排序代码用整数测没问题,但换负数、换小数就可能出错。原因是很多人在比较时写了硬编码,或者用了不合适的变量类型。
我自己测试排序算法,一般固定用三组数据:
// 随机乱序 int test1[] = {5, 2, 9, 1, 5, 6, 3, 8, 7, 4}; // 完全有序 int test2[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 完全逆序 int test3[] = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1}; // 大量重复元素 int test4[] = {3, 3, 3, 3, 3, 1, 1, 1, 2, 2};跑完打印结果,用肉眼检查是第一层;更可靠的是写一个校验函数,检查结果是否非递减、以及是否包含原数组的全部元素(排列一致性):
int isSorted(int arr[], int n) { for (int i = 0; i < n - 1; i++) { if (arr[i] > arr[i + 1]) return 0; } return 1; }注意:只检查“有序”还不够,还要确认元素没丢没换。比如有人写的冒泡可能出现覆盖元素的情况,排序后数组长度不变但少了一个元素。做排列校验时,可以先复制原数组,排序后逐元素对比是否一致(可以用哈希计数或者先复制再排序对比)。
6.2 C语言实现排序的常见坑
坑一:交换函数写错指针
// 错误写法 void swap(int a, int b) { int tmp = a; a = b; b = tmp; }这种写法在C语言里根本不会交换实参,必须传指针。很多新手在快排、堆排里频繁用到swap,如果这个函数写错了,整段代码看起来逻辑没问题但排序结果就是一塌糊涂。
坑二:递归深度过大导致栈溢出
快排和归并都是递归实现的,当数据量很大(比如几百万甚至上千万)时,递归深度可能达到数千层,C语言的函数调用栈很容易爆掉。我在Windows上用默认栈大小跑100万元素的快排,就遇到过栈溢出。解决方案有两个:一是增大栈空间;二是把快排改成非递归(用显式栈模拟递归),或者对递归深度做限制,比如当子数组长度小于某个阈值时,改用插入排序。
坑三:int溢出与无符号类型
数组下标的临时变量,比如归并里的m = l + (r - l) / 2,如果写成(l+r)/2,当数组长度超过int最大值的一半时就会溢出。虽然现实中很少遇到这么大的数组,但刷题时用极端测试用例就可能触发。养成防溢出的写法是好习惯。
坑四:堆排序的下标从0还是从1开始
很多教材用C语言实现堆排序时,把根节点下标从1开始,左右孩子就是2i和2i+1,这样写起来稍微简单,但和数组下标0对应时需要每个下标都减一,极易出错。我的建议是:明确自己选哪种约定,写注释标注清楚,否则过两天自己回来看都容易懵。
6.3 七种排序的实际选型建议
在实际项目中,没人会跑到排序网站上看热闹,选型主要看几个维度:数据规模、稳定性要求、内存限制、是否接近有序、是否要求原地排序。
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 数据量很小(几十个) | 插入排序 | 常数小、实现简单、接近有序时极快 |
| 不要求稳定、内存紧张 | 快排或堆排 | O(1)空间或少量辅助空间 |
| 必须稳定、数据量大 | 归并排序 | O(nlogn)且稳定,代价是O(n)空间 |
| 基本有序的数组 | 插入排序或希尔排序 | 自适应特性让它们在这种场景接近O(n) |
| TopK问题 | 堆排序 | 只维护K大小的堆,内存开销可控 |
现实中有个例子:Linux内核里sprintf等字符串列表的排序用快排的变体;Java的Arrays.sort对基本类型用双轴快排,对对象类型用TimSort(归并的优化版);Python内置的sorted是用TimSort。可以看到,工程上几乎不会只用一种排序,都是混合策略。
7. 常见问题与排查技巧实录
7.1 为什么我的快速排序输出没有变化?
这种问题十有八九是swap函数或者分区逻辑写错了。我的排查顺序是:
- 先检查swap是否正确传指针。
- 再单独测partition函数,看返回的下标是否合理。
- 递归调用时,左右子区间的边界是否传递正确——这是非常容易错的地方,比如快排递归时应该传
low, pi - 1和pi + 1, high,有人会写成low, pi导致死循环或栈溢出。 - 打印中间过程,把每趟partition后的数组状态打出来,肉眼定位问题。
7.2 为什么我的归并排序在小数组上正确,大数组上越界?
这通常是临时数组长度不够或者递归边界错误。建议把数组长度n传到merge函数里验证一下,确保L和R的拷贝范围不越界。特别是C语言不检查数组越界,一旦越界,表现出来的是诡异的值或偶发崩溃,非常难定位。用valgrind或者开启AddressSanitizer(-fsanitize=address)编译,能让越界问题变成明确的报错。
7.3 为什么我的插入排序对负数排序出错?
检查一下排序函数里是否用了无符号整数或者硬编码了比较基准。很多人在初学时会拿数组第一个元素当成最小值来初始化,但遇到负数可能导致初始化错误。实际上插入排序不应该依赖任何预估值,它只是做相邻比较,理论上对任意可比较类型都适用。
7.4 快排和归并到底谁更快?
实测数据会给你非常明确的答案:在随机数据、内存充足的情况下,快排通常比归并快,因为它省去了合并步骤的额外拷贝,缓存命中率高。但差距不是数量级的差距,在几百万数据量下可能是“快排200ms vs 归并300ms”这种感觉。真正拉开差距的是稳定性需求和内存限制。所以面试遇到“为什么工业界用快排而不是归并”这种问题,回答“快排原地排序、缓存友好、常数小”就切中要害了。
7.5 排序算法学完之后,下一步学什么?
一个自然的延伸路径是:从比较排序进入非比较排序(计数排序、基数排序、桶排序),然后去理解“基于比较的排序下界是O(nlogn)”这个结论是怎么来的(决策树模型)。另一个方向是学会“利用排序思想解决实际问题”,比如用归并排序求逆序数对、用堆做多路归并外部排序。我见过很多面试题,本质上都披着“排序”的皮,比如“两个有序数组合并”就是归并的merge函数,“找出数组中第K大的元素”就是快排partition的活用。
8. 最后说点我在实际写代码时的小体会
七大排序学到现在,你会发展出一个自己的“排序直觉”,就是看到数据规模和场景,脑子里会立刻浮现该用哪种算法。这种直觉不是背出来的,是踩坑踩出来的。我有几个亲测有效的经验可以分享:
第一个经验是排序代码写完后,不要只测一次就收工。我习惯写一个小的测试框架,自动用多组随机数据、重复数据、边界数据跑,并且和系统自带的qsort结果做对比。这种方式能帮你快速发现隐藏的边界bug。尤其是快排分区和堆排序的下标计算,这些地方出bug概率极高。
第二个经验是理解“交换次数”和“比较次数”的区别。选择排序比较次数很多,但交换次数很少;冒泡排序两者都多。如果你的数组元素是结构体,交换一次可能是很大的拷贝开销,这时候“少交换”的算法优势就体现出来了。这就是为什么面试官会问“如果元素很大,选哪种排序”的原因——他想考察的是对排序底层行为的理解,不只是背复杂度表。
第三个经验是手写排序是面试的基本功,也是调试能力的试金石。我见过不少候选人张口就能背“快排O(nlogn)、稳定、原地”,但让他手写10行快排就卡壳。所以不管你是自学还是准备面试,一定要做到“闭着眼也能把七种排序写出来”。写错了不要紧,关键是能从报错信息定位问题,这种能力永远比背答案值钱。
最后,如果你正在学数据结构与算法,请把排序这块当成“磨刀石”。它覆盖了数据结构里最核心的几种思维方式——暴力遍历、贪心选择、插入维护、分而治之、二叉树。把这七个算法真正吃透,后面学树、图、动态规划都会顺畅很多。别急,慢慢来,写完每个排序后,试着用笔在纸上画出它处理一个具体数组的每一步,你会发现自己对算法的理解会上一个台阶。