算法概述

符号 名称 数学含义 直观理解
O(g(n))O(g(n)) 渐进上界 f(n)cg(n)f(n) \le c \cdot g(n) ff 不超过 gg
Ω(g(n))\Omega(g(n)) 渐进下界 f(n)cg(n)f(n) \ge c \cdot g(n) ff 至少是 gg
Θ(g(n))\Theta(g(n)) 紧渐进界 c1g(n)f(n)c2g(n)c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n) ffgg 同阶
o(g(n))o(g(n)) 非紧上界 f(n)<cg(n)f(n) < c \cdot g(n) ff 远小于 gg
ω(g(n))\omega(g(n)) 非紧下界 f(n)>cg(n)f(n) > c \cdot g(n) ff 远大于 gg

定义类

  • 渐近上界记号
    • f(n)=O(g(n))f(n) = O(g(n)) 当且仅当存在正常数 ccn0n_0,使得对所有 nn0n \ge n_0 有:0f(n)cg(n)0 \le f(n) \le cg(n)
  • 渐近下界记号
    • f(n)=Ω(g(n))f(n) = \Omega(g(n)) 当且仅当存在正常数 ccn0n_0,使得对所有 nn0n \ge n_0 有:0cg(n)f(n)0 \le cg(n) \le f(n)
  • 紧渐近界记号
    • f(n)=Θ(g(n))f(n) = \Theta(g(n)) 当且仅当存在正常数 c1c_1c2c_2n0n_0,使得对所有 nn0n \ge n_0 有:0c1g(n)f(n)c2g(n)0 \le c_1g(n) \le f(n) \le c_2g(n)
  • 非紧上界记号
    • f(n)=o(g(n))f(n) = o(g(n)) 当且仅当对于任何正常数 c>0c > 0,存在正数 n0>0n_0 > 0,使得对所有 nn0n \ge n_0 有:0f(n)<cg(n)0 \le f(n) < cg(n)
  • 非紧下界记号
    • f(n)=ω(g(n))f(n) = \omega(g(n)) 当且仅当对于任何正常数 c>0c > 0,存在正数 n0>0n_0 > 0,使得对所有 nn0n \ge n_0 有:0cg(n)<f(n)0 \le cg(n) < f(n)

数学证明类

例题1:证明:0.12n2n+1000n2logn=O(n5/2)0.12n^2\sqrt{n} + 1000n^2 \log n = O(n^{5/2})

  1. 定义:需证明存在正常数 ccn0n_0,使 0.12n2n+1000n2logncn5/20.12n^2\sqrt{n} + 1000n^2 \log n \le cn^{5/2}
  2. 原式变形0.12n2n+1000n2logn=n2(0.12n+1000logn)0.12n^2\sqrt{n} + 1000n^2 \log n = n^2(0.12\sqrt{n} + 1000 \log n)
  3. 不等式放大:对于任意 n1n \ge 1,已知 lognn\log n \le \sqrt{n},则有:
    0.12n+1000logn0.12n+1000n=1000.12n0.12\sqrt{n} + 1000 \log n \le 0.12\sqrt{n} + 1000\sqrt{n} = 1000.12\sqrt{n}
  4. 结论:因此,n2(1000.12n)=1000.12n2.5=1000.12n5/2n^2(1000.12\sqrt{n}) = 1000.12n^{2.5} = 1000.12n^{5/2}
  5. 结果:取 c=1000.12,n0=1c = 1000.12, n_0 = 1 满足定义,证毕。

例题2:证明:5n2+2=Ω(20n1.8)5n^2 + 2 = \Omega(20n^{1.8})

  1. 定义:需证明存在正常数 ccn0n_0,使 5n2+2c(20n1.8)5n^2 + 2 \ge c(20n^{1.8})
  2. 初步放缩:对于所有 n>0n > 0,明显有 5n2+2>5n25n^2 + 2 > 5n^2
  3. 寻找参数:只需证明 5n220cn1.85n^2 \ge 20cn^{1.8},即 0.25n0.2c0.25n^{0.2} \ge c
  4. 结论:取 c=0.25,n0=1c = 0.25, n_0 = 1
  5. 结果:当 n1n \ge 1 时,5n2+2>5n220(0.25)n1.8=5n1.85n^2 + 2 > 5n^2 \ge 20(0.25)n^{1.8} = 5n^{1.8},证毕。

重点公式证明

没考过但是PPT有

证明:O(f(n))+O(g(n))=O(max{f(n),g(n)})O(f(n)) + O(g(n)) = O(\max\{f(n), g(n)\})

  • f1(n)O(f(n))f_1(n) \in O(f(n))
    • 对于 f1(n)f_1(n),存在正常数 c1c_1 和自然数 n1n_1,使得对所有 nn1n \ge n_1,有 f1(n)c1f(n)f_1(n) \le c_1f(n)
  • g1(n)O(g(n))g_1(n) \in O(g(n))
    • 对于 g1(n)g_1(n),存在正常数 c2c_2 和自然数 n2n_2,使得对所有 nn2n \ge n_2,有 g1(n)c2g(n)g_1(n) \le c_2g(n)
  • c3=max{c1,c2}c_3 = \max\{c_1, c_2\}n3=max{n1,n2}n_3 = \max\{n_1, n_2\}h(n)=max{f(n),g(n)}h(n) = \max\{f(n), g(n)\}
  • 则对于所有的 nn3n \ge n_3
    f1(n)+g1(n)c1f(n)+c2g(n)c3(f(n)+g(n))2c3h(n)=O(h(n))f_1(n) + g_1(n) \le c_1f(n) + c_2g(n) \le c_3(f(n) + g(n)) \le 2c_3h(n) = O(h(n))

主定理

PPT没有但是可能有用

主定理计算复杂度
递归方程符合以下形式:

T(n)=aT(n/b)+f(n)T(n)=a⋅T(n/b)+f(n)

计算“临界函数”:nlogban^{\log_b a}

  • f(n) 远小于 nlogban^{\log_b a}T(n)=Θ(nlogba)T(n)=Θ(n^{\log_b a})
  • f(n) 等于 nlogban^{\log_b a}T(n)=Θ(nlogbalogn)T(n)=Θ(n^{\log_b a}⋅\log n)
  • f(n) 远大于 nlogban^{\log_b a}T(n)=Θ(f(n))T(n)=Θ(f(n))

题目类型:

  • “证明合并排序的时间复杂度为 O(nlogn)O(n \log n)
  • “证明快速排序最好情况下的复杂度”
  • “分析线性时间选择算法的复杂度”

例题1
给出递归方程 T(n)=2T(n/2)+nT(n) = 2T(n/2) + n,请证明 T(n)=O(nlogn)T(n) = O(n \log n)

  • 写出方程的形式:T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n)
  • 确定参数:a=2,b=2,f(n)=na=2, b=2, f(n)=n
  • 计算“临界函数”:nlogba=nlog22=n1=nn^{\log_b a} = n^{\log_2 2} = n^1 = n
  • 套用主定理:因为 f(n)=Θ(n)f(n) = \Theta(n),符合主定理第二种情况。
  • 得出结论:T(n)=Θ(nlogn)T(n) = \Theta(n \log n)

递归

阶乘函数

