排序算法_总述

八大排序算法,归于 5 类

有八种典型的排序算法,可归于五大类:

  1. 插入排序
    1. 直接插入排序
    2. 希尔排序
  2. 选择排序
    1. 简单选择排序
    2. 堆排序
  3. 交换排序
    1. 冒泡排序
    2. 快速排序
  4. 归并排序
  5. 基数排序

稳定性

两要素:

  1. 两个元素相同
  2. 排序后这两个元素是否能保持原来的顺序

以下讨论的默认都是从小到大的排序。

插入排序(直接、希尔)

直接插入排序

依次从待排序序列中取值,向已排序序列中投放,保证再次完全有序,直到待排序列中所有值取完。

插入排序大多数都是两层循环。

直接插入排序的思想:将待排序数据分为两部分,左边为有序的,右边是无序的。
从右边拿一个数据插入到左边(为了方便,每次拿右边的第一个数据),
需保持左边继续有序,直到右边没有数据。

过程可以参照扑克牌插入排序。

《算法导论》写法

思想:j指向的是左侧已排序的末尾。i指向的是未排序的开头。
外部for是从左到右遍历i的。
内部for是从右到左遍历j的。
每次,a[i]存入到了tmp,让a[j]tmp比较,若大于,说明还没找到tmp(a[i])应该在的位置。则--j继续找,直到找到一个小于tmp的位置。那么,退出内部for,把tmp存入到它的右边一个位置a[j + 1] = tmp。开始下一个a[i]的排序。

这个《算法导论》的写法可读性不如《算法》的写法,推荐看后者的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void straight_insert_sort(int a[], int n)
{
for(int i = 1; i < n; ++i)
{
int tmp = a[i];
for(int j = i - 1; j > -1; --j)//j指示欲插入的位置的前一个
{
if(a[j] > tmp)//移动
{
a[j + 1] = a[j];
}
else break;//j不减1
}
a[j + 1] = tmp;
}
}

for循环体中的条件判断可以与for表达式合并

1
2
3
4
5
6
7
8
9
10
11
12
13
void straight_insert_sort(int a[], int n)
{
for(int i = 1; i < n; ++i)
{
int tmp = a[i];
for(int j = i - 1; tmp < a[j] && j > -1; --j)
{
a[j + 1] = a[j];
}
//j指示插入位置的前一个位置
a[j + 1] = tmp;
}
}

《算法》写法

在每遍历一个值时,直接把ij同时指向了一个位置。第 0 个位置跳过(排序算法的常见套路,第 0 个位置略过,当作已排序数据)。

同时,不用存tmp了,直接封装到了swap函数中。

内部for循环条件,看a[j - 1]是否大于a[j],大于则交换。
内部for循环做的事情,就是让a[i]在左侧(j的区域),从右到左,找到合适的位置插入。

1
2
3
4
5
6
7
8
9
10
void straight_insert_sort(int a[], int n)
{
for(int i = 1; i < n; ++i)
{
for(int j = i; a[j - 1] > a[j] && j > 0; --j)
{
swap(&a[j], &a[j - 1]);
}
}
}
情况 时间复杂度
最好情况 O(n)O(n)
平均情况 O(n2)O(n^2)
最坏情况 O(n2)O(n^2)

希尔排序 - 插入排序的优化

《算法》的写法 - 3x+1 增量

最大间隔为 n / 3。每次除以 3 取整,间隔序列长度是O(log3n)O(\log_3 n)级别。1, 4, 13, 40, 121, 364, 1093, …

在插入排序中加入一个外循环,来将 h 按照递增序列递减,就能得到这个简洁的希尔排序。
增幅 h 的初始值是数组长度乘以一个常数因子,最小为1。

假设 n 是 10 ,n / 3 是 3。则 h 初值为 4。
先按 下标间隔 4 进行排序。让数组a 每 4 个有序,即:0、4、8有序,1、5、9有序,2、6(没有10)有序,3、7有序。
再让 h 递减,h = h / 3,得 h 为 1 。即按 下标间隔 1 进行排序。让数组a 每 1 个有序,即:0、1、2、3、4、5、6、7、8、9有序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void shell_sort(int a[], int n)
{
int h = 1;
while (h < n / 3) h = 3 * h + 1; // h 可能是 1, 4, 13, 40, 121, 364, 1093, ...
while (h >= 1)
{
// 将数组变为 每 h 有序
for (int i = h; i < n; ++i)
{
// 将a[i]插入到a[i-h], a[i-2*h], a[i-3*h]... 之中
for (int j = i; j - h > -1 && a[j] < a[j - h]; j -= h)
{
swap(&a[j], &a[j - h]);
}
}
h = h / 3;
}
}

在最坏的情况下该算法的比较次数 和 N3/2N^{3/2} 成正比。
经验平均复杂度(大量随机输入):O(n3/2)O(n^{3/2})
最坏情况复杂度O(n2)O(n^{2})。Shell 排序没有严格的最坏情况次方级优化保证,某些特殊序列会退化到平方级
最好情况复杂度(数组已经有序):O(nlogn)O(n \log n)。因为每个 h 排序只需一次线性扫描

Bubo的写法 - 折半增量

这是折半增量序列,虽然在常数因子上比普通插入排序好,但在数据规模大时明显慢于用 3x+1 序列的排序。

最大间隔为n / 2。增量依次为:n/2,n/4,n/8,…,1。序列长度 约等于 log2n\log_2 n。 1, 2, 4, 8, 16, 32, 64, …

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void shell_sort(int a[], int n)
{
for(int k = n / 2; k > 0; k /= 2) // k为组距或组数
{
// 组内进行"直接插入排序"
for(int i = k; i < n; i += k)
{
for(int j = i; j > k - 1 && a[j - k] > a[j]; j -= k)
{
swap(&a[j - k], &a[j]);
}
}
}
}

由于最后一次 k=1 等同于直接插入排序,且前几轮效果不够显著,总体时间复杂度:O(n2)O(n^2)
平均情况:依然接近O(n2)O(n^2)。比单纯的插入排序快常数倍,但数量级不变。
最好情况:如果数组初始就是有序的,每轮只做一次扫描,复杂度:O(nlogn)O(n \log n)

情况 时间复杂度
最好情况 O(nlogn)O(n \log n)
平均情况 O(n2)O(n^2)
最坏情况 O(n2)O(n^2)
所以,这种(折半增量)如果不在序列增量(3x+1 增量)上进行优化,还不如直接用直接插入排序。

3x+1 对比 折半 增量的优势

插入排序的一个核心特点 —— 它的效率高度依赖数据的有序程度,所以序列不同性能差异会很大。

折半增量的早期问题:
当 gap 很大时(比如 n/2):

  • 子序列很短
    gap = n/2 → 每个子序列只有 2 个元素
    gap = n/4 → 每个子序列只有 4 个元素
  • 比较覆盖度低
    大 gap 排序时,只比较很稀疏的元素对,大部分元素间的相对顺序并没有直接调整
  • 逆序对消除效率低
    Shell 排序的性能提升依赖于尽早减少逆序对
    但大 gap 只能消除相隔很远的逆序对,很多局部逆序(相隔 < gap 的)还留着,等到 gap 变小时才会处理

3x+1 增量 对比 折半增量的优势

  • 折半增量 n/2=8 第一轮时,每个子序列长度只有 2,对局部逆序几乎没影响;
  • 3x+1 第一轮 h=13 时,子序列长达 3 或 4,能一次调整更大范围的元素,迅速减少逆序对
  • 到 h=1 时,3x+1 版本剩下的乱序非常少,所以最后插入排序很快

原始数组(逆序):
16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1

折半增量第一轮:

1
2
3
原始:16 15 14 13 12 11 10  9  8  7  6  5  4  3  2  1
gap=8: (16,8), (15,7), (14,6), (13,5), (12,4), (11,3), (10,2), (9,1)
结果: 8 7 6 5 4 3 2 1 16 15 14 13 12 11 10 9

虽然位置变化很大,但前 8 个和后 8 个依旧是逆序的;
绝大多数局部逆序还在,下一轮 gap=4 时才开始打乱它们。

3x+1 增量第一轮:

  • 子序列是按下标相差 13 分的分组:
    • 组1: (16, 3)
    • 组2: (15, 2)
    • 组3: (14, 1)
    • 其他元素只和自己比较
  • 对每个子序列进行插入排序:
1
3  2  1 13 12 11 10  9  8  7  6  5  4 16 15 14

3x+1 增量第二轮(h = 4)

  • 子序列(间隔 4)
    • 组1: (3, 12, 8, 4)
    • 组2: (2, 11, 7, 16)
    • 组3: (1, 10, 6, 15)
    • 组4: (13, 9, 5, 14)
  • 各组内部插入排序:

结果:
3 2 1 5 8 7 6 9 12 11 10 13 4 16 15 14
(已经更接近有序)

插入排序总结

  • 逆序对分析:时间复杂度与数组中的逆序对数量成正比
  • 与希尔排序关系:希尔排序是插入排序的推广(多间隔插入),解决了大规模数据移动代价高的问题
  • 实际案例:C++ STL 的 std::sort 对小数组(一般 ≤ 32 元素)内部会用插入排序收尾(快速排序的尾递归部分),因为它在缓存命中率和分支预测上很高效。
  • 非常适合小规模或接近有序的数据
  • 不同的增量序列 → 决定了希尔排序每一轮能消除多少逆序对 → 决定了最后 gap=1 时的工作量 → 直接影响性能。

增量序列的作用

  • 希尔排序的效率,取决于它能多快把逆序对减少到接近 0。
  • gap 很大时 → 只能消除长距离的逆序对(下标差 ≥ gap)
  • gap 变小时 → 才能处理更短距离的逆序对(下标差 < gap)

最后一轮 gap=1(普通插入排序) → 必须处理剩余所有短距离逆序对

  1. 折半增量(n/2, n/4, …, 1)
    • 前几轮 gap 很大时,每个子序列很短
    • 只能消除少量长距离逆序对,很多短距离逆序对要留到最后一次 gap=1 时处理
    • 导致最后一轮几乎是完整的 O(n2)O(n^2) 插入排序
  2. 优化过的增量(3x+1)
    • gap 序列递减比例较小(约 2~3 倍),而不是直接减半
    • 每一轮的子序列更长,能一次消除多种不同跨度的逆序对
    • 到最后 gap=1 时,数组已经接近有序,逆序对很少 → 插入排序非常快

逆序对是什么?

1
2
3
[3, 1, 2]
逆序对:(3, 1)、(3, 2)
总共 2 个逆序对。

选择排序(简单、堆排)

每次找到剩余序列中最小的元素,放到已排序序列的尾部(或者看作剩余序列的头部)。
不断地选择剩余元素之中的最小者

简单选择排序

