文章目录

  • 第八章 排序
  • 一、排序的基本概念
    • (一)什么是排序
    • (二)排序的应用
    • (三)排序算法的评价指标
    • (四)排序算法的分类
    • (五)总结
  • 二、插入排序
    • (一)算法思想
    • (二)算法实现
    • (三)算法效率分析
    • (四)优化——折半插入排序
    • (五)对链表进行插入排序
    • (六)总结
  • 三、希尔排序(Shell Sort)
    • (一)算法思想
    • (二)算法实现
    • (三)算法性能分析
    • (四)总结
  • 四、冒泡排序
    • (一)算法思想
    • (二)算法实现
    • (三)算法性能分析
    • (四)总结
  • 五、快速排序
    • (一)算法思想
    • (二)算法实现
    • (三)算法性能分析
      • 1.时间复杂度
      • 2.空间复杂度
      • 3.递归层数
      • 4.比较好的情况
      • 5.最坏的情况
      • 6.快速排序优化
      • 7.稳定性
    • (四)总结
  • 六、简单选择排序
    • (一)算法思想
    • (二)算法实现
    • (三)算法性能分析
      • 1.空间复杂度
      • 2.时间复杂度
      • 3.稳定性
    • (四)总结
  • 七、堆排序
    • (一)什么是堆(Heap)
    • (二)如何基于堆进行排序
    • (三)建立大根堆
    • (四)建立大根堆(代码)
    • (五)基于大根堆进行排序
    • (六)基于大根堆进行排序(代码)
    • (七)算法性能分析
      • 1.建堆的过程
      • 2.排序的过程
      • 3.堆排序结论
      • 4.稳定性
    • (八)总结
    • (九)练习:基于“小根堆”如何建堆、排序?
  • 八、堆的插入删除
    • (一)在堆中插入新元素
    • (二)在堆中删除元素
    • (三)总结
  • 九、归并排序(Merge Sort)
    • (一)什么是归并/合并(Merge)
    • (二)“2路”归并
    • (三)“4路”归并
    • (四)归并排序(手算)
    • (五)代码实现
    • (六)算法效率分析
    • (七)总结
  • 十、基数排序(Radix Sort)
    • (一)算法思想
    • (二)算法性能分析
      • 1.空间复杂度
      • 2.时间复杂度
      • 3.稳定性
    • (三)基数排序的应用
    • (四)总结
  • 十一、外部排序
    • (一)外存、内存之间的数据交换
    • (二)外部排序原理
      • 1.构造初始“归并段”
      • 2.第一趟归并
      • 3.第二趟归并
      • 4.第三趟归并
    • (三)时间开销分析
    • (四)优化:多路归并
    • (五)总结
    • (六)纠正:多路平衡归并是什么
  • 十二、败者树
    • (一)什么是败者树
    • (二)败者树在多路平衡归并中的应用
    • (三)败者树的实现思路
    • (四)总结
  • 十三、置换-选择排序
    • (一)传统方法制造初始归并段
    • (二)置换-选择排序
    • (三)总结
  • 十四、最佳归并树
    • (一)归并树的神秘性质
    • (二)构造2路归并的最佳归并树
    • (三)多路归并的情况
      • 1.三路归并
      • 2.如果减少一个归并段
      • 3.正确的做法
    • (四)总结

第八章 排序

在进行这一章的学习的时候,一个很神奇的网站:

https://www.cs.usfca.edu/~galles/visualization/Algorithms.html

在这里面可以可视化的观看每种排序的执行过程。

一、排序的基本概念

(一)什么是排序

什么是排序?

不用那么多废话,直接看图。

所谓排序,就是把n个关键字,按照递增或递减的顺序,把它们给重新排列一遍。

此外,在排序的过程当中,我们难免会遇到关键字相同的情况。

(二)排序的应用

略。

(三)排序算法的评价指标

算法的稳定性。若待排序表中有两个元素Ri和Rj,其对应的关键字相同即keyi = keyj,且在排序前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍在Rj的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。

稳定的:关键字相同的元素在排序之后相对位置不变。反之是不稳定的。

问:稳定的排序算法一定比不稳定的好?

答:不一定,要结合实际需求出发。例如实际场景对排序的稳定性没有要求,或者关键字根本就没有重复的,那么稳定不稳定都一样。

(四)排序算法的分类

  • 内部排序:数据都在内存中
  • 外部排序:数据太多,无法全部放入内存

所谓的内部排序,指的是我们可以把所有需要排序的数据,全部放到内存当中。比如说我们自己写的排序,我们自己定义一个数组之类的,其数据量不是很大,都是放在内存当中的。

但是有的时候,我们又难免要遇到,我们需要排序的数据量很大,没有办法全部放入内存的情况。我们的外部磁盘的容量一般是很大的,而内存就小很多。比如我们现在有一个超大的文件,超过了8G,不可能将它一次性全部放入内存。那么这时,我们想对相关的数据进行排序,就只能采取一部分一部分处理这样的策略。

对于一个内部排序,由于数据都在内存中,而内存又是一个很高速的设备,所以我们在设计排序算法的时候,我们会更多地关注这个算法的时间复杂度、空间复杂度是怎么样的。

而当我们在设计外部排序算法的时候,除了这个算法的时间、空间复杂度之外,我们还需要关注,怎么追求更少的读写磁盘的次数。

因为我们磁盘的读写速度很慢,比如对于一个机械硬盘来说,其读写速度在100MB/s左右。相比之下,我们内存读写的速度可以到60GB/s。

所以我们要将磁盘中的数据和内存之间进行读写,就会很慢。而数据一旦读入内存后,对数据的处理就不会消耗什么时间。

所以在设计外部排序的时候,我们也需要关注怎么使读写磁盘次数更少的问题。

(五)总结

排序

  • 将各元素按关键字递增或递减顺序重新排列
  • 评价指标
    • 稳定性:关键字相同的元素经过排序后相对顺序是否会改变
    • 时间复杂度、空间复杂度
  • 分类
    • 内部排序:数据都在内存中
    • 外部排序:数据太多,无法全部放入内存

二、插入排序

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

(一)算法思想

一开始会从第二个元素开始入手。

我们会认为,当前这个元素之前的所有元素,是已经排好序的。

我们现在需要将当前元素38,与之前元素依次进行对比,之前元素中比38大的,都要将之依次后移。

49>38,所以49需要后移。

接下来要处理的是65这个元素。

而65>49,所以其之前的元素都不需要移动,直接把65放回来即可。

接下来处理的是97,它比它前边的65还大,所以依然直接放回来。

接下来要处理76这个元素。

在76之前的这些元素是已经排好序的。我们从76依次往前检查,大于76的就后移一位。

因此97需要后移一位,之后继续往前检查,65<76,则停止检查。并将76插入到65后面的位置。

接下来要处理13这个元素。

再其之前的元素依次检查,都比13要大,在检查到38并将其后移之后,再往前检查就没有元素了。所以13应该放入0号位置。

接下来,27的处理方式是一样的。

之后我们要处理49这个元素。最后一个49加一个下划线,是为了和前面的那个49作区分。

处理的方法一样,我们要把其之前的排好序的序列中,大于49的元素依次后移一位,然后插入。结果如下。

注意,我们是把比它更大的元素右移,而与之相等的元素是没有右移的。

这样可以保证算法的稳定性。

(二)算法实现

