第八章排序
8.1排序的基本概念
1,定义
排序(sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。
2,评价指标
稳定性:关键字相同的元素经过排序后相对顺序是否会改变
时间复杂度、空间复杂度
3,分类
1,内部排序
数据都在内存中
2,外部排序
数据太多,无法全部放入内存
8.2插入排序
8.2.1插入排序
1,算法思想
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
2,直接插入排序
顺序查找到插入的位置,适用于顺序表,链表
//直接插入排序
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;
}
}
}
空间复杂度O(1)
最好时间复杂度(全部有序):O(n)
最坏时间复杂度(全部逆序):O(n^2)
平均时间复杂度:O(n^2)
稳定性:稳定
3,折半插入排序
折半查找找到应插入的位置,仅适用于顺序表
//折半插入排序
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
lwo = mid + 1//查找右半子表
}
for(j = i - 1; j >= high + 1; --j)
A[j + 1] = A[j];//统一后移元素,空出插入位置
A[high + 1] = A[0];//插入操作
}
}
注意:一直到low>high时才停止折半查找。当mid所指元素等于当前元素时,应继续令low=mid+1. 以保证“稳定性” 最终应将当前元素插入到low所指位置(即high+1)
移动元素的次数变少了,但是关键字对比的次数依然是O(n2)数量级,整体来看时间复杂度依然是O(n2)
4,性能
1,空间复杂度
O(1)
2,时间复杂度
最好:原本有序O(n)
最坏:原本逆序O(n^2)
平均:O(n^2)
3,稳定性
稳定
8.2.2希尔排序
1,算法思想
希尔排序:先将待排序表分割成若干形如L[i.i+d.i+2d…….i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。
//希尔排序
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];//插入
}
}
}
}
2,性能
空间复杂度:O(1)
时间复杂度:未知,但优于直接插入排序
稳定性:不稳定
适用性:仅可用于顺序表
3,高频题型
给出增量序列,分析每一趟排序后的状态
8.3交换排序
8.3.1冒泡排序
1,算法原理
从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换它们,直到序列比较完。称这样过程为“一趟”冒泡排序。最多只需n-1趟排序
每一趟排序都可以使一个元素的移动到最终位置,已经确定最终位置的元素在之后的处理中无需再对比。
如果某一趟排序过程中未发生“交换”,则算法可提前结束。
//交换
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 == flase)
{
return;//本趟遍历后没有发送交换,说明已经有序,提前结束算法
}
}
}
2,性能
空间复杂度:O(1)
时间复杂度:
最好O(n),有序
最差O(n^2),逆序
平均O(n^2)
稳定性:稳定
适用性:顺序表、链表都可以
8.3.2快速排序
1,算法思想
算法思想:在待排序表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)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
//用第一个元素将待排序序列划分成左右两个部分
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, pivot + 1, high);//划分右子表
}
}
2,性能
算法表现主要取决于递归深度,若每次“划分”越均匀,则递归深度越低。“划分”越不均匀,递归深度越深。
空间复杂度
最好:O(n)
最坏:O(log n)
时间复杂度
最好:O(nlog2n),每次划分很平均
最坏:O(n^2),原本正序或逆序
平均:O(nlog2n)
稳定性
不稳定
8.4选择排序
选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列
8.4.1简单选择排序
1,算法原理
每一趟在待排序元素中选取关键字最小的元素加入有序子序列
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++)
{
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;
}
2,性能
空间复杂度:0(1)
时间复杂度:O(n^2)
稳定性:不稳定
适用性:顺序表、链表都可以
8.4.2堆排序
1,堆
顺序存储的“完全二叉树”
结点i的左孩子是2i;右孩子是2i+1;父节点是i/2
编号≤n/2的结点都是分支结点
大根堆(根≥左、右);小根堆(根≤左、右)
2,算法思想
1,建堆
编号≤n/2的所有结点依次“下坠”调整(自底向上处理各分支节点)
调整规则:小元素逐层“下坠”(与关键字更大的孩子交换)
//建立大根堆
void BuildMaxHeap(int A[], int len)
{
for(int i = len/2; i > 0; i--)//从后往前调整所有非终端节点
{
HeadAdjusr(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];//被筛选节点的值放入最终位置
}
2,排序
堆顶元素加入有序子序列(堆顶元素与堆底元素交换)
准底元素换到堆顶后,需要进行“下坠”调整,恢复“大根堆”的特性
上述过程重复n-1趟
3,特性
空间复杂度:O(1)
时间复杂度:建堆O(n)、排序O(nlogn);总的时间复杂度=O(nlog n)
稳定性:不稳定
基于大根堆的堆排序得到“递增序列”,基于小根堆的堆排序得到“递减序列”
8.4.3堆的插入和删除
1,插入
新元素放到表尾(堆底)
根据大/小根堆的要求,新元素不断“上升”,直到无法继续上升为止
2,删除
被删除元素用表尾(堆底)元素替代
根据大/小根堆的要求,替代元素不断“下坠”,直到无法继续下坠为止
3,关键字对比次数
每次“上升”调整只需对比关键字1次
每次“下坠”调整可能需要对比关键字2次,也可能只需对比1次
4,基本操作
i的左孩子–2i
i的右孩子–2i+1
i的父节点–[i/2]
8.5归并排序和基数排序
8.5.1归并排序
1,定义
把两个或多个有序的子序列合并为一个
2路归并——二合一
k路归并——k合一
m路归并选择一个元素需要比较m-1次
2,算法
①若low<high,则将序列分从中间mid=(low+high)/2分开
②对左半部分[low,mid]递归地进行归并排序
③对右半部分[mid+1,high]递归地进行归并排序
④将左右两个有序子序列Merge为一个
3,性能
空间复杂度:O(n)
时间复杂度:O(nlog n)
稳定性:稳定的
8.5.2基数排序
1,算法思想
将整个关键字拆分为d位(或“组”)
按照各个关键字位权重递增的次序(如:个、十、百),做d趟“分配”和“收集”,若当前处理的关键字位可能取得r个值,则需要建立r个队列
分配:顺序扫描各个元素,根据当前处理的关键字位,将元素插入相应队列。一趟分配耗时O(n)
收集:把各个队列中的结点依次出队并链接。一趟收集耗时O(r)
2,性能
空间复杂度:O®
时间复杂度:O(d(n+r))
稳定性:稳
3,擅长处理
①数据元素的关键字可以方便地拆分为d组,且d较小
②每组关键字的取值范围不大,即r较小
③数据元素个数n较大
8.7外部排序
8.7.1外部排序的基本概念
1,外存与内存之间的数据交互
操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB。各个磁盘块内存放着各种各样的数据
磁盘的读/写以“块”为单位
数据读入内存后才能被修改
修改完了还要写回磁盘
2,外部排序的原理
外部排序:数据元素太多,无法一次全部读入内存进行排序
使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大文件进行排序
3,如何进行K路归并
把k个归并段的块读入k个输入缓冲区
用“归并排序”的方法从k个归并段中选出几个最小记录暂存到输出缓冲区中
当输出缓冲区满时,写出外存
4,外部排序时间开销
读写外存的时间+内部排序所需时间+内部归并所需时间
5,优化
增加归并路数K,进行多路平衡归并:
代价1:需要增加相应的输入缓冲区
代价2:每次从k个归并段中选一个最小元素需要(k-1)次关键字对比
减少初始归并段数量r
8.7.2败者树
败者树解决的问题:使用多路平衡归并可减少归并趟数,但是用老土方法从k个归并段选出一个最小/最大元素需要对比关键字k-1次,构造败者树可以使关键字对比次数减少到[log2k]。
败者树可视为一棵完全二叉树(多了一个头头)。k个叶结点分别对应k个归并段中当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。
8.7.3置换——选择排序
设初始待排文件为F,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,WA可容纳w个记录。置换-选择算法的步骤如下:
1)从F输入w个记录到工作区WA。
2)从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
3)将MINIMAX记录输出到FO中去。
4)若F不空,则从F输入下一个记录到WA中。
5)从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的
MINIMAX记录。
6)重复3)~5),直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输
出一个归并段的结束标志到FO中去。
7)重复2)~6),直至WA为空。由此得到全部初始归并段。
8.7.4最佳归并树
1,理论基础
每个初始归并段对应一个叶子结点,把归并段的块数作为叶子的权值
归并树的WPL=树中所有叶结点的带权路径长度之和
归并过程中的磁盘l/O次数=归并树的WPL*2
2,注意:
k叉归并的最佳归并树一定是严格k叉树,即树中只有度为k、度为0的结点
3,如何构造
1,补充虚段
①若(初始归并段数量-1)%(k-1)=0,说明刚好可以构成严格k叉树,此时不需要添加虚段
②若(初始归并段数量-1)%(k-1)=u≠0,则需要补充(k-1)-u个虚段
2,构造k叉哈夫曼树
每次选择k个根节点权值最小的树合并,并将k个根节点的权值之和作为新的根节点的权值