快速排序法的使用
快速排序法作为一种广受好评的排序方法,不仅仅因为它的排序效率很高,更因为它体现了分治的思想。因此许多广为人知的软件公司(BAT)的笔试面试都喜欢考,甚至在一些大大小小的考试如软考、考研中也能见到它的身影。因此熟练默写快速排序法的代码并掌握其核心思想对我们来说尤为重要。
快速排序法的背景:
快速排序由C. A. R. Hoare在1960年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
这里所说的方法体现了分治的思想。
分治法:字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
分治法在快速排序法中的体现:
- 任意选取一个基准值(一般我们为了方便会选取数组的第一个元素)。
- 想办法将这个基准数置于数组中的一个特殊位置,使得这个特殊位置左边的元素均小于等于基准值,右边的元素均大于等于基准值。
- 将这个新的数组按照基准数的位置分成两个小数组,对左右区间重复一二步的操作(选取新的基准值,继续使其归位),直至排序完成。
这样,我们对数组中元素的排序,就转化成了重复将基准值归位的过程。
这个办法是什么呢?
首先再次明确我们的目标:找到数组中的一个特殊位置,使得这个位置左边的数都小于等于基准数,右边的数都大于等于基准值( p i v o t pivot pivot)。这里我们的办法是:分别使用两个变量 l l l 和 r r r ,从这个数组的左右两端开始“ 探测 ”。这样两个变量用于“ 探测 ”的变量,我们一般称之为哨兵( g u a r d guard guard)。
开始我们分别让这两个哨兵出发,一步一步向彼此逼近:(注意:一定是右哨兵先走)
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 73 | 26 | 37 |
---|---|---|---|---|---|---|---|---|---|
0 ( l l l) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9( r r r) |
当右哨兵 r r r 遇到一个比基准值(此处为55)小的数 a 1 a1 a1(此处为37),
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 73 | 26 | 37( a 1 a1 a1) |
---|---|---|---|---|---|---|---|---|---|
0( l l l) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9( r r r) |
同时左哨兵 l l l 遇到一个比基准值(此处为55)大的数 b 1 b1 b1 (此处为73)时,
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 73( b 1 b1 b1) | 26 | 37( a 1 a1 a1) |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7( l l l) | 8 | 9( r r r) |
我们将这两个数 a 1 、 b 1 a1、b1 a1、b1 对换。
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 37 | 26 | 73 |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7( l l l) | 8 | 9( r r r) |
在这之后左右哨兵可能还会再遇到 a 2 、 b 2 a2、b2 a2、b2 等等(此处没有),经过一系列的对换之后,左右哨兵会碰头,
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 37 | 26 | 73 |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8( l , r l, r l,r) | 9 |
此时的这个碰头点,也就是我们要找的特殊位置了。由于我们是先让右哨兵出发的,所以这个碰头点对应的数字会小于基准数。这时,我们将这个数(即左右哨兵代表的值)与基数对换。
26 | 2 | 6 | 4 | 11 | 12 | 9 | 37 | 55( p i v o t pivot pivot) | 73 |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
这样就完成了一次归位。接下来我们利用分治的思想,继续对这个特殊位置的左右区间进行递归操作,数次之后即可完成排序。(使用这种方法的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn))
26( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 37 |
---|---|---|---|---|---|---|---|
0( l l l) | 1 | 2 | 3 | 4 | 5 | 6 | 7( r r r) |
快速排序法的C++代码实现:
#include <iostream>
using namespace std;
void Quicksort(int[], int, int);
int main()
{
int nums[] = { 55,2,6,4,11,12,9,73,26,37 }; // 定义数组
int len = sizeof(nums) / sizeof(int); // 定义数组长度 len
Quicksort(nums, 0, len - 1); //快速排序
}
void Quicksort(int a[], int left, int right) // left,right 分别代表传入数组的左右索引
{
int pivot = a[left], l = left, r = right, temp; // 定义基准值,左右哨兵,以及临时变量
while (l < r) // 左右哨兵碰头之前执行循环(递归出口)
{ // 这里右哨兵先出发
while (l < r && a[r] > pivot) --r; // 在右哨兵遇见小于等于基准值的数之前,右哨兵不断往左走
while (l < r && a[l] <= pivot) ++l; // 在左哨兵遇见大于基准值的数之前,左哨兵不断向右走
temp = a[l], a[l] = a[r], a[r] = temp; // 对换左右哨兵的值
}
temp = a[left], a[left] = a[l], a[l] = temp; // 把左哨兵(或右哨兵,此时已经碰头)的值(这个值满足a[r] < pivot)与基准值对换,基准数完成归位
/* 本步之后,基准数之前的数全部小于等于基准数,基准数之后的数全部大于等于基准数 */
if (left < r - 1) Quicksort(a, left, r - 1); // 此时 r = l,其值为最开始选择的基准值所在的位置
if (r + 1 < right) Quicksort(a, r + 1, right);
} // 对基准数左右两侧的数递归操作
补充说明:
1.为什么不能让左哨兵先出发?
如果我们让左哨兵先出发,那么直到最后一次对换完成整个过程都是没有问题的,
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 37 | 26 | 73 |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7( l l l) | 8 | 9( r r r) |
不过当最后一次对换完成之后,我们让左哨兵先走,那么左哨兵会直接走到 73 的位置与右哨兵碰头。这显然不是我们希望它们碰头的位置。
55( p i v o t pivot pivot) | 2 | 6 | 4 | 11 | 12 | 9 | 37 | 26 | 73 |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9( l , r l, r l,r) |
2.关于数组中出现重复元素?
细心的读者可能发现:我上面例举的数组中并没有重复的元素,但其实即使有重复的元素,利用这种办法找到的特殊位置也不会有任何问题,不过快速排序法为不稳定排序。有兴趣的读者可以自行模拟过程。
3.如果所给元素并没有以数组的方式呈现?
实际上,我们遇到的序列并非都以数组呈现。在面试中,面试官经常会要求我们用单链表完成快速排序法。遇到这种情况,我们仍可以以链表第一个节点对应值为基准值。然后我们设置两个指针来代替哨兵。 利用这两个指针遍历链表并交换其中的的元素,让基准值归位,重复数次即可完成排序。
(实际上快速排序法是不适合单链表的,使用归并排序法效率会更好。)