//直接插入排序
void InsertSort(int A[], int n) {int i, j, temp;for(i = 1; i < n; i++){       //将各元素插入已排好序的序列中 if(A[i] < A[i-1]){      //若A[i]关键字小于前驱 temp = A[i];        //用temp暂存A[i] for(j = i-1; j>=0 && A[j]>temp; --j){ //检查所有前面已排好序的元素 A[j+1] = A[j];        //所有大于temp的元素都向后挪位 }A[j+1] = temp;        //赋值到插入位置 }}
}

另一种实现方法(带哨兵)

//直接插入排序(带哨兵)
void InsertSort(int A[], int n) {int i, j;for(i = 2; i<=n; i++) {        //依次将A[2]~A[n]插入到前面已排序序列 if(A[i] < A[i-1]){      //若A[i]关键字小于其前驱,将A[i]插入有序表 A[0] = A[i];     //赋值为哨兵,A[0]不存放元素 for(j = i-1; A[0] < A[j]; --j){        //从后往前查找待插入位置 A[j+1] = A[j];      //向后挪位 }A[j+1] = A[0];        //赋值到插入位置 }}
}

带哨兵,优点:不用每轮循环都判断j>=0

(三)算法效率分析

空间复杂度:O(1)

时间复杂度:主要来自对比关键字、移动元素。若有n个元素,则需要n-1趟处理。

最好情况:

原始的表就是一个有序表。

共n-1趟处理,每一趟只需要对比关键字1次,不用移动元素。

最好时间复杂度——O(n)

最坏情况:

表中元素全部都是逆序排放。

这种情况下,我们每一趟处理,都需要把当前元素,与它之前所有元素都进行一次对比,并且把之前排好序的元素依次后移。

第1趟:对比关键字2次,移动元素3次;

第2趟:对比关键字3次,移动元素4次;

第i趟:对比关键字i+1次,移动元素i+2次;

第n-1趟:对比关键字n次,移动元素n+1次。

最坏时间复杂度——O(n²)

即:

  • 空间复杂度:O(1)
  • 最好时间复杂度(全部有序):O(n)
  • 最坏时间复杂度(全部逆序):O(n²)
  • 平均时间复杂度:O(n²)
  • 算法稳定性:稳定

(四)优化——折半插入排序

之前我们执行插入的时候,都是从当前元素顺序地往前依次寻找,找到它应该插入的位置。

但是由于当前处理的元素,它之前的元素已经是有序的了,并且还是顺序存储的话,那么就可以用折半查找的方法,更快的找到当前处理元素应该插入的位置。

先把当前处理的元素a[i]保存下来。(放入哨兵,或者存入temp)

之后,对当前元素之前的元素,进行折半查找。

当low>high时折半查找停止,此时应该将[low, i-1]内的元素全部右移,并将A[0]赋值到low所指位置。

之后,处理下一个元素,60。

同样利用折半查找的算法,但是有一个问题。当mid所指元素为60时,由于折半查找算法的原理,当查找到相同的元素,就会停止折半查找。

但是此处我们为了保证插入排序的稳定性,当我们发现和当前元素相等的元素时,我们还应该继续查找,继续往右半部分确定处理元素的插入位置。

所以此时并不会让折半查找停止,我们还会继续在mid所指的60的右半部分,继续进行折半查找,即令low=mid+1。

当A[mid] == A[0]时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置。

继续执行,到low>high时,停止折半查找。并将[low, i-1]范围内的元素全部右移,并将A[0]复制到low所指位置。

代码实现如下

//折半插入排序
void InsertSort(int A[], int n) {int i, j, low, high, mid;for(i = 2; i<=n; i++){ //依次将A[2]~A[n]插入前面的已排序序列 A[0] = A[i];  //将A[i]暂存到A[0]low=1; high=i-1;    //设置折半查找的范围while(low<=high){    //折半查找 mid = (low + high) / 2;    //取中间点if(A[mid] > A[0])high = mid - 1; //查找左半子表else low = mid + 1;    //查找右半子表 } for(j = i-1; j>=high+1; --j){A[j+1] = A[j];      //统一后移元素,空出插入位置 }A[high+1] = A[0];     //插入操作 }
}

当low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到low所指位置。

当A[mid]==A[0]时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置。

比起“直接插入排序”,比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度依然是O(n²)

(五)对链表进行插入排序

插入排序的思想也可以对链表进行。

对链表的插入排序,代码实现自己思考。

此处要说的是,当我们用链表进行插入排序的话,移动元素的次数变少了。但是关键字对比的次数依然是O(n²)数量级,整体来看时间复杂度依然是O(n²)

(因为链表没办法用折半查找,所以只能顺序地,从后往前依次对比各个元素)

(六)总结

插入排序

  • 算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
  • 直接插入排序:顺序查找找到插入位置,适用于顺序表、链表
  • 折半插入排序:折半查找找到应插入的位置,仅适用于顺序表
    • 注意:一直到low>high时才停止折半查找。当mid所指元素等于当前元素时,应继续令low=mid+1,以保证“稳定性”。最终应将当前元素插入到low所指位置(即high+1所指位置)。
  • 性能
    • 空间复杂度:O(1)
    • 时间复杂度
      • 最好:原本有序,O(n)
      • 最坏:原本逆序,O(n²)
      • 平均:O(n²)
    • 稳定性:稳定

三、希尔排序(Shell Sort)

希尔排序是一个叫希尔的人发明的。

它是对上一小节中讲的插入排序,进行的一个优化。

对于插入排序来说,若原本表中的元素是有序的,则在这种情况下,直接插入排序可以得到一个很不错的执行效率。

那么,再把这个条件放宽一点,若原本表中的元素是基本有序的。那这种情况下,直接插入排序的效率也会很不错。

总之,如果能保证表中元素基本有序的话,我们的插入排序也能得到很好的执行。

希尔排序的思想就是基于这样的一个考虑。

我们先追求表中的元素部分有序,再逐渐逼近全局有序。

(一)算法思想

希尔排序:先将待排序表分割成若干形如L[i, i+d, i+2d, ..., i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。

在每一趟排序时,我们都会设一个增量d,然后对相距距离为d的各个元素,对它们进行直接插入排序。

若第一趟排序,令d=4。则子表情况如下:

接下来要对各个子表分别进行直接插入排序。

在进行完毕第一趟排序之后,表中的数据应该如下所示。

第二趟:令d = d/2 = 2。则子表情况如下:

接下来对各个子表分别进行直接插入排序。

在进行完毕第二趟排序之后,表中数据如下。

第三趟:令d = d/2 = 1。则子表情况如下:

最后一趟处理就相当于总体对所有元素直接进行插入排序。

但这时,经过前两趟处理,我们这时这个序列已经达成了基本有序的状态。

经过三趟的处理之后,最终得到一个递增序列。

整个过程如下图所示:

此处,我们每次将增量d缩小一半。

实际上这也是希尔本人建议的。

希尔本人建议:每次将增量缩小一半。

不过,考试当中,可能会遇到各种的增量。按照原理分析即可。

(二)算法实现

//希尔排序
void ShellSort(int A[], int n) {int d, i, j;for(d = n/2; d>=1; d = d/2){  //步长变化 for(i = d+1; i<=n; ++i){if(A[i] < A[i-d]){    //需将A[i]插入有序增量子表 A[0] = A[i];  //暂存在A[0] for(j = i-d; j>0 && A[0]<A[j]; j-=d){A[j+d] = A[j]; //记录后移,查找插入的位置 }A[j+d] = A[0]; //插入 }}}
}

(三)算法性能分析

空间复杂度:O(1)

我们的d,如果依次采用不同的增量序列,如取4、2、1,和取3、1,其希尔排序过程的趟数会受到影响,同时在每一趟排序当中,各个元素的对比、元素的移动这些也都会受到影响。

所以希尔排序的时间复杂度是怎么样的,到目前为止分析起来都很困难。

时间复杂度:和增量序列d的选择有关,目前无法用数学手段证明确切的时间复杂度

如果第一趟就取d=1,那么希尔排序就会退化成直接插入排序。

于是其最坏时间复杂度为O(n²)。

另外,当n在某个范围内时,希尔排序的时间复杂度可以达到O(n^1.3),可见与直接插入排序相比,效率还是提升了很多的。

接下来看这个算法的稳定性如何。

对于这个表,第一趟取d=2,会导致49跑到49的前边。

所以,显然是不稳定的。

此外,其必须要具有随机访问的特性,才可以实现。因此希尔排序仅适用于顺序表,不适用于链表。

(四)总结

希尔排序

  • 先将待排序表分割成若干形如L[i, i+d, i+2d, ..., i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。
  • 性能
    • 空间复杂度:O(1)
    • 时间复杂度:未知,但优于直接插入排序
    • 稳定性:不稳定
    • 适用性:仅可用于顺序表
  • 高频题型:给出增量序列,分析每一趟排序后的状态

四、冒泡排序

交换排序:

  • 冒泡排序
  • 快速排序

基于”交换“的排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。

(一)算法思想

从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1] > A[i]),则交换它们,直到序列比较完。称这样过程为“一趟”冒泡排序。

从后往前两两对比相邻元素,依次对27 4913 27……进行对比

由于我们的目标是使整个表呈递增,因此,若后一个元素较小,则交换这两个元素。

第二趟同理。并且同样将最小的元素“冒”到最前面。

此时需要注意,之前已经确定位置的元素,不用再与之进行对比了。

因此,第二趟结束后,会导致,最小的两个元素已经“冒”到了最前边。

第三趟同理。同样地,之前确定过位置的元素不用再与之对比。

第三趟结束后,值最小的三个元素已经“冒”到了最前边。

第四趟同理,两两对比,该交换交换。

当两个数的值相等时,我们不应该交换它们两个的位置,这么做能保证算法的稳定性。能保证原本在右边的元素不会跑到左边去。

接下来,进行第五趟排序。

会发现,两两对比,每一对数字都不需要进行交换。那么这种情况其实说明,整体已经达到了一个有序的状态了。所以经过第五趟处理,我们就可以确定,整个表已经有序了,我们就不需要再进行后续的处理。

(二)算法实现

理清楚逻辑之后,代码的理解就很简单了。

//交换
void swap(int &a, int &b) {int temp = a;a = b;b = temp;
}//冒泡排序
void BubbleSort(int A[], int n) {for(int i=0; i<n-1; i++){bool flag = false; //表示本趟冒泡是否发生交换的标志for(int j=n-1; j>i; j--){  //一趟冒泡过程 if(A[j-1] > A[j]){      //若为逆序 swap(A[j-1], A[j]);  //交换flag = true; }}if(flag == false)return;      //本趟遍历没有发生交换,说明表已经有序 }
}

只有A[j-1] > A[j]时才交换,相等时不交换,因此可以保证此算法是稳定的。

(三)算法性能分析

空间复杂度:O(1)

最好时间复杂度(有序):O(n)

进行一趟冒泡排序即可确认有序。两两对比,对比次数为n-1次;交换次数为0次。

最坏时间复杂度(逆序):O(n²)

每一趟,首先都是需要两两对比的,对比次数为n-1、n-2…

其次,由于原表是完全逆序的,因此每个关键字在进行每一次两两对比时,都需要进行交换操作。

比较次数 = (n-1) + (n-2) + … +1 = n(n-1)/2 = 交换次数

平均时间复杂度:O(n²)

上面我们说的都是“交换”的次数。

而每次交换,都需要移动元素3次。(因为t = a; a = b; b = t;)

所以如果题目中说的是移动元素的次数,需要和交换元素的次数进行区分。

稳定性:稳定。

问:冒泡排序是否适用于链表?

显然是可以的。我们可以从链头开始,从头到尾地进行冒泡排序,若逆序则交换。

可从前往后“冒泡”,每一趟将更大的元素“冒”到链尾。

(四)总结

冒泡排序

  • 算法原理

    • 从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换它们,直到序列比较完。称这样过程为“一趟”冒泡排序。最多只需n-1趟排序。
    • 每一趟排序都可以使一个元素移动到最终位置,已经确定最终位置的元素在之后的处理中无需再对比。
    • 如果某一趟排序过程中未发生“交换”,则算法可提前结束。
  • 性能
    • 空间复杂度:O(1)
    • 时间复杂度
      • 最好(完全有序):O(n)
      • 最差(完全逆序):O(n²)
      • 平均:O(n²)
    • 稳定性:稳定
    • 适用性:顺序表、链表都可以

五、快速排序

交换排序:

  • 冒泡排序
  • 快速排序

基于”交换“的排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。

“快速排序”,它的名字就起成这个样子,可以看出它这个算法还是比较优秀的。

事实上,我们学习的所有内部排序的排序算法中,快速排序确实是最优秀的。

在冒泡排序中,我们每一次都会使一个最大/最小元素确定它的最终位置。

在快速排序中,我们每次都会使一个中间元素确定它的最终位置。

(一)算法思想

在待排序表L[1…n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

这是一个含有8个元素的待排序表。我们每次选择一个元素作为枢轴(或者基准),通常取第一个元素。

第一次取49为枢轴,接下来在一趟排序之后,我们应该能够找到49所要摆放的最终位置。

49这个元素在整个表当中,既不是最小的,也不是最大的,它应该处于一个中间位置。

所以,我们要确定49这个元素的最终位置,我们就需要对整个表进行一个调整,我们需要把这个表划分为左右两个部分,左半部分所有元素都要比49更小,而右半部分所有元素都要大于等于49这个基准元素。

这样的一次处理,我们把它称为一次“划分”。因为我们利用49作为基准,把整个表划分为了左右两个部分。

我们看一下一次划分的具体过程

首先,low和high分别指向我们要处理的这个序列的头、尾处。

然后,我们选择low所指向的这个元素,把它作为基准元素。

接下来,我们会让low和high都向中间移动,把中间的元素都给扫描一遍。

在扫描的过程中,我们需要保证,high指针的右边,都是大于等于49的元素;而low指针的左边,都是小于49的元素。

扫描的过程:

low所指位置当前为空,不动;

high所指的当前元素为49,是大于等于49的,不处理,指向下一处。

high当前所指的元素为27,而27<49,因此27最终需要放到low指针所指位置的左边,因此我们将27放到low指针指向的位置。

此时,high所指的位置空出来了,那么我们接下来要将low指针向右扫描了。

当前low所指的元素为27,27<49,所以不需要处理,指向下一处;

当前low所指的元素为38,38<49,所以不需要处理,指向下一处;

此时,low指向的元素65,65≥49,因此65需要放到high指针所指位置的右边。因此我们将65放到high所指的位置。

然后,low所指位置空了,所以接下来我们令high继续扫描。

当前high所指元素为65,65≥49,不需要处理,继续扫描;

当前high所指元素为13,13<49,需要把13放到low指针所指位置。

接下来让low指针右移,进行扫描。

当前low所指元素为13,13<49,不需要处理,继续扫描;

当前low所指元素为97,97≥49,需要将97放到high所指位置。

然后又让high指针左移进行扫描。

当前high所指元素为97,不需要处理,继续扫描;

当前high所指元素为76,不需要处理,继续扫描,即继续左移,high–。

当low和high碰到一起的时候,就说明我们已经把所有待排序的元素都扫描了一遍,所有比基准元素更小的元素,我们都把它放到了low指针所指位置的左边;所有大于等于基准元素的元素,我们都把它放到了high指针所指位置的右边。

并且此时我们可以确定,49就放在low和high相遇的这个位置。

以上就是一次“划分”的过程。

我们选定了待排序序列当中的第一个元素,作为所谓的枢轴元素,或者叫基准元素。

然后我们用low和high两个指针分别指向了待排序序列的最左和最右两个位置。当这两个指针往中间移动的时候,我们每扫描到一个元素,都会对比这个元素和基准元素的大小关系,如果比基准元素更小的,我们统统会把它移动到左边;如果比基准元素大于等于的,我们会把它移动到右边。

当low和high相遇的时候,我们就确定了这个基准元素(枢轴元素)的最终存放位置。

那既然49的最终位置确定了,那么在接下来的排序中,我们就不需要再管49这个元素了。我们只需要管它左边的子表,和它右边的子表。

接下来我们要做的,就是对其左、右子表,再次进行同样的“划分”。

先来看左子表,对它进行划分

同样地,是用low和high分别指向待排序序列的头、尾两处。并选中第一个元素作为基准元素。

现在来让high进行扫描。

high当前所指向的元素,13<27,因此要将13放到low所指位置。

然后让low进行扫描。

low指向13,不处理;

low指向38,38>27,因此要将38放到high所指位置。

然后让high进行扫描,high左移。

low和high相遇,如下:

此时,low和high相遇,说明待排序序列中的所有元素都被我们扫描了一遍,说明基准元素的最终存放位置应该是low和high相遇的这个位置。

此时,这个子表,又再一次地被27这个基准元素划分为了左右子表。由于我们的左右两个子表都只剩余一个元素,显然,这两个子表不需要再进行处理,也就是说0~2这个子序列中的所有元素的最终位置,我们都已经确定了。

接下来,对49的右子表进行处理

将low和high分别指向待处理序列的头、尾位置。同时选中low所指元素作为基准元素。

中间过程略。

最终确定了基准元素76的位置。76再一次地把它划分成了两个更小的部分。

对于76左半部分的处理,处理思路是一样的,结果如下图所示:

此时,显然49的右半部分,以及76的右半部分,都不需要排序了。

因此整个表已经排序完毕。

以上就是快速排序的整个过程。

我们会不断地进行“划分”这个过程。

每次划分,我们的序列都会被分为左边和右边两个部分,左边的元素都比枢轴元素更小;右边的元素都大于等于枢轴元素。

然后再对枢轴元素的左、右部分,再次进行“划分”。

(二)算法实现

//用第一个元素将待排序序列划分成左右两个部分
int Partition(int A[], int low, int high) {int pivot = A[low];     //第一个元素作为枢轴 while(low < high){       //用low、high搜索枢轴的最终位置 while(low < high && A[high] >= pivot){--high; }A[low] = A[high];   //比枢轴小的元素移动到左端 while(low < high && A[low] <= pivot){++low;}A[high] = A[low];  //比枢轴大的元素移动到右端 }A[low] = pivot;        //枢轴元素存放到最终位置return low;        //返回存放枢轴的最终位置
}//快速排序
void QuickSort(int A[], int low, int high) {if(low < high){      //递归跳出的条件 int pivotpos = Partition(A, low ,high);  //划分QuickSort(A, low, pivotpos-1);      //划分左子表QuickSort(A, pivotpos+1, high);     //划分右子表 }
}

(三)算法性能分析

1.时间复杂度

其实每一轮处理,都是一层QuickSort,都是Partition函数执行一遍划分的过程,都是扫描一遍待排序序列除了基准元素外的所有元素。

所以第一次Partition,扫描了n-1个元素,处理的时间复杂度为O(n)。

而在之后的“划分”,每次要扫描的元素都不可能比n-1大,因此其处理的时间复杂度都不会超过O(n)。

而,对于这个初始序列有8个元素的表来说,我们处理了4层QuickSort。

时间复杂度 = O(n * 递归层数)

2.空间复杂度

由于快速排序,是一个递归函数,每次执行都会向递归工作栈内存放函数信息。所以如果递归调用的层数越深,那么相应的需要的空间复杂度也会越高。

空间复杂度 = O(递归层数)

3.递归层数

由于每次“划分”,都是由一个枢轴元素将序列划分为左右两部分,直至左右部分为空,或只有一个元素。

因此可以将其化为一棵二叉树。

然后,按照二叉树的性质,对于有n个结点的二叉树,最小高度为⌊ log₂n ⌋ + 1;最大高度为n

而这个二叉树的高度,就是快速排序递归调用的深度。

也就是说,快速排序最少需要⌊ log₂n ⌋ + 1层调用,最多需要n层调用,才可以完成整个快速排序。

刚才我们说了,

时间复杂度 = O(n * 递归层数)

空间复杂度 = O(递归层数)

所以,最好时间复杂度 = O(nlog₂n)最坏时间复杂度 = O(n²)

最好空间复杂度 = O(log₂n)最坏空间复杂度 = O(n)

在实际应用当中,快速排序算法的时间复杂度还是接近于O(nlog₂n)的。

平均时间复杂度 = O(nlog₂n)

快速排序是所有内部排序算法中平均性能最优的排序算法。

4.比较好的情况

像我们一开始举的那个例子,就属于比较好的情况。

若每一次选中的“枢轴”将待排序序列划分均匀的两个部分,则递归深度最小,算法效率最高

5.最坏的情况

若初始序列就是有序的,那么high需要不断地左移,直到与low相遇,此时才能确定当前枢轴元素在此位置。

之后的每一层划分都需要这么做。

此外,经过这样的一层划分之后,划分出的左右两个部分是很不均匀的:左边有0个元素,而右边有7个元素。

同样,之后的每一层处理,所划分出的左右两部分都是很不均匀的。

可见,若初始序列为有序或逆序,则快速排序的性能最差

因为每次选择的都是最靠边的元素,导致每次划分都很不均匀。

而比较好的情况是,每次选取的枢轴,都能够将带排序序列划分为均匀的两个部分。

6.快速排序优化

因此,我们可以将快速排序算法进行一个优化

优化思路是,尽量选择可以把数据中分的枢轴元素。

方案:

①选头、中、尾三个位置的元素,取中间值作为枢轴元素;

②随机选一个元素作为枢轴元素。

7.稳定性

对于这么一个序列。

以第一个元素作为枢轴。

由于high所指元素1<2,所以该元素移到low所指位置。

然后令low右移。

low指向2时,由于2 == 2,所以不处理,low继续右移。

low和high相遇,放入。

可见,这是不稳定的。

(四)总结

快速排序

  • 算法表现主要取决于递归深度,若每次“划分”越均匀,则递归深度越低。“划分”越不均匀,则递归深度越深。
  • 性能
    • 空间复杂度

      • 最好:O(n)
      • 最坏:O(log₂n)
    • 时间复杂度
      • 最好:O(nlog₂n)——每次划分很平均
      • 最坏:O(n²)——原本正序或逆序
      • 平均:O(nlog₂n)
    • 稳定性:不稳定

:在王道书里面,对于“一趟排序”,指的是“进行一次划分”。

而408原题当中,对于所有尚未确定最终位置的所有元素进行一遍处理,称为“一趟”排序,因此一次“划分” ≠ “一趟”排序。

也就是,在408当中,每一层QuickSort,是一趟排序。

而一次划分,只是对一个子序列进行一个Partition。

所以一次划分,只能确定一个元素的最终位置;

而一趟排序,可以确定多个元素的最终位置。

这个区别,在做习题的时候会遇到。此处先提一下。

很多教材会认为,一次划分就是一趟排序。

而对于408的考试来说,是有区别的。

六、简单选择排序

选择排序:

  • 简单选择排序
  • 堆排序

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

(一)算法思想

每一趟在待排序元素中选取关键字最小的元素加入有序子序列。

刚开始所有元素都是无序排列的。

第一趟扫描,我们会找到所有元素中最小的那个元素,显然13是最小的。

之后,我们会把最小的元素与最前面的元素进行交换。

那这样的话,接下来我们就不需要再管最前面那个位置了。

第二趟的排序,一样的。我们要从头到尾扫描各个元素,找到关键字的值最小的一个,然后把它与最前面元素交换。

这是第二趟排序。

接下来,1327的位置就确定了,不用管了,继续对后面的序列进行相同的处理。

第三趟略。

第四趟,此时最小的是49,但是有两个49。实际上,经过选择排序,我们会将第一个49交换到头部的位置。

接下来的过程略。

最后只剩下一个待排序元素。它肯定不需要再进行排序操作了。而且它一定是最大的一个。

因此,n个元素的简单选择排序需要n-1趟处理。

(二)算法实现

//简单选择排序
void SelectSort(int A[], int n) {for(int i=0; i<n-1; i++){        //一共进行n-1趟 int min = i;            //记录最小元素位置 for(int j=i+1; j<n; j++){ //在A[i...n-1]中选择最小的元素 if(A[j] < A[min])min = j;     //更新最小元素位置 }if(min != i)swap(A[i], A[min]);    //交换最小元素至最头处 }
}//交换
void swap(int &a, int &b) {int temp = a;a = b;b = temp;
}

用 i 指向当前待排序序列的最开头的位置;

然后用 j 依次扫描其中的各个元素,找到关键字的值最小的一个。

接下来把通过 j 找到的那个最小的元素,放到 i 所指的位置。

(三)算法性能分析

1.空间复杂度

空间复杂度:O(1)

空间复杂度,只是存放一些变量占用的空间。

2.时间复杂度

对于这三个序列,我们可以看出。

无论是有序、逆序,还是乱序,一定都必须经过n-1趟处理。

而且总共需要对比关键字的次数为(n-1)+(n-2)+...+1 = n(n-1)/2次。

元素交换次数 < n-1。

时间复杂度:O(n²)

3.稳定性

稳定性:不稳定。

适用性:既可以用于顺序表,也可用于链表。

(四)总结

简单选择排序

  • 算法原理

    • 每一趟在待排序元素中选取关键字最小的元素加入有序子序列
    • 必须进行总共n-1趟处理
  • 性能
    • 空间复杂度:O(1)
    • 时间复杂度:O(n²)
    • 稳定性:不稳定
    • 适用性:顺序表、链表都可以

七、堆排序

选择排序:

  • 简单选择排序
  • 堆排序

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

堆排序比较难理解,但同时也是考察频率较高的一个算法。

这种算法的实现,也是和选择排序核心思想一样的,但是它是基于一种“堆”的结构。

(一)什么是堆(Heap)

若n个关键字序列L[1…n]满足下面某一条性质,则称为堆(Heap)

①若满足:L(i) ≥ L(2i) 且 L(i) ≥ L(2i + 1),(1 ≤ i ≤ n/2)——大根堆(大顶堆)

②若满足:L(i) ≤ L(2i) 且 L(i) ≤ L(2i + 1),(1 ≤ i ≤ n/2)——小根堆(小顶堆)

只看定义,其实看不出来什么。

我们来回顾一下,二叉树的顺序存储

对于一棵二叉树,我们按照层序,将其依次存入数组中,那么对于数组下标 i ,我们可以确定各个结点的位置,以及各个结点直接的逻辑关系。

此时,再回到堆这里。

对于堆,从物理视角上来看,它是一片连续的数据元素。但是从逻辑的视角来看,它实际上是一棵完全二叉树。

编号为1的结点就是这个树的根结点。

而,下标为 i 的结点,它的左孩子下标应该是 2i,右孩子下标应该是 2i+1。

此外,当1 ≤ i ≤ n/2 时,这个结点一定是分支结点。

所以,什么是大根堆呢:完全二叉树中,根 ≥ 左、右

在一棵完全二叉树中,对于任何一个子树,它的根结点都大于左、右子树中的所有结点的值。这样的一棵完全二叉树,就是一个大根堆。

可以和BST(二叉排序树):左 ≤ 根 ≤ 右,进行一个对比。

相应的,小根堆就是:完全二叉树中,根 ≤ 左、右

此时,我们已经知道了,什么是“堆”。

接下来我们来看,如何利用堆,来实现排序。

(二)如何基于堆进行排序

首先,堆排序,它是属于选择排序中的一种。

而选择排序的基本思想是,它会在每一趟,在待排序元素中选取关键字最小(或)最大的元素加入有序子序列。

那么,如果我们有一个大根堆,那么,我们在这个大根堆当中,选取关键字最大的元素,就变得非常的方便。

对于大根堆来说,肯定是堆顶的关键字的值是最大的。

如果从数组的视角来看,那么肯定是第一个元素的值是最大的。

也就是说,对于一个数组,如果我们能够把它整理成“堆”这种形式,接着我们进行选择排序就会变得很简单了。

所以接下来我们要探讨的问题是,对于一个给定的初试序列,我们如何把它建立成大根堆。

(三)建立大根堆

对于一个给定的初试序列,我们如何把它建立成大根堆,也就是具有根 ≥ 左、右这样一种形态。

也就是,保证所有子树的根结点都比它的左右孩子更大。

既然我们要保证所有子树的根结点,都要比它的左右孩子更大。

那么我们只需要检查这棵树中所有的分支节点,因为所有的分支结点,都是它所属的子树的根结点。

因此,接下来我们要做的事情就是,把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整

之前我们也说了,对于顺序存储的完全二叉树,非终端结点编号i ≤ ⌊n/2⌋

在这个例子中,n=8,也就是我们要检查 i ≤ 4 的所有结点。

接下来,我们会从后往前地,依次来处理各个结点。

第一个被处理的,是4号结点,这个结点是所有分支结点中编号最大的一个。

我们可以利用之前学过的,完全二叉树顺序存储的特性,根据下标,来找到它的左孩子和右孩子。

  • i 的左孩子:2i
  • i 的右孩子:2i + 1
  • i 的父节点:⌊i/2⌋

对于这个根结点,它只有一个左孩子,而它的这个左孩子是比它要大的,不满足大根堆的特性,所以我们要进行调整。

调整的方式就是,将当前结点与它更大的那个孩子互换

此时就处理完毕,满足了大根堆的要求。

接下来,我们处理下一个元素,3号结点78

它的右孩子比它大,不符合大根堆的特性,所以也需要调整。

调整的方法就是,把当前结点,与它的值更大的孩子互换。即78和87互换。

处理下一个节点,2号节点17

当前结点比它的左右孩子都要更小,不符合大根堆的特性,所以需要把当前结点和它更大的那个孩子互换。即1745互换。

为什么要和更大的那个孩子互换呢?

因为,如果不把更大的那个孩子互换,换上去之后依然不符合大根堆的特性。

最后,处理1号结点53。它的右孩子要更大,所以需要把它和它的右孩子互换。

但是,现在问题发生了:

我们把更小的结点53“下坠”到下一层之后,又导致了以53为根的这棵子树不符合大根堆的要求。

怎么办呢?和之前的处理方式是一样的。我们把这个元素继续往下调整。把它和它更大的孩子交换。即将5378互换。

此时,小元素53不需要继续下坠了,即为调整完成。

53这个元素,从第一层,下坠了两次,直接落在了第三层。这个过程是一个小元素不断下坠的过程。(自己取的叫法,不是术语)

(四)建立大根堆(代码)

刚才我们用手算实现了大根堆的建立,接下来看看代码该怎么实现。

//建立大根堆
void BuildMaxHeap(int A[], int len) {for(int i=len/2; i>0; i--){        //从后往前调整所有非终端结点 HeadAdjust(A, i, len);}
}//将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len) {A[0] = A[k];     //A[0]暂存子树的根结点 for(int i=2*k; i<=len; i*=2){  //沿key较大的子节点向下筛选 if(i < len && A[i] < A[i+1])i++;      //取key较大的子节点的下标 if(A[0] >= A[i])break;      //筛选结束else{A[k] = A[i];    //将A[i]调整到双亲结点上 k = i;     //修改k值,以便继续向下筛选 } }A[k] = A[0];     //被筛选结点的值放入最终位置
}

前边几轮处理,都是较简单的处理,不再赘述。此处分析一下处理最后一个元素53时的具体过程。

其中len为整个表的长度。

if(i < len && A[i] < A[i+1])i++;      //取key较大的子节点的下标

它的意思是,由于 i = 2*k,i 指向了当前根结点的左孩子,然后比较A[i] < A[i+1],即看一下右孩子是否更大,若右孩子更大,则i++,使指针 i 指向右孩子处,此时A[i]是左右孩子中较大的那个。

至于为什么要判断 i < len,是因为,若不符合这个条件,根结点的左孩子是不存在右兄弟的。

if(A[0] >= A[i])break;      //筛选结束

由于A[0]存放的是根结点的值,若A[0] >= A[i],则是满足根比左右孩子都大的条件的。所以筛选结束,跳出这一轮循环,也就是当前这个根结点是不需要处理的。

else{A[k] = A[i]; //将A[i]调整到双亲结点上 k = i;     //修改k值,以便继续向下筛选
}

若不满足A[0] >= A[i],即左右孩子中较大的那个,比根结点的值还要大。那么就需要将A[i]换到此时的根结点A[k]。

接下来,由于我们不能确定这个值较小的根结点“下坠”后,是否满足大根堆的要求,因此需要看看它交换后,是否满足大根堆的特性。所以令 k = i,即将根结点指向刚刚的较大孩子处,再执行下一轮的for循环。

这也是为什么for循环的第三个语句是i*=2,这是为了将指针接着指向下一处左孩子。

在将53假设放在上图空白处后(为什么说是假设放在,是因为还没有最终确认53的位置),执行一次循环。发现还需要将之与其更大的孩子78互换。

也就是之前说的“小元素下坠”的一个处理。

继续 i *= 2,再次执行一次for循环,由于此时 i 的值已经超过了 len ,说明此时根结点 k 已经没有左右孩子了。(也就是不满足 i <= len了,for循环终止)

然后就可以执行A[k] = A[0];,即将最开始的那个子树根结点插入到最终得到的 k 处。

(五)基于大根堆进行排序

我们此时已经有一个大根堆了。(见上面这张图)

选择排序:每一趟在待排序元素中选取关键字最大的元素加入有序子序列。

堆排序:每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换)

我们每一趟将堆顶元素,也就是整个序列最大的元素拿出来,和末尾元素交换。

也就是把879互换,如下。

并且,从此以后87这个元素的位置就最终确定下来了,不需要改变了。(二叉树中,连接87的部分标为了虚线)

接下来,我们再进行处理,只需要对除了87外,前面这个堆进行排序。

而由于刚才9被换到了堆顶,此时显然这个堆不是一个大根堆。

所以我们需要对9这个元素进行一个“下坠”的调整。

即调用void HeadAdjust(int A[], int k, int len),注意此时len变为7了。

调整结束。此时待排序元素序列再次构成一个大根堆。

此时我们完成了第一趟的处理。

在第一趟处理中,我们把最大的元素(堆顶元素)换到了末尾,同时把剩余元素重新调整成了一个大根堆。

接下来进行第二趟的处理。

将堆顶元素与末尾元素互换,即78和53互换。同时,78确定了最终位置,往后不再考虑。

此时,由于53被换到了堆顶,不符合大根堆的特性,所以需要调整,令53不断下坠。

53需要和它的右孩子互换,且此时53比左孩子更大(此时53是没有右孩子的,因为右孩子已经被排除在外了)。因此下坠完毕。第二趟结束。

中间趟数的处理方法同理,不再赘述,直接看最后两趟。

第六趟开始时,如图所示:

将堆顶元素与堆底元素互换,即329互换。并且互换后,还需要将9下坠,再次构成大根堆,才能进行第七趟处理。

第七趟开始,如下:

堆顶元素与堆底元素互换,且堆顶元素换到堆底后不再参与处理。那么,此时只剩下最后一个待排序元素。此时就不用继续处理了。

最后,排序结果如下。

可见,我们在经过n-1趟处理之后,就得到了一个递增序列。

我们刚才是基于大根堆进行的排序,最终得到的是递增序列

如果是基于小根堆,原理是一样的,但是得到的是递减序列

(六)基于大根堆进行排序(代码)

弄清楚怎么用大根堆进行排序后,看一看如何用代码实现。

//建立大根堆
void BuildMaxHeap(int A[], int len)//将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len)//堆排序的完整逻辑
void HeapSort(int A[], int len) {BuildMaxHeap(A, len);      //初始建堆for(int i=len; i>1; i--){ //n-1趟的交换和建堆过程swap(A[i], A[1]); //堆顶元素和堆底元素交换HeadAdjust(A, 1, i-1); //把剩余的待排序元素整理成堆     }
}

首先要将一个顺序存储的数组建立为一个堆。

之后对这个建好的堆进行一次排序,并且在一次排序后,把剩下的待排序元素重新整理成堆。

(七)算法性能分析

//建立大根堆
void BuildMaxHeap(int A[], int len) {for(int i=len/2; i>0; i--){        //从后往前调整所有非终端结点 HeadAdjust(A, i, len);}
}//将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len) {A[0] = A[k];     //A[0]暂存子树的根结点 for(int i=2*k; i<=len; i*=2){  //沿key较大的子节点向下筛选 if(i < len && A[i] < A[i+1])i++;      //取key较大的子节点的下标 if(A[0] >= A[i])break;      //筛选结束else{A[k] = A[i];    //将A[i]调整到双亲结点上 k = i;     //修改k值,以便继续向下筛选 } }A[k] = A[0];     //被筛选结点的值放入最终位置
}//堆排序的完整逻辑
void HeapSort(int A[], int len) {BuildMaxHeap(A, len);      //初始建堆for(int i=len; i>1; i--){ //n-1趟的交换和建堆过程swap(A[i], A[1]); //堆顶元素和堆底元素交换HeadAdjust(A, 1, i-1); //把剩余的待排序元素整理成堆     }
}

堆排序:①建堆;②栈顶栈底互换;③重新整理成堆(下坠调整)。

对于建堆操作,其中也包含了下坠调整的操作;对于排序操作,也包含下坠调整操作。

1.建堆的过程

所以我们必须分析清楚,下坠调整的算法效率。

//建立大根堆
void BuildMaxHeap(int A[], int len) {for(int i=len/2; i>0; i--){        //从后往前调整所有非终端结点 HeadAdjust(A, i, len);}
}//将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len) {A[0] = A[k];     //A[0]暂存子树的根结点 for(int i=2*k; i<=len; i*=2){  //沿key较大的子节点向下筛选 if(i < len && A[i] < A[i+1])i++;      //取key较大的子节点的下标 if(A[0] >= A[i])break;      //筛选结束else{A[k] = A[i];    //将A[i]调整到双亲结点上 k = i;     //修改k值,以便继续向下筛选 } }A[k] = A[0];     //被筛选结点的值放入最终位置
}

对于根结点 k 来说,每层for循环,需要进行一次对比操作(A[i] < A[i+1]),即对比其左右孩子谁更大;之后再进行一次对比(A[0] >= A[i]),即对比根结点是否比更大孩子的值大。

所以对于一个有两个孩子的根结点,它每往下下坠一层,都要进行2次关键字的对比。

若某个根结点只有左孩子、没有右孩子,则说明它的左孩子下标刚好等于表长,那么就不满足i < len的条件,因此不需要对比左右孩子的关键字。

所以对于一个只有一个孩子的根结点,下坠一层只需对比1次关键字。

结论

一个结点,每“下坠”一层,最多只需对比关键字2次。

若树高为h,某结点在第 i 层,则将这个结点向下调整,最多只需要“下坠” h-i 层,关键字对比次数不超过 2(h-i)

n个结点的完全二叉树,它的树高 h = ⌊log₂n⌋ + 1

第 i 层最多有2^(i-1)个结点,而只有第1~(h-1)层的结点才有可能需要“下坠”调整。

第一层:1个结点,需要进行20∗(h−1)次下坠第二层:21个结点,需要进行21∗(h−2)次下坠……第(h−1)层:2h−2个结点,需要进行2h−2∗1次下坠第一层:1个结点,需要进行2^0*(h-1)次下坠\\ 第二层:2^1个结点,需要进行2^1*(h-2)次下坠\\ ……\\ 第(h-1)层:2^{h-2}个结点,需要进行2^{h-2}*1次下坠 第一层:1个结点,需要进行20∗(h−1)次下坠第二层:21个结点,需要进行21∗(h−2)次下坠……第(h−1)层:2h−2个结点,需要进行2h−2∗1次下坠

所以,将整棵树调整为大根堆,每一层的下坠次数之和,再乘2,就是需要对比关键字的最大次数。
将整棵树调整为大根堆,关键字对比次数不超过:∑i=h−112i−12(h−i)=∑i=h−112i(h−i)=∑j=1h−12h−jj≤2n∑j=1h−1j2j≤4n(其中,h=⌊log2n⌋+1,代入)将整棵树调整为大根堆,关键字对比次数不超过:\\ \sum_{i=h-1}^{1}2^{i-1}2(h-i)=\sum_{i=h-1}^{1}2^i(h-i)=\sum_{j=1}^{h-1}2^{h-j}j≤2n\sum_{j=1}^{h-1}\frac{j}{2^j}≤4n\\ (其中,h = \lfloor log_2n \rfloor+1,代入) 将整棵树调整为大根堆,关键字对比次数不超过:i=h−1∑1​2i−12(h−i)=i=h−1∑1​2i(h−i)=j=1∑h−1​2h−jj≤2nj=1∑h−1​2jj​≤4n(其中,h=⌊log2​n⌋+1,代入)

对于化简到最后一步的和式(即4n前面那个),它是一个差比数列求和,用到的是错位相减法。(高考考的经典题型)

建堆的过程,关键字对比次数不超过4n,建堆时间复杂度 = O(n)

如果想偷懒的话,前面推理的过程可以不用记,把结论记住也可以。

2.排序的过程

//堆排序的完整逻辑
void HeapSort(int A[], int len) {BuildMaxHeap(A, len);      //初始建堆for(int i=len; i>1; i--){ //n-1趟的交换和建堆过程swap(A[i], A[1]); //堆顶元素和堆底元素交换HeadAdjust(A, 1, i-1); //把剩余的待排序元素整理成堆     }
}

我们已经知道,建堆的过程,即BuildMaxHeap(A, len);,其时间复杂度为O(n)。

接下来看排序的过程。

排序的过程,总共需要n-1趟,而每一趟交换后都需要将根结点“下坠”调整。

这个”下坠“,最多只会下坠h-1层,而每下坠一层,最多只需对比关键字2次。

因此每一趟排序,时间复杂度不超过O(h) = O(log₂n)

共n-1趟,总的时间复杂度 = O(nlog₂n)

3.堆排序结论

因此,一次完整的堆排序,我们需要O(n)的时间来建堆,还需要O(nlog₂n)的时间来进行排序。

堆排序的时间复杂度 = O(n) + O(nlog₂n) = O(nlog₂n)

堆排序的空间复杂度 = O(1)。

4.稳定性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e7sts6Bi-1652890823743)(C:\Users\11202\AppData\Roaming\Typora\typora-user-images\1652889970460.png)]

对于这个初始序列,我们要建立大根堆。

由于根结点1没有左右孩子大,所以需要调整,将孩子中更大的那个换上来。

但是左右孩子一样大,那么把哪个换上来呢?

看一下这个代码的逻辑:

//建立大根堆
void BuildMaxHeap(int A[], int len) {for(int i=len/2; i>0; i--){        //从后往前调整所有非终端结点 HeadAdjust(A, i, len);}
}//将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len) {A[0] = A[k];     //A[0]暂存子树的根结点 for(int i=2*k; i<=len; i*=2){  //沿key较大的子节点向下筛选 if(i < len && A[i] < A[i+1])i++;      //取key较大的子节点的下标 if(A[0] >= A[i])break;      //筛选结束else{A[k] = A[i];    //将A[i]调整到双亲结点上 k = i;     //修改k值,以便继续向下筛选 } }A[k] = A[0];     //被筛选结点的值放入最终位置
}

注意其中的判断条件:

if(i < len && A[i] < A[i+1])i++;        //取key较大的子节点的下标

i 这个指针首先指向左孩子,i+1 指向右孩子。

那么,按照这个代码逻辑,只有右孩子的值比左孩子的值更大的时候,我们才会让 i 指向右孩子。

也就是说,如果左孩子和右孩子相等的话,我们不会让 i++,更大的孩子仍是左孩子。

若左右孩子一样大,则优先和左孩子交换。

此时我们已经完成了建堆的工作。

接下来需要进行堆排序。

//堆排序的完整逻辑
void HeapSort(int A[], int len) {BuildMaxHeap(A, len);      //初始建堆for(int i=len; i>1; i--){ //n-1趟的交换和建堆过程swap(A[i], A[1]); //堆顶元素和堆底元素交换HeadAdjust(A, 1, i-1); //把剩余的待排序元素整理成堆     }
}

把堆顶元素与堆底元素交换。且此时剩余的元素依然是一个大根堆,不需要调整。

直接进行第二趟排序。同样地,堆顶元素与堆底元素互换。

现在已经进行了n-1趟的排序,而且显然也已经排序结束了。

由于初始序列为:

到此,我们可以得出结论,堆排序是不稳定的。

(八)总结

堆排序

    • 顺序存储的“完全二叉树”

      • 结点 i 的左孩子是 2i;右孩子是 2i+1;父节点是 i/2
      • 编号 ≤ n/2 的结点都是分支结点
    • 大根堆(根 ≥ 左、右);小根堆(根 ≤ 左、右)
  • 算法思想(以大根堆为例)
    • 建堆

      • 编号 ≤ n/2 的所有结点依次“下坠”调整(自底向上处理各分支结点)
      • 调整规则:小元素逐层“下坠”(与关键字更大的孩子交换)
    • 排序
      • 将堆顶元素加入有序子序列(堆顶元素与堆底元素交换)
      • 堆底元素换到堆顶后,需要进行“下坠”调整,恢复“大根堆”的特性
      • 上述过程重复n-1趟
  • 特性
    • 空间复杂度:O(1)
    • 时间复杂度:建堆O(n)、排序O(nlog₂n);总的时间复杂度 = O(nlog₂n)
    • 稳定性:不稳定
    • 基于大根堆的堆排序得到的是“递增序列”;基于小根堆的堆排序得到的是“递减序列”

内容确实比较多,也不算好理解的。

考试喜欢考堆排序,因为它顺道还考察了很多二叉树的知识。

所以对堆排序的理解,是很考验综合能力的。

(九)练习:基于“小根堆”如何建堆、排序?

自己手动练习。

八、堆的插入删除

(一)在堆中插入新元素

给你一个大根堆或小根堆,你要往这个堆里面插入一个新的元素或者删除元素,应该怎么做呢?

假设我们新插入的元素为13

首先,这个新插入的元素我们把它放到表尾的位置。

从逻辑视角来看,我们把它放到了堆底。

原本我们是一个小根堆,但是在插入了新元素13之后,就破坏了原来的小根堆。小根堆要求,根结点要小于等于左、右孩子。

那怎么办呢?

我们需要做的是,把这个元素与它的父节点相比,如果新元素更小,则将二者互换。

我们的新元素在表尾的位置,而找到它的父节点很简单。

  • i 的左孩子——2i
  • i 的右孩子——2i+1
  • i 的父节点——⌊i / 2⌋

新结点13存放下标为9,而⌊9/2⌋ = 4,因此它的父节点存放位置为4,即32这个元素。

找到父节点一比较,发现13 < 32,13比它的父节点更小,所以要将13上升一层。

到了这层,我们还不能停止,还要继续与它的父节点进行比较。13同样比它的父节点17更小,所以同样也需要把它俩互换,让13继续上升

到了这一步,同样需要把13和它的父节点9进行对比。而13>9,所以到这一步时,就已经符合了小根堆的特性要求,所以13无法继续上升。

对于小根堆,新元素放在表尾,与父节点相比,若新元素比父节点更小,则二者互换。新元素就这样一路“上升”,直到无法继续上升为止。

对比关键字的次数:3次。

接下来,再插入一个新元素46

新元素放在表尾,也就是堆底的位置。然后尝试着让46上升。

发现46的父节点45更小,因此是无法上升的。所以46待在此处就可以。

对比关键字的次数:1次。

(二)在堆中删除元素

假设我们要删除的元素是13这个元素。

那么,把13删除之后。

这个位置就空了。怎么处理呢?

我们会用堆底的元素来代替这个被删除的元素。

即,让46移到之前13存储的位置。

现在,为了让这个整体,恢复成小根堆该有的特性,所以我们需要做的事情,是让46这个元素不断下坠,直到无法下坠为止。

由于小根堆要求更小的元素在上面,所以我们要把46的更小的孩子和它交换。也就是将46和17互换。

到这一步后,还要和它的下一层孩子进行对比,使更小的孩子和它互换。

到此,46已经无法继续下坠了。

对比关键字的次数:4次。(对比两个孩子谁更小、对比父节点和较小的孩子谁更小,各算一次对比)

其实考试还是会经常考察,问你“对比关键字的次数”的。

上一节当中,我们说过,如果一个父节点下面有两个孩子,则需要进行关键字的对比2次,一次是两个孩子的对比,一次是根结点和孩子结点的对比。

而如果说,下方只有一个孩子,则无需进行两个孩子的对比,而只需对比1次关键字。

因此,在考虑下坠过程中,关键字的对比次数的问题,这个细节一定要注意。

接下来,如果我们继续删除,删除65这个元素。

删除之后,同样地,我们会将栈底元素去替代它。

会让46挪到下标为3的位置。

接下来,会让46进行下坠的调整。

但是会发现,46下面的两个关键字都要比它更大,所以46已经无法再下坠了。

对比关键字的次数:2次。(第一次,是将左右孩子进行对比,选出一个更小的孩子;第二次,是将根结点与更小的孩子进行对比)

(三)总结

  • 插入

    • 新元素放到表尾(堆底)
    • 根据大/小根堆的要求,新元素不断“上升”,直到无法继续上升为止
  • 删除
    • 被删除元素用表尾(堆底)元素替代
    • 根据大/小根堆的要求,替代元素不断“下坠”,直到无法继续下坠为止
  • 关键字对比次数
    • 每次“上升”调整只需对比关键字1次
    • 每次“下坠”调整可能需要对比关键字2次,也可能只需对比1次
  • 基本操作
    • i 的左孩子 —— 2i
    • i 的右孩子 —— 2i+1
    • i 的父节点 —— ⌊ i / 2 ⌋

九、归并排序(Merge Sort)

(一)什么是归并/合并(Merge)

归并:把两个多个已经有序的序列合并成一个。

现在,左边这个数组和右边这个数组,都是有序的,现在我们要把它合并成一个。

首先我们肯定得定义一个更大的数组,才能把所有的这些元素都存放在一起。

接下来要做的事情是,我们可以设置这样的三个指针。

接下来,我们每一次会对比 i、j 所指元素,选择一个更小的放入 k 所指位置

此时,i、j 所指元素较小的是7,于是将j 所指元素7放入 k 所指位置。之后,将j、k向后移动一位。

此时,i、j 所指元素较小的是10,所以把10放入,之后将j、k向后移。

之后,12更小,所以将12放入,之后将i、k向后移。

…………

此时,i、j 所指的元素,都是24

如果是我们自己写代码的话,可以在代码里设计,让 i 所指的元素优先放入,也可以设计让 j 所指的元素优先放入。

这个地方,我们让 i 所指的优先。

所以会把 i 所指的24先放进来。

下一步,对比,把 j 所指的24放进来。

…………

到了这一步,j 指针已经超出了它所对应的数组的范围了。也就是说明右边这个数组中所有的元素,我们都已经进行了合并。

那现在,只有左边这个数组,是还没有合并完毕所有元素的。所以接下来我们就不再需要进行关键字的对比,我们可以直接把其中剩余元素全部加入总表。

到此为止,我们就完成了两个有序序列的归并。

(二)“2路”归并

什么叫2路归并呢?

就是我们刚刚所做的这个过程。

我们把两个有序的序列合二为一,这就是2路归并。

“2路”——二合一。

“2路”归并 —— 每选出一个小元素需对比关键字1次。

(三)“4路”归并

既然有2路归并,当然也可以有多路归并

比如说“4路”归并。

“4路” —— 四合一。

我们给出了4个有序的数组,我们将它们四合一。

“4路”归并 —— 每选出一个小元素需对比关键字3次。

结论m路归并,每选出一个元素需要对比关键字m-1次

(四)归并排序(手算)

在内部排序中,我们对于归并排序算法,一般采用的是2路归并。

每相邻的两两进行归并。

给定一个初始序列。我们会把它看作一个、一个的,独立的,排好序的部分,每部分含有1个元素。

接下来,我们会对相邻的两个部分进行2路归并。

比如说,0和1两处的元素,进行2路归并。接下来,2和3进行归并;4和5进行归并。

而6处的这个元素,它是单个的,所以对它的处理相当于什么也没做。

这是第一趟归并排序。

之后,第二趟归并排序,会基于第一趟归并的结果,再次地对它们归并。

可见第一部分(0、1)已经有序,第二部分(2、3)也已经有序。把这两个有序的子序列归并成一个。第三、四部分同理。

这是第二趟归并排序。

此时,我们得到了2个已经有序的子序列。最后再进行一次归并就可以了,就能得到整体有序的一个序列。

整个过程如下所示。

核心操作:

把数组内的两个有序序列归并成一个。

(五)代码实现

int *B = (int *)malloc(n * sizeof(int));    //辅助数组B//A[low...mid]和A[mid+1...high]各自有序,将两个部分归并
void Merge(int A[], int low, int mid, int high){int i,j,k;for(k=low; k<=high; k++){B[k] = A[k]; //将A中所有元素都复制到B中 }for(i=low,j=mid+1,k=i; i<=mid && j<=high; k++){if(B[i] <= B[j])A[k] = B[i++]; //将较小值复制到A中 elseA[k] = B[j++];}while(i<=mid){A[k++] = B[i++];} while(j<=high){A[k++] = B[j++];}
}

对于两个有序子序列来说(如上图3-6已经是有序的、7-9已经是有序的),我们会将low指针指向最前面的元素,然后让mid指向第一个有序子序列的最后一个元素,让high指针指向第二个子序列的最后一个元素。

这样,我们就可以用low、mid、high,区分出两个要归并的有序子序列的范围。

而且这两个子序列是相邻的。

那么,这就契合了我们之前对于归并排序手算过程中,提出的需求(核心操作),即每次归并,都是要归并两两相邻的子序列。

此代码当中,我们

if(B[i] <= B[j])A[k] = B[i++];    //将较小值复制到A中
elseA[k] = B[j++];

即两数相等时,我们也将 i 所指的元素优先放入,而 i 是靠前的那个元素。保证了稳定性

以上是归并排序的核心操作。

接下来看归并排序的完整代码:

int *B = (int *)malloc(n * sizeof(int));    //辅助数组B//A[low...mid]和A[mid+1...high]各自有序,将两个部分归并
void Merge(int A[], int low, int mid, int high){int i,j,k;for(k=low; k<=high; k++){B[k] = A[k]; //将A中所有元素都复制到B中 }for(i=low,j=mid+1,k=i; i<=mid && j<=high; k++){if(B[i] <= B[j])A[k] = B[i++]; //将较小值复制到A中 elseA[k] = B[j++];}while(i<=mid){A[k++] = B[i++];} while(j<=high){A[k++] = B[j++];}
} void MergeSort(int A[], int low, int high){if(low < high){int mid = (low+high)/2;    //从中间划分MergeSort(A, low, mid);  //对左半部分归并排序MergeSort(A, mid+1, high);  //对右半部分归并排序Merge(A, low, mid, high);    //归并 }
}

对于一个待排序序列,我们首先将low和high指向其头尾,然后让mid指向它中间部分。

然后对其左半部分、右半部分,递归地进行归并排序。

递归地进行,就是说。

对于其左半部分、右半部分,又分别要进行它们各自的左右两部分。

一层套一层。直到不满足low<high的条件,即达到递归的最深层。即每部分只有1个元素的时候。

当其左半部分、右半部分变得有序后,

我们再对左右这两个子序列进行归并即可。

(六)算法效率分析

刚才我们2路归并的这个过程,从形态上来看,就像一棵倒立的二叉树。

因此也叫归并树

把它看作一棵二叉树,接下来我们就可以用二叉树的相关知识进行分析。

树高h,则进行归并的趟数就是h-1趟。

而二叉树的第h层最多有2^(h-1)个结点,所以,若树高h,则应满足n ≤ 2^(h-1) 。因为我们整个序列中的n个结点,都只会出现在二叉树的第h层。(即“初始序列”那一层)

h-1 = ⌈ log₂n ⌉

而刚刚我们说了,对于树高为h的2路归并排序,需要归并的趟数是h-1,也就是需要归并的趟数是⌈ log₂n ⌉。

而每一趟的归并,时间复杂度都是O(n)。

每对比关键字一次,都会挑出一个当前剩余的最小的关键字,所以对比的次数 ≤ n-1。

因此,算法的时间复杂度为O(nlog₂n)

空间复杂度为O(n)。其空间复杂度主要来源于辅助数组B。而辅助数组B的大小,和原本数组A的大小是一样的,因此是O(n)。

问题:既然归并排序是用递归算法实现的。

那么对于空间复杂度来说,是不是还要考虑递归工作栈所使用的空间呢?

的确。

但是,我们递归调用的深度,是不会超过log₂n的(归并的趟数),因此递归工作栈所带来的空间复杂度为O(log₂n),它与辅助数组带来的空间复杂度相比,我们只需要保留O(n)。

稳定性:稳定。

(七)总结

归并排序

  • Merge(归并)

    • 把两个或多个有序的子序列合并为一个
    • 2路归并 —— 二合一
    • k路归并 —— k合一
  • 归并排序算法
    • ①若low<high,则将序列从中间mid=(low+high)/2分开
    • ②对左半部分[low, mid]递归地进行归并排序
    • ③对右半部分[mid+1, high]递归地进行归并排序
    • ④将左右两个有序子序列Merge为一个
  • 性能
    • 空间复杂度:O(n)
    • 时间复杂度:O(nlog₂n)
    • 稳定性:稳定

对于内部排序,我们的归并排序一般使用的是2路排序。

对于外部排序,我们的归并排序可能还会用到多路排序,这个我们到外部排序的地方再说。

十、基数排序(Radix Sort)

比较神奇的排序算法。

之所以说它比较神奇,是因为之前的排序算法都比较了关键字的大小,而基数排序并没有比较关键字的大小。

来看一下基数排序是怎么做的。

(一)算法思想

假设有这么一个序列,我们要用基数排序将它排成一个递减的序列。

对于其中的所有元素,我们会发现,都可以分为三个部分:“个位”、“十位”、“百位”。而且,这三个部分,每个部分有可能的取值只有0~9

所以我们就建立一个辅助队列,分别对应0~9

接下来,进行我们的第一趟处理

第一趟”分配“

我们会以“个位”进行分配。

由于第一个元素520,它的个位是0,因此会被我们放在Q0当中。

下一个元素是211,个位是1,因此放到Q0这个队列当中。

如果两个数字的个位一样,由于每次放入,都是放到队列的队尾,因此先后次序不难区分。

中间过程省略,最后,第一趟分配结束,结果如下。

接下来,我们需要进行一个操作,就是“收集”。我们要把各个队列中的元素收集起来,把它们连成一个统一的链表。

第一趟“收集”

由于我们最后想要得到一个“递减”的序列,所以我们要从个位数最大的地方开始收集。也就是从Q9Q0依次进行收集。

依次将每个队列中的元素拆下来,并且让靠近队头的元素放在前面,靠近队尾的元素放在后面。

中间过程省略,最后,第一趟收集结束,结果如下:

由于我们刚才是按照“个位”进行“分配”的,所以按照这种方式,我们“收集”到的是按“个位”递减的序列。

以上总共是第一趟的处理。接下来我们进行第二趟的处理。

第二趟“分配”:以“十位”进行“分配”。

第一个数438的十位是3,所以放到Q3队列中,…………

在分配的过程中,我们会发现。

由于此处对于“十位”的分配,是基于个位已经分配并收集好之后进行的。

所以对于十位数相同的元素,个位数更大的会排在前面,个位数更小的会排在后面。

也就是,十位数相同的元素,个位数更大的先入队,个位数更小的后入队。

中间过程略,第二趟分配结果如下。

接下来进行第二趟“收集”

同理,我们从十位更大的位置开始收集,这样能够保证十位数更大的元素是排在链表的前面的。而且每个队列的队头在前,队尾在后。

基于第二趟处理的结果。我们第三趟会基于它,进行又一趟的分配和收集。

第三趟“分配“:以”百位“进行”分配“

并且,由于我们是基于第二趟处理的结果。

所以,如果有百位相同的元素,则十位越大的会越先入队。

中间过程略,最终第三趟“分配”结果如图所示。

接下来要进行第三趟“收集”

先收集Q9队列,且队头排在前面、队尾排在后面,依次收集。

最终会得到一个按“百位”递减的序列。

而且,对于百位相同的元素,我们是按十位递减的顺序排列的,而对于十位还相同的元素,我们又是按个位递减的顺序排列的。

整个过程如下所示

我们再对基数排序的过程用文字的方式,完整的总结一遍,如下:
假设长度为n的线性表中每个结点aj的关键字由d元组(kjd−1,kjd−2,kjd−3,...,kj1,kj0)组成其中,0≤kji≤r−1(0≤j<n,0≤i≤d−1),r称为“基数”假设长度为n的线性表中每个结点a_j的关键字由d元组(k_j^{d-1},k_j^{d-2},k_j^{d-3},...,k_j^1,k_j^0)组成\\ 其中,0≤k_j^i≤r-1(0≤j<n,0≤i≤d-1),r称为“基数” 假设长度为n的线性表中每个结点aj​的关键字由d元组(kjd−1​,kjd−2​,kjd−3​,...,kj1​,kj0​)组成其中,0≤kji​≤r−1(0≤j<n,0≤i≤d−1),r称为“基数”

每个节点把它分为“d元组”。像刚才那个例子,我们就是把每个结点分为了“三元组”:个位、十位、百位。

d元组中,最靠近左边的,称为最高位关键字(最主位关键字);最靠近右边的,称为最低位关键字(最次位关键字)。像刚才那个例子,百位就是最高位关键字,个位就是最低位关键字。

(因为最左边的关键字是对整个数值大小影响最大的,所以把它称为最高位关键字;同理,最低位关键字)

而每一位可能的取值,是0到r-1,r称为“基数”。像刚才的例子中,我们可能的取值是0~9,其基数就应该是10,即关键字的每个部分都有可能得到10种不同的取值。

基数排序得到递减序列的过程如下:

初始化:设置r个空队列Q_(r-1),Q_(r-2),...,Q_0

按照各个 关键字位 权重递增的次序(个、十、百),对d个关键字位分别做“分配”和“收集“。

分配:顺序扫描各个元素,若当前处理的关键字位 = x,则将元素插入Q_x队尾。

收集:把Q_(r-1),Q_(r-2),...,Q_0各个队列中的结点依次出队并链接。

基数排序通常只考察手算的过程,几乎不考代码。

所以只要理解,能用手动的方式模拟其过程即可。

可见,基数排序并不是基于“比较”的排序算法。而我们学习的其他的那些排序都是基于比较的排序算法。

以上是递减的序列,那么得到一个递增序列的过程,也是同理的,如下:

基数排序得到递增序列的过程如下:

初始化:设置r个空队列Q_0,Q_1,...,Q_(r-1)

按照各个 关键字位 权重递增的次序(个、十、百),对d个关键字位分别做“分配”和“收集“。

分配:顺序扫描各个元素,若当前处理的关键字位 = x,则将元素插入Q_x队尾。

收集:把Q_0,Q_1,...,Q_(r-1)各个队列中的结点依次出队并链接。

(二)算法性能分析

基数排序通常是基于链式存储实现的。

typedef struct LinkNode {ElemType data;struct LinkNode *next;
}LinkNode, *LinkList;

接着,我们还定义了十个队列。而且这十个队列链接的是单链表中的一个一个的结点,所以这些队列又都是链队列。

typedef struct { //链式队列LinkNode *front, *rear;   //队列的队头和队尾指针
}LinkQueue;

队头指针指向最上面那个元素,队尾指针指向最下面那个元素。

可见,基数排序的背后,都是之前所学过的很熟悉的东西。

1.空间复杂度

基数排序的空间复杂度方面,主要是需要r个辅助队列,因为我们每设立一个辅助队列,实际上就是新增了LinkNode *front, *rear;这两个指针域,每个队列的空间复杂度是O(1)的数量级。

因此空间复杂度 = O®

r的含义是,每个关键字有可能有多少种不同的取值。需要相应的设立r个辅助队列。

2.时间复杂度

我们算法总共执行的过程,是进行了d趟的分配、收集。

d的含义是,我们的关键字可以被拆分成几个部分。

每一趟的分配,就是把整个链表从头到尾扫描了一遍。总共有n个元素,那么把它们都扫一遍,总共需要O(n)的时间。

而一趟收集,只需要O®这么多的时间。因为总共有r个队列,收集的过程就是依次扫描这些队列,把每个队列当中的这些元素给拆下来,然后连到我们最终收集的那个链表当中。每收集一个队列,只需要O(1)的时间复杂度。

例如,当前的收集表,p指针指向收集表的最后一个元素。

此时需要将Q_6队列中的元素拆下来,连到链表最后。

p->next = Q[6].front;
Q[6].front = NULL;
Q[6].rear = NULL;

我们若要将某个队列中的元素拆下来,并且连到收集的链表上,只需要如上操作。

因为实际上对于每个队列中串起来的元素来说,他们之间也是用*next链接起来的链表。

所以只需要将front拆下来,连到链尾即可,之后将队列头尾指针置空。

因此,对于每个队列的收集,只需要O(1)的时间。

而一趟收集r个队列,所以一趟收集的时间复杂度为O®。

由于我们每一趟处理,包含了这一趟的分配+收集,而我们总共有d趟处理。

因此总的时间复杂度为O(d(n+r))

3.稳定性

假设由这么一个序列,其中有两个元素的关键字是相同的。

我们在分配的时候,是从左往右依次扫描初始序列中的各个元素。所以第一个被“分配”的,应该是左边的那个12(不带下划线的12)。

之后,不论这两个12之间还有多少其他的个位为2的元素,总之这两个12的相对位置是确定的。

而,当我们把这个队列,进行“收集“的时候。

肯定也是没有下划线的12排在前面,有下划线的排在后面。

所以这个算法,显然是稳定的。

基数排序是稳定的。(鸡你太稳)

(三)基数排序的应用

某学校有10000名学生,将学生信息按年龄递减排序。

可以将学生的生日信息,拆分为三组关键字:年(1991-2005)、月(1-12)、日(1-31)。

年有可能取得15个值,月有可能取得12个值,日有可能取得31个值。

那么,生日的这三组信息,肯定是:年的影响,大于月,大于日。

即,权重:年 > 月 > 日

所以按照基数排序的思想,我们进行“分配”和“收集“,是先按照日,再按照月,再按照年,这种权重递增的次序进行分配和收集的。

所以第一趟分配,我们可以按”日“的信息来处理:

而由于我们最终是要得到一个年龄递减的次序,那么日越大的,年龄越小,所以我们在收集这些信息的时候,先收集日更小的,再收集日更大的。

经过这一趟处理,我们就得到了这10000个学生,按照“日”递增的一个次序。“日”越大的越靠后。

第二趟分配,我们会按照“月”的信息来处理:

同样地,“月”更大的,年龄越小,所以,我们先收集月份更小的,再收集月份更大的。

第三趟分配,按照“年”的信息进行处理:

同理。

按照这三趟分配之后,我们就能得到这10000个学生,年龄按照递减的排序。

通过这个例子,可以说明,我们的基数排序,其实并不像课本当中举的例子那样,每一个关键字的取值都是固定的0~r-1这样一个范围。

其实像这个例子当中,我们每一个关键字的部分,它的取值有可能出现各种各样的情况。(如,年:1991-2005)。

对于这个例子当中,我们分析一下它的时间复杂度是怎么样的。

由于基数排序时间复杂度 = O(d(n+r))

我们此处,把每个学生的信息分为了三个关键字:年、月、日。因此d为3。

n = 10000,因为总共有10000个学生。

r,每一趟分配时是不一样的。第一趟是31,第二趟是12,第三趟是15。那在这三趟当中,r的值最大也就是31。所以我们按最坏的情况来考虑,就是r = 31。

d,n,r代入,可得时间为3*(10000+31),约为O(30000)。

而如果采用之前的那些算法,有的算法是O(n²)的时间复杂度,那么就约为O(10^8);

有的是O(nlog₂n),那么就约为O(140000)。

可以看到,在这种场景下,我们基数排序的时间复杂度,比之前所有算法都要优秀不少。

所以,基数排序擅长解决的问题:

①数据元素的关键字可以方便地拆分为d组,且d较小;

②每组关键字的取值范围不大,即r较小;

③数据元素个数n较大。

对于①的反例:给5个人的身份证号排序。

18位身份证号需要分配、回收18趟。而我们只有5个人的元素,即n很小,而d很大。所以此时效率是很低的。

对于②的反例:给中文人名排序。

中国人的名字,一般是两到三个字,也有四个字的,总之是d比较小的。但是我们的r,即每一位上有可能出现的汉字字符的取法,就太庞大了。所以,即使对于人名,最多只有4个分组,但是每个分组中有可能的取值有上万种,即r的值很大。相应的,空间复杂度就会很高,时间复杂度也不低,因此效率很低。

对于③的反例:还是,给5个人的身份证号排序的问题。

对于③的正例:给十亿人的身份证号排序。

(四)总结

基数排序

  • 算法思想

    • 将整个关键字拆分为d位(或“组”)。
    • 按照各个关键字位权重递增的次序(如:个、十、百),做d趟“分配”和“收集“,若当前处理的关键字位可能取得r个值,则需要建立r个队列。
    • 分配:顺序扫描各个元素,根据当前处理的关键字位,将元素插入相应队列。一趟分配耗时O(n)。
    • 收集:把各个队列中的结点依次出队并链接。一趟收集耗时O®。
  • 性能
    • 空间复杂度:O®
    • 时间复杂度:O(d(n+r))
    • 稳定性:稳定
  • 擅长处理
    • ①数据元素的关键字可以方便地拆分为d组,且d较小
    • ②每组关键字的取值范围不大,即r较小
    • ③数据元素个数n较大

十一、外部排序

外部排序

  • 外存与内存之间的数据交换
  • 外部排序的原理
  • 影响外部排序效率的因素
  • 优化思路

(一)外存、内存之间的数据交换

此处的外存特指“磁盘”。而磁盘是以所谓的“磁盘块”为单位的。

操作系统也是以这些“磁盘块”,对磁盘的存储空间进行管理。

每个磁盘块的大小,比如1KB,比如4KB。此处以1KB为例。

数据信息可以存放在各个磁盘块上。

现在,比如磁盘块4、磁盘块11,里面存放了一些数据。

问题:如果要修改磁盘里面存储的数据,应该怎么办?

我们要修改磁盘里的数据,需要做的事,就是把对应磁盘块里的数据,读到内存里。

比如说,我们要在内存里申请开辟一块1KB的缓冲区。这样一个缓冲区的大小,是可以和一个磁盘块的大小保持一致的。

接下来,我们就可以把磁盘块4当中的数据,读到内存当中。

磁盘的读/写,都是以”块“为单位进行的。也就是说每次都读一块、写一块。

数据已经读入内存了,接下来我们就可以用程序代码,对内存里的数据进行修改。

现在,我们只是把内存里面的数据修改了,如果要更改磁盘块的数据,那么还需要把这些数据写回磁盘。同样地,还是以“块”为单位,进行“写”操作的。

所以我们可以把这1KB内存缓冲区中的内容,写回磁盘块4。当然,也可以把它写到其他的磁盘块,比如同时也写到磁盘块11中。

这就是外存和内存之间数据交换的原理。每次是以“块”为单位进行读写的。

(二)外部排序原理

外部排序是指,我们的这些数据元素是存放在外存,即存放在磁盘的。

由于磁盘的容量很大,而内存的容量很小。

所以很多时候我们没有办法,把磁盘当中的所有数据元素都给读入内存,内存存不下这么多。

所以我们要对存在外存中的这些数据进行排序,这就是所谓的外部排序。

接下来看如何实现外部排序。

外部排序实现的思想,其实还是来源于之前的“归并排序”。采用这种方式,我们最少只需要在内存中分配3块大小的缓冲区,就可以完成对任意大小的文件进行排序。

其中,这几个缓冲区的大小,是和磁盘块的大小保持一致的。

此处,我们为了演示方便,磁盘中这个文件大小总共包括了16块数据块,而每个数据块中包括了3个记录。

现在,我们要对整个文件中的所有记录中的关键字,用归并排序的方式,把它变成一个递增的序列。

回顾一下归并排序,在进行每一趟归并时,需要把两个已经有序的子序列,合并成一个更长的子序列。

所以在归并排序开始之前,我们需要构造一些已经有序的子序列。

1.构造初始“归并段”

可以这么做:

由于我们内存当中已经有两块输入缓冲区。我们可以把文件中的第1、2块数据,分别读入内存。

那么,这些数据一旦被我们读入内存之后,想怎么处理它,都可以。所以我们对于读入内存中的这些数据,进行一个内部排序。

就将这两块的内容各自都变成了一个递增的状态。

接下来,先把输入缓冲区1的内容,先放到输出缓冲区当中。

通过输出缓冲区,再把这些数据写回磁盘。由于缓冲区的大小和磁盘块的大小是一致的,所以可以写回。

我们把8 9 26写回第一个磁盘块。

接下来,再把输入缓冲区2的内容,放到输出缓冲区,写到第二个磁盘块当中。

那么现在,第一个磁盘块和第二个磁盘块里面的这些记录,就变成了一个递增的有序状态。

之后我们就可以将这两块的整体内容,作为一个有序的子序列,进行归并排序。这就叫一个有序的“归并段”。

之后同样地,我们再把磁盘块3、磁盘块4中的内容,读入内存。

在内存里,对它们进行一个内部排序。然后再将它们写回磁盘。

于是我们就得到了第二个归并段。

之后同理,过程略。

最终,我们将16个磁盘块,构造成了8个初始归并段。

其中,经过了16次“读”和16次“写”。

2.第一趟归并

接下来,我们可以用刚刚构造的初始归并段,进行接下来的排序。

把8个有序子序列(归并段)两两归并。

接下来,我们会对每两个归并段,进行2路归并。

我们先把归并段1、归并段2中更小的那两块,分别读入内存,放到输入缓冲区1、缓冲区2。

接下来,对这两个缓冲区中的数据进行归并。即利用三个指针i、j、k,将更小的元素依次放入输出缓冲区。

不要忘了,我们的输出缓冲区和磁盘块的大小是相等的,都是1KB,并且每次读和写都只能读写1KB。

那当我们在输出缓冲区当中凑足1KB之后,就可以写回外存了。

所以现在我们可以把这一块的内容,给写回外存。

此时输出缓冲区已经清空了,我们对剩下的元素同样进行归并排序。

注意,当缓冲区1空了的时候,就要立即用归并段1的后一块内容补上。

这么做可以保证,我们的输入缓冲区1当中,永远是包含着归并段1里面,此时暂时还没有被归并,但是是数值最小的一个记录。

下面继续让两个缓冲区进行归并。

现在,输出缓冲区又满了,于是又可以写回外存。

同时,现在输入缓冲区2里面已经空了,我们需要立即用归并段2里面的下一块内容补上。

之后,经过归并。

再写回外存。

到此为止,我们的两个归并段,就归并成为了一个更长、更大的段。

为了美观一些,我们把其中的数据,在图上放回原来的位置,进行展示。

之后,我们会把后续的每两个初始归并段,均归并为一个更长的有序序列。方法是一样的。不再赘述。

最终结果如下:

经过第一趟归并之后,我们把8个初始归并段,归并成了4个。

接下来我们可以进行第二趟的归并。

3.第二趟归并

把4个有序子序列(归并段)两两归并。

先来归并前两个归并段。归并之后,我们会把它写到外存的另外一片空间当中。

归并的方法和之前是一样的。

我们先把归并段1、归并段2当中更小的两个块,读入内存。分别放入缓冲区1、缓冲区2中。

接下来用归并排序的方法,挑选出三个最小的元素。

这样,凑足一整块之后,把它写回外存。

接下来继续归并。

此时,输入缓冲区已经空了。那么,我们需要把归并段1当中的下一块内容,读入输入缓冲区1当中。

有人会比较疑惑:

你把归并段1中的下一块内容读入缓冲区1之后,下一步进行归并操作,不也是读入缓冲区2当中的12吗?

那你先把13放进去,然后两块缓冲区都空了,一起再读入,是不是也可以呢?

这种想法是不对的。

刚才我们读入的三个元素是25 26 27,都比缓冲区2的13要大,所以可能会有这种错觉。

但是如果我们读入的元素是10 26 27呢?

所以,如果没有在缓冲区1变空之后,立马将下一个磁盘块记录读入内存,而是先归并的话,就会出错!!

所以再次强调,每当一个输入缓冲区空了的时候,我们都需要立即把与之对应的那个归并段的下一个磁盘块中的内容读入内存,然后才可以接着往下归并。

那么接下来,将13放入输入缓冲区。那么此时,缓冲区2空了,同样地,我们也要立即把缓冲区2当中的下一块内容读入内存。

再继续进行归并,写入外存……

到此为止,我们把两个归并段,归并成了一个更长更长的归并段。

同样地,为了美观,我们把这部分的图像,挪到下面。

不过,需要知道的是,我们归并之后得到的这个更长的子序列,其实是放在磁盘的另一片空间当中的,以前的那些空间我们会归还给系统

图像上为了美观,我们把它挪下来,导致看上去像是同一片空间,但是实际的逻辑上并不是同一片空间。

现在,我们需要把归并段3、归并段4进行归并。用同样的方法。

此时,第二趟归并完成。

第二趟归并完成之后,我们得到了两个更长的有序的归并段。

4.第三趟归并

把2个有序子序列(归并段)归并。

归并的过程和之前的,是一模一样的。只不过是这里的归并段的长度变长了而已。

此处不再赘述。

总之,经过这一趟归并之后,整个文件,就变成了一个整体有序的序列了。

(三)时间开销分析

回顾一下刚才那个例子。

在最开始的时候,我们需要把一个磁盘块均完全乱序的文件,两两归并,生成初始归并段。

进行了读、写操作各16次,而且在数据读入内存之后,还要进行内部排序。

经过这样的处理之后,我们才得到了8个初始归并段,每个段占两块磁盘块。

这是因为,在我们这个例子当中,只使用了两块的输入缓冲区(输入缓冲区1、输入缓冲区2),所以我们每次只能读入两块的内容,对它们进行内部排序。

如果分配的内存缓冲区更大,那我们得到的这个初始归并段的长度,相应的也会变得更长。

但总之,在我们的例子当中,我们会生成8个初始的归并段,每段占两块。

接下来,我们会进行三趟的归并,把有序的归并段归并成一个更长的整体。

每一趟归并,会基于上一趟归并的结果,再次把两个有序的归并段合并成更长的一个归并段。

这样经过三趟归并之后,就可以得到一个整体有序的文件。

通过刚才我们对归并过程的演示,不难发现,我们每一趟归并,其实都需要把16个磁盘块的数据,都需要读入内存,并写回外存。所以每一趟归并,读、写磁盘块的次数,都是16次。当然了,在每一趟读入内存之后,还需要在内存处进行内部的归并排序。

所以我们外部排序的时间开销如下:

外部排序时间开销 = 读写外存的时间 + 内部排序所需时间 + 内部归并所需时间

其实读写外存的时间,基本上和读写磁盘的次数,是成正比的。

刚才的例子中,我们需要读写磁盘的次数共有32*4 = 128次。

那么,如果每次读或写磁盘都需要10ms的时间的话,整体来看,读写磁盘的时间就需要1280ms,也就是1.28s。

对于计算机来说,内部排序、内部归并,由于是在内存里面进行的,这个速度是很快的,肯定达不到“秒”这样的级别。

显然,文件的总块数是多少,我们没法改变。但是,归并的趟数,其实我们可以想办法,让它缩小。

只要归并的趟数缩小了,那么我们读写磁盘的次数就会减少,相应的,外部排序的时间开销就会下降。

所以,这是我们接下来进行优化的一个思路:怎么减少归并的趟数。

(四)优化:多路归并

刚才我们说了,如果能够减少归并的趟数,相应的就会减少读写磁盘的次数。

那么怎么做呢?

我们可以用多路归并。

之前我们是用了2路归并。

现在我们来看一下,如果采用4路归并。

相应的,如果要进行4路归并,我们就需要在内存中分配四个输入缓冲区。

然后把4个归并段中的内容,分别读入这四个缓冲区当中。

接下来归并的原理,其实和刚才是一样的。

每一次从这4个缓冲区当中挑选一个最小的元素,把它放到输出缓冲区。

凑足一整块之后,把它写回外存。

同样需要注意的是,每当一个缓冲区空了的时候,我们就需要把这个缓冲区所对应的归并段的下一块的内容,立即读入内存。

总之,我们进行4路归并之后,可以把这4个归并段,归并为一个更长的有序序列。

下面那4个归并段也是一样的。

所以,在第一趟归并之后,我们整个文件就只剩下两个归并段了。

那么,接下来我们再对这两个归并段进行一个2路归并,就可以得到一个整体的有序文件了。

所以,如果我们采用4路归并的话,整体归并的趟数就只有两趟。

采用4路归并,只需进行两趟归并即可。

读、写磁盘的次数 = 32 + 32*2 = 96次。

重要结论:采用多路归并可以减少归并趟数,从而减少磁盘I/O(读写)次数
对r个初始归并段,做k路归并,则归并树可用k叉树表示。若树高为h,则归并趟数=h−1=⌈logkr⌉对 r 个初始归并段,做 k 路归并,则归并树可用 k 叉树表示。\\ 若树高为 h,则归并趟数=h-1=\lceil log_k r\rceil 对r个初始归并段,做k路归并,则归并树可用k叉树表示。若树高为h,则归并趟数=h−1=⌈logk​r⌉

推导过程为:
k叉树第h层最多有kh−1个结点则r≤kh−1,(h−1)最小=⌈logkr⌉k叉树第h层最多有k^{h-1}个结点\\ 则r≤k^{h-1},(h-1)_{最小}=\lceil log_kr\rceil k叉树第h层最多有kh−1个结点则r≤kh−1,(h−1)最小​=⌈logk​r⌉

从这个式子,很明显的可以看到,k越大,r越小,则归并趟数越少,读写磁盘次数越少。

所以我们的优化策略就是,令归并的路数变多一些(k变大),或者令初试归并段的数量少一些(r变小)。

但是值得一提的是,归并路数并不是越多越好,因为归并路数变多,也会带来一些负面影响:

①k路归并时,需要开辟k个输入缓冲区,内存开销增加。

②每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加。

当然,这个问题,我们接下来要讲的败者树,可以解决在k路归并的时候,每挑选一次关键字都需要对比关键字k-1次的问题。

接下来我们看怎么减少r,也就是怎么减少初始归并段的数量。

刚才我们进行4路归并的时候,不是开辟了4个输入缓冲区吗?

那既然我们有4个输入缓冲区,那么对于最开始的一个无序的文件,我们完全可以读入4块的内容,然后把这些内容在内存里面进行一个内部排序。再分别写回外存。这样的话,我们得到的初始归并段就包含了4块的内容。用这种方式构造初始归并段,就只会有4个(对上面的例子来讲)。

所以,生成初始归并段的“内存工作区”(也就是输入缓冲区)越大,初始归并段越长。也就意味着r越小。

结论:若能增加初始归并段的长度,则可减少初始归并段数量r。

(五)总结

外部排序

  • 若要进行k路归并排序,则需要在内存中分配k个输入缓冲区和1个输出缓冲区
  • 步骤
    • ①生成r个初始归并段(对L个记录进行内部排序,组成一个有序的初始归并段)
    • ②进行S趟k路归并,S = ⌈logk(r)⌉
  • 如何进行k路归并
    • 把k个归并段的块读入k个输入缓冲区
    • 用“归并排序”的方法从k个归并段中选出几个最小记录暂存到输出缓冲区中
    • 当输出缓冲区满时,写出外存
  • 外部排序时间开销:读写外存的时间 + 内部排序所需时间 + 内部归并所需时间
  • 优化
    • 增加归并路数k,进行多路平衡归并

      • 代价1:需要增加相应的输入缓冲区
      • 代价2:每次从k个归并段中选一个最小元素需要(k-1)次关键字对比
    • 减少初始归并段数量r

对于“代价2”,在下一小节中,我们将介绍“败者树”,来减少关键字对比次数。

:按照本节介绍的方法生成的初始归并段,若共N个记录,内存工作区可容纳L个记录,则初始归并段数量 r = ⌈ N/L ⌉

对于此处,我们在之后的小节当中,会介绍一种“置换—选择排序”的方法,进一步减少初始归并段数量。

那么此处提到的“败者树”,和“归并—选择排序”,就是我们接下来要学习的内容。

(六)纠正:多路平衡归并是什么

课本上写的是:对r个初始归并段,做k路平衡归并,归并树可用严格k叉树(即只有度为k与度为0的结点的k叉树)来表示。

上面这种说法是有疏漏的(但是图确实是一个4路平衡归并)。因为,这是4路归并,但是对于根结点来说,根结点是2叉的,并不是4叉的,与它的文字描述相矛盾。

我们把这种说法纠正一下,如下:

k路平衡归并

①最多只能有k个段归并为一个;

②每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到⌈ m/k ⌉个新的归并段。

如果不满足这两个条件,就不能称之为“k路平衡归并”。

因此,对于上图:

这个例子是不是4路归并排序?——是。

这个例子是不是4路平衡归并排序?——不是。

因为,最初有8个归并段R1~R8,对于第一趟归并,其将8个归并段归并为了3个新的归并段,R1' R2' R3'

对于4路归并来说,如果当前这一趟有8个归并段参与归并的话,最后应当得到⌈ 8/4 ⌉ = 2 个新的归并段,而不是3个。

再来看书上的这个图。

第一趟归并,把8个归并段归并成了2个新的归并段,OK。

第二趟归并,把2个归并段归并成了⌈ 2/4 ⌉ = 1个新的归并段,也没问题。

所以这个图,的确是一个4路平衡归并的排序过程。

十二、败者树

上一小节中,我们说过,对于外部排序,若使得归并路数k增加,则归并趟数会减小,从而读写磁盘总次数减少。

但是问题是,归并路数k如果过大,由于k路归并的过程需要对比关键字k-1次才能选出一个最小元素,从而导致内部归并所需时间增加。

这样一来,多路归并虽然减少了读写磁盘次数,但是又会使得内部归并所需时间增加。

对于这个,增加归并路数所带来的负面影响,可以用败者树来进行优化。

可以让我们从k个归并段中选出最小元素,需要对比关键字的次数更少。

(一)什么是败者树

可以看到,若有8位参赛者,则构造败者树需要7次比拼。

这就是败者树,所有选手进行一回合一回合的晋级,最终选出获胜者。

我们把它简化一下,如下,就成为了一棵二叉树。

败者树——可视为一棵完全二叉树(多了一个头)。k个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点为止。

上图,最下面一层8个叶子结点就是参加比较的元素,上面的若干非叶子结点用来记忆中间的失败者。

那么,最终“天津饭”就是冠军。

但是,此时,如果“天津饭”退出比赛,并且由“派大星”顶替。

现在冠军走了,那么是不是意味着,我们需要重新进行7个回合的比赛?

其实是不需要这样的。

我们完全可以基于之前构造好的这棵败者树,来对比赛的流程进行优化,可以进行更少的比赛就可以找出谁最强。

此时,若要让这8个人重新选出一个冠军。其实右半边的那几场比赛完全没有必要再进行一次(上图圈住的部分)。因为那几个人,谁更厉害,在之前就已经得知了。

此时,我们可以让“派大星”和“阿乐”打一场。因为阿乐只是输给了之前的那个人,但是不能确定阿乐和派大星谁更厉害。所以必须让派大星和阿乐打一场。

此时,如果派大星赢了,那么接下来再让派大星和程龙打一场。

如果派大星又打赢了,那么接下来可以让派大星和孙悟空打一场。它们俩对打之后,就得出了冠军是谁。

此时,原来的冠军走了,派大星加入之后,新的冠军,就求了出来。

可见,对于这个例子,基于已经构建好的败者树,选出新的胜者只需进行3场比赛。(并不需要重新打7场)

例子中的“对打”,其实就是我们关键字的对比。

(二)败者树在多路平衡归并中的应用

这里有8个归并段,我们要在这8个归并段当中选出一个最小的元素。之前,每选出一个最小的元素,我们都需要进行7次的对比。

但是现在,我们可以这么做。

先来构造一棵败者树。败者树中的每一个叶子结点,会对应我们的每一个归并段。

接下来我们会继续构建这棵败者树,从中找出最小的一个 元素。

对于败者树来说,在中间的分支节点上(记录失败者信息),我们本应该记录的是失败者的元素。但是在此处,我们对于每个分支节点,要记录失败者是来自哪一个归并段。

对于失败者结点,我们只需要记录其来自于归并段的编号,并不需要把其数据元素值记录其中。

现在我们构建好了一棵败者树,其中,根结点(最顶上的那个结点)记录了冠军,即最小的元素是来自于哪一个归并段当中。

这里记录的是3,所以,最小的元素是来自于归并段3当中。

所以,第一轮,我们经过了7次关键字对比,找到了最小的元素。

对于k路归并,第一次构造败者树需要对比关键字k-1次。

接下来,按照归并排序的规则,我们还需要再从这8个归并段中,再选出一个最小的元素。

因此,我们需要将归并段3中的下一个元素,替代1这个元素原有的位置。(因为1是冠军,已经走了)

接下来,我们只需要用这个新来的元素,和第4个归并段中此时最小的元素进行对比(因为它的父节点是4,表示来自于归并段4的那个失败者,也就是刚刚由归并段4进入败者树的结点,也就是归并段4的第一个结点,而由于归并段是有序的,也就是归并段4的最小的元素)。

这个元素6是更小的,那么它胜出。

然后就可以进行下一轮的对比,即与归并段2中的最小元素进行对比。对比之后,同样是来自归并段3的胜出。

因此,来自归并段3的元素又可以进入下一轮的对比。接下来这一轮,是要和归并段5的最小元素进行对比。

归并段5的最小元素是2,显然比6更小,因此5号选手胜出。

那么最终,我们选出了一个新的冠军,即8个归并段中的下一个最小的元素。

目前来看,最小的元素是来自于5号段中的最小元素,也就是数据元素2。

所以在这个例子当中,我们可以看到。

只要我们构建好了败者树,接下来每一次要选出一个最小的元素,只需要进行3次的关键字对比(即灰色结点的层数)。

结论

  • 对于k路归并,第一次构造败者树需要对比关键字k-1
  • 有了败者树,选出最小元素,只需对比关键字⌈ log₂k ⌉次。

此时,如果要进行1024路归并。

在以前,我们每次要选出最小的关键字,都需要进行1023次对比。

但如果我们有了败者树,我们每次选出最小关键字就只需要10次对比。

(三)败者树的实现思路

在考研当中,对败者树的考察一般是手算。

此处讲一下代码的实现思路,作为了解。

如果我们要进行8路归并。

我们要定义一个长度为8的int型数组,用这个数组就可以存储8路归并的败者树。

其中,数组下标为1的,就可以表示传统意义上的树的根结点,数组下标为0的,就是败者树中新增加的那个头。其他的位置结点,以及一些性质,和一棵完全二叉树是对应的。

此处会发现,我们最底层的叶子结点,在存储败者树的数组当中是不对应任何数据的。

也就是说叶子结点是虚拟的。但是从逻辑上看,这些叶子结点是“存在的”,每个叶子结点会对应一个归并段,但实际上,这些叶子结点只是我们脑补上去的。

到此,我们应该就能看懂,课本中给的这个图示是什么意思。

5路归并的败者树,其背后实际上对应的是一个长度为5的int型数组。

数组中,下标为1到4的这几个结点,其实对应的就是失败者的结点,而0号结点就是冠军结点。

并且,对于这个5路归并,根据⌈log₂5⌉ = 3,可知其失败结点总共有三层。因此每次只需3次对比。

当然,也有可能只需要2次对比。因为,如果我们填补的新元素,在它上方只有2层失败结点,那么就只需两次对比了。(如上图的b0、b1、b2处)

(四)总结

  • 败者树解决的问题:使用多路平衡归并可减少归并趟数,但是用传统方法,从k个归并段中选出一个最小/最大元素需要对比关键字k-1次,构造败者树可以使关键字对比次数减少到⌈log₂k⌉。
  • 败者树可视为一棵完全二叉树(多了一个头)。k个叶结点分别对应k个归并段中当前参加比较的元素,非叶子结点用来记忆左右子树的“失败者”,而让胜者往上继续进行比较,一直到根结点。
  • 如何构造和使用败者树?结合图示进行理解。

十三、置换-选择排序

在外部排序的时候,生成r个初始归并段,进行S趟k路归并。S = ⌈logk®⌉。

我们说了,如果让初始归并段r减少,则可以使外部排序的效率提升。

这一小节学习的置换-选择排序,就能够用于制造更长的归并段,也就是让初始归并段的数量尽可能的少。

(一)传统方法制造初始归并段

我们用传统方法制造的初始归并段,就是在内存中开辟两块输入缓冲区与一块输出缓冲区,然后将文件中的2个磁盘块中的乱序内容读入内存中,进行内部排序,再将排好序的两块写回磁盘。

而由于我们的内存只开辟了两块缓冲区,因此只能对这两块的内容进行内部排序,得到两块整体有序的序列。

如何让这个序列更长呢?

不难想到,我们可以用一片更大的内存区域来进行内部排序。

比如我们有六块缓冲区,那么一次可以将6个磁盘块中的数据读入,并且对它们进行内部排序并写回磁盘,最终得到一个长度为六块的有序序列,即长度是6块的初始归并段。

这样一来,就使得初始归并段的个数降低了。

用于内部排序的内存工作区WA可容纳l个记录,则每个初始归并段也只能包含l个记录。(这个l,就是我上面说的块数乘每块中的记录数)

若文件共有n个记录,则初始归并段的数量r = n / l

(二)置换-选择排序

上面的扩大初始归并段长度的方法,仍然是一种传统思想。其初始归并段的长度,是和内存工作区的大小相等的。

怎样能制造一个比内存工作区更大的初始归并段呢?可以用置换-选择排序来解决。

假设用于内部排序的这个内存工作区,只能容纳3个记录(即,对于传统思路来讲,当存入3个数据元素时,就够一块了,就要写回,并组成一个初始归并段了,因此初始归并段每段的记录也为3,由于总共有24个记录,所以传统方法只能生成8个初始归并段)。

我们最终要实现递增的序列。

我们先把文件中的前三个记录读入进来。此时,内存工作区已经填满,我们要把最小的元素置换出去。

置换出去,放到归并段1当中。并且,同时,用MINIMAX,把刚才这个变量的值4给记下来。

接下来,我们会从待排序文件当中读入下一个记录。

经过对比发现,此时最小的一个元素是6,并且它的值要比刚才输出的值4更大(即比MINIMAX=4更大)。所以我们要把6放到归并段1的后面,并且令MINIMAX为6。

此时,6拿出去了,就空出了一个位置。此时,我们要把下一个记录读入进来。

在这几个记录当中,最小的是7,同时,7要比刚刚输出的值6更大(即比MINIMAX=6更大)。所以我们要把7这个记录,放到6的后面。同时令MINIMAX为7。

接下来读入下一个记录。

此时最小的是9,把9放在刚才的7的后面。读入下一个记录。

…………中间略去若干步骤。

此时,最小的记录是10,但是,通过MINIMAX这个变量我们知道,上一个输出到归并段1当中的记录应该是13,所以现在10这个记录,我们不可能把它放到归并段1的末尾。(因为我们的归并段1肯定是要内部递增的)

因此,虽然10这个记录是最小的,但是我们不能把它置换出去。

而,除了10之外,最小的是14,而且14要比刚才输出的13更大。所以,我们可以把14放到归并段1的末尾,并令MINIMAX=14。

…………中间略去若干步骤。

此时,最小的是2,并且由于刚才输出的记录是22,因此我们不可能把2放到归并段1的末尾。

此时,其他的关键字中最小的为30。(10已经不被考虑了)30比22更大,所以将其放到归并段1的末尾,并令MINIMAX=30。

接下来,读入记录3

现在,3这个元素,要比MINIMAX=30更小,因此不可能把它放到归并段1的末尾。

而此时,内存工作区内的三个关键字都比MINIMAX更小,则次归并段在此截止。

第一个归并段就到此为止,构造完成。

接下来我们构造第二个归并段,归并段2。

我们先将刚刚“标红”的三个关键字给解除一下,重新纳入考虑。

则,首先输出2,并将MINIMAX设为2。

…………之后步骤和上面同理,略。

至此,当前工作区中的关键字都比MINIMAX更小,则归并段2生成完毕。

接下来构造归并段3的方法是一样的,略。

到这一步时,初始的待排序文件已经全部读入完毕了。那么接下来把工作区中的三个记录依次有序输出即可。

这样一来,我们就得到了三个初始归并段。

这些初始归并段的长度,可以超过内存工作区的限制。

原本内存工作区只能容纳3个记录,而我们生成的归并段中的记录都能够大于3。由于归并段中记录数越多,则归并段个数越少,即r越小,即读写磁盘的次数越少。

其实输出文件FO,是在磁盘当中的。

在刚刚我们演示的例子过程当中,是每次从内存工作区中直接找到最小元素,输出到FO中。

但是实际上,我们的内存当中是要有一个输出缓冲区的,内存工作区中每次找到的最小元素,要先排列在输出缓冲区当中,当输出缓冲区满了(即达到一个磁盘块的大小了),才一并写入磁盘,存入FO当中。

同样地,在从FI文件读入记录至内存工作区时,也不是一个记录一个记录读入的,而是一次读入一块的内容(输入缓冲区)。只不过每次会把一个记录放进内存工作区WA。

(三)总结

设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,WA可容纳w个记录。

置换-选择排序算法的步骤如下:

①从FI输入w个记录到工作区WA。

②从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。

③将MINIMAX记录输出到FO中去。

④若FI不空,则从FI输入下一个记录到WA中。

⑤从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的MINIMAX记录。

⑥重复③-⑤,直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中去。

⑦重复②-⑥,直至WA为空。由此得到全部初始归并段。

十四、最佳归并树

之前我们学习的归并树,它有一个性质。

(一)归并树的神秘性质

通过上一小节的置换-选择排序,我们了解到,通过那种方法得到的初始归并段,长度是各不相同的。

我们对于通过置换-选择排序得到的长度不一的初始归并段,对于这些初始归并段,我们来进行二路归并。

上图中,每个数字代表该归并段所占磁盘块的块数。

我们对这5个初始归并段,进行二路归并,即两两归并。

那么首先我们可以归并R2、R3两个归并段,得到一个长度为6块的归并段。

我们要把R2、R3这两个,一个长度为5个磁盘块、一个长度为1个磁盘块,的归并段,来读入内存当中去进行二路归并。而由于我们的读写操作,是以磁盘块为单位的。

因此,对于R2、R3的二路归并,我们需要进行5+1,即读、写操作各6次。之后,经过我们内存的处理,最终会将R2、R3两个归并段合二为一,得到一个总共占有6个磁盘块的新的归并段。

然后,我们将R4、R5进行二路归并,同理,总共需要进行读、写操作各8次。

之后,我们再把这个占6块、占8块的两个归并段再次进行归并,得到一个占14块的归并段。并且总共需要进行读、写操作各14次。

最后,我们再把R1这个归并段,和这个14块的归并段进行归并。并且总共需要进行读、写操作各16次。

最终将所有初始归并段,归并为了一个整体,归并完毕。

最终,我们可以看到,整个过程进行的读、写操作各为6+8+14+16,即44次。

现在我们把上图中的4个绿色结点看作二叉树的5个叶子结点。

现在来计算这棵二叉树(归并树)的带权路径长度,WPL = 2×1 + (5+1+6+2)×3 = 44 = 读磁盘的次数 = 写磁盘的次数。即读写磁盘的总共操作次数为88次。

如果忘了带权路径长度是什么,可以回去看一下哈夫曼树那一小节。

重要结论:归并过程中的磁盘I/O次数 = 归并树的WPL×2

基于此,我们不难想到,如果我们想让归并的过程中磁盘读写的次数最少,那么只需要使归并树的WPL最小,这不就是哈夫曼树吗?

所以接下来我们会构造一棵哈夫曼树,来优化这个二路归并的策略。

(二)构造2路归并的最佳归并树

就是将初始归并段对应的结点,构造成一棵哈夫曼树。

简单描述一下构造哈夫曼树的过程:

先从5个结点中选出两个权值最小的结点1、2,令他们两个构成兄弟,进行归并,产生一个权值为3的结点;

此时所有结点中权值最小的两个结点为3和2,令他们两个构成兄弟,进行归并,产生一个权值为5的结点;

此时所有结点中权值最小的两个结点为5和5,构成兄弟并归并,产生一个10的结点;

此时最小的两个,10和6,构成兄弟,归并,产生最终的一个结点16。

这个哈夫曼树的含义,或者说按照这棵归并树的操作过程,就是。刚开始我们先把1和2两个归并段进行归并,然后再与长度2的归并段归并,再与长度5的归并段归并,再与长度6的归并段归并。

最佳归并树,WPL_min = (1+2)×4 + 2×3 + 5×2 + 6×1 = 34。

也就是用我们这种归并方案,总共需要读、写磁盘各34次,即总的磁盘I/O次数68次。

这就是一棵最佳归并树,我们按照它来进行归并,能够得到最少的磁盘I/O次数。

(三)多路归并的情况

有这些初始归并段,同样地,每个小圆圈里面的数字,代表该归并段长度占了多少个磁盘块。

1.三路归并

现在,我们若要进行3路归并

按照我们之前的传统方法,我们的三路归并应该是如下结果:

那可以计算一下这棵树的带权路径长度,WPL = (9+30+12+18+3+17+2+6+24)*2 = 242,即整个归并过程的磁盘I/O总次数为484次。

这是我们之前学习的,三路归并的方法。显然这并不是一棵最佳的归并树。

那三路归并的最佳归并树应该怎么构造呢?

其实原理和二路归并是非常类似的。

和二路归并构造哈夫曼树的操作非常类似,它只不过是每次拿来权值最小的三个结点。

首先拿来所有结点中权值最小的三个结点2、3、6,使他们构成兄弟,进行归并,得到一个11的新的结点;

接下来,权值最小的三个结点就是9、12、11,让他们构成兄弟,进行归并,得到一个32的新结点。

此时,权值最小的三个为17、18、24,让他们构成兄弟,进行归并。

最后,只剩下了三个结点30、32、59,我们再把它们三个进行归并。

用这样的方法得到的一棵三路归并的归并树,它就是一棵最佳归并树。

WPL_min = (2+3+6)×3 + (9+12+17+24+18)×2 + 30×1 = 223

那么按照这种归并过程进行的三路归并,总共需要的磁盘I/O次数为446次。

2.如果减少一个归并段

接下来,问题来了,如果我们去掉一个归并段。比如把30这个归并段给去掉。

也就是说我们总共只有8个归并段,来参与三路归并。

那按照三路归并构造哈夫曼树的规则,过程如下:

首先选择最小的三个2、3、6,归并。

此时,最小的三个是9、12、11,归并。

接下来,最小的三个是17、24、18,归并。

现在我们只剩下了这两棵树,而如果我们要把这两棵树进行归并,就只能进行二路归并。

这棵树的带权路径长度是:

WPL = (2+3+6)×3 + (9+12+17+24+18)×2 = 193。

归并过程中,总共的磁盘I/O次数是386次。

此处,要说的是,上述这中构造最佳归并树的方法是不对的。这个并不是最佳归并树。

最后的一次归并,是进行的二路归并,而不是三路归并。

而,如果初始归并段能够再多一个,就能够保证刚好都是三路归并了。

所以,正确的做法应该是这样的,如下。

3.正确的做法

注意:对于k路归并,若初始归并段的数量无法构成严格的k叉归并树,则需要补充几个长度为0的“虚段”,再进行k叉哈夫曼树的构造。

那么像刚才那个例子当中,我们需要补充1个虚段。

长度为0的这个结点实际上是不存在的,但是我们把它加上,是为了总共的结点能够刚好凑成k叉归并树。

我们加入了虚段的结点后,就可以构造出正确的三叉归并树,过程省略,结果如下。

WPL_min = (2+3+0)×3 + (6+9+12+17+18)×2 + 24×1 = 163。

即,归并过程中,总的磁盘I/O次数为326次。

上面这个,就是三路归并的最佳归并树。

对于0这个虚段的设置,现实含义为:

我们在进行三路归并的时候,是在内存中开辟了三个缓冲区。当我们对2 3 0这三个归并段进行归并的时候,我们把2放到缓冲区1当中,把3放到缓冲区2,而缓冲区3中什么都不用放。对于0这个归并段,在参与归并的时候,只需看作已经被归并完毕了的归并段,不用再进行任何操作,就可以了。

总之,当我们进行k路归并的时候,若k>2,则我们就会遇到,初始归并段的数量无法构成严格的k叉归并树的情况。此时我们就要补充若干个长度为0的虚段,再进行处理。

那么到底要补充几个呢?

我们现在是要进行k路归并,而k路归并的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0的结点。

设度为k的结点有n_k个,度为0的结点有n_0个,归并树总结点数位n,则:

对于初始时给的归并段,以及我们补充上去的虚段,它们最终肯定都是叶子结点,即度为0的结点。即,初始归并段数量 + 虚段数量 = n_0。

另外,根据k叉树本身的性质,应该有如下两个等式:

  • n = n_0 + n_k

这个式子不需要解释。

  • k × n_k = n-1

对于严格的k叉树,除了根结点外,每个节点头上都会连着一个分叉。

对于k叉树,每个度为k的结点,共发出k×n_k个分叉,也就是说有k×n_k个结点,但由于这样计算是不会将根结点包含在内的,因此其等于n-1。

由上面这两个式子,可以得出:

n_0 = (k-1)×n_k + 1

进而得出:
nk=(n0−1)(k−1)n_k = \frac{(n_0-1)}{(k-1)} nk​=(k−1)(n0​−1)​
由于n_k表示的是,度为k的结点的数量,因此它必须是一个整数。

也就是说,如果是严格k叉树,则上面这个分式,一定得到的是一个整数的结果,或者说一定能除得尽。

又因为初始归并段数量 + 虚段数量 = n_0

因此,用人话来说一遍就是,初始归并段数量,再加上我们补充的虚段数量,再减1,应该是刚好能除尽(k-1)的。

即:

  • 若(初始归并段数量 - 1)% (k - 1) = 0,说明刚好可以构成严格k叉树,此时不需要添加虚段。
  • 若(初始归并段数量 - 1)% (k - 1) = u ≠ 0,则需要补充(k - 1)- u个虚段。

例子:

假设我们要进行8路归并,初始归并段数量为19。

由(19-1)%(8-1) = 4,因此不能直接构成严格8叉树,需要补充虚段。

再补3个虚段就行了。

(四)总结

最佳归并树

  • 理论基础

    • 每个初始归并段对应一个叶子结点,把归并段的块数作为叶子的权值
    • 归并树的WPL = 树中所有叶子结点的带权路径长度之和
    • 归并过程中的磁盘I/O次数 = 归并树的WPL × 2
  • 注意:k叉归并的最佳归并树一定是严格k叉树,即树中只有度为k、度为0的结点
  • 如何构造
    • 补充虚段:(初始归并段数量 - 1)% (k - 1)

      • 若能除尽,则不用补虚段
      • 若不能除尽,则补一些虚段让它能除尽
    • 构造k叉哈夫曼树:每次选择k个根结点权值最小的树合并,并将k个根结点的权值之和作为新的根结点的权值

数据结构(八):排序 | 插入排序 | 希尔排序 | 冒泡排序 | 快速排序 | 简单选择排序 | 堆排序 | 归并排序 | 基数排序 | 外部排序 | 败者树 | 置换-选择排序 | 最佳归并树相关推荐

  1. 【外排序】外排序算法(磁盘排序、磁带排序) 外存设备结构分析 败者树多路归并 最佳归并树白话讲解

    外排序 外排序概述 外排序的基本方法是归并排序法 例子 总结 存储设备(可忽略) 磁带 磁带结构 磁盘 硬盘结构 块 硬盘上的数据定位 磁盘排序 磁盘排序过程 1.生成初始顺串 方法1(常规方法): ...

  2. 【排序算法】冒泡排序|选择排序|插入排序|希尔排序

    文章目录 冒泡排序 选择排序 插入排序 希尔排序 冒泡排序   第一个元素开始向第二个元素比较,若大于则交换位置,不大于则不动.然后第二个元素和第三个元素比较,再然后第三个元素和第四个元素比较-一直比 ...

  3. 数据结构之外部排序:最佳归并树

    外部排序:最佳归并树 思维导图: 归并树的定义: 例: 最佳归并树(本质是一颗哈夫曼树): 所有的初始归并段一定能构造出一颗完美的哈夫曼树吗? 怎么选择补充虚短的个数? 思维导图: 归并树的定义: 例 ...

  4. 算法整理:外排序篇-置换选择排序最佳归并树

    目录 置换-选择排序 最佳归并树 外部排序分为几个步骤,首先根据内存将待排序文件分段,然后按照分段依次将每个分段的数据读入内存排序,最后将排序后的分段通过归并算法组合在一起.在排序的过程算法对外存的读 ...

  5. 直接插入排序 希尔排序 冒泡排序 快速排序 直接选择排序 堆排序 归并排序 基数排序的算法分析和具体实现 ...

    排序分为内部排序和外部排序 内部排序是把待排数据元素全部调入内存中进行的排序. 外部排序是因数量太大,把数据元素分批导入内存,排好序后再分批导出到磁盘和磁带外存介质上的排序方法. 比较排序算法优劣的标 ...

  6. C语言——十四种内部排序算法【直接插入排序-冒泡排序-选择排序-插入排序-希尔排序-归并排序-快速排序-堆排序-折半插入排序-二分查找-路插入排序-表插入排序-简单选择排序-直接选择排序-树形选择】

    目录: 一:插入排序 A:直接插入排序 1.定义: 2.算法演示 实例1: 3.基本思想 4.排序流程图 实例1: B:希尔排序 1.定义: 2.算法演示 实例2: C:其他插入排序 a:折半插入排序 ...

  7. dataStructure_外部排序/多路归并/败者树/最佳归并树

    文章目录 外部排序 时间代价:为什么不用二路归并 概念 归并段 小结:每趟归并需要读写磁盘的次数取决于 内存工作区 严格k叉树 k叉树和k路归并的趟数s与初始归并段数量m的关系 提高外部排序性能 k路 ...

  8. 数据结构:直接插入排序 希尔排序 选择排序 堆排序 冒泡排序 快速排序 归并排序

    一.什么是排序 排序就是将一组杂乱无章的数据按照一定的次序组织起来,此次序可以是升序也可以是降序 二.为什么需要进行排序 为了满足一些需求,比如在比较学生的成绩时,我们就需要给所有学生的成绩排一个顺序 ...

  9. 【排序算法】冒泡排序 选择排序 插入排序 希尔排序(数组)

    冒泡排序 #include<iostream> using namespace std; #define SWAP(a,b) {int tmp;tmp=a;a=b;b=tmp;} int ...

最新文章

  1. spark streaming 5: InputDStream
  2. 18个堪称神器的命令行工具,高效运维必备
  3. CSS模块化方案分类
  4. Oracle 创建用户 scott 例
  5. 玩转大数据系列之一:数据采集与同步
  6. 使用webgl(three.js)搭建一个3D智慧园区、3D建筑,3D消防模拟,web版3D,bim管理系统——第四课(炫酷版一)
  7. PM-项目管理(Project Management)
  8. 项目中的“里程碑”就是我们常说的里程碑吗?
  9. 基于QtGUI的宠物小精灵对战游戏设计
  10. Java判断上海自来水来自海上_JavaAPI
  11. Java学习06–前端基础之HTML
  12. 浏览器如何工作:在现代web浏览器场景的之下
  13. 201809 CCF
  14. RTL8372-CG/RTL8373-CG
  15. 2023二建建筑施工备考第二天Day02
  16. 如何破解EXCEL的单元格保护密码
  17. 学物理赶不上计算机,高二上学期物理为何这么难?
  18. java jdk 8学习笔记,Java JDK 8学习笔记 PDF_源雷技术空间
  19. bootstrapt 表格自适应_BootStrap table表格插件自适应固定表头(超好用)
  20. 登月源码开源登顶GitHub No.1!接而又被中国程序员“玩坏”了

热门文章

  1. GSM Channel Mode Modify和Channel Mode Modify Acknowledge信令
  2. PAT乙级-1051复数乘法(保留两位数-四舍五入)
  3. MATLAB2018simulink打不开MATLAB2019b的simulink,低版本simulink模型出现
  4. IM界面高仿微信,android表情转ios表情,支持自定义表情,支持语音(实战界面)
  5. xmanager linux 远程桌面,Windows系统下通过xmanager远程桌面控制Linux
  6. DataFactory造数-前期准备工作(DF安装、myodbc32的安装与配置、Oracle客户端的安装与配置)
  7. 电商小程序实战教程-需求分析
  8. Docker 部署 FreeIPA 服务
  9. 字节跳动半夜给员工发钱,全员沸腾了
  10. 机械原理复习试题及答案