首先,快速排序算法属于交换排序,
交换排序基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,
交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
交换排序中我们比较熟悉的就是冒泡排序,选取数组头两个值进行比较,将大的元素放在后面,然后继续向后比较,比到最后那么就一定把最大的元素放在了最后一个,因为这个把最大值通过比较向上挪动的过程就像泡泡从水底冒出的过程,所有称这种排序算法叫做冒泡排序。
而我们的重点不是冒泡排序而是快速排序,所以下面我们着重说明一下快速排序。
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
将区间按照基准值划分为左右两半部分的常见方式有:
下面我们逐个介绍这三种方法
快速排序的hoare版本是Hoare创建这个算法时最原始的方法,也叫左右指针法,其基本思想是在数组中选出一个key值,一般是最左边的或最右边的值,然后通过左右两个指针,假设我们以最左边的值作为key值,那么就是右指针先向左走,寻找比key值小的元素,找到之后就暂停,然后左指针向右走,寻找比key值大的元素,找到之后,交换这时候左右指针指向的值,如果以最右边作为key值,就是左指针先走。
以最左边作key值为例,当左右指针相遇的时候,在把key与现在的左指针进行交换,这时就保证了在key的左边的元素都是比key小的,右边的元素都是比key大的。
下面是一组数的比较过程。
通过这一组的比较,我们就把这个数组分成了两部分,仔细观察我们还能发现这个key值现在的位置其实就是排序结束后它应该在的位置,相当于我们就排序好了key这个值,那么接下来我们就可以通过递归的方法,在对key值左边和右边的元素用相同的方法进行排序,最终就可以把整个数组排好。
以上就是hoare版本快排的基本思想,在实现排序之前,我们还有一点需要研究一下,那就是我们的最后一步是交换left与keyi的值,那么如果我们要在一组结束之后满足左侧内容比keyi值小,右侧比keyi值大,那就必须满足left与right相遇的时候left指针指向的值是小于keyi的,这一点可以保证吗?答案是可以的,下面我们来分析一下
left与right相遇只有两种情况:
(1)right遇left。
因为在比较时是right先走,所以当right先走然后遇到left时,这时的left指向的值是刚刚在上一轮与right找到的比keyi值小的值交换来的,所以它一定是小于keyi的值的,符合要求。
(2)left遇right。
left在right后走,而left寻找的是大于keyi的值,当left开始移动时说明right这时候已经找到了比keyi小的值,当left遇到right时,说明在right的左边的值没有比keyi大的值,那么这时候left指向的值就一定是小于keyi的,也符合要求。
以上的两种情况都是符合的,那么如果我们比较时让left先走,right后走,这时候就出现问题了。当right遇到left时,因为left是找大于keyi的,所以这时的left是比keyi大的,就不符合要求,所以比较时left和right的先后顺序是不可以调换的。
我们把快排的实现分为两部分。
首先是通过一次排序排好keyi值并且返回keyi值的下标,
第二部分是我们通过递归的方法,对keyi的左边部分和右边部分进行排序
上面是第一部分的过程
这是第二部分的过程。
以上两部分就是hoare版本快速排序的全部过程,下面是整体的代码
int PartSort1(int* a, int left, int right)
{
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]);
}
Swap(&a[left], &a[keyi]);
return left;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = PartSort1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
挖坑法的思路是把keyi作为一个坑,也是使用left和right两个指针,right向向左找比keyi值小的元素,然后用这个元素去填坑,这时right就变成了一个新坑。然后再让left向右找大的,找到后让这个元素去填坑,让left的位置变成新坑,以此往复,直到left与right相遇,那么这时left与right一定会在坑处相遇,这时再把keyi的值填到坑这里就好了。
下面是挖坑法一次排序的过程
每次交换完成后都像是留下一个坑一样。
其思路与hoare版本类似,先对一组数进行排序,然后再用递归的方法对整个数组进行排序。
他递归的部分与hoare版本类似,就不赘述了。
下面是挖坑法快排的全部代码
int PartSort2(int* a, int left, int right)
{
int key = a[left];
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
//放到左边的坑位,右边形成新的坑
a[left] = a[right];
while (left < right && a[left] <= key)
{
left++;
}
a[right] = a[left];
}
//相遇的位置一定是坑
a[left] = key;
return left;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = PartSort2(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
前后指针法的基本思想是用一前一后两个指针,从左向右遍历数组,当发现有大于key值的元素时,后面的指针就停在原地,然后前面的指针继续向前遍历寻找小于key值的元素,找到以后让后面的指针向前走一步,然后交换两个指针指向的值,交换完成后前面的指针向前挪动一位,然后后面的指针就不动了,前面的指针继续向后寻找小于key值的元素,找到就交换,以此类推。
下面是一次排序的过程图
相当于把大于key值的元素都放在了两个指针之间,前面的指针遍历完整个数组,那么就完成了一次排序,这时交换keyi和prev的值即可。
下面是一次排序的实现过程
与上面两种方法类似,使用递归的方式完成对整个数组的排序,下面是全部的代码
int PartSort3(int* a, int left, int right)
{
int keyi = left;
int prev = left, 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;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
现在我们假设要排序两个数组,假设第一个数组我们每一次排序的key值都能够选择到数组的中间位置,那么它的快排过程如下图
假设第二个数组每次我们找到的key值都是最小的哪个,那么排序过程如下图
这种情况就比较拐杖了,虽然不太可能遇到,但是我们也是已这一个极端的情况打一个比方,计算这种情况的时间复杂度,首先有N层,每层的比较次数又是一个等差数列的和,二者相乘时间复杂度肯定是O(N2)级别的,O(N2)就属于是效率最低的一个级别了,那我们的快排这么可以这么拉,所以为了防止这种情况的发生,我们还要对快排的细节进行一些优化。
我们发现,出现这两种情况的原因是因为我们的key值选的不好,当key值是数组的中间值时,快排的效率是最高的,当我们选到最大或最小值时,导致我们进行了一次排序以后只排出来一个数,大量的增加了我们排序的次数,为了防止这种情况,我们可以使用三数取中法,即我们不再使用数组的第一个元素作为我们的key值,而是选择数组头,尾,中三个地方的三个值进行比较,去他们中间的中间值作为key,再把这个新key与第一个元素调换位置。
这样选取key值的方式虽然不能让我们一定能够选出数组的中间值作key,但是它一定避免了我们选择最大或者最小的值做key,直接就避免了最坏的情况发生。
使用三数取中优化,我们可以先写一个三数取中的函数
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) >> 1;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
return right;
}
else
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
return right;
}
}
然后在每次排序的前面通过加上这两段代码,使得每次都使用三数取中的方法选取key值
int midIndex = GetMidIndex(a, left, right);
Swap(&a[left], &a[midIndex]);
上面的三种排序都是一直递归,然后把数组不断的分割,递归的层数太多可能就会影响效率,那么当递归到底层的时候,我们就可以通过使用别的排序方式,不使用快排来减少快排导致的递归次数。
因为当数组被分割的非常小时,那么不同的排序算法之间的差别可能就没有那么大了,所以这种方法是有道理的,我就以直接插入排序来代替快排,直接插入排序的内容可以看我前面的文章中有详细的介绍排序算法-插入排序。
这虽然也是一种优化的方式,但是这种优化方式在现在的编译器下的优化效果非常有限,因为现在的编译器对递归的优化非常的好,所以递归对效率的影响并没有那么大了,尤其是在Release版本下,优化效果就更不明显了,但是如果排序的数据量非常大时,还是有一定的效果的。
在上面对快排的优化中我们直到,递归对排序效率的影响并没有那么大了,所以递归最致命的问题不在时间上,而在于空间。
我们每次递归都会在栈上建立栈帧,而当我们递归调用的层数太深时,有可能栈的空间不足,造成栈溢出,这时我们就只能使用一种非递归的方法进行排序了,就是循环的方法。
如何使用循环的方法实现快排呢?我们可以用数据结构中的栈来存储数组模拟递归的过程,其排序过程我画了一张图。
先让数组的头和尾入栈,然后经过一次排序对数组进行拆分,把拆分成的两段的头尾指针先后入栈,然后判断如果栈不为空,就从栈中拿出两个下标作为下次排序的头和尾,因为我们是把段的头尾先后入栈的,所以取出的也是一段的头和尾,但是如果遇到特殊情况,即最后一段只有一个元素,那么这一个元素就不需要再进行一次排序了,那这一个元素代表的一段就不进行入栈的操作,这样就保证了我们入栈是头尾一起入,出栈是头尾一起处,不会打乱分组。
栈的实现我在前面的文章数据结构-栈和队列详解中有详细的介绍,并且对栈进行了实现,下面我也会直接使用这里实现的栈,有需要的同志可以去这里找到栈的实现代码。
void QuickSortNonR(int* a, int left, int right)
{
Stack st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
int begin, end;
end = StackTop(&st);
StackPop(&st);
begin = StackTop(&st);
StackPop(&st);
int keyi = PartSort1(a, begin, end);
if (begin < keyi - 1)
{
StackPush(&st, begin);
StackPush(&st, keyi - 1);
}
if (keyi + 1 < end)
{
StackPush(&st, keyi + 1);
StackPush(&st, end);
}
}
StackDestory(&st);
}
以上就是本篇的全部内容。