前言
本章主要讲解常见的排序算法
一、插入排序
1、直接插入排序
(1)基本思想
基本思想: 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序得有序序列中,直到所有的记录插完为止,得到一个新的有序序列。
(2)代码实现
问题1: 在实现排序时我们是直接完整的将其写出吗?
不是,我们一般先局部后整体。
tip:如何写算法程序
- 由简单到复杂
- 验证一步走一步
- 多打印中间结果
- 先局部在整体
- 没思路时先细分
- 先粗糙后精细
- 变量更名
- 语句合并
- 边界处理
问题2: 直接插入排序是怎样将一个元素插入到一个有序区间,并保证插入后仍然是一个有序区间。
- 依次将插入元素与前一个元素比较
- 升序:如果插入元素小于前一个元素则前一个元素往后挪动,直到插入元素大于前一个元素才停止。注:最坏情况------插入元素都小于插入的那个有序区间,即前一个元素下标为-1时停止。
- 降序:如果插入元素大于前一个元素则前一个元素往后挪动,直到插入元素小于前一个元素才停止。注:最坏情况------插入元素都大于插入的那个有序区间,即前一个元素下标为-1时停止。
- 最后插入tmp。
单趟直接插入排序:
c
//升序
//实现排序先局部在整体
//单趟------将一个元素插入到一个有序区间,并保证插入后仍然是一个有序区间。
void InsertSort(int* a, int n)
{
int end;//插入元素的前一个元素下标
int tmp;//插入元素
// 将tmp插入到[0,end]区间中,保持有序
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
//往后挪动
a[end + 1] = a[end];
//迭代
end--;
}
else
{
break;
}
}
//2、插入tmp
a[end + 1] = tmp;
}
问题3: 如何将一个无序数组按照直接插入排序排成升序。
- 把第一个元素看成一个升序,从第二个元素开始看成插入元素tmp,即单趟的直接插入排序。
- 直到将数组最后一个插入进去,即是一个完整的直接插入排序。
整体直接插入排序
:
c
//升序
//实现排序先局部在整体
//整体------将一个无序数组按照直接插入排序排成升序。
void InsertSort(int* a, int n)
{
//整体:把第一个元素看成升序,从第二个元素开始看成插入tmp,直到插入到最后一个元素停止。
int i = 1;
for (i = 1; i < n; i++)
{
//单趟
int end = i - 1;//插入元素的前一个元素下标
int tmp = a[i];//插入元素
// 将tmp插入到[0,end]区间中,保持有序
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
//往后挪动
a[end + 1] = a[end];
//迭代
end--;
}
else
{
break;
}
}
//2、插入tmp
a[end + 1] = tmp;
//打印观察每一次排序后的结果
PrintArray(a, n);
}
}
tip:以升序为例总结直接插入排序
- 时间复杂度
- 最好情况:升序排升序------O(N)
- 最坏情况:降序排升序------O(N^2)
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2、希尔排序(缩小增量排序)
(1)基本思想
基本思想:
- 预排序:分组插排,目的是使数组接近有序
- 直接插入排序
(2)代码实现
问题1: 预排序为什么会使数组接近有序?
tip:如图可知
- 间隔为gap分为一组,对每组数据直接插入排序
- gap是几就会分成几组。
- 间隔为gap的序列与间隔为1的序列,直接插入排序的思想都是一样的,只需要把1改成gap即可。(间隔为1一次向后挪动一步,间隔为gap一次向后挪动gap步------》减少挪动)
- 发现预排序就是让大的数尽快到后面,小的数尽快到前面(减少了挪动),以此接近有序。
代码示例1:预排序------一组排完再排另一组
c
//希尔排序
// 1、预排序
// 2、直接插入排序
// 先局部再整体
//单趟的预排序
void ShellSort(int* a, int n)
{
//预排序
//①分组
int gap = 3;//假设分为三组
//方式一:一组排完再排另外一组
for (int j = 0; j < gap; j++)
{
//②对每组数据直接插入排序
for (int i = j + gap; i < n ; i += gap)
{
//单趟
int end = i - gap;
int tmp = a[i];
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
//2、插入tmp
a[end + gap] = tmp;
}
//观察每一组排完序后的序列
PrintArray(a, n);
}
}
tip: 预排序我们套了三段循环,看着有点复杂了,我们可以对其优化------》语句合并:把第一层循环和第二层循环合并,注合并之后效率并没有改变。
代码示例2:预排序------多组并排
c
void ShellSort(int* a, int n)
{
//预排序
//①分组
int gap = 3;//假设分为三组
//方式二:多组并排
//②对每组数据直接插入排序
for (int i = gap; i < n; i++)
{
//单趟
int end = i - gap;
int tmp = a[i];
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
//2、插入tmp
a[end + gap] = tmp;
}
//观察一次预排序后的序列
PrintArray(a, n);
}
问题2: gap是多少合适?
前置知识:
- gap越大,跳得越快,越不接近有序
- gap越小,跳得越慢,越接近有序
- gap == 1时,预排序就是直接插入排序
因为排序时数组的大小是不知道,可能很大也可能很小,所以gap的值由数组的大小决定,且gap是变化的(即gap越来越接近1,最后等于1)。如下两种取法:
- gap = n,gap = gap / 2,直到gap == 1。
- gap = n,gap = gap / 3 + 1,直到gap == 1。
代码示例3:整体的希尔排序
c
//整体的希尔排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//预排序
//①分组
gap = gap / 2;//逐渐接近1,最后等于1
//方式二:多组并排
//②对每组数据直接插入排序
for (int i = gap; i < n; i++)
{
//单趟
int end = i - gap;
int tmp = a[i];
//1、end向后挪动
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
//2、插入tmp
a[end + gap] = tmp;
}
//观察一次预排序后的序列
PrintArray(a, n);
}
}
希尔排序特性总结:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近有序。当gap == 1时就是直接插入排序,这时数组已经接近有序了,效率高,所以说希尔是对直接插入的优化。
- 稳定性:不稳定。
- 时间复杂度:O(N^1.3)(tip:量级略大于O(N*logN))
二、选择排序
1、直接选择排序
(1)基本思想
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
(2)代码实现
问题1: 怎样选出最小的数据元素?
- 在元素集合array[i]~array[n-1]中选择关键码最小的数据元素
- 若它不是这组元素中的第一个元素,则将它与这组元素的第一个交换
- 在剩余的array[i+1}~array[n-1]集合中,重复上述步骤,直到集合剩余一个元素
代码示例:直接选择排序------遍历一次只选一个数
c
//交换
void Swap(int* e1, int* e2)
{
int temp = *e1;
*e1 = *e2;
*e2 = temp;
}
//直接选择排序
void SelectSort(int* a, int n)
{
//直接选择排序------n个数据,需要选n-1次
for (int i = 0; i < n - 1; i++)
{
//在[i , n - 1]区间,选择最小的数据元素
int minPos = i;
for (int j = i + 1; j < n; j++)
{
if (a[j] < a[minPos])
{
minPos = j;
}
}
//交换------最小元素与第一个元素交换
Swap(&a[i], &a[minPos]);
}
}
tip: 直接选择排序遍历一次只能选一个最小(或最大)的数,对其优化------》①遍历一次区间[left,right],选出最大的数和最小的数;②最小的与这组元素的第一个交换,最大的与这组元素的最后一个交换;③缩小区间[left+1,right-1],在这个区间重复上述操作,直到left >= right结束。
代码示例:优化的直接选择排序------遍历一次选两个数
c
//优化的直接选择排序------遍历一次选两个数
void SelectSort(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
//在[left , right]区间,选出最小和最大的两个数
int minPos = left, maxPos = left;
for (int i = left + 1; i <= right; i++)
{
//选出最小
if (a[i] < a[minPos])
{
minPos = i;
}
//选出最大
if (a[i] > a[maxPos])
{
maxPos = i;
}
}
//排升序:小左大右
Swap(&a[left], &a[minPos]);
//如果left和maxPos重叠,需要修正maxPos
if (left == maxPos)
{
maxPos = minPos;
}
Swap(&a[right], &a[maxPos]);
//迭代
left++;
right--;
}
}
注意: 如果left和maxPos重叠,需要修正maxPos的位置,因为通过第一次Swap交换之后maxPos的位置可能改变到minPos。
tip:直接选择排序的特性总结
- 时间复杂度
- 最坏时间复杂度:O(N^2)
- 最好时间复杂度:O(N^2)
- 直接选择排序非常好理解,但是效率不好,实际中很少使用
- 稳定性:不稳定
2、堆排序
(1)基本思想
堆排序是利用堆的思想所设计的一种排序,它是选择排序的一种。
基本思想:
- 建堆
- 升序:建大堆
- 降序:建小堆
- 利用堆删除思想来进行排序
(2)代码实现
因为堆排序我在堆应用那篇博客已经详细讲解了,所以这里我们直接实现堆排序。
代码实现:堆排序
c
//向下调整------大堆
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;//保存较大孩子的下标
//向下调整------调整到叶子结束
while (child < n)
{
//注意:要先判断右孩子是否为有效数据
if (child + 1 < n && a[child] < a[child + 1])
{
++child;
}
//当父亲小于孩子时才向下调整
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
//迭代
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆排序
void HeapSort(int* a, int n)
{
//向下调整建堆------从倒数第一个非叶子结点开始向下调整,然后向前迭代,直到根才结束。
int i = (n - 2) / 2;
for (i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
//利用堆删除思想来排序
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);//end等于数组前面的数据个数
//迭代
--end;
}
}
tip:堆排序特性总结
- 时间复杂度:O(N*logN)------》堆排序使用堆来选数,效率就高了很多。
- 空间复杂度:O(1)
- 稳定性:不稳定
三、交换排序
1、冒泡排序
(1)基本思想
百度百科:
它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行,直到没有相邻元素需要交换,也就是说该元素列已经排序完成。
这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端(升序或降序排列),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名"冒泡排序"。
tip:
- 相邻元素两两比较,大的就往后交换,即一趟冒泡解决一个数字
- 确定趟数:因为一次冒泡解决一个数字,所以趟数 = 元素个数 - 已经排过的趟数 - 1
(2)代码实现
代码示例:冒泡排序
c
//冒泡排序
void BubbleSort(int* a, int n)
{
//整体:n个元素,需要n - 1趟冒泡
for (int j = 0; j < n - 1; j++)
{
//单趟:相邻两两比较,大的往后交换
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
}
}
}
}
tip: 根据分析:上述代码的时间复杂度,最好情况和最坏情况都是O(N^2),即不管数组是否有序,我们都需要n-1趟冒泡,所以我们对其优化------如果一趟冒泡之后如果没有交换,说明序列已经有序了,就结束排序。(可以定义一个变量exchange来判断是否发生交换------exchange初始化为false,如果发生交换exchange=true)
代码示例:冒泡排序------优化:有序了,就结束排序
c
//冒泡排序------优化:当冒泡排序没有发生交换时,结束排序
void BubbleSort(int* a, int n)
{
//整体:n个元素,需要n - 1趟冒泡
for (int j = 0; j < n - 1; j++)
{
bool exchange = false;
//单趟:相邻两两比较,大的往后交换
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
exchange = true;
Swap(&a[i - 1], &a[i]);
}
}
//判断是否发生交换,没有发生交换,结束排序
if (exchange == false)
{
break;
}
}
}
2、快速排序
(1)基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某个元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
(2)代码实现
- 快排递归实现的框架
- 每一次单趟快排选出一个key将它排到它的最终位置,并将序列分为左右两个子序列
- 将左右两个子序列重复单趟的过程,直到序列只有一个值或序列不存在才结束
- 注意:整个递归的过程是在原数组上操作的,(当使用交换函数时使用指针标识选取的基准值,因为使用临时变量保存基准值,最后交换时,交换的只是临时变量)
- 我们发现快速排序递归的主框架,与二叉树前序遍历非常像,所以在写快速的递归框架时,可以想想二叉树前序遍历规则可快速写出,后续只需分析如何按照基准值来对区间中数据进行划分的方式即可。
- 将区间按照基准值划分为左右两个部分的常见方式有:
- hoare版本
- 挖坑法
- 前后指针版本
c
//快排递归实现的框架
void QuickSort(int* a, int left, int right)
{
//递归出口------当区间只有一个值or区间不存在就递推结束,开始回归
if (left >= right)
{
return;
}
//调用函数将区间[left,right]中的元素分割成两个部分,并接收基准值key的下标
int keyi = PartSort1(a, left, right);
//根据keyi继续划分左右两个子区间
//左区间[left,key-1]
QuickSort(a, left, keyi - 1);
//右区间[key+1,right]
QuickSort(a, keyi + 1, right);
}
- hoare法
问题1: hoare分割左右区间的方式?
选出一个关键值/基准值key,把它放到正确的位置(即排好序它最终的位置)
步骤:
- 选出一个关键值/基准值key(一般可以选left/right),我们这里选择left做基准值,注意选左边做基准值要让右边先走(反之,如果选right做基准值要让左边先走)
- right找小,left找大(right找比key小的,left找比key大的)
- 交换right与left
- 重复2与3两个步骤,直到left与right相遇才结束(结论:相遇位置一定比key小)
- 结束之后,left(或right)与key交换
目的:
- 排好一个数(基准值)
- 同时将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值
hoare单趟1
:
c
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//选左边的为基准值
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小
while (a[right] > a[keyi])
--right;
//左边选大
while (a[left] < a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
return left;
}
调试发现left不会走,如下图:
既然left为基准值不会往后走,那我们一开始就left++可以解决该问题吗?
不可以,它的本质问题是当left/right与key相等时,left/right就不会走,所以left++不可以解决该问题,如下面两个场景:
- 场景1:当left/right都遇到与基准值key相等的情况时,死循环!
- 场景2:当基准值key右边的值都大于基准值时,排完序后并没有满足单趟排序的目的
解决该问题的方式是,相等时left/right也可以继续向后走,因为相等的在左边右边都可以,所以没必要交换。
hoare单趟2
:
c
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//选左边的为基准值
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小
while (a[right] >= a[keyi])
--right;
//左边选大
while (a[left] <= a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
return left;
}
调试之后发现,如果基准值key右边的值都大于或等于key时,right一直走,会发生越界(left也可能越界)
解决方案:加一个结束条件,当left>=right时,left/right不再走。
hoare单趟3
:
c
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//选左边的为基准值
//注意:快排的操作是在原数组上操作的,所以我们使用指针标识基准值,不使用临时变量保存key
//使用临时变量保存基准值,最后交换时,交换的只是临时变量
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小,
//注意:特殊情况key右边的值都大于或等于key时,right越界
while (left < right && a[right] >= a[keyi])
--right;
//左边选大
while (left < right && a[left] <= a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
//返回基准值最终的位置
return left;
}
问题2: 为什么相遇点一定比key小?
左边做key,右边先走,保证相遇位置比key小或者相遇位置就是key。
相遇的两种情况:
- L遇R:R找到小,L找大没有找到,L遇到R
- R遇L:R找不到小,R遇到L
类似道理,右边做key,左边先走,相遇位置比key大或者相遇位置就是key。
- 挖坑法
问题1: 挖坑法分割左右区间的方式?
步骤:
- 选左边为基准值,将基准值存放到一个临时变量中,此位置形成坑位
- 左边为坑位,所以右边先走,右边选小
- right找到小,将该值放到坑位,自己形成新的坑位
- right成为坑位,让左边找大
- left找到大,将该值放到坑位,自己形成新的坑位
- left成为坑位,让右边找小......
- 当left和right相遇时,把基准值放到坑位,结束
tip:挖坑法本质与hoare一样,但是它不需要考虑谁先走和和相遇点一定比key小的问题。
c
//挖坑法:将区间按照基准值划分为左右两个部分
int PartSort2(int* a, int left, int right)
{
//选左边的为基准值,将基准值保存到一个临时变量中,这个时候该位置形成一个坑位
int key = a[left];
int hole = left;
//直到L与R相遇结束
while (left < right)
{
//左边为坑位,右边先走
//右边选小,
//注意:特殊情况key右边的值都大于或等于key时,right越界
while (left < right && a[right] >= key)
--right;
//找到比key小的,将小的值放到坑位,更新坑位
a[hole] = a[right];
hole = right;
//左边选大
while (left < right && a[left] <= key)
++left;
//找到比key大的,将大的值放到坑位,更新坑位
a[hole] = a[left];
hole = left;
}
//把基准值放到相遇点的坑位
a[hole] = key;
//返回基准值最终的位置
return hole;
}
- 前后指针法
问题1: 前后指针法分割左右区间的方式?
步骤:
- 选择左边做基准值,使用指针标识基准值(因为使用临时变量保存基准值,最后交换时,交换的只是临时变量,所以我们使用指针标识基准值)
- 初始化前后指针,prev指针指向序列开头,cur指针指向prev指针的后一个位置
- cur找到比key小的值,++prev,cur和prev位置的值交换,++cur
- cur找到比key大的值,prev不动,cur++
- 重复上述操作,当cur越界时,交换prev和key的值,结束
说明:
- prev要么紧跟着cur(prev下一个就是cur)
- 要么prev跟cur中间间隔着比key大的一段值区间
tip:前后指针的本质就是把比key大的值往右翻,比key小的值翻到左边。
c
//前后指针法:将区间按照基准值划分为左右两个部分
int PartSort3(int* a, int left, int right)
{
//选左边为基准值
int keyi = left;
//初始化前后指针
int prev = left;
int cur = left + 1;
//当cur越界时结束
while (cur <= right)
{
//当cur找到的值小于key,++prev,cur和prev位置的值交换
//注意:避免自己交换自己
if (a[cur] < a[keyi] && a[++prev] != a[cur])
{
Swap(&a[cur], &a[prev]);
}
//迭代
++cur;
}
//cur越界时,交换prev和key的值
Swap(&a[prev], &a[keyi]);
//返回基准值最终的位置
return prev;
}
- 快排的时间复杂度
最好情况:快排最好的情况是每一次选key都是中位数,刚好把序列二分,这样的快排递归图就像一棵满二叉树,所以时间复杂度为O(N*logN)
最坏情况:当序列有序时,快排的效率最差,时间复杂度为O(N^2)
tip:
- 当序列有序时,我们每一次都是在最左位置选key,所以每一次都选到最大值或最小值,不能将序列二分
- 影响快排性能的是keyi(key最终的位置),keyi每一次越接近中间,就越能二分,序列的递归逻辑图就越接近满二叉树,递归的深度就越均匀,快排的效率就越高。
那我们该怎么选key呢?
- 随机选key:在区间中随机选一个值作为key, (随机选key之后,key可能在任意位置,每一次的单趟都不一样)所以随机选key之后再与区间最左位置交换,这样还是让左边位置做key,但是key值是随机的
- 三数取中:从区间的开始、中间、结束三个值,选出中间值(既不是大的也不是小的)
随机取key与三数取中两种优化方式,推荐使用三数取中
- 针对有序三数取中比随机取key好,因为三数取中一定可以选到中间值做key不可能有最坏的情况,而随机选key是随机的可能有最坏的情况
- 一般排序都是随机序列,再随机选key没有多大的意义,而特殊为有序序列时,三数取中比随机选key更科学,效率更高
随机选key
:
c
//前后指针法:将区间按照基准值划分为左右两个部分
int PartSort3(int* a, int left, int right)
{
//随机选key------针对快排最坏情况的优化
//注意:可能区间的起始点不是0,所以需要加上left
int randi = left + rand() % (right - left);
Swap(&a[left], &a[randi]);
//仍选左边为基准值
int keyi = left;
//初始化前后指针
int prev = left;
int cur = left + 1;
//当cur越界时结束
while (cur <= right)
{
//当cur找到的值小于key,++prev,cur和prev位置的值交换
//注意:避免自己交换自己
if (a[cur] < a[keyi] && a[++prev] != a[cur])
{
Swap(&a[cur], &a[prev]);
}
//迭代
++cur;
}
//cur越界时,交换prev和key的值
Swap(&a[prev], &a[keyi]);
//返回基准值最终的位置
return prev;
}
三数取中
:
c
//从区间开始、中间、结束三个值,选出中间值的下标
int GetMidNumi(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])// a[begin] < a[mid] < a[end]
{
return mid;
}
else if (a[begin] > a[end])// a[end] < a[begin] < a[mid]
{
return begin;
}
else// a[begin] < a[end] < a[mid]
{
return end;
}
}
else// a[begin] > a[mid]
{
if (a[mid] > a[end])// a[begin] > a[mid] > a[end]
{
return mid;
}
else if (a[begin] < a[end])// a[mid] < a[begin] < a[end]
{
return begin;
}
else// a[begin] > a[end] > a[mid]
{
return end;
}
}
}
//hoare版本:将区间按照基准值划分为左右两个部分
int PartSort1(int* a, int left, int right)
{
//三数取中
int midi = GetMidNumi(a, left, right);
if (midi != left)
Swap(&a[midi], &a[left]);
//选左边的为基准值
//注意:快排的操作是在原数组上操作的,所以我们使用指针标识基准值,不使用临时变量保存key
//使用临时变量保存基准值,最后交换时,交换的只是临时变量
int keyi = left;
//直到L与R相遇结束
while (left < right)
{
//左边为基准值,右边先走
//右边选小,
//注意:特殊情况key右边的值都大于或等于key时,right越界
while (left < right && a[right] >= a[keyi])
--right;
//左边选大
while (left < right && a[left] <= a[keyi])
++left;
//交换R与L
Swap(&a[left], &a[right]);
}
//交换L与keyi
Swap(&a[left], &a[keyi]);
//返回基准值最终的位置
return left;
}
- 快排的优化:小区间使用直接插入排序,减少递归
如图,快排递归的最后三层差不多占了递归总数的80%左右。
快排小区间优化:将最后三层(即当区间长度<=10时)直接插入排序,减少快排的递归次数。
c
//快排递归实现的框架
void QuickSort(int* a, int left, int right)
{
//递归出口------当区间只有一个值or区间不存在就递推结束,开始回归
if (left >= right)
{
return;
}
//小区间优化:减少递归次数
if (right - left + 1 <= 10)
{
//直接插入排序
InsertSort(a + left, right - left + 1);//注意:小区间不一定是从头开始的,可能在中间,所以需要+left
}
else
{
//调用函数将区间[left,right]中的元素分割成两个部分,并接收基准值key的下标
int keyi = PartSort1(a, left, right);
//根据keyi继续划分左右两个子区间
//左区间[left,key-1]
QuickSort(a, left, keyi - 1);
//右区间[key+1,right]
QuickSort(a, keyi + 1, right);
}
}
tip:快排一般不看最坏,因为快排加了优化,几乎不会出现最坏,时间复杂度的量级还是在O(N*logN)
- 快排的非递归
当递归深度太深时,会栈溢出!
所以我们程序员要学会将递归传非递归。
递归传非递归:
- 直接改循环
- 使用栈辅助改循环
快排递归转非递归
- 初始状态:先把第一段区间入栈
- 判断栈是否为空:
- 栈不为空:①区间出栈,单趟排序;②单趟分割的子区间入栈(注意:先入右);③子区间只有一个值或不存在就不入栈了
- 栈为空,即排好序
- 如图所示:
c
//快排非递归
void QuickSortNonR(int* a, int left, int right)
{
ST st;
STInit(&st);
//初始状态
STPush(&st, left);
STPush(&st, right);
//判断栈是否为空
while (!STEmpty(&st))
{
//不为空,区间出栈
int end = STTop(&st);
STPop(&st);
int begin = STTop(&st);
STPop(&st);
//调用函数单趟排序分割区间
int keyi = PartSort3(a, begin, end);
//区间入栈
//注意:当区间只有一个值或不存在时就不入栈了
//[begin,keyi-1] keyi [keyi+1,end]
if (keyi + 1 < end)
{
STPush(&st, keyi + 1);
STPush(&st, end);
}
if (begin < keyi - 1)
{
STPush(&st, begin);
STPush(&st, keyi - 1);
}
}
//排完序,销毁栈
STDestroy(&st);
}
tip:快排特性总结
- 快排一般不看最坏,因为快排加了优化,几乎不会出现最坏,时间复杂度的量级还是在O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
四、归并排序
1、基本思想
归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
2、归并排序的步骤
在总结归并排序的步骤之前,我们先来清楚以下几个问题:
- 问题1:两个有序区间是怎样归并成一个有序区间的?
- 设定四个指针:begin1指向左区间的第一个元素,end1指向左区间的最后一个元素;begin2指向右区间的第一个元素,end2指向右区间的最后一个元素
- 比较begin1、begin2所指向的元素,选择小的元素尾插到新空间,并移动指针到下一位置
- 重复步骤2,直到某一指针超出区间尾
- 将另一区间剩下的所有元素继续尾插到新空间
- 因为是对原数组排序,所以要将归并后的结果拷贝回原数组
- 图示:
- 问题2:归并的前提是左右区间有序,那我们怎么让它有序?
- 分治法:要让整体有序,先让左右区间有序------子问题递归
- 分解:不断将区间二分,二分到区间只有一个值的时候就有序了
- 归并:当左右区间有序了,就归并
- 图示:
tip:
- 归并递归类似于二叉树的后序遍历------要让整体有序,先让左右区间有序,左右区间有序了就归并。
- 归并的时间复杂度:O(N*logN)------高度logN,每一层分解没有消耗,归并才有消耗,每一层合计的归并都是N所以时间复杂度O(N)
- 问题3:归并是直接递归调用自己吗?
归并并不是直接递归调用自己,而是实现了一个核心的子函数来进行递归,因为之间递归调用自己,需要开很多小空间(存归并的结果),消耗太大,所以不递归调用自己。
归并排序的步骤:
- 开辟一个临时空间,存放归并后的序列,空间大小与排序数组一样大
- 调用核心子函数来实现归并排序
3、归并的递归写法
c
//完成归并排序
void _MergeSort(int* a, int left, int right, int* tmp)
{
//递归出口:区间分解到一个值或不存在就不分解了
if (left >= right)
{
return;
}
//分解------使左右区间有序
int mid = (left + right) / 2;
//[left,mid] [mid+1,right] 子区间递归分解
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
//合并------左右区间有序了就归并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
//如果左区间未走完,将其剩下的元素继续尾插到临时数组
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
//如果右区间未走完,将其剩下的元素继续尾插到临时数组
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
//将归并后的结果拷贝回原数组
//注意:区间并不一定从下标0开始,可能为在中间,所以需要加上left
memcpy(a + left, tmp + left, sizeof(int) * (right - left + 1));
}
//归并排序的递归写法
void MergeSort(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//调用核心子函数完成归并排序
_MergeSort(a, 0, n - 1, tmp);
//使用完堆空间,记得释放
free(tmp);
}
4、归并的非递归写法
- 问题1: 我们需要借用栈来辅助改循环吗?
不用,归并的非递归不需要借用栈,直接使用循环即可。
归并的递归中需要我们将原数组分解成只有一个值的区间,但是非递归就不需要分解了,我们可以直接把它看成gap个值的区间的归并。如图:
错误代码示例
:
c
//归并排序的非递归
void MergeSortNonR(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//gap存放每组区间的元素个数
int gap = 1;
while (gap < n)
{
//合并左右区间
for (int i = 0; i < n; i += 2 * gap)
{
//每个区间有gap个元素
//[i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//如果左区间未走完,将其剩下的元素继续尾插到临时数组
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
//如果右区间未走完,将其剩下的元素继续尾插到临时数组
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
//间隔为gap的多组数据,归并完以后一次性将归并后的结果拷贝回原数组
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
//使用完堆空间,记得释放
free(tmp);
}
- 问题2:上述代码错误在哪?
- 每一次循环之后,区间开始位置i += 2 * gap,这样就会造成一个问题,当数组长度不是2的次方倍就会出现越界问题。图示:
- 当数组长度不是2的次方倍时,end1、begin2、end2可能越界。
- 问题3: 当数组长度不是2的次方倍时,我们怎样解决数组越界问题呢?
复杂问题分解为简单问题:分类讨论
- end1越界:不归并了
- end1没有越界,begin2越界:也不归并了
- end1、begin2没有越界,end2越界:修正end2,继续归并
- 问题4:归并之后需要将排好序的结果拷贝回原数组,是归并一部分就拷贝回一部分好呢,还是间隔为gap的多组数据,归并完以后,一把拷贝原数组好呢?
归并一部分拷贝一部分好一点。
- 因为如果是归并一部分就拷贝回一部分,当end1或begin2越界了,我们就可以直接break退出循环了,不用归并了
- 如果是间隔为gap的多组数据,归并完以后,一把拷贝原数组,当end1或begin2越界了,我们不可以直接break退出循环了,虽然不需要归并了,但是也要需要将原数组剩余的元素拷贝到临时空间
正确代码1
:一次性拷贝,不能直接break,需要修正边界
c
//间隔为gap的多组数据,归并完以后一次性将归并后的结果拷贝回原数组
void MergeSortNonR(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//gap存放每组区间的元素个数
int gap = 1;
while (gap < n)
{
//合并左右区间
for (int i = 0; i < n; i += 2 * gap)
{
//每个区间有gap个元素
//[i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//一次性拷贝不能直接break,修正越界
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//如果左区间未走完,将其剩下的元素继续尾插到临时数组
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
//如果右区间未走完,将其剩下的元素继续尾插到临时数组
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
//间隔为gap的多组数据,归并完以后一次性将归并后的结果拷贝回原数组
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
//使用完堆空间,记得释放
free(tmp);
}
正确代码
:归并一部分拷贝一部分
c
void MergeSortNonR(int* a, int n)
{
//创建一个临时数组存放每一次归并后的序列
int* tmp = (int*)malloc(sizeof(int) * n);
assert(tmp);
//gap存放每组区间的元素个数
int gap = 1;
while (gap < n)
{
//合并左右区间
for (int i = 0; i < n; i += 2 * gap)
{
//每个区间有gap个元素
//[i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//归并一部分拷贝一部分
if (end1 >= n || begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
//如果左区间未走完,将其剩下的元素继续尾插到临时数组
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
//如果右区间未走完,将其剩下的元素继续尾插到临时数组
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
//归并一部分拷贝一部分
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
//使用完堆空间,记得释放
free(tmp);
}
补充:外排序
- 外排序:在磁盘中排序
- 内排序:在内存中排序
我们学的排序都可以内排序,但只有归并可以外排序。
例如我们要排序一个500GB的文件,我们可以先将其分为500个1GB的小文件排好序,之后利用归并不断两两合并成一个有序区间。
tip:归并排序特性总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思想更多是解决磁盘中的外排序问题
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
五、非比较排序
1、常见的非比较排序
- 计数排序:计数排序适合范围集中,且范围不大的整形数组排序,不适合范围分散或非整形的排序,例如字符串、浮点数、结构体等
- 基数排序:实际中没有什么应用价值,校招也不考,可学不可学
- 桶排序:设计太差了,不推荐学习
- 综上非比较排序我们这里只详细讲解计数排序
2、计数排序
(1)基本思想
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。用到了哈希映射的思想。
(2)操作步骤
- 统计每个数据出现的次数
给每个数据建立一个专门的坑位,即开辟一个countA数组。如图:
- 绝对位置映射:下标即为值的位置(数组长度为max)
- 相对位置映射:下标对应数据的范围位置,即为a[i]-min(数组长度为max-min+1)
- 推荐使用相对映射
- 排序:根据countA数组统计的结果,将数据读会原数组
c
//计数排序
void CountSort(int* a, int n)
{
//相对映射,先找出max、min算范围
int max = a[0], min = a[0];
for (int i = 0; i < n; ++i)
{
if (max < a[i])
{
max = a[i];
}
if (min > a[i])
{
min = a[i];
}
}
int range = max - min + 1;//左闭右闭区间个数需要+1
int* countA = (int*)malloc(sizeof(int) * range);
assert(countA);
//malloc不会初始化,使用memset初始化数组
memset(countA, 0, sizeof(int) * range);
//计数
for (int i = 0; i < n; ++i)
{
//相对位置映射:下标对应数据的范围位置,即为a[i]-min
countA[a[i] - min]++;
}
//排序
int j = 0;//原数组下标
for (int i = 0; i < range; ++i)
{
while (countA[i]--)
{
a[j++] = i + min;
}
}
free(countA);
}
tip:计数排序特性总结
- 计数排序在数据范围集中时,效率很高,但是使用范围及场景有限
- 时间复杂度:O(N+范围)
- 空间复杂度:O(范围)
- 稳定性:稳定
五、总结
-
稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。如图:
-
稳定性的意义:例如在高考中的排名就需要稳定性。
-
排序稳定性的分析,如图: