数据结构——7.排序
考纲
- 排序的基本概念
- 插入排序
- 简单选择排序
- 希尔排序
- 快速排序
- 堆排序
- 归并排序
- 基数排序
- 排序算法的比较
选填居多,算法可能与链表结合
一、排序的基本概念
- 算法的稳定性。稳定:关键字相同的元素在排序之后相对位置不变;反之不稳定。稳定与否与算法优劣无关。
- 内部排序:数据都在内存中。关注如何使时空复杂度更低。
- 外部排序:数据太多,无法全部放入内存。还要关注如何使读写磁盘次数少。(不考)
排序可视化:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
二、插入排序
- 算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
(一)直接插入排序
- 手推过程(选填,考察第i步结果)
- 算法实现
//直接插入排序 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; //复制到插入位置 } } //直接插入排序(带哨兵) //优点:不用每轮循环都判断j>=0 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[0]=A[i]; //复制为哨兵,A[0]不存放元素 for(j=i-1;A[0]<A[j];--j) //从后往前查找待插入位置 A[j+1] = A[j]; //向后挪位 A[j+1] = A[0]; //复制到插入位置 } }
- 算法效率分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度:若有n个元素,需要n-1趟处理。比较关键字+移动元素。
- 最好情况:本已有序, O ( n ) O(n) O(n)。n-1次比对关键字。
- 最坏情况:本为逆序, O ( n 2 ) O(n^2) O(n2)。第n趟,比对n次,移动n+1次。
- 平均情况: O ( n 2 ) O(n^2) O(n2)
- 算法稳定性:稳定。
- 适用性:适用于顺序和链式存储。链式存储时刻从前往后查找指定元素位置。
(二)折半插入排序
- 算法思路:先用折半查找找到应该插入的位置,再移动元素。
- 步骤:
- 当low>high时折半查找停止,将[low, i-1]/[high+1,i-1]内的元素全部右移,并将A[0]复制到low所指位置。
- 当A[mid]==A[0]时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置。
- 算法实现:
void InsertSort(int A[], int n){ int i,j,low,high,mid; for(i=2li<=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]; //插入操作 } }
- 算法性能分析(参考直插)
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)。比起直插,比较关键字次数减少,约为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),但移动不变。
- 适用性:顺序表。
- 稳定性:稳定。
(三)希尔排序
- 算法思路:先将待排序表分割成若干形如 L [ i , i + d , i + 2 d , . . . , i + k d ] L[i,i+d,i+2d,...,i+kd] L[i,i+d,i+2d,...,i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复直到d=1。希尔建议: d 1 = n / 2 , d i + 1 = [ d i / 2 ] d_1=n/2,d_{i+1}=[d_i/2] d1=n/2,di+1=[di/2],下取整。
- 手推实现(选填,第i步)
- 算法实现
void ShellSort(int A[], int n){ int d,i,j; //A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到 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 ) O(1) O(1)
- 时间复杂度:和增量序列选择有关。最坏为 O ( n 2 ) O(n^2) O(n2),n在某个范围内 O ( n 1.3 ) O(n^{1.3}) O(n1.3)
- 稳定性:不稳定。
- 适用性:仅适用于顺序表。
三、交换排序
- 根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
(一)冒泡排序
简单的 考的少
- 手推实现(重要)
- 算法实现
//交换 void swap(int &a, int &b){ int temp = a; a = b; b = temp; } //冒泡排序 void BubbleSort(int){ for(int i=0;i<=n-1;i++){ bool flag=false; //表示本趟冒泡是否发生交换的标志 for(int j=n-1;j>1;j--) //一趟冒泡过程 if(A[j-1]>A[j]){ //若为逆序(所以稳定) swap(A[j-1],A[j])//交换 } if(flag==false) return; //本趟遍历后没有发生交换,说明表已经有序 } }
- 算法性能分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度:
- 最好情况(有序): O ( n ) O(n) O(n)。比较次数n-1,交换次数0。
- 最坏情况(逆序): O ( n 2 ) O(n^2) O(n2)。比较次数=交换次数= ( n − 1 ) + ( n − 2 ) + . . . + 1 = n ( n − 1 ) 2 (n-1)+(n-2)+...+1=\frac{n(n-1)}{2} (n−1)+(n−2)+...+1=2n(n−1)。每次交换需要移动元素3次。
- 平均情况: O ( n 2 ) O(n^2) O(n2)。
- 稳定性:稳定。
- 适用性:顺序表、链表都可以。
(二)快速排序(最重要)
- 算法思想:在待排序表任选一个元素pivot作为枢轴/基准(多为首元素),把待排序序列“划分”为两个部分。左边小,右边大。分别递归对两个子表重复划分,直到每部分只有一个元素或为空。
- 手推实现(主要,第i步推导)
- 算法实现(次要,主要手推)
//用第一个元素将待排序序列划分成左右两个部分 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); //划分右子表 } }
- 算法效率分析
- 递归调用层数:n个结点的二叉树,最小高度
[
l
o
g
2
n
]
+
1
[log_2n]+1
[log2n]+1,最大高度
n
n
n
- 时间复杂度:
O
(
n
∗
递
归
层
数
)
O(n*递归层数)
O(n∗递归层数)。每一层的QS只需处理剩余的待排序元素时间复杂度不超过
O
(
n
)
O(n)
O(n)
- 最好情况: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)。若每一次选中的枢轴将序列划分为均匀的两部分,则递归深度最小,算法效率最高。
- 最坏情况: O ( n 2 ) O(n^2) O(n2)。若每一次都很不均匀,则递归深度增加,效率变低。
- 平均情况: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)。快排是所有内部排序算法中平均性能最优的排序算法。
- 空间复杂度:
O
(
递
归
层
数
)
O(递归层数)
O(递归层数)
- 最好情况: O ( l o g 2 n ) O(log_2n) O(log2n)
- 最坏情况: O ( n ) O(n) O(n)
- 若序列本就逆序/有序,则复杂度最高。优化:选取能将数据中分的元素;随机选取。
- 稳定性:不稳定。
- 递归调用层数:n个结点的二叉树,最小高度
[
l
o
g
2
n
]
+
1
[log_2n]+1
[log2n]+1,最大高度
n
n
n
- 408注:一趟≠一次划分。一趟指对所有尚未确定最终位置的所有元素进行一遍处理。一次划分可以确定一个元素的最终位置;一趟可能确定多个。类似一层QS。
四、选择排序
- 每一趟在待排序元素中选取关键字最小或最大的元素加入有序子序列。
(一)简单选择排序
- 算法思想:每一趟在待排序元素中选取关键字最小的元素加入有序子序列。n个元素的简单选择排序需要n-1趟处理。
- 算法实现:
void SelectSort(int A[], int n){ for(int i=0;i<n-1;i++){ //一共进行n-1趟 int min = 1; //记录最小元素位置 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]);//封装的swap()函数共移动元素三次 } }
- 算法性能分析
- 空间复杂度: O ( 1 ) O(1) O(1)
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)。无论什么情况,都需n-1趟处理,总共对比关键字 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)次,元素交换次数 < n − 1 <n-1 <n−1
- 稳定性:不稳定。
- 适用性:顺序表、链表。
(二)堆排序
1、概念
堆(Heap)
- 大根堆(大顶堆):满足
L
(
i
)
≥
L
(
2
i
)
且
L
(
i
)
≥
L
(
2
i
+
1
)
(
1
≤
i
≤
n
/
2
)
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)
- 小根堆(小顶堆):满足 L ( i ) ≤ L ( 2 i ) 且 L ( i ) ≤ L ( 2 i + 1 ) ( 1 ≤ i ≤ n / 2 ) 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)
2、建立大根堆
- 思路:把所有非终端节点都检查一遍,是否满足大根堆要求,若不满足,则进行调整。在顺序存储的完全二叉树中,非终端结点编号 i ≤ [ n / 2 ] i≤[n/2] i≤[n/2]
- 实现:
①检查当前结点是否满足根≥左、右,若不满足,将当前结点与更大的一个孩子互换。
②若元素互换破坏了下一级堆,则采用相同方法继续往下调整(小元素不断“下坠”)。 - 手动调整堆:
- 算法(了解)
//建立大根堆 void BuildMazHeap(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]; //被筛选结点的值放入最终位置 }
3、基于大根堆进行排序
- 堆排序:每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换)。并将待排序元素序列再次调整为大根堆(小元素不断“下坠”)。
- 基于大根堆输出为递增,小根堆输出为递减。
- 输出堆顶元素87后将剩余元素调整成新堆
- 算法
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); //把剩余的待排序元素整理成堆 } }
- 性能分析
- 一个结点,每下坠一层,最多只需对比关键字两次。若树高为h,某结点在第i层,则将这个结点向下调整最多只需要下坠h-i层,关键字对比不超过2(h-i)次。 h = [ l o g 2 n ] + 1 h=[log_2n]+1 h=[log2n]+1
- 建堆的过程,关键字对比次数不超过4n,建堆时间复杂度 O ( n ) O(n) O(n)
- 堆排序的时间复杂度 = O ( n ) + O ( n l o g 2 n ) = O ( n l o g 2 n ) =O(n)+O(nlog_2n)=O(nlog_2n) =O(n)+O(nlog2n)=O(nlog2n)
- 堆排序的空间复杂度 = O ( 1 ) =O(1) =O(1)
- 稳定性:不稳定
4、堆的插入删除
- 大根堆插入示例
五、归并排序
- 概念:归并:把两个或多个已经有序的序列合并成一个。“二/四路归并”:对比所指元素,选择更小的一个放入。
- m路归并,每选出一个元素需要对比关键字m-1次。
- 手算(内部排序一般采用2路归并)
- 算法
int *B=(ElemTYpe *)malloc((n+1)*sizeof(ElemType)); //辅助数组B //表A的两端A[low...mid]和A[mid+1...high]各自有序,将他们合并 void Merge(ElemType A[],int low,int mid,int high){ for(int 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]) //比较B的左右两段中的元素 A[k]=B[i++]; //将较小值复制到A else A[k]=B[j++]; } while(i<=mid) A[k++]=B[i++]; //若第一个表未检测完,复制 while(j<=high) A[k++]=B[j++]; //若第二个表未检测完,复制 } void MergeSort(ElemType 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); //归并 } }
- 算法效率
- 分析:2路归并的归并树,形态上就是一棵倒立的二叉树。二叉树第h层最多有 2 h − 1 2^{h-1} 2h−1个结点。若树高为h,则应满足 n ≤ 2 h − 1 n≤2^{h-1} n≤2h−1,即 h − 1 = [ l o g 2 n ] h-1=[log_2n] h−1=[log2n]。此处h-1恰为算法趟数。
- 结论:n个元素进行2路归并排序,归并趟数 = [ l o g 2 n ] =[log_2n] =[log2n](上取整)。
- 时间复杂度 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)。每趟归并时间复杂度为 O ( n ) O(n) O(n)。
- 空间复杂度 O ( n ) O(n) O(n)。来自辅助数组B。
- 稳定性:稳定。
六、基数排序/桶排序
- 基于关键字个位的大小进行排序。不是基于比较的排序算法。假设长度为n的线性表中每个结点 a j a_j aj的关键字由d元组 ( k j d − 1 , k j d − 2 , . . . , k j 1 , k j 0 ) (k_j^{d-1},k_j^{d-2},...,k_j^{1},k_j^{0}) (kjd−1,kjd−2,...,kj1,kj0)组成。其中, 0 ≤ k j i ≤ r − 1 ( 0 ≤ j < n , 0 ≤ i ≤ d − 1 ) 0≤k_j^i≤r-1(0≤j<n,0≤i≤d-1) 0≤kji≤r−1(0≤j<n,0≤i≤d−1),r称为“基数”。
- 得到递增序列:最高位优先MSD,关键字位权重递减。得到递减序列:最低位优先LSD。
- 手推链式基数排序操作(每趟的结果)
- 算法效率分析
- 空间复杂度 O ( r ) O(r) O(r)。需要r个辅助队列。
- 时间复杂度 O ( d ( n + r ) ) O(d(n+r)) O(d(n+r))。一趟分配 O ( n ) O(n) O(n),一趟收集 O ( r ) O(r) O(r),总共d趟分配、收集。
- 稳定性:稳定。
- 擅长处理:
①数据元素的关键字可以方便地拆分为d组,且d较小。(反例:身份证号)
②每组关键字的取值范围不大,即r较小。(反例:中文人名)
③数据元素个数n较大。
七、排序算法的比较
选填
- 性质比较
- 选取排序方法需考虑的因素:
①待排序的元素数目n。
②元素本身信息量的大小。
③关键字的结构及其分布情况。
④稳定性的要求。
⑤语言工具的条件,存储结构及辅助空间的大小等。 - 小结
- n较小:直接插入、简单选择(信息量较大时)。
- n较大:快速排序(时间短)、堆排序(空间少)、归并排序(稳定)。
- n很大:基数排序(关键字位数少且可分解时)
- 初始即基本有序:直接插入、冒泡排序。
- 关键字随机分布时,借助比较的排序算法至少需 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 本身信息量较大时,可用链表存储,避免耗费时间移动记录。
八、外部排序
- 基本概念:外部排序指待排序文件较大,内存一次放不下,需存放在外存的文件的排序。排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换
- 实现方法(使用归并排序,最少只需在内存中分配3块大小的缓冲区):
①构造初始归并段
②将有序归并段两两归并为一个。示例第一趟归并(8→4)过程如下
③得到最终有序文件。示例一共需归并3次(8→4→2→1)
- 外部排序时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间
读、写磁盘次数=文件总块数 ∗ 2 + *2+ ∗2+文件总快数 ∗ * ∗归并趟数 - 优化:多路归并。
- 减少归并趟数,从而减少磁盘I/O次数。(k大)
- 对于r个初始归并段,做k路归并,则归并树可用k叉树表示。若树高为h,则归并趟数 = h − 1 = [ l o g k r ] =h-1=[log_kr] =h−1=[logkr](上取整)
- 代价
①k路归并时,需要开辟k个输入缓冲区,内存开销增加。
②每挑选一个关键字需要对比关键字k-1次,内部归并所需时间增加。
- 优化:减少初始归并段数量
- 生成初始归并段的内存工作区越大,初始归并段越长。(r小)