第 1 章 算法思维与复杂度分析
本章解决的问题:你写的代码为什么在小数据下飞快、在大数据下崩掉?以及如何用一个统一的方式,在写代码之前就判断出这件事。
1.1 从"能跑"到"能扛":为什么需要算法分析
学习目标:学完本节,你能
- 解释为什么同样功能的两个实现,在真实数据量下性能可以差几百倍;
- 用「输入规模」和「基本操作次数」描述一段代码的代价;
- 在自己和同事的代码里找出被忽略的重复扫描。
先修:会写循环、方法,用过 List<T> 和 Dictionary<TKey, TValue>。
固定术语:数据结构、算法、输入规模、基本操作。
环境与版本:.NET 8 / C# 12。每段代码均附等效伪代码,可用任意语言对照。
预计阅读:25 分钟(含动手运行代码)。
一、直觉:一个上线事故
小陈给订单导入功能加了个"重复订单预警":客户导入的订单表里经常有同一个订单号出现多次,运营想知道一共有多少对重复订单,好评估影响面。
小陈写了个两层循环的版本。测试库 200 条数据,瞬间返回。上线后,客户导入了 5 万条订单,这个接口卡了半分钟。
老周看了一眼代码,只说了一句话:
"你这里套了两层循环。5 万条订单,你要比 12 亿次。"
代码能跑通,和代码能扛住真实数据,是两件不同的事。 本书要给你的第一件工具,就是判断这两者区别的能力。
用图书馆打个比方。你要数清楚有多少对书名相同的书:
- 做法 A:拿起第 1 本,和后面每一本比一遍;再拿起第 2 本,和后面每一本比一遍……书一多,比较次数的增长快得吓人。
- 做法 B:拿一张纸,边看书名边统计"这个名字我之前见过几次"。每本书只看一次。
书从 5000 本变成 50000 本(规模涨 10 倍):做法 A 的工作量涨约 100 倍,做法 B 只涨约 10 倍。
这个差别,就是本节要量化的东西。
二、形式化:换一种方式度量"快"
要比较两种做法,先统一度量方式。我们不数"秒" —— 秒取决于机器、语言、编译器版本,换台电脑结论就变了。
我们数两件事:
| 概念 | 定义 | 本节中的例子 |
|---|---|---|
| 输入规模(Input Size) | 问题有多大,本书统一记作 $n$ | 订单的条数 |
| 基本操作(Basic Operation) | 用来计量代价的最小步骤 | 比较两个订单号是否相同 |
于是问题从"哪个更快"变成:基本操作次数随 $n$ 怎么增长。
顺带固定两个后面天天要用的词:
- 算法(Algorithm):把输入变成输出的有限步骤序列。
- 数据结构(Data Structure):这些步骤组织与存储数据的方式。
同一个算法换用不同数据结构,代价可能天差地别 —— 下面的做法 B 就只是把"用数组硬找"换成了"用哈希表记录"。
三、推导:两种做法的代价
做法 A(朴素两两比较)
$n$ 条订单两两配对,一共有多少对?这是一个组合数:
$$\binom{n}{2} = \frac{n(n-1)}{2} \approx \frac{n^2}{2}$$
- 第 1 条要和后面 $n-1$ 条比;
- 第 2 条要和后面 $n-2$ 条比;
- ……
全部加起来正好就是 $\frac{n(n-1)}{2}$。
当 $n = 50{,}000$ 时:
$$\frac{50000 \times 49999}{2} \approx 1.25 \times 10^{9}$$
也就是老周说的"12 亿次"。
做法 B(一遍扫描 + 哈希表)
- 每条订单只处理一次;
- 每次做一次「查表 + 记录」。
总操作次数约为 $n$。当 $n = 50{,}000$ 时,约 $5 \times 10^{4}$ 次操作。
两者相差约 25,000 倍。
注意一个关键点:数据量再涨 10 倍,做法 A 的耗时涨 100 倍,差距还会继续拉大。做法 A 不是"慢一点",而是"数据一涨就完蛋"。
四、代码:亲手跑一遍
我们要数的是:有多少对订单的订单号是相同的。两种做法必须给出同一个数字,这也是下面程序末尾校验 结果一致 的意义。
新建一个控制台项目,把下面代码全部粘贴进 Program.cs:
using System.Diagnostics;
// ---------- 做法 A:两层循环,检查所有配对 ----------
static long CountEqualPairsSlow(int[] data)
{
long count = 0;
for (int i = 0; i < data.Length; i++)
{
for (int j = i + 1; j < data.Length; j++)
{
if (data[i] == data[j])
count++; // 注意:没有 break,每一对都必须检查
}
}
return count;
}
// ---------- 做法 B:一遍扫描 + 哈希表 ----------
static long CountEqualPairsFast(int[] data)
{
var seen = new Dictionary<int, int>(); // 值 -> 之前出现过多少次
long count = 0;
foreach (int x in data)
{
seen.TryGetValue(x, out int prev); // prev = 之前出现过几次(没有则为 0)
count += prev; // 与之前的每一个都构成一对
seen[x] = prev + 1;
}
return count;
}
// ---------- 造数据:固定随机种子,保证结果可复现 ----------
static int[] MakeData(int n)
{
var rng = new Random(42);
var a = new int[n];
for (int i = 0; i < n; i++)
a[i] = rng.Next(0, n / 10); // 取值范围偏小,保证有大量重复
return a;
}
// 每个规模跑 3 次,取最快的一次:最快值受 GC 与系统调度干扰最小
// 用微秒而不是毫秒:快速实现快到毫秒计不出来,用毫秒会显示成 0.0
static double MeasureFastest(Func<long> work, int repeats = 3)
{
double best = double.MaxValue;
for (int r = 0; r < repeats; r++)
{
var sw = Stopwatch.StartNew();
work();
sw.Stop();
if (sw.Elapsed.TotalMicroseconds < best)
best = sw.Elapsed.TotalMicroseconds;
}
return best;
}
// 预热:先让 JIT 编译好,避免第一轮测量被编译开销污染
_ = CountEqualPairsSlow(MakeData(500));
_ = CountEqualPairsFast(MakeData(500));
Console.WriteLine($"{"n",7} | {"慢速(μs)",10} | {"快速(μs)",9} | {"结果一致",8}");
foreach (int n in new[] { 5_000, 50_000 })
{
int[] data = MakeData(n);
long slow = CountEqualPairsSlow(data); // 先各跑一遍拿到结果
long fast = CountEqualPairsFast(data);
double slowUs = MeasureFastest(() => CountEqualPairsSlow(data));
double fastUs = MeasureFastest(() => CountEqualPairsFast(data));
Console.WriteLine($"{n,7} | {slowUs,10:F0} | {fastUs,9:F0} | {slow == fast,8}");
}
实测输出(机器:i5-8250U / .NET 8 Release;你的数字会不同,但倍率关系相同):
n | 慢速(μs) | 快速(μs) | 结果一致
5000 | 2796 | 46 | True
50000 | 239299 | 883 | True
这张表要横着看,也要竖着看。
竖着看($n$ 从 5000 涨到 50000,涨了 10 倍):
| 5000 | 50000 | 实际涨了 | 理论应该涨 | |
|---|---|---|---|---|
| 慢速 | 2796 μs | 239299 μs | 85.6 倍 | 100 倍($n^2$) |
| 快速 | 46 μs | 883 μs | 19.2 倍 | 10 倍($n$) |
窄慢速那一行几乎精确命中 $n^2$:规模涨 10 倍,耗时涨约 100 倍。这就是"代价随规模增长的方式",也是全书要反复使用的语言。
横着看(同一个 $n$ 下两种做法的差距):
- $n = 5000$ 时,慢了 61 倍;
- $n = 50000$ 时,慢了 271 倍。
差距随 $n$ 一起变大 —— 这正是"数据一涨就完蛋"的量化版本。
两个诚实的提醒
- 快速实现在 $n$ 涨 10 倍时涨了 19.2 倍,而不是理论上的 10 倍。这不是理论错了,而是哈希表在不断扩容、内存变大后 CPU 缓存开始失效、垃圾回收(GC)也开始介入。复杂度描述的是增长趋势,不是精确倍数。 这个话题我们在 16.2 节展开。
- 上表用的是微秒(μs)。如果按毫秒显示,快速实现那一列会全是
0.0—— 快到普通毫秒计时器测不出来。这本身就是一种说明。
等效伪代码(做法 B):
函数 统计重复对(数据 data):
已见 ← 空字典 // 值 -> 之前出现过几次
计数 ← 0
对 data 中每个元素 x:
之前 ← 已见 中 x 的次数(没有则为 0)
计数 ← 计数 + 之前 // x 与之前每一个相同的值都构成一对
已见[x] ← 之前 + 1
返回 计数
结束
五、练习
练习 1.1.1(热身) 下面这段代码,基本操作(加法)执行次数与 $n$ 是什么关系?
static int Sum(int[] a)
{
int s = 0;
foreach (var x in a) s += x;
return s;
}
练习 1.1.2(判断) 判断对错并说明理由:"一段代码在 $n = 100$ 时比另一段快,那么在 $n = 100{,}000$ 时也一定更快。"
练习 1.1.3(找隐藏扫描) 下面这段"去除重复值"的代码,为什么在小数据下很快、在大数据下会崩?指出隐藏的那个循环。
var unique = new List<int>();
foreach (var x in data)
{
if (!unique.Contains(x))
unique.Add(x);
}
练习 1.1.4(工程判断) 订单表有 50 万条记录,你需要找出所有金额大于 1000 的订单。有人提出"先按金额排序,再二分查找"。请说明什么情况下这个方案是多余的,什么情况下才划算。
练习 1.1.5(挑战·算量级)
你的服务每天处理 100 万次请求,每次请求都要在一个 1 万条记录的 List 上做一次线性查找。如果把 List 换成 HashSet,每天大约省下多少次基本操作?请写出计算过程。
练习 1.1.6(挑战·改代码)
把本节做法 A 的代码改成:一旦发现任意一对重复就立刻返回 true,否则返回 false。改完之后,它在"数据里有重复"和"数据里没有重复"两种情况下的耗时一样吗?为什么?这个差异我们在 1.3 节会正式命名。
六、练习答案
1.1.1 加法执行 $n$ 次,与 $n$ 成正比,是线性关系。遍历一次的代码基本都是这个量级。
1.1.2 错。 反例:实现甲需要 $n^2$ 次操作,实现乙需要 $100n$ 次操作。
| $n$ | 甲($n^2$) | 乙($100n$) | 谁快 |
|---|---|---|---|
| 100 | 10,000 | 10,000 | 打平 |
| 1,000 | 1,000,000 | 100,000 | 乙快 10 倍 |
| 100,000 | $10^{10}$ | $10^{7}$ | 乙快 1000 倍 |
小数据下的结论不能外推。这正是本书要用增长趋势、而不是秒表来下判断的原因。
1.1.3 隐藏的循环是 List.Contains(x)。它看起来是一个方法调用,内部却要从头到尾扫一遍列表。
外层循环 $n$ 次 × 内层平均 $n/2$ 次 = 约 $n^2/2$ 次比较。改法:把 unique 换成 HashSet<int>,Contains 变成接近常数时间,整体降为线性。
规律:看到在循环里调用
Contains、IndexOf、Find、Where、Any这类"在集合里找元素"的方法,先怀疑它内部是线性扫描。
1.1.4 如果只是查一次,一次遍历就够了,代价约为 $n$;排序需要 $n \log n$,反而更慢、还要额外内存 —— 属于多余。 只有在需要反复按金额区间查询(比如每秒几十次不同阈值的查询)时,预先排序 + 二分(每次约 $\log n$ 步)才划算。这是典型的「用一次性预处理换多次查询加速」。
1.1.5
- 现状:$10^6$ 次请求 × $10^4$ 次比较 = $10^{10}$ 次操作。
- 改成哈希表后:$10^6$ 次请求 × 约 1 次操作 = $10^{6}$ 次操作。
- 省下约 $10^{10} - 10^{6} \approx 10^{10}$ 次,即约 99.99% 的工作量。
(严格说哈希表平均是常数时间而非严格 1 次操作,但量级判断到此已经足够。)
1.1.6 改法:把内层循环里的 count++ 换成 return true,循环结束后 return false。
static bool HasDuplicate(int[] data)
{
for (int i = 0; i < data.Length; i++)
for (int j = i + 1; j < data.Length; j++)
if (data[i] == data[j])
return true; // 找到一对就收工
return false;
}
两种情况耗时完全不同:
- 有重复:可能第一对就命中,几毫秒返回。
- 没有重复:必须把所有 $\frac{n(n-1)}{2}$ 对都检查完才能确定"没有",跑满全程。
同一段代码,因为输入数据的差异,代价可以差上万倍。这三个不同的答案——最好情况、最坏情况、平均情况——就是 1.3 节的主题。
七、常见错误
| 误区 | 纠正 |
|---|---|
| 用"秒"下结论 | 秒数依赖机器、语言、编译模式。要比较的是随 $n$ 的增长趋势,不是绝对耗时。 |
| "小数据没问题 = 没问题" | $n$ 涨 10 倍,$O(n^2)$ 的耗时涨 100 倍。今天 200 条够用,明天 5 万条就卡死。 |
| 以为哈希表"免费" | 哈希表要额外内存,还要算哈希。$n$ 很小(比如 10)时,数组遍历往往反而更快、更省。 |
| 见到循环就优化 | $n$ 恒定很小的时候,清晰直白的写法比"更快的写法"更有价值。先测量,再优化。 |
| 认为复杂度能预测精确耗时 | 实测里快速实现涨了 19.2 倍而非 10 倍。复杂度说的是趋势,精确耗时还要看缓存、GC、内存分配。 |
八、本节总结
- 判断代码好坏,看的是基本操作次数随输入规模 $n$ 的增长方式,不是绝对秒数。
- 一次遍历 ≈ $n$ 次操作;两两配对 ≈ $\frac{n^2}{2}$ 次操作。实测中 $n$ 从 5000 涨到 50000,两者差距从 61 倍扩大到 271 倍。
- 换数据结构(数组 → 哈希表)能在算法不变的前提下改变量级 —— 这就是数据结构值得单独学的原因。
- 同一段代码在不同输入上代价可能天差地别(练习 1.1.6)。
- 在循环里调用
Contains/IndexOf这类方法,是最常见的性能陷阱。
下一节衔接:本节我们用"$\frac{n^2}{2}$"和"$n$"来比较两种做法,但每次都写公式太啰嗦,而且 $\frac{n^2}{2}$ 里的 $\frac{1}{2}$ 其实无关紧要($n$ 翻倍时它不变)。下一节引入大 O 记法,用一套统一写法把这种比较压缩成 $O(n^2)$ 与 $O(n)$。
状态外显
{
"book": "数据结构与算法自学:从工程直觉到复杂度思维",
"section": "1.1",
"title": "从\"能跑\"到\"能扛\":为什么需要算法分析",
"covered": [
"输入规模 n 与基本操作的定义",
"用操作次数而非秒数度量代价",
"n(n-1)/2 的配对推导",
"数组两两比较 vs 哈希表一遍扫描",
"可运行且已实测的 C# 代码",
"循环内调用 Contains/IndexOf 的陷阱"
],
"unresolved": [
"大 O 的正式写法留到 1.2",
"最好/最坏/平均的区分留到 1.3(练习 1.1.6 已埋伏笔)",
"缓存与 GC 导致的实测偏差留到 16.2",
"哈希表内部原理留到第 6 章"
],
"canonical_terms": {
"数据结构": "组织与存储数据的方式,决定操作的代价",
"算法": "把输入变成输出的有限步骤序列",
"输入规模": "问题的大小,记号 n",
"基本操作": "用来计量代价的最小步骤"
},
"symbols_units": {
"n": "输入规模",
"C(n,2)": "n 个元素两两配对的组合数,等于 n(n-1)/2",
"μs": "微秒,百万分之一秒"
},
"assumptions": [
"读者会写 C# 循环、方法,用过 List<T> 与 Dictionary<TKey,TValue>",
"读者环境为 .NET 8 / C# 12(或可读伪代码)"
],
"word_count_actual": 1090,
"checks": [
"C# 代码已在 .NET 8 (SDK 10.0.303) Release 下实跑,输出见正文,倍率关系已核对",
"项目文件:99-tools/samples/Ch01/Sec11/",
"算法语义已用独立参考实现交叉校验(Python),边界用例全部通过",
"练习答案含关键步骤,不只给结果",
"术语写法与 glossary.md 一致"
],
"known_issues": [
"初稿用 break 提前退出,导致实测复杂度远低于 n^2(n=20000 仅 7ms),与正文推导矛盾;已改为必须检查所有配对的版本,实测倍率 85.6 倍与理论 100 倍相符",
"初稿两种实现语义不一致(一个数不同重复值个数,一个数重复出现位置数),已统一为「相等的配对数」"
],
"next": "1.2 大 O 记法:描述增长,而不是秒数"
}