快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序分为三种方法:
而其又可以使用递归和非递归来实现,接下来将依次演示每种方法:
单趟动图演示:
hoare法的快排分为以下步骤:
排完一趟要求(数据特点)如下:
那我们在排序过程中如何保证相遇位置的值比key小呢?
如若key为最右边的值呢?排完一趟如何?
动图演示:
//快排单趟排序
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);
}
挖坑法的步骤如下:
挖坑法相较于上面的hoare法并没有优化,本质上也没有区别,但是其思想更好理解:
//挖坑法
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);
}
前后指针法的步骤如下:
总的来说,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设定为最后一个数据呢?该如何控制?
除了这三处有所变动外,别的没有什么变动,交换的过程步骤都是一样的。
//前后指针法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);
}
快排的时间复杂度分两种情况讨论:
画图分析:
可能有人会觉着正常的数组怎么会次次都会选出最小的或者最大的作为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
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】以此类推……一直排下去。