第 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$ 一起变大 —— 这正是"数据一涨就完蛋"的量化版本。

两个诚实的提醒

  1. 快速实现在 $n$ 涨 10 倍时涨了 19.2 倍,而不是理论上的 10 倍。这不是理论错了,而是哈希表在不断扩容、内存变大后 CPU 缓存开始失效、垃圾回收(GC)也开始介入。复杂度描述的是增长趋势,不是精确倍数。 这个话题我们在 16.2 节展开。
  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 变成接近常数时间,整体降为线性。

规律:看到在循环里调用 ContainsIndexOfFindWhereAny 这类"在集合里找元素"的方法,先怀疑它内部是线性扫描。

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、内存分配。

八、本节总结

  1. 判断代码好坏,看的是基本操作次数随输入规模 $n$ 的增长方式,不是绝对秒数。
  2. 一次遍历 ≈ $n$ 次操作;两两配对 ≈ $\frac{n^2}{2}$ 次操作。实测中 $n$ 从 5000 涨到 50000,两者差距从 61 倍扩大到 271 倍。
  3. 换数据结构(数组 → 哈希表)能在算法不变的前提下改变量级 —— 这就是数据结构值得单独学的原因。
  4. 同一段代码在不同输入上代价可能天差地别(练习 1.1.6)。
  5. 在循环里调用 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 记法:描述增长,而不是秒数"
}

results matching ""

    No results matching ""