跳到主要内容 快速排序算法原理及多种实现方式 | 极客日志
C 算法
快速排序算法原理及多种实现方式 快速排序是一种基于分治策略的交换排序算法,平均时间复杂度为 O(nlogn)。文章详细讲解了快排的主框架及三种核心分区实现方法:Hoare 版本、挖坑法和 Lomuto 前后指针法。此外还介绍了非递归版本的实现思路,以及针对重复数据优化的三路划分算法和防止最坏情况的自省排序(Introsort)。通过对比不同场景下的性能表现,提供了完整的 C 语言代码示例与优化建议。
1. 快排主框架
快速排序是 Hoare 于 1962 年提出的一种二叉树结构的交换排序方法。
其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
简单地说,就是将数组分成左右两个部分,左部分都大于(或小于)中间的基准值,右部分都小于(或大于)中间的基准值,然后不断重复上述过程,直到数组完全有序。
void QuickSort (int * a, int left, int right) {
if (left >= right) return ;
int mid = PartSort(a, left, right);
QuickSort(a, left, mid - 1 );
QuickSort(a, mid + 1 , right);
}
那么显然快排最关键的就是 PartSort 这个函数怎么实现了,这个函数要实现:找到基准值并将数据按照大小划分到基准值的两侧。
时间复杂度:O(nlogn)
空间复杂度:O(logn)
2. 快排的不同实现
这里的 PartSort 函数的实现有很多种,这里介绍 3 种。
2.1 Hoare 版本
算法思路
创建左右指针,确定基准值
从右向左找出比基准值小的数据,从左向右找出比基准值大的数据,左右指针数据交换,进入下次循环
跳出循环后,交换 right 和 key
问题 1:为什么跳出循环后 right 位置的值一定不大于 key?
当 left > right 时,即 right 走到 left 的左侧,而 left 扫描过的数据均不大于 key,因此 right 此时指向的数据一定不大于 key
问题 2:当 left==right 时要不要跳出循环?
不能,因为此时不知道 left 和 right 同时指向的这个值的大小,必须让 right 或者 left 额外多走一步。
我们通过这三个场景来分析问题 1 并详细解释 Hoare 版本的思路:
首先我们选定第一个元素为基准值(实际上选择基准值还有其他更好地方式,但这里先简化),然后将 left=1,right=numsSize-1,也就是除了第一个元素外,数组的头和尾。
注:以升序为例。
场景一:
left<key,left++;right > key,right 不动。
left++,left++,left 指向 7。
left 和 right 交换数据,数组变为:
int a[]={6 ,1 ,2 , , , };
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
3
9
7
left++,right++,left==right,right 指向 9。
此时 right 大于 key,肯定不能直接交换,必须再进行一次交换,让 left > right,而因为此时 right 已经走到了 left 的左边,left 的左边一定小于 key。
将 right 与 key 交换,第一次快排结束,此时数组为:
这里调用时,PartSort 的返回值就是 mid,left 和 right 依然是传入的 left 和 right,
QuickSort(a, left, mid -1 );
QuickSort(a, mid +1 , right);
接下来就是继续递归,left 和 right(在递归传参时改变)最终会在循环之前就 left >= right,递归停止。
场景二:
第一次快排在交换 right 与 key 之前的结果是这样的:
left 与 right 相遇在 6。
且此时 left == right,right 指向 3。right 位置的值不大于 key。
场景三:
第一次快排在交换 right 与 key 之前的结果是这样的:
left 与 right 相遇在 4。
且此时 left == right,right 指向 3。right 位置的值不大于 key。
为什么不用 left 与 right 交换?
我们来看这个数组:
left 和 right 在 7 相遇,left++,right-- 之后,left 指向的位置就已经越界了。
但是 right 就不会有这个问题,因为我们选中的基准值在最左边,即使在第二个数字相遇,right-- 之后也不会越界。
int PartSort1 (int * a, int left, int right) {
int keyi = left;
left++;
while (left <= right) {
while (left <= right && a[left] < a[keyi]) left++;
while (left <= right && a[right] > a[keyi]) right--;
if (left <= right)
Swap(&a[left++], &a[right--]);
}
Swap(&a[right], &a[keyi]);
return right;
}
2.2 挖坑法 思路:
创建左右指针。首先从右向左找出比基准小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标 (即分界值下标)。
int a[]={6 ,1 ,2 ,7 ,9 ,3 ,4 ,5 ,10 ,8 };
我们以这个数组为例,分析挖坑法的步骤:
首先将 6 挖成坑,并将 6 存成 key。
int hole = left;
int key = a[left];
先从右边开始遍历,到 5 时 a[right] > key,停下遍历,将 right 的值放到坑中,并将 right 的位置挖成坑:
a[hole]= a[right];
hole = right;
再从左边遍历,到 7 时,停下遍历,将 left 的值放到坑中,并将 left 的位置挖成坑。
重复上述过程,直到 left > right,把最开始的 key 放进现在的坑中。
挖坑法实际上和 Hoare 版本的原理差不多,只不过是挖坑填坑这个动作代替了交换,并没有本质的变化。
int PartSort2 (int * a, int left, int right) {
int hole = left;
int key = a[left];
while (left < right) {
while (left < right && a[right] >= key) right--;
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key) left++;
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
2.3 Lomuto 前后指针法 创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。
前指针 prev 在开始的时候指向 left+1,后指针 cur 指向 left 也就是基准值。
prev 开始遍历,如果发现 cur 的值小于 key,就把 prev++,再 cur 和 prev 指向的值交换,当遍历完成后,把 prev 和 left 指向的值交换。这样结果就是比基准值小的值都会在 prev 的左边,那么比基准值大的值就会都在 prev 右边,划分就完成了。
我们以这个数组为例,简单说明一下前后指针法的步骤:
int a[]={6 ,1 ,2 ,7 ,9 ,3 ,4 ,5 ,10 ,8 };
一开始,prev 指向 6,cur 指向 1,key 为 6。
cur 向后遍历,发现 2 比 6 小,prev++,并与 cur 交换,但这是我们会发现 prev 和 cur 其实是一样的,那么交换就没有意义了,还会浪费性能,可以在这里添加一个判断。此时 prev 和 cur 都指向 1。
然后 cur 继续遍历到 2,和 1 一样。此时 prev 和 cur 都指向 2。
接着 cur 继续遍历,直到 cur 指向 3,prev++,然后与 3 进行交换,因为 prev++ 之后指向的值是 cur 遍历过的,所以一定是大于 key 的。此时数组为:
int a[]={6 ,1 ,2 ,3 ,9 ,7 ,4 ,5 ,10 ,8 };
prev 指向 3,cur 指向 7。
…………
可以发现,在这个过程中比基准值小的值不断地被交换到 prev 及 prev 的左边,那么最终把基准值与 prev 交换,就可以实现划分了。
int PartSort3 (int * a, int left, int right) {
int prev = left;
int cur = left + 1 ;
int key = a[left];
while (cur <= right) {
if (a[cur] < key && ++prev != cur)
Swap(&a[cur], &a[prev]);
cur++;
}
Swap(&a[left], &a[prev]);
return prev;
}
2.4 快排的非递归版本 上面的三种方法都是通过递归实现的,那么有没有办法不使用递归实现快速排序呢?
当然有,只不过需要借助数据结构——栈。
用 left 和 right 进行基准值的计算并划分,然后把本应递归的区间的新的 left 和 right 入栈,在入栈或出栈时判断 left 和 right 的大小是否合适,一直运行到栈为空,快速排序就完成了。
void QuickSortNonR (int * a, int left, int right) {
Stack st;
StackInit(&st);
Stack* tmp = &st;
StackPush(tmp, right);
StackPush(tmp, left);
while (!StackEmpty(tmp)) {
int lefti = StackTop(tmp);
StackPop(tmp);
int righti = StackTop(tmp);
StackPop(tmp);
int keyi = lefti;
int prev = lefti;
int cur = lefti + 1 ;
while (cur <= righti) {
if (a[cur] < a[keyi] && prev++ != cur)
Swap(&a[cur], &a[prev]);
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
if (keyi - 1 > lefti) {
StackPush(tmp, keyi - 1 );
StackPush(tmp, lefti);
}
if (keyi + 1 < righti) {
StackPush(tmp, righti);
StackPush(tmp, keyi + 1 );
}
}
StackDestroy(tmp);
}
3. 快排优化
3.1 快排性能的关键点分析 决定快排性能的关键点是每次单趟排序后,key 对数组的分割,如果每次选 key 基本二分居中,那么快排的递归树就是颗均匀的满二叉树,性能最佳。但是实践中虽然不可能每次都是二分居中,但是性能也还是可控的。但是如果出现每次选到最小值/最大值,划分为 0 个和 N-1 的子问题时,时间复杂度为 O(N^2),数组序列有序时就会出现这样的问题,我们可以用三数取中(选取 3 个数,把大小在中间那个作为基准值)或者随机选 key(使用随机数选基准值,这两种办法实际的效率提升不是很高,不单独介绍了)解决这个问题,但是现在还是有一些场景没解决,比如数组中有大量重复数据时,比如以下代码:
int a[] = {6 ,1 ,7 ,6 ,6 ,6 ,4 ,9 };
int a[] = {3 ,2 ,3 ,3 ,3 ,3 ,2 ,3 };
int a[] = {2 ,2 ,2 ,2 ,2 ,2 ,2 ,2 };
这种时候快排的效率就会急剧下降,完全不如其他的较快的排序算法(如堆排序)。
3.2 三路划分 当面对有大量跟 key 相同的值时,三路划分的核心思想有点类似 Hoare 的左右指针和 Lomuto 的前后指针的结合。核心思想是把数组中的数据分为三段【比 key 小的值】【跟 key 相等的值】【比 key 大的值】,所以叫做三路划分算法。结合步骤,理解一下实现思想:
key 默认取 left 位置的值。
left 指向区间最左边,right 指向区间最后边,cur 指向 left+1 位置。
cur 遇到比 key 小的值后跟 left 位置交换,换到左边,left++,cur++。
cur 遇到比 key 大的值后跟 right 位置交换,换到右边,right--。
cur 遇到跟 key 相等的值后,cur++。
直到 cur > right 结束
int a[] = {6 ,1 ,7 ,6 ,6 ,6 ,4 ,9 };
int a[] = {1 ,4 ,6 ,6 ,6 ,6 ,9 ,7 };
int b[] = {6 ,6 ,6 ,6 ,6 ,6 ,6 ,6 };
int b[] = {6 ,6 ,6 ,6 ,6 ,6 ,6 ,6 };
void QuickSortTreeWay (int * a, int left, int right) {
if (left >= right) return ;
int begin = left;
int cur = left + 1 ;
int end = right;
int key = a[left];
while (cur <= right) {
if (a[cur] > key) {
Swap(&a[cur], &a[end--]);
} else if (a[cur] == key) {
cur++;
} else {
Swap(&a[cur++], &a[begin++]);
}
}
QuickSortTreeWay(a, left, begin - 1 );
QuickSortTreeWay(a, end + 1 , right);
}
3.3 Introsort 自省排序 Introsort 是由 David Musser 在 1997 年设计的排序算法,C++ STL sort 中就是用的 introspective sort(内省排序) 思想实现的。
内省排序可以认为不受数据分布的影响,无论什么原因划分不均匀导致递归深度太深,它都会转换为堆排,而堆排不受数据分布影响,具体可以看下面代码。
三路划分针对有大量重复数据时效率很好,其他场景就一般,而自省排序在任何场景都有优异的性能。
Introsort 是 introspective sort 采用了缩写,他的名字其实表达了它的实现思路,也就是进行自我侦测和反省,快排递归深度太深 (STL 中使用的是深度为 2 倍排序元素数量的对数值) 那就说明在这种数据序列下,选 key 出现了问题,性能在快速退化,那么就不要再进行快排分割递归了,改换为堆排序进行排序。
void IntroSort (int * a, int left, int right, int depth, int defaultDepth) {
if (left >= right) return ;
if (right - left + 1 < 16 ) {
InsertSort(a + left, right - left + 1 );
return ;
}
if (depth > defaultDepth) {
HeapSort(a + left, right - left + 1 );
return ;
}
depth++;
int begin = left;
int end = right;
int randi = left + (rand() % (right - left + 1 ));
Swap(&a[left], &a[randi]);
int prev = left;
int cur = prev + 1 ;
int keyi = left;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur) {
Swap(&a[prev], &a[cur]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
IntroSort(a, begin, keyi - 1 , depth, defaultDepth);
IntroSort(a, keyi + 1 , end, depth, defaultDepth);
}
void QuickSort (int * a, int left, int right) {
int depth = 0 ;
int logn = 0 ;
int N = right - left + 1 ;
for (int i = 1 ; i < N; i *= 2 ) {
logn++;
}
IntroSort(a, left, right, depth, logn * 2 );
}
int * sortArray (int * nums, int numsSize, int * returnSize) {
srand(time(0 ));
QuickSort(nums, 0 , numsSize - 1 );
*returnSize = numsSize;
return nums;
}