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. 查询次数多不多? 如果只查 1 次,建表本身就是浪费 —— 建表的 $O(n)$ 比直接扫一次的 $O(n)$ 还多了内存分配。
  2. 这 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) 反转一个数组(用两个下标 ij 交换,不用新数组) (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)$ —— 原地算法,只用了 ij 两个下标和一个临时变量。
  • (b) $O(n)$ —— 合并两个有序段时需要一块和原数组同规模的临时数组来存放结果。(存在 $O(1)$ 额外空间的原地归并变体,但实现复杂且常数很大,工程中很少用。)
  • (c) $O(n)$ —— 代码里没有任何数组,但递归深度是 $n$,每个未返回的调用都占一个栈帧。这是最容易漏算的一类空间成本。
  • (d) $O(n)$ —— (int[])source.Clone() 复制了一份完整数组。这是为了不破坏调用者的数据而付的代价,属于工程上正确的选择,但要意识到它是要花钱的。

1.4.2 至少要确认:

  1. 查询频率是多少? 每秒几次还是每秒几千次?如果每天只查几次,扫全表虽然慢一点但完全可接受,不值得付 400 MB。
  2. 这 400 MB 付得起吗? 服务当前的内存占用是多少?容器内存上限是多少?留出多少余量给峰值和 GC?这是小陈踩过的坑。
  3. 数据和索引需要实时更新吗? 如果日志是持续写入的,索引还要处理插入和失效,复杂度和内存都会继续涨。
  4. (补充)能不能用更省内存的方案? 比如只对最近 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)$。对延迟极度敏感的系统(如高频交易),这个抖动是需要专门处理的。
无脑用空间换时间 换之前先算清两件事:查询次数够不够多?内存余量够不够?

八、本节总结

  1. 空间复杂度算的是额外空间,不算输入本身。
  2. 递归的空间成本 = 递归深度,这是最容易被漏算的一笔账。
  3. 用空间换时间(前缀和)实测加速 170 倍,代价是 1.53 MB —— 划不划算取决于查询频率与内存预算。
  4. 摊还代价描述"连续多次操作的平均"。List<T> 用倍增扩容把单次最坏 $O(n)$ 的搬运摊还成 $O(1)$;换成固定增量则会退化成 $O(n)$。
  5. 存在"数据会变"的场景时,前缀和会失效,需要分块或树状数组 —— 本书不展开,但你要知道它们的存在。

下一节衔接:第 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 递归的两个必要条件"
}

results matching ""

    No results matching ""