排序算法的推导思想
Posted Debroon
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了排序算法的推导思想相关的知识,希望对你有一定的参考价值。
主线:排序算法的来龙去脉
计算机最早的排序算法源于人的生活和经验,那么我们人是怎么排序的呢?
如果只有三五个数字,我们可以扫一眼就排完了序。但如果到几十个数字,这就有点麻烦了,因为如果没有一个必须严格遵守的流程,排完序经常会有些小错误。
问题:如何给班上50个同学的成绩排序?
在没有计算机的年代,可能就只有两种笨办法:
-
方法一:第一次挑出成绩最高的同学,第二次挑出成绩次高的,如此重复,肯定能完成成绩的排序,一定不会错。
-
方法二:先将成绩单上第一个同学的名字和成绩写到旁边一张白纸的中央,如果第二个同学成绩比他高,就写到第一个同学的上方,如果比他低,就写到下方。等看到第三个同学的成绩后,根据他的成绩与前两个同学成绩的比较,插入到相应的位置。比如他的成绩正好在两个同学之间,就在旁边那张排序的纸上,把他的名字插入到前两个人之间。当然,那张排序的纸要留够空白,以方便插入后来同学的名字。
用这两种方法排50个人的成绩,工作量并不算小。
其实,早期的计算机科学家比普通人也强不到哪里去,他们提出的排序算法就是上面两种。
-
第一种算法被称为冒泡排序,因为每一次选出一个最好的,如同从水里冒出的气泡。
-
第二种被称为插入排序,因为每一次要找到合适的位置插入。
接下来怎么提高计算机算法的效率呢?
- 全世界所有的算法专家经过了十多年,终于发现从经验出发的排序速度慢的原因,就是做了无数的无用功。要提高效率,就需要让计算机少做事情。
以冒泡排序为例,之所以慢,是因为每一次选出一个最大的数,都要和其它所有的数字相比,其实并不需要这么麻烦,要想提高效率,就要减少数据之间的相互比较。
最早对冒泡排序的改进是一种叫做归并排序的算法,它就利用了少做事情的思想,归并排序的思想大致如下:
-
首先,科学家们发现,如果我们把全班同学分成两组,分别排序,那么从每一组中挑选出一个最大的,就能省去一半的相互比较时间。于是他们就先将整个班级一分为二,先分别进行排序,再把两个排好序的组,合并成为一个有序的序列。相比排序,对有序的序列合并是很快的。归并排序这个词就是这么来的。这样做大约可以节省一半时间。当然,我们在前面也讲过,节省一半时间意义不大,但是别着急,因为对一个班级分出来的两个小组,排序时也可以采用上述技巧。
-
第二步,就是对两个组的排序。显然我们不应该再用冒泡排序。聪明一点的人马上会想到,既然能分成两组,就能把每个小组再分为两组,即分成四组,重复上面的算法,分别排序再合并。这样就能省3/4的时间。
-
再接下来,四组可以分为八组,能省7/8的时间,八组可以分为十六组,时间就不断省得越来越多。分到最后每个小组只剩下两个人的时候,其实就不用排序了,只要比较一次大小即可。
后面还有科学家优化了归并排序,优化的地方是充分利用现实世界待排序数据力,很多序列是已经排好序的不需要再重新排序,利用这个特性并且加上合适的合并规则可以更加高效的排序剩下的待排序序列。
这种思想就是分而治之,在分治思想下的排序算法不止归并排序,还有快速排序,快速排序是基于比较中最快的排序算法了,排序速度是比较类排序算法的极限。
它还是强调少做事情,其原理大致是这样的:
-
首先,对于一大堆无序的数字,从中随机挑选一个,比如是53,这个被随机选上的数字被称为枢值(枢纽的枢),接下来,将所有要排序的数字分成两部分,第一部分是大于等于枢值53的,第二部分是小于枢值53的。在第一步完成后,一大堆无序的数字就变得稍微有序一点了。
-
第二步,从上面得到的两堆数字,分别采用第一步的方法各自再找一个枢值。对于第一堆,由于所有的数字都比53大,至少也等于53,因此,第二次随机挑选的枢值肯定是一个大于53的数字,比如79;类似地,对于第二堆,由于所有的数字都小于53,因此第二次随机挑选的枢值肯定小于它,比如4。接下来,再把两堆数字各自分成大于等于相应枢值的数字序列,以及小于枢值的数字序列。这样做下来,原来的一大堆数就变成了四小堆,它们分别是小于4的数字,介于4到53之间的,介于53到79之间的,以及大于或等于79的。
-
再接下来,用同样的方法,四堆变八堆,八堆变十六堆,很快所有的数字就排好序了。
我们可以在一个例子中,对比一下这几种排序:
-
假如有一个学区,里面有20000名高中学生,如果让大家到一个超级大的学校上大课,再从中挑出学生中的尖子,效率一定高不了。这就相当于冒泡排序,每一个人都要和所有人去比。
-
如果我们把2万人放到10所学校中,每所学校只有两千人,从各个学校先各自挑出学习尖子,再彼此进行比较,这就有效得多了。这就是归并排序原理。
-
如果我们先划出几个分数线,根据个人成绩的高低把20000个学生分到十所学校去,第一所学校里的学生成绩最好,第十所最差,对比归并排序没有彼此比较的那步了,再找出学习尖子,那就容易了,工作量最小,这就是快速排序的原理,也是快速排序最快的原因。
快速排序是通常情况下最好的算法,但是,在极端的情况下,它的复杂度是N平方,和冒泡排序一样糟糕。而归并排序,即使在最坏的情况下,也能保证N乘以log(N)的复杂度,所以,我们在学习排序时,主要是把精力花在快速排序上,特别是对快速排序的优化上。
随着科技的进一步进步,有没有可能发明一种比快速排序更好的算法?从科学上讲,答案是否定的。因为从数学上可以证明N个任意随机数的排序,复杂度不可能比N乘以log(N)更低,这是数学给出的极限,或者边界,因此有点头脑的人都知道别在这方面瞎费工夫。
具体的证明过程:
- 《算法导论》的证明:基于比较的排序算法最大比较次数为 O(N logn)。
- 《渐近记号》的证明:算法导论很多细节没有写全,我在博客渐进记号里补全了每一步推导的细节和前置知识。
从算法分析角度来看,降低复杂度的方法无非种:
- 分而治之
- 空间换时间
比较排序算法的极限是O(N logn),而归并、快速排序恰是这个极限速度,说明分治思想把基于比较的排序算法优化到极限了。
这时候我们还可以用空间换时间思想搞一个速度更快的方法,比如给20000人的成绩排序。
所有人的成绩无非是 0-100,那我们买101个桶,按照次序一字排开,桶的排列顺序分别代表了具体的分数,第1个桶是0分,第2个桶是1分···第101个桶是100分。
假如小明考了82分,那就把小明的名片放进第83号桶,有多个人就放多少次即可,直接就没有比较的过程,速度是O(N)。
如果要把一个集合排序,那贪心的话,就是只看局部(紧邻的俩个元素)。
比如,第 n n n 个和第 n − 1 n-1 n−1 个,这是一个局部(紧邻的俩个元素)。
如果我们要把这个集合,弄成从小到大。
局部就俩种情况:
- 前小后大(V)
- 前大后小(X)
那我们可以把第二种改成第一种,这样若干个局部叠加起来就组成了有序的整体了。
于是,就产生了【冒泡】、【地精】排序。
俩种排序的思想是一样的(贪心),只不过策略不同。
地精排序:攘外必先安内(贪心)
地精排序的策略是比较朴素的,所以也被称为“愚蠢排序”。
比如,集合「8,24,5」。
- 5 和 24 交换一次,变成「8,5,24」
- 5 和 8 交换一次,变成「5,8,24」
这个交换的过程就是【地精排序】的核心。
地精排序策略:攘外必先安内
。
- 从左到右扫描
- 如果第 n n n 个比第 n − 1 n-1 n−1 小,那就 s w a p ( n , n − 1 ) swap(n, n-1) swap(n,n−1)
这时这个集合就划分成了 2
部分:
-「 第 1 个 元 素 到 第 n − 1 个 元 素 第 1 个元素到第 n-1 个元素 第1个元素到第n−1个元素」
-「第 n 个元素到最后一个元素」
如果把「 1 到 n − 1 1到n-1 1到n−1」称之为【内】,那「n到最后一个元素」就是【外】。
地精排序是让先「
1
到
n
−
1
1到n-1
1到n−1」完全是排序好的,才会开始排序「n到最后一个元素」,也就是攘外必先安内
。
比如上面的例子,排序集合「8,24,5」。
地精排序的过程:
- 从左到右扫描,第
1
个元素小于第2
个元素,内部已然安定; - 接着,看第
2
个元素与第3
个元素,发现这个局部顺序不对,所以交换。 - 5 和 24 交换一次,变成「8,5,24」
- 「
1
到
n
−
1
1到n-1
1到n−1」都是内部,
攘外必先安内
,也就是回过头看第 n − 1 n-1 n−1 个元素与第 n − 2 n-2 n−2 个元素对比…如果满足条件不断的交换,那不断的回头比较、交换,直到换到第1
个元素为止。 - 5 和 8 交换一次,变成「5,8,24」。
void gnomeSort(int a[],int len){
int i = 1;
while(i < len)
{
if(i < 1 || a[i-1] <= a[i]) // 前小后大,不需要动。
i++;
else{ // 前大后小
swap( a[i], a[i-1] );
i--; // 攘外必先安内,回过头继续比较
// 但不能一直退,退到第一个元素就行了,所以加了一个哨兵位 i < 1 时,i ++
}
}
}
优化思路,如果退了很多步,那回到原位置,要一步步加进去。这里做了很多无用功,我们可以直接设置一个变量来定位,实现瞬间移动。
void gnomeSort(int a[],int len){
for( int i = 0; i < len; i ++ )
for( int j = i; j > 0 && a[j-1] > a[j]; j -- )
// j = i 就是定位直接回来
swap( a[j-1], a[j] );
}
冒泡排序:一直攘外(贪心)
冒泡排序类似,只不过冒泡的策略是:一直攘外
。
- 从左到右扫描,第 1 个元素小于第 2 个元素,内部已然安定;
- 接着,看第 2 个元素与第 3 个元素,发现这个局部顺序不对,所以交换。
- 5 和 24 交换一次,变成「8,5,24」
上面这俩步和地精一样。
地精第三步是【回过头】检查,等内部完全排序好了,再继续和外面的比较。
冒泡第三步是【一直攘外】,交换好了就好了,至于局部是什么样的以后再说,而后继续往前比较,直到比较完。
冒泡排序相信,排序完后,局部的有效性会得到改善。
经过一趟一趟的迭代,就会产生一个不变性,经过 i
次迭代,最大的 i
的元素必然已经排好序,问题规模就变成了 len - i
。
经过 len
趟迭代后,就排好序了。
void bubbleSort (int arr[], int len) {
int temp;
for (int i=0; i<len-1; i++) // 外循环为排序趟数,len个数进行len-1趟
for (int j=0; j<len-1-i; j++) // 内循环为每趟比较的次数,第i趟比较len-i次
if (arr[j] > arr[j+1]) // 相邻元素比较,若逆序则交换
swap(arr[j], arr[j+1]);
}
选择排序优化:比而不换
锦标赛排序:从全局排序到局部排序
锦标赛排序,顾名思义,是受体育比赛的启发想出来的。
锦标赛排序的好处是,它并非要等到所有的排序工作都做完的时候,才知道谁是第一名,而是可以只排出前几名。
这种赛制的合理性来自下面一个假设:
-
如果张三赢了李四,李四赢了王五,那么张三一定能赢王五。
-
也就是说:A>B, B>C, 那么必然有A>C。
只要上面这种胜负的传递性成立,通过这种比赛的结果得到的冠军,一定是最好的选手。
但是,第二名是否如此,就难说了。因为冠军一路打下来,被他刷掉的选手可能水平都不差,只是运气不好,提前遇到他了,在决赛之前被淘汰了。
比如说在某次比赛中,A半决赛赢了B,决赛赢了C。
- A的冠军,不会有什么异议,但你说到底是C该得亚军,还是B更厉害,还真不好说,B只能怪自己那次抽签运气不好。
因此,如果真要较真,就需要把被冠军淘汰下来的人放到一个组里再相互比赛,才能知道谁是亚军。
这就是锦标赛排序的步骤了:
-
第一步,把所有的数字放到二叉树的叶子结点,而后按照锦标赛单淘汰的方式,两两比较选出最大的。
-
第二步,对于第二大的,从所有被最大的数字淘汰的数字中选择。比如在某次比赛中,被A淘汰的分别是B、C、D等人,那么这些人再进行单淘汰,选亚军。对于第三、第四大的数字,可以以此类推。
第二步,是锦标赛排序的难点,恰好高盛有一道面试题,如果您懂第二步,就可以做出了。
高盛面试题:假定有二十五名短跑选手比赛竞争金银铜牌,赛场上有五条赛道,因此一次可以有五个人同时比赛。比赛并不计时,只看相应的名次。假如选手的发挥是稳定的,也就是说如果张三比李四跑得快,李四比王五跑得快,那么张三一定比王五跑得快。最少需要几组比赛才能决出前三名?
- 思考10min…(先不要往下看)
第一步,将25名选手分为五个组,每组五个人,为了便于说明,我们不妨把这25人根据所在的组进行编号,A1-A5在A组,B1-B5在B组……最后E1-E5在最后的E组。
而后让每个组分别比赛,排出各组的名次来。我们假设他们的名次就是他们在小组中的编号,即A组的名次是A1、A2、A3、A4、A5,B组和其它组的名次也是类似(如下图):
第二步,让各组的第一名,也就是A1、B1、C1、D1、E1再比一次,上图中是第一排红颜色的,这样就能决出第一名。不失一般性,我们假设A1在这次比赛中获胜,这样我们就知道了第一名。
由于A1是第一名,根据我们前面讲的淘汰赛的问题,A2可能也很厉害,只是运气不好,小组赛遇到了A1,当A1已经获得冠军了,他就应该作为亚军的候选。接下来,就进入第三步。
第三步,A2和另外四个组的第一名竞争亚军。如果这一次A2赢了,他显然是亚军,就由A3递进参加争夺第三名的比赛。我在下图中用红色圈定了这种情况下参加第八次比赛的五位选手。如果A2没有赢,另四个组的某个第一名赢了,那个赢的人是亚军,就由那个组下一位选手递进,决逐第三名。
第四步,如上图选出的五个人进行第三名的比赛,至此,前三名全部产生。
如果你在面试中告诉对方上面这种方法,表现可能算是中规中矩,但不算完美。
那么这个问题最好的答案是什么呢?
- 其实前六次比赛都是必须的,也就是说,最佳答案的前两步和上述答案中的前两步是相同的。但是,在上述方案中,有一个信息被忽略了,那就是在第六组比赛(即五个第一名的比赛)结束之后,最后的两名已经没有资格决逐前三名了。我们不妨假设那一次比赛从最快到最慢的结果是A1、B1、C1、D1、E1。在D1和E1之前已经有三名选手了,他们肯定不是前三名。
那么谁还会是第二名的候选呢?
- 根据锦标赛排序的原则,直接输给第一名的人,也就是A组中的A2,以及最后附加赛输给他的B1,仅此两人而已。
接下来我们要问,除了A2和B1,谁还会是第三名的候选呢?
- 和A1在某一组比赛的第三名,他们是A3、C1,或者输给第二名候选人B1的人那个人,即B2。
因此,第二、三名的候选人一共只有五个,即A2、A3、B1、B2和C1(下图中的红色选手),刚好凑一组。
前六次,得到第一名。
第七次,让他们五个人再跑一次即可同时得到第二、三名。
这样加上前六次,只需要赛七组,这是最佳的方法。
堆排序:锦标赛排序的改进
以上是关于排序算法的推导思想的主要内容,如果未能解决你的问题,请参考以下文章