针对近两天所复盘的《数据结构》中的内排序部分,做一个小总结。尽力在最大程度上把考点重现出来,以便复盘之需。
本文所有算法均使用 C语言 实现。
本博客仅从个人的考点梳理为基,内容相较全局还有很多缺失,读者请权衡参考。
目录
内排序
基本概念
对于文件而言,排序是 根据记录关键字值的递增或者递减关系将记录的次序进行重新排列,使得原来一组次序任意的记录转变为按其关键字值有序进行排列的一组记录;
排序操作 (功能 1)—— 将一个按值无序的数据元素序列转换为一个按值有序排列的数据元素序列;
又称为 分类
(功能 2)提高查找时间效率
分类方法
稳定排序 和 非稳定排序
参加排序的项 (关键字)—— 排序码 或 排序项
排序码相同的记录可能只有一个,也可能有多个
对于具有相同排序码的多个记录而言,若采用的排序方法使得排序后记录的相对位置保持不变,则称 此排序为稳定的,否则为不稳定的
连续顺序文件排序 和 链表排序
取决于文件在存储介质的组织方式
连续顺序文件排序
记录之间的逻辑顺序是通过其物理地址的先后来映射,因而在排序过程中需要移动记录的位置
链表排序
文件中的一个记录对应链表中的一个链结点
记录之间的逻辑顺序通过指针来反映
因此排序过程值无需移动记录的位置,只需改变指针的指向
按照所需工作量划分
简单排序法
先进排序法
基数排序法
按照所采用的策略
插入排序
选择排序
交换排序
归并排序
基数排序
-
In-place —— 占用常数级内存,不占用额外内存
-
Out-place —— 占用额外内存
-
k —— “桶”个数
-
n —— 数据规模
就排序方法的全面性而言,不能断言某一种方法为最好,因为各有各自的优势与不足,只有根据所处的环境和情况(序列数据量、序列的初始状态)选择合适的
衡量主要指标
执行排序算法所需要的时间
比较两个元素的大小
将元素从一个位置移动到另一个位置 (或 改变指针的指向)
(排序的工作量取决于这两种动作的执行次数,尤其是前一个动作)
执行排序算法所需要的附加空间
在将一个按值任意的序列转换为按值有序排列的序列的过程中,大多排序方法都要经过若干趟处理才能达到目的
直接插入排序及折半插入排序
基本原理及特点
插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入;
插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入;
代码实现
#include <stdio.h>
// 直接插入排序
void insertionSort(int a[], int len) {
int i, j, key;
for (int i = 1; i < len; i++) {
key = a[i];
j = i - 1;
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
j--;
}
a[j + 1] = key;
}
}
// 折半插入排序
void binInsertionSort(int a[], int len0) {
int low, high, mid;
int len = len0 - 1;
for (int i = 2; i <= len; i++) {
a[0] = a[i]; // a[0] 监视哨 不参与排序
low = 1;
high = i - 1;
while (low <= high) {
mid = (low + high) >> 1;
if (a[0] >= a[mid]) {
low = mid + 1;
} else {
high = mid - 1;
}
}
for (int j = i - 1; j >= low; j--) {
r[j + 1] = r[j];
}
r[low] = r[0];
}
}
直接插入
-
最好情况 —— 初始序列已经有序,比较次数最少,为 \Sigma_{i= 2}^{n}1 = n - 1 ,且无需移动记录
-
最坏情况 —— 初始序列完全逆序,比较次数最多,为 \Sigma_{i=2}^{n}(i-1) = n(n-1)/2
-
排序总趟数(最坏情况) —— n - 1
-
平均时间复杂度 —— O(n^2)
-
稳定排序方法
折半插入
-
最坏情况下与直接插入方法一样,但在最好情况下时间复杂度降为 O(n log_2n)
选择排序
基本原理及特点
一种简单直观的排序算法,无论怎样的数据规模,时间复杂度都为 O(n²) ,因此用到它的时候,数据规模越小越好。唯一的好处在于不占用额外的内存空间。
算法步骤
首先在初始序列中找到最小(大)元素,存放到排序序列的起始位置;
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾;
重复第二步,直到所有元素均排序完毕;
代码实现
#include <stdio.h>
// 原生选择排序(升序)
void selectionSort(int a[], int len) {
for (int i = 0; i < len; i++) {
int minIndex = i;
for (int j = i + 1; j < len; j++) {
if (a[i] > a[j]) {
minIndex = j;
}
}
int t = a[i];
a[i] = a[minIndex];
a[minIndex] = t;
}
}
-
与插入排序一样,对于数据规模 n ,需要经过的总趟数为 n - 1
-
元素移动次数
-
(原始序列为升序时)最少为 0 次
-
(原始序列为降序时)最多为 3 * (n - 1)
-
其中,3 表示:交换的执行次数
-
-
-
比较总次数恒为 n(n - 1) / 2 —— 与原始序列的排序情况无关
冒泡排序
基本原理和特点
冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序,但这种改进对性能并不会有显著的提升。
算法步骤
比较相邻的元素。如果第一个比第二个大,就交换它们两个;
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数;
针对所有的元素重复以上的步骤,除了最后一个;
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较;
代码实现
#include <stdio.h>
void bubbleSort(int a[], int len) {
for (int i = 0; i < len - 1; i++) {
for (int j = 0; j < len - 1 - i; j++) {
if (a[j] > a[j + 1]) {
int t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}
// 优化
void optBubbleSort(int a[], int len) {
int flag = 1; // 表示是否有交换动作 1:有 0:没有
for (int i = 0; i < len - 1; i++) {
flag = 0;
for (int j = 0; j < len - 1 - i; j++) {
if (a[j] > a[j + 1]) {
int t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
flag = 1;
}
}
}
}
-
最好情况 —— 只需经过一趟 n - 1 次的比较,且=不移动元素,复杂度为 O(n);
-
最坏情况 —— 初始序列为逆序或最小值元素在序列末尾,则需要 n - 1 趟排序,共进行 n(n-1)/2次元素之间的比较;
-
平均时间复杂度 O(n^2)
-
适合数据规模小的情况,一般情况下,该算法的排序时间效率最低
-
稳定的排序
谢尔排序
基本原理及特点
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
算法步骤
选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
按增量序列个数 k,对序列进行 k 趟排序;
每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
代码实现
#include <stdio.h> void shellSort(int a[], int len) { int gap, i, j; // gap 增量/元素间隔数 int tmp; for (gap = len >> 1; gap > 0; gap >>= 1) { for (i = gap; i < len; i++) { tmp = a[i]; for (j = i - gap; j >= 0 && a[j] > tmp; j -= gap) { a[j + gap] = a[j]; } a[j + gap] = tmp; } } }
-
排序总趟数 —— \lfloor{log_{2}n}\rfloor
-
一般情况下,时间复杂度在 O(nlog_2n) 与 O(n^2) 之间
-
不稳定的排序
-
不适合用于链表结构的排序
-
快速排序
-
又称 划分排序
基本原理及特点
通过一趟排序将原始序列分割为独立子序列,其中一序列的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两子序列的数据分别进行快速排序。
快速排序算法通过多次比较和交换来实现排序,流程如下:
1、首先设定一个分界值,通过该分界值将数组分成左右两子序列 。
2、将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。此时,左部中各元素都小于或等于分界值,而右部中各元素都大于或等于分界值。
3、然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
4、重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。
挖坑填数 + 分治。
代码实现
非递归
#include <stdio.h>
typedef struct _Range {
int start, end;
} Range;
Range new_Range(int s, int e) {
Range r;
r.start = s;
r.end = e;
return r;
}
void swap(int *x, int *y) {
int t = *x;
*x = *y;
*y = t;
}
void quick_sort(int arr[], const int len) {
if (len <= 0)
return; // 避免len等于负值时引发段错误(Segment Fault)
// r[]模拟列表,p为数量,r[p++]为 push,r[--p]为 pop且取得元素
Range r[len];
int p = 0;
r[p++] = new_Range(0, len - 1);
while (p) {
Range range = r[--p];
if (range.start >= range.end)
continue;
int mid = arr[(range.start + range.end) / 2]; // 中间数为基准点
int left = range.start, right = range.end;
do {
while (arr[left] < mid) ++left; // 检测基准点左侧
while (arr[right] > mid) --right; // 检测基准点右侧
if (left <= right) {
swap(&arr[left], &arr[right]);
left++;
right--; // 移动指针以继续
}
} while (left <= right);
if (range.start < right) r[p++] = new_Range(range.start, right);
if (range.end > left) r[p++] = new_Range(left, range.end);
}
}
递归
#include <stdio.h>
void swap(int *x, int *y) {
int t = *x;
*x = *y;
*y = t;
}
void quick_sort_recursive(int arr[], int start, int end) {
if (start >= end)
return;
int mid = arr[end];
int left = start, right = end - 1;
while (left < right) {
while (arr[left] < mid && left < right)
left++;
while (arr[right] >= mid && left < right)
right--;
swap(&arr[left], &arr[right]);
}
if (arr[left] >= arr[end])
swap(&arr[left], &arr[end]);
else
left++;
if (left)
quick_sort_recursive(arr, start, left - 1);
quick_sort_recursive(arr, left + 1, end);
}
void quick_sort(int arr[], int len) {
quick_sort_recursive(arr, 0, len - 1);
}
-
初始时已经有序的情况下,耗时最长,总的比较次数为 n(n - 1)/2,时间复杂度 —— O(n^2)
-
若每趟排序后,分界元素正好定位在序列中间,从而把当前待排序的序列分成大小相等的前后两个子序列,则所需时间为 O(nlog_2n)
-
因此,平均时间复杂度为 O(nlog_2n)
-
最坏情况 —— 分界元素的位置都偏向子序列的一端,空间复杂度 O(n),一般情况为 O(logn)
-
不稳定的排序
-
各部分的分界元素恰好为最大值元素时,快排就会变成“慢速排序”
堆积排序
堆积的定义
(定义 1)具有 n 个数据元素的序列 K = (k_1, k_2, k_3, k_4, . . . , k_n); 当且仅当满足条件
k[ i ] >= k[i*2] \&\& k[ i ] >= k[i*2+1]
或者
k[i]<=k[i*2]\&\&k[i]<=k[i*2+1],i = (1, 2, 3, 4, . . . , n/2) 时称序列K为一个堆积 (heap),简称堆。有时将满足第一种条件的堆积称为大顶堆积,满足第二种条件的堆积称为小顶堆积。大顶堆积的第一个元素具有最大值。下面的讨论针对大顶堆积而言。
若将序列的元素依次存放于一个一维数组中,并将此一维数组看做是一棵完全二叉树的顺序存储结构,则堆积可以与一棵完全二叉树对应,而且很容易确定该完全二叉树中任意结点i的孩子结点的位置(如果存在孩子结点),因此可以从另外一个角度给堆积下定义(定义 2):
堆积是一棵完全二叉树,其中每个分支结点的值均大于或者等于其左子树和右子树(如果存在)中所有结点的值,并且该完全二叉树的根节点值最大。
基本原理及特点
堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
算法步骤(堆积的构造)
建立初始堆积
交换堆积的第一个元素(最大值元素)与堆积的最后元素的位置
将移走的最大值元素之后的剩余元素组成的序列再转换为一个堆积
重复上述的第二步和第三步 n - 1 次
代码实现
#include <stdio.h>
#include <stdlib.h>
void swap(int *a, int *b) {
int temp = *b;
*b = *a;
*a = temp;
}
void adjust(int arr[], int start, int end) {
int dad = start;
int son = dad * 2 + 1;
while (son <= end) {
if (son + 1 <= end && arr[son] < arr[son + 1])
son++;
if (arr[dad] > arr[son])
return;
else {
swap(&arr[dad], &arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}
void heap_sort(int arr[], int len) {
int i;
for (i = len / 2 - 1; i >= 0; i--)
adjust(arr, i, len - 1);
for (i = len - 1; i > 0; i--) {
swap(&arr[0], &arr[i]);
adjust(arr, 0, i - 1);
}
}
-
对于数据规模为 n 的输入,堆积排序需要进行 n - 1 趟排序才能完成工作;
-
适合数据规模很大的数据元素序列;
-
第一层循环所需时间 —— 各层结点数与结点可移动最大距离之积的总和,耗时 O(n);
-
第二个循环中每次都要调用一次 adjust(int, int, int),总共调用 n - 1 次,因此共耗时 (n-1)log_2(n+1) = O(nlog_2n)
-
总效率 =》O(n) + O(nlog_2n) = O(nlog_2n) —— 时间复杂度,无论最好还是最坏的情况;
-
空间复杂度 —— O(1);
-
不稳定的排序;