《算法》的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void selection_sort(int a[], int n)
{
for (int i = 0; i < n; ++i)
{
int min_idx = i;
for (int j = i + 1; j < n; ++j)
{
if (a[j] < a[min_idx])
{
min_idx = j;
}
}
// 注意,是 a[i]而不是a[j] 和 a[min_idx]交换
swap(&a[i], &a[min_idx]);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
i min   0 1 2 3 4 5 6 7 8 9 10
S O R T E X A M P L E
0 6 S O R T E X A M P L E
1 4 A O R T E X S M P L E
2 10 A E R T O X S M P L E
3 9 A E E T O X S M P L R
4 7 A E E L O X S M P T R
5 7 A E E L M X S O P T R
6 8 A E E L M O S X P T R
7 10 A E E L M O P X S T R
8 8 A E E L M O P R S T X
9 9 A E E L M O P R S T X
10 10 A E E L M O P R S T X

优化:第一层for中的i可以优化为i < n - 1,因为排最后一个时,剩余序列就1个数了,无需再找最小了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void selection_sort(int a[], int n)
{
int min_idx;
for(int i = 0; i < n - 1; ++i)
{
min_idx = i;
for(int j = i + 1; j < n; ++j)
{
if(a[j] < a[min_idx])
{
min_idx = j;
}
}
swap(&a[i], &a[min_idx]);
}
}

堆排序

堆的作用是产生一个极值元素。

堆是一种完全二叉树。(区分概念:二叉树、满二叉树、完全二叉树)

总体的排序过程:首先要保证整个二叉树是一个大(小)根堆,即每一个父节点都要比其子节点大(小),如此就能保证整个树的根节点就是最大(小)的节点,即产生了一个极值元素。
我们把它和未排序的最后一个叶子节点互换值,而交换完之后,根节点打乱了大(小)根堆的结构,需要重新保证整个二叉树是一个大(小)根堆,才能产生极值元素与最后位置交换。
如此,循环往复,每找到一个极值点,就换其到最后(归到已排序数列中),就可以达到排序的目的。

其中,找极值元素,即保证树为大(小)根堆的过程,是一个递归过程:因为大(小)根堆的概念就是:完全二叉树中,每一个父节点都要比其子节点大(小)。由于涉及到树,并且涉及到层层相扣的大小关系,因此堆化的过程是一个递归过程。

如果把数据抽象为堆结构去排序,那么首先我们需要建立堆。需要从最后一个非叶子节点进行逆向遍历,依次以节点为根进行堆化。

堆化函数

堆化函数,递归地让整颗完全二叉树每个节点都大于(小于)其子节点。根节点大于字节点则是大根堆,根节点小于子节点则是小根堆。

注意!!!堆化函数只适用于:只有根节点不是大(小)根堆,而其他节点符合大(小)根堆 的情况。

以下的堆化函数,是基于一个root为根到last节点的大根堆。(这段代码,事实上是:由于节点比子节点小,进行下沉操作)
堆化函数,让它确保恢复到大根堆的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void heapify(int a[], int root_idx, int last_idx)
{
int left = root_idx * 2 + 1; //root最小值可能为0
int right = left + 1;
// 判断该根节点是否有子节点,如果left超过了last_idx值则退出
if(left > last_idx) return;
int max = root_idx;
// 运行到这里时,left > last_idx,即last_idx <= left,由于是完全二叉树的逻辑结构,因此max一定有左子树,所以我们直接看[left]和[max]的大小关系
if(a[left] > a[max])
{
max = left;
}
// 但我们不能确定是否max有右子树,需要判断
if(right <= last_idx)
{
if(a[right] > a[max])
{
max = right;
}
}
if(max != root_idx)
{
exchange(a, &a[root_idx], &a[max]);
heapify(a, max, last_idx);
}
}

堆化函数和优先级队列的关系 - 上浮、下沉函数

优先级队列,可以看作是一个大根堆,根节点的优先级最大。

大根堆中,上浮,swim,或FilterUp。即由于一个节点大于其父节点,上浮。
大根堆中,下沉,sink,或FilterDown。即由于一个节点小于其所有子节点,下放。

我们以下描述的均为数组下标。因此,假设某节点对应的下标为i,则左子节点的下标就为2 * i + 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void FilterDown(int a[], int start_idx, int end_idx)
{
int i = start_idx;
int j = i * 2 + 1;
// 看有没有子节点。
while (j <= end_idx)
{
// 看 有没有 右子节点。如果有则 看 左子节点和右子节点哪个更大。
if (j < end_idx && a[j + 1] > a[j])
{
j = j + 1;
}
// 把更大的子节点a[j] 与 a[i]比较。如果a[i] 大于等于 a[j],则 i 节点不用换位置了
if (a[i] >= a[j])
{
break;
}
swap(&a[i], &[j]);
// a[i] 和 a[j] 交换后,i 节点和 它的子节点有序了
// 但是,j 节点的值换成了 a[i],有可能 j 节点和它的子节点乱序了。重新开始。
i = j;
j = i * 2 + 1;
}
}

(i - 1) / 2i节点的父节点。
所谓上浮,是重点关注于该节点本身,与父节点比较大小。无需关注兄弟节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
void FilterUp(int a[], int start_idx)
{
int i = start_idx; // 子节点
int j = (i - 1) / 2; // 父节点
// 若 子节点 大于 父节点 则 上位
while (i > 0 && a[i] > a[j])
{
swap(&a[i], &a[j]);
// a[i] 和 a[j] 交换后,i 节点和 它的父节点 j 有序了(目前a[i] < a[j],子节点小于父节点)
// 但是,有可能 j 节点 和 j 的父节点 乱序。重新开始。
i = j;
}
}

建堆

再次注意!!!堆化函数只适用于只有根节点不是大(小)根堆,而其他节点符合大(小)根堆 的情况。

因此在堆化之前我们应该先建立一个大(小)根堆!

怎么建立大(小)根堆呢?我们要利用上面这个堆化函数。而刚才一直在反复强调,堆化函数只能用于除了根节点没有堆化,其他节点已全部堆化的情况。所以:只能从非叶子节点逆着依次堆化!最后一个非叶子节点为:(last_idx - 1) / 2。(last_idx表示数组下标,从0开始)

1
2
3
4
5
6
7
void create_heap(int a[], int root_idx, int last_idx)
{
for(int i = (last_idx - 1) / 2; i > -1; --i)
{
heapify(a, i, last_idx);
}
}

排序

如上文,每次产生一个极值元素,和未排序的最后一个叶子节点互换值,就可以达到排序的目的。

1
2
3
4
5
6
7
8
9
10
void heap_sort(int a[], int n)
{
create_heap(a, 0, n - 1);
for(int i = n - 1; i > 0; )
{
exchange(a, &a[0], &a[i]);
--i;
heapify(a, 0, i);
}
}

用下沉函数进行堆排序

此处,sink,生成了一个大根堆。
之后每次把大根(整棵树的最大值)放到后面,当作已排序序列。再对未排序进行大根化。以此往复。
因此,基于大根堆的排序之后的序列是升序的(从小到大)
同理,基于小根堆的排序之后的序列是降序的(从大到小)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void heap_sort(int a[], int n)
{
// i 初始化为 尾 节点的 父 节点
// 从 最后一个 有分支的 节点 开始,一直到 0 节点(整棵树的根)每个 二叉树 都 做一次 大根化 操作
for (int i = (n - 1) / 2; i >= 0; i--)
{
// sink 的 第三个参数传入的是 下标,n - 1指尾节点的下标
sink(a, i, n - 1);
}
int i = n - 1;
// i 等于 0 时,则 未排序的只剩一个数了,不用再排序。
while (i > 0)
{
// 目前 0 位置就是最大值,放到最后(就是 i 位置),然后最后的边界缩小 1(--i),让 0 到新边界大根化,生成下一个极值。
swap(&a[0], &a[i]);
--i;
sink(a, 0, i);
}
}

冒泡排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void bubble_sort(int a[], int n)
{
for(int i = 0; i < n - 1; ++i)
{
// 每完成一次 内部 for 循环,都会为 已排序序列 确定一个数。因此 已排序序列的边界 更新为 i
for(int j = n - 1; j > 0 + i; --j)
{
// 把 左边大的数 交换到了 右边,因此最后生成了 升序序列(从小到大)
if(a[j - 1] > a[j])
{
swap(&a[j - 1], &a[j]);
}
}
}
}

第一层for循环退出的条件可以由i < n优化成i < n - 1。因为当i == n - 1时,最后的数一定是最大的数。

优化:如果第一次for循环,从尾部到头部一路比较下来,发现,没有交换过,说明这是一个已经有升序的序列,直接return,不再进行往后无谓的排序了。
最好情况(已排序数组)只需 O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void bubble_sort(int a[], int n)
{
for(int i = 0; i < n - 1; ++i)
{
int is_exchanged = 0;
for(int j = n - 1; j > 0 + i; --j)
{
if(a[j - 1] > a[j])
{
swap(&a[j - 1], &a[j]);
is_exchanged = 1;
}
}
if(!is_exchanged)
return;
}
}

快速排序

划分基准

在快速排序中,最重要的即为划分过程,每个数的位置将在每一次划分后确定。

那么,基准值(pivot)其实可以从未定序列(除了确定位置的其他值)中随机选择。

在不同教材中,讲解的快速排序往往有两大方案,一是每次选择左首为基准,二是每次选择右尾为基准进行划分。

在《算法导论》中,选择的是右尾;
在《算法》(普林斯顿大学)中选择的是左首。
在Bubo的讲解中,选择的是右尾;
在老杨派系的讲解中,选择的是左首。
在网络上,大部分选择的是左首。

划分方向 - 左右向 + 单双向

除了基准位置的选取不同。迭代方向也有所不同:分为单向(单轴)法(需要分左右)、双轴法等。

这些各式各样的方法,无所谓其细节,其实达到的效果都是一样的,

即我们需要关注其核心本质,不要忘了快速排序中,划分函数的初心是什么。

所以,我们可以想象,如果每次选择左首划分,那么其实给元素的位置交换多了一些麻烦,即出现不同的情况。

如果选择右尾划分,我们可以发现,划分区域的扩张过程很简洁,那么元素的位置交换也不需要太多的分情况,所以思路相对简洁

总的来说,划分的方式有三大要素:左首、右尾划分;单向、双向划分;左、右划分(如果是双向划分则是先左后右或先右后左)

单向划分方法又称为:Lomuto 分区(Lomuto partition scheme)
双向划分(向中间逼近)又称为:Hoare 分区(Hoare partition scheme)
单向、双向都是把数组分成了两个区域加一个基准值。
还有一种三路划分(Dutch National Flag partition),是分成了三个区域,小于的、等于的、大于的。
三路划分单独处理了数组中和基准值重复过多的情况。

总之,可分为:

  1. 左首、单向、左划分;
  2. 左首、单向、右划分;
  3. 左首、双向、先左后右划分;
  4. 左首、双向、先右后左划分;
  5. 右尾、单向、左划分;
  6. 右尾、单向、右划分;
  7. 右尾、双向、先左后右划分;
  8. 右尾、双向、先右后左划分;
  9. 三路划分

0:左首、三路划分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void quick_sort_3way(int a[], int left, int right)
{
if (left >= right) return;
int pivot = a[left];
int lt = left, i = left + 1, gt = right; // a[left..lt - 1] < p, a[lt ..gt] == p, a[gt+1..right] > p
while (i <= gt)
{
if (a[i] < pivot)
{
swap(&a[lt], &a[i]);
++lt;
++i;
}
else if (a[i] > pivot)
{
swap(&a[i], &a[gt]);
--gt;
}
else
{
++i;
} // a[i] == pivot
}
quick_sort_3way(a, left, lt - 1);
quick_sort_3way(a, gt + 1, right);
}

1:左首、单向、左划分(老杨)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int pivot = a[left];
int i = left;
int j = i + 1;
// j 遍历向右
while (j <= right)
{
if (a[j] <= pivot)
{
swap(&a[i + 1], &a[j]);
++i;
}
++j;
}
// i 的位置是 左侧部分的末尾。
// left 属于 左侧部分。不应与i+1交换,而是与i交换
swap(&a[i], &a[left]);
// i 的位置是基准值,已固定。只需排除了i之后剩下的。
quick_sort(a, left, i - 1);
quick_sort(a, i + 1, right);
}

优化:if (a[j] <= pivot)中最好把<=改为<

  1. 优化前,如果数组中等值太多,则会进行多次无意义的交换。
    • 设数组全是相同值,选左端为轴。
    • 对每个 j,a[j] <= pivot 都成立,于是 i 每次自增,循环结束后 i == right,最后再把 pivot 和 a[i] 交换——轴被放到最右端
    • 递归变成:[left, right-1](规模 n-1)和空区间。于是递归关系 T(n)=T(n-1)+O(n),整体退化到 O(n^2)
  2. 优化后,用 < 会好一点(少很多交换),但在“全等/大量重复”的数据上还是会退化到 O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int pivot = a[left];
int i = left;
int j = i + 1;
// j 遍历向右
while (j <= right)
{
if (a[j] < pivot)
{
swap(&a[i + 1], &a[j]);
++i;
}
++j;
}
// i 的位置是 左侧部分的末尾。
// left 属于 左侧部分。不应与i+1交换,而是与i交换
swap(&a[i], &a[left]);
// i 的位置是基准值,已固定。只需排除了i之后剩下的。
quick_sort(a, left, i - 1);
quick_sort(a, i + 1, right);
}

