1,hoare版本
hoare版本分为两种情况,第一种是定义两个整型L和R,分别对应待排序数组第一和最后一个元素的下标。同时,定义一整型k对应第一个元素的下标。
两种情况分别为:
一,当选择最右边的值做key时,右边(R)先走,左边(L)后走,L找比key大的数,R找比key小的数,当二者均找到目标元素时,将两元素对调,然后继续L先走,Y后走。当L与Y相遇时,将相遇的位置处的节点与最左边key对应的节点互换。
二,当选择最左边的值做key时,左边(L)先走,右边(R)后走,L找比key大的数,R找比key小的数,当二者均找到目标元素时,将两元素对调,然后继续L先走,Y后走。当L与Y相遇时,将相遇的位置处的节点与最右边key对应的节点互换。
注:根据上面的描述,最后相遇位置的节点处的值一定比key小(法一)或比key大(法二),为什么会出现这种情况?记住即可。
这里的核心就是左边的数要找比头节点大的数,以便将其挪到右边,右边的数要找些头节点小的数,以便将其挪到左边。由于最后在两个数字相遇时(left<right不成立时),相遇节点上一定会是比头节点小的数字,再将该数字与原先的头节点互换,然后再重复该循环,这样相当于标准又严苛了一些,放到数组左端的数字变得更小,排序结果也就越接近最终顺序。
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]);
2,hoare版本思维抽象的提升:
由于在一次hoare版本的排序中,对于被放到数组中间的原先的头节点而言,其左边都是小于它的数字,右边全是大于它的数字,亦即该数字已经找到了它在数组中的正确位置。对于其左右两侧而言,只要左边和右边都实现从小到大的排序,整个数组从小到大的排序任务也就完成了。这就是明显的递归思想。对于定义的快速排序函数QuickSort而言,内部要包含一次hoare版本的替换,以及对QuickSort函数的两次引用,分别处理其左侧和右侧待排序的两个小数组。由于keyi = Partion(a, left, right);且Partion函数的返回值是L对应的值,而在代表hoare思想的Partion函数运行完一次后,L对应的节点一定是L和R相遇的节点,这样的话keyi = Partion(a, left, right);就相当于keyi代表下一轮中待排序的左右两个数组中夹着的那个节点。
整个递归过程如下图:
如上图,递归结束的条件是left>right,因为当一个数组的左侧大于右侧时,这个数组便不可能存在 。
递归思想的代码实现:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = Partion1(a, left, right);//将最开始的头节点,即衡量
//数组中的数是该放到左侧的小数还是该放到右侧的大数的那
// 个数,放到最后L和R相遇的那个位置,这样,此时的数组中,
//该数左侧的数都是比它小的数,该数的右侧都是比它大的数
QuickSort(a, left, keyi - 1);//下一层次的递归,对中间点左侧的数组进行排序
QuickSort(a, keyi+1, right);//对中间点右侧的数组进行排序
}
3,优化:
排序部分的优化:
挖坑法:由于前面的hoare版本相对难以理解,因此提出挖坑法。相比于hoare版本,挖坑法在排序开始之前,先将第一个数据存到一个临时变量key中,而不是一直放在数组内部,这样就在数组内部形成了一个空隙(这也就是为什么叫挖坑法),其余同理,仍然是key在左边,则右边先走,找到比key小的数,放到左边的空隙中(这样右边又形成了一个空隙),然后左边再走,找到比key大的数,放到右边的空隙中。当L与R相遇时,将最开始保存在临时变量中的数据放到L与R相遇的节点中。
代码实现:
int key = a[left];
int pivot = left;
while (left < right)
{
// 右边找小, 放到左边的坑里面
while (left < right && a[right] >= key)
{
--right;
}
a[pivot] = a[right];
pivot = right;
// 左边找大,放到右边的坑里面
while (left < right && a[left] <= key)
{
++left;
}
a[pivot] = a[left];
pivot = left;
}
a[pivot] = key;
return pivot;
4,快速排序的缺陷:拍顺序很乱的数组时,优势明显,但在排接近有序的数组时,速度反而变慢。具体原因如下图:
理想的情况下:每一次待处理的数组最开始的元素在处理后都被放到中位数的位置上,此时,递归的结构就类似于一个二叉树,当递归到最后一个循环时,每一个数组中只有一个元素,亦即递归的次数为logN次。由于每一层递归中待处理的元素数量级都为O(N)级,因此理想情况下,该方法的时间复杂度为0(N*logN)。
最坏的情况下:
最坏的情况即数组原先就从小到大排列的情况,对于每一次递归的头节点而言,在开始比较之前,其右侧就全部都是比它大的元素,所以在遍历一遍之后,其仍在第一位。这样在下一次递归时,左侧的数组为空,右侧的数组是原先的数组去掉头节点剩下的部分。
在这里,决定两种方法好坏的根本原因是,这个类似数的结构什么时候能将初始数组中的全部子数组全都化成单一元素组成的数组。对于最理想的情况而言,每一次都将数组二分,这样自然是最快的将数组分割成单一元素的方法(可以理解为逆向的指数爆炸)。而再最坏的情况下,虽然仍然严格按照原先的方式、进行计算,但每次递归过后,待分割的最大的那个数组数量只减少了1,这样,想要把该数组完全分割完,就需要N次才能实现。
通过上面的论述,就可以解释为什么对于快速排序而言,数组越有规律,排序越慢。这是一个相当符合逻辑的数学问题。
5,针对快速排序缺陷的抽象提升:
在上一点中,分析了数组在什么情况下会影响快速排序的排序速度。当搞明白影响快速排序速度的因素后,就可以想办法避免它。决定快速排序的关键因素是什么时候能将整个数组完全分成一个个元素,越早实现这一点,排序过程也就越快完成。但是,我们只能决定排序的算法,不能决定被排序的数组长什么样子。即便如此,我们仍可以在正式排序开始之前先改变数组中部分元素的顺序,以尽可能的让数组在排序之前接近理想的状态。
这里可以用到三数取中的思想,针对数组的第一个和最后一个元素,以及最中间的元素(left+right/2)(这个数可能不是最中间,但已经接近最中间了),比较它们三者的大小,然后将三者中数值大小在最中间的那个元素并将其与首元素的值互换(如果大小在中间的那个值恰为首元素,则自己与自己互换,相当于不变)。这样,就为本次直接排序的递归创造了良好的条件,开头衡量哪个数是大数,应该挪到数组右边,那个数是小数,应该挪到数组左边的首元素在快速排序之后更有可能处在数组的尽量靠中心的位置(虽然根据实际情况也不一定,比如数组中有100个数,前、中、后三个位置上的元素分别是1,2,3,其它位置上的元素都大于3,那这样即使对其值进行交换,最后首元素的节点仍相当靠前),即便如此,这种方法仍有意义,比如之前所说的最坏条件,经过这样一比较之后,数组的首节点就变成了数组中元素值中间大的那一个,即一半值比它大,一半值比它小,经快速排序处理后,首节点会挪到数组正中间,这样向下递归时结构就成为 了最理想情况的状态,向下的每一层递归都是如此。可能数组中原先有很多元素,但是我只需要在每一次排序之前改变这三个关键的元素,就可以将递归的结构优化成最理想的状态。
代码实现:
GetMidIndex函数的核心思想是想办法找出三个数中中间大的那个值并输出。
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) / 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;
}
}
}
int mini = GetMidIndex(a, left, right);
Swap(&a[mini], &a[left]);
int key = a[left];
int pivot = left;
6,进一步优化,弥补在递归进行到后期时,因为指数爆炸而造成的计算量浪费。
继续之前的思路,由于数组在分割时类似二叉树的结构,那这里仍按照二叉树的思路来分析。在递归进入最后几次时,对一个小数组而言,即使其中仅有很少的元素,但仍需要若干次递归才能将数组完全分割成单个元素。而且,在递归到这里的一次递归和最开始将初始的大数组对半分的递归消耗的时间都是O(N)(虽然后面的那一次递归中涉及相当多的小数组,但这些小数组消耗的时间和仍是O(N),下面的递归相比于上面的递归就极不划算。因此可以考虑在数组被分割到一定程度时就不再分割,而是将该数组按照插入排序的方法进行排序
代码实现:
即当数组长度小于10时,停止递归,改用直接插入法进行排序。
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 小区间优化,当分割到小区间时,不再用递归分割思路让这段子区间有序
// 对于递归快排,减少递归次数
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
}
else
{
int keyi = Partion3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}