大话数据结构学习笔记 - 排序算法及图解以及C
实现
概述
排序概念
假设含有
n
个记录的序列为 r1,r2,...,rn r 1 , r 2 , . . . , r n , 其相应的关键字分别为 k1,k2,...,kn k 1 , k 2 , . . . , k n , 需确定1, 2, ... , n
的一种排列 p1,p2,...,pn p 1 , p 2 , . . . , p n , 使其相应的关键字满足 kp1≤kp2≤...≤kpn k p 1 ≤ k p 2 ≤ . . . ≤ k p n (非递减或非递增)关系,即使的序列称为一个按关键字有序的序列 {rp1,rp2,...,rpn} { r p 1 , r p 2 , . . . , r p n } , 这样的操作就称为排序
排序的稳定性
假设 ki=kj(1≤i≤n,1≤j≤n,i≠j) k i = k j ( 1 ≤ i ≤ n , 1 ≤ j ≤ n , i ≠ j ) , 且在排序前的序列中 ri r i 领先于 rj r j (即 i<j i < j 。 如果排序后 ri r i 仍领先于 rj r j , 则称所用的排序方法是稳定的; 反之,若可能使得排序后的序列中 rj r j 领先于 ri r i , 则称所用过得排序方法是不稳定的。
内排序与外排序
根据在排序过程中待排序的记录是否全部被放置在内存中, 排序分为:内排序和外排序
内排序是在排序整个过程中, 待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外寸之间多次交换数据才能进行
内排序算法的性能主要受3
个方面影响
- 时间性能:内排序主要进行两种操作: 比较和 移动, 高效的算法应尽可能减少比较次数和移动次数
- 辅助空间:即除了存放待排序所占用的存储空间外,算法执行所需要的其他存储空间
- 算法复杂性:是指算法本身的复杂度,而不是算法的时间复杂度
算法实现前提
顺序表结构
该结构用于后续学习的所有排序算法
#define MAXSIZE 10 // 用于待排序数组个数的最大值, 可根据需要修改
typedef struct
{
int r[MAXSIZE + 1]; // 用于存储待排序数组, r[0] 用作哨兵或临时变量
int length; // 用于记录顺序表的长度
}SqList;
交换算法
用于交换数组两元素的值
/*
* 交换 L 中数组 r 的下标为 i 和 j 的值
*/
void swap(SqList *L, int i, int j)
{
int temp = L->r[i];
L->r[i] = L->r[j];
L->r[j] = temp;
}
冒泡排序
思想
在要排序的一组数中,对当前还未排好序的范围内的全部数,自上而下对相邻的两个数依次进行比较和调整,让较大的数往下沉,较小的往上冒:即每当两相邻的数比较后发现他们的排序与排序要求相反时,就交换
操作步骤
- 比较相邻的元素,如果第一个比第二个大,就交换
- 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对,每次循环都会把最大的数放在最后
- 针对所有的元素重复以上的步骤,除了已经排好序的
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较
复杂度分析
- 时间复杂度 : O(n2) O ( n 2 )
代码
/*
* 冒泡排序
*/
void BubbleSort(SqList *L)
{
for(int i = 1; i < L->length; i++)
{
// 注意 j 是从前往后循环, 也可以从后往前循环, 此处的 -i + 1 是为了每次减少比较的次数,因为每次循环都会把当前的最大数放置在其位置
for(int j = 1; j < L->length - i + 1; j++)
if(L->r[j] > L->r[j + 1]) // 若前者大于后者
swap(L, j, j + 1); // 交换元素值
}
}
直接插入排序
思想
简单选择排序(Simple Selection Sort
)就是通过n - i
次关键字间的比较,从n - i + 1
个记录中选出关键字最小的记录, 并和第
i(1≤i≤n)
i
(
1
≤
i
≤
n
)
个记录交换
复杂度
交换移动数据次数相当少,且最好做茶情况比较次数一样多
- 时间复杂度 : O(n2) O ( n 2 ) , 虽然与冒泡一样,但要略优于冒泡
代码
/*
* 简单选择排序
*/
void SelectSort(SqList *L)
{
for(int i = 1; i < L->length; i++)
{
int min = i;
for(int j = i + 1; j <= L->length; j++) // 每次从身后的元素中选择出最小值
if(L->r[j] < L->r[min])
min = j;
if(i != min)
swap(L, i, min); // 交换最小值到当前坐标
}
}
直接插入排序
思想
直接插入排序(Straight Insertion Sort
)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1
的有序表
复杂度
- 时间复杂度: 平均为 O(n2) O ( n 2 ) , 但要比冒泡和简单选择排序性能要好一些
- 空间复杂度: 一个哨兵,故为 O(1) O ( 1 )
希尔排序
思想
基本有序: 即小的关键字基本在前面,大的基本在后面,而不大不小的基本在 中间, 类似于{2,1,3,6,4,7,5,8,9}
, 而像{1,5,8,3,7,8,2,4,6}
只能算局部有序,不是基本有序
希尔排序就是通过采取跳跃分割的策略,将相距某个 增量的记录组成一个子序列, 这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序
复杂度
- 时间复杂度: 希尔排序的时间复杂度依赖于 增量的选取, 时间复杂度为最好为 O(n32) O ( n 3 2 )
代码
/**
* 希尔排序
* @param L
* @param s
* @param m
*/
void ShellSort(SqList *L)
{
int increment = L->length;
do
{
increment = increment / 3 + 1; // 增量序列
for(int i = increment + 1; i <= L->length; i++)
{
if(L->r[i] < L->r[i - increment])
{
// 需将 L->r[i] 插入有序增量子表
int j;
L->r[0] = L->r[i]; // 暂存在 L->r[0]
for(j = i - increment; j > 0 && L->r[j] > L->r[0]; j -= increment)
L->r[j + increment] = L->r[j]; // 记录后移, 查找插入位置
L->r[j + increment] = L->r[0]; // 插入
}
}
}
while(increment > 1);
}
堆排序
堆是具有下列性质得到完全二叉树
- 大顶堆: 每个结点的值都大于或等于其左右孩子结点的值
- 小顶堆: 每个结点的值都小于或等于其左右孩子结点的值
堆排序主要是借助堆来实现的选择排序,首先实现堆排序需要解决两个问题
如何由一个无序序列构建成一个堆?
使用数组表示一个堆的元素,由初始的无序数组构建堆只需要自底向上从第一个非叶元素开始依次调整成一个堆
如何在输出堆顶元素后,调整剩余元素成为一个新的堆?
首先将堆顶元素和未排序的子序列最后一个元素交换,然后比较堆顶元素的左右孩子节点,因为除了堆顶元素,左右孩子都满足堆条件,故只需让堆顶元素与左右孩子的较大者(大顶堆)交换,直至叶子节点。
复杂度
堆排序是一种不稳定的排序方法,且初始构建堆所需的比较次数较多,故不适合待排序序列个数较少的情况
- 时间复杂度: 总体来说为 O(nlogn) O ( n l o g n ) , 无论最好、最坏还是平均都是
- 空间复杂度: 仅用一个交换的暂存单元, 为 O(1) O ( 1 )
代码
/*
* 构建大顶堆: 已知 L->r[s ... m]中记录的关键字除 L->r[s] 之外均满足堆的定义
* 本函数挑中 L->r[s] 的关键字, 使 L->r[s ... m]称为一个大顶堆
*/
void HeapAdjust(SqList *L, int s, int m)
{
int temp = L->r[s];
for(int j = s * 2; j <= m; j *= 2) // 沿关键字较大的孩子节点向下筛选
{
if(j < m && L->r[j] < L->r[j + 1])
++j; // j 为关键字中较大的记录下标
if(temp >= L->r[j])
break; // 表示父节点比孩子节点大
L->r[s] = L->r[j]; // 将孩子节点中最大值插入其父节点
s = j;
}
L->r[s] = temp; // 插入孩子节点或子孙结点
}
/*
* 堆排序
*/
void HeapSort(SqList *L)
{
for(int i = L->length / 2; i >= 1; i--) // 把 L 中的 r 构建成一个大顶堆
HeapAdjust(L, i, L->length);
for(int i = L->length; i > 1; i--)
{
swap(L, 1, i); // 将对顶记录和当前未经排序子序列的最后一个记录交换
HeapAdjust(L, 1, i - 1); // 将 L->r[1 ... i - 1] 重新调整为大顶堆
}
}
归并排序
思想
归并排序(Merging Sort
)就是利用归并的思想实现的排序方法。假设初始序列含有n
个记录,则可以看成n
个有序的子序列, 每个子序列的长度为1
,然后两两归并,得到
⌈n/2⌉
⌈
n
/
2
⌉
(
⌈x⌉
⌈
x
⌉
表示不小于x
的最小整数)个长度为2
或1
的有序子序列; 再两两归并, …… , 如此重复, 直至得到一个长度为n
的有序序列为止, 这种排序方法称为2
路归并排序
复杂度
- 时间复杂度: 每次归并都要将相邻的有序序列进行两两归并,故耗费 O(n) O ( n ) , 完全二叉树的深度为 ⌈log2n⌉ ⌈ l o g 2 n ⌉ , 故总的时间复杂度为 O(nlogn) O ( n l o g n ) , 而且是最好、最坏、【平均的时间性能
- 空间复杂度: 归并排序在归并过程需要与原始记录序列同样数量的存储空间存放归并结果以及递归深度为 log2n l o g 2 n 的栈空间,故时间复杂度为 O(n+logn) O ( n + l o g n )
代码
/**
* 归并操作, 将 arr[left ... mid] 和 arr[mid + 1 ... right] 归并
* 该方法先将所有元素复制到辅助数组 temp 中, 然后再归并到 arr[] 中
* @param arr 待归并数组
* @param left 起始位置
* @param mid 中间位置
* @param right 结束位置
*/
void merge(int arr[], int left, int mid, int right)
{
int i = left, j = mid + 1, k = 0, temp[right - left + 1];
while(i <= mid && j <= right) // 将左半边和右半边按大小依次放入 temp 中
{
if(arr[i] <= arr[j])
temp[k++] = arr[i++];
else
temp[k++] = arr[j++];
}
while(i <= mid) // 若左半边留有元素, 则将剩余元素全部放入 temp 中
temp[k++] = arr[i++];
while(j <= right) // 若右半边留有元素, 则将剩余元素全部放入 temp 中
temp[k++] = arr[j++];
for(int p = 0; p < k; p++) // 将排序好的所有元素放回 arr数组, left + p 表示 arr[left, right] 区域
arr[left + p] = temp[p];
}
/**
* 归并排序 排序操作
* @param arr 待排序数组
* @param left 起始位置
* @param right 结束位置
*/
void mSort(int arr[], int left, int right)
{
if(right <= left) return;
int mid = left + (right - left) / 2;
mSort(arr, left, mid); // 递归排序左半边
mSort(arr, mid + 1, right); // 递归排序右半边
merge(arr, left, mid, right); // 归并
}
/**
* 归并排序
* @param L
*/
void MergeSort(SqList *L)
{
// 将 L 中的数组 r 归并排序, 范围是 [1, L->length]
mSort(L->r, 1, L->length);
}
快速排序
思想
快速排序(Quick Sort
) 的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,已达到整个序列有序的目的
复杂度
时间复杂度
- 最优情况下,即
Partition
每次都均匀划分序列为两部分,故若排序n
个关键字,则递归树深度为 ⌊log2n⌋+1 ⌊ l o g 2 n ⌋ + 1 ( ⌊x⌋ ⌊ x ⌋ 表示不大于x
的最小整数, 故仅需递归 log2n l o g 2 n 次。假设时间为 T(n) T ( n ) , 每次Partition
都会扫描整个数组,即n
次比较, 然后将数组一分为二,则两部分各自需要 T(n/2) T ( n / 2 ) 时间, 推导公式如下
故最优情况下,时间复杂度为 O(logn) O ( l o g n )
最坏情况下,待排序的序列为正序或逆序时,每次划分都只会得到一个比上一次划分少一个记录的子序列,另一个子序列为空。此时的递归树为一棵斜树,故需要执行
n - 1
次递归调用, 且第i
次划分需要比较n - i
次,故最终时间复杂度为 O(n2) O ( n 2 )平均时间复杂度为 O(nlogn) O ( n l o g n )
- 最优情况下,即
空间复杂度: 递归造成的栈空间,最好情况,递归树深度为 log2n l o g 2 n , 故空间复杂度为 O(logn) O ( l o g n ) ; 最坏情况,需要进行
n - 1
次递归调用,空间复杂度为 O(n) O ( n ) , 平均为 O(nlogn) O ( n l o g n )
代码
/* 快速排序******************************** */
/**
* 交换顺序表 L 中子表的记录, 使得枢轴确定其位置, 并返回坐标
* 此时在枢轴左边的元素都不大于它, 在其右边的都不小于它
* @param L 包含子表的顺序表 L
* @param low 起始位置
* @param high 结束位置
* @return 枢轴位置
*/
int Partition(SqList *L, int low, int high)
{
int pivotkey = L->r[low]; // 用待排序序列的第一个记录做枢轴记录
while(low < high) // 从序列的两端交替的向中间扫描
{
while(low < high && L->r[high] > pivotkey) // 从后向前, 获取小于枢轴记录的记录位置
--high;
swap(L, low, high); // 将比枢轴记录小的记录交换到首端
while(low < high && L->r[low] < pivotkey) // 从前向后, 获取大于枢轴记录的记录位置
++low;
swap(L, low, high); // 将比枢轴记录大的记录交换到尾端
}
return low; // 返回枢轴所在位置
}
/**
* 快速排序
* @param L 包含子表的顺序表 L
* @param low 起始位置下标
* @param high 结束位置下标
*/
void QSort(SqList *L, int low, int high)
{
if(low < high)
{
int pivot = Partition(L, low, high); // 将 L->r[low ... high] 一分为二, 算出枢轴记录下标 pivot
QSort(L, low, pivot - 1); // 对首端子表递归排序
QSort(L, pivot + 1, high); // 对尾端子表递归排序
}
}
/**
* 快速排序驱动程序
* @param L 包含子表的顺序表 L
*/
void QuickSort(SqList *L)
{
QSort(L, 1, L->length);
}
/* **************************************** */
优化快速排序算法
- 优化选取枢轴: 三位取中法(
median-of-three
) 即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数 - 优化不必要交换: 将枢轴元素直接替换到其最终的位置,而非交换
- 优化小数组的排序方案: 若数组非常小,快速排序性能还不如直接插入排序,原因在于快排使用了递归操作
改进快速排序算法代码
/* 改进后快速排序******************************** */
/**
* 改进后快速排序:交换顺序表 L 中子表的记录, 使得枢轴确定其位置, 并返回坐标
* 此时在枢轴左边的元素都不大于它, 在其右边的都不小于它
* @param L 包含子表的顺序表 L
* @param low 起始位置
* @param high 结束位置
* @return 枢轴位置
*/
int Partition1(SqList *L, int low, int high)
{
/* 三位取中法, 即取三个关键字先进行排序, 将中间数作为枢轴, 一般是取左端、中间和右端三个数 */
int middle = low + (high - low) / 2; // 计算数组中间的元素下标
if(L->r[low] > L->r[high])
swap(L, low, high); // 交换左端与右端数据, 保证左端较小
if(L->r[middle] > L->r[high])
swap(L, middle, high); // 交换中间与右端数据, 保证中间较小
if(L->r[low] < L->r[middle])
swap(L, low, middle); // 交换左端与中间数据, 保证中间最小, 左端居中
int pivotkey = L->r[low]; // 将三位取中后的中间数作为枢轴记录
L->r[0] = pivotkey; // 将枢轴关键字保存在 L->r[0]
while(low < high) // 从表的两端交替向中间扫描
{
while(low < high && L->r[high] >= pivotkey)
--high;
L->r[low] = L->r[high]; // 采用替换而不是交换的方式进行操作
while(low < high && L->r[low] <= pivotkey)
++low;
L->r[high] = L->r[low]; // 采用替换而不是交换的方式进行操作
}
L->r[low] = L->r[0]; // 将枢轴数值替换回 L->r[low]
return low; // 返回枢轴下标
}
/**
* 改进快速排序:大数据使用快速排序, 小数据量使用直接插入排序
* 使用尾递归即迭代的方式减少递归深度
* @param L 包含子表的顺序表 L
* @param low 起始位置下标
* @param high 结束位置下标
*/
void QSort1(SqList *L, int low, int high)
{
if((high - low) >= MAX_LENGTH_INSERT_SORT)
{
while(low < high)
{
int pivot = Partition1(L, low, high); /* 将L->r[low..high]一分为二,算出枢轴值pivot */
QSort(L, low, pivot - 1); /* 对低子表递归排序 */
low = pivot + 1; /* 尾递归 */
}
}
else
InsertSort(L);
}
/**
* 改进快速排序驱动程序
* @param L 包含子表的顺序表 L
*/
void QuickSort1(SqList *L)
{
QSort1(L, 1, L->length);
}
/* **************************************** */
源代码
结语
本文学习了排序的基本概念,包括排序定义、稳定性、内排序与外排序
排序的分类如下图所示
7
种算法的指标对比从算法的简单性,排序算法主要分为两类
- 简单算法:冒泡、简单选择、直接插入
- 改进算法:希尔、堆、归并和快速排序
不过 快排是性能最好的排序算法, 一定要熟记于心
至此,大话数据结构这本书笔记已经学习整理完毕,但还有更多的知识等待掌握,加油 , Fighting