这是本人根据王道考研数据结构课程整理的笔记,希望对您有帮助。
8.1 排序的基本概念
排序算法的稳定性
排序算法的分类
- 内部排序:数据都砸内存中(关注如何使算法时间、空间复杂度更低)
- 外部排序:数据太多,无法全部放入内存(还要关注如何使读/写磁盘次数更少)
8.2 插入排序
8.2.1 直接插入排序
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
//直接插入排序
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 ( n 2 ) O(n^2) O(n2)
稳定性:稳定
8.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)
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 2 ) O(n^2) O(n2)。
- 如果采用链表,移动元素的次数变少了,但是关键字对比的次数依然是 O ( n 2 ) O(n^2) O(n2)数量级,整体来看时间复杂度依然是 O ( n 2 ) O(n^2) O(n2)。
8.2.3 希尔排序
算法思想:先追求表中元素部分有序,再逐渐逼近全局有序
希尔排序:先将待排序表分割成若干形如 L [ i , i + d , i + 2 d , … , i + k d ] L[i,i+d,i+2d,\dots,i+kd] L[i,i+d,i+2d,…,i+kd]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量 d d d,重复上述过程,直到 d = 1 d=1 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]; //插入
}
}
}
}
希尔排序的时间复杂度和增量序列 d 1 , d 2 , d 3 , … d_1,d_2,d_3,\dots d1,d2,d3,…的选择有关,目前无法用数学手段证明确切的时间复杂度。
时间复杂度:最坏时间复杂度为 O ( n 2 ) O(n^2) O(n2),当 n n n在某个范围内时,可达 O ( n 1.3 ) O(n^{1.3}) O(n1.3)
稳定性:不稳定
适用性:仅适用于顺序表,不适用于链表
8.3 交换排序
基于“交换”的排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置
8.3.1 冒泡排序
从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]
),则交换它们,直到序列比较完。称这样过程为“一趟”冒泡排序。
(待排序序列)
(第1趟)
(第2趟)
(以下略)
//交换
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; //本趟遍历后没有发生交换,说明表已经有序
}
}
平均时间复杂度: O ( n 2 ) O(n^2) O(n2)
稳定性:稳定
冒泡排序可以适用于链表,从前往后比较即可。
8.3.2 快速排序
算法思想:在待排序表
L
[
1
,
…
,
n
]
L[1,\dots,n]
L[1,…,n]中任取一个元素
pivot
\text{pivot}
pivot作为枢轴(或基准,通常取首元素),通过一趟排序将排序表划分为独立的两部分
L
[
1
,
…
,
k
−
1
]
L[1,\dots,k-1]
L[1,…,k−1]和
L
[
k
+
1
,
…
,
n
]
L[k+1,\dots,n]
L[k+1,…,n],使得
L
[
1
,
…
,
k
−
1
]
L[1,\dots,k-1]
L[1,…,k−1]中的所有元素小于
pivot
\text{pivot}
pivot,
L
[
k
+
1
,
…
,
n
]
L[k+1,\dots,n]
L[k+1,…,n]中的所有元素大于等于
pivot
\text{pivot}
pivot,则
pivot
\text{pivot}
pivot放在了其最终位置
L
(
k
)
L(k)
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, pivotpos + 1, high); //划分右子表
}
}
时间复杂度: O ( n × 递 归 层 数 ) O(n\times递归层数) O(n×递归层数)
- 最好时间复杂度: O ( n log 2 n ) O(n\log_2n) O(nlog2n)
- 最坏时间复杂度: O ( n 2 ) O(n^2) O(n2)
稳定性:不稳定
- 若每一次选中的“枢轴”将待排序序列划分为很不均匀的两个部分,则会导致递归深度增加,算法效率变低。
- 若每一次选择的“枢轴”将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高。
优化思路:尽量选择可以把数据中分的枢轴元素。
- 选头、中、尾三个位置的元素,取中间值作为枢轴元素
- 随机选一个元素作为枢轴元素
8.4 选择排序
算法思想:每一趟在待排序元素中选取关键字最小的元素加入有序子序列
8.4.1 简单选择排序
//交换
void swap(int &a, int &b)
{
int temp = a;
a = b;
b = temp;
}
//简单选择排序
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]); //封装的swap()函数共移动元素3次
}
}
时间复杂度: O ( n 2 ) O(n^2) O(n2)
稳定性:不稳定
适用性:既可以适用于顺序表,也可用于链表
8.4.2 堆排序
堆的定义
若 n n n 个关键字序列 L [ 1 , … , n ] L[1,\dots,n] L[1,…,n] 满足下面某一条性质,则成为堆(Heap):
- 若满足:
L
(
i
)
≥
L
(
2
i
)
L(i)\ge L(2i)
L(i)≥L(2i) 且
L
(
i
)
≥
L
(
2
i
+
1
)
L(i)\ge L(2i+1)
L(i)≥L(2i+1),其中
(
1
≤
i
≤
n
/
2
)
(1\le i \le n/2)
(1≤i≤n/2) ——大根堆(大顶堆)
- 从二叉树的顺序存储角度来看,大根堆:根 ≥ 左、右
- 若满足:
L
(
i
)
≤
L
(
2
i
)
L(i)\le L(2i)
L(i)≤L(2i) 且
L
(
i
)
≤
L
(
2
i
+
1
)
L(i)\le L(2i+1)
L(i)≤L(2i+1),其中
(
1
≤
i
≤
n
/
2
)
(1\le i \le n/2)
(1≤i≤n/2) ——小根堆(小顶堆)
- 从二叉树的顺序存储角度来看,大根堆:根 ≤ 左、右
二叉树的顺序存储:
按层序从1开始编号,结点 i i i 的左孩子为 2 i 2i 2i,右孩子为 2 i + 1 2i+1 2i+1;结点 i i i 的父节点为 ⌊ i 2 ⌋ \lfloor \frac{i}{2} \rfloor ⌊2i⌋(如果有的话)
建立大根堆
思路:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整。
- 检查当前结点是否满足根 ≥ 左、右。若不满足,将当前结点与更大的一个孩子互换。
- 若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整(小元素不断“下坠”)
//建立大根堆
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; //修改key值,以便继续向下筛选
}
}
A[k] = A[0]; //被筛选结点的值放入最终位置
}
建堆时间复杂度: O ( n ) O(n) O(n)
基于大根堆进行排序
堆排序:
- 每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换)
- 并将待排序元素序列再次调整为大根堆(小元素不断“下坠”)
- 基于大根堆得到的是递增序列(小根堆是递减)
//堆排序的完整逻辑
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); //把剩余的待排序元素整理成堆
}
}
堆排序的时间复杂度: O ( n ) + O ( n log 2 n ) = O ( n log 2 n ) O(n)+O(n\log_2n)=O(n\log_2n) O(n)+O(nlog2n)=O(nlog2n)
稳定性:不稳定
8.4.3 堆的插入和删除
在堆中插入新元素
对于小根堆,新元素放到表尾,与父节点对比。若新元素比父节点更小,则将二者互换。新元素就这样一路“上升",直到无法继续上升为止。
在堆中删除元素
对于小根堆,被删除的元素用堆底元素替代,然后让该元素不断下坠,直到无法下坠为止。
8.5 归并排序和基数排序
8.5.1 归并排序(Merge Sort)
归并:Merge,把两个或多个已经有序的序列合并成一个
“2路”归并:把两个已经有序的序列合并成一个
对比
i
,
j
i,j
i,j 所指元素,选择更小的一个放入
k
k
k 所指位置。
只剩一个子表未合并时,可以将该表中剩余元素全部加到总表。
“4路”归并:把四个已经有序的序列合并成一个
对比 p 1 , p 2 , p 3 , p 4 p_1,p_2,p_3,p_4 p1,p2,p3,p4 所指元素,选择更小的一个放入 k k k 所指位置。
每选出一个元素需对比关键字3次。
“m路”归并:每选出一个元素需要对比关键字m-1次
归并排序(一般采用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++];
else
A[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); //归并
}
}
归并时间复杂度: O ( n log 2 n ) O(n\log_2n) O(nlog2n)
稳定性:稳定
8.5.2 基数排序(Radix Sort)
基数排序
假设长度为 n n n 的线性表中每个结点 a j a_j aj 的关键字由 d d d 元组 ( k j d − 1 , k j d − 2 , k j d − 3 , … , k j 1 , k j 0 ) (k_j^{d-1},k_j^{d-2},k_j^{d-3},\dots,k_j^{1},k_j^{0}) (kjd−1,kjd−2,kjd−3,…,kj1,kj0)组成。其中, 0 ≤ k j i ≤ r − 1 ( 0 ≤ j < n , 0 ≤ i ≤ d − 1 ) 0 \leq k_{j}^{i} \leq r-1 (0 \leq j<n, 0 \leq i \leq d-1) 0≤kji≤r−1(0≤j<n,0≤i≤d−1), r r r 称为“基数”。
-
基数排序得到递减序列的过程如下:
-
初始化:设置** r r r个空队列**, Q r − 1 , Q r − 2 , … , Q 0 Q_{r-1},Q_{r-2},\dots,Q_{0} Qr−1,Qr−2,…,Q0。按照各个关键字位权重递增的次序(个、十、百),对 d d d 个关键字位分别做“分配”和“收集”
-
分配:顺序扫描各个元素,若当前处理的关键字位 = x =x =x,则将元素插入 Q x Q_{x} Qx 队尾。
-
收集:把 Q r − 1 , Q r − 2 , … , Q 0 Q_{r-1},Q_{r-2},\dots,Q_{0} Qr−1,Qr−2,…,Q0 各个队列中的结点依次出队并链接。
-
-
基数排序得到递增序列的过程如下:
-
初始化:设置** r r r个空队列**, Q 0 , Q 1 , … , Q r − 1 Q_{0},Q_{1},\dots,Q_{r-1} Q0,Q1,…,Qr−1。按照各个关键字位权重递增的次序(个、十、百),对 d d d 个关键字位分别做“分配”和“收集”
-
分配:顺序扫描各个元素,若当前处理的关键字位 = x =x =x,则将元素插入 Q x Q_{x} Qx 队尾。
-
收集:把 Q 0 , Q 1 , … , Q r − 1 Q_{0},Q_{1},\dots,Q_{r-1} Q0,Q1,…,Qr−1 各个队列中的结点依次出队并链接。
-
应用背景
- 数据元素的关键字可以方便地拆分为 d d d 组,且 d d d 较小
- 每组关键字的取值范围不大,即 r r r 较小
- 数据元素个数 n n n 较大
过程演示
(第一趟)
(第二趟)
(第三趟)
时间复杂度: O ( d ( n + r ) ) O(d(n+r)) O(d(n+r))(把关键字拆为 d d d 个部分,每个部分可能取得 r r r 个值。
稳定性:稳定
实现:基数排序通常基于链式存储实现
8.6 外部排序
8.6.1 外部排序
外部排序:数据元素太多,无法一次全部读入内存进行排序
使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大文件进行排序
“归并排序”要求各个子序列有序,每次读入两个块的内容,进行内部排序后写回磁盘。
(得到初始归并段)
(对两个输入缓冲区中的6个数据排序)
(把输入缓冲区1的内容放到输出缓冲区,然后将输出缓冲区的内容写回磁盘;把输入缓冲区2的内容放到输出缓冲区,然后将输出缓冲区的内容写回磁盘)
(重复上述操作,得到8个初始的归并段)
(第一趟归并:把8个归并段两两归并)
(输出缓冲区凑满就写入外存,这里外存是另外开辟的空间,写满了以后直接取代原空间)
(第一趟归并的结果)
(第二趟归并:把4个归并段两两归并)
(第三趟归并:把2个归并段两两归并)
外部时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间
优化思路:采用多路归并(增加输入缓冲区数量)可以减少归并趟数,从而减少磁盘I/O(读写)次数
8.6.2 败者树
败者树:可视为一棵完全二叉树(多了一个头头)。 k k k 个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。
- 对于 k k k 路归并,第一次构造败者树需要对比关键字 k − 1 k-1 k−1 次。
- 有了败者树,选出最小元素,只需对比关键字 ⌈ log 2 k ⌉ \lceil \log_2k \rceil ⌈log2k⌉ 次。
败者树解决的问题:使用多路平衡归并可减少归并趟数,但是用老土方法从 k k k 个归并段选出一个最小/最大元素需要对比关键字 k − 1 k-1 k−1 次,构造败者树可以使关键字对比次数减少到 ⌈ log 2 k ⌉ \lceil \log_2k \rceil ⌈log2k⌉。
败者树可视为一棵完全二叉树(多了一个头头)。 k k k 个叶结点分别对应 k k k 个归并段中当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。
过程演示
(第一次构造败者树)
(选出最小元素是归并段3中的1,拿走,用6填上)
(一层一层往上对比,归并段5胜出,拿走归并段5中的2,3填上)
(这里只对比了3次)
8.6.3 置换-选择排序
(从待排序文件读入4,6,9)
(输出4,将MINIMAX置为4)
(读入7)
(发现内存工作区中最小的元素6大于MINIMAX=4,输出6,并读入13,将MINIMAX置为6)
(发现内存工作区中最小的元素7大于MINIMAX=6,输出7,并读入11,将MINIMAX置为7)
(以此类推,直到内存工作区中最小的元素10小于MINIMAX=11,排除掉10)
(发现内存工作区中最小的元素14大于MINIMAX=13,输出14,并读入22,将MINIMAX置为14)
(以此类推,直到内存工作区中所有元素都比MINIMAX小,则该归并段截止)
(重复上述过程,得到剩余的归并段)
由此得到3个归并段,远小于正常得到的8个归并段,可以用于外部排序的初始归并段的选择。