基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序分为三种方法:
- hoare法
- 挖坑法
- 前后指针法
而其又可以使用递归和非递归来实现,接下来将依次演示每种方法:
hoare法
单趟动图演示:
hoare法的快排分为以下步骤:
- 选出一个key,一般是第一个数,或者是最后一个数。
- 定义变量L和R,L从左走,R从右走。
- R先向前走,找到比key小的位置停下,再让L向后走,找到比key大的值停下。
- 交换L和R代表的数值。
- 继续遍历,同样让R先走,L后走,同上规则。
- 当L和R相遇的时候,把相遇位置的值与key位置的值交换,结束。
排完一趟要求(数据特点)如下:
- 左边的值都比key小。
- 右边的值都比key大。
那我们在排序过程中如何保证相遇位置的值比key小呢?
- 不需要保证,此算法思想(右边先走)足矣解决。注意看本算法思想,它明确了每次让R先走,R找到比key小的值之后才会停下来,这时候才轮到L走,L要么找不到比key大的值就一直走直至相遇R,此时正好满足小于key,要么L找到比key大,交换L和R的值,但随后,R又会继续向前走,一直走,最坏刚好遇到L,因为L先前和R已经换过一次,也就是说这个L的值一定是比key小的,那么同样交换key的值,综上,此算法思想足矣解决。
如若key为最右边的值呢?排完一趟如何?
- 思想和上面一样,唯一要改变的是此时是L先走,R后走,其余没有变。
动图演示:
- 接下来,就先写下单趟排序:
//快排单趟排序
int PartSort(int* a, int left, int right)
{
int keyi = left; //选左边作key
while (left < right)
{
//右边先走,找小
while (left < right && a[right] >= a[keyi]) //防止right找不到比keyi小的值直接飙出去,要加上left < right
{
right--;
}
//右边找到后,左边再走,找大
while (left < right && a[left] <= a[keyi]) //同上,也要加上left < right
{
left++;
}
//右边找到小,左边找到大,就交换
Swap(&a[left], &a[right]);
}
//此时left和right相遇,交换与key的值
Swap(a[keyi], &a[left]);
return left;
}
- 写好了单趟排序,就要进行整体排序:
仔细观察上述单趟排序,有没有发现排完后,key已经排到了正确的位置,因为其左边的值均小于key,而右边的值均大于key,此时key的位置就是最终排序好后应该在的位置。那么如果左边有序,右边有序,那么整体就有序了,只需要用到递归+分治的思想即可。
- 画图演示:
- 总代码如下:
//hoare
//快排单趟排序
int PartSort(int* a, int left, int right)
{
int keyi = left; //选左边作key
while (left < right)
{
//右边先走,找小
while (left < right && a[right] >= a[keyi]) //防止right找不到比keyi小的值直接飙出去,要加上left < right
{
right--;
}
//右边找到后,左边再走,找大
while (left < right && a[left] <= a[keyi]) //同上,也要加上left < right
{
left++;
}
//右边找到小,左边找到大,就交换
Swap(&a[left], &a[right]);
}
//此时left和right相遇,交换与key的值
Swap(&a[keyi], &a[left]);
return left;
}
//快速排序
void QuickSort(int* a, int begin, int end)
{
//子区间相等只有一个值或者不存在那么就是递归结束的子问题
if (begin >= end)
{
return;
}
int keyi = PartSort(a, begin, end);
//分成左右两段区间递归
// [begin, keyi-1] 和 [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
挖坑法
- 动图演示单趟挖坑:
挖坑法的步骤如下:
- 把最左边的位置用key保存起来,此位置形成坑位。
- 定义变量L和R分别置于最左和最右。
- 让R先向前走,找到比key小的位置停下。
- 找到后,将该值放入坑位,自己形成新的坑位。
- 再让L向后走,找比key大的位置停下。
- 找到后,将该值放入坑位,自己形成新的坑位。
- 再让R走……。
- 当L和R相遇时,把key的值放到坑位,结束。
挖坑法相较于上面的hoare法并没有优化,本质上也没有区别,但是其思想更好理解:
- 不需要理解为什么最终相遇位置比key小。
- 不需要理解为什么左边做key,右边先走。
- 总代码如下:
//挖坑法
int PartSort2(int* a, int left, int right)
{
//把最左边的值用key保存起来
int key = a[left];
//把left位置设为坑位pit
int pit = left;
while (left < right) //当left小于right时就继续
{
//右边先走,找小于key的值
while (left < right && a[right] >= key)
{
right--; //如若right的值>=key的值就继续
}
//找到小于key的值时就把此位置赋到坑位,并把自己置为新的坑位
a[pit] = a[right];
pit = right;
//左边走,找大于key的值
while (left < right && a[left] <= key)
{
left++;
}
//找到大于key的值就把此位置赋到坑位,并把自己置为新的坑位
a[pit] = a[left];
pit = left;
}
//此时L和R相遇,将key赋到坑位
a[pit] = key;
return pit;
}
//快速排序
void QuickSort(int* a, int begin, int end)
{
//子区间相等只有一个值或者不存在那么就是递归结束的子问题
if (begin >= end)
{
return;
}
int keyi = PartSort2(a, begin, end);
//分成左右两段区间递归
// [begin, keyi-1] 和 [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
前后指针法
- 动图演示:
前后指针法的步骤如下:
- 把第一个位置的值设为key保存起来。
- 定义prev指针指向第一个位置,cur指向prev后一个位置。
- 若cur指向的数值小于key,prev和cur均后移。
- 当cur指向的数据大于key时,prev不动,cur继续后移。
- 当cur的值小于key时,prev后移一位,交换与cur的值,cur再++。
- 重复上述操作,当cur越界时,交换此时的prev和key的值,结束。
总的来说,cur是在找小,找到后就++prev,prev的值无论怎么走都是小于key的值的,当cur找到大与key时,cur的后面紧挨着的prev是小于key的,接下来让cur++到小于key的值,此过程间prev始终不动,唯有cur找到了小于key的值时,让prev再++,此时的prev就是大于key的值了,仔细揣摩这句话,随后交换cur和prev的值,上述操作相当于是把小于key的值甩在左边,大于key的值甩在右边。
- 总代码如下:
//前后指针法
int PartSort3(int* a, int left, int right)
{
int key = left;//注意不能写成 int key = a[left]
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (a[cur] < a[key] && a[++prev] != a[cur])
{
Swap(&a[prev], &a[cur]);//在cur的值小于key的值的前提下,并且prev后一个值不等于cur的值时交换,避免了交换两个小的(虽然也可以,但是没有意义)
}
cur++; //如若cur的值大于key,则cur++
}
Swap(&a[prev], &a[key]); //此时cur越界,直接交换key与prev位置的值
return prev;
}
//快速排序
void QuickSort(int* a, int begin, int end)
{
//子区间相等只有一个值或者不存在那么就是递归结束的子问题
if (begin >= end)
{
return;
}
int keyi = PartSort2(a, begin, end);
//分成左右两段区间递归
// [begin, keyi-1] 和 [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
如若把key设定为最后一个数据呢?该如何控制?
- 总的来说有三处发生变动:
- cur和prev初始时的位置:先前定义的prev是第一个数据,cur是prev的后一个,而现在,cur是第一个位置,而prev是cur的前一个,相当于是初始时整体后移一位。
- 停止的条件: 原先的cur有效范围是整个数组,现在的cur有效范围是前n-1个数组,省去最后一个定为key的值。
- 交换与key的值的条件: 先前是cur越界时,直接交换prev与key的值,现在是先++prev,再交换与key的值,(因为此时的prev值依旧小于key,要++后才大于key)。
除了这三处有所变动外,别的没有什么变动,交换的过程步骤都是一样的。
- 动图演示:
- 代码如下:
//前后指针法key在右边
int PartSort3(int* a, int left, int right)
{
int key = right;
//变动1: int prev = left - 1; //先前 int prev =left; int cur = left + 1;
int cur = left;
//变动2: while (cur < right) //先前 while (cur <= right)
{
if (a[cur] < a[key] && a[++prev] != a[cur])
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
//变动3: Swap(&a[++prev], &a[key]); //先前Swap(&a[prev], &a[key]);
return prev;
}
//快速排序
void QuickSort(int* a, int begin, int end)
{
//子区间相等只有一个值或者不存在那么就是递归结束的子问题
if (begin >= end)
{
return;
}
int keyi = PartSort3(a, begin, end);
//分成左右两段区间递归
// [begin, keyi-1] 和 [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
快排特性总结
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序。
- 稳定性:不稳定。
- 空间复杂度:O(logN)。
- 时间复杂度:O(N*logN)。
快排的时间复杂度分两种情况讨论:
- 最好:每次选key都是中位数,通俗讲是左边一半右边一半,具体看是key的左序列长度和右序列长度相同。时间复杂度O(N*logN)
- 最坏:每次选出最小的或者最大的作为key。时间复杂度O(N^2)
画图分析:
可能有人会觉着正常的数组怎么会次次都会选出最小的或者最大的作为key呢?这也太巧合了,但是仔细想想,当数组是有序或者接近有序时,不就是最坏的情况吗?更何况如若数据量再大一点,程序很有可能会因为数据量过多而递归次数过多以至于栈溢出,
综上我们需要深思:能否针对快排最坏的情况进行优化?看下文:
快排优化
就以最坏的情况为例:
三数取中
对于我们自己来说,是很清楚其是有序的,可计算机并不清楚,它依旧是选取最左边或者最右边作为key,如果key不是取最小或者最大的,取出的值是介于之间的,那么情况也会好很多,至此:引出三数取中
- 规则:
取第一个数,最后一个数,中间那个数,在这三个数中选不是最大也不是最小的那个数作为key。此法针对有序瞬间从最坏变成最好,针对随机数,那么选出来的数也同样不是最大也不是最小,同样进行了优化。
三数取中其实针对hoare法,挖坑法,前后指针法都适用,这里我们就以前后指针法示例:
- 总代码如下:
//快排
//三数曲中优化
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) / 2; // int mid = left + (right - left) / 2
// left mid right
if (a[left] < a[mid])
{
if (a[mid] < a[right]) // left < mid < right
return mid;
else if (a[left] < a[right]) // left < right <mid
return right;
else // right < left < mid
return left;
}
else // left > mid
{
if (a[right] > a[left]) // right > left > mid
return left;
else if (a[mid] > a[right])// left > mid > right
return mid;
else // left > right > mid
return right;
}
}
//前后指针法
int PartSort3(int* a, int left, int right)
{
//三数取中优化
int midi = GetMidIndex(a, left, right);
Swap(&a[midi], &a[left]);
int key = left;//注意不能写成 int key = a[left]
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
if (a[cur] < a[key] && a[++prev] != a[cur])
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[prev], &a[key]);
return prev;
}
//快速排序
void QuickSort(int* a, int begin, int end)
{
//子区间相等只有一个值或者不存在那么就是递归结束的子问题
if (begin >= end)
{
return;
}
int keyi = PartSort3(a, begin, end);
//分成左右两段区间递归
// [begin, keyi-1] 和 [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
小区间优化
假设快排每次递归的过程中,选出key,然后递归分成左边和右边,并且都是均匀的,如果是有序,每次选中间值,这个过程就像是二分,跟二叉树的样子差不多,正如上述画过的图:
快排递归调用的简化图其实就类似于一个二叉树,假设长度N为1000,那么递归调用就要走logN层也就是10层,假设其中一个递归到只有5个数了,那么还要递归3次,当然这只是左边的,右边还要递归3次,这么小的一块区间还要递归这么多次,小区间优化就是为了解决这一问题,针对最后的小区间进行其它的算法排序,就比如插入就很可以。
当递归到越小的区间时,递归次数就会越多,针对这一小区间采取插入排序更优,减少了大量的递归次数。
- 代码如下:
//三数取中优化
int GetMidIndex(int* a, int left, int right)
{
//……
}
//前后指针法
int PartSort3(int* a, int left, int right)
{
//三数取中优化
int midi = GetMidIndex(a, left, right);
Swap(&a[midi], &a[left]);
//……
}
//小区间优化
void QuickSort2(int* a, int begin, int end)
{
//子区间相等只有一个值或者不存在那么就是递归结束的子问题
if (begin >= end)
{
return;
}
//小区间直接插入排序控制有序
if (end - begin + 1 <= 10)
{
InsertSort(a + begin, end - begin + 1);
}
else
{
int keyi = PartSort3(a, begin, end);
// [begin, keyi-1] 和 [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
}
快排非递归
先前的学习中,我们的快排都是用递归来实现的,但是要知道:递归也是有缺陷的。如果深度过大,可能会导致栈溢出,即使你用了快排优化可能也无法解决此问题,所以我们引出非递归的版本来解决栈溢出问题。
- 规则:
在快排递归的过程中是要建立栈帧的,仔细看看每次递归时传的参数,有begin和end,其递归过程存储的是排序过程中要控制的区间,那我们用非递归模拟递归的过程中也要按照它这个存储方式进行,这就需要借助栈了,跟上篇博文的层序遍历一样利用到了栈。
- 代码如下:
//快排非递归
void QuickSort3(int* a, int begin, int end)
{
ST st;
StackInit(&st);
//先把第一块区间入栈
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st)) //栈不为空就继续
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
//使用前后指针法进行排序
int keyi = PartSort3(a, left, right); // keyi已经到了正确位置
// [left, kryi-1] [keyi+1, right]
if (left < keyi - 1)//如若左区间不只一个数就入栈
{
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
if (keyi + 1 < right)//若右区间不只一个就入栈
{
StackPush(&st, keyi + 1);
StackPush(&st, right);
}
}
StackDestory(&st);
}
上述代码恰好巧妙的实现了递归的过程,仔细观察上述代码,一开始我们入栈了下标为begin和end的值,如下:
随后,取出这两个值,并用right和left分别保存起来,随后对区间[left,right]这块区间进行单趟排序,取出keyi的值为5,此时a[keyi]也就排到了正确的位置了,接下来就是效仿递归的关键了,以keyi为分界线,将数组分为两块区间:【left,keyi-1】和【keyi+1,right】,此时再把这两块区间的入栈:
接下来进入第二趟while循环,同样是再次出栈里的两个数据6和9,并再次传入单趟排序,算出keyi的值为8,也就意味着a[keyi]到了正确的位置,再以keyi为分界线,将右区间的数组分为【6,7】和【9,8】以此类推……一直排下去。