Coursera 数据结构 清华 邓俊辉 第十二章 漫谈快排

引言

?
?

本文从多个方面讲解了快速排序的知识点,包括快排分而治之的思想,以及他与归并排序注重点的不同,快排的性能,包括最优最差以及平均性能,并以均匀分布为例,证明了快排的平均性能是1.39*(n+1)logn,接下来又从熵的角度说明了一下快排为什么下界只能达到nlogn,为什么堆排比快排慢,而基排又能够逃脱nlogn的界限,因为nlogn只针对基于比较的排序算法

?
?

快排的思想

?
?

快排采取分而治之的思想,将待排序的序列分为两个子序列S分为SL和SR两个子序列,这个思想跟归并排序有点类似,但是快排又与归并排序有一些小小的不同之处,比如快排中子序列之间的独立性更为鲜明,快排中要求max(SL)要严格小于等于min(SR)。另外快排的重点是去找如何将两个子序列分开,而归并排序的重点是在于如何将两个子序列合起来,一个注重于分,一个注重于合,这也是他们之间的不同

?
?

性能分析

?
?

快排的时间复杂度在每次划分都比较平均的情况下达到时间复杂度的下界,nlogn

?
?

T(n)=2T((n-1)/2)+O(n)≈nlogn

?
?

而在每次划分都极不均匀的情况下,时间复杂度到达上界,n^2

?
?

T(n)=T(n-1)+T(0)+O(n)=n^2

?
?

而快排的平均性能呢,我们可以证明快排的平均性能是nlogn。

?
?

证明

?
?

以均匀分布为例

?
?

?
?

两边同时乘以n

?
?

再将n替换为n-1

?
?

两式相减

?
?

nT(n)-(n-1)T(n-1)=n*2+2T(n-1)

?
?

nT(n)=n*2+(n+1)T(n-1)

?
?

两边同时除以n(n+1)

T(n)/n+1=2/n+1+T(n-1)/n

?
?

记左边为S(n),那么S(n)=2/(n+1)+2/n+……+2/2+T(0)/1

?
?

右边是一个调和级数,调和级数与lnn同阶

?
?

所以S(n)=(2lnn)=2*ln2*logn=1.39logn

?
?

所以T(n)=1.39(n+1)logn

?
?

为什么快排的下界是nlogn

?
?

排序的本质可以这样来表述:有一组未排序的N个数字,它们一共有N!种排列方式,而其中只有一种排列是满足题意的(譬如从大到小排列)。

?
?

我们考虑N个数字中的任意两个数字ab,假设我们在比较a和b的时候,a<b和a>b的概率是均等的,那么我们我们在确定了a和b的大小关系之后,就可以将排列方式减少为N!/2种(因为a<b和a>b的概率是均等的)。一个直接的推论是,如果每次都像上面这样的完美比较,那么N个元素的N!种可能排列只需要log_2{N!}就排查玩了,而log_2{N!}近似于NlogN。这正是快排的复杂度。

?
?

如果想进一步深入了解可以参看刘未鹏老师的《快排为什么那样快》,以下部分摘自这篇文章

?
?

为什么堆排比快排慢

?
?

回顾一下堆排的过程:

?
?

1. 建立最大堆(堆顶的元素大于其两个儿子,两个儿子又分别大于它们各自下属的两个儿子… 以此类推)

?
?

2. 将堆顶的元素和最后一个元素对调(相当于将堆顶元素(最大值)拿走,然后将堆底的那个元素补上它的空缺),然后让那最后一个元素从顶上往下滑到恰当的位置(重新使堆最大化)。

?
?

3. 重复第2步。

?
?

这里的关键问题就在于第2步,堆底的元素肯定很小,将它拿到堆顶和原本属于最大元素的两个子节点比较,它比它们大的可能性是微乎其微的。实际上它肯定小于其中的一个儿子。而大于另一个儿子的可能性非常小。于是,这一次比较的结果就是概率不均等的,根据前面的分析,概率不均等的比较是不明智的,因为它并不能保证在糟糕情况下也能将问题的可能性削减到原本的1/2。

?
?

在堆排里面有大量这种近乎无效的比较,因为被拿到堆顶的那个元素几乎肯定是很小的,而靠近堆顶的元素又几乎肯定是很大的,将一个很小的数和一个很大的数比较,结果几乎肯定是"小于"的,这就意味着问题的可能性只被排除掉了很小一部分。

?
?