3:左首、双向、先左后右划分(《算法》 - 普林斯顿大学)

以左首为基准的双向划分,不推荐先从左划分,代码可读性不太好,而且最后i、j边界不能乱用。
以左首为基准的双向划分,推荐先右后左划分(老杨版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//此方法是前置++,i和j的位置最后不一定重合,会错开1位!
//由于是以左首划分,那么最后替换到左首的值则应该是一个小于(等于)左首的值。
//以上两个条件决定了,必须选择a[j](即j停下来的位置的值)与左首交换,则j就作为确定的划分界限。

//另外,左右划分的顺序无所谓!先左先右都可以!
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int i = left, j = right + 1;//为了便于下面++i和--j的代码统一性,所以从left到right+1,实际上只考察到了left+1到right的部分
int pivot = a[left];
while(1)
{
while(a[++i] <= pivot)
if(i == right) break;
while(a[--j] > pivot)
if(j == left) break;
if(i >= j) break;
swap(&a[i], &a[j]);
}
swap(&a[left], &a[j]);// a[left]为左首元素,即基准值
quick_sort(a, left, j - 1);
quick_sort(a, j + 1, right);
}

可读性更好的自用版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;

int pivot = a[left];
int i = left + 1, j = right;

while (1)
{
while (i <= j && a[i] <= pivot) ++i; // 停在 > pivot
while (i <= j && a[j] > pivot) --j; // 停在 <= pivot
if (i > j) break;
swap(&a[i], &a[j]);
++i; --j;
}
// 轴与 j 交换,保证 [left..j-1] <= pivot,pivot 在 j
swap(&a[left], &a[j]);

