深入了解快速排序

概述

快速排序是面试中的常见题,每次简述一遍快速排序的原理便觉得仿佛已经掌握了它。不是挺简单的吗?然而实际实现的时候还是会遇到一些坑。于是,之前一次面试就跪了,很尴尬。所以,还是需要对它进行深入理解!

基本步骤

  1. 检查范围,(以及终止条件)

注意递归终止条件

  1. 选择基准(pivot),分割序列

即筛选不大于pivot的元素和不小于pivot的元素),将pivot放至正确位置

  1. 对pivot的左半段和右半段序列分别进行快速排序

参考[1], 我将快速排序的函数签名定为
public <T extends Comparable<? super T>> void executeProcess(T[] sequ, int left, int right) { }

1.小数组优化

递归的快速排序终会落入对于小数组的排序。而对于小数组的排序,快速排序不如插入排序。

quicksort insertsort
Size:10 Range:0~10 2222223 623112
Size:20 Range:0~20 1433779 487111
Size:30 Range:0~30 3454668 540889

单位:nanosecond
环境:CPU 4核8线程 2.30GHZ

2.检查范围,以及终止条件

// 检查下标是否越界
super.rangeCheck(sequ.length, left, right);

// 数组个数为0或1,已排序(终止条件)
int size = right - left + 1;
if (size < 2) {
    return;
}

3.选择基准(pivot),分割序列

3.1 选择基准

基准选择常见的有以下三种方法。

  1. 序列首/序列尾
    对于有序序列分割极不平衡
  2. 随机选择
    优于序列首,但开销不小
  3. 三数中值分割
    它将考虑序列中left, right, (left + right) / 2这三个位置的元素值,选择它们的中位数作为基准

进一步,可在三数中值分割的基础上将三个位置上的较小值和较大值分别置于left位置、right位置。在使用下文的分割方式1时可保证两个指针不越过序列端点。

// 三数排序决定基准,left/right/中位
int middle = (left + right) / 2;
if (sequ[left].compareTo(sequ[middle]) > 0) {
    swap(sequ, left, middle);
}
if (sequ[left].compareTo(sequ[right]) > 0) {
     swap(sequ, left, right);
}
if (sequ[middle].compareTo(sequ[right]) > 0) {
    swap(sequ, middle, right);
}
// 数组仅有2个或3个元素,此时已经排好序
//(若对小数组使用插入排序,则该语句没有必要)
if (!super.insertSortOptimized && middle == right - 1) {
    return super.InvalidPoint;
}
// 将基准(三数中值)放至right-1位置
swap(sequ, middle, right - 1);
3.2 分割策略
3.2.1 分割方式1

forePoint从前往后找大于pivot的元素,backPoint从后往前找小于pivot的元素,并交换。
当forePoint与backPoint相遇后,将pivot放至正确位置。


分割方式1

之后以此类推
标红的元素为pivot

// 对left+1和right-2之间的范围进行分割
int forePoint = left;
int backPoint = right - 1;
T pivot = sequ[right - 1];

while (true) {
    while (sequ[++forePoint].compareTo(pivot) < 0) {
    }
    while (sequ[--backPoint].compareTo(pivot) > 0) {
    }

    if (forePoint >= backPoint) {
        // 将基准放到合适位置
        swap(sequ, forePoint, right - 1);
        break;
    } else {
        swap(sequ, forePoint, backPoint);
    }
}

相等元素的处理
当遇到和基准值相等的值时,应该如何处理?是往左半段移动?还是往右半段移动?
特别地,对于forePoint和backPoint同时分别遇到与基准值相等的元素时,应该如何处理?
按照[1]中所说,forePoint和backPoint的地位应是等价的,那么它们对于与基准值相等的元素的处理方式也应相同。否则,则会有左半段与右半段不均衡的情况出现,降低快速排序的效率。
那么我们还剩下forePoint和backPoint均停止(进行交换)和均不停止(不进行交换)的选择。 [1]中推荐前种做法。那么对于后种做法,可不可行呢?
针对上述的三种基准选择方法分别进行分析:

  • 前两种选择的基准均有可能是该序列中的最大值或者最小值,序列中可能存在其他与该值相同的元素,也可能不存在。因此,必须考虑forePoint和backPoint越过序列端点的情况,停止与不停止并没有差别。
  • 而对于三数中值分割,它所选择的基准,最大仅可能是该序列中的次大值(可能等于最大值),或者最小仅可能是次小值(可能等于最小值)。若在三数中值分割的基础上将三个位置上的较小值和较大值分别置于left位置、right位置,那么,forePoint和backPoint则无法越过序列端点。但考虑到right位置与基准值相等的情况,若采用不停止的方式,则需要再次考虑forePoint越过序列端点的情况,
    因此,遇到与基准相等的元素,forePoint或者backPoint停止并且交换的做法相对更佳。

其实值等于pivot的元素在该次快速排序中,既可以随便出现在左半段,也可以随便出现在右半段,不用恰好紧挨在该次被作为pivot的元素周围。因为,随着之后对于左半段和右半段调用的快速排序,它们会各自被放到正确的位置上,这并不属于该次快速排序的职责。

3.2.2 分割方式2

curPoint从前往后遍历序列,parPoint指向小于基准与大于等于基准的序列的分割位置 —— 大于等于基准的序列的第一个元素。
当curPoint遍历结束,将pivot与parPoint位置的元素交换。

分割方式2

之后以此类推
标红的元素为pivot

// 对left+1和right-2之间的范围进行分割
int curPoint = left + 1;
int parPoint = left + 1;
T pivot = sequ[right - 1];

while(curPoint < right - 1) {
    if(sequ[curPoint].compareTo(pivot) < 0) {
        swap(sequ, curPoint, parPoint);
        parPoint++;
    }
    curPoint++;
}
swap(sequ, parPoint, right - 1);

