基本了解
首先,快排是一个基于二叉树结构的排序,理想情况下时间复杂度为N*logN,极端情况下为N^2,但实际使用中,通过一些优化(后文会说),基本不会出现时间复杂度为N^2的情况,所以可以将快排简单地看成是一个时间复杂度为N*logN的排序算法。快排是如今主流的排序算法。后文快排以升序为例。
排序思路
假设有一串数组,
我们再取一个中间值5作为key,
比key值小的放key左边,比key值大的放key右边,得到
再基于key的位置,将数组分为两部分,key的左边,key的右边,然后重复以上的操作,直到分不了,即分出的部分没有值或只有一个值为止,如图,
以上便是快排的基本思路。
在理想情况下,key值为数组的中位数,数组的长度为N,递归深度为logN,因此时间复杂度为N*logN,而在极端情况下,比如数组是个有序数组,且我们一直取数组的第一个数作为key,这样一来递归的深度为N-1,时间复杂度为N^2。
了解了基本思路后还剩下两个问题,1. 如何取key?2. 如何在原有数组中交换数据,使数组达到我们想要的效果?
如何取key
从上文中我们可以看出key值的好坏决定了快排的效率,一旦key取得不好,调用堆栈过深,还会有栈溢出的风险。但给你一串数组,我们不知道一串数组中的所有值,我们无法像上文那样精确地找到中位数作为key值,因此我们只能尽量在数组的所有值中尽量找出接近中位数的key。
常用的方法为:三数取中法。即取数组的头、中间、尾的数据进行比对,取出三数中中间的那个数,再与头一个数作交换。
int GetMidIndex(int* a, int left, int right)
{
//left和right为数组下标,返回中间值的数组下标,交给上层去做交换
int mid = left + (right - left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
else // a[left] >= a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
}
交换数据
由于交换数据需要在数组内进行,无法另开空间,所以交换数据的方法反而是快排中最难的点。这里提供三种方法。
hoare法
代码如下,
// 快速排序hoare版本
int PartSort1(int* a, int left, int right) {
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
int keyi = left;
while (left < right) {
while (left < right && a[right] >= a[keyi]) {
right--;
}
while (left < right && a[left] <= a[keyi]) {
left++;
}
Swap(&a[left], &a[right]);
}
int meeti = left;
Swap(&a[meeti], &a[keyi]);
return meeti;
}
需要注意的是,这里一定要右边先走,因为我们需要保证当left=right的节点是小于key的。
挖坑法
代码如下,
// 快速排序挖坑法
int PartSort2(int* a, int left, int right) {
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
int key = a[left];
int hole = 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;
}
挖坑法也需要从右边找起,因为需要先找到比key小的数据与头位置的坑做交换。
前后指针法
代码如下,
// 快速排序前后指针法
int PartSort3(int* a, int left, int right) {
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur) {
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
以上三种方法没有明显差距,用哪个都可以。
优化策略
当快排递归到快结束时,比如当此递归只有8个数,8个数却至少需要三次递归,过于浪费栈空间,这时我们就可以使用其他排序,如插入排序来对这8个数进行排序。
优化后的快排代码如下,
void QuickSort(int* a, int left, int right) {
if (left >= right) {
return;
}
if (right - left <= 8) {
InsertSort(a + left, right - left + 1);
}
else {
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
快速排序的缺陷
快速排序目前最大的缺点就是它的不稳定。通俗点来说,比如一串数组有两个1,一个在前一个在后,快速排序算法不能保证原本在前的那个1在排序结束后还是在前。ps: 大多数情况下我们可以忽略这个缺陷,但仍有不少场景需要算法稳定,这种时候就不会选择快排。
拓展:快排的非递归写法
非递归写法主要应用在栈空间不够的情况下,基本思路是通过栈,储存数组下标,来模拟递归。
代码如下,
void QuickSortNonR(int* a, int left, int right) {
Stack st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st)) {
int right1 = StackTop(&st);
StackPop(&st);
int left1 = StackTop(&st);
StackPop(&st);
if (left1 >= right1) {
continue;
}
int keyi = PartSort1(a, left1, right1);
StackPush(&st, keyi + 1);
StackPush(&st, right1);
StackPush(&st, left1);
StackPush(&st, keyi - 1);
}
StackDestroy(&st);
}
栈是我用c手搓的,嫌麻烦的可以替换成c++STL库里的栈。