quick_sort(a, left, j - 1);
quick_sort(a, j + 1, right);
}

指针交错(或相遇)后应把 pivot 与 a[j] 交换,而不是和 a[i]。用 i 会出错(如 [1,3,2] 会变成 [3,1,2])。

4:左首、双向、先右后左划分(仿照Bubo的“右尾、双向、先左后右划分”方法写的,见7)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int l = left + 1, pivot = left, r = right;
while (l < r)
{
if (a[r] > a[pivot])
--r;
else if (a[l] <= a[pivot])
++l;
else
swap(&a[l], &a[r]);
}
if (a[r] > a[pivot])
{
swap(&a[r - 1], &a[pivot]);
pivot = r - 1;
}
else
{
swap(&a[r], &a[pivot]);
pivot = r;
}
quick_sort(a, left, pivot - 1);
quick_sort(a, pivot + 1, right);
}

上面的代码是初版,最后的if-else想复杂了,以下是优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int l = left + 1, pivot = left, r = right;
while (l < r)
{
if (a[r] > a[pivot])
--r;
else if (a[l] <= a[pivot])
++l;
else
swap(&a[l], &a[r]);
}
if (a[l] < a[pivot])
{
swap(&a[l], &a[pivot]);
pivot = l;
}
quick_sort(a, left, pivot - 1);
quick_sort(a, pivot + 1, right);
}

⭐老杨版(挖坑法)

挖坑法(又叫“填坑法”)是快速排序的一种原地分区写法:先把轴值(pivot)暂存出来,把它原来的位置当成“坑”,然后从数组两端向中间扫,用遇到的元素去“填”当前的坑;被搬走的位置又形成新的坑,交替进行,直到左右指针相遇,最后把轴值放回这个最终的坑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int pivot = a[left];
int i = left;
int j = right;
while (i < j)
{
while (i < j && a[j] > pivot)
{
--j;
}
if (i < j) // a[right] <= pivot
{
a[i] = a[j];
}
while (i < j && a[i] <= pivot)
{
++i;
}
if (i < j) // a[left] >= pivot
{
a[j] = a[i];
}
}
a[i] = pivot;
quick_sort(a, left, i - 1);
quick_sort(a, i + 1, right);
}