n!={1n=0(边界条件)n(n1)!n>0(递归方程)n! = \begin{cases} 1 & n = 0 \quad (\text{边界条件}) \\ n(n-1)! & n > 0 \quad (\text{递归方程}) \end{cases}

Fibonacci 数列

F(n)={1n=01n=1F(n1)+F(n2)n>1F(n) = \begin{cases} 1 & n = 0 \\ 1 & n = 1 \\ F(n-1) + F(n-2) & n > 1 \end{cases}

1
2
3
4
5
int fibonacci(int n)
{
if (n <= 1) return 1;
return fibonacci(n-1) + fibonacci(n-2);
}

线性递归

f(n)=b1f(n1)+b2f(n2)f(n) = b_1 f(n-1) + b_2 f(n-2)

求解四步法:

  1. 写出特征方程:
    f(n)f(n) 换成 r2r^2f(n1)f(n-1) 换成 r1r^1f(n2)f(n-2) 换成 r0r^0
    得到:r2b1rb2=0r^2 - b_1 r - b_2 = 0
  2. 求特征根:
    解这个一元二次方程,得到两个根 r1r_1r2r_2
  3. 列出通解公式
    • 情况 A(两不等实根)f(n)=C1(r1)n+C2(r2)nf(n) = C_1(r_1)^n + C_2(r_2)^n
    • 情况 B(二重等根)f(n)=(C1+C2n)(r1)nf(n) = (C_1 + C_2 n)(r_1)^n
  4. 代入初值解系数:
    根据题目给出的 f(0)f(0)f(1)f(1),列方程组求出常数 C1C_1C2C_2

例题1f(n)=5f(n1)6f(n2)f(n) = 5f(n-1) - 6f(n-2)f(0)=1,f(1)=1f(0)=1, f(1)=1

  • Step 1 特征方程r25r+6=0r^2 - 5r + 6 = 0
  • Step 2 求根(r2)(r3)=0r1=2,r2=3(r-2)(r-3)=0 \Rightarrow r_1=2, r_2=3
  • Step 3 通解f(n)=C12n+C23nf(n) = C_1 \cdot 2^n + C_2 \cdot 3^n
  • Step 4 解系数
    • n=0C1+C2=1n=0 \Rightarrow C_1 + C_2 = 1
    • n=12C1+3C2=1n=1 \Rightarrow 2C_1 + 3C_2 = 1
    • 解得:C1=2,C2=1C_1 = 2, C_2 = -1
  • 最后结果f(n)=22n3n=2n+13nf(n) = 2 \cdot 2^n - 3^n = 2^{n+1} - 3^n

排列问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <class Type>
inline void Swap (Type &a, Type &b)
{
Type temp = a;
a = b;
b = temp;
}
template <class Type>
void Perm (Type list [], int k, int m) {
// 产生 list[k:m] 的所有排列

if (k == m) {
// 边界条件:只剩下一个元素,此时已完成一种全排列,进行打印
for (int i = 0; i <= m; i++)
cout << list[i];
cout << endl;
}
else {
// 递归步骤:还有多个元素待排列
for (int i = k; i <= m; i++) {
// 将第 i 个元素交换到当前待排列位置 k
Swap( list[k], list[i] );

// 递归产生剩余元素 (k+1 到 m) 的排列
Perm( list, k + 1, m );

// 恢复原状(回溯):再次交换还原数组,以便进行下一次尝试
Swap( list[k], list[i] );
}
}
}

整数划分

q(n,m)q(n, m):将 n 拆分,且规定拆分出的最大数不能超过 m,一共有多少种方法?

q(n,m)={1(n=1 或 m=1)q(n,n)(n<m)1+q(n,n1)(n=m)q(n,m1)+q(nm,m)(n>m>1)q(n, m) = \begin{cases} 1 & (n=1 \text{ 或 } m=1)\\ q(n, n) & (n < m) \\ 1 + q(n, n-1) & (n = m) \\ q(n, m-1) + q(n-m, m) & (n > m > 1) \end{cases}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* n 待划分的正整数
* m 划分中允许的最大加数
* return 划分方案的总数
*/
int q(int n, int m) {
// 1. 边界条件:n或m为1时,只有一种划分方式(即 1+1+...+1 或 n=1)
if (n < 1 || m < 1) return 0;
if (n == 1 || m == 1) return 1;

// 2. 当最大加数限制 m 超过 n 时,有效限制其实就是 n
if (n < m) {
return q(n, n);
}

// 3. 当 n == m 时,划分包含两种情况:
// - 只有 {n} 这一种划分:即 1
// - 最大加数小于 n 的划分:即 q(n, n-1)
if (n == m) {
return 1 + q(n, n - 1);
}

// 4. 当 n > m 时(一般情况),根据是否包含 m 分为两部分:
// - q(n, m-1): 划分中不包含 m,即最大加数限制减小为 m-1
// - q(n-m, m): 划分中至少包含一个 m,则剩下的值 n-m 继续按最大加数 m 划分
return q(n, m - 1) + q(n - m, m);
}

汉诺塔 Hanoi

1
2
3
4
5
6
7
8
9
void hanoi(int n, int a, int b, int c)
{
if (n > 0)
{
hanoi(n-1, a, c, b);
move(a,b);
hanoi(n-1, c, b, a);
}
}

分治

1.说明算法策略

  • 分解:将原问题划分为若干个规模较小、相互独立、与原问题形式相同的子问题。
  • 解决:递归地解这些子问题。若子问题规模足够小,则直接解决。
  • 合并:将子问题的解合并为原问题的解。

2.描述算法过程

  • 用文字或图示描述算法逻辑

3.编写伪代码

4.复杂度分析

  • 列递归方程T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n)
  • 证明方法
    • 主定理
    • 递归树法

递归树法
1.以合并排序 T(n)=2T(n/2)+nT(n) = 2T(n/2) + n 为例(平衡递归树)

1
2
3
4
5
6
7
>第 0 层:           n                     ---- 工作量: n
/ \
>第 1 层: n/2 n/2 ---- 工作量: (n/2 + n/2) = n
/ \ / \
>第 2 层: n/4 n/4 n/4 n/4 ---- 工作量: (n/4 * 4) = n
... ... ... ...
>第 log n 层: 1 1 1 1 ... 1 ---- 工作量: n 个 1 = n

第一步:计算树的高度

  • 方程:n/2k=1n / 2^k = 1
  • 解得高度:k=log2nk = \log_2 n

第二步:计算每一层的工作量之和

  • 每一层的工作量都是 nn

第三步:求总和

  • 总复杂度 T(n)T(n) = 每一层的工作量 ×\times 总层数:
  • T(n)=n+n++n=n×(层数 logn)=O(nlogn)T(n) = n + n + \dots + n = n \times (\text{层数 } \log n) = O(n \log n)

2.以T(n)=T(n/3)+T(2n/3)+nT(n) = T(n/3) + T(2n/3) + n为例(不平衡递归树)

1
2
3
4
5
6
7
>第 0 层:               n            ---> 工作量: n
/ \
>第 1 层: n/3 2n/3 ---> 工作量: n/3 + 2n/3 = n
/ \ / \
>第 2 层: n/9 2n/9 2n/9 4n/9 ---> 工作量: n/9 + 2n/9 + 2n/9 + 4n/9 = n
/ \ / \ / \ / \
>... ... ... ... ... ... ... ...
  • 每一层的工作量之和都是 nn
  • 最短路径(最左侧):高度 hminh_{min} 满足 (1/3)hn=1h=log3n(1/3)^h \cdot n = 1 \Rightarrow h = \log_3 n
  • 最长路径(最右侧):高度 hmaxh_{max} 满足 (2/3)hn=1h=log3/2n(2/3)^h \cdot n = 1 \Rightarrow h = \log_{3/2} n
  • 下界:总工作量 n×log3n=Ω(nlogn)\ge n \times \log_3 n = \Omega(n \log n)
  • 上界:总工作量 n×log3/2n=O(nlogn)\le n \times \log_{3/2} n = O(n \log n)
  • 该算法的复杂度为 T(n)=Θ(nlogn)T(n) = \Theta(n \log n)

二分搜索

1
2
3
4
5
6
7
8
9
10
template<class Type>
int BinarySearch(Type a[], const Type& x, int l, int r)
{
while (r >= l){
int m = (l+r)/2;
if (x == a[m]) return m;
if (x < a[m]) r = m-1; else l = m+1;
}
return -1;
}

最坏时间复杂度O(logn)O(\log n)

棋盘覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* 棋盘覆盖算法
* @param tr 当前子棋盘左上角的行号 (Top Row)
* @param tc 当前子棋盘左上角的列号 (Top Column)
* @param dr 特殊方格所在的行号 (Defective Row)
* @param dc 特殊方格所在的列号 (Defective Column)
* @param size 当前子棋盘的边长 (size = 2^k)
*/
void chessBoard(int tr, int tc, int dr, int dc, int size) {
// 递归终止条件:如果子棋盘大小为 1,则无需覆盖
if (size == 1) return;

int t = tile++; // 全局变量,记录当前使用的 L 型骨牌编号
int s = size / 2; // 分割棋盘,计算子棋盘的边长

// --- 1. 覆盖左上角子棋盘 ---
if (dr < tr + s && dc < tc + s) {
// 特殊方格在此子棋盘中
chessBoard(tr, tc, dr, dc, s);
} else {
// 此子棋盘中无特殊方格,用 t 号 L 型骨牌覆盖其右下角
board[tr + s - 1][tc + s - 1] = t;
// 将被覆盖的这个点视为该子棋盘的“特殊方格”进行递归
chessBoard(tr, tc, tr + s - 1, tc + s - 1, s);
}

// --- 2. 覆盖右上角子棋盘 ---
if (dr < tr + s && dc >= tc + s) {
// 特殊方格在此子棋盘中
chessBoard(tr, tc + s, dr, dc, s);
} else {
// 此子棋盘中无特殊方格,用 t 号 L 型骨牌覆盖其左下角
board[tr + s - 1][tc + s] = t;
// 将被覆盖的这个点视为该子棋盘的“特殊方格”进行递归
chessBoard(tr, tc + s, tr + s - 1, tc + s, s);
}

// --- 3. 覆盖左下角子棋盘 ---
if (dr >= tr + s && dc < tc + s) {
// 特殊方格在此子棋盘中
chessBoard(tr + s, tc, dr, dc, s);
} else {
// 此子棋盘中无特殊方格,用 t 号 L 型骨牌覆盖其右上角
board[tr + s][tc + s - 1] = t;
// 将被覆盖的这个点视为该子棋盘的“特殊方格”进行递归
chessBoard(tr + s, tc, tr + s, tc + s - 1, s);
}

// --- 4. 覆盖右下角子棋盘 ---
if (dr >= tr + s && dc >= tc + s) {
// 特殊方格在此子棋盘中
chessBoard(tr + s, tc + s, dr, dc, s);
} else {
// 此子棋盘中无特殊方格,用 t 号 L 型骨牌覆盖其左上角
board[tr + s][tc + s] = t;
// 将被覆盖的这个点视为该子棋盘的“特殊方格”进行递归
chessBoard(tr + s, tc + s, tr + s, tc + s, s);
}
}

复杂度递归公式

T(k)={O(1)k=04T(k1)+O(1)k>0T(k) = \begin{cases} O(1) & k = 0 \\ 4T(k - 1) + O(1) & k > 0 \end{cases}

T(k)=O(4k)T(k)=O(4^k)

合并排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// a: 原数组, b: 辅助数组, l: 左边界, m: 中间点, r: 右边界
void Merge(Type a[], Type b[], int l, int m, int r)
{
int i = l, j = m + 1, k = l; // i指向左半部分起始,j指向右半部分起始,k为目标数组下标

// 比较两个子序列,将较小的元素依次放入数组 b
while ((i <= m) && (j <= r)) {
if (a[i] <= a[j])
b[k++] = a[i++];
else
b[k++] = a[j++];
}

// 检查是否有剩余元素并复制到数组 b
if (i > m) {
// 如果左边元素已放完,将右边剩余部分直接复制
for (int q = j; q <= r; q++) b[k++] = a[q];
} else {
// 如果右边元素已放完,将左边剩余部分直接复制
for (int q = i; q <= m; q++) b[k++] = a[q];
}
}

// a: 待排序数组, left: 起始索引, right: 结束索引
void MergeSort(Type a[], int left, int right)
{
if (left < right) { // 递归条件:当前区间至少有两个元素
int i = (left + right) / 2; // 取中点,将序列一分为二

MergeSort(a, left, i); // 递归排序左半部分
MergeSort(a, i + 1, right); // 递归排序右半部分

Merge(a, b, left, i, right); // 将排好序的两部分合并到辅助数组 b
copy(a, b, left, right); // 将合并后的结果从 b 复制回原数组 a
}
}

T(n)={O(1)n12T(n/2)+O(n)n>1T(n) = \begin{cases} O(1) & n \leq 1 \\ 2T(n/2) + O(n) & n > 1 \end{cases}

最坏、平均时间复杂度O(nlogn)O(n\log n)
辅助空间O(n)O(n)

快速排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 划分函数 (Partition)
// 该函数选择一个基准元素(pivot),并将数组重新排列:
// 小于基准的移到左边,大于基准的移到右边。
template<class Type>
int Partition(Type a[], int p, int r) {
int i = p; // 左指针,从基准位置开始
int j = r + 1; // 右指针,从数组末尾之后开始(配合 --j 使用)

Type x = a[p]; // 选择当前子数组的第一个元素作为基准(pivot)

// 将 < x 的元素交换到左边区域
// 将 > x 的元素交换到右边区域
while (true) {
// 从左向右寻找第一个大于或等于 x 的元素
while (a[++i] < x && i < r);

// 从右向左寻找第一个小于或等于 x 的元素
while (a[--j] > x);

// 如果指针相遇或交叉,说明划分完成,跳出循环
if (i >= j) break;

// 交换这两个元素,使它们各归其位(左小右大)
Swap(a[i], a[j]);
}

// 将基准元素 x (即 a[p]) 放置到最终的正确位置 j
a[p] = a[j];
a[j] = x;

// 返回基准元素的位置,用于下一次递归划分
return j;
}

// 快速排序递归主函数 (QuickSort)
// 参数:a[] 数组, p 起始索引, r 结束索引
template<class Type>
void QuickSort(Type a[], int p, int r) {
// 只有当区间内至少有两个元素时才执行排序
if (p < r) {
// 1. 进行划分,获取基准元素的位置 q
int q = Partition(a, p, r);

// 2. 对左半段子数组进行递归排序(基准位置 q 之前的元素)
QuickSort(a, p, q - 1);

// 3. 对右半段子数组进行递归排序(基准位置 q 之后的元素)
QuickSort(a, q + 1, r);
}
}

最坏时间复杂度O(n2)O(n^2)
每次划分产生的两个区域分别包含 n1n-1 个元素和 11 个元素

T(n)={O(1)n1T(n1)+O(n)n>1T(n) = \begin{cases} O(1) & n \le 1 \\ T(n - 1) + O(n) & n > 1 \end{cases}

最好时间复杂度O(nlogn)O(n \log n)
每次划分所取的基准(pivot)都恰好为当前序列的中值

T(n)={O(1)n12T(n/2)+O(n)n>1T(n) = \begin{cases} O(1) & n \le 1 \\ 2T(n/2) + O(n) & n > 1 \end{cases}

基于随机选择策略的优化

1
2
3
4
5
6
7
8
9
template<class Type>
int RandomizedPartition (Type a[], int p, int r)
{
// 随机选择一个索引
int i = Random(p, r);
// 将随机基准换到首位,复用原有的 Partition 逻辑
Swap(a[i], a[p]);
return Partition (a, p, r);
}

线性时间选择

给定一个包含 n 个元素的无序数组和一个整数 k,要求找出这个数组中第 k 小的元素

随机化选择算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* a[]: 输入数组
* p: 当前处理区域的左边界
* r: 当前处理区域的右边界
* k: 寻找第 k 小的元素 (1 <= k <= r-p+1)
*/
template<class Type>
Type RandomizedSelect(Type a[], int p, int r, int k)
{
// 如果区域内只有一个元素,直接返回该元素
if (p == r) return a[p];

// 调用随机划分函数,将数组划分为两部分,返回基准元素最终所在的位置 i
int i = RandomizedPartition(a, p, r);

// 计算左半部分(包括基准元素自身)包含的元素个数 j
int j = i - p + 1;

// 情况 1:如果 k 小于或等于左半部分的元素个数 j
// 说明第 k 小的元素一定在左半部分(包含基准 i)中
if (k <= j) {
return RandomizedSelect(a, p, i, k);
}
// 情况 2:如果 k 大于 j
// 说明第 k 小的元素在右半部分,由于左边已经排除了 j 个元素
// 因此在右边区域中只需寻找第 k-j 小的元素
else {
return RandomizedSelect(a, i + 1, r, k - j);
}
}
  • 平均 时间复杂度:O(n)O(n)
  • 最坏 时间复杂度:O(n2)O(n^2)

确定性线性时间选择

例子:一组大小为5,选75作为分界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 用于在最坏 $O(n)$ 时间内找到第 k 小的元素
template<class Type>
Type Select(Type a[], int p, int r, int k) {
// 1. 如果元素个数小于 75,直接排序并返回第 k 个元素
if (r - p < 75) {
Sort(a, p, r);
return a[p + k - 1];
}

// 2. 将数组划分为 5 个一组,找出每组的中位数并移到数组前部
for (int i = 0; i <= (r - p - 4) / 5; i++) {
int s = p + 5 * i;
int t = s + 4;
Sort(a, s, t);
// 将各组中位数交换到 a[p] 开始的连续位置
Swap(a[p + i], a[s + 2]);
}

// 3. 递归寻找“中位数之中的中位数”作为基准元素 x
Type x = Select(a, p, p + (r - p - 4) / 5, (r - p - 4) / 10 + 1);

// 4. 找到 x 的实际索引并将其换到 a[p],以便调用您提供的 Partition
for (int m = p; m <= r; m++) {
if (a[m] == x) {
Swap(a[p], a[m]);
break;
}
}

// 5. 执行划分操作
int i = Partition(a, p, r);
int j = i - p + 1; // 左半部分(含基准)的元素个数

// 6. 递归处理子问题
if (k <= j) {
return Select(a, p, i, k);
} else {
return Select(a, i + 1, r, k - j);
}
}

最接近点对

一维平面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 函数功能:求解一维空间下的最接近点对距离
* 参数说明:S 为点集,d 为距离变量
*/
double cpair1(S, d)
{
// 1. 获取集合 S 的点数
n = |S|;

// 2. 基础情况处理:如果点数较少(图中复杂度分析显示边界为 n < 4)
if (n < 4) {
return 直接计算的最小距离;
}

// 3. 划分:寻找 S 中的中位数 m 作为分割点
m = S 中的中位数;

// 4. 分治:构造子集 S1 和 S2
// S1 为小于等于 m 的部分,S2 为大于 m 的部分
构造 S1, S2;

// 5. 递归求解子问题
d1 = Cpair1(S1, d1);
d2 = Cpair1(S2, d2);

// 6. 合并:寻找跨越分割线的最近点对
// p 为左侧集合的最大值,q 为右侧集合的最小值
p = max(S1);
q = min(S2);

// 7. 更新最小距离:取左右子集最小距离与跨中点距离的最小值
d = min(d1, d2, q - p);

return d;
}

时间复杂度O(nlogn)O(n\log n)

T(n)={O(1)n<42T(n/2)+O(n)n4T(n) = \begin{cases} O(1) & n < 4 \\ 2T(n/2) + O(n) & n \geq 4 \end{cases}

二维平面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 函数功能:求解二维平面下的最接近点对距离
* 参数说明:S 为点集
*/
double cpair2(S)
{
n = |S|; // 1. 获取点集 S 的大小

// 基础情况处理
if (n < 4) {
return 直接计算(S); // 点数较少时直接通过穷举法返回最小距离
}

// 1. 划分集合
// 选取垂直直线 l: x=m 作为分割线,m 为 S 中各点 x 坐标的中位数
m = S 中各点 x 坐标的中位数;
构造 S1 = {p ∈ S | x(p) <= m}; // 左侧子集
构造 S2 = {p ∈ S | x(p) > m}; // 右侧子集

// 2. 递归求解子问题
d1 = cpair2(S1); // 递归计算 S1 中的最小距离
d2 = cpair2(S2); // 递归计算 S2 中的最小距离

// 3. 计算当前已知的最小距离 dm
dm = min(d1, d2);

// 4. 寻找跨越分割线的候选点集
// P1 是 S1 中距垂直分割线距离在 dm 之内的点集
// P2 是 S2 中距垂直分割线距离在 dm 之内的点集
P1 = {p ∈ S1 | m - x(p) < dm};
P2 = {p ∈ S2 | x(p) - m < dm};

// 5. 合并扫描
// 将 P1 和 P2 中的所有点投影到分割线 l 上,并按 y 坐标排序
// 对于 P1 中的每一点 p,在 P2 中最多只需要检查按 y 坐标排序后的相继 6 个点
dl = 按此扫描方式找到的点对间的最小距离;

// 6. 确定并返回最终最小距离
d = min(dm, dl);
return d;
}

T(n)=O(nlogn)T(n) = O(n \log n)

T(n)={O(1)n<42T(n/2)+O(n)n4T(n) = \begin{cases} O(1) & n < 4 \\ 2T(n/2) + O(n) & n \geq 4 \end{cases}

循环日程表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 函数功能:生成 2^k 个选手的循环赛日程表
* 参数说明:k - 选手数量为 2^k, a - 存储日程表的二维数组
*/
void Table(int k, int **a)
{
int n = 1;
// 1. 计算总人数 n = 2^k
for (int i = 1; i <= k; i++) n *= 2;

// 2. 初始化第一行:第一天每个选手的对手就是自己(编号 1 到 n)
for (int i = 1; i <= n; i++) a[1][i] = i;

int m = 1; // m 为当前处理的子问题规模(初始块大小)

// 3. 按照分治思想,通过已有的块构造更大的块
for (int s = 1; s <= k; s++) {
n /= 2; // 当前阶段需要构造的块的数量

for (int t = 1; t <= n; t++) {
// 遍历并填充下方对应的方格
for (int i = m + 1; i <= 2 * m; i++) {
for (int j = m + 1; j <= 2 * m; j++) {
// 对角线对称填充:左上角的块复制到右下角
a[i][j + (t - 1) * m * 2] = a[i - m][j + (t - 1) * m * 2 - m];

// 对角线对称填充:右上角的块复制到左下角
a[i][j + (t - 1) * m * 2 - m] = a[i - m][j + (t - 1) * m * 2];
}
}
}
m *= 2;
}
}

动态规划

核心要素

  • 重叠子问题
  • 最优子结构

步骤

  • 找出最优解的性质,并刻画其结构特征:证明问题具有最优子结构 。
  • 递归地定义最优值:写出核心的递归方程式
  • 以自底向上的方式计算最优值:构造表格并填充 。
  • 根据计算信息构造最优解:回溯找到具体的选择方案 。

矩阵连乘问题

目标:确定矩阵相乘的顺序,使总乘法次数最少 。

核心数学模型
m[i][j]m[i][j] 为计算矩阵连乘 AiAjA_i \dots A_j 所需的最少乘法次数:

  • i=ji = j 时(只有一个矩阵):m[i][i]=0m[i][i] = 0
  • i<ji < j 时(多个矩阵,寻找最优切分点 kk):m[i][j]=minik<j{m[i][k]+m[k+1][j]+pi1pkpj}m[i][j] = \min_{i \le k < j} \{m[i][k] + m[k+1][j] + p_{i-1} \cdot p_k \cdot p_j\}
    • m[i][k]m[i][k]:左半部分连乘的代价。
    • m[k+1][j]m[k+1][j]:右半部分连乘的代价。
    • pi1pkpjp_{i-1}p_kp_j:最后两个矩阵(左结果 ×\times 右结果)相乘的代价。

最终递推公式

m[i,j]={0if i=jminik<j{m[i,k]+m[k+1,j]+pi1pkpj}if i<jm[i, j] = \begin{cases} 0 & \text{if } i = j \\ \min_{i \le k < j} \{ m[i, k] + m[k+1, j] + p_{i-1} p_k p_j \} & \text{if } i < j \end{cases}

自底向上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void MatrixChain(int *p, int n, int **m, int **s)
{
// 初始化链长为 1 的情况,m[i][i] = 0
for (int i = 1; i <= n; i++) m[i][i] = 0;

// r 是矩阵链的长度,从 2 开始
for (int r = 2; r <= n; r++) {
// i 是链的起始位置
for (int i = 1; i <= n - r + 1; i++) {
// j 是链的结束位置
int j = i + r - 1;
// 初始假设在第一个位置切开
m[i][j] = m[i+1][j] + p[i-1]*p[i]*p[j];
s[i][j] = i; // 暂时记录 k=i 为最优分割点

// 循环 k 从 i+1 到 j-1 (因为 k=i 的情况已经算过了)
for (int k = i+1; k < j; k++) {
// 计算 k 为分割点时的成本
int t = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];

// 如果找到了更优的(成本更低的)分割点 k
if (t < m[i][j]) {
m[i][j] = t; // 更新最小成本
s[i][j] = k; // 更新最优分割点
}
}
}
}
}

/*
* s[i][j] 的值为计算 A[i:j] 的最小乘次数的断开位置。
*/
void Traceback ( int i, int j, int **s )
{
if ( i == j )
return;

// 递归处理左子链 (A[i]...A[s[i,j]])
Traceback( i, s[i][j], s );

// 递归处理右子链 (A[s[i,j]+1]...A[j])
Traceback( s[i][j] + 1, j, s );

// 打印当前的乘法步骤
cout << "Multiply A" << i << "," << s[i][j];
cout << " and A" << ( s[i][j] + 1 ) << "," << j << endl;
}

时间复杂度 O(n3)O(n^3)
空间复杂度 O(n2)O(n^2)

备忘录法

保留了递归的结构,但通过一个表格(备忘录)记录已解决的子问题,避免重复运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 备忘录方法计算矩阵连乘的最少乘法次数
* i 矩阵链的起始下标
* j 矩阵链的结束下标
* return 从 Ai 到 Aj 连乘的最小代价
*/
int LookupChain(int i, int j)
{
// 1. 检查备忘录:如果该子问题已经计算过(m[i][j] > 0),则直接返回结果
if (m[i][j] > 0) return m[i][j];

// 2. 边界情况:如果只有一个矩阵,乘法次数为 0
if (i == j) return 0;

// 3. 初始化计算:先计算以 i 为切分点的情况 (k = i)
// p 数组存储矩阵维度:矩阵 Ai 的维数为 p[i-1] x p[i]
int u = LookupChain(i, i) + LookupChain(i+1, j) + p[i-1]*p[i]*p[j];
s[i][j] = i; // 记录当前最优切分点

// 4. 循环尝试所有可能的切分点 k,寻找最小值
for (int k = i + 1; k < j; k++) {
// 计算在 k 处切分的代价:左边代价 + 右边代价 + 左右结果相乘的代价
int t = LookupChain(i, k) + LookupChain(k+1, j) + p[i-1]*p[k]*p[j];

// 如果找到更小的代价,更新最小值 u 和切分点 s[i][j]
if (t < u) {
u = t;
s[i][j] = k;
}
}

// 5. 将计算结果填入备忘录,方便下次直接使用
m[i][j] = u;
return u;
}

最长公共子序列

子序列:通过删除原序列中的零个或多个元素(不改变剩余元素的相对顺序)得到的序列。

递推关系

c[i][j]={0if i=0 or j=0c[i1][j1]+1if i,j>0 and xi=yjmax{c[i1][j],c[i][j1]}if i,j>0 and xiyjc[i][j] = \begin{cases} 0 & \text{if } i=0 \text{ or } j=0 \\ c[i-1][j-1] + 1 & \text{if } i,j>0 \text{ and } x_i = y_j \\ \max\{c[i-1][j], c[i][j-1]\} & \text{if } i,j>0 \text{ and } x_i \neq y_j \end{cases}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* * m, n: 字符串 x 和 y 的长度
* x, y: 待比较的两个字符串(下标通常从 1 开始)
* c: 存储 LCS 长度的二维数组
* b: 存储路径标记的二维数组(用于后续构造序列)
*/
void LCSLength(int m, int n, char *x, char *y, int **c, int **b)
{
int i, j;

// 初始化边界:第一列和第一行长度设为 0
for (i = 1; i <= m; i++) c[i][0] = 0;
for (i = 1; i <= n; i++) c[0][i] = 0;

// 自底向上计算每一个子问题的最优解
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (x[i] == y[j]) {
// 如果当前字符相等,LCS 长度加 1
c[i][j] = c[i-1][j-1] + 1;
b[i][j] = 1; // 标记 1:表示来自左上方(匹配成功)
}
else if (c[i-1][j] >= c[i][j-1]) {
// 如果上方格子的值更大,继承上方的值
c[i][j] = c[i-1][j];
b[i][j] = 2; // 标记 2:表示来自上方
}
else {
// 否则继承左方格子的值
c[i][j] = c[i][j-1];
b[i][j] = 3; // 标记 3:表示来自左方
}
}
}
}
/* * i, j: 当前扫描到的字符串位置
* x: 字符串 x
* b: 存储路径标记的二维数组
*/
void LCS(int i, int j, char *x, int **b)
{
// 递归终止条件:到达数组边界
if (i == 0 || j == 0) return;

if (b[i][j] == 1) {
// 标记为 1,说明 x[i] 是 LCS 的一部分
LCS(i-1, j-1, x, b); // 先递归前面的部分
cout << x[i]; // 然后打印当前匹配的字符
}
else if (b[i][j] == 2) {
// 标记为 2,向上回溯
LCS(i-1, j, x, b);
}
else {
// 标记为 3,向左回溯
LCS(i, j-1, x, b);
}
}

时间复杂度O(m×n)O(m \times n)
空间复杂度O(m×n)O(m \times n)

改进一:空间常数级优化(省去 b 表)

  • 空间复杂度仍为 O(mn)O(mn),但消耗减半

改进二:空间复杂度量级优化(滚动数组)

  • 空间复杂度降至 O(min(m,n))O(\min(m, n))

最大子段和

分治

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* 使用分治法递归计算最大子段和
* @param a 数组
* @param left 子数组的左边界
* @param right 子数组的右边界
* @return int left 到 right 之间的最大子段和
*/
int MaxSubSum(int *a, int left, int right)
{
int sum = 0;

// 基本情况(Base Case):如果只有一个元素
if (left == right) {
sum = a[left] > 0 ? a[left] : 0;
}
else {
// --- 1. 分解 (Divide) ---
// 找到中点
int center = (left + right) / 2;

// --- 2. 治理 (Conquer) ---
// (a) 递归求“左半段”的最大和
int leftsum = MaxSubSum(a, left, center);
// (b) 递归求“右半段”的最大和
int rightsum = MaxSubSum(a, center + 1, right);

// --- 3. 合并 (Combine) ---
// (c) 求“跨越中点”的最大和

// (c.1) 计算包含中点的、向左延伸的最大和 (s1)
int s1 = 0;
int lefts = 0;
for (int i = center; i >= left; i--) {
lefts += a[i];
if (lefts > s1)
s1 = lefts;
}

// (c.2) 计算包含中点+1的、向右延伸的最大和 (s2)
int s2 = 0;
int rights = 0;
for (int i = center + 1; i <= right; i++) {
rights += a[i];
if (rights > s2)
s2 = rights;
}

// (c.3) 跨越中点的最大和即 s1 + s2
sum = s1 + s2;

// 比较三种情况,取最大值
if (sum < leftsum)
sum = leftsum;
if (sum < rightsum)
sum = rightsum;
}

// 返回最终的最大值
return sum;
}

/**
* @brief 最大子段和的分治算法 - 入口函数
* @param n 数组元素个数
* @param a 数组
* @return int 整个数组的最大子段和
*/
int MaxSum(int n, int *a) {
// 调用递归函数,从 1 到 n (假设数组是 1-based 索引)
return MaxSubSum(a, 1, n);
}

动态规划(Kadane 算法)

dp[i]dp[i] 是以第 ii 个元素结尾的最大子段和。

转移方程
对于每一个元素 a[i]a[i],我们面临两个选择:

  1. 如果之前的子段和 dp[i1]dp[i-1] 是正数,那么 a[i]a[i] 加上它会变得更大。
  2. 如果 dp[i1]dp[i-1] 是负数,加上它反而会变小,不如直接从 a[i]a[i] 开始。

dp[i]=max(a[i],dp[i1]+a[i])dp[i] = \max(a[i], dp[i-1] + a[i])

1
2
3
4
5
6
7
8
9
int MaxSum(int n, int a[]) {
int sum = 0, b = 0;
for (int i = 1; i <= n; i++) {
if (b > 0) b += a[i]; // 之前的贡献是正的,接上
else b = a[i]; // 之前的贡献是负的,丢弃并从当前位开始
if (b > sum) sum = b; // 记录全局出现过的最大值
}
return sum;
}

时间复杂度: O(n)O(n)
空间复杂度:O(1)O(1)

推广

最大子矩阵和

最大子段和在二维的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 核心思路:将二维矩阵的“行”进行纵向压缩,转化成一维数组。
* 只要固定了【起始行 i】和【结束行 j】,这两行之间的所有数据就可以“压扁”成一行。
*/
int MaxSum2(int m, int n, int **a)
{
int sum = 0; // 整个矩阵中,你能找到的那个“最肥”矩形的总和
int *b = new int [n + 1]; // 这是一个“压扁器”,用来存 i 到 j 行合并后的结果

// 第一层循环:确定子矩阵的【顶边】
for (int i = 1; i <= m; i++) {

// 每当顶边下移一行,我们要把“压扁器”清零,重新开始累加
for(int k = 1; k <= n; k++)
b[k] = 0;

// 第二层循环:确定子矩阵的【底边】
// 随着 j 的增加,子矩阵的高度在不断增加
for (int j = i; j <= m; j++) {

/**
* 【关键步骤:纵向压扁】
* 不管 i 到 j 之间有多少行,我们把每一列的数字都加起来,存进 b[k]。
* 此时 b[k] 就像是一个“厚度为 j-i+1”的一维数组。
* 比如:b[1] 就是第 i 行到第 j 行中,第一列所有数字的总和。
*/
for(int k = 1; k <= n; ++k)
b[k] += a[j][k]; // 在旧的累加基础上,叠加上新出现的第 j 行

/**
* 【核心转化:降维打击】
* 既然上下边界 (i 和 j) 已经固定了,我们现在只需要找【左右边界】。
* 找左右边界的问题,就变成了对数组 b 进行“最大子段和”计算。
*/
int max = MaxSum(n, b);

// 如果在这个特定的【上下范围】内找到了更棒的矩形,就记录下来
if (max > sum)
sum = max;
}
}

delete[] b; // 任务完成,销毁临时“压扁器”
return sum; // 返回全场最高分
}

时间复杂度:O(m2n)O(m^2n)

最大m子段和

最大子段和 问题是最大 m 子段和 问题在 m=1 时的特例
最大 m 子段和 不仅要决定当前元素是否开启新子段,还要记录当前已经开启了多少个子段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/*
* 问题:从长度为 n 的数组 a 中,选取 m 个互不相交的连续子段,使得这 m 个子段的总和最大。
* b[i][j] 的定义:前 j 个元素中形成 i 个子段,且第 i 个子段【必须包含】第 j 个元素 a[j] 时的最大和。
*/
int MaxSum(int m, int n, int *a)
{
// --- 1. 边界情况处理 ---
// 如果数组总长度不够分 m 段,或者要求的段数不合法,直接返回 0
if (n < m || m < 1) return 0;

// --- 2. 初始化 DP 表 ---
// b[i][j] 记录状态。i 范围 [0, m], j 范围 [0, n]
int **b = new int *[m + 1];
for (int i = 0; i <= m; i++) {
b[i] = new int[n + 1];
b[i][0] = 0; // 当没有元素时,和为 0
}

// --- 3. 动态规划主体逻辑 ---
for (int i = 1; i <= m; i++) {
// 外层循环:当前正在处理第 i 个子段

for (int j = i; j <= n - m + i; j++) {
// 中层循环:当前考虑数组的前 j 个元素
// 提示:j 的上限设为 n-m+i 是因为要给后面还没分的段留出足够的元素位置

if (j > i) {
//方案 A:让 a[j] 紧跟在 a[j-1] 后面,合并到当前的第 i 段。
//逻辑:此时的和就是“包含 a[j-1] 的第 i 段最大和” + a[j]。
b[i][j] = b[i][j - 1] + a[j];

//方案 B:让 a[j] 独立作为第 i 段的开头。
//逻辑:这意味着我们要从前面(1 到 j-1 范围内)找出一个已经分好 i-1 段的最优结果。
//这个 k 循环就是在寻找:前 k 个元素分出 i-1 段的最大和。
for (int k = i - 1; k < j; k++) {
// 如果“之前的 i-1 段最优和 + a[j]”比当前的方案 A 更好,就更新它
// 注意:这里 k < j,如果 k < j-1,中间的元素就被“跳过”了
if (b[i][j] < b[i - 1][k] + a[j]) {
b[i][j] = b[i - 1][k] + a[j];
}
}
}
else {
//特殊情况:j == i
//当元素个数正好等于段数时,每个元素必须独自成为一段,没得选。
b[i][j] = b[i - 1][j - 1] + a[j];
}
}
}

// --- 4. 统计最终结果 ---
/*
* 因为 b[m][j] 要求第 j 个元素必须包含在第 m 段内,
* 但最优解的第 m 段可能在数组的任何位置结束(比如数组末尾几个是负数,我们会跳过它们)。
* 所以我们需要遍历 b[m][m] 到 b[m][n],找出其中的最大值。
*/
int sum = 0;
for (int j = m; j <= n; j++) {
if (sum < b[m][j]) {
sum = b[m][j];
}
}

return sum;
}

时间复杂度:O(mn2)O(mn^2)
空间复杂度:O(mn)O(mn)

优化算法:引入一个辅助数组 c 来记录上一轮(i1i-1 个子段)的最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/*
* 优化版:最大 m 子段和问题
*/
int MaxSum(int m, int n, int *a)
{
// 合法性校验:如果元素个数少于子段数,返回 0
if (n < m || m < 1) return 0;

// b[j] 相当于原先的 b[i][j],表示前 j 个元素分 i 段(且包含 a[j])的最大值
// 使用一维数组实现空间优化
int *b = new int[n + 1]; b[0] = 0;

// c[j] 记录上一轮(即 i-1 段)在 1 到 j 范围内的最大子段和
int *c = new int[n + 1];

for (int i = 1; i <= m; i++) {
// 初始情况:当前 i 段的前 i 个元素(j=i)
// 每个元素必须独自成段,所以和就是前一个状态加 a[i]
b[i] = (i == 1) ? a[i] : b[i - 1] + a[i];

int max = b[i]; // 用于记录当前第 i 行计算过程中的最大值

for (int j = i + 1; j <= n - m + i; j++) {
/*
* 优化点:不再使用 k 循环。
* b[j-1] + a[j] 代表:a[j] 拼在包含 a[j-1] 的第 i 段后面(方案 A)
* c[j-1] + a[j] 代表:a[j] 作为第 i 段开头,前面是第 i-1 段的最优解(方案 B)
* c[j-1] 已经预存了上一行(i-1 段)在前 j-1 个位置里的最大和。
*/
b[j] = (b[j - 1] > c[j - 1] ? b[j - 1] : c[j - 1]) + a[j];

// 在计算当前行的同时,把“前一位置”算出的最大值存入 c,供下一轮(i+1 段)使用
c[j - 1] = max;

if (max < b[j]) max = b[j]; // 更新当前行的最大值
}

// 记录当前行最后一个有效位置的最大值
c[n - m + i] = max;
}

// --- 统计结果 ---
int sum = 0;
for (int j = m; j <= n; j++) {
if (sum < b[j]) sum = b[j];
}

// 释放内存
delete[] b;
delete[] c;

return sum;
}

最优三角形剖分

跟矩阵连乘类似
给定一个凸多边形 P={v0,v1,,vn1}P = \{v_0, v_1, \dots, v_{n-1}\},通过不相交的弦(对角线)将其划分成若干个三角形。寻找一种剖分方式,使得所有三角形的权值之和最小。

最优子结构性质
原问题的最优解 = 子问题1的最优解 + 子问题2的最优解 + 当前三角形的权值。

递推方程
t[i][j]t[i][j] 为子多边形 {vi1,vi,,vj}\{v_{i-1}, v_i, \dots, v_j\} 的最优三角剖分权值之和(其中 1i<jn11 \le i < j \le n-1

t[i][j]={0i=jminik<j{t[i][k]+t[k+1][j]+w(vi1vkvj)}i<jt[i][j] = \begin{cases} 0 & i = j \\ \min_{i \le k < j} \{t[i][k] + t[k+1][j] + w(v_{i-1} v_k v_j)\} & i < j \end{cases}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* @brief 计算凸多边形的最优三角剖分(最小权值和)
* @param n 多边形的边数 (顶点为 v0, v1, ..., vn)
* @param t (传出参数) 存储 t[i][j] (子问题 {vi-1...vj} 的最优解) 的表
* @param s (传出参数) 存储 s[i][j] (最优分割点 k) 的表
* @note w(i, j, k) 是一个假定的函数,用于计算三角形 {vi, vj, vk} 的权值
*/
void MinWeightTriangulation(int n, Type **t, int **s)
{
// 1. 初始化:t[i][i] = 0 (基本情况,只有一条边 {vi-1, vi})
for (int i = 1; i <= n; i++) t[i][i] = 0;

// 2. 按“链长” r 循环 (r+1 是子多边形的顶点数)
for (int r = 2; r <= n; r++) {
// 3. 循环子问题的起始点 i
for (int i = 1; i <= n - r + 1; i++) {
// 4. 计算子问题的结束点 j
int j = i + r - 1;

// 5. 初始化:先用 k=i 作为初始“最优解”
// 成本 = t[i][i] + t[i+1][j] + w(vi-1, vi, vj)
// (因为 t[i][i] == 0)
t[i][j] = t[i+1][j] + w(i-1, i, j);
s[i][j] = i; // 暂时记录 k=i 为最优

// 6. 尝试所有其他可能的分割点 k (从 i+1 到 j-1)
// (注释 /* j = i+r-1 */ 是对循环条件的提醒)
for (int k = i+1; k < j; k++) {

// 计算使用 k 分割时的总权值
int u = t[i][k] + t[k+1][j] + w(i-1, k, j);

// 7. 如果找到了更优的(权值更小)的 k
if (u < t[i][j]) {
t[i][j] = u; // 更新最小权值
s[i][j] = k; // 更新最优分割点
}
}
}
}
}

时间复杂度O(n3)O(n^3)。三层循环:跨度、起点、中间切割点。
空间复杂度O(n2)O(n^2)。需要一个二维数组存储 t[i][j]t[i][j]

多边形游戏

矩阵连乘/最优三角形剖分的进阶版,引入负值

给定一个有 nn 个顶点的多边形。每个顶点有一个整数值,每条边有一个运算符(+*)。
目标:寻找一种删除边的方案以及后续的合并顺序,使得最终剩下的数值最大

状态定义
v1,v2,,vnv_1, v_2, \dots, v_n 为顶点值,op1,op2,,opnop_1, op_2, \dots, op_n 为边上的运算符。

  • m[i][j][0]m[i][j][0]:从顶点 ii 开始,长度为 jj 的顺时针链合并后的最小值
  • m[i][j][1]m[i][j][1]:从顶点 ii 开始,长度为 jj 的顺时针链合并后的最大值

对于合并位置 kk(将链分为左右两部分):

  • 如果是加法 (+):
    • Max=Maxleft+MaxrightMax = Max_{left} + Max_{right}
    • Min=Minleft+MinrightMin = Min_{left} + Min_{right}
  • 如果是乘法 (*):
    • a=Minleft×Minrighta = Min_{left} \times Min_{right}
    • b=Minleft×Maxrightb = Min_{left} \times Max_{right}
    • c=Maxleft×Minrightc = Max_{left} \times Min_{right}
    • d=Maxleft×Maxrightd = Max_{left} \times Max_{right}
    • Max=max(a,b,c,d)Max = \max(a, b, c, d)
    • Min=min(a,b,c,d)Min = \min(a, b, c, d)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 功能:计算多边形合并后的最大值
* 参数 n: 多边形的顶点数/边数
* 返回值: 所有可能合并路径中的最大分值
*/
int PolyMax(int n)
{
int minf, maxf; // 存储 MinMax 函数返回的当前分割下的最小和最大结果

// j 代表子段的长度(从长度为 2 的子段开始计算,直到长度为 n)
for (int j = 2; j <= n; j++) {

// i 代表子段的起始位置(从第 1 个顶点开始)
for (int i = 1; i <= n; i++) {

// s 代表分割点,将长度为 j 的子段分成长度为 s 和 j-s 的两部分
for (int s = 1; s < j; s++) {

// 调用辅助函数 MinMax,计算在当前分割点 s 下,
// 两个子段合并后能产生的最小值(minf)和最大值(maxf)
MinMax(n, i, s, j, minf, maxf);

// m[i][j][0]:存储从顶点 i 开始,长度为 j 的段的最小值
if (m[i][j][0] > minf)
m[i][j][0] = minf;

// m[i][j][1]:存储从顶点 i 开始,长度为 j 的段的最大值
if (m[i][j][1] < maxf)
m[i][j][1] = maxf;
}
}
}

// --- 最终结果提取 ---
// 因为是多边形,任何一条边都可能被最先删除,所以我们要遍历所有可能的起点
// temp 记录全局最大值,初始化为从顶点 1 开始、长度为 n 的最大值
int temp = m[1][n][1];

// 遍历从其他顶点 (2 到 n) 开始、长度为 n 的合并结果
for (int i = 2; i <= n; i++) {
if (temp < m[i][n][1]) {
temp = m[i][n][1];
}
}

// 返回多边形合并后能得到的最大得分
return temp;
}

时间复杂度O(n4)O(n^4),通过将数组倍增为 2n2n 处理环形问题,可以将复杂度优化到 O(n3)O(n^3)
空间复杂度O(n2)O(n^2)。需要存储每个区间的最大和最小值。

图像压缩

状态定义:设 s[i]s[i] 为前 ii 个像素 {p1,,pi}\{p_1, \dots, p_i\} 的最优分段所需的最小存储位数。

元数据:11位
像素数据:l[i]×b[i]l[i] \times b[i]

  • l[i]l[i]:该段的像素个数。
  • b[i]b[i]:该段中最大像素值所需的位数,b[i]=log(max(pk)+1)b[i] = \lceil \log(\max(p_k) + 1) \rceil

递推公式
为了计算 s[i]s[i],我们需要考虑最后一段的长度 kk1kmin(i,256)1 \le k \le \min(i, 256))。

s[i]=min1kmin(i,256){s[ik]+k×bmax(ik+1,i)}+11s[i] = \min_{1 \le k \le \min(i, 256)} \{s[i-k] + k \times bmax(i-k+1, i)\} + 11

其中 bmax(j,i)bmax(j, i) 是第 jj 到第 ii 个像素中最大值所需的位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
* 图像压缩动态规划算法
* n: 像素点的个数
* p: 像素点灰度值数组
* s: s[i] 存储前 i 个像素点所需的最少总比特数
* l: l[i] 第 i 个像素点所在最优分段的长度
* b: b[i] 第 i 个像素点灰度值所占的最小位数
*/
void Compress(int n, int p[], int s[], int l[], int b[])
{
// Lmax: 一段像素序列的最大长度
// header: 每一段所需的固定额外开销
int Lmax = 256, header = 11;

s[0] = 0; // 初始状态:0个像素占用0位

for (int i = 1; i <= n; i++) {
// 1. 计算当前像素点 p[i] 自身所需的比特位
b[i] = length(p[i]);

// 2. 状态初始化:假设当前第 i 个像素自成一段
int bmax = b[i]; // 当前段中的最大位宽
s[i] = s[i-1] + bmax; // 前 i-1 个的最优值 + 当前像素的存储位
l[i] = 1; // 初始记录段长为 1

// 3. 尝试将第 i 个像素与前面的像素合并,寻找更优的段划分
// 遍历长度 j,尝试将 [i-j+1, ..., i] 这 j 个像素归为一段
for (int j = 2; j <= i && j <= Lmax; j++) {
// 保持 bmax 为当前探测段 [i-j+1, i] 中的最大位宽
if (bmax < b[i-j+1]) {
bmax = b[i-j+1];
}

// 比较“当前最优解”与“将最后 j 个像素合并为一段”的开销
// 计算公式:前 i-j 个像素的最优值 + 当前段长度 j * 当前段最大位宽 bmax
if (s[i] > s[i-j] + j * bmax) {
s[i] = s[i-j] + j * bmax;
l[i] = j; // 更新第 i 个像素所在段的最优长度
}
}

// 4. 最后加上当前这一段的头部开销(11位)
s[i] += header;
}
}

时间复杂度:O(n)O(n),内循环的次数最大固定为 256,为常数时间。
空间复杂度:O(n)O(n)

电路布线

最长递增子序列的另一种表现形式

状态定义
C(i,j)C(i, j) 表示:在上端接线柱取前 ii{1,2,,i}\{1, 2, \dots, i\},下端接线柱取前 jj{1,2,,j}\{1, 2, \dots, j\} 的范围内,能够选出的最大不相交连线集的数量。

递归方程
(1)当 i=1i = 1 时(基础情况):

C(1,j)={0j<π(1)1jπ(1)C(1, j) = \begin{cases} 0 & j < \pi(1) \\ 1 & j \ge \pi(1) \end{cases}

(2)当 i>1i > 1 时:

  • 情况 A:如果 j<π(i)j < \pi(i)
    当前下端的范围 jj 根本够不到 π(i)\pi(i),所以第 ii 条线无法连上。

    C(i,j)=C(i1,j)C(i, j) = C(i-1, j)

  • 情况 B:如果 jπ(i)j \ge \pi(i)
    • 两个选择,取其中的最大值:
      1. 不选ii 条线:结果为 C(i1,j)C(i-1, j)
      2. 选中ii 条线:那么第 ii 条线占用了一个名额(+1+1),为了不相交,前面的 i1i-1 条线上端只能在 i1i-1 范围,下端只能在 π(i)1\pi(i)-1 范围。结果为 C(i1,π(i)1)+1C(i-1, \pi(i)-1) + 1C(i,j)=max{C(i1,j),C(i1,π(i)1)+1}C(i, j) = \max\{C(i-1, j), C(i-1, \pi(i)-1) + 1\}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
* 计算电路布线最大不相交子集的大小
* @param C 数组 C[i] 表示第 i 条连线连接到另一侧的终端编号
* @param n 连线的总数
* @param size size[i][j] 表示前 i 条连线中,终端编号小于等于 j 的最大不相交子集大小
*/
void MNS(int C[], int n, int **size)
{
// 如果终端 j 小于第 1 条连线的终点 C[1],则无法包含该连线
for (int j = 0; j < C[1]; j++)
size[1][j] = 0;

// 如果终端 j 大于等于 C[1],则可以包含第 1 条连线,大小为 1
for (int j = C[1]; j <= n; j++)
size[1][j] = 1;

// --- 填充 DP 表 (从第 2 条连线到第 n-1 条) ---
for (int i = 2; i < n; i++) {
// 情况 A: 终端 j 小于当前连线的终点 C[i]
// 第 i 条连线无法加入,最优解等于前 i-1 条连线在终端 j 时的解
for (int j = 0; j < C[i]; j++)
size[i][j] = size[i - 1][j];

// 情况 B: 终端 j 大于等于当前连线的终点 C[i]
// 决策:取“不包含第 i 条连线”与“包含第 i 条连线”中的较大值
// 包含第 i 条连线时,贡献为 1 + size[i-1][C[i]-1]
for (int j = C[i]; j <= n; j++)
size[i][j] = max(size[i - 1][j], size[i - 1][C[i] - 1] + 1);
}

// --- 处理最后一条连线 (i = n) ---
size[n][n] = max(size[n - 1][n], size[n - 1][C[n] - 1] + 1);
}

时间复杂度: O(n2)O(n^2),因为包含两层嵌套循环。
空间复杂度: O(n2)O(n^2),需要一个二维数组存储所有子问题的解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*
* 回溯构造最优解的具体连线
* @param C 数组 C[i] 连线映射关系
* @param size 计算好的 DP 表
* @param n 连线总数
* @param Net 用于存储结果的数组(选中的连线编号)
* @param m 引用参数,返回选中的连线总数
*/
void Traceback(int C[], int **size, int n, int Net[], int &m)
{
int j = n; // 从最大终端编号开始回溯
m = 0; // 初始化计数器

// 从最后一条连线 i = n 往前遍历到第二条连线
for (int i = n; i > 1; i--) {
// 如果 size[i][j] 不等于 size[i-1][j],
// 说明在计算过程中选择了第 i 条连线
if (size[i][j] != size[i - 1][j]) {
Net[m++] = i; // 记录选中的连线编号
j = C[i] - 1; // 更新终端约束,下一条线必须在 C[i] 之前
}
}

// 处理第一条连线的特殊情况
if (j >= C[1])
Net[m++] = 1;
}

时间复杂度: O(n)O(n)

流水作业调度

场景:有 nn 个作业要在两台机器 M1M_1M2M_2 组成的流水线上完成加工。必须先在 M1M_1 上加工(耗时 aia_i),然后在 M2M_2 上加工(耗时 bib_i)。
目标:确定这 nn 个作业的最优加工顺序,使得从第一个作业在 M1M_1 开始,到最后一个作业在 M2M_2 结束的总时间 最短。

动态规划(复杂度太高)

Johnson 法则
如果作业 iijj 满足以下不等式,则作业 ii 应该排在作业 jj 之前:

min{bi,aj}min{bj,ai}\min\{b_i, a_j\} \geq \min\{b_j, a_i\}

  • 分组:将所有作业分为两组:
    • N1={iai<bi}N_1 = \{i \mid a_i < b_i\}:在第一台机器上加工更快的作业。
    • N2={iaibi}N_2 = \{i \mid a_i \geq b_i\}:在第二台机器上加工更快(或相等)的作业。
  • 排序
    • N1N_1 中的作业按 aia_i非递减序(从小到大)排序。
    • N2N_2 中的作业按 bib_i非递增序(从大到小)排序。
  • 合并:将排好序的 N1N_1 序列接上排好序的 N2N_2 序列,构成的总序列即为最优调度方案。

时间复杂度:主要耗费在排序上,为 O(nlogn)O(n \log n)
空间复杂度:需要存储作业组,为 O(n)O(n)

0-1背包

m(i,j)m(i, j) 是背包容量为 jj,可选物品为 i,i+1,,ni, i+1, \dots, n 时的最大价值:

  • 当背包容量不足以装下物品 ii 时 (j<wij < w_i):m(i,j)=m(i+1,j)m(i, j) = m(i+1, j)
  • 当背包容量可以装下物品 ii 时 (jwij \geq w_i):取最大值。m(i,j)=max{m(i+1,j),m(i+1,jwi)+vi}m(i, j) = \max\{m(i+1, j), \,\, m(i+1, j-w_i) + v_i\}

递推公式

m(i,j)={max{m(i+1,j),m(i+1,jwi)+vi}jwim(i+1,j)0j<wim(i, j) = \begin{cases} \max\{m(i+1, j), m(i+1, j-w_i) + v_i\} & j \geq w_i \\ m(i+1, j) & 0 \leq j < w_i \end{cases}

递归边界

m(n,j)={vnjwn00j<wnm(n, j) = \begin{cases} v_n & j \geq w_n \\ 0 & 0 \leq j < w_n \end{cases}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
* 0-1 背包问题动态规划算法
* * @param v 物品价值数组
* @param w 物品重量数组
* @param c 背包总容量
* @param n 物品总数
* @param m m[i][j] 表示前 i 到 n 个物品在容量为 j 时的最大价值
*/
template<class Type>
void knapsack(Type v[], int w[], int c, int n, Type **m)
{
// --- 1. 处理最后一个物品 (物品 n) ---
// 计算容量 j 不足以装下物品 n 的边界情况
int jMax = min(w[n] - 1, c);

// 如果容量 j < w[n],则无法装入物品 n,价值为 0
for (int j = 0; j <= jMax; j++)
m[n][j] = 0;

// 如果容量 j >= w[n],则可以装入物品 n,价值为 v[n]
for (int j = w[n]; j <= c; j++)
m[n][j] = v[n];

// --- 2. 递归处理中间的物品 (从 n-1 到 2) ---
for (int i = n - 1; i > 1; i--)
{
// 情况 A:当前容量 j 小于第 i 个物品的重量,无法放入
jMax = min(w[i] - 1, c);
for (int j = 0; j <= jMax; j++)
m[i][j] = m[i+1][j]; // 继承后面物品的最优解

// 情况 B:当前容量 j 足以放入第 i 个物品
for (int j = w[i]; j <= c; j++)
// 决策:取【不放物品 i】和【放入物品 i】两者中的最大价值
// m[i+1][j] : 不放第 i 个物品
// m[i+1][j-w[i]] + v[i] : 放第 i 个物品,剩余空间去找之前的最优解
m[i][j] = max(m[i+1][j], m[i+1][j - w[i]] + v[i]);
}

// --- 3. 处理第一个物品 (物品 1) ---
// 这里只计算了容量为 c 时的情况(即最终答案)
m[1][c] = m[2][c];
if (c >= w[1])
m[1][c] = max(m[1][c], m[2][c - w[1]] + v[1]);
}

时间复杂度O(nc)O(nc)
空间复杂度O(nc)O(nc)

最优二叉搜索树

构造一棵二叉搜索树,使得搜索一个元素的期望代价达到最小。
期望代价等于每一层结点的“概率 ×\times (深度 +1+ 1)”的总和。

一棵树的代价 = 左子树代价 + 右子树代价 + 当前树所有节点深度加 1 带来的额外代价:

m[i,j]=minikj{m[i,k1]+m[k+1,j]}+w[i,j]m[i, j] = \min_{i \le k \le j} \{ m[i, k-1] + m[k+1, j] \} + w[i, j]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 最优二叉搜索树算法
* @param a[] 虚拟键(失败节点)的概率/频率 (q0, q1, ..., qn)
* @param b[] 关键字(成功节点)的概率/频率 (p1, p2, ..., pn)
* @param n 关键字的数量
* @param **m 存储子树最小搜索代价的二维数组 (cost)
* @param **s 存储子树根节点的二维数组 (root)
* @param **w 存储子树权重之和的二维数组 (weight)
*/
void OptimalBinarySearchTree(int a[], int b[], int n, int **m, int **s, int **w)
{
// 1. 初始化:处理只包含虚拟键(空树)的情况
// 当子树不包含任何关键字时,搜索代价为0,权重仅为当前的虚拟键概率
for (int i = 0; i <= n; i++) {
w[i + 1][i] = a[i];
m[i + 1][i] = 0;
}

// 2. 动态规划计算:r 表示当前处理的子树跨度(关键字的数量)
// 从跨度为 0 到 n-1 依次计算
for (int r = 0; r < n; r++) {
for (int i = 1; i <= n - r; i++) {
int j = i + r; // 子树的结束索引

// 计算权值和 w[i][j] = w[i][j-1] + p[j] + q[j]
// w[i][j] 代表该范围内所有节点被访问的概率总和
w[i][j] = w[i][j - 1] + a[j] + b[j];

// 初始化:先假设以第 i 个关键字为根
m[i][j] = m[i + 1][j];
s[i][j] = i;

// 3. 核心循环:尝试在这个范围 [i, j] 内寻找最优的根节点 k
// 使得左右子树代价之和最小
for (int k = i + 1; k <= j; k++) {
int t = m[i][k - 1] + m[k + 1][j];
if (t < m[i][j]) {
m[i][j] = t; // 更新最小代价
s[i][j] = k; // 记录产生最小代价的根节点
}
}

// 4. 加上当前子树的所有权值和
// 根据公式:e[i,j] = e[i, k-1] + e[k+1, j] + w(i, j)
m[i][j] += w[i][j];
}
}
}

贪心算法

  • 贪心选择性质
  • 最优子结构性质

一般需要先排序

活动安排

优化目标:数量最多
输入的活动必须已经按照结束时间 f[] 的非减序(从小到大)排列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class Type>
void GreedySelector(int n, Type s[], Type f[], bool A[])
{
// 步骤 1: 总是选择第一个活动(因为它结束最早)
A[1] = true;

// j 记录最近一次被选中的活动索引
int j = 1;

// 步骤 2: 从第二个活动开始遍历到第 n 个
for (int i = 2; i <= n; i++) {
// 步骤 3: 贪心选择策略
// 如果当前活动 i 的开始时间 (s[i]) 大于等于 上一个选中活动 j 的结束时间 (f[j])
if (s[i] >= f[j]) {
A[i] = true; // 选中该活动
j = i; // 更新 j 为当前活动,作为下一次比较的基准
}
else {
A[i] = false; // 产生冲突,不选中
}
}
}

时间复杂度O(n)O(n)
(如果算上排序的时间,则是 O(nlogn)O(n \log n))

普通背包

可以选择物品 i 的一部分,而不一定要全部装入背包,1≤i≤n。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// n: 物品数量
// M: 背包的总容量 (Maximum capacity)
// v[]: 物品价值数组 (Value)
// w[]: 物品重量数组 (Weight)
// x[]: 结果数组,x[i] 表示第 i 个物品拿走的比例 (0.0 到 1.0)
void Knapsack(int n, float M, float v[], float w[], float x[])
{
// 关键步骤 1: 排序
// 必须将物品按照“单位重量价值”(v[i]/w[i]) 从大到小排序
// 这样我们才能优先拿最值钱的物品
Sort(n, v, w);

int i;

// 初始化结果数组,默认所有物品都不拿 (0)
for (i = 1; i <= n; i++) x[i] = 0;

// c 用于记录背包当前的剩余容量 (current capacity)
float c = M;

// 关键步骤 2: 贪心循环
// 按性价比从高到低遍历物品
for (i = 1; i <= n; i++) {

// 如果当前物品 i 的重量大于背包剩余容量 c
// 说明这个物品装不下了,必须跳出循环,进行分割处理
if (w[i] > c) break;

// 如果能装下,就全拿走 (比例设为 1)
x[i] = 1;

// 背包剩余容量减去该物品的重量
c -= w[i];
}

// 关键步骤 3: 处理装不下的那个物品(分数处理)
// 如果循环是因为 break 跳出的(即 i <= n),说明第 i 个物品只能装下一部分
if (i <= n) {
// 拿走的比例 = 剩余容量 / 该物品总重量
x[i] = c / w[i];
}
}

O(nlogn)O(n\log n)

最优装载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template<class Type>
// x[]: 解向量,x[i]=1 表示选中,0 表示不选
// w[]: 物品重量数组
// c: 船的剩余载重量 (Capacity)
// n: 物品总数
void Loading(int x[], Type w[], Type c, int n)
{
// 创建一个索引数组 t,用于存储排序后的索引
// 这样可以不打乱原有的 w 数组顺序,方便最后在 x[] 中记录结果
int *t = new int [n+1];

// 【关键步骤】排序
// 这里是对重量 w 进行从小到大排序
// 注意:Sort 函数通过 t 返回排序后的下标顺序,而不是直接交换 w 的元素
Sort(w, t, n);

// 初始化解向量,默认都不选
for (int i = 1; i <= n; i++) x[i] = 0;

// 循环遍历,注意这里有两个终止条件:
// 1. 遍历完所有物品 (i <= n)
// 2. 当前最轻的物品重量 w[t[i]] 已经超过剩余载重 c (装不下了)
for (int i = 1; i <= n && w[t[i]] <= c; i++) {

// 选中由于排序后的第 i 个物品 (其实际索引是 t[i])
x[t[i]] = 1;

// 减少剩余载重量
c -= w[t[i]];
}

// 释放辅助数组内存(虽然图中没写 delete,但在 C++ 中应当释放)
// delete[] t;
}

主要计算量在于将集装箱依其重量从小到大排序,因此计算时间为O(nlogn)O(n\log n)

哈夫曼编码

给出现频率高的字符较短的编码,出现频率较低的字符以较长的编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 建立最小优先队列 (MinHeap)
// 这里的 Huffman<type> 是节点结构,包含权重和左右子树指针
MinHeap<Huffman<type>> Q(1);

// 使用数组 w 中的权值初始化堆
// n 个叶子节点
Q.Initialize(w, n, n);

// 用于临时存储取出的两个最小节点
Huffman<Type> x, y;
// z 用于存储合并后的新节点
Huffman<Type> z;

// 反复合并最小频率树
// n 个节点需要合并 n-1 次才能变成一棵树
for (int i = 1; i < n; i++) {

// 1. 从堆中取出当前权值最小的节点,存入 x
Q.DeleteMin(x);

// 2. 从堆中取出当前权值次小的节点,存入 y
Q.DeleteMin(y);

// 3. 合并:创建一个新节点 z
// z 的左孩子是 x,右孩子是 y
z.MakeTree(0, x.tree, y.tree);

// 4. 新节点的权值 = 两个子节点权值之和
x.weight += y.weight; // 这里复用了 x 的 weight 字段来存新权值
x.tree = z; // 更新 x 的树指针指向新生成的树 z

// 5. 将合并后的新节点重新插入堆中
Q.insert(x);
}
// 循环结束后,堆中只剩下一个节点,即完整的哈夫曼树的根

初始化优先队列需要 O(n)O(n) 计算时间,由于最小堆的 removeMin 和 put 运算均需O(logn)O(\log n)时间,n-1 次的合并总共需要 O(nlogn)O(n\log n) 计算时间

单源最短路径问题

要计算从源到所有其它各顶点的最短路长度,路的长度是指路上各边权之和。

Dijkstra 算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 单源最短路径 (Single Source Shortest Path - Dijkstra)
// n: 节点总数
// dist[]: 存储源点到各点的当前最短距离
// s[]: 布尔数组,s[i]=true 表示节点 i 的最短路径已确定 (Set of visited nodes)
// c[][]: 邻接矩阵,存储边权,c[u][v] 表示 u 到 v 的距离
// prev[]: 前驱数组,用于记录路径

// 初始化:源点 v 的距离设为 0,标记为已访问
dist[v] = 0;
s[v] = true;

// 外层循环:只需遍历 n-1 次
// 因为源点已确定,剩下 n-1 个节点需要逐个加入集合 S
for (int i = 1; i < n; i++) {

int temp = maxint; // maxint 代表无穷大
int u = v; // u 用于记录当前找到的“最近节点”

// 步骤 1: 贪心选择
// 在所有未被访问的节点 (!s[j]) 中,找到距离源点最近的那个节点
for (int j = 1; j <= n; j++) {
if ((!s[j]) && (dist[j] < temp)) {
u = j;
temp = dist[j];
}
}

// 找到后,将节点 u 标记为已访问 (加入集合 S)
s[u] = true;

// 步骤 2: 松弛操作 (Relaxation)
// 检查是否可以通过新加入的节点 u,让它的邻居 j 离源点更近
for (int j = 1; j <= n; j++) {
// 条件:j 未访问,且 u 到 j 之间有边 (距离小于无穷大)
if ((!s[j]) && (c[u][j] < maxint)) {

// 计算经过 u 到达 j 的新距离
Type newdist = dist[u] + c[u][j];

// 如果新距离比原来的 dist[j] 更短,则更新
if (newdist < dist[j]) {
dist[j] = newdist;
prev[j] = u; // 记录 j 的前驱节点是 u (方便后续回溯路径)
}
}
}
}

对于具有n个顶点和e条边的带权有向图,如果用带权邻接矩阵表示这个图
,时间复杂度是 O(n2)O(n^2),因为它有两层嵌套循环(外层 nn 次,内层查找最小值 nn 次)。

最小生成树问题

Prim 算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 核心循环:寻找 n-1 条边来构建最小生成树
for (int i = 1; i < n; i++) {
Type min = inf;
int j = 1;

// 步骤 1: 遍历节点,寻找当前距离生成树集合(S)最近的未访问节点
for(int k = 2; k <= n; k++) {
// 如果节点 k 到生成树的距离小于 min,且 k 不在集合 S 中
if((lowcost[k] < min) && (!s[k])) {
min = lowcost[k];
j = k; // 记录下当前权重最小的节点位置
}
}

// 打印当前选中的边:节点 j 以及它在生成树中的连接点 closet[j]
cout << j << ' ' << closest[j] << endl;

// 步骤 2: 将选定的节点 j 加入集合 S
s[j] = true;

// 步骤 3: 更新剩余未访问节点到生成树的距离
for (int k = 2; k <= n; k++) {
// 如果新加入的节点 j 到 k 的距离比 k 原有的 lowcost 更小
if((c[j][k] < lowcost[k]) && (!s[k])) {
lowcost[k] = c[j][k]; // 更新最小权重
closest[k] = j; // 更新 k 的连接点为 j
}
}
}

时间复杂度:O(n2)O(n^2)

Krustal 算法

使用了最小堆来选取边,并结合并查集来检测环路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 循环条件:堆中还有边(e) 且 已选入生成树的边数小于 n-1
while( e && k < n-1){
EdgeNode<int>x;

// 1. 贪心策略:从最小堆 H 中取出当前权值最小的边
H.DeleteMin(x);
e--; // 剩余边数减一

// 2. 查找根节点:利用并查集查找两个端点 u, v 所属的连通分量
int a = U.Find(x.u);
int b = U.Find(x.v);

// 3. 判环:如果 a != b,说明两点不在同一个集合,连接它们不会形成环
if(a != b) {
t[k++] = x; // 将该边加入结果数组 t
U.Union(a,b); // 4. 合并:将U中两个联通分支a和b连接起来,视为已连通
}
}

当图的边数为e时,Kruskal算法所需的计算时间是O(eloge)O(e\log e)。当 e=Ω(n2)e = \Omega(n^2) 时(稠密图),Kruskal 算法比 Prim 算法差,但当 e=o(n2)e = o(n^2) (稀疏图)时,Kruskal 算法却比 Prim 算法好得多。

多机调度问题

最长处理时间作业优先
当n≤m时,只要将机器 i 的 [0,ti][0,t_i] 时间区间分配给作业即可
当n>m时,首先将n个作业依其所需的处理时间从大到小 排序,依此顺序将作业分配给空闲的处理机
算法所需时间为 O(nlogn)O(n\log n)

回溯法

两大核心要素

  • 约束函数:在结点处剪去不满足约束条件的子树。
  • 限界函数:在结点处剪去得不到最优解的子树(用于最优化问题)。
  • 两者合称为剪枝函数

画树

【题目示例】:已知背包容量 C=7C=7,有3件物品,重量 w={3,5,2}w=\{3, 5, 2\},价值 v={9,10,7}v=\{9, 10, 7\}。请画出回溯法搜索的最优解空间树,并标注剪枝情况。

节点内标注 [cw,cp][cw, cp]cwcw 为当前总重,cpcp 为当前总价值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
第0层 (根)                     [0, 0]
/ \
x1=1 / \ x1=0
/ \
第1层 (物1) [3, 9] [0, 0]
/ \ / \
x2=1 / x2=0 \ x2=1/ \ x2=0
/ \ / \
第2层 (物2) 【X】 [3, 9] [5, 10] 【X】
cw=8>7 / \ / \ Bound=7<bestV
(约束剪枝)x3=1/ x3=0/ /x3=1 \x3=0 (限界剪枝)
/ \ / \
第3层 (物3) [5, 16] [3, 9][7, 17] [5, 10]
(叶1) (叶2) (叶3) (叶4)
bestV=16 bestV=17

递归回溯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// t: 当前正在处理的层级(或第 t 个分量),比如第 t 行皇后,或第 t 个物品
void backtrack (int t)
{
// 【递归出口】如果当前层级 t 超过了最大深度 n,说明找到了一个完整的解
if (t > n)
output(x); // 输出当前的解向量 x
else
// 【遍历解空间】遍历当前层 t 所有可能的取值
// f(n,t) 是当前层取值的下界,g(n,t) 是上界
for (int i = f(n,t); i <= g(n,t); i++) {

x[t] = h(i); // 【尝试】将当前值赋给解向量的第 t 个位置

// 【剪枝操作】
// constraint(t): 约束函数,检查当前解是否合法(例如:是否冲突)
// bound(t): 限界函数,用于最优化问题,如果当前部分解已经不如已知最优解,则剪枝
if (constraint(t) && bound(t))
backtrack(t+1); // 如果满足条件,递归进入下一层 (t+1)

// 【回溯】虽然代码中没有显式的回溯语句,
// 但当 backtrack(t+1) 执行完毕返回,或者 if 条件不满足时,
// 循环会进入下一次迭代 (i++),覆盖 x[t] 的值,这本质上就是回溯。
}
}

迭代回溯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void iterativeBacktrack ()
{
// t: 当前层级,通常初始化为 1
int t = 1;

// 当 t > 0 时循环,如果 t 减到 0 说明所有可能性都搜索完毕,算法结束
while (t > 0) {

// 检查当前层 t 是否还有未遍历的子节点(取值范围是否有效)
if (f(n,t) <= g(n,t))

// 遍历当前层的所有可能取值
for (int i = f(n,t); i <= g(n,t); i++) {

x[t] = h(i); // 【尝试】设置当前层的值

// 【剪枝判断】满足约束条件 且 满足限界条件
if (constraint(t) && bound(t)) {

// 如果当前已经是完整解(到达叶子节点或满足解的定义)
if (solution(t))
output(x); // 输出解
else
t++; // 否则,【前进】到下一层继续搜索

// 注意:在某些伪代码实现中,这里隐含了 break 或状态保存逻辑,
// 实际编写迭代回溯时,通常需要手动记录每一层遍历到了哪个 i,
// 避免 t++ 后回来时重新从头遍历。
}
}
else
t--; // 【回溯】如果当前层没有可行解,或者遍历完所有可能,退回到上一层
}
}

子集树问题

装载问题

装入集装箱数量最多贪心算法
要求第一艘船装载量最大装满两艘船回溯法

目标函数:

maxi=1nwixi\max \sum_{i=1}^{n} w_i x_i

约束条件:

i=1nwixic1\sum_{i=1}^{n} w_i x_i \leq c_1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void backtrack(int i)
{
// 【递归出口】到达叶子结点(i > n),说明所有集装箱都做完了决策
if (i > n) {
// 如果当前重量 cw 超过了历史最优 bestw,则更新最优解
if (cw > bestw) {
bestw = cw;
bestx = x;
}
return;
}

// 在进入左右分支前,先处理剩余重量 r
//因为我们要对第 i 个物品做决策了,所以它不再属于“剩余未处理”的物品
r -= w[i];

// ---------------------------------------------------------
// 【左子树】:尝试“装入”第 i 个集装箱 (x[i] = 1)
// ---------------------------------------------------------
// 约束函数 (Constraint Function):只有在装入后不超过载重量 c 时才进入
if (cw + w[i] <= c) {
x[i] = 1; // 标记选中
cw += w[i]; // 更新当前载重量

backtrack(i + 1); // 递归进入下一层

cw -= w[i]; // 【回溯】:恢复现场,把装进去的拿出来,以便尝试右子树
}

// ---------------------------------------------------------
// 【右子树】:尝试“不装入”第 i 个集装箱 (x[i] = 0)
// ---------------------------------------------------------
// 限界函数 (Bound Function):这是算法效率的关键!
// 逻辑:如果 (当前重量 cw + 剩余所有能装的重量 r) > 当前已知最优解 bestw
// 我们才有必要继续搜索。否则,就算后面全装进去也赢不了现在的 bestw,直接剪枝。
if (cw + r > bestw) {
x[i] = 0; // 标记不选中

backtrack(i + 1); // 递归进入下一层
}

// 在函数返回前,将第 i 个物品的重量加回 r
// 因为回退到上一层(i-1)时,第 i 个物品又变回“剩余未处理”的状态了
r += w[i];
}

符号三角形问题

给定第一行的长度 nn,问有多少种不同的第一行排列方式,使得整个三角形中 + 的数量和 - 的数量完全相等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void Triangle::Backtrack(int t) // t: 当前正在确定第一行的第 t 个符号
{
// 【剪枝 (Pruning) 核心逻辑】
// 在进入下一层递归前,先检查目前已生成的子三角形是否已经“没救了”。
// t*(t-1)/2 是由前 t-1 个符号生成的子三角形的总元素个数。
// count 是目前已生成的 "-" (1) 的总数。
// (t*(t-1)/2 - count) 则是目前已生成的 "+" (0) 的总数。
// half 是整个 n 层三角形所有元素总数的一半 ( n*(n+1)/4 )。
// 如果 "+" 或 "-" 的数量已经超过了总数的一半,说明无论后面怎么填,都不可能达到平衡,直接回溯。
if ((count > half) || ((t * (t - 1) / 2 - count) > half)) return;

// 【递归出口】
// 如果 t > n,说明第一行的 n 个符号都已经确定,且通过了上面的剪枝检查,
// 这意味着我们找到了一个合法的符号三角形,解的数量 (sum) 加 1。
if (t > n)
sum++;
else
// 【尝试两种可能】
// 0 代表 "+", 1 代表 "-"。遍历这两种选择。
for (int i = 0; i < 2; i++) {

p[1][t] = i; // 设置第一行第 t 个位置的符号
count += i; // 更新 "-" 的计数 (如果是 0 则不变,是 1 则加 1)

// 【推导依赖】
// 当第一行第 t 个符号确定后,它会立刻影响下面所有行对应的位置。
// 这个循环用于生成由第 t 个符号引起的“右侧边缘”的所有新符号。
for (int j = 2; j <= t; j++) {
// 异或运算 (XOR) 完美对应符号规则:
// 同号 (0^0=0, 1^1=0) -> "+" (0)
// 异号 (0^1=1, 1^0=1) -> "-" (1)
// 索引逻辑:利用上一行的两个相邻元素计算当前行的元素
p[j][t - j + 1] = p[j - 1][t - j + 1] ^ p[j - 1][t - j + 2];

count += p[j][t - j + 1]; // 将新生成的符号也计入总数
}

// 【递归进入下一层】
// 继续确定第一行的第 t+1 个符号
Backtrack(t + 1);

// 【回溯 (Backtracking) - 恢复现场】
// 递归返回后,需要撤销之前的操作,以便尝试 i 的下一个取值。
// 1. 减去循环中生成的衍生符号的计数
for (int j = 2; j <= t; j++)
count -= p[j][t - j + 1];

// 2. 减去第一行第 t 个符号的计数
count -= i;
}
}

时间复杂度O(n2n)O(n \cdot 2^n)

0-1 背包问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template<class Typew, class Typep>
Typep Knap<Typew, Typep>::Bound(int i)
{
// cleft: 剩余的背包容量
Typew cleft = c - cw;

// b: 当前价值 cp + 剩余可能的最大价值
Typep b = cp;

// 【贪心策略】
// 假设物品已经按照“单位重量价值” (p[i]/w[i]) 从大到小排序
// 循环装入完整的物品,只要剩余空间还够
while (i <= n && w[i] <= cleft) {
cleft -= w[i];
b += p[i];
i++;
}

// 【装满背包】
// 如果背包还没满,但下一个物品装不下了,
// 为了求上界,我们假设可以装入该物品的一部分(分数背包),填满剩余空间
if (i <= n)
b += p[i] / w[i] * cleft;

return b; // 返回计算出的上界值
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
template<class Typew, class Typep>
void Knap<Typew, Typep>::Backtrack(int i)
{
// 【递归出口】
// 如果 i > n,说明已经考虑完了所有物品,到达叶子节点
if (i > n) {
bestp = cp; // 更新全局最优价值
return;
}

// ---------------------------------------------
// 【左子树】:尝试“装入”第 i 个物品
// ---------------------------------------------
// 可行性约束:只有在装入后不超过背包容量 c 时,才进入左分支
if (cw + w[i] <= c) {
cw += w[i]; // 更新当前重量
cp += p[i]; // 更新当前价值

Backtrack(i + 1); // 递归处理下一个物品

cw -= w[i]; // 【回溯】恢复现场
cp -= p[i]; // 【回溯】恢复现场
}

// ---------------------------------------------
// 【右子树】:尝试“不装入”第 i 个物品
// ---------------------------------------------
// 限界函数 (Bound):只有当(当前价值 + 剩余物品的理论最大价值)超过当前已知最优解 bestp 时,
// 才有必要搜索右子树。否则剪枝。
if (Bound(i + 1) > bestp) {
Backtrack(i + 1); // 递归处理下一个物品
}
}

最大团问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 假设全局变量或类成员变量已定义:
// int x[N]; // 当前解:x[i]=1表示选入,0表示不选
// int bestx[N]; // 当前最优解
// int cn; // 当前团的顶点数 (current number)
// int bestn; // 当前最大团的顶点数 (best number)
// int n; // 图的顶点总数
// int a[N][N]; // 图的邻接矩阵,a[i][j]=1表示有边

void Clique::Backtrack(int i) {
// i 表示当前正在考察第 i 个顶点 (从 1 到 n)

// 【1. 递归出口】
if (i > n) {
// 到达叶节点,说明找到了一个完整的可行解
for (int j = 1; j <= n; j++) {
bestx[j] = x[j]; // 记录最优解的具体选法
}
bestn = cn; // 更新最大团的顶点数量
return;
}

// 【2. 检查左子树:尝试将顶点 i 加入团】
int OK = 1; // 标记是否可以加入
for (int j = 1; j < i; j++) {
// 检查顶点 i 与前面已选入团的顶点(x[j]==1)是否都有边
if (x[j] == 1 && a[i][j] == 0) {
OK = 0;
break;
}
}

// 如果满足可行性约束(与现有团成员均相连),则进入左子树
if (OK) {
x[i] = 1; // 标记选中 i
cn++; // 当前团大小 +1
Backtrack(i + 1); // 递归处理下一个点
x[i] = 0; // 回溯:恢复状态
cn--; // 回溯:当前团大小 -1
}

// 【3. 检查右子树:不将顶点 i 加入团】
// 上界函数剪枝:
// cn: 当前已选人数
// n - i: 剩余可选的最大人数
// 只有当 (当前 + 剩余) > 目前已知最优解 (bestn) 时,才有必要继续搜索
if (cn + n - i > bestn) {
x[i] = 0; // 标记不选中 i
Backtrack(i + 1); // 递归处理下一个点
}
}

图的 m 着色问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 假设在一个类 Color 中,或者作为全局变量定义
// n: 顶点数, m: 颜色数, sum: 找到的方案总数
// a[N][N]: 图的邻接矩阵,a[i][j]=1表示顶点i和j相邻
// x[N]: 当前解向量,x[i] 表示顶点 i 的颜色

void Color::Backtrack(int t) {
// t 代表当前正在尝试为第 t 个顶点着色

// 【1. 递归出口】
if (t > n) {
// 如果 t > n,说明所有 n 个顶点都已经成功着色
sum++; // 方案数加 1

// 打印当前找到的方案
for (int i = 1; i <= n; i++) {
cout << x[i] << " ";
}
cout << endl;
return;
}
else {
// 【2. 尝试每一种颜色】
// 遍历所有可能的颜色,从 1 到 m
for (int i = 1; i <= m; i++) {
x[t] = i; // 尝试给第 t 个顶点涂上颜色 i

// 【3. 剪枝 / 可行性检查】
// 检查涂上颜色 i 后是否与已经着色的邻接点冲突
if (Ok(t)) {
Backtrack(t + 1); // 如果可行,继续递归处理第 t+1 个顶点
}
// x[t] = 0; // (可选) 回溯时恢复状态,虽然直接覆盖也不影响
}
}
}

// 检查颜色可行性的函数
bool Color::Ok(int k) {
// 检查顶点 k 的颜色 x[k] 是否与其邻接顶点的颜色冲突

// 遍历所有顶点 j (PPT写法是从1遍历到n)
// 实际上通常只需要遍历到 k-1 (已着色部分),但遍历到 n 逻辑上也没错,
// 因为未着色的 x[j] 通常初始化为 0,不会与 x[k] (1~m) 冲突。
for (int j = 1; j <= n; j++) {
// 判断条件:
// 1. a[k][j] == 1 : 顶点 k 和顶点 j 之间有边(相邻)
// 2. x[j] == x[k] : 顶点 k 和顶点 j 颜色相同
if ((a[k][j] == 1) && (x[j] == x[k])) {
return false; // 发现冲突,返回不可行
}
}
return true; // 与所有邻接点都不冲突,返回可行
}

排列树问题

批处理作业调度问题

使最后一台机器完成所有任务的时间最短 \rightarrow 动态规划(流水作业调度/Johnson法则)

使所有作业的完成时间之和最小 \rightarrow 回溯法(批处理作业调度)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void Flowshop::Backtrack(int i)
{
// 【递归出口】到达叶子节点,说明所有作业都已安排
if (i > n) {
for (int j = 1; j <= n; j++)
bestx[j] = x[j]; // 保存当前最优解
bestf = f; // 更新最优时间
}
else
// 【排列树遍历】尝试将剩余的作业(j从i开始)放到当前位置 i
for (int j = i; j <= n; j++) {

f1 += M[x[j]][1]; // 加上当前作业在机器1的时间

// 【关键逻辑】计算机器2完成时间:
// 取 max(上一作业在机器2完成时间, 当前作业在机器1完成时间) + 当前作业在机器2的时间
f2[i] = ((f2[i-1] > f1) ? f2[i-1] : f1) + M[x[j]][2];

f += f2[i]; // 累加完成时间

// 【剪枝】只有当前时间和 f 小于已知最优 bestf 时才继续搜索
if (f < bestf) {
Swap(x[i], x[j]); // 交换元素,确定第 i 个位置的作业
Backtrack(i+1); // 递归进入下一层
Swap(x[i], x[j]); // 回溯:恢复交换前的状态
}

// 【回溯】恢复时间状态,以便尝试下一个作业
f1 -= M[x[j]][1];
f -= f2[i];
}
}

n 后问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 检查将皇后放在第 k 行 (位置 x[k]) 是否合法
bool Queen::Place(int k)
{
// 遍历之前已经放置好的所有皇后 (从第 1 行到第 k-1 行)
for (int j = 1; j < k; j++) {

// 判断是否冲突:
// 1. abs(k - j) == abs(x[j] - x[k])
// -> 检查对角线冲突。
// -> 原理:如果两个皇后在同一斜线上,它们行距(k-j)等于列距(x[j]-x[k])。
// 2. x[j] == x[k]
// -> 检查列冲突。
// -> 原理:如果它们列号相同,说明在同一列。

if ((abs(k - j) == abs(x[j] - x[k])) || (x[j] == x[k]))
return false; // 只要满足任意一个冲突条件,返回 false
}
return true; // 与之前所有皇后都不冲突,位置合法
}

// 核心回溯函数
// t: 当前正在尝试放置皇后的行号 (深度)
void Queen::Backtrack(int t)
{
// 【递归出口】
// 如果 t > n,说明前 n 行都已经成功放置了皇后,找到了一个完整解
if (t > n) {
sum++; // 解决方案总数 +1 (sum 应该是类成员变量)
// output(x); // 通常这里还可以打印具体的解向量
}
else {
// 【尝试分支】
// 遍历当前第 t 行的所有可能列 (从 1 到 n)
for (int i = 1; i <= n; i++) {

x[t] = i; // 【尝试】将第 t 行的皇后放在第 i 列

// 【剪枝 / 约束检查】
// 调用 Place 函数检查当前放置是否安全
if (Place(t)) {
Backtrack(t + 1); // 如果安全,递归处理下一行 (t+1)
}

// 【回溯】
// 注意:这里不需要显式的 "撤销" 代码(如 x[t]=0),
// 因为下一次循环会直接用新的 i 值覆盖 x[t],
// 或者函数返回后,上一层的 x[t] 也会被重新赋值。
}
}
}

旅行售货员问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
template<class Type>
void Traveling<Type>::Backtrack(int i) {
// i: 当前递归层级,表示正在决定第 i 个访问的城市
// n: 城市总数
// x[]: 当前路径数组,x[1...n] 存储城市的编号
// bestx[]: 存储当前找到的最优路径
// cc: current cost, 当前已走的路径长度
// bestc: best cost, 当前找到的最短路径长度(初始化为无穷大/NoEdge)
// NoEdge: 表示两个城市之间没有直达边

// 【1. 递归出口:到达叶子节点】
// 当 i == n 时,表示已经排列好了前 n-1 个城市,只剩下最后一个城市 x[n]
if (i == n) {
// 检查连通性:
// 1. 倒数第二个城市 x[n-1] 到最后一个城市 x[n] 是否有路?
// 2. 最后一个城市 x[n] 到 起点 x[1] 是否有路(回路)?
if (a[x[n-1]][x[n]] != NoEdge && a[x[n]][1] != NoEdge &&
// 检查成本:如果这条完整回路的总长度比当前最优解 bestc 更短
(cc + a[x[n-1]][x[n]] + a[x[n]][1] < bestc || bestc == NoEdge)) {

// 更新最优解
for (int j = 1; j <= n; j++) {
bestx[j] = x[j];
}
// 更新最优成本
bestc = cc + a[x[n-1]][x[n]] + a[x[n]][1];
}
}
else {
// 【2. 递归搜索:排列树的生成】
// 尝试从第 i 个位置开始,与后面的每个位置 j (i到n) 进行交换
for (int j = i; j <= n; j++) {

// 【3. 剪枝策略 / 可行性检查】
// 检查:
// 1. 上一个城市 x[i-1] 到 潜在的下一个城市 x[j] 是否有路?
// 2. 剪枝:当前成本 cc + 新边成本 是否仍然小于 bestc?
// 如果已经大于等于 bestc,就没必要往下走了(Bound)
if (a[x[i-1]][x[j]] != NoEdge &&
(cc + a[x[i-1]][x[j]] < bestc || bestc == NoEdge)) {

// 交换位置,将 x[j] 放入当前第 i 个位置
Swap(x[i], x[j]);

// 累加成本
cc += a[x[i-1]][x[i]];

// 递归进入下一层
Backtrack(i + 1);

// 回溯:恢复成本
cc -= a[x[i-1]][x[i]];

// 回溯:恢复交换(还原数组状态)
Swap(x[i], x[j]);
}
}
}
}

具体的更新操作需要 O(n)O(n) 的时间(复制数组),总的节点数约为 n!n!,因此总的时间复杂性为 O(n!)O(n!)

圆排列问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void Circle::Backtrack(int t)
{
if (t > n) Compute();
else
for (int j = t; j <= n; j++) {
Swap(r[t], r[j]);
float centerx = Center(t);

// 下面这一行在图中被遮挡了一部分,是剪枝逻辑
// 意思是:如果当前圆排完后的“预估最小长度”已经超过当前最优解 min,就不再递归
if (centerx + r[t] + r[1] < min) {
x[t] = centerx;
Backtrack(t + 1);
}

Swap(r[t], r[j]);
}
}

float Circle::Center(int t)
{ // 计算当前所选择圆的圆心横坐标
float temp = 0;
for (int j = 1; j < t; j++) {
// 计算与前面第 j 个圆相切时的坐标
// 2.0 * sqrt(r[j] * r[t]) 是两个相切圆圆心的水平距离
float valuex = x[j] + 2.0 * sqrt(r[j] * r[t]);
if (valuex > temp) temp = valuex;
}
return temp;
}

void Circle::Compute() {
float low = 0, high = 0;
// 遍历所有圆,找出最左边的边界(low)和最右边的边界(high)
// 注意:圆心坐标 x[i] 加上或减去半径 r[i] 才是边缘
for (int i = 1; i <= n; i++) {
if (x[i] - r[i] < low) low = x[i] - r[i];
if (x[i] + r[i] > high) high = x[i] + r[i];
}

// 如果当前排列的长度比已知的最小长度还小,则更新
if (high - low < minLen) {
minLen = high - low;
}
}

连续邮资问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
void Stamp::Backtrack(int i, int r) {
// i: 当前正在搜索第 i 张邮票的面值
// r: 传入时表示上一层计算出的边界,但在函数内会更新为当前的最大连续邮资区间上限

// ============================================================
// 1. 动态规划更新状态 (利用上一层确定的 x[i-1] 更新 y 数组)
// ============================================================
// y[j] 表示贴出面值为 j 的邮资所需的最少邮票数
// 我们需要更新加入 x[i-1] 面值后,能凑出的新邮资及其最少张数

// 优化循环边界:j 只需要遍历到前 i-2 种邮票能凑出的最大值即可
// x[i-2] * (m-1) 是一个估算的搜索上界
for (int j = 0; j <= x[i - 2] * (m - 1); j++) {
if (y[j] < m) { // 如果凑出 j 的张数还没满 m 张,说明还可以利用剩余张数贴 x[i-1]
// k 代表使用 k 张新加入的 x[i-1] 邮票
for (int k = 1; k <= m - y[j]; k++) {
// 状态转移方程:
// 如果用 y[j] 张旧邮票 + k 张新邮票能凑出更优解,则更新
if (y[j] + k < y[j + x[i - 1] * k]) {
y[j + x[i - 1] * k] = y[j] + k;
}
}
}
}

// ============================================================
// 2. 计算当前的最大连续邮资 r
// ============================================================
// 检查 y 数组,找到第一个断点(即无法凑出或所需张数 > m 的位置)
// maxint 通常代表无穷大(初始值),表示该面值尚未被凑出
while (y[r] < maxint) {
r++;
}
// 循环结束后,r 指向的是第一个“无法凑出”的邮资值
// 所以当前最大连续邮资区间是 [1, r-1]

// ============================================================
// 3. 叶子节点处理 (找到一个完整解)
// ============================================================
if (i > n) {
// 已经选够了 n 种邮票,判断是否是目前为止的最优解
if (r - 1 > maxvalue) {
maxvalue = r - 1; // 更新全局最大连续邮资
for (int j = 1; j <= n; j++) {
bestx[j] = x[j]; // 记录对应的邮票面值组合
}
}
return; // 回溯
}

// ============================================================
// 4. 递归搜索下一层 (选择 x[i])
// ============================================================

// 备份当前状态:因为回溯回来时需要恢复 y 数组到这一层的状态
int *z = new int[maxl + 1];
for (int k = 1; k <= maxl; k++) {
z[k] = y[k];
}

// 确定 x[i] 的取值范围:
// 下界:必须比上一张面值大,即 x[i-1] + 1
// 上界:不能超过当前最大连续值 + 1 (在这里即 r),否则无法凑出 r 这个空缺值
for (int j = x[i - 1] + 1; j <= r; j++) {
x[i] = j; // 尝试将第 i 张邮票面值设为 j
Backtrack(i + 1, r); // 递归进入下一层

// 恢复状态 (回溯):将 y 数组恢复为进入下一层之前的样子
// 以便尝试 j 的下一个取值
for (int k = 1; k <= maxl; k++) {
y[k] = z[k];
}
}

delete[] z; // 释放备份数组内存
}

时间复杂度O(n)O(n)