4. 对pivot的左半段和右半段序列分别进行快速排序

4.1 递归
int partionPoint = partition(sequ, left, right);
if(partionPoint < 0) {
    return;
}
executeProcess(sequ, left, partionPoint - 1);
executeProcess(sequ, partionPoint + 1, right);
4.2 非递归

采用栈保存下次要进行分割的序列首尾位置,深度优先。

Stack<Integer> stack = new Stack<Integer>();

int partionPoint = partition(sequ, left, right);
if(partionPoint < 0) {
    return;
}

stack.push(partionPoint + 1);
stack.push(right);

stack.push(left);
stack.push(partionPoint - 1);

while(!stack.isEmpty()) {
    int sRight = stack.pop();
    int sLeft = stack.pop();

    partionPoint = partition(sequ, sLeft, sRight);
    if(partionPoint < 0) {
        continue;
    }

    stack.push(partionPoint + 1);
    stack.push(sRight);

    stack.push(sLeft);
    stack.push(partionPoint - 1);
}

About Error

  • 若产生无限循环,则问题可能出在两个方面:一个是递归终止条件;另一个是分割序列处的循环,尤其注意forePoint和backPoint同时分别遇到与基准值相等的元素时,forePoint和backPoint的移动情况

  • 若没有正确排序,由于结果基本有序,我们可以从错误序列中看出端倪。如以下情况:

    Before:
    8 3 15 13 2 0 0 5 10 2 1 9 7 3 9 10 15 5 8 2 9 12 1 8 10
    After:
    0 0 1 1 2 2 2 3 3 5 5 7 8 8 9 8 9 9 10 10 10 12 13 15 15

共有25个元素,下标14和15位置的元素没有正确排序。25的分割沿着出错位置依次为
0~11 12 13~24; 13~17 18 19~24; 13~14 15 16~17。 即可知道是13~17这次快速排序发生差错,从而进行仔细调试。

More

[3] 中通过尾递归对快速排序C语言版优化。关于尾递归,[4]讲述得比较明了。然而Java没有实现尾递归优化。相对的,我们只能采取避免递归过深或者用迭代取代递归的方式。

It's important to note that this isn't a bug in the JVM. It's an optimization that can be implemented to help functional programmers who use recursion, which is much more common and normal in those languages. I recently spoke to Brian Goetz at Oracle about this optimization, and he said that it's on a list of things to be added to the JVM, but it's just not a high-priority item. For now, it's best to make this optimization yourself, if you can, by avoiding deeply recursive functions when coding a functional language on the JVM.

DualPivotQuicksort

Java中对于基本数据类型的排序算法通过DualPivotQuicksort实现。它有如下特性:This algorithm offers O(n log(n)) performance on many data sets that cause other quicksorts to degrade to quadratic performance, and is typically faster than traditional (one-pivot) Quicksort implementations.


DualPivotQuicksort

排序方式具体如下:

  1. For small arrays (length < 17), use the Insertion sort algorithm.
  2. Choose two pivot elements P1 and P2. We can get, for example, the first element a[left] as P1 and the last element a[right] as P2.
  3. P1 must be less than P2, otherwise they are swapped. So, there are the following parts:
  • part I with indices from left+1 to L–1 with elements, which are less than P1,
  • part II with indices from L to K–1 with elements, which are greater or equal to P1 and less or equal to P2,
  • part III with indices from G+1 to right–1 with elements greater than P2,
  • part IV contains the rest of the elements to be examined with indices from K to G.
  1. The next element a[K] from the part IV is compared with two pivots P1 and P2, and placed to the corresponding part I, II, or III.
  2. The pointers L, K, and G are changed in the corresponding directions.
  3. The steps 4 - 5 are repeated while K ≤ G.
  4. The pivot element P1 is swapped with the last element from part I, the pivot element P2 is swapped with the first element from part III.
  5. The steps 1 - 7 are repeated recursively for every part I, part II, and part III.
性能比较
基准选择 分割策略 递归? 插排优化?
ver1 三数中值分割 分割方式1 递归
ver2 三数中值分割 分割方式2 递归
ver3 三数中值分割 分割方式1 非递归
ver1 ver2 ver3
Round1 8283115 11229782 2312889
Round2 2574668 4175557 2521779
Round3 2995112 2246667 3599113

单位:nanosecond
环境:CPU 4核8线程 2.30GHZ
测试序列:长度100范围0~100的随机数序列

代码地址

参考文献

1 Mark Allen Weiss[美]. 数据结构与算法分析: Java语言描述:第2版[M]. 机械工业出版社, 2012.
2 ThomasH.Cormen…. 算法导论:第2版[M]. 机械工业出版社, 2007.
3 http://blog.csdn.net/insistgogo/article/details/7785038
4 http://www.ruanyifeng.com/blog/2015/04/tail-call.html
5 http://stackoverflow.com/questions/20917617/whats-the-difference-of-dual-pivot-quick-sort-and-quick-sort

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容

  • 背景 一年多以前我在知乎上答了有关LeetCode的问题, 分享了一些自己做题目的经验。 张土汪:刷leetcod...
    土汪阅读 12,716评论 0 33
  • 概述 排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    蚁前阅读 5,155评论 0 52
  • 概述:排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    每天刷两次牙阅读 3,726评论 0 15
  • 1.插入排序—直接插入排序(Straight Insertion Sort) 基本思想: 将一个记录插入到已排序好...
    依依玖玥阅读 1,233评论 0 2
  • 2017-04-07 在这样的信息爆炸时代,我们无时无刻都在接受信息的冲刷洗礼。 只是一两个小时没有看手机,微信上...
    一粟于海阅读 641评论 4 3