⭐5:右尾、单向、左划分(北航算法)

lefti的区域为小于等于基准值的区域。

在划分结束前,i + 1right的区域,均为大于基准值的区域。

划分结束后,i + 1为基准值最后落到的位置。(需要单独进行a[i+1]a[right]的交换)

leftright均为数组下标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void quick_sort(int a[], int left, int right)
{
if(left >= right) return;
int i = left - 1, j = left;
int pivot = a[right];
while(j < right)
{
if(a[j] > pivot)
{
++j;
}
else
{
swap(&a[i + 1], &a[j]);
++i;
++j; // 不管if是否成立,都++j
}
}
// i 的位置是 左侧部分的末尾。
// right 属于 右侧部分。不应与i交换,而是与i+1交换
swap(&a[i + 1], &a[right]);
quick_sort(a, left, i);
quick_sort(a, i + 2, right);
}

if-else优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void quick_sort(int a[], int left, int right)
{
if(left >= right) return;
int i = left - 1, j = left;
int pivot = a[right];
while(j < right)
{
if(a[j] <= pivot)
{
swap(&a[i + 1], &a[j]);
++i;
}
++j; // 不管if是否成立,都++j
}
// i 的位置是 左侧部分的末尾。
// right 属于 右侧部分。不应与i交换,而是与i+1交换
swap(&a[i + 1], &a[right]);
quick_sort(a, left, i);
quick_sort(a, i + 2, right);
}

i的初始值应为left - 1

以上程序,i的初始值不应是left,而是left - 1

怎么记住呢?我们可以想象一下,如果划分两个元素,那么right = 1left = 0。如果i一开始和j都等于left,那么如果a[j] <= pivot,则a[i + 1]a[j]交换,这时i + 1right,造成小数在右,所以错误。

实际上,ij指示器分别代表左、右部分的末端。

两个极端的情况就是:如果i超出数组左边界(值为-1)则说明左部分为空,即没有比基准值小于(等于)的值;
如果j超出待排数组右边界(当以右尾划分时,j超出边界时值为right)则说明右部分为空,即没有比基准值大的值。

遍历数组过程中,主要依托j的行进,如果遇到的值比基准值大,则j单独进步;如果遇到值比基准值小(等),则需要与右部分的首位置替换,如此,遇到的小的值去了左部分,原先首位置的值换到了右部分的最末端。最后,ij同时进一步。

7:右尾、双向、先左后右划分:(Bubo)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//这个版本下,left和right必定相等
//但是,存在一种情况,即只有两个元素进行划分,如2、3;
//那么此时left和right一上来就相等,都指向2
//如果我们不去做a[l]和a[pivot]的大小对比,那么最后就会划分为3、2(3为基准),此时发现,2比3小,却错误的排到后面了。
//所以,必须在后面加上a[l]和右尾值的大小对比!才能进行替换

//另外,此方法不能交换划分顺序!即必须先左划分,后右划分,否则错误!
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
//此时若只有两个元素,则出现l == r
int l = left, pivot = right, r = right - 1;
while (l < r)
{
if (a[l] <= a[pivot])
++l;
else if (a[r] > a[pivot])
--r;
else
swap(&a[l], &a[r]);
}
if (a[l] > a[pivot])
{
swap(&a[l], &a[pivot]);
pivot = l;
}
quick_sort(a, left, pivot - 1);
quick_sort(a, pivot + 1, right);
}

8:右尾、双向、先右后左划分;(仿照“左首、双向先左后右划分”《算法》写的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int i = left - 1, j = right;//为了便于下面++i和--j的代码统一性,所以从left-1到right,实际上只考察到了left到right-1的部分
int pivot = a[right];
while (1)
{
while (a[--j] > pivot)
if (j == left) break;
while (a[++i] <= pivot)
if (i == right) break;
if (i >= j) break;
swap(&a[i], &a[j]);
}
swap(&a[right], &a[i]);// a[right]为右尾元素,即基准值 //必须和a[i]交换,否则不对
quick_sort(a, left, i - 1); // 必须是i - 1,否则不对
quick_sort(a, i + 1, right);// 必须是i + 1,否则不对
}

其实我们发现,先左后右划分也正确,但是后三句中的基准位置是i,不能是j

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void quick_sort(int a[], int left, int right)
{
if (left >= right) return;
int i = left - 1, j = right;//为了便于下面++i和--j的代码统一性,所以从left-1到right,实际上只考察到了left到right-1的部分
int pivot = a[right];
while (1)
{
while (a[++i] <= pivot)
if (i == right) break;
while (a[--j] > pivot)
if (j == left) break;
if (i >= j) break;
swap(&a[i], &a[j]);
}
swap(&a[right], &a[i]);// a[right]为右尾元素,即基准值 //必须和a[i]交换,否则不对
quick_sort(a, left, i - 1); // 必须是i - 1,否则不对
quick_sort(a, i + 1, right);// 必须是i + 1,否则不对
}

总结

1
2
3
4
若用前置++,i和j的位置最后不一定重合,可能会错开1位!
由于是以左首划分,那么最后替换到左首的值则应该是一个小于(等于)左首的值。
以上两个条件决定了,必须选择a[j](即j停下来的位置的值)与左首交换,则j就作为确定的划分界限。
相反,如果以右尾划分,那么替换到右尾的值则应该是一个大于(等于)右尾的值。则必须选择a[i]与右尾交换,则i就作为确定的划分界限。

