本文来源于《数据结构考研复习指导》,仅做笔记记录用。
文章目录
基本概念
排序的定义
排序,就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。
排序的确切定义如下:
- 输入
n个记录R1,R2,…,Rn,对应关键字为k1,k2,…,kn。 - 输出
输入序列的一个重排R1’,R2’,…,Rn’,使得k1’ ≤ k1’ ≤ … ≤ kn’。
算法的稳定性
若待排序表中有元素Ri和Rj,其对应的关键字相同即keyi = keyj,且在排序前Ri在Rj前面,若使用某一排序算法后,Ri仍然在Rj前面,则称这个排序算法是稳定的,否则成排序算法是不稳定的。
排序算法的分类
数据元素是否完全在内存中
根据数据元素是否完全在内存中,可以将排序算法分为两类:内部排序、外部排序。
- 内部排序
内部排序是指排序期间内元素全部存放在内存中的排序。 - 外部排序
外部排序是指排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。
类型分类
通常可以将排序算法分为插入排序、交换排序、选择排序、归并排序和基数排序五大类。
插入排序
基本思想
插入排序的基本思想是每次将一个待排序的记录按其关键字大小插入到已排好序的子序列中,直到全部记录插入完成。
由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序、希尔排序。
直接插入排序
操作
假设在排序过程中,待排序表L[1…n] 在某次排序过程中的某一时刻状态如下:
有序序列L[1…i-1] | L(i) | 无序序列L[i+1…n] |
---|
要将元素L(i)插入到已有的子序列L[1…i - 1]中,需要执行以下操作:(L[] 表示一个表,L()表示一个元素)
- 查找出L(i)在L[1…i - 1]中的插入位置k。
- 将L[k… i - 1]中的所有元素依次后移一个位置。
- 将L(i)赋值到L(k)
算法实现
void InsertSort(ElemType 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]; // 复制为哨兵
for (j = i - 1; A[0] < A[j]; --j) // 从后往前查找待插入位置
A[j + 1] = A[j]; // 向后挪位
A[j + 1] = A[0]; // 复制到插入位置
}
}
算法性能分析
- 空间效率:O(1)
- 时间效率:
- 最好情况:O(n)(表中元素已有序)
- 最坏情况:总比较次数为 ∑ i = 2 n i \sum_{i=2}^ni i=2∑ni总的移动次数也达到最大,为 ∑ i = 2 n ( i + 1 ) \sum_{i=2}^n(i+1) i=2∑n(i+1)
- 平均情况:O(n2)
- 稳定性:由于每次插入元素总是从后向前比较再移动,因此不会出现相同元素相对位置变化,即稳定。
- 适用性:使用于顺序存储和链式存储的线性表。
折半插入排序
操作
由于是顺序存储的线性表,所以查找有序子表的时候可以用折半查找来实现。确定待插入位置后,就可以统一地向后移动元素。
算法实现
void InsertSort(ElemtType 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]; // 插入操作
}
}
算法性能分析
- 时间效率:比较次数约为O(nlog2n),该比较次数与待排序表的初始状态无关,仅取决于表中的元素个数n;而元素移动次数并未改变,依赖于表的初始状态。因此,时间复杂度仍为O(n2)
- 适用性:使用于顺序存储的线性表。对于数据量不很大的排序表,折半插入排序往往能表现出很好的性能。
- 稳定性:折半插入排序是一种稳定的排序方法。
希尔排序
基本思想
先将待排序表分割成若干个形如L[i, i + d, i + 2d, …, i + kd]的“特殊”子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。
过程
- 先取一个小于n的步长d1,把表中的全部记录分成d1组,所有距离d1的倍数的记录放在同一组,在各组内进行直接插入排序。
- 然后取第二个步长d2<d1,重复上述过程。
- 直到dt=1,即所有记录都在同一组。再进行直接插入排序,获得最终结果。
希尔提出的方法是:d1=n/2,di-1=└di/2┘,并且最后一个增量等于1。
算法实现
void ShellSort(ElemType A[], int n) {
//A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
for (dk = n / 2; dk >= 1; dk = dk / 2) // 步长变化
for (i = dk + 1; i <= n; ++i)
if (A[i] < A[i - dk]) { // 需将A[i]插入有序增量子表
A[0] = A[i]; //暂存在A[0]
for (j = i - dk; j > 0 && A[0] < A[j]; j -= dk)
A[j + dk] = A[j]; // 记录后移,查找插入的位置
A[j + dk] = A[0]; // 插入
}
}
算法性能分析
- 空间效率:O(1)
- 时间效率:当n在某个特定范围时,希尔排序的时间复杂度约为O(n1.3)。在最坏情况下,时间复杂度为O(n2)。
- 适用性:使用于顺序存储的线性表
- 稳定性:当相同关键字的记录被划分到不同子表时,可能会改变它们之间的相对位置,因此是不稳定的。
交换排序
交换排序是指根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
冒泡排序
基本思想
从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i- 1] > A[i]),则交换它们,直到序列比较完。下一趟冒泡时,前一趟确定的最小元素不再参与比较,每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置…这样最多做n - 1趟冒泡就能把所有元素排好序。
算法实现
void BubbleSort(ElemType A[], int n) {
for (i = 0; i < n - 1; i++) {
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)
- 时间效率:
- 最好情况:O(n)。(当初始序列有序时,比较次数为n - 1,移动次数为0。)
- 最坏情况:O(n2)。(当初始序列逆序,需要进行n-1趟排序,第i趟排序要进行n-i次关键字比较,而且每次比较后都必须移动元素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} \\ 移动次数=\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)
- 稳定性:稳定。
快速排序
基本思想
在待排序表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)上。这个过程称为一趟快速排序(或一次划分)。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止。即所有的元素放在了其最终位置上。
算法实现
Partition()
int Partition(ElemType A[], int low, int high) { // 一趟划分
// 将当前表中第一个元素设为枢轴,对表进行划分
ElemType pivot = A[low];
// 条件跳出循环
while (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(ElemType A[], int low, int high) {
if (low < high) { // 跳出递归的条件
// Partition()就是划分操作,将表A[low...high]划分为两个子表
int pivotpos = Partition(A, low, high); // 划分
QuickSort(A, low, pivotpos - 1); // 依次对两个子表进行递归排序
QuickSort(A, pivotpos + 1, high);
}
}
算法性能分析
- 空间效率:快排需要一个工作栈保存每层递归调用的必要信息。
- 最好情况:O(log2n)
- 最坏情况:O(n)
- 平均情况:O(log2n)
- 时间效率:
- 最坏情况:O(n2)(初始排序表基本逆序)
- 最好情况:O(nlog2n)
- 稳定性:在划分算法中,若右端区间有两个相同关键字,且均小于基准值,则在交换到左端区间后相对位置会发生变化,即不稳定。
快速排序是所有内部排序算法中平均性能最优的排序算法。
选择排序
基本思想
每一趟(如第i趟)在后面n - i + 1(i = 1, 2, …, n - 1)个待排序元素中选择关键字最小的元素,作为有序子序列的第i个元素,直到第n - 1趟做完,待排序元素只剩下1个,就不用再选了。
简单选择排序
基本思想
假设排序表为L[1…n],第i趟排序即从L[i…n]中选择关键字最小的元素与L(i)交换,每一趟排序可以确定一个元素的最终位置,这样经过n-1趟排序就可以使得整个排序表有序。
算法实现
void SelectSort(ElemType A[], int n) {
for (i = 0; i < n - 1; i++) { // 一共进行n-1趟
min = i; // 记录最小元素位置
for (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(1)
- 时间效率:O(n2)
- 稳定性:不稳定。
堆排序
堆的定义
堆的定义如下,n个关键字序列L[1…n]称为堆,当且仅当满足:
- L(i)>= L(2i)且 L(i)>= L(2i + 1)或
- L(i)<= L(2i)且 L(i)<= L(2i + 1)(1 ≤ i ≤ └n / 2┘)
可以将该以为数组视为一棵完全二叉树,满足条件1的堆称为大根堆(大顶堆),大根堆的最大元素存放在根结点,且其任一非根结点的值小于等于其双亲结点值。满足条件2的堆称为小根堆(小顶堆),小顶堆的定义正好相反,根元素是最小元素。
基本思路
- 首先将存放在L[1…n]中的n个元素建成初始堆,由于堆本身的特点(以大顶堆为例),堆顶元素就是最大值。
- 输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大堆顶的性质,堆被破坏,将堆顶元素向下调整使其继续保持大顶堆的性质,再输出堆顶元素。
- 如此重复,直到堆中仅剩一个元素为止。
算法实现
建立大根堆的算法
void BuildMaxHeap(ElemType A[], int len) {
for (int i = len / 2; i > 0; i--) // 从i=[n/2]~1,反复调整堆
HeadAdjust(A, i, len);
}
void HeadAdjust(ElemType A[], int k, int len) {
// 函数HeadAdjust将元素k为根的子树进行调整
A[0] = A[k]; // A[0]暂存子树根结点
for (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]; // 被筛选结点的值放入最终位置
}
堆排序算法
void HeapSort(ElemType A[], int len) {
BuildMaxHeap(A, len); // 初始建堆
for (i = len; i > 1; i--){ // n-1趟的交换和建堆过程
Swap(A[i], A[1]); // 输出堆顶元素
HeadAdjust(A, 1, i - 1); // 调整,把剩下的i-1个元素整理成堆
}
}
算法性能分析
- 空间效率:O(1)
- 时间效率:O(nlog2n)(建堆时间为O(n),之后有n-1次向下调整操作,每次调整时间复杂度为O(h))
- 稳定性:进行筛选时,可能把后面相同关键字的元素调整到前面,即不稳定。
归并排序
基础思想
“归并”的含义是将两个或以上的有序表组合成一个新的有序表。假定待排序表含有n个记录,则可将其视为n个有序的子表,每个子表的长度为1,然后两两归并,得到┌n / 2┐个长度为2或1的有序表,然后继续两两归并…如此重复,直到合并成一个长度为n的有序表为止。这种方法称为2路归并排序。
算法实现
分解
将含有n个元素的待排序表分成各含有n / 2个元素的子表,采用2路归并排序算法对两个子表递归地进行排序。
// 辅助数组B
ElemType *B = (ElemType *)malloc((n+1) * sizeof(ElemType));
void Merge(ElemType A[], int low, int high, int mid) {
// 表A的两段A[low...mid]和A[mid+1...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); // 归并
}
}
算法性能分析
- 空间效率:O(n)
- 时间效率:O(nlog2n)(每趟归并的时间复杂度为O(n),共需进行┌log2n┐)
- 稳定性:稳定。
基数排序
基本思想
假设长度为n的线性表中每个结点aj的关键字由d元组(kjd-1,kjd-2,…,kj1,kj0)组成,满足0 ≤ kji ≤ r - 1(0 ≤ j < n,0 ≤ i ≤ d - 1)。其中djd-1为最主关键字,kj0为最次关键字。
类型
- 最高为优先(MSD)
按关键字位权重递减一次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列。 - 最低位优先(LSD)
按关键字权重递增依次进行排序,最后形成一个有序序列。
过程
以r为基数的最低位优先基数排序的过程,在排序过程中,使用r个队列Q0,Q1,…,Qr-1。
对i = 0,1,…,d - 1,依次做一次“分配”和“收集”。
分配:开始时,把Q0,Q1,…,Qr-1各个队列置成空队列,然后依次考察线性表中的每个结点aj(j = 0,1,…,n-1),若aj的关键字kji = k,就把aj放进Qk队列中。
收集:把Q0,Q1,…,Qr-1各个队列中的结点依次首尾相接,得到新的结点序列,从而组成新的线性表。
算法性能分析
- 空间效率:O(r)(一趟排序需要的辅助存储空间为r(r个队列:r个队头指针和r个队尾指针))
- 时间效率:O(d(n+r))(基数排序要进行d趟分配和收集,一趟分配需要O(n),一趟收集需要O(r))
- 稳定性:稳定。
【mark:错题整理未整理完,后会补上】