欢迎阅读本篇绪论,开启美妙(bushi)的数据结构与算法分析之旅,本篇文章将详细介绍数据结构与算法的时间复杂度或者空间复杂度的前置知识,以便后续深入学习
先从时间复杂度入手,接下来三个部分,分别是计算时间复杂度常用的数学符号,产生时间复杂度的行为,与求解时间复杂度的常见方法(甚至罕见,Ph1z倾情呈现),第四部分则是空间复杂度,希望读者看完这些能有较大收获喵(下文中打*的部分可以略看,了解即可,以及提供的实战例子会在未来出现,仅帮助初学但没有实例的人找到能计算的实例,以及参考我/ai的过程)
第一部分:渐进记号基础
为什么我们需要渐进记号捏? 因为精确计数太麻烦,
和 在 时"长得一样快",我们只想抓住增长趋势这个本质,忽略常数因子和低阶项的细节。渐进记号就是做这件事的数学工具,用来描述复杂度的阶。
1.1 渐进记号的数学定义
1.1.1 Θ 记号(渐进紧界)
读作 大Theta
定义: 对于给定函数
Ph1zの理解:
严格证明示例: 证明
- 需要找到
使得对所有 : - 右边:
,取 - 左边:当
时, ,取 - 因此
✓
1.1.2 O 记号(渐进上界)
读作 大O
定义:
Ph1zの理解:
注意事项:
记号给出的是上界,但不一定是紧界- 例如
成立,但 ——就像说"我跑步速度 (光速)",对的,但毫无信息量 - 在算法分析中,当我们说"时间复杂度为
"时,一般认为这是一个紧界
1.1.3 Ω 记号(渐进下界)
读作 大Omega
定义:
Ph1zの理解:
1.1.4 o 记号(非紧上界)*
读作 小O
定义:
等价定义:
示例:
1.1.5 ω 记号(非紧下界)*
读作 小Omega
定义:
等价定义:
1.2 渐进记号的性质*
1.2.1 传递性*
| 性质 | 关系 | 通俗解释 |
|---|---|---|
| 传递性 | ||
| 传递性 | ||
| 传递性 | 同量级具有传递性 | |
| 传递性 | 被甩开也传递 | |
| 传递性 | 超越也传递 |
1.2.2 自反性*
, , ,
1.2.3 对称性与转置对称性*
- 对称性:
- 转置对称性:
- 转置对称性:
1.2.4 常见函数的渐进比较(可自行证明)
对于任意小 $ \epsilon > 0$:
其中
用具体数字感受差距(
):
函数 时的值 量级感 一只手数得过来 教室里的人数 一栋楼的人数 一个小体育场 无敌大 略( 略(
1.2.5 三类情况下的渐进记号*
定理(CLRS 定理 3.1): 对任意两个函数
Ph1zの理解: 上下界都卡住了,就是紧界。就像证明一个人的身高在 170~175cm 之间,既
又 ,所以 就是"大约 170 多 cm"。
1.3 常用函数的渐进公式
1.3.1 多项式
其中
1.3.2 对数
| 公式 | 说明 | 直觉 |
|---|---|---|
| 对数底数无关紧要(常数因子差异) | 换底只差一个常数: | |
| Stirling 近似的推论 | ||
| 对数换底的重要恒等式 | 主定理的核心! |
1.3.3 Stirling 近似
部分推论(读者可以自行推导):
第二部分:产生时间复杂度的行为
一、哪些行为会产生时间复杂度
时间复杂度衡量的是算法执行时间随输入规模增长的变化趋势。任何一条指令的执行都需要时间,但在分析中,我们关注的是与输入规模 n 相关的操作。
以下行为会产生时间复杂度(按重要程度排列):
1. 基本操作
最核心的计数单位,通常选算法中最频繁执行的关键操作:
- 比较操作:
if (a > b) - 算术运算:
a + b、a * b - 赋值操作:
a = b - 数组索引访问:
arr[i] - 指针/引用操作
关键: 单个基本操作是
。复杂度来自基本操作被重复执行了多少次。
2. 循环结构——复杂度的最大来源
循环使基本操作重复执行,是时间复杂度的主要来源:
for (int i = 0; i < n; i++) // 循环n次
sum += arr[i]; // 基本操作执行n次 → O(n)循环总复杂度的计算方法: 循环体执行次数 × 循环体内部复杂度 = 该循环的总复杂度
常见循环模式速查:
| 代码模式 | 执行次数 | 时间复杂度 | 直觉 |
|---|---|---|---|
for(i=0; i<n; i++) | 遍历一遍 | ||
for(i=1; i<n; i*=2) | 每次翻倍,很快到头 | ||
for(i=n; i>0; i/=2) | 每次减半,很快到头 | ||
嵌套两层 for(i=0; i<n; i++) for(j=0; j<n; j++) | 遍历所有对 | ||
嵌套 for(i=0; i<n; i++) for(j=0; j<i; j++) | 三角形区域 | ||
for(i=1; i<n; i*=2) for(j=0; j<i; j++) | 几何级数! |
3. 递归调用
每次递归调用都会产生函数调用开销,且递归深度和每层的工作量决定复杂度:
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // 递归n次 → O(n)
}递归的思维方式: 把递归想象成一棵树——每个节点是一次调用,子节点是该次调用产生的更小调用。总工作量 = 所有节点的代价之和。
4. 函数调用
- 普通函数调用本身是
,但函数内部可能有循环 - 库函数有自己的复杂度,不能当作免费:
| 常见操作 | 复杂度 | 注意 |
|---|---|---|
sort() | C++ std::sort(introsort) | |
Arrays.sort() 基本类型 | Java Dual-Pivot Quicksort,最坏退化 | |
Arrays.sort() 对象 | TimSort,保证最坏 | |
HashMap.get() | 平均 | 哈希冲突时退化 |
Collections.sort() | 基于归并排序(TimSort) | |
String.contains() | 线性扫描 |
5. 内存分配/释放*
- 栈上分配:
- 堆上分配(
malloc/new):通常视为 ,但实际涉及系统调用,粗略分析时常看作常数时间
6. I/O 操作*
- 读写文件、网络传输、打印输出等,通常比内存操作慢几个数量级
- 在纯算法分析中常简化为
或单独考虑
关键原则:时间复杂度分析的核心是找出执行次数与输入规模
二、实战:从代码到复杂度
由内向外,逐层累乘循环次数
例 1:单层循环
for (int i = 0; i < n; i++) {
printf("%d", arr[i]); // O(1) 的基本操作
}
// 总:n × O(1) = O(n)例 2:嵌套循环——独立变量
for (int i = 0; i < n; i++) { // 外层 n 次
for (int j = 0; j < m; j++) { // 内层 m 次
sum += arr[i][j]; // O(1)
}
}
// 总:n × m × O(1) = O(nm)例 3:嵌套循环——依赖变量
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) { // j 跑到 i,不是 n!
sum += arr[i][j];
}
}
// 总:Σ(i=0..n-1) i = n(n-1)/2 = O(n²)常见错误: 看到两层循环就写
。如果内层循环次数不是 而是依赖外层变量,必须求和!
例 4:对数循环
int i = 1;
while (i < n) {
i = i * 2; // 每次翻倍
// ... O(1) 操作
}
// i 的变化:1, 2, 4, 8, ..., n → 循环 log₂n 次
// 总:O(log n)例 5:混合模式
for (int i = 1; i <= n; i *= 2) { // log n 次
for (int j = 0; j < i; j++) { // i 次(随 i 变化!)
sum++;
}
}
// 总:Σ(k=0..log n) 2^k = 2n - 1 = O(n)
// 虽然有"两层循环",但不是 O(n log n)!常见错误: 不能机械地"循环层数 → 复杂度",必须分析实际执行次数。
例 6:调用了库函数
for (int i = 0; i < n; i++) {
sort(arr, arr + n); // 每次 sort 是 O(n log n)!
}
// 总:n × O(n log n) = O(n² log n)三、复杂度分析常见陷阱
| 陷阱 | 错误分析 | 正确分析 |
|---|---|---|
| 内层循环依赖外层 | 直接 | 要对内层求和: |
| 对数循环看成线性 | while(i<n) i*=2 当成 | 每次翻倍 → |
| 忽略库函数代价 | sort() 当 | sort() 是 |
| 多个分支取最好的 | 只看最短分支 | 应取最坏分支(上界) |
| 最好=平均=最坏 | 对所有算法混为一谈 | 快排最坏 |
第三部分:标准递推式与主定理
3.1 递归树方法
方法 把递推式画成一棵树,根节点是当前层的工作量
,子节点是更小问题的递推调用。然后逐层求和。
3.1.1 方法步骤
- 将递推式展开为树形结构(根 = 当前层,子节点 = 递归调用)
- 计算每层的总代价(节点数 × 每个节点的代价)
- 确定树的深度(递归到叶子需要几层)
- 将所有层的代价求和 + 叶子节点的代价
- 用代换法验证结果(递归树给出猜测,代换法严格证明)
3.1.2 详细示例:
递归树展开(建议在纸上画出来):
cn² ← 第0层:1个节点,代价 cn²
/ | \
cn²/16 cn²/16 cn²/16 ← 第1层:3个节点,总代价 (3/16)cn²
/|\ /|\ /|\
... ... ... ← 第2层:9个节点,总代价 (3/16)²cn²- 第 0 层(根):代价
- 第 1 层:3 个子节点,每个代价
,总代价 - 第 2 层:9 个子节点,每个代价
,总代价 - 第
层: 个子节点,每个代价 ,总代价 - 树的深度:
- 叶子层:
个叶子,每个代价
总代价:
几何级数
与主定理情况 3(
3.2 主定理(Master Theorem)(配合3.6食用更佳)
3.2.1 定理陈述
设
参数含义:
:每次分出几个子问题(分支数) :每个子问题的规模(缩小比例) :合并/划分这一层的工作量 :临界函数——所有叶子节点总工作量(如果叶子占主导)
其中
情况 1: 若对某个常数
Ph1zの理解: 合并工作量
太小,叶子节点(递归到底的总工作量)占主导。就像搬运工搬砖——搬一块砖的时间可以忽略,主要时间花在"有很多小任务"本身。
情况 2: 若
Ph1zの理解: 合并工作量和叶子工作量刚好平衡,每层贡献差不多,乘以树高
。归并排序就是这种情况——每层合并 ,共 层。
情况 3: 若对某个常数
Ph1zの理解: 合并工作量
太大,根节点那一层就占了绝大部分。子问题的工作量加起来还不如合并开销。就像"准备工作比干活本身还耗时"。
3.2.2 主定理应用详例
使用步骤:
- 从递推式中读出
(子问题数)、 (缩小比例)、 (合并代价) - 计算临界值
- 比较
和 的大小关系 - 套用对应情况
| 递推式 | 情况 | 结果 | ||||
|---|---|---|---|---|---|---|
| 2 | 2 | 2 | ||||
| 2 | 2 | 1 ( | ||||
| 2 | 2 | 3 ( | ||||
| 4 | 2 | 1 ( | ||||
| 4 | 2 | 2 | ||||
| 4 | 2 | 3 ( | ||||
| 7 | 3 | 3 | ||||
| 7 | 2 | 1 |
3.2.3 主定理不能应用的情况*
当
和 之间"差了非多项式因子"时,三种情况都不满足。
典型例子:
, 与 的比较: 多了一个 因子- 不满足情况 1(
) - 不满足情况 2(
) - 不满足情况 3(
) - 需要使用更精细的方法(见下文主定理的推广)
为什么会"卡住"?
比 大,但不是多项式地大(差的是 ,不是 )。主定理的三种情况之间有"缝隙", 恰好落在缝隙里。
3.3 主定理的补充版本*
定理: 设
则:
应用:
, ,
3.4 代换法(猜测-验证)
核心思想: 先猜答案,再用数学归纳法证明。就像做数学题先算出答案,再写证明过程——猜得对,证明就顺了。
3.4.1 方法步骤
- 猜测解的形式(来源:递归树、经验、类比)
- 用数学归纳法证明猜测
- 必要时减去低阶项使归纳可行(这是最常见的技巧!)
3.4.2 示例:
猜测:
归纳证明:
假设对
当
3.4.3 常见陷阱
错误做法: 猜测
这无法证明
为什么失败? 因为
实际上是 ,猜 太小了!归纳法会诚实地告诉你——归纳过不去,说明猜测有误。
正确做法: 猜测
减去低阶项的技巧: 当直接猜
归纳不过时,尝试 。多出的 给了归纳一些"缓冲空间",让不等式能够闭合。这是 CLRS 重点强调的方法。
3.5 非标准递推式的处理*
3.5.1 不同大小的子问题(下文3.6 Akra-Bazzi 方法)*
- 子问题大小不同,不能直接应用主定理
- 用递归树:每层总代价为
,但子问题缩小速度不均匀 - 树的深度为
(由较大的子问题 决定) - 结果:
3.5.2 子问题大小相减而非相除*
- 特征方程:
,根为 - 解为
为什么会指数增长? 每次分出两个子问题,但规模只减 1 和 2,树的节点数呈 Fibonacci 增长 → 指数级。和主定理的分治(规模除以
,大幅缩小)截然不同。
3.6 Akra-Bazzi 方法——我们所追求的大一统之美 (内含证明)*
为什么需要 Akra-Bazzi? 主定理只能处理
的"整齐"分治——所有子问题大小相同。但现实中经常遇到"不整齐"的分治:
- 子问题大小不同:
- 子问题系数不同:
- 子问题大小不是精确的
: Akra-Bazzi 方法一把全收,是主定理的严格推广。
3.6.1 定理陈述*
Akra-Bazzi 定理(1998): 设递推式形如:
条件:
为正常数(各子问题的系数) 为 中的常数(各子问题的缩小比例) (允许取整等微小偏差) 在 上非负且满足增长条件(见下文)
定义临界值
的唯一正实数解
结论:
与主定理的对应关系:
主定理 Akra-Bazzi 说明 (子问题数) (各子问题系数) 主定理中 ,共 个 (子问题大小) (各子问题大小) 主定理中 (临界函数) ,其中 主定理中 ,解得 (合并代价) 同一角色 主定理是 Akra-Bazzi 在
时的特例!
3.6.2 直觉理解*
核心直觉: Akra-Bazzi 的关键就是那个临界方程
。
是第 个子问题的"权重"(调了几次) 是第 个子问题的"缩小比例"(规模变成原来的几分之几) 是使得"子问题总权重 × 缩小比例的 次方"恰好平衡为 1 的指数 递归树视角: 在递归树中,第
层有约 个节点,每个节点规模约 。临界值 恰好是使得每层总代价的增长/衰减趋势发生转折的指数——和主定理中 的角色完全一样。
3.6.3 使用步骤*
四步走:
- 识别参数: 从递推式中读出
(系数)、 (缩小比例)、 (驱动函数) - 求解临界方程: 解
得到 - 计算积分:
- 组合结果:
3.6.4 应用示例*
例 1:主定理特例验证
- 临界方程:
,解得 - 积分:
- 结果:
✓
与主定理情况 2 完全一致!
例 2:不等大子问题
主定理不能用! 因为两个子问题大小不同(
vs )。
; ;- 临界方程:
时: ✓,所以
- 积分:
- 结果:
递归树验证: 每层总代价约
,树深约 (由较大的 决定),总代价 ✓
例 3:不等大子问题 + 非线性驱动函数
; ;- 临界方程:
时: ✓,所以
- 积分:
- 结果:
例 4:多个不同系数的子问题
; ;- 临界方程:
时: 时:- 所以
(精确值需数值求解,约 )
- 积分:
- 因为
( ),积分收敛到
- 因为
- 结果:
例 5: 因子的情况(填补主定理的缝隙)
这正是主定理"卡住"的那个例子!
- 临界方程:
, - 积分:
- 结果:
与推广主定理的结果一致!✓
3.6.5 证明方法与思路*
Akra-Bazzi 定理的证明是分析学中一个优美的论证,核心分为三步。
第一步:构造辅助函数与临界值 的性质
定义辅助函数:
性质分析:
(至少一个子问题) ( , ) ( )- 因此
严格递减,从 递减到
由介值定理,存在唯一
的含义: 是递归树中"每层总代价增长率"的转折点。 刻画了叶子节点总工作量。
第二步:构造特解——积分变换法
核心构造: 定义
验证
需要证明
关键计算:
利用
通过变量替换
(此处利用了
最终得到:
这验证了
第三步:上下界夹逼( 证明)
上界证明(
构造
- 基础情况:对所有
, - 归纳步:假设对
, ,则
利用
下界证明(
合起来:
证明的核心洞见:
- 临界方程
不是凭空来的——它来自要求"特解 代入递推式后, 的系数恰好平衡" - 积分不是凭空来的——它来自对递归树逐层求和后的连续化近似。递归树每层代价约
,求和 用积分 近似 的条件保证了取整不影响—— 的偏差在积分中是"可忽略的",这就是为什么 和 在渐进分析中等价
- Akra-Bazzi 是主定理的严格推广,会 Akra-Bazzi 就会主定理
第四部分:空间复杂度分析
逝者如斯,时间是一去不返的——每个操作花费的时间都会累加;但是空间是物质存在的,用完即还的——函数返回后,栈帧被弹出,内存被回收。因此:
- 时间复杂度看递归树的所有节点总代价
- 空间复杂度看递归树的根到叶最长路径上的代价
4.1 空间复杂度的定义
4.1.1 形式化定义
定义: 算法的空间复杂度
"额外"的含义: 我们只算算法自己申请的内存,不算输入数据占用的空间。这叫"辅助空间"(auxiliary space)。如果算上输入,那所有算法至少都是
,何意味。
4.1.2 与时间复杂度的根本区别
| 维度 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 累积方式 | 加法:所有操作时间累加 | 最大值:取任意时刻的峰值 |
| 递归树含义 | 所有节点的代价之和 | 根到叶最长路径上的代价之和 |
| 释放/回收 | 时间不可回收 | 空间可以回收(函数返回后释放) |
| 子问题并行 | 并行子问题时间可重叠 | 并行子问题空间不重叠(各占各的) |
| 子问题串行 | 串行子问题时间累加 | 串行子问题空间可复用 |
4.2 空间消耗的三大来源
4.2.1 栈空间——递归算法的主要空间消耗
每次递归调用,系统在调用栈上压入一个栈帧(Stack Frame),包含:
- 返回地址
- 函数参数
- 局部变量
- 保存的寄存器值
栈帧大小: 对于简单递归函数(参数和局部变量都是基本类型),每层栈帧大小为
。如果参数中传了大数组(值传递),栈帧可能为 。
关键性质: 栈帧在函数返回时弹出释放。因此在任意时刻,栈中只存在从 main 到当前正在执行的函数这条调用链上的栈帧。
main() → f(10) → f(7) → f(4) → f(1) ← 当前正在执行
↑ ↑ ↑ ↑
栈帧存在 栈帧存在 栈帧存在 栈帧存在
f(10) 的其他子调用 f(3) 已经返回,栈帧已释放!结论: 递归算法的栈空间 = 递归深度 × 每层栈帧大小
4.2.2 堆空间——显式申请的额外数据结构
算法中通过 malloc、new、数组声明等方式在堆上分配的内存:
| 操作 | 空间消耗 | 说明 |
|---|---|---|
int* aux = new int[n] | 归并排序的辅助数组 | |
vector<int> path 逐步 push | 最多 | DFS 的路径数组 |
HashMap<K,V> map 插入 | 图遍历的 visited 集合 | |
int temp | 单个临时变量 |
堆空间的释放时机: 如果辅助数组是在递归函数内部声明的局部变量,函数返回后自动释放。如果在递归前只申请一次(如归并排序的
aux数组),则全程占用。
4.2.3 输入空间——通常不计
输入数据本身占用的空间通常不计入空间复杂度,原因:
- 输入是"给定的",算法无法控制
- 如果计入,所有算法至少
,无法区分 - 我们关注的是算法额外需要的空间
例外: 如果算法修改了输入数据并以此作为额外存储(如原地算法在输入数组上做标记),这种"借用"的空间可以算作
4.3 空间复杂度的核心分析原则
核心公式:
递 归 最 大 深 度 每 层 栈 帧 大 小 栈 空 间 堆 上 同 时 存 在 的 最 大 数 据 量 堆 空 间
4.3.1 递归算法的空间分析——"看树的最长路径"
递归树视角:
- 时间:递归树所有节点的代价之和(因为每个节点都要执行)
- 空间:递归树从根到叶最长路径上的代价之和(因为同一时刻只有一条路径上的栈帧存在)
这就是空间分析的黄金法则:盯住最长路径,忽略兄弟节点!
具体步骤:
- 确定递归树的最大深度
- 确定每层栈帧的大小
- 确定算法申请的堆空间
- 组合:
4.3.2 非递归算法的空间分析——"盯住最大分配"
对于没有递归的迭代算法:
- 没有栈增长的问题
- 空间 = 所有同时存在的变量/数据结构占用的内存之和
"同时存在"是关键: 如果两个数组分别在 if 的两个分支中创建,它们不会同时存在,空间取 max 而非 sum。
4.4 实战:从代码到空间复杂度
例 1:递归二分查找
int binarySearch(int* arr, int lo, int hi, int target) {
if (lo > hi) return -1;
int mid = (lo + hi) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] > target)
return binarySearch(arr, lo, mid-1, target); // 只走一条路!
else
return binarySearch(arr, mid+1, hi, target);
}- 递归深度:每次规模减半 →
- 每层栈帧:参数
lo, hi, target+ 局部变量mid→ - 堆空间:无
- 结果:
递归树: 每个节点只有一个子节点(只走一条路),树是一条链,深度
。
例 2:归并排序
void mergeSort(int* arr, int lo, int hi) {
if (lo >= hi) return;
int mid = (lo + hi) / 2;
mergeSort(arr, lo, mid); // 先排左半
mergeSort(arr, mid+1, hi); // 再排右半
merge(arr, lo, mid, hi); // 合并(需要辅助数组)
}- 递归深度:
- 每层栈帧:
(只传指针和索引) - 堆空间:
merge中申请辅助数组 (如果在外部一次性申请则全程占 ) - 结果:
(堆空间主导)
常见错误: 认为"每层递归都申请
辅助数组,共 层,所以 "。 纠正: 辅助数组通常在 merge 函数内局部申请,merge 结束后释放。在任意时刻,只有当前正在 merge 的那一层占用
,所以堆空间就是 。
例 3:递归斐波那契
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}- 时间复杂度:
(递归树节点总数) - 递归深度:
(最深的路径是 ) - 每层栈帧:
- 空间复杂度:
反直觉: 时间是指数级,空间是线性级!这就是"空间只看深度"的威力——递归树虽然庞大,但同一时刻只有一条路径上的栈帧存在。
例 4:快速排序
void quickSort(int* arr, int lo, int hi) {
if (lo >= hi) return;
int pivot = partition(arr, lo, hi);
quickSort(arr, lo, pivot-1); // 先排左半
quickSort(arr, pivot+1, hi); // 再排右半
}- 最坏情况(已排序数组):pivot 总是取到端点,退化为链
- 递归深度:
→ (可能栈溢出!)
- 递归深度:
- 最好/平均情况:pivot 接近中位数
- 递归深度:
→
- 递归深度:
- 堆空间:
partition是原地操作,
递归深度优化: 始终先递归较小的半边,较大半边用循环,可以保证深度
: cvoid quickSort(int* arr, int lo, int hi) { while (lo < hi) { int p = partition(arr, lo, hi); if (p - lo < hi - p) { quickSort(arr, lo, p-1); // 递归小的半边 lo = p + 1; // 大的半边用循环 } else { quickSort(arr, p+1, hi); hi = p - 1; } } }
例 5:DFS 图遍历
void dfs(int node, bool* visited) {
visited[node] = true;
for (int neighbor : adj[node]) {
if (!visited[neighbor])
dfs(neighbor, visited);
}
}- 递归深度:取决于图的结构
- 链状图:
- 平衡树:
- 一般图:
(最坏)
- 链状图:
- 堆空间:
visited数组 + 递归隐含的路径栈 - 结果:
(visited 数组 + 最坏递归深度)
例 6:动态规划——空间优化
二维 DP(未优化):
int dp[n+1][m+1]; // O(nm) 空间
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
dp[i][j] = dp[i-1][j] + dp[i][j-1];滚动数组优化:
int dp[2][m+1]; // O(m) 空间!
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
dp[i%2][j] = dp[(i-1)%2][j] + dp[i%2][j-1];核心思想: 如果
dp[i][j]只依赖前一行dp[i-1][*]和当前行dp[i][*],那只需保留两行。空间从降到 。
一维优化(如果只依赖左边和上方):
int dp[m+1]; // O(m) 空间!
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
dp[j] = dp[j] + dp[j-1]; // 覆盖旧值4.5 递推法分析空间——类比主定理
核心思想: 空间递推式和时间的关键区别——子问题个数
不乘!
4.5.1 为什么空间递推不乘 ?
对于时间递推
但要注意,空间有两种,递推式不一样:
- 栈空间沿递归深度累加(每层一个栈帧,从根到叶子同时存在):
栈 栈 - 堆空间子问题复用,取峰值:
堆 堆 当 前 层 辅 助 空 间
一句话:栈空间加法,堆空间取 max。这正是 4.3 节「空间看树的最长路径」的递推形式。
4.5.2 递推求解示例
归并排序:
- 栈空间:递归深度
,每层栈帧 → - 堆空间:
merge的辅助数组 ,子问题依次执行、空间复用,峰值就是 - 合计:
(堆空间主导)
二分查找:
- 栈空间:递归深度
,每层栈帧 → - 堆空间:无
- 合计:
快速排序(最坏):
- 栈空间:已有序时退化成链,深度
,每层 → - 堆空间:
partition原地, - 合计:
Karatsuba 大整数乘法:
时间:
空间:栈
4.5.3 通用公式
对于分治递推
- 栈空间:
,其中栈 是递归深度 - 堆空间:
堆 第 层 同 时 存 在 的 辅 助 空 间
合计
常见情况速查:
| 每层额外空间 | 递归深度 | 空间复杂度 | 例子 |
|---|---|---|---|
| 二分查找、堆操作 | |||
| 递归斐波那契、最坏快排 | |||
| 归并排序、Karatsuba | |||
| 递归中每层拷贝数组 |
4.6 原地算法(In-place Algorithm)*
4.6.1 严格定义*
定义: 如果算法的辅助空间(不包括输入和输出)为
,则称其为原地算法。
4.6.2 常见算法的原地性分类*
| 算法 | 辅助空间 | 是否原地 | 说明 |
|---|---|---|---|
| 冒泡排序 | ✅ 严格原地 | 只需一个 temp 变量 | |
| 插入排序 | ✅ 严格原地 | 只需一个 key 变量 | |
| 堆排序 | ✅ 严格原地 | 在原数组上建堆 | |
| 快速排序 | ❌ 非严格原地 | 递归栈空间 | |
| 归并排序 | ❌ 非原地 | 辅助数组 | |
| 计数排序 | ❌ 非原地 | 计数数组 + 输出数组 | |
| 基数排序 | ❌ 非原地 | 桶空间 |
关于快排: 快排的
栈空间通常被认为"近似原地",因为 在实践中很小( 时 )。但严格数学定义上, ,所以快排不是原地算法。
4.6.3 原地算法的设计技巧*
| 技巧 | 说明 | 例子 |
|---|---|---|
| 交换操作 | 用 temp 或 XOR 交换,不需要额外数组 | 快排的 partition |
| 原地反转 | 首尾指针向中间交换 | 反转字符串 |
| 位运算压缩 | 用输入数据的空闲位存额外信息 | 原地哈希标记 |
| 输入做输出 | 在输入数组上直接修改 | 堆排序、快排 |
| 双指针 | 两个指针在原数组上移动 | 原地去重、荷兰国旗问题 |
4.7 空间复杂度常见陷阱
| 陷阱 | 错误分析 | 正确分析 |
|---|---|---|
| 混淆时间和空间 | 认为时间 | 空间只看深度,斐波那契空间 |
| 每层递归都算堆空间 | "归并每层都申请 | 辅助数组用完即释放,同一时刻只有一层占 |
| 忽略递归栈 | 只算堆空间,忘了递归本身也占栈 | 递归深度 × 栈帧大小 |
| 传值拷贝数组 | 每层递归拷贝整个数组 | 传引用/指针只占 |
| DP 不优化空间 | 直接开 | 滚动数组可降到 |
| 认为快排是原地的 | 严格来说快排不是原地算法 |
空间分析的核心口诀: 时间加总,空间看峰;递归看深,堆看最大。
4.8 时间与空间的权衡(Time-Space Tradeoff)*
核心思想: 时间和空间往往不可兼得——用空间换时间,或用时间换空间。
4.8.1 经典权衡案例*
| 权衡方向 | 例子 | 额外空间 | 时间收益 |
|---|---|---|---|
| 空间换时间 | 哈希表 vs 线性查找 | 查找从 | |
| 空间换时间 | 记忆化递归(fib) | 从 | |
| 空间换时间 | 预计算前缀和 | 区间和从 | |
| 时间换空间 | 原地反转 vs 拷贝反转 | 时间都是 | |
| 时间换空间 | 位运算代替数组 | 位操作略慢于数组索引 | |
| 空间换时间 | B树 vs 二叉搜索树 | B树节点更大 | 减少磁盘 I/O(更浅的树) |
4.8.2 记忆化的空间分析*
普通递归斐波那契: 时间
记忆化斐波那契:
int memo[MAX]; // O(n) 额外空间
int fib(int n) {
if (n <= 1) return n;
if (memo[n] != 0) return memo[n]; // 查表
return memo[n] = fib(n-1) + fib(n-2);
}- 时间:
(每个值只算一次) - 空间:
(memo 数组 + 递归深度 ) - 权衡:用
空间,把时间从 降到
4.8.3 递归 vs 迭代的空间对比*
| 算法 | 递归实现空间 | 迭代实现空间 | 说明 |
|---|---|---|---|
| 二分查找 | 迭代消除递归栈 | ||
| 归并排序 | 迭代版(自底向上)省了栈 | ||
| 快速排序 | 用显式栈模拟,空间不变 | ||
| DFS | 用显式栈模拟,空间不变 | ||
| 斐波那契 | 迭代只需两个变量 |
消除递归的通用方法: 用显式栈模拟递归调用。空间上通常不会更优(栈还是要用的),但可以避免系统栈溢出。某些情况下(如尾递归),可以完全消除栈。
4.9 特殊空间概念*
4.9.1 输入空间 vs 辅助空间*
- 输入空间: 存储输入数据所需的空间。对于
个元素的数组,输入空间为 。 - 辅助空间: 算法额外申请的空间,
。 - 空间复杂度通常指辅助空间。
4.9.2 输出空间*
如果算法的输出规模与输入不同(如排列生成所有
4.9.3 工作空间(Work Space)*
定义: 工作空间 = 辅助空间中,在算法运行过程中只读部分之外的空间。
对于原地算法,工作空间 =
4.9.4 空间层次定理*
Savitch 定理(1970): 对于任何
的空间可构造函数:
即非确定性空间
例子: 如果一个问题用非确定性算法在
与时间层次的对比: 时间的类似模拟需要指数级增长(
),而空间只需平方级。这说明空间比时间更"强"——空间可以复用,时间不行。
4.10 常见数据结构的空间复杂度速查
| 数据结构 | 存储空间 | 说明 |
|---|---|---|
数组 int arr[n] | 连续存储 | |
| 链表( | 每个节点还要存指针 | |
| 二叉树( | 每个节点 2 个指针 | |
| 哈希表( | 平均负载因子 < 1 | |
| 栈/队列( | ||
| 堆( | 数组实现 | |
| 图——邻接矩阵 | 稠密图 | |
| 图——邻接表 | 稀疏图 | |
| 并查集 | parent + rank 数组 | |
| 字典树(Trie) | ||
| 线段树 | 通常开 | |
| 树状数组(BIT) |