1.4 空间复杂度与时间-空间权衡
学习目标:学完本节,你能
- 计算一个算法的额外空间复杂度,包括递归的栈空间;
- 说出「用空间换时间」的典型场景,并算清这笔交易的账;
- 解释
List<T>的扩容策略,理解什么是摊还代价。
先修:1.3(三种情况)、3.1 之前只需知道数组按下标访问是 $O(1)$。 固定术语:空间复杂度、原地算法、摊还代价。 环境与版本:.NET 8 / C# 12。 预计阅读:22 分钟。
一、直觉:内存也是要还的
前面三节我们只算了一笔账:时间。但程序跑起来还要吃内存,而内存比你想的贵。
小陈遇到过一件事:他为了加速查询,把一张 200 万行的订单表全量读进内存建了个哈希索引,接口确实快了 40 倍。三个月后,服务开始在凌晨批量任务运行时被系统杀掉 —— 内存不够了。
用空间换时间是笔好生意,但你得先知道价格。
先明确一个概念:
空间复杂度(Space Complexity)指的是算法运行额外需要的空间,不包括输入本身占的空间。
为什么要排除输入?因为输入是你必须付的成本,无论用什么算法都得付。真正能比较的是"为了实现这个算法,我额外还要花多少内存"。
| 说法 | 含义 | 例子 |
|---|---|---|
| 原地算法(In-place) | 额外空间 $O(1)$,只用几个临时变量 | 冒泡排序、堆排序、反转数组 |
| 额外空间 $O(n)$ | 需要一个和输入同规模的辅助结构 | 归并排序的临时数组、前缀和、哈希索引 |
| 额外空间 $O(\log n)$ | 递归调用栈的深度 | 二分查找(递归版)、快排的递归栈 |
二、递归的空间成本
递归代码常常看起来很干净,但它的空间开销是藏起来的。
static int Sum(int[] a, int i)
{
if (i == a.Length) return 0; // 基准情形
return a[i] + Sum(a, i + 1); // 递归情形
}
这段代码调 Sum(a, 0) 处理 10 万个元素时,会同时存在 10 万个未返回的函数调用,每一个都占着一个栈帧。这就是为什么它会抛 StackOverflowException。
结论:递归算法的空间复杂度 = 递归深度 × 每个栈帧的大小。递归深度是 $n$ 时,空间就是 $O(n)$,哪怕代码里一个数组都没建。
2.2 节会专门演示栈溢出,并告诉你如何估算递归深度上限。
三、实验一:用空间换时间(前缀和)
问题:一个 20 万条的销售额数组,需要反复回答"从第 $l$ 条到第 $r$ 条的区间和是多少"。
做法 A(不预处理):每次查询都把区间扫一遍,每次 $O(n)$。
做法 B(前缀和):先花 $O(n)$ 建一个前缀和数组,之后每次查询只用两个前缀和相减,$O(1)$。
原理:设 prefix[i] 表示前 $i$ 个元素的和,那么
$$\text{区间和}(l, r) = \text{prefix}[r+1] - \text{prefix}[l]$$
因为"前 $r+1$ 个的和"减去"前 $l$ 个的和",剩下的恰好是第 $l$ 到第 $r$ 个。
新建控制台项目,粘贴代码:
using System.Diagnostics;
// ============ 实验一:用 O(n) 额外空间,把区间求和从 O(n) 降到 O(1) ============
// 不预处理:每次查询都要把区间扫一遍,O(n)
static long RangeSumSlow(int[] a, int l, int r)
{
long s = 0;
for (int i = l; i <= r; i++) s += a[i];
return s;
}
// 预处理出前缀和数组,额外空间 O(n)
static long[] BuildPrefixSums(int[] a)
{
var prefix = new long[a.Length + 1];
for (int i = 0; i < a.Length; i++)
prefix[i + 1] = prefix[i] + a[i];
return prefix;
}
// 有前缀和之后,区间和 = 两个前缀和之差,O(1)
static long RangeSumFast(long[] prefix, int l, int r)
=> prefix[r + 1] - prefix[l];
const int N = 200_000;
const int Queries = 10_000;
var rng = new Random(42);
var data = new int[N];
for (int i = 0; i < N; i++) data[i] = rng.Next(1, 100);
// 先生成好所有查询区间,两种做法查的是同一批区间,结果才可比
var queries = new (int L, int R)[Queries];
for (int i = 0; i < Queries; i++)
{
int l = rng.Next(0, N);
int r = rng.Next(l, N);
queries[i] = (l, r);
}
// ---- 做法一:不预处理 ----
var sw = Stopwatch.StartNew();
long sumSlow = 0;
foreach (var (l, r) in queries) sumSlow += RangeSumSlow(data, l, r);
sw.Stop();
double slowMs = sw.Elapsed.TotalMilliseconds;
// ---- 做法二:先建前缀和,再查询 ----
sw.Restart();
long[] prefix = BuildPrefixSums(data); // 一次性 O(n) 预处理
long sumFast = 0;
foreach (var (l, r) in queries) sumFast += RangeSumFast(prefix, l, r);
sw.Stop();
double fastMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine("=== 实验一:区间求和,10000 次查询,数据量 20 万 ===");
Console.WriteLine($" 不预处理(每次 O(n)) : {slowMs,10:F1} ms");
Console.WriteLine($" 前缀和(建表 O(n),查询 O(1)): {fastMs,10:F1} ms");
Console.WriteLine($" 结果一致: {sumSlow == sumFast}");
Console.WriteLine($" 额外空间: 一个 long[{N + 1}],约 {(N + 1) * 8 / 1024.0 / 1024.0:F2} MB");
Console.WriteLine();
实测输出:
=== 实验一:区间求和,10000 次查询,数据量 20 万 ===
不预处理(每次 O(n)) : 119.2 ms
前缀和(建表 O(n),查询 O(1)): 0.7 ms
结果一致: True
额外空间: 一个 long[200001],约 1.53 MB
这笔交易的账:
| 不预处理 | 前缀和 | |
|---|---|---|
| 预处理 | 无 | $O(n)$,一次性 |
| 每次查询 | $O(n)$ | $O(1)$ |
| 1 万次查询总耗时 | 119.2 ms | 0.7 ms(快 170 倍) |
| 额外空间 | $O(1)$ | 1.53 MB |
用 1.53 MB 换了 170 倍的速度。 但这笔交易划不划算,取决于两件事:
- 查询次数多不多? 如果只查 1 次,建表本身就是浪费 —— 建表的 $O(n)$ 比直接扫一次的 $O(n)$ 还多了内存分配。
- 这 1.53 MB 付得起吗? 20 万条数据是 1.53 MB,2000 万条就是 153 MB。在容器内存受限的环境里,这可能就是压垮服务的最后一根稻草 —— 正是小陈遇到的那个问题。
经验法则:预处理换查询加速,只有在"查询次数足够多、且额外内存可承受"时才划算。
四、实验二:List<T> 的摊还代价
现在看一个反过来的问题:有些操作的代价不是均匀的。
List<T> 底层是数组。数组长度固定,那 List<T> 是怎么做到"想加多少加多少"的?
它用的是倍增扩容:容量不够时,申请一块两倍大的新数组,把老元素全部搬过去。在实验一的同一个项目里,把下面代码加到末尾:
// ============ 实验二:List<T> 的扩容策略与摊还代价 ============
Console.WriteLine("=== 实验二:List<int> 的扩容过程 ===");
var list = new List<int>();
int lastCapacity = list.Capacity;
long totalMoved = 0;
int expandCount = 0;
Console.WriteLine($"初始容量: {lastCapacity}");
for (int i = 0; i < 100_000; i++)
{
list.Add(i);
if (list.Capacity != lastCapacity)
{
totalMoved += list.Count; // 这次扩容把已有的元素都搬了一遍
expandCount++;
// 只打印前 8 次,以及容量达到 65536 之后的两次,中间省略
if (expandCount <= 8 || list.Capacity >= 65_536)
Console.WriteLine($" 第 {list.Count,7} 个元素时扩容: {lastCapacity,7} -> {list.Capacity,7}");
lastCapacity = list.Capacity;
}
}
Console.WriteLine();
Console.WriteLine($"总插入次数 : {list.Count,10}");
Console.WriteLine($"总扩容次数 : {expandCount,10}");
Console.WriteLine($"总搬运元素次数 : {totalMoved,10}");
Console.WriteLine($"平均每次插入搬运: {(double)totalMoved / list.Count,10:F2} 次");
Console.WriteLine();
Console.WriteLine("每次 Add 都要搬运吗?不需要。绝大多数 Add 是 O(1),");
Console.WriteLine("只有少数几次扩容是 O(n),平摊下来仍是 O(1) —— 这就是摊还代价。");
实测输出:
=== 实验二:List<int> 的扩容过程 ===
初始容量: 0
第 1 个元素时扩容: 0 -> 4
第 5 个元素时扩容: 4 -> 8
第 9 个元素时扩容: 8 -> 16
第 17 个元素时扩容: 16 -> 32
第 33 个元素时扩容: 32 -> 64
第 65 个元素时扩容: 64 -> 128
第 129 个元素时扩容: 128 -> 256
第 257 个元素时扩容: 256 -> 512
第 32769 个元素时扩容: 32768 -> 65536
第 65537 个元素时扩容: 65536 -> 131072
总插入次数 : 100000
总扩容次数 : 16
总搬运元素次数 : 131084
平均每次插入搬运: 1.31 次
这张表要这样读:
- 扩容次数只有 16 次(100,000 次插入里)。绝大多数
Add根本不扩容,就是往数组下一个空位放一个值,$O(1)$。 - 总搬运 131,084 次,平均到每次插入只有 1.31 次 —— 是个常数。
- 所以:单次
Add最坏是 $O(n)$(正好赶上扩容),但摊还下来是 $O(1)$。
这个"平摊下来"的说法就叫摊还代价(Amortized Cost):
摊还代价:一系列操作的总代价除以操作次数。它描述的是连续多次操作的平均,而不是任何单次的代价。
为什么是常数? 因为每次扩容都翻倍,搬运的总量构成一个等比数列:
$$4 + 8 + 16 + \cdots + 131072 < 2 \times 131072 = 262144$$
等比数列的和总是小于最后一项的 2 倍。所以搬运总量始终小于 $2n$,摊还到每次插入就是 $O(1)$。
List<T>性能建议:如果你已经知道要放多少元素,用new List<int>(capacity)预先指定容量,可以完全避免扩容搬运。这是 C# 里最省事、收益最明确的一条微优化。
五、练习
练习 1.4.1(算空间)
判断下列算法的额外空间复杂度,并说明理由:
(a) 反转一个数组(用两个下标 i、j 交换,不用新数组)
(b) 归并排序
(c) 递归计算 $n$ 的阶乘
(d) 1.3 节里的插入排序(它先 Clone 了一份数组)
练习 1.4.2(交易判断) 你要为一个 500 万条记录的日志表做查询。有两种方案:
- A:不建索引,每次查询扫全表。
- B:建一个哈希索引,占约 400 MB 内存,查询降到接近 $O(1)$。 请列出至少三个需要向业务方确认的问题,再决定选哪个方案。
练习 1.4.3(摊还计算) 某动态数组的扩容策略不是翻倍,而是每次只增加 1000 个容量。请分析它的摊还代价,并说明为什么这个策略比倍增差。(提示:算一算插入 $n$ 个元素总共要搬运多少次。)
练习 1.4.4(挑战·权衡)
前缀和方案在"数据会变"时会出问题:如果数组中间某个元素被修改了,整个 prefix 数组从那个位置往后都要重算,代价 $O(n)$。请说明这个缺陷在什么场景下会是致命的,并给出一个直觉上的改进方向(不必写代码)。
六、练习答案
1.4.1
- (a) $O(1)$ —— 原地算法,只用了
i、j两个下标和一个临时变量。 - (b) $O(n)$ —— 合并两个有序段时需要一块和原数组同规模的临时数组来存放结果。(存在 $O(1)$ 额外空间的原地归并变体,但实现复杂且常数很大,工程中很少用。)
- (c) $O(n)$ —— 代码里没有任何数组,但递归深度是 $n$,每个未返回的调用都占一个栈帧。这是最容易漏算的一类空间成本。
- (d) $O(n)$ ——
(int[])source.Clone()复制了一份完整数组。这是为了不破坏调用者的数据而付的代价,属于工程上正确的选择,但要意识到它是要花钱的。
1.4.2 至少要确认:
- 查询频率是多少? 每秒几次还是每秒几千次?如果每天只查几次,扫全表虽然慢一点但完全可接受,不值得付 400 MB。
- 这 400 MB 付得起吗? 服务当前的内存占用是多少?容器内存上限是多少?留出多少余量给峰值和 GC?这是小陈踩过的坑。
- 数据和索引需要实时更新吗? 如果日志是持续写入的,索引还要处理插入和失效,复杂度和内存都会继续涨。
- (补充)能不能用更省内存的方案? 比如只对最近 7 天的热数据建索引,冷数据走全表扫描。
关键判断:如果查询频率不高,或者内存余量紧张,A 方案反而是对的 —— 这正是本节开头说的"先算清价格再交易"。
1.4.3
每次扩容固定增加 1000,那么插入 $n$ 个元素需要扩容约 $\frac{n}{1000}$ 次。
第 $k$ 次扩容要搬运约 $1000k$ 个元素,总搬运量为
$$1000 + 2000 + \cdots + 1000 \cdot \frac{n}{1000} = 1000 \cdot \left(1 + 2 + \cdots + \frac{n}{1000}\right) = 1000 \cdot \frac{\frac{n}{1000}\left(\frac{n}{1000}+1\right)}{2} \approx \frac{n^2}{2000}$$
总搬运量是 $O(n^2)$,摊还到每次插入是 $O(n)$ —— 比倍增策略的 $O(1)$ 差了整整一个量级。
为什么差这么多? 因为搬运总量构成的是等差数列(求和得 $n^2$),而不是等比数列(求和得 $2n$)。倍增的关键作用就是让"上一次搬过的元素,在很久以后才需要再搬一次"。固定增量则会让扩容越来越频繁。
这就是为什么 .NET、Java、C++ 的动态数组全都采用倍增策略,而不是固定增量。
1.4.4
致命场景:数据频繁修改 + 查询也频繁。
具体的例子:一个实时更新的排行榜,用户的分数每秒都在变,同时每秒有上千次"查询某段排名的总分"的请求。每次分数变化都要重算 $O(n)$ 的前缀和,查询的 $O(1)$ 优势瞬间被抵消,整体反而比直接扫描更慢。
改进方向(直觉层面):
- 分块:把数组切成若干块,每块维护自己的和。修改时只重算所在块,查询时把"完整的块 + 两端不完整的部分"加起来。这就把单次修改从 $O(n)$ 降到了 $O(\sqrt{n})$。
- 树形结构:让每一层都维护一部分和,修改和查询都是 $O(\log n)$。这类结构叫树状数组(Fenwick Tree)或线段树(Segment Tree)。
这两个结构超出了本书的中级范围,但你应该知道它们存在,并且知道它们要解决的就是"前缀和不能应对修改"这个问题。面试中如果被问到"数据会变怎么办",答出"分块或树状数组"就已经足够。
七、常见错误
| 误区 | 纠正 |
|---|---|
| 算空间时把输入本身也计入 | 空间复杂度指的是额外空间。输入是你无论如何都要付的成本,不参与算法之间的比较。 |
| 忘记递归的栈空间 | 递归深度为 $n$ 时,额外空间是 $O(n)$,哪怕代码里一个容器都没建。 |
| 认为"空间复杂度"只是内存大小 | 递归还会带来栈溢出的风险。$n$ 很大时,$O(n)$ 的栈空间可能直接让程序崩溃,而不只是慢。 |
| 认为摊还 $O(1)$ 等于每次都是 $O(1)$ | 摊还是"平均下来"的意思。某一次 Add 撞上扩容仍然是实打实的 $O(n)$。对延迟极度敏感的系统(如高频交易),这个抖动是需要专门处理的。 |
| 无脑用空间换时间 | 换之前先算清两件事:查询次数够不够多?内存余量够不够? |
八、本节总结
- 空间复杂度算的是额外空间,不算输入本身。
- 递归的空间成本 = 递归深度,这是最容易被漏算的一笔账。
- 用空间换时间(前缀和)实测加速 170 倍,代价是 1.53 MB —— 划不划算取决于查询频率与内存预算。
- 摊还代价描述"连续多次操作的平均"。
List<T>用倍增扩容把单次最坏 $O(n)$ 的搬运摊还成 $O(1)$;换成固定增量则会退化成 $O(n)$。 - 存在"数据会变"的场景时,前缀和会失效,需要分块或树状数组 —— 本书不展开,但你要知道它们的存在。
下一节衔接:第 1 章到此结束。你现在有了三件工具:用增长趋势代替秒表(1.1–1.2)、用三种情况说清楚"在什么输入下"(1.3)、用空间账衡量交易是否划算(1.4)。下一章我们处理一个更根本的思维工具 —— 递归。它是后面树、图、分治、动态规划的共同语言,也是最多人"看得懂但写不出"的地方。
状态外显
{
"book": "数据结构与算法自学:从工程直觉到复杂度思维",
"section": "1.4",
"title": "空间复杂度与时间-空间权衡",
"covered": [
"额外空间的定义与原地算法",
"递归栈空间 = 递归深度",
"前缀和:O(n) 空间换 O(1) 查询(实测 170 倍)",
"List<T> 倍增扩容的实测过程",
"摊还代价的定义与等比数列求和论证",
"固定增量扩容为何退化为 O(n) 摊还",
"在线修改场景下前缀和失效,引出分块/树状数组"
],
"unresolved": [
"递归与栈溢出留到 2.1-2.2",
"归并排序的空间代价留到 8.1",
"树状数组/线段树超出本书范围,仅提及存在性"
],
"canonical_terms": {
"空间复杂度": "算法运行额外需要的空间,不含输入本身",
"原地算法": "额外空间 O(1) 的算法",
"摊还代价": "一系列操作的总代价除以操作次数"
},
"symbols_units": {
"MB": "兆字节",
"prefix[i]": "前 i 个元素的和"
},
"assumptions": [
"读者已掌握前三节内容",
"读者知道 List<T> 是基于数组实现的(3.2 节会详解)"
],
"word_count_actual": 1310,
"checks": [
"C# 代码已在 .NET 8 (SDK 10.0.303) Release 下实跑,输出见正文",
"项目文件:99-tools/samples/Ch01/Sec14/",
"两个实验的结果一致性与数值均已核对:119.2ms vs 0.7ms、100000 次插入共 131084 次搬运",
"练习答案含关键步骤,不只给结果",
"术语写法与 glossary.md 一致"
],
"known_issues": [
"正文中 List 扩容示例的输出注释写明「只打印前 8 次与容量达到 65536 之后的两次」,与实际输出(10 行)一致"
],
"next": "2.1 递归的两个必要条件"
}