算法笔记 09
1 排序
第 11 章 排序 - Hello 算法
https://www.hello-algo.com/chapter_sorting/
2 选择排序
【选择排序每次循环区间内的最小放在已排序的区间,怎么保证新选出的最小一定大于原来的最小呢,也就是原给出的数组不是递增的】
核心结论很明确:选择排序不用刻意 “保证新最小大于原最小”,而是通过 “已排序区间” 和 “未排序区间” 的严格划分,自然让新选出的最小元素≥已排序区间的最大元素,最终实现整体递增。
1. 先明确选择排序的核心逻辑(以升序为例)
选择排序的本质是 “逐步扩充已排序区间”,每一轮只做两件事:
- 划分区间:数组分为「已排序区间(左半部分,初始为空)」和「未排序区间(右半部分,初始为整个数组)」。
- 选最小放前面:在未排序区间中找最小元素,和未排序区间的第一个元素交换,此时这个最小元素就加入已排序区间的末尾。
2. 关键:为什么新选的最小一定≥已排序区间的所有元素?
核心在于「未排序区间的定义」—— 未排序区间是 “还没处理的元素”,而每一轮选的是 “当前未排序区间里的最小”,这就天然满足:
- 已排序区间的所有元素,都是「之前每一轮从当时的未排序区间里选出来的最小」。
- 本轮新选的最小,是「剩下的未排序元素里的最小」,必然≥之前所有轮次选出来的最小(因为之前的最小已经被移到已排序区间了,剩下的元素不可能比它们更小)。
举个具体例子(原数组非递增):假设原数组 [5, 3, 8, 1, 2],一步步看:
-
初始:已排序区间为空,未排序区间
[5,3,8,1,2]- 找未排序最小:
1,和未排序第一个元素5交换 → 数组变为[1, 3, 8, 5, 2] - 已排序区间变为
[1](此时已排序的最大 / 最小都是1)
- 找未排序最小:
-
第二轮:未排序区间
[3,8,5,2]- 找未排序最小:
2,和未排序第一个元素3交换 → 数组变为[1, 2, 8, 5, 3] - 已排序区间变为
[1,2](新最小2≥ 已排序最大1)
- 找未排序最小:
-
第三轮:未排序区间
[8,5,3]- 找未排序最小:
3,和未排序第一个元素8交换 → 数组变为[1,2,3,5,8] - 已排序区间变为
[1,2,3](新最小3≥ 已排序最大2)
- 找未排序最小:
-
后续轮次以此类推,每轮新选的最小都≥已排序区间的最大元素,最终数组递增。
3. 原数组非递增也不影响的原因
不管原数组是递减([5,4,3,2,1])、无序([3,1,4,2]),甚至有重复元素([2,1,2]),选择排序的 “区间划分 + 选当前未排序最小” 逻辑都成立:
- 重复元素时:新选的最小可能等于已排序区间的最大元素(比如
[2,1,2]第二轮选2,已排序区间是[1]),但依然满足 “非递减”,不影响最终有序。 - 递减数组时:每轮选的最小都是未排序区间的最后一个元素,交换后逐步把最小元素移到前面,最终变成递增。
总结
选择排序的核心是 “每轮只关注当前未排序区间的最小”,通过 “交换到已排序区间末尾” 的操作,天然保证已排序区间始终是有序的(递增)。不用刻意比较 “新最小” 和 “原最小”,因为区间划分已经决定了 “剩下的元素里的最小,不可能比已经选走的最小更小”。
cpp
选择排序(升序)完整实现(C++ 代码)
#include <iostream>
using namespace std;// 选择排序函数:对数组nums从索引0到n-1排序
void selectionSort(int nums[], int n) {// 外层循环:控制已排序区间的扩充(i是已排序区间的末尾索引)for (int i = 0; i < n - 1; i++) { // 最后一个元素无需处理,故循环到n-2int minIndex = i; // 假设未排序区间的第一个元素是最小值,记录其索引// 内层循环:在未排序区间[i..n-1]中找最小元素的索引for (int j = i + 1; j < n; j++) {if (nums[j] < nums[minIndex]) { // 找到更小的元素,更新minIndexminIndex = j;}}// 将找到的最小元素,与未排序区间的第一个元素(nums[i])交换swap(nums[i], nums[minIndex]);}
}// 测试函数
int main() {int nums[] = {5, 3, 8, 1, 2}; // 测试用例(非递增、无序)int n = sizeof(nums) / sizeof(nums[0]); // 数组长度cout << "排序前:";for (int i = 0; i < n; i++) cout << nums[i] << " ";selectionSort(nums, n);cout << "\n排序后:";for (int i = 0; i < n; i++) cout << nums[i] << " ";return 0;
}
逐轮执行拆解(以测试用例 [5,3,8,1,2] 为例)
每一轮清晰标注「已排序区间」「未排序区间」「最小元素索引」「交换后数组」,直观看到逻辑走向:
初始状态
- 数组:
[5, 3, 8, 1, 2] - 已排序区间:
[](空) - 未排序区间:
[5, 3, 8, 1, 2](索引 0~4)
第 1 轮(i=0)
- 目标:在未排序区间 [0~4] 找最小元素,放到 i=0 位置(已排序区间末尾)
- 内层循环(j=1~4):
- j=1:3 < 5 → minIndex=1
- j=2:8 > 3 → 不变
- j=3:1 < 3 → minIndex=3
- j=4:2 > 1 → 不变
- 最小元素索引:
minIndex=3(元素 1) - 交换 nums [0] 和 nums [3] → 数组变为
[1, 3, 8, 5, 2] - 更新区间:
- 已排序区间:
[1](索引 0) - 未排序区间:
[3, 8, 5, 2](索引 1~4)
- 已排序区间:
第 2 轮(i=1)
- 目标:在未排序区间 [1~4] 找最小元素,放到 i=1 位置
- 内层循环(j=2~4):
- j=2:8 > 3 → 不变
- j=3:5 > 3 → 不变
- j=4:2 < 3 → minIndex=4
- 最小元素索引:
minIndex=4(元素 2) - 交换 nums [1] 和 nums [4] → 数组变为
[1, 2, 8, 5, 3] - 更新区间:
- 已排序区间:
[1, 2](索引 0~1) - 未排序区间:
[8, 5, 3](索引 2~4)
- 已排序区间:
第 3 轮(i=2)
- 目标:在未排序区间 [2~4] 找最小元素,放到 i=2 位置
- 内层循环(j=3~4):
- j=3:5 < 8 → minIndex=3
- j=4:3 < 5 → minIndex=4
- 最小元素索引:
minIndex=4(元素 3) - 交换 nums [2] 和 nums [4] → 数组变为
[1, 2, 3, 5, 8] - 更新区间:
- 已排序区间:
[1, 2, 3](索引 0~2) - 未排序区间:
[5, 8](索引 3~4)
- 已排序区间:
第 4 轮(i=3)
- 目标:在未排序区间 [3~4] 找最小元素,放到 i=3 位置
- 内层循环(j=4):
- j=4:8 > 5 → 不变
- 最小元素索引:
minIndex=3(元素 5) - 交换 nums [3] 和 nums [3](自身交换,无变化)→ 数组仍为
[1, 2, 3, 5, 8] - 更新区间:
- 已排序区间:
[1, 2, 3, 5](索引 0~3) - 未排序区间:
[8](索引 4)
- 已排序区间:
循环结束
- 未排序区间只剩 1 个元素,无需处理,排序完成。
关键细节重申(解决你的核心疑问)
-
无需刻意比较 “新最小” 和 “原最小”:每轮的未排序区间是 “剩下的元素”,而本轮选的是 “剩下元素里的最小”,自然比已排序区间的所有元素都大(或相等)—— 因为已排序区间的元素都是 “之前每轮从当时的未排序区间里选出来的最小”,已经被移走了。
-
原数组是否递增不影响逻辑:不管原数组是无序、递减还是有重复元素,“划分区间 + 选当前未排序最小” 的逻辑始终成立,每轮都会让已排序区间扩充且保持有序,最终整体递增。
c
选择排序(升序)的 C 语言实现
#include <stdio.h>// 交换两个整数的值(C 中没有内置 swap,需手动实现)
void swap(int *a, int *b) {int temp = *a; // 用临时变量存储 a 的值*a = *b; // 将 b 的值赋给 a*b = temp; // 将临时变量(原 a 的值)赋给 b
}// 选择排序函数:对数组 nums 从索引 0 到 n-1 排序
void selectionSort(int nums[], int n) {// 外层循环:控制已排序区间的末尾索引 i(从 0 到 n-2,最后一个元素无需处理)for (int i = 0; i < n - 1; i++) {int minIndex = i; // 假设未排序区间的第一个元素(nums[i])是最小值,记录其索引// 内层循环:在未排序区间 [i..n-1] 中找最小元素的索引for (int j = i + 1; j < n; j++) {// 若当前元素比“假设的最小值”小,则更新最小索引if (nums[j] < nums[minIndex]) {minIndex = j;}}// 将找到的最小元素(nums[minIndex])与未排序区间的第一个元素(nums[i])交换swap(&nums[i], &nums[minIndex]);}
}// 测试函数
int main() {int nums[] = {5, 3, 8, 1, 2}; // 测试用例(非递增、无序)int n = sizeof(nums) / sizeof(nums[0]); // 计算数组长度(元素个数)printf("排序前:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}selectionSort(nums, n); // 调用选择排序printf("\n排序后:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}return 0;
}
C 版本与 C++ 版本的核心区别
-
交换函数:C 语言没有内置的
swap函数,因此需要手动实现void swap(int *a, int *b),通过指针操作交换两个变量的值(C++ 可以直接用std::swap,或同样手动实现)。 -
输出方式:C 用
printf输出,C++ 常用cout;但逻辑上完全一致,都是遍历数组打印元素。
逐轮执行拆解(与 C++ 版本完全相同,以 [5,3,8,1,2] 为例)
为了让你更清晰地看到 C 代码的执行过程,这里简化重复说明,只标注核心步骤:
初始状态
- 数组:
[5, 3, 8, 1, 2] - 已排序区间:
[],未排序区间:[0~4]
第 1 轮(i=0)
- 内层循环找
[0~4]的最小元素:索引 3(值 1)。 - 交换
nums[0]和nums[3]→ 数组变为[1, 3, 8, 5, 2]。 - 已排序区间:
[0](值 1),未排序区间:[1~4]。
第 2 轮(i=1)
- 内层循环找
[1~4]的最小元素:索引 4(值 2)。 - 交换
nums[1]和nums[4]→ 数组变为[1, 2, 8, 5, 3]。 - 已排序区间:
[0~1](值 1,2),未排序区间:[2~4]。
第 3 轮(i=2)
- 内层循环找
[2~4]的最小元素:索引 4(值 3)。 - 交换
nums[2]和nums[4]→ 数组变为[1, 2, 3, 5, 8]。 - 已排序区间:
[0~2](值 1,2,3),未排序区间:[3~4]。
第 4 轮(i=3)
- 内层循环找
[3~4]的最小元素:索引 3(值 5)。 - 交换
nums[3]和nums[3](无变化)→ 数组仍为[1, 2, 3, 5, 8]。 - 已排序区间:
[0~3],未排序区间:[4](只剩一个元素,循环结束)。
运行结果
排序前:5 3 8 1 2
排序后:1 2 3 5 8
核心逻辑再强调
C 版本的选择排序和 C++ 版本在算法逻辑上完全一致,都是通过 “划分已排序 / 未排序区间→找未排序区间最小元素→交换到已排序区间末尾” 的步骤,逐步让数组整体有序。关键依然是:每轮选出的 “未排序区间最小元素”,必然大于等于已排序区间的所有元素(因为已排序区间的元素是 “之前选走的最小元素”,剩下的元素不可能更小),因此无需额外判断,自然保证最终有序。
用数组[2,1,2]作为测试用例,验证选择排序对含重复元素数组的排序效果。下面用之前的 C 语言选择排序代码测试该数组,还会逐轮拆解执行过程,清晰展示重复元素的处理细节,同时说明选择排序处理重复元素时的特点:
测试代码(存在重复元素时)
基于之前的选择排序代码,将测试数组改为[2,1,2],具体代码如下:
#include <stdio.h>// 交换两个整数的值
void swap(int *a, int *b) {int temp = *a;*a = *b;*b = temp;
}// 选择排序函数
void selectionSort(int nums[], int n) {for (int i = 0; i < n - 1; i++) {int minIndex = i;// 在内层循环中找未排序区间的最小元素索引for (int j = i + 1; j < n; j++) {if (nums[j] < nums[minIndex]) {minIndex = j;}}// 交换最小元素与未排序区间第一个元素swap(&nums[i], &nums[minIndex]);}
}// 测试数组[2,1,2]
int main() {int nums[] = {2, 1, 2};int n = sizeof(nums) / sizeof(nums[0]);printf("排序前:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}selectionSort(nums, n);printf("\n排序后:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}return 0;
}
逐轮执行拆解
数组[2,1,2]长度为 3,外层循环只需执行 2 轮(i=0和i=1),具体过程如下:
- 初始状态
- 数组:
[2, 1, 2] - 已排序区间:空,未排序区间:
[0~2](整个数组)
- 数组:
- 第 1 轮(i=0)
- 初始假设未排序区间第一个元素(
nums[0]=2)是最小值,minIndex=0。 - 内层循环遍历
j=1和j=2:j=1时,nums[1]=1 < nums[0]=2,更新minIndex=1;j=2时,nums[2]=2 > nums[1]=1,minIndex保持 1 不变。 - 交换
nums[0]和nums[1],数组变为[1, 2, 2]。 - 此时已排序区间:
[0](值 1),未排序区间:[1~2]。
- 初始假设未排序区间第一个元素(
- 第 2 轮(i=1)
- 假设未排序区间第一个元素(
nums[1]=2)是最小值,minIndex=1。 - 内层循环遍历
j=2:nums[2]=2 == nums[1]=2,不更新minIndex。 - 无需交换,数组仍为
[1, 2, 2]。 - 此时已排序区间:
[0~1](值 1,2),未排序区间仅剩[2],循环结束。
- 假设未排序区间第一个元素(
运行结果
排序前:2 1 2
排序后:1 2 2
关于重复元素的补充说明
选择排序是不稳定排序。比如若测试数组为[2a,1,2b](2a和2b为两个值相同但位置不同的元素),排序后2a和2b的相对位置会交换为[1,2b,2a]。不过这并不影响排序的核心目标(数组整体有序),仅当场景要求保留相同元素原始相对位置时,才需要考虑用稳定排序(如冒泡排序)。
3 不稳定排序
不稳定排序是指排序后,数组中值相等的元素,原始相对位置发生了改变;反之,原始相对位置保持不变的就是稳定排序。
1. 用通俗例子理解:给 “带标记的重复元素” 排序
拿之前的测试数组 [2₁, 1, 2₂] 举例(下标₁、₂代表两个值相同但原始位置不同的元素):
- 排序前:
2₁在2₂前面(相对位置:2₁ → 2₂)。 - 用选择排序(不稳定) 排序后:
- 第一轮找最小元素
1,和2₁交换 → 数组变为[1, 2₁, 2₂]。 - 第二轮无需交换,最终结果
[1, 2₁, 2₂](这里看似位置没变?再看另一个例子)。
- 第一轮找最小元素
再换一个数组 [3, 2₁, 4, 2₂]:
- 排序前:
2₁在2₂前面(相对位置:2₁ → 2₂)。 - 用选择排序排序:
- 第一轮(i=0):找未排序区间
[0~3]的最小元素2₁(索引 1),和3(索引 0)交换 → 数组变为[2₁, 3, 4, 2₂]。 - 第二轮(i=1):找未排序区间
[1~3]的最小元素2₂(索引 3),和3(索引 1)交换 → 数组变为[2₁, 2₂, 4, 3]。 - 后续轮次排序后,最终结果
[2₁, 2₂, 3, 4](这里位置仍没变?再看极端情况)。
- 第一轮(i=0):找未排序区间
真正体现 “不稳定” 的例子:[2₁, 2₂, 1]
- 排序前:
2₁在2₂前面(2₁ → 2₂)。 - 用选择排序排序:
- 第一轮(i=0):找未排序区间
[0~2]的最小元素1(索引 2),和2₁(索引 0)交换 → 数组变为[1, 2₂, 2₁]。
- 第一轮(i=0):找未排序区间
- 排序后:
2₂在2₁前面(相对位置变成2₂ → 2₁),原始顺序被打乱 → 这就是 “不稳定”!
2. 稳定排序的对比(用冒泡排序)
同样对 [2₁, 2₂, 1] 用冒泡排序(稳定):
- 排序过程:
- 第一轮:比较相邻元素,
2₂和1交换 →[2₁, 1, 2₂];再2₁和1交换 →[1, 2₁, 2₂]。
- 第一轮:比较相邻元素,
- 排序后:
2₁仍在2₂前面(原始相对位置不变)→ 这就是 “稳定”!
3. 核心区别:是否保留相等元素的原始顺序
| 排序类型 | 核心特点 | 常见例子 |
|---|---|---|
| 不稳定排序 | 相等元素原始相对位置可能改变 | 选择排序、快速排序、堆排序 |
| 稳定排序 | 相等元素原始相对位置保持不变 | 冒泡排序、插入排序、归并排序 |
4. 为什么需要关注 “稳定性”?
大部分场景下(比如单纯对数字排序),稳定性不重要。但如果是带附加信息的排序(比如给 “学生成绩表” 排序,成绩相同的学生要保留原始排名),就必须用稳定排序:
- 例:学生列表
[(小明, 90), (小红, 85), (小刚, 90)],按成绩升序排序。- 稳定排序后:
[(小红, 85), (小明, 90), (小刚, 90)](小明仍在小刚前面,保留原始顺序)。 - 不稳定排序可能得到:
[(小红, 85), (小刚, 90), (小明, 90)](小明和小刚顺序颠倒,破坏原始排名)。
- 稳定排序后:
简单总结:不稳定排序只 “关心” 元素值的大小,不 “关心” 它们原本的位置;稳定排序则在保证值有序的同时,记住相等元素的 “先后关系”。
4 快速排序实现
一、代码结构与调用关系
先明确三个函数的分工和调用链:
swap:辅助工具,负责交换数组中两个元素的值(被partition调用)。partition:核心分区函数,将数组划分为左右两部分并返回基准索引(被quickSort调用)。quickSort:主递归函数,通过调用partition获得基准索引,再递归排序左右子数组(用户直接调用的入口)。
调用关系:用户 → quickSort(...) → partition(...) → swap(...)
二、如何使用?(完整示例代码)
下面是可直接运行的完整程序,包含测试用例,演示如何调用这些函数对数组排序:
#include <stdio.h>// 1. 元素交换函数(辅助工具)
void swap(int nums[], int i, int j) {int tmp = nums[i];nums[i] = nums[j];nums[j] = tmp;
}// 2. 哨兵划分函数(核心分区逻辑)
int partition(int nums[], int left, int right) {int i = left, j = right;while (i < j) {while (i < j && nums[j] >= nums[left]) j--; // 右指针左移while (i < j && nums[i] <= nums[left]) i++; // 左指针右移swap(nums, i, j); // 交换左右指针指向的元素}swap(nums, i, left); // 基准数归位return i; // 返回基准索引
}// 3. 快速排序主函数(递归入口)
void quickSort(int nums[], int left, int right) {if (left >= right) return; // 子数组长度为1时终止递归int pivot = partition(nums, left, right); // 分区得到基准索引quickSort(nums, left, pivot - 1); // 递归排序左子数组quickSort(nums, pivot + 1, right); // 递归排序右子数组
}// 测试函数(演示如何使用)
int main() {// 待排序数组int nums[] = {3, 1, 4, 2, 5};// 计算数组长度(元素个数)int n = sizeof(nums) / sizeof(nums[0]);// 打印排序前的数组printf("排序前:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}// 调用快速排序:排序整个数组(从索引0到n-1)quickSort(nums, 0, n - 1); // 关键调用:left=0,right=n-1// 打印排序后的数组printf("\n排序后:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}return 0;
}
三、执行流程拆解(以 [3,1,4,2,5] 为例)
通过跟踪 main 函数的调用,看三个函数如何配合完成排序:
步骤 1:用户调用 quickSort(nums, 0, 4)
- 目标:排序整个数组(索引
0到4)。 - 此时
left=0,right=4(left < right,不终止递归)。
步骤 2:quickSort 调用 partition(nums, 0, 4) 分区
partition 函数执行过程(详见上一次解析):
- 基准数为
nums[0] = 3。 - 经过左右指针扫描和交换,最终基准数
3归位到索引2,数组变为[2, 1, 3, 4, 5]。 - 返回
pivot = 2(基准索引)。
步骤 3:递归排序左、右子数组
-
递归左子数组:
quickSort(nums, 0, 1)(排序[0,1]区间,元素[2,1])。left=0 < right=1,调用partition(nums, 0, 1):- 基准数
nums[0] = 2,分区后数组变为[1, 2, 3, 4, 5],返回pivot=1。
- 基准数
- 递归左子数组
quickSort(nums, 0, 0)(left >= right,直接返回)。 - 递归右子数组
quickSort(nums, 2, 1)(left > right,直接返回)。 - 左子数组排序完成。
-
递归右子数组:
quickSort(nums, 3, 4)(排序[3,4]区间,元素[4,5])。left=3 < right=4,调用partition(nums, 3, 4):- 基准数
nums[3] = 4,分区后数组不变(4 <= 5),返回pivot=3。
- 基准数
- 递归左子数组
quickSort(nums, 3, 2)(left > right,返回)。 - 递归右子数组
quickSort(nums, 4, 4)(left >= right,返回)。 - 右子数组排序完成。
最终结果
数组变为 [1, 2, 3, 4, 5],排序完成。
四、关键注意事项(使用时必看)
-
参数范围:调用
quickSort时,left必须是数组起始索引(通常为0),right必须是数组最后一个元素的索引(n-1),否则会访问越界。 -
数组类型:这里的代码只支持
int类型数组,若要排序其他类型(如float、char),需修改函数参数中的类型(如float nums[])。 -
稳定性:快速排序是不稳定排序(相等元素可能交换位置),若需稳定排序,需改用归并排序等算法。
-
性能:平均时间复杂度为
O(n log n),但在极端情况(如有序数组)下会退化为O(n²)。实际使用时可优化partition函数的基准选择(如随机基准)避免最坏情况。
总结
使用方法非常简单:只需在你的代码中包含这三个函数,然后调用 quickSort(nums, 0, n-1) 即可完成对数组 nums 的排序(n 是数组长度)。内部通过 quickSort 递归调用 partition 划分区间,partition 再调用 swap 交换元素,三者配合实现高效排序。
5 归并排序
归并排序(Merge Sort)是典型的分治算法,核心思想是 “分而治之”:
将大问题拆解为小问题,解决小问题后再合并结果。它的稳定性(相等元素相对位置不变)和稳定的 O(n log n) 时间复杂度使其在很多场景中非常实用。下面从核心逻辑→分步实现→代码细节→执行流程全方位拆解归并排序的实现。
一、归并排序的核心逻辑(分治思想)
归并排序分为三个步骤,形成递归闭环:
- 分(Divide):将原数组从中点拆分为两个等长的子数组(若长度为奇数,左子数组比右子数组多一个元素)。
- 治(Conquer):递归对两个子数组进行归并排序,直到子数组长度为 1(单个元素天然有序)。
- 合(Merge):将两个已排序的子数组合并为一个有序数组。
关键:合并两个有序子数组是核心操作,决定了归并排序的正确性和效率。
二、完整实现代码(C 语言)
下面是可直接运行的归并排序代码,包含辅助函数和详细注释:
#include <stdio.h>
#include <stdlib.h> // 用于动态分配内存(malloc/free)// 辅助函数:将两个有序子数组合并为一个有序数组
// nums[left..mid] 和 nums[mid+1..right] 是两个已排序的子数组
void merge(int nums[], int left, int mid, int right) {// 1. 开辟临时数组,存储合并后的结果int len = right - left + 1; // 合并后数组的长度int* temp = (int*)malloc(len * sizeof(int)); // 动态分配临时数组int i = left; // 左子数组的起始索引int j = mid + 1; // 右子数组的起始索引int k = 0; // 临时数组的起始索引// 2. 比较两个子数组的元素,按从小到大顺序放入临时数组while (i <= mid && j <= right) {// 若左子数组元素 <= 右子数组元素,取左元素(保证稳定性)if (nums[i] <= nums[j]) {temp[k++] = nums[i++];} else {temp[k++] = nums[j++];}}// 3. 将剩余元素(左或右子数组)复制到临时数组while (i <= mid) {temp[k++] = nums[i++]; // 左子数组有剩余}while (j <= right) {temp[k++] = nums[j++]; // 右子数组有剩余}// 4. 将临时数组的结果复制回原数组(覆盖原区间 [left..right])for (k = 0; k < len; k++) {nums[left + k] = temp[k];}// 5. 释放临时数组的内存(避免内存泄漏)free(temp);
}// 归并排序主函数:递归排序 nums[left..right]
void mergeSort(int nums[], int left, int right) {// 递归终止条件:子数组长度为 1 时(left == right),无需排序if (left >= right) {return;}// 1. 分:计算中点,拆分左右子数组int mid = left + (right - left) / 2; // 等价于 (left + right)/2,避免溢出// 2. 治:递归排序左右子数组mergeSort(nums, left, mid); // 排序左子数组 [left..mid]mergeSort(nums, mid + 1, right); // 排序右子数组 [mid+1..right]// 3. 合:合并两个已排序的子数组merge(nums, left, mid, right);
}// 测试函数
int main() {int nums[] = {3, 1, 4, 2, 5}; // 待排序数组int n = sizeof(nums) / sizeof(nums[0]); // 数组长度printf("排序前:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}// 调用归并排序:排序整个数组(left=0,right=n-1)mergeSort(nums, 0, n - 1);printf("\n排序后:");for (int i = 0; i < n; i++) {printf("%d ", nums[i]);}return 0;
}
三、核心细节拆解
1. 递归终止条件:if (left >= right) return;
- 当子数组长度为 1(
left == right)时,数组本身有序,无需排序。 - 若
left > right(理论上不会出现,因拆分逻辑保证子数组有效),同样直接返回。
2. 拆分逻辑:mid = left + (right - left) / 2
- 计算中点
mid时,用left + (right - left)/2而非(left + right)/2,是为了避免left + right溢出(例如left和right都是接近INT_MAX的值时,相加会超出 int 范围)。 - 拆分后,左子数组为
[left..mid],右子数组为[mid+1..right],确保两个子数组无重叠且覆盖原区间。
3. 合并函数 merge 的关键步骤
合并是归并排序的 “灵魂”,需重点理解:
- 临时数组
temp:用于暂存合并结果,避免在原数组上直接操作导致元素被覆盖。 - 双指针比较:
i指向左子数组当前元素,j指向右子数组当前元素,每次取较小的元素放入temp,确保合并后有序。 - 稳定性保证:当
nums[i] == nums[j]时,取左子数组的元素(temp[k++] = nums[i++]),这样相等元素的原始相对位置(左在前,右在后)得以保留,使归并排序成为稳定排序。 - 剩余元素处理:当一个子数组遍历完后,将另一个子数组的剩余元素直接复制到
temp(因子数组已排序,剩余元素必然大于等于temp中已有元素)。 - 复制回原数组:最后将
temp中的结果覆盖原数组的[left..right]区间,完成合并。
四、执行流程示例(以 [3,1,4,2,5] 为例)
为直观展示分治过程,按 “拆分→递归排序→合并” 三步拆解:
初始调用:mergeSort(nums, 0, 4)(排序整个数组)
- 拆分:
mid = 0 + (4-0)/2 = 2,左子数组[0..2] = [3,1,4],右子数组[3..4] = [2,5]。
递归处理左子数组 [0..2]:mergeSort(nums, 0, 2)
- 拆分:
mid = 0 + (2-0)/2 = 1,左子数组[0..1] = [3,1],右子数组[2..2] = [4]。- 递归排序
[0..1]:mergeSort(nums, 0, 1)- 拆分:
mid = 0 + (1-0)/2 = 0,左子数组[0..0] = [3],右子数组[1..1] = [1]。 - 递归排序
[0..0]和[1..1]:因长度为 1,直接返回。 - 合并:调用
merge(nums, 0, 0, 1),合并[3]和[1]→ 结果[1,3],原数组变为[1,3,4,2,5]。
- 拆分:
- 递归排序
[2..2]:直接返回。 - 合并:调用
merge(nums, 0, 1, 2),合并[1,3]和[4]→ 结果[1,3,4],原数组变为[1,3,4,2,5]。
- 递归排序
递归处理右子数组 [3..4]:mergeSort(nums, 3, 4)
- 拆分:
mid = 3 + (4-3)/2 = 3,左子数组[3..3] = [2],右子数组[4..4] = [5]。 - 递归排序两个子数组:直接返回。
- 合并:调用
merge(nums, 3, 3, 4),合并[2]和[5]→ 结果[2,5],原数组仍为[1,3,4,2,5](右子数组本身有序)。
最终合并:调用 merge(nums, 0, 2, 4),合并 [1,3,4] 和 [2,5]
- 双指针比较过程:
i=0(1) vsj=3(2)→ 取 1 →temp=[1],i=1。i=1(3) vsj=3(2)→ 取 2 →temp=[1,2],j=4。i=1(3) vsj=4(5)→ 取 3 →temp=[1,2,3],i=2。i=2(4) vsj=4(5)→ 取 4 →temp=[1,2,3,4],i=3(左子数组遍历完)。- 复制右子数组剩余元素 5 →
temp=[1,2,3,4,5]。
- 复制回原数组 → 最终数组
[1,2,3,4,5]。
五、归并排序的特性
-
时间复杂度:
O(n log n)- 拆分过程:将数组拆分为
log n层(每一层子数组总数翻倍,长度减半)。 - 合并过程:每一层的合并操作总耗时为
O(n)(所有子数组的总长度为n)。 - 总耗时:
O(log n) * O(n) = O(n log n),且最好、最坏、平均情况均为此值,非常稳定。
- 拆分过程:将数组拆分为
-
空间复杂度:
O(n)- 主要来自合并时的临时数组
temp,其最大长度为n(合并最上层时)。递归调用栈的深度为O(log n),可忽略。
- 主要来自合并时的临时数组
-
稳定性:稳定排序
- 合并时当
nums[i] == nums[j]取左子数组元素,保证相等元素的原始相对位置不变。
- 合并时当
-
适用场景:
- 适合大规模数据排序(时间复杂度稳定)。
- 适合链表排序(无需额外空间存储临时数组,通过指针操作合并)。
- 适合需要稳定排序的场景(如带附加信息的排序)。
总结
归并排序的核心是 “分治 + 合并”,其中合并两个有序子数组的操作是关键。它通过牺牲 O(n) 的空间换取了稳定的 O(n log n) 时间复杂度和稳定性,是非常经典的高效排序算法。实际实现时需注意临时数组的内存管理(避免泄漏)和中点计算(避免溢出)。
