08排序
8.1 排序基本概念
时间复杂度:评价排序算法的执行效率
空间复杂度:评价排序算法执行所需占用空间的程度
稳定性:序列中存在关键字相同的元素,经过排序后,相同元素之间的相对位置是否发生改变来评估排下序算法的稳定性
扩展:
对于任意序列进行基于比较的排序,最少的比较次数需要考虑最坏的情况下
- 对于任意n个关键字排序的比较次数至少为\(\lceil log_2(n!)\rceil\)次
- 在基于比较的排序方法中,每次比较两个关键字后,仅出现两种可能的转移
- 假设真个排序过程至少需要t次比较,则有\(2^t\)次情况。由于n个记录共有n!种不同排列,因此必须有n!种不同的比较路径
- 则有\(2^t\) ≥ \(log_2(n!)\),由于t为整数,故向上取整
- 对一个数据序列,如果存在两个关键字的排列数序关系,考虑关键词的排序优先级,优先级越高关键字越最后排,且后排的关键词需要使用稳定算法
- 如,一个线性表,要求数据项k1小的排在前,大的在后;在k1相同的情况下,数据项k2小的在前,大的在后
- 对这个线性表可先按k2进行简单选择排序,然后再按k1进行直接插入排序
8.2 内部排序
内部排序过程中,数据都存在内存中,主要关注算法的时间、空间复杂度
各种排序算法性能比较
| 算法类型 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 |
|---|---|---|---|---|---|
| 直接插入排序 | O(n) | O(\(n^2\)) | O(\(n^2\)) | O(1) | 稳定 |
| 冒泡排序 | O(n) | O(\(n^2\)) | O(\(n^2\)) | O(1) | 稳定 |
| 简单选择排序 | O(\(n^2\)) | O(\(n^2\)) | O(\(n^2\)) | O(1) | 不稳定 |
| 希尔排序 | - | - | - | O(1) | 不稳定 |
| 快速排序 | O(\(nlog_2n\)) | O(\(nlog_2n\)) | O(\(n^2\)) | 最好O(\(log_2n\)),最坏O(n) | 不稳定 |
| 堆排序 | O(\(nlog_2n\)) | O(\(nlog_2n\)) | O(\(nlog_2n\)) | O(1) | 不稳定 |
| 二路归并排序 | O(\(nlog_2n\)) | O(\(nlog_2n\)) | O(\(nlog_2n\)) | O(n) | 稳定 |
| 基数排序 | O(\(d(n+r)\)) | O(\(d(n+r)\)) | O(\(d(n+r)\)) | O(r) | 稳定 |
- 其中
- 希尔排序和增量序列 \(d_i\) 的选择有关
- 目前无法用数学手段证明确切的时间复杂度。最坏时间复杂度为 O(\(n^2\)),当n在某个范围内时,可达O(\(n^{1.3}\)),一般比直接插入排序要快
- 当d初始值为1是,退化为直接插入排序
- 基数排序中的r表示使用的辅助队列,与基数的码元长度(位码的数量)有关
- 基数排序每一趟分配需要O(n),每一趟收集需要O(r),共需要d趟分配、收集,因此总时间复杂度为O(d(n+r))
- 每趟收集中,收集队列的过程只需要将队头与收集好的链表队尾相连,时间复杂度为O(1)
- 希尔排序和增量序列 \(d_i\) 的选择有关
8.2.1 直接插入排序
Direct Insert Sort
算法思路
- 每次将一个待排序的计入按关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成
//假设排序升序
void DirectInsertSort(ElemType a[],int n){
for(int i=1;i<n;i++){ //首元素或者序列中单个元素默认有序
//插入元素大于有序子序列中最大元素,不需要调整,保留原位置
if(a[i-1]>a[i]){
//存放移动下标以及当前插入元素的值
int j,tmp=a[i];
//如果遍历元素小于等于插入元素,直接结束循环,无需调整
for(j=i-1;j>=0 && a[j]>tmp;j--)
a[j+1]=a[j];
//j下标为小于插入元素的最大值,直接插入在j元素后
a[j+1]=tmp;
}
}
}
该算法可优化为数组中下标为0的位置存放哨兵,时间复杂度和空间复杂度上差别不大,优点是内层循环不需要判断j>=0
直接插入排序评价
- 直接插入排序只需要有限个辅助参数,因此空间复杂度为O(1)
- 直接插入排序最好情况序列本身有序,时间复杂度为O(n);最坏情况序列本身逆序,则时间复杂度为O(\(n^2\)),平均时间复杂度O(\(n^2\))
- 直接插入排序是一个稳定的排序算法
如果针对链表进行插入排序,移动元素的次数减少,但关键字比较的时间复杂度仍未O(\(n^2\))
优化直接插入排序
由于原直接插入排序在有序子序中是顺序查找的过程,可优化为在有序序列中折半查找插入位置,在进行元素移动
- 折半查找,当low>high时停止查找,并将[low,i-1]内的元素全部后移,将插入元素插在low位置
- 当出现插入元素在有序序列中相同元素时,为了保证稳定性,low=mid+1折半查找继续,直到low>high时停止查找
- 若折半查找low>i-1时,不需要进行元素移动,直接插入下一个元素即可
//假设排序升序
void DirectInsertSort2(ElemType a[],int n){
for(int i=1;i<n;i++){ //首元素或者序列中单个元素默认有序
//插入元素大于有序子序列中最大元素,不需要调整,保留原位置
if(a[i-1]>a[i]){
//存放移动下标以及当前插入元素的值
int low=0,high=i-1;
int tmp=a[i];
//折半查找插入位置
while(low<=high){
int mid=(low+high)/2;
if(a[mid]>tmp) high=mid-1;
//保证排序算法稳定性,a[mid]==tmp不直接推出循环,区间继续右移low=mid+1
else low=mid+1;
}
for(int j=i-1;j>=low;j--)
a[j+1]=a[j];
//j下标为小于插入元素的最大值,直接插入在j元素后
a[low]=tmp;
}
}
}
优化后时间复杂度仍然为O(\(n^2\))
- 因为折半插入排序只是优化了找到插入位置的时间消耗,对于时间开销更大的元素移动并没有优化
8.2.2 希尔排序
Shell Sort
算法思路
- 将待排序的表分割成若干形如L[i,i+d,i+2d,...,i+kd]的子表,对各个子表分别进行直接插入排序,缩小增量d,直到d=1
- 一般情况,假设表长为n,d初值取n/2,每次d=d/2,直到d=1。d的取值和变化视情况而定
//假定为升序
void ShellSort(ElemType a[],int n){
//d初值为n/2,每次d=d/2,直到d=1
for(int d=n/2;d>=1;d/=2){
//每个子表L(i)首元素均为i,i初值为0
//内部循环为子表L(i)直接插入排序,从i+d开始
for(int i=d;i<n;i++){
if(a[i-d]>a[i]){
ElemType insertData=a[i];
int j;
//由于每次d都是缩小的,子表之间是不断归并,使得部分有序,子表相邻元素相差d
for(j=i-d;j>=0 && S[j]>insertData;j-=d)
a[j+d]=a[j];
S[j+d]=insertData;
}
}
}
}
希尔排序评价
- 希尔排序空间复杂度为O(1)
- 希尔排序和增量序列 \(d_i\) 的选择有关,目前无法用数学手段证明确切的时间复杂度。最坏时间复杂度为 O(\(n^2\)),当n在某个范围内时,可达O(\(n^{1.3}\)),一般比直接插入排序要快
- 当d的初值就为1时,希尔排序就是直接插入排序
- 希尔排序是不稳定的排序算法
- 希尔排序仅适合顺序结构,不适用于链表
- 希尔排序本质是分组排序,所以对于已经有序的序列进行排序,每个分组内本身就是有序的,无法减少比较次数,无法发挥其优势
8.2.3 交换排序
根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
冒泡排序
Bubble Sort
算法思路
- 从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换它们,直到序列比较完。称这样过程为"一趟"冒泡排序
- 若某一趟排序没有发生"交换",说明此时已经整体有序若某一趟排序没有发生"交换",说明此时已经整体有序
//假定为升序
void BubbleSort(ElemType a[],int n){
for(int i=0;i<n-1;i++){
bool flag=false;
for(int j=1;j<n-i;j++){
if(a[j-1]>a[j]){
ElemType tmp=a[j-1];
a[j-1]=a[j];
a[j]=tmp;
flag=true;
}
}
//如果某趟冒泡没有交换任何元素,说明数据已经有序直接结束
if(!flag) break;
}
}
冒泡排序评价
- 冒泡排序空间复杂度为O(1)
- 冒泡排序最好情况序列有序,时间复杂度为O(n);最坏情况序列逆序,时间复杂度为O(\(n^2\))。平均时间复杂度为O(\(n^2\))
- 冒泡排序是稳定的排序算法
由于冒泡排序只需要交换相邻两个元素即可,因此对于单链表也可以使用冒泡排序,将最大/最小元素冒泡到链表尾部,以实现一趟冒泡
快速排序
Quick Sort
算法思路:
- 在待排序表中选取一个基准值pivot,通过一趟排序将待排序表划分为两个独立的部分,左半边小于基准值pivot,右半边大于等于基准值pivot,基准值pivot位于右半边第一个元素,从而确定了基准值pivot的最终位置,以上过程称之为一次划分
- 分别递归左右两部分,直到每个部分只剩一个元素或为空,则将所有元素放到了最终位置
注:
- 选定基准值后,会申请额外空间存放基准值,基准值所在位置,即第一个元素位置用于放数据
- 假定按从小到大的顺序进行排序,那么空出位置放从右向左找到的第一个小于基准值的值,指针向右移动一位。同时右边复制过来的元素所在位置也为空
传统填坑法
//假定为升序
void QuickSort(ElemType a[],int low ,int high){
//待排序部分元素大于2才进行排序
if(high>low){
int begin=low,end=high;
//基准值使用额外空间存储
ElemType temp=a[begin];
while(begin < end){
//从右向左找小于基准值的元素
while(begin < end && a[end] >= temp) end--;
a[begin]=a[end];
//从左向右找大于基准值的元素
while(begin < end && a[begin] <= temp) begin++;
a[end]=a[begin];
}
//退出循环时begin==end
A[begin]=temp;
//将基准值填入坑中
//左半边递归快排,左半边从初始位置low到begin-1
QuickSort(a,low,begin-1);
//右半边递归快排,右半边从begin到最终位置high
QuickSort(a,beign+1,high);
}
}
改良版快速排序:使用左右指针双向交换方式,可以减少交换次数
考研中快速排序默认采用传统填坑法,但是快速排序的实现有多种方式,所有快排共性是每趟排序(一次函数调用)一定确定了一个元素的位置
//假定为升序
void QuickSort(ElemType a[],int low ,int high){
//待排序部分元素大于2才进行排序
if(high>low){
//begin、end分别标记待排序部分第一、最后一个元素下标
int begin=low++,end=high;
//pivot存放基准值
int pivot=a[begin];
//low、high分别为左半边、右半边已归类下标
//当low>high时说明已经归类完成
while(high>=low){
//如果low元素小于基准值,low后移
if(a[low]<pivot) low++;
//如果high元素大于等于基准值,high前移
else if(a[high]>=pivot) high--;
//如果low、high元素同时不符合所在类,所在元素交换位置,low继续后移,high继续前移
else if(a[high]<pivot && a[low]>=pivot){
ELemType tmp=a[high];
a[high--]=a[low];
a[low++]=tmp;
}
}
//循环结束后,high元素为左半边最后一个元素,即最后一个小于基准值的元素;low元素为右半边第一个元素
//high元素与基准元素begin交换位置,交换后,high为基准元素的最终位置
ELemType tmp=a[high];
a[high]=a[begin];
a[begin]=tmp;
//左半边递归快排,左半边从初始位置begin到high-1
QuickSort(a,begin,high-1);
//右半边递归快排,右半边从low到最终位置end
QuickSort(a,low,end);
}
}
快速排序评价
- 快速排序空间占用与递归调用层数有关,最好空间复杂度为O(\(log_2n\)),最坏空间复杂度为O(n)
- 快速排序每一层递归调用只需要处理一部分待排序元素,每次处理均不会超过O(n),因此时间复杂度为O(n*递归层数)。最好时间复杂度为O(n\(log_2n\)),最坏时间复杂度为O(\(n^2\)),平均时间复杂度为O(n\(log_2n\))
- 若每一次选中的基准值将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高
- 若每一次选中的基准值将待排序序列划分为很不均匀的两个部分,则会导致递归深度增加,算法效率变低
- 若初始序列有序或逆序,因为每次选择的都是最靠边的元素,快速排序的性能最差
- 快速排序是不稳定的排序算法
快速排序是所有内部排序算法中平均性能最优的排序算法
在408原题中,一趟排序指的是是对所有尚未确定最终位置的所有元素进行一遍处理。这个概念和之前提及的一次划分不一样,若按照408原题中的定义,那么一趟排序指的是递归调用中同一层的全部完成调用后的的排序过程
例如,进行第一层quicksort函数调用\(f_1\)时,左、右半边\(f_2,f_3\)会进递归调用,这两个调用属于同一层,按照408题目的定义,\(f_1\)调用完成是一趟排序,\(f_2,f_3\)均调用完成才是一趟排序
一次划分是确定了一个元素的最终位置,而一趟排序可以确定多个元素的最终位置
由于快速排序的执行效率取决于每轮基准值的选取
- 如果每次恰好选择了中间元素作为基准值,则可以将数据部分一分为二的分治,达到最理想状态,递归深度为O(\(log_2n\))
- 如果序列本身基本有序,选择左右两端的数据则是序列中的最值,每次划分左右不均匀,导致效率极差,递归深度为O(n),因此对于基本有序的序列快速排序往往无法发挥其优势
优化快速排序
算法的效率主要取决于递归深度,若每次划分越均匀,递归深度越低,因此优化思路主要在于基准值的选取
- 选择头、中、尾三个位置的元素,取中间值作为基准值
- 在待排序序列中随机选一个元素做基准值
8.2.4 选择排序
每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列
简单选择排序
Simple Select Sort
算法思路
- 每一趟在待排序元素中选取关键字最小的元素加入有序子序列
- 最后一趟仅剩下一个元素,该元素在之前的排序过程中已经称为最大/最小的元素,因此不需要在进行排序,循环结束
- n个元素的简单选择排序需要 n-1 趟处理
//假定为升序
void SimpleSelectSort(ElemType a[],int n){
for(int i=0;i<n-1;i++){
int index=i;
for(int j=i+1;j<n;j++)
if(a[index]>a[j]) index=j;
//如果index发生了变化,则进行交换
if(index-i){
ELemType tmp=a[index];
a[index]=a[i];
a[i]=tmp;
}
}
}
简单选择排序评价
- 简单选择排序空间复杂度为O(1)
- 简单选择排序不论最好、最坏情况都需要 n-1 趟处理,因此需要比较1+2+3+...+n-1=\(\frac{n(n+1)}{2}\),因此平均时间复杂度为O(\(n^2\))
- 简单选择排序是不稳定的排序算法
简单选择排序既可以用于顺序表,也可用于链表
堆排序
堆排序需要使用到堆这种数据结构,Heap,堆的n个关键字序列L[1...n]满足以下条件
- 若满足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的左孩子下标为2i,右孩子下标为2i+1,除根以外任何节点i的父节点下标为\(\lfloor i/2 \rfloor\)
- 一棵n个节点的堆中叶子节点的下标i,一定满足 i 大于 \(\lfloor n/2 \rfloor\)
- 节点i,若 2i > n,则该节点没有孩子;若 2i+1 > n,则该节点没有右孩子
堆初始化
- 把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整
- 注意
- 一般题目不是插入建堆,而是按照层序建树作为起始状态,并进行初始化
- 初始化从下标为\(\lfloor i/2 \rfloor\)的元素开始向前检查,即从最后一个非终端节点开始检查是否符合根堆规则,不符合直接进行调整
//调整大根堆
void AdjustBigHeap(ElemType H[],int){
//当前节点index下标超过n/2则为终端节点,不需要调整
while(index<=n/2-1){
//计算左、右子下标,由于下标从0开始,因此左子下标为2(i+1)-1=2i+1
int lchild=2*(index+1)-1,rchild=lchild+1;
//假设左子为孩子中较大的节点
int max=lchild;
//若右子存在,且右子比左子大,则max=rchild
if(rchild<n && H[lchild]<H[rchild]) max++;
//当前节点和左右子中较大节点大小比较,若父节点更小,则两节点互换位置
if(H[max]>H[index]){
ELemType tmp=H[max];
H[max]=H[index];
H[index]=tmp;
//由于发生了交换,交换后的子树可能大根堆结构发生破坏,需要重新调整
index=max;
}
//若父节点已经为大根堆,则说明从初值index开始,整棵子树均已为大根堆
else break;
}
}
//建立大根堆
void CreateBigHeap(ElemType H[],int n){
for(int i=n/2-1;i>=0;i--)
AdjustBigHeap(H,i,n);
}
//小根堆类似大根堆,不进行注释解释
//调整小根堆
void AdjustSmallHeap(ElemType H[],int){
while(index<=n/2-1){
int lchild=2*(index+1)-1,rchild=lchild+1;
int max=lchild;
if(rchild<n && H[lchild]<H[rchild]) max++;
if(H[max]>H[index]){
ElemType tmp=H[max];
H[max]=H[index];
H[index]=tmp;
index=max;
}
else break;
}
}
//建立小根堆
void CreateSmallHeap(ElemType H,int n){
for(int i=n/2-1;i>=0;i--)
AdjustSmallHeap(H,i,n);
}
Heap Sort
算法思路
- 由于根堆的根元素是整棵树中最值,因此每趟排序将堆顶元素与待排序序列中的最后一个元素交换
- 使用大根堆,则每趟将最大值后置,形成升序序列;使用小根堆,则每趟将最小值后置,形成降序序列
void AdjustBigHeap(ElemType H[],int); //调整大根堆
void AdjustSmallHeap(ElemType H[],int); //调整小根堆
void CreateBigHeap(ElemType H[],int n); //大根堆初始化
void CreateSmallHeap(ElemType H[],int n); //小根堆初始化
void BigHeapSort(ElemType H[],int n){
//初始化大根堆
CreateBigHeap(H,n);
//遍历每个节点
for(int i=n-1;i>=0;i--){
//遍历前交换根顶元素和末尾元素
ElemType tmp=H[0];
H[0]=H[i];
H[i]=tmp;
//由于末尾元素已经有序,堆大小-1
//由于只有堆顶元素改变,只需要调整堆顶
AdjustBigHeap(H,0,i);
}
}
void SmallHeapSort(ElemType H[],int n){
//初始化小根堆
CreateSmallHeap(H,n);
//遍历每个节点
//两种写法都行,但是末尾元素表示不一样
for(int i=0;i<n;i++){
//遍历前交换根顶元素和末尾元素
ElemType tmp=H[0];
H[0]=H[n-i-1];
H[n-i-1]=tmp;
//由于末尾元素已经有序,堆大小-1
//由于只有堆顶元素改变,只需要调整堆顶
AdjustSmallHeap(H,0,n-i-1);
}
}
堆排序评价
- 堆排序空间复杂度为O(1)
- 建堆:
- 一个长度为n的序列,堆排序需要进行一次建堆以及n-1次调整堆顶过程,而建堆过程关键字比较次数不超过4n,因此时间复杂度为O(n)
- 堆顶元素调整:
- 最多向下传递h-1层,每一层最多对比两次关键字,则每一趟调整时间复杂度不超过O(h)=O(\(log_2n\))。综上所述,堆排序的总时间复杂度为O(n)+O(n\(log_2n\))=O(n\(log_2n\))
- 堆排序是不稳定的排序算法
堆插入、删除
- 堆的插入
- 新元素直接放在表尾,新节点与父节点比较,若不符合堆结构则交换,一路上升,直到无法上升
- 堆的删除
- 被删除的元素用堆底元素进行替代,然后让该元素不断下坠,直到无法下坠为止
//堆插入
void InsertBigHeap(ElemType H[],int n,ElemType insertdata){
if(!isFullHeap(H)){
//此处忽略调整堆大小代码
int index=n;
H[n]=insertdata;
while(index>0){
//计算父节点下标
int parent=(index+1)/2-1;
//由于堆中除了根节点为一定有唯一父节点,因此不用判定父节点下标是否合法
if(H[parent]<H[index]){
ElemType tmp=H[parent];
H[parent]=H[index];
H[index]=tmp;
index=parent;
}
//如果新增后不改变堆结构,直接结束
else break;
}
}
}
void AdjustBigHeap(ElemType H[],int); //调整大根堆
void DeleteBigHeap(ElemType H[],int n,ElemType deletedata){
int index;
//查找
for(index=0;index<n;){
if(H[index]>deletedata) index=2*index+1;
else if(H[index]<deletedata) index=index=2*index+2;
else break;
}
//如果找到删除节点,index值合法
//如果删除的为末尾节点,则直接删除
if(index<n-1){
//此处忽略调整堆大小代码
//尾部节点与删除节点交换
H[index]=H[n-1];
//由于调整为中间节点,可以直接复用之前的调整代码进行堆调整
AdjustBigHeap(H,index,n-1);
}
}
8.2.5 归并排序
Merge Sort
算法思路
- 将两个或者多个已经有序的序列合并成一个
- 对于m路归并,每选出一个元素需要对比关键字m-1次
在内部排序中,一般采用二路归并
//假定为升序
#define MAXSIZE 10^10
ElemType Cache[MAXSIZE];
void DoubleMergeArray(ElemType S[],int low,int mid,int high){
//将待排序的元素复制到辅助数组Cache中
for(int i=low;i<=high;i++) Cache[i]=S[i];
//i表示前半部分遍历下标,j表示后半部分的遍历下标
//前半部分[low,mid-1],后半部分从[mid,high]
int i=low,j=mid;
//n表示合并序列遍历下标
int n=low;
//如果两部分任意部分已经合并到最终数组时,跳出循环
while(i<mid && j<high){
//每次前半、后半进行比较,插入到最终数组中,相应下标后移
if(Cache[i]<Cache[j])
S[n++]=Cache[i++];
else
S[n++]=Cache[j++];
}
//将前半数组剩余元素依次插入到最终数组
while(i<mid) S[n++]=Cache[i++];
//将后半数组剩余元素依次插入到最终数组
while(j<=high) S[n++]=Cache[j++];
}
void MergeSort(ElemType S[],int low,int high){
if(low<high){
int mid=(low+high)/2;
//排序前半部分
MergeSort(S,low,mid-1);
//排序后半部分
MergeSort(S,mid,high);
//合并前、后两半部分
DoubleMergeArray(S,low,mid,high);
}
}
归并排序评价
- 归并排序尽管上述代码定义中使用了MAXSIZE,但实际上需要n个额外空间即可;算法使用递归进行实现,递归深度为O(\(log_2n\)),整体空间复杂度为O(n)+O(\(log_2n\))=O(n)
- 归并排序时间复杂度
- 2路归并形态上相当于一棵二叉树,也可叫做归并树。假设树高h,n个节点,则需要h-1次归并,对于二叉树的h层最多有\(2^{h-1}\)个节点,则有n ≤ \(2^{h-1}\)
- 因此n个元素进行2路归并排序,归并趟数为\(\lceil log_2n \rceil\)。每趟的时间复杂度为O(n),则归并算法的整体时间复杂度为O(n\(log_2n\))
- 归并排序是稳定的排序算法
归并算法,对于两个含N个元素的有序表进行合并时候
- 最少的比较次数:其中一个表的所有元素和另一个表的第一个元素进行比较,比较次数为N
- 最多的比较次数:其中一个表的N-1个元素和另一个表的第一个元素比较,共N-1次;第一个表最后一个元素和另一个表的所有元素进行比较,共N次。合计共2N-1次
8.2.6 基数排序
假设长度为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称为基数
- 最高位/主位关键字为\(k_j^{d-1}\),最低位/次位关键字为\(k_j^0\)
Radix Sort
基数排序得到降序序列算法思路
- 初始化设置r个空队列或链表,\(Q_{r-1},Q_{r-2},...,Q_0\)
- 按照各个关键字位的权重递增的次序(个、十、百),对d个关键字位分别做分配和收集
- 分配:顺序扫描整个元素,若当前处理的关键字位=x,则将元素插入\(Q_x\)队尾
- 收集:把\(Q_{r-1},Q_{r-2},...,Q_0\)各个队列中节点依次出队并链接
- 如需要得到升序的序列,则收集过程按照\(Q_0,Q_1,...,Q_{r-1}\)依次出队链接即可
基数排序评价
基数排序不是基于比较关键字的排序算法,基数排序通常基于链表存储实现
- 基数排序需要r个辅助队列,空间复杂度为O(r)
- 基数排序每一趟分配需要O(n),每一趟收集需要O(r),共需要d趟分配、收集,因此总时间复杂度为O(d(n+r))
- 每趟收集中,收集队列的过程只需要将队头与收集好的链表队尾相连,时间复杂度为O(1)
- 基数排序是稳定的排序算法
基数排序应用
某学校共1w个学生,需要将学生信息按照年龄递减排序
- 可将学生生日拆分为三组关键词年、月、日,这三个关键词中权重大小年>月>日
- 第一趟收集,按照日递增的序列收集,年月日关键字越大,年龄越小
- 第二趟按照月递增收集,第三趟按照年递增收集
基数排序适用于以下场景
- 数据元素的关键字可以方便的拆分成d组,且d较小
- 按照身份证号对100个人进行排序就不适合基数排序
- 每组关键字的取值范围不大,即r较小
- 按照中文人名进行排序并不适合基数排序
- 数据元素个数n较大
- 但是如果同样按照身份证号,对10亿人进行排序,基数排序的效率就远比其他内部排序算法要高
8.3 外部排序
外部排序针对数据过多,无法全部放入内存的情况,除了考虑时间、空间复杂度,还要关注如何减少磁盘的读写次数
- 操作系统以块为单位,对磁盘存储空间进行管理
- 如果需要修改磁盘块中的数据,需要在内存中申请同样大小的空间,将磁盘中的数据读入到内存中进行修改,修改后写回磁盘中
8.3.1 外部归并排序
如果使用归并排序的方法,最少只需要在内存中分配3个块大小的缓冲区,便可对磁盘内任意大文件进行排序。缓冲区包括一块输出缓冲区,两块输入缓冲区
- 通过两个输入缓冲区,依次将n个磁盘块两两读入内存中,进行归并排序。经过n次读操作、n次写操作,构造n/2组初始有序归并段
- 第一趟归并,取出两个归并段中较小的块进行归并排序,以块为单位输出有序块按照顺序写入此磁盘,从而归并为一个归并段。同样经过n次读操作、n次写操作,归并为n/4组有序归并段
- 由于每次取两个块进行归并排序,当其中一个输入缓冲区空了,先读入归并段中的剩余块,再继续归并排序
- 为了保证真个磁盘有序,先读入块数较小的块,以便内存写入外存时有序
- 依次类推,直至将整个磁盘数据归并成一个有序归并段,则完成排序
外部归并排序评价
外部排序时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间
读写次数
=初始化归并段读写次数+归并过程读写次数
=块数*2+归并趟数*块数小*2
=块数小*2*(归并趟数+1)
外部归并排序优化
不难发现,读写时间开销比内部归并所需的时间多得多,因此对于外部排序的优化方向主要在于减少磁盘读写时间。归并路数k越大,初始归并段r越小,归并趟数越少,读写磁盘次数越少
- 如果优化归并趟数,可减少读写时间,即增加归并过程中输入缓冲区的数量
- 对于r个初始归并段,做k路归并,则归并树可用k叉树进行表示,若树高为h,第h层最多有\(k^{h-1}\)个节点,则有r ≤ \(k^{h-1}\),因此归并趟数=\((h-1)_{min}\)=\(\lceil log_kr \rceil\)
- k路平衡归并需要满足两个要求
- 最多只能有k个段归并为一个段
- 每一趟归并中,若由m个归并段参与归并,经过一趟处理可以得到\(\lceil m/k \rceil\)个新归并段
- 但是,k路归并需要申请k个输入缓冲区,内存开销增加;同时每挑选一个关键字需要比较k-1次,内部归并时间增加
对于k路平衡归并
- 如果不需要实现输入、内部归并、输出的并行处理,为了实现归并排序只需要m个输入缓冲以及1个输出缓冲即可
- 如果要实现输入、内部归并、输出的并行处理,内部归并与输入、输出不存在干扰可以进行直接并行;而输入、输出是需要占用io资源的
- 为了实现全双工工作,那么则需要两倍的io资源即可,即需要2m个输入缓冲以及2个输出缓冲
- 如果优化初始归并段的数量,也可以减少的读写时间开销
- 在生成初始归并段时,临时增加内存工作区从而增加初始归并段的长度,使初始归并段的数量r减小,减少磁盘读写时间开销
- 若共有N个记录,内存工作区可以容纳L个记录,则初始归并段数量r=N/L,但由于磁盘读写是以块为单位的,因此L的选取必须为块大小的整数倍
8.3.2 败者树
优化归并趟数
败者树:可作为一个完全二叉树,只是在根节点多了一个直接后继。k个叶节点分别是当前参加比较的元素,非叶子节点用来记忆左右子树的失败者,而让胜者向上继续比较,一直到更节电,共需要比较k-1次
- 根节点移除,插入新元素。基于已经构建好的败者树,新插入的元素只要和非叶子节点进行比较,就能确定新的根节点,压缩了元素比较的次数
败者树应用到外部归并排序,算法思路
- 从每个归并段中选取一个元素作为败者树的叶子节点,分支节点记录失败摆着来自哪个归并段,根节点记录冠军来自哪个归并段,初始败者树构建完成
- 根节点所指的归并段取出下一个元素放到叶子节点位置,向上与分支节点记录的归并段序号进行比较,一路更新败者贵宾段序号直至根节点,产生新的根点
败者树评价
- 对于k路归并,第一次构造败者树需要对比关键字k-1次
- 构建败者树后,选出最小元素,只需对比关键字\(\lceil log_2k \rceil\)次,与除去根节点和叶子节点的败者树层数相同
- 第h层共有\(2^{h-1}\)个叶子节点,则k ≤ \(2^{h-1}\)
败者树用于做序列升序的操作,如果需用序列降序可采用胜者树,分支节点记录获胜者所在的归并段
8.3.3 置换-选择排序
优化初始归并段的数量
置换-选择排序算法思路
- 假设用于内部排序的内存工作区WA只能容纳m个记录,m允许小于块内存放的记录数,即不要求初始归并段长度等长。从初始待排序文件中依次读入记录直至内存工作区WA填满
- 从初始内存工作区中选择最小的元素,输出到初始归并段\(L_0\),并用变量MIN记录当前输出的最小元素值
- 随后过程每输出一个最小元素,更新MIN的值,再从初始待排序文件中读入新的记录插入到内存工作区WA中。同时如果当前WA内最小元素小于MIN时,不进行输出,进行标记flag=true。当WA内所有元素均被标记后,当前归并段不再新增元素,切换下一个归并段继续写入,清空所有标记flag,重新选择最小元素进行输出
置换-选择排序评价
通过该算法,可以将每个初始归并段的长度超越内存工作区大小的限制,但置换-选择排序本身的作用是生成较少的外部排序初始归并段,并不能完成外部排序整个过程
置换-选择排序实际在输出记录时,仍需要依赖输出缓冲区进行磁盘写入,因此每次输出记录是输出到输出缓冲区中,当输出缓冲区满了以后在写入到磁盘
8.3.4 最佳归并树排序
由于置换-选择排序算法,通过延长归并段的长度让归并段数量减少,同时也让每个归并段的长度不等长
将不同归并段作为一棵二叉树的叶子节点,归并段中的记录数作为叶子的权值,则构成了一棵归并树。进行两两归并的过程中,每次合并需要读、写的次数相当于两叶子权值之和,同时带权路径长度WPL正是整个归并过程的读、写磁盘数量,I/O读写次数=2WPL
不难发现该过程和哈夫曼树的构建很类似,因此为了让磁盘读写次数越少,则需要使归并树成为一棵哈夫曼树,使得WPL最小,算法思路如下
- 在归并段中需寻找根节点长度最短的两个,新建一个新节点作为两个节点的双亲节点构成新子树,根权值为两个节点的归并段长度之和。
- 随后每次将新建的子树与剩余节点中数量最少的节点进行结合,直到所有节点都加入到归并树中
考虑k路归并的情况,每次选择k个根最小的子树进行归并
- k路归并(k>2)和二路归并的区别在于,每次可选择的节点可能不足k个,即初始归并段数量无法构成严格的k叉归并树
- 当出现上述情况,需要补充几个长度为0的虚段,在进行k叉哈夫曼树的构造
- 在虚段的实现上是如下过程:假设k=3,共有8个归并段,那么为了构造3叉哈夫曼树,需要补充一个虚段。其中两个输入缓冲区填入长度最短的两个归并段记录,第三个输入缓冲区置空
- k叉最佳归并树一定是一颗严格的k叉树,即树只包含度为0和度为k的节点
- 设度为k的节点有\(n_k\)个,度为0的节点有\(n_0\)个,归并树的总结点数为n,则有n = \(n_0\) + \(n_k\),k\(n_k\) = n -1,则\(n_0\) = (k-1)\(n_k\)+1,那么有\(n_k=\frac{n_0-1}{k-1}\)
- 初始归并段数量+虚段数量=\(n_0\),当(初始归并段-1) % (k-1) = 0时,说明刚刚好可以构成k叉树,不需要添加虚段;当(初始归并段-1) % (k-1) = u ≠ 0 时,则需要补充 (k-1)-u 个虚段
8.4 可视化演示
https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
本文来自博客园,作者:GK_Jerry,转载请注明原文链接:https://www.cnblogs.com/GKJerry/articles/18296964

浙公网安备 33010602011771号