算法分析与设计
第一章 算法概述
五种算法渐进界
1. O (Big-O) —— 渐近上界
设 f(n) 和 g(n) 是定义在非负整数上的正函数。 如果存在正常数 c 和 n0,使得对于所有的 n ≥ n0,都有:
0 ≤ f(n) ≤ c ⋅ g(n)
则称 f(n) = O(g(n))。
2. Ω (Big-Omega) —— 渐近下界
设 f(n) 和 g(n) 是定义在非负整数上的正函数。 如果存在正的常数 c 和 n0,使得对于所有的 n ≥ n0,都有:
0 ≤ c ⋅ g(n) ≤ f(n)
则称 f(n) = Ω(g(n))。
3. Θ (Big-Theta) —— 渐近紧确界
设 f(n) 和 g(n) 是定义在非负整数上的正函数。 如果存在正的常数 c1, c2 和 n0,使得对于所有的 n ≥ n0,都有:
0 ≤ c1 ⋅ g(n) ≤ f(n) ≤ c2 ⋅ g(n)
则称 f(n) = Θ(g(n))。
4. o (Little-o) —— 非紧确上界
设 f(n) 和 g(n) 是定义在非负整数上的正函数。 如果对于任意正的常数 c > 0,都存在 n0,使得对于所有的 n ≥ n0,都有:
0 ≤ f(n) < c ⋅ g(n)
则称 f(n) = o(g(n))。 (等价定义:$\lim_{n \to \infty} \frac{f(n)}{g(n)} = 0$),通常证明时使用极限
5. ω (Little-omega) —— 非紧确下界
设 f(n) 和 g(n) 是定义在非负整数上的正函数。 如果对于任意正的常数 c > 0,都存在 n0,使得对于所有的 n ≥ n0,都有:
0 ≤ c ⋅ g(n) < f(n)
则称 f(n) = ω(g(n))。 (等价定义:$\lim_{n \to \infty} \frac{f(n)}{g(n)} = \infty$),通常证明时使用极限
解递归方程
在算法分析中,递归方程主要分为两类:
- 线性递推式 (如斐波那契数列):an = c1an − 1 + c2an − 2 + …
- 分治递归式 (如归并排序):T(n) = aT(n/b) + f(n)
一、 常系数线性齐次递推方程
形式: an + c1an − 1 + c2an − 2 + … + ckan − k = 0
解法步骤(特征方程法):
写出特征方程: 将 an 替换为 xk, an − 1 替换为 xk − 1,以此类推。 例如对于二阶方程 an − c1an − 1 − c2an − 2 = 0,特征方程为:
x2 − c1x − c2 = 0
求特征根: 解出方程的根 r1, r2, …。
写出通解: 根据根的情况,通解形式如下(以二阶为例):
根的情况 通解公式 (A,B 为待定常数) 两个不相等的实根(r1 ≠ r2) an = A ⋅ r1n + B ⋅ r2n 两个相等的实根(r1 = r2 = r) an = (A + B ⋅ n) ⋅ rn 求特解(确定常数): 代入初始条件(如 a0, a1 的值),解方程组求出 A 和 B。
二、 常系数线性非齐次递推方程
形式: an + c1an − 1 + … = f(n)
(等式右边不是0,而是一个关于 n 的函数)
解法步骤:
通解 = 齐次通解 + 特异解 (an = an(h) + an(p))
- 求齐次通解 an(h): 令右边 f(n) = 0,按照第一部分的方法求出通解。
- 求特异解 an(p): 根据 f(n) 的形式,“猜测”特解的形式并代入原方程求解系数。
f(n) 的形式 猜测特解 an(p) 的形式 注意事项 多项式 Pk(n) (如 n2 + 1) Qk(n) (同阶多项式,如 An2 + Bn + C) 如果 1 是特征根,需乘n 或 n2 指数函数 βn (如 3n) C ⋅ βn 如果 β 是特征根,需乘 n (重根乘 n2 ) - 组合并求解: 将 an(h) + an(p) 组合,代入初始条件求解常数。
三、 分治递归方程 (Divide and Conquer)
形式: T(n) = aT(n/b) + f(n)
(常用于分析归并排序、二分搜索等算法的时间复杂度)
方法1:主定理 (Master Theorem) —— 最常用
比较 nlogba 与 f(n) 的增长速率:
- 情况 1 (根重): 如果 f(n) = O(nlogba − ϵ) (即 f(n) 增长慢),则: T(n) = Θ(nlogba)
- 情况 2 (平衡): 如果 f(n) = Θ(nlogba) (即同阶),则: T(n) = Θ(nlogbalg n)
- 情况 3 (叶重): 如果 f(n) = Ω(nlogba + ϵ) (即 f(n) 增长快),且满足正则条件,则: T(n) = Θ(f(n))
方法2:递归树法 (Recursion Tree)
当主定理不适用或需要画图分析时使用。
- 画出树状结构,每层写出消耗的代价。
- 对每一层的代价求和。
- 对所有层求总和。
方法3:代换法 (Substitution Method)
- 猜测解的形式(如猜测 T(n) = O(nlg n))。
- 使用数学归纳法证明猜测是正确的。
示例:解 an = 5an − 1 − 6an − 2, a0 = 1, a1 = 0
- 特征方程: x2 − 5x + 6 = 0
- 求根: (x − 2)(x − 3) = 0 ⟹ r1 = 2, r2 = 3
- 通解: an = A ⋅ 2n + B ⋅ 3n
- 代入初始条件:
- n = 0 : A + B = 1
- n = 1 : 2A + 3B = 0
- 解方程组: 由(1)得 A = 1 − B,代入(2): 2(1 − B) + 3B = 0 ⟹ 2 − 2B + 3B = 0 ⟹ B = −2 则 A = 1 − (−2) = 3
- 最终解: an = 3 ⋅ 2n − 2 ⋅ 3n
第二章 递归与分治
二分搜索
1 | int binarysearch(int a[ ] ,int x,int n){ |
每执行一次算法的 while 循环,待搜索数组的大小减少一半。 因此,在最坏情况下,while 循环被执行了 O(log n) 次。循环体内运算需要 O(1) 时间,因此整个算法在最坏情况下的计算时间复杂性为 O(log n) 。
合并排序
实现代码
1 | // 辅助函数:合并两个有序子数组 |
非递归版
1 |
|
时间复杂度
**T**(**n**)**=**2**T**(**n**/**2**)+**O**(**n**)
使用主定理分析:
a = 2, b = 2, f(n) = n
计算关键指数:
nlogba = nlog22 = n1 = n
由于 f(n) = Θ(nlogba),符合主定理第二种情况,因此:
T(n) = O(nlog n)
快速排序
1 | // 核心分区函数:返回基准值的最终索引 |
快速排序(Quick Sort)的时间复杂度分析完全取决于划分(Partition)的质量,也就是基准值(Pivot)选得好不好。
以下是关于最好情况和最坏情况的详细分析:
1. 最好情况 (Best Case)
条件: 每次划分选取的基准值(Pivot)恰好都是当前子数组的中位数。 这意味着每次都能将数组均匀地一分为二,左边一半,右边一半。
分析推导:
递归结构:这时的递归树是一棵标准的、平衡的二叉树。
递推公式:
$$ T(n) = 2T\left(\frac{n}{2}\right) + O(n) $$
- 2T(n/2):解决左右两个规模减半的子问题。
- O(n):本层划分(Partition)所需的遍历比较时间。
求解: 根据主定理(Master Theorem)或递归树法:
- 递归深度为 log2n。
- 每一层的工作量总和是 O(n)。
- 总时间 = 层数 × 每层工作量。
结论:
T(n) = O(nlog n)
2. 最坏情况 (Worst Case)
条件: 每次划分选取的基准值都是当前子数组中的最大值或最小值。 这种情况通常发生在:
- 数组已经有序(或逆序)。
- 并且我们固定选取第一个元素(或最后一个)作为基准值。
分析推导:
划分极度不平衡: 比如数组
[1, 2, 3, 4, 5],选1做基准。- 左边子数组长度:0
- 右边子数组长度:n − 1
- 这导致递归树退化成了一个链表(斜树)。
递推公式:
T(n) = T(n − 1) + T(0) + O(n) = T(n − 1) + O(n)
- T(n − 1):只解决了一个规模减少 1 的子问题。
- O(n):本层划分的时间。
求解(等差数列求和):
$$ \begin{aligned} T(n) &= n + (n-1) + (n-2) + \dots + 1 \\ &= \frac{n(n+1)}{2} \\ &\approx \frac{n^2}{2} \end{aligned} $$
结论:
T(n) = O(n2)
3. 总结对比图表
| 情况 | 发生条件 | 递归树形态 | 递推公式 | 时间复杂度 |
|---|---|---|---|---|
| 最好情况 | 每次选到中位数 | 平衡二叉树 | T(n) = 2T(n/2) + n | O(nlog n) |
| 最坏情况 | 每次选到极值 (有序数组) | 线性链表 (斜树) | T(n) = T(n − 1) + n | O(n2) |
| 平均情况 | 随机选取基准 | 近似平衡 | N/A | O(nlog n) |
4. 如何避免最坏情况?
这就回到了我们之前讨论的代码优化:
- 随机化 (Randomization):在 Partition
之前,随机选一个数与
left交换。这样即使原数组有序,也能打破其顺序,将期望复杂度稳定在 O(nlog n)。 - 三数取中 (Median of Three):取头、中、尾三个数的中位数作为基准,也能有效避免有序数组带来的 O(n2) 问题。
线性时间选择
找出第k小的元素
随机化选择
1 | int RandomizedSelect(int a[], int l, int r, int k) { |
线性时间选择:
1 | int Select(int a[], int l, int r, int k) { |
时间复杂度分析
证明 x 至少比 $\frac{3n}{10} - 1$ 个元素大
- 第一步:分组与中位数 我们将 n 个元素分成 m = ⌈n/5⌉ 组。在这些组的中位数里,递归找到它们的中位数 x。根据中位数的定义,至少有 m/2 个组的中位数是 ≤ x 的。
- 第二步:计算“确定比 x 小”的元素 除去 x 所在的组,至少有 m/2 − 1 个组,它们的中位数都小于 x。 在每一个这样的组中,由于组内 5 个元素已经排过序,中位数小于 x,那么该组内比中位数小的 2 个元素 也一定小于 x。 加上中位数本身,每组至少有 3 个元素 小于 x。
- 第三步:求和
- 这些组贡献了:$3 \times (\frac{m}{2} - 1)$ 个元素。
- 将 m ≈ n/5 代入:$3 \times (\frac{n/5}{2} - 1) = \frac{3n}{10} - 3$。
- 再加上 x 所在的组里,至少还有 2 个元素 比 x 小。
- 最终结果 :$(\frac{3n}{10} - 3) + 2 = \frac{3n}{10} - 1$。
BFPTR 的时间复杂度递推公式为:
$$ T(n) \le T(\frac{n}{5}) + T(\frac{7n}{10}) + O(n) $$
- T(n/5) 是找中位数的中位数。
- T(7n/10) 是最坏情况下,剩下需要处理的元素(即 $n - \frac{3n}{10} = \frac{7n}{10}$)。
- 由于 $\frac{1}{5} + \frac{7}{10} = \frac{9}{10} < 1$,根据主定理或递推分析,该算法可以保证在 O(n) 内完成。
教材中采用
$$ T(n) \le T(\frac{n}{5}) + T(\frac{3n}{4}) + O(n) $$
因为0.7约等于0.75,3/4更好算
在 n 较小时,递归寻找“中位数的中位数”带来的开销(如函数调用栈、数组切片等)会超过直接排序。
- 数学上的正值要求 :在证明 T(n) ≤ cn 时,我们需要保证 $\frac{3n}{10} - 6 > 0$(即 n > 20)。
- 工程经验值 :当 n 达到 75 左右时,$\frac{3n}{10}$ 带来的“理想基准值”优势才开始显著大于递归本身的损耗。
- 效率平衡 :对于小于 75 的数组,插入排序的常数项非常小,速度极快。强行递归反而会变慢。
动态规划
矩阵连乘
1.定义最优解
假设矩阵连乘积 AiAi + 1…Aj 的一个 最优计算次序 (即乘法次数最少的方案)在矩阵 Ak 和 Ak + 1 之间将矩阵链断开。
那么,这个最优方案的总代价可以表示为:
m[i][j] = m[i][k] + m[k + 1][j] + pi − 1pkpj
其中:
- m[i][k] 是左子链 Ai…Ak 的计算代价。
- m[k + 1][j] 是右子链 Ak + 1…Aj 的计算代价。
- pi − 1pkpj 是最后这两部分结果相乘的代价。
2. 核心证明:反证法
我们要证明: 如果原问题是最优的,那么它的子问题(左子链和右子链)也必须是最优的。
证明过程:
假设子问题不是最优的: 假设对于左子链 Ai…Ak,存在另一种计算次序,其代价 m′[i][k] 比我们当前方案中的 m[i][k] 还要小(即 m′[i][k] < m[i][k])。
进行“剪切与粘贴”: 我们把原方案中的左子链计算部分“剪掉”,换成这个更优的方案(“粘贴”进来)。
推导矛盾: 替换后的总代价 M 将变为:
M = m′[i][k] + m[k + 1][j] + pi − 1pkpj
由于 m′[i][k] < m[i][k],显然有:
M < m[i][j]
结论: 这意味着我们找到了一个比“最优解”代价更小的方案。这与我们最初假设“m[i][j] 是最优解”相 矛盾 。
因此,原问题的最优解所包含的子问题的解也必须是最优的。
1 | /** |
状态方程
$$ m[i, j] = \begin{cases} 0 & \text{if } i = j \\ \min_{i \leq k < j} \{ m[i, k] + m[k+1, j] + p_{i-1}p_k p_j \} & \text{if } i < j \end{cases} $$
最长公共子序列
最优子结构证明
假设有两个序列:
- X = {x1, x2, …, xm}
- Y = {y1, y2, …, yn}
- 令 Z = {z1, z2, …, zk} 是它们的 任意一个 LCS 。
我们将序列的“前缀”记为 Xi = {x1, …, xi}。
- 定理陈述:LCS 的最优子结构
LCS 的最优子结构性质可以分三种情况讨论:
- 情况 1:若 xm = yn 则 zk = xm = yn,且 Zk − 1 是 Xm − 1 和 Yn − 1 的一个 LCS。
- 情况 2:若 xm ≠ yn 且 zk ≠ xm 则 Z 是 Xm − 1 和 Y 的一个 LCS。
- 情况 3:若 xm ≠ yn 且 zk ≠ yn 则 Z 是 X 和 Yn − 1 的一个 LCS。
- 证明过程
情况 1 的证明(末尾相等):
- 已知 :xm = yn。
- 假设 :zk ≠ xm。那么我们可以在 Z 的末尾加上 xm,得到一个长度为 k + 1 的公共子序列。这与 Z 是长度为 k 的“最长”公共子序列相 矛盾 。所以必有 zk = xm = yn。
- 子问题证明 :现在我们要证 Zk − 1 是 Xm − 1 和 Yn − 1 的 LCS。
- 使用 反证法 :若存在一个更长的公共子序列 W,其长度大于 k − 1。
- 那么把 xm 加到 W 的末尾,就会得到一个长度大于 k 的 X 和 Y 的公共子序列。
- 这与 Z 的长度为 k 是最优解矛盾。
情况 2 和 3 的证明(末尾不等):
以情况 2为例(xm ≠ yn 且 zk ≠ xm):
- 分析 :既然 zk ≠ xm,说明 X 的最后一个元素 xm 并没有参与构成当前的 LCS。
- 结论 :因此,Z 必然也是 Xm − 1 和 Y 的一个公共子序列。
- 子问题证明 :若 Xm − 1 和 Y 存在一个比 Z 更长的公共子序列 W(长度 > k),那么 W 显然也是 X 和 Y 的公共子序列。
- 这同样与 Z 是 X 和 Y 的最优解(长度为 k)矛盾。
状态转移方程
$$ dp[i][j] = \begin{cases} 0 & \text{if } i=0 \text{ or } j=0 \\ dp[i-1][j-1] + 1 & \text{if } X[i] = Y[j] \\ \max(dp[i-1][j], dp[i][j-1]) & \text{if } X[i] \neq Y[j] \end{cases} $$
- 相等时 (X[i] = = Y[j]) :说明发现了一个公共字符。当前长度 = 左上角格子的长度 + 1。
- 不相等时 (X[i] ≠ Y[j]) :说明当前字符对不上。我们要继承之前的“遗产”,看“去掉X当前字符”和“去掉Y当前字符”哪个留下的子序列更长,取最大值。
1 | /** |
时间复杂度 : O(m × n)
- 双重循环填表耗时 m × n。
- 回溯过程最多走 m + n 步。
根据最长公共子序列求解最长递减子序列
将原来的数组降序排序,与原数组进行最长公共子序列算法可得。
sorted_arr=sorted(arr, reverse=True)
最大字段和
1.定理陈述
若 dp[i] 是以 ai 结尾的所有子段中和最大的那一个,则它必然包含以 ai − 1 结尾的最大子段和 dp[i − 1](前提是 dp[i − 1] > 0)。
2. 证明过程(反证法 / 剪切粘贴法)
第一步:定义结构
以 ai 结尾的子段只有两种可能:
- 单独一个元素 :即 [ai]。
- 与前面相连 :即 [astart, …, ai − 1, ai]。此时,dp[i] = (以 ai − 1 结尾的某个子段) + ai。
第二步:假设与矛盾推导
假设第 2 种情况是我们要的最优解(即 dp[i] 的和最大),但它所包含的子问题解——“以 ai − 1 结尾的子段”——不是最优的。
- 假设 :存在另一个以 ai − 1 结尾的子段,其和为 dp′[i − 1],且 dp′[i − 1] > dp[i − 1]。
- 剪切与粘贴 :我们将原方案中以 ai − 1 结尾的部分“剪掉”,换成这个更优的 dp′[i − 1]。
- 得到新解 :新的总和 M = dp′[i − 1] + ai。
- 推导矛盾 :由于 dp′[i − 1] > dp[i − 1],显然 M > dp[i − 1] + ai。这说明原来的 dp[i] 根本不是以 ai 结尾的最大子段和。
结论 :这与前提矛盾。因此,以 ai** 结尾的最优解,必然包含了以 ai − 1 结尾的最优解。**
3. 状态转移方程
基于以上最优子结构性质,我们可以得出最大子段和的递归式:
dp[i] = max (dp[i − 1] + ai, ai)
- 如果 dp[i − 1] > 0**** :加上前面的子段会对当前更有利,所以“拖家带口”一起走。
- 如果 dp[i − 1] ≤ 0**** :前面的子段是“负资产”,不如断舍离,从 ai 重新开始。
4. 最终答案的获取
注意,dp[i] 仅仅是以 i 结尾的最优解。整个数组的最大子段和应该是所有这些 dp[i] 中的最大值:
Result = max1 ≤ i ≤ n{dp[i]}
1 | int MaxSubSum(int n, int a[]) { |
时间复杂度:O(N)
0/1背包
证明(反证法):
假设我们已经找到了容量为 C 时的最优选法,其中包含了物品 i。
那么,如果我们将物品 i 从背包中拿走,剩下的物品对于剩余容量 C − wi 来说,也必须是最优的。
- 如果不是最优的: 意味着在容量 C − wi 下,存在另一种选法价值更高。
- 那么: 我们把这组更好的选法放进背包,再加上物品 i,就会得到一个比“最优解”总价值更高的方案。
- 结论: 这产生了矛盾,因此子问题的解也必须是最优的。
0/1 背包问题最优解的递归方程式
设:
- dp[i][j] 表示在前 i 个物品中进行选择,且背包容量为 j 时的最大总价值。
- Wi 表示第 i 个物品的重量(注意:若物品编号从1开始,则对应 Wi;若代码中数组从0开始,通常对应 Wi − 1)。
- Vi 表示第 i 个物品的价值。
递归方程式(状态转移方程)如下:
$$ dp[i][j] = \begin{cases} 0 & \text{if } i=0 \text{ or } j=0 \quad (\text{边界条件}) \\ dp[i-1][j] & \text{if } j < W_i \quad (\text{当前背包装不下第 } i \text{ 个物品}) \\ \max \{ dp[i-1][j], \quad dp[i-1][j - W_i] + V_i \} & \text{if } j \ge W_i \quad (\text{能装下,决策:选或不选}) \end{cases} $$
(2)算法伪代码描述
该算法分为两步:
- 填表 :计算最大价值。
- 回溯 :根据表格反推选了哪些物品。
1 | /** |
(3)算法时间复杂度分析
对上述算法进行分析:
- 填表过程 :
- 算法使用了两层嵌套循环。
- 外层循环遍历物品数量 i,从 1 到 n,执行 n 次。
- 内层循环遍历背包容量 j,从 1 到 C,执行 C 次。
- 循环体内部的操作(比较、加法、赋值)都是常数时间操作 O(1)。
- 因此,填表的时间复杂度为 O(n × C) 。
- 回溯过程 :
- 循环从 n 递减到 1,执行 n 次。
- 内部操作为常数时间。
- 这部分的时间复杂度为 O(n)。
总时间复杂度:
T(n) = O(n × C) + O(n) ≈ O(n × C)
注意:
这里的 C 是背包的容量数值,而不是输入的规模。如果 C 非常大(例如 C = 2n),该算法的效率会变得很低。因此,严谨地说,这是一个 伪多项式时间(Pseudo-polynomial time) 算法。
贪心算法
活动安排
算法伪代码及复杂度分析
核心思路:
为了安排尽可能多的活动,我们应该总是选择结束时间最早且与当前已选活动不冲突的活动。这样可以为剩下的时间段留出最大的空间,从而容纳更多的活动。
伪代码
1 | /** |
对该函数的复杂度分析如下:
- 循环次数 :代码中只有一个
for循环,循环变量 i 从 2 遍历到 n。这意味着循环体总共执行了 n − 1 次。 - 循环内部操作 :在循环体内,执行的是简单的比较
(
>=) 和赋值操作。这些都是常数时间操作,记为 O(1)。 - 总运算量 :(n − 1) × O(1)。
结论:
该函数本身的时间复杂度为 O(n)(线性时间复杂度)。
说明算法采用的贪心策略
该算法采用的贪心策略是: 最早结束时间优先 (Earliest Finish Time First) 。
解释:
在所有与当前已选活动相容(不冲突)的剩余活动中,总是选择结束时间最早的那个活动。
- 理由: 结束时间越早,剩余的可安排时间资源就越多,从而更有可能容纳后续的活动。这是一种“目光短浅”但在此问题中能导致全局最优的策略。
贪心选择性质的证明(以“替换法”证明)
这一步的目的是证明:全局最优解中,一定可以包含贪心选择出来的那个局部最优项。
- 设定前提 :设 E = {1, 2, …, n} 是按结束时间 fi 非减序排列的活动集合,其中活动 1 具有最早结束时间。
- 假设最优解 :设 A ⊆ E 是该问题的一个最优解,且 A 中的第一个活动是 k。
- 分类讨论 :
- 如果 k = 1**** :说明最优解 A 已经包含了贪心选择的活动 1,证明结束。
- 如果 k > 1**** :说明最优解没有选活动 1,而是选了 k。此时我们构造一个新的集合 B = (A − {k}) ∪ {1},即 用活动 1 替换掉活动 k**** 。
- 证明可行性 :因为活动 1 的结束时间 f1 ≤ fk(因为集合是按结束时间排序的),而 k 与 A 中后续活动是不冲突的,那么结束得更早的活动 1 自然也不会与后续活动冲突。
- 结论 :由于 B 中的活动数量和 A 一样多,且 B 也是相容的,所以 B 也是一个最优解。这证明了 总存在一个包含贪心选择(活动 1)的最优解 。
最优子结构性质(以“反证法”证明)
这一步的目的是证明:做出贪心选择后,原问题的最优解包含了子问题的最优解。
简化问题 :当我们选择了活动 1 后,剩下的可选活动集合变为 E′ = {i ∈ E : si ≥ f1}(即所有开始时间晚于活动 1 结束时间的活动)。 反证逻辑 :
- 设 A 是原问题 E 的最优解,则 A′ = A − {1} 应该是子问题 E′ 的最优解。
- 假设 A′** 不是 E′ 的最优解** :那么一定存在另一个解 B′,它包含的活动数量比 A′ 更多。
- 推导矛盾 :如果我们把活动 1 加回到 B′ 中,得到 B = B′ ∪ {1}。由于 B′ 活动更多,那么 B 的活动数量就会比原始最优解 A 还要多。
- 结论 :这与“A 是最优解”的前提矛盾。因此,每一步贪心选择所留下的子问题,其最优解与贪心选择合并后,一定是原问题的最优解。
单源最短路径
形式化描述
对图G(V, E), 源点v,从三方面描述问题
- 源点 v,图中顶点集合
V, 各顶点
u ∈ V
的全局最短路径及其长度 d(v, u)
- 算法迭代过程中保持不变
- 用于构造最短路径的当前顶点集合 Si,
- 不断增加,定义了不同规模的子问题
- 指标:相对于现有 Si,对各顶点
u ∈ V,dist[u]
- 随着 Si 的变化,dist[u]可能需要不断更新
问题/子问题描述: [Si, { < u, dist[u] > }], Si ⊆ V, u ∈ V
- 最简子问题: [{u}, { < u, 0 > }], Si只包含源点u
- 原问题: [V, { < u, dist[u] > }] = [V, { < u, d(u, v) > }], Si = V
贪心选择性质的证明 (图 4-6)
这一部分是为了证明:为什么每次从“未确定集合”中挑选 dist** 值最小的顶点 u,这个 dist[u] 就一定是源点到 u 的最短路径距离?**
证明思路:反证法
- 假设: 存在一条从源点 v 到 u 的路径,其长度比当前的 dist[u] 还要短。
- 寻找矛盾点:
- 如图 4-6 所示,这条假设的“更短路径”必然会离开已确定集合 S。
- 设 x 是这条路径上离开 S 后的第一个顶点。
- 那么,从源点 v 到 x 的路径长度为 d(v, x)。因为 x 在 S 之外,且它是路径上的一个中间点,根据边权非负的原则,到达 x 的距离 dist[x] 必然满足: dist[x] ≤ d(v, x) ≤ 假设的 u 的路径长度 < dist[u]
- 结论: * 如果 dist[x] < dist[u],那么按照
Dijkstra 算法的贪心策略,算法应该先选择顶点 x 加入 S,而不是先选择
u。
- 这与“算法当前选择了 u”相矛盾。
- 因此,不存在更短的路径,dist[u] 就是最短路径长度。
最优子结构性质的证明 (图 4-7)
这一部分是为了证明:当我们将顶点 u** 加入集合 S 后,更新其他顶点 i 的 dist 值时,为什么只需要考虑经过 u 的直接边?**
证明逻辑:
当 u 加入 S 后,可能会产生新的通往顶点 i 的特殊路径(特殊路径指:除了终点 i 外,其他点都在 S 中的路径)。
- 直接路径: 路径为 v → … → u → i。其长度为 dist[u] + c[u][i]。如果这个值比原来的 dist[i] 小,我们就更新它。
- 间接路径 (图 4-7): 如果新路径是经过 u 后,又回到了
S
中的某个老顶点 x,最后再到达 i。
- 分析: 因为顶点 x 是“老 S”中的成员,它比 u 更早加入 S。
- 根据算法逻辑,源点到 x 的最短距离在 u 加入之前就已经确定了。
- 结论: 经过 u 再绕回 x 的路径长度,必然大于源点直接到 x 的长度。因此,这种“绕路”的情况不需要考虑。
总结: 每次加入新点 u 后,只需要通过 u 对其相邻的、不在 S 中的顶点进行一次“松弛操作”(Relaxation)即可。
代码实现
- 选离原点最近且未访问的点
- 标记该点访问过
- 更新所有未访问结点到原点距离
1 |
|
T(n) = O(n) + (n − 1) × [O(n) + O(n)] = O(n2)
- 最终复杂度: O(n2)
- 空间复杂度: O(n2)(因为使用了邻接矩阵
c[n+1][n+1])
分数背包问题
贪心选择性质证明(证明“第一步”拿单位价值最高的是对的)
我们使用 替换法(Substitution) 来证明,这种方法在你提供的最优装载问题证明中也有体现。
- 设定前提 :设物品已按单位价值从大到小排序,即 $\frac{v_1}{w_1} \ge \frac{v_2}{w_2} \ge \dots \ge \frac{v_n}{w_n}$。
- 假设最优解 :设 A = {x1, x2, …, xn} 是一个最优解,其中 xi 是物品 i 装入的比例(0 ≤ xi ≤ 1)。
- 比较贪心解 :贪心算法会尽可能多地装入物品 1。如果 A 中 x1 < 1 且背包还有空间,或者 x1 不是在当前限制下的最大比例,那么 A 就没有完全遵循贪心策略。
- 构造新解 :
- 如果在 A 中物品 1 没有装满,我们可以从 A 中剔除一部分单位价值较低的物品 j(其中 j > 1),换成相同重量的物品 1。
- 设换入的重量为 Δw。
- 证明价值增加 :
- 原价值减少了 $\Delta w \cdot \frac{v_j}{w_j}$,新价值增加了 $\Delta w \cdot \frac{v_1}{w_1}$。
- 因为 $\frac{v_1}{w_1} \ge \frac{v_j}{w_j}$,所以总价值 ∑vixi 必然不会减少。
- 结论 :这说明总存在一个包含“尽可能多地装入单位价值最高物品”的最优解。
最优子结构性质证明(证明“剩下的”部分依然是最优的)
这一步利用 反证法 ,证明原问题的最优解包含子问题的最优解,逻辑与你图片中的 Step 2 一致。
- 简化问题 :假设我们已经根据贪心选择,装入了重量为 w 的物品 1。此时,剩余的背包容量为 C′ = C − w,剩余物品集合为 E′ = {2, 3, …, n}。
- 反证逻辑 :
- 设 S 是原问题的最优解。那么 S 去掉物品 1 后的部分 S′,必须是子问题(容量 C′,集合 E′)的最优解。
- 假设 S′** 不是子问题的最优解** :意味着存在另一个方案 S″,在容量 C′ 下能获得比 S′ 更高的价值。
- 推导矛盾 :如果我们把物品 1 加回到 S″ 中,得到的新解总价值将超过 S。这与“S 是原问题的最优解”矛盾。
- 结论 :每一步贪心选择后,剩下的问题依然满足最优子结构,可以递归地使用贪心策略。
贪心解决的是分数背包,对于0/1背包不行
1 | void Knapsack(int n, float M, float v[], float w[], float x[]) { |
时间复杂度分析
- 排序 (Sort): 这是算法中最耗时的部分。使用高效排序(如快排、归并),时间复杂度为 O(nlog n)。
- 循环 (for): 代码中只有一个从 1 到 n 的循环,最坏情况遍历所有物品,时间复杂度为 O(n)。
总时间复杂度:
T(n) = O(nlog n) + O(n) = O(nlog n)
最优装载问题
最优装载问题(Optimal Loading Problem) 是贪心算法中最经典、最直观的案例之一。虽然它看起来像背包问题,但它的目标略有不同,因此解法也更加简单。
以下是关于该问题的详细描述和贪心策略。
问题描述 (Problem Description)
1. 场景设定
假设有一艘轮船(或者一辆卡车),它的最大载重量为 C。
码头上有 n 个集装箱(古董、货物),第 i 个集装箱的重量为 wi。
注意 :与背包问题不同的是,这些集装箱 没有“价值”的区别 (或者说每个集装箱的价值都视为 1)。
2. 目标
我们的目标是在不超过轮船载重量 C 的前提下, 装入尽可能多的集装箱数量 。
3. 数学形式化
设 xi 为变量,当 xi = 1 时表示装入第 i 个集装箱,当 xi = 0 时表示不装入。
- 目标函数:最大化装入的数量 $$ \max \sum_{i=1}^{n} x_i $$
- 约束条件: $$ \sum_{i=1}^{n} w_i x_i \le C, \quad x_i \in \{0, 1\} $$
贪心策略 (Greedy Strategy)
对于这个问题,直觉往往就是正确的。
1. 核心策略:重量最轻者优先 (Lightest First)
为了装入更多数量的集装箱,我们应该尽可能留出空间。每一个被选中的集装箱都应该尽可能少地消耗船的载重。因此,贪心策略就是: 优先选择重量最小的物品 。
2. 算法步骤
- 排序 :将所有集装箱的重量 wi 按从小到大(非递减)的顺序排序。
- 循环选择 :
- 从最轻的集装箱开始,依次尝试装入。
- 如果当前集装箱的重量 ≤ 船的剩余载重,就装入,并减少剩余载重。
- 如果当前集装箱装不下,由于后面的更重,肯定也装不下,算法直接结束。
证明贪心选择策略
这一步的核心是证明:在所有最优方案中,一定存在一个方案包含了重量最轻的 1 号集装箱。
- 前提条件 :假设集装箱已按重量从小到大排序 w1 ≤ w2 ≤ … ≤ wn。
- 假设最优解 :设 X = {x1, x2, …, xn} 是一个最优解(装入箱子最多的方案),其中 k 是该方案中装入的最轻集装箱的索引,即 k = min {i|xi = 1}。
- 分类讨论 :
- 情况 1(k = 1) :如果最优解本来就包含了最轻的 1 号箱,证明结束。
- 情况 2(k > 1) :如果最优解没装 1 号箱,而是装了更重的 k 号箱。
- 构造新解 :我们用 1 号箱替换掉 k 号箱,得到新方案 Y。即令 y1 = 1, yk = 0,其余物品选取状态与 X 保持不变。
- 证明新解的合法性与最优性 :
- 重量限制 :由于 w1 ≤ wk,替换后的总重量 ∑wiyi = w1 − wk + ∑wixi ≤ ∑wixi ≤ C。这意味着新方案 Y 依然没有超过载重。
- 箱子数量 :新方案 Y 的箱子总数 ∑yi 等于原最优解 X 的箱子总数 ∑xi。
- 结论 :既然 Y 合法且数量也是最多的,说明 优先装入最轻的集装箱可以使总箱数最大化 。
2. 最优子结构性质证明(证明“剩下的”也能这么选)
这一步证明:当你装入最轻的集装箱后,剩下的问题依然可以用同样的贪心策略解决。
- 原问题与子问题 :设原问题是在容量为 C 时从 {1, 2, …, n} 中选。当你根据贪心策略选了 1 号箱(x1 = 1)后,问题变成了:在容量为 C − w1 的情况下,从剩余的集装箱 {2, 3, …, n} 中进行选择。
- 证明逻辑 :如果 {x1, x2, …, xn} 是原问题的最优解,那么其中的子集 {x2, x3, …, xn} 必然是容量为 C − w1 时子问题的最优解。
- 直观理解 :如果子问题有更好的解(能装更多箱子),那么把它和 1 号箱结合,就会得到一个比原最优解还要好的方案,这与前提矛盾。
代码
1 | void Loading(int x[], Type w[], Type c, int n) { |
总时间复杂度 T(n) 是各部分之和:
$$ T(n) = \underbrace{O(n \log n)}_{\text{排序}} + \underbrace{O(n)}_{\text{初始化}} + \underbrace{O(n)}_{\text{贪心选择}} $$
最终时间复杂度:
O(nlog n)
哈夫曼编码
贪心选择性质
命题: 设字符集 C 中频率最低的两个字符为 x 和 y,则存在一棵最优前缀码树 T,使得 x 和 y 是深度最深的兄弟叶节点。
证明过程(交换论证法):
初始假设 :设 T 是一棵最优前缀码树。在 T 中,设 b 和 c 是深度最深(码长最长)的一对兄弟叶节点。不妨设 f(b) ≤ f(c) 且已知在T中有两个非最深的频率最高的节点,x,y,不妨设 f(x) ≤ f(y)。
构造新树 T′**** :由于 x, y 是全局频率最小的,必有 f(x) ≤ f(b) 且 f(y) ≤ f(c)。我们将 x 与 b 交换, y 与 c 交换,得到新树 T′。
计算代价差: 对比 T 与 T′ 的总权路径长度(代价):
B(T) − B(T′) = ∑c ∈ Cf(c)dT(c) − ∑c ∈ Cf(c)dT′(c)
B(T) − B(T′) = [f(x)dT(x) + f(b)dT(b) + f(y)dT(y) + f(c)dT(c)] − [f(x)dT′(x) + f(b)dT′(b) + f(y)dT′(y) + f(c)dT′(c)]
B(T) − B(T′) = f(x)dT(x) + f(b)dT(b) + f(y)dT(y) + f(c)dT(c) − [f(x)dT(b) + f(b)dT(x) + f(y)dT(c) + f(c)dT(y)]
经过代数化简(仅 x, y, b, c 的项未抵消):
B(T) − B(T′) = (f(b) − f(x))(dT(b) − dT(x)) + (f(c) − f(y))(dT(c) − dT(y))
4.结论 :
- 因为 f(b) ≥ f(x) 且 f(c) ≥ f(y)(频率最小性)
- 因为 dT(b) ≥ dT(x) 且 dT(c) ≥ dT(y)(b, c 在最深层)
- 所得结果 ≥ 0,即 B(T′) ≤ B(T)。
- 由于 T 已经是最优的, T′ 的代价只能等于 T。故 T′ 的码长仍然是最短的,即 T′ 是最优前缀码,并且其最小频率的x、y具有最深的深度(最长的编码),且只有最后一位不同
这证明了:贪心选择(合并最小的两个)不会错过全局最优解。
最优子结构性质
命题: 将 x 和 y 合并为新字符 z(频率 f(z) = f(x) + f(y)),得到子问题 C′。若 T′ 是 C′ 的最优树,则将 z 替换回 x, y 得到的树 T 必是原问题 C 的最优树。
关键点 1:建立原问题与子问题的代价关系
- 递推表达式:原树 T 的平均码长 B(T) 可以用子树 T′ 的码长 B(T′) 表示为: B(T) = B(T′) + f(x) + f(y)
- 深度关系推导 :因为 x, y 是 z 的子节点,所以深度满足 dT(x) = dT(y) = dT′(z) + 1。
- 代数关系: f(x)dT(x) + f(y)dT(y) = (f(x) + f(y))(dT′(z) + 1) = f(x) + f(y) + (f(x) + f(y))dT′(z) = f(x) + f(y) + f(z)dT′(z)
关键点 2:采用反证法证明 T′ 是最优的
假设 :存在另一个树 T″ 是子问题 C′ 的更优前缀码,即满足 B(T′) > B(T″)。
构造 :在树 T″ 中,将节点 z(作为叶子节点)替换为其子节点 x, y,从而得到原问题 C 的一个解 T‴。
计算 T‴ 的代价:根据上述关键点 1 的关系,可得:
B(T‴) = B(T″) + f(x) + f(y)
得出矛盾: 由于假设 B(T″) < B(T′),则:
B(T‴) = B(T″) + f(x) + f(y) < B(T′) + f(x) + f(y) = B(T)
这意味着 T‴ 是比最优树 T 更优的解,这与“T 是原问题的最优解”这一前提矛盾。
结论 :因此 T′ 必然是子问题的最优解,最优子结构性质得证。
代码实现
1 |
|
时间复杂度分析
- 构建堆 :O(n),其中 n 是字符集大小。
- 合并过程 :共进行 n − 1 次合并,每次弹出和压入堆的操作为 O(log n),总计 O(nlog n)。
- 总复杂度 :O(nlog n)。
最小生成树
MST 性质 (Cut Property)
设 G = (V, E) 是一个连通带权无向图,边 (u, v) 的权值为 w(u, v)。
性质描述: 设 U 是顶点集 V 的一个真子集(即 U ⊂ V 且 U ≠ ∅)。如果 (u, v) 是连接 U 和 V − U 的所有边(称为横切边)中权值最小的一条边,那么一定存在一棵最小生成树 (MST) 包含边 (u, v)。
证明 (反证法)
假设: 假设图 G 的任何一棵最小生成树都不包含这条权值最小的横切边 (u, v)。
构造矛盾:
设 T 是 G 的一棵最小生成树。根据假设,(u, v) ∉ T。
由于 T 是生成树,它包含连接所有顶点的路径。因此,在 T 中必然存在一条从 u 到 v 的唯一路径。
因为 u ∈ U 且 v ∈ V − U,这条路径必然要从集合 U 跨越到集合 V − U。这意味着路径上至少有一条边 (x, y) 也是横切边(即 x ∈ U, y ∈ V − U)。
根据 MST 性质的前提条件,(u, v) 是所有横切边中权值最小的,所以有 w(u, v) ≤ w(x, y)。
现在,我们构造一个新的图 T′:将边 (u, v) 加入 T,并删除边 (x, y)。
T′ = (T − {(x, y)}) ∪ {(u, v)}
连通性分析:加入 (u, v) 会在 T 中形成一个环,而删除环上的另一条边 (x, y) 会打破这个环,同时保持图的连通性(因为 (x, y) 和 (u, v) 都在跨越切口的路径上)。因此 T′ 仍然是一棵生成树。
权值分析:
w(T′) = w(T) − w(x, y) + w(u, v)
由于 w(u, v) ≤ w(x, y),则 w(T′) ≤ w(T)。
结论:
- 如果 w(u, v) < w(x, y),则 w(T′) < w(T),这与 T 是最小生成树矛盾。
- 如果 w(u, v) = w(x, y),则 w(T′) = w(T),说明 T′ 也是一棵最小生成树,且它包含了 (u, v)。这与“任何 MST 都不包含 (u, v)”的假设矛盾。
综上所述,假设不成立,边 (u, v) 一定属于某棵最小生成树。
Prim 算法
Prim 算法是 MST 性质的直接应用。
算法思路:
- 维护一个集合 S,初始时只包含任意一个起始顶点(例如 S = {1})。
- 在所有连接 S 和 V − S 的边中,选择权值最小的那条边 (u, v) (其中 u ∈ S, v ∈ V − S)。
- 将顶点 v 加入集合 S,并将边 (u, v) 加入最小生成树。
- 重复步骤 2 和 3,直到 S 包含所有顶点。
时间复杂度:
- 使用邻接矩阵 + 数组实现:O(V2)
1 | /** |
Kruskal 算法
Kruskal 算法也是基于 MST 性质,但它是从边的角度出发。
算法思路:
- 将图中所有边按权值从小到大排序。
- 初始时,每个顶点自成一个连通分量。
- 依次考察排序后的每条边 (u, v):
- 如果 u 和 v 属于不同的连通分量,则选择这条边,并合并这两个分量(因为这条边是连接这两个分量的最短边,符合 MST 性质)。
- 如果 u 和 v 已经属于同一个连通分量,则舍弃这条边(否则会形成环)。
- 重复步骤 3,直到选择了 n − 1 条边。
实现细节: 通常使用 并查集 (Union-Find) 数据结构来快速判断两个顶点是否属于同一个连通分量以及合并分量。
1 |
|
时间复杂度:
- 排序边:O(Elog E)
- 并查集操作:O(Eα(V)) (α 为阿克曼函数的反函数,极小,可视为常数)
- 总复杂度:O(Elog E)
回溯
0-1背包问题
解向量: < x1, x2, .....xn> xi 表示第i个物品选或不选
解空间树:
1 |
|
该算法的 最坏时间复杂度 是:
O(n ⋅ 2n)
其中:
- n 是物品的数量。
- 2n 来自于解空间树的大小(子集树)。
- 额外的 n
系数来自于
Bound函数的计算开销。
详细分析
我们可以从 解空间结构 和 节点计算耗时 两个方面来分析。
- 解空间树(Subset Tree)
0-1背包问题的解空间是一棵 子集树 。
- 对于 n 个物品,每个物品都有 2 种选择(选 或 不选)。
- 这构成了一棵二叉树,树的深度为 n。
- 总节点数 :满二叉树的节点数接近 2n + 1,即 O(2n) 量级。
- 单个节点的计算开销
在递归过程的每一个节点(即每次调用
BackTrack),我们可能会调用 Bound(i)
函数来计算上界。
Bound函数内部包含一个while循环,最坏情况下会扫描剩余的所有物品。- 因此,
Bound函数的时间复杂度是 O(n) 。
- 综合复杂度
- 最坏情况 :如果你的剪枝策略(
Bound函数)失效(例如数据构造得很特殊,使得每次计算出的上界都比bestp大),算法就必须遍历整棵树。 - 计算公式: 总时间 ≈ 节点总数 × 单节点开销 ≈ 2n × n
N皇后
解向量设计:给棋盘上的行和列从1到N编号,同时也给皇后1-N编号,假设皇后i放在第i行,则问题可以表示为n元组
[x1, x2.....xi, xj....xN]
xi
表示i皇后的列号
- 列约束:当 i ≠ j 时,要求 xi ≠ xj(即不同行不能在同一列)。
- 对角线约束:要求 |i − j| ≠ |xi − xj|(即不能在同一对角线上)。
1 | // ========================================== |
- 理论上限(最坏情况)
如果我们使用回溯法(Backtracking)来寻找所有解,算法的核心逻辑是逐行放置皇后。
- 第 1 行 :有 N 种选择(即放在第 1 列到第 N 列)。
- 第 2 行 :为了避免列冲突,剩下 N − 1 种选择。
- 第 3 行 :剩下 N − 2 种选择。
- …
- 第 N 行 :只剩下 1 种选择。
如果不考虑对角线的限制,仅考虑列不冲突,可能的状态总数就是 N × (N − 1) × (N − 2) × … × 1 = N!。
因此,算法搜索空间的上界是 O(N!) 。
- 实际运行效率(剪枝的影响)
虽然理论上界是 O(N!),但在实际运行中,我们加入了
对角线冲突检测 (即你的代码中的 is_ok
函数或布尔数组判断)。
- 一旦发现当前位置与之前的皇后在对角线上冲突,算法会立即停止该分支的搜索(剪枝/Pruning)。
- 这意味着算法根本不会遍历完整的 N! 个节点。实际访问的节点数远小于 N!。
- 尽管如此,对于非常大的 N,其增长速度仍然是指数级甚至是超指数级的。
- 具体代码实现的细节影响
你之前提供的代码中,is_ok
函数的实现方式也会微调复杂度:
1 | // 你的 is_ok 函数 |
- 常规回溯 :如果使用布尔数组(
colUsed,diag1,diag2)来判断冲突,判断操作是 O(1) 的。此时总复杂度接近 O(N!)。 - 你的回溯 :由于
is_ok内部有一个循环,判断一个位置是否合法的代价是 O(N)。因此,严格来说,你的代码的时间复杂度上界是 O(N ⋅ N!) 。
m图着色
代码实现
递归
1 | void Color::Backtrack(int t) |
非递归
1 | /** |
对于解空间树中的每一个 非叶节点 ,在扩展它的子节点时,最坏情况下的耗时是 O(mn) 。
- m 的含义 :当前节点有 m 个分叉(对应 m 种颜色),算法需要尝试每一个子节点。
- n 的含义
:对于这 m
个可能的颜色,每一个都要调用一次
OK函数。OK函数需要检查当前顶点与图中其它最多 n − 1** 个顶点的相邻关系及颜色冲突,耗时为 O(n)**。 - 结论 :因此,处理一个父节点以产生其所有子节点所需的总时间是 m × O(n) = O(mn)。
$$ \sum_{i=0}^{n} m^i(mn) $$
- ∑mi :这是解空间树中各层节点的总和。
- (mn) :这是在每一层处理节点扩展时的单位工作量。
根据等比数列求和公式推导:
nm(mn − 1)/(m − 1) = O(nmn)
旅行商问题TSP
目标是寻找一条回路,使得总费用(距离)最小化
$$ \min \left\{ \sum_{i=1}^{n-1} w(v_i, v_{i+1}) + w(v_n, v_1) \right\} $$
- 第一部分 $\sum_{i=1}^{n-1} w(v_i, v_{i+1})$:表示从起点 v1 出发,依次经过其它 n − 1 个城市,直到到达最后一个城市 vn 的路径总长。
- 第二部分 w(vn, v1):表示从最后一个城市 vn 回到起点 v1 的距离(闭合回路)。
- w(vi, vj) 或 w(i, j)**** :表示城市 i 和城市 j 之间的直接距离(权重)。
- w(vi, vj) = ∞ :表示两个城市之间没有直接路径(非完全图的情况)。
- 顶点的编号 :将 n 个城市简单编号为 1, 2, …, n。
- 解空间大小 = (n − 1)!****
- 推导逻辑 :虽然总共有 n 个城市,全排列是 n!,但由于TSP是回路问题,起点的选择不影响路径的总长度(是一个圈)。为了消除重复计算,我们固定起点(如节点1),剩下的 n − 1 个节点进行全排列。
剪枝
剪枝/约束条件 1:连通性剪枝 (Feasibility)
如果当前正在考虑的顶点 j,与当前已经走过的部分路径中的末端结点 i 没有边相连(即 w[i, j] = ∞),则放弃搜索 j 所在分支。
剪枝/约束条件 2:最优性剪枝 (Optimality)
令到第 i 层结点为止,构造的部分解路径为 < x1, x2, …, xi>
该路径的当前权值总和 cw (Current Weight) 为:
$$ cw = \sum_{j=1}^{i-1} w(x_j, x_{j+1}) $$
剪枝逻辑:
如果 当前部分路径的开销 cw 已经大于或等于 当前已找到的最优解(Best Weight, bestw),则剪枝。
- 为什么剪枝? 即使剩下的路径长度为0(不可能),这条路的总长度也已经超过了之前找到的一条完整回路(最优解)。继续往下搜只会得到更差的结果,纯属浪费时间。
解空间树
代码实现
1 | // 假设最大城市数量 N |
时间复杂度
O(n!)
(注:更精确的说法是 O((n − 1)!),但在大O表示法中,通常简写为阶乘级 O(n!))