这就是为什么堆排比较慢(堆排虽然和快排一样复杂度都是O(NlogN)但堆排复杂度的常系数更大)。

?
?

MacKay也提供了一个修改版的堆排:每次不是将堆底的元素拿到上面去,而是直接比较堆顶(最大)元素的两个儿子,即选出次大的元素。由于这两个儿子之间的大小关系是很不确定的,两者都很大,说不好哪个更大哪个更小,所以这次比较的两个结果就是概率均等的了。具体参考这里。

?
?

为什么快排其实也不是那么快

?
?

我们考虑快排的过程:随机选择一个元素做"轴元素",将所有大于轴元素的移到左边,其余移到右边。根据这个过程,快排的第一次比较就是将一个元素和轴元素比较,这个时候显而易见的是,"大于"和"小于"的可能性各占一半。这是一次漂亮的比较。

?
?

然而,快排的第二次比较就不那么高明了:我们不妨令轴元素为pivot,第一次比较结果是a1<pivot,那么可以证明第二次比较a2也小于pivot的可能性是2/3!这容易证明:如果a2>pivot的话,那么a1,a2,pivot这三个元素之间的关系就完全确定了——a1<pivot<a2,剩下来的元素排列的可能性我们不妨记为P(不需要具体算出来)。而如果a2<pivot呢?那么a1和a2的关系就仍然是不确定的,也就是说,这个分支里面含有两种情况:a1<a2<pivot,以及a2<a1<pivot。对于其中任一种情况,剩下的元素排列的可能性都是P,于是这个分支里面剩下的排列可能性就是2P。所以当a2<pivot的时候,还剩下2/3的可能性需要排查。

?
?

再进一步,如果第二步比较果真发现a2<pivot的话,第三步比较就更不妙了,模仿上面的推理,a3<pivot的概率将会是3/4!

?
?

这就是快排也不那么快的原因,因为它也没有做到每次比较都能将剩下的可能性砍掉一半。

?
?

基排为什么又那么快呢?

?
?

传统的解释是:基排不是基于比较的,所以不具有后者的局限性。话是没错,但其实还可以将它和基于比较的排序做一个类比。

?
?

基排的过程也许是源于我们理顺一副牌的过程:如果你有N(N<=13)张牌,乱序,如何理顺呢?我们假象桌上有十三个位置,然后我们将手里的牌一张一张放出去,如果是3,就放在位置3上,如果是J,就放在位置11上,放完了之后从位置1到位置13收集所有的牌(没有牌的位置上不收集任何牌)。

?
?

我们可以这样来理解基排高效的本质原因:假设前i张牌都已经放到了它们对应的位置上,第i+1张牌放出去的时候,实际上就相当于"一下子"就确立了它和前i张牌的大小关系,用O(1)的操作就将这张牌正确地插入到了前i张牌中的正确位置上,这个效果就相当于插入排序的第i轮原本需要比较O(i)次的,现在只需要O(1)了。

?
?

但是,为什么基排能够达到这个效果呢?上面只是解释了过程,解释了过程不代表解释了本质。

?
?

当i张牌放到位之后,放置第i+1张牌的时候有多少种可能性?大约i+1种,因为前i张牌将13个位置分割成了i+1个区间——第i+1张牌可以落在任意一个区间。所以放置第i+1张牌就好比是询问这样一个问题:"这张牌落在哪个区间呢?"而这个问题的答案有i+1种可能性?所以它就将剩下来的可能性均分成了i+1份(换句话说,砍掉了i/i+1的可能性!)。再看看基于比较的排序吧:由于每次比较只有两种结果,所以最多只能将剩下的可能性砍掉一半。

?
?

这就是为什么基排要快得多。而所有基于比较的排序都逃脱不了NlogN的宿命。

?
?

?
?

快排实例

?
?

?
?

?
?

时间: 2024-10-05 04:27:52

Coursera 数据结构 清华 邓俊辉 第十二章 漫谈快排的相关文章

《数据结构:邓俊辉版》——冒泡排序

1.思路 每次都是相邻两个数之间进行比较: 每轮比较之后总是把最大的数或者最小的数筛选出来. 2.源码 #include <memory> void BubbleSort(int szArray[], int nLen); void main() { int szArray[] = {6,4,8,1,9,13}; BubbleSort(szArray, _countof(szArray)); getchar(); } void BubbleSort(int szArray[], int nLe

《数据结构:邓俊辉版》——并归排序

