前面学习排序算法时,我们已经接触过直接插入排序、希尔排序、选择排序、冒泡排序和归并排序。随着排序方法逐渐增多,可以发现:不同排序算法之间真正拉开差距的,不只是代码写法,还有它们处理问题的思路。
插入排序是在已有有序区间中寻找插入位置,选择排序是不断选出最小值,冒泡排序是通过相邻元素交换让较大值逐步向后移动。而快速排序与归并排序都使用了"把一个大问题拆成若干小问题"的思想。
不过,这两种排序的拆分方式并不相同:
- 快速排序先选择一个基准值,再根据基准值划分区间;
- 归并排序先把区间分开,再将两个有序区间合并。
它们最常见的写法都是递归,但递归并不是唯一实现方式。只要把递归过程中隐含的操作明确写出来,就可以改成迭代版本:
- 快速排序使用栈保存尚未处理的区间;
- 归并排序从长度为 1 的有序区间开始,逐层合并为更长的有序区间。
本文先从递归版本理解两种排序的核心过程,再重点实现它们的迭代版本,并比较两种写法之间的联系和区别。
一、快速排序的基本思路
假设现在有一个待排序数组:
text
6 1 7 3 9 2 4 8 5
快速排序首先从当前区间中选择一个元素作为基准值,然后执行一次划分,使数组形成下面的结构:
text
小于等于基准值的元素 | 基准值 | 大于等于基准值的元素
假设选择最左边的 6 作为基准值,完成一次划分后,6 会来到它最终应该处在的位置:
text
5 1 4 3 2 | 6 | 9 8 7
此时能够确定的是:
6左边的元素不大于6;6右边的元素不小于6;6已经位于整个数组排序完成后的正确位置;- 左右两个区间内部还不一定有序。
接下来只需要继续处理:
text
[5 1 4 3 2]
以及:
text
[9 8 7]
当所有区间都完成划分,整个数组就有序了。
快速排序中的一次划分,只能保证基准值到达最终位置,不能保证基准值两侧的区间已经有序。
二、快速排序的一次划分
快速排序能否正确实现,关键在于一次划分过程。本文使用"左右指针法"完成划分,并选择区间最左边的元素作为基准值。
1. 区间表示
使用:
cpp
left
right
表示当前要处理的闭区间:
text
[left, right]
也就是说,left 和 right 指向的位置都属于当前排序范围。
2. 保存基准值位置
cpp
int keyi = left;
keyi 保存基准值所在位置。因为选取最左边的元素作为基准值,所以初始时:
cpp
keyi = left;
然后使用两个变量在区间中移动:
cpp
int begin = left;
int end = right;
3. 为什么选择最左边的元素为基准值时,要让右边先走?
这一点不能只记成一句规则,必须弄清楚最后一次交换到底需要什么条件。
当前区间选择最左边的元素作为基准值:
cpp
int keyi = left;
划分结束时,会执行:
cpp
Swap(&a[begin], &a[keyi]);
也就是说,基准值最终要和两个指针的相遇位置交换。为了保证交换后仍满足"左边不大于基准值、右边不小于基准值",相遇位置中的元素必须满足:
text
相遇位置的元素 <= 基准值
右边指针的任务恰好是寻找小于基准值的元素。因此,让右边先走,可以保证两个指针相遇时,相遇位置保存的是一个不大于基准值的元素。
用一个正常划分过程观察右边先走
假设数组为:
text
6 1 7 3 9 2 4 8 5
选择最左边的 6 作为基准值:
text
keyi
↓
6 1 7 3 9 2 4 8 5
↑ ↑
begin end
右边先寻找小于 6 的元素。此时 a[end] 是 5,满足:
text
5 < 6
所以右指针停下:
text
6 1 7 3 9 2 4 8 5
↑ ↑
begin end
左边再寻找大于 6 的元素:
text
6 不继续作为普通元素比较
1 <= 6,跳过
7 > 6,停止
两个指针分别停在 7 和 5:
text
6 1 7 3 9 2 4 8 5
↑ ↑
begin end
交换后得到:
text
6 1 5 3 9 2 4 8 7
继续由右边开始寻找。右指针依次经过:
text
7 >= 6,跳过
8 >= 6,跳过
4 < 6,停止
左指针从 5 的位置向右寻找:
text
5 <= 6,跳过
3 <= 6,跳过
9 > 6,停止
此时交换 9 和 4:
text
6 1 5 3 4 2 9 8 7
第三轮右边寻找时,end 会经过 9,最后停在 2。左边指针也不断向右,最终与右边指针在 2 的位置相遇:
text
6 1 5 3 4 2 9 8 7
↑ ↑
keyi begin/end
相遇位置的 2 小于基准值 6,所以交换:
text
2 1 5 3 4 6 9 8 7
此时 6 左边的元素都不大于 6,右边的元素都不小于 6。
如果错误地让左边先走,会发生什么?
再看一个更容易暴露问题的数组:
text
6 1 2 7 8 9
仍然选择最左边的 6 作为基准值,但这次错误地让左边先移动。
左指针依次经过:
text
1 <= 6,继续
2 <= 6,继续
7 > 6,停止
此时:
text
6 1 2 7 8 9
↑ ↑
begin end
右指针从 9 开始寻找小于 6 的元素:
text
9 >= 6,继续向左
8 >= 6,继续向左
然后右指针与左指针在元素 7 的位置相遇:
text
6 1 2 7 8 9
↑
begin/end
如果此时把基准值 6 与相遇位置的 7 交换,就会得到:
text
7 1 2 6 8 9
显然,基准值 6 的左边出现了比它大的 7,划分失败。
问题正是:左边先走时,最终相遇位置可能是左指针找到的"大于基准值的元素"。这个位置不能直接和最左边的基准值交换。
因此,当前实现必须遵守下面的对应关系:
text
基准值在左边 → 右指针先走
基准值在右边 → 左指针先走
这并不是固定不变的口诀,而是为了保证最终相遇位置中的元素适合与基准值交换。
选择最左边元素作基准值时,右边先走的真正原因是:右指针负责寻找较小值,从而保证最终相遇位置中的元素不大于基准值。这样把基准值交换到相遇位置后,左右区间关系才成立。
右边寻找小于基准值的元素:
cpp
while (begin < end && a[end] >= a[keyi])
{
--end;
}
找到后,再让左边寻找大于基准值的元素:
cpp
while (begin < end && a[begin] <= a[keyi])
{
++begin;
}
如果两个指针还没有相遇,就交换它们指向的元素:
cpp
Swap(&a[begin], &a[end]);
当 begin == end 时,将相遇位置的元素与基准值交换:
cpp
Swap(&a[begin], &a[keyi]);
此时 begin 就是基准值最终所在的位置。
4. 划分函数实现
cpp
int PartSort(int* a, int left, int right)
{
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && a[end] >= a[keyi])
{
--end;
}
while (begin < end && a[begin] <= a[keyi])
{
++begin;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[keyi]);
return begin;
}
返回值是基准值完成划分后的下标。
5. 为什么条件中必须有 begin < end?
下面两个循环都必须先判断:
cpp
begin < end
完整条件为:
cpp
while (begin < end && a[end] >= a[keyi])
以及:
cpp
while (begin < end && a[begin] <= a[keyi])
如果没有边界判断,指针可能在已经相遇后继续移动,甚至越过当前区间。
用一个有序数组观察越界风险
假设数组为:
text
1 2 3 4 5
选择最左边的 1 作为基准值。右指针从 5 开始寻找小于 1 的元素:
text
5 >= 1,end--
4 >= 1,end--
3 >= 1,end--
2 >= 1,end--
此时 end 已经移动到基准值所在位置,begin == end == 0。
如果循环只有:
cpp
while (a[end] >= a[keyi])
那么:
text
a[0] >= a[0]
1 >= 1
条件仍然成立,end 还会继续减小为 -1。下一次再访问:
cpp
a[end]
就越出了数组范围。
加入:
cpp
begin < end
后,两个指针一旦相遇,循环立即停止,不会让 end 继续移动。
&& 左右顺序也不能随意交换
正确写法是:
cpp
while (begin < end && a[end] >= a[keyi])
C 语言的 && 具有短路特性。程序先判断 begin < end,只有边界仍然有效时,才会继续访问 a[end]。
如果把边界判断放到后面:
cpp
while (a[end] >= a[keyi] && begin < end)
程序会先访问 a[end],再检查边界。假如 end 已经不合法,就失去了先保护下标的意义。
边界判断不仅要写,而且要放在数组访问之前。先确认位置合法,再读取该位置的元素。
6. 为什么比较中要包含等号?
右边寻找小于基准值的元素:
cpp
a[end] >= a[keyi]
左边寻找大于基准值的元素:
cpp
a[begin] <= a[keyi]
包含等号可以让指针越过与基准值相等的元素。
用重复元素观察等号的作用
假设数组为:
text
5 5 5 5 5
基准值也是 5。
使用包含等号的写法时,右指针会继续向左移动:
text
5 >= 5,继续
5 >= 5,继续
5 >= 5,继续
直到与左指针相遇,当前划分正常结束。
如果错误地写成:
cpp
while (begin < end && a[end] > a[keyi])
右指针看到最右边的 5 时,因为:
text
5 > 5 不成立
所以它立即停止。
左边如果也写成:
cpp
while (begin < end && a[begin] < a[keyi])
那么左指针同样会在第一个 5 处停止。交换两个相等元素后,begin 和 end 都没有变化,下一轮仍停在原位置,循环就可能无法结束。
因此,在当前左右指针法中,等号不是多余的。它保证遇到与基准值相等的元素时,指针仍然可以推进。
划分函数中既要保证指针不越界,也要保证每一轮指针能够继续移动。边界判断解决越界,比较中的等号解决重复值下的停滞问题。
三、快速排序的递归版本
一次划分结束后,基准值下标为 keyi。接下来需要继续排序两个区间:
text
[left, keyi - 1]
[keyi + 1, right]
这与递归的结构非常吻合。
1. 递归结束条件
如果当前区间中没有元素或只有一个元素,就不需要继续排序。
对于闭区间 [left, right],结束条件是:
cpp
if (left >= right)
{
return;
}
当 left == right 时,区间中只有一个元素;当 left > right 时,区间为空。
2. 递归实现
cpp
void QuickSortRecursive(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = PartSort(a, left, right);
QuickSortRecursive(a, left, keyi - 1);
QuickSortRecursive(a, keyi + 1, right);
}
3. 递归展开过程
假设一次划分后,基准值下标为 keyi:
text
[left, keyi - 1] keyi [keyi + 1, right]
递归函数会先处理左区间,然后处理右区间。每个区间内部又会继续进行相同的划分,直到区间长度小于等于 1。
递归版本的优点是代码简洁,因为系统调用栈替我们保存了:
- 当前函数的
left; - 当前函数的
right; - 左区间处理完成后,还需要回来处理右区间。
问题在于,这些信息虽然没有写在代码中的容器里,却仍然会保存在系统调用栈中。如果划分结果长期不均匀,递归层数就会增加。
四、快速排序如何改成迭代版本?
去掉递归后,最重要的问题是:
一个区间完成划分后,怎样记住接下来还需要处理的左右区间?
递归版本由系统调用栈保存这些区间。迭代版本则可以自己创建一个栈,把尚未处理的区间边界保存起来。
1. 栈中需要保存什么?
每一个待排序区间都由两个边界组成:
text
left
right
例如数组共有 9 个元素,第一次需要处理整个区间:
text
[0, 8]
栈中不能只保存 0,因为程序不知道区间结束在哪里;也不能只保存 8,因为程序不知道区间从哪里开始。因此每个任务都必须成对保存两个边界:
text
0 8
假设第一次划分后还需要处理:
text
[0, 4]
[6, 8]
栈中的数据可以排列为:
text
0 4 6 8
从数据本身看,它们只是四个整数;按照"每相邻两个数是一组区间边界"的约定看,它们表示两个尚未完成的排序任务。
取出时要一次取两个数,才能还原一个完整区间。
2. 为什么栈容量可以设置为 2 * n?
每个区间需要保存两个整数。本文为了把重点放在递归转迭代的过程上,直接申请 2 * n 个整数作为辅助栈:
cpp
int* stack = (int*)malloc(sizeof(int) * 2 * n);
例如 n == 9 时,申请的是:
text
18 个 int 的空间
每个区间使用两个位置:
text
stack[0] 保存 left
stack[1] 保存 right
后续区间继续使用:
text
stack[2]、stack[3]
stack[4]、stack[5]
......
这里采用的是便于理解的保守容量。实际上,不同的压栈顺序还可以进一步减少同时保存在栈中的区间数量,但这不是本篇要解决的核心问题。
分配完成后必须检查是否成功:
cpp
if (stack == NULL)
{
perror("malloc fail");
exit(1);
}
如果申请失败却继续执行:
cpp
stack[top++] = left;
就会通过空指针访问内存,程序行为将失去保证。
3. 第一个区间如何入栈?
cpp
int top = 0;
stack[top++] = left;
stack[top++] = right;
top 表示下一个可以存放数据的位置。
假设:
text
left = 0
right = 8
执行第一句前:
text
top = 0
执行:
cpp
stack[top++] = left;
等价于先执行:
cpp
stack[0] = 0;
再让:
text
top = 1
然后执行:
cpp
stack[top++] = right;
得到:
text
stack[1] = 8
top = 2
此时栈的有效内容为:
text
0 8
top == 2 表示前两个位置已经保存数据,下一个可用位置是 stack[2]。
4. 为什么出栈时先取 right,再取 left?
入栈顺序是:
text
left → right
以 [0, 8] 为例,栈中内容为:
text
下标: 0 1
数据: 0 8
↑
栈顶
由于栈遵循后进先出,最后压入的 8 必须先取出:
cpp
int end = stack[--top];
原来 top == 2,先执行 --top 得到 1,再读取:
text
stack[1] == 8
因此:
text
end = 8
再次执行:
cpp
int begin = stack[--top];
top 从 1 变成 0,读取:
text
stack[0] == 0
因此:
text
begin = 0
完整区间 [0, 8] 被恢复出来。
如果错误地先把栈顶元素当成 left:
text
left = 8
right = 0
区间就会变成 [8, 0],左右边界完全颠倒。
5. 划分后哪些区间需要入栈?
完成划分后,得到基准值下标 keyi。
假设处理区间:
text
[0, 8]
划分后:
text
keyi = 5
那么:
text
左区间:[0, 4]
基准值:下标 5
右区间:[6, 8]
左右区间都至少有两个元素,需要继续排序,因此都应该入栈。
左区间判断:
cpp
if (begin < keyi - 1)
代入数据:
text
0 < 4,成立
右区间判断:
cpp
if (keyi + 1 < end)
代入数据:
text
6 < 8,成立
所以两个区间都压入栈中。
再看只有一个元素的情况。假设:
text
begin = 0
keyi = 1
左区间是:
text
[0, 0]
只有一个元素,判断:
text
begin < keyi - 1
0 < 0,不成立
所以不入栈。
如果基准值恰好位于最左边:
text
begin = 0
keyi = 0
左区间相当于:
text
[0, -1]
它是空区间,更不需要入栈。
因此,判断条件不是简单检查区间是否存在,而是在检查区间中是否至少有两个元素。一个元素已经有序,零个元素则没有排序任务。
6. 快速排序的迭代实现
cpp
void QuickSortNonRecursive(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int n = right - left + 1;
int* stack = (int*)malloc(sizeof(int) * 2 * n);
if (stack == NULL)
{
perror("malloc fail");
exit(1);
}
int top = 0;
stack[top++] = left;
stack[top++] = right;
while (top > 0)
{
int end = stack[--top];
int begin = stack[--top];
int keyi = PartSort(a, begin, end);
if (begin < keyi - 1)
{
stack[top++] = begin;
stack[top++] = keyi - 1;
}
if (keyi + 1 < end)
{
stack[top++] = keyi + 1;
stack[top++] = end;
}
}
free(stack);
}
7. 迭代过程示意
假设开始处理:
text
[0, 8]
第一次入栈:
text
0 8
取出 [0, 8] 并完成划分,假设基准值来到下标 5,则得到:
text
[0, 4] 5 [6, 8]
再把还需要处理的区间压栈:
text
0 4 6 8
因为栈是后进先出,下一次先处理 [6, 8]。处理完后,再继续取出其他区间。
处理左右区间的先后顺序可以不同,只要所有长度大于 1 的区间最终都被处理即可。
8. 快速排序迭代版本的易错点
区间边界要保持统一
本文所有快速排序区间都使用闭区间:
text
[left, right]
所以:
cpp
int n = right - left + 1;
递归子区间是:
cpp
[left, keyi - 1]
[keyi + 1, right]
如果一部分代码使用闭区间,另一部分代码却按照左闭右开的方式处理,就很容易出现漏排或越界。
入栈和出栈顺序必须对应
入栈:
cpp
stack[top++] = left;
stack[top++] = right;
出栈:
cpp
right = stack[--top];
left = stack[--top];
顺序不能写反。
基准值位置不再进入子区间
完成划分后,keyi 已经在最终位置,因此左右子区间不能再次包含 keyi。
动态申请的空间要释放
cpp
free(stack);
如果使用 malloc 申请空间,使用结束后必须调用 free 释放。
五、快速排序递归版与迭代版的比较
| 比较内容 | 递归版本 | 迭代版本 |
|---|---|---|
| 待处理区间保存位置 | 系统调用栈 | 自己维护的栈 |
| 代码长度 | 较短 | 较长 |
| 过程表现 | 隐藏在函数调用中 | 压栈、出栈过程明确可见 |
| 结束方式 | 区间长度小于等于 1 时返回 | 栈为空时结束 |
| 空间来源 | 递归调用占用的栈空间 | 手动申请或预先准备的栈空间 |
| 核心划分函数 | 相同 | 相同 |
两种写法的排序思想没有变化,区别只在于待处理区间由谁保存。
递归版使用系统调用栈,迭代版使用自己维护的栈。把递归改成迭代,不是改变快速排序,而是把递归背后隐含的区间保存过程写出来。
六、归并排序的基本思路
归并排序同样使用了拆分思想,但它和快速排序的处理顺序正好相反。
快速排序是:
text
先划分,再分别处理左右区间
归并排序是:
text
先分别处理左右区间,再把两个有序区间合并
假设有两个已经有序的区间:
text
1 4 7
和:
text
2 3 8
要把它们合并为一个有序区间,可以比较两个区间当前最前面的元素:
text
1 和 2 比较,取 1
4 和 2 比较,取 2
4 和 3 比较,取 3
4 和 8 比较,取 4
7 和 8 比较,取 7
第一个区间结束,追加 8
最终得到:
text
1 2 3 4 7 8
这就是归并排序中最核心的操作:合并两个有序区间。
七、两个有序区间如何合并?
假设当前要合并两个相邻的有序区间:
text
[begin1, end1]
[begin2, end2]
其中:
cpp
begin2 = end1 + 1;
使用一个临时数组 tmp 保存合并结果。
1. 比较两个区间的当前元素
cpp
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
每次选择两个区间当前较小的元素放入临时数组。
用完整过程观察两个指针如何移动
假设两个有序区间为:
text
左区间:[1 4 7]
右区间:[2 3 8]
最开始:
text
begin1 指向 1
begin2 指向 2
index 指向临时数组第一个待写入位置
第一次比较:
text
1 <= 2
所以把 1 放入临时数组,并让 begin1 向后移动:
text
tmp:[1]
左区间剩余:[4 7]
右区间剩余:[2 3 8]
第二次比较:
text
4 <= 2 不成立
把 2 放入临时数组,并移动 begin2:
text
tmp:[1 2]
左区间剩余:[4 7]
右区间剩余:[3 8]
第三次比较:
text
4 <= 3 不成立
放入 3:
text
tmp:[1 2 3]
左区间剩余:[4 7]
右区间剩余:[8]
继续比较后得到:
text
tmp:[1 2 3 4 7]
左区间已经取完
右区间剩余:[8]
此时主循环结束,因为条件:
cpp
begin1 <= end1 && begin2 <= end2
要求两个区间都还有元素。只要其中一个区间用完,主循环就停止。
为什么每次取当前较小值就不会漏掉更小元素?
因为两个区间本身已经有序。
例如左区间当前元素为 4,它后面的元素是 7;右区间当前元素为 3,它后面的元素是 8。当前最小值一定只可能出现在两个区间的开头:
text
min(4, 3) = 3
没有必要拿 4 再与右区间后面的 8 比较,因为 3 已经小于或等于右区间后续所有元素。
为什么相等时优先取左区间?
代码使用:
cpp
a[begin1] <= a[begin2]
而不是只使用 <。
为了看出区别,给相同数值添加来源标记:
text
左区间:[2左 5]
右区间:[2右 6]
两个 2 的数值相同,但 2左 在原数组中的位置早于 2右。
使用 <= 时:
text
2左 <= 2右,先取 2左
合并后仍然是:
text
2左 2右 5 6
相等元素的原有先后顺序没有改变。
如果使用 <,当二者相等时条件不成立,程序会先取右区间的 2右,结果可能变成:
text
2右 2左 5 6
相同元素的相对顺序发生了改变。因此,相等时优先取左区间元素,是归并排序保持稳定性的关键之一。
2. 为什么主循环结束后还要处理剩余元素?
主循环结束后,只能说明至少一个区间已经全部取完,不能说明两个区间同时取完。
继续使用刚才的例子:
text
左区间:[1 4 7]
右区间:[2 3 8]
比较结束时可能得到:
text
tmp:[1 2 3 4 7]
左区间:已取完
右区间:还剩 8
如果此时直接结束,8 就会丢失,合并结果只有 5 个元素。
因此需要补充两个循环。
左区间可能有剩余:
cpp
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
右区间可能有剩余:
cpp
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
为什么剩余元素不需要继续比较?因为剩下的元素来自同一个已经有序的区间。
例如右区间剩余:
text
8 10 13
它们本身已经满足:
text
8 <= 10 <= 13
并且另一个区间已经没有元素,所以直接按原顺序追加即可。
3. 为什么还要把临时数组拷回原数组?
临时数组中的结果最终还要拷回原数组对应位置。
在本文的递归实现中,临时数组下标与原数组下标保持一致:
cpp
for (int i = left; i <= right; ++i)
{
a[i] = tmp[i];
}
假设原数组的一部分是:
text
下标:0 1 2 3 4 5 6 7
数据:8 4 5 7 1 3 6 2
第一次将 [0, 0] 和 [1, 1] 合并后,临时数组中得到:
text
tmp[0] = 4
tmp[1] = 8
如果不拷回原数组,原数组前两个位置仍然是:
text
8 4
下一层准备把 [0, 1] 与 [2, 3] 合并时,读取到的还是无序区间 [8 4],前一层归并结果等于没有真正保存下来。
拷回之后:
text
原数组:4 8 5 7 1 3 6 2
下一层才能把有序区间 [4 8] 和 [5 7] 继续合并。
临时数组负责暂存本次归并结果,原数组负责为下一层归并提供已经有序的子区间。只写入临时数组而不拷回,后续过程就无法使用本次排序结果。
八、归并排序的递归版本
1. 递归如何拆分区间?
对于闭区间:
text
[left, right]
先找到中间位置:
cpp
int mid = left + (right - left) / 2;
于是区间被拆成:
text
[left, mid]
[mid + 1, right]
分别让这两个区间有序后,再把它们合并。
2. 递归结束条件
当区间中只有一个元素或没有元素时,它已经天然有序:
cpp
if (left >= right)
{
return;
}
3. 递归归并核心实现
cpp
void MergeSortRecursiveCore(int* a, int* tmp, int left, int right)
{
if (left >= right)
{
return;
}
int mid = left + (right - left) / 2;
MergeSortRecursiveCore(a, tmp, left, mid);
MergeSortRecursiveCore(a, tmp, mid + 1, right);
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int i = left; i <= right; ++i)
{
a[i] = tmp[i];
}
}
外层函数负责申请临时数组:
cpp
void MergeSortRecursive(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
MergeSortRecursiveCore(a, tmp, 0, n - 1);
free(tmp);
}
4. 为什么临时数组只申请一次?
如果在每一层递归中都调用 malloc,就会发生大量重复申请和释放。
例如一个长度为 8 的数组会被拆成:
text
1 个长度为 8 的区间
2 个长度为 4 的区间
4 个长度为 2 的区间
8 个长度为 1 的区间
如果每个需要合并的递归函数都单独申请临时空间,程序会反复执行:
text
申请 → 使用 → 释放
申请 → 使用 → 释放
申请 → 使用 → 释放
不仅代码更复杂,也增加了动态内存管理次数。
实际上,不同递归层不会在同一时刻同时写入同一段归并结果。左区间归并完成后,临时数组对应位置可以继续被后续合并使用;右区间也是一样。
因此,外层只申请一次长度为 n 的临时数组:
cpp
int* tmp = (int*)malloc(sizeof(int) * n);
然后把同一个 tmp 传入所有递归调用:
cpp
MergeSortRecursiveCore(a, tmp, 0, n - 1);
假设正在合并 [0, 1],就使用:
text
tmp[0]、tmp[1]
正在合并 [4, 7],就使用:
text
tmp[4] 到 tmp[7]
最终合并 [0, 7] 时,再使用整个:
text
tmp[0] 到 tmp[7]
同一个临时数组按照当前区间范围反复使用,不需要每进入一层递归就重新申请。
归并排序确实需要额外数组,但需要的是一块可以反复使用的工作区,不是每个递归函数各自永久占有一块新空间。
5. 递归版本的执行顺序
对于:
text
8 4 5 7 1 3 6 2
递归拆分过程可以看成:
text
[8 4 5 7 1 3 6 2]
↓
[8 4 5 7] [1 3 6 2]
↓ ↓
[8 4] [5 7] [1 3] [6 2]
↓ ↓ ↓ ↓
[8][4] [5][7] [1][3] [6][2]
当区间被拆成单个元素后,开始向上合并:
text
[8] + [4] → [4 8]
[5] + [7] → [5 7]
[4 8] + [5 7] → [4 5 7 8]
另一半也完成相同过程,最后再合并整个数组。
九、归并排序如何改成迭代版本?
递归版本是从大区间不断拆成小区间,然后再逐层合并。
迭代版本可以反过来观察:
- 单个元素本身就是有序区间;
- 两个长度为 1 的有序区间,可以合并成长度为 2 的有序区间;
- 两个长度为 2 的有序区间,可以合并成长度为 4 的有序区间;
- 两个长度为 4 的有序区间,可以合并成长度为 8 的有序区间。
所以归并区间长度依次为:
text
1 → 2 → 4 → 8 → 16 → ...
这就是归并排序的迭代实现,也称为自底向上的归并排序。
1. gap 表示什么?
cpp
int gap = 1;
gap 表示本轮中每个有序子区间的长度。
为什么从 1 开始?因为任何单独的一个元素都可以直接看作有序区间。
例如数组:
text
8 4 5 7 1 3 6 2
虽然整个数组无序,但每一个单元素区间内部都不可能出现逆序:
text
[8] [4] [5] [7] [1] [3] [6] [2]
所以第一轮可以把相邻的单元素区间两两合并,也就是:
text
gap == 1
这一轮结束后,每两个元素组成的区间有序:
text
[4 8] [5 7] [1 3] [2 6]
下一轮就应该把两个长度为 2 的有序区间合并:
text
gap == 2
得到:
text
[4 5 7 8] [1 2 3 6]
再下一轮:
text
gap == 4
最终合并整个数组。
因此 gap 的变化为:
text
1 → 2 → 4 → 8
每完成一轮,合并后的区间长度最多会变成原来的两倍,所以代码写成:
cpp
gap *= 2;
如果错误地写成:
cpp
gap++;
区间长度会按照 1、2、3、4...... 变化,这与上一轮形成的有序区间长度无法稳定对应。例如 gap == 2 后已经形成长度为 4 的有序区间,下一轮却用 gap == 3 重新切分,新的区间边界会切进原来的有序块中,归并基础被破坏。
2. 每组区间的边界为什么这样计算?
对于每一组,设置:
cpp
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
它们表示两个相邻区间:
text
[begin1, end1]
[begin2, end2]
假设:
text
i = 0
gap = 2
第一个区间从 i 开始:
text
begin1 = 0
长度为 2 的闭区间结束位置是:
text
end1 = 0 + 2 - 1 = 1
所以第一个区间为:
text
[0, 1]
第二个区间紧接在第一个区间后面:
text
begin2 = 0 + 2 = 2
它也希望包含 2 个元素,所以右边界为:
text
end2 = 0 + 2 * 2 - 1 = 3
第二个区间为:
text
[2, 3]
合起来正好是:
text
[0, 1] 与 [2, 3]
处理下一组时:
cpp
i += 2 * gap;
此时 i 从 0 变为 4,下一组是:
text
[4, 5] 与 [6, 7]
如果只写:
cpp
i += gap;
i 会从 0 变成 2,下一组变为:
text
[2, 3] 与 [4, 5]
其中 [2, 3] 已经在上一组中参与过合并,区间发生重叠。所以每一组必须向后移动两个 gap。
3. 最后一组为什么容易越界?
数组长度不一定正好是 2 * gap 的整数倍。前面的完整分组都比较规则,最后一组却可能缺少元素。
下面用不同数组长度分别观察三种情况。
情况一:两个区间都完整
假设:
text
n = 8
gap = 2
i = 4
计算得到:
text
begin1 = 4
end1 = 5
begin2 = 6
end2 = 7
区间为:
text
[4, 5] 与 [6, 7]
所有下标都小于 n,可以正常合并。
情况二:只有第一个区间,没有第二个区间
假设数组长度为 10,当:
text
gap = 4
i = 8
按照公式计算:
text
begin1 = 8
end1 = 11
begin2 = 12
end2 = 15
实际数组最后一个下标只有:
text
n - 1 = 9
也就是说,剩余部分只有:
text
[8, 9]
第二个区间从下标 12 开始,已经完全不存在。判断:
cpp
if (begin2 >= n)
{
break;
}
代入:
text
12 >= 10,成立
于是结束当前轮。为什么可以不处理 [8, 9]?因为在上一轮中,它已经是一个有序区间;现在没有相邻的第二个区间可供合并,保留原状即可。
情况三:第二个区间存在,但不完整
假设:
text
n = 10
gap = 4
i = 0
第一组为:
text
[0, 3] 与 [4, 7]
它是完整的。
为了更直观看到不完整右区间,再取:
text
n = 7
gap = 2
i = 4
计算得到:
text
begin1 = 4
end1 = 5
begin2 = 6
end2 = 7
实际最后一个下标是:
text
n - 1 = 6
第二个区间确实存在,因为 begin2 == 6;但它只有下标 6 这一个元素,计算出的 end2 == 7 已经越界。
所以需要修正:
cpp
if (end2 >= n)
{
end2 = n - 1;
}
修正后区间变为:
text
[4, 5] 与 [6, 6]
虽然第二个区间长度不足 gap,但它仍然是有序区间,可以正常参与合并。
为什么本文不需要单独修正 end1?
只要:
text
begin2 < n
就说明第二个区间至少有一个元素。因为:
text
begin2 = end1 + 1
第二个区间能够存在,就代表 end1 一定在数组有效范围内。
反过来,如果 end1 已经越界,begin2 必然更大,也一定满足:
cpp
begin2 >= n
程序会直接结束当前轮。因此,在本文这组边界公式下,只需要判断第二个区间是否存在,并修正 end2。
4. 归并排序的迭代实现
cpp
void MergeSortNonRecursive(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
if (begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int index = begin1;
int left = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int j = left; j <= end2; ++j)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
}
5. 为什么每合并一组就拷回原数组?
假设 gap == 1 时,把两个单元素区间合并成长度为 2 的有序区间。下一轮 gap == 2 时,需要继续使用这一轮已经排好的结果。
因此,每组合并完成后必须把临时数组中的结果拷回原数组:
cpp
for (int j = left; j <= end2; ++j)
{
a[j] = tmp[j];
}
否则下一轮读取的仍然是原来的无序内容。
也可以先把一整轮归并结果全部写入临时数组,再统一交换原数组和临时数组的角色。但这种写法需要额外处理没有参与合并的尾部区间。为了让边界过程更直观,本文采用每组合并后立即拷回的方式。
十、归并排序迭代版本的完整过程
以数组:
text
8 4 5 7 1 3 6 2
为例。
第一轮:gap == 1
把相邻的单个元素进行归并:
text
[8] [4] → [4 8]
[5] [7] → [5 7]
[1] [3] → [1 3]
[6] [2] → [2 6]
数组变为:
text
4 8 5 7 1 3 2 6
现在每个长度为 2 的区间内部有序。
第二轮:gap == 2
合并相邻的长度为 2 的有序区间:
text
[4 8] [5 7] → [4 5 7 8]
[1 3] [2 6] → [1 2 3 6]
数组变为:
text
4 5 7 8 1 2 3 6
现在每个长度为 4 的区间内部有序。
第三轮:gap == 4
合并两个长度为 4 的有序区间:
text
[4 5 7 8] [1 2 3 6]
得到:
text
1 2 3 4 5 6 7 8
此时整个数组有序。
递归归并是先拆到最小,再从下向上合并;迭代归并直接从最小区间开始,一轮一轮扩大有序区间长度。
十一、归并排序迭代版本的易错点
1. gap 必须逐轮扩大
cpp
gap *= 2;
如果忘记更新 gap,循环会一直停留在长度为 1 的区间,导致死循环。
2. 外层循环条件是 gap < n
当 gap 已经不小于数组长度时,整个数组已经处在一个有序区间中,不需要继续合并。
3. 每次移动 2 * gap
一组归并包含两个长度约为 gap 的区间,因此下一组从:
cpp
i += 2 * gap
开始。
如果错误地写成:
cpp
i += gap
相邻归并区间会发生重叠。
4. 必须处理不完整的最后一组
当数组长度不是 2 * gap 的整数倍时,最后一个右区间可能不存在,也可能不完整。必须判断:
cpp
begin2 >= n
并修正:
cpp
end2 = n - 1;
5. 临时数组下标要与原数组位置对应
本文让:
cpp
int index = begin1;
因此临时数组中的结果直接写在原区间对应的位置,拷回时使用:
cpp
for (int j = left; j <= end2; ++j)
如果 index 从 0 开始,则拷回时必须重新计算偏移量。两种写法都可以,但前后必须统一。
6. 比较时使用 <= 保持稳定性
cpp
if (a[begin1] <= a[begin2])
当两个元素相等时,先取左区间的元素,可以保持相等元素原有的相对顺序。
如果只比较整数值,可能看不出稳定性的区别;但当元素还附带其他信息时,相对顺序是否改变就会体现出来。
十二、归并排序递归版与迭代版的比较
| 比较内容 | 递归版本 | 迭代版本 |
|---|---|---|
| 思考方向 | 从大区间不断拆分 | 从小区间逐层合并 |
| 合并顺序 | 递归返回时完成 | gap 从 1 开始不断翻倍 |
| 结束条件 | 区间长度小于等于 1 | gap >= n |
| 调用栈 | 使用递归调用栈 | 不需要递归调用 |
| 临时数组 | 需要 | 需要 |
| 边界难点 | mid 和递归子区间 |
最后一组区间可能不完整 |
| 时间复杂度 | O(NlogN) |
O(NlogN) |
| 额外数组空间 | O(N) |
O(N) |
| 稳定性 | 稳定 | 稳定 |
两种实现都必须完成相同的归并工作。递归版本把"先拆后合"的过程直接写出来,迭代版本则把递归返回时的合并顺序转换成 gap 的逐轮增长。
十三、快速排序与归并排序的区别
虽然快速排序和归并排序都使用拆分思想,但它们的核心过程不同。
| 比较内容 | 快速排序 | 归并排序 |
|---|---|---|
| 核心操作 | 按基准值划分区间 | 合并两个有序区间 |
| 排序发生的阶段 | 划分过程中 | 合并过程中 |
| 基准元素 | 需要 | 不需要 |
| 平均时间复杂度 | O(NlogN) |
O(NlogN) |
| 最坏时间复杂度 | O(N²) |
O(NlogN) |
| 额外空间 | 与区间保存方式有关 | 需要 O(N) 临时数组 |
| 稳定性 | 通常不稳定 | 稳定 |
| 递归改迭代方式 | 自己维护待处理区间栈 | 让 gap 从 1 开始翻倍 |
快速排序每次划分后,可以确定一个基准值的最终位置;归并排序则不能依靠一个基准值确定位置,而是通过不断合并,让更大的区间逐渐有序。
快速排序的迭代版本仍然按照"划分一个区间,再保存两个子区间"的方式工作,所以需要栈。
归并排序的迭代版本不需要保存待处理区间,因为每一轮要处理的区间可以由 gap 和下标直接计算出来。
十四、完整测试代码
下面给出快速排序、归并排序的递归版与迭代版完整代码,并使用相同数组进行测试。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void PrintArray(const int* a, int n)
{
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
int PartSort(int* a, int left, int right)
{
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && a[end] >= a[keyi])
{
--end;
}
while (begin < end && a[begin] <= a[keyi])
{
++begin;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[keyi]);
return begin;
}
void QuickSortRecursive(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = PartSort(a, left, right);
QuickSortRecursive(a, left, keyi - 1);
QuickSortRecursive(a, keyi + 1, right);
}
void QuickSortNonRecursive(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int n = right - left + 1;
int* stack = (int*)malloc(sizeof(int) * 2 * n);
if (stack == NULL)
{
perror("malloc fail");
exit(1);
}
int top = 0;
stack[top++] = left;
stack[top++] = right;
while (top > 0)
{
int end = stack[--top];
int begin = stack[--top];
int keyi = PartSort(a, begin, end);
if (begin < keyi - 1)
{
stack[top++] = begin;
stack[top++] = keyi - 1;
}
if (keyi + 1 < end)
{
stack[top++] = keyi + 1;
stack[top++] = end;
}
}
free(stack);
}
void MergeSortRecursiveCore(int* a, int* tmp, int left, int right)
{
if (left >= right)
{
return;
}
int mid = left + (right - left) / 2;
MergeSortRecursiveCore(a, tmp, left, mid);
MergeSortRecursiveCore(a, tmp, mid + 1, right);
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int i = left; i <= right; ++i)
{
a[i] = tmp[i];
}
}
void MergeSortRecursive(int* a, int n)
{
if (n <= 1)
{
return;
}
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
MergeSortRecursiveCore(a, tmp, 0, n - 1);
free(tmp);
}
void MergeSortNonRecursive(int* a, int n)
{
if (n <= 1)
{
return;
}
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
if (begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int index = begin1;
int left = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int j = left; j <= end2; ++j)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
}
int main()
{
int original[] = { 6, 1, 7, 3, 9, 2, 4, 8, 5, 5 };
int n = sizeof(original) / sizeof(original[0]);
int quickRecursive[10];
int quickNonRecursive[10];
int mergeRecursive[10];
int mergeNonRecursive[10];
memcpy(quickRecursive, original, sizeof(original));
memcpy(quickNonRecursive, original, sizeof(original));
memcpy(mergeRecursive, original, sizeof(original));
memcpy(mergeNonRecursive, original, sizeof(original));
QuickSortRecursive(quickRecursive, 0, n - 1);
QuickSortNonRecursive(quickNonRecursive, 0, n - 1);
MergeSortRecursive(mergeRecursive, n);
MergeSortNonRecursive(mergeNonRecursive, n);
printf("original: ");
PrintArray(original, n);
printf("quick recursive: ");
PrintArray(quickRecursive, n);
printf("quick non-recursive: ");
PrintArray(quickNonRecursive, n);
printf("merge recursive: ");
PrintArray(mergeRecursive, n);
printf("merge non-recursive: ");
PrintArray(mergeNonRecursive, n);
return 0;
}
预期输出:
text
original: 6 1 7 3 9 2 4 8 5 5
quick recursive: 1 2 3 4 5 5 6 7 8 9
quick non-recursive: 1 2 3 4 5 5 6 7 8 9
merge recursive: 1 2 3 4 5 5 6 7 8 9
merge non-recursive: 1 2 3 4 5 5 6 7 8 9
四种实现得到相同结果,说明递归和迭代改变的是过程组织方式,而不是最终排序目标。
十五、时间复杂度与空间复杂度
1. 快速排序
理想情况下,每次划分都能把区间分成规模接近的两部分,递归或区间处理层数约为 logN,每一层总共处理约 N 个元素,因此平均时间复杂度为:
text
O(NlogN)
如果每次选择的基准值都接近当前区间的最大值或最小值,划分会非常不均匀,最坏时间复杂度退化为:
text
O(N²)
递归版使用系统调用栈;迭代版自己保存待处理区间。二者都需要为尚未完成的子区间保留信息,只是保存方式不同。
本文的迭代实现为了让逻辑清晰,直接申请了 2 * n 个整数作为栈空间,因此辅助栈的申请规模是 O(N)。这不代表所有快速排序迭代实现都必须固定申请这么多空间,通过调整子区间入栈顺序等方式还可以进一步控制栈中同时保存的区间数量,但那属于后续优化问题。
2. 归并排序
归并排序每一轮都需要处理数组中的全部元素,总共有约 logN 轮,因此时间复杂度为:
text
O(NlogN)
无论初始数组是否有序,归并排序仍然按照拆分和合并过程工作,所以它的最好、平均和最坏时间复杂度都保持在 O(NlogN)。
归并排序需要长度为 N 的临时数组,因此额外数组空间为:
text
O(N)
递归版还会额外使用递归调用栈,迭代版则不需要递归调用。
十六、总结
快速排序和归并排序都在处理"如何把一个大区间变成若干小区间"的问题,但它们选择了不同的方向。
快速排序先选择基准值,通过一次划分把基准值放到最终位置,再继续处理基准值左右两侧的区间。递归版由系统调用栈保存这些区间,迭代版则自己建立栈,明确记录尚未处理的左右边界。
归并排序先让小区间有序,再把相邻的有序区间合并。递归版从大区间不断拆分,递归返回时逐层合并;迭代版直接从长度为 1 的区间开始,让 gap 按照 1、2、4、8...... 的顺序不断扩大。
从递归版本改成迭代版本之后,可以更清楚地看到递归背后隐藏的过程:
- 快速排序隐藏的是"还有哪些区间没有处理";
- 归并排序隐藏的是"不同长度的区间应该按照什么顺序合并"。
递归并不是一种全新的排序逻辑,迭代也不是把原算法推翻重写。它们只是使用不同方式组织同一个过程。
快速排序的迭代关键是栈,归并排序的迭代关键是
gap。前者记录待处理区间,后者控制有序区间长度。真正理解这两点,递归版本和迭代版本之间就能够自然对应起来。
本篇结束!!!