综上所述,快排划分函数的细节需要注意:

  1. 如果你选择的是双向划分——最终的ij指针是一定重合吗?有可能错开1位吗?
    1. 如果错开1位,则先左先右无所谓!
    2. 如果一定重合,则必须有个先左或先右的顺序!(为什么呢?)
  2. 你选择以左首或右尾划分,决定了你最后替换左首(右尾)的条件和具体位置!这个和ij错不错开没关系!如果选择左首划分,则需要拿比左首小的值与之替换;如果选择右尾划分,则需要拿比右尾大的值与之替换;
    1. 如果你的方法有可能使ij错开,那么i停下的位置一定是比基准值大于(等于)的;j停下的位置一定是比基准值小于(等于)的
    2. 如果你的方法使ij最后一定重合,那么由于两个元素时ij也恰巧重合,所以不能直接替换左首(右尾),需要比较i(或j,他俩一样,无所谓),如果你选择左首划分,则判断左首是否比i(或j)位置的值小(等);如果你选择右尾划分,则判断右尾是否比i(或j)位置的值大(等)——若不是,则交换值,更新基准下标(pivot);

非递归形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int Partition(int * ar, int left, int right)
{
int i = left;
int j = right;
int pivot = ar[left];
while (i < j)
{
while (i < j && ar[j] > pivot)
{
--j;
}
if (i < j) // ar[j] <= pivot
{
ar[i] = ar[j];
}
while (i < j && ar[i] <= pivot)
{
++i;
}
if (i < j)
{
ar[j] = ar[i];
}
}
ar[i] = pivot;
return i;
}
void QuickSort(int * ar, int n)
{
std::queue<int> qu;
qu.push(0);
qu.push(n - 1);
while (!qu.empty())
{
int left = qu.front(); qu.pop();
int right = qu.front(); qu.pop();
int mid = Partition(ar, left, right);
if (left < mid - 1)
{
qu.push(left);
qu.push(mid - 1);
}
if (mid + 1 < right)
{
qu.push(mid + 1);
qu.push(right);
}
}
}

优化:随机基准或三位取中

优化:尾递归消除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// partition 返回轴最终下标 p;两侧为 [left, p-1] 与 [p+1, right]
void quick_sort(int a[], int left, int right)
{
while (left < right)
{
int p = partition(a, left, right); // 任选 Lomuto/挖坑,保证返回轴位 p
// 递归较小一侧,较大一侧用循环“缩区间”
if (p - left < right - p)
{
quick_sort(a, left, p - 1); // 小的那半递归
left = p + 1; // 大的那半转为尾部迭代
}
else
{
quick_sort(a, p + 1, right);
right = p - 1;
}
}
}

最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#define INSERTION_SORT_THRESHOLD 16

// 三数取中:把 a[left], a[mid], a[right] 排成 a[left] <= a[mid] <= a[right],然后把中位数放到 left 当轴
static inline void median_of_three_to_left(int a[], int left, int right)
{
int mid = left + ((right - left) >> 1);
if (a[mid] < a[left]) swap_int(&a[mid] , &a[left]);
if (a[right] < a[left]) swap_int(&a[right], &a[left]);
if (a[right] < a[mid]) swap_int(&a[right], &a[mid]);
// 此时 a[left] <= a[mid] <= a[right],取中位数作为轴:交换到 left
swap_int(&a[left], &a[mid]);
}
// 快速排序(挖坑法 + 尾递归消除 + 小区间插排 + 三数取中选轴)
void quick_sort(int a[], int left, int right)
{
while (left < right)
{
// 小区间用插入排序更快
if (right - left + 1 <= INSERTION_SORT_THRESHOLD)
{
insertion_sort(a, left, right);
return; // 当前区间完成,直接返回(外层 while 退出)
}

// 选轴优化:三数取中,并将中位数放到 left
median_of_three_to_left(a, left, right);

// 分区
int p = partition_pit(a, left, right);

// 尾递归消除:总是递归较小的一侧,较大的一侧改为迭代
if (p - left < right - p)
{
quick_sort(a, left, p - 1); // 递归较小段
left = p + 1; // 大段转为下一轮循环
}
else
{
quick_sort(a, p + 1, right);
right = p - 1;
}
}
}

归并排序

归并排序涉及到分治(Divide & Conquer)策略的思想。分治策略有很多例子,比如MapReduce用于大规模数据集(大于1TB)的并行运算。涉及"Map(映射)“和"Reduce(归约)”。

需要注意,min和max是绝对下标,即要排序的整个数组的下标。

1
2
3
4
5
6
7
8
void divide_conquer(int a[], int min, int max, int b[])
{
if(min >= max) return; //终止条件
int mid = (max - min) / 2 + min; //找到中间位置(奇数时左边多1个)
divide_conquer(a, min, mid, b); //处理完之后,此次递归时的min~max的左半侧已经有序
divide_conquer(a, mid + 1, max, b); //处理完之后,此次递归时的min~max的有半侧已经有序
conquer(a, min, max, b); //conquer即合并两边数组的前提是左右双边均已有序
}

conquer即合并两边数组的前提是左右双边均已有序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void conquer(int a[], int min, int max, int b[])
{
int mid = (max - min) / 2 + min;
int i = min; //左半边
int j = mid + 1; //右半边
int k = min; //合并的新空间的下标
while(k <= max)
{
if(i <= mid && j <= max)//i和j都没到结尾,则需要一一比对大小
{
if(a[i] < a[j])
{
b[k] = a[i++];
}
else b[k] = a[j++];
}
else if(i > mid) //左半侧已结束,则右半侧(j)剩下的可直接全部放入b
{
b[k] = a[j++];
}
else b[k] = a[i++]; //右半侧已结束,右半侧(i)剩下的可直接全部放入b
++k;
}
// 别忘了b数组中处理完成之后还要复制一份给a数组
for(int k = min; k <= max; ++k)
{
a[k] = b[k];
}
}
1
2
3
4
5
6
7
void merging_sort(int a[], int n)
{
int * b = (int *)malloc(n * sizeof(a[0]));
divide_conquer(a, 0, n - 1, b);
// 无须再把b数组复制给a数组,因为再conquer过程中b数组已经复制给了a数组
free(b);
}