void MergeSort(int low, int high) { int mid = (low + high) / 2; if (high - low < 1) { return; } MergeSort(low, mid); MergeSort(mid + 1, high); Merge(low, mid, high); } void Merge(int low, int mid, int high) { int* A = g_szArray + low; int llen = mid

《数据结构:邓俊辉版》——交换排序

void SelectSort(int nLen) { for (int i = 0; i < nLen; i++) { int j = i; int nMax = i; while (j < nLen) { if (g_szArray[j] > g_szArray[nMax]) { nMax = j; } j++; } int tmp = g_szArray[i]; g_szArray[i] = g_szArray[nMax]; g_szArray[nMax] = tmp; } } 原

《数据结构:邓俊辉版》——插入排序

void InsertSort(int nLen) { for (int i = 1; i < nLen; i++) { int j = i - 1; while (g_szArray[j] > g_szArray[i]) { j--; if (j < 0) { break; } } if (j == i - 1) { continue; } int tmp = g_szArray[i]; int k = i; while (k > j) { g_szArray[k] = g_sz

清华大学邓俊辉老师的数据结构在线课程

前几天想重新把数据结构学一遍,于是乎,翻出以前上数据结构的课件,orz...知识遗忘的速度太快了,想找个视频跟着看.令我意想不到的是,我居然能搜索到清华大学邓俊辉老师的数据结构课程,当时把我激动的啊,我要感谢互联网让教育变得那么open.能让我这个二本学校的学生听清华老师的课程,这真的是一件很幸福很幸福的事情. 一流的学校的教育方式就是那么高大上,用OJ提交作业,系统对代码进行黑盒测试...这个课程一直持续到来年1.5日才结束.这么好的资源一定要和大家分享:http://www.xuetangx

C和指针 (pointers on C)——第十二章:使用结构和指针

第十二章 使用结构和指针 这章就是链表.先单链表,后双向链表. 总结: 单链表是一种使用指针来存储值的数据结构.链表中的每个节点包含一个字段,用于指向链表的下一个节点. 有一个独立的根指针指向链表的第1个节点.单链表只能从一个方向遍历. 如何insert单链表:1.新节点的link字段必须设置为指向它的后面节点.2.前一个节点的link字段必须指向这个新节点. 为了防止可能会插入链表的起始位置这种情况,在C中,可以保存一个指向必须进行修改的link字段的指针,而不是保存一个指向前一个节点的指针.

进击的Python【第十二章】:mysql介绍与简单操作,sqlachemy介绍与简单应用

进击的Python[第十二章]:mysql介绍与简单操作,sqlachemy介绍与简单应用 一.数据库介绍 什么是数据库? 数据库(Database)是按照数据结构来组织.存储和管理数据的仓库,每个数据库都有一个或多个不同的API用于创建,访问,管理,搜索和复制所保存的数据.我们也可以将数据存储在文件中,但是在文件中读写数据速度相对较慢.所以,现在我们使用关系型数据库管理系统(RDBMS)来存储和管理的大数据量.所谓的关系型数据库,是建立在关系模型基础上的数据库,借助于集合代数等数学概念和方法来

C和指针 (pointers on C)——第十二章:利用结构和指针

第十二章 利用结构和指针 这章就是链表.先单链表,后双向链表. 总结: 单链表是一种使用指针来存储值的数据结构.链表中的每一个节点包括一个字段,用于指向链表的下一个节点. 有一个独立的根指针指向链表的第1个节点. 单链表仅仅能从一个方向遍历. 怎样insert单链表:1.新节点的link字段必须设置为指向它的后面节点. 2.前一个节点的link字段必须指向这个新节点. 为了防止可能会插入链表的起始位置这样的情况,在C中,能够保存一个指向必须进行改动的link字段的指针.而不是保存一个指向前一个节

perl5 第十二章 Perl5中的引用/指针

第十二章 Perl5中的引用/指针 by flamephoenix 一.引用简介二.使用引用三.使用反斜线(\)操作符四.引用和数组五.多维数组六.子程序的引用  子程序模板七.数组与子程序八.文件句柄的引用 一.引用简介    引用就是指针,可以指向变量.数组.哈希表(也叫关联数组)甚至子程序.Pascal或C程序员应该对引用(即指针)的概念很熟悉,引用就是某值的地址,对其的使用则取决于程序员和语言的规定.在Perl中,可以把引用称为指针,二者是通用的,无差别的.引用在创建复杂数据方面十分有用