第 2 章 排序与搜索
第 11 节 基础排序
101 冒泡排序
冒泡排序就像逐个清洗盘子,你不断把最大的盘子移到最下面,直到所有东西都干净且有序。它简单、直观,非常适合在深入学习更高级的方法之前建立你的排序直觉。
我们要解决什么问题?
我们希望通过重复比较和交换顺序错误的相邻项,来将一系列元素按顺序(升序或降序)排列。
正式定义: 给定一个数组 A[0…n-1],重复进行遍历,直到没有交换发生。 每次遍历都会将剩余元素中最大的一个“冒泡”到其最终位置。
示例
| 步骤 | 数组状态 | 描述 |
|---|---|---|
| 0 | [5, 3, 4, 1] | 初始数组 |
| 1 | [3, 4, 1, 5] | 5 冒泡到末尾 |
| 2 | [3, 1, 4, 5] | 4 冒泡到位置 3 |
| 3 | [1, 3, 4, 5] | 数组完全排序 |
它是如何工作的(通俗解释)?
想象气泡上升到水面,最大的气泡最先到达顶部。在冒泡排序中,每次遍历列表都会比较相邻的元素对,如果它们的顺序错误就交换它们。每次完整的遍历之后,就会有一个元素被放置到正确的位置。
我们重复这个过程,直到某次遍历没有发生任何交换,这意味着数组已经排序好了。
逐步过程
| 步骤 | 动作 | 结果数组 |
|---|---|---|
| 1 | 比较 A[0] 和 A[1] | 如果需要则交换 |
| 2 | 比较 A[1] 和 A[2] | 如果需要则交换 |
| 3 | 继续直到 A[n-2] | 重复比较 |
| 4 | 重复遍历直到排序完成 | 如果已排序则提前停止 |
微型代码(简易版本)
C
#include <stdio.h>
#include <stdbool.h>
void bubble_sort(int a[], int n) {
bool swapped;
for (int pass = 0; pass < n - 1; pass++) {
swapped = false;
for (int i = 0; i < n - pass - 1; i++) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
swapped = true;
}
}
if (!swapped) break; // 如果已经排序则提前退出
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
bubble_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def bubble_sort(a):
n = len(a)
for pass_num in range(n - 1):
swapped = False
for i in range(n - pass_num - 1):
if a[i] > a[i + 1]:
a[i], a[i + 1] = a[i + 1], a[i]
swapped = True
if not swapped:
break
arr = [5, 3, 4, 1, 2]
bubble_sort(arr)
print(arr)为什么它重要
- 通过直觉教授基于比较的排序
- 建立对相邻交换和基于遍历的进展的理解
- 引入稳定性(相等元素保持其相对顺序)
- 为改进版本(改进冒泡排序、鸡尾酒排序、梳排序)奠定基础
一个温和的证明(为什么它有效)
第一次遍历后,最大的元素移动到最后的位置。 第二次遍历后,第二大的元素移动到位置 n-2。
因此,经过 k 次遍历后,最后的 k 个元素已排序。
如果我们跟踪比较次数: 第 1 次遍历: (n−1) 次比较 第 2 次遍历: (n−2) 次比较 ... 第 (n−1) 次遍历: 1 次比较
| 遍历次数 | 比较次数 | 在末尾排序的元素 |
|---|---|---|
| 1 | n−1 | 最大元素 |
| 2 | n−2 | 次大元素 |
| ... | ... | ... |
| n−1 | 1 | 完全排序的数组 |
总比较次数 = (n−1) + (n−2) + ... + 1 = n(n−1)/2
所以最坏情况下的时间复杂度 = O(n²)。 如果已经排序,提前退出使其时间复杂度为 O(n)。
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 逐步排序 [3, 2, 1] |
| 2 | 计算发生了多少次交换 |
| 3 | 添加一个标志来检测提前终止 |
| 4 | 在相同数据上与插入排序进行比较 |
| 5 | 修改为降序排序 |
测试用例
| 输入 | 输出 | 遍历次数 | 交换次数 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 3 | 3 |
| [1, 2, 3] | [1, 2, 3] | 1 | 0 |
| [5, 1, 4, 2, 8] | [1, 2, 4, 5, 8] | 4 | 5 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最好) | O(n) |
| 空间 | O(1)(原地排序) |
| 稳定 | 是 |
| 自适应 | 是(如果已排序则提前停止) |
冒泡排序是你进入排序世界的第一步,简单到可以手写代码,直观到可以动画演示,强大到足以激发你对更高级排序算法的直觉。
102 改进的冒泡排序
改进的冒泡排序在基础版本之上构建,它认识到一旦数组的某部分已经有序,就无需再次遍历该部分。它引入了一些小的优化,比如提前终止和记录最后一次交换的位置,以减少不必要的比较。
我们要解决什么问题??
基础冒泡排序在每一轮扫描中都会遍历整个数组,即使尾部已经有序。 改进的冒泡排序通过记录最后一次交换发生的位置来修复这个问题。该索引之后的元素已经就位,因此下一轮扫描可以提前停止。
这种优化对于近乎有序的数组尤其有效。
示例
| 步骤 | 数组状态 | 最后交换索引 | 检查范围 |
|---|---|---|---|
| 0 | [5, 3, 4, 1, 2] | - | 0 到 4 |
| 1 | [3, 4, 1, 2, 5] | 3 | 0 到 3 |
| 2 | [3, 1, 2, 4, 5] | 2 | 0 到 2 |
| 3 | [1, 2, 3, 4, 5] | 1 | 0 到 1 |
| 4 | [1, 2, 3, 4, 5] | 0 | 提前停止 |
它是如何工作的(通俗解释)?
我们通过将每一轮的扫描范围缩小到仅未排序的部分来提高效率。 当没有发生交换时,我们也提前停止,这表示数组已经有序。
逐步说明:
- 记录每一轮中最后一次交换的索引
- 下一轮扫描在该索引处结束
- 当没有交换发生时停止(完全有序)
这减少了在近乎有序数组中不必要的比较。
精简代码(简易版本)
C
#include <stdio.h>
void improved_bubble_sort(int a[], int n) {
int new_n;
while (n > 1) {
new_n = 0;
for (int i = 1; i < n; i++) {
if (a[i - 1] > a[i]) {
int temp = a[i - 1];
a[i - 1] = a[i];
a[i] = temp;
new_n = i;
}
}
n = new_n;
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
improved_bubble_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def improved_bubble_sort(a):
n = len(a)
while n > 1:
new_n = 0
for i in range(1, n):
if a[i - 1] > a[i]:
a[i - 1], a[i] = a[i], a[i - 1]
new_n = i
n = new_n
arr = [5, 3, 4, 1, 2]
improved_bubble_sort(arr)
print(arr)为什么它很重要
- 减少了冗余的比较
- 自动适应部分有序的数据
- 数组一旦有序就立即停止
- 保持了稳定性和简单性
一个温和的证明(为什么它有效)
如果最后一次交换发生在索引 k 处,那么 k 之后的所有元素都已经有序。 下一轮扫描只需要扫描到 k。 如果没有发生交换(k = 0),则数组已有序。
| 轮次 | 比较次数 | 最后交换 | 下一轮范围 |
|---|---|---|---|
| 1 | n−1 | k₁ | 0..k₁ |
| 2 | k₁−1 | k₂ | 0..k₂ |
| ... | ... | ... | ... |
在最佳情况下(已经有序),只进行一轮扫描:O(n) 最坏情况仍然是 O(n²)
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 排序 [1, 2, 3, 4, 5] 并观察提前停止 |
| 2 | 排序 [5, 4, 3, 2, 1] 并跟踪最后交换索引 |
| 3 | 修改代码以打印每轮的最后交换索引 |
| 4 | 与标准冒泡排序的扫描轮数进行比较 |
| 5 | 尝试包含重复值的数组以验证稳定性 |
测试用例
| 输入 | 输出 | 轮次 | 改进点 |
|---|---|---|---|
| [1, 2, 3, 4, 5] | [1, 2, 3, 4, 5] | 1 | 提前停止 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 4 | 每轮检查次数减少 |
| [2, 1, 3, 4, 5] | [1, 2, 3, 4, 5] | 1 | 检测到已排序的尾部 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最佳) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
改进的冒泡排序展示了如何通过一个小的观察让一个经典算法变得更智能。通过跟踪最后一次交换,它跳过了已经有序的尾部,并让我们一窥自适应排序在实际中是如何工作的。
103 鸡尾酒排序
鸡尾酒排序,也称为双向冒泡排序,通过在每个遍历中双向排序来改进冒泡排序。它将最大元素移动到末尾,最小元素移动到开头,从而减少所需的遍历次数。
我们要解决什么问题?
标准的冒泡排序只在一个方向上进行冒泡,每次遍历将最大元素推到末尾。 如果小元素一开始就靠近末尾,它们需要多次遍历才能到达正确位置。
鸡尾酒排序通过来回扫描,同时处理两端,解决了这个问题。
示例
| 步骤 | 方向 | 数组状态 | 描述 |
|---|---|---|---|
| 0 | – | [5, 3, 4, 1, 2] | 初始数组 |
| 1 | 从左到右 | [3, 4, 1, 2, 5] | 5 冒泡到末尾 |
| 2 | 从右到左 | [1, 3, 4, 2, 5] | 1 冒泡到开头 |
| 3 | 从左到右 | [1, 3, 2, 4, 5] | 4 冒泡到位置 4 |
| 4 | 从右到左 | [1, 2, 3, 4, 5] | 2 冒泡到位置 2 |
经过 4 次方向遍历后排序完成。
它是如何工作的(通俗解释)?
鸡尾酒排序就像从数组的两端进行搅拌。 每次正向遍历将最大的未排序元素移动到末尾。 每次反向遍历将最小的未排序元素移动到开头。
未排序区域随着每个完整循环从两端缩小。
逐步过程
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 从左到右扫描,将最大元素冒泡到末尾 | |
| 2 | 从右到左扫描,将最小元素冒泡到开头 | |
| 3 | 缩小边界,重复直到排序完成 |
精简代码(简易版本)
C
#include <stdio.h>
#include <stdbool.h>
void cocktail_shaker_sort(int a[], int n) {
bool swapped = true;
int start = 0, end = n - 1;
while (swapped) {
swapped = false;
// 正向遍历
for (int i = start; i < end; i++) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
swapped = true;
}
}
if (!swapped) break;
swapped = false;
end--;
// 反向遍历
for (int i = end - 1; i >= start; i--) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
swapped = true;
}
}
start++;
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
cocktail_shaker_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def cocktail_shaker_sort(a):
n = len(a)
start, end = 0, n - 1
swapped = True
while swapped:
swapped = False
for i in range(start, end):
if a[i] > a[i + 1]:
a[i], a[i + 1] = a[i + 1], a[i]
swapped = True
if not swapped:
break
swapped = False
end -= 1
for i in range(end - 1, start - 1, -1):
if a[i] > a[i + 1]:
a[i], a[i + 1] = a[i + 1], a[i]
swapped = True
start += 1
arr = [5, 3, 4, 1, 2]
cocktail_shaker_sort(arr)
print(arr)为什么它重要
- 双向排序,减少了不必要的遍历次数
- 在许多实际输入上比冒泡排序表现更好
- 稳定且易于可视化
- 展示了双向改进,是自适应排序的基础
一个温和的证明(为什么它有效)
每次正向遍历将未排序范围的最大元素移动到末尾。 每次反向遍历将未排序范围的最小元素移动到开头。 因此,未排序范围从两端缩小,保证了每个循环都有进展。
| 循环 | 正向遍历 | 反向遍历 | 已排序范围 |
|---|---|---|---|
| 1 | 最大元素到末尾 | 最小元素到开头 | [0], [n-1] |
| 2 | 次大元素 | 次小元素 | [0,1], [n-2,n-1] |
| ... | ... | ... | ... |
最坏情况仍然是 O(n²),如果已经排序,最好情况是 O(n)。
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 排序 [5, 3, 4, 1, 2] 并跟踪正向/反向遍历 |
| 2 | 可视化缩小的未排序范围 |
| 3 | 在逆序数组上与标准冒泡排序进行比较 |
| 4 | 修改代码以在每次遍历后打印数组 |
| 5 | 用重复值测试稳定性 |
测试用例
| 输入 | 输出 | 遍历次数 | 备注 |
|---|---|---|---|
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 4 | 比冒泡排序遍历次数少 |
| [1, 2, 3, 4, 5] | [1, 2, 3, 4, 5] | 1 | 提前终止 |
| [2, 1, 3, 5, 4] | [1, 2, 3, 4, 5] | 2 | 快速移动最小元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最好) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
鸡尾酒排序继承了冒泡排序的简单性,并针对某些输入将其效率提高了一倍。通过双向排序,它突显了对称性和微小算法调整的力量。
104 选择排序
选择排序就像整理一副扑克牌,通过反复挑选最小的牌并按顺序放置。它简单、可预测,有助于理解基于选择的排序工作原理。
我们要解决什么问题?
我们希望通过反复从未排序部分选择最小(或最大)元素,并将其交换到正确位置来对数组进行排序。
选择排序将数组分为两部分:
- 一个已排序的前缀(每次构建一个元素)
- 一个未排序的后缀(我们从其中选择下一个最小值)
示例
| 步骤 | 数组状态 | 操作 | 已排序部分 | 未排序部分 |
|---|---|---|---|---|
| 0 | [5, 3, 4, 1, 2] | 开始 | [] | [5,3,4,1,2] |
| 1 | [1, 3, 4, 5, 2] | 将 1 放到索引 0 处 | [1] | [3,4,5,2] |
| 2 | [1, 2, 4, 5, 3] | 将 2 放到索引 1 处 | [1,2] | [4,5,3] |
| 3 | [1, 2, 3, 5, 4] | 将 3 放到索引 2 处 | [1,2,3] | [5,4] |
| 4 | [1, 2, 3, 4, 5] | 将 4 放到索引 3 处 | [1,2,3,4] | [5] |
| 5 | [1, 2, 3, 4, 5] | 完成 | [1,2,3,4,5] | [] |
它是如何工作的(通俗解释)?
选择排序遍历未排序部分,找到最小的元素,并将其移到最前面。在每次选择完成之前,它不关心中间的顺序。
每一轮都会永久固定一个位置。
逐步过程
| 步骤 | 操作 | 效果 |
|---|---|---|
| 1 | 在未排序部分中找到最小的元素 | 将其移到最前面 |
| 2 | 对下一个未排序索引重复此操作 | 增长已排序前缀 |
| 3 | 当整个数组排序完成时停止 |
精简代码(简易版本)
C
#include <stdio.h>
void selection_sort(int a[], int n) {
for (int i = 0; i < n - 1; i++) {
int min_idx = i;
for (int j = i + 1; j < n; j++) {
if (a[j] < a[min_idx]) {
min_idx = j;
}
}
int temp = a[i];
a[i] = a[min_idx];
a[min_idx] = temp;
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
selection_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def selection_sort(a):
n = len(a)
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n):
if a[j] < a[min_idx]:
min_idx = j
a[i], a[min_idx] = a[min_idx], a[i]
arr = [5, 3, 4, 1, 2]
selection_sort(arr)
print(arr)为什么它重要
- 简单、确定性的排序算法
- 演示了选择而非交换相邻元素
- 适用于小列表和教学目的
- 在需要最小化交换次数时很有用
一个温和的证明(为什么它有效)
在每次迭代中,剩余的最小元素被放置在其正确位置。 一旦放置,它就永远不会再移动。
算法执行 n−1 次选择,最多 n−1 次交换。 每次选择都需要扫描未排序部分:$O(n)$ 次比较。
| 轮次 | 搜索范围 | 比较次数 | 交换 |
|---|---|---|---|
| 1 | n 个元素 | n−1 | 1 |
| 2 | n−1 个元素 | n−2 | 1 |
| ... | ... | ... | ... |
| n−1 | 2 个元素 | 1 | 1 |
总比较次数 = $n(n−1)/2 = O(n²)$
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 逐步跟踪 [5, 3, 4, 1, 2] 的排序过程 |
| 2 | 计算总交换次数和比较次数 |
| 3 | 修改为每轮查找最大值(降序排序) |
| 4 | 添加打印语句以查看进度 |
| 5 | 与冒泡排序的效率进行比较 |
测试用例
| 输入 | 输出 | 轮次 | 交换次数 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 2 | 2 |
| [1, 2, 3] | [1, 2, 3] | 2 | 0 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 4 | 4 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏情况) | $O(n²)$ |
| 时间(最好情况) | $O(n²)$ |
| 空间 | $O(1)$ |
| 稳定性 | 否(交换可能破坏顺序) |
| 自适应性 | 否 |
选择排序是一种平静、有条理的排序算法。它不具备自适应性,但也不会浪费交换次数。它是最简单的思想演示:找到最小的,放置它,重复。
105 双向选择排序
双向选择排序是选择排序的一种改进版本。它不再每轮只寻找最小元素,而是同时寻找最小和最大元素,并将它们分别放置到数组的开头和末尾。这使所需的轮数减半。
我们要解决什么问题?
标准选择排序每轮找到一个元素。 双向选择排序通过每轮从两端各选择一个元素来提高效率,将总迭代次数减少约一半。
当可以在一次扫描中找到两个极值时,这种方法很有用,它在保持整体简单性的同时改善了常数因子。
示例
| 步骤 | 数组状态 | 最小值 | 最大值 | 操作 | 已排序部分 |
|---|---|---|---|---|---|
| 0 | [5, 3, 4, 1, 2] | 1 | 5 | 交换 1 → 前端,5 → 后端 | [1, …, 5] |
| 1 | [1, 3, 4, 2, 5] | 2 | 4 | 交换 2 → 索引 1,4 → 索引 3 | [1,2, …,4,5] |
| 2 | [1, 2, 3, 4, 5] | 3 | 3 | 中间元素已排序 | [1,2,3,4,5] |
用 3 轮完成排序,而不是 5 轮。
它是如何工作的(通俗解释)?
双向选择排序从两端同时缩小未排序的范围。 每轮:
- 扫描未排序部分一次。
- 找到最小和最大的元素。
- 将它们交换到前端和后端的正确位置。
然后缩小边界并重复。
逐步过程
| 步骤 | 操作 | 效果 |
|---|---|---|
| 1 | 在未排序部分中找到最小值和最大值 | 将最小值移到左边,最大值移到右边 |
| 2 | 缩小未排序范围 | 重复搜索 |
| 3 | 当范围缩减至空时停止 | 数组已排序 |
精简代码(简易版本)
C
#include <stdio.h>
void double_selection_sort(int a[], int n) {
int left = 0, right = n - 1;
while (left < right) {
int min_idx = left, max_idx = left;
for (int i = left; i <= right; i++) {
if (a[i] < a[min_idx]) min_idx = i;
if (a[i] > a[max_idx]) max_idx = i;
}
// 将最小值移到前端
int temp = a[left];
a[left] = a[min_idx];
a[min_idx] = temp;
// 如果最大元素被交换到了 min_idx 的位置
if (max_idx == left) max_idx = min_idx;
// 将最大值移到后端
temp = a[right];
a[right] = a[max_idx];
a[max_idx] = temp;
left++;
right--;
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
double_selection_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def double_selection_sort(a):
left, right = 0, len(a) - 1
while left < right:
min_idx, max_idx = left, left
for i in range(left, right + 1):
if a[i] < a[min_idx]:
min_idx = i
if a[i] > a[max_idx]:
max_idx = i
a[left], a[min_idx] = a[min_idx], a[left]
if max_idx == left:
max_idx = min_idx
a[right], a[max_idx] = a[max_idx], a[right]
left += 1
right -= 1
arr = [5, 3, 4, 1, 2]
double_selection_sort(arr)
print(arr)为什么它重要
- 通过减少轮数改进了选择排序
- 在一次扫描中选择两个极值
- 总交换和比较次数更少
- 展示了双向选择的思想
一个温和的证明(为什么它有效)
每一轮都将两个元素移动到它们最终的正确位置。 因此,经过 k 轮后,前 k 个和后 k 个位置都已排序。 未排序范围每轮缩小 2。
| 轮次 | 检查的范围 | 固定的元素 | 剩余未排序元素 |
|---|---|---|---|
| 1 | [0..n−1] | 2 | n−2 |
| 2 | [1..n−2] | 2 | n−4 |
| ... | ... | ... | ... |
总轮数 = n/2,每轮 O(n) 扫描 ⇒ 总体 O(n²)。
自己动手试试
| 任务 | 描述 |
|---|---|
| 1 | 手动排序 [5, 3, 4, 1, 2] |
| 2 | 计算轮数和交换次数 |
| 3 | 每轮打印范围边界 |
| 4 | 与选择排序的轮数进行比较 |
| 5 | 修改以实现降序排序 |
测试用例
| 输入 | 输出 | 轮次 | 交换次数 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 2 | 2 |
| [1, 2, 3, 4, 5] | [1, 2, 3, 4, 5] | 2 | 0 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 3 | 6 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最好) | O(n²) |
| 空间 | O(1) |
| 稳定性 | 否 |
| 自适应性 | 否 |
双向选择排序保持了选择排序的简单性,但扩展了其能力。通过每轮抓取两端,它展示了对称性如何能在不引入新数据结构的情况下带来效率提升。
106 插入排序
插入排序每次构建一个有序数组,就像整理手中的扑克牌一样。它取出每个新元素,并将其插入到已排序部分的正确位置。
我们要解决什么问题?
我们想要一种简单、稳定的方法,通过将每个元素插入到不断增长的有序部分中来对元素进行排序。 这种方法特别适用于小型数组或已接近有序的数据。
插入排序将数组分为两部分:
- 有序前缀:已经按顺序排列的元素
- 无序后缀:尚未插入的剩余元素
示例
| 步骤 | 数组状态 | 插入的元素 | 操作 | 有序前缀 |
|---|---|---|---|---|
| 0 | [5, 3, 4, 1, 2] | - | 从第一个元素开始视为已排序 | [5] |
| 1 | [3, 5, 4, 1, 2] | 3 | 将 3 插入到 5 之前 | [3, 5] |
| 2 | [3, 4, 5, 1, 2] | 4 | 将 4 插入到 5 之前 | [3, 4, 5] |
| 3 | [1, 3, 4, 5, 2] | 1 | 将 1 插入到最前面 | [1, 3, 4, 5] |
| 4 | [1, 2, 3, 4, 5] | 2 | 将 2 插入到 1 之后 | [1, 2, 3, 4, 5] |
它是如何工作的(通俗解释)?
想象一下一张一张地拿起扑克牌,并将每张牌放到手中已有牌的正确位置。 插入排序对数组重复这个逻辑:
- 从第一个元素开始(已视为有序)
- 取出下一个元素
- 在有序部分中向后比较
- 移动元素以腾出空间,然后将其插入
逐步过程
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 取出下一个未排序元素 | |
| 2 | 遍历有序部分以找到位置 | |
| 3 | 将较大的元素向右移动 | |
| 4 | 将元素插入正确位置 |
精简代码(简易版本)
C
#include <stdio.h>
void insertion_sort(int a[], int n) {
for (int i = 1; i < n; i++) {
int key = a[i];
int j = i - 1;
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
j--;
}
a[j + 1] = key;
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
insertion_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def insertion_sort(a):
for i in range(1, len(a)):
key = a[i]
j = i - 1
while j >= 0 and a[j] > key:
a[j + 1] = a[j]
j -= 1
a[j + 1] = key
arr = [5, 3, 4, 1, 2]
insertion_sort(arr)
print(arr)为什么它重要
- 简单、直观且稳定
- 适用于小型或已接近有序的数组
- 常用作高级算法(如 Timsort)的子程序
- 展示了增量插入的概念
一个温和的证明(为什么它有效)
在第 i 步,前 i 个元素是有序的。 插入元素 a[i] 保持了前缀的有序性。 每次插入都会将大于 key 的元素向右移动,确保其位置正确。
| 轮次 | 有序部分 | 比较次数(最坏) | 移动次数(最坏) |
|---|---|---|---|
| 1 | [a₀,a₁] | 1 | 1 |
| 2 | [a₀,a₁,a₂] | 2 | 2 |
| ... | ... | ... | ... |
| n-1 | [a₀…aₙ₋₁] | n-1 | n-1 |
在最坏情况下,总操作数约为 (n²)/2。
如果已经有序,每个元素只需一次比较 → O(n)。
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 逐步排序 [5, 3, 4, 1, 2] |
| 2 | 计算移动和比较的次数 |
| 3 | 修改代码以降序排序 |
| 4 | 与冒泡排序比较运行时间 |
| 5 | 插入打印语句以跟踪插入过程 |
测试用例
| 输入 | 输出 | 轮次 | 交换/移动次数 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 2 | 3 |
| [1, 2, 3] | [1, 2, 3] | 2 | 0 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 4 | 8 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最好) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
插入排序体现了细致、增量式组织的逻辑。对于大型随机列表来说它很慢,但当数据已接近有序时,它优雅、稳定且非常高效。
107 二分插入排序
二分插入排序通过使用二分查找来寻找正确的插入位置,而不是线性扫描,从而改进了传统的插入排序。这使每次插入的比较次数从线性减少到对数级,同时保持了相同的稳定性和自适应性。
我们要解决什么问题?
标准的插入排序通过线性扫描已排序部分来寻找新元素的插入位置。 如果已排序的前缀很长,每个元素需要 O(n) 次比较。
二分插入排序用二分查找替代了线性扫描,它能在 O(log n) 时间内找到位置,同时仍然执行 O(n) 次移动。
这使得它在比较操作代价高昂但移动操作代价低廉时成为一个很好的选择。
示例
| 步骤 | 已排序部分 | 待插入元素 | 插入索引(二分查找) | 结果数组 |
|---|---|---|---|---|
| 0 | [5] | 3 | 0 | [3, 5, 4, 1, 2] |
| 1 | [3, 5] | 4 | 1 | [3, 4, 5, 1, 2] |
| 2 | [3, 4, 5] | 1 | 0 | [1, 3, 4, 5, 2] |
| 3 | [1, 3, 4, 5] | 2 | 1 | [1, 2, 3, 4, 5] |
它是如何工作的(通俗解释)?
就像插入排序一样,我们一次一个元素地构建一个已排序的前缀。 但我们不是线性地向后扫描,而是使用二分查找来定位下一个元素的正确插入位置。
我们仍然需要将较大的元素向右移动,但现在我们确切地知道在哪里停止。
逐步过程
| 步骤 | 操作 | 效果 |
|---|---|---|
| 1 | 在已排序前缀中进行二分查找 | 找到插入点 |
| 2 | 将较大的元素向右移动 | 腾出空间 |
| 3 | 在找到的索引处插入元素 | 保持顺序 |
| 4 | 重复直到排序完成 | 完全排序的数组 |
精简代码(简易版本)
C
#include <stdio.h>
int binary_search(int a[], int item, int low, int high) {
while (low <= high) {
int mid = (low + high) / 2;
if (item == a[mid]) return mid + 1;
else if (item > a[mid]) low = mid + 1;
else high = mid - 1;
}
return low;
}
void binary_insertion_sort(int a[], int n) {
for (int i = 1; i < n; i++) {
int key = a[i];
int j = i - 1;
int pos = binary_search(a, key, 0, j);
while (j >= pos) {
a[j + 1] = a[j];
j--;
}
a[pos] = key;
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
binary_insertion_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def binary_search(a, item, low, high):
while low <= high:
mid = (low + high) // 2
if item == a[mid]:
return mid + 1
elif item > a[mid]:
low = mid + 1
else:
high = mid - 1
return low
def binary_insertion_sort(a):
for i in range(1, len(a)):
key = a[i]
pos = binary_search(a, key, 0, i - 1)
a[pos + 1 : i + 1] = a[pos : i]
a[pos] = key
arr = [5, 3, 4, 1, 2]
binary_insertion_sort(arr)
print(arr)为什么它重要
- 比标准插入排序的比较次数更少
- 保留了稳定性和自适应性
- 当比较操作主导运行时间时(例如,复杂对象)非常有用
- 展示了结合搜索和插入思想的方法
一个温和的证明(为什么它有效)
对于第 i 个元素,二分查找总是能在 O(log i) 次比较内找到正确的索引。 移动元素仍然需要 O(i) 的时间。 所以总成本为:
$$ T(n) = \sum_{i=1}^{n-1} (\log i + i) = O(n^2) $$
但比标准插入排序的比较次数更少。
| 步骤 | 比较次数 | 移动次数 | 总成本 |
|---|---|---|---|
| 1 | log₂1 = 0 | 1 | 1 |
| 2 | log₂2 = 1 | 2 | 3 |
| 3 | log₂3 ≈ 2 | 3 | 5 |
| … | … | … | … |
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 逐步排序 [5, 3, 4, 1, 2] |
| 2 | 打印每一轮的插入索引 |
| 3 | 比较与普通插入排序的比较次数 |
| 4 | 修改为降序排序 |
| 5 | 尝试使用已排序的列表 |
测试用例
| 输入 | 输出 | 比较次数 | 移动次数 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | ~3 | 3 |
| [1, 2, 3] | [1, 2, 3] | ~2 | 0 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | ~7 | 8 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最好) | O(n log n) 次比较,O(n) 次移动 |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
二分插入排序是一种深思熟虑的平衡,更智能的搜索,相同的简单结构。它提醒我们,即使是微小的改变(比如使用二分查找),在精度至关重要时也能带来真正的效率提升。
108 侏儒排序
侏儒排序是一种简单的排序算法,它通过交换相邻元素来工作,类似于冒泡排序,但有一个转折:每当发生交换时,它会向后移动。想象一个侏儒在整理花盆:每次发现两个顺序不对,它就交换它们,然后退一步重新检查前一对。
我们要解决什么问题?
我们想要一种简单、直观且原地工作的排序方法,它使用局部交换来恢复顺序。 侏儒排序特别容易实现,其工作方式类似于插入排序,但使用相邻交换而不是移动元素。
它不是最快的,但其简单性令人着迷,非常适合理解局部校正逻辑。
示例
| 步骤 | 位置 | 数组状态 | 动作 |
|---|---|---|---|
| 0 | 1 | [5, 3, 4, 1, 2] | 比较 5 > 3 → 交换,向后移动 |
| 1 | 0 | [3, 5, 4, 1, 2] | 在起始位置 → 向前移动 |
| 2 | 1 | [3, 5, 4, 1, 2] | 比较 5 > 4 → 交换,向后移动 |
| 3 | 0 | [3, 4, 5, 1, 2] | 在起始位置 → 向前移动 |
| 4 | 1 | [3, 4, 5, 1, 2] | 比较 4 < 5 → 正常 → 向前移动 |
| 5 | 3 | [3, 4, 5, 1, 2] | 比较 5 > 1 → 交换,向后移动 |
| 6 | 2 | [3, 4, 1, 5, 2] | 比较 4 > 1 → 交换,向后移动 |
| 7 | 1 | [3, 1, 4, 5, 2] | 比较 3 > 1 → 交换,向后移动 |
| 8 | 0 | [1, 3, 4, 5, 2] | 在起始位置 → 向前移动 |
| 9 | ... | ... | 继续直到排序完成 [1,2,3,4,5] |
它是如何工作的(通俗解释)?
该算法在列表中"行走":
- 如果当前元素大于或等于前一个元素,则向前移动。
- 如果不是,则交换它们并向后退一步。
- 重复直到到达末尾。
如果到达数组的起始位置,则向前移动。
这就像插入排序,但它不是移动元素,而是通过行走和交换来完成。
逐步过程
| 步骤 | 条件 | 动作 |
|---|---|---|
| 如果 A[i] ≥ A[i−1] | 向前移动 (i++) | |
| 如果 A[i] < A[i−1] | 交换,向后移动 (i−−) | |
| 如果 i == 0 | 向前移动 (i++) |
微型代码(简易版本)
C
#include <stdio.h>
void gnome_sort(int a[], int n) {
int i = 1;
while (i < n) {
if (i == 0 || a[i] >= a[i - 1]) {
i++;
} else {
int temp = a[i];
a[i] = a[i - 1];
a[i - 1] = temp;
i--;
}
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
gnome_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def gnome_sort(a):
i = 1
n = len(a)
while i < n:
if i == 0 or a[i] >= a[i - 1]:
i += 1
else:
a[i], a[i - 1] = a[i - 1], a[i]
i -= 1
arr = [5, 3, 4, 1, 2]
gnome_sort(arr)
print(arr)为什么它重要
- 展示了通过局部校正进行排序
- 视觉上直观(适合动画演示)
- 不需要额外的内存
- 稳定且对部分有序数据具有自适应性
一个温和的证明(为什么它有效)
每次我们交换无序元素时,都会退一步与前一个元素验证顺序。 因此,当我们再次向前移动时,可以保证之前的所有元素都已排序。
侏儒排序通过相邻交换有效地执行了插入排序。
| 遍历 | 交换次数 | 移动方向 | 已排序部分 |
|---|---|---|---|
| 1 | 少 | 向后 | 逐渐扩展 |
| n | 多 | 振荡 | 完全排序 |
最坏情况交换次数:O(n²) 最佳情况(已排序):O(n)
自己动手试试
| 任务 | 描述 |
|---|---|
| 1 | 逐步排序 [5, 3, 4, 1, 2] |
| 2 | 追踪 i 指针的移动 |
| 3 | 与插入排序的移动操作进行比较 |
| 4 | 使用控制台输出制作动画 |
| 5 | 尝试反转输入以查看最大交换次数 |
测试用例
| 输入 | 输出 | 交换次数 | 备注 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 3 | 多次回溯 |
| [1, 2, 3] | [1, 2, 3] | 0 | 已排序 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 8 | 中等交换次数 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最佳) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
侏儒排序是一种异想天开的算法,它教会了我们坚持:每当有东西顺序不对时,退一步,修复它,然后继续前进。对于大数据来说效率不高,但对于学习和可视化来说却令人愉快。
109 奇偶排序
奇偶排序,也称为砖块排序,是冒泡排序的一种并行友好变体。它交替比较奇数-偶数和偶数-奇数索引对,以逐步对数组进行排序。它在可以同时比较多对元素的并行处理中特别有用。
我们要解决什么问题?
冒泡排序在一次遍历中比较每一对相邻元素。奇偶排序将其分解为两个交替阶段:
- 奇数阶段:比较 (1,2), (3,4), (5,6), ...
- 偶数阶段:比较 (0,1), (2,3), (4,5), ...
重复这两个阶段可以确保所有相邻元素对最终变得有序。
由于每个阶段中的比较是独立的,因此它非常适合并行系统或硬件实现。
示例
| 阶段 | 类型 | 数组状态 | 操作 |
|---|---|---|---|
| 0 | 初始 | [5, 3, 4, 1, 2] | 开始 |
| 1 | 偶数 | [3, 5, 1, 4, 2] | 比较 (0,1), (2,3), (4,5) |
| 2 | 奇数 | [3, 1, 5, 2, 4] | 比较 (1,2), (3,4) |
| 3 | 偶数 | [1, 3, 2, 4, 5] | 比较 (0,1), (2,3), (4,5) |
| 4 | 奇数 | [1, 2, 3, 4, 5] | 比较 (1,2), (3,4) → 已排序 |
它是如何工作的(通俗解释)?
奇偶排序通过每个交替阶段将元素移动到更接近其正确位置。 它就像一个交通系统:偶数交叉路口的车移动,然后是奇数交叉路口的车移动。 随着时间的推移,所有的车(元素)都按顺序排列好了。
逐步过程
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 比较所有偶数-奇数对 | 如果顺序错误则交换 |
| 2 | 比较所有奇数-偶数对 | 如果顺序错误则交换 |
| 3 | 重复直到没有交换发生 | 排序后的数组 |
微型代码(简单版本)
C
#include <stdio.h>
#include <stdbool.h>
void odd_even_sort(int a[], int n) {
bool sorted = false;
while (!sorted) {
sorted = true;
// 偶数阶段
for (int i = 0; i < n - 1; i += 2) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
sorted = false;
}
}
// 奇数阶段
for (int i = 1; i < n - 1; i += 2) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
sorted = false;
}
}
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
odd_even_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def odd_even_sort(a):
n = len(a)
sorted = False
while not sorted:
sorted = True
# 偶数阶段
for i in range(0, n - 1, 2):
if a[i] > a[i + 1]:
a[i], a[i + 1] = a[i + 1], a[i]
sorted = False
# 奇数阶段
for i in range(1, n - 1, 2):
if a[i] > a[i + 1]:
a[i], a[i + 1] = a[i + 1], a[i]
sorted = False
arr = [5, 3, 4, 1, 2]
odd_even_sort(arr)
print(arr)为什么它重要
- 展示了并行排序的原理
- 概念简单,易于可视化
- 可以在并行处理器(SIMD,GPU)上实现
- 稳定且原地排序
一个温和的证明(为什么它有效)
奇偶排序通过交替比较系统地消除所有逆序:
- 偶数阶段修复 (0,1), (2,3), (4,5), ... 对
- 奇数阶段修复 (1,2), (3,4), (5,6), ... 对
经过 n 次迭代后,每个元素都"冒泡"到其正确位置。 它的行为类似于冒泡排序,但更有结构性和基于阶段。
| 阶段 | 比较次数 | 独立? | 交换 |
|---|---|---|---|
| 偶数 | ⌊n/2⌋ | 是 | 一些 |
| 奇数 | ⌊n/2⌋ | 是 | 一些 |
总复杂度仍然是 O(n²),但可并行化的阶段减少了实际运行时间。
自己动手试试
| 任务 | 描述 |
|---|---|
| 1 | 跟踪 [5, 3, 4, 1, 2] 的各个阶段 |
| 2 | 计算排序所需的阶段数 |
| 3 | 使用线程实现(并行版本) |
| 4 | 与冒泡排序比较 |
| 5 | 动画演示偶数和奇数阶段的遍历 |
测试用例
| 输入 | 输出 | 阶段数 | 备注 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 3 | 交替阶段 |
| [1, 2, 3] | [1, 2, 3] | 1 | 提前停止 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 4 | 中等数量的阶段 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(最好) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
奇偶排序表明结构很重要。通过交替阶段,它为并行排序打开了大门,其中独立的比较可以同时运行,这是迈向高性能排序的一个巧妙步骤。
110 傻瓜排序
傻瓜排序(Stooge Sort)是最奇特、最古怪的递归排序算法之一。它效率不高,但因其通过递归排序数组的重叠部分来实现排序而引人入胜,是研究递归和算法趣味性的绝佳方式。
我们要解决什么问题?
傻瓜排序并不追求速度。相反,它提供了一个递归分治策略的例子,这种策略既不高效也不传统。它将数组划分为重叠的部分并递归地对它们进行排序:先对前三分之二排序两次,再对后三分之二排序一次。
该算法常用于教学目的,以展示如何以非传统方式应用递归。
示例
| 步骤 | 范围 | 操作 | 数组状态 |
|---|---|---|---|
| 0 | [0..4] | 开始排序 [5,3,4,1,2] | [5,3,4,1,2] |
| 1 | [0..3] | 排序前 2/3(4 个元素) | [3,4,1,5,2] |
| 2 | [1..4] | 排序后 2/3(4 个元素) | [3,1,4,2,5] |
| 3 | [0..3] | 再次排序前 2/3(4 个元素) | [1,3,2,4,5] |
| 4 | 重复 | 直到子数组长度缩小为 1 | [1,2,3,4,5] |
它是如何工作的(通俗解释)?
如果第一个元素大于最后一个元素,则交换它们。 然后递归地:
- 排序数组的前三分之二。
- 排序数组的后三分之二。
- 再次排序数组的前三分之二。
这就像先检查前面,再检查后面,然后重新检查前面,直到所有元素都按顺序排列好。
逐步过程
| 步骤 | 操作 |
|---|---|
| 1 | 比较首尾元素;如果需要则交换 |
| 2 | 递归排序数组的前 2/3 |
| 3 | 递归排序数组的后 2/3 |
| 4 | 再次递归排序数组的前 2/3 |
| 5 | 当子数组长度 ≤ 1 时停止 |
精简代码(简易版本)
C
#include <stdio.h>
void stooge_sort(int a[], int l, int h) {
if (a[l] > a[h]) {
int temp = a[l];
a[l] = a[h];
a[h] = temp;
}
if (h - l + 1 > 2) {
int t = (h - l + 1) / 3;
stooge_sort(a, l, h - t);
stooge_sort(a, l + t, h);
stooge_sort(a, l, h - t);
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
stooge_sort(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def stooge_sort(a, l, h):
if a[l] > a[h]:
a[l], a[h] = a[h], a[l]
if h - l + 1 > 2:
t = (h - l + 1) // 3
stooge_sort(a, l, h - t)
stooge_sort(a, l + t, h)
stooge_sort(a, l, h - t)
arr = [5, 3, 4, 1, 2]
stooge_sort(arr, 0, len(arr) - 1)
print(arr)为什么它重要
- 展示了递归分治逻辑
- 是“更多递归 = 更快速度”的一个有趣反例
- 在理论讨论或算法幽默中很有用
- 有助于理解重叠子问题
一个温和的证明(为什么它有效)
在每一步,傻瓜排序确保:
- 最小的元素移向前端,
- 最大的元素移向后端,
- 数组通过重叠的递归调用收敛到有序状态。
每次递归操作于范围的 2/3,重复 3 次,得到递归式:
$$ T(n) = 3T(2n/3) + O(1) $$
求解它(使用主定理):
$$ T(n) = O(n^{\log_{1.5} 3}) \approx O(n^{2.7095}) $$
比冒泡排序(O(n²))还要慢!
| 步骤 | 子数组长度 | 递归调用次数 | 每层工作量 |
|---|---|---|---|
| n | 3 | O(1) | 常数 |
| n/2 | 3×3 | O(3) | 更大 |
| ... | ... | ... | ... |
亲自尝试
| 任务 | 描述 |
|---|---|
| 1 | 手动排序 [5, 3, 4, 1, 2] 并追踪递归调用 |
| 2 | 计算总交换次数 |
| 3 | 打印递归深度 |
| 4 | 与归并排序步骤进行比较 |
| 5 | 测量 n=10, 100, 1000 时的运行时间 |
测试用例
| 输入 | 输出 | 递归深度 | 备注 |
|---|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 3 | 递归工作 |
| [1, 2, 3] | [1, 2, 3] | 1 | 最小交换次数 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 6 | 许多重叠 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n².7095) |
| 时间(最好) | O(n².7095) |
| 空间 | O(log n) 递归栈 |
| 稳定 | 否 |
| 自适应 | 否 |
傻瓜排序是一个令人愉快的怪胎,缓慢、冗余,但无疑具有创造性。它提醒我们,并非每个递归想法都能带来效率,算法设计既是科学也是艺术。
第 12 章 分治排序
111 归并排序
归并排序是最著名的分治排序算法之一。它将数组分成两半,递归地对每一半进行排序,然后将两个已排序的半部分合并成一个有序数组。它保证了 O(n log n) 的性能,是稳定的,并且是许多现代排序库的基石。
我们要解决什么问题?
我们需要一个满足以下条件的排序算法:
- 在大数据集上高效(O(n log n))
- 稳定(保持相等元素的顺序)
- 可预测(没有最坏情况下的性能退化)
归并排序通过将问题分解为更小、易于解决的子问题并组合其结果来实现这些目标。
它非常适合:
- 排序链表
- 外部排序(在磁盘上)
- 稳定归并(用于多键排序)
示例
| 步骤 | 操作 | 结果 |
|---|---|---|
| 0 | 输入 | [5, 3, 4, 1, 2] |
| 1 | 分割 | [5, 3, 4] 和 [1, 2] |
| 2 | 进一步分割 | [5], [3, 4], [1], [2] |
| 3 | 排序子数组 | [3, 4, 5], [1, 2] |
| 4 | 合并已排序的两半 | [1, 2, 3, 4, 5] |
它是如何工作的(通俗解释)?
- 分解:将数组分成两半。
- 解决:递归地对两半进行排序。
- 合并:将已排序的两半合并成一个有序数组。
可以想象成先对两小堆牌进行排序,然后按顺序交错合并它们,就像合并两叠扑克牌一样。
逐步过程
- 如果数组有 0 或 1 个元素,它已经是有序的。
- 递归地对左半部分和右半部分进行排序。
- 使用一个辅助的合并函数来组合它们。
精简代码(简易版本)
C
#include <stdio.h>
void merge(int a[], int left, int mid, int right) {
int n1 = mid - left + 1, n2 = right - mid;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = a[left + i];
for (int j = 0; j < n2; j++) R[j] = a[mid + 1 + j];
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) a[k++] = L[i++];
else a[k++] = R[j++];
}
while (i < n1) a[k++] = L[i++];
while (j < n2) a[k++] = R[j++];
}
void merge_sort(int a[], int left, int right) {
if (left < right) {
int mid = left + (right - left) / 2;
merge_sort(a, left, mid);
merge_sort(a, mid + 1, right);
merge(a, left, mid, right);
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
merge_sort(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def merge_sort(a):
if len(a) > 1:
mid = len(a) // 2
left = a[:mid]
right = a[mid:]
merge_sort(left)
merge_sort(right)
i = j = k = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
a[k] = left[i]
i += 1
else:
a[k] = right[j]
j += 1
k += 1
while i < len(left):
a[k] = left[i]
i += 1
k += 1
while j < len(right):
a[k] = right[j]
j += 1
k += 1
arr = [5, 3, 4, 1, 2]
merge_sort(arr)
print(arr)为什么它很重要
- 稳定:保持相等元素的相对顺序
- 确定性的 O(n log n):总是高效的
- 可并行化:子数组可以独立排序
- 基础:是 TimSort 和外部归并排序等混合算法的基础
一个温和的证明(为什么它有效)
归并排序在每一层都将数组分成两半。 递归有 log₂ n 层,每次合并需要 O(n) 时间。
所以总时间: $$ T(n) = O(n \log n) $$
合并是线性的,因为每个元素在每一层只被复制一次。
| 步骤 | 工作量 | 子数组数量 | 总计 |
|---|---|---|---|
| 1 | O(n) | 1 | O(n) |
| 2 | O(n/2) × 2 | 2 | O(n) |
| 3 | O(n/4) × 4 | 4 | O(n) |
| ... | ... | ... | O(n log n) |
亲自尝试
- 逐步将 [5, 3, 4, 1, 2] 分割成两半。
- 手动合并 [3, 5] 和 [1, 4]。
- 在纸上追踪递归调用。
- 实现一个迭代的自底向上版本。
- 修改为降序排序。
- 在每次合并步骤打印数组。
- 比较与冒泡排序的比较次数。
- 尝试合并两个预排序数组 [1,3,5] 和 [2,4,6]。
- 对一个字符串列表(按字母顺序)进行排序。
- 可视化 n = 8 时的递归树。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 标准测试 |
| [1, 2, 3] | [1, 2, 3] | 已排序 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 一般情况 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 测试稳定性 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n log n) |
| 时间(最好) | O(n log n) |
| 时间(平均) | O(n log n) |
| 空间 | O(n) |
| 稳定 | 是 |
| 自适应 | 否 |
归并排序是你初次体验分治排序,它冷静、可靠且优雅。它清晰地分解问题,递归地解决,并精确地合并。
112 迭代归并排序
迭代归并排序是归并排序的一种非递归版本,采用自底向上的归并方式。 它不进行递归划分,而是从大小为 1 的子数组开始,迭代地成对合并它们,每轮将子数组大小加倍。这使得它非常适合递归开销大或受限的环境。
我们要解决什么问题?
递归归并排序需要函数调用和栈空间。 在某些系统中,递归可能很慢或不可行(例如嵌入式系统、大型数组)。 迭代归并排序通过迭代排序来避免递归,不断合并大小递增的子数组,直到整个数组有序。
它在以下场景中尤其方便:
- 迭代环境(无递归)
- 大型数据集(内存使用可预测)
- 需要迭代遍历的外部排序
示例
| 步骤 | 子数组大小 | 操作 | 数组状态 |
|---|---|---|---|
| 0 | 1 | 每个元素本身已有序 | [5, 3, 4, 1, 2] |
| 1 | 1 | 合并成对的 1 元素子数组 | [3, 5, 1, 4, 2] |
| 2 | 2 | 合并成对的 2 元素子数组 | [1, 3, 4, 5, 2] |
| 3 | 4 | 将 4 元素有序块与最后一个元素合并 | [1, 2, 3, 4, 5] |
| 4 | 完成 | 完全有序 | [1, 2, 3, 4, 5] |
它是如何工作的(通俗解释)?
可以将其理解为先对小分组进行排序,然后将它们合并成更大的分组,无需递归。
过程:
- 从大小为 1 的子数组开始(已经有序)。
- 合并相邻的子数组对。
- 将子数组大小加倍并重复步骤 2。
- 继续直到子数组大小 ≥ n。
逐步过程
- 外层循环:size = 1, 2, 4, 8, ... 直到 ≥ n
- 内层循环:合并给定大小的每两个相邻块
精简代码(简易版本)
C
#include <stdio.h>
void merge(int a[], int left, int mid, int right) {
int n1 = mid - left + 1, n2 = right - mid;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = a[left + i];
for (int j = 0; j < n2; j++) R[j] = a[mid + 1 + j];
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) a[k++] = L[i++];
else a[k++] = R[j++];
}
while (i < n1) a[k++] = L[i++];
while (j < n2) a[k++] = R[j++];
}
void iterative_merge_sort(int a[], int n) {
for (int size = 1; size < n; size *= 2) {
for (int left = 0; left < n - 1; left += 2 * size) {
int mid = left + size - 1;
int right = (left + 2 * size - 1 < n - 1) ? (left + 2 * size - 1) : (n - 1);
if (mid < right)
merge(a, left, mid, right);
}
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
iterative_merge_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def merge(a, left, mid, right):
left_arr = a[left:mid+1]
right_arr = a[mid+1:right+1]
i = j = 0
k = left
while i < len(left_arr) and j < len(right_arr):
if left_arr[i] <= right_arr[j]:
a[k] = left_arr[i]
i += 1
else:
a[k] = right_arr[j]
j += 1
k += 1
while i < len(left_arr):
a[k] = left_arr[i]
i += 1
k += 1
while j < len(right_arr):
a[k] = right_arr[j]
j += 1
k += 1
def iterative_merge_sort(a):
n = len(a)
size = 1
while size < n:
for left in range(0, n, 2 * size):
mid = min(left + size - 1, n - 1)
right = min(left + 2 * size - 1, n - 1)
if mid < right:
merge(a, left, mid, right)
size *= 2
arr = [5, 3, 4, 1, 2]
iterative_merge_sort(arr)
print(arr)为什么它重要
- 消除了递归(内存使用更可预测)
- 仍然保证 O(n log n) 的性能
- 适用于迭代、自底向上或外部排序
- 由于合并操作相互独立,更容易并行化
一个温和的证明(为什么它有效)
每次迭代都将有序块的大小加倍。 由于每个元素参与 log₂ n 个合并层级,并且每个层级的代价是 O(n) 的工作量,总代价为:
$$ T(n) = O(n \log n) $$
与递归归并排序类似,每个合并步骤是线性的,并且合并子数组是稳定的。
| 迭代次数 | 块大小 | 合并次数 | 工作量 |
|---|---|---|---|
| 1 | 1 | n/2 | O(n) |
| 2 | 2 | n/4 | O(n) |
| 3 | 4 | n/8 | O(n) |
| ... | ... | ... | ... |
总计 = O(n log n)
亲自尝试
- 使用自底向上的遍历手动排序 [5, 3, 4, 1, 2]。
- 跟踪每次遍历:子数组大小 = 1 → 2 → 4。
- 打印每次遍历后的中间数组。
- 与递归版本的递归深度进行比较。
- 实现一个空间高效的版本(原地合并)。
- 修改为降序排序。
- 应用于链表版本。
- 在大型数组(n = 10⁶)上测试性能。
- 将合并遍历过程可视化为树。
- 在外部存储(基于文件)上实现。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 小型测试 |
| [1, 2, 3] | [1, 2, 3] | 已经有序 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 通用测试 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 测试稳定性 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n log n) |
| 时间(最好) | O(n log n) |
| 空间 | O(n) |
| 稳定 | 是 |
| 自适应 | 否 |
迭代归并排序是经典归并排序的非递归孪生兄弟,高效、稳定且内存使用可预测,使其在栈空间宝贵时成为完美选择。
113 快速排序
快速排序是最快且应用最广泛的排序算法之一。它通过围绕一个枢轴元素将数组划分为两半,并递归地对这两部分进行排序。凭借平均情况 O(n log n) 的性能和原地操作,它是许多库和实际系统中的首选。
我们要解决什么问题?
我们需要一个满足以下条件的排序算法:
- 在实践中高效(平均情况快)
- 原地操作(内存使用最少)
- 基于分治法(可并行化)
快速排序对数组进行划分,使得:
- 所有小于枢轴的元素移到左边
- 所有大于枢轴的元素移到右边
然后递归地对每一半进行排序。
它适用于:
- 内存中的大型数据集
- 内存分配有限的系统
- 平均情况性能优化
示例
| 步骤 | 枢轴 | 操作 | 数组状态 |
|---|---|---|---|
| 0 | 5 | 划分 | [3, 4, 1, 2, 5] |
| 1 | 3 | 划分左侧 | [1, 2, 3, 4, 5] |
| 2 | 完成 | 已排序 | [1, 2, 3, 4, 5] |
它是如何工作的(通俗解释)?
- 选择一个枢轴元素。
- 重新排列(划分)数组,使得:
- 小于枢轴的元素移到左边
- 大于枢轴的元素移到右边
- 对每一半递归地应用相同的逻辑。
可以将枢轴视为"分隔符",它将未排序的数组分割成两个更小的问题。
逐步过程
| 步骤 | 操作 |
|---|---|
| 1 | 选择一个枢轴(例如,最后一个元素) |
| 2 | 围绕枢轴划分数组 |
| 3 | 递归地对左右子数组进行排序 |
| 4 | 当子数组大小 ≤ 1 时停止 |
精简代码(简易版本)
C (Lomuto 划分方案)
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int partition(int a[], int low, int high) {
int pivot = a[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (a[j] < pivot) {
i++;
swap(&a[i], &a[j]);
}
}
swap(&a[i + 1], &a[high]);
return i + 1;
}
void quick_sort(int a[], int low, int high) {
if (low < high) {
int pi = partition(a, low, high);
quick_sort(a, low, pi - 1);
quick_sort(a, pi + 1, high);
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
quick_sort(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def partition(a, low, high):
pivot = a[high]
i = low - 1
for j in range(low, high):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[high] = a[high], a[i + 1]
return i + 1
def quick_sort(a, low, high):
if low < high:
pi = partition(a, low, high)
quick_sort(a, low, pi - 1)
quick_sort(a, pi + 1, high)
arr = [5, 3, 4, 1, 2]
quick_sort(arr, 0, len(arr) - 1)
print(arr)为什么它重要
- 原地操作,在大多数实际情况下速度快
- 分治法:天然可并行化
- 常被用作库(C、Java、Python)中的默认排序算法
- 引入了划分这一关键的算法模式
一个温和的证明(为什么它有效)
每次划分都将问题分解为更小的子数组。 平均划分是平衡的,产生 O(log n) 的深度和每层 O(n) 的工作量:
$$ T(n) = 2T(n/2) + O(n) = O(n \log n) $$
如果枢轴选择不佳(例如最小或最大),复杂度会恶化:
$$ T(n) = T(n-1) + O(n) = O(n^2) $$
| 情况 | 划分质量 | 复杂度 |
|---|---|---|
| 最佳 | 完美对半分 | O(n log n) |
| 平均 | 随机 | O(n log n) |
| 最差 | 不平衡 | O(n²) |
明智地选择枢轴(随机化、三数取中)可以避免最坏情况的划分。
亲自尝试
- 排序 [5, 3, 4, 1, 2] 并跟踪划分过程。
- 更改枢轴选择方式(第一个、中间、随机)。
- 统计每种情况下的比较和交换次数。
- 使用 Hoare 划分方案实现。
- 修改为降序排序。
- 可视化 n = 8 时的递归树。
- 与归并排序比较运行时间。
- 尝试已排序的输入 [1, 2, 3, 4, 5] 并注意其行为。
- 添加一个计数器来统计递归调用次数。
- 实现尾递归优化。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 基本 |
| [1, 2, 3] | [1, 2, 3] | 最坏情况(已排序输入) |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 一般情况 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n log n) |
| 时间(平均) | O(n log n) |
| 时间(最差) | O(n²) |
| 空间 | O(log n)(递归栈) |
| 稳定 | 否 |
| 自适应 | 否 |
快速排序是排序算法中实用的主力军,快速、优雅且广受喜爱。它教会我们,一个聪明的枢轴如何能将混乱变为有序。
114 Hoare 分区方案
Hoare 分区方案是快速排序分区方法的一个早期且优雅的版本,由 C.A.R. Hoare 本人设计。在许多情况下,它比 Lomuto 方案更高效,因为它进行更少的交换,并使用两个指针从两端向中间移动。
我们要解决什么问题?
在快速排序中,我们需要一种方法将数组分成两部分:
- 小于或等于枢轴的元素
- 大于或等于枢轴的元素
Hoare 的方案通过使用两个彼此相向移动的索引,交换位置错误的元素来实现这一点。与 Lomuto 方案相比,它减少了交换次数,并且在实际数据上通常表现更好。
它特别适用于:
- 大型数组(写入次数少)
- 性能关键的系统
- 无需额外空间的原位分区
示例
| 步骤 | 枢轴 | 左指针 (i) | 右指针 (j) | 数组状态 | 操作 |
|---|---|---|---|---|---|
| 0 | 5 | i=0 | j=4 | [5, 3, 4, 1, 2] | pivot = 5 |
| 1 | 5 | i→2 | j→4 | [5, 3, 4, 1, 2] | a[j]=2<5 swap(5,2) → [2,3,4,1,5] |
| 2 | 5 | i→2 | j→3 | [2,3,4,1,5] | a[i]=4>5? 不交换 |
| 3 | 停止 | - | - | [2,3,4,1,5] | 分区完成 |
枢轴最终会接近其正确位置,但不一定在最终索引上。
它是如何工作的(通俗解释)?
算法选取一个枢轴(通常是第一个元素),然后移动两个指针:
- 左指针 (i):向右移动,跳过较小的元素
- 右指针 (j):向左移动,跳过较大的元素
当两个指针都找到放错位置的元素时,交换它们。 这个过程持续到指针交叉为止,此时数组就被分区了。
逐步过程
- 选择一个枢轴(例如第一个元素)。
- 设置两个索引:
i = left - 1,j = right + 1。 - 递增
i直到a[i] >= pivot。 - 递减
j直到a[j] <= pivot。 - 如果
i < j,交换a[i]和a[j]。否则,返回j(分区索引)。
精简代码(简易版本)
C
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int hoare_partition(int a[], int low, int high) {
int pivot = a[low];
int i = low - 1;
int j = high + 1;
while (1) {
do { i++; } while (a[i] < pivot);
do { j--; } while (a[j] > pivot);
if (i >= j) return j;
swap(&a[i], &a[j]);
}
}
void quick_sort_hoare(int a[], int low, int high) {
if (low < high) {
int p = hoare_partition(a, low, high);
quick_sort_hoare(a, low, p);
quick_sort_hoare(a, p + 1, high);
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
quick_sort_hoare(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def hoare_partition(a, low, high):
pivot = a[low]
i = low - 1
j = high + 1
while True:
i += 1
while a[i] < pivot:
i += 1
j -= 1
while a[j] > pivot:
j -= 1
if i >= j:
return j
a[i], a[j] = a[j], a[i]
def quick_sort_hoare(a, low, high):
if low < high:
p = hoare_partition(a, low, high)
quick_sort_hoare(a, low, p)
quick_sort_hoare(a, p + 1, high)
arr = [5, 3, 4, 1, 2]
quick_sort_hoare(arr, 0, len(arr) - 1)
print(arr)为什么它很重要
- 比 Lomuto 分区交换次数少
- 在大多数数据集上实际效率更高
- 仍然是原地、分治、平均 O(n log n) 复杂度
- 引入了双指针分区的思想
一个温和的证明(为什么它有效)
循环不变量确保了:
- 左侧:所有元素 ≤ 枢轴
- 右侧:所有元素 ≥ 枢轴
i和j向中间移动直到交叉 当它们交叉时,所有元素都被正确分区。
枢轴不会落在其最终排序位置,但子数组可以独立地递归排序。
$$ T(n) = T(k) + T(n - k - 1) + O(n) $$
平均复杂度 O(n log n);如果枢轴选择不当,最坏情况为 O(n²)。
| 情况 | 枢轴 | 行为 | 复杂度 |
|---|---|---|---|
| 最佳 | 中位数 | 平衡的两半 | O(n log n) |
| 平均 | 随机 | 轻微不平衡 | O(n log n) |
| 最坏 | 最小/最大 | 不平衡 | O(n²) |
亲自尝试
- 使用 Hoare 方案逐步排序 [5, 3, 4, 1, 2]。
- 在每次迭代时打印
i和j。 - 与 Lomuto 版本在同一个数组上进行比较。
- 尝试不同的枢轴位置(第一个、最后一个、随机)。
- 测量与 Lomuto 的交换次数对比。
- 修改为降序排序。
- 可视化分区边界。
- 在有重复元素的数组 [3,3,2,1,4] 上测试。
- 实现混合枢轴选择(三数取中)。
- 与归并排序比较运行时间。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 简单 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 一般 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 适用于重复元素 |
| [1, 2, 3, 4, 5] | [1, 2, 3, 4, 5] | 已排序输入(最坏情况) |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n log n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n²) |
| 空间 | O(log n) 递归 |
| 稳定 | 否 |
| 自适应 | 否 |
Hoare 分区方案优雅而高效,是快速排序的原始精髓。它的双指针之舞优雅而经济,是算法设计中永恒的经典。
115 Lomuto 分区方案
Lomuto 分区方案是快速排序中一种简单且被广泛教授的分区方法。它比 Hoare 的方案更容易理解和实现,尽管它通常执行的交换操作稍多一些。它总是选择一个枢轴(通常是最后一个元素),并在一次前向遍历中对数组进行分区。
我们要解决什么问题?
我们需要一种清晰直观的方法来围绕枢轴对数组进行分区,确保所有较小的元素都位于左侧,所有较大的元素都位于右侧。
Lomuto 的方法使用一个扫描指针和一个边界指针,这使得它易于初学者理解,非常适合教学目的或小型数据集。
示例
| 步骤 | 枢轴 | i (边界) | j (扫描) | 数组状态 | 操作 |
|---|---|---|---|---|---|
| 0 | 2 | i=-1 | j=0 | [5, 3, 4, 1, 2] | 选择枢轴 = 2 |
| 1 | 2 | i=-1 | j=0 | a[0]=5>2 → 不交换 | |
| 2 | 2 | i=-1 | j=1 | a[1]=3>2 → 不交换 | |
| 3 | 2 | i=-1 | j=2 | a[2]=4>2 → 不交换 | |
| 4 | 2 | i=0 | j=3 | a[3]=1<2 → 交换(5,1) → [1,3,4,5,2] | |
| 5 | 2 | i=0 | j=4 | 扫描结束;将枢轴与 a[i+1]=a[1] 交换 | |
| 6 | 完成 | - | - | 分区后的数组 [1,2,4,5,3] |
枢轴 2 被放置在其最终位置索引 1 处。 2 左侧的元素较小,右侧的元素较大。
它是如何工作的(通俗解释)?
- 选择一个枢轴(通常是最后一个元素)。
- 在数组开始之前初始化边界指针
i。 - 使用指针
j遍历数组:- 如果
a[j] < pivot,则递增i并交换a[i]和a[j]。
- 如果
- 循环结束后,将
pivot交换到位置i + 1。 - 返回
i + 1(枢轴的最终位置)。
这就像整理一副牌,你在扫描时不断将较小的牌移到前面。
逐步过程
| 步骤 | 操作 |
|---|---|
| 1 | 选择枢轴(通常是最后一个元素) |
| 2 | 将较小的元素移动到枢轴之前 |
| 3 | 将较大的元素移动到枢轴之后 |
| 4 | 将枢轴放置在其正确位置 |
| 5 | 返回枢轴索引以供递归排序使用 |
精简代码(简易版本)
C
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int lomuto_partition(int a[], int low, int high) {
int pivot = a[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (a[j] < pivot) {
i++;
swap(&a[i], &a[j]);
}
}
swap(&a[i + 1], &a[high]);
return i + 1;
}
void quick_sort_lomuto(int a[], int low, int high) {
if (low < high) {
int pi = lomuto_partition(a, low, high);
quick_sort_lomuto(a, low, pi - 1);
quick_sort_lomuto(a, pi + 1, high);
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
quick_sort_lomuto(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def lomuto_partition(a, low, high):
pivot = a[high]
i = low - 1
for j in range(low, high):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[high] = a[high], a[i + 1]
return i + 1
def quick_sort_lomuto(a, low, high):
if low < high:
pi = lomuto_partition(a, low, high)
quick_sort_lomuto(a, low, pi - 1)
quick_sort_lomuto(a, pi + 1, high)
arr = [5, 3, 4, 1, 2]
quick_sort_lomuto(arr, 0, len(arr) - 1)
print(arr)为什么它重要
- 简单且易于实现
- 每一步枢轴都位于其正确的最终位置
- 有助于快速排序的教学演示
- 常见于教科书和基本的快速排序示例中
一个温和的证明(为什么它有效)
不变式:
i左侧的元素小于枢轴。i和j之间的元素大于枢轴或尚未检查。 最后,将枢轴与a[i+1]交换将其放置在其最终位置。
时间复杂度:
$$ T(n) = T(k) + T(n - k - 1) + O(n) $$
平均情况:O(n log n) 最坏情况(已排序):O(n²)
| 情况 | 分区 | 复杂度 |
|---|---|---|
| 最佳 | 平衡 | O(n log n) |
| 平均 | 随机 | O(n log n) |
| 最坏 | 不平衡 | O(n²) |
亲自尝试
- 使用 Lomuto 方案逐步排序 [5, 3, 4, 1, 2]。
- 跟踪每次比较时
i和j的位置。 - 与 Hoare 分区方案的交换次数进行比较。
- 使用已排序的输入进行测试,观察最坏情况。
- 随机化枢轴以避免最坏情况。
- 修改代码以按降序排序。
- 计算总的交换和比较次数。
- 结合尾递归优化。
- 可视化每次遍历后的分区边界。
- 实现一个混合快速排序,对小数组使用 Lomuto 方案。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 简单情况 |
| [1, 2, 3] | [1, 2, 3] | 最坏情况 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 一般情况 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 处理重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n log n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n²) |
| 空间 | O(log n) 递归栈 |
| 稳定 | 否 |
| 自适应 | 否 |
Lomuto 方案是快速排序的友好导师,易于掌握,代码简单,非常适合建立关于分区和分治排序的直觉。
116 随机快速排序
随机快速排序通过随机选择枢轴来增强经典的快速排序。这一微小调整消除了在已排序或对抗性输入上出现最坏情况 O(n²) 行为的风险,使其成为实际应用中最稳健、最实用的排序策略之一。
我们要解决什么问题?
如果枢轴选择不当(例如,在已排序数组中总是选择第一个或最后一个元素),常规快速排序的性能可能会严重下降。 随机快速排序通过随机选择一个枢轴来修复此问题,确保无论输入分布如何,平均而言分区都是平衡的。
这使得它非常适合:
- 不可预测或对抗性的输入
- 避免最坏情况至关重要的大型数据集
- 需要一致行为的高性能关键系统
示例
| 步骤 | 操作 | 数组状态 | 枢轴 |
|---|---|---|---|
| 0 | 随机选择枢轴 | [5, 3, 4, 1, 2] | 4 |
| 1 | 围绕 4 进行分区 | [3, 2, 1, 4, 5] | 4 在索引 3 处 |
| 2 | 对左侧 [3, 2, 1] 递归排序 | [1, 2, 3] | 2 |
| 3 | 合并子数组 | [1, 2, 3, 4, 5] | 完成 |
随机化确保枢轴不太可能产生不平衡的分区。
它是如何工作的(通俗解释)?
它与快速排序相同,但在分区之前,我们随机选取一个元素并将其用作枢轴。 这个随机枢轴被交换到最后一个位置,然后继续进行常规的 Lomuto 或 Hoare 分区。
这种微小的随机性使其在平均情况下稳健高效,即使对于最坏情况的输入也是如此。
逐步过程
- 在
low和high之间随机选择一个枢轴索引。 - 将随机枢轴与最后一个元素交换。
- 对数组进行分区(例如,使用 Lomuto 或 Hoare 方法)。
- 递归排序左右分区。
精简代码(简易版本)
C (Lomuto + 随机枢轴)
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int lomuto_partition(int a[], int low, int high) {
int pivot = a[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (a[j] < pivot) {
i++;
swap(&a[i], &a[j]);
}
}
swap(&a[i + 1], &a[high]);
return i + 1;
}
int randomized_partition(int a[], int low, int high) {
int pivotIndex = low + rand() % (high - low + 1);
swap(&a[pivotIndex], &a[high]);
return lomuto_partition(a, low, high);
}
void randomized_quick_sort(int a[], int low, int high) {
if (low < high) {
int pi = randomized_partition(a, low, high);
randomized_quick_sort(a, low, pi - 1);
randomized_quick_sort(a, pi + 1, high);
}
}
int main(void) {
srand(time(NULL));
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
randomized_quick_sort(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
import random
def lomuto_partition(a, low, high):
pivot = a[high]
i = low - 1
for j in range(low, high):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[high] = a[high], a[i + 1]
return i + 1
def randomized_partition(a, low, high):
pivot_index = random.randint(low, high)
a[pivot_index], a[high] = a[high], a[pivot_index]
return lomuto_partition(a, low, high)
def randomized_quick_sort(a, low, high):
if low < high:
pi = randomized_partition(a, low, high)
randomized_quick_sort(a, low, pi - 1)
randomized_quick_sort(a, pi + 1, high)
arr = [5, 3, 4, 1, 2]
randomized_quick_sort(arr, 0, len(arr) - 1)
print(arr)为什么它很重要
- 防止最坏情况 O(n²) 行为
- 简单却非常有效
- 确保在所有输入上具有一致的平均情况性能
- 是随机选择和随机算法的基础
一个温和的证明(为什么它有效)
随机枢轴选择确保期望的分割大小是平衡的,与输入顺序无关。 每个枢轴对数组进行划分,使得期望的递归深度为 O(log n),总比较次数为 O(n log n)。
期望复杂度: $$ E[T(n)] = O(n \log n) $$ 最坏情况仅以极低的概率 (1/n!) 发生。
| 情况 | 枢轴选择 | 复杂度 |
|---|---|---|
| 最好情况 | 平衡 | O(n log n) |
| 平均情况 | 随机 | O(n log n) |
| 最坏情况 | 运气不佳(罕见) | O(n²) |
亲自尝试
- 多次排序 [5, 3, 4, 1, 2],注意枢轴的差异。
- 打印每次递归调用时的枢轴。
- 与确定性枢轴(第一个、最后一个)进行比较。
- 在已排序输入 [1, 2, 3, 4, 5] 上测试。
- 在反向输入 [5, 4, 3, 2, 1] 上测试。
- 统计多次运行的递归深度。
- 修改为使用 Hoare 分区。
- 实现
random.choice()版本。 - 比较运行时与普通快速排序。
- 设置 RNG 种子以复现相同的运行。
测试用例
| 输入 | 输出 | 注释 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 每次运行随机选择枢轴 |
| [1, 2, 3] | [1, 2, 3] | 避免 O(n²) |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 通用情况 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 处理重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最好情况) | O(n log n) |
| 时间(平均情况) | O(n log n) |
| 时间(最坏情况) | O(n²)(罕见) |
| 空间 | O(log n) |
| 稳定 | 否 |
| 自适应 | 否 |
随机快速排序展示了随机性的力量,一个微小的改变将一个脆弱的算法转变为一个可靠快速的算法,使其成为现代计算中最实用的排序算法之一。
117 堆排序
堆排序是一种经典的基于比较、原地操作、时间复杂度为 O(n log n) 的排序算法,它建立在堆数据结构之上。它首先将数组转换为最大堆,然后反复移除最大元素(堆根)并将其放置在末尾,同时逐步缩小堆的大小。它高效且节省内存,但不是稳定排序。
我们要解决什么问题?
我们想要一种排序算法,它具备以下特点:
- 在所有情况下都能保证 O(n log n) 的时间复杂度
- 使用常数级别的额外空间 (O(1))
- 不像归并排序那样需要递归或额外的数组
堆排序通过使用二叉堆来高效地提取最大元素,然后将其放置在末尾的正确位置,从而实现了这些目标。
非常适合:
- 内存受限的系统
- 需要可预测性能的场景
- 数据能放入 RAM 的离线排序
示例
| 步骤 | 操作 | 数组状态 | 堆大小 |
|---|---|---|---|
| 0 | 构建最大堆 | [5, 3, 4, 1, 2] | 5 |
| 1 | 交换根节点与最后一个元素 | [2, 3, 4, 1, 5] | 4 |
| 2 | 堆化根节点 | [4, 3, 2, 1, 5] | 4 |
| 3 | 交换根节点与最后一个元素 | [1, 3, 2, 4, 5] | 3 |
| 4 | 堆化根节点 | [3, 1, 2, 4, 5] | 3 |
| 5 | 重复直到堆缩小 | [1, 2, 3, 4, 5] | 0 |
它是如何工作的(通俗解释)?
可以把堆想象成一个存储在数组中的树。根节点(索引 0)是最大的元素。 堆排序主要分为两个步骤:
- 构建一个最大堆(排列使得每个父节点 ≥ 其子节点)
- 反复提取最大值:
- 交换根节点与最后一个元素
- 减少堆的大小
- 堆化根节点以恢复最大堆性质
每次提取后,已排序的部分在数组的末尾增长。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从数组构建最大堆 |
| 2 | 交换第一个(最大)元素与最后一个元素 |
| 3 | 将堆大小减一 |
| 4 | 堆化根节点 |
| 5 | 重复直到堆为空 |
精简代码(简易版本)
C
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
void heapify(int a[], int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && a[left] > a[largest]) largest = left;
if (right < n && a[right] > a[largest]) largest = right;
if (largest != i) {
swap(&a[i], &a[largest]);
heapify(a, n, largest);
}
}
void heap_sort(int a[], int n) {
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--)
heapify(a, n, i);
// 从堆中提取元素
for (int i = n - 1; i > 0; i--) {
swap(&a[0], &a[i]);
heapify(a, i, 0);
}
}
int main(void) {
int a[] = {5, 3, 4, 1, 2};
int n = sizeof(a) / sizeof(a[0]);
heap_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def heapify(a, n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and a[left] > a[largest]:
largest = left
if right < n and a[right] > a[largest]:
largest = right
if largest != i:
a[i], a[largest] = a[largest], a[i]
heapify(a, n, largest)
def heap_sort(a):
n = len(a)
# 构建最大堆
for i in range(n // 2 - 1, -1, -1):
heapify(a, n, i)
# 提取最大值并堆化
for i in range(n - 1, 0, -1):
a[0], a[i] = a[i], a[0]
heapify(a, i, 0)
arr = [5, 3, 4, 1, 2]
heap_sort(arr)
print(arr)为什么它很重要
- 在所有情况下都有可预测的 O(n log n) 时间复杂度
- 原地操作,不需要额外内存
- 在内存紧张或不宜使用递归时表现优异
- 展示了基于树的排序逻辑
一个温和的证明(为什么它有效)
构建堆:O(n) 提取每个元素:O(log n) 总时间:
$$ T(n) = O(n) + n \times O(\log n) = O(n \log n) $$
每个元素最多被"向下冒泡" log n 层一次。
| 阶段 | 工作量 | 总计 |
|---|---|---|
| 构建堆 | O(n) | 线性 |
| 提取 n 个元素 | n × O(log n) | O(n log n) |
不是稳定排序,因为交换可能会破坏相等元素的顺序。
亲自尝试
- 从 [5, 3, 4, 1, 2] 构建最大堆。
- 为每一步绘制堆树。
- 跟踪
heapify调用和交换。 - 实现最小堆版本用于降序排序。
- 计算每个阶段的比较次数。
- 与归并排序的空间使用情况比较。
- 修改算法,使其在已排序时提前停止。
- 动画演示堆的构建过程。
- 在反转数组 [5,4,3,2,1] 上测试。
- 添加调试打印,显示每一步之后的堆。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3, 2, 1] | [1, 2, 3] | 小型测试 |
| [1, 2, 3] | [1, 2, 3] | 已排序 |
| [5, 3, 4, 1, 2] | [1, 2, 3, 4, 5] | 一般情况 |
| [2, 2, 1, 1] | [1, 1, 2, 2] | 不稳定但结果正确 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最好情况) | O(n log n) |
| 时间(平均情况) | O(n log n) |
| 时间(最坏情况) | O(n log n) |
| 空间 | O(1) |
| 稳定 | 否 |
| 自适应 | 否 |
堆排序是保证性能的主力军,稳定、空间高效,并且建立在优雅的树逻辑之上。它永远不会因为糟糕的情况让你感到意外,在一致性至关重要时,它是一个可靠的朋友。
118 三路快速排序
三路快速排序是快速排序的一种改进版本,旨在高效处理包含大量重复元素的数组。它不再将数组划分为两个部分(小于基准和大于基准),而是划分为三个区域:
< 基准= 基准> 基准
当许多元素等于基准时,这避免了冗余工作,使其对于低熵数据集或重复键的数据集特别有效。
我们要解决什么问题?
当存在重复元素时,标准快速排序可能会执行不必要的工作。 例如,如果所有元素都相同,标准快速排序仍然会递归 O(n log n) 次。
三路快速排序通过以下方式解决这个问题:
- 在分区过程中跳过相等的元素
- 显著减少递归深度
它非常适合:
- 包含许多重复元素的数组
- 具有公共前缀的字符串排序
- 具有重复键的键值对
示例
| 步骤 | 基准 | 操作 | 数组状态 | 分区指针 |
|---|---|---|---|---|
| 0 | 3 | 开始 | [3, 2, 3, 1, 3] | l=0, i=0, g=4 |
| 1 | 3 | a[i]=3 → 相等 | [3, 2, 3, 1, 3] | i=1 |
| 2 | 3 | a[i]=2 < 3 → 交换(a[l],a[i]) | [2, 3, 3, 1, 3] | l=1, i=2 |
| 3 | 3 | a[i]=3 → 相等 | [2, 3, 3, 1, 3] | i=3 |
| 4 | 3 | a[i]=1 < 3 → 交换(a[l],a[i]) | [2, 1, 3, 3, 3] | l=2, i=4 |
| 5 | 3 | a[i]=3 → 相等 | [2, 1, 3, 3, 3] | 完成 |
现在递归排序左侧 < 基准 区域 [2,1],跳过中间的 =3 块,并排序右侧 > 基准 区域(空)。
它是如何工作的(通俗解释)?
我们使用三个指针来跟踪三个区域:
lt(小于区域)i(当前元素)gt(大于区域)
每次迭代比较 a[i] 与基准:
- 如果
< 基准:与lt交换,扩展两个区域 - 如果
> 基准:与gt交换,缩小右侧区域 - 如果
= 基准:向前移动
继续直到 i > gt。 这一趟遍历就将数组划分为三个区域,无需重新处理等于基准的元素。
逐步过程
| 步骤 | 条件 | 操作 |
|---|---|---|
| 1 | a[i] < pivot | swap(a[i], a[lt]), i++, lt++ |
| 2 | a[i] > pivot | swap(a[i], a[gt]), gt-- |
| 3 | a[i] == pivot | i++ |
| 4 | 当 i > gt 时停止 |
然后递归排序 [low..lt-1] 和 [gt+1..high]。
精简代码(简易版本)
C
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
void quicksort_3way(int a[], int low, int high) {
if (low >= high) return;
int pivot = a[low];
int lt = low, i = low, gt = high;
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++;
}
}
quicksort_3way(a, low, lt - 1);
quicksort_3way(a, gt + 1, high);
}
int main(void) {
int a[] = {3, 2, 3, 1, 3};
int n = sizeof(a) / sizeof(a[0]);
quicksort_3way(a, 0, n - 1);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def quicksort_3way(a, low, high):
if low >= high:
return
pivot = a[low]
lt, i, gt = low, low, high
while i <= gt:
if a[i] < pivot:
a[lt], a[i] = a[i], a[lt]
lt += 1
i += 1
elif a[i] > pivot:
a[i], a[gt] = a[gt], a[i]
gt -= 1
else:
i += 1
quicksort_3way(a, low, lt - 1)
quicksort_3way(a, gt + 1, high)
arr = [3, 2, 3, 1, 3]
quicksort_3way(arr, 0, len(arr) - 1)
print(arr)为什么它很重要
- 对于有重复元素的数组效率高
- 减少了不必要的递归和比较
- 用于字符串排序和键密集型数据
- 将“分区”思想推广到多路划分
一个温和的证明(为什么它有效)
标准快速排序总是将数组划分为两个区域,即使所有元素都等于基准,也会导致在相同元素上出现 O(n²) 的情况。
三路快速排序将数组划分为三个区域:
< 基准(左侧)= 基准(中间)> 基准(右侧)
中间区域被跳过递归,从而显著减少了工作量。
如果所有元素都相等 → 只需一趟遍历 O(n)。
预期复杂度: $$ T(n) = O(n \log n) $$ 最坏情况(无重复元素):与快速排序相同。
| 情况 | 重复元素 | 复杂度 |
|---|---|---|
| 全部相等 | O(n) | 仅需一趟遍历 |
| 很多重复 | O(n log n) | 高效 |
| 没有重复 | O(n log n) | 正常行为 |
亲自尝试
- 逐步排序 [3, 2, 3, 1, 3]。
- 在每次迭代后打印区域指针 (
lt,i,gt)。 - 与普通快速排序比较递归深度。
- 测试输入 [1, 1, 1, 1]。
- 测试输入 [5, 4, 3, 2, 1]。
- 排序 ["apple", "apple", "banana", "apple"]。
- 在纸上可视化分区过程。
- 修改代码以统计交换和比较次数。
- 实现降序排序。
- 应用于带有重复项的随机整数(例如 [1,2,2,2,3,3,1])。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3, 2, 3, 1, 3] | [1, 2, 3, 3, 3] | 有重复元素 |
| [5, 4, 3, 2, 1] | [1, 2, 3, 4, 5] | 无重复元素 |
| [2, 2, 2, 2] | [2, 2, 2, 2] | 全部相等 (O(n)) |
| [1, 3, 1, 3, 1] | [1, 1, 1, 3, 3] | 聚集的重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) (全部相等) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(log n) 递归 |
| 稳定 | 否 |
| 自适应 | 是 (高效处理重复元素) |
三路快速排序展示了如何通过一个小小的改变——三路分区,就能将快速排序转变为一个处理重复密集型数据集的强大工具,将优雅与效率融为一体。
119 外部归并排序
外部归并排序是一种专为无法完全放入主内存(RAM)的超大数据集设计的排序算法。它的工作原理是:在内存中对数据块进行排序,将其写入磁盘,然后合并这些已排序的数据块。这使其成为数据库、文件系统和大数据处理中的关键工具。
我们要解决什么问题?
当数据量超过 RAM 容量时,像快速排序或堆排序这样的内存排序算法会失效,因为它们需要对所有元素进行随机访问。 外部归并排序通过分块处理数据来解决这个问题:
- 在内存中对可管理的块进行排序
- 将已排序的块("归并段")写入磁盘
- 使用流式 I/O 顺序合并归并段
这最大限度地减少了磁盘读写,而磁盘 I/O 是大规模排序的主要瓶颈。
它非常适合以下场景:
- 大文件(GB 到 TB 级别)
- 数据库查询引擎
- 批处理流水线
示例
假设你有 1 GB 的数据,但只有 100 MB 的 RAM。
| 步骤 | 操作 | 描述 |
|---|---|---|
| 1 | 分割 | 将文件分成 10 个 100 MB 的块 |
| 2 | 排序 | 将每个块加载到内存中,排序,然后写入磁盘 |
| 3 | 合并 | 使用 k 路归并(例如 10 路)来合并已排序的归并段 |
| 4 | 输出 | 最终排序好的文件被顺序写入 |
它是如何工作的(通俗解释)?
可以把它想象成整理一本巨书的书页:
- 一次取几页(能放进内存)
- 将它们排序并按顺序放成一摞一摞
- 按顺序合并这些摞,直到整本书排序完毕
这是一个多趟算法:
- 第 1 趟:创建已排序的归并段
- 第 2+ 趟:在多趟中合并归并段,直到只剩下一个
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将大文件分割成能放入内存的块 |
| 2 | 加载一个块,使用内存排序算法对其进行排序 |
| 3 | 将每个已排序的块(归并段)写入磁盘 |
| 4 | 使用 k 路归并合并所有归并段 |
| 5 | 重复合并,直到只剩下一个已排序的文件 |
微型代码(简化模拟)
Python(模拟外部排序)
import heapq
import tempfile
def sort_chunk(chunk):
chunk.sort()
temp = tempfile.TemporaryFile(mode="w+t")
temp.writelines(f"{x}\n" for x in chunk)
temp.seek(0)
return temp
def merge_files(files, output_file):
iters = [map(int, f) for f in files]
with open(output_file, "w") as out:
for num in heapq.merge(*iters):
out.write(f"{num}\n")
def external_merge_sort(input_data, chunk_size=5):
chunks = []
for i in range(0, len(input_data), chunk_size):
chunk = input_data[i:i + chunk_size]
chunks.append(sort_chunk(chunk))
merge_files(chunks, "sorted_output.txt")
data = [42, 17, 93, 8, 23, 4, 16, 99, 55, 12, 71, 3]
external_merge_sort(data, chunk_size=4)这个例子在 Python 中模拟了外部排序,将输入分割成块,对每个块排序,并使用 heapq.merge 进行合并。
为什么它很重要
- 处理超出内存限制的海量数据集
- 顺序磁盘 I/O(快速且可预测)
- 数据库排序-归并连接的基础
- 与分布式系统(MapReduce, Spark)配合良好
一个温和的证明(为什么它有效)
每一趟都需要 O(n) 的工作量来读取和写入整个数据集。 如果 r 是归并段的数量,k 是归并的扇入(一次合并的归并段数量):
$$ \text{趟数} = \lceil \log_k r \rceil $$
总成本 ≈ $$ O(n \log_k r) $$ 主要由 I/O 操作而非比较操作决定。
对于 r = n/M(内存大小为 M 的块),通过选择 k ≈ M 可以优化性能。
| 阶段 | 工作量 | 成本 |
|---|---|---|
| 创建归并段 | O(n log M) | 排序块 |
| 合并归并段 | O(n log_k r) | 合并趟 |
自己动手试试
- 将 [42, 17, 93, 8, 23, 4, 16, 99, 55, 12, 71, 3] 分割成 4 个元素的块。
- 分别对每个块进行排序。
- 模拟合并已排序的归并段。
- 尝试 2 路归并与 4 路归并,计算趟数。
- 可视化归并树(归并段合并过程)。
- 用随机大数组(模拟文件)进行测试。
- 修改块大小并观察性能变化。
- 与内存排序比较 I/O 次数。
- 使用
heapq.merge来合并已排序的流。 - 扩展到合并磁盘上的文件(不仅仅是列表)。
测试用例
| 输入 | 内存限制 | 输出 | 备注 |
|---|---|---|---|
| [9, 4, 7, 2, 5, 1, 8, 3, 6] | 3 个元素 | [1,2,3,4,5,6,7,8,9] | 3 路归并 |
| 1 GB 整数 | 100 MB | 已排序的文件 | 10 个已排序的归并段 |
| [1,1,1,1,1] | 小 | [1,1,1,1,1] | 处理重复项 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(n log_k (n/M)) |
| 空间 | O(M) (内存缓冲区) |
| I/O 趟数 | O(log_k (n/M)) |
| 稳定 | 是 |
| 自适应 | 是 (如果数据部分有序,归并段更少) |
外部归并排序是大规模排序背后默默无闻的英雄,当内存耗尽时,它凭借基于磁盘的精确性介入,以冷静高效的姿态维持着数 TB 数据的有序。
120 并行归并排序
并行归并排序采用了熟悉的归并排序分治结构,并将工作分摊到多个线程或处理器上,从而在多核 CPU 或分布式系统上实现更快的排序。它完美地展示了如何在不改变算法逻辑的情况下,通过并行化来增强经典算法。
我们要解决什么问题?
传统的归并排序是顺序执行的,因此即使其复杂度为 O(n log n),它也仅使用一个 CPU 核心。在现代多核硬件上,这是一种浪费。
并行归并排序通过以下方式解决这个问题:
- 并行排序子数组
- 并发合并结果
- 充分利用 CPU 或集群的潜力
它对于以下场景至关重要:
- 高性能计算
- 大规模排序
- 实时分析流水线
示例
使用 2 个线程排序 [5, 3, 4, 1, 2]:
| 步骤 | 操作 | 线程分配 | 结果 |
|---|---|---|---|
| 1 | 将数组分割成两半 | 2 个线程 | [5, 3, 4], [1, 2] |
| 2 | 并发排序每一半 | T1: 排序 [5,3,4], T2: 排序 [1,2] | [3,4,5], [1,2] |
| 3 | 合并结果 | 1 个线程 | [1,2,3,4,5] |
并行化大致能将总时间减少到原来的 1 / 线程数(考虑开销)。
它是如何工作的(通俗解释)?
它仍然是分而治之,只是多了团队协作:
- 将数组分成两半。
- 并行排序每一半。
- 合并两个已排序的半部分。
- 当子数组很小时停止分割(然后顺序排序)。
每个递归层级都可以启动新线程,直到达到某个阈值或最大深度。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将数组分成两半 |
| 2 | 并发排序两个半部分 |
| 3 | 等待两者都完成 |
| 4 | 顺序合并结果 |
| 5 | 对子部分递归重复此过程 |
这种模式非常适合线程池、任务调度器或 fork-join 框架。
微型代码(简易版本)
C (POSIX 线程示例)
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
typedef struct {
int *arr;
int left;
int right;
} Args;
void merge(int arr[], int l, int m, int r) {
int n1 = m - l + 1, n2 = r - m;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = arr[l + i];
for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j];
int i = 0, j = 0, k = l;
while (i < n1 && j < n2) {
arr[k++] = (L[i] <= R[j]) ? L[i++] : R[j++];
}
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
void *parallel_merge_sort(void *arg) {
Args *args = (Args *)arg;
int l = args->left, r = args->right;
int *arr = args->arr;
if (l < r) {
int m = l + (r - l) / 2;
Args leftArgs = {arr, l, m};
Args rightArgs = {arr, m + 1, r};
pthread_t leftThread, rightThread;
pthread_create(&leftThread, NULL, parallel_merge_sort, &leftArgs);
pthread_create(&rightThread, NULL, parallel_merge_sort, &rightArgs);
pthread_join(leftThread, NULL);
pthread_join(rightThread, NULL);
merge(arr, l, m, r);
}
return NULL;
}
int main(void) {
int arr[] = {5, 3, 4, 1, 2};
int n = sizeof(arr) / sizeof(arr[0]);
Args args = {arr, 0, n - 1};
parallel_merge_sort(&args);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}(注意:这个简单版本可能会创建过多线程;实际实现会限制线程深度。)
Python (使用 multiprocessing)
from multiprocessing import Pool
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
def parallel_merge_sort(arr):
if len(arr) <= 1:
return arr
if len(arr) < 1000: # 阈值
return sorted(arr)
mid = len(arr) // 2
with Pool(2) as p:
left, right = p.map(parallel_merge_sort, [arr[:mid], arr[mid:]])
return merge(left, right)
arr = [5, 3, 4, 1, 2]
print(parallel_merge_sort(arr))为什么它很重要
- 利用多核架构
- 显著减少实际耗时
- 保持 O(n log n) 的工作量
- 是并行分治法的绝佳展示
应用于:
- HPC(高性能计算)
- 现代标准库(
std::execution::par) - 大数据框架(Spark, Hadoop)
一个温和的证明(为什么它有效)
每个递归调用排序 n/2 个元素,但现在并行进行。令 P = 处理器数量。
工作量(总操作数): $$ T_{work}(n) = O(n \log n) $$ 跨度(关键路径时间): $$ T_{span}(n) = O(\log^2 n) $$
总时间 ≈ $$ O\left(\frac{n \log n}{P} + \log^2 n\right) $$
加速比 ≈ P 倍,受同步和合并开销限制。
| 阶段 | 可并行化 | 工作量 |
|---|---|---|
| 排序子数组 | 是 | O(n log n / P) |
| 合并 | 部分 | O(n log P) |
亲自尝试
- 使用 1、2、4、8 个线程运行,比较速度。
- 在每个递归调用处打印线程 ID。
- 为小子数组实现阈值。
- 使用并行合并进行合并。
- 测量排序期间的 CPU 利用率。
- 用大型随机列表(10⁶ 个元素)测试。
- 与顺序归并排序进行比较。
- 使用计时工具进行性能分析。
- 尝试 C 语言的 OpenMP 版本。
- 扩展到分布式节点(MPI)。
测试用例
| 输入 | 线程数 | 输出 | 备注 |
|---|---|---|---|
| [3,2,1] | 2 | [1,2,3] | 简单情况 |
| [5,3,4,1,2] | 2 | [1,2,3,4,5] | 工作量均衡 |
| 1e6 个随机整数 | 8 | 已排序 | 并行化提升 |
| [1,1,1,1] | 4 | [1,1,1,1] | 稳定性行为 |
复杂度
| 方面 | 值 |
|---|---|
| 工作量 | O(n log n) |
| 跨度 | O(log² n) |
| 并行时间 | O(n log n / P + log² n) |
| 空间 | O(n) |
| 稳定性 | 是 |
| 自适应性 | 否 |
并行归并排序是为多核时代重生的归并排序,同样的优雅,现在有了团队协作。它是经典算法学习如何随硬件扩展的典范。
第 13 章 计数和分布排序
121 计数排序
计数排序是一种非比较排序算法,它通过统计每个值出现的次数来对整数(或映射到整数键的项)进行排序。它不比较元素,而是直接使用它们的值作为计数数组的索引。它速度快(O(n + k))、稳定,并且在输入范围有限且较小时非常完美。
我们要解决什么问题?
当键是已知范围内的整数时,基于比较的排序(O(n log n))就显得大材小用了。 计数排序利用有限的范围,在线性时间内完成排序,无需任何比较。
完美适用于:
- 排序成绩(0–100)
- 排序数字或 ASCII 码(0–255)
- 基数排序或桶排序的预处理步骤
- 键的范围远小于 n² 的场景
示例
排序数组 [4, 2, 2, 8, 3, 3, 1]
| 步骤 | 描述 | 结果 |
|---|---|---|
| 1 | 找到最大值 = 8 | 范围 = 0–8 |
| 2 | 初始化 count[9] = [0,0,0,0,0,0,0,0,0] | |
| 3 | 统计每个数字 | count = [0,1,2,2,1,0,0,0,1] |
| 4 | 前缀和(位置) | count = [0,1,3,5,6,6,6,6,7] |
| 5 | 根据计数放置元素 | [1,2,2,3,3,4,8] |
计数数组记录了每个键的位置边界。
它是如何工作的(通俗解释)?
计数排序不比较元素。它统计每个值出现的次数,然后利用这些计数来重建有序列表。
可以把它想象成填充有标签的箱子:
- 每个数字对应一个箱子
- 将每个元素放入其对应的箱子
- 然后按顺序遍历箱子并倒空它们
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到最小值和最大值(确定范围 k) |
| 2 | 创建大小为 k + 1 的计数数组 |
| 3 | 统计每个值的出现次数 |
| 4 | 将计数转换为前缀和(用于确定位置) |
| 5 | 反向遍历输入(为了稳定性),放置元素 |
| 6 | 将排序后的输出复制回去 |
精简代码(简易版本)
C
#include <stdio.h>
#include <string.h>
void counting_sort(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++)
if (arr[i] > max) max = arr[i];
int count[max + 1];
memset(count, 0, sizeof(count));
for (int i = 0; i < n; i++)
count[arr[i]]++;
for (int i = 1; i <= max; i++)
count[i] += count[i - 1];
int output[n];
for (int i = n - 1; i >= 0; i--) {
output[count[arr[i]] - 1] = arr[i];
count[arr[i]]--;
}
for (int i = 0; i < n; i++)
arr[i] = output[i];
}
int main(void) {
int arr[] = {4, 2, 2, 8, 3, 3, 1};
int n = sizeof(arr) / sizeof(arr[0]);
counting_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}Python
def counting_sort(arr):
max_val = max(arr)
count = [0] * (max_val + 1)
for num in arr:
count[num] += 1
for i in range(1, len(count)):
count[i] += count[i - 1]
output = [0] * len(arr)
for num in reversed(arr):
count[num] -= 1
output[count[num]] = num
return output
arr = [4, 2, 2, 8, 3, 3, 1]
print(counting_sort(arr))为什么它重要
- 当范围较小时是线性时间(
O(n + k)) - 稳定,保持输入顺序
- 是基数排序的基础
- 非常适合整数、数字或桶排序
一个温和的证明(为什么它有效)
计数排序通过基于索引的放置来取代比较。
如果 n 是元素数量,k 是键的范围:
- 统计出现次数:O(n)
- 前缀和:O(k)
- 放置元素:O(n)
总计 = O(n + k)
之所以稳定,是因为我们在放置时反向遍历输入。
| 阶段 | 工作量 | 复杂度 |
|---|---|---|
| 统计元素 | O(n) | 扫描一次 |
| 前缀和 | O(k) | 范围遍历 |
| 放置元素 | O(n) | 稳定写入 |
自己动手试试
- 逐步排序
[4, 2, 2, 8, 3, 3, 1]。 - 展示统计后的计数数组。
- 将计数转换为前缀和。
- 将元素放入输出数组(反向扫描)。
- 比较稳定版本与非稳定版本。
- 将输入改为
[9, 9, 1, 2]。 - 尝试排序
[5, 3, 5, 1, 0]。 - 处理最小值大于 0 的输入(偏移计数)。
- 测量与冒泡排序的运行时间对比。
- 将其用作基数排序的子程序。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [4,2,2,8,3,3,1] | [1,2,2,3,3,4,8] | 示例 |
| [1,4,1,2,7,5,2] | [1,1,2,2,4,5,7] | 稳定 |
| [9,9,9,9] | [9,9,9,9] | 重复元素 |
| [0,1,2,3] | [0,1,2,3] | 已排序 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(n + k) |
| 空间复杂度 | O(n + k) |
| 稳定性 | 是 |
| 自适应性 | 否 |
| 对范围敏感 | 是 |
计数排序就像按箱子排序,无需比较,没有压力,只有清晰的计数和线性时间。它是基数排序以及性能关键型流水线中数据分桶背后的强大动力。
122 稳定计数排序
稳定计数排序在基本计数排序的基础上进行了改进,确保相等的元素保持其原始顺序。这一特性称为稳定性,在对多键数据进行排序时至关重要,例如先按年龄再按姓名对人进行排序。稳定版本也是基数排序的基础构建块,其中每一位数字的排序都依赖于稳定性。
我们要解决什么问题?
基本计数排序可能会破坏相等元素之间的顺序,因为它以任意顺序放置它们。 当对顺序很重要的记录或元组(例如,按次要键)进行排序时,我们需要稳定性。如果 a 和 b 具有相等的键,它们在输出中的顺序必须与输入中的顺序一致。
稳定计数排序确保:
如果
arr[i]和arr[j]具有相同的键且i < j, 那么在排序后的输出中arr[i]出现在arr[j]之前。
非常适合:
- 基数排序的数字处理
- 多字段记录(例如先按姓名,再按分数排序)
- 数据库和稳定的处理流水线
示例
排序 [4a, 2b, 2a, 8a, 3b, 3a, 1a](字母标记顺序)
| 步骤 | 描述 | 结果 |
|---|---|---|
| 1 | 统计频率 | count = [0,1,2,2,1,0,0,0,1] |
| 2 | 前缀和(位置) | count = [0,1,3,5,6,6,6,6,7] |
| 3 | 从后向前遍历输入 | output = [1a, 2b, 2a, 3b, 3a, 4a, 8a] |
观察 2b(索引 1)如何出现在 2a(索引 2)之前,保持了稳定的顺序。
它是如何工作的(通俗解释)?
这是计数排序的一个变体:我们从输入的末尾开始填充输出,确保最后看到的相等项放在最后。 通过反向遍历,较早的元素被放置在较后的位置,从而保持了它们的原始顺序。
这是稳定排序背后的核心思想。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 确定键的范围 (0..max) |
| 2 | 统计每个键的频率 |
| 3 | 计算前缀和(以确定位置) |
| 4 | 从右向左遍历输入 |
| 5 | 使用计数作为索引将元素放入输出 |
| 6 | 放置后递减 count[key] |
微型代码(简易版本)
C
#include <stdio.h>
#include <string.h>
typedef struct {
int key;
char tag; // 用于可视化稳定性
} Item;
void stable_counting_sort(Item arr[], int n) {
int max = arr[0].key;
for (int i = 1; i < n; i++)
if (arr[i].key > max) max = arr[i].key;
int count[max + 1];
memset(count, 0, sizeof(count));
// 统计出现次数
for (int i = 0; i < n; i++)
count[arr[i].key]++;
// 前缀和
for (int i = 1; i <= max; i++)
count[i] += count[i - 1];
Item output[n];
// 为保持稳定性,反向遍历输入
for (int i = n - 1; i >= 0; i--) {
int k = arr[i].key;
output[count[k] - 1] = arr[i];
count[k]--;
}
for (int i = 0; i < n; i++)
arr[i] = output[i];
}
int main(void) {
Item arr[] = {{4,'a'},{2,'b'},{2,'a'},{8,'a'},{3,'b'},{3,'a'},{1,'a'}};
int n = sizeof(arr)/sizeof(arr[0]);
stable_counting_sort(arr, n);
for (int i = 0; i < n; i++) printf("(%d,%c) ", arr[i].key, arr[i].tag);
printf("\n");
}Python
def stable_counting_sort(arr):
max_val = max(arr)
count = [0] * (max_val + 1)
for num in arr:
count[num] += 1
for i in range(1, len(count)):
count[i] += count[i - 1]
output = [0] * len(arr)
for num in reversed(arr):
count[num] -= 1
output[count[num]] = num
return output
arr = [4, 2, 2, 8, 3, 3, 1]
print(stable_counting_sort(arr))为什么它很重要
- 稳定排序对于多键操作至关重要
- 是基数排序正确性所必需的
- 保证了对重复项行为的一致性
- 用于数据库、语言排序库、流水线
一个温和的证明(为什么它有效)
每个键通过前缀和分配了一个位置范围。 从右向左遍历输入确保较早的项占据较小的索引,从而保持顺序。
如果在输入中 a 出现在 b 之前且 key(a) = key(b), 那么 count[key(a)] 会将 a 放置在 b 之前,保持稳定。
| 阶段 | 工作量 | 复杂度 |
|---|---|---|
| 计数 | O(n) | 遍历一次 |
| 前缀和 | O(k) | 范围遍历 |
| 放置 | O(n) | 反向遍历 |
总计 = O(n + k),与基本计数排序相同,但是稳定的。
亲自尝试
- 排序
[(4,'a'), (2,'b'), (2,'a'), (3,'a')]。 - 显示计数数组和前缀和数组。
- 从末尾开始遍历输入,跟踪输出。
- 与不稳定版本进行比较。
- 尝试排序
[5,3,5,1,0]。 - 当出现相等键时,可视化稳定性。
- 修改以处理偏移键(负值)。
- 与基数排序(LSD)结合。
- 分析运行时与普通计数排序的比较。
- 通过添加标签(字母)来检查稳定性。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [4,2,2,8,3,3,1] | [1,2,2,3,3,4,8] | 与基本排序相同 |
| [(2,'a'),(2,'b')] | [(2,'a'),(2,'b')] | 保持稳定 |
| [1,1,1] | [1,1,1] | 幂等性 |
| [0,1,2,3] | [0,1,2,3] | 已排序 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(n + k) |
| 空间 | O(n + k) |
| 稳定 | 是 |
| 自适应 | 否 |
| 范围敏感 | 是 |
稳定计数排序是具有记忆的计数排序,它不仅排序速度快,还能记住你的顺序,这使其对于像基数排序这样的多遍算法来说不可或缺。
123 基数排序(LSD)
基数排序(Least Significant Digit first,最低有效位优先)是一种非比较、稳定的排序算法,它从最低有效位(LSD)开始,逐位处理整数(或字符串)。通过对每一位重复应用稳定排序(如计数排序),当位数较少时,它可以在线性时间内对数字进行排序。
我们要解决什么问题?
当对整数或固定长度的键(如日期、ID 或数字字符串)进行排序时,传统的基于比较的排序会花费不必要的精力。基数排序(LSD)通过利用数字顺序和稳定性来避免比较,从而实现 O(d × (n + k)) 的性能。
非常适合:
- 对数字、日期、邮政编码或字符串进行排序
- 数字位数有限的数集
- 需要确定性性能的应用场景
示例
对 [170, 45, 75, 90, 802, 24, 2, 66] 进行排序
| 轮次 | 数位 | 输入 | 输出(稳定排序) |
|---|---|---|---|
| 1 | 个位 | [170,45,75,90,802,24,2,66] | [170,90,802,2,24,45,75,66] |
| 2 | 十位 | [170,90,802,2,24,45,75,66] | [802,2,24,45,66,170,75,90] |
| 3 | 百位 | [802,2,24,45,66,170,75,90] | [2,24,45,66,75,90,170,802] |
最终排序输出:[2, 24, 45, 66, 75, 90, 170, 802]
每一轮都对当前数位使用稳定的计数排序。
它是如何工作的(通俗解释)?
想象一下按数位位置排序:
- 按个位(单位)分组
- 按十位分组
- 按百位分组,依此类推。
每一轮都根据该数位上的数字重新排序元素,同时保持之前数位的顺序不变(得益于稳定性)。
这就像先按姓氏排序,然后按名字排序,一次一个字段,每一轮都是稳定的。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到最大数以确定位数 d |
| 2 | 对于每个数位(1, 10, 100, …): |
| 3 | 基于该数位使用稳定的计数排序 |
| 4 | 最后一轮之后,数组完全排序 |
精简代码(简易版本)
C
#include <stdio.h>
int get_max(int a[], int n) {
int max = a[0];
for (int i = 1; i < n; i++)
if (a[i] > max) max = a[i];
return max;
}
void counting_sort_digit(int a[], int n, int exp) {
int output[n];
int count[10] = {0};
for (int i = 0; i < n; i++)
count[(a[i] / exp) % 10]++;
for (int i = 1; i < 10; i++)
count[i] += count[i - 1];
for (int i = n - 1; i >= 0; i--) {
int digit = (a[i] / exp) % 10;
output[count[digit] - 1] = a[i];
count[digit]--;
}
for (int i = 0; i < n; i++)
a[i] = output[i];
}
void radix_sort(int a[], int n) {
int max = get_max(a, n);
for (int exp = 1; max / exp > 0; exp *= 10)
counting_sort_digit(a, n, exp);
}
int main(void) {
int a[] = {170, 45, 75, 90, 802, 24, 2, 66};
int n = sizeof(a) / sizeof(a[0]);
radix_sort(a, n);
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n");
}Python
def counting_sort_digit(arr, exp):
n = len(arr)
output = [0] * n
count = [0] * 10
for num in arr:
index = (num // exp) % 10
count[index] += 1
for i in range(1, 10):
count[i] += count[i - 1]
for num in reversed(arr):
index = (num // exp) % 10
count[index] -= 1
output[count[index]] = num
for i in range(n):
arr[i] = output[i]
def radix_sort(arr):
max_val = max(arr)
exp = 1
while max_val // exp > 0:
counting_sort_digit(arr, exp)
exp *= 10
arr = [170, 45, 75, 90, 802, 24, 2, 66]
radix_sort(arr)
print(arr)为什么它重要
- 对于固定位数,具有线性时间复杂度 (O(d × (n + k)))
- 稳定,对于相等的键保留顺序
- 适用于大型数值数据集
- 是基于键的高效排序(字符串、日期)的基础
一个温和的证明(为什么它有效)
在每个数位位置:
- 稳定的计数排序根据该数位重新排序
- 较早的数位保持有序(稳定性)
- 在所有数位处理完毕后,数组完全有序
如果每个数位的范围是 k,总位数是 d:
$$ T(n) = O(d \times (n + k)) $$
| 阶段 | 工作量 | 复杂度 |
|---|---|---|
| 每个数位 | O(n + k) | 计数排序 |
| 所有数位 | d × O(n + k) | 总计 |
如果 d 和 k 是常数 → 总体 O(n)。
亲自尝试
- 对
[170, 45, 75, 90, 802, 24, 2, 66]进行排序。 - 追踪每一轮(个位、十位、百位)。
- 显示每个数位的计数表。
- 比较稳定排序与非稳定排序。
- 添加零:
[07, 70, 700]。 - 尝试
[3, 1, 2, 10, 11, 21]。 - 统计数字比较次数。
- 修改以处理负数(偏移量)。
- 将基数改为 16(十六进制)。
- 在大输入上与归并排序的性能进行比较。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [170,45,75,90,802,24,2,66] | [2,24,45,66,75,90,170,802] | 经典示例 |
| [9,8,7,6,5] | [5,6,7,8,9] | 逆序 |
| [10,1,100,1000] | [1,10,100,1000] | 不同长度 |
| [22,22,11,11] | [11,11,22,22] | 稳定性 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(d × (n + k)) |
| 空间复杂度 | O(n + k) |
| 稳定性 | 是 |
| 自适应性 | 否 |
| 对范围敏感 | 是 |
基数排序(LSD)就像是排序的流水线,每一轮都建立在前一轮的基础上,通过简单的稳定步骤产生完美有序的输出。
124 基数排序(MSD)
基数排序(最高位优先)是基数排序的一种递归变体,它从最高位(MSD)开始排序并向下处理。与 LSD 基数排序(迭代式且对所有数位保持稳定)不同,MSD 关注基于前缀的分组,并递归地对子组进行排序。这使得它非常适合处理变长键,例如字符串、IP 地址或长整数。
我们要解决什么问题?
LSD 基数排序最适合固定长度的键,即每个元素具有相同数量的数位。 但是当键的长度不同时(例如,字符串 "a"、"ab"、"abc"),我们需要遵循前缀顺序,"a" 应该排在 "ab" 之前。
MSD 基数排序通过按前缀数位分组,然后递归地对每个组进行排序来处理这种情况。
非常适合:
- 字符串、单词或变长键
- 分层数据(对前缀敏感)
- 字典序(字典顺序)
示例
排序:["b", "ba", "abc", "ab", "ac"]
| 步骤 | 数位 | 分组 |
|---|---|---|
| 1 | 第 1 个字符 | a → ["abc", "ab", "ac"], b → ["b", "ba"] |
| 2 | 分组 "a" | 第 2 个字符 → b: ["abc", "ab"], c: ["ac"] |
| 3 | 分组 "ab" | 第 3 个字符 → c: ["abc"], 结束: ["ab"] |
| 4 | 最终合并 | ["ab", "abc", "ac", "b", "ba"] |
即使长度不同,也保持了字典序。
它是如何工作的(通俗解释)?
MSD 基数排序在概念上通过前缀树(trie)来组织数据:
- 根据最高有效位(或字符)对元素进行分区
- 在每个组内递归处理下一个数位
- 按数位值的顺序合并组
如果说 LSD 像是从后向前按数位进行桶排序,那么 MSD 就像是自上而下的树状排序。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到最高数位(或第一个字符) |
| 2 | 根据该数位将数组分区成组 |
| 3 | 递归地按下一个数位对每个组进行排序 |
| 4 | 按顺序连接各组 |
对于字符串,如果一个字符串提前结束,则被视为较小。
微型代码(简易版本)
Python(字符串示例)
def msd_radix_sort(arr, pos=0):
if len(arr) <= 1:
return arr
# 为 ASCII 范围 (0-255) 创建桶,+1 用于字符串结束标记
buckets = [[] for _ in range(257)]
for word in arr:
index = ord(word[pos]) + 1 if pos < len(word) else 0
buckets[index].append(word)
result = []
for bucket in buckets:
if bucket:
# 仅当桶中有多个元素且不是字符串结束标记时才递归
if len(bucket) > 1 and (pos < max(len(w) for w in bucket)):
bucket = msd_radix_sort(bucket, pos + 1)
result.extend(bucket)
return result
arr = ["b", "ba", "abc", "ab", "ac"]
print(msd_radix_sort(arr))输出:
$$'ab', 'abc', 'ac', 'b', 'ba']C(数字示例)
#include <stdio.h>
int get_digit(int num, int exp, int base) {
return (num / exp) % base;
}
void msd_radix_sort_rec(int arr[], int n, int exp, int base, int max) {
if (exp == 0 || n <= 1) return;
int buckets[base][n];
int count[base];
for (int i = 0; i < base; i++) count[i] = 0;
// 分发
for (int i = 0; i < n; i++) {
int d = get_digit(arr[i], exp, base);
buckets[d][count[d]++] = arr[i];
}
// 递归和收集
int idx = 0;
for (int i = 0; i < base; i++) {
if (count[i] > 0) {
msd_radix_sort_rec(buckets[i], count[i], exp / base, base, max);
for (int j = 0; j < count[i]; j++)
arr[idx++] = buckets[i][j];
}
}
}
void msd_radix_sort(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++)
if (arr[i] > max) max = arr[i];
int exp = 1;
while (max / exp >= 10) exp *= 10;
msd_radix_sort_rec(arr, n, exp, 10, max);
}
int main(void) {
int arr[] = {170, 45, 75, 90, 802, 24, 2, 66};
int n = sizeof(arr) / sizeof(arr[0]);
msd_radix_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它重要
- 处理变长键
- 天然适合字典序排序
- 用于字符串排序、基于 trie 的系统、后缀数组构造
- 递归分区,对于大型多样化键通常更快
一个温和的证明(为什么它有效)
每次递归调用都按数位前缀对数组进行分区。 由于分区是互不相交的并按数位排序,因此连接它们会产生一个完全有序的序列。
对于 n 个元素,d 个数位,基数 k:
$$ T(n) = O(n + k) \text{ 每层,深度 } \le d $$ $$ \Rightarrow O(d \times (n + k)) $$
通过有序分组保持稳定性。
| 阶段 | 工作量 | 描述 |
|---|---|---|
| 分区 | O(n) | 将项放入数位桶中 |
| 递归 | O(d) | 每层处理子组 |
| 总计 | O(d(n + k)) | 与数位数量呈线性关系 |
亲自尝试
- 排序
["b", "ba", "abc", "ab", "ac"]。 - 按字符绘制递归树。
- 与字典序比较顺序。
- 测试
["dog", "cat", "apple", "apricot"]。 - 排序整数
[170,45,75,90,802,24,2,66]。 - 更改基数(二进制、十六进制)。
- 与 LSD 基数排序比较。
- 添加重复项并测试稳定性。
- 可视化分组桶。
- 使用类似 trie 的数据结构实现。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| ["b","ba","abc","ab","ac"] | ["ab","abc","ac","b","ba"] | 变长 |
| [170,45,75,90,802,24,2,66] | [2,24,45,66,75,90,170,802] | 数字 |
| ["a","aa","aaa"] | ["a","aa","aaa"] | 前缀顺序 |
| ["z","y","x"] | ["x","y","z"] | 反向输入 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(d × (n + k)) |
| 空间 | O(n + k) |
| 稳定 | 是 |
| 自适应 | 否 |
| 适用于 | 变长键 |
基数排序(MSD)是通过递归进行字典序排序,它自上而下地构建顺序,将前缀视为领导者,细节视为跟随者,很像字典排列单词的方式。
125 桶排序
桶排序是一种基于分布的排序算法,它将输入划分为若干个桶(箱子),分别对每个桶进行排序(通常使用插入排序),然后将它们合并。当输入数据均匀分布时,桶排序可以达到线性时间性能(O(n))。
我们要解决什么问题?
基于比较的排序在一般情况下需要 O(n log n) 的时间。但如果我们知道数据值在一个范围内均匀分布,我们可以利用这种结构,通过将相似的值分组在一起来实现更快的排序。
桶排序在以下情况下效果最佳:
- 输入是 [0, 1) 区间或任何已知范围内的实数
- 数据均匀分布
- 桶是平衡的,每个桶包含少量元素
应用场景:
- 概率分布
- 基于直方图的排序
- 浮点数排序
示例
排序 [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 为范围 [0, 1) 创建 10 个桶 | [[] ... []] |
| 2 | 根据 int(n * value) 分配元素 | [[0.12,0.17,0.21,0.23,0.26],[0.39],[0.68,0.72,0.78],[0.94]] |
| 3 | 对每个桶排序(插入排序) | 每个桶单独排序 |
| 4 | 合并桶 | [0.12, 0.17, 0.21, 0.23, 0.26, 0.39, 0.68, 0.72, 0.78, 0.94] |
它是如何工作的(通俗解释)?
想象一下对考试成绩进行排序:
- 你将成绩分组到各个分数段(0–10, 10–20, 20–30, …)
- 分别对每个分数段排序
- 按顺序将分数段合并回来
桶排序利用了范围分组、每个桶内的局部顺序以及桶序列带来的全局顺序。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 为每个范围区间创建空桶 |
| 2 | 将元素分配到桶中 |
| 3 | 分别对每个桶排序 |
| 4 | 按顺序合并桶 |
如果桶被均匀填充,每个小排序都很快,几乎是常数时间。
精简代码(简易版本)
C
#include <stdio.h>
#include <stdlib.h>
void insertion_sort(float arr[], int n) {
for (int i = 1; i < n; i++) {
float key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
void bucket_sort(float arr[], int n) {
float buckets[n][n];
int count[n];
for (int i = 0; i < n; i++) count[i] = 0;
// 分配到桶中
for (int i = 0; i < n; i++) {
int idx = n * arr[i]; // 根据范围索引
buckets[idx][count[idx]++] = arr[i];
}
// 对每个桶排序
for (int i = 0; i < n; i++)
if (count[i] > 0)
insertion_sort(buckets[i], count[i]);
// 合并
int k = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < count[i]; j++)
arr[k++] = buckets[i][j];
}
int main(void) {
float arr[] = {0.78,0.17,0.39,0.26,0.72,0.94,0.21,0.12,0.23,0.68};
int n = sizeof(arr) / sizeof(arr[0]);
bucket_sort(arr, n);
for (int i = 0; i < n; i++) printf("%.2f ", arr[i]);
printf("\n");
}Python
def bucket_sort(arr):
n = len(arr)
buckets = [[] for _ in range(n)]
for num in arr:
idx = int(n * num)
buckets[idx].append(num)
for bucket in buckets:
bucket.sort()
result = []
for bucket in buckets:
result.extend(bucket)
return result
arr = [0.78,0.17,0.39,0.26,0.72,0.94,0.21,0.12,0.23,0.68]
print(bucket_sort(arr))为什么它重要
- 对于均匀分布的数据具有线性时间复杂度
- 非常适合浮点数排序
- 展示了基于分布的排序思想
- 是直方图排序、闪存排序和扩散排序的基础
一个温和的证明(为什么它有效)
如果输入元素独立且均匀分布,每个桶的预期元素数 = O(1)。 对每个小桶排序花费常数时间 → 总时间为线性。
总复杂度:
$$ T(n) = O(n + \sum_{i=1}^{n} T_i) $$ 如果每个 T_i = O(1), $$ T(n) = O(n) $$
| 阶段 | 工作量 | 复杂度 |
|---|---|---|
| 分配 | O(n) | 一次遍历 |
| 局部排序 | O(n) 总计 | 预期 |
| 合并 | O(n) | 合并 |
亲自尝试
- 排序
[0.78,0.17,0.39,0.26,0.72,0.94,0.21,0.12,0.23,0.68]。 - 将桶可视化为箱子。
- 将桶的数量改为 5、20,观察效果。
- 尝试非均匀数据
[0.99,0.99,0.98]。 - 用计数排序替换插入排序。
- 用 10⁶ 个浮点数测量性能。
- 测试
[0.1,0.01,0.001],不均匀分布。 - 为任意范围实现桶索引。
- 与快速排序的运行时间进行比较。
- 在排序前绘制分布直方图。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [0.78,0.17,0.39,0.26] | [0.17,0.26,0.39,0.78] | 基本 |
| [0.1,0.01,0.001] | [0.001,0.01,0.1] | 稀疏 |
| [0.9,0.8,0.7,0.6] | [0.6,0.7,0.8,0.9] | 逆序 |
| [0.1,0.1,0.1] | [0.1,0.1,0.1] | 重复 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n) |
| 时间(最坏) | O(n²) (所有元素在一个桶中) |
| 空间 | O(n) |
| 稳定性 | 是(如果桶排序是稳定的) |
| 自适应性 | 是(取决于分布) |
桶排序就像按箱子排序,当你的数据均匀分布时,它快速、简单且高效。它是固定范围内连续值排序的首选方法。
126 鸽巢排序
鸽巢排序是一种简单的分布排序算法,它根据每个元素的关键值将其直接放入对应的“鸽巢”(或桶)中。当元素是已知小范围内的整数时,它非常理想,可以将其视为带有显式放置而非计数的计数排序。
我们要解决什么问题?
当数据值是整数且彼此接近时,我们不需要比较,可以直接将每个值映射到一个槽位。 鸽巢排序特别适用于密集的整数范围,例如:
- 对 0 到 100 的整数进行排序
- 对分数、排名或 ID 进行排序
- 具有大量重复值的小范围数据
它用空间换取速度,实现了 O(n + range) 的性能。
示例
对 [8, 3, 2, 7, 4, 6, 8] 排序
| 步骤 | 描述 | 结果 |
|---|---|---|
| 1 | 找到 min=2, max=8 → range = 7 | holes[0..6] |
| 2 | 创建鸽巢:[[],[],[],[],[],[],[]] | |
| 3 | 将每个数字放入其对应的巢中 | holes = [[2],[3],[4],[6],[7],[8,8]] |
| 4 | 按顺序连接所有鸽巢 | [2,3,4,6,7,8,8] |
每个值都精确地放入其映射到的鸽巢索引中。
它是如何工作的(通俗解释)?
这就像根据 ID 范围将学生分配到考场,每个槽位存放所有匹配的 ID。 你填满槽位(鸽巢),然后按顺序读取它们。
与计数排序只统计出现次数不同,鸽巢排序存储实际的值,直接保留了重复项。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到最小和最大元素 |
| 2 | 计算 range = max - min + 1 |
| 3 | 创建一个大小为 range 的空鸽巢数组 |
| 4 | 对于每个元素,映射到鸽巢 arr[i] - min |
| 5 | 将元素放入该鸽巢(追加) |
| 6 | 按顺序读取鸽巢并展平到输出数组中 |
精简代码(简易版本)
C
#include <stdio.h>
#include <stdlib.h>
void pigeonhole_sort(int arr[], int n) {
int min = arr[0], max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < min) min = arr[i];
if (arr[i] > max) max = arr[i];
}
int range = max - min + 1;
int *holes[range];
int counts[range];
for (int i = 0; i < range; i++) {
holes[i] = malloc(n * sizeof(int));
counts[i] = 0;
}
// 将元素放入鸽巢
for (int i = 0; i < n; i++) {
int index = arr[i] - min;
holes[index][counts[index]++] = arr[i];
}
// 展平回原数组
int index = 0;
for (int i = 0; i < range; i++) {
for (int j = 0; j < counts[i]; j++) {
arr[index++] = holes[i][j];
}
free(holes[i]);
}
}
int main(void) {
int arr[] = {8, 3, 2, 7, 4, 6, 8};
int n = sizeof(arr) / sizeof(arr[0]);
pigeonhole_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}Python
def pigeonhole_sort(arr):
min_val, max_val = min(arr), max(arr)
size = max_val - min_val + 1
holes = [[] for _ in range(size)]
for x in arr:
holes[x - min_val].append(x)
sorted_arr = []
for hole in holes:
sorted_arr.extend(hole)
return sorted_arr
arr = [8, 3, 2, 7, 4, 6, 8]
print(pigeonhole_sort(arr))为什么它重要
- 适用于小整数范围的简单映射
- 如果 range ≈ n,则为线性时间
- 在数字、排名或 ID 排序中很有用
- 通过显式放置提供稳定的分组
一个温和的证明(为什么它有效)
每个键都被唯一地映射到一个鸽巢(通过最小值偏移)。 所有重复项都落入同一个鸽巢,保留了重复次数。 按顺序读取鸽巢会产生排序后的顺序。
如果 n = 元素数量,k = 值的范围:
$$ T(n) = O(n + k) $$
| 阶段 | 工作 | 复杂度 |
|---|---|---|
| 查找最小/最大值 | O(n) | 单次遍历 |
| 分发到鸽巢 | O(n) | 每个元素放置一次 |
| 收集结果 | O(n + k) | 展平所有鸽巢 |
自己动手试试
- 手动排序
[8,3,2,7,4,6,8]。 - 展示分发后的鸽巢内容。
- 添加重复值,确认稳定的分组。
- 尝试
[1,1,1,1],所有元素都在一个鸽巢中。 - 排序负数
[0,-1,-2,1](通过最小值偏移)。 - 增加范围以观察空间成本。
- 与计数排序比较运行时间。
- 用链表替换鸽巢。
- 实现原地版本。
- 扩展到键值对。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [8,3,2,7,4,6,8] | [2,3,4,6,7,8,8] | 示例 |
| [1,1,1,1] | [1,1,1,1] | 所有元素相等 |
| [9,8,7,6] | [6,7,8,9] | 逆序 |
| [0,-1,-2,1] | [-2,-1,0,1] | 负数偏移 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(n + k) |
| 空间复杂度 | O(n + k) |
| 稳定性 | 是 |
| 自适应性 | 否 |
| 对范围敏感 | 是 |
鸽巢排序是排序中最直接的方式,每个值一个槽位,一次放入,一次取出。当你的数据密集且离散时,它快速而简洁。
127 闪电排序(Flash Sort)
闪电排序是一种基于分布的排序算法,它结合了桶排序和插入排序的思想。它分为两个阶段:
- 分类:使用线性映射将元素分配到类别(桶)中
- 置换:利用循环在原地重新排列元素
它在均匀分布的数据上可以达到 O(n) 的平均时间复杂度,但在最坏情况下可能退化到 O(n²)。 由 Karl-Dietrich Neubert(20世纪90年代)发明,以其在处理大型数据集时极快的实际速度而闻名。
我们要解决什么问题?
当数据在数值范围内分布时,我们可以近似地判断每个元素应该去的位置,并将其移动到接近其最终位置,而无需进行完整的比较排序。
闪电排序专为以下场景构建:
- 均匀分布的数值数据
- 大型数组
- 对性能要求苛刻的应用(例如,模拟、物理、图形)
它利用近似索引和原地置换来获得速度优势。
示例
将 [9, 3, 1, 7, 4, 6, 2, 8, 5] 排序到 m = 5 个类别中。
| 步骤 | 描述 | 结果 |
|---|---|---|
| 1 | 找到 min = 1, max = 9 | 范围 = 8 |
| 2 | 将每个值映射到类别 k = (m-1)*(a[i]-min)/(max-min) | 类别索引: [4,1,0,3,1,3,0,4,2] |
| 3 | 计算每个类别的元素数量,计算累积位置 | 类别边界 = [0,2,4,6,8,9] |
| 4 | 将元素循环移动到正确的类别位置 | 近似重排后的顺序 |
| 5 | 在每个类别内应用插入排序 | 排序后的列表 |
最终结果:[1,2,3,4,5,6,7,8,9]
它是如何工作的(通俗解释)?
想象一下,在一次遍历中,将每个元素“闪送”到其近似的目标类别,这就是“闪电”阶段。 然后,在每个类别内使用更简单的排序(如插入排序)进行微调。
这就像先把书大致放到书架上,然后整理每个书架。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到最小值和最大值 |
| 2 | 选择类别数量(通常 ≈ 0.43 * n) |
| 3 | 计算每个元素的类别索引 |
| 4 | 计算每个类别的元素数量并计算前缀和 |
| 5 | 将元素移动到近似的类别位置(闪电阶段) |
| 6 | 在每个类别内使用插入排序完成最终排序 |
微型代码(简易版本)
C
#include <stdio.h>
void insertion_sort(float arr[], int start, int end) {
for (int i = start + 1; i <= end; i++) {
float key = arr[i];
int j = i - 1;
while (j >= start && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
void flash_sort(float arr[], int n) {
if (n <= 1) return;
float min = arr[0], max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < min) min = arr[i];
if (arr[i] > max) max = arr[i];
}
if (max == min) return;
int m = n * 0.43; // 类别数量
int L[m];
for (int i = 0; i < m; i++) L[i] = 0;
// 分类计数
for (int i = 0; i < n; i++) {
int k = (int)((m - 1) * (arr[i] - min) / (max - min));
L[k]++;
}
// 前缀和
for (int i = 1; i < m; i++) L[i] += L[i - 1];
// 闪电阶段
int move = 0, j = 0, k = m - 1;
while (move < n - 1) {
while (j >= L[k]) k = (int)((m - 1) * (arr[j] - min) / (max - min));
float flash = arr[j];
while (j != L[k]) {
k = (int)((m - 1) * (flash - min) / (max - min));
float hold = arr[--L[k]];
arr[L[k]] = flash;
flash = hold;
move++;
}
j++;
}
// 插入排序收尾
insertion_sort(arr, 0, n - 1);
}
int main(void) {
float arr[] = {9,3,1,7,4,6,2,8,5};
int n = sizeof(arr) / sizeof(arr[0]);
flash_sort(arr, n);
for (int i = 0; i < n; i++) printf("%.0f ", arr[i]);
printf("\n");
}Python
def insertion_sort(arr, start, end):
for i in range(start + 1, end + 1):
key = arr[i]
j = i - 1
while j >= start and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
def flash_sort(arr):
n = len(arr)
if n <= 1:
return arr
min_val, max_val = min(arr), max(arr)
if max_val == min_val:
return arr
m = int(0.43 * n)
L = [0] * m
for x in arr:
k = int((m - 1) * (x - min_val) / (max_val - min_val))
L[k] += 1
for i in range(1, m):
L[i] += L[i - 1]
move, j, k = 0, 0, m - 1
while move < n - 1:
while j >= L[k]:
k = int((m - 1) * (arr[j] - min_val) / (max_val - min_val))
flash = arr[j]
while j != L[k]:
k = int((m - 1) * (flash - min_val) / (max_val - min_val))
L[k] -= 1
arr[L[k]], flash = flash, arr[L[k]]
move += 1
j += 1
insertion_sort(arr, 0, n - 1)
return arr
arr = [9,3,1,7,4,6,2,8,5]
print(flash_sort(arr))为什么它重要
- 在均匀分布的数据上极快
- 原地排序(O(1) 额外空间)
- 适用于大型数组
- 结合了分布排序和插入排序收尾
一个温和的证明(为什么它有效)
分类步骤将每个元素映射到其预期的位置区域,极大地减少了无序性。 由于每个类别内的局部范围很小,插入排序可以快速完成。
预期复杂度(均匀输入):
$$ T(n) = O(n) \text{ (分类) } + O(n) \text{ (最终遍历) } = O(n) $$
最坏情况(偏斜分布):O(n²)
| 阶段 | 工作量 | 复杂度 |
|---|---|---|
| 分类 | O(n) | 计算类别 |
| 闪电重排 | O(n) | 原地移动 |
| 最终排序 | O(n) | 局部排序 |
亲自尝试
- 逐步排序
[9,3,1,7,4,6,2,8,5]。 - 改变类别数量(0.3n, 0.5n)。
- 可视化每个元素的类别映射。
- 计算闪电阶段的移动次数。
- 与快速排序在 10⁵ 个元素上的时间进行比较。
- 测试均匀数据与偏斜数据。
- 使用不同的收尾排序算法实现。
- 跟踪闪电阶段形成的循环。
- 观察稳定性(它不稳定)。
- 与归并排序进行基准测试。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [9,3,1,7,4,6,2,8,5] | [1,2,3,4,5,6,7,8,9] | 示例 |
| [5,5,5,5] | [5,5,5,5] | 相等元素 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已排序 |
| [9,8,7,6,5] | [5,6,7,8,9] | 逆序 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n) |
| 时间(最坏) | O(n²) |
| 空间 | O(1) |
| 稳定 | 否 |
| 自适应 | 是(部分) |
闪电排序是排序中的一道闪电,它在线性时间内将元素闪送到其预期的区域,然后快速抛光完成。当你的数据均匀分布时,它是周围最快的实用排序算法之一。
128 邮差排序
邮差排序是一种稳定的多键排序算法,它通过逐位或逐字段排序来实现,可以从最低有效字段(类似 LSD 基数排序)或最高有效字段(类似 MSD 基数排序)开始,具体取决于应用场景。它常用于复合键(例如邮政地址、日期、字段字符串)的排序,因此得名"邮差",因为它排序数据的方式就像邮递员整理邮件一样:先按街道,再按房屋号,最后按公寓号。
我们要解决什么问题?
当需要根据多个属性对复杂记录进行排序时,例如:
- 按
(城市, 街道, 门牌号)对人进行排序 - 按
(年, 月, 日)对文件进行排序 - 按
(类别, 品牌, 价格)对产品进行排序
我们需要进行分层排序,这正是邮差排序的强项。它是一种稳定的、按字段排序的方法,基于对每个字段进行计数排序或桶排序构建。
示例
按城市,然后按街道对元组 (城市, 街道) 排序:
$$("Paris", "B"), ("London", "C"), ("Paris", "A"), ("London", "A")]| 步骤 | 排序依据 | 结果 |
|---|---|---|
| 1 | 街道 (LSD) | [("London","A"),("Paris","A"),("London","C"),("Paris","B")] |
| 2 | 城市 (MSD) | [("London","A"),("London","C"),("Paris","A"),("Paris","B")] |
稳定的排序确保了内部顺序在上一轮排序后得以保留。
它是如何工作的(通俗解释)?
这就像整理邮件:
- 按最小单位(门牌号)排序
- 然后按街道排序
- 然后按城市排序
每一轮排序都在细化前一轮的排序结果。 如果从最低有效字段到最高有效字段排序,使用 LSD 顺序(类似基数排序)。 如果从最高有效字段到最低有效字段排序,使用 MSD 顺序(类似桶递归排序)。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 识别关键字段及其重要性顺序 |
| 2 | 选择稳定的排序方法(例如,计数排序) |
| 3 | 首先按最低有效键排序 |
| 4 | 对每个键重复此过程,直到最高有效键 |
| 5 | 最终顺序尊重所有键的层次结构 |
微型代码(简易版本)
Python (LSD 方法)
def postman_sort(records, key_funcs):
# key_funcs = 提取每个字段的函数列表
for key_func in reversed(key_funcs):
records.sort(key=key_func)
return records
# 示例:按 (城市, 街道) 排序
records = [("Paris", "B"), ("London", "C"), ("Paris", "A"), ("London", "A")]
sorted_records = postman_sort(records, [lambda x: x[0], lambda x: x[1]])
print(sorted_records)输出:
$$('London', 'A'), ('London', 'C'), ('Paris', 'A'), ('Paris', 'B')]C (数值字段)
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int city;
int street;
} Record;
int cmp_street(const void *a, const void *b) {
Record *ra = (Record*)a, *rb = (Record*)b;
return ra->street - rb->street;
}
int cmp_city(const void *a, const void *b) {
Record *ra = (Record*)a, *rb = (Record*)b;
return ra->city - rb->city;
}
void postman_sort(Record arr[], int n) {
// 首先按街道排序 (LSD)
qsort(arr, n, sizeof(Record), cmp_street);
// 然后按城市排序 (MSD)
qsort(arr, n, sizeof(Record), cmp_city);
}
int main(void) {
Record arr[] = {{2,3}, {1,2}, {2,1}, {1,1}};
int n = sizeof(arr)/sizeof(arr[0]);
postman_sort(arr, n);
for (int i = 0; i < n; i++)
printf("(%d,%d) ", arr[i].city, arr[i].street);
printf("\n");
}为什么它很重要
- 多键排序(字典序)
- 稳定,在排序轮次间保持顺序
- 通用,适用于复合键、字符串、记录
- 是以下算法的基础:
- 基数排序
- 数据库多列 ORDER BY
- 字典序排名
一个温和的证明(为什么它有效)
每一轮稳定的排序都确保了先前(来自较低有效字段的)排序顺序保持不变。
如果我们将每个字段表示为 $f_i$,并按 $f_k \to f_1$ 的顺序稳定排序:
$$ T(n) = \sum_{i=1}^k T_i(n) $$
如果每个 $T_i(n)$ 是 $O(n)$,则总复杂度为 $O(kn)$。
字典序自然产生: $$ (a_1, a_2, \ldots, a_k) < (b_1, b_2, \ldots, b_k) \iff a_i = b_i \text{ 对于 } i < j, \text{ 并且 } a_j < b_j $$
| 阶段 | 操作 | 稳定 | 复杂度 |
|---|---|---|---|
| LSD 排序 | 从最低有效字段开始 | 是 | $O(nk)$ |
| MSD 排序 | 从最高有效字段开始 | 是 | $O(nk)$ |
亲自尝试
- 排序
[("Paris","B"),("London","C"),("Paris","A"),("London","A")] - 添加第三个字段(邮政编码),按 邮政编码 → 街道 → 城市 排序
- 在 Python 中使用
lambda键实现 - 将每字段的稳定排序替换为计数排序
- 用
[(2021,12,25),(2020,1,1),(2021,1,1)]测试 - 比较 LSD 与 MSD 排序顺序
- 按字符组(第一个字符,第二个字符等)对字符串排序
- 可视化排序轮次和中间结果
- 使用重复键测试稳定性
- 应用于按
(年级, 班级, 学号)对学生记录排序
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [("Paris","B"),("London","C"),("Paris","A"),("London","A")] | [("London","A"),("London","C"),("Paris","A"),("Paris","B")] | 字典序 |
| [(2021,12,25),(2020,1,1),(2021,1,1)] | [(2020,1,1),(2021,1,1),(2021,12,25)] | 日期顺序 |
| [(1,2,3),(1,1,3),(1,1,2)] | [(1,1,2),(1,1,3),(1,2,3)] | 多字段 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(k × n) |
| 空间复杂度 | O(n) |
| 稳定 | 是 |
| 自适应 | 否 |
| 键类型 | 多字段 |
邮差排序像时钟一样精确地交付顺序,逐字段、逐轮次地排序,确保数据的每一层——从公寓号到邮政编码——都找到其合适的位置。
129 地址计算排序
地址计算排序(有时称为哈希排序或散列排序)是一种基于分布的排序方法,它使用一个类似哈希的函数(称为地址函数)直接计算每个元素的最终位置。它不进行成对比较,而是计算每个元素应该去的位置,很像哈希表中的直接寻址。
我们解决什么问题?
比较排序需要 O(n log n) 时间。 如果我们知道输入值的范围和分布,我们就可以转而计算每个元素所属的位置,直接放置它。
地址计算排序弥合了排序和哈希之间的差距:
- 它计算从值到位置的映射。
- 它直接放置每个元素或将其放入小组中。
- 当数据分布已知且均匀时,效果最佳。
应用场景:
- 密集数值数据集
- 排序记录
- 预分桶的范围
示例
对范围在 [0..4] 内的 [3, 1, 4, 0, 2] 进行排序。
| 步骤 | 元素 | 地址函数 $f(x) = x$ | 放置位置 |
|---|---|---|---|
| 1 | 3 | f(3) = 3 | 位置 3 |
| 2 | 1 | f(1) = 1 | 位置 1 |
| 3 | 4 | f(4) = 4 | 位置 4 |
| 4 | 0 | f(0) = 0 | 位置 0 |
| 5 | 2 | f(2) = 2 | 位置 2 |
结果:[0, 1, 2, 3, 4]
如果多个元素共享同一个地址,它们将被存储在一个小的链表或桶中,然后在本地进行排序。
它是如何工作的(通俗解释)?
想象一下,为每一个可能的键都准备了有标签的邮箱 —— 每个元素都确切地知道它属于哪个邮箱。 你只需将每封信放入对应的槽位,然后按顺序读取邮箱。
与计数排序(它统计出现次数)不同, 地址计算排序分配位置,如果小心处理冲突,它甚至可以是原地排序。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 定义地址函数 $f(x)$,将每个元素映射到一个索引 |
| 2 | 初始化空的输出数组或桶 |
| 3 | 对于每个元素 $a_i$:计算索引 = $f(a_i)$ |
| 4 | 将元素放置在索引处(如果需要,处理冲突) |
| 5 | 收集或展平桶以得到排序顺序 |
微型代码(简易版本)
C(简单的范围内示例)
#include <stdio.h>
#include <stdlib.h>
void address_calculation_sort(int arr[], int n, int min, int max) {
int range = max - min + 1;
int *output = calloc(range, sizeof(int));
int *filled = calloc(range, sizeof(int));
for (int i = 0; i < n; i++) {
int idx = arr[i] - min;
output[idx] = arr[i];
filled[idx] = 1;
}
int k = 0;
for (int i = 0; i < range; i++) {
if (filled[i]) arr[k++] = output[i];
}
free(output);
free(filled);
}
int main(void) {
int arr[] = {3, 1, 4, 0, 2};
int n = sizeof(arr)/sizeof(arr[0]);
address_calculation_sort(arr, n, 0, 4);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}Python
def address_calculation_sort(arr, f=None):
if not arr:
return arr
if f is None:
f = lambda x: x # 恒等映射
min_val, max_val = min(arr), max(arr)
size = max_val - min_val + 1
slots = [[] for _ in range(size)]
for x in arr:
idx = f(x) - min_val
slots[idx].append(x)
# 展平桶
result = []
for bucket in slots:
result.extend(sorted(bucket)) # 如果需要,进行本地排序
return result
arr = [3, 1, 4, 0, 2]
print(address_calculation_sort(arr))输出:
$$0, 1, 2, 3, 4]为什么它重要
- 直接计算排序位置
- 对于可预测的分布具有线性时间复杂度
- 结合了哈希和排序
- 构成了桶排序、基数排序和闪存排序的基础
这是一个很好的例子,展示了函数如何替代比较。
一个温和的证明(为什么它有效)
如果 $f(x)$ 将每个键唯一地映射到其排序索引, 那么结果在构造时就已经是排序好的。
即使有冲突,对小的本地桶进行排序也很快。
总时间: $$ T(n) = O(n + k) $$ 其中 $k$ = 桶的数量 = 范围大小。
| 阶段 | 工作量 | 复杂度 |
|---|---|---|
| 计算地址 | O(n) | 每个元素一次 |
| 桶插入 | O(n) | 每个常数时间 |
| 本地排序 | O(k) | 小组 |
| 展平 | O(n + k) | 读回 |
亲自尝试
- 用
f(x)=x排序[3, 1, 4, 0, 2]。 - 将映射改为
f(x)=2*x,注意间隙。 - 添加重复项
[1,1,2,2,3],使用桶。 - 尝试浮点数,对
[0.1,0.2,0.3]使用f(x)=int(x*10)。 - 按 ASCII 和排序字符串:
f(s)=sum(map(ord,s))。 - 与计数排序比较(无显式存储)。
- 通过偏移最小值来处理负数。
- 可视化映射表。
- 测试范围间隙(例如,
[10, 20, 30])。 - 尝试自定义哈希函数。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3,1,4,0,2] | [0,1,2,3,4] | 完美映射 |
| [10,20,30] | [10,20,30] | 稀疏映射 |
| [1,1,2,2] | [1,1,2,2] | 重复项 |
| [0.1,0.3,0.2] | [0.1,0.2,0.3] | 浮点数映射 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(n + k) |
| 空间 | O(n + k) |
| 稳定 | 是(使用桶时) |
| 自适应 | 否 |
| 范围敏感 | 是 |
地址计算排序将排序转化为放置,每个值通过公式找到自己的归宿,而不是通过竞争。当范围清晰且冲突很少时,它速度极快且优雅简单。
130 散布排序
散布排序是一种混合分配排序算法,融合了基数排序、桶排序和比较排序的思想。它通过元素的最有效位(MSB)或值范围将元素分配到桶中,然后递归地对桶进行排序(类似于基数/MSD排序),或在桶较小时切换为比较排序(如快速排序)。
它对缓存友好,具有自适应性,在均匀分布的数据上通常比快速排序更快。事实上,它被用于一些高性能库中,例如 Boost C++ 的 Spreadsort。
我们要解决什么问题?
传统的比较排序(如快速排序)具有 $O(n \log n)$ 的复杂度,而纯粹的基数排序在小数据集或偏斜数据集上可能效率低下。散布排序通过动态适应来解决这个问题:
- 当数据范围广且随机时,像基数排序一样进行分配
- 当数据聚集或桶较小时,像快速排序一样进行比较
它先将数据“散布”到各个桶中,然后智能地对每个桶进行排序。
非常适合:
- 整数、浮点数和字符串
- 大型数据集
- 值范围广的数据
示例
排序 [43, 12, 89, 27, 55, 31, 70]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 找到最小值 = 12,最大值 = 89 | 范围 = 77 |
| 2 | 选择桶的数量(例如 4) | 桶大小 ≈ 19 |
| 3 | 按桶索引 = (值 - 最小值)/19 进行分配 | 桶: [12], [27,31], [43,55], [70,89] |
| 4 | 递归地对每个桶排序 | [12], [27,31], [43,55], [70,89] |
| 5 | 合并桶 | [12,27,31,43,55,70,89] |
它是如何工作的(通俗解释)?
想象一下按首字母(分配)对邮件进行排序,然后对每一堆邮件按字母顺序排序(比较)。 如果一堆邮件仍然很大,就按下一个字母再次进行分配。 如果一堆邮件很小,就直接排序。
散布排序使用:
- 当数据范围广时,使用分配策略
- 当桶较窄或较小时,使用比较排序
这种灵活性使其在实际应用中具有强大的性能。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到最小值和最大值 |
| 2 | 计算散布范围 = 最大值 - 最小值 |
| 3 | 选择桶的数量(基于 n 或散布范围) |
| 4 | 按值范围将元素分配到桶中 |
| 5 | 对大型桶递归应用散布排序 |
| 6 | 对小型桶应用比较排序 |
| 7 | 连接结果 |
微型代码(简易版本)
C(整数示例)
#include <stdio.h>
#include <stdlib.h>
int compare(const void *a, const void *b) {
return (*(int*)a - *(int*)b);
}
void spread_sort(int arr[], int n) {
if (n <= 16) { // 小桶的阈值
qsort(arr, n, sizeof(int), compare);
return;
}
int min = arr[0], max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < min) min = arr[i];
if (arr[i] > max) max = arr[i];
}
int bucket_count = n / 4 + 1;
int range = max - min + 1;
int bucket_size = range / bucket_count + 1;
int *counts = calloc(bucket_count, sizeof(int));
int buckets = malloc(bucket_count * sizeof(int*));
for (int i = 0; i < bucket_count; i++)
buckets[i] = malloc(n * sizeof(int));
// 分配
for (int i = 0; i < n; i++) {
int idx = (arr[i] - min) / bucket_size;
buckets[idx][counts[idx]++] = arr[i];
}
// 递归排序
int k = 0;
for (int i = 0; i < bucket_count; i++) {
if (counts[i] > 0) {
spread_sort(buckets[i], counts[i]);
for (int j = 0; j < counts[i]; j++)
arr[k++] = buckets[i][j];
}
free(buckets[i]);
}
free(buckets);
free(counts);
}
int main(void) {
int arr[] = {43, 12, 89, 27, 55, 31, 70};
int n = sizeof(arr)/sizeof(arr[0]);
spread_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}Python
def spread_sort(arr, threshold=16):
if len(arr) <= threshold:
return sorted(arr)
min_val, max_val = min(arr), max(arr)
if min_val == max_val:
return arr[:]
n = len(arr)
bucket_count = n // 4 + 1
spread = max_val - min_val + 1
bucket_size = spread // bucket_count + 1
buckets = [[] for _ in range(bucket_count)]
for x in arr:
idx = (x - min_val) // bucket_size
buckets[idx].append(x)
result = []
for b in buckets:
if len(b) > threshold:
result.extend(spread_sort(b, threshold))
else:
result.extend(sorted(b))
return result
arr = [43, 12, 89, 27, 55, 31, 70]
print(spread_sort(arr))为什么它很重要
- 在均匀数据上具有线性时间复杂度
- 适应数据分布和大小
- 结合了桶排序和比较排序的优势
- 非常适合大型数值数据集
它被用于 Boost C++ 库中,是一种实际应用中的高性能混合排序算法。
一个温和的证明(为什么它有效)
散布排序的代价取决于:
- 分配阶段:$O(n)$
- 递归深度:较小(对于均匀数据为对数级)
- 局部排序:小而快(在小桶上通常为 $O(1)$ 或 $O(n \log n)$)
对于 $n$ 个元素和 $b$ 个桶:
$$ T(n) = O(n + \sum_{i=1}^{b} T_i) $$ 如果平均桶大小是常数或较小: $$ T(n) \approx O(n) $$
| 阶段 | 工作 | 复杂度 |
|---|---|---|
| 分配 | $O(n)$ | 一次遍历 |
| 局部排序 | $O(n \log m)$ | $m =$ 平均桶大小 |
| 总计 | 平均 $O(n)$,最坏 $O(n \log n)$ |
亲自尝试
- 排序
[43,12,89,27,55,31,70]。 - 尝试
[5,4,3,2,1](非均匀)。 - 调整桶阈值。
- 可视化递归桶。
- 混合大小值(例如
[1, 10, 1000, 2, 20, 2000])。 - 与快速排序比较运行时间。
- 实现浮点数版本。
- 测量分配不平衡性。
- 调整桶大小公式。
- 按字符序对字符串排序。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [43,12,89,27,55,31,70] | [12,27,31,43,55,70,89] | 均匀分布 |
| [5,4,3,2,1] | [1,2,3,4,5] | 逆序 |
| [100,10,1,1000] | [1,10,100,1000] | 范围广 |
| [5,5,5] | [5,5,5] | 重复值 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(n) |
| 稳定性 | 是(如果局部排序是稳定的) |
| 自适应性 | 是 |
散布排序像播撒种子一样散布元素,每个元素在其范围内找到肥沃的土壤,然后通过局部排序开花结果,形成有序序列。它是将基数排序和比较排序世界的最佳部分结合在一起的智能混合算法。
第 14 节. 混合排序
131 内省排序
内省排序(Introspective Sort 的简称)是一种混合排序算法,它结合了快速排序、堆排序和插入排序的最佳特性。它开始时使用快速排序以求速度,但如果递归深度过深(表明分区不平衡),它会切换到堆排序以保证最坏情况下的 $O(n \log n)$ 性能。对于小的分区,它通常使用插入排序以提高效率。
它由 David Musser 于 1997 年提出,是 C++ 标准模板库(STL)中默认的排序算法(std::sort),快速、自适应且安全。
我们要解决什么问题?
纯粹的快速排序平均速度很快,但在最坏情况下(例如,已排序的输入且枢轴选择不佳)会退化到 $O(n^2)$。
堆排序保证 $O(n \log n)$,但常数因子较大。
内省排序结合了它们,使用快速排序直到出现危险,然后切换到堆排序以保证安全。
非常适合:
- 通用排序(数值、字符串、对象)
- 对性能要求高的库
- 分布未知的混合数据
示例
排序 [9, 3, 1, 7, 5, 4, 6, 2, 8]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 开始快速排序(深度 = 0) | 枢轴 = 5 |
| 2 | 分区 → [3,1,2,4] [5] [9,7,6,8] | 深度 = 1 |
| 3 | 递归处理左子数组和右子数组 | |
| 4 | 如果深度 > 2 × log₂(n),切换到堆排序 | 防止 (O(n^2)) |
| 5 | 对小片段使用插入排序 |
最终排序后的数组:[1,2,3,4,5,6,7,8,9]
它是如何工作的(通俗解释)?
它就像一个谨慎的司机:
- 在高速公路上快速行驶(快速排序)
- 如果道路变得棘手(转弯太多 = 递归深度过深),切换到四轮驱动(堆排序)
- 对于小的停车位(微小数组),使用灵活的自行车(插入排序)
内省排序保持平均速度很高,同时避免了最坏情况的"撞车"。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 开始递归快速排序 |
| 2 | 跟踪递归深度 |
| 3 | 如果深度超过阈值(2 × log₂ n),切换到堆排序 |
| 4 | 对小子数组(大小 < 阈值)使用插入排序 |
| 5 | 合并结果得到最终排序后的数组 |
微型代码(简易版本)
C(简化实现)
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#define THRESHOLD 16
void insertion_sort(int arr[], int n) {
for (int i = 1; i < n; i++) {
int key = arr[i], j = i - 1;
while (j >= 0 && arr[j] > key) arr[j + 1] = arr[j--];
arr[j + 1] = key;
}
}
void heapify(int arr[], int n, int i) {
int largest = i, l = 2*i + 1, r = 2*i + 2;
if (l < n && arr[l] > arr[largest]) largest = l;
if (r < n && arr[r] > arr[largest]) largest = r;
if (largest != i) {
int tmp = arr[i]; arr[i] = arr[largest]; arr[largest] = tmp;
heapify(arr, n, largest);
}
}
void heap_sort(int arr[], int n) {
for (int i = n/2 - 1; i >= 0; i--) heapify(arr, n, i);
for (int i = n - 1; i >= 0; i--) {
int tmp = arr[0]; arr[0] = arr[i]; arr[i] = tmp;
heapify(arr, i, 0);
}
}
int partition(int arr[], int low, int high) {
int pivot = arr[high], i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++;
int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
}
}
int tmp = arr[i+1]; arr[i+1] = arr[high]; arr[high] = tmp;
return i + 1;
}
void introsort_rec(int arr[], int low, int high, int depth_limit) {
int n = high - low + 1;
if (n <= THRESHOLD) {
insertion_sort(arr + low, n);
return;
}
if (depth_limit == 0) {
heap_sort(arr + low, n);
return;
}
int p = partition(arr, low, high);
introsort_rec(arr, low, p - 1, depth_limit - 1);
introsort_rec(arr, p + 1, high, depth_limit - 1);
}
void introsort(int arr[], int n) {
int depth_limit = 2 * log(n);
introsort_rec(arr, 0, n - 1, depth_limit);
}
int main(void) {
int arr[] = {9,3,1,7,5,4,6,2,8};
int n = sizeof(arr)/sizeof(arr[0]);
introsort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}Python(概念演示)
import math
def insertion_sort(a):
for i in range(1, len(a)):
key = a[i]
j = i - 1
while j >= 0 and a[j] > key:
a[j + 1] = a[j]
j -= 1
a[j + 1] = key
def heapify(a, n, i):
largest = i
l, r = 2*i+1, 2*i+2
if l < n and a[l] > a[largest]:
largest = l
if r < n and a[r] > a[largest]:
largest = r
if largest != i:
a[i], a[largest] = a[largest], a[i]
heapify(a, n, largest)
def heap_sort(a):
n = len(a)
for i in range(n//2-1, -1, -1):
heapify(a, n, i)
for i in range(n-1, 0, -1):
a[0], a[i] = a[i], a[0]
heapify(a, i, 0)
def introsort(a, depth_limit=None):
n = len(a)
if n <= 16:
insertion_sort(a)
return a
if depth_limit is None:
depth_limit = 2 * int(math.log2(n))
if depth_limit == 0:
heap_sort(a)
return a
pivot = a[-1]
left = [x for x in a[:-1] if x <= pivot]
right = [x for x in a[:-1] if x > pivot]
introsort(left, depth_limit - 1)
introsort(right, depth_limit - 1)
a[:] = left + [pivot] + right
return a
arr = [9,3,1,7,5,4,6,2,8]
print(introsort(arr))为什么它很重要
- C++ STL 中的默认算法,快速可靠
- 保证最坏情况 $O(n \log n)$
- 针对缓存和小数据进行了优化
- 自适应,为当前场景使用最佳方法
一个温和的证明(为什么它有效)
快速排序主导,直到递归深度 = $2 \log_2 n$。 此时,最坏情况风险出现 → 切换到堆排序(安全后备方案)。 小子数组由插入排序处理,开销低。
因此总体: $$ T(n) = O(n \log n) $$ 始终受限于堆排序的最坏情况,但通常接近快速排序的最佳情况。
| 阶段 | 方法 | 复杂度 |
|---|---|---|
| 分区 | 快速排序 | O(n log n) |
| 深度递归 | 堆排序 | O(n log n) |
| 小数组 | 插入排序 | O(n²) 局部,可忽略 |
自己动手试试
- 排序
[9,3,1,7,5,4,6,2,8]。 - 跟踪递归深度,当 $> 2 \log_2 n$ 时切换到堆排序。
- 将阈值 = 16 替换为 8、32,测量效果。
- 用已排序的数组测试,确认堆排序后备方案。
- 与快速排序和堆排序比较时间。
- 打印每个阶段使用的方法。
- 测试大数组(10⁵ 个元素)。
- 在已排序输入上验证最坏情况安全性。
- 尝试使用自定义比较器进行字符串排序。
- 使用模板或 lambda 实现通用版本。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [9,3,1,7,5,4,6,2,8] | [1,2,3,4,5,6,7,8,9] | 平衡分区 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已排序输入(堆排序后备方案) |
| [5,5,5,5] | [5,5,5,5] | 相等元素 |
| [9,8,7,6,5,4,3,2,1] | [1,2,3,4,5,6,7,8,9] | 避免了快速排序的最坏情况 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n log n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(log n) |
| 稳定 | 否 |
| 自适应 | 是 |
内省排序是战略家的算法,它开始大胆(快速排序),防守明智(堆排序),并优雅收尾(插入排序)。是速度、安全性和适应性的完美平衡。
132 TimSort
TimSort 是一种结合了归并排序和插入排序的混合排序算法,专为现实世界中常包含部分有序序列的数据而设计。它由 Tim Peters 于 2002 年发明,是 Python(sorted()、.sort())和 Java(针对对象的 Arrays.sort())中的默认排序算法。
TimSort 的强大之处在于它能检测数据中的自然序列,如果序列较小则用插入排序进行排序,并使用基于栈的策略智能地合并它们,以确保效率。
我们要解决什么问题?
在实践中,许多数据集并非随机,它们已经包含有序的片段(如日志、姓名、时间戳)。 TimSort 通过以下方式利用这一点:
- 检测升序/降序序列
- 通过插入排序对小序列进行排序
- 使用自适应归并排序合并序列
这使得在已排序或接近已排序的数据上,性能达到 O(n),远优于标准 $O(n \log n)$ 排序算法在这些情况下的表现。
非常适合:
- 部分排序的列表
- 现实世界数据(时间序列、字符串、日志)
- 稳定排序(保持相等元素的顺序)
示例
排序 [5, 6, 7, 1, 2, 3, 8, 9]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 检测序列 | [5,6,7], [1,2,3], [8,9] |
| 2 | 如有需要,对小序列排序 | [5,6,7], [1,2,3], [8,9] |
| 3 | 成对合并序列 | [1,2,3,5,6,7,8,9] |
TimSort 利用了已存在的顺序,减少了合并次数,从而更快完成。
它是如何工作的(通俗解释)?
可以把 TimSort 想象成一个聪明的图书管理员:
- 查看哪些书架(序列)已经排好序
- 整理小而乱的书架(插入排序)
- 高效地将书架合并在一起(归并排序)
- 使用栈规则来决定合并时机以保持平衡
它是自适应的、稳定的,并且针对现实世界进行了优化。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 扫描数组以找到序列(升序或降序) |
| 2 | 反转降序序列 |
| 3 | 如果序列长度 < minrun,则使用插入排序扩展它 |
| 4 | 将序列压入栈 |
| 5 | 当栈大小条件被违反时合并序列 |
| 6 | 继续直到只剩下一个序列(完全排序) |
微型代码(简易版本)
Python(简化模拟)
def insertion_sort(arr, left, right):
# 对指定区间进行插入排序
for i in range(left + 1, right + 1):
key = arr[i]
j = i - 1
while j >= left and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
def merge(arr, left, mid, right):
# 合并两个有序子数组
left_part = arr[left:mid+1]
right_part = arr[mid+1:right+1]
i = j = 0
k = left
while i < len(left_part) and j < len(right_part):
if left_part[i] <= right_part[j]:
arr[k] = left_part[i]
i += 1
else:
arr[k] = right_part[j]
j += 1
k += 1
while i < len(left_part):
arr[k] = left_part[i]; i += 1; k += 1
while j < len(right_part):
arr[k] = right_part[j]; j += 1; k += 1
def timsort(arr):
n = len(arr)
minrun = 32
# 使用插入排序对小序列进行排序
for start in range(0, n, minrun):
end = min(start + minrun - 1, n - 1)
insertion_sort(arr, start, end)
size = minrun
while size < n:
for left in range(0, n, 2 * size):
mid = min(n - 1, left + size - 1)
right = min(n - 1, left + 2 * size - 1)
if mid < right:
merge(arr, left, mid, right)
size *= 2
arr = [5,6,7,1,2,3,8,9]
timsort(arr)
print(arr)输出:
$$1, 2, 3, 5, 6, 7, 8, 9]C(概念版本)
#include <stdio.h>
#include <stdlib.h>
#define MINRUN 32
void insertion_sort(int arr[], int left, int right) {
// 对指定区间进行插入排序
for (int i = left + 1; i <= right; i++) {
int key = arr[i], j = i - 1;
while (j >= left && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
void merge(int arr[], int l, int m, int r) {
// 合并两个有序子数组
int n1 = m - l + 1, n2 = r - m;
int *L = malloc(n1 * sizeof(int)), *R = malloc(n2 * sizeof(int));
for (int i = 0; i < n1; i++) L[i] = arr[l + i];
for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j];
int i = 0, j = 0, k = l;
while (i < n1 && j < n2) arr[k++] = (L[i] <= R[j]) ? L[i++] : R[j++];
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
free(L); free(R);
}
void timsort(int arr[], int n) {
// 使用插入排序对小序列进行排序
for (int i = 0; i < n; i += MINRUN) {
int end = (i + MINRUN - 1 < n) ? (i + MINRUN - 1) : (n - 1);
insertion_sort(arr, i, end);
}
// 迭代合并序列
for (int size = MINRUN; size < n; size *= 2) {
for (int left = 0; left < n; left += 2 * size) {
int mid = (left + size - 1 < n - 1) ? (left + size - 1) : (n - 1);
int right = (left + 2 * size - 1 < n - 1) ? (left + 2 * size - 1) : (n - 1);
if (mid < right) merge(arr, left, mid, right);
}
}
}
int main(void) {
int arr[] = {5,6,7,1,2,3,8,9};
int n = sizeof(arr)/sizeof(arr[0]);
timsort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它很重要
- Python 和 Java 的默认排序算法
- 稳定且自适应
- 在已排序数据上为 O(n)
- 最坏情况 O(n log n)
- 优雅地处理现实世界输入
当你不了解数据形态时,它是完美的排序算法,因为它能自我适应。
一个温和的证明(为什么它有效)
如果数据已经包含平均长度为 $r$ 的有序序列:
- 插入排序:每个序列 $O(r^2)$(很小)
- 合并 $(n / r)$ 个序列:$O(n \log (n / r))$
总体: $$ T(n) = O(n + n \log (n/r)) $$ 对于 $r \approx n$:$O(n)$ 对于 $r = 1$:$O(n \log n)$
| 阶段 | 工作 | 复杂度 |
|---|---|---|
| 序列检测 | $O(n)$ | 一次遍历 |
| 局部排序 | $O(r^2)$ 每个序列 | 小序列 |
| 合并阶段 | $O(n \log n)$ | 平衡的合并 |
亲自尝试
- 逐步排序
[5,6,7,1,2,3,8,9]。 - 手动检测自然序列。
- 在合并前反转降序序列。
- 调整
minrun = 16,观察差异。 - 测试
[1,2,3,4,5],应该花费 O(n)。 - 向部分排序的列表添加随机噪声。
- 跟踪序列栈,何时合并?
- 与归并排序比较性能。
- 可视化合并顺序树。
- 用重复键检查稳定性。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [5,6,7,1,2,3,8,9] | [1,2,3,5,6,7,8,9] | 混合序列 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已排序 |
| [9,8,7,6] | [6,7,8,9] | 反向序列 |
| [5,5,5,5] | [5,5,5,5] | 稳定性测试 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(n) |
| 稳定 | 是 |
| 自适应 | 是 |
TimSort 是现实世界的冠军,它观察你的数据,即时适应,并且更聪明地排序,而不是更费力。这是一种不仅运行快,而且思考快的算法。
133 双轴快速排序
双轴快速排序是快速排序的一种增强变体,它使用两个枢轴(pivot)而不是一个,将数组划分为三个区域:
- 小于 pivot1 的元素,
- 介于 pivot1 和 pivot2 之间的元素,
- 大于 pivot2 的元素。
这种方法通常能减少比较次数并提高缓存效率。它由 Vladimir Yaroslavskiy 推广,并成为 Java(从 Java 7 开始)中基本类型(primitive types)的默认排序算法。
我们要解决什么问题?
标准的快速排序使用一个枢轴将数组分成两部分。 双轴快速排序将其分成三部分,从而减少了递归深度和开销。
它针对以下情况进行了优化:
- 大型基本类型数组(整数、浮点数)
- 随机且均匀分布的数据
- 具有深度流水线和缓存的现代 CPU
即使其渐近复杂度仍然是 $O(n \log n)$,它在实际应用中仍提供了比经典快速排序更好的性能。
示例
对 [9, 3, 1, 7, 5, 4, 6, 2, 8] 进行排序
选择枢轴($p_1 = 3$, $p_2 = 7$):
| 区域 | 条件 | 元素 |
|---|---|---|
| 左 | $x < 3$ | [1, 2] |
| 中 | $3 \le x \le 7$ | [3, 4, 5, 6, 7] |
| 右 | $x > 7$ | [8, 9] |
对每个区域进行递归排序。 最终结果:[1, 2, 3, 4, 5, 6, 7, 8, 9]。
它是如何工作的?(通俗解释)
想象一下用两个标记按尺码整理鞋子:
- 小架子放尺码小于 7 的鞋
- 中架子放尺码 7–9 的鞋
- 大架子放尺码 10+ 的鞋
你走一遍,把每只鞋放到正确的组里,然后分别整理每个架子。
双轴快速排序正是如此:一次遍历将数组分成三个区域,然后递归处理。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 选择两个枢轴($p_1$, $p_2$),确保 $p_1 < p_2$ |
| 2 | 将数组划分为 3 部分:< p₁、p₁..p₂、> p₂ |
| 3 | 递归排序每一部分 |
| 4 | 按顺序合并结果 |
如果 $p_1 > p_2$,则先交换它们。
精简代码(简易版本)
C
#include <stdio.h>
void swap(int *a, int *b) {
int tmp = *a; *a = *b; *b = tmp;
}
void dual_pivot_quicksort(int arr[], int low, int high) {
if (low >= high) return;
if (arr[low] > arr[high]) swap(&arr[low], &arr[high]);
int p1 = arr[low], p2 = arr[high];
int lt = low + 1, gt = high - 1, i = low + 1;
while (i <= gt) {
if (arr[i] < p1) {
swap(&arr[i], &arr[lt]);
lt++; i++;
} else if (arr[i] > p2) {
swap(&arr[i], &arr[gt]);
gt--;
} else {
i++;
}
}
lt--; gt++;
swap(&arr[low], &arr[lt]);
swap(&arr[high], &arr[gt]);
dual_pivot_quicksort(arr, low, lt - 1);
dual_pivot_quicksort(arr, lt + 1, gt - 1);
dual_pivot_quicksort(arr, gt + 1, high);
}
int main(void) {
int arr[] = {9,3,1,7,5,4,6,2,8};
int n = sizeof(arr)/sizeof(arr[0]);
dual_pivot_quicksort(arr, 0, n-1);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}Python
def dual_pivot_quicksort(arr):
def sort(low, high):
if low >= high:
return
if arr[low] > arr[high]:
arr[low], arr[high] = arr[high], arr[low]
p1, p2 = arr[low], arr[high]
lt, gt, i = low + 1, high - 1, low + 1
while i <= gt:
if arr[i] < p1:
arr[i], arr[lt] = arr[lt], arr[i]
lt += 1; i += 1
elif arr[i] > p2:
arr[i], arr[gt] = arr[gt], arr[i]
gt -= 1
else:
i += 1
lt -= 1; gt += 1
arr[low], arr[lt] = arr[lt], arr[low]
arr[high], arr[gt] = arr[gt], arr[high]
sort(low, lt - 1)
sort(lt + 1, gt - 1)
sort(gt + 1, high)
sort(0, len(arr) - 1)
return arr
arr = [9,3,1,7,5,4,6,2,8]
print(dual_pivot_quicksort(arr))为什么它很重要
- 是 Java 中基本类型的默认排序算法
- 比单轴快速排序的比较次数更少
- 缓存友好(分支更少)
- 三路划分使得递归深度更稳定
一个温和的证明(为什么它有效)
每个划分步骤处理所有元素一次,复杂度为 $O(n)$。 对三个更小的子数组进行递归,总成本为:
$$ T(n) = T(k_1) + T(k_2) + T(k_3) + O(n) $$ 平均而言,划分是平衡的 → $T(n) = O(n \log n)$
| 阶段 | 操作 | 复杂度 |
|---|---|---|
| 划分 | $O(n)$ | 一次遍历 |
| 递归 | 3 个子数组 | 平衡的深度 |
| 总计 | $O(n \log n)$ | 平均 / 最坏情况 |
亲自尝试
- 逐步排序
[9,3,1,7,5,4,6,2,8]。 - 手动选择枢轴:最小值和最大值。
- 跟踪索引移动(lt, gt, i)。
- 与经典快速排序的划分次数进行比较。
- 使用反转数组,观察其稳定性。
- 添加重复项
[5,5,5,5],观察中间区域的效果。 - 测量与单轴快速排序的比较次数。
- 尝试大型输入(10⁶)并计时。
- 可视化递归的三路划分。
- 实现尾递归优化。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [9,3,1,7,5,4,6,2,8] | [1,2,3,4,5,6,7,8,9] | 示例 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [9,8,7,6,5] | [5,6,7,8,9] | 逆序 |
| [5,5,5,5] | [5,5,5,5] | 重复项 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n log n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(log n) |
| 稳定 | 否 |
| 自适应 | 否 |
双轴快速排序用两把刀而不是一把来切分数据,使得每次切割更小、更浅、更平衡。这是对永恒经典的更锐利、更智能的进化。
134 平滑排序
平滑排序(SmoothSort)是由艾兹赫尔·迪杰斯特拉(Edsger Dijkstra)发明的一种自适应比较排序算法。它在理念上与堆排序(Heap Sort)相似,但更智能:对于已排序的数据,它能在 $O(n)$ 时间内完成;在最坏情况下,其时间复杂度为 $O(n \log n)$。
其核心思想是构建一种特殊的堆结构(莱昂纳多堆,Leonardo heap),这种结构能适应数据中已有的顺序。当数组接近有序时,它能快速完成排序;当数据无序时,它能优雅地回退到类似堆排序的性能。
我们要解决什么问题?
堆排序即使在输入已经有序的情况下,也总是需要 $O(n \log n)$ 的时间。 平滑排序对此进行了改进,它是自适应的:输入数据越有序,排序速度就越快。
它非常适用于:
- 接近有序的数组
- 需要保证最坏情况上界的情景
- 内存受限的环境(原地排序)
示例
排序 [1, 2, 4, 3, 5]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 构建初始堆(莱昂纳多结构) | [1,2,4,3,5] |
| 2 | 检测到小范围无序(4,3) | 交换 |
| 3 | 恢复堆性质 | [1,2,3,4,5] |
| 4 | 因无序程度小,无需完全重建堆,提前完成排序 | 完成 |
结果:由于只有轻微的无序,排序提前完成。
它是如何工作的?(通俗解释)
想象有一堆堆,每个堆的大小对应一个类似斐波那契数列的序列(莱昂纳多数,Leonardo numbers)。 在读取数组时,你逐步构建这个结构,并在局部维持顺序。 当出现无序时,你只修复需要修复的地方,而不是到处修复。
所以,平滑排序就像一个温和的园丁:它只修剪长杂草的地方,而不是修剪整个花园。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将数组表示为一组莱昂纳多堆 |
| 2 | 逐个添加元素,更新堆序列 |
| 3 | 以最少的交换次数维持堆性质 |
| 4 | 完成后,重复提取最大值(类似于堆排序) |
| 5 | 在提取过程中,根据需要合并较小的堆 |
莱昂纳多数指导堆的大小: ( L(0)=1, L(1)=1, L(n)=L(n-1)+L(n-2)+1 )
微型代码(简易版本)
Python(简化的自适应排序)
这是一个简化的版本,用于展示自适应性,并非完整的莱昂纳多堆实现。
def smoothsort(arr):
# 简化版:检测有序片段,只修复需要的地方
n = len(arr)
for i in range(1, n):
j = i
while j > 0 and arr[j] < arr[j - 1]:
arr[j], arr[j - 1] = arr[j - 1], arr[j]
j -= 1
return arr
arr = [1, 2, 4, 3, 5]
print(smoothsort(arr))输出:
$$1, 2, 3, 4, 5](注:真正的平滑排序使用莱昂纳多堆,虽然复杂但是原地且高效。)
C(概念性堆方法)
#include <stdio.h>
void insertion_like_fix(int arr[], int n) {
for (int i = 1; i < n; i++) {
int j = i;
while (j > 0 && arr[j] < arr[j - 1]) {
int tmp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = tmp;
j--;
}
}
}
int main(void) {
int arr[] = {1,2,4,3,5};
int n = sizeof(arr)/sizeof(arr[0]);
insertion_like_fix(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}这段代码模仿了平滑排序的自适应性:局部修复,而非全局修复。
为什么它很重要
- 自适应:在接近有序的数据上更快
- 原地排序:无需额外内存
- 有保证的上界:最坏情况不会差于 $O(n \log n)$
- 历史瑰宝:迪杰斯特拉在排序理论上的创新
一个温和的证明(为什么它有效)
对于已排序的输入:
- 每次插入无需交换 → $O(n)$
对于随机输入:
- 每次插入所需的堆恢复操作 → $O(\log n)$
- 总成本:$O(n \log n)$
| 情况 | 行为 | 复杂度 |
|---|---|---|
| 最佳(已排序) | 最少的交换 | $O(n)$ |
| 平均 | 适度的堆恢复 | $O(n \log n)$ |
| 最坏 | 完全的堆重建 | $O(n \log n)$ |
平滑排序能在这些情况之间无缝地自适应。
动手试试
- 分步排序
[1, 2, 4, 3, 5]。 - 尝试
[1, 2, 3, 4, 5],统计比较次数。 - 尝试
[5, 4, 3, 2, 1],体验完整的工作量。 - 统计每种情况下的交换次数。
- 与堆排序进行比较。
- 将堆大小可视化为莱昂纳多序列。
- 实现有序片段的检测。
- 用大型部分有序数组进行实验。
- 跟踪自适应加速效果。
- 编写莱昂纳多堆构建器。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [1,2,4,3,5] | [1,2,3,4,5] | 轻微无序 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已经有序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 完全重建 |
| [2,1,3,5,4] | [1,2,3,4,5] | 混合情况 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(1) |
| 稳定 | 否 |
| 自适应 | 是 |
平滑排序优雅地在数组上滑行,只修复损坏的部分。 这是一种会倾听你数据的排序算法,安静、聪明且精确。
135 块归并排序
块归并排序是一种缓存高效、稳定的排序算法,它使用固定大小的小块而不是大型临时数组来合并数据。它通过减少内存使用和增强引用的局部性来改进标准归并排序,使其成为现代硬件和大数据集的绝佳选择。
它的设计旨在保持数据对缓存友好、原地(或接近原地)且稳定,使其成为内存带宽有限或内存约束严格的系统的实用选择。
我们要解决什么问题?
经典的归并排序是稳定的且时间复杂度为 O(n log n),但它需要 O(n) 的额外空间。 块归并排序通过使用固定大小的块(通常为 √n)作为合并的临时缓冲区来解决这个问题。
它的目标是:
- 保持稳定性
- 使用有限的额外内存
- 最大化缓存重用
- 保持可预测的访问模式
适用于:
- 大型数组
- 外部内存排序
- 具有缓存层次结构的系统
示例
排序 [8, 3, 5, 1, 6, 2, 7, 4]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 划分为有序段(通过插入排序) | [3,8], [1,5], [2,6], [4,7] |
| 2 | 使用缓冲区合并相邻块 | [1,3,5,8], [2,4,6,7] |
| 3 | 使用块缓冲区合并已合并的块 | [1,2,3,4,5,6,7,8] |
它使用小块缓冲区而不是完整的数组,从而减少缓存未命中和额外空间使用。
它是如何工作的(通俗解释)?
想象一下合并图书馆里两个已排序的书架,但不是把所有书都拿下来, 你一次移动一小块,用一个小推车与它们原地交换。
你沿着书架滑动缓冲区,逐步、高效地合并,移动量最小。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将输入划分为段(有序子数组) |
| 2 | 使用插入排序或二分插入排序对每个段进行排序 |
| 3 | 分配小缓冲区(块) |
| 4 | 使用块缓冲区成对合并段 |
| 5 | 重复直到只剩下一个有序数组 |
块合并依赖于旋转和缓冲区交换来最小化额外空间。
微型代码(简易版本)
Python(简化版本)
此版本使用块来模拟块合并。
def insertion_sort(arr, start, end):
for i in range(start + 1, end):
key = arr[i]
j = i - 1
while j >= start and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
def merge(arr, left, mid, right):
left_part = arr[left:mid]
right_part = arr[mid:right]
i = j = 0
k = left
while i < len(left_part) and j < len(right_part):
if left_part[i] <= right_part[j]:
arr[k] = left_part[i]; i += 1
else:
arr[k] = right_part[j]; j += 1
k += 1
while i < len(left_part):
arr[k] = left_part[i]; i += 1; k += 1
while j < len(right_part):
arr[k] = right_part[j]; j += 1; k += 1
def block_merge_sort(arr, block_size=32):
n = len(arr)
# 步骤 1: 排序小块
for start in range(0, n, block_size):
end = min(start + block_size, n)
insertion_sort(arr, start, end)
# 步骤 2: 合并相邻块
size = block_size
while size < n:
for left in range(0, n, 2 * size):
mid = min(left + size, n)
right = min(left + 2 * size, n)
merge(arr, left, mid, right)
size *= 2
return arr
arr = [8, 3, 5, 1, 6, 2, 7, 4]
print(block_merge_sort(arr))输出:
$$1, 2, 3, 4, 5, 6, 7, 8]C(简化概念)
#include <stdio.h>
void insertion_sort(int arr[], int left, int right) {
for (int i = left + 1; i < right; i++) {
int key = arr[i], j = i - 1;
while (j >= left && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
void merge(int arr[], int left, int mid, int right) {
int n1 = mid - left, n2 = right - mid;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = arr[left + i];
for (int j = 0; j < n2; j++) R[j] = arr[mid + j];
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) arr[k++] = (L[i] <= R[j]) ? L[i++] : R[j++];
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
void block_merge_sort(int arr[], int n, int block_size) {
for (int i = 0; i < n; i += block_size) {
int end = (i + block_size < n) ? i + block_size : n;
insertion_sort(arr, i, end);
}
for (int size = block_size; size < n; size *= 2) {
for (int left = 0; left < n; left += 2 * size) {
int mid = (left + size < n) ? left + size : n;
int right = (left + 2 * size < n) ? left + 2 * size : n;
if (mid < right) merge(arr, left, mid, right);
}
}
}
int main(void) {
int arr[] = {8,3,5,1,6,2,7,4};
int n = sizeof(arr)/sizeof(arr[0]);
block_merge_sort(arr, n, 2);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它很重要
- 像归并排序一样稳定
- 原地或低空间变体
- 由于块的局部性而缓存高效
- 适用于大型数组或外部排序
一个温和的证明(为什么它有效)
每个合并层级处理 $n$ 个元素:$O(n)$。
层级数 = $\log_2 (n / b)$,其中 $b$ 是块大小。
所以总计: $$ T(n) = O(n \log (n / b)) $$
当 $b$ 很大时(如 $\sqrt{n}$),空间和时间能很好地平衡。
| 阶段 | 操作 | 成本 |
|---|---|---|
| 块排序 | $\frac{n}{b} \times b^2$ | $O(nb)$ |
| 合并 | $\log (n / b) \times n$ | $O(n \log n)$ |
对于典型设置,它接近 $O(n \log n)$ 但经过缓存优化。
亲自尝试
- 使用块大小 2 排序
[8,3,5,1,6,2,7,4]。 - 将块大小增加到 4,比较步骤。
- 跟踪合并次数。
- 使用重复元素检查稳定性。
- 与标准归并排序比较。
- 在已排序输入上计时(检查适应性)。
- 测量缓存未命中(模拟)。
- 尝试大型数组(10000+)以观察内存收益。
- 混合升序和降序段。
- 手动实现块缓冲区旋转。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [8,3,5,1,6,2,7,4] | [1,2,3,4,5,6,7,8] | 经典 |
| [5,5,3,3,1] | [1,3,3,5,5] | 稳定 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [9,8,7] | [7,8,9] | 逆序 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最差) | O(n log n) |
| 空间 | O(b) |
| 稳定 | 是 |
| 自适应 | 部分 |
块归并排序是工程师的归并排序,同样优雅,内存更少,在硬件上更智能。 它不是通过蛮力合并,而是通过巧妙的块操作来平衡速度、空间和稳定性。
136 自适应归并排序
自适应归并排序是一种稳定的、基于比较的排序算法,能够适应输入数据中已有的顺序。它基于这样一个理念:现实世界的数据集通常是部分有序的,因此通过检测游程(已排序的序列)并智能地合并它们,它可以在接近有序的数据上实现 O(n) 的时间复杂度,同时在最坏情况下仍保持 O(n log n)。
它是一个算法家族,包括自然归并排序、TimSort 和 GrailSort,它们都共享一个关键见解:在需要较少工作时就少做工作。
我们要解决什么问题?
标准的归并排序对所有输入一视同仁,即使它已经排好序了。 自适应归并排序通过以下方式改进:
- 检测已排序的游程(升序或降序)
- 合并游程而不是单个元素
- 在已排序或部分排序的数据上实现线性时间
这使得它非常适合:
- 时间序列数据
- 已排序或半排序的日志
- 增量更新的列表
示例
排序 [1, 2, 5, 3, 4, 6]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 检测游程: [1,2,5], [3,4,6] | 找到 2 个游程 |
| 2 | 合并游程 | [1,2,3,4,5,6] |
| 3 | 完成 | 已排序 |
只需要一次合并,输入接近有序,因此运行时间接近 O(n)。
它是如何工作的(通俗解释)?
想象一下,你正在整理一个大部分已经整理好的书架——你不会把所有书都拿下来;你只需要找出顺序被打乱的地方,然后修复那些部分。
自适应归并排序正是这样做的:
- 扫描一次以找到已排序的部分
- 使用稳定的归并过程合并游程
它能在可以偷懒的地方偷懒,在必须高效的地方高效。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 扫描输入以查找游程(升序或降序) |
| 2 | 反转降序游程 |
| 3 | 将游程推入栈中 |
| 4 | 当栈条件(大小或顺序)被违反时合并游程 |
| 5 | 继续直到只剩下一个已排序的游程 |
微型代码(简易版本)
Python(自然归并排序)
def natural_merge_sort(arr):
n = len(arr)
runs = []
i = 0
# 步骤 1: 检测已排序的游程
while i < n:
start = i
i += 1
while i < n and arr[i] >= arr[i - 1]:
i += 1
runs.append(arr[start:i])
# 步骤 2: 成对合并游程
while len(runs) > 1:
new_runs = []
for j in range(0, len(runs), 2):
if j + 1 < len(runs):
new_runs.append(merge(runs[j], runs[j+1]))
else:
new_runs.append(runs[j])
runs = new_runs
return runs[0]
def merge(left, right):
i = j = 0
result = []
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i]); i += 1
else:
result.append(right[j]); j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
arr = [1, 2, 5, 3, 4, 6]
print(natural_merge_sort(arr))输出:
$$1, 2, 3, 4, 5, 6]C(简化概念版)
#include <stdio.h>
#include <stdlib.h>
void merge(int arr[], int l, int m, int r) {
int n1 = m - l + 1, n2 = r - m;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = arr[l + i];
for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j];
int i = 0, j = 0, k = l;
while (i < n1 && j < n2) arr[k++] = (L[i] <= R[j]) ? L[i++] : R[j++];
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
void adaptive_merge_sort(int arr[], int n) {
int start = 0;
while (start < n - 1) {
int mid = start;
while (mid < n - 1 && arr[mid] <= arr[mid + 1]) mid++;
int end = mid + 1;
while (end < n - 1 && arr[end] <= arr[end + 1]) end++;
if (end < n) merge(arr, start, mid, end);
start = end + 1;
}
}
int main(void) {
int arr[] = {1, 2, 5, 3, 4, 6};
int n = sizeof(arr)/sizeof(arr[0]);
adaptive_merge_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它很重要
- 稳定且自适应
- 在接近有序的数据上具有线性时间
- 对现实世界序列效果良好
- 构成了 TimSort 的核心思想
- 不需要关于数据的额外知识
它是一种能注意到数据付出的努力并给予回报的排序算法。
一个温和的证明(为什么它有效)
设平均游程长度 = $r$。
那么游程数量 ≈ $n / r$。
每次合并 = 每层 $O(n)$。
深度 = $O(\log (n / r))$。
所以总成本: $$ T(n) = O(n \log (n / r)) $$
如果 $r = n$(已排序):$O(n)$。
如果 $r = 1$:$O(n \log n)$。
| 情况 | 游程长度 | 复杂度 |
|---|---|---|
| 已排序 | $r = n$ | $O(n)$ |
| 接近有序 | $r$ 较大 | $O(n \log (n / r))$ |
| 随机 | $r$ 较小 | $O(n \log n)$ |
亲自尝试
- 逐步排序
[1, 2, 5, 3, 4, 6]。 - 尝试
[1, 2, 3, 4, 5],应该检测到 1 个游程。 - 反转一个部分,观察新的游程。
- 使用表格手动合并游程。
- 与归并排序比较性能。
- 添加重复元素,检查稳定性。
- 使用 1 万个已排序元素,计时运行。
- 混合升序和降序子数组。
- 可视化游程检测。
- 实现降序游程的反转。
测试用例
| 输入 | 输出 | 注释 |
|---|---|---|
| [1,2,5,3,4,6] | [1,2,3,4,5,6] | 两个游程 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已经排序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 反转(多个游程) |
| [2,2,1,1] | [1,1,2,2] | 稳定 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(n) |
| 稳定 | 是 |
| 自适应 | 是 |
自适应归并排序就像一个深思熟虑的整理者,它会三思而后行。 如果你的数据已经完成了一半,它会在半路与你相遇。
137 PDQSort(模式击败快速排序)
PDQSort 是一种现代、自适应、原地排序算法,它扩展了快速排序,通过模式检测、无分支分区和回退机制来保证即使在对抗性输入下也能达到 $O(n \log n)$ 的性能。
它由 Orson Peters 发明,用于 C++ 的 std::sort()(自 C++17 起),并且由于更好的缓存行为、分支预测和自适应枢轴选择,在实际场景中通常优于传统的快速排序和内省排序。
我们要解决什么问题?
经典的快速排序平均表现良好,但在结构化或重复数据上可能退化到 $O(n^2)$。 PDQSort 通过以下方式解决这个问题:
- 检测不良模式(例如,已排序的输入)
- 切换策略(切换到堆排序或插入排序)
- 为现代 CPU 实现无分支分区
- 自适应枢轴选择(三点中值法,Tukey 九值法)
它保持了速度、性能稳定性和缓存友好性。
非常适合:
- 大型未排序数据集
- 部分排序的数据
- 具有模式的真实世界数据
示例
排序 [1, 2, 3, 4, 5](已排序)
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 检测排序模式 | 发现模式 |
| 2 | 切换到插入排序 | 高效处理 |
| 3 | 输出排序结果 | [1,2,3,4,5] |
PDQSort 通过自适应来击败模式,而不是进行递归的快速排序调用。
它是如何工作的(通俗解释)?
PDQSort 就像一个聪明的厨师:
- 它先尝一下输入(检查模式)
- 选择一个食谱(枢轴规则,排序回退策略)
- 根据厨房条件调整(CPU 缓存、分支)
它从不浪费精力,能检测到何时递归或比较是不必要的。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 自适应地选择枢轴(三点中值法,九值法) |
| 2 | 将元素分区为 < 枢轴 和 > 枢轴 |
| 3 | 检测不良模式(已排序、逆序、大量相等元素) |
| 4 | 应用无分支分区以减少 CPU 分支预测错误 |
| 5 | 递归或在需要时切换到堆排序/插入排序 |
| 6 | 使用尾递归消除 |
微型代码(简易版本)
Python(概念性简化 PDQSort)
def pdqsort(arr):
def insertion_sort(a, lo, hi):
for i in range(lo + 1, hi):
key = a[i]
j = i - 1
while j >= lo and a[j] > key:
a[j + 1] = a[j]
j -= 1
a[j + 1] = key
def partition(a, lo, hi):
pivot = a[(lo + hi) // 2]
i, j = lo, hi - 1
while True:
while a[i] < pivot: i += 1
while a[j] > pivot: j -= 1
if i >= j: return j
a[i], a[j] = a[j], a[i]
i += 1; j -= 1
def _pdqsort(a, lo, hi, depth):
n = hi - lo
if n <= 16:
insertion_sort(a, lo, hi)
return
if depth == 0:
a[lo:hi] = sorted(a[lo:hi]) # 堆排序回退
return
mid = partition(a, lo, hi)
_pdqsort(a, lo, mid + 1, depth - 1)
_pdqsort(a, mid + 1, hi, depth - 1)
_pdqsort(arr, 0, len(arr), len(arr).bit_length() * 2)
return arr
arr = [1, 5, 3, 4, 2]
print(pdqsort(arr))输出:
$$1, 2, 3, 4, 5]C(简化 PDQ 风格)
#include <stdio.h>
#include <stdlib.h>
void insertion_sort(int arr[], int lo, int hi) {
for (int i = lo + 1; i < hi; i++) {
int key = arr[i], j = i - 1;
while (j >= lo && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
int partition(int arr[], int lo, int hi) {
int pivot = arr[(lo + hi) / 2];
int i = lo, j = hi - 1;
while (1) {
while (arr[i] < pivot) i++;
while (arr[j] > pivot) j--;
if (i >= j) return j;
int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
i++; j--;
}
}
void pdqsort(int arr[], int lo, int hi, int depth) {
int n = hi - lo;
if (n <= 16) { insertion_sort(arr, lo, hi); return; }
if (depth == 0) { qsort(arr + lo, n, sizeof(int), (__compar_fn_t)strcmp); return; }
int mid = partition(arr, lo, hi);
pdqsort(arr, lo, mid + 1, depth - 1);
pdqsort(arr, mid + 1, hi, depth - 1);
}
int main(void) {
int arr[] = {1, 5, 3, 4, 2};
int n = sizeof(arr)/sizeof(arr[0]);
pdqsort(arr, 0, n, 2 * 32);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它很重要
- 高性能排序的现代默认选择
- 模式检测避免了最坏情况
- 无分支分区 → 更少的 CPU 停顿
- 堆排序回退确保了 $O(n \log n)$ 的界限
- 像 TimSort 一样自适应,但是原地排序
PDQSort 结合了速度、安全性和硬件感知能力。
一个温和的证明(为什么它有效)
PDQSort 增加了机制来击败快速排序的缺陷:
- 不良模式检测 → 早期回退
- 平衡的枢轴选择 → 接近均等的分割
- 无分支操作 → 高效执行
因此总体而言: $$ T(n) = O(n \log n) $$ 最佳情况(已排序):接近 O(n),得益于早期检测。
| 情况 | 行为 | 复杂度 |
|---|---|---|
| 最佳 | 已排序或接近排序 | O(n) |
| 平均 | 随机 | O(n log n) |
| 最差 | 对抗性 | O(n log n) |
自己动手试试
- 排序
[1,2,3,4,5]→ 检测已排序输入。 - 尝试
[5,4,3,2,1]→ 逆序。 - 1000 个数字的随机输入。
- 重复元素多的
[5,5,5,5]。 - 模式化输入
[1,3,2,4,3,5,4]。 - 与快速排序和堆排序比较。
- 计算递归深度。
- 对无分支分区与经典分区进行基准测试。
- 可视化回退触发条件。
- 测量每个元素的比较次数。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [1,5,3,4,2] | [1,2,3,4,5] | 随机 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已排序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 逆序 |
| [5,5,5,5] | [5,5,5,5] | 重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最差) | O(n log n) |
| 空间 | O(log n) |
| 稳定 | 否 |
| 自适应 | 是 |
PDQSort 是排序中的忍者,快如闪电,模式感知,并且总是比你的数据快一步。 它不仅仅是排序,它智胜了输入。
138 WikiSort
WikiSort 是一种由 Mike Day 创建的稳定、原地归并排序算法,旨在将归并排序的稳定性与原位算法的低内存占用相结合。它实现了 O(n log n) 的性能,并且只使用 O(1) 的额外内存,这使其成为现有最节省空间的稳定排序算法之一。
与分配完整大小临时数组的经典归并排序不同,WikiSort 通过旋转操作执行块归并,直接在数组内部合并已排序的区域。
我们要解决什么问题?
大多数稳定的排序算法(如归并排序)需要 O(n) 的额外空间。 WikiSort 通过在原位执行稳定归并来解决这个问题,使用了:
- 块旋转代替大型缓冲区
- 尽可能使用自适应归并
- 高效复用的小型本地缓冲区
非常适合:
- 内存受限的环境
- 嵌入式系统中排序大型数组
- 无需大量分配的稳定排序
示例
排序 [3, 5, 1, 2, 4]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 分割为有序段 | [3,5], [1,2,4] |
| 2 | 使用块旋转进行归并 | [1,2,3,4,5] |
| 3 | 完成,稳定且原地 | 最终排序后的数组 |
结果:[1,2,3,4,5],已排序、稳定且使用最少的额外空间。
它是如何工作的(通俗解释)?
想象两个已排序的书架,你不是把所有书都搬到桌子上,而是通过原地旋转部分书籍,使书架无缝合并。
WikiSort 保留一个小的本地缓冲区(像一个托盘),用它来移动小块数据,然后旋转数组中的段到合适位置。它永远不需要第二个完整的数组。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将数组分割成有序段 |
| 2 | 分配小缓冲区(约 √n 个元素) |
| 3 | 使用缓冲区合并相邻的有序段 |
| 4 | 旋转块以保持稳定性 |
| 5 | 重复直到只剩下一个有序区域 |
微型代码(简易版本)
Python(简化的原地稳定归并)
这是一个原地稳定归并的概念性演示。
def rotate(arr, start, mid, end):
arr[start:end] = arr[mid:end] + arr[start:mid]
def merge_in_place(arr, start, mid, end):
left = arr[start:mid]
i, j, k = 0, mid, start
while i < len(left) and j < end:
if left[i] <= arr[j]:
arr[k] = left[i]; i += 1
else:
val = arr[j]
rotate(arr, i + start, j, j + 1)
arr[k] = val
j += 1
k += 1
while i < len(left):
arr[k] = left[i]; i += 1; k += 1
def wiki_sort(arr):
n = len(arr)
size = 1
while size < n:
for start in range(0, n, 2 * size):
mid = min(start + size, n)
end = min(start + 2 * size, n)
if mid < end:
merge_in_place(arr, start, mid, end)
size *= 2
return arr
arr = [3,5,1,2,4]
print(wiki_sort(arr))输出:
$$1, 2, 3, 4, 5]C(简化的概念版本)
#include <stdio.h>
void rotate(int arr[], int start, int mid, int end) {
int temp[end - start];
int idx = 0;
for (int i = mid; i < end; i++) temp[idx++] = arr[i];
for (int i = start; i < mid; i++) temp[idx++] = arr[i];
for (int i = 0; i < end - start; i++) arr[start + i] = temp[i];
}
void merge_in_place(int arr[], int start, int mid, int end) {
int i = start, j = mid;
while (i < j && j < end) {
if (arr[i] <= arr[j]) {
i++;
} else {
int value = arr[j];
rotate(arr, i, j, j + 1);
arr[i] = value;
i++; j++;
}
}
}
void wiki_sort(int arr[], int n) {
for (int size = 1; size < n; size *= 2) {
for (int start = 0; start < n; start += 2 * size) {
int mid = (start + size < n) ? start + size : n;
int end = (start + 2 * size < n) ? start + 2 * size : n;
if (mid < end) merge_in_place(arr, start, mid, end);
}
}
}
int main(void) {
int arr[] = {3,5,1,2,4};
int n = sizeof(arr)/sizeof(arr[0]);
wiki_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它重要
- 稳定且原地(O(1) 额外空间)
- 适用于内存受限的系统
- 可预测的性能
- 缓存友好的归并
- 理论与实践的绝佳平衡
它结合了归并排序(稳定性)和原地算法(低内存)的优点。
一个温和的证明(为什么它有效)
每次归并花费 $O(n)$,并且有 $O(\log n)$ 层归并:
$$ T(n) = O(n \log n) $$
额外内存 = 小缓冲区($O(\sqrt{n})$)甚至常数空间。
| 阶段 | 操作 | 成本 |
|---|---|---|
| 块检测 | 扫描有序段 | $O(n)$ |
| 归并 | 基于旋转的归并 | $O(n \log n)$ |
| 空间 | 固定缓冲区 | $O(1)$ |
自己动手试试
- 逐步排序
[3,5,1,2,4]。 - 可视化归并过程中的旋转。
- 添加重复项
[2,2,3,1],验证稳定性。 - 将大小增加到 16,跟踪缓冲区复用情况。
- 与归并排序的内存使用情况比较。
- 测量交换次数与归并排序的比较。
- 尝试
[1,2,3,4],最小化旋转。 - 反转
[5,4,3,2,1],最大工作量。 - 实现块旋转辅助函数。
- 测量在已排序输入上的运行时间。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3,5,1,2,4] | [1,2,3,4,5] | 基础 |
| [5,4,3,2,1] | [1,2,3,4,5] | 最坏情况 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [2,2,3,1] | [1,2,2,3] | 稳定行为 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
WikiSort 是极简主义者的归并排序,稳定、优雅且几乎不占用内存。 它通过旋转而非复制来归并,流畅、稳定且节省空间。
139 GrailSort
GrailSort(全称“Greedy Adaptive In-place stable Sort”,即贪婪自适应原地稳定排序)是一种稳定的、原地的、基于比较的排序算法,它使用块合并和局部缓冲区来合并已排序的子数组。
GrailSort 由 Michał Oryńczak 创建,它结合了归并排序的稳定性和自适应性,并实现了原地操作,仅需一个微小的内部缓冲区(通常为 $O(\sqrt{n})$),甚至在其纯变体中不需要额外的内存。
该算法专为内存紧张时需要稳定排序的实际场景而设计,具有 $O(n \log n)$ 的最坏情况时间复杂度。
我们要解决什么问题?
典型的稳定排序算法(如归并排序)需要 O(n) 的额外空间。 GrailSort 通过以下方式解决这个问题:
- 使用小的局部缓冲区而非大数组
- 执行原地稳定合并
- 检测并重用自然游程(自适应行为)
非常适合:
- 内存受限的系统
- 嵌入式设备
- 有限 RAM 上的大型稳定排序
示例
对 [4, 1, 3, 2, 5] 排序
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 检测短游程 | [4,1], [3,2], [5] |
| 2 | 对每个游程排序 | [1,4], [2,3], [5] |
| 3 | 使用块旋转合并游程 | [1,2,3,4,5] |
| 4 | 保持稳定顺序 | 完成 |
所有合并都是原地完成的,只使用一个小的可重用缓冲区。
它是如何工作的?(通俗解释)
可以把 GrailSort 想象成一个聪明的图书管理员,他只有一张小桌子(缓冲区)。 他不是把所有的书都从书架上拿下来,而是:
- 将书架分成小的已排序组,
- 留出几本作为辅助缓冲区,
- 通过原地旋转部分书籍,直接在书架上合并书架。
它稳定、原地、自适应,这是一种罕见的组合。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 检测并对小游程排序(插入排序) |
| 2 | 选择小缓冲区(例如,√n 个元素) |
| 3 | 使用缓冲区和旋转成对合并游程 |
| 4 | 逐渐增加块大小(1, 2, 4, 8, ...) |
| 5 | 继续合并直到完全排序 |
算法的结构模仿归并排序,但使用块旋转来避免复制大块数据。
微型代码(简易版本)
Python(简化概念)
以下是受 GrailSort 原理启发的简化稳定块合并。
def rotate(arr, start, mid, end):
arr[start:end] = arr[mid:end] + arr[start:mid]
def merge_in_place(arr, left, mid, right):
i, j = left, mid
while i < j and j < right:
if arr[i] <= arr[j]:
i += 1
else:
val = arr[j]
rotate(arr, i, j, j + 1)
arr[i] = val
i += 1
j += 1
def grailsort(arr):
n = len(arr)
size = 1
while size < n:
for start in range(0, n, 2 * size):
mid = min(start + size, n)
end = min(start + 2 * size, n)
if mid < end:
merge_in_place(arr, start, mid, end)
size *= 2
return arr
arr = [4,1,3,2,5]
print(grailsort(arr))输出:
$$1, 2, 3, 4, 5]C(简化思路)
#include <stdio.h>
void rotate(int arr[], int start, int mid, int end) {
int temp[end - start];
int idx = 0;
for (int i = mid; i < end; i++) temp[idx++] = arr[i];
for (int i = start; i < mid; i++) temp[idx++] = arr[i];
for (int i = 0; i < end - start; i++) arr[start + i] = temp[i];
}
void merge_in_place(int arr[], int start, int mid, int end) {
int i = start, j = mid;
while (i < j && j < end) {
if (arr[i] <= arr[j]) i++;
else {
int val = arr[j];
rotate(arr, i, j, j + 1);
arr[i] = val;
i++; j++;
}
}
}
void grailsort(int arr[], int n) {
for (int size = 1; size < n; size *= 2) {
for (int start = 0; start < n; start += 2 * size) {
int mid = (start + size < n) ? start + size : n;
int end = (start + 2 * size < n) ? start + 2 * size : n;
if (mid < end) merge_in_place(arr, start, mid, end);
}
}
}
int main(void) {
int arr[] = {4,1,3,2,5};
int n = sizeof(arr)/sizeof(arr[0]);
grailsort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为什么它很重要
- 稳定且原地(仅需小缓冲区)
- 自适应,在部分已排序数据上更快
- 性能可预测
- 适用于内存有限的系统
- 用于实际的排序库和研究
它是稳定、低空间排序的黄金标准。
一个温和的证明(为什么它有效)
每个合并层级处理 $O(n)$ 个元素。 共有 $O(\log n)$ 个合并层级。 因此:
$$ T(n) = O(n \log n) $$
一个小的缓冲区($O(\sqrt{n})$)被重复使用,从而实现了原地稳定性。
| 阶段 | 操作 | 成本 |
|---|---|---|
| 游程排序 | 插入排序 | $O(n)$ |
| 块合并 | 基于旋转 | $O(n \log n)$ |
| 空间 | 局部缓冲区 | $O(1)$ 或 $O(\sqrt{n})$ |
亲自尝试
- 逐步排序
[4,1,3,2,5]。 - 尝试
[1,2,3,4,5],无需合并。 - 检查重复项
[2,2,1,1],验证稳定性。 - 用
[10,9,8,7,6,5]做实验。 - 可视化合并过程中的旋转。
- 更改块大小,观察性能。
- 手动实现 √n 缓冲区。
- 与归并排序(空间)比较。
- 测量与 WikiSort 的时间对比。
- 尝试大数组(1 万个元素)。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [4,1,3,2,5] | [1,2,3,4,5] | 基础测试 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 逆序 |
| [2,2,1,1] | [1,1,2,2] | 稳定 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(1) 到 O(√n) |
| 稳定 | 是 |
| 自适应 | 是 |
GrailSort 是排序领域温和的工程师,稳健、稳定且节省空间。 它不急于求成;而是精确地重新排列,从内部合并出秩序。
140 自适应混合排序
自适应混合排序是一种元排序算法,它根据检测到的数据特征和运行时模式,动态地结合多种排序策略,例如快速排序、归并排序、插入排序和堆排序。
它实时适应,根据数组大小、预排序程度、数据分布和递归深度等因素在不同方法之间切换。这使其成为一种适用于多样化工作负载的通用、实用的优化排序器。
我们要解决什么问题?
没有一种排序算法在所有情况下都是最好的:
- 快速排序在随机数据上很快,但不稳定,并且在最坏情况下表现糟糕。
- 归并排序稳定但耗内存。
- 插入排序对于小数组或几乎已排序的数组非常有效。
- 堆排序保证 O(n log n) 的时间复杂度,但局部性差。
自适应混合排序通过混合算法来解决这个问题:
- 从快速排序开始以获得速度。
- 检测到已排序或小区域 → 切换到插入排序。
- 检测到糟糕的枢轴选择模式 → 切换到堆排序。
- 检测到稳定性需求或模式 → 使用归并排序。
它是一个统一的、自调优的排序系统。
示例
排序 [2, 3, 5, 4, 6, 7, 8, 1]
| 步骤 | 检测 | 动作 |
|---|---|---|
| 1 | 除了最后几个元素外大部分已排序 | 切换到插入排序 |
| 2 | 局部排序 | [1,2,3,4,5,6,7,8] |
| 3 | 完成 | 自适应路径自动选择 |
如果输入是随机的,它将保持使用快速排序。如果是恶意构造的输入,它将转向堆排序。
它是如何工作的(通俗解释)?
想象一位技艺娴熟的厨师,拥有许多工具:刀、搅拌机、烤箱。 当切胡萝卜(小数据)时,他们使用削皮刀(插入排序)。 当处理坚硬的根茎(未排序的数组)时,他们抓起一把大刀(快速排序)。 当事情太复杂时,他们使用机器(归并排序)。
自适应混合排序以同样的方式工作,在正确的时间选择正确的工具。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从快速排序开始(平均情况良好) |
| 2 | 如果递归深度过高 → 切换到堆排序 |
| 3 | 如果子数组较小(≤ 阈值) → 使用插入排序 |
| 4 | 如果需要稳定排序 → 使用归并排序 |
| 5 | 如果数据部分已排序 → 使用类似 TimSort 的合并 |
| 6 | 合并结果以获得最终排序输出 |
精简代码(简易版本)
Python(简化混合排序)
def insertion_sort(arr, left, right):
for i in range(left + 1, right):
key = arr[i]
j = i - 1
while j >= left and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
def heapify(arr, n, i):
largest = i
l = 2*i + 1
r = 2*i + 2
if l < n and arr[l] > arr[largest]:
largest = l
if r < n and arr[r] > arr[largest]:
largest = r
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
def heap_sort(arr):
n = len(arr)
for i in range(n//2 - 1, -1, -1):
heapify(arr, n, i)
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0]
heapify(arr, i, 0)
def partition(arr, low, high):
pivot = arr[(low + high) // 2]
i, j = low, high
while i <= j:
while arr[i] < pivot: i += 1
while arr[j] > pivot: j -= 1
if i <= j:
arr[i], arr[j] = arr[j], arr[i]
i += 1; j -= 1
return i
def hybrid_sort(arr, low=0, high=None, depth_limit=None):
if high is None:
high = len(arr) - 1
if depth_limit is None:
import math
depth_limit = 2 * math.floor(math.log2(len(arr) + 1))
size = high - low + 1
if size <= 16:
insertion_sort(arr, low, high + 1)
return
if depth_limit == 0:
sub = arr[low:high + 1]
heap_sort(sub)
arr[low:high + 1] = sub
return
pivot_index = partition(arr, low, high)
if low < pivot_index - 1:
hybrid_sort(arr, low, pivot_index - 1, depth_limit - 1)
if pivot_index < high:
hybrid_sort(arr, pivot_index, high, depth_limit - 1)
arr = [2, 3, 5, 4, 6, 7, 8, 1]
hybrid_sort(arr)
print(arr)输出:
$$1, 2, 3, 4, 5, 6, 7, 8]C(概念性混合排序)
#include <stdio.h>
#include <math.h>
void insertion_sort(int arr[], int left, int right) {
for (int i = left + 1; i < right; i++) {
int key = arr[i], j = i - 1;
while (j >= left && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
void heapify(int arr[], int n, int i) {
int largest = i;
int l = 2*i + 1, r = 2*i + 2;
if (l < n && arr[l] > arr[largest]) largest = l;
if (r < n && arr[r] > arr[largest]) largest = r;
if (largest != i) {
int tmp = arr[i]; arr[i] = arr[largest]; arr[largest] = tmp;
heapify(arr, n, largest);
}
}
void heap_sort(int arr[], int n) {
for (int i = n/2 - 1; i >= 0; i--) heapify(arr, n, i);
for (int i = n - 1; i > 0; i--) {
int tmp = arr[0]; arr[0] = arr[i]; arr[i] = tmp;
heapify(arr, i, 0);
}
}
int partition(int arr[], int low, int high) {
int pivot = arr[(low + high) / 2];
int i = low, j = high;
while (i <= j) {
while (arr[i] < pivot) i++;
while (arr[j] > pivot) j--;
if (i <= j) {
int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
i++; j--;
}
}
return i;
}
void hybrid_sort(int arr[], int low, int high, int depth_limit) {
int size = high - low + 1;
if (size <= 16) { insertion_sort(arr, low, high + 1); return; }
if (depth_limit == 0) { heap_sort(arr + low, size); return; }
int p = partition(arr, low, high);
if (low < p - 1) hybrid_sort(arr, low, p - 1, depth_limit - 1);
if (p < high) hybrid_sort(arr, p, high, depth_limit - 1);
}
int main(void) {
int arr[] = {2,3,5,4,6,7,8,1};
int n = sizeof(arr)/sizeof(arr[0]);
int depth_limit = 2 * log2(n);
hybrid_sort(arr, 0, n - 1, depth_limit);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}为何重要
- 适应输入形态
- 混合 = 灵活性 + 安全性
- 跨数据类型的稳定运行时
- 对混合数据的现实世界鲁棒性
- 平衡速度、内存、稳定性和可预测性
一个温和的证明(为何有效)
令 $T(n)$ 为运行时间。 每个阶段都是 $O(n)$,递归深度是 $O(\log n)$。 自适应切换确保了没有病态行为。
$$ T(n) = O(n \log n) $$
最佳情况(已排序):插入排序以 $O(n)$ 运行。 最坏情况(恶意构造):堆排序后备 → $O(n \log n)$。
| 情况 | 行为 | 复杂度 |
|---|---|---|
| 已排序 | 插入排序 | $O(n)$ |
| 随机 | 快速排序 | $O(n \log n)$ |
| 恶意构造 | 堆排序 | $O(n \log n)$ |
亲自尝试
- 排序
[2,3,5,4,6,7,8,1]。 - 尝试
[1,2,3,4,5,6]→ 插入排序路径。 - 反转
[9,8,7,6,5]→ 堆排序路径。 - 混合已排序 + 随机的一半。
- 测量递归深度。
- 将阈值增加到 32。
- 添加重复项 → 观察稳定性。
- 与 IntroSort、TimSort 比较。
- 在大规模随机数据上进行基准测试。
- 可视化切换决策。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [2,3,5,4,6,7,8,1] | [1,2,3,4,5,6,7,8] | 随机 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已排序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 反转 |
| [10,10,9,9,8] | [8,9,9,10,10] | 重复项 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n log n) |
| 空间 | O(log n) |
| 稳定 | 否 |
| 自适应 | 是 |
自适应混合排序是排序中的变色龙,它观察数据,审时度势,并选择完美的行动。 在可能时快速,在必要时安全。
第 15 节. 特殊排序
141 循环排序
循环排序是一种基于比较的排序算法,旨在最小化写入操作的次数。当向内存或存储设备写入数据代价高昂时(例如 EEPROM 或闪存),它非常理想,因为每个元素都恰好被写入其最终位置一次。
它需要进行 O(n²) 次比较,但执行了可能的最小写入次数,这使其在排序算法中独树一帜。
我们要解决什么问题?
大多数排序算法(如快速排序或归并排序)在元素到达其最终位置之前会多次交换元素。如果每次写入操作代价都很高(例如嵌入式系统或闪存),那就很浪费。
循环排序提出的问题是: “我们如何才能让每个元素在一个循环中直接到达其归属位置,同时尽可能减少写入次数?”
示例
排序 [3, 1, 2]
| 步骤 | 元素 | 正确位置 | 操作 |
|---|---|---|---|
| 1 | 3 | 索引 2 | 交换 3 → 位置 2 |
| 2 | 2 | 索引 1 | 交换 2 → 位置 1 |
| 3 | 1 | 索引 0 | 完成 |
| 最终 | [1, 2, 3] | 已排序 | ✅ 最小写入次数 |
每个元素都循环一次到位。
它是如何工作的(通俗解释)?
可以把它想象成把书放到书架上:
- 你拿起一本书(元素),
- 弄清楚它应该放在哪里,
- 把它放到那里,并与当前在那里的任何东西交换,
- 重复直到每本书都在正确的位置。
每个循环确保每个元素恰好到达其最终位置一次。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 遍历数组位置 |
| 2 | 对于每个位置,计算有多少个元素比它小 |
| 3 | 该计数 = 最终位置 |
| 4 | 如果尚未正确,则将该元素循环到其正确位置 |
| 5 | 继续循环直到原始元素回到起点 |
| 6 | 移动到下一个位置并重复 |
精简代码(简易版本)
Python
def cycle_sort(arr):
n = len(arr)
writes = 0
for cycle_start in range(n - 1):
item = arr[cycle_start]
pos = cycle_start
for i in range(cycle_start + 1, n):
if arr[i] < item:
pos += 1
if pos == cycle_start:
continue
while item == arr[pos]:
pos += 1
arr[pos], item = item, arr[pos]
writes += 1
while pos != cycle_start:
pos = cycle_start
for i in range(cycle_start + 1, n):
if arr[i] < item:
pos += 1
while item == arr[pos]:
pos += 1
arr[pos], item = item, arr[pos]
writes += 1
print("Total writes:", writes)
return arr
arr = [3, 1, 2, 4]
print(cycle_sort(arr))输出:
Total writes: 3
[1, 2, 3, 4]C
#include <stdio.h>
void cycle_sort(int arr[], int n) {
int writes = 0;
for (int cycle_start = 0; cycle_start < n - 1; cycle_start++) {
int item = arr[cycle_start];
int pos = cycle_start;
for (int i = cycle_start + 1; i < n; i++)
if (arr[i] < item)
pos++;
if (pos == cycle_start) continue;
while (item == arr[pos]) pos++;
int temp = arr[pos]; arr[pos] = item; item = temp;
writes++;
while (pos != cycle_start) {
pos = cycle_start;
for (int i = cycle_start + 1; i < n; i++)
if (arr[i] < item)
pos++;
while (item == arr[pos]) pos++;
temp = arr[pos]; arr[pos] = item; item = temp;
writes++;
}
}
printf("Total writes: %d\n", writes);
}
int main(void) {
int arr[] = {3, 1, 2, 4};
int n = sizeof(arr) / sizeof(arr[0]);
cycle_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
Total writes: 3
1 2 3 4为什么它很重要
- 最小化写入次数(对闪存、EEPROM 有用)
- 原地排序
- 确定性写入 = 更少的磨损周期
- 排序中置换循环的教学示例
它不快,但很节俭,每一次移动都算数。
一个温和的证明(为什么它有效)
每个元素移动到其正确位置一次。 如果数组大小为 $n$,总写入次数 $\le n$。
计算较小元素确保了正确性: $$ \text{pos}(x) = |{y \mid y < x}| $$
每个循环解决一个错位元素的置换循环。 因此,算法终止时所有元素都恰好放置一次。
亲自尝试
- 逐步排序
[3, 1, 2]。 - 计算你执行了多少次写入操作。
- 尝试
[4, 3, 2, 1],最大循环数。 - 尝试
[1, 2, 3, 4],无写入操作。 - 测试重复项
[3, 1, 2, 3]。 - 实现一个计算循环次数的版本。
- 与选择排序比较写入次数。
- 对 1000 个随机元素进行基准测试。
- 衡量对闪存的磨损均衡益处。
- 在置换图中将循环可视化为箭头。
测试用例
| 输入 | 输出 | 写入次数 | 备注 |
|---|---|---|---|
| [3,1,2,4] | [1,2,3,4] | 3 | 3 个循环 |
| [1,2,3] | [1,2,3] | 0 | 已排序 |
| [4,3,2,1] | [1,2,3,4] | 4 | 最大写入次数 |
| [3,1,2,3] | [1,2,3,3] | 3 | 处理重复项 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(n²) |
| 写入次数 | ≤ n |
| 空间 | O(1) |
| 稳定 | 否 |
| 自适应 | 否 |
循环排序是极简主义者的排序器,每一次写入都是有意的,每一次移动都是有意义的。它可能不快,但精确高效。
142 梳排序
梳排序是冒泡排序的改进版本,它通过首先比较相距较远的元素,采用缩小的间隔策略,更快地消除小元素(通常称为“乌龟”)。
它从一个较大的间隔(例如数组长度)开始,并在每一轮中缩小该间隔,直到间隔为 1,此时其行为类似于常规的冒泡排序。结果是减少了比较次数并加快了收敛速度。
我们要解决什么问题?
冒泡排序简单但速度慢,主要是因为:
- 它只交换相邻元素。
- 小元素缓慢地向前移动。
梳排序通过使用一个间隔来跳过元素,从而解决了这个问题,允许“乌龟”快速向前移动。
这就像用梳子排序,先用宽齿(大间隔),然后用细齿(小间隔)。
示例
对 [8, 4, 1, 3, 7] 进行排序
| 步骤 | 间隔 | 单轮结果 | 说明 |
|---|---|---|---|
| 1 | 5 / 1.3 ≈ 3 | [3, 4, 1, 8, 7] | 比较 8↔3 |
| 2 | 3 / 1.3 ≈ 2 | [1, 4, 3, 8, 7] | 比较 3↔1 |
| 3 | 2 / 1.3 ≈ 1 | [1, 3, 4, 7, 8] | 冒泡完成 |
| 完成 | , | [1, 3, 4, 7, 8] | 已排序 |
乌龟(1, 3)更早地向前跳跃,加快了收敛速度。
它是如何工作的(通俗解释)?
可以把它想象成缩短一根跳绳,开始时,你大步跳跃以快速覆盖地面,然后进行较小的跳跃以进行微调。
你从一个间隔开始,比较并交换相距该间隔的元素,每轮缩小间隔,当间隔达到 1 且没有发生交换 时停止。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 初始化 gap = n 和 shrink = 1.3 |
| 2 | 重复直到 gap == 1 且没有交换 |
| 3 | 每轮将 gap 除以收缩因子 |
| 4 | 比较距离为 gap 的元素 |
| 5 | 如果顺序错误则交换 |
| 6 | 继续直到排序完成 |
微型代码(简易版本)
Python
def comb_sort(arr):
n = len(arr)
gap = n
shrink = 1.3
swapped = True
while gap > 1 or swapped:
gap = int(gap / shrink)
if gap < 1:
gap = 1
swapped = False
for i in range(n - gap):
if arr[i] > arr[i + gap]:
arr[i], arr[i + gap] = arr[i + gap], arr[i]
swapped = True
return arr
arr = [8, 4, 1, 3, 7]
print(comb_sort(arr))输出:
[1, 3, 4, 7, 8]C
#include <stdio.h>
void comb_sort(int arr[], int n) {
int gap = n;
const float shrink = 1.3;
int swapped = 1;
while (gap > 1 || swapped) {
gap = (int)(gap / shrink);
if (gap < 1) gap = 1;
swapped = 0;
for (int i = 0; i + gap < n; i++) {
if (arr[i] > arr[i + gap]) {
int tmp = arr[i];
arr[i] = arr[i + gap];
arr[i + gap] = tmp;
swapped = 1;
}
}
}
}
int main(void) {
int arr[] = {8, 4, 1, 3, 7};
int n = sizeof(arr) / sizeof(arr[0]);
comb_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
1 3 4 7 8为什么它重要
- 比冒泡排序快
- 实现简单
- 原地排序且自适应
- 对于小数据集或接近排序的数组高效
是通往更高效算法(如希尔排序)的垫脚石。
一个温和的证明(为什么它有效)
收缩因子确保间隔在 $O(\log n)$ 步内收敛到 1。
每一轮都尽早修正了远距离的逆序对,减少了总的交换次数。
总成本主要由间隔为 1 时的局部轮次(冒泡排序)主导。
因此,对于随机数据,平均复杂度约为 $O(n \log n)$,最坏情况为 $O(n^2)$。
| 阶段 | 描述 | 成本 |
|---|---|---|
| 大间隔轮次 | 将乌龟向前移动 | $O(n \log n)$ |
| 小间隔轮次 | 最终微调 | $O(n^2)$ 最坏情况 |
自己动手试试
- 逐步排序
[8, 4, 1, 3, 7]。 - 尝试
[1, 2, 3, 4, 5],观察最小轮次。 - 尝试
[5, 4, 3, 2, 1],观察间隔缩小过程。 - 更改收缩因子(1.5, 1.2)。
- 测量每次迭代的交换次数。
- 与冒泡排序进行比较。
- 可视化最小元素的移动。
- 对大型随机数组进行基准测试。
- 跟踪间隔随时间的变化。
- 实现提前停止优化。
测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
| [8,4,1,3,7] | [1,3,4,7,8] | 基本测试 |
| [1,2,3,4,5] | [1,2,3,4,5] | 已排序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 逆序 |
| [4,1,3,2] | [1,2,3,4] | 短数组 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(n log n) |
| 时间(平均) | O(n log n) |
| 时间(最坏) | O(n²) |
| 空间 | O(1) |
| 稳定 | 否 |
| 自适应 | 是 |
梳排序就像用梳子梳理打结的头发一样梳理数据,先是大范围梳理,然后是精细梳理,直到一切平滑有序。
143 侏儒排序
侏儒排序是一种简单的基于比较的排序算法,其工作方式类似于花园侏儒整理花盆:它查看两个相邻元素,如果它们顺序不对就交换它们,然后后退一步再次检查前一对元素。
它在概念上类似于插入排序,但使用单个循环实现,没有嵌套结构,这使得它对于学习者来说优雅且直观。
我们要解决什么问题?
插入排序需要嵌套循环或递归,这可能难以形象化理解。 侏儒排序通过简单的前后移动提供了相同的逻辑:
- 如果元素有序,则向前移动。
- 如果无序,则后退并交换。
这就创建了一个类似人类行为的排序例程:向前一步,修正,后退一步,重复。
示例
对 [5, 3, 4, 2] 排序
| 步骤 | 索引 | 操作 | 结果 |
|---|---|---|---|
| 1 | 1 | 5 > 3 → 交换 | [3, 5, 4, 2] |
| 2 | 0 | 在起点 → 向前移动 | [3, 5, 4, 2] |
| 3 | 2 | 5 > 4 → 交换 | [3, 4, 5, 2] |
| 4 | 1 | 3 < 4 → 向前 | [3, 4, 5, 2] |
| 5 | 3 | 5 > 2 → 交换 | [3, 4, 2, 5] |
| 6 | 2 | 4 > 2 → 交换 | [3, 2, 4, 5] |
| 7 | 1 | 3 > 2 → 交换 | [2, 3, 4, 5] |
| 8 | 0 | 完成 | [2, 3, 4, 5] |
通过局部修正完成排序,没有显式的嵌套循环。
它是如何工作的(通俗解释)?
想象一个侏儒在一排花盆前行走:
- 如果两个花盆顺序正确,就向前走一步。
- 如果顺序不对,就交换它们,然后后退一步。
- 如果到达了起点,就再次向前移动。
继续行走直到到达末尾,花园(数组)就排好序了。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从索引 1 开始 |
| 2 | 比较 arr[i] 与 arr[i-1] |
| 3 | 如果 arr[i] >= arr[i-1],向前移动 (i++) |
| 4 | 否则,交换 arr[i] 和 arr[i-1],后退 (i--) |
| 5 | 如果 i == 0,移动到 i = 1 |
| 6 | 重复直到 i 达到 n |
微型代码(简易版本)
Python
def gnome_sort(arr):
i = 1
n = len(arr)
while i < n:
if i == 0 or arr[i] >= arr[i - 1]:
i += 1
else:
arr[i], arr[i - 1] = arr[i - 1], arr[i]
i -= 1
return arr
arr = [5, 3, 4, 2]
print(gnome_sort(arr))输出:
[2, 3, 4, 5]C
#include <stdio.h>
void gnome_sort(int arr[], int n) {
int i = 1;
while (i < n) {
if (i == 0 || arr[i] >= arr[i - 1]) {
i++;
} else {
int tmp = arr[i];
arr[i] = arr[i - 1];
arr[i - 1] = tmp;
i--;
}
}
}
int main(void) {
int arr[] = {5, 3, 4, 2};
int n = sizeof(arr) / sizeof(arr[0]);
gnome_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
2 3 4 5为什么它重要
- 简单的思维模型,易于理解。
- 没有嵌套循环,控制流清晰。
- 原地排序,无需额外空间。
- 展示了排序中的局部修正。
它比高级算法慢,但非常适合教学目的。
一个温和的证明(为什么它有效)
每次交换都将一个元素向其正确位置移动一步。 每当发生交换时,侏儒会后退一步以确保局部有序。
由于每个逆序最终都会被修正,算法终止时数组已排序。
交换次数与逆序数量成正比 → $O(n^2)$。
| 情况 | 行为 | 复杂度 |
|---|---|---|
| 已排序 | 线性扫描 | O(n) |
| 随机 | 频繁交换 | O(n²) |
| 逆序 | 最大交换次数 | O(n²) |
亲自尝试
- 手动逐步排序
[5,3,4,2]。 - 尝试
[1,2,3,4],步骤最少。 - 尝试
[4,3,2,1],最坏情况。 - 计算交换次数。
- 与插入排序比较。
- 跟踪每次交换后的索引变化。
- 实现可视化动画(指针移动)。
- 尝试包含重复元素
[2,1,2,1]。 - 测量 n = 1000 时的运行时间。
- 添加提前退出优化。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [5,3,4,2] | [2,3,4,5] | 基础 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [4,3,2,1] | [1,2,3,4] | 逆序 |
| [2,1,2,1] | [1,1,2,2] | 重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最好) | O(n) |
| 时间(平均) | O(n²) |
| 时间(最坏) | O(n²) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
侏儒排序是一个友好的、逐步进行的排序器,它不急于求成,只是一次整理一个花盆,直到整排都井然有序。
144 鸡尾酒排序
鸡尾酒排序(也称为双向冒泡排序或摇晃排序)是冒泡排序的一种简单变体,它在每一轮中交替地双向遍历列表,先向前再向后。
这种双向移动有助于小元素("乌龟")更快地从末尾"冒泡"上来,从而解决了冒泡排序的一个主要弱点。
我们要解决什么问题?
冒泡排序只朝一个方向移动元素,大的元素会浮到末尾,但小的元素会缓慢地爬向开头。
鸡尾酒排序通过"摇晃"列表来解决这个问题:
- 向前遍历:将大项向右移动
- 向后遍历:将小项向左移动
这使得它在接近有序的数组上或当两端都需要"清理"时效率更高。
示例
排序 [4, 3, 1, 2]
| 步骤 | 方向 | 操作 | 结果 |
|---|---|---|---|
| 1 | 向前 | 比较并交换 4↔3, 3↔1, 4↔2 | [3,1,2,4] |
| 2 | 向后 | 比较并交换 2↔1, 3↔1 | [1,3,2,4] |
| 3 | 向前 | 比较并交换 3↔2 | [1,2,3,4] |
| 完成 | 已排序 | ✅ |
比冒泡排序的轮次更少。
它是如何工作的(通俗解释)?
想象一下调酒师来回摇晃鸡尾酒调酒器,每一次摇晃都让配料(元素)从两侧更接近正确的位置。
你遍历数组:
- 从左到右:将最大的元素推到末尾
- 从右到左:将最小的元素推到开头
当没有交换发生时停止,数组即已排序。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 初始化 swapped = True |
| 2 | 当 swapped 为真时: |
| a. 设置 swapped = False | |
| b. 向前遍历 (i = 起始 → 末尾): 如果 arr[i] > arr[i+1] 则交换 | |
| c. 如果 swapped == False: 中断(已排序) | |
| d. 向后遍历 (i = 末尾-1 → 起始): 如果 arr[i] > arr[i+1] 则交换 | |
| 3 | 重复直到排序完成 |
简洁代码(简易版本)
Python
def cocktail_sort(arr):
n = len(arr)
swapped = True
start = 0
end = n - 1
while swapped:
swapped = False
# 向前遍历
for i in range(start, end):
if arr[i] > arr[i + 1]:
arr[i], arr[i + 1] = arr[i + 1], arr[i]
swapped = True
if not swapped:
break
swapped = False
end -= 1
# 向后遍历
for i in range(end - 1, start - 1, -1):
if arr[i] > arr[i + 1]:
arr[i], arr[i + 1] = arr[i + 1], arr[i]
swapped = True
start += 1
return arr
arr = [4, 3, 1, 2]
print(cocktail_sort(arr))输出:
$$1, 2, 3, 4]C
#include <stdio.h>
void cocktail_sort(int arr[], int n) {
int start = 0, end = n - 1, swapped = 1;
while (swapped) {
swapped = 0;
// 向前遍历
for (int i = start; i < end; i++) {
if (arr[i] > arr[i + 1]) {
int tmp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = tmp;
swapped = 1;
}
}
if (!swapped) break;
swapped = 0;
end--;
// 向后遍历
for (int i = end - 1; i >= start; i--) {
if (arr[i] > arr[i + 1]) {
int tmp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = tmp;
swapped = 1;
}
}
start++;
}
}
int main(void) {
int arr[] = {4, 3, 1, 2};
int n = sizeof(arr) / sizeof(arr[0]);
cocktail_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
1 2 3 4为什么它重要
- 对冒泡排序的双向改进
- 原地排序且简单
- 在接近有序的数据上表现良好
- 自适应的,当排序完成时提前停止
是理解双向扫描和自适应排序的绝佳教学桥梁。
一个温和的证明(为什么它有效)
每次向前遍历将最大的元素推到右边。 每次向后遍历将最小的元素推到左边。
每个完整循环后,已排序区域从两端扩展。 当没有交换发生时(已排序),算法停止。
总操作数取决于逆序对的数量: $$ O(n^2) \text{ 最坏情况}, \quad O(n) \text{ 最好情况(已排序输入)} $$
| 情况 | 行为 | 复杂度 |
|---|---|---|
| 已排序 | 一次双向扫描 | O(n) |
| 随机 | 多次交换 | O(n²) |
| 逆序 | 最大轮次 | O(n²) |
亲自尝试
- 手动逐步排序
[4,3,1,2]。 - 尝试
[1,2,3,4],应该会提前停止。 - 尝试
[5,4,3,2,1],观察摇晃效果。 - 计算每一轮的交换次数。
- 与冒泡排序的轮次进行比较。
- 可视化向前/向后移动。
- 添加一个"交换计数器"变量。
- 测试重复元素
[3,1,3,2,1]。 - 测量在接近有序数据上的性能。
- 修改收缩窗口的大小。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [4,3,1,2] | [1,2,3,4] | 基础 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [5,4,3,2,1] | [1,2,3,4,5] | 逆序 |
| [3,1,3,2,1] | [1,1,2,3,3] | 重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最好) | O(n) |
| 时间(平均) | O(n²) |
| 时间(最坏) | O(n²) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
鸡尾酒排序不是简单地冒泡,而是将数据"摇晃"成序,确保每一轮两端都得到关注。
145 煎饼排序
煎饼排序是一种有趣且富有教育意义的排序算法,灵感来源于翻转盘子上的煎饼。在每一步中,你将最大的未排序煎饼带到顶部,然后翻转整个堆栈将其移动到正确的位置。
对于大型数据集来说,它并不实用,但它是说明前缀翻转、最大值选择和原地转换的绝佳方式。
我们要解决什么问题?
标准的选择排序是成对交换元素。 煎饼排序则使用前缀翻转,即翻转数组的整个部分,来将最大的元素移动到其位置。
这是一个关于受限操作的思想实验: 如果唯一能做的事情就是翻转,会怎样?
示例
排序 [3, 6, 1, 5, 2, 4]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 找到最大值 (6) → 索引 1,翻转前 2 个元素 | [6, 3, 1, 5, 2, 4] |
| 2 | 翻转前 6 个元素 | [4, 2, 5, 1, 3, 6] |
| 3 | 找到最大值 (5) → 索引 2,翻转前 3 个元素 | [5, 2, 4, 1, 3, 6] |
| 4 | 翻转前 5 个元素 | [3, 1, 4, 2, 5, 6] |
| 5 | 对更小的前缀重复此过程 | [1, 2, 3, 4, 5, 6] |
仅使用翻转完成排序!
它是如何工作的(通俗解释)?
想象翻转一叠煎饼:
- 找到尚未放置的最大煎饼。
- 翻转堆栈将其带到顶部。
- 再次翻转将其移动到最终位置。
重复此过程,每次排除已排序的顶部部分。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 找到未排序部分中最大元素的索引 |
| 2 | 翻转从开始到该索引的子数组 |
| 3 | 翻转整个未排序子数组以将其移动到末尾 |
| 4 | 将未排序部分的大小减一 |
| 5 | 重复直到排序完成 |
微型代码(简易版本)
Python
def flip(arr, k):
arr[:k+1] = reversed(arr[:k+1]) # 翻转前 k+1 个元素
def pancake_sort(arr):
n = len(arr)
for curr_size in range(n, 1, -1):
max_idx = arr.index(max(arr[:curr_size])) # 找到当前未排序部分最大值的索引
if max_idx != curr_size - 1:
flip(arr, max_idx) # 将最大值翻转到顶部
flip(arr, curr_size - 1) # 将最大值翻转到其最终位置
return arr
arr = [3, 6, 1, 5, 2, 4]
print(pancake_sort(arr))输出:
[1, 2, 3, 4, 5, 6]C
#include <stdio.h>
void flip(int arr[], int k) {
int start = 0;
while (start < k) {
int temp = arr[start];
arr[start] = arr[k];
arr[k] = temp;
start++;
k--;
}
}
int find_max(int arr[], int n) {
int max_idx = 0;
for (int i = 1; i < n; i++)
if (arr[i] > arr[max_idx])
max_idx = i;
return max_idx;
}
void pancake_sort(int arr[], int n) {
for (int size = n; size > 1; size--) {
int max_idx = find_max(arr, size);
if (max_idx != size - 1) {
flip(arr, max_idx);
flip(arr, size - 1);
}
}
}
int main(void) {
int arr[] = {3, 6, 1, 5, 2, 4};
int n = sizeof(arr) / sizeof(arr[0]);
pancake_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
1 2 3 4 5 6为什么它重要
- 前缀操作的有趣演示
- 原地且简单
- 展示了受限操作如何仍然可以排序
- 理论兴趣,是煎饼网络的基础
- 用于生物信息学(基因组重排)
一个温和的证明(为什么它有效)
每次迭代都将剩余的最大元素放置在其最终索引处。 每次迭代最多进行两次翻转(最坏情况)。 总共最多进行 $2(n - 1)$ 次翻转。
正确性源于:
- 翻转是一种反转,除了翻转段内部,它保持了顺序。
- 每个最大的元素在放置后就被锁定在末尾。
$$ T(n) = O(n^2) $$ 因为每个 max() 和 flip() 操作都是 O(n)。
亲自尝试
- 手动排序
[3,6,1,5,2,4]。 - 可视化追踪每次翻转。
- 尝试
[1,2,3,4],不需要翻转。 - 反转
[4,3,2,1],观察最大翻转次数。 - 计算每次迭代的翻转次数。
- 实现翻转可视化。
- 用手动搜索替换
max()。 - 打印中间数组。
- 分析随机输入的翻转次数。
- 挑战:实现递归版本。
测试用例
| 输入 | 输出 | 翻转次数 | 备注 |
|---|---|---|---|
| [3,6,1,5,2,4] | [1,2,3,4,5,6] | 8 | 经典案例 |
| [1,2,3,4] | [1,2,3,4] | 0 | 已排序 |
| [4,3,2,1] | [1,2,3,4] | 6 | 最坏情况 |
| [2,1,3] | [1,2,3] | 3 | 小数组 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n²) |
| 时间(平均) | O(n²) |
| 时间(最好) | O(n) |
| 空间 | O(1) |
| 稳定 | 否 |
| 自适应 | 否 |
煎饼排序以其翻转的方式走向胜利,这是在约束条件下展现创造力的迷人例子。 你不需要花哨的工具,只需要一把好锅铲和一些耐心。
146 双调排序
双调排序是一种为排序网络设计的并行排序算法。它通过构建和合并双调序列(即先递增后递减,或先递减后递增的序列)来工作。
它在硬件、GPU 和并行处理器上尤其强大,因为可以同时进行多次比较。
我们要解决什么问题?
大多数标准算法(快速排序、归并排序)是数据依赖的,它们的流程会根据比较结果而变化。这对于硬件或并行系统来说是个问题。
双调排序通过固定的比较模式解决了这个问题,非常适合并行执行。
它回答了以下问题:
"我们如何使用可预测的、固定的电路进行并行排序?"
示例
排序 [3, 7, 4, 8, 6, 2, 1, 5]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 构建双调序列 ↑↓ | [3,7,4,8, 6,2,1,5] |
| 2 | 比较并交换成对元素 | [3,2,1,5, 6,7,4,8] |
| 3 | 递归合并两半 | [1,2,3,4, 5,6,7,8] |
每个阶段都会使已排序的子序列长度翻倍,直到整个数组排序完成。
它是如何工作的(通俗解释)?
想象一下你在排序卡片,但你只能在固定的位置比较成对的卡片,不能做自适应的决策。
你首先构建一个双调序列(先上后下),然后使用双调合并步骤将其合并成一个有序列表,反复比较并交换镜像位置的元素。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将数组分成两半 |
| 2 | 前半部分升序排序,后半部分降序排序 |
| 3 | 将它们组合成一个双调序列 |
| 4 | 运行双调合并:递归地比较-交换成对元素 |
| 5 | 重复直到序列大小 = 1 |
当 n = 2^k(2 的幂)时效果最佳。
精简代码(简易版本)
Python
def bitonic_merge(arr, low, cnt, direction):
if cnt > 1:
k = cnt // 2
for i in range(low, low + k):
if (direction and arr[i] > arr[i + k]) or (not direction and arr[i] < arr[i + k]):
arr[i], arr[i + k] = arr[i + k], arr[i]
bitonic_merge(arr, low, k, direction)
bitonic_merge(arr, low + k, k, direction)
def bitonic_sort(arr, low=0, cnt=None, direction=True):
if cnt is None:
cnt = len(arr)
if cnt > 1:
k = cnt // 2
bitonic_sort(arr, low, k, True)
bitonic_sort(arr, low + k, k, False)
bitonic_merge(arr, low, cnt, direction)
arr = [3, 7, 4, 8, 6, 2, 1, 5]
bitonic_sort(arr)
print(arr)输出:
[1, 2, 3, 4, 5, 6, 7, 8]C
#include <stdio.h>
void swap(int *a, int *b) {
int t = *a; *a = *b; *b = t;
}
void bitonic_merge(int arr[], int low, int cnt, int dir) {
if (cnt > 1) {
int k = cnt / 2;
for (int i = low; i < low + k; i++) {
if ((dir && arr[i] > arr[i + k]) || (!dir && arr[i] < arr[i + k]))
swap(&arr[i], &arr[i + k]);
}
bitonic_merge(arr, low, k, dir);
bitonic_merge(arr, low + k, k, dir);
}
}
void bitonic_sort(int arr[], int low, int cnt, int dir) {
if (cnt > 1) {
int k = cnt / 2;
bitonic_sort(arr, low, k, 1);
bitonic_sort(arr, low + k, k, 0);
bitonic_merge(arr, low, cnt, dir);
}
}
int main(void) {
int arr[] = {3, 7, 4, 8, 6, 2, 1, 5};
int n = sizeof(arr) / sizeof(arr[0]);
bitonic_sort(arr, 0, n, 1);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
1 2 3 4 5 6 7 8为什么它很重要
- 并行友好(排序网络)
- 确定性结构(无分支)
- 非常适合硬件、GPU、SIMD
- 是分治 + 合并的优秀教学模型
它不在于 CPU 上的运行时间,而在于并行深度。
一个温和的证明(为什么它有效)
双调序列: 一个先递增后递减的序列是双调的。
合并规则: 将每个元素与其镜像元素比较;递归地合并两半。
在每个合并阶段,数组变得更有序。 递归深度 = $\log n$,每层做 $O(n)$ 的工作 → $O(n \log^2 n)$。
| 步骤 | 工作量 | 层数 | 总计 |
|---|---|---|---|
| 合并 | $O(n)$ | $\log n$ | $O(n \log^2 n)$ |
亲自尝试
- 手动排序
[3,7,4,8,6,2,1,5]。 - 识别每个阶段的双调序列。
- 尝试用 4 个元素
[4,1,3,2]。 - 改变方向标志(升序/降序)。
- 绘制比较网络图。
- 实现迭代版本。
- 在 2 的幂次大小上运行。
- 测量并行步骤与快速排序的对比。
- 在 GPU(Numba/CUDA)上实验。
- 可视化递归结构。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3,7,4,8,6,2,1,5] | [1,2,3,4,5,6,7,8] | 标准 |
| [4,1,3,2] | [1,2,3,4] | 小规模情况 |
| [5,4,3,2,1,0,9,8] | [0,1,2,3,4,5,8,9] | 反向 |
| [8,4,2,1,3,6,5,7] | [1,2,3,4,5,6,7,8] | 随机 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(n log² n) |
| 空间复杂度 | O(1) |
| 稳定性 | 否 |
| 自适应性 | 否 |
| 并行深度 | O(log² n) |
双调排序在并行性主导的领域大放异彩,例如 GPU、电路和排序网络。 每一次比较都是计划好的,每一次移动都是同步的,这是一场在固定节奏下秩序的交响乐。
147 奇偶归并排序
奇偶归并排序是一种并行排序算法,也是一个排序网络,它通过比较奇数索引和偶数索引元素之间的固定模式来合并两个已排序的序列。
它由 Ken Batcher 提出,与双调排序类似,专为并行硬件或 SIMD 处理器设计,在这些场景下,可预测的比较模式比数据相关的分支更重要。
我们要解决什么问题?
传统的归并算法依赖于条件分支,它们在运行时决定接下来选取哪个元素。 这在并行或硬件实现中是有问题的,因为你需要固定的、可预测的比较序列。
奇偶归并排序通过使用静态比较网络来解决这个问题,该网络无需分支即可合并已排序的两半。
它在以下情况下是完美的:
- 你需要确定性的行为
- 你正在构建并行电路或 GPU 内核
示例
合并两个已排序的半部分:[1, 4, 7, 8] 和 [2, 3, 5, 6]
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | 合并奇数 [1,7] 与 [2,5] | [1,2,5,7] |
| 2 | 合并偶数 [4,8] 与 [3,6] | [3,4,6,8] |
| 3 | 组合并比较相邻元素 | [1,2,3,4,5,6,7,8] |
固定模式,无分支,并行完成合并。
它是如何工作的(通俗解释)?
想象两条拉链链,一条是奇数的,一条是偶数的。 你以一种固定的、互锁的模式将它们编织在一起,沿途进行比较和交换。 没有猜测,每个元素都知道要检查哪个邻居。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将数组分割为左半部分和右半部分 |
| 2 | 递归排序每一半 |
| 3 | 使用奇偶归并来合并两半 |
| 4 | 奇偶归并: |
| a. 递归合并奇数索引和偶数索引元素 | |
| b. 比较并交换相邻对 | |
| 5 | 继续直到数组排序完成 |
当 $n = 2^k$ 时效果最佳。
微型代码(简易版本)
Python
def odd_even_merge(arr, lo, n, direction):
if n > 1:
m = n // 2
odd_even_merge(arr, lo, m, direction)
odd_even_merge(arr, lo + m, m, direction)
for i in range(lo + m, lo + n - m):
if (arr[i] > arr[i + m]) == direction:
arr[i], arr[i + m] = arr[i + m], arr[i]
def odd_even_merge_sort(arr, lo=0, n=None, direction=True):
if n is None:
n = len(arr)
if n > 1:
m = n // 2
odd_even_merge_sort(arr, lo, m, direction)
odd_even_merge_sort(arr, lo + m, m, direction)
odd_even_merge(arr, lo, n, direction)
arr = [8, 3, 2, 7, 4, 6, 5, 1]
odd_even_merge_sort(arr)
print(arr)输出:
[1, 2, 3, 4, 5, 6, 7, 8]C
#include <stdio.h>
void swap(int *a, int *b) {
int t = *a; *a = *b; *b = t;
}
void odd_even_merge(int arr[], int lo, int n, int dir) {
if (n > 1) {
int m = n / 2;
odd_even_merge(arr, lo, m, dir);
odd_even_merge(arr, lo + m, m, dir);
for (int i = lo + m; i < lo + n - m; i++) {
if ((arr[i] > arr[i + m]) == dir)
swap(&arr[i], &arr[i + m]);
}
}
}
void odd_even_merge_sort(int arr[], int lo, int n, int dir) {
if (n > 1) {
int m = n / 2;
odd_even_merge_sort(arr, lo, m, dir);
odd_even_merge_sort(arr, lo + m, m, dir);
odd_even_merge(arr, lo, n, dir);
}
}
int main(void) {
int arr[] = {8, 3, 2, 7, 4, 6, 5, 1};
int n = sizeof(arr)/sizeof(arr[0]);
odd_even_merge_sort(arr, 0, n, 1);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
1 2 3 4 5 6 7 8为什么它重要
- 固定序列,非常适合并行化
- 无数据依赖的分支
- 用于硬件排序网络
- 并行排序的理论基础
当你需要确定性和并发性时,这个算法就大放异彩。
一个温和的证明(为什么它有效)
每次奇偶归并使用固定的比较-交换操作来合并两个已排序的序列。 在每个阶段:
- 奇数索引单独合并
- 偶数索引单独合并
- 比较相邻元素以恢复全局顺序
每一层执行 $O(n)$ 的工作,深度为 $O(\log^2 n)$ → 总复杂度: $$ T(n) = O(n \log^2 n) $$
亲自尝试
- 逐步排序
[8,3,2,7,4,6,5,1]。 - 追踪奇数索引和偶数索引的归并过程。
- 绘制归并网络图。
- 尝试较小的
[4,3,2,1]以更清晰。 - 在长度为 2 的幂的数组上运行。
- 统计比较次数。
- 与双调排序进行比较。
- 实现迭代版本。
- 可视化并行深度。
- 试验升序/降序标志。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [8,3,2,7,4,6,5,1] | [1,2,3,4,5,6,7,8] | 经典案例 |
| [4,3,2,1] | [1,2,3,4] | 小型数组 |
| [9,7,5,3,1,2,4,6] | [1,2,3,4,5,6,7,9] | 混合顺序 |
| [5,4,3,2] | [2,3,4,5] | 反向半部分 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | $O(n \log^2 n)$ |
| 空间复杂度 | $O(1)$ |
| 稳定性 | 否 |
| 自适应性 | 否 |
| 并行深度 | $O(\log^2 n)$ |
奇偶归并排序像钟表一样精确地从两半中编织出顺序,稳定、并行且可预测。 每一次比较都是计划好的,每一次归并都是同步的,它是作为架构的排序。
148 睡眠排序
睡眠排序是有史以来最有趣、最不传统的算法之一,它通过利用时间延迟来排序数字。每个元素会“睡眠”一段与其值成比例的时长,当睡眠结束时,它便打印出该数字。
实际上,时间本身成为了排序机制。
我们要解决什么问题?
虽然不实用,但睡眠排序提供了一个有趣的并行性和异步时序的演示,表明即使是排序也可以通过时间顺序而非比较来表达。
它常被用作思想实验,用于教授并发、时序以及解决问题的创造性思维。
示例
排序 [3, 1, 4, 2]
| 步骤 | 值 | 睡眠(秒) | 打印顺序 |
|---|---|---|---|
| 1 | 1 | 1s | 1 |
| 2 | 2 | 2s | 2 |
| 3 | 3 | 3s | 3 |
| 4 | 4 | 4s | 4 |
输出(随时间推移): 1 2 3 4
按完成时间排序!
它是如何工作的(通俗解释)?
每个数字被分配一个等于其值的计时器。 所有计时器同时启动,当一个计时器结束时,就输出对应的数字。 较小的数字先“醒来”,因此它们被更早打印出来,从而形成一个有序序列。
这就像一场赛跑,每位选手的速度与其大小成反比,较小的选手先完成。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 对于每个元素 x,创建一个线程或协程 |
| 2 | 每个线程睡眠 x 个单位时间 |
| 3 | 当睡眠完成时,打印 x |
| 4 | 数字按排序顺序出现 |
| 5 | (可选)将输出收集到一个列表中 |
微型代码(简易版本)
Python(使用线程)
import threading
import time
def sleeper(x):
time.sleep(x * 0.1) # 用于调整速度的比例因子
print(x, end=' ')
def sleep_sort(arr):
threads = []
for x in arr:
t = threading.Thread(target=sleeper, args=(x,))
t.start()
threads.append(t)
for t in threads:
t.join()
arr = [3, 1, 4, 2]
sleep_sort(arr)输出(定时):
1 2 3 4C(使用线程和睡眠)
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
void* sleeper(void* arg) {
int x = *(int*)arg;
usleep(x * 100000); // 按比例缩小
printf("%d ", x);
return NULL;
}
void sleep_sort(int arr[], int n) {
pthread_t threads[n];
for (int i = 0; i < n; i++)
pthread_create(&threads[i], NULL, sleeper, &arr[i]);
for (int i = 0; i < n; i++)
pthread_join(threads[i], NULL);
}
int main(void) {
int arr[] = {3, 1, 4, 2};
int n = sizeof(arr) / sizeof(arr[0]);
sleep_sort(arr, n);
printf("\n");
}输出(定时):
1 2 3 4为什么它重要
- 并行性的创造性演示
- 教授并发性的有趣教学工具
- 视觉直观,排序自然产生
- 很好的提醒:算法 ≠ 仅仅是代码,它们是过程
它不实用,但极具教育意义。
一个温和的证明(为什么它有效)
如果所有线程同时启动,并且睡眠时间与其值成比例,那么:
- 较小的值较早完成
- 没有冲突(如果整数互不相同)
- 输出序列 = 排序后的列表
对于重复值,可以添加微小的偏移量来保持稳定性。
局限性:
- 需要正整数
- 依赖于精确的计时器
- 对调度器延迟敏感
亲自尝试
- 排序
[3,1,4,2],观察时序。 - 尝试
[10,5,1,2],速度更慢但模式更清晰。 - 添加重复值
[2,2,1],测试排序顺序。 - 按比例缩小睡眠时间(
x * 0.05)。 - 在多核 CPU 上运行,观察并发性。
- 将
sleep替换为await asyncio.sleep(x)以实现异步版本。 - 将结果收集到列表中,而不是打印。
- 使用
multiprocessing代替线程。 - 可视化时间与值的图表。
- 尝试使用浮点数的小数延迟。
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3,1,4,2] | [1,2,3,4] | 经典示例 |
| [1,2,3,4] | [1,2,3,4] | 已排序 |
| [4,3,2,1] | [1,2,3,4] | 逆序 |
| [2,2,1] | [1,2,2] | 处理重复值 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(理论) | O(n) 实时(挂钟时间) |
| 时间(CPU 工作) | O(n) 设置 |
| 空间 | O(n) 线程 |
| 稳定 | 是(带偏移量) |
| 自适应 | 否 |
睡眠排序是对排序的重新构想,不是通过计算,而是通过耐心。 每个数字只是等待其轮次,没有比较,没有循环,只有时间。
149 珠排序
珠排序(Bead Sort),也称为重力排序(Gravity Sort),是一种受珠子在平行杆上受重力下滑启发的自然排序算法。想象一个侧放的算盘:较重的珠子堆先沉降,自动完成排序。
它在理念上是视觉化的、并行的、模拟的,更像一个概念模型而非实用工具,但对于建立直觉非常出色。
我们要解决什么问题?
排序算法通常依赖于比较。 珠排序则使用物理模拟,物品下落直到它们按顺序排列。
这种方法有助于可视化分布排序和自然计算,其中排序是通过物理定律而非算术运算完成的。
示例
排序 [5, 3, 1, 7, 4]:
- 将每个数字表示为一排珠子。
- 让珠子在重力作用下下落。
- 从下往上数每列的珠子数。
| 步骤 | 表示方式 | 重力作用后 | 输出 |
|---|---|---|---|
| 初始状态 | 5●●●●● 3●●● 1● 7●●●●●●● 4●●●● | , | , |
| 重力作用 | 列从底部开始填充 | 行变短 | , |
| 结果 | 1● 3●●● 4●●●● 5●●●●● 7●●●●●●● | [1, 3, 4, 5, 7] |
最小的数字上升到顶部,最大的数字沉到底部,完成排序。
它是如何工作的(通俗解释)?
每个数字是一堆珠子。 珠子"下落"直到下方没有空位。 由于较重的行推动珠子向下更快,较大的数字在底部累积。 当重力停止时,从上到下读取每行的珠子长度就得到了排序后的顺序。
这是通过模拟重力进行排序,完全不需要比较。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 用杆上的珠子表示每个整数(每个单位一个珠子) |
| 2 | 让珠子落到每列中最低的空位 |
| 3 | 沉降后,数每行的珠子数(从上到下) |
| 4 | 这些计数构成了排序后的列表 |
仅适用于非负整数。
精简代码(简易版本)
Python
def bead_sort(arr):
if any(x < 0 for x in arr):
raise ValueError("仅允许非负整数")
max_val = max(arr)
grid = [[1 if i < x else 0 for i in range(max_val)] for x in arr]
for col in range(max_val):
beads = sum(row[col] for row in grid)
for row in range(len(arr)):
grid[row][col] = 1 if row >= len(arr) - beads else 0
return [sum(row) for row in grid]
arr = [5, 3, 1, 7, 4]
print(bead_sort(arr))输出:
[1, 3, 4, 5, 7]C
#include <stdio.h>
#include <string.h>
void bead_sort(int *a, int n) {
int max = 0;
for (int i = 0; i < n; i++) if (a[i] > max) max = a[i];
unsigned char beads[n][max];
memset(beads, 0, n * max);
for (int i = 0; i < n; i++)
for (int j = 0; j < a[i]; j++)
beads[i][j] = 1;
for (int j = 0; j < max; j++) {
int sum = 0;
for (int i = 0; i < n; i++) sum += beads[i][j];
for (int i = 0; i < n; i++)
beads[i][j] = (i >= n - sum) ? 1 : 0;
}
for (int i = 0; i < n; i++) {
a[i] = 0;
for (int j = 0; j < max; j++)
a[i] += beads[i][j];
}
}
int main(void) {
int arr[] = {5, 3, 1, 7, 4};
int n = sizeof(arr) / sizeof(arr[0]);
bead_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出:
1 3 4 5 7为什么它重要
- 演示了非基于比较的排序
- 展示了计算的物理类比
- 非常适合视觉化和教育目的
- 可并行化(每列独立)
尽管不实用,但它启发了受生物学和物理学启发的算法设计。
一个温和的证明(为什么它有效)
每一列就像一个重力通道:
- 珠子下落填充最低的位置
- 列表示数字间的量级
- 沉降后,每行的珠子数 = 排序后的值
没有两个珠子可以占据同一个位置两次,确保了正确性。 复杂度: $$ T(n) = O(S) $$ 其中 $S = \sum a_i$,即珠子总数。
仅当数字较小时才高效。
自己动手试试
- 用点手动排序
[5,3,1,7,4]。 - 画出杆并让珠子下落。
- 尝试
[3,0,2,1],零保持在顶部。 - 用重复项
[2,2,3]进行实验。 - 在 Python 中使用网格可视化。
- 与计数排序进行比较。
- 扩展以实现稳定排序。
- 逐步动画演示珠子下落。
- 用数字 ≤ 10 进行缩放。
- 思考:如果重力是横向的会怎样?
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [5,3,1,7,4] | [1,3,4,5,7] | 经典示例 |
| [3,0,2,1] | [0,1,2,3] | 处理零 |
| [2,2,3] | [2,2,3] | 适用于重复项 |
| [1] | [1] | 单个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(S),其中 S = 元素之和 |
| 空间复杂度 | O(S) |
| 稳定性 | 否 |
| 自适应性 | 否 |
珠排序展示了即使是重力也能排序,数字变成了珠子,时间、运动和物质完成了工作。 这是一种你可以看见的排序,而不仅仅是计算。
150 Bogo 排序
Bogo 排序(也称为排列排序或愚蠢排序)是一种刻意荒谬的算法,它反复打乱数组直到其变得有序。
它是低效的典型代表,常在课堂上用作滑稽的反例,通过纯粹的运气进行排序。
我们要解决什么问题??
我们不是在解决问题,更多的是在展示徒劳。 Bogo 排序提出的问题是:"如果我们只是不断尝试随机顺序,直到运气好碰上了呢?"
它是一个很好的教学工具,用于:
- 理解算法的低效性
- 体会复杂度界限
- 学习区分好策略与坏策略
示例
排序 [3, 1, 2]
| 尝试次数 | 打乱结果 | 已排序? |
|---|---|---|
| 1 | [3,1,2] | 否 |
| 2 | [1,2,3] | 是 ✅ |
运气好时就停止! (你可能很早就走运……也可能永远不走运。)
它是如何工作的(通俗解释)?
这个想法简单得令人痛苦:
- 检查数组是否已排序。
- 如果未排序,则随机打乱它。
- 重复直到排序完成。
它是靠随机机会排序,而不是逻辑。 每次尝试只有微小的概率得到有序数组,但给定无限时间,它最终会完成。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 检查数组是否已排序 |
| 2 | 如果已排序,完成 |
| 3 | 否则,随机打乱 |
| 4 | 回到步骤 1 |
微型代码(简易版本)
Python
import random
def is_sorted(arr):
return all(arr[i] <= arr[i+1] for i in range(len(arr)-1))
def bogo_sort(arr):
attempts = 0
while not is_sorted(arr):
random.shuffle(arr)
attempts += 1
print("Sorted in", attempts, "attempts")
return arr
arr = [3, 1, 2]
print(bogo_sort(arr))输出(随机):
Sorted in 7 attempts
[1, 2, 3]C
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int is_sorted(int arr[], int n) {
for (int i = 0; i < n - 1; i++)
if (arr[i] > arr[i + 1]) return 0;
return 1;
}
void shuffle(int arr[], int n) {
for (int i = 0; i < n; i++) {
int j = rand() % n;
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
void bogo_sort(int arr[], int n) {
int attempts = 0;
while (!is_sorted(arr, n)) {
shuffle(arr, n);
attempts++;
}
printf("Sorted in %d attempts\n", attempts);
}
int main(void) {
srand(time(NULL));
int arr[] = {3, 1, 2};
int n = sizeof(arr) / sizeof(arr[0]);
bogo_sort(arr, n);
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}输出(随机):
Sorted in 12 attempts
1 2 3为什么它重要
- 幽默的警示故事,告诉我们不该做什么
- 演示了期望运行时间分析
- 一种可视化随机性的好方法
- 强化了对算法推理的需求
它在算法上等同于掷骰子直到排序完成,数学上很愚蠢,但概念上很丰富。
一个温和的证明(为什么它有效)
对于 $n$ 个元素,有 $n!$ 种排列。 只有一种是有序的。
成功概率 = $\frac{1}{n!}$
期望尝试次数: $$ E(n) = n! $$
每次检查需要 $O(n)$,所以总期望时间: $$ T(n) = O(n \times n!) $$
保证终止(最终),因为永远不排序的概率 $\to 0$。
亲自尝试
- 在
[3,1,2]上运行并计数尝试次数。 - 尝试
[1,2,3],立即成功。 - 测试
[4,3,2,1],可能需要无限的耐心。 - 将 random.shuffle 替换为确定性打乱(看看失败)。
- 添加超时机制。
- 在屏幕上可视化打乱过程。
- 测量 100 次试验的平均尝试次数。
- 与冒泡排序比较。
- 尝试 "Bogobogosort"(递归的 Bogo!)。
- 思考:
n=5时的期望运行时间是多少?
测试用例
| 输入 | 输出 | 备注 |
|---|---|---|
| [3,1,2] | [1,2,3] | 经典示例 |
| [1,2,3] | [1,2,3] | 已经排序 |
| [2,1] | [1,2] | 很快 |
| [4,3,2,1] | [1,2,3,4] | 可能永远不会终止 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(期望) | O(n × n!) |
| 时间(最佳) | O(n) |
| 时间(最坏) | 无界 |
| 空间 | O(1) |
| 稳定性 | 是(如果打乱是稳定的) |
| 自适应性 | 否 |
Bogo 排序是伪装成秩序的混沌,靠信念而非逻辑排序。 它是宇宙的提醒:希望不是一种策略,即使在算法中也是如此。
第 16 节. 线性搜索与二分搜索
151 线性搜索
线性搜索(也称为顺序搜索)是最简单、最直观的搜索算法。它逐个扫描每个元素,直到找到目标值或到达列表末尾。
它易于理解,易于实现,并且适用于已排序和未排序的数据。
我们要解决什么问题?
给定一个列表和一个目标值,我们如何检查目标值是否存在,如果存在,它在哪个索引位置?
线性搜索通过按顺序扫描每个元素直到找到匹配项来解决这个问题。
非常适合:
- 小型数据集
- 未排序的数组
- 搜索原理的早期学习
示例
在 [3, 5, 7, 2, 9] 中查找 7:
| 步骤 | 索引 | 值 | 匹配? |
|---|---|---|---|
| 1 | 0 | 3 | 否 |
| 2 | 1 | 5 | 否 |
| 3 | 2 | 7 | ✅ 是 |
| 4 | 停止 | 在索引 2 处找到 |
它是如何工作的(通俗解释)?
这就像一页一页地翻书寻找一个单词。 不跳过,不猜测,只是按顺序检查所有内容。
如果列表是 [a₀, a₁, a₂, ..., aₙ₋₁]:
- 从索引 0 开始
- 将
a[i]与目标值进行比较 - 如果相等 → 找到
- 否则 → 移动到下一个
- 找到或到达末尾时停止
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从索引 0 开始 |
| 2 | 将当前元素与目标值进行比较 |
| 3 | 如果相等,返回索引 |
| 4 | 否则,增加索引 |
| 5 | 重复直到末尾 |
| 6 | 如果未找到,返回 -1 |
微型代码(简易版本)
Python
def linear_search(arr, target):
for i, val in enumerate(arr):
if val == target:
return i
return -1
arr = [3, 5, 7, 2, 9]
target = 7
idx = linear_search(arr, target)
print("Found at index:", idx)输出:
Found at index: 2C
#include <stdio.h>
int linear_search(int arr[], int n, int target) {
for (int i = 0; i < n; i++)
if (arr[i] == target)
return i;
return -1;
}
int main(void) {
int arr[] = {3, 5, 7, 2, 9};
int n = sizeof(arr) / sizeof(arr[0]);
int target = 7;
int idx = linear_search(arr, n, target);
if (idx != -1)
printf("Found at index: %d\n", idx);
else
printf("Not found\n");
}输出:
Found at index: 2为什么它重要
- 适用于任何列表,无论是否排序
- 无需预处理
- 保证能找到(如果存在)
- 是学习时间复杂度的绝佳入门
- 是更好搜索算法的基础
一个温和的证明(为什么它有效)
如果元素存在,扫描每个元素确保最终会被找到。
对于 ( n ) 个元素:
- 最佳情况:在索引 0 处找到 → O(1)
- 最坏情况:未找到或在最后一个 → O(n)
- 平均情况:在中间位置找到 → O(n)
因为没有结构的情况下没有更快的方法。
亲自尝试
- 在
[3,5,7,2,9]中搜索7 - 搜索
10(不在列表中) - 尝试
[1,2,3,4,5]和target=1(最佳情况) - 尝试
target=5(最坏情况) - 统计进行的比较次数
- 打印 "Found" 或 "Not Found"
- 在未排序与已排序的数组上尝试
- 修改以返回目标值的所有索引
- 实现递归版本
- 扩展以在单词列表中搜索字符串
测试用例
| 输入 | 目标值 | 输出 | 说明 |
|---|---|---|---|
| [3,5,7,2,9] | 7 | 2 | 找到 |
| [3,5,7,2,9] | 10 | -1 | 未找到 |
| [1,2,3] | 1 | 0 | 最佳情况 |
| [1,2,3] | 3 | 2 | 最坏情况 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(1) |
| 时间(最坏) | O(n) |
| 时间(平均) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 否 |
线性搜索是窥见算法思维最简单的镜头,虽然暴力但保证有效。 它是你从猜测走向推理的第一步。
152 哨兵线性搜索
哨兵线性搜索是对基础线性搜索的一个巧妙改进。 我们不再每次检查数组边界,而是在数组末尾放置一个哨兵(一个守卫值),其值等于目标值。
这样就无需在循环内部进行显式的边界检查,使得搜索速度稍快且代码更简洁,尤其是在像 C 这样的低级语言中。
我们要解决什么问题?
在标准的线性搜索中,每次迭代都需要检查两件事:
- 当前元素是否等于目标值
- 索引是否仍在边界内
第二个检查增加了开销。
通过放置哨兵,我们可以保证循环总会终止,无需边界检查。
这在紧凑循环、嵌入式系统和性能关键型代码中非常有用。
示例
在 [3, 5, 7, 2, 9] 中查找 7:
- 追加哨兵(复制目标值) →
[3, 5, 7, 2, 9, 7] - 扫描直到找到
7 - 如果索引 < n,则在数组中找到
- 如果索引 == n,则只找到哨兵 → 不在数组中
| 步骤 | 索引 | 值 | 匹配? |
|---|---|---|---|
| 1 | 0 | 3 | 否 |
| 2 | 1 | 5 | 否 |
| 3 | 2 | 7 | ✅ 是 |
| 4 | 停止,索引 < n | 在 2 处找到 |
它是如何工作的(通俗解释)?
可以把哨兵想象成放在最后一个元素后面的一个停止标志。 你不需要回头检查是否走得太远,哨兵会拦住你。
它确保你总会遇到一个匹配项,但之后你需要检查这是真正的匹配项还是哨兵。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 保存最后一个元素 |
| 2 | 将目标值放在末尾(哨兵) |
| 3 | 循环直到 arr[i] == target |
| 4 | 恢复最后一个元素 |
| 5 | 如果索引 < n → 找到,否则 → 未找到 |
微型代码(简易版本)
C
#include <stdio.h>
int sentinel_linear_search(int arr[], int n, int target) {
int last = arr[n - 1];
arr[n - 1] = target; // 哨兵
int i = 0;
while (arr[i] != target)
i++;
arr[n - 1] = last; // 恢复
if (i < n - 1 || arr[n - 1] == target)
return i;
return -1;
}
int main(void) {
int arr[] = {3, 5, 7, 2, 9};
int n = sizeof(arr) / sizeof(arr[0]);
int target = 7;
int idx = sentinel_linear_search(arr, n, target);
if (idx != -1)
printf("Found at index: %d\n", idx);
else
printf("Not found\n");
}输出:
Found at index: 2Python(模拟)
def sentinel_linear_search(arr, target):
n = len(arr)
last = arr[-1]
arr[-1] = target
i = 0
while arr[i] != target:
i += 1
arr[-1] = last
if i < n - 1 or arr[-1] == target:
return i
return -1
arr = [3, 5, 7, 2, 9]
print(sentinel_linear_search(arr, 7)) # 输出: 2为什么它很重要
- 移除了边界检查的开销
- 对于大数组速度稍快
- 哨兵优化的经典示例
- 教授循环不变量和守卫条件
这就是如何让一个简单的算法变得紧凑而优雅。
一个温和的证明(为什么它有效)
通过将目标值作为最后一个元素:
- 循环必须终止(保证匹配)
- 只有在循环之后我们才检查它是哨兵还是真正的匹配
没有浪费的比较。 总比较次数 ≤ n + 1(对比朴素版本的 2n)。
$$ T(n) = O(n) $$
自己动手试试
- 在
[3,5,7,2,9]中搜索7 - 搜索
10(不在列表中) - 跟踪比较次数,与常规线性搜索对比
- 用 Python、Java、C++ 实现
- 可视化哨兵放置
- 使用 1000 个随机元素的数组,进行基准测试
- 尝试临时替换最后一个元素
- 搜索第一个元素,检查最佳情况
- 搜索最后一个元素,检查哨兵恢复
- 讨论这种优化在何时帮助最大
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [3,5,7,2,9] | 7 | 2 | 找到 |
| [3,5,7,2,9] | 10 | -1 | 未找到 |
| [1,2,3] | 1 | 0 | 最佳情况 |
| [1,2,3] | 3 | 2 | 哨兵替换了最后一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(1) |
| 时间(最差) | O(n) |
| 时间(平均) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 否 |
哨兵线性搜索展示了如何将简单转化为优雅,一个小小的守卫让整个循环变得更智能。
153 二分查找(迭代)
二分查找(迭代)是针对有序数组最优雅、最高效的搜索算法之一。 它反复将搜索区间对半分割,每一步都排除掉剩余元素的一半。
此版本使用循环,避免了递归,从而将内存使用降至最低。
我们要解决什么问题?
当处理有序数据时,线性扫描是低效的。 如果你总是知道列表是有序的,你可以使用二分查找在 O(log n) 时间内找到目标,而不是 O(n)。
示例
在 [1, 3, 5, 7, 9, 11] 中查找 7:
| 步骤 | 低 | 高 | 中 | 值 | 比较 |
|---|---|---|---|---|---|
| 1 | 0 | 5 | 2 | 5 | 7 > 5 → 向右搜索 |
| 2 | 3 | 5 | 4 | 9 | 7 < 9 → 向左搜索 |
| 3 | 3 | 3 | 3 | 7 | ✅ 找到 |
在索引 3 处找到。
它是如何工作的(通俗解释)?
二分查找就像猜一个 1 到 100 之间的数字:
- 总是选择中点。
- 如果数字太低,搜索上半部分。
- 如果数字太高,搜索下半部分。
- 重复直到找到或区间为空。
每次猜测都将搜索空间减半,这就是它如此快的原因。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从 low = 0, high = n - 1 开始 |
| 2 | 当 low ≤ high 时,计算 mid = (low + high) // 2 |
| 3 | 如果 arr[mid] == target → 返回索引 |
| 4 | 如果 arr[mid] < target → 搜索右半部分 (low = mid + 1) |
| 5 | 否则 → 搜索左半部分 (high = mid - 1) |
| 6 | 如果未找到,返回 -1 |
精简代码(简易版本)
Python
def binary_search(arr, target):
low, high = 0, len(arr) - 1
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
arr = [1, 3, 5, 7, 9, 11]
print(binary_search(arr, 7)) # 输出: 3输出:
3C
#include <stdio.h>
int binary_search(int arr[], int n, int target) {
int low = 0, high = n - 1;
while (low <= high) {
int mid = low + (high - low) / 2; // 避免溢出
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int main(void) {
int arr[] = {1, 3, 5, 7, 9, 11};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = binary_search(arr, n, 7);
if (idx != -1)
printf("Found at index: %d\n", idx);
else
printf("Not found\n");
}输出:
Found at index: 3为什么它很重要
- 基础的分治算法
- O(log n) 时间复杂度
- 无处不在:搜索引擎、数据库、编译器
- 为二叉决策树建立直觉
这是大多数程序员学习的第一个真正高效的搜索算法。
一个温和的证明(为什么它有效)
在每一步,搜索区间减半。 经过 $k$ 步后,剩余元素 = $\frac{n}{2^k}$。
当 $\frac{n}{2^k} = 1$ 时停止 ⟹ $k = \log_2 n$
因此,总比较次数: $$ T(n) = O(\log n) $$
仅适用于有序数组。
亲自尝试
- 在
[1,3,5,7,9,11]中搜索7 - 搜索
2(未找到) - 追踪
low、high、mid的值 - 在偶数长度数组
[1,2,3,4,5,6]上尝试 - 在奇数长度数组
[1,2,3,4,5]上尝试 - 与线性搜索比较迭代次数
- 实现递归版本
- 使用二分查找查找插入点
- 添加计数器以测量步骤数
- 解释为什么需要排序
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [1,3,5,7,9,11] | 7 | 3 | 找到 |
| [1,3,5,7,9,11] | 2 | -1 | 未找到 |
| [1,2,3,4,5] | 1 | 0 | 第一个元素 |
| [1,2,3,4,5] | 5 | 4 | 最后一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最佳) | O(1) |
| 时间(最差) | O(log n) |
| 时间(平均) | O(log n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 否 |
| 前提条件 | 有序数组 |
二分查找(迭代)是效率的黄金标准,每一步都将问题减半,一次一个决策。
154 二分查找(递归)
二分查找(递归)是经典的二分查找分治形式。 它不是通过循环,而是在更小的子数组上调用自身,每次都将搜索空间减半,直到找到目标或区间变为空。
这是递归在行动中的完美演示,每次调用都处理问题的一个更小的切片。
我们要解决什么问题?
给定一个已排序的数组,我们想要高效地找到一个目标值。 我们不进行线性扫描,而是反复将数组一分为二,只关注可能包含目标的那一半。
这个版本通过递归调用来表达这种逻辑。
示例
在 [1, 3, 5, 7, 9, 11] 中查找 7
| 步骤 | 低 | 高 | 中 | 值 | 操作 |
|---|---|---|---|---|---|
| 1 | 0 | 5 | 2 | 5 | 7 > 5 → 搜索右半部分 |
| 2 | 3 | 5 | 4 | 9 | 7 < 9 → 搜索左半部分 |
| 3 | 3 | 3 | 3 | 7 | ✅ 找到 |
递归调用每次都会缩小区间,直到找到匹配项。
它是如何工作的(通俗解释)?
二分查找说:
"如果中间元素不是我想要的,我可以忽略一半的数据。"
在递归形式中:
- 检查中点。
- 如果相等 → 找到。
- 如果目标值更大 → 递归搜索右侧。
- 如果目标值更小 → 递归搜索左侧。
- 基本情况:如果
low > high,元素未找到。
每次调用都将搜索空间减半,递归深度为对数级别。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 检查基本情况:如果 low > high,返回 -1 |
| 2 | 计算 mid = (low + high) // 2 |
| 3 | 如果 arr[mid] == target,返回 mid |
| 4 | 如果 arr[mid] > target,递归搜索左半部分 |
| 5 | 否则,递归搜索右半部分 |
精简代码(简易版本)
Python
def binary_search_recursive(arr, target, low, high):
if low > high:
return -1
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search_recursive(arr, target, mid + 1, high)
else:
return binary_search_recursive(arr, target, low, mid - 1)
arr = [1, 3, 5, 7, 9, 11]
print(binary_search_recursive(arr, 7, 0, len(arr)-1)) # 输出: 3输出:
3C
#include <stdio.h>
int binary_search_recursive(int arr[], int low, int high, int target) {
if (low > high)
return -1;
int mid = low + (high - low) / 2;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
return binary_search_recursive(arr, mid + 1, high, target);
else
return binary_search_recursive(arr, low, mid - 1, target);
}
int main(void) {
int arr[] = {1, 3, 5, 7, 9, 11};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = binary_search_recursive(arr, 0, n - 1, 7);
if (idx != -1)
printf("Found at index: %d\n", idx);
else
printf("Not found\n");
}输出:
Found at index: 3为什么它重要
- 优雅的分治演示
- 显示递归深度 = log₂(n)
- 与迭代版本具有相同的复杂度
- 为递归算法(归并排序、快速排序)奠定基础
递归反映了将区间减半的数学思想,简洁而直观。
一个温和的证明(为什么它有效)
在每次递归调用中:
- 问题规模减半:$n \to n/2 \to n/4 \to \dots$
- 递归在 $\log_2 n$ 层后停止
因此总复杂度为: $$ T(n) = T(n/2) + O(1) = O(\log n) $$
正确性源于:
- 排序的输入确保了排序决策的有效性
- 基本情况确保了终止
亲自尝试
- 在
[1,3,5,7,9,11]中搜索7 - 搜索
2(不存在) - 添加打印语句来跟踪递归
- 比较调用次数与迭代版本
- 用
low > high测试基本情况 - 搜索第一个和最后一个元素
- 观察大小为 8 的数组的递归深度 → 3 次调用
- 为每一步添加
(low, high)的备忘录 - 实现尾递归变体
- 思考栈与循环的权衡
测试用例
| 输入 | 目标 | 输出 | 说明 |
|---|---|---|---|
| [1,3,5,7,9,11] | 7 | 3 | 找到 |
| [1,3,5,7,9,11] | 2 | -1 | 未找到 |
| [1,2,3,4,5] | 1 | 0 | 第一个元素 |
| [1,2,3,4,5] | 5 | 4 | 最后一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最好) | O(1) |
| 时间(最坏) | O(log n) |
| 时间(平均) | O(log n) |
| 空间 | O(log n)(递归栈) |
| 稳定 | 是 |
| 自适应 | 否 |
| 前提条件 | 已排序数组 |
二分查找(递归)是最纯粹的分治算法,每次都将世界一分为二,直到答案自己显现。
155 二分查找(下界)
下界二分查找是二分查找的一种变体,用于找到在不破坏排序顺序的情况下可以插入某个值的第一个位置。 换句话说,它返回第一个大于或等于目标值的元素的索引。
它广泛应用于搜索引擎、数据库、范围查询以及 C++ STL(std::lower_bound)中。
我们要解决什么问题?
在许多情况下,你不仅仅想知道一个元素是否存在——你还想知道它在有序结构中的位置。
- 如果该值存在,则返回其首次出现的位置。
- 如果不存在,则返回可以插入该值以保持数组有序的位置。
这对于插入、频率计数和范围边界至关重要。
示例
在 [1, 3, 5, 7, 7, 9, 11] 中查找 7 的下界:
| 步骤 | 低(Low) | 高(High) | 中(Mid) | 值(Value) | 比较(Compare) | 操作(Action) |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | arr[mid] >= 7 | 移动 high = 3 |
| 2 | 0 | 2 | 1 | 3 | arr[mid] < 7 | 移动 low = 2 |
| 3 | 2 | 3 | 2 | 5 | arr[mid] < 7 | 移动 low = 3 |
| 4 | low == high | , | , | , | 停止(Stop) |
结果:索引 3(第一个 7)
它是如何工作的(通俗解释)?
下界查找目标值可以放置的最左边的位置。 它滑动搜索窗口直到 low == high,其中 low 标记了第一个候选位置(≥ 目标值)。
你可以这样理解:
“在仍然满足 ≥ 目标值的条件下,我能走多远到左边?”
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 设置 low = 0, high = n(注意:high = n,不是 n-1) |
| 2 | 当 low < high 时: |
a. mid = (low + high) // 2 | |
b. 如果 arr[mid] < target,移动 low = mid + 1 | |
c. 否则,移动 high = mid | |
| 3 | 循环结束时,low 即为下界索引 |
简洁代码(简易版本)
Python
def lower_bound(arr, target):
low, high = 0, len(arr)
while low < high:
mid = (low + high) // 2
if arr[mid] < target:
low = mid + 1
else:
high = mid
return low
arr = [1, 3, 5, 7, 7, 9, 11]
print(lower_bound(arr, 7)) # 输出: 3输出:
3C
#include <stdio.h>
int lower_bound(int arr[], int n, int target) {
int low = 0, high = n;
while (low < high) {
int mid = low + (high - low) / 2;
if (arr[mid] < target)
low = mid + 1;
else
high = mid;
}
return low;
}
int main(void) {
int arr[] = {1, 3, 5, 7, 7, 9, 11};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = lower_bound(arr, n, 7);
printf("下界索引: %d\n", idx);
}输出:
下界索引: 3为什么它很重要
- 为有序数组查找插入位置
- 在二叉搜索树、映射、区间中很有用
- 范围查询的核心(例如计算 ≥ x 的元素数量)
- 建立对边界二分查找的理解
当你需要的不仅仅是“是/否”,而是“在哪里”时,就需要使用下界查找。
一个温和的证明(为什么它有效)
不变式:
low之前的所有索引包含的元素< targethigh之后的所有索引包含的元素≥ target
循环保持此不变式直到收敛: $$ low = high = \text{第一个满足 arr[i] ≥ target 的索引} $$
复杂度: $$ T(n) = O(\log n) $$
亲自尝试
arr = [1,3,5,7,7,9],target = 7→ 3target = 6→ 3(将插入在第一个 7 之前)target = 10→ 6(末尾)target = 0→ 0(开头)- 计算 ≥ 7 的元素数量:
len(arr) - lower_bound(arr, 7) - 与 Python 中的
bisect_left比较 - 使用它来插入元素,同时保持列表有序
- 应用于有序字符串
["apple", "banana", "cherry"] - 可视化重复元素的范围 [lower, upper)
- 对于大的 n,与线性扫描进行基准测试
测试用例
| 输入 | 目标值 | 输出 | 含义 |
|---|---|---|---|
| [1,3,5,7,7,9,11] | 7 | 3 | 第一个 7 |
| [1,3,5,7,7,9,11] | 6 | 3 | 插入在 7 之前 |
| [1,3,5,7,7,9,11] | 12 | 7 | 末尾位置 |
| [1,3,5,7,7,9,11] | 0 | 0 | 开头 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 稳定性 | 是 |
| 自适应性 | 否 |
| 前提条件 | 有序数组 |
二分查找(下界)让算法学会事物属于哪里,而不仅仅是它们是否存在。 它是有序性的精确边界。
156 二分查找(上界)
上界二分查找是下界二分查找的近亲,旨在找到第一个大于目标元素的索引。 它用于定位相等元素的右边界或重复元素之后的插入点。
简单来说:
它找到目标值的“最后一次出现之后的位置”。
我们要解决什么问题?
有时你需要知道在现有重复值之后插入新值的位置。 例如,在频率计数或范围查询中,你可能想要找到相同元素块的末尾。
上界返回的正是这个位置,即满足以下条件的最小索引: $$ \text{arr[i]} > \text{target} $$
示例
在 [1, 3, 5, 7, 7, 9, 11] 中查找 7 的上界:
| 步骤 | 低 | 高 | 中 | 值 | 比较 | 操作 |
|---|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | 7 ≤ 7 | 移动低 = 4 |
| 2 | 4 | 7 | 5 | 9 | 9 > 7 | 移动高 = 5 |
| 3 | 4 | 5 | 4 | 7 | 7 ≤ 7 | 移动低 = 5 |
| 4 | 低 == 高 | , | , | , | 停止 |
结果:索引 5 → 最后一个 7 之后的位置。
它是如何工作的(通俗解释)?
如果下界查找的是“第一个 ≥ 目标值”, 那么上界查找的就是“第一个 > 目标值”。
两者共同定义了相等范围: $$ [\text{lower_bound}, \text{upper_bound}) $$ → 所有等于目标值的元素。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 设置 low = 0, high = n |
| 2 | 当 low < high 时: |
a. mid = (low + high) // 2 | |
b. 如果 arr[mid] <= target,移动 low = mid + 1 | |
c. 否则,移动 high = mid | |
| 3 | 循环结束时,low 就是上界索引 |
简洁代码(简易版本)
Python
def upper_bound(arr, target):
low, high = 0, len(arr)
while low < high:
mid = (low + high) // 2
if arr[mid] <= target:
low = mid + 1
else:
high = mid
return low
arr = [1, 3, 5, 7, 7, 9, 11]
print(upper_bound(arr, 7)) # 输出: 5输出:
5C
#include <stdio.h>
int upper_bound(int arr[], int n, int target) {
int low = 0, high = n;
while (low < high) {
int mid = low + (high - low) / 2;
if (arr[mid] <= target)
low = mid + 1;
else
high = mid;
}
return low;
}
int main(void) {
int arr[] = {1, 3, 5, 7, 7, 9, 11};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = upper_bound(arr, n, 7);
printf("上界索引: %d\n", idx);
}输出:
上界索引: 5为什么它很重要
- 定位重复块的末尾
- 支持范围计数: 计数 =
upper_bound - lower_bound - 用于映射、集合、STL 容器
- 范围查询和区间合并的关键组成部分
它是排序区间的右锚点。
一个温和的证明(为什么它有效)
不变式:
low之前的所有索引包含 $\le$ 目标值的元素high之后的所有索引包含 $>$ 目标值的元素
当 low == high 时,它就是满足 arr[i] > target 的最小索引。
步骤数 = $\log_2 n$ → 复杂度: $$ T(n) = O(\log n) $$
亲自尝试
arr = [1,3,5,7,7,9],target=7→ 5target=6→ 3 (插入到 5 之后)target=10→ 6 (末尾)target=0→ 0 (开头)- 计算等于 7 的元素个数 →
upper - lower - 与 Python 中的
bisect_right比较 - 用于在重复元素后插入新元素
- 结合下界来查找范围
- 可视化 [lower, upper) 范围
- 应用于浮点数的有序列表
测试用例
| 输入 | 目标值 | 输出 | 含义 |
|---|---|---|---|
| [1,3,5,7,7,9,11] | 7 | 5 | 最后一个 7 之后 |
| [1,3,5,7,7,9,11] | 6 | 3 | 插入到 5 之后 |
| [1,3,5,7,7,9,11] | 12 | 7 | 末尾位置 |
| [1,3,5,7,7,9,11] | 0 | 0 | 开头 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 稳定性 | 是 |
| 自适应性 | 否 |
| 前提条件 | 有序数组 |
二分查找(上界)是你找到“大于”开始之处、相等范围的右边界、越过最后一个孪生元素一步的方法。
157 指数搜索
指数搜索是一种混合搜索算法,它首先快速定位目标值可能存在的范围,然后在该范围内使用二分搜索。
它非常适合在无界或非常大的有序数组中搜索,尤其是当数组大小未知或动态变化时(例如数据流或无限数组)。
我们要解决什么问题?
在标准的二分搜索中,你需要知道数组的大小。 但如果大小未知,或者数组非常巨大呢?
指数搜索通过以下方式解决这个问题:
- 快速找到一个目标值可能存在的区间。
- 在该区间内执行二分搜索。
这使得它非常适用于:
- 无限数组(概念上)
- 数据流
- 已知顺序的链表结构
- 确定边界成本高昂的大型有序数据
示例
在 [1, 2, 4, 8, 16, 32, 64, 128] 中查找 15
| 步骤 | 范围 | 值 | 比较 | 动作 |
|---|---|---|---|---|
| 1 | 索引 1 | 2 | 2 < 15 | 扩大范围 |
| 2 | 索引 2 | 4 | 4 < 15 | 扩大范围 |
| 3 | 索引 4 | 16 | 16 ≥ 15 | 停止 |
| 4 | 范围 = [2, 4] | 在 [4, 8, 16] 中进行二分搜索 | ✅ 在索引 4 处找到 15 |
我们每次将边界加倍(1, 2, 4, 8...),直到超过目标值。
它是如何工作的(通俗解释)?
可以把它想象成放大镜:
- 从小范围开始,逐步加倍你的步长,直到超过目标值。
- 一旦你框定了目标值所在的范围,就使用二分搜索来精确查找。
这避免了在数组可能非常庞大时进行线性扫描。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从索引 1 开始 |
| 2 | 当 i < n 且 arr[i] < target 时,将 i 加倍 |
| 3 | 现在 target ∈ [i/2, min(i, n-1)] |
| 4 | 在该子范围内应用二分搜索 |
| 5 | 返回找到的索引或 -1 |
简洁代码(简易版本)
Python
def binary_search(arr, target, low, high):
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
def exponential_search(arr, target):
if arr[0] == target:
return 0
i = 1
n = len(arr)
while i < n and arr[i] < target:
i *= 2
return binary_search(arr, target, i // 2, min(i, n - 1))
arr = [1, 2, 4, 8, 16, 32, 64, 128]
print(exponential_search(arr, 16)) # 输出: 4输出:
4C
#include <stdio.h>
int binary_search(int arr[], int low, int high, int target) {
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int exponential_search(int arr[], int n, int target) {
if (arr[0] == target)
return 0;
int i = 1;
while (i < n && arr[i] < target)
i *= 2;
int low = i / 2;
int high = (i < n) ? i : n - 1;
return binary_search(arr, low, high, target);
}
int main(void) {
int arr[] = {1, 2, 4, 8, 16, 32, 64, 128};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = exponential_search(arr, n, 16);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 4为什么它很重要
- 适用于大小未知或无限的数组
- 对于大的
n,比线性扫描更快 - 结合了倍增搜索和二分搜索
- 用于流式数据结构、文件系统、无界容器
它就像是搜索者的手电筒,不断调亮光线直到看到你的目标。
一个温和的证明(为什么它有效)
倍增过程最多产生 $\log_2 p$ 次扩展, 其中 $p$ 是目标值的位置。
然后在一个大小为 $O(p)$ 的范围内进行二分搜索,需要另外 $O(\log p)$ 的时间。
所以总时间: $$ T(n) = O(\log p) $$
当 $p \ll n$ 时,渐近时间复杂度与二分搜索相同。
亲自尝试
- 在
[1,2,4,8,16,32,64,128]中搜索16 - 搜索
3→ 未找到 - 追踪扩展过程:1,2,4,8...
- 在
[10,20,30,40,50,60,70]中尝试target=60 - 修改倍增因子(尝试 3 倍)
- 与简单二分搜索进行比较
- 实现递归的指数搜索
- 用于未知大小的输入流
- 测量扩展次数
- 在数轴上可视化范围
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [1,2,4,8,16,32,64] | 16 | 4 | 经典案例 |
| [1,2,4,8,16,32,64] | 3 | -1 | 未找到 |
| [2,4,6,8] | 2 | 0 | 第一个元素 |
| [2,4,6,8] | 10 | -1 | 超出范围 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log p),其中 p = 目标值的位置 |
| 空间复杂度 | O(1) |
| 稳定 | 是 |
| 自适应 | 部分 |
| 前提条件 | 有序数组 |
指数搜索是你在黑暗中寻找方向的方法:先加倍你的探索范围,然后仔细观察光线落下的地方。
158 跳转搜索
跳转搜索是对线性搜索的一种简单改进,专为有序数组设计。 它通过以固定大小的步长向前跳跃(而不是检查每个元素)来工作,然后在目标可能存在的块内执行线性扫描。
它用一点点额外的逻辑换取速度上的巨大提升,尤其是在数据已排序且随机访问成本较低的情况下。
我们要解决什么问题?
线性搜索逐个检查每个元素,对于大型数组来说速度很慢。 跳转搜索通过以固定步长“向前跳跃”来改进这一点,因此总体上进行的比较次数更少。
它非常适合:
- 已排序的数组
- 快速随机访问(如数组,而非链表)
- 简单、可预测的搜索步骤
示例
在 [1, 3, 5, 7, 9, 11, 13, 15] 中查找 9
数组大小 = 8 → 跳跃步长 = √8 = 2 或 3
| 步骤 | 跳转到索引 | 值 | 比较 | 动作 |
|---|---|---|---|---|
| 1 | 2 | 5 | 5 < 9 | 向前跳 |
| 2 | 4 | 9 | 9 ≥ 9 | 停止跳跃 |
| 3 | 从 2 开始线性扫描 | 5, 7, 9 | ✅ 找到 9 |
在索引 4 处找到。
它是如何工作的(通俗解释)?
想象你正在阅读一个已排序的数字列表。 你不是一次读一个数字,而是每隔几步就向前跳一下,就像跳台阶一样。 一旦你跳过了目标或匹配到目标,你就在那个块内线性地往回走。
跳跃步长 ≈ √n 可以在跳跃和扫描之间取得良好的平衡。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 选择跳跃步长 step = √n |
| 2 | 当 arr[min(step, n)-1] < target 时向前跳跃 |
| 3 | 一旦跳过目标,就在前一个块内进行线性搜索 |
| 4 | 如果找到,返回索引;否则返回 -1 |
微型代码(简易版本)
Python
import math
def jump_search(arr, target):
n = len(arr)
step = int(math.sqrt(n))
prev = 0
# 向前跳跃
while prev < n and arr[min(step, n) - 1] < target:
prev = step
step += int(math.sqrt(n))
if prev >= n:
return -1
# 在块内进行线性搜索
for i in range(prev, min(step, n)):
if arr[i] == target:
return i
return -1
arr = [1, 3, 5, 7, 9, 11, 13, 15]
print(jump_search(arr, 9)) # 输出: 4输出:
4C
#include <stdio.h>
#include <math.h>
int jump_search(int arr[], int n, int target) {
int step = sqrt(n);
int prev = 0;
while (prev < n && arr[(step < n ? step : n) - 1] < target) {
prev = step;
step += sqrt(n);
if (prev >= n) return -1;
}
for (int i = prev; i < (step < n ? step : n); i++) {
if (arr[i] == target) return i;
}
return -1;
}
int main(void) {
int arr[] = {1, 3, 5, 7, 9, 11, 13, 15};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = jump_search(arr, n, 9);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 4为什么它很重要
- 对于有序数组,比线性搜索更快
- 比二分搜索更容易实现
- 适用于存在非随机访问惩罚的系统
- 跳跃和扫描的平衡最小化了比较次数
两全其美:快速跳跃和细致步进。
一个温和的证明(为什么它有效)
设跳跃步长 = $m$。
我们最多执行 $\frac{n}{m}$ 次跳跃和 $m$ 次线性步进。 总成本: $$ T(n) = O\left(\frac{n}{m} + m\right) $$
当 $m = \sqrt{n}$ 时最小化
因此: $$ T(n) = O(\sqrt{n}) $$
亲自尝试
- 在
[1,3,5,7,9,11,13,15]中搜索11 - 搜索
2(未找到) - 使用步长 = 2, 3, 4 → 比较跳跃次数
- 实现递归版本
- 在纸上可视化跳跃过程
- 尝试无序数组(观察失败情况)
- 搜索边界情况:第一个、最后一个、中间
- 使用不同的步长公式
- 与指数跳跃结合
- 与二分搜索比较时间
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [1,3,5,7,9,11,13,15] | 9 | 4 | 找到 |
| [1,3,5,7,9,11,13,15] | 2 | -1 | 未找到 |
| [2,4,6,8,10] | 10 | 4 | 最后一个元素 |
| [2,4,6,8,10] | 2 | 0 | 第一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(√n) |
| 空间复杂度 | O(1) |
| 稳定 | 是 |
| 自适应 | 否 |
| 前提条件 | 已排序的数组 |
跳转搜索就像在有序的地面上玩跳房子游戏,聪明地跳跃,一旦接近目标就仔细地步进。
159 斐波那契搜索
斐波那契搜索是一种分治搜索算法,它使用斐波那契数来确定探测位置,而不是像二分搜索那样使用中点。 对于存储在顺序内存中且元素访问成本随距离增长(例如,在磁带或缓存敏感系统上)的已排序数组,它尤其高效。
这是对二分搜索的一个巧妙变体,使用斐波那契数代替 2 的幂次。
我们要解决什么问题?
在二分搜索中,中点将数组均匀分割。 在斐波那契搜索中,我们使用斐波那契比例进行分割,这使得索引与整数运算对齐,无需除法,并且在某些硬件上具有更好的缓存局部性。
这在以下情况下特别有用:
- 访问成本取决于距离
- 内存访问是顺序的或受限的
- 我们希望避免除法和浮点运算
示例
在 [1, 3, 5, 8, 13, 21, 34] 中查找 8
| 步骤 | Fib(k) | 检查的索引 | 值 | 比较 | 操作 |
|---|---|---|---|---|---|
| 1 | 8 | 5 | 13 | 13 > 8 | 向左移动 |
| 2 | 5 | 2 | 5 | 5 < 8 | 向右移动 |
| 3 | 3 | 3 | 8 | 8 = 8 | ✅ 找到 |
斐波那契数列:1, 2, 3, 5, 8, 13, … 我们使用之前的斐波那契值来缩小搜索空间,类似于斐波那契分解。
它是如何工作的?(通俗解释)
可以将斐波那契搜索视为由斐波那契跳跃引导的二分搜索。 在每一步:
- 比较索引
offset + Fib(k-2)处的元素 - 根据比较结果,向左或向右移动,并使用更小的斐波那契数来定义新的区间。
你沿着斐波那契序列“向下走”,直到范围缩小到一点。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 生成大于等于 n 的最小斐波那契数 |
| 2 | 使用 Fib(k-2) 作为探测索引 |
| 3 | 比较目标值与 arr[offset + Fib(k-2)] |
| 4 | 如果目标值更小,则向左移动(范围减少 Fib(k-2)) |
| 5 | 如果目标值更大,则向右移动(增加偏移量,范围减少 Fib(k-1)) |
| 6 | 继续直到 Fib(k) = 1 |
| 7 | 如果需要,检查最后一个元素 |
微型代码(简易版本)
Python
def fibonacci_search(arr, target):
n = len(arr)
fibMMm2 = 0 # 第 (m-2) 个斐波那契数
fibMMm1 = 1 # 第 (m-1) 个斐波那契数
fibM = fibMMm1 + fibMMm2 # 第 m 个斐波那契数
# 找到大于等于 n 的最小斐波那契数
while fibM < n:
fibMMm2, fibMMm1 = fibMMm1, fibM
fibM = fibMMm1 + fibMMm2
offset = -1
while fibM > 1:
i = min(offset + fibMMm2, n - 1)
if arr[i] < target:
fibM = fibMMm1
fibMMm1 = fibMMm2
fibMMm2 = fibM - fibMMm1
offset = i
elif arr[i] > target:
fibM = fibMMm2
fibMMm1 -= fibMMm2
fibMMm2 = fibM - fibMMm1
else:
return i
if fibMMm1 and offset + 1 < n and arr[offset + 1] == target:
return offset + 1
return -1
arr = [1, 3, 5, 8, 13, 21, 34]
print(fibonacci_search(arr, 8)) # 输出: 3输出:
3C
#include <stdio.h>
int min(int a, int b) { return (a < b) ? a : b; }
int fibonacci_search(int arr[], int n, int target) {
int fibMMm2 = 0;
int fibMMm1 = 1;
int fibM = fibMMm1 + fibMMm2;
while (fibM < n) {
fibMMm2 = fibMMm1;
fibMMm1 = fibM;
fibM = fibMMm1 + fibMMm2;
}
int offset = -1;
while (fibM > 1) {
int i = min(offset + fibMMm2, n - 1);
if (arr[i] < target) {
fibM = fibMMm1;
fibMMm1 = fibMMm2;
fibMMm2 = fibM - fibMMm1;
offset = i;
} else if (arr[i] > target) {
fibM = fibMMm2;
fibMMm1 -= fibMMm2;
fibMMm2 = fibM - fibMMm1;
} else {
return i;
}
}
if (fibMMm1 && offset + 1 < n && arr[offset + 1] == target)
return offset + 1;
return -1;
}
int main(void) {
int arr[] = {1, 3, 5, 8, 13, 21, 34};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = fibonacci_search(arr, n, 8);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 3为什么它很重要
- 仅使用加法和减法,无需除法
- 非常适合顺序访问内存
- 性能与二分搜索相当(O(log n))
- 在某些硬件上提供更好的局部性
这是一种源于自然本身数字的搜索策略。
一个温和的证明(为什么它有效)
每次迭代按斐波那契比例缩小搜索空间: $$ F_k = F_{k-1} + F_{k-2} $$
因此,搜索深度 ≈ 斐波那契索引 $k \sim \log_\phi n$,
其中 $\phi$ 是黄金比例($\approx 1.618$)。
所以总时间: $$ T(n) = O(\log n) $$
亲自尝试
- 在
[1,3,5,8,13,21,34]中搜索8 - 搜索
21 - 搜索
2(未找到) - 跟踪斐波那契序列步骤
- 将探测索引与二分搜索进行比较
- 尝试
n=10→ 斐波那契数 13 ≥ 10 - 实现递归版本
- 可视化探测区间
- 用 2 的幂次替换斐波那契数(二分搜索)
- 使用非均匀数组进行实验
测试用例
| 输入 | 目标值 | 输出 | 备注 |
|---|---|---|---|
| [1,3,5,8,13,21,34] | 8 | 3 | 找到 |
| [1,3,5,8,13,21,34] | 2 | -1 | 未找到 |
| [1,3,5,8,13,21,34] | 34 | 6 | 最后一个元素 |
| [1,3,5,8,13,21,34] | 1 | 0 | 第一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 稳定 | 是 |
| 自适应 | 否 |
| 前提条件 | 已排序的数组 |
斐波那契搜索是以自然的节奏进行搜索,每一步都由黄金比例塑造,平衡了覆盖范围和精度。
160 均匀二分查找
均匀二分查找是二分查找的一种优化形式,其中探测位置是预先计算好的。 它使用一个偏移量表来确定下一步的位置,而不是在每一步重新计算中点,这使得它在紧密循环或硬件受限的系统中更快。
其核心在于消除重复的中点计算和分支判断,实现一致、均匀的步骤。
我们要解决什么问题?
标准的二分查找重复计算:
$$ mid = low + \frac{high - low}{2} $$
这在现代 CPU 上开销很小,但在以下情况下开销很大:
- 早期硬件
- 嵌入式系统
- 除法或移位操作代价高昂的紧密循环
均匀二分搜索(UBS)用预先计算的每一步偏移量替换了这些计算,从而提供可预测的、均匀的跳跃。
示例
在 [5, 10, 15, 20, 25, 30, 35, 40] 中查找 25
| 步骤 | 偏移量 | 索引 | 值 | 比较 | 动作 |
|---|---|---|---|---|---|
| 1 | 3 | 3 | 20 | 20 < 25 | 向右移动 |
| 2 | 1 | 5 | 30 | 30 > 25 | 向左移动 |
| 3 | 0 | 4 | 25 | 25 = 25 | ✅ 找到 |
UBS 使用偏移量表 [3, 1, 0],而不是重新计算中点。
它是如何工作的(通俗解释)?
这是一种带有预先规划路径的二分查找。 在每一层:
- 你按固定的偏移量(来自一个表)跳跃
- 比较
- 向左或向右移动,均匀地缩小搜索窗口
没有除法,没有中点计算,只有跳跃和比较。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 根据数组大小预先计算偏移量表 |
| 2 | 从起始位置偏移 offset[0] 开始 |
| 3 | 将元素与目标值比较 |
| 4 | 使用下一个偏移量向左/右移动 |
| 5 | 当偏移量为 0 或找到目标时停止 |
精简代码(简易版本)
Python
def uniform_binary_search(arr, target):
n = len(arr)
# 预计算偏移量(小于 n 的 2 的幂)
offsets = []
k = 1
while k < n:
offsets.append(k)
k *= 2
offsets.reverse()
low = 0
idx = offsets[0]
for offset in offsets:
mid = low + offset if low + offset < n else n - 1
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
# 否则向左移动(在下一轮迭代中隐式处理)
# 最终检查
if low < n and arr[low] == target:
return low
return -1
arr = [5, 10, 15, 20, 25, 30, 35, 40]
print(uniform_binary_search(arr, 25)) # 输出: 4输出:
4C
#include <stdio.h>
int uniform_binary_search(int arr[], int n, int target) {
int k = 1;
while (k < n) k <<= 1;
k >>= 1;
int low = 0;
while (k > 0) {
int mid = low + k - 1;
if (mid >= n) mid = n - 1;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
low = mid + 1;
k >>= 1; // 下一个更小的偏移量
}
return (low < n && arr[low] == target) ? low : -1;
}
int main(void) {
int arr[] = {5, 10, 15, 20, 25, 30, 35, 40};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = uniform_binary_search(arr, n, 25);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 4为什么它很重要
- 避免重新计算中点
- 适用于硬件、固件、微控制器
- 确保一致的运行时路径
- 指令更少 → 在紧密循环中更快
均匀性 = 可预测性 = 性能。
一个温和的证明(为什么它有效)
预先计算的偏移量对应于将搜索空间减半。 在每一步,偏移量 = floor(剩余大小 / 2)。 经过 log₂(n) 步后,我们缩小到单个元素。
总步数 = ⌈log₂ n⌉ 每一步 = 恒定开销(无需重新计算)
因此: $$ T(n) = O(\log n) $$
亲自尝试
- 在
[5,10,15,20,25,30,35,40]中查找25 - 查找
35 - 查找
6→ 未找到 - 为 n=8 预计算偏移量表
- 与二分查找比较步骤
- 为 n=16 实现
- 用于微控制器表查找
- 在纸上可视化跳跃过程
- 测量比较次数
- 实现递归版本
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [5,10,15,20,25,30,35,40] | 25 | 4 | 找到 |
| [5,10,15,20,25,30,35,40] | 5 | 0 | 第一个元素 |
| [5,10,15,20,25,30,35,40] | 50 | -1 | 未找到 |
| [5,10,15,20,25,30,35,40] | 40 | 7 | 最后一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 稳定 | 是 |
| 自适应 | 否 |
| 前提条件 | 有序数组 |
均匀二分查找是带有路线图的二分查找,无需猜测,无需重新计算,只需平滑、均匀间隔地跳跃到答案。
第 17 节 插值查找与指数查找
161 插值查找
插值查找(Interpolation Search)是一种用于在值均匀分布的已排序数组中查找元素的搜索算法。 与总是探测中间位置的二分查找不同,插值查找通过值插值来估计目标值的位置,就像在数轴上定位一个数字一样。
如果说二分查找是“按索引划分”,那么插值查找就是“按值划分”。
我们要解决什么问题?
二分查找假设索引和值之间没有关系。 但如果数组值是均匀分布的,我们可以通过猜测目标值应该在哪里(而不仅仅是中间位置)来做得更好。
它适用于:
- 均匀分布的已排序数据
- 数值键(如 ID、价格、时间戳)
- 大型数组,其中基于值的位置估计很重要
示例
在 [10, 20, 30, 40, 50, 60, 70, 80, 90] 中查找 70
估计位置:
$$ pos = low + \frac{(target - arr[low]) \times (high - low)}{arr[high] - arr[low]} $$
| 步骤 | Low | High | Pos | Value | 比较 | 操作 |
|---|---|---|---|---|---|---|
| 1 | 0 | 8 | 7 | 80 | 80 > 70 | 向左移动 |
| 2 | 0 | 6 | 6 | 70 | 70 = 70 | ✅ 找到 |
它是如何工作的(通俗解释)?
想象数组是一条数线。 如果你的目标值更接近高端,就从更靠右的位置开始搜索。 你进行插值,根据目标值在最小值和最大值之间的比例来估计其索引。
因此,你不是盲目地对半分,而是跳到可能的位置。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 初始化 low = 0, high = n - 1 |
| 2 | 当 low <= high 且 target 在范围内时: |
| 使用插值公式估计位置 | |
| 3 | 比较 arr[pos] 与 target |
| 4 | 如果相等 → 找到 |
| 5 | 如果更小 → 移动 low = pos + 1 |
| 6 | 如果更大 → 移动 high = pos - 1 |
| 7 | 重复直到找到或 low > high |
微型代码(简易版本)
Python
def interpolation_search(arr, target):
low, high = 0, len(arr) - 1
while low <= high and arr[low] <= target <= arr[high]:
if arr[high] == arr[low]:
if arr[low] == target:
return low
break
pos = low + (target - arr[low]) * (high - low) // (arr[high] - arr[low])
if arr[pos] == target:
return pos
elif arr[pos] < target:
low = pos + 1
else:
high = pos - 1
return -1
arr = [10, 20, 30, 40, 50, 60, 70, 80, 90]
print(interpolation_search(arr, 70)) # 输出: 6输出:
6C
#include <stdio.h>
int interpolation_search(int arr[], int n, int target) {
int low = 0, high = n - 1;
while (low <= high && target >= arr[low] && target <= arr[high]) {
if (arr[high] == arr[low]) {
if (arr[low] == target) return low;
else break;
}
int pos = low + (double)(high - low) * (target - arr[low]) / (arr[high] - arr[low]);
if (arr[pos] == target)
return pos;
if (arr[pos] < target)
low = pos + 1;
else
high = pos - 1;
}
return -1;
}
int main(void) {
int arr[] = {10, 20, 30, 40, 50, 60, 70, 80, 90};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = interpolation_search(arr, n, 70);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 6为什么它重要
- 对于均匀分布的数据,比二分查找更快
- 适用于密集的键空间(如哈希槽、ID 范围)
- 可以达到 O(log log n) 的平均时间复杂度
- 保持了已排序顺序的搜索逻辑
它是二分查找的“懂值”表亲。
一个温和的证明(为什么它有效)
如果数据是均匀分布的,每次探测都会以对数方式将值范围减半。 预期探测次数: $$ T(n) = O(\log \log n) $$ 最坏情况(非均匀分布): $$ T(n) = O(n) $$
因此,它只在值均匀分布的情况下优于二分查找。
亲自尝试
- 在
[10,20,30,40,50,60,70,80,90]中搜索70 - 搜索
25(未找到) - 尝试
arr = [2,4,8,16,32,64],注意分布不均匀 - 绘制估计位置
- 与二分查找比较步骤
- 使用浮点数除法与整数除法
- 实现递归版本
- 搜索
10(第一个元素) - 搜索
90(最后一个元素) - 测量均匀与非均匀数据上的迭代次数
测试用例
| 输入 | 目标 | 输出 | 说明 |
|---|---|---|---|
| [10,20,30,40,50,60,70,80,90] | 70 | 6 | 找到 |
| [10,20,30,40,50,60,70,80,90] | 25 | -1 | 未找到 |
| [10,20,30,40,50,60,70,80,90] | 10 | 0 | 第一个 |
| [10,20,30,40,50,60,70,80,90] | 90 | 8 | 最后一个 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(平均) | O(log log n) |
| 时间(最坏) | O(n) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
| 前提条件 | 已排序且均匀的数组 |
插值查找就像一张按值缩放的地图,它不只是猜测中间点,而是猜测宝藏真正所在的位置。
162 递归插值搜索
递归插值搜索是经典插值搜索的递归变体。 它不再使用循环,而是在更小的子范围上递归调用自身,使用相同的基于值的插值公式来估算可能的位置。
对于习惯递归思考的学习者来说,这是一种表达算法的自然方式,逻辑相同,流程更清晰。
我们要解决什么问题?
我们将迭代插值搜索用递归形式表达,以突出其分治的本质。 递归形式通常更直观,并且在数学上与其插值逻辑更一致。
在以下情况下你会用到它:
- 教学或可视化递归逻辑
- 编写清晰、声明式的搜索代码
- 练习递归到迭代的转换
示例
在 [10, 20, 30, 40, 50, 60, 70, 80, 90] 中查找 50
步骤 1: low = 0, high = 8 pos = 0 + (50 - 10) × (8 - 0) / (90 - 10) = 4 arr[4] = 50 → ✅ 找到
递归立即停止,一次调用,一次成功。
它是如何工作的(通俗解释)?
与循环不同,每次递归调用都会聚焦到目标值可能所在的子范围。 每次调用都计算一个估算的索引 pos,该索引与目标值在 arr[low] 和 arr[high] 之间的距离成比例。
如果值更高 → 向右递归 如果值更低 → 向左递归 如果相等 → 返回索引
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 基本情况:如果 low > high 或目标值超出范围 → 未找到 |
| 2 | 使用插值公式计算位置估算值 |
| 3 | 如果 arr[pos] == target → 返回 pos |
| 4 | 如果 arr[pos] < target → 在右半部分递归 |
| 5 | 否则在左半部分递归 |
精简代码(简易版本)
Python
def interpolation_search_recursive(arr, low, high, target):
if low > high or target < arr[low] or target > arr[high]:
return -1
if arr[high] == arr[low]:
return low if arr[low] == target else -1
pos = low + (target - arr[low]) * (high - low) // (arr[high] - arr[low])
if arr[pos] == target:
return pos
elif arr[pos] < target:
return interpolation_search_recursive(arr, pos + 1, high, target)
else:
return interpolation_search_recursive(arr, low, pos - 1, target)
arr = [10, 20, 30, 40, 50, 60, 70, 80, 90]
print(interpolation_search_recursive(arr, 0, len(arr) - 1, 50)) # 输出: 4输出:
4C
#include <stdio.h>
int interpolation_search_recursive(int arr[], int low, int high, int target) {
if (low > high || target < arr[low] || target > arr[high])
return -1;
if (arr[high] == arr[low])
return (arr[low] == target) ? low : -1;
int pos = low + (double)(high - low) * (target - arr[low]) / (arr[high] - arr[low]);
if (arr[pos] == target)
return pos;
else if (arr[pos] < target)
return interpolation_search_recursive(arr, pos + 1, high, target);
else
return interpolation_search_recursive(arr, low, pos - 1, target);
}
int main(void) {
int arr[] = {10, 20, 30, 40, 50, 60, 70, 80, 90};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = interpolation_search_recursive(arr, 0, n - 1, 50);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 4为什么它重要
- 展示了基于插值估算的递归本质
- 为教学和分析提供了简洁的数学形式
- 演示了与搜索成本成正比的递归深度
- 凭借基于值的直觉更容易推理
可以把它看作是二分搜索的艺术表亲,优雅且具有值感知能力。
一个温和的证明(为什么它有效)
在均匀分布中,每次递归步骤以乘法方式缩小搜索空间,而不仅仅是减半。
如果数据是均匀的: $$ T(n) = T(n / f) + O(1) \Rightarrow T(n) = O(\log \log n) $$
如果不均匀: $$ T(n) = O(n) $$
递归深度 = 插值细化的次数 ≈ log log n。
亲自尝试
- 在
[10,20,30,40,50,60,70,80,90]中搜索70 - 搜索
25(未找到) - 手动追踪递归调用
- 添加打印语句观察 low/high 的缩小过程
- 与二分搜索比较深度
- 尝试
[2,4,8,16,32,64](非均匀) - 添加
arr[low] == arr[high]的防护 - 实现尾递归优化
- 测试基本情况(第一个、最后一个、单个元素)
- 时间比较:迭代 vs 递归
测试用例
| 输入 | 目标值 | 输出 | 备注 |
|---|---|---|---|
| [10,20,30,40,50,60,70,80,90] | 50 | 4 | 找到 |
| [10,20,30,40,50,60,70,80,90] | 25 | -1 | 未找到 |
| [10,20,30,40,50,60,70,80,90] | 10 | 0 | 第一个 |
| [10,20,30,40,50,60,70,80,90] | 90 | 8 | 最后一个 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(平均) | O(log log n) |
| 时间(最坏) | O(n) |
| 空间 | O(log log n)(递归) |
| 稳定 | 是 |
| 自适应 | 是 |
| 前提条件 | 已排序且均匀的数组 |
递归插值搜索就像基于值的直觉进行放大,每一步都是一个数学猜测,每一次调用都使焦点更清晰。
163 指数搜索
指数搜索结合了范围扩展与二分查找。 当数组大小未知或无限时,它非常适用。它首先通过倍增索引来找到可能包含目标的区间,然后在该区间内使用二分查找。
这是针对已排序数据的“先放大视野,再精确聚焦”的搜索策略。
我们要解决什么问题?
如果你不知道已排序数组的长度,就无法直接应用二分查找。 你需要先界定搜索空间,以便知道在哪里查找。
指数搜索正是这样做的:
- 它倍增索引(1, 2, 4, 8, 16...)直到越界。
- 然后在该区间内执行二分查找。
适用于:
- 无限或动态大小的已排序数组
- 数据流
- 未知长度的文件或数据结构
示例
在 [2, 4, 8, 16, 19, 23, 42, 64, 128] 中查找 19
| 步骤 | 范围 | 值 | 比较 | 操作 |
|---|---|---|---|---|
| 1 | 1 | 4 | 4 < 19 | 倍增索引 |
| 2 | 2 | 8 | 8 < 19 | 倍增索引 |
| 3 | 4 | 19 | 19 = 19 | ✅ 找到 |
如果它越界了(例如,目标是 23),我们将在索引 4 和 8 之间进行二分查找。
它是如何工作的(通俗解释)?
想象一下在一个无尽的书架上找书。 你以 1, 2, 4, 8... 的步长前进,直到超过你想要的书号。 现在你知道它在哪个书架区间了,然后使用二分查找进行精确查找。
快速扩展,精确收尾。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从索引 1 开始 |
| 2 | 当 i < n 且 arr[i] < target 时,倍增 i |
| 3 | 当越界时 → 在 i/2 和 min(i, n-1) 之间进行二分查找 |
| 4 | 如果找到则返回索引,否则返回 -1 |
精简代码(简易版本)
Python
def binary_search(arr, low, high, target):
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
def exponential_search(arr, target):
n = len(arr)
if n == 0:
return -1
if arr[0] == target:
return 0
i = 1
while i < n and arr[i] <= target:
i *= 2
return binary_search(arr, i // 2, min(i, n - 1), target)
arr = [2, 4, 8, 16, 19, 23, 42, 64, 128]
print(exponential_search(arr, 19)) # 输出: 4输出:
4C
#include <stdio.h>
int binary_search(int arr[], int low, int high, int target) {
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int exponential_search(int arr[], int n, int target) {
if (n == 0) return -1;
if (arr[0] == target) return 0;
int i = 1;
while (i < n && arr[i] <= target)
i *= 2;
int low = i / 2;
int high = (i < n) ? i : n - 1;
return binary_search(arr, low, high, target);
}
int main(void) {
int arr[] = {2, 4, 8, 16, 19, 23, 42, 64, 128};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = exponential_search(arr, n, 19);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 4为什么它很重要
- 处理大小未知的数组
- 扩展后具有对数级的搜索复杂度
- 对于小目标值比较次数更少
- 常用于无界搜索、数据流、链式内存
这是一种按需增长的搜索,就像不断放大视野直到目标出现。
一个温和的证明(为什么它有效)
每次迭代都会按斐波那契比例减少搜索空间: $$ F_k = F_{k-1} + F_{k-2} $$
因此,搜索深度 ≈ 斐波那契索引 $k \sim \log_\phi n$,
其中 $\phi$ 是黄金分割率($\approx 1.618$)。
所以总时间: $$ T(n) = O(\log n) $$
亲自尝试
- 在
[2,4,8,16,19,23,42,64,128]中搜索19 - 搜索
42 - 搜索
1(未找到) - 追踪扩展步骤:1, 2, 4, 8...
- 比较扩展步骤与二分查找调用次数
- 在巨大数组、小目标值上尝试
- 在动态大小列表上尝试(模拟无限)
- 实现递归版本
- 测量扩展次数与比较次数
- 与 TimSort 中的跳跃搜索结合
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [2,4,8,16,19,23,42,64,128] | 19 | 4 | 找到 |
| [2,4,8,16,19,23,42,64,128] | 23 | 5 | 找到 |
| [2,4,8,16,19,23,42,64,128] | 1 | -1 | 未找到 |
| [2,4,8,16,19,23,42,64,128] | 128 | 8 | 最后一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log p) |
| 空间复杂度 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
| 前提条件 | 已排序数组 |
指数搜索是你的寻路者,它以 2 的幂次向外探索,然后精确地聚焦于目标隐藏之处。
164 倍增搜索
倍增搜索(也称为无界搜索)是指数搜索背后的通用模式。当数据大小或范围未知时,需要快速找到一个包含目标的搜索区间,然后在该区间内执行精确搜索(如二分搜索),这时就会用到它。
可以将其理解为 “通过倍增进行搜索,直到找到你的范围。”
我们要解决什么问题?
在许多实际场景中,你不知道数组的长度或搜索域的边界。你无法直接进行二分搜索,首先需要一个上界。
倍增搜索为你提供了一种策略,通过倍增索引或步长直到超过目标,从而在对数时间内快速找到该边界。
非常适合:
- 无限或流式序列
- 函数或隐式数组(未存储在内存中)
- 由值而非长度定义的搜索域
示例
在 [2, 4, 8, 16, 19, 23, 42, 64, 128] 中查找 23
| 步骤 | 范围 | 值 | 比较 | 操作 |
|---|---|---|---|---|
| 1 | i = 1 | 4 | 4 < 23 | 倍增 i |
| 2 | i = 2 | 8 | 8 < 23 | 倍增 i |
| 3 | i = 4 | 16 | 16 < 23 | 倍增 i |
| 4 | i = 8 | 128 | 128 > 23 | 停止 |
找到的范围:[4, 8] 现在在 [16, 19, 23, 42, 64, 128] 内运行二分搜索 ✅ 在索引 5 处找到
它是如何工作的(通俗解释)?
从小处开始,测试前几步。每次未找到目标且值仍小于目标时,倍增你的索引(1, 2, 4, 8, 16...)。一旦超过目标,你就找到了区间,然后进行精确搜索。
这就像在黑暗中行走,每次倍增步幅直到看到光,然后小心地后退。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从索引 1 开始 |
| 2 | 当 arr[i] < target 时,设置 i = 2 × i |
| 3 | 当超过时,定义范围 [i/2, min(i, n-1)] |
| 4 | 在范围内应用二分搜索 |
| 5 | 返回索引,如果未找到则返回 -1 |
简洁代码(简易版本)
Python
def doubling_search(arr, target):
n = len(arr)
if n == 0:
return -1
if arr[0] == target:
return 0
i = 1
while i < n and arr[i] < target:
i *= 2
low = i // 2
high = min(i, n - 1)
# 二分搜索
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
arr = [2, 4, 8, 16, 19, 23, 42, 64, 128]
print(doubling_search(arr, 23)) # 输出: 5输出:
5C
#include <stdio.h>
int binary_search(int arr[], int low, int high, int target) {
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int doubling_search(int arr[], int n, int target) {
if (n == 0) return -1;
if (arr[0] == target) return 0;
int i = 1;
while (i < n && arr[i] < target)
i *= 2;
int low = i / 2;
int high = (i < n) ? i : n - 1;
return binary_search(arr, low, high, target);
}
int main(void) {
int arr[] = {2, 4, 8, 16, 19, 23, 42, 64, 128};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = doubling_search(arr, n, 23);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 5为什么它很重要
- 允许在大小未知时进行二分搜索
- 仅需 O(log p) 次探测,其中
p是目标索引 - 适用于流式、无限或惰性结构的自然策略
- 用于指数搜索、跳跃搜索和 Tim-sort 合并
它是所有“先扩展再搜索”算法的蓝图。
一个温和的证明(为什么它有效)
每次倍增步骤将范围乘以 2,因此扩展次数 ≈ log₂(p) 然后在大小 ≤ p 的范围内进行二分搜索
总时间: $$ T(n) = O(\log p) $$
时间复杂度仍然是对数级的,基于目标位置而非数组大小,即使对于无界数据也很高效。
亲自尝试
- 在
[2,4,8,16,19,23,42,64,128]中搜索23 - 搜索
42(需要更多跳跃) - 搜索
1(在第一个元素之前) - 搜索
128(最后一个元素) - 尝试使用倍增因子 3 而不是 2
- 与指数搜索比较扩展步骤
- 实现递归版本
- 在数轴上可视化
- 使用安全检查模拟未知长度数组
- 测量小目标与大目标的步骤数
测试用例
| 输入 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [2,4,8,16,19,23,42,64,128] | 23 | 5 | 找到 |
| [2,4,8,16,19,23,42,64,128] | 4 | 1 | 早期 |
| [2,4,8,16,19,23,42,64,128] | 1 | -1 | 未找到 |
| [2,4,8,16,19,23,42,64,128] | 128 | 8 | 最后一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(log p) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
| 前提条件 | 有序数组 |
倍增搜索是你探索未知、倍增范围、找到边界,然后精确定位目标的方式。
165 跳跃搜索
跳跃搜索(也称为指数跳跃或倍增搜索)是一种混合搜索技术,它通过以指数方式向前跳跃快速定位一个范围,然后在该范围内切换为二分搜索。
它被广泛应用于 TimSort 的合并步骤中,通过跳过不需要详细比较的大段数据,帮助高效地合并有序的游程。
我们要解决什么问题?
当合并两个有序数组(或在有序列表中搜索)时,如果元素之间的差距很大,逐个重复比较是低效的。跳跃搜索通过快速"向前跳跃"来找到感兴趣的区域,然后用二分搜索精确定位确切的边界。
用于:
- TimSort 合并
- 混合排序中的游程合并
- 在有序块中搜索
- 优化比较密集的算法
示例
在 [5, 10, 15, 20, 25, 30, 35, 40, 45] 中查找 25
| 步骤 | 索引 | 值 | 比较 | 操作 |
|---|---|---|---|---|
| 1 | 1 | 10 | 10 < 25 | 跳跃(×2) |
| 2 | 2 | 15 | 15 < 25 | 跳跃 |
| 3 | 4 | 25 | 25 = 25 | ✅ 找到 |
如果我们跳过了目标(例如,搜索 22),我们会跳过它(索引 4),然后在之前的边界和当前边界之间执行二分搜索。
它是如何工作的(通俗解释)?
你开始跳跃,检查 1、2、4、8 步远的位置,直到超过目标或到达末尾。一旦你跳过了目标,你就在最后一个有效的区间内"跳跃回来"(进行二分搜索)。
这就像冲刺向前找到街区,然后一旦知道是哪条街,就挨家挨户地走。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 从 start 索引开始 |
| 2 | 以 2 的幂次向前跳跃(1, 2, 4, 8, ...),直到 arr[start + k] >= target |
| 3 | 定义范围 [start + k/2, min(start + k, n-1)] |
| 4 | 在该范围内应用二分搜索 |
| 5 | 如果找到则返回索引 |
微型代码(简易版本)
Python
def binary_search(arr, low, high, target):
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
def galloping_search(arr, start, target):
n = len(arr)
if start >= n:
return -1
if arr[start] == target:
return start
k = 1
while start + k < n and arr[start + k] < target:
k *= 2
low = start + k // 2
high = min(start + k, n - 1)
return binary_search(arr, low, high, target)
arr = [5, 10, 15, 20, 25, 30, 35, 40, 45]
print(galloping_search(arr, 0, 25)) # 输出:4输出:
4C
#include <stdio.h>
int binary_search(int arr[], int low, int high, int target) {
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == target)
return mid;
else if (arr[mid] < target)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int galloping_search(int arr[], int n, int start, int target) {
if (start >= n) return -1;
if (arr[start] == target) return start;
int k = 1;
while (start + k < n && arr[start + k] < target)
k *= 2;
int low = start + k / 2;
int high = (start + k < n) ? start + k : n - 1;
return binary_search(arr, low, high, target);
}
int main(void) {
int arr[] = {5, 10, 15, 20, 25, 30, 35, 40, 45};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = galloping_search(arr, n, 0, 25);
printf("Found at index: %d\n", idx);
}输出:
Found at index: 4为什么它很重要
- 加速 TimSort 中的合并
- 在合并大型有序游程时最小化比较次数
- 适用于自适应排序和范围扫描
- 平衡了速度(跳跃)和精度(二分)
这是一种动态平衡,远时跳跃,近时踮脚。
一个温和的证明(为什么它有效)
跳跃阶段:
需要 $O(\log p)$ 步到达目标附近(其中 $p$ 是距离起点的距离)。
二分阶段:
局部搜索需要另一个 $O(\log p)$。
总计: $$ T(n) = O(\log p) $$
当游程长度差异很大时,比线性合并更快。
亲自尝试
- 在
[5,10,15,20,25,30,35,40,45]中搜索25 - 搜索
40(更大的跳跃) - 搜索
3(在第一个之前) - 尝试
start=2 - 打印跳跃步骤
- 与纯二分搜索比较
- 实现递归跳跃搜索
- 用于合并两个有序数组
- 统计每次搜索的比较次数
- 在长列表上测试早期/晚期目标
测试用例
| 输入 | 目标 | 输出 | 说明 |
|---|---|---|---|
| [5,10,15,20,25,30,35,40,45] | 25 | 4 | 找到 |
| [5,10,15,20,25,30,35,40,45] | 40 | 7 | 更大的跳跃 |
| [5,10,15,20,25,30,35,40,45] | 3 | -1 | 未找到 |
| [5,10,15,20,25,30,35,40,45] | 5 | 0 | 第一个元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log p) |
| 空间复杂度 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
| 前提条件 | 有序数组 |
跳跃搜索是 TimSort 自信地向前奔跑的方式,大步跨越巨大间隙,只在需要精度时放慢脚步。
166 无界二分查找
无界二分查找是一种在有序但无界(或无限)序列中查找特定值的技术。
你不知道数据在哪里结束,甚至不知道它有多大,但你知道它是有序的。因此,首先你需要找到一个搜索边界,然后在这个发现的范围内执行二分查找。
它是倍增搜索后接二分查找的直接应用,特别适用于单调函数或数据流。
我们要解决什么问题?
如果你处理的数据具有以下特点:
- 没有固定大小,
- 以流的形式出现,
- 或者表示为单调函数(例如 f(x) 随 x 递增),
那么你就不能立即应用二分查找,因为你不知道 high(上界)。
因此,你首先需要通过指数级扩展来找到包含目标的边界 [low, high],然后才能使用二分查找。
示例
假设你在一个单调函数中搜索 20:
f(x) = 2x + 2你想要找到满足 f(x) ≥ 20 的最小 x。
步骤 1:找到边界。 检查 x = 1 → f(1) = 4 检查 x = 2 → f(2) = 6 检查 x = 4 → f(4) = 10 检查 x = 8 → f(8) = 18 检查 x = 16 → f(16) = 34 (超出!)
现在你知道目标位于 x = 8 和 x = 16 之间。
步骤 2:在 [8, 16] 内执行二分查找。 ✅ 找到 f(9) = 20
它是如何工作的(通俗解释)?
你从一个小的步长开始,每次将搜索范围加倍,直到超过目标。
一旦超出,你就知道了搜索区间,然后可以在该区间内进行精确的二分查找。
当数组(或定义域)没有明确终点时,这是首选策略。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 初始化 low = 0, high = 1 |
| 2 | 当 f(high) < target 时,设置 low = high, high *= 2 |
| 3 | 一旦 f(high) >= target,在 [low, high] 之间进行二分查找 |
| 4 | 返回索引(或值) |
精简代码(简易版本)
Python
def unbounded_binary_search(f, target):
low, high = 0, 1
# 步骤 1: 找到边界
while f(high) < target:
low = high
high *= 2
# 步骤 2: 在 [low, high] 中进行二分查找
while low <= high:
mid = (low + high) // 2
val = f(mid)
if val == target:
return mid
elif val < target:
low = mid + 1
else:
high = mid - 1
return -1
# 示例: f(x) = 2x + 2
f = lambda x: 2 * x + 2
print(unbounded_binary_search(f, 20)) # 输出: 9输出:
9C
#include <stdio.h>
int f(int x) {
return 2 * x + 2;
}
int binary_search(int (*f)(int), int low, int high, int target) {
while (low <= high) {
int mid = low + (high - low) / 2;
int val = f(mid);
if (val == target)
return mid;
else if (val < target)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int unbounded_binary_search(int (*f)(int), int target) {
int low = 0, high = 1;
while (f(high) < target) {
low = high;
high *= 2;
}
return binary_search(f, low, high, target);
}
int main(void) {
int idx = unbounded_binary_search(f, 20);
printf("Found at x = %d\n", idx);
}输出:
Found at x = 9为什么它很重要
- 处理无限序列或无界定义域
- 非常适合单调函数(例如 f(x) 递增)
- 在没有数组长度的情况下进行搜索的关键
- 用于求根、倍增法、流处理
这是“扩展,然后精炼”的模式,是探索者的搜索方式。
一个简单的证明(为什么它有效)
扩展步骤:每次将索引加倍 → $O(\log p)$,其中 p 是目标的位置。
二分查找步骤:在大小 ≤ p 的范围内搜索 → 另一个 $O(\log p)$。
总复杂度: $$ T(n) = O(\log p) $$
与整个定义域的大小无关。
亲自尝试
f(x) = 2x + 2, target = 20 → 9f(x) = x², target = 64 → 8- 搜索不存在的值(例如在偶数序列中搜索 7)
- 将 f(x) 修改为指数函数
- 在二分查找前打印边界
- 尝试负定义域(用 if 语句保护)
- 应用于有序无限列表(用 f 模拟)
- 使用浮点函数和容差检查
- 与线性探测进行比较
- 用于找到满足 f(x) ≥ target 的最小 x
测试用例
| 函数 | 目标 | 输出 | 备注 |
|---|---|---|---|
| 2x+2 | 20 | 9 | f(9)=20 |
| x² | 64 | 8 | f(8)=64 |
| 2x+2 | 7 | -1 | 未找到 |
| 3x+1 | 31 | 10 | f(10)=31 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(log p) |
| 空间 | O(1) |
| 稳定 | 是 |
| 自适应 | 是 |
| 前提条件 | 单调函数或有序数据 |
无界二分查找是你搜索无限的方式:先加倍你的边界,然后聚焦于真相。
167 求根二分法
求根二分法是一种数值搜索算法,用于定位连续函数过零点的位置。它反复对函数符号发生变化的区间进行对半分割,确保在该范围内存在一个根。
当你只知道解存在于 a 和 b 之间的某个位置,并且需要求解像 f(x) = 0 这样的方程时,二分法是最简单、最可靠的方法。
我们要解决什么问题?
当你有一个函数 $f(x)$ 并且想求解 $f(x) = 0$,
但又无法用代数方法求解时,你可以使用二分法。
如果 $f(a)$ 和 $f(b)$ 符号相反,那么根据介值定理,
在 $a$ 和 $b$ 之间至少存在一个根。
示例:
在 $[1, 2]$ 区间内求 $f(x) = x^3 - x - 2$ 的根。
- $f(1) = -2$
- $f(2) = 4$
符号不同 → 在 $[1, 2]$ 中存在一个根。
它是如何工作的(通俗解释)?
你从一个区间 $[a, b]$ 开始,其中 $f(a)$ 和 $f(b)$ 符号相反。
然后,在每一步中:
- 找到中点 $m = \frac{a + b}{2}$
- 计算 $f(m)$
- 替换
a或b,使得区间两端的函数值仍然符号相反 - 重复直到区间足够小
你正在将区间越缩越紧,直到它"拥抱"住根。
逐步过程
| 步骤 | 描述 | ||||
|---|---|---|---|---|---|
| 1 | 选择 a、b,使得 f(a) 和 f(b) 符号相反 | ||||
| 2 | 计算中点 m = (a + b) / 2 | ||||
| 3 | 计算 f(m) | ||||
| 4 | 如果 f(m) 与 f(a) 符号相同,则令 a = m,否则令 b = m | ||||
| 5 | 重复直到 ` | f(m) | 或 | b - a | ` < 容差 |
| 6 | 返回 m 作为根 |
微型代码(简易版本)
Python
def f(x):
return x3 - x - 2
def bisection(f, a, b, tol=1e-6):
if f(a) * f(b) >= 0:
raise ValueError("函数值符号未改变:不能保证有根")
while (b - a) / 2 > tol:
m = (a + b) / 2
if f(m) == 0:
return m
elif f(a) * f(m) < 0:
b = m
else:
a = m
return (a + b) / 2
root = bisection(f, 1, 2)
print("近似根:", root)输出:
近似根: 1.52138C
#include <stdio.h>
#include <math.h>
double f(double x) {
return x*x*x - x - 2;
}
double bisection(double (*f)(double), double a, double b, double tol) {
if (f(a) * f(b) >= 0) {
printf("函数值符号未改变。不能保证有根。\n");
return NAN;
}
double m;
while ((b - a) / 2 > tol) {
m = (a + b) / 2;
double fm = f(m);
if (fm == 0)
return m;
if (f(a) * fm < 0)
b = m;
else
a = m;
}
return (a + b) / 2;
}
int main(void) {
double root = bisection(f, 1, 2, 1e-6);
printf("近似根: %.6f\n", root);
}输出:
近似根: 1.521380为什么它很重要
- 如果 ( f ) 连续且符号相反,则保证收敛
- 简单且鲁棒
- 适用于非线性方程
- 是学习更高级方法(牛顿法、割线法)之前的绝佳起点
- 在物理、金融、工程等领域用于精确求解
一个温和的证明(为什么它有效)
根据介值定理:
如果 $f(a) \cdot f(b) < 0$,则存在 $c \in [a, b]$ 使得 $f(c) = 0$。
每次迭代都将区间大小减半,因此在 $k$ 步之后:
$$ b_k - a_k = \frac{b_0 - a_0}{2^k} $$
要达到容差 $\varepsilon$:
$$ k \approx \log_2\left(\frac{b_0 - a_0}{\varepsilon}\right) $$
因此,收敛是线性的,但得到保证。
亲自尝试
- $f(x) = x^2 - 2$,区间 $[1, 2]$ → $\sqrt{2}$
- $f(x) = \cos x - x$,区间 $[0, 1]$
- $f(x) = x^3 - 7$,区间 $[1, 3]$
- 尝试更严格的容差(
1e-3,1e-9) - 计算需要多少次迭代
- 打印每个中点
- 与牛顿法比较
- 绘制收敛曲线
- 修改代码以同时返回根和迭代次数
- 在具有多个根的函数上测试
测试用例
| 函数 | 区间 | 根(近似值) | 备注 |
|---|---|---|---|
| $x^2 - 2$ | $[1, 2]$ | 1.4142 | $\sqrt{2}$ |
| $x^3 - x - 2$ | $[1, 2]$ | 1.5214 | 经典示例 |
| $\cos x - x$ | $[0, 1]$ | 0.7391 | 不动点根 |
| $x^3 - 7$ | $[1, 3]$ | 1.913 | 7 的立方根 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log((b−a)/tol)) |
| 空间复杂度 | O(1) |
| 收敛速度 | 线性 |
| 稳定性 | 高 |
| 要求函数连续 | 是 |
二分法是数值分析中可靠的指南针,当根确实存在时,它永远不会失败。
168 黄金分割搜索
黄金分割搜索是一种巧妙的优化算法,用于在闭区间 $[a, b]$ 上寻找单峰函数的最小值(或最大值)——无需导数。 它是二分搜索的近亲,但它不是对半分割,而是使用黄金比例来最小化函数求值次数。
我们要解决什么问题?
你想在区间 $[a, b]$ 上找到最小化 $f(x)$ 的 $x$, 但你无法或不想求导(可能 $f$ 有噪声或不连续)。
如果 $f(x)$ 是单峰的(只有一个峰值或谷值), 那么黄金分割搜索能为你提供一条保证收敛到最优值的路径。
示例
在 $[0, 5]$ 上寻找 $$ f(x) = (x-2)^2 + 1 $$ 的最小值。
由于 $f(x)$ 是二次函数,其最小值在 $x = 2$ 处。 该算法将通过计算黄金分割点处的值,在 $2$ 附近进行搜索。
它是如何工作的(通俗解释)?
想象一下用黄金比例($\phi \approx 1.618$)来分割你的搜索区间。 通过在这些比例点放置测试点,你可以重用过去的求值结果, 并在每一步消除区间的一侧。
每次迭代都会缩小搜索范围,同时保持比例不变—— 就像数学上完美的缩放。
逐步过程
| 步骤 | 描述 | 条件 | 结果 / 注意 |
|---|---|---|---|
| 1 | 选择初始区间 $[a, b]$ | ||
| 2 | 计算黄金比例 $\phi = \frac{\sqrt{5} - 1}{2} \approx 0.618$ | ||
| 3 | 设置 $c = b - \phi(b - a)$, $d = a + \phi(b - a)$ | ||
| 4 | 计算 $f(c)$ 和 $f(d)$ | ||
| 5 | 如果 $f(c) < f(d)$,新区间 = $[a, d]$ | ||
| 6 | 否则,新区间 = $[c, b]$ | ||
| 7 | 重复直到 $b - a < \text{tolerance}$ | ||
| 8 | 返回中点作为最佳估计值 |
微型代码(简易版本)
Python
import math
def f(x):
return (x - 2)2 + 1
def golden_section_search(f, a, b, tol=1e-5):
phi = (math.sqrt(5) - 1) / 2
c = b - phi * (b - a)
d = a + phi * (b - a)
fc, fd = f(c), f(d)
while abs(b - a) > tol:
if fc < fd:
b, d, fd = d, c, fc
c = b - phi * (b - a)
fc = f(c)
else:
a, c, fc = c, d, fd
d = a + phi * (b - a)
fd = f(d)
return (b + a) / 2
root = golden_section_search(f, 0, 5)
print("Minimum near x =", root)输出:
Minimum near x = 2.0000C
#include <stdio.h>
#include <math.h>
double f(double x) {
return (x - 2)*(x - 2) + 1;
}
double golden_section_search(double (*f)(double), double a, double b, double tol) {
double phi = (sqrt(5.0) - 1) / 2;
double c = b - phi * (b - a);
double d = a + phi * (b - a);
double fc = f(c), fd = f(d);
while (fabs(b - a) > tol) {
if (fc < fd) {
b = d;
d = c;
fd = fc;
c = b - phi * (b - a);
fc = f(c);
} else {
a = c;
c = d;
fc = fd;
d = a + phi * (b - a);
fd = f(d);
}
}
return (b + a) / 2;
}
int main(void) {
double x = golden_section_search(f, 0, 5, 1e-5);
printf("Minimum near x = %.5f\n", x);
}输出:
Minimum near x = 2.00000为什么它重要
- 无需导数
- 比简单的二分搜索求值次数更少
- 对单峰函数保证收敛
- 用于数值优化、调参、工程设计、超参数搜索
- 黄金比例确保了计算点的最优重用
一个温和的证明(为什么它有效)
在每一步,区间长度乘以 $\phi \approx 0.618$。
所以经过 $k$ 步后: $$ L_k = (b_0 - a_0) \times \phi^k $$
要达到容差 $\varepsilon$: $$ k = \frac{\log(\varepsilon / (b_0 - a_0))}{\log(\phi)} $$
因此,收敛是线性的但高效,并且每次迭代只需要一次新的求值。
自己动手试试
- $f(x) = (x - 3)^2$, 区间 $[0, 6]$
- $f(x) = x^4 - 10x^2 + 9$, 区间 $[-5, 5]$
- $f(x) = \vert x - 1\vert $, 区间 $[-2, 4]$
- 尝试将容差改为
1e-3,1e-9 - 跟踪迭代次数
- 绘制搜索区间
- 切换到求最大值(比较符号)
- 测试非单峰函数(观察失败情况)
- 修改以同时返回 f(x*)
- 与三分搜索比较
测试用例
| 函数 | 区间 | 最小值 $x$ | $f(x)$ |
|---|---|---|---|
| $(x - 2)^2 + 1$ | $[0, 5]$ | 2.0000 | 1.0000 |
| $(x - 3)^2$ | $[0, 6]$ | 3.0000 | 0.0000 |
| $x^2$ | $[-3, 2]$ | 0.0000 | 0.0000 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(log((b−a)/tol)) |
| 空间 | O(1) |
| 每步求值次数 | 1 个新点 |
| 收敛性 | 线性 |
| 需要单峰性 | 是 |
黄金分割搜索是优化领域里安静的匠人,用 φ 的优雅平衡了精度与简洁。
169 斐波那契搜索(最优)
斐波那契搜索是一种分治算法,它利用斐波那契数列在已排序数组中搜索目标时确定探测位置。 它类似于二分搜索,但使用斐波那契数而非对半分割区间,这使其非常适合顺序访问系统(如磁带或大型内存数组)。
在比较次数至关重要或随机访问成本高昂的场景下,它表现出色。
我们要解决什么问题?
你想在已排序数组中高效地搜索一个元素, 但不同于二分搜索的对半分割区间, 你希望使用斐波那契数来决定搜索位置—— 以最小化比较次数,并利用算术友好的跳跃。
示例
让我们在以下数组中搜索 x = 55:
arr = [10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100]- 找到大于等于数组长度 (11) 的最小斐波那契数 → F(7) = 13
- 使用斐波那契偏移量来决定索引跳跃。
- 使用 fibM2 = 5 (F(5)=5 → 索引 4) 检查中间值:arr[4] = 45 < 55
- 移动窗口并重复,直到找到或范围缩小。
它是如何工作的(通俗解释)?
与二分搜索的对半分割不同, 它使用斐波那契数的比例进行分割,保持子数组大小接近黄金比例。
这种方法减少了比较次数,并且在数组大小已知 且访问成本是线性或受限的情况下尤其有效。
可以把它看作是由斐波那契间距引导的、数学上平衡的跳跃搜索。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 计算大于等于 n 的最小斐波那契数 Fm |
| 2 | 设置偏移量:Fm1 = Fm-1, Fm2 = Fm-2 |
| 3 | 比较目标值与 arr[偏移量 + Fm2] |
| 4 | 如果目标值更小,搜索左侧子数组(调整 Fm1, Fm2) |
| 5 | 如果目标值更大,搜索右侧子数组(更新偏移量,调整 Fm1, Fm2) |
| 6 | 如果相等,返回索引 |
| 7 | 继续直到 Fm1 = 1 |
微型代码(简易版本)
Python
def fibonacci_search(arr, x):
n = len(arr)
# 初始化斐波那契数
fibMMm2 = 0 # F(m-2)
fibMMm1 = 1 # F(m-1)
fibM = fibMMm2 + fibMMm1 # F(m)
# 找到大于等于 n 的最小斐波那契数
while fibM < n:
fibMMm2 = fibMMm1
fibMMm1 = fibM
fibM = fibMMm2 + fibMMm1
offset = -1
while fibM > 1:
i = min(offset + fibMMm2, n - 1)
if arr[i] < x:
fibM = fibMMm1
fibMMm1 = fibMMm2
fibMMm2 = fibM - fibMMm1
offset = i
elif arr[i] > x:
fibM = fibMMm2
fibMMm1 -= fibMMm2
fibMMm2 = fibM - fibMMm1
else:
return i
if fibMMm1 and offset + 1 < n and arr[offset + 1] == x:
return offset + 1
return -1
arr = [10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100]
print("Found at index:", fibonacci_search(arr, 55)) # 输出: -1
print("Found at index:", fibonacci_search(arr, 85)) # 输出: 8输出:
Found at index: -1
Found at index: 8C
#include <stdio.h>
int min(int a, int b) { return (a < b) ? a : b; }
int fibonacci_search(int arr[], int n, int x) {
int fibMMm2 = 0; // F(m-2)
int fibMMm1 = 1; // F(m-1)
int fibM = fibMMm2 + fibMMm1; // F(m)
while (fibM < n) {
fibMMm2 = fibMMm1;
fibMMm1 = fibM;
fibM = fibMMm2 + fibMMm1;
}
int offset = -1;
while (fibM > 1) {
int i = min(offset + fibMMm2, n - 1);
if (arr[i] < x) {
fibM = fibMMm1;
fibMMm1 = fibMMm2;
fibMMm2 = fibM - fibMMm1;
offset = i;
} else if (arr[i] > x) {
fibM = fibMMm2;
fibMMm1 -= fibMMm2;
fibMMm2 = fibM - fibMMm1;
} else {
return i;
}
}
if (fibMMm1 && offset + 1 < n && arr[offset + 1] == x)
return offset + 1;
return -1;
}
int main(void) {
int arr[] = {10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100};
int n = sizeof(arr)/sizeof(arr[0]);
int x = 85;
int idx = fibonacci_search(arr, n, x);
if (idx != -1)
printf("Found at index: %d\n", idx);
else
printf("Not found\n");
}输出:
Found at index: 8为什么它重要
- 使用斐波那契数来减少比较次数
- 对于需要顺序访问的已排序数组效率高
- 避免除法运算(仅使用加法/减法)
- 受黄金比例搜索(最优探测)启发
- 是教授分治逻辑的绝佳教学工具
一个温和的证明(为什么它有效)
斐波那契分割保持了接近黄金比例的平衡。 在每一步,一个子问题的规模大约是前一个的 $\frac{1}{\phi}$。
因此总步数 ≈ 小于等于 n 的斐波那契数的数量, 其增长为 $O(\log_\phi n) \approx O(\log n)$。
因此,时间复杂度与二分搜索相同, 但比较次数更少,算术运算效率更高。
亲自尝试
- 在
[10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100]中搜索 45 - 搜索 100(最后一个元素)
- 搜索 10(第一个元素)
- 搜索不在数组中的值
- 统计比较次数
- 与二分搜索比较
- 在长度等于斐波那契数(例如 13)的数组上尝试
- 可视化索引跳跃
- 修改代码以打印区间
- 应用于已排序的字符串(字典序)
测试用例
| 数组 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [10,22,35,40,45,50,80,82,85,90,100] | 85 | 8 | 找到 |
| [10,22,35,40,45] | 22 | 1 | 找到 |
| [1,2,3,5,8,13,21] | 21 | 6 | 找到 |
| [2,4,6,8,10] | 7 | -1 | 未找到 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 比较次数 | ≤ log₍φ₎(n) |
| 访问类型 | 顺序友好 |
| 需要输入已排序 | 是 |
斐波那契搜索,离散世界的黄金搜索,每一步都遵循自然的节奏。
170 跳步 + 二分混合搜索
跳步 + 二分混合搜索融合了两种方法的优点:跳步搜索用于快速跳跃,二分搜索用于精确查找。 当你的数据已排序,并且你希望在小的子范围内在线性跳跃和对数探测之间取得平衡时,这种方法非常完美。
我们要解决什么问题?
二分搜索功能强大,但需要随机访问(你可以跳到任何位置)。 跳步搜索在顺序数据(如链接块或缓存)上效果很好,但可能会跳过目标。
这种混合方法结合了两者:
- 以固定步长向前跳跃以找到目标块。
- 一旦确定了目标范围,就在该范围内切换到二分搜索。
对于随机访问受限的已排序数据集(如磁盘块或数据库页),这是一种实用的方法。
示例
在 [10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100] 中搜索 45。
步骤 1:以块大小 √n = 3 进行跳跃
- 检查
arr[2] = 35→ 35 < 45 - 检查
arr[5] = 50→ 50 > 45
现在我们知道目标在 [35, 40, 45, 50) → 索引 [3..5)
步骤 2:在块 [3..5) 内进行二分搜索
- 中点 = 4 → arr[4] = 45 ✅ 找到!
它是如何工作的(通俗解释)?
- 选择块大小 $m = \sqrt{n}$。
- 向前跳跃 $m$ 步,直到越过或到达目标。
- 一旦进入目标块,就切换到块内的二分搜索。
跳跃可以快速缩小搜索区域, 二分搜索则能干净利落地完成搜索,比单独使用任何一种方法所需的比较次数都少。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 计算块大小 $m = \lfloor \sqrt{n} \rfloor$ |
| 2 | 以 $m$ 为步长跳跃,直到 arr[j] ≥ target 或到达末尾 |
| 3 | 确定块 $[j - m, j)$ |
| 4 | 在该块内运行二分搜索 |
| 5 | 返回索引,如果未找到则返回 -1 |
简洁代码(简易版本)
Python
import math
def jump_binary_search(arr, x):
n = len(arr)
step = int(math.sqrt(n))
prev = 0
# 跳跃阶段
while prev < n and arr[min(step, n) - 1] < x:
prev = step
step += int(math.sqrt(n))
if prev >= n:
return -1
# 在块内进行二分搜索
low, high = prev, min(step, n) - 1
while low <= high:
mid = (low + high) // 2
if arr[mid] == x:
return mid
elif arr[mid] < x:
low = mid + 1
else:
high = mid - 1
return -1
arr = [10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100]
print("Found at index:", jump_binary_search(arr, 45))输出:
Found at index: 4C
#include <stdio.h>
#include <math.h>
int jump_binary_search(int arr[], int n, int x) {
int step = (int)sqrt(n);
int prev = 0;
// 跳跃阶段
while (prev < n && arr[(step < n ? step : n) - 1] < x) {
prev = step;
step += (int)sqrt(n);
if (prev >= n)
return -1;
}
// 在块内进行二分搜索
int low = prev;
int high = (step < n ? step : n) - 1;
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == x)
return mid;
else if (arr[mid] < x)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
int main(void) {
int arr[] = {10, 22, 35, 40, 45, 50, 80, 82, 85, 90, 100};
int n = sizeof(arr) / sizeof(arr[0]);
int idx = jump_binary_search(arr, n, 45);
if (idx != -1)
printf("Found at index: %d\n", idx);
else
printf("Not found\n");
}输出:
Found at index: 4为什么它重要
- 结合了快速跳跃(跳步)和高效缩小范围(二分)
- 在访问速度慢的已排序列表上效果良好
- 与纯跳步或线性搜索相比,减少了比较次数
- 非常适合基于块的存储和数据库索引
- 展示了算法设计中的混合思维
一个温和的证明(为什么它有效)
跳跃阶段:$O(\sqrt{n})$ 步 二分阶段:$O(\log \sqrt{n}) = O(\log n)$
总计: $$ T(n) = O(\sqrt{n}) + O(\log n) $$
对于大的 $n$,主要由 $O(\sqrt{n})$ 主导,但在实践中更快。
亲自尝试
- 在
[10,22,35,40,45,50,80,82,85,90,100]中搜索80 - 尝试搜索
10(第一个元素) - 尝试搜索
100(最后一个元素) - 尝试搜索数组中不存在的值
- 与二分搜索比较比较次数
- 更改块大小(尝试 2√n 或 n/4)
- 打印跳跃过程和块
- 在长度为 100 的数组上运行
- 与指数块发现方法结合
- 扩展到降序数组
测试用例
| 数组 | 目标 | 输出 | 备注 |
|---|---|---|---|
| [10,22,35,40,45,50,80,82,85,90,100] | 45 | 4 | 找到 |
| [10,22,35,40,45] | 10 | 0 | 在开头找到 |
| [10,22,35,40,45] | 100 | -1 | 未找到 |
| [1,3,5,7,9,11,13] | 7 | 3 | 在中间找到 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(√n + log n) |
| 空间复杂度 | O(1) |
| 需要排序 | 是 |
| 稳定 | 是 |
| 自适应 | 否 |
跳步 + 二分混合搜索,有目的地跳跃,然后精确瞄准。这就是探索者如何以速度和专注力进行搜索。
第 18 节 选择算法
171 快速选择
快速选择是一种选择算法,用于在未排序的数组中找到第 k 小的元素 —— 平均速度比排序整个数组更快。
它基于与快速排序相同的划分思想,但只递归进入包含目标元素的那一侧。
平均时间复杂度:O(n) 最坏情况(罕见):O(n²)
我们要解决什么问题?
假设你有一个未排序的列表,你想找到:
- 中位数,
- 第 k 小的元素,或
- 第 k 大的元素,
你不需要完全排序,你只需要一个顺序统计量。
快速选择通过划分数组并将焦点缩小到相关的一半来解决这个问题。
示例
在 [7, 2, 1, 6, 8, 5, 3, 4] 中找到第 4 小的元素:
- 选择枢轴(例如 4)。
- 划分 →
[2, 1, 3] [4] [7, 6, 8, 5] - 枢轴位置 = 3(从 0 开始计数)
- k = 4 → 枢轴索引 3 匹配 → 4 是第 4 小的元素 ✅
无需对剩余部分排序!
它是如何工作的(通俗解释)?
快速选择一个枢轴,将列表划分为小于和大于枢轴的部分,然后根据枢轴的索引与目标 k 的比较,决定递归进入哪一侧。
这是一种基于位置(而非顺序)的分治搜索。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 选择枢轴(随机或最后一个元素) |
| 2 | 围绕枢轴划分数组 |
| 3 | 获取枢轴索引 p |
| 4 | 如果 p == k,返回该元素 |
| 5 | 如果 p > k,搜索左侧 |
| 6 | 如果 p < k,搜索右侧(调整 k) |
简洁代码(简易版本)
Python
import random
def partition(arr, low, high):
pivot = arr[high]
i = low
for j in range(low, high):
if arr[j] < pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[high] = arr[high], arr[i]
return i
def quickselect(arr, k):
low, high = 0, len(arr) - 1
while low <= high:
pivot_index = partition(arr, low, high)
if pivot_index == k:
return arr[pivot_index]
elif pivot_index > k:
high = pivot_index - 1
else:
low = pivot_index + 1
arr = [7, 2, 1, 6, 8, 5, 3, 4]
k = 3 # 基于 0 的索引:第 4 小的元素
print("第 4 小的元素:", quickselect(arr, k))输出:
第 4 小的元素: 4C
#include <stdio.h>
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = low;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
swap(&arr[i], &arr[j]);
i++;
}
}
swap(&arr[i], &arr[high]);
return i;
}
int quickselect(int arr[], int low, int high, int k) {
if (low <= high) {
int pi = partition(arr, low, high);
if (pi == k)
return arr[pi];
else if (pi > k)
return quickselect(arr, low, pi - 1, k);
else
return quickselect(arr, pi + 1, high, k);
}
return -1;
}
int main(void) {
int arr[] = {7, 2, 1, 6, 8, 5, 3, 4};
int n = sizeof(arr) / sizeof(arr[0]);
int k = 3; // 第 4 小的元素 (基于 0 的索引)
printf("第 4 小的元素: %d\n", quickselect(arr, 0, n - 1, k));
}输出:
第 4 小的元素: 4为什么它重要
- 以线性时间(期望)找到中位数
- 当你只需要一个元素时避免排序
- 是中位数之中位数、BFPRT 等算法的基础
- 常用于顺序统计、分位数、Top-K 问题
- 在库中使用(例如 C++ 中的
nth_element)
一个温和的证明(为什么它有效)
每次划分通过消除一侧来减小问题规模。 平均分割 ≈ 一半 → 期望比较次数为 O(n)。 最坏情况(糟糕的枢轴) → O(n²), 但如果使用随机枢轴,这种情况极不可能发生。
期望时间: $$ T(n) = T(n/2) + O(n) \Rightarrow O(n) $$
亲自尝试
- 找到第 1 小的元素(最小值)
- 找到最后一个(最大值)
- 找到中位数(
k = n/2) - 添加随机枢轴选择
- 计算每次迭代的比较次数
- 修改为寻找第 k 大的元素(
n-k) - 与完全排序比较运行时间
- 可视化划分步骤
- 在重复元素上测试
- 与确定性枢轴选择结合
测试用例
| 数组 | k (基于 0) | 输出 | 备注 |
|---|---|---|---|
| [7,2,1,6,8,5,3,4] | 3 | 4 | 第 4 小的元素 |
| [3,1,2] | 1 | 2 | 中位数 |
| [10,80,30,90,40,50,70] | 4 | 70 | 中间元素 |
| [5,5,5,5] | 2 | 5 | 重复元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(平均) | O(n) |
| 时间(最坏) | O(n²) |
| 空间 | O(1) |
| 稳定 | 否 |
| 原地 | 是 |
| 随机化 | 推荐使用 |
快速选择,排序中的精准打击:找到你需要的,忽略其余。
172 中位数的中位数
中位数的中位数是一种确定性选择算法,它保证了在最坏情况下以 O(n) 的时间复杂度找到第 k 小的元素。 它改进了快速选择算法,后者在运气不佳的情况下可能退化到 O(n²)。中位数的中位数通过每次都精心选择一个好的枢轴来避免这种情况。
它是理论计算机科学的基石,在速度和最坏情况安全性之间取得了平衡。
我们要解决什么问题?
在快速选择中,一个糟糕的枢轴可能导致不平衡的分区(例如总是选择最小/最大的元素)。 中位数的中位数通过确保枢轴“足够好”来修复这个问题——它总是将数组分割成两部分,使得每一部分至少包含恒定比例的元素。
目标: 确定性地在 O(n) 时间内找到第 k 小的元素,无需随机性,没有风险。
示例
在 [12, 3, 5, 7, 4, 19, 26, 23, 8, 15] 中找到第 5 小的元素。
- 分成每组 5 个元素:
[12, 3, 5, 7, 4],[19, 26, 23, 8, 15] - 对每组排序:
[3,4,5,7,12],[8,15,19,23,26] - 取中位数:
[5, 19] - 找到中位数的中位数:
19 - 围绕
19对数组进行分区 - 在包含第 k 小元素的适当一侧递归查找,直到找到为止
枢轴 19 确保了平衡的分割,从而实现了线性运行时间。
它是如何工作的(通俗解释)?
- 将数组分成每组 5 个元素
- 对每个小组进行排序(开销小)
- 收集所有组的中位数
- 递归地找到这些中位数的中位数 → 好的枢轴
- 围绕枢轴进行分区
- 递归进入包含第 k 小元素的那一半
每一层都丢弃恒定比例的元素 → 总工作量为 O(n)。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 将数组分成大小为 5 的块 |
| 2 | 对每个块排序 |
| 3 | 提取中位数 |
| 4 | 递归地找到中位数的中位数 |
| 5 | 使用此枢轴进行分区 |
| 6 | 根据 k 的值递归进入正确的一侧 |
精简代码(简易版本)
Python
def partition(arr, pivot):
less = [x for x in arr if x < pivot]
equal = [x for x in arr if x == pivot]
greater = [x for x in arr if x > pivot]
return less, equal, greater
def select(arr, k):
if len(arr) <= 5:
return sorted(arr)[k]
# 步骤 1: 按 5 个一组分组
chunks = [arr[i:i+5] for i in range(0, len(arr), 5)]
# 步骤 2: 找到中位数
medians = [sorted(chunk)[len(chunk)//2] for chunk in chunks]
# 步骤 3: 枢轴 = 中位数的中位数
pivot = select(medians, len(medians)//2)
# 步骤 4: 分区
less, equal, greater = partition(arr, pivot)
# 步骤 5: 递归
if k < len(less):
return select(less, k)
elif k < len(less) + len(equal):
return pivot
else:
return select(greater, k - len(less) - len(equal))
arr = [12, 3, 5, 7, 4, 19, 26, 23, 8, 15]
k = 4 # 0-based: 第 5 小
print("第 5 小的元素:", select(arr, k))输出:
第 5 小的元素: 8C 语言(简化版本)
(类伪代码风格,便于阅读)
#include <stdio.h>
#include <stdlib.h>
int cmp(const void* a, const void* b) {
return (*(int*)a - *(int*)b);
}
int median_of_medians(int arr[], int n, int k);
int select_group_median(int arr[], int n) {
qsort(arr, n, sizeof(int), cmp);
return arr[n/2];
}
int median_of_medians(int arr[], int n, int k) {
if (n <= 5) {
qsort(arr, n, sizeof(int), cmp);
return arr[k];
}
int groups = (n + 4) / 5;
int medians[groups];
for (int i = 0; i < groups; i++) {
int size = (i*5 + 5 <= n) ? 5 : n - i*5;
medians[i] = select_group_median(arr + i*5, size);
}
int pivot = median_of_medians(medians, groups, groups/2);
// 分区
int less[n], greater[n], l = 0, g = 0, equal = 0;
for (int i = 0; i < n; i++) {
if (arr[i] < pivot) less[l++] = arr[i];
else if (arr[i] > pivot) greater[g++] = arr[i];
else equal++;
}
if (k < l)
return median_of_medians(less, l, k);
else if (k < l + equal)
return pivot;
else
return median_of_medians(greater, g, k - l - equal);
}
int main(void) {
int arr[] = {12, 3, 5, 7, 4, 19, 26, 23, 8, 15};
int n = sizeof(arr)/sizeof(arr[0]);
printf("第 5 小的元素: %d\n", median_of_medians(arr, n, 4));
}输出:
第 5 小的元素: 8为什么它很重要
- 即使在最坏情况下也能保证 O(n)
- 没有糟糕的枢轴 → 性能稳定
- BFPRT 算法的基础
- 用于实际系统的理论保证
- 确定性选择和安全的分位数计算的关键
一个温和的证明(为什么它有效)
每个枢轴确保每次递归至少丢弃 30% 的元素(通过分组证明)。
递归式: $$ T(n) = T(n/5) + T(7n/10) + O(n) \Rightarrow O(n) $$
因此,即使在最坏情况下,也总是线性时间。
亲自尝试
- 求
[5, 2, 1, 3, 4]的中位数 - 用重复元素测试
- 与快速选择的运行时间比较
- 计算递归调用次数
- 将组大小改为 3 或 7
- 可视化分组步骤
- 打印每轮的枢轴
- 应用于大型随机列表
- 与排序进行基准测试
- 将其实现为快速选择的枢轴选择策略
测试用例
| 数组 | k | 输出 | 备注 |
|---|---|---|---|
| [12,3,5,7,4,19,26,23,8,15] | 4 | 8 | 第 5 小的元素 |
| [5,2,1,3,4] | 2 | 3 | 中位数 |
| [7,7,7,7] | 2 | 7 | 重复元素 |
| [10,9,8,7,6,5] | 0 | 5 | 最小值 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(最坏) | O(n) |
| 时间(平均) | O(n) |
| 空间 | O(n) |
| 稳定 | 否 |
| 确定性 | 是 |
中位数的中位数,选择算法世界中的平衡思考者:缓慢而稳健,但总是线性的。
173 随机选择算法
随机选择算法是快速选择算法的一个概率版本,它通过随机选择枢轴来避免最坏情况的发生。 这个小改动使得算法的期望时间复杂度为 O(n),尽管最坏情况仍然是 O(n²)。 在实践中,它快速、简单且鲁棒,是顺序统计量计算中真正的“主力军”。
我们要解决什么问题?
你需要在一个未排序的列表中找到第 k 小的元素。 快速选择算法效果不错,但选择第一个或最后一个元素作为枢轴可能导致糟糕的分区。
随机选择算法通过随机选择枢轴来改进这一点,使得运气不佳的情况变得罕见,性能更加稳定。
示例
在 [7, 2, 1, 6, 8, 5, 3, 4] 中查找第 4 小的元素
- 随机选择枢轴(例如
5) - 分区 →
[2,1,3,4] [5] [7,6,8] - 枢轴索引 = 4 → 4 > 3,因此在左侧
[2,1,3,4]上递归 - 再次随机选择枢轴(例如
3) - 分区 →
[2,1] [3] [4] - 索引 2 = k=3 → 找到第 4 小的元素 = 4 ✅
它是如何工作的(通俗解释)?
它就是使用随机枢轴的快速选择算法。 在每一步:
- 随机选择一个元素作为枢轴。
- 围绕枢轴进行分区。
- 只向包含 k 的那一侧递归。
这种随机性确保了即使在对抗性输入下,也能实现平均情况下的平衡。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 随机选择枢轴索引 |
| 2 | 围绕枢轴对数组进行分区 |
| 3 | 获取枢轴索引 p |
| 4 | 如果 p == k,返回该元素 |
| 5 | 如果 p > k,向左递归 |
| 6 | 如果 p < k,向右递归(调整 k 值) |
精简代码(简易版本)
Python
import random
def partition(arr, low, high):
pivot = arr[high]
i = low
for j in range(low, high):
if arr[j] < pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[high] = arr[high], arr[i]
return i
def randomized_select(arr, low, high, k):
if low == high:
return arr[low]
pivot_index = random.randint(low, high)
arr[pivot_index], arr[high] = arr[high], arr[pivot_index]
p = partition(arr, low, high)
if p == k:
return arr[p]
elif p > k:
return randomized_select(arr, low, p - 1, k)
else:
return randomized_select(arr, p + 1, high, k)
arr = [7, 2, 1, 6, 8, 5, 3, 4]
k = 3 # 第 4 小的元素
print("第 4 小的元素:", randomized_select(arr, 0, len(arr)-1, k))输出:
第 4 小的元素: 4C
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
void swap(int *a, int *b) {
int t = *a; *a = *b; *b = t;
}
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = low;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
swap(&arr[i], &arr[j]);
i++;
}
}
swap(&arr[i], &arr[high]);
return i;
}
int randomized_select(int arr[], int low, int high, int k) {
if (low == high) return arr[low];
int pivot_index = low + rand() % (high - low + 1);
swap(&arr[pivot_index], &arr[high]);
int p = partition(arr, low, high);
if (p == k) return arr[p];
else if (p > k) return randomized_select(arr, low, p - 1, k);
else return randomized_select(arr, p + 1, high, k);
}
int main(void) {
srand(time(NULL));
int arr[] = {7, 2, 1, 6, 8, 5, 3, 4};
int n = sizeof(arr)/sizeof(arr[0]);
int k = 3;
printf("第 4 小的元素: %d\n", randomized_select(arr, 0, n - 1, k));
}输出:
第 4 小的元素: 4为什么它很重要
- 期望 O(n) 时间复杂度,简单且实用
- 避免了固定枢轴快速选择算法的最坏情况陷阱
- 非常适合 top-k 查询、分位数、中位数计算
- 结合了简单性与随机性 = 鲁棒的性
- 常用于竞技编程和实际系统
一个温和的证明(为什么它有效)
期望递归关系: $$ T(n) = T(\alpha n) + O(n) $$ 其中 $\alpha$ 是随机的,期望值 $\approx \tfrac{1}{2}$ → $T(n) = O(n)$
最坏情况仍然是 $O(n^2)$,但很罕见。 期望比较次数 $\approx 2n$。
亲自尝试
- 多次运行并观察枢轴的随机性
- 与确定性快速选择算法进行比较
- 计算递归调用次数
- 用已排序的输入进行测试(鲁棒性)
- 测试所有元素相同的情况
- 改变 k 值(第一个、中位数、最后一个)
- 修改代码以查找第 k 大的元素(
n-k-1) - 与 sort() 的性能进行比较
- 记录枢轴索引
- 在 10⁶ 个元素上测量运行时间
测试用例
| 数组 | k | 输出 | 注释 |
|---|---|---|---|
| [7,2,1,6,8,5,3,4] | 3 | 4 | 第 4 小的元素 |
| [10,80,30,90,40,50,70] | 4 | 70 | 适用于任何顺序 |
| [1,2,3,4,5] | 0 | 1 | 已排序输入安全 |
| [5,5,5,5] | 2 | 5 | 处理重复元素没问题 |
复杂度
| 方面 | 值 |
|---|---|
| 时间(期望) | O(n) |
| 时间(最坏) | O(n²) |
| 空间 | O(1) |
| 稳定 | 否 |
| 随机化 | 是 |
| 原地 | 是 |
随机选择算法,一场几乎总能获胜的概率游戏:快速、公平且优美地简单。
174 答案二分查找
答案二分查找(也称为参数化搜索)是一种强大的优化技巧,当搜索空间具有单调性时使用——即一旦某个条件变为真,它将保持为真(反之亦然)。我们不是在搜索一个已排序的数组,而是在搜索满足某个条件的最小或最大值。
我们要解决什么问题?
有时你没有一个数组可供搜索,但你需要最小化或最大化一个数值答案。 例如:
- 在
k天内运输物品的最小容量 - 路由器之间的最小最大距离
- 满足条件的最大中位数
我们无法高效地遍历所有可能性,但可以对答案空间进行二分查找。
示例
问题:给定数组 [1, 2, 3, 4, 5],将其分成 2 部分,最小化各部分中的最大和。
我们无法直接找到它,但如果我们能检查一个候选值 mid 是否有效(能否以和 ≤ mid 进行分割),我们就可以对 mid 进行二分查找。
| mid | canSplit(nums, 2, mid) | 结果 |
|---|---|---|
| 9 | True (分割: [1,2,3,4], [5]) | 可行 → 向左移动 |
| 7 | False | 向右移动 |
| 8 | True | 可行 → 最终答案 = 9 |
✅ 结果 = 9
它是如何工作的(通俗解释)?
你不是在搜索元素,而是在搜索值。 你定义一个函数 can(mid),检查使用 mid 是否可能得到一个解决方案。 然后使用二分查找来缩小范围,直到找到最优值。
逐步过程
| 步骤 | 描述 |
|---|---|
| 1 | 定义答案的范围 (lo, hi) |
| 2 | 当 lo < hi 时: |
| mid = (lo + hi) // 2 | |
| If can(mid): hi = mid | |
| Else: lo = mid + 1 | |
| 3 | 返回 lo 作为最优答案 |
精简代码(简易版本)
Python
def can_split(nums, k, mid):
count, current = 1, 0
for x in nums:
if current + x > mid:
count += 1
current = x
else:
current += x
return count <= k
def binary_search_answer(nums, k):
lo, hi = max(nums), sum(nums)
while lo < hi:
mid = (lo + hi) // 2
if can_split(nums, k, mid):
hi = mid
else:
lo = mid + 1
return lo
nums = [1, 2, 3, 4, 5]
k = 2
print("最小最大和:", binary_search_answer(nums, k))输出:
最小最大和: 9C
#include <stdio.h>
int can_split(int arr[], int n, int k, int mid) {
int count = 1, sum = 0;
for (int i = 0; i < n; i++) {
if (arr[i] > mid) return 0;
if (sum + arr[i] > mid) {
count++;
sum = arr[i];
} else {
sum += arr[i];
}
}
return count <= k;
}
int binary_search_answer(int arr[], int n, int k) {
int lo = arr[0], hi = 0;
for (int i = 0; i < n; i++) {
if (arr[i] > lo) lo = arr[i];
hi += arr[i];
}
while (lo < hi) {
int mid = (lo + hi) / 2;
if (can_split(arr, n, k, mid))
hi = mid;
else
lo = mid + 1;
}
return lo;
}
int main(void) {
int arr[] = {1, 2, 3, 4, 5};
int n = sizeof(arr)/sizeof(arr[0]);
int k = 2;
printf("最小最大和: %d\n", binary_search_answer(arr, n, k));
}输出:
最小最大和: 9为什么它很重要
- 无需暴力破解即可解决优化问题
- 将决策问题转化为搜索问题
- 一种通用模式:适用于容量、距离、时间等
- 常见于 LeetCode、面试和竞赛编程
一个温和的证明(为什么它有效)
如果一个函数 f(x) 是单调的(在某点之后为真或在某点之后为假),二分查找可以找到阈值。 形式化地说:
如果 f(lo) = false,f(hi) = true, 并且 f(x) 是单调的, 那么二分查找将收敛到使得 f(x) = true 的最小 x。
亲自尝试
- 找到在
d天内运送包裹的最小容量 - 找到每个学生的最小最大页面负载(书籍分配)
- 找到路由器之间的最大最小距离
- 找到粉刷所有木板的最小时间
- 找到准时到达的最小速度
- 定义一个单调函数
can(x)并应用搜索 - 尝试
float范围和容差 - 尝试最大值而不是最小值(反转条件)
- 计算每种情况下的二分查找步骤
- 与暴力方法进行比较
测试用例
| 问题 | 输入 | 输出 | 解释 |
|---|---|---|---|
| 分割数组 | [1,2,3,4,5], k=2 | 9 | [1,2,3,4],[5] |
| 书籍分配 | [10,20,30,40], k=2 | 60 | [10,20,30],[40] |
| 路由器放置 | [1,2,8,12], k=3 | 5 | 放置在 1,6,12 |
复杂度
| 方面 | 值 |
|---|---|
| 时间复杂度 | O(n log(max - min)) |
| 空间复杂度 | O(1) |
| 单调性要求 | 是 |
| 类型 | 基于决策的二分查找 |
答案二分查找:当你无法对数据进行排序时,就对解空间进行排序。
175 顺序统计树
顺序统计树是一种特殊的增强型二叉搜索树(BST),它能高效地支持两种强大的操作:
- Select(k):查找第 k 小的元素。
- Rank(x):查找元素
x的位置(排名)。
这是一种经典的数据结构,其中每个节点都存储子树的大小,从而允许在 O(log n) 时间内完成基于顺序的查询。
我们要解决什么问题?
有时,你不仅想通过键值进行搜索,还想通过顺序进行搜索。例如:
- “第 5 小的元素是什么?”
- “37 在树中的排名是多少?”
- “有多少个数 ≤ 50?”
顺序统计树让你在一个结构中既能进行基于键值的访问,也能进行基于排名的访问。
示例
假设你插入 [20, 15, 25, 10, 18, 22, 30]。
每个节点存储 size(其子树中的节点数)。
20(size=7)
/ \
15(3) 25(3)
/ \ / \
10(1) 18(1) 22(1) 30(1)Select(4) → 20(第 4 小的元素) Rank(22) → 6(22 是第 6 小的元素)
它是如何工作的(通俗解释)?
每个节点都跟踪其子树中存在多少个节点(左子树 + 右子树 + 自身)。 当你遍历时:
- 要选择第 k 小的元素,将
k与左子树的大小进行比较。 - 要查找 x 的排名,在向下遍历时累加大小。
Select(k) 伪代码
select(node, k):
left_size = size(node.left)
if k == left_size + 1: return node.key
if k <= left_size: return select(node.left, k)
else: return select(node.right, k - left_size - 1)Rank(x) 伪代码
rank(node, x):
if node == NULL: return 0
if x < node.key: return rank(node.left, x)
if x == node.key: return size(node.left) + 1
else: return size(node.left) + 1 + rank(node.right, x)精简代码(简易版本)
Python
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
self.size = 1
def update_size(node):
if node:
node.size = 1 + (node.left.size if node.left else 0) + (node.right.size if node.right else 0)
def insert(node, key):
if node is None:
return Node(key)
if key < node.key:
node.left = insert(node.left, key)
else:
node.right = insert(node.right, key)
update_size(node)
return node
def select(node, k):
left_size = node.left.size if node.left else 0
if k == left_size + 1:
return node.key
elif k <= left_size:
return select(node.left, k)
else:
return select(node.right, k - left_size - 1)
def rank(node, key):
if node is None:
return 0
if key < node.key:
return rank(node.left, key)
elif key == node.key:
return (node.left.size if node.left else 0) + 1
else:
left_size = node.left.size if node.left else 0
return left_size + 1 + rank(node.right, key)
root = None
for x in [20, 15, 25, 10, 18, 22, 30]:
root = insert(root, x)
print("Select(4):", select(root, 4)) # 20
print("Rank(22):", rank(root, 22)) # 6C(概念性框架)
typedef struct Node {
int key;
int size;
struct Node *left, *right;
} Node;
int size(Node* n) { return n ? n->size : 0; }
void update_size(Node* n) {
if (n) n->size = 1 + size(n->left) + size(n->right);
}
Node* new_node(int key) {
Node* n = malloc(sizeof(Node));
n->key = key; n->size = 1;
n->left = n->right = NULL;
return n;
}
Node* insert(Node* root, int key) {
if (!root) return new_node(key);
if (key < root->key) root->left = insert(root->left, key);
else root->right = insert(root->right, key);
update_size(root);
return root;
}
int select_k(Node* root, int k) {
int left_size = size(root->left);
if (k == left_size + 1) return root->key;
else if (k <= left_size) return select_k(root->left, k);
else return select_k(root->right, k - left_size - 1);
}为什么它很重要
- 在基于排名的查询、中位数查找和顺序统计问题中很有用
- 是带有顺序增强功能的平衡树(AVL、红黑树、Treap)的核心
- 支持动态中位数查询和范围计数
你可以把它想象成一个自我更新的排行榜,总是知道谁在第 k 位。
一个简单的证明(为什么它有效)
因为子树大小在插入/删除时被正确更新,所以每次遍历都能在 O(log n) 时间内(在平衡树中)计算出排名或第 k 个值。如果树是平衡的(如 AVL 或红黑树),操作将保持对数时间复杂度。
亲自尝试
- 为
[10,20,30,40,50]构建一个顺序统计树。 - 查找
Select(3)和Rank(40)。 - 插入新元素并重新检查排名。
- 扩展功能以动态查找中位数。
- 修改以支持删除操作。
- 与先排序再索引的方法进行比较(O(n log n) 对比 O(log n))。
- 尝试在红黑树的基础上构建。
- 用于计算运行百分位数。
- 探索用于相同查询的动态线段树。
- 使用 rank 实现
countLessThan(x)。
测试用例
| 查询 | 预期结果 |
|---|---|
| Select(1) | 10 |
| Select(4) | 20 |
| Rank(10) | 1 |
| Rank(22) | 6 |
| Rank(30) | 7 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 插入 / 删除 | O(log n) |
| Select(k) | O(log n) |
| Rank(x) | O(log n) |
| 空间 | O(n) |
顺序统计树融合了搜索和排名功能,非常适合那些需要同时知道是什么和在哪里的问题。
176 锦标赛树选择
锦标赛树是一种模拟元素间淘汰赛的二叉树结构。每场比赛比较两个元素,胜者晋级。这是一种通过结构化比较来寻找最小值、最大值甚至第 k 小元素的优雅方法。
我们要解决什么问题?
在列表中寻找最小值或最大值需要 O(n) 的时间。 但如果你还想找第二小、第三小或第 k 小的元素,你会希望重用之前的比较结果。 锦标赛树记录了所有比赛,因此你无需从头开始。
示例
假设我们有元素:[4, 7, 2, 9, 5, 1, 8, 6]。
- 将它们配对:比较 (4,7), (2,9), (5,1), (8,6)
- 胜者晋级:[4, 2, 1, 6]
- 下一轮:(4,2), (1,6) → 胜者 [2, 1]
- 决赛:(2,1) → 胜者 1
树的根节点 = 最小元素 (1)。
如果你存储每场比赛的失败者,你就可以追溯第二小的元素,它必定是直接输给 1 的元素。
它是如何工作的(通俗解释)?
想象一场体育锦标赛:
- 每位选手进行一场比赛。
- 胜者晋级,败者淘汰。
- 冠军(根节点)是最小的元素。
- 第二小的元素是那些输给冠军的选手中最好的那个。
每场比赛是一次比较,所以寻找最小值总共需要 n - 1 次比较。 要找到第二小的值,只需检查 log n 个失败者。
步骤
| 步骤 | 描述 |
|---|---|
| 1 | 构建一棵完全二叉树,其中每个叶子节点是一个元素。 |
| 2 | 比较每对元素,并将胜者向上移动。 |
| 3 | 在每个节点中存储“失败者”。 |
| 4 | 根节点 = 最小值。第二小值 = 胜者路径上所有失败者中的最小值。 |
简单代码(简易版本)
Python
def tournament_min(arr):
matches = []
tree = [[x] for x in arr]
while len(tree) > 1:
next_round = []
for i in range(0, len(tree), 2):
if i + 1 == len(tree):
next_round.append(tree[i])
continue
a, b = tree[i][0], tree[i+1][0]
if a < b:
next_round.append([a, b])
else:
next_round.append([b, a])
matches = next_round
tree = next_round
return tree[0][0]
def find_second_min(arr):
# 构建锦标赛,跟踪失败者
n = len(arr)
tree = [[x, []] for x in arr]
while len(tree) > 1:
next_round = []
for i in range(0, len(tree), 2):
if i + 1 == len(tree):
next_round.append(tree[i])
continue
a, a_losers = tree[i]
b, b_losers = tree[i+1]
if a < b:
next_round.append([a, a_losers + [b]])
else:
next_round.append([b, b_losers + [a]])
tree = next_round
winner, losers = tree[0]
return winner, min(losers)
arr = [4, 7, 2, 9, 5, 1, 8, 6]
min_val, second_min = find_second_min(arr)
print("最小值:", min_val)
print("第二小值:", second_min)输出:
最小值: 1
第二小值: 2C
#include <stdio.h>
#include <limits.h>
int tournament_min(int arr[], int n) {
int size = n;
while (size > 1) {
for (int i = 0; i < size / 2; i++) {
arr[i] = (arr[2*i] < arr[2*i + 1]) ? arr[2*i] : arr[2*i + 1];
}
size = (size + 1) / 2;
}
return arr[0];
}
int main(void) {
int arr[] = {4, 7, 2, 9, 5, 1, 8, 6};
int n = sizeof(arr) / sizeof(arr[0]);
printf("最小值: %d\n", tournament_min(arr, n));
}为什么它很重要
- 在 O(n) 次比较内找到最小值,在 O(n + log n) 次比较内找到第二小值
- 如果存储所有比赛信息,可重复用于第 k 小选择
- 构成了选择网络、并行排序和归并锦标赛的基础
一个温和的证明(为什么它有效)
除了最小元素外,每个元素恰好失败一次。 最小元素参与了 log₂n 场比赛(树的高度)。 所以第二小的元素必须是 log₂n 个失败者中最小的那个,这需要额外的 log₂n 次比较。
总计 = n - 1 + log₂n 次比较,这在渐进意义上是最优的。
亲自尝试
- 为
[5,3,8,2,9,4]构建一个锦标赛。 - 手动找出最小值和第二小值。
- 修改代码以寻找最大值和第二大值。
- 打印树的每一轮以可视化比赛。
- 尝试非 2 的幂次方(大小不均匀)的情况。
- 尝试扩展它以找到第三小的元素(提示:存储路径)。
- 与基于排序的方法进行比较。
- 使用锦标赛结构解决成对淘汰问题。
- 模拟体育比赛对阵图中的冠军路径。
- 计算每一步的比较次数。
测试用例
| 输入 | 最小值 | 第二小值 |
|---|---|---|
| [4,7,2,9,5,1,8,6] | 1 | 2 |
| [10,3,6,2] | 2 | 3 |
| [5,4,3,2,1] | 1 | 2 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建锦标赛 | O(n) |
| 寻找最小值 | O(1) |
| 寻找第二小值 | O(log n) |
| 空间 | O(n) |
锦标赛树将比较转化为比赛,每个元素都参与一次,冠军不仅揭示了胜利,也讲述了每一次失败的故事。
177 堆选择(最小堆)
堆选择是一种简单而强大的技术,利用堆来查找集合中第 k 小(或第 k 大)的元素。它是最实用的选择算法之一,以最少的代码换取强大的效率。
我们要解决什么问题?
你通常不需要完整的排序,只需要第 k 小或第 k 大的项。 例如:
- 查找前 10 名分数
- 获取最小的 5 个距离
- 维护前 k 个热门话题
堆(优先队列)使这变得容易,只需维护一个大小为 k 的运行集合,根据需要弹出或推入元素。
示例
在 [7, 2, 9, 1, 5, 4] 中查找 3 个最小的元素。
- 为前
k=3个元素创建最大堆 →[7, 2, 9]→ 堆 = [9, 2, 7] - 对于后续每个元素:
- 1 < 9 → 弹出 9,推入 1 → 堆 = [7, 2, 1]
- 5 < 7 → 弹出 7,推入 5 → 堆 = [5, 2, 1]
- 4 < 5 → 弹出 5,推入 4 → 堆 = [4, 2, 1]
结果 → [1, 2, 4](最小的 3 个元素)
它是如何工作的(通俗解释)?
你维护一个大小为 k 的堆:
- 对于查找最小元素 → 使用最大堆(如果出现新的更小元素,则移除当前最大的元素)。
- 对于查找最大元素 → 使用最小堆(如果出现新的更大元素,则移除当前最小的元素)。
这确保了在任何时候堆中只保留前 k 个感兴趣的值。
逐步过程
| 步骤 | 操作 |
|---|---|
| 1 | 用前 k 个元素初始化堆 |
| 2 | 转换为最大堆(如果查找最小的 k 个元素) |
| 3 | 对于每个剩余元素 x: |
| 如果 x < heap[0] → 替换堆顶元素 | |
| 4 | 结果就是堆的内容(未排序) |
| 5 | 如果需要最终输出排序,则对堆进行排序 |
简洁代码(简易版本)
Python
import heapq
def k_smallest(nums, k):
# 使用负数来模拟最大堆
heap = [-x for x in nums[:k]]
heapq.heapify(heap)
for x in nums[k:]:
if -x > heap[0]:
heapq.heappop(heap)
heapq.heappush(heap, -x)
return sorted([-h for h in heap])
nums = [7, 2, 9, 1, 5, 4]
print("3 smallest:", k_smallest(nums, 3))输出:
3 smallest: [1, 2, 4]C
#include <stdio.h>
#include <stdlib.h>
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
void heapify(int arr[], int n, int i) {
int largest = i, l = 2*i+1, r = 2*i+2;
if (l < n && arr[l] > arr[largest]) largest = l;
if (r < n && arr[r] > arr[largest]) largest = r;
if (largest != i) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest);
}
}
void build_heap(int arr[], int n) {
for (int i = n/2 - 1; i >= 0; i--) heapify(arr, n, i);
}
void heap_select(int arr[], int n, int k) {
int heap[k];
for (int i = 0; i < k; i++) heap[i] = arr[i];
build_heap(heap, k);
for (int i = k; i < n; i++) {
if (arr[i] < heap[0]) {
heap[0] = arr[i];
heapify(heap, k, 0);
}
}
printf("%d smallest elements:\n", k);
for (int i = 0; i < k; i++) printf("%d ", heap[i]);
}
int main(void) {
int arr[] = {7, 2, 9, 1, 5, 4};
int n = 6;
heap_select(arr, n, 3);
}输出:
3 smallest elements:
1 2 4为什么它很重要
- 避免了完全排序(O(n log n))
- 非常适合流数据、滑动窗口、前 k 大/小问题
- 对于大的
n和小的k扩展性良好 - 用于排行榜、分析、数据管道
如果你只需要部分顺序,就不要全部排序。
一个温和的证明(为什么它有效)
- 建堆:O(k)
- 对于每个新元素:比较 + 堆化 = O(log k)
- 总计:O(k + (n-k) log k) ≈ O(n log k)
对于小的 k,这比排序快得多。
自己动手试试
- 使用最小堆查找 3 个最大的元素
- 从输入流中读取数字,维护最小的 5 个
- 动态跟踪前 10 名分数
- 与
sorted(nums)[:k]比较运行时间 - 尝试
k = 1(最小值),k = n(完全排序) - 修改以处理具有自定义键的对象(例如分数、ID)
- 处理重复项,保留所有或仅保留唯一项
- 用大小为 1e6 的随机数组进行实验
- 可视化每一步堆的演变
- 与二分搜索结合以调整阈值
测试用例
| 输入 | k | 输出 |
|---|---|---|
| [7,2,9,1,5,4] | 3 | [1,2,4] |
| [10,8,6,4,2] | 2 | [2,4] |
| [1,1,1,1] | 2 | [1,1] |
复杂度
| 操作 | 复杂度 |
|---|---|
| 建堆 | O(k) |
| 遍历数组 | O((n-k) log k) |
| 总时间 | O(n log k) |
| 空间 | O(k) |
堆选择是你的实用捷径,只排序你需要的,忽略其余部分。
178 部分快速排序
部分快速排序是经典快速排序的一种变体,它一旦将前 k 个元素(或前 k 大/小元素)放置到正确位置后就会停止排序。当你需要前 k 个最小/最大元素,但不需要对其余部分进行排序时,这种方法非常完美。
可以把它看作是带有提前停止的快速排序,是快速排序和快速选择之间的混合体。
我们要解决什么问题?
有时你只需要部分排序的结果:
- "获取前 10 个分数"
- "找出最小的 k 个元素"
- "对前半部分排序"
完全排序会做无用功。部分快速排序会跳过不必要的分区。
示例
数组:[9, 4, 6, 2, 8, 1], k = 3
我们想要最小的 3 个元素。 快速排序选取一个基准,对数组进行分区:
基准 = 6
→ [4, 2, 1] | 6 | [9, 8]现在我们知道基准左侧的所有元素(4, 2, 1)都更小。 由于 left_size == k,我们可以停止,[1,2,4] 就是我们最小的 3 个元素。
它是如何工作的(通俗解释)?
就像快速排序一样,但在分区之后:
- 如果基准索引 == k → 完成。
- 如果基准索引 > k → 只递归处理左侧。
- 如果基准索引 < k → 部分递归处理右侧。
你永远不会对不需要的部分进行排序。
分步过程
| 步骤 | 描述 |
|---|---|
| 1 | 选择基准 |
| 2 | 对数组进行分区 |
| 3 | 如果基准索引 == k → 完成 |
| 4 | 如果基准索引 > k → 递归处理左侧 |
| 5 | 否则递归处理右侧的剩余部分 |
精简代码(简易版本)
Python
def partial_quicksort(arr, low, high, k):
if low < high:
p = partition(arr, low, high)
if p > k:
partial_quicksort(arr, low, p - 1, k)
elif p < k:
partial_quicksort(arr, low, p - 1, k)
partial_quicksort(arr, p + 1, high, k)
def partition(arr, low, high):
pivot = arr[high]
i = low - 1
for j in range(low, high):
if arr[j] < pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
arr = [9, 4, 6, 2, 8, 1]
k = 3
partial_quicksort(arr, 0, len(arr) - 1, k - 1)
print("最小的 3 个元素:", sorted(arr[:k]))输出:
最小的 3 个元素: [1, 2, 4]C
#include <stdio.h>
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return i + 1;
}
void partial_quicksort(int arr[], int low, int high, int k) {
if (low < high) {
int p = partition(arr, low, high);
if (p > k)
partial_quicksort(arr, low, p - 1, k);
else if (p < k)
partial_quicksort(arr, p + 1, high, k);
}
}
int main(void) {
int arr[] = {9, 4, 6, 2, 8, 1};
int n = 6, k = 3;
partial_quicksort(arr, 0, n - 1, k - 1);
printf("最小的 %d 个元素:\n", k);
for (int i = 0; i < k; i++) printf("%d ", arr[i]);
}输出:
最小的 3 个元素:
1 2 4为什么它很重要
- 当顺序重要时,能高效地进行前 k 个元素选择
- 避免对不必要的部分进行排序
- 结合了快速选择和快速排序的优点
- 原地工作(无需额外内存)
非常适合部分排序场景,如"排行榜"、"前几名结果"或"有界优先级列表"。
一个温和的证明(为什么它有效)
快速排序将数据分成两半; 只探索可能包含前 k 个元素的部分。 对于选择操作,平均时间复杂度变为 O(n);对于部分排序,则为 O(n log k)。
亲自尝试
- 在
[10,9,8,7,6,5,4,3,2,1]中找出最小的 5 个数 - 修改代码以找出最大的 k 个元素
- 与完整
sort()的运行时间进行比较 - 可视化递归路径
- 跟踪实际有多少元素被排序
- 尝试随机基准与中位数基准
- 测试 k = 1(最小值)和 k = n(完全排序)
- 测量比较次数
- 尝试包含重复元素的情况
- 与堆结合以实现混合版本
测试用例
| 输入 | k | 输出 |
|---|---|---|
| [9,4,6,2,8,1] | 3 | [1,2,4] |
| [5,4,3,2,1] | 2 | [1,2] |
| [7,7,7,7] | 2 | [7,7] |
复杂度
| 方面 | 值 |
|---|---|
| 平均时间复杂度 | O(n) |
| 最坏时间复杂度 | O(n²) |
| 空间复杂度 | O(1) |
| 稳定性 | 否 |
部分快速排序,快速、专注且节省,因为有时你只需要一小片有序,而不是整个面包。
179 BFPRT 算法(中位数的中位数选择)
BFPRT 算法(以 Blum、Floyd、Pratt、Rivest 和 Tarjan 命名)是一种确定性的线性时间选择算法。它能在未排序的数组中找到第 k 小的元素,保证最坏情况 O(n) 的时间复杂度,是随机化快速选择算法在数学上优雅且精确的替代方案。
我们要解决什么问题?
你想找到第 k 小的元素,比如中位数,但你不想在随机枢轴上碰运气(这可能导致最坏情况的 O(n²))。BFPRT 算法能非常出色地选择枢轴,从而始终保证 O(n) 的时间复杂度。
这使得它非常适合需要确定性行为的系统,例如嵌入式系统、编译器和实时应用。
示例
在 [9, 4, 7, 3, 6, 1, 8, 2, 5, 10] 中查找中位数(k=5)
- 分成每组 5 个元素的组:
[9,4,7,3,6],[1,8,2,5,10] - 找出每组的中位数: →
[6, 5] - 找出这些中位数的中位数: →
[6,5]的中位数是 5.5 ≈ 5 - 围绕枢轴 5 进行划分 →
[4,3,1,2,5] | 5 | [9,7,8,6,10] - 5 的位置 = 5 → 完成。
✅ 第 5 小的元素 = 5
它是如何工作的(通俗解释)?
它是带有更智能枢轴的快速选择:
- 将数组分成每组 5 个元素的组。
- 找出每组的中位数。
- 递归地找出这些中位数的中位数。
- 将其用作枢轴 → 划分 → 在正确的一侧递归。
这确保了枢轴总是足够好,能够合理地分割数组,保持递归平衡。
分步总结
| 步骤 | 描述 |
|---|---|
| 1 | 将数组分成每组 5 个元素的组 |
| 2 | 对每组排序并取其中位数 |
| 3 | 递归地找出中位数的中位数 |
| 4 | 围绕枢轴进行划分 |
| 5 | 在包含第 k 小元素的一侧递归 |
精简代码(简易版本)
Python
def partition(arr, pivot):
less, equal, greater = [], [], []
for x in arr:
if x < pivot: less.append(x)
elif x > pivot: greater.append(x)
else: equal.append(x)
return less, equal, greater
def select(arr, k):
if len(arr) <= 5:
return sorted(arr)[k]
# 步骤 1:分成每组 5 个元素的组
groups = [arr[i:i+5] for i in range(0, len(arr), 5)]
# 步骤 2:找出中位数
medians = [sorted(g)[len(g)//2] for g in groups]
# 步骤 3:中位数的中位数作为枢轴
pivot = select(medians, len(medians)//2)
# 步骤 4:划分
less, equal, greater = partition(arr, pivot)
# 步骤 5:递归
if k < len(less):
return select(less, k)
elif k < len(less) + len(equal):
return pivot
else:
return select(greater, k - len(less) - len(equal))
arr = [9,4,7,3,6,1,8,2,5,10]
k = 4 # 基于 0 的索引 → 第 5 小的元素
print("第 5 小的元素:", select(arr, k))输出:
第 5 小的元素: 5C 语言(概念框架)
#include <stdio.h>
#include <stdlib.h>
int cmp(const void *a, const void *b) { return (*(int*)a - *(int*)b); }
int median_of_medians(int arr[], int n);
int select_kth(int arr[], int n, int k) {
if (n <= 5) {
qsort(arr, n, sizeof(int), cmp);
return arr[k];
}
int groups = (n + 4) / 5;
int *medians = malloc(groups * sizeof(int));
for (int i = 0; i < groups; i++) {
int start = i * 5;
int end = (start + 5 < n) ? start + 5 : n;
qsort(arr + start, end - start, sizeof(int), cmp);
medians[i] = arr[start + (end - start) / 2];
}
int pivot = median_of_medians(medians, groups);
free(medians);
int *left = malloc(n * sizeof(int));
int *right = malloc(n * sizeof(int));
int l = 0, r = 0, equal = 0;
for (int i = 0; i < n; i++) {
if (arr[i] < pivot) left[l++] = arr[i];
else if (arr[i] > pivot) right[r++] = arr[i];
else equal++;
}
if (k < l) return select_kth(left, l, k);
else if (k < l + equal) return pivot;
else return select_kth(right, r, k - l - equal);
}
int median_of_medians(int arr[], int n) {
return select_kth(arr, n, n / 2);
}为什么它很重要
- 确定性的 O(n),没有随机性或坏枢轴
- 用于理论计算机科学、最坏情况分析、精确求解器
- 确定性选择、中位数查找、线性时间排序界限的基础
- 算法理论导论的核心(CLRS 第 9 章)
一个温和的证明(为什么它有效)
- 每组 5 个元素 → 中位数在组内大于等于 3 个元素(2 个在下,2 个在上)
- 至少一半的中位数 ≥ 枢轴 → 枢轴 ≥ 30% 的元素
- 至少一半 ≤ 枢轴 → 枢轴 ≤ 70% 的元素
- 因此枢轴总是以 30–70 的比例分割数组,保证 T(n) = T(n/5) + T(7n/10) + O(n) = O(n)
没有出现二次爆炸的可能。
自己动手试试
- 找出
[5,3,2,8,1,9,7,6,4]的中位数 - 追踪枢轴选择树
- 与随机快速选择的枢轴进行比较
- 测量 n = 1e6 时的时间
- 尝试有重复元素的情况
- 尝试 k = 0, k = n-1(最小/最大值)
- 修改组大小(例如 3 或 7),比较性能
- 验证递归深度
- 用于百分位数查询
- 使用重复选择实现流式数据中位数
测试用例
| 输入 | k | 输出 | 含义 |
|---|---|---|---|
| [9,4,7,3,6,1,8,2,5,10] | 4 | 5 | 第 5 小的元素 |
| [1,2,3,4,5] | 2 | 3 | 中间值 |
| [10,9,8,7,6] | 0 | 6 | 最小的元素 |
复杂度
| 方面 | 值 |
|---|---|
| 时间 | O(n) 确定性 |
| 空间 | O(n)(可优化为 O(1)) |
| 稳定性 | 否 |
| 枢轴质量 | 保证 30–70 分割 |
BFPRT 算法证明了,凭借巧妙的枢轴和数学,即使是混沌也能在线性时间内被征服。
180 第 K 大流
第 K 大流问题关注于在一个随时间增长、不断流入的序列(即流)中维护第 k 大的元素。 与其每次都排序所有元素,我们可以使用一个大小为 k 的最小堆来高效地持续追踪最大的 k 个元素。
这是实时排行榜、流式分析和在线排名系统的基础。
我们要解决什么问题?
给定一个数字流(逐个到达),我们希望:
- 始终知道迄今为止的第 k 大元素。
- 当新数字到来时能快速更新。
你并不知道最终的列表,你需要在数据流动时处理它。
示例
假设 k = 3,流 = [4, 5, 8, 2]
- 起始堆为空
- 添加 4 → 堆 = [4] → 第 k 大 = 4
- 添加 5 → 堆 = [4, 5] → 第 k 大 = 4
- 添加 8 → 堆 = [4, 5, 8] → 第 k 大 = 4
- 添加 2 → 忽略(2 < 4)→ 第 k 大 = 4
添加新数字 10:
- 10 > 4 → 弹出 4,压入 10 → 堆 = [5,8,10]
- 第 k 大 = 5
✅ 每个新元素处理时间为 O(log k)
它是如何工作的(通俗解释)?
保持一个大小为 k 的最小堆:
- 它保存迄今为止看到的 k 个最大元素。
- 其中最小的那个(堆顶)就是第 k 大的元素。
- 当新值到达时:
- 如果堆大小 < k → 压入它
- 否则如果值 > heap[0] → 弹出最小值,压入新值
逐步总结
| 步骤 | 操作 |
|---|---|
| 1 | 初始化空的最小堆 |
| 2 | 对于每个新元素 x: |
如果堆大小 < k → 压入 x | |
否则如果 x > heap[0] → 弹出,压入 x | |
| 3 | 第 k 大 = heap[0] |
简洁代码(简易版本)
Python
import heapq
class KthLargest:
def __init__(self, k, nums):
self.k = k
self.heap = nums
heapq.heapify(self.heap)
while len(self.heap) > k:
heapq.heappop(self.heap)
def add(self, val):
if len(self.heap) < self.k:
heapq.heappush(self.heap, val)
elif val > self.heap[0]:
heapq.heapreplace(self.heap, val)
return self.heap[0]
# 示例
stream = KthLargest(3, [4,5,8,2])
print(stream.add(3)) # 4
print(stream.add(5)) # 5
print(stream.add(10)) # 5
print(stream.add(9)) # 8
print(stream.add(4)) # 8输出:
4
5
5
8
8C
#include <stdio.h>
#include <stdlib.h>
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
void heapify(int arr[], int n, int i) {
int smallest = i, l = 2*i+1, r = 2*i+2;
if (l < n && arr[l] < arr[smallest]) smallest = l;
if (r < n && arr[r] < arr[smallest]) smallest = r;
if (smallest != i) {
swap(&arr[i], &arr[smallest]);
heapify(arr, n, smallest);
}
}
void push_heap(int heap[], int *n, int val) {
heap[(*n)++] = val;
for (int i = (*n)/2 - 1; i >= 0; i--) heapify(heap, *n, i);
}
int pop_min(int heap[], int *n) {
int root = heap[0];
heap[0] = heap[--(*n)];
heapify(heap, *n, 0);
return root;
}
int add(int heap[], int *n, int k, int val) {
if (*n < k) {
push_heap(heap, n, val);
} else if (val > heap[0]) {
heap[0] = val;
heapify(heap, *n, 0);
}
return heap[0];
}
int main(void) {
int heap[10] = {4,5,8,2};
int n = 4, k = 3;
for (int i = n/2 - 1; i >= 0; i--) heapify(heap, n, i);
while (n > k) pop_min(heap, &n);
printf("%d\n", add(heap, &n, k, 3)); // 4
printf("%d\n", add(heap, &n, k, 5)); // 5
printf("%d\n", add(heap, &n, k, 10)); // 5
printf("%d\n", add(heap, &n, k, 9)); // 8
printf("%d\n", add(heap, &n, k, 4)); // 8
}为什么重要
- 实时流式 top-k 追踪
- 常数时间查询(O(1)),快速更新(O(log k))
- 核心构建模块,用于:
- 排行榜
- 监控系统
- 持续分析
- 在线中位数和百分位数
一个温和的证明(为什么它有效)
最小堆只存储最大的 k 个值。 每当新值 > heap[0] 时,它必然属于最大的 k 个值之一。 因此不变量成立:堆 = 迄今为止看到的最大的 k 个元素。 第 k 大 = heap[0]。
每次更新 → O(log k)。 处理 n 个元素后总计 → O(n log k)。
亲自尝试
- 用
[4,5,8,2]初始化,k=3,流 = [3,5,10,9,4] - 尝试递减序列
- 尝试重复元素
- 测试
k = 1(最大值追踪器) - 随机添加 1000 个元素,测量性能
- 与每次全排序进行比较
- 可视化每一步堆的演变
- 修改为追踪第 k 小
- 使用两个堆构建实时中位数追踪器
- 扩展到对象流(按分数字段追踪)
测试用例
| 初始值 | k | 流 | 输出序列 |
|---|---|---|---|
| [4,5,8,2] | 3 | [3,5,10,9,4] | [4,5,5,8,8] |
| [10,7,11,5] | 2 | [8,12,4] | [10,11,11] |
| [1] | 1 | [2,3] | [2,3] |
复杂度
| 操作 | 复杂度 |
|---|---|
| 添加 | O(log k) |
| 查询第 k 大 | O(1) |
| 空间 | O(k) |
第 K 大流,在数据流中保持冷静;堆记住了最重要的部分。
第 19 节 范围搜索与最近邻
181 二分查找范围
二分查找范围扩展了基础的二分查找,不仅查找单个出现位置,还查找给定值出现的所有位置范围,具体来说,就是目标值在有序数组中的第一个(下界)和最后一个(上界)索引。
它是解决需要统计出现次数、范围查询以及在有序数据中确定插入位置等问题的基础。
我们要解决什么问题?
标准的二分查找返回一个匹配项。但如果目标值出现多次,或者我们想知道它应该插入的位置呢?
示例:
- 统计有序数组中
x的出现次数 - 查找第一个 ≥
x的元素 - 查找最后一个 ≤
x的元素
通过两次二分查找,我们可以高效地找到完整的 [起始, 结束] 范围。
示例
数组:[1, 2, 2, 2, 3, 4, 5],目标值 = 2
| 函数 | 结果 |
|---|---|
| 下界 (≥ 2) | 1 |
| 上界 (> 2) | 4 |
| 范围 | [1, 3] |
出现次数 = 上界 - 下界 = 3
它是如何工作的(通俗解释)?
我们使用两次二分查找:
- 一次查找第一个索引 ≥ 目标值(下界)
- 一次查找第一个索引 > 目标值(上界)
将它们相减得到计数,或者截取范围。
逐步总结
| 步骤 | 描述 |
|---|---|
| 1 | 二分查找第一个索引 i,使得 arr[i] >= target |
| 2 | 二分查找第一个索引 j,使得 arr[j] > target |
| 3 | 如果 i < j 且 arr[i] == target,范围 = [i, j - 1] |
| 4 | 计数 = j - i |
简洁代码(简易版本)
Python
def lower_bound(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def upper_bound(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
def binary_search_range(arr, target):
l = lower_bound(arr, target)
r = upper_bound(arr, target)
if l == r:
return (-1, -1) # 未找到
return (l, r - 1)
arr = [1, 2, 2, 2, 3, 4, 5]
target = 2
print("范围:", binary_search_range(arr, target))
print("计数:", upper_bound(arr, target) - lower_bound(arr, target))输出:
范围: (1, 3)
计数: 3C
#include <stdio.h>
int lower_bound(int arr[], int n, int target) {
int lo = 0, hi = n;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (arr[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}
int upper_bound(int arr[], int n, int target) {
int lo = 0, hi = n;
while (lo < hi) {
int mid = (lo + hi) / 2;
if (arr[mid] <= target) lo = mid + 1;
else hi = mid;
}
return lo;
}
int main(void) {
int arr[] = {1, 2, 2, 2, 3, 4, 5};
int n = 7, target = 2;
int l = lower_bound(arr, n, target);
int r = upper_bound(arr, n, target);
if (l == r) printf("未找到\n");
else printf("范围: [%d, %d], 计数: %d\n", l, r - 1, r - l);
}输出:
范围: [1, 3], 计数: 3为什么它很重要
- 将二分查找从"找到与否"扩展到更广的范围
- 在频率统计、范围查询、直方图等方面至关重要
- 为线段树、树状数组和范围索引等数据结构提供支持
- 用于竞赛编程和数据库索引
一个温和的证明(为什么它有效)
因为二分查找保持了有序的不变性(lo < hi 和 mid 的条件),
- 下界查找条件翻转(
< target→≥ target)的第一个索引 - 上界查找超过目标值的第一个索引
两者都以 O(log n) 的时间运行,给出精确的范围边界。
亲自尝试
- 测试没有出现的情况(例如
[1,3,5], target=2) - 测试所有元素都相等的情况(例如
[2,2,2,2], target=2) - 测试第一个元素等于目标值的情况
- 测试最后一个元素等于目标值的情况
- 尝试统计 ≤
x或 <x的元素 - 扩展到浮点数或自定义比较器
- 在字符串或元组上使用
- 在 Python 中与 bisect 结合使用
- 比较迭代与递归版本
- 用作频率表的原语
测试用例
| 数组 | 目标值 | 范围 | 计数 |
|---|---|---|---|
| [1,2,2,2,3,4,5] | 2 | [1,3] | 3 |
| [1,3,5,7] | 2 | [-1,-1] | 0 |
| [2,2,2,2] | 2 | [0,3] | 4 |
| [1,2,3,4] | 4 | [3,3] | 1 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 下界查找 | O(log n) |
| 上界查找 | O(log n) |
| 空间复杂度 | O(1) |
| 稳定性 | 是 |
二分查找范围,当一个答案不够时,精确性就是一切。
182 线段树查询
线段树查询是一种强大的数据结构技术,它允许你高效地计算子数组上的区间查询,例如求和、最小值、最大值,甚至是自定义的结合性操作。
它将数组预处理成二叉树结构,其中每个节点存储一个区间的汇总(聚合)信息。
一旦构建完成,你可以在 O(log n) 时间内回答查询和更新操作。
我们要解决什么问题?
给定一个数组,我们经常希望对区间进行查询:
[L, R]区间内的和[L, R]区间内的最小值或最大值- GCD、乘积、XOR 或任何结合性函数
一种朴素的方法是循环处理每个查询:每次查询 O(n)。 线段树通过一次性的 O(n) 构建,将查询时间降低到 O(log n)。
示例
数组:[2, 4, 5, 7, 8, 9]
| 查询 | 结果 |
|---|---|
| Sum(1,3) | 4+5+7 = 16 |
| Min(2,5) | min(5,7,8,9) = 5 |
工作原理(直观理解)
线段树类似于一个二叉层次结构:
- 根节点覆盖整个区间
[0, n-1] - 每个节点覆盖一个子区间
- 叶节点是单个元素
- 每个内部节点存储其子节点信息的合并(求和、最小值、最大值...)
要查询一个区间,你只需要遍历相关的分支。
构建、查询、更新
| 操作 | 描述 | 时间复杂度 |
|---|---|---|
| 构建 | 递归地合并子区间 | O(n) |
| 查询 | 遍历重叠的节点 | O(log n) |
| 更新 | 沿路径重新计算 | O(log n) |
精简代码(求和查询示例)
Python
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self._build(arr, 1, 0, self.n - 1)
def _build(self, arr, node, l, r):
if l == r:
self.tree[node] = arr[l]
else:
mid = (l + r) // 2
self._build(arr, 2 * node, l, mid)
self._build(arr, 2 * node + 1, mid + 1, r)
self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]
def query(self, node, l, r, ql, qr):
if qr < l or ql > r: # 无重叠
return 0
if ql <= l and r <= qr: # 完全重叠
return self.tree[node]
mid = (l + r) // 2
left = self.query(2 * node, l, mid, ql, qr)
right = self.query(2 * node + 1, mid + 1, r, ql, qr)
return left + right
# 示例
arr = [2, 4, 5, 7, 8, 9]
st = SegmentTree(arr)
print(st.query(1, 0, len(arr) - 1, 1, 3)) # 索引 1 到 3 的和输出:
16C
#include <stdio.h>
#define MAXN 100
int tree[4 * MAXN];
int arr[MAXN];
int build(int node, int l, int r) {
if (l == r) return tree[node] = arr[l];
int mid = (l + r) / 2;
int left = build(2 * node, l, mid);
int right = build(2 * node + 1, mid + 1, r);
return tree[node] = left + right;
}
int query(int node, int l, int r, int ql, int qr) {
if (qr < l || ql > r) return 0;
if (ql <= l && r <= qr) return tree[node];
int mid = (l + r) / 2;
return query(2 * node, l, mid, ql, qr) + query(2 * node + 1, mid + 1, r, ql, qr);
}
int main() {
int n = 6;
int data[] = {2, 4, 5, 7, 8, 9};
for (int i = 0; i < n; i++) arr[i] = data[i];
build(1, 0, n - 1);
printf("Sum [1,3] = %d\n", query(1, 0, n - 1, 1, 3));
}输出:
Sum [1,3] = 16为什么它很重要
- 高效处理动态区间查询和更新
- 是算法竞赛和数据分析的核心
- 是区间最小值查询、二维查询和惰性传播的基础
- 在数据库、金融系统和游戏引擎中很有用
直观理解(结合律规则)
线段树仅在操作满足结合律时有效:
merge(a, merge(b, c)) = merge(merge(a, b), c)示例:
- 求和、最小值、最大值、GCD、XOR
- 不适用于中位数、众数(非结合性操作)
动手尝试
- 实现求最小值或最大值,而非求和
- 添加用于单点修改的
update()函数 - 实现用于区间更新的惰性传播
- 扩展到二维线段树
- 与树状数组(BIT)进行比较
- 在非平凡区间上进行测试
- 可视化树的结构
- 构建迭代式线段树
- 处理自定义操作(GCD、XOR)
- 对 O(n log n) 与朴素 O(nq) 进行基准测试
测试用例
| 数组 | 查询 | 预期结果 |
|---|---|---|
| [2,4,5,7,8,9] | Sum(1,3) | 16 |
| [1,2,3,4] | Sum(0,3) | 10 |
| [5,5,5,5] | Sum(1,2) | 10 |
| [3,2,1,4] | Min(1,3) | 1 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建 | O(n) |
| 查询 | O(log n) |
| 更新 | O(log n) |
| 空间 | O(4n) |
线段树查询,一次构建,多次查询,永远快速。
183 树状数组查询
树状数组(或二叉索引树)是一种数据结构,专为在 O(log n) 时间内进行前缀查询和单点更新而设计。 它是线段树的一个更节省空间、迭代式的“表亲”,当操作是累积性的(求和、异或等)且更新频繁时,它是完美的选择。
我们要解决什么问题?
我们希望:
- 高效地计算前缀和
- 动态支持更新
一种朴素的方法每次查询或更新需要 O(n) 时间。 而树状数组可以在 O(log n) 时间内完成这两项操作。
示例
数组:[2, 4, 5, 7, 8]
| 查询 | 结果 |
|---|---|
| PrefixSum(3) | 2 + 4 + 5 + 7 = 18 |
| RangeSum(1, 3) | Prefix(3) - Prefix(0) = 18 - 2 = 16 |
工作原理(通俗解释)
树状数组以索引块的形式存储累积信息。 每个索引覆盖的范围由其最低有效位 (LSB) 决定。
索引 i 覆盖范围 (i - LSB(i) + 1) ... i我们可以通过使用位操作遍历索引来进行更新或查询:
- 更新:通过加上 LSB 向前移动
- 查询:通过减去 LSB 向后移动
这个巧妙的位操作技巧确保了操作复杂度为 O(log n)。
逐步示例
对于数组 [2, 4, 5, 7, 8](基于 1 的索引):
| 索引 | 二进制 | LSB | 覆盖范围 | 存储值 |
|---|---|---|---|---|
| 1 | 001 | 1 | [1] | 2 |
| 2 | 010 | 2 | [1–2] | 6 |
| 3 | 011 | 1 | [3] | 5 |
| 4 | 100 | 4 | [1–4] | 18 |
| 5 | 101 | 1 | [5] | 8 |
精简代码(求和示例)
Python
class FenwickTree:
def __init__(self, n):
self.n = n
self.bit = [0] * (n + 1)
def update(self, i, delta):
while i <= self.n:
self.bit[i] += delta
i += i & -i
def query(self, i):
s = 0
while i > 0:
s += self.bit[i]
i -= i & -i
return s
def range_sum(self, l, r):
return self.query(r) - self.query(l - 1)
# 示例
arr = [2, 4, 5, 7, 8]
ft = FenwickTree(len(arr))
for i, val in enumerate(arr, 1):
ft.update(i, val)
print(ft.range_sum(2, 4)) # 4 + 5 + 7 = 16输出:
16C
#include <stdio.h>
#define MAXN 100
int bit[MAXN + 1], n;
void update(int i, int delta) {
while (i <= n) {
bit[i] += delta;
i += i & -i;
}
}
int query(int i) {
int s = 0;
while (i > 0) {
s += bit[i];
i -= i & -i;
}
return s;
}
int range_sum(int l, int r) {
return query(r) - query(l - 1);
}
int main() {
n = 5;
int arr[] = {0, 2, 4, 5, 7, 8}; // 基于1的索引
for (int i = 1; i <= n; i++) update(i, arr[i]);
printf("Sum [2,4] = %d\n", range_sum(2,4)); // 16
}输出:
Sum [2,4] = 16为什么它很重要
- 优雅的位操作实现高效查询
- 比线段树更简单、更小巧
- 完美适用于前缀和、逆序对、频率表
- 可扩展到二维树状数组处理网格数据
- 是算法竞赛、流处理、金融计算中的核心数据结构
直观理解(最低有效位)
LSB 技巧 (i & -i) 可以找到最右边的置位位,控制着我们跳跃的距离。 这确保了在相关节点间的遍历是对数级的。
动手尝试
- 实现一个前缀异或版本
- 使用两棵树实现区间更新
- 扩展到二维 BIT 用于矩阵求和
- 为数组 [1..8] 可视化树结构
- 与朴素 O(n) 方法比较速度
- 跟踪元素的频率计数
- 用它来计算逆序对数量
- 用 C++ 创建一个树状数组类
- 交互式处理单点更新
- 练习位运算:画出索引覆盖范围
测试用例
| 数组 | 查询 | 期望结果 |
|---|---|---|
| [2,4,5,7,8] | Sum(2,4) | 16 |
| [1,2,3,4] | Prefix(3) | 6 |
| [5,5,5,5] | Sum(1,3) | 15 |
| [3,1,4,2] | Update(2,+3), Sum(1,2) | 7 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建 | O(n log n) |
| 查询 | O(log n) |
| 更新 | O(log n) |
| 空间占用 | O(n) |
树状数组将前缀操作变成了闪电般快速的位运算魔法,简单、小巧且强大。
184 区间树搜索
区间树是一种数据结构,旨在高效存储区间(如 [l, r] 这样的范围)并查询所有与给定区间或点重叠的区间。 它就像一个具备范围感知能力的二叉搜索树,能够快速进行诸如“哪些任务与时间 t 重叠?”或“哪些矩形与此区域重叠?”等查询。
我们要解决什么问题?
我们希望高效地找到重叠的区间。 一种朴素的搜索方法会检查所有区间,每次查询时间复杂度为 O(n)。 区间树将这一过程加速到 O(log n + k),其中 k 是重叠区间的数量。
示例
存储的区间:
$$5, 20], [10, 30], [12, 15], [17, 19], [30, 40]查询:[14, 16]
重叠的区间:[10, 30], [12, 15]
工作原理(通俗解释)
- 使用区间的中点或起点作为键值构建一个二叉搜索树。
- 每个节点存储:
- 区间 [low, high]
- 其子树的最大端点值
- 对于查询:
- 遍历树,跳过那些
low > query_high或max < query_low的分支。 - 高效地收集重叠的区间。
- 遍历树,跳过那些
这种剪枝策略使得在大多数情况下查询时间是对数级别的。
示例树(按 low 排序)
[10, 30]
/ \
[5, 20] [17, 19]
\
[30, 40]每个节点存储其子树的 max 端点值。
微型代码(查询示例)
Python
class IntervalNode:
def __init__(self, low, high):
self.low = low
self.high = high
self.max = high
self.left = None
self.right = None
def insert(root, low, high):
if root is None:
return IntervalNode(low, high)
if low < root.low:
root.left = insert(root.left, low, high)
else:
root.right = insert(root.right, low, high)
root.max = max(root.max, high)
return root
def overlap(i1, i2):
return i1[0] <= i2[1] and i2[0] <= i1[1]
def search(root, query):
if root is None:
return []
result = []
if overlap((root.low, root.high), query):
result.append((root.low, root.high))
if root.left and root.left.max >= query[0]:
result += search(root.left, query)
if root.right and root.low <= query[1]:
result += search(root.right, query)
return result
# 示例
intervals = [(5,20), (10,30), (12,15), (17,19), (30,40)]
root = None
for l, h in intervals:
root = insert(root, l, h)
print(search(root, (14,16))) # [(10,30), (12,15)]输出:
$$(10, 30), (12, 15)]C
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int low, high, max;
struct Node *left, *right;
} Node;
Node* newNode(int low, int high) {
Node* n = malloc(sizeof(Node));
n->low = low;
n->high = high;
n->max = high;
n->left = n->right = NULL;
return n;
}
int max(int a, int b) { return a > b ? a : b; }
Node* insert(Node* root, int low, int high) {
if (!root) return newNode(low, high);
if (low < root->low)
root->left = insert(root->left, low, high);
else
root->right = insert(root->right, low, high);
root->max = max(root->max, high);
return root;
}
int overlap(int l1, int h1, int l2, int h2) {
return l1 <= h2 && l2 <= h1;
}
void search(Node* root, int ql, int qh) {
if (!root) return;
if (overlap(root->low, root->high, ql, qh))
printf("[%d, %d] overlaps\n", root->low, root->high);
if (root->left && root->left->max >= ql)
search(root->left, ql, qh);
if (root->right && root->low <= qh)
search(root->right, ql, qh);
}
int main() {
Node* root = NULL;
int intervals[][2] = {{5,20},{10,30},{12,15},{17,19},{30,40}};
int n = 5;
for (int i = 0; i < n; i++)
root = insert(root, intervals[i][0], intervals[i][1]);
printf("Overlaps with [14,16]:\n");
search(root, 14, 16);
}输出:
Overlaps with [14,16]:
$$10, 30] overlaps
$$12, 15] overlaps为什么它很重要
- 对于重叠查询(例如事件、任务、范围)非常高效
- 用于:
- 调度(检测冲突)
- 计算几何
- 内存分配检查
- 基因组范围匹配
- 是处理区间的线段树的基础
核心直觉
每个节点存储其子树的最大端点值。 这有助于尽早剪枝不重叠的分支。
可以将其视为一种“范围感知的二叉搜索树”。
动手尝试
- 为区间构建树: [1,5], [2,6], [7,9], [10,15]
- 查询 [4,8],哪些区间与之重叠?
- 可视化剪枝路径
- 扩展到删除区间
- 添加重叠区间计数
- 实现迭代搜索
- 与暴力 O(n) 方法进行比较
- 调整为仅支持点查询
- 尝试动态更新
- 用于检测会议时间冲突
测试用例
| 区间集合 | 查询区间 | 预期重叠区间 |
|---|---|---|
| [5,20], [10,30], [12,15], [17,19], [30,40] | [14,16] | [10,30], [12,15] |
| [1,3], [5,8], [6,10] | [7,9] | [5,8], [6,10] |
| [2,5], [6,8] | [1,1] | 无 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建 | O(n log n) |
| 查询 | O(log n + k) |
| 空间占用 | O(n) |
区间树是处理范围重叠查询的得力工具,它结合了二叉搜索树的优雅与区间处理的智能。
185 KD 树搜索
KD 树(k 维树)是一种空间划分数据结构,用于组织 k 维空间中的点,以实现高效的最近邻搜索、范围搜索和半径搜索。 它类似于二叉搜索树,但沿着交替的维度分割空间。
我们要解决什么问题?
我们希望:
- 查找给定位置附近的点
- 查询某个区域或半径内的点
- 比检查所有点(O(n))更快地完成此操作
KD 树可以在 O(log n)(平均)时间内回答此类查询,而暴力方法需要 O(n) 时间。
示例
二维空间中的点:
(2,3), (5,4), (9,6), (4,7), (8,1), (7,2)查询:点 (9,2) 的最近邻 结果:(8,1)
工作原理(通俗解释)
构建树
- 选择一个分割维度(x, y, …)
- 沿该轴选取中位数点
- 递归构建左/右子树
搜索
- 沿当前轴比较查询坐标
- 递归进入更近的子树
- 必要时回溯检查另一侧(仅当超球体跨越边界时)
这种剪枝使得最近邻搜索变得高效。
示例(二维分割)
(7,2) [分割 x]
/ \
(5,4) [y] (9,6) [y]
/ \ /
(2,3) (4,7) (8,1)微型代码(二维示例)
Python
from math import sqrt
class Node:
def __init__(self, point, axis):
self.point = point
self.axis = axis
self.left = None
self.right = None
def build_kdtree(points, depth=0):
if not points:
return None
k = len(points[0])
axis = depth % k
points.sort(key=lambda p: p[axis])
mid = len(points) // 2
node = Node(points[mid], axis)
node.left = build_kdtree(points[:mid], depth + 1)
node.right = build_kdtree(points[mid+1:], depth + 1)
return node
def distance2(a, b):
return sum((x - y) 2 for x, y in zip(a, b))
def nearest(root, target, best=None):
if root is None:
return best
point = root.point
if best is None or distance2(point, target) < distance2(best, target):
best = point
axis = root.axis
next_branch = root.left if target[axis] < point[axis] else root.right
best = nearest(next_branch, target, best)
if (target[axis] - point[axis]) 2 < distance2(best, target):
other = root.right if next_branch == root.left else root.left
best = nearest(other, target, best)
return best
points = [(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)]
tree = build_kdtree(points)
print(nearest(tree, (9,2))) # (8,1)输出:
(8, 1)C 语言(简化二维)
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
typedef struct Node {
double point[2];
int axis;
struct Node *left, *right;
} Node;
int cmpx(const void* a, const void* b) {
double* pa = (double*)a;
double* pb = (double*)b;
return (pa[0] > pb[0]) - (pa[0] < pb[0]);
}
int cmpy(const void* a, const void* b) {
double* pa = (double*)a;
double* pb = (double*)b;
return (pa[1] > pb[1]) - (pa[1] < pb[1]);
}
double dist2(double a[2], double b[2]) {
return (a[0]-b[0])*(a[0]-b[0]) + (a[1]-b[1])*(a[1]-b[1]);
}
// 为简洁起见,省略了简化的构建和搜索代码(树构建与 Python 类似)为什么它很重要
- 对于二维、三维等空间查询非常高效。
- 应用于:
- 机器学习(KNN 分类)
- 图形学(光线追踪、碰撞检测)
- 机器人学(路径规划、SLAM)
- 数据库(多维索引)
直观理解
KD 树就像在多维空间中玩"二分查找"。 每次分割都会缩小搜索区域。
亲自尝试
- 为二维点构建一个 KD 树
- 搜索 (3,5) 的最近邻
- 添加三维点,使用模运算交替分割轴
- 将分割可视化为交替的垂直/水平线
- 扩展到 k-NN(前 k 个最近邻)
- 添加半径查询(半径 r 内的点)
- 与暴力方法比较速度
- 跟踪回溯次数以可视化剪枝效果
- 尝试非均匀数据
- 实现删除功能(加分项)
测试用例
| 点集 | 查询 | 期望的最近邻 |
|---|---|---|
| (2,3),(5,4),(9,6),(4,7),(8,1),(7,2) | (9,2) | (8,1) |
| (1,1),(3,3),(5,5) | (4,4) | (3,3) |
| (0,0),(10,10) | (7,8) | (10,10) |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建 | O(n log n) |
| 最近邻查询 | O(log n) 平均 |
| 最坏情况 | O(n) |
| 空间 | O(n) |
KD 树沿着维度切割空间,是你在多维世界中快速进行最近邻搜索的首选工具。
186 R-Tree 查询
R-Tree 是一种为高效查询二维或更高维度的几何对象(矩形、多边形、圆形)而构建的层次化空间索引。 它就像是矩形的 B 树,将附近的对象分组到边界框中,并将它们组织在树中以实现快速的空间查找。
我们要解决什么问题?
我们需要高效地查询空间数据:
- "哪些矩形与此区域重叠?"
- "哪些点落在此区域内?"
- "哪些形状与此多边形相交?"
朴素的方法需要检查每个对象(O(n))。 R-Tree 利用边界框层次结构,将复杂度降低到 O(log n + k)。
示例
矩形:
A: [1,1,3,3]
B: [2,2,5,4]
C: [4,1,6,3]查询:[2.5,2.5,4,4] 重叠对象:A, B
工作原理(通俗解释)
- 将矩形(或边界框)存储为叶子节点。
- 将附近的矩形分组到最小边界矩形中。
- 构建层次结构,使每个节点的边界框覆盖其子节点。
- 通过递归检查其边界框与查询区域重叠的节点来进行查询。
这种空间分组允许快速跳过整个区域。
示例树
[1,1,6,4]
/ \
[1,1,3,3] [4,1,6,4]
(A,B) (C)查询 [2.5,2.5,4,4]:
- 与左节点相交 → 检查 A, B
- 与右节点部分相交 → 检查 C(无重叠)
微型代码(二维矩形)
Python
def overlap(a, b):
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
class RTreeNode:
def __init__(self, box, children=None, is_leaf=False):
self.box = box # [x1, y1, x2, y2]
self.children = children or []
self.is_leaf = is_leaf
def search_rtree(node, query):
results = []
if not overlap(node.box, query):
return results
if node.is_leaf:
for child in node.children:
if overlap(child.box, query):
results.append(child.box)
else:
for child in node.children:
results.extend(search_rtree(child, query))
return results
# 示例
A = RTreeNode([1,1,3,3], is_leaf=True)
B = RTreeNode([2,2,5,4], is_leaf=True)
C = RTreeNode([4,1,6,3], is_leaf=True)
left = RTreeNode([1,1,5,4], [A,B], is_leaf=True)
right = RTreeNode([4,1,6,3], [C], is_leaf=True)
root = RTreeNode([1,1,6,4], [left, right])
query = [2.5,2.5,4,4]
print(search_rtree(root, query))输出:
$$[1, 1, 3, 3], [2, 2, 5, 4]]C 语言(简化查询)
#include <stdio.h>
typedef struct Box {
float x1, y1, x2, y2;
} Box;
int overlap(Box a, Box b) {
return !(a.x2 < b.x1 || a.x1 > b.x2 || a.y2 < b.y1 || a.y1 > b.y2);
}
// 示例:为简洁起见,省略了手动树模拟为什么它很重要
- 非常适合地理空间数据库、地图、碰撞检测和 GIS。
- 为 PostGIS、SQLite R*Tree 模块、空间索引提供支持。
- 处理重叠、包含和范围查询。
直观理解
R-Tree 通过边界化和分组来工作。 每个节点都是一个“容器框”,如果它不与查询区域重叠,则完全跳过它。 这在空间数据集中节省了大量时间。
亲自尝试
- 用
[x1,y1,x2,y2]表示二维矩形。 - 构建一个两层树(分组附近的矩形)。
- 查询重叠区域。
- 扩展到三维边界框。
- 使用最小扩展规则实现插入。
- 添加 R*-Tree 优化(溢出时重新插入)。
- 与 QuadTree(基于网格)进行比较。
- 可视化每层的边界框。
- 实现最近邻搜索。
- 尝试包含 1 万个矩形的数据集,测量加速效果。
测试用例
| 矩形 | 查询 | 预期重叠对象 |
|---|---|---|
| A[1,1,3,3], B[2,2,5,4], C[4,1,6,3] | [2.5,2.5,4,4] | A, B |
| A[0,0,2,2], B[3,3,4,4] | [1,1,3,3] | A |
| A[1,1,5,5], B[6,6,8,8] | [7,7,9,9] | B |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建 | O(n log n) |
| 查询 | O(log n + k) |
| 空间占用 | O(n) |
R-Tree 就像是你的几何图书管理员,将空间组织成嵌套的矩形,让你能够快速而清晰地查询复杂区域。
187 区间最小值查询(RMQ),稀疏表方法
区间最小值查询(RMQ)回答类似这样的问题:
“在索引 L 和 R 之间的最小元素是什么?”
它是许多算法中的核心子程序,从 LCA(最近公共祖先)到调度、直方图和区间分析。 稀疏表方法预先计算答案,因此在 O(n log n) 的预处理之后,每次查询都是 O(1)。
我们要解决什么问题?
给定一个数组 arr[0..n-1],我们想要高效地回答:
RMQ(L, R) = min(arr[L], arr[L+1], …, arr[R])适用于多个静态查询(无更新)。
朴素方法:每次查询 O(R-L) 稀疏表:预处理后每次查询 O(1)。
示例
数组:[2, 5, 1, 4, 9, 3]
| 查询 | 结果 |
|---|---|
| RMQ(1, 3) | min(5,1,4) = 1 |
| RMQ(2, 5) | min(1,4,9,3) = 1 |
工作原理(通俗解释)
- 为所有长度为 2^k 的区间预先计算答案。
- 为了回答 RMQ(L,R):
- 令
len = R-L+1 - 令
k = floor(log2(len)) - 合并两个大小为
2^k的重叠区间:
RMQ(L,R) = min(st[L][k], st[R - 2^k + 1][k]) - 令
没有更新,因此数据保持静态,查询保持 O(1)。
稀疏表示例
| i | arr[i] | st[i][0] | st[i][1] | st[i][2] |
|---|---|---|---|---|
| 0 | 2 | 2 | min(2,5)=2 | min(2,1)=1 |
| 1 | 5 | 5 | min(5,1)=1 | min(5,4)=1 |
| 2 | 1 | 1 | min(1,4)=1 | min(1,9)=1 |
| 3 | 4 | 4 | min(4,9)=4 | min(4,3)=3 |
| 4 | 9 | 9 | min(9,3)=3 | , |
| 5 | 3 | 3 | , | , |
微型代码
Python
import math
def build_sparse_table(arr):
n = len(arr)
K = math.floor(math.log2(n)) + 1
st = [[0]*K for _ in range(n)]
for i in range(n):
st[i][0] = arr[i]
j = 1
while (1 << j) <= n:
i = 0
while i + (1 << j) <= n:
st[i][j] = min(st[i][j-1], st[i + (1 << (j-1))][j-1])
i += 1
j += 1
return st
def query(st, L, R):
j = int(math.log2(R - L + 1))
return min(st[L][j], st[R - (1 << j) + 1][j])
# 示例
arr = [2, 5, 1, 4, 9, 3]
st = build_sparse_table(arr)
print(query(st, 1, 3)) # 1
print(query(st, 2, 5)) # 1输出:
1
1C
#include <stdio.h>
#include <math.h>
#define MAXN 100
#define LOG 17
int st[MAXN][LOG];
int arr[MAXN];
int n;
void build() {
for (int i = 0; i < n; i++)
st[i][0] = arr[i];
for (int j = 1; (1 << j) <= n; j++) {
for (int i = 0; i + (1 << j) <= n; i++) {
st[i][j] = (st[i][j-1] < st[i + (1 << (j-1))][j-1])
? st[i][j-1]
: st[i + (1 << (j-1))][j-1];
}
}
}
int query(int L, int R) {
int j = log2(R - L + 1);
int left = st[L][j];
int right = st[R - (1 << j) + 1][j];
return left < right ? left : right;
}
int main() {
n = 6;
int arr_temp[] = {2,5,1,4,9,3};
for (int i = 0; i < n; i++) arr[i] = arr_temp[i];
build();
printf("RMQ(1,3) = %d\n", query(1,3)); // 1
printf("RMQ(2,5) = %d\n", query(2,5)); // 1
}输出:
RMQ(1,3) = 1
RMQ(2,5) = 1为什么它很重要
- 预处理后即时查询
- 对以下情况至关重要:
- 区间分析(最小值、最大值)
- 树中的 LCA
- 稀疏区间数据
- 静态数组(无更新)
- 当数组不经常变化时非常完美。
直观理解
每个表项 st[i][k] 存储了区间 [i, i + 2^k - 1] 的最小值。 查询合并两个覆盖 [L,R] 的重叠区间。
亲自尝试
- 为
[1,3,2,7,9,11,3,5,6]构建表 - 查询 RMQ(2,6) 和 RMQ(4,8)
- 修改代码以计算区间最大值查询
- 可视化查询的重叠区间
- 与线段树版本进行比较
- 添加预计算的 log[] 数组以加快查找速度
- 仔细处理基于 1 和基于 0 的索引
- 在随机数组上练习
- 比较预处理时间与朴素方法
- 使用欧拉环游来解决 LCA
测试用例
| 数组 | 查询 | 预期结果 |
|---|---|---|
| [2,5,1,4,9,3] | RMQ(1,3) | 1 |
| [2,5,1,4,9,3] | RMQ(2,5) | 1 |
| [1,2,3,4] | RMQ(0,3) | 1 |
| [7,6,5,4,3] | RMQ(1,4) | 3 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 预处理 | O(n log n) |
| 查询 | O(1) |
| 空间 | O(n log n) |
稀疏表将重复查询转变为即时查找,当数组是静态的且速度至关重要时,它是你的首选工具。
188 莫队算法
莫队算法是一种巧妙的离线技术,用于在静态数组上回答区间查询,时间复杂度约为 O((n + q)√n)。 当你有很多像求和、不同元素计数、频率等查询,但没有更新操作时,它是理想的选择。 莫队算法不是重新计算每个查询,而是通过高效地移动区间端点来智能地复用结果。
我们要解决什么问题?
我们想要高效地回答多个区间查询:
给定一个数组
arr[0..n-1]和q个查询[L, R], 为每个区间计算诸如和、不同元素计数等结果。
朴素方法每个查询需要 O(n) 时间 → 总共 O(nq) 时间。 莫队算法通过巧妙地对查询排序,实现总共 O((n + q)√n) 的时间复杂度。
示例
数组:[1, 2, 1, 3, 4, 2, 3] 查询:
[0, 4]→ 不同元素数 = 4[1, 3]→ 不同元素数 = 3[2, 4]→ 不同元素数 = 3
工作原理(通俗解释)
将数组划分为大小为
√n的块。按以下规则对查询排序:
- L 所在的块
- 块内的 R 值
维护一个滑动窗口
[currL, currR):- 逐步向左/右移动端点
- 增量更新答案
按查询索引存储结果。
这种排序确保了连续查询之间的移动最小化。
示例
如果 √n = 3,查询按块排序后:
块 0: [0,4], [1,3]
块 1: [2,4]指针移动最小:
- 从 [0,4] → [1,3] → [2,4]
- 复用了大部分之前的计算。
精简代码(不同元素计数示例)
Python
import math
def mos_algorithm(arr, queries):
n = len(arr)
q = len(queries)
block_size = int(math.sqrt(n))
# 对查询排序
queries = sorted(enumerate(queries), key=lambda x: (x[1][0] // block_size, x[1][1]))
freq = {}
currL, currR = 0, 0
curr_ans = 0
answers = [0]*q
def add(x):
nonlocal curr_ans
freq[x] = freq.get(x, 0) + 1
if freq[x] == 1:
curr_ans += 1
def remove(x):
nonlocal curr_ans
freq[x] -= 1
if freq[x] == 0:
curr_ans -= 1
for idx, (L, R) in queries:
while currL > L:
currL -= 1
add(arr[currL])
while currR <= R:
add(arr[currR])
currR += 1
while currL < L:
remove(arr[currL])
currL += 1
while currR > R + 1:
currR -= 1
remove(arr[currR])
answers[idx] = curr_ans
return answers
# 示例
arr = [1,2,1,3,4,2,3]
queries = [(0,4), (1,3), (2,4)]
print(mos_algorithm(arr, queries)) # [4,3,3]输出:
[4, 3, 3]C 语言(结构与思路)
#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#define MAXN 100000
#define MAXQ 100000
typedef struct { int L, R, idx; } Query;
int arr[MAXN], ans[MAXQ], freq[1000001];
int curr_ans = 0, block;
int cmp(const void* a, const void* b) {
Query *x = (Query*)a, *y = (Query*)b;
if (x->L / block != y->L / block) return x->L / block - y->L / block;
return x->R - y->R;
}
void add(int x) { if (++freq[x] == 1) curr_ans++; }
void remove_(int x) { if (--freq[x] == 0) curr_ans--; }
int main() {
int n = 7, q = 3;
int arr_[] = {1,2,1,3,4,2,3};
for (int i = 0; i < n; i++) arr[i] = arr_[i];
Query queries[] = {{0,4,0},{1,3,1},{2,4,2}};
block = sqrt(n);
qsort(queries, q, sizeof(Query), cmp);
int currL = 0, currR = 0;
for (int i = 0; i < q; i++) {
int L = queries[i].L, R = queries[i].R;
while (currL > L) add(arr[--currL]);
while (currR <= R) add(arr[currR++]);
while (currL < L) remove_(arr[currL++]);
while (currR > R+1) remove_(arr[--currR]);
ans[queries[i].idx] = curr_ans;
}
for (int i = 0; i < q; i++) printf("%d ", ans[i]);
}输出:
4 3 3为什么重要
将许多区间查询转换为接近线性的总时间
适用于:
- 求和 / 计数 / 频率查询
- 不同元素计数
- 具有结合律性质的 GCD、XOR 等操作
适用于静态数组(无更新)
直观理解
莫队算法就像按地理位置对你的差事进行排序。 通过先访问附近的"块",你最小化了行程时间。 在这里,"行程" = 指针移动。
动手尝试
- 在
[1,2,3,4,5]上运行,查询为(0,2),(1,4),(2,4) - 改为求和而不是不同元素计数
- 可视化指针移动
- 尝试不同的块大小变化
- 添加离线查询索引跟踪
- 尝试平方根分解与莫队算法对比
- 计算每个区间内最大元素的频率
- 混合不同的查询类型(仍然是离线)
- 添加预计算的 sqrt(n) 块分组
- 在竞赛编程问题中使用
测试用例
| 数组 | 查询 | 输出 |
|---|---|---|
| [1,2,1,3,4,2,3] | (0,4),(1,3),(2,4) | [4,3,3] |
| [1,1,1,1] | (0,3),(1,2) | [1,1] |
| [1,2,3,4,5] | (0,2),(2,4) | [3,3] |
复杂度
| 操作 | 复杂度 |
|---|---|
| 预排序查询 | O(q log q) |
| 处理 | O((n + q)√n) |
| 空间 | O(n) |
莫队算法是你处理区间查询的主力工具,它排序巧妙、基于分块,对于静态数据集来说效率极高。
189 扫描线范围搜索
扫描线算法是一种几何技术,它沿着一个维度(通常是 x 轴)按排序顺序处理事件,以高效解决范围、区间和重叠问题。 可以想象成在二维平面上拖动一条垂直线,并随着移动更新活动区间。
我们要解决什么问题?
我们想要高效地找到:
- 哪些区间与一个点重叠
- 哪些矩形相交
- 有多少形状覆盖一个区域
对所有对进行暴力检查的时间复杂度是 O(n²)。 扫描线算法通过排序和增量处理事件,将其减少到 O(n log n)。
示例
矩形:
R1: [1, 3], R2: [2, 5], R3: [4, 6]事件(按 x 排序):
x = 1: R1 开始
x = 2: R2 开始
x = 3: R1 结束
x = 4: R3 开始
x = 5: R2 结束
x = 6: R3 结束活动集合随着扫描线的移动而变化 → 动态跟踪重叠。
工作原理(通俗解释)
- 将对象转换为事件
- 每个区间/矩形生成开始和结束事件。
- 按坐标(x 或 y)对所有事件排序。
- 扫描处理事件:
- 遇到开始事件,将对象添加到活动集合。
- 遇到结束事件,从活动集合中移除对象。
- 在每一步,查询活动集合以获取交集、计数等。
- 使用平衡树 / 集合来维护活动范围。
示例演练
区间:[1,3], [2,5], [4,6] 事件:
(1, 开始), (2, 开始), (3, 结束), (4, 开始), (5, 结束), (6, 结束)逐步执行:
- 在 1:添加 [1,3]
- 在 2:添加 [2,5],检测到重叠 (1,3) ∩ (2,5)
- 在 3:移除 [1,3]
- 在 4:添加 [4,6],检测到重叠 (2,5) ∩ (4,6)
- 在 5:移除 [2,5]
- 在 6:移除 [4,6]
结果:2 对重叠
微型代码(区间重叠)
Python
def sweep_line_intervals(intervals):
events = []
for l, r in intervals:
events.append((l, 'start'))
events.append((r, 'end'))
events.sort()
active = 0
overlaps = 0
for pos, typ in events:
if typ == 'start':
overlaps += active # 计算重叠数
active += 1
else:
active -= 1
return overlaps
intervals = [(1,3), (2,5), (4,6)]
print(sweep_line_intervals(intervals)) # 2 个重叠输出:
2C
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int x;
int type; // 1 = 开始, -1 = 结束
} Event;
int cmp(const void* a, const void* b) {
Event *e1 = (Event*)a, *e2 = (Event*)b;
if (e1->x == e2->x) return e1->type - e2->type;
return e1->x - e2->x;
}
int main() {
int intervals[][2] = {{1,3},{2,5},{4,6}};
int n = 3;
Event events[2*n];
for (int i = 0; i < n; i++) {
events[2*i] = (Event){intervals[i][0], 1};
events[2*i+1] = (Event){intervals[i][1], -1};
}
qsort(events, 2*n, sizeof(Event), cmp);
int active = 0, overlaps = 0;
for (int i = 0; i < 2*n; i++) {
if (events[i].type == 1) {
overlaps += active;
active++;
} else {
active--;
}
}
printf("Total overlaps: %d\n", overlaps);
}输出:
Total overlaps: 2为什么它很重要
- 计算几何中的通用模式:
- 区间交集计数
- 矩形重叠
- 线段并集长度
- 平面扫描算法(Voronoi,凸包)
- 将复杂度从 O(n²) 优化到 O(n log n)
应用于:
- 地理信息系统(地理数据)
- 调度(冲突检测)
- 事件模拟
直观理解
想象一下在你的数据上滑动一条垂直线: 你只"看到"当前活动的区间。 无需回顾,后面的一切都已解决。
自己动手试试
- 计算
[1,4],[2,3],[5,6]中的重叠数 - 修改代码以计算最大活动区间数(峰值并发数)
- 扩展到矩形相交(扫描 + 线段树)
- 跟踪总覆盖长度
- 结合优先队列处理动态范围
- 在时间线上可视化(调度冲突)
- 应用于会议室分配
- 扩展到 2D 扫描(事件按 x 排序,活动 y 范围)
- 计算每个区间的重叠数
- 与暴力法比较运行时间
测试用例
| 区间 | 重叠数 |
|---|---|
| [1,3],[2,5],[4,6] | 2 |
| [1,2],[3,4] | 0 |
| [1,4],[2,3],[3,5] | 3 |
| [1,5],[2,4],[3,6] | 3 |
复杂度
| 操作 | 复杂度 |
|---|---|
| 事件排序 | O(n log n) |
| 扫描 | O(n) |
| 总计 | O(n log n) |
| 空间复杂度 | O(n) |
扫描线算法是你的移动扫描器,它扫过时间或空间,管理活动元素,并以优雅、有序的方式揭示隐藏的重叠。
190 球树最近邻
球树是一种由嵌套超球面(“球”)构建的层次化空间数据结构。 它根据距离将点组织成簇,从而能够高效地在高维或非欧几里得空间中进行最近邻查询和范围查询。
KD 树按轴分割,而球树按距离分割,这使得它在维度增加或距离度量不与轴对齐时更加鲁棒。
我们要解决什么问题?
我们希望高效地:
- 为查询点找到最近邻
- 执行半径搜索(距离
r内的所有点) - 处理 KD 树性能下降的高维数据
朴素搜索每次查询是 O(n)。 球树平均可提升到大约 O(log n)。
示例
二维点:
(2,3), (5,4), (9,6), (4,7), (8,1), (7,2)查询:(9,2) → 最近邻:(8,1)
球树不是按 x/y 轴分割,而是根据到中心点(质心或中位数点)的距离将附近的点分组。
工作原理(通俗解释)
递归构建树:
- 选择一个枢轴点(中心)(通常是质心或中位数点)。
- 计算半径(到簇中任意点的最大距离)。
- 将点划分为两个子集(内球与外球)。
- 递归构建子球。
查询:
- 从根球开始。
- 检查子球是否可能包含更近的点。
- 剪枝掉满足
distance(center, query) - radius > best_dist条件的分支。
这带来了对数级的平均性能。
示例树(简化版)
Ball(center=(6,4), radius=5)
├── Ball(center=(3,5), radius=2) → [(2,3),(4,7),(5,4)]
└── Ball(center=(8,3), radius=3) → [(7,2),(8,1),(9,6)]查询 (9,2):
- 检查根节点
- 比较两个子节点
- 剪枝
(3,5)(太远) - 搜索
(8,3)簇 → 最近邻(8,1)
微型代码(2D 示例)
Python
from math import sqrt
class BallNode:
def __init__(self, points):
self.center = tuple(sum(x)/len(x) for x in zip(*points))
self.radius = max(sqrt(sum((p[i]-self.center[i])2 for i in range(len(p)))) for p in points)
self.points = points if len(points) <= 2 else None
self.left = None
self.right = None
if len(points) > 2:
points.sort(key=lambda p: sqrt(sum((p[i]-self.center[i])2 for i in range(len(p)))))
mid = len(points)//2
self.left = BallNode(points[:mid])
self.right = BallNode(points[mid:])
def dist(a, b):
return sqrt(sum((x - y)2 for x, y in zip(a, b)))
def nearest(node, target, best=None):
if node is None:
return best
if node.points is not None:
for p in node.points:
if best is None or dist(p, target) < dist(best, target):
best = p
return best
d_center = dist(node.center, target)
candidates = []
if d_center - node.radius <= dist(best, target) if best else True:
candidates.append(node.left)
candidates.append(node.right)
for child in candidates:
best = nearest(child, target, best)
return best
points = [(2,3),(5,4),(9,6),(4,7),(8,1),(7,2)]
tree = BallNode(points)
print(nearest(tree, (9,2))) # (8,1)输出:
(8, 1)C(结构体思路)
#include <stdio.h>
#include <math.h>
#include <stdlib.h>
typedef struct Node {
double cx, cy, radius;
struct Node *left, *right;
double (*points)[2];
int count;
} Node;
// 构建逻辑:计算质心、半径,按距离分割。
// 查询逻辑:如果 (dist(center, query) - radius) > best 则剪枝。由于复杂性,球树很少在 C 语言中手动实现;通常内置于库中(如 scikit-learn)。
为什么它很重要
适用于非轴对齐的数据
处理高维和非欧几里得度量(例如余弦距离)
用于:
- KNN 搜索
- 聚类(球树 K-Means)
- 空间数据库
- 机器学习(scikit-learn 的 NearestNeighbors)
直观理解
想象用嵌套的气泡包裹你的数据集。 每个气泡包围一个簇。 要找到最近邻,你只需打开那些可能包含更近点的气泡。
动手尝试
- 为
(1,1),(2,2),(3,3),(10,10)构建一个球树 - 查询
(2.5,2.5) - 可视化嵌套的“球”
- 改为 3D 点
- 使用曼哈顿距离代替欧几里得距离
- 比较与 KD 树的性能
- 跟踪剪枝分支的数量
- 实现 k-最近邻
- 添加半径搜索(距离
r内) - 处理重复点
测试用例
| 点集 | 查询 | 最近邻 |
|---|---|---|
| (2,3),(5,4),(9,6),(4,7),(8,1),(7,2) | (9,2) | (8,1) |
| (1,1),(2,2),(3,3) | (2.5,2.5) | (3,3) |
| (0,0),(10,10) | (7,8) | (10,10) |
复杂度
| 操作 | 复杂度 |
|---|---|
| 构建 | O(n log n) |
| 查询(平均) | O(log n) |
| 查询(最坏) | O(n) |
| 空间 | O(n) |
球树是你在高维空间中的球形向导,它按邻近度而非轴进行分割,从而快速灵活地找到邻居。
第 20 节 搜索优化与变体
191 带容差的二分查找
带容差的二分查找是二分查找的一种变体,专为实数或浮点数值设计,在这些场景中,由于舍入或测量误差,可能不存在精确匹配。
我们不再检查严格的相等性(==),而是接受一个由容差 ε(epsilon)定义的接近范围。
我们要解决什么问题?
对于整数,相等性是精确的。 对于浮点数,微小的舍入误差使得相等性不可靠:
if (arr[mid] == x) // ❌ 对于浮点数不可靠我们改为测试:
if (fabs(arr[mid] - x) < epsilon) // ✅ 基于容差的匹配这种方法对于以下领域至关重要:
- 科学计算
- 数值分析
- 近似计算
- 求根
- 仿真和测量数据
示例
给定已排序的实数:
$$0.1, 0.2, 0.3000000001, 0.4, 0.5]使用 epsilon = 1e-6 搜索 0.3:
fabs(0.3000000001 - 0.3) < 1e-6→ 找到!
精简代码
C 语言实现
#include <stdio.h>
#include <math.h>
int binary_search_tolerance(double arr[], int n, double target, double eps) {
int low = 0, high = n - 1;
while (low <= high) {
int mid = (low + high) / 2;
double diff = arr[mid] - target;
if (fabs(diff) < eps)
return mid; // 在容差范围内找到
else if (diff < 0)
low = mid + 1;
else
high = mid - 1;
}
return -1; // 未找到
}
int main() {
double arr[] = {0.1, 0.2, 0.3000000001, 0.4, 0.5};
int n = 5;
double x = 0.3;
int idx = binary_search_tolerance(arr, n, x, 1e-6);
if (idx != -1)
printf("在索引 %d 处找到 %.6f\n", x, idx);
else
printf("未找到\n");
}输出:
在索引 2 处找到 0.300000Python 实现
def binary_search_tolerance(arr, x, eps=1e-6):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if abs(arr[mid] - x) < eps:
return mid
elif arr[mid] < x:
lo = mid + 1
else:
hi = mid - 1
return -1
arr = [0.1, 0.2, 0.3000000001, 0.4, 0.5]
print(binary_search_tolerance(arr, 0.3)) # 2重要性
在比较浮点数时避免假阴性结果
优雅地处理舍入误差
适用于:
- 求根
- 浮点数数据集
- 物理模拟
- 数值优化
直观理解
二分查找假设精确比较。 对于浮点数,"相等"通常意味着"足够接近"。 ε 定义了可接受的误差范围。
可以理解为:
"如果差值小于 ε,就认为找到了。"
动手尝试
- 使用数组
[0.1, 0.2, 0.3, 0.4, 0.5]用ε = 1e-5搜索0.3000001 - 减小
ε→ 观察搜索何时失败 - 尝试负数或小数
- 与整数二分查找进行比较
- 尝试非均匀间隔的数组
- 修改代码,使其在不在 ε 范围内时找到最接近的值
- 将容差可视化为目标值周围的一个小范围
- 应用于求根问题 (
f(x) ≈ 0) - 根据数值大小动态调整 ε
- 测量大浮点数带来的精度损失
测试用例
| 数组 | 目标值 | 容差 ε | 预期结果 |
|---|---|---|---|
| [0.1, 0.2, 0.3000000001] | 0.3 | 1e-6 | 找到 |
| [1.0, 2.0, 3.0] | 2.000001 | 1e-5 | 找到 |
| [1.0, 2.0, 3.0] | 2.1 | 1e-5 | 未找到 |
复杂度
| 步骤 | 复杂度 |
|---|---|
| 搜索 | O(log n) |
| 空间 | O(1) |
带容差的二分查找在处理实数时是一个虽小但至关重要的升级——因为在浮点数的世界里,"足够接近"往往就是真相。
192 三分查找
三分查找是一种用于寻找单峰函数最大值或最小值的算法——单峰函数是指先递增到某一点然后递减(或反之)的函数。它是一种类似于二分查找的分治方法,但每次将区间划分为三个部分。
我们要解决什么问题?
你有一个定义在区间 [l, r] 上的函数 f(x),并且你知道它有一个峰值(或谷值)。你想找到使 f(x) 最大化(或最小化)的 x。
与二分查找(用于查找相等性)不同,三分查找用于查找极值点。
示例(单峰函数)
令
f(x) = - (x - 2)^2 + 4这是一个在 x = 2 处取得最大值的抛物线。
三分搜索逐步缩小包含最大值的区间:
- 将
[l, r]分成三部分 - 计算
f(m1)和f(m2) - 保留包含峰值的那一侧
- 重复直到区间足够小
工作原理(逐步说明)
| 步骤 | 操作 |
|---|---|
| 1 | 选取两个中点:m1 = l + (r - l) / 3, m2 = r - (r - l) / 3 |
| 2 | 比较 f(m1) 和 f(m2) |
| 3 | 如果 f(m1) < f(m2) → 最大值在 [m1, r] 中 |
| 4 | 否则 → 最大值在 [l, m2] 中 |
| 5 | 重复直到 r - l < ε |
微型代码
C 语言实现(求最大值)
#include <stdio.h>
#include <math.h>
double f(double x) {
return -pow(x - 2, 2) + 4; // 峰值在 x=2
}
double ternary_search(double l, double r, double eps) {
while (r - l > eps) {
double m1 = l + (r - l) / 3;
double m2 = r - (r - l) / 3;
if (f(m1) < f(m2))
l = m1;
else
r = m2;
}
return (l + r) / 2; // 近似峰值位置
}
int main() {
double l = 0, r = 4;
double res = ternary_search(l, r, 1e-6);
printf("Approx max at x = %.6f, f(x) = %.6f\n", res, f(res));
}输出:
Approx max at x = 2.000000, f(x) = 4.000000Python 实现
def f(x):
return -(x - 2)2 + 4 # 峰值在 x=2
def ternary_search(l, r, eps=1e-6):
while r - l > eps:
m1 = l + (r - l) / 3
m2 = r - (r - l) / 3
if f(m1) < f(m2):
l = m1
else:
r = m2
return (l + r) / 2
res = ternary_search(0, 4)
print(f"Max at x = {res:.6f}, f(x) = {f(res):.6f}")为何重要
- 在连续函数中寻找极值(最大值/最小值)
- 无需导数(不同于基于微积分的优化方法)
- 适用于以下情况:
f(x)是单峰的- 定义域是连续的
- 可以低成本地计算
f(x)
应用场景:
- 数学优化
- 机器学习超参数调优
- 几何问题(例如,最近距离)
- 物理模拟
动手尝试
- 尝试
f(x) = (x - 5)^2 + 1(求最小值) - 使用区间
[0, 10]和eps = 1e-6 - 将
eps改为1e-3→ 观察更快但更粗糙的结果 - 应用于两个移动点之间的距离问题
- 与基于导数的二分搜索进行比较
- 绘制 f(x) 图像以可视化缩小区间的过程
- 切换条件以寻找最小值
- 在
[0, π]上测试f(x) = sin(x) - 使用整数搜索版本处理离散数组
- 结合黄金分割搜索以提高效率
测试用例
| 函数 | 区间 | 预期结果 | 类型 |
|---|---|---|---|
| f(x) = -(x-2)^2 + 4 | [0, 4] | x ≈ 2 | 最大值 |
| f(x) = (x-5)^2 + 1 | [0, 10] | x ≈ 5 | 最小值 |
| f(x) = sin(x) | [0, 3.14] | x ≈ 1.57 | 最大值 |
复杂度
| 指标 | 值 |
|---|---|
| 时间复杂度 | O(log((r - l)/ε)) |
| 空间复杂度 | O(1) |
三分搜索将搜索空间分成三份——无需导数,以数学精度逼近峰值或谷值。
193 基于哈希的搜索
基于哈希的搜索使用哈希函数将键直接映射到表中的索引,从而实现常数时间期望的查找。 它无需扫描或比较元素,而是直接跳转到数据应处的存储桶。
我们要解决什么问题?
在大型数据集中搜索时,线性搜索太慢(O(n)),而二分搜索需要数据有序(O(log n))。 基于哈希的搜索允许你在 O(1) 的平均时间内查找、插入或删除一个项目,而无需考虑排序。
它是哈希表、哈希映射和字典的基础。
示例(简单查找)
假设你想快速存储和搜索姓名:
$$"Alice", "Bob", "Carol", "Dave"]哈希函数将每个姓名映射到一个索引:
hash("Alice") → 2
hash("Bob") → 5
hash("Carol") → 1你将每个姓名放入其对应的槽位。搜索变得即时:
hash("Bob") = 5 → 找到了!工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 计算 hash(key) 得到索引 |
| 2 | 查找该索引处的桶 |
| 3 | 如果多个项哈希到同一个桶(冲突),使用某种策略处理(链地址法、开放寻址法) |
| 4 | 必要时比较键 |
| 5 | 返回结果 |
微型代码
C 语言实现(使用线性探测)
#include <stdio.h>
#include <string.h>
#define SIZE 10
typedef struct {
char key[20];
int value;
int used;
} Entry;
Entry table[SIZE];
int hash(char *key) {
int h = 0;
for (int i = 0; key[i]; i++)
h = (h * 31 + key[i]) % SIZE;
return h;
}
void insert(char *key, int value) {
int h = hash(key);
while (table[h].used) {
if (strcmp(table[h].key, key) == 0) break;
h = (h + 1) % SIZE;
}
strcpy(table[h].key, key);
table[h].value = value;
table[h].used = 1;
}
int search(char *key) {
int h = hash(key), start = h;
while (table[h].used) {
if (strcmp(table[h].key, key) == 0)
return table[h].value;
h = (h + 1) % SIZE;
if (h == start) break;
}
return -1; // 未找到
}
int main() {
insert("Alice", 10);
insert("Bob", 20);
printf("Bob 的值: %d\n", search("Bob"));
}输出:
Bob 的值: 20Python 实现(内置字典)
people = {"Alice": 10, "Bob": 20, "Carol": 30}
print("Bob" in people) # True
print(people["Carol"]) # 30Python 的 dict 使用了优化的开放寻址哈希表。
为何重要
- O(1) 的平均查找、插入和删除时间复杂度
- 无需排序
- 是符号表、缓存、字典、编译器和数据库的核心
- 可通过调整大小(重新哈希)进行扩展
动手尝试
- 使用链表实现链地址法的哈希表
- 用二次探测替换线性探测
- 在同一数据集上测量查找时间与线性搜索的对比
- 插入会产生冲突的键,确保正确性
- 为整数创建自定义哈希函数:
h(x) = x % m - 观察哈希表填充时的性能变化(负载因子 > 0.7)
- 小心实现删除操作
- 当负载因子过高时调整哈希表大小
- 尝试一个糟糕的哈希函数(如
h=1)并测量性能下降 - 与 Python 内置字典进行比较
测试用例
| 操作 | 输入 | 预期输出 |
|---|---|---|
| 插入 | ("Alice", 10) | 已存储 |
| 插入 | ("Bob", 20) | 已存储 |
| 搜索 | "Alice" | 10 |
| 搜索 | "Eve" | 未找到 |
| 删除 | "Bob" | 已移除 |
| 搜索 | "Bob" | 未找到 |
复杂度
| 指标 | 平均情况 | 最坏情况 |
|---|---|---|
| 搜索 | O(1) | O(n) |
| 插入 | O(1) | O(n) |
| 空间占用 | O(n) | O(n) |
基于哈希的搜索就像一个神奇的索引,它能直接跳转到你所需的数据,将搜索变为即时查找。
194 布隆过滤器查找
布隆过滤器是一种概率型数据结构,它能告诉你某个元素肯定不在集合中,或者可能在集合中。 它的速度极快且内存效率高,但允许假阳性(永远不会出现假阴性)。
我们解决什么问题?
当处理海量数据集(如 URL、缓存键或 ID)时,你可能不想仅仅为了检查成员资格而存储每个元素。 布隆过滤器为你提供快速的 O(1) 检查:
- “这个元素在我的集合中吗?” → 可能在
- “它肯定不在我的集合中吗?” → 是的
它们广泛应用于数据库、网络系统和搜索引擎(例如,用于跳过磁盘查找)。
示例(缓存查找)
你有一个包含 100 万个项目的缓存。在访问数据库之前,你想知道:
我是否需要为这个键去检查缓存?
使用布隆过滤器来快速判断该键是否可能在缓存中。 如果过滤器说“否”,则完全跳过查找。
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 创建一个大小为 m 的位数组(全部初始化为 0) |
| 2 | 选择 k 个独立的哈希函数 |
| 3 | 插入元素:计算 k 个哈希值,将对应的位设置为 1 |
| 4 | 查询元素:计算 k 个哈希值,检查所有对应位是否都为 1 |
| 5 | 如果任何一位 = 0 → 肯定不在集合中 |
| 6 | 如果所有位 = 1 → 可能在集合中(可能出现假阳性) |
微型代码
Python(简易布隆过滤器)
from hashlib import sha256
class BloomFilter:
def __init__(self, size=1000, hash_count=3):
self.size = size
self.hash_count = hash_count
self.bits = [0] * size
def _hashes(self, item):
for i in range(self.hash_count):
h = int(sha256((item + str(i)).encode()).hexdigest(), 16)
yield h % self.size
def add(self, item):
for h in self._hashes(item):
self.bits[h] = 1
def contains(self, item):
return all(self.bits[h] for h in self._hashes(item))
# 使用示例
bf = BloomFilter()
bf.add("Alice")
print(bf.contains("Alice")) # True (很可能)
print(bf.contains("Bob")) # False (肯定不在)输出:
True
False为何重要
- 对于大型集合内存效率高
- 没有假阴性,如果它说“否”,你可以相信它
- 用于缓存、数据库、分布式系统
- 通过跳过不存在的条目来减少 I/O
动手实践
- 构建一个具有 1000 位和 3 个哈希函数的布隆过滤器
- 插入 100 个随机元素
- 查询 10 个存在的和 10 个不存在的元素
- 测量假阳性率
- 尝试不同的
m和k值 - 集成到一个简单的缓存模拟中
- 实现双重哈希以减少哈希计算成本
- 与 Python
set比较内存使用情况 - 添加合并过滤器(按位或)的支持
- 尝试计数布隆过滤器(以支持删除操作)
测试用例
| 输入 | 预期输出 |
|---|---|
| add("Alice") | 位被设置 |
| contains("Alice") | True (可能) |
| contains("Bob") | False (肯定不在) |
| add("Bob") | 位被更新 |
| contains("Bob") | True (可能) |
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 插入 | O(k) | O(m) |
| 查找 | O(k) | O(m) |
k: 哈希函数的数量 m: 位数组的大小
布隆过滤器就像一个礼貌的门卫,它永远不会错误地把你拒之门外,但偶尔可能会让一个陌生人进来。
195 布谷鸟哈希查找
布谷鸟哈希表是一种巧妙的基于哈希的结构,它能在避免长探测链的同时保证 O(1) 的查找时间。 它使用两个哈希函数,并在发生冲突时重新安置已有的键,就像布谷鸟将蛋踢出鸟巢一样。
我们要解决什么问题?
当冲突堆积时,传统的哈希表性能可能退化到 O(n)。 布谷鸟哈希通过保证每个键最多只有两个可能的位置,从而确保常数时间的查找。 如果两个位置都被占用了,它会“踢出”一个已存在的键,并将其重新插入到其他地方。
它被广泛应用于网络路由器、高性能缓存和基于哈希的索引中。
示例(小表)
假设你有 2 个哈希函数:
h1(x) = x % 3
h2(x) = (x / 3) % 3插入键:5, 8, 11
| 键 | h1 | h2 |
|---|---|---|
| 5 | 2 | 1 |
| 8 | 2 | 2 |
| 11 | 2 | 1 |
当插入 11 时,槽位 2 已满,因此我们将 5 踢出到它的备用位置。 这个过程持续进行,直到每个键都找到自己的位置。
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 计算两个哈希索引:h1(key), h2(key) |
| 2 | 尝试将键放入 h1 槽位 |
| 3 | 如果被占用,则将现有键驱逐到它的备用槽位 |
| 4 | 重复此过程直到达到阈值(以防止无限循环) |
| 5 | 如果检测到完整循环 → 使用新函数重新哈希 |
每个键要么位于位置 h1(key),要么位于位置 h2(key)。
微型代码
C 语言实现(简化版)
#include <stdio.h>
#define SIZE 7
int table1[SIZE], table2[SIZE];
int h1(int key) { return key % SIZE; }
int h2(int key) { return (key / SIZE) % SIZE; }
void insert(int key, int depth) {
if (depth > SIZE) return; // 避免无限循环
int pos1 = h1(key);
if (table1[pos1] == 0) {
table1[pos1] = key;
return;
}
int displaced = table1[pos1];
table1[pos1] = key;
int pos2 = h2(displaced);
if (table2[pos2] == 0)
table2[pos2] = displaced;
else
insert(displaced, depth + 1);
}
int search(int key) {
return table1[h1(key)] == key || table2[h2(key)] == key;
}
int main() {
insert(10, 0);
insert(20, 0);
insert(30, 0);
printf("Search 20: %s\n", search(20) ? "Found" : "Not Found");
}输出:
Search 20: FoundPython 实现
SIZE = 7
table1 = [None] * SIZE
table2 = [None] * SIZE
def h1(x): return x % SIZE
def h2(x): return (x // SIZE) % SIZE
def insert(key, depth=0):
if depth > SIZE:
return False # 检测到循环
pos1 = h1(key)
if table1[pos1] is None:
table1[pos1] = key
return True
key, table1[pos1] = table1[pos1], key
pos2 = h2(key)
if table2[pos2] is None:
table2[pos2] = key
return True
return insert(key, depth + 1)
def search(key):
return table1[h1(key)] == key or table2[h2(key)] == key
insert(10); insert(20); insert(30)
print("Search 20:", search(20))为什么重要
- O(1) 最坏情况查找(总是两次探测)
- 消除了长冲突链
- 非常适合高性能系统
- 确定性的位置 = 易于调试
动手尝试
- 插入数字 1–10 并追踪移动过程
- 添加循环检测 → 触发重新哈希
- 比较平均探测次数与线性探测法
- 实现删除键功能(标记槽位为空)
- 尝试动态调整表的大小
- 使用不同的哈希函数以获得多样性
- 在重新哈希前跟踪负载因子
- 存储
(键, 值)对 - 与链式法进行基准测试
- 可视化插入过程中的移动路径
测试用例
| 操作 | 输入 | 预期结果 |
|---|---|---|
| 插入 | 5 | 已放置 |
| 插入 | 8 | 踢出 5,两者均放置 |
| 查找 | 5 | 找到 |
| 查找 | 11 | 未找到 |
| 删除 | 8 | 已移除 |
| 查找 | 8 | 未找到 |
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 查找 | O(1) | O(n) |
| 插入 | O(1) 平均 | O(n) |
| 删除 | O(1) | O(n) |
布谷鸟哈希就像是为键进行的抢椅子游戏,当一个键不能坐下时,它会让另一个键站起来并移动,但最终每个人都会找到一个座位。
196 罗宾汉哈希
罗宾汉哈希是一种开放寻址策略,旨在平衡哈希表查找的公平性。当两个键发生冲突时,距离其“原始”索引位置更远的那个键将占据该槽位,就像罗宾汉劫富济贫一样,从“富”键(已占据更佳位置)那里“偷”来槽位给“贫”键(探测距离更远)。
我们要解决什么问题?
标准的线性探测可能导致聚集现象,即形成连续的被占用槽位长串,从而拖慢搜索速度。罗宾汉哈希减少了探测长度的方差,使得每个键的访问时间大致相等。即使在较高的负载因子下,也能带来更可预测的性能。
示例(冲突处理)
假设 hash(x) = x % 10。 插入 [10, 20, 30, 21]。
| 键值 | 哈希值 | 位置 | 探查距离 |
|---|---|---|---|
| 10 | 0 | 0 | 0 |
| 20 | 0 | 1 | 1 |
| 30 | 0 | 2 | 2 |
| 21 | 1 | 2 (冲突) | 1 |
在位置 2,21 遇到了 30(其距离 = 2)。 由于 21 的距离(1)更小,它继续探查。 罗宾汉规则:如果新来者的距离大于或等于当前位置元素的探查距离 → 交换。 这保持了均匀分布。
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 计算哈希值 = key % table_size |
| 2 | 如果槽位为空 → 放置元素 |
| 3 | 否则,比较探查距离与占用者的距离 |
| 4 | 如果新元素的距离 ≥ 当前元素的距离 → 交换并继续探查被置换的元素 |
| 5 | 重复直到插入成功 |
微型代码
C 语言实现(简化版)
#include <stdio.h>
#define SIZE 10
typedef struct {
int key;
int used;
int distance;
} Entry;
Entry table[SIZE];
int hash(int key) { return key % SIZE; }
void insert(int key) {
int index = hash(key);
int dist = 0;
while (table[index].used) {
if (table[index].distance < dist) {
int temp_key = table[index].key;
int temp_dist = table[index].distance;
table[index].key = key;
table[index].distance = dist;
key = temp_key;
dist = temp_dist;
}
index = (index + 1) % SIZE;
dist++;
}
table[index].key = key;
table[index].distance = dist;
table[index].used = 1;
}
int search(int key) {
int index = hash(key);
int dist = 0;
while (table[index].used) {
if (table[index].key == key) return 1;
if (table[index].distance < dist) return 0;
index = (index + 1) % SIZE;
dist++;
}
return 0;
}
int main() {
insert(10);
insert(20);
insert(30);
printf("Search 20: %s\n", search(20) ? "Found" : "Not Found");
}输出:
Search 20: FoundPython 实现
SIZE = 10
table = [None] * SIZE
distances = [0] * SIZE
def h(k): return k % SIZE
def insert(key):
index = h(key)
dist = 0
while table[index] is not None:
if distances[index] < dist:
table[index], key = key, table[index]
distances[index], dist = dist, distances[index]
index = (index + 1) % SIZE
dist += 1
table[index], distances[index] = key, dist
def search(key):
index = h(key)
dist = 0
while table[index] is not None:
if table[index] == key:
return True
if distances[index] < dist:
return False
index = (index + 1) % SIZE
dist += 1
return False
insert(10); insert(20); insert(30)
print("Search 20:", search(20))为何重要
- 可预测的查找时间,探查长度几乎均匀
- 在高负载下性能优于线性探查
- 减少了长簇,表行为更稳定
- 非常适合性能关键的系统
动手尝试
- 插入
[10, 20, 30, 21]并跟踪交换过程 - 测量探查长度方差并与线性探查比较
- 实现带有墓碑处理的 delete() 函数
- 当负载因子 > 0.8 时添加扩容功能
- 与二次探查比较性能
- 在 20 次插入后可视化表的状态
- 尝试不同的表大小
- 创建探查长度的直方图
- 添加键值对存储功能
- 在 70%、80%、90% 负载下对搜索时间进行基准测试
测试用例
| 操作 | 输入 | 预期结果 |
|---|---|---|
| 插入 | 10, 20, 30, 21 | 均匀分布 |
| 搜索 | 20 | 找到 |
| 搜索 | 40 | 未找到 |
| 删除 | 10 | 已移除 |
| 插入 | 50 | 放置在最优位置 |
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 搜索 | O(1) 平均 | O(n) |
| 插入 | O(1) 平均 | O(n) |
| 删除 | O(1) 平均 | O(n) |
罗宾汉哈希让所有键都保持"诚实",没有键能独占快速访问权,也没有键会流浪得太远。
197 Jump Consistent Hashing
Jump Consistent Hashing(跳转一致性哈希)是一种轻量级、快速且确定性的方法,用于将键分配到桶(服务器、分片或分区)中,当桶的数量发生变化时,它能最大限度地减少重新映射。
它专为分布式系统中的负载均衡而设计,例如数据库分片或缓存集群。
我们要解决什么问题?
当水平扩展系统时,你通常需要将键(如用户ID)分配到桶(如服务器)中。 简单的方法(例如 key % N)在 N 变化时会导致大量的重新映射。 Jump Consistent Hashing 避免了这个问题,当添加或删除一个桶时,只有一小部分键需要移动。
这确保了稳定性和可预测的重新分布,非常适合分布式缓存(Memcached、Redis)和数据库(Bigtable、Ceph)。
示例(添加桶)
假设我们有键 1 到 6 和 3 个桶:
| 键 | 桶 (3) |
|---|---|
| 1 | 2 |
| 2 | 0 |
| 3 | 2 |
| 4 | 1 |
| 5 | 0 |
| 6 | 2 |
当我们添加一个新桶(总共 4 个)时,只有少数键会改变桶,大多数键保持原样。 这就是一致性的魔力。
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 将键视为 64 位整数 |
| 2 | 初始化 b = -1 和 j = 0 |
| 3 | 当 j < num_buckets 时,更新: |
b = j, key = key * 2862933555777941757ULL + 1 | |
j = floor((b + 1) * (1LL << 31) / ((key >> 33) + 1)) | |
| 4 | 返回 b 作为桶索引 |
它只使用整数运算,没有表,没有存储,只有数学。
精简代码
C 语言实现
#include <stdint.h>
#include <stdio.h>
int jump_consistent_hash(uint64_t key, int num_buckets) {
int64_t b = -1, j = 0;
while (j < num_buckets) {
b = j;
key = key * 2862933555777941757ULL + 1;
j = (b + 1) * ((double)(1LL << 31) / ((key >> 33) + 1));
}
return (int)b;
}
int main() {
for (uint64_t k = 1; k <= 6; k++)
printf("Key %llu → Bucket %d\n", k, jump_consistent_hash(k, 3));
}输出:
Key 1 → Bucket 2
Key 2 → Bucket 0
Key 3 → Bucket 2
Key 4 → Bucket 1
Key 5 → Bucket 0
Key 6 → Bucket 2Python 实现
def jump_hash(key, num_buckets):
b, j = -1, 0
while j < num_buckets:
b = j
key = key * 2862933555777941757 + 1
j = int((b + 1) * (1 << 31) / ((key >> 33) + 1))
return b
for k in range(1, 7):
print(f"Key {k} → Bucket {jump_hash(k, 3)}")为什么重要
- 稳定分布:调整大小时重新映射最少
- O(1) 时间,O(1) 空间
- 非常适合分片、负载均衡、分区
- 不需要外部存储或环形结构(与一致性哈希环不同)
亲自尝试
- 将 10 个键分配到 3 个桶中
- 添加第 4 个桶,看看哪些键移动了
- 与
key % N方法进行比较 - 尝试非常大的桶数量(10k+)
- 基准测试速度,注意它几乎是恒定的
- 与分布式缓存模拟集成
- 测试均匀性(键的分布)
- 为每个服务的变化添加随机种子
- 可视化重新分布模式
- 与 Rendezvous Hashing 进行比较
测试用例
| 输入 | 桶数 | 输出 |
|---|---|---|
| Key=1, N=3 | 3 | 2 |
| Key=2, N=3 | 3 | 0 |
| Key=3, N=3 | 3 | 2 |
| Key=1, N=4 | 4 | 3 或 2 (取决于数学运算) |
当 N 改变时,只有一小部分键会被重新映射。
复杂度
| 操作 | 时间 | 空间 |
|---|---|---|
| 查找 | O(1) | O(1) |
| 插入 | O(1) | O(1) |
跳转一致性哈希就像一只稳健的手,即使你的系统在增长,它也能让大多数键保持在它们所属的位置。
198 字典树中的前缀搜索
字典树(前缀树)是一种专门用于存储字符串的树形数据结构,它通过字符串的前缀来组织数据,从而实现基于前缀的快速查找,非常适合自动补全、词典和单词搜索引擎等应用场景。
使用字典树,搜索 "app" 可以立即找到 "apple"、"apply"、"appetite" 等单词。
我们要解决什么问题?
传统的数组或哈希表等数据结构无法高效地回答以下问题:
- "列出所有以
pre开头的单词" - "是否有单词以
tri开头?"
字典树通过前缀路径来组织数据,使得这类查询变得快速而自然,其时间复杂度通常为 O(k),其中 k 是前缀的长度。
示例(单词:app, apple, bat)
字典树(Trie)看起来像这样:
(根节点)
├─ a
│ └─ p
│ └─ p *
│ └─ l
│ └─ e *
└─ b
└─ a
└─ t *星号 (*) 标记单词的结尾。 搜索 "app" → 找到;列出所有补全 → "app", "apple"。
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 每个节点代表一个字符 |
| 2 | 从根节点到某个节点的路径代表一个前缀 |
| 3 | 插入时,按字符遍历,必要时创建新节点 |
| 4 | 到达最后一个字符时,标记单词结束 |
| 5 | 搜索前缀:逐个字符遍历节点 |
| 6 | 如果所有字符都存在 → 前缀找到;否则 → 未找到 |
精简代码
C 语言实现(简化版)
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define ALPHABET 26
typedef struct TrieNode {
struct TrieNode *child[ALPHABET];
bool isEnd;
} TrieNode;
TrieNode* newNode() {
TrieNode* node = calloc(1, sizeof(TrieNode));
node->isEnd = false;
return node;
}
void insert(TrieNode *root, const char *word) {
for (int i = 0; word[i]; i++) {
int idx = word[i] - 'a';
if (!root->child[idx]) root->child[idx] = newNode();
root = root->child[idx];
}
root->isEnd = true;
}
bool startsWith(TrieNode *root, const char *prefix) {
for (int i = 0; prefix[i]; i++) {
int idx = prefix[i] - 'a';
if (!root->child[idx]) return false;
root = root->child[idx];
}
return true;
}
int main() {
TrieNode *root = newNode();
insert(root, "app");
insert(root, "apple");
printf("Starts with 'ap': %s\n", startsWith(root, "ap") ? "Yes" : "No");
}输出:
Starts with 'ap': YesPython 实现
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def starts_with(self, prefix):
node = self.root
for ch in prefix:
if ch not in node.children:
return False
node = node.children[ch]
return True
trie = Trie()
trie.insert("apple")
trie.insert("app")
print(trie.starts_with("ap")) # True为何重要
- O(k) 时间复杂度的前缀查找(k = 前缀长度)
- 完美适用于自动补全、拼写检查器、搜索引擎
- 高效存储共享的前缀
- 可以扩展以支持频率、权重或通配符匹配
动手尝试
- 插入 ["app", "apple", "apply", "apt"]
- 搜索前缀 "ap" → 期望结果为 True
- 搜索前缀 "ba" → 期望结果为 False
- 添加一个函数,列出以某个前缀开头的所有单词
- 实现删除单词(delete(word))功能
- 在每个节点添加频率计数
- 支持大写字母
- 清晰地存储单词结束标记
- 比较与哈希表的内存使用情况
- 扩展为返回前 N 个补全的自动补全功能
测试用例
| 操作 | 输入 | 输出 |
|---|---|---|
| 插入 | "app" | 已存储 |
| 插入 | "apple" | 已存储 |
| startsWith | "ap" | True |
| startsWith | "ba" | False |
| startsWith | "app" | True |
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 插入 | O(k) | O(k) |
| 搜索前缀 | O(k) | O(1) |
| 空间(n个词) | O(所有字符的总和) |
字典树(Trie)将你的数据转化为单词的地图,每条分支都是通往意义的路径,每个前缀都是通往发现的捷径。
199 后缀数组中的模式搜索
后缀数组是字符串所有后缀的排序列表。 它支持快速的子字符串搜索,非常适合文本编辑器、DNA 分析和搜索引擎中的模式匹配。
通过将其与二分搜索结合,可以在 O(m log n) 时间内判断一个模式是否出现在字符串中。
我们要解决什么问题?
给定一个大型文本 T(例如 "banana")和一个模式 P(例如 "ana"),我们希望快速检查 P 是否存在于 T 中。 朴素搜索需要 O(nm) 时间(比较每个位置)。 后缀数组允许我们通过在一个预先排序的后缀列表上进行操作来更高效地搜索。
示例(文本 = "banana")
列出所有后缀并排序:
| 索引 | 后缀 |
|---|---|
| 0 | banana |
| 1 | anana |
| 2 | nana |
| 3 | ana |
| 4 | na |
| 5 | a |
排序后的后缀:
| SA 索引 | 后缀 | 原始位置 |
|---|---|---|
| 5 | a | 5 |
| 3 | ana | 3 |
| 1 | anana | 1 |
| 0 | banana | 0 |
| 4 | na | 4 |
| 2 | nana | 2 |
现在在这些排序后的后缀上使用二分搜索来查找 "ana"。
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 构建后缀数组 = 所有后缀按字典序排序 |
| 2 | 使用二分搜索查找模式的下界/上界 |
| 3 | 每次比较只比较 m 个字符 |
| 4 | 如果找到 → 模式出现在后缀索引处 |
| 5 | 否则 → 不在文本中 |
精简代码
C 语言实现(简化版)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int cmp(const void *a, const void *b, void *txt) {
int i = *(int*)a, j = *(int*)b;
return strcmp((char*)txt + i, (char*)txt + j);
}
void build_suffix_array(char *txt, int n, int sa[]) {
for (int i = 0; i < n; i++) sa[i] = i;
qsort_r(sa, n, sizeof(int), cmp, txt);
}
int binary_search_suffix(char *txt, int sa[], int n, char *pat) {
int l = 0, r = n - 1;
while (l <= r) {
int mid = (l + r) / 2;
int res = strncmp(pat, txt + sa[mid], strlen(pat));
if (res == 0) return sa[mid];
if (res < 0) r = mid - 1;
else l = mid + 1;
}
return -1;
}
int main() {
char txt[] = "banana";
int n = strlen(txt), sa[n];
build_suffix_array(txt, n, sa);
char pat[] = "ana";
int pos = binary_search_suffix(txt, sa, n, pat);
if (pos >= 0) printf("Pattern found at %d\n", pos);
else printf("Pattern not found\n");
}输出:
Pattern found at 1Python 实现
def build_suffix_array(s):
return sorted(range(len(s)), key=lambda i: s[i:])
def search(s, sa, pat):
l, r = 0, len(sa) - 1
while l <= r:
mid = (l + r) // 2
suffix = s[sa[mid]:]
if suffix.startswith(pat):
return sa[mid]
if suffix < pat:
l = mid + 1
else:
r = mid - 1
return -1
s = "banana"
sa = build_suffix_array(s)
print("Suffix Array:", sa)
print("Search 'ana':", search(s, sa, "ana"))输出:
Suffix Array: [5, 3, 1, 0, 4, 2]
Search 'ana': 1为何重要
- 在 O(m log n) 时间内进行子串搜索
- 空间高效的替代后缀树方案
- 适用于全文搜索、DNA 测序、抄袭检测
- 可以扩展 LCP 数组以支持最长公共前缀查询
动手尝试
- 为
"banana"构建后缀数组 - 搜索
"na"、"ban"、"apple" - 打印所有后缀以便可视化
- 添加 LCP 数组以加速重复查询
- 与 KMP 算法速度进行比较
- 手动使用二分搜索追踪比较过程
- 扩展功能以统计模式出现次数
- 尝试更长的文本(例如 "mississippi")
- 为 SA 实现原地快速排序
- 与朴素子串搜索进行基准测试
测试用例
| 文本 | 模式 | 预期结果 |
|---|---|---|
| "banana" | "ana" | 在位置 1 找到 |
| "banana" | "ban" | 在位置 0 找到 |
| "banana" | "na" | 在位置 2 或 4 找到 |
| "banana" | "cat" | 未找到 |
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 构建 SA | O(n log n) | O(n) |
| 搜索 | O(m log n) | O(1) |
后缀数组就像一个图书馆索引,一旦排序完成,每次搜索都变得像翻到正确页码一样简单。
200 无限数组中的搜索
无限数组(或无界数组)搜索是一种在长度未知的已排序列表中查找元素的技术。 你不能直接使用二分查找,因为你不知道 n,所以你必须首先找到一个搜索边界,然后在该边界内执行二分查找。
这个思想对于数据是流式传输或动态大小的系统(如日志、展开列表或文件扫描)至关重要。
我们要解决什么问题?
如果你被给定一个类似数组的接口(如 get(i)),但没有大小 n,你如何高效地找到 target? 你不能进行线性搜索,因为它可能是无限的。 技巧是:使用指数搜索,以指数方式扩大边界,直到超过目标值,然后在该窗口内应用二分查找。
示例
给定有序序列:
$$3, 5, 9, 12, 17, 23, 31, 45, 67, 88, 100, ...]搜索 31:
从
low = 0, high = 1开始当
arr[high] < 31时,将high加倍high = 1 → 2 → 4 → 8现在
arr[8] = 67 > 31,所以搜索范围是[4, 8]在
[4, 8]中执行二分查找在索引 6 处找到
工作原理(通俗解释)
| 步骤 | 操作 |
|---|---|
| 1 | 从 low = 0, high = 1 开始 |
| 2 | 当 arr[high] < target 时,设置 low = high, high *= 2 |
| 3 | 现在目标值位于 low 和 high 之间 |
| 4 | 在 [low, high] 中执行标准二分查找 |
| 5 | 找到则返回索引,否则返回 -1 |
精简代码
C 语言实现(模拟无限数组)
#include <stdio.h>
int get(int arr[], int size, int i) {
if (i >= size) return 1e9; // 模拟无穷大
return arr[i];
}
int binary_search(int arr[], int low, int high, int target, int size) {
while (low <= high) {
int mid = low + (high - low) / 2;
int val = get(arr, size, mid);
if (val == target) return mid;
if (val < target) low = mid + 1;
else high = mid - 1;
}
return -1;
}
int search_infinite(int arr[], int size, int target) {
int low = 0, high = 1;
while (get(arr, size, high) < target) {
low = high;
high *= 2;
}
return binary_search(arr, low, high, target, size);
}
int main() {
int arr[] = {3, 5, 9, 12, 17, 23, 31, 45, 67, 88, 100};
int size = sizeof(arr)/sizeof(arr[0]);
int idx = search_infinite(arr, size, 31);
printf("Found at index %d\n", idx);
}输出:
Found at index 6Python 实现
def get(arr, i):
return arr[i] if i < len(arr) else float('inf')
def binary_search(arr, low, high, target):
while low <= high:
mid = (low + high) // 2
val = get(arr, mid)
if val == target:
return mid
if val < target:
low = mid + 1
else:
high = mid - 1
return -1
def search_infinite(arr, target):
low, high = 0, 1
while get(arr, high) < target:
low = high
high *= 2
return binary_search(arr, low, high, target)
arr = [3, 5, 9, 12, 17, 23, 31, 45, 67, 88, 100]
print("Found at index:", search_infinite(arr, 31))输出:
Found at index: 6为何重要
- 适用于流、链式存储、API 或无限生成器
- 避免完全遍历,呈对数级增长
- 结合了探索(寻找边界)和二分查找(精确匹配)
- 非常适合搜索引擎、日志读取器、云数据分页
动手尝试
- 在
[1, 3, 5, 9, 12, 20]中搜索9 - 在
[2, 4, 8, 16, 32, 64]中搜索33(未找到) - 计算
get()的调用次数,与线性搜索进行比较 - 尝试
target小于第一个元素的情况 - 处理边界情况:空数组,
target> 最大值 - 使用
get()模拟无限流 - 将加倍策略替换为
high = low + step以实现自适应增长 - 可视化搜索窗口的扩展
- 推广到降序数组
- 与朴素扫描比较性能
测试用例
| 输入数组 | 目标值 | 输出 |
|---|---|---|
| [3, 5, 9, 12, 17, 23, 31, 45] | 31 | 6 |
| [3, 5, 9, 12, 17, 23, 31, 45] | 4 | -1 |
| [1, 2, 4, 8, 16] | 8 | 3 |
| [10, 20, 30] | 40 | -1 |
复杂度
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 搜索 | O(log p) | O(1) |
p = 目标值在数组中的位置
在无限数组中搜索就像在雾中驾驶,首先找到你的车灯(边界),然后直抵目标。