大家好,前两期,我给大家介绍了插入排序和选择排序两组排序算法,这一期,我将给大家介绍另一类排序算法:交换排序。
交换排序基本思想:
所谓的交换,就是根据序列中两个记录键值的比较结果来对换两个记录在序列中的位置。
交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
交换排序分为冒泡排序和快速排序两种
废话不多说,我们直接开始:
一:冒泡排序
1. 原理
每一趟排序都两两比较相邻元素,将键值大的元素向后移动。
整体类似冒泡的过程,每一次都把待排序数据中的最大键值元素移动到最后。
每一个元素都可以向小气泡一样,根据自身大小一点一点向数组的一侧移动。
2. 动图
接下来通过一张动图直观感受一下冒泡排序的过程:

3. 代码实现
任何排序算法的代码实现都建议先写单趟排序最后再套起来,因为冒泡排序比较简单,直接给出
cpp
void BubbleSort(int* a, int n)
{
// 一共 n - 1 趟排序
for (int j = 0; j < n; j++)
{
// 控制每一趟排序的结束位置
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
}
}
}
}
4. 优化:
优化:如果在某一趟排序中发现数组已经有序了,那么就不用再往后进行了。
我们在每一趟排序之后判断一下这一趟排序有没有发生数据交换,如果没有发生,说明数据已经有序了,直接结束就可以了。
cpp
void BubbleSort(int* a, int n)
{
// 一共 n - 1 趟排序
for (int j = 0; j < n; j++)
{
bool exchange = false;
// 控制每一趟排序的结束位置
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = true;
}
}
if (exchange == false)
{
break;
}
}
}
5. 总结
冒泡排序算法:
时间复杂度:O(n ^ 2)
最差情况:O(n ^ 2)
最好情况:O(n),数组本来就有序,一趟扫描就退出。
空间复杂度:O(1),没有借助额外空间。
是一个稳定的排序算法。
实际中很少用,但是有教学的意义。
6. 扩展
我们已经学过了直接插入排序、直接选择排序、冒泡排序,矮子里面挑一个将军,哪一个更好一些
直接选择排序直接淘汰:最好最坏都是 O(n ^ 2)
直接插入排序和冒泡排序:最好 O (n),最坏 O(n ^ 2)
完全无序时二者差不多的,完全有序也是差不多的。
当数组部分有序时,直接插入排序的优势就出来了,直观感受一下,冒泡可能要全冒,插入只需几次移动。
所以 O(n ^ 2)量级的排序,直接插入排序是王者。
二:快速排序(快排)
1. 原理
快速排序是 Hoare 于 1962 年提出的一种二叉树结构的交换排序算法,其基本思想是:任取待排序元素序列中的某个元素作为基准值,按照该排序码将待排序序列集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直至所有元素都排列相应位置为止。
快速排序实现主框架:
cpp
//快速排序
void QuickSort(int* a, int left, int right)
{
if (left >= right) {
return;
}
//_QuickSort⽤于按照基准值将区间[left,right)中的元素进⾏划分
int meet = _QuickSort(a, left, right);
QuickSort(a, left, meet - 1);
QuickSort(a, meet + 1, right);
}
2. 实现方式
a. hoare 版本(最经典)
算法思路:
单趟排序:选出一个关键值/基准值(key),把他放到一个正确的位置(最终排好序要在的位置)

实现方式:
step1:创建左右指针,确定基准值(一般就选最左边的元素)
step2:右指针从右向左找比基准值小的数据,左指针从左往后找比基准值大的数据,然后左右指 针交换,直至双指针相遇,交换 key 和 L。(把小的往左边换,大的往右边换)
step3:递归处理左右区间。
动图:(自己动手模拟)

细节问题:
左边做 key,要让右边先走,这样能保证相遇位置一定比 key 要小或相等。(动手画几次就ok)
右边做 key,要让左边先走,这样能保证相遇位置一定比 key 要大或相等。
找大就是找大,找小就是找小,相等不行,否则会出现死循环。(动手画几次就ok)
left 必须从 keyi 开始,不能从 keyi + 1 开始。(动手画几次就ok)
代码实现(单趟):
cpp
void QuickSort(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
// 左边找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left
}
单趟排完,递归左右区间:

cpp
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
int keyi = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
// 左边找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
// [begin, keyi - 1] keyi [keyi + 1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
b. 挖坑法

谁不动,谁就是坑,相遇位置一定在坑上,谁先走谁后走,相遇位置直接放 key
大思想没变,但是相对于方法一更好理解。
由于本方法原理和第一种方法类似(但是两种方法单趟排序结果不一定一样),大家自己看动图理解即可,这里直接给出代码:
cpp
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
int midi = GetMidNumi(a, left, right);
Swap(&a[midi], &a[left]);
int key = a[left]; // 保存这个值
int hole = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= key)
{
--right;
}
a[hole] = a[right];
hole = right;
// 左边找大
while (left < right && a[left] <= key)
{
++left;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
// [begin, keyi - 1] keyi [keyi + 1, end]
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
c. lomuto 前后指针法
严格来说最好的方法,也是最抽象的方法。如果你掌握了,写起来会非常简单。
先看动图:

cur:在找小,cur 找到比 key 小的值,就 ++prev,cur 和 prev 的位置的值交换,++cur。
如果 cur 找到比 key 大的值,++cur
prev:要么紧跟着 cur(prev 下一个就是 cur),要么跟 cur 中间间隔着比 key 大的一段值区间
把比 key 大的值往右翻,比 key 小的值,挪到左边。
cpp
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
int midi = GetMidNumi(a, left, right);
Swap(&a[midi], &a[left]);
int keyi = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
// [begin, keyi - 1] keyi [keyi + 1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
3. 时空复杂度
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序。
理想状况下:时间复杂度为 O(n log n)(量级)
空间复杂度为 O(log n)(深度)
第一层 n
第二层 n - 1(key 就不用参与了)
第三层 n - 3 (key 不用参与了)
..................
一共有 lg n 层。

但是,在一些极端情况下,如果不加任何优化的化,快排的时间复杂度会退化成 O(n ^ 2),而且有可能会发生栈溢出。
比如下面场景:(数组本来就有序(顺序 or 逆序))

之所以时间复杂度在极端情况下会退化到 O(n ^ 2),是因为我们选择的 keyi 在极端情况下是最小值或最大值导致的。
所以有人就想出了随机选i 和 三数取中 的方式去优化:
那么如果元素大量重复,极端情况下所有元素都相等怎么办呢???
所以有人就想出了三路划分 的方式去优化:
4. 优化(以hoare版本为例)
a. 随机选 keyi
通过随机数的方式随机选 keyi,这样就不容易选到最大最小的数了。
cpp
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left, end = right;
// 随机选 key
int randi = left + (rand() % (right - left));
Swap(&a[left], &a[randi]); // 后面逻辑不变
int keyi = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
// 左边找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
// [begin, keyi - 1] keyi [keyi + 1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
b. 三数取中(更推荐)
开始,中间,最后三个数选择不是最大也不是最小的那一个。
比随机数更好一点,这样肯定选出的不是最小的。
cpp
int GetMidNumi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right]) return mid;
else if (a[left] > a[right]) return left;
else return right;
}
else
{
if (a[mid] > a[right]) return mid;
else if (a[left] < a[right]) return left;
else return right;
}
}
int midi = GetMidNumi(a, left, right);
Swap(&a[midi], &a[left]);
5. 总结
快速排序的时间复杂度为 O(n log n),空间复杂度为 O(log n)。(几乎就是这样的)