一、快速排序的基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
// 假设按照升序对array数组中[left, right)区间中的元素进行排序 void QuickSort(int array[], int left, int right) { if(right - left <= 1) return; // 按照基准值对array数组的 [left, right)区间中的元素进行划分 int div = partion(array, left, right); // 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right) // 递归排[left, div) QuickSort(array, left, div); // 递归排[div+1, right) QuickSort(array, div+1, right); }
上述为快速排序递归实现的主框架,会发现与二叉树前序遍历规则非常像,先取中间,递归左区间,再递归右区间。
二、将序列划分成左右区间的常见方法
从上面的主框架就可以看出,我们需要一个partion函数,将序列分为左区间和右区间。下面我将介绍三种划分左右区间的方法。我通常是选取最左边的数作为基准值,将比它小的数都换到它的左边,比它大的数都换到它的右边。
2.1hoare版本(动图+解释+代码实现)
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[keyi], &a[left]); return left; }
2.2挖坑法
挖坑法虽然与hoare的方法实现上有差异,但思想上其实差别不大。
坑位就是最终key要去的位置。
int PartSort2(int* a, int left, int right) { 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; } //左右标志相遇在坑位上,相遇时坑位的左边都小于等于key,右边都大于等于key, //坑位就是key应该去的位置。 a[hole] = key; return hole; }
2.3前后指针法
prev最终在的位置的值一定会比key的值要小,prev最终在的位置也就是key最终要去的位置。交换到最后prev位置以及它前面的值都是比key要小的,后面的值都是大于等于key的。
int PartSort3(int* a, int left, int right) { int prev = left; int cur = prev + 1; int keyi = left; while (cur <= right) { if ( a[keyi] > a[cur] && ++prev != cur) //a[keyi] > a[cur]找小,但如果a[keyi] > a[cur]一直成立, //++prev 会一直== cur不会拉开差距。只有a[keyi] <= a[cur],++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 = PartSort1(a, left, right); //这里用PartSort1/PartSort2/PartSort3都可以,时间效率都是一样的 QuickSort(a, left, keyi-1); QuickSort(a, keyi+1, right); //不断递归左区间和右区间 }
四、快速排序的优化实现
4.1快排的特殊情况
上面的写法面对绝大多数情况的排序已经可以实现时间复杂度接近 ,但面对某些特殊的情况,比如说你要将一个序列排成一个升序序列,然而这个序列本身就是一个升序序列,那就会造成keyi的左边没有数而其他数都在它的右边的情况,这样就会造成时间复杂度变成 ,影响了快排的效率。看下图解释,红色矩形代表keyi的位置,如果是一个完全无序的序列进行排序,keyi所处的为值一般不会特别靠左也不会特别靠右,能保证排序的时间复杂度接近 。
但如果是下面这种情况keyi一直位于最左侧,就会使时间复杂度为 (每一次遍历时间复杂度为 ,遍历n次)。
所以为了防止这种特殊情况的出现,可以对划分区间的代码进行一点优化。
4.2对区间划分代码的优化
我们还是可以取最左边的数作为key,只是要将最左边的数换成一个在序列中不是那么大也不是那么小的数,在此我们引进了一段三数取中代码。
int getMid(int* a, int left,int right) { int mid = (left + right) / 2; if (a[left] > a[right]) { if (a[mid] > a[left]) { return left; } else if (a[mid] > a[right]) { return mid; } else { return right; } } else { if (a[mid] > a[right]) return right; else if (a[mid] > a[left]) return mid; else return left; } }
这段代码负责找到序列中最左边,最右边,中间三个数中第二大的那个数。区间划分代码加入三数取中后,就可以规避掉刚才所说的那种特殊情况了。
int PartSort1(int* a, int left, int right) { int mid = getMid(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]); } Swap(&a[keyi], &a[left]); return left; }
这里以hoare版本为例,其它两种区间划分方法也可以加上三数取中。
4.3小区间优化
因为在递归到后期时,有的小序列已经接近有序,使用直接插入排序效率就会很高。(直接插入排序在希尔排序那篇博客已有实现,在这里就不再赘述)
void QuickSort(int* a, int left, int right) { if (left >= right) return; //小区间优化 if ((right - left + 1) > 10)//当区间长度大于10时 { int keyi = PartSort1(a, left, right); QuickSort(a, left, keyi-1); QuickSort(a, keyi+1, right); } else//区间长度小于10时 { InsertSort(a + left, right - left + 1); } }
五、快速排序的非递归实现
快排使用到了递归的思想和方法,但是递归如果递归太深的话就会有爆栈的风险,所以在这里也介绍一下快速排序的非递归实现方法。因为快排是先处理左边的序列再处理右边的序列,这里就需要用到数据结构中的栈了,先入右再入左,进行排序。(栈的结构在之前的博客中已经实现过了,在这里同样不赘述)
void QuickSortNonR(int* a, int left, int right) { Stack st; StackInit(&st); StackPush(&st, right); StackPush(&st, left); while (StackEmpty(&st)) { int begin = StackTop(&st); StackPop(&st); int end = StackTop(&st); StackPop(&st); int keyi = PartSort1(a, begin, end); //不断把大区间化成小区间处理 if (keyi + 1 < end) { StackPush(&st, end); StackPush(&st, keyi+1); } if (begin < keyi - 1) { StackPush(&st, keyi-1); StackPush(&st, begin); } } StackDestroy(&st); }
六、总结
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定