文章目录
第7章 排序
7.1 排序的基本概念
- 算法的稳定性。若待排序表中有两个元素Ri和Rj,其对应关键字keyi = keyj,且在排序前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称这个排序算法是不稳定的。
- 内部排序。是指在排序期间元素全部存放在内存中的排序。
- 外部排序。是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。
并非所有内部排序算法都要基于比较操作,事实上,基数排序就不基于比较。
【注意】
- 对同一线性表使用不同的排序方法进行排序,得到的排序结果可能不同。事实上,当对线性表中的元素按关键字进行排序,不稳定的排序算法可能得到的排序结果不同。
- 2路归并排序中比较操作的执行次数为 ⌈ log 2 ( n ! ) ⌉ \lceil \log_2(n!) \rceil ⌈log2(n!)⌉.
7.2 插入排序
7.2.1 直接插入排序
//直接插入排序(带哨兵)
//优点:不用每轮循环都判断i>=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[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]; //复制到插入位置
}//if
}//for
}
//直接插入排序
void InsertSort(int A[], int n){
int i, j, temp;
for(i = 1; i < n; ++i){ //将个元素插入已排好序的序列中
if(A[i] < A[i-1]){ //若A[i]关键码小于其前驱,将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; //复制到插入位置
}//if
}//for
}
- 空间效率:插入排序的空间复杂度为O(1),
- 时间效率:最好情况下,表中元素已经有序,此时每插入一个元素,都只需要比较一次而不用移动元素,因而时间复杂度为O(n)。最坏情况下,表中元素顺序刚好与排序结果中元素顺序相反,总的比较次数达到最大,时间复杂度为O(n2)
- 直接插入排序是一个稳定的算法。
- 适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。
虽然折半插入排序算法的时间复杂度也为O(n2),但对于数据量较小的排序表,折半插入排序往往能表现出很好的性能。值得注意的是,大部分排序算法都仅适用于顺序存储的线性表。
7.2.2 折半插入排序
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) //high+1其实就是low,统一后移元素,空出插入位置
A[j + 1] = A[j];
A[high + 1] = A[0]; //插入操作
}
}
-
当low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到low所指位置。
-
当A[mid]==A[0]时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置。
-
折半插入排序仅减少了比较次数,约为O(nlog2n),该比较次数与待排序的表的初始状态无关,仅取决于表中的元素个数n;而元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为O(n2)。
-
折半插入排序是一种稳定的排序方法。
7.2.3 希尔排序
void ShellSort(int A[], int n){
//对顺序表做希尔插入排序,本算法和直接插入排序相比,做了以下修改:
//1.前后记录位置的增量是d,不是1
//2.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){
/*这个循环体的操作保证了全部序列中相隔d个元素的位置上的n/d个元素相对有序*/
A[j + d] = A[j];
}
A[j + d] = A[0]; //插入
}//if
}
}
}
- 空间效率:空间复杂度为O(1);
- 时间效率:当n在某个特定范围时,希尔排序的时间复杂度为O(n1.3)。在最坏情况下希尔排序的时间复杂度为O(n2)。
- 希尔排序是一种不稳定的排序方法。
- 希尔排序算法仅适用于线性表为顺序存储的情况
【注意】
- 在待排序的元素序列基本有序的前提下,效率最高的排序方法是直接插入排序。
- 选择排序、冒泡排序、堆排序,在每趟排序后均会使一个记录存放在最终位置上。
- 插入排序经k趟排序后,会使原序列中的前k+1个元素拍成局部有序序列。
- 2路归并排序经过k趟排序后,原序列中每2k个元素局部有序。
7.3 交换排序
7.3.1 冒泡排序
void swap(int & a, int & b){
int temp = a;
a = b;
b = temp;
}
void BubbleSort(int A[], int n){
//用冒泡排序法将序列A中的元素按从小到大排列
for(i = 0; i < n-1; i++){
bool flag = false; //表示本趟冒泡是否发生交换的标志
for(j = n-1; j > i; j--){ //一趟冒泡的过程
if(A[j-1] > A[j]){ //若为逆序
swap(A[j-1], A[j]); //交换
flag = true;
}
}
if(flag == false)
return; //本趟遍历后没有发生交换,说明表已经有序。
}
}
-
空间效率:仅使用了常数个辅助单元,因而空间复杂度为O(1)。
-
时间效率:当初始序列有序时,显然第一趟冒泡后flag依然为false(本趟冒泡没有元素交换),从而直接跳出循环,比较次数为n-1,移动次数为0,从而最好情况下的时间复杂度为O(n);当初始序列为逆序时,需要进行n-1趟排序,第i趟排序要进行n-1次关键字的比较,而且每次比较都必须移动元素3次来交换元素位置。
比较次数 = ∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 , 移动次数 = ∑ i = 1 n − 1 3 ( n − i ) = 3 n ( n − 1 ) 2 比较次数 = \sum_{i=1}^{n-1}(n-i) = \frac{n(n-1)}2, \quad 移动次数 = \sum_{i=1}^{n-1}3(n-i) =\frac{3n(n-1)}2 比较次数=i=1∑n−1(n−i)=2n(n−1),移动次数=i=1∑n−13(n−i)=23n(n−1)
从而,最坏时间复杂度为O(n2),其平均时间复杂度也为O(n2)。
- 稳定性:由于i>j且A[i].ekey = A[j].key时,不会交换两个元素,因此冒泡排序是一种稳定的排序方法。
冒泡排序中所产生的有序子序列一定是全局有序的,这样每趟排序都会一个元素放置在其最终的位置上。
7.3.2 快速排序
//快速排序
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); //划分右子表
}
}
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; //返回存放枢轴的最终位置
}
- 空间效率:由于快速排序是递归的,需要借助一个递归工作栈来跑村每层递归调用的必要信息,其容量应与递归调用的最大深度一致。最好情况下为 ⌈ log 2 ( n + 1 ) ⌉ \lceil \log_2(n+1) \rceil ⌈log2(n+1)⌉;最坏情况下,因为要进行n-1次递归调用,所以栈的深度为O(n);平均情况下,栈的深度为O(log2n)。
- 时间效率:快速排序的运行时间与划分是否对称有关,而后者又与具体使用的划分算法有关。快速排序的最坏情况发生在两个区域分别包含n-1个元素和0个元素时,这种最大程度的不对成性若发生在每层递归上,即对应于初始排序表基本有序时,就得到最坏情况下的时间复杂度为O(n2)。在最理想的状态下,即partition()可能做到最平衡的划分中,得到的两个子问题的大小都不可能大于n/2,在这种情况下,时间复杂度为O(nlog2n)。快速排序平均情况下的运行时间与其最佳情况下的运行时间很接近,时所有内部排序算法中平均性能最优的排序算法。
- 快速排序是一种不稳定的排序方法。
- 在快速排序算法中,并不产生有序子序列,但每趟排序后会将一个元素(基准元素)放到其最终位置上。
- 有的题中会说,对所有尚未确定最终位置的所有元素进行一边处理称为“一趟”排序,因此一次“划分” ≠ \neq =一趟排序。一次划分可以确定一个元素的最终位置,而一趟排序也许可以确定多个元素的最终位置。
【注意】
- 计算冒泡排序的排序的趟数有些类似寻找逆序,注意体会。
- 对于快排,当每次枢轴把表等分为长度相近的两个子表时,速度是最快的;当划分后的子表已经有序或逆序时,都会使得速度变慢;若表本身已经有序或逆序,此时速度最慢。
- 在使用非递归方法实现快速排序时,通常要利用一个栈记忆待排序区间的两个端点,同样也可以用队列来代替栈。在快速排序过程中,通过一趟划分,可以把一个待排序区间分为两个子区间,然后分别对两个子区间施行同样的划分。栈的作用是在处理一个子区间时,保存另一个子区间的上界和下界(排序过程中可能产生新的左、右子区间),待该区间处理完后再从栈中取出另一个子区间的边界,对其进行处理。这个功能用队列也可以实现,只不过处理子区间的顺序有所变动而已。
7.4 选择排序
7.4.1 简单选择排序
//交换
void swap(int & a, int &b){
int temp = a;
a = b;
b = temp;
}
void SelectSort(int A[], int n){
//对表A做简单选择排序,A[]从0开始存放元素
for(int i = 0; i < n-1; i++){
int min = i;
for(j = i+1; j < n; j++)
if(A[j] < A[min])
min = j;
if(min != i)
swap(A[i], A[min]);
}
}
- 空间效率:仅使用常数个辅助单元,空间效率为O(1).
- 时间效率:在简单选择排序过程中,元素移动的操作次数很少,不会超过3(n-1),最好的情况是移动0次;但元素间比较的次数与序列的状态无关,始终是n(n-1)/2,所以时间复杂度始终是O(n2).
- 稳定性:简单选择排序是一种不稳定的排序方法。
7.4.2 堆排序
堆排序是一种树形选择排序方法,其特点是:在排序过程中,将L视为一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)的元素。
非叶结点大于左右孩子结点的堆称为大根堆,非叶结点小于左右孩子结点的堆称为小根堆。在大根堆中,最大元素存放在根结点中,小根堆中,最小元素存放在根结点。堆经常被用来实现优先级队列,优先级队列在操作系统的作业调度和其他领域有广泛的应用。
⭐️对初始序列建堆,是一个反复筛选的过程。n个结点的完全二叉树,最后一个非叶结点是第 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋个结点,对第 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋个结点为根的子树筛选(对于大根堆,若根节点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。之后向前依次对各结点( ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋-1~1)为根的子树进行筛选,看该结点值是否大于其左右子结点的值,若不大于,则将左右子结点中较大者与之交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级堆,直到以该结点为根的子树构成堆为止。反复利用上述调整堆的方法建堆,直到根结点。
初始堆的建立并不是逐个添加结点构成堆的结构,而是先排成一个完全二叉树,再调整成为堆。
//函数AdjustDown将以k为根的子树调整为大根堆
void AdjustDown(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值,以便继续向下筛选
}
}//for
A[k] = A[0]; //被筛选结点的值放入最终位置
}
//建立大根堆
void BuildMaxHeap(int A[], int len){
for(int i = len/2; i > 0; --i) //从后往前调整所有非终端结点
AdjustDown(A, i, len);
}
//堆排序的完整逻辑
void HeapSort(int A[], int len){
BuildMaxHeap(A, len); //初始建堆
for(int i = len; i>1; i--){ //n-1趟的交换和建堆过程
swap(A[i], A[1]); //堆顶元素和堆底元素交换
AdjustDown(A, 1, i-1); //把剩余的待排序元素整理成堆
}
}
可以证明再元素个数为n的序列上建堆,其时间复杂度为O(n),这说明可以在线性时间内将一个无序数组中组建成一个大根堆。
由于堆本身的特点(以大根堆为例),堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大根堆的性质,堆被破坏,将对顶元素向下调整使其继续保持大根堆的性质,再输出堆顶元素,直至队中仅剩下一个元素为止。
//下面是向上调整堆的算法
void adjustUp(ElemType A[], int k){
A[0] = A[k];
int i = k/2;
while(i > 0 && A[i] < A[0])
{
A[k] = A[i];
k = i;
i = k/2;
}//while
A[k] = A[0];
}
- 空间效率:仅使用了常数个辅助单元,所以空间复杂度为O(1).
- 时间效率:建堆时间为O(n),之后有n-1次向下调整操作,每次调整的时间复杂度为O(h),故在最好、最坏和平均情况下,堆排序的时间复杂度为O(nlog2n)
- 稳定性:堆排序算法是一种不稳定的排序方法。
【注意】
- 堆与二叉排序树的区别:
以小根堆为例,堆的特点是双亲结点的关键字必然小于等于该孩子结点的关键字,而两个孩子结点的关键字没有次序规定。在二叉排序树中,每个双亲结点的关键字均大于左子树结点的关键字,均小于右子树结点的关键字,也就是说,每个双亲结点的左、右孩子的关键字有次序关系。这样,当对两种树执行中序遍历后,二叉排序树会得到一个有序的序列,而堆则不一定能得到一个有序的序列。 - 若只想得到一个序列中第k(k≥5)个最小元素之间的部分排序序列,则最好采用什么排序方法?
在基于比较的排序方法中,插入排序、快速排序和归并排序只有在将元素全部排完序后,才能得到前k小的元素序列,算法的效率不高。
冒泡排序、堆排序和简单排序可以,建立初始堆的时间不超过4n,取得第k个最小元素之前的排序序列所花的时间为klog2n,总时间为4n+klog2n;冒泡和简单选择排序完成此功能索化时间为kn,当k≥5时,通过比较可以得出堆排序最优。
7.5 归并排序和基数排序
7.5.1 归并排序
int *B = (int *)malloc(sizeof(int) * n); //辅助数组
//表A的两段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++];
else
A[k] = B[j++];
}//for
//以下两个循环只有一个会执行
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); //归并
}
}
二路归并排序算法的性能分析如下:
- 空间效率:merge()操作中,辅助空间刚好占用n个单元,所以归并排序的时间复杂度为O(n)。
- 时间效率:每趟归并的时间复杂度为O(n),共需要进行 ⌈ log 2 n ⌉ \lceil \log_2n \rceil ⌈log2n⌉趟归并,所以算法时间复杂度为O(nlog2n).
- 稳定性:2路归并排序算法是稳定的排序方法。
7.5.2 基数排序
基数排序是一种不基于比较进行排序,而采用多关键字排序思想(即基于关键字各位的大小进行排序),借助“分配”和“收集”两种操作对单逻辑关键字进行排序。基数排序又分为最高位优先(MSD)和最低位优先(LSD)排序。
- 空间效率:一趟排序需要的辅助存储空间为r(r个队列,r是关键字的取值范围)。空间复杂度为O®.
- 时间效率:基数排序需要进行d(d也是关键字个数)趟分配和收集,一趟分配需要O(n),一趟收集需要O®,所以基数排序的时间复杂度为O(d*(n+r)),它与序列的初始状态无关。
- 稳定性:基数排序是一种稳定的排序方法。
基数排序擅长解决的问题:
- 数据元素的关键字可以方便得拆分为d组,且d较小。
- 每组关键字的取值范围不大,即r较小。
- 数据元素个数n较大。
【注意】
- 选择排序的比较次数与序列初始状态无关,归并排序也与序列的初始状态无关。
- 外部排序通常采用归并排序法。
- 对N个元素进行k路归并排序时,排序的趟数m满足km=N。
7.6 内部排序算法的比较及应用
算法种类 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
折半插入排序 | O(n2) | O(1) | 稳定 | ||
希尔排序 | O(n) | O(n1.3) | O(n2) | O(1) | 不稳定 |
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
快速排序 | O(nlog2n) | O(nlog2n) | O(n2) | 平均:O(log2n);最坏:O(n) | 不稳定 |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 不稳定 |
归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O® | 稳定 |
- 若n较小(n≤50),则可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动操作较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好。
- 若文件初始状态已按关键字基本有序,则选哪用直接插入排序或冒泡排序为宜。
- 若n较大,则应采用时间复杂度为O(nlog2n)的排序方法:快速排序、堆排序或归并排序。快速排序被认为时目前基于比较的内部排序法中最好的方法,当待排序的关键字随机分布时,快速排序的平均时间最短。堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,这两种排序都是不稳定的。若要求排序稳定且时间复杂度为O(nlog2n),则可选用归并排序。但通常将归并排序和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序文件,然后两两归并。直接插入排序是稳定的,因此改进后的归并排序仍是稳定的。
- 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的n个关键字随机分布时,任何借助于“比较”排序算法,至少需要O(nlog2n)的时间。
- 若n很大,记录的关键字位数较少且可以分解时,采用基数排序较好。
- 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。