基数排序

用的少,考的少,基本上已经退出历史舞台,暂不讨论。

随机数-测试用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include<stdio.h>
#include<time.h>
#define SIZE 13
void show(int * arr, int len)
{
for(int i = 0; i < len; ++i)
{
printf("%i ", arr[i]);
}
printf("\n");
}
bool isSort(int * arr, int len)
{
for(int i = 0; i < len - 1; ++i)
{
if(arr[i] > arr[i + 1])
return false;
}
return true;
}
int main()
{
int arr[SIZE] = { 0 };
// 设置一个随即因子
srand((unsigned)(time(NULL)));
for(int i = 0; i < SIZE; ++i)
{
arr[i] = rand() % 100;
}
show(arr, SIZE); //展示原始数据
XXX(arr); //调用排序算法
show(arr, SIZE); //展示排后数据
if(isSort(arr, SIZE))
printf("数据已经完全有序\n");
else printf("数据无序\n");
}

为何快速排序和归并排序很重要

因为这两个算法可以把整个数据进行划分,所以可以使用多线程处理划分的任务,处理完后,最后再用一个主线程处理所有数据。这是可划分算法的优势。

Cpp_继承和多态

内容

引入

类与类之间的关系:

组合/嵌套:一个类中声明了另一个类,是属于的关系。一个类是另一个类的一部分。

代理:一个类的接口是另一个类接口的子集

继承:fish–goldFish

隐藏

子类会隐藏父类的同名成员。----同名隐藏

访问被隐藏的成员需要加上(父类)作用域。

动多态

产生:使用指针或者引用调用虚函数,就会产生动多态调用

动多态调用的过程:

  1. 使用指针或者引用调用虚函数
  2. 在对象中找到vfptr
  3. 根据vfptr找到vftable
  4. 在vftable中找到要调用的函数
  5. 调用

vftable

编译时期如果发现类中有虚函数,就会对这个类生成vftable(虚函数表)。将当前

面试

  1. 父子类/组合类的构造顺序
    1. 先构造父类,再构造子类。
    2. 先构造内部类,再构造大类。
  2. 类的编译顺序:
    1. 编译类名
    2. 编译成员名=====编译嵌套类
    3. 编译成员方法体
  3. 什么是多态
    1. 面向对象方法学中,派生类继承基类后表现出来的形式叫多态。这个形式为在基类的行为基础上又派生出各自派生类的特性
  4. 动多态的产生条件
    1. 父类有虚函数
  5. 动多态的过程
    1. 子类继承父类时,拷贝父类虚函数表,覆盖虚函数表中的同名方法。
    2. 在调用子类方法时,如果发现其是虚函数,则由虚函数表指针找到虚函数表,查出函数指针,调用继承或重写的方法。
  6. vftable什么时候产生?在哪里存储?
    1. 在构造子类前,构造父类时产生。在内存的只读数据段中存储。
  7. 构造函数能不能写成虚函数
    1. 不能,因为调用虚函数需要去构造好的对象中找虚函数表指针。但是他还没构造出来。
  8. 静态函数能不能写成虚函数
    1. 静态函数属于类的函数,理论上可以被定义为虚函数并继承。
  9. 析构函数能不能写成虚函数
    1. 可以,并且在运用多态时,常用。
  10. 虚函数能不能被处理为内联
    1. 不能。内联是在编译期代码段展开,而动多态是在运行时。
  11. 什么情况下析构函数必须写成虚函数
    1. 父类指针指向子类对象
  12. 父类指针能不能指向子类对象?子类指针能不能指向父类对象?
    1. 前者可以,后者不可。
  13. 什么是RTTI?在什么时候产生?存储在哪里?
  14. 父类指针如何转化为子类指针?转化有什么条件?
  15. 什么是菱形继承?菱形继承有什么问题?如何解决?
  16. 纯虚函数有什么作用?
  17. 隐藏
  18. 覆盖

答案

    1. 静多态
      1. 编译时期的多态,又被称为早绑定
      2. 代表:函数重载、模板
    2. 动多态
      1. 运行时期的多态,又被称为晚绑定
      2. 代表:继承中的多态
    1. 编译类名
    2. 编译成员名=====编译嵌套类
    3. 编译成员方法体
    1. 指针或引用调用虚函数 + 对象必须完整
    2. 完整对象:构造函数执行完毕,虚构函数还没开始
    1. 使用指针或者引用调用虚函数
    2. 在对象中找到vfptr
    3. 找到vftable
    4. 在表中找到对应的函数
    1. 编译期产生
    2. 放在rodata区
    1. 不能,构造函数无法通过指针或者引用调用,写成虚函数没有意义。
    2. vfptr是在构造时才写入对象,而调用虚构造函数时没有对象构造出来。
  1. 不能,因为静态函数不依赖于对象。

    1. 不能。已内联的函数在编译期代码段展开,在release版本没有地址。
  2. 父类指针指向堆上的子类对象时,必须把父类的虚构函数写为虚函数。

    1. runtime type info,是一个指向类型信息的指针。
    2. 编译期产生,RTTI指针放在vftable里面,类型信息放在rodata段。
  3. Derive* pd = dynamic_cast<Derive*>(p);
    dynamic_cast : 父类指针转为子类指针专用的类型强转
    要求:
        1.必须有RTTI
        2.父类指针指向的对象中的RTTI确实是子类类型。
    <!--code0-->