排序算法总览
分类:
- 插入类:直接插入排序、折半插入排序、希尔排序
- 交换类:冒泡排序、快速排序
- 选择类:直接选择排序、堆排序
- 归并类:二路归并排序
特征:
- 平均时间复杂度:快、希、归、堆排序为:O(nlog n);其余排序为O(n )(记忆口诀:快些归队)
- 稳定性:快、希、选、堆排序为:不稳定;其他排序是稳定的(记忆口诀:快些选一堆)
- 经过一趟排序就能保证一个元素到达最终位置:(交换类)冒泡排序、快速排序、(选择类)直接选择排序、堆排序
- 元素比较次数与初始序列无关:直接选择排序、折半插入排序
- 排序的趟数与初始序列有关:交换类
- 冒泡排序:越有序,需要的趟数越少
- 快速排序:越有序,需要的趟数越多
- 注意插入排序需要的趟数是固定n-1的,只是越有序每趟元素比较的次数越少
——>下面具体分析每种排序:
- 注:默认序列下标从0开始
一、插入排序
基本思想:将待排序表看做两个部分:无序区、有序区。整个排序过程就是将无序区的元素逐个插入到有序区中(注意①如何寻找插入点,②插入时如何移动有序区的元素是关键),构成新的有序区。
- 直接插入排序
- 折半插入排序
- 希尔排序
1、直接插入排序
基本思想
将待排序表分为左右两个部分,无序区在左边,有序区在右边。一边寻找插入点一边后移。
-------○■
----------○■
-------------○■
- 双层循环。
- 外层:由小到大(从左到右)。由■代表
i
:待插入值的序号,从i = 1
开始 - 内层:从右到左。由○代表
j=i-1
:被比较值的序号
代码
/**
* 直接插入排序
* @param R[0..N-1] 待排序表
* @param n 序列个数
*/
void insertSort(int[] R, int n) {
int i, j;
int temp;
//外层循环:由小到大
for (i = 1; i < n; i++) {
temp = R[i];
j = i-1;
//内层循环:从右到左(首先要进行数组越界判断)
while (j >= 0 && temp < R[j]) {
//一边寻找插入点一边后移
R[j+1] = R[j];
--j;
}
//插入位置
R[j+1] = temp;
}
}
方法改进:监视哨
对于待排序表R[1..N],由于R[0]不设置元素,在R[0]处设置“监视哨”,作用:
- 相当于越界判断
- 相当于temp
——>改进代码:
/**
* 带监视哨的直接插入排序
* @param R[1..N] 待排序表
* @param n 序列个数
*/
void insertSort1(int[] R, int n) {
int i, j;
// 外层循环
for (i = 1; i < n; i++) {
R[0] = R[i];
j = i-1;
//内层循环(不需要j>=1)
while (R[0] < R[j]) {
R[j+1] = R[j];
--j;
}
// 插入位置
R[j+1] = R[0];
}
}
性能分析
适用在序列基本有序的情况中。
- 最好的情况:整个序列已经有序,时间复杂度O(n)
- 最坏的情况:整个序列逆序,基本操作需要执行 =n(n-1)/2,时间复杂度O(n )
- 平均时间复杂度:O(n )
- 空间复杂度:O(1),额外空间只有一个temp
2、折半插入排序
基本思想
其排序的思想与直接插入排序一样,只不过在寻找插入点时不一样:先通过二分法找到插入点,即low处,然后在后移元素(不能像直接插入排序一边寻找一边后移)
-------○■
----------○■
-------------○■
- 双层循环。
- 外层:由小到大(从左到右)。由■代表
i
:待插入值的序号 - 内层:从右到左。由○代表
high=i-1
(这里high相当于直接插入排序中的j) - 比较的方式不同:先二分查找,在整体后移
代码
/**
* 折半插入排序
* @param R[0..N-1] 待排序表
* @param n 序列个数
*/
void binaryInsertSort(int[] R, int n) {
//外层循环
for (int i = 1; i < n; i++) {
int temp = R[i];
int low = 0;
int high = i-1;
//内层循环
//①先寻找插入点
while (low <= high) {
int middle = (low + high) / 2;
if (temp < R[middle]) {
high = middle -1;
}else {
low = middle + 1;
}
}
//②插入点的索引就是low,然后后移元素
for (int j = i; j > low; j--) {
R[j] = R[j-1];
}
//插入
R[low] = temp;
}
}
性能分析
折半插入排序适合序列数较多的场景。与直接插入排序相比,折半插入排序在寻找插入点所花费的时间将大大减少(比较次数与初始序列无关),但是在移动次数方面和直接插入排序一样的。所以时间复杂度与直接插入排序是一样的。
- 最好的情况:整个序列已经有序,时间复杂度O(n)
- 最坏的情况:整个序列逆序,时间复杂度O(n )
- 平均时间复杂度:O(n )
- 空间复杂度:O(1)
3、希尔排序
基本思想
将待排序表划分为若干组(步长d),在每组中进行直接插入排序,通过缩小步长使序列逐渐有序。
- 注意是三层循环:最外层循环用于缩量步长,后两次循环按照直接插入排序的过程
——>为啥需要希尔排序?
- 我们知道直接插入排序适合序列基本有序的情况,希尔排序在每次迭代(最外层循环)中通过缩小增量步长的方式来使整个序列逐渐基本有序
- 元素个数越少,直接插入排序的效率越高
——>步长的取值
依次逐渐缩小:d = n/2,d = d /2,…,d = 1
注意,最后一趟的d 一定要为1
——>例子:
代码
/**
* 希尔排序
* @param R[0..N-1] 待排序表
* @param n 元素个数
*/
void shellSort(int R[], int n) {
//增加步长d
for (int d = n/2; d >= 1 ; d /= 2) {
//以下按照直接插入排序的过程
for (int i = 0 + d; i < n; i++) {
int temp = R[i];
//相当于直接插入排序中的j = i-1
int j = i-d;
while (j > 0 && temp < R[j]) {
//后移d位
R[j+d] = R[j];
j = j-d;
}
R[j+d] = temp;
}
}
}
性能分析
- 时间复杂度O(nlog n):由于每一趟的序列都认为基本有序,则各趟的时间复杂度为O(n);总共需要的趟数为log n
- 空间复杂度O(1)
二、交换排序
基本思想:两两比较待排序表的元素,发现倒序就交换。比较/交换的位置不同出现不同的方法。
- 冒泡排序:相邻位置比较
- 快速排序:与选出的中间元素比较
1、冒泡排序
基本思想
冒泡一趟,一定能将该趟序列中的最大(最小)元素交换到最终的位置。
--○----------■
--○--------■
--○------■
- 双层循环
- 外层:由大到小(从右到左)。由■代表
i
:每趟逐渐减小i
。从i=n-1
开始,到i=1
结束 - 内层:从左到右。由○代表
j
:一趟比较序号从j=1
开始,到j=i
结束(下标0基址)
代码
/**
* 冒泡排序
* @param R[0..N-1] 待排序表
* @param n 元素个数
*/
void bubbleSort(int[] R, int n) {
//外层循环
for (int i = n-1; i >= 1; i--) {
//用来标记该趟排序是否发生了交换
boolean flag = false;
//内层循环
for (int j = 1; j <= i; j++) {
if (R[j-1] > R[j]) {
//交换
int temp = R[j];
R[j] = R[j-1];
R[j-1] = temp;
flag = true;
}
}
//一趟排序过程中没有发生交换,说明序列已经有序
if (!flag) {
return;
}
}
}
性能分析
- 最好的情况:整个序列已经有序,仅需要一趟排序,执行n-1次,时间复杂度O(n)
- 最坏的情况:整个序列逆序,基本操作需要执行n(n-1)/2,时间复杂度O(n )
- 平均时间复杂度:O(n )
- 空间复杂度:O(1),额外空间只有一个temp
2、快速排序
基本思想
分治法的思想(在分阶段同时进行排序):首先选定一个元素作为中间元素,然后将表中所有元素与该元素比较,比它小的调到表的前面,比它大的调到表的后面。一趟排序完后以中间元素为分裂点将表分为左右两个子表继续排序。
通用的快速排序思想:首先选择表头作为中间元素temp。然后,从j开始扫描,遇到小于temp的停止扫描,将A[i](此时的i在中间元素位置,并保存在temp中)与A[j]交换,然后i++。接着,从i开始扫描,遇到大于temp的停止扫描,将A[j]与A[i]交换,然后j- -。以此类推,直到i与j交叉或相遇,将temp赋值到A[i]中。
具体分析详见:分治法中的合并排序和快速排序
代码
/**
* 快速排序
* @param R[0..N-1] 待排序表
* @param n 元素个数
*/
void quickSort(int[] R,int l,int r){
if (l < r) {
int i = l;
int j = r;
int temp = R[l];
while (i < j) {
//i<j为越界限制
while(i < j && R[j] > temp) // 从右向左找第一个小于x的数
j--;
if(i < j)
R[i++] = R[j];
while(i < j && R[i] < temp) // 从左向右找第一个大于等于x的数
i++;
if(i < j)
R[j--] = R[i];
}
R[i] = temp;
quickSort(R, l, i-1);
quickSort(R, i+1, r);
}
}
性能分析
从平均时间性能来说,快速排序目前被认为是最好的一种内部排序方法。
- 一趟划分比较的时间复杂度固定在O(n)
- 最好的情况:每趟都将子表等分成两部分,需要log n趟,理想的时间复杂度为O(nlog n)
- 最坏的情况:序列基本有序,每次选取的中间元素要么最大要么最小,划分成一个空表一个n-1的子表,则需要n-1趟,最坏的时间复杂度O(n )
- 平均时间复杂度:趋向于最好的情况,O(nlog n)
- 空间复杂度:O(log n),递归需要栈的辅助
三、选择排序
基本思想:在每一趟排序中,在待排序表中都选出最大或最小的元素放到最终的位置。选择的方式不同,出现不同的方法。
- 直接选择排序
- 堆排序
1、直接选择排序
基本思想
每趟选择完,都将待排序表中的最大(小)元素放到表后(前)。这里每次选的是最小元素。
■○----------N-1
■○--------N-1
■○------N-1
- 双层循环
- 外层:由大到小(但从左到右)。由■代表
i
:每趟也逐渐减小i
。但从i=0
开始,到i=n-1
结束 - 内层:从左到右。由○代表
j
:一趟比较序号从j=i+1
开始,到j=n-1
结束
代码
/**
* 直接选择排序
* @param R R[0..N-1] 待排序表
* @param n 元素个数
*/
void selcetSort(int[] R, int n) {
int i, j;
int minIndex;
//外层循环
for (i = 0; i < n; i++) {
minIndex = i;
//内层循环
for (j = i+1; j < n; j++) {
//选出最小元素索引
if (R[j] < R[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
//交换
int temp = R[i];
R[i] = R[minIndex];
R[minIndex] = temp;
}
}
}
性能分析
- 时间复杂度:由于比较次数固定:(n-1 + 1)(n-1)/2 = n(n-1)/2,且与初始序列无关。所以时间复杂度为O(n )
- 空间复杂度:O(1),额外空间只有一个temp
2、堆排序
基本思想
可以把堆看成一颗完全二叉树。必须满足任何一个非叶子节点的值不大于(小根堆)或不小于(大根堆)左右孩子节点的值。
堆排序的过程:初始建堆——>n-1调整——>n-1-1调整——>…
建立完一趟的大根堆,并不意味着排序完成。虽然可以得到最大的元素,但是无法知道左右孩子的正确序列。所以需要将根删除继续n-1建堆,直到堆规模只剩下一个元素。
建堆的过程:先将待排序表转变成完全二叉树形式,然后建堆,可以参考:图解排序算法(三)之堆排序
但是要记住:从第一个非叶子节点开始,从右到左,从下到上,对每个节点进行调整,最终得到一颗大根堆。
代码
- 注:序列的下标从1开始。则2i为节点i的左孩子,2i+1为节点i的右孩子
/**
* 堆排序
* @param R[1..N] 待排序表
* @param n 元素个数
*/
void heapSort(int[] R, int n) {
//初始建堆
//从n/2表示最后一个非叶子节点开始,从右到左,从下到上
for (int i = n/2; i >= 1; i--) {
sift(R, i, n);
}
for (int i = n; i >= 2 ; i--) {
//交换,将根节点放到序列最终位置
int temp = R[1];
R[1] = R[i];
R[i] = temp;
//往后的调整,每次只需要从头结点开始就可以了
sift(R, 1, i-1);
}
}
/**
* 筛选
* @param R
* @param low
* @param high
*/
void sift(int[] R, int low, int high) {
//节点i,和其左孩子2i
int i = low, j = 2*i;
int temp = R[i];
while (j <= high) {
//如果右孩子大,则j指向有孩子
if (j < high && R[j] < R[j+1]) {
++j;
}
//如果父节点小于孩子,赋值父节点
if (temp < R[j]) {
R[i] = R[j];
i = j;
//继续比较下去,孩子的孩子节点是否大于temp
j = 2*i;
}else {
break;
}
}
R[i] = temp;
}
性能分析
- 时间复杂度为:O(nlog n)
- 筛选操作的时间:堆排序算法花费的时间最多用在初始建堆和调整时所进行的筛选上。每个节点筛选的时间复杂度为O(log n)(解释:完全二叉树的高度k=log n+1,最多需要比较2(k-1)次)。
- 初次建堆的时间:初始建堆需要n/2次筛选操作
- 调整操作的时间:剩下需要n-1调整操作,每次只需要筛选头节点
- 因此堆排序算法的整个基本操作次数为O(log n)x(n/2) + O(log n)x(n-1)。简化后时间复杂度为O(nlog n)
- 空间复杂度:O(1),额外空间只有一个temp
- 堆排序适合的场景是记录数很多的情况,比如从10000个记录中选出前10个最小的。
四、归并排序
基本思想
所谓归并是指将两个或两个以上的有序表合并成一个新的有序表。归并排序也可以归纳为分治法的思想,但是重点在合并阶段。
具体分析及代码详见:分治法中的合并排序和快速排序
性能分析
- 时间复杂度为:O(nlog n)。其中,一趟排序的时间复杂度为O(n),需要 log n趟递归
- 空间复杂度:O(n),需要存储整个待排序表
参考
- 《数据结构(C++描述)》 胡学刚 张晶 主编 人民邮电出版社
- 《2015版数据结构高分笔记》
- 图解排序算法