数据结构与算法设计 · 第 01 讲

第 01 讲 绪论:数据结构与算法分析

这一讲不写复杂算法,只做一件事:把「数据结构」这四个字拆开揉碎,让你知道后面 14 讲要学的每一个结构 究竟在解决什么问题;再交给你一把尺子——大 O 记号,用来衡量任何一个算法的快慢与省费。

预计 90 分钟 前置:C++ 基础语法(变量 / 数组 / 函数 / 指针) 关键词:逻辑结构 · 存储结构 · 抽象数据类型 · 时间复杂度 · 空间复杂度
本章导读
  • 先立概念:数据、数据元素、数据项、数据对象四个词经常被混用,考试却专挑它们出选择题。
  • 再分两层:逻辑结构(跟计算机无关的关系)与存储结构(内存里怎么摆),两层分清,后面所有结构都是这两层的组合。
  • 然后抽象:抽象数据类型 ADT 把「能做什么」和「怎么做」彻底分开,这是工程代码能长期维护的根本原因。
  • 最后量尺:算法效率不看秒表看渐近,本章给出大 O 的严格定义、三条推导法则、8 道手推例题。
  • 回到机器:1.16 换到工程视角,看内存层次、缓存行与数据规模分布如何让「大 O 相同」的两个算法实测差出几倍。
  • 顺手打底:末尾 43 条中英对照术语表覆盖全课程,建议打印出来贴在书桌前。

1.1 四个底层术语:数据、数据元素、数据项、数据对象

这四个词是层层包含的,先在最熟悉的场景——学生成绩表——里把它们一次性认清:

数据 data
能输入计算机并被程序识别处理的符号集合。整数 65 与字符 'A' 在内存里都是 0x41——同一份存储,不同解释即不同数据。
数据项 data item
不可再分的最小单位,也叫字段。如「学号 = 1001」;再往下拆成 1、0、0、1 就失去含义了。
数据元素 data element
数据的基本单位,由若干数据项组成,也叫记录结点。如「张三 1001 计科1班 92」这一整条记录。
数据对象 data object
性质相同的数据元素的集合。全班成绩记录格式一致,构成一个数据对象。

要特别注意数据对象与数据结构的区别:数据对象回答「有哪些成员」,数据结构回答 「成员之间是什么关系」。下面这张图把四者的包含关系画了出来——

数据 data:一切能输入计算机并被程序处理、具有含义的符号集合 数据对象:学生成绩表 data object —— 性质相同的数据元素的集合 数据元素 1:张三 1001 计科1班 92 数据元素 2:李四 1002 计科1班 78 数据元素 3:王五 1003 计科2班 85 … 共 n 条记录,每一条都是一个数据元素 … (在链表 / 树 / 图中,也常被称为「结点 node」) 取出一条 一个数据元素(一条记录 / 一个结点) 数据项 1:学号 = 1001 数据项 2:姓名 = 张三 数据项 3:班级 = 计科 1 班 数据项:不可再分割的最小单位(也叫「字段 field」) 数据元素:数据的基本单位,由若干数据项组成 数据对象:性质相同的数据元素的集合,是数据的子集 层级关系:数据 ⊇ 数据对象 ⊇ 数据元素 ⊇ 数据项
图 1-1 数据、数据对象、数据元素、数据项四者的层级关系(以学生成绩表为例)

1.1.1 数据结构的严格定义

把上面的铺垫收拢,给出教材上那个必须一字不差记住的定义:

定义 1-1 数据结构(data structure) 数据结构相互之间存在一种或多种特定关系的数据元素的集合。 换句话说,数据结构 = 数据元素 + 元素之间的关系。没有关系的一堆数据只是「一袋土豆」, 戴上关系这顶帽子,它才成为「结构」。

这个定义里最值钱的是「关系」两个字。数组里有下标相邻关系,链表里有指针指向关系, 二叉树里有父子关系,图里有邻接关系。所以学一个新结构时,第一句要问的就是: 它的元素之间到底是什么关系?

但这还不完整。只谈关系,算法没法运行,因为关系必须落到内存上。于是数据结构包含三个方面的内容:

数据结构 = 逻辑结构 + 存储结构(物理结构) + 数据的运算

这三者的关系可以用一句话概括:逻辑结构决定「能做什么运算」,存储结构决定「这些运算要花多少代价」。 后面第 1.4 节会用顺序表和链表把这个结论演示得非常具体。

数据结构 ① 逻辑结构 ② 存储结构(物理) ③ 数据的运算 集合 / 线性 / 树形 / 图形 面向问题,与计算机无关 研究「元素之间是什么关系」 顺序 / 链式 / 索引 / 散列 面向机器,与语言实现有关 研究「关系在内存里怎么表示」 增 / 删 / 改 / 查 / 遍历 / 排序 定义在逻辑结构上 实现依赖存储结构,代价不同 存储结构是逻辑结构的「机器投影」;运算效率由二者共同决定 记忆口诀:逻辑结构管「关系」 · 存储结构管「落地」 · 运算管「操作」 · 三者合起来才叫数据结构
图 1-2 数据结构的三要素:逻辑结构、存储结构与数据的运算
一个常见误解 不少同学把「数据结构」等同于「存储结构」,于是觉得「数组是一种数据结构,链表是另一种数据结构」。 更准确的说法是:线性表是逻辑结构,顺序表和链表是它的两种存储实现。 把这句话想通,第 1.4 节就无须再讲。

1.2 逻辑结构:四类基本结构

逻辑结构只关心一件事:元素之间有没有关系、是什么关系。按关系的复杂程度,可以把常见的逻辑结构 分成四类:集合结构、线性结构、树形结构、图形结构(网状结构)。 这四类几乎覆盖了本课程后面全部的章节,所以务必把它们的图示刻进脑子里。

1.2.1 集合结构:只有「同属一伙」这一种关系

集合结构中的元素除了「同属于一个集合」之外,没有任何其他关系。元素之间没有次序、没有层级、 没有邻接。它是四类里关系最弱的一种。

典型的例子是并查集(Union-Find)里维护的等价类,或者 C++ 的 std::set: 你只关心「某个元素在不在这个集合里」,不关心它排第几、旁边是谁。 集合结构的运算通常是:判断元素是否属于集合、求并集、求交集、求差集。

1.2.2 线性结构:一对一的前驱后继

线性结构就是一条链:所有元素排成一列,每个元素最多只有一个直接前驱、一个直接后继, 并且链条有头有尾——恰好一个元素没有前驱(首元素),恰好一个元素没有后继(尾元素)。 教材上那四条性质,说的就是这一件事。

记忆抓手:只数「两个 1」 判断是不是线性结构,不必背四条,只数两个数:每个元素的前驱数 ≤ 1,后继数 ≤ 1。 满足就是「线」,不满足就是「网」。首尾那两个特殊元素,只是为了保证这条线是完整的一条

线性结构是本课程最庞大的家族:线性表、栈、队列、双端队列、串、数组都属于线性结构。 它们之间的区别只在于「限制在哪里」——栈限制只能在一端插入删除,队列限制一端进另一端出, 串限制元素是字符且操作以「子串」为单位。抓住这条主线,第 2~6 讲会非常轻松。

1.2.3 树形结构:一对多的层次关系

树形结构中,数据元素之间存在一对多的层次关系:每个元素最多有一个直接前驱(父结点), 但可以有多个直接后继(孩子结点)。树形结构最贴近现实世界的组织方式——文件目录、家族谱系、 公司组织架构、HTML 文档结构,全都是树。

树形结构天生适合表达「分类」与「层次」:从根到叶的一条路径就是一次「细分」。 也正因为层次的存在,树上的查找、插入、删除可以做到 O(log n)(平衡树), 这是线性结构做不到的——这就是「结构决定算法」的最好例证。

1.2.4 图形结构(网状结构):多对多

图形结构中,数据元素之间存在多对多的任意关系:任一元素都可以与任意多个其他元素相邻。 图是四类逻辑结构里表达能力最强、也最难处理的:交通网、社交网络、课程先修关系、 状态机、依赖关系图,都只能用它描述。

强表达能力的代价是算法复杂度。比如「求两点间最短路径」,在树上是唯一的简单路径(沿树走即可), 在图上却要考虑指数级的路径数量,于是才有了 Dijkstra、Floyd、Bellman-Ford 这些算法。

1 2 3 4 5 元素间无任何连线
图 1-3 集合结构(关系:同属一个集合)
a1 a2 a3 a4 一对一:唯一前驱 / 唯一后继 除首尾外每个元素前后各一个
图 1-4 线性结构(关系:一对一)
A B C D E F
图 1-5 树形结构(关系:一对多的层次)
1 2 3 4
图 1-6 图形结构(关系:多对多,任意相连)

1.2.5 换个切法:线性结构与非线性结构

四类结构还可以再粗分一刀,这也是考试里出现频率极高的一个划分: 线性结构(线性表、栈、队列、串、数组)与非线性结构(集合、树、图)。 判断标准只有一个:元素之间是否是一对一的线性关系

划分包含的逻辑结构关系特征本课程对应章节典型运算代价
线性结构 线性表、栈、队列、双端队列、串、数组 一对一,有唯一首元素与唯一尾元素 第 02 ~ 06 讲 按位置访问 O(1)(顺序存储)或 O(n)(链式)
非线性结构 集合、树形结构、图形结构 一对多(树)、多对多(图)、无关系(集合) 第 07 ~ 09 讲 树查找 O(log n)、图遍历 O(V+E)
逻辑结构 线性结构(一对一) 非线性结构(一对多 / 多对多) 线性表 队列 串 / 数组 (都只有唯一前驱与后继) 集合 (关系不再是一对一)
图 1-7 逻辑结构的两种分法:四分类与「线性 / 非线性」二分
易错点:「数组是线性结构」要说清是在哪一层 说「数组是线性结构」,指的是它的逻辑结构——元素一个一个排下去,每个最多一个前驱、一个后继。 这与它在内存里怎么摊开是两件事:二维数组为了塞进一维内存,必须人为约定「先横着排还是先竖着排」, 于是才有了行优先 / 列优先的地址计算。那是存储层面的选择,不改变它的逻辑结构(第 06 讲细讲)。

1.3 存储结构(物理结构):四种落地手段

逻辑结构是纸上谈兵,它必须被放进计算机的存储器里才能真正跑起来。存储结构(也叫物理结构) 研究的就是:数据元素本身怎么存,元素之间的关系怎么存。请注意这句话有两半,很多人只做了前一半。 只把数据丢进内存是不够的,你还得把「谁挨着谁」「谁指向谁」也存下来——而表示关系的方式, 恰恰是区分不同存储结构的唯一标准。

教材上把存储结构分成四种:顺序存储、链式存储、索引存储、散列存储。前两种是基础, 后两种是「加外挂」的思路。下面逐一拆解。

1.3.1 顺序存储:用「物理相邻」表示「逻辑相邻」

顺序存储把逻辑上相邻的元素存放在物理位置也相邻的存储单元里,元素之间的逻辑关系 不需要额外空间来记录——因为「挨着」这件事本身就蕴含了「相邻」这个关系。 这就是顺序存储最漂亮的地方:关系是免费的

由于每个元素占用的字节数固定,第 i 个元素的地址可以直接算出来:

LOC(ai) = LOC(a1) + (i − 1) × sizeof(ElemType)

有了这个公式,随机访问(random access)就成了可能:访问第 1 个元素和访问第 10⁶ 个元素, 花的时间完全一样,都是 O(1)。代价也很直接——插入和删除时,为了保持「物理相邻」, 必须成片地搬移元素,最坏 O(n)。另外,顺序存储通常要求预先分配一段连续空间, 空间不够要扩容(重新申请 + 整体拷贝),空间富余则浪费。

典型例子:C++ 的 std::vectorstd::array、字符串的字面量存储、 完全二叉树的顺序存储(第 07 讲)。

1.3.2 链式存储:用「指针」显式记录关系

链式存储不要求物理相邻,它在每个元素上附加指针(pointer),用指针的取值 显式地指出下一个元素在哪儿。这时「关系」不再免费——每个结点都要多花几个字节来存指针。 换来的是巨大的灵活性:

典型例子:单链表、双向链表、循环链表、静态链表(用数组下标模拟指针)、 二叉链表(第 07 讲)、邻接表(第 08 讲)、散列表的链地址法。 一句话总结:顺序存储用「地址的算术」换「关系空间」,链式存储用「关系空间」换「地址的算术」

1.3.3 索引存储:主表 + 索引表的二级结构

索引存储同时维护两份数据:一份是存放全部元素的数据表(主表),另一份是 索引表,索引表的每一项形如「关键字 → 该记录的存储地址」。查找时先查索引表定位, 再按地址直接取记录。

它的优点是检索速度快(索引表通常远小于数据表,可以常驻内存甚至做成树形索引), 缺点是多了一份索引的空间开销,而且插入、删除记录时必须同步维护索引表, 否则索引就失效了。现实中的数据库索引(B+ 树)、图书目录、字典的部首表都是这个思路。 注意索引存储里的数据表本身既可以是顺序的,也可以是链式的——这再次说明 存储结构的四种划分不是互斥的,而是可以叠加的

1.3.4 散列存储:由关键字直接算出地址

散列存储(哈希存储)用散列函数 h(key) 直接由关键字算出存储地址, 理想情况下「查找一次命中」,平均时间复杂度 O(1)。它最激进的地方在于: 它放弃了元素之间的顺序关系。散列表里元素是「乱」的,所以你没法做范围查询 (「查所有成绩在 80~90 之间的学生」),也没法按序遍历。

另一个必须面对的问题是冲突(collision):不同的 key 可能算出同一个地址。 解决办法有开放定址法、链地址法、再散列法、公共溢出区法四种,第 10 讲会逐一展开, 并教你手算平均查找长度 ASL。这里先记住结论:散列存储是用「顺序性」换「常数级查找」

存储结构关系怎么表示随机访问插入 / 删除空间特点典型例子
1双层嵌套T(n) = 2n² + 2n + 2外层 n × 内层 nO(n²)
2i *= 2 / i /= 2k ≈ log₂n + 1通项 i = 2kO(log n)
3i = i * i / i = √ik ≈ log₂log₂n + 1通项 i = 22kO(log log n)
4归并排序 T(n) = 2T(n/2) + cnT(n) = cn·log₂n + O(n)归并在规模 n、n/2、…、2 这 log₂n 层,每层 cn;规模 1 那层只 return,不归并O(n log n)
5二分查找 T(n) = T(n/2) + cT(n) = c(log₂n + 1)每层常数代价 × log 层O(log n)

下面这张图是本章最重要的一张图:同一个逻辑结构(线性表)分别用顺序存储与链式存储实现。 请特别留意图中标注的内存地址指针箭头的差别。

① 逻辑结构:线性表 L = (a1, a2, a3, a4) —— 元素之间是一对一的前驱后继关系 a1 a2 a3 a4 逻辑相邻 ≠ 物理相邻,逻辑结构不规定存放位置 ② 顺序存储:逻辑相邻 ⇒ 物理相邻,地址可以算出来 1000 1004 1008 1012 a1 a2 a3 a4 下标 0 下标 1 下标 2 下标 3 相邻元素地址相差 sizeof(ElemType) = 4 字节 地址连续 ⇒ 第 i 个元素地址可直接计算 ⇒ 随机访问 O(1) 插入 / 删除需整体搬移后继元素 ⇒ 最坏 O(n) 容量固定,扩容要重新申请并整体拷贝 ③ 链式存储:结点 = 数据域 + 指针域,地址任意(不必连续) 2000 2104 2020 2160 a1 next a2 next a3 next a4 NULL head 2104 2020 2160 · 结点之间靠指针 next 相连,内存地址可以不连续(2000 → 2104 → 2020 → 2160) · 想找第 i 个结点,必须从 head 顺着指针走 i 步,无法直接算地址 ⇒ 按位查找 O(n);已知前驱时插入只需改两条指针 ⇒ O(1)
图 1-8 同一个逻辑结构(线性表)的两种存储结构对照:上部是逻辑结构,中部是顺序存储,下部是链式存储
索引存储 index storage:给「专业」建一张索引表 某大学 70 万名学生,分在 100 个专业里,每个专业正好 7000 人;学生记录按「同专业放在一起」连续存放。 现在要查「计算机专业学号 1005 的学生」——查得快不快,全看有没有下面这张索引表。 索引表:只有 100 项,一项 = 一个专业的起始位置 专业 起始记录号 计算机 第 0 条起 数学 第 7000 条起 物理 第 14000 条起 数据表:70 万条记录,按专业分块连续存放 计算机7000 人 数学7000 人 物理7000 人 …其余专业97 个 一共 100 个这样的块,合起来 70 万条 索引直接指到计算机块的第 1 条 不建索引:只能从第 1 条开始逐条比对专业,直到碰上「计算机」——最坏要翻过 99 个专业、约 69.3 万条记录。 → 查找代价:最多约 70 万次(等于把整张表扫一遍) 建了索引:先在只有 100 项的索引表里二分查找(log₂100 ≈ 7 次比较)定位到「计算机」,   再从它后面的 7000 人里逐个比学号 → 查找代价:7 + 最多 7000 次 这就是索引的全部意义:把「翻遍 70 万条」变成「查 100 项 + 翻 7000 条」,快了约 100 倍;索引本身只占 100 项的空间,非常划算。
图 1-9 索引存储:给「专业」建索引后,查一个学生从「最多约 70 万次」降到「7 + 最多 7000 次」,快约 100 倍
散列存储 hash storage:不建索引、也不排序,由关键字直接算出该去哪个桶 演示:4 个桶(编号 0~3),散列函数 h(key) = key % 4。把 key 丢进去,算出的余数就是它该待的桶号。 下面往表里放 3 个 key:1001、1009、2002,看看它们各自去哪。 ① 由 key 算出桶号(一次取模,不需要任何比较) 1001 % 4 = 1 1009 % 4 = 1 2002 % 4 = 2 桶号 ② 桶数组:[0] 和 [3] 空着,1001 与 1009 撞进同一个桶 [0] [1] 1001 张三 1009 李四 ← 链 两个 key 都算出 1,这叫冲突;查 1009 要在这个桶里比 2 次 [2] 2002 王五 桶里只有它一个,比 1 次就找到 [3] 优势:定位一个 key 只要「算一次 %」+「看这一个桶」, 与总记录数 n 无关——这是真正的 O(1), 比索引存储的 O(log k) 还快一步。 代价一:会冲突,桶里要再逐个比较(如上) 代价二:元素是「乱」的,顺序性被放弃, 不能做「取成绩 80~90 之间的学生」这类范围查询 所以散列存储是「用顺序性换常数级查找」:查单个 key 最快,但按顺序、按范围的需求它就无能为力了(第 10 讲细讲)。 注:桶数取 4 只为画得下;真实实现里桶数是几万到几百万,且元素变多时会自动扩容。
图 1-10 散列存储:由关键字直接算出桶号(O(1) 定位),代价是必须处理冲突、且放弃顺序性

1.4 逻辑结构与存储结构的关系

1.4.1 为什么逻辑结构独立于存储结构

逻辑结构描述的是「元素之间的关系」,这个关系是数学意义上的关系,与元素在内存第几个字节毫无关系。 同一份逻辑关系,你可以用 C++ 写,也可以用 Python 写;可以放在 32 位机器上,也可以放在 64 位机器上。 逻辑结构不变,只是因为它压根没提过「地址」这两个字。

这个「独立性」不是文字游戏,它带来两个非常实用的好处:

  1. 可以脱离实现思考算法。分析算法时我们只关心「这个操作要访问几个元素」, 不关心机器是几核、内存多快。这就是大 O 记号能够成立的前提——它只统计运算次数的增长趋势
  2. 可以换实现而不改接口。今天用顺序表实现了栈,明天发现数据量太大需要换成链栈, 只要 ADT 接口不变,调用方的代码一行都不用改。这就是 1.5 节要讲的抽象数据类型。

1.4.2 为什么同一逻辑结构可以有多种存储结构

因为「关系」只是一个约束,满足这个约束的物理排布方式往往有很多种。线性表要求「一对一、有首有尾」, 那么:

它们全都满足线性结构的逻辑约束,但性能特征截然不同。所以选存储结构,本质是在时间、空间、 实现复杂度之间做取舍——这也是本课程后面每一章都在重复的动作。

1.4.3 存储结构如何影响算法效率:一个必须亲手算一遍的例子

「取线性表中第 i 个元素」这个运算,在逻辑结构层面是一模一样的:给定位置 i,返回对应元素。 但换成不同存储结构,代价的增长方式完全不同。

顺序表:元素地址 = 首地址 + (i-1) × 元素大小。一次乘法加一次访存, 与表长 n 无关——取第 1 个和取第 100 万个耗时一样,所以是 O(1)。

单链表:第 i 个结点在哪里?没有人知道。必须从 head 出发, 沿着 next 一步一步走 i-1 次,与 i 成正比,最坏(i = n)走 n 步,所以是 O(n)。

注意:两种不同的方式,实现同一个逻辑,竟然差了 n 倍 上面两个操作在逻辑上完全一样——都是「取第 i 个元素」、都是「在第 i 个位置插入」, 但换一种存储方式,代价就相差 n 倍。 这正是「存储结构影响算法效率」最直接的证据,也是本章最该记住的一句话。

反过来,在「已知前驱结点 p」的位置插入一个新元素:链表只要改两条指针,O(1); 顺序表却要把 p 后面的所有元素整体后移,O(n)——这一次是链表快 n 倍。 所以没有绝对的好坏,只有场景的匹配:读多写少、需要按下标随机访问时选顺序表; 频繁在中间插删、且已经拿到位置信息时选链表。这也正是「结构决定算法」最直观的一课。

// seqlist_access.cpp —— 顺序存储:逻辑相邻 ⇒ 物理相邻,按位查找 O(1)
//
// 为什么顺序表能「一步到位」?因为第 i 个元素的地址可以直接算出来:
//     LOC(a_i) = LOC(a_1) + (i - 1) * sizeof(int)
// 一次乘法 + 一次访存,跟表长 n 毫无关系 —— 这就是随机存取 O(1)。
// 代价藏在插入里:为了保持物理相邻,位序 i 及其后的元素必须整体后移。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;              // 容量:这类题 n 一般 <= 1e5,一次开够,不做扩容
int a[MAXN];                          // 数据就放在这段连续空间里:下标相邻 = 物理相邻
int len = 0;                          // 元素个数

// 按位查找(GetElem):与 i 和 len 都无关
int getElem(int i) {                  // i 是位序,从 1 开始
    if (i < 1 || i > len) return -1;  // 越界返回 -1,竞赛里不抛异常
    return a[i - 1];                  // 一次地址计算:&a[i-1] = &a[0] + (i-1)*sizeof(int)
}

// 插入(ListInsert):位序 i 及其后的元素整体后移一位 → 最坏 O(n)
// 长度为 n 时有 n+1 个可插入位置,插在第 i 个位置要搬 n-i+1 个元素,
// 等概率下平均搬移 (n + (n-1) + ... + 0) / (n+1) = n/2 个 —— 仍是 O(n)
// 返回本次搬移的元素个数(-1 表示位置非法),方便亲手数一数代价
int insertAt(int i, int x) {
    if (i < 1 || i > len + 1 || len == MAXN) return -1;
    int moved = 0;
    for (int k = len; k >= i; --k) { a[k] = a[k - 1]; ++moved; }
    a[i - 1] = x;
    ++len;
    return moved;
}

int main() {
    for (int i = 1; i <= 5; ++i) insertAt(len + 1, i * 10);   // 尾插:10 20 30 40 50
    printf("第 4 个元素 a[3] = %d(算一次地址就取到,O(1))\n", getElem(4));
    printf("地址公式实测:&a[3] - &a[0] = %lld 字节 = 3 * sizeof(int)\n",
           (long long)&a[3] - (long long)&a[0]);
    printf("头部插 999 要搬 %d 个元素(最坏 O(n))\n", insertAt(1, 999));
    printf("现在第 1 个元素 = %d,表长 = %d\n", getElem(1), len);
    return 0;
}
// linklist_access.cpp —— 链式存储:靠指针串联,按位查找 O(n)
#include <iostream>

struct Node {
    int val;
    Node* next;
    Node(int v, Node* n = nullptr) : val(v), next(n) {}
};

// 按位查找:必须从头指针出发走 i 步,无法直接算出地址,最坏 O(n)
Node* locate(Node* head, int i) {
    Node* p = head;
    while (p != nullptr && i-- > 0) p = p->next;
    return p;
}

// 已知前驱结点 p 时的插入:只改两条指针,与表长无关 → O(1)
void insertAfter(Node* p, int v) {
    Node* s = new Node(v, p->next);
    p->next = s;
}

int main() {
    Node* head = nullptr;
    for (int i = 5; i >= 1; --i) head = new Node(i * 10, head);
    std::cout << locate(head, 3)->val << '\n';   // 40:走了 3 步,O(n)
    insertAfter(head, 999);                      // O(1)
    std::cout << locate(head, 1)->val << '\n';   // 999
    return 0;
}
逻辑结构:线性表 顺序表 单链表 / 双向链表 静态链表 散列表 数组存数据 逻辑相邻 = 物理相邻 随机访问 O(1) 插删要搬元素 O(n) 结点 + 指针 物理位置任意 按位查找 O(n) 已知前驱插删 O(1) 用数组下标当游标 不依赖指针类型 容量固定 需维护空闲链表 关键字 → 地址 平均查找 O(1) 顺序性丢失 不支持范围查询 存储结构可以随便换,逻辑结构始终不变 —— 这就是「逻辑结构独立于存储结构」的含义
图 1-11 一个逻辑结构,多种存储实现:换的是代价,不换的是语义
考点 三种代价的对比 设线性表长度为 n,在下表所列操作上,两种存储结构的复杂度差异是必考内容:
运算顺序存储(顺序表)链式存储(单链表)原因
按位查找第 i 个O(1)O(n)地址可否直接计算
按值查找O(n)O(n)都得逐个比较
已知位置插入 / 删除O(n)O(1)是否需要搬移后继元素
表尾追加O(1)(均摊)O(1)(保留尾指针)或 O(n)是否维护尾指针
存储密度(数据占比)= 1< 1(要存指针)是否需要额外关系空间

1.5 抽象数据类型 ADT:把「能做什么」和「怎么做」分开

1.5.1 ADT 的定义

抽象数据类型(Abstract Data Type, ADT)是指一个数学模型以及定义在该模型上的一组操作。 它由三部分组成:

ADT 的关键词是「抽象」。抽象的意思是:只保留「做什么」的说明,隐藏「怎么做」的细节。 你家里的电视机遥控器就是一个完美的 ADT——它告诉你「按 + 键音量变大」, 但绝不告诉你红外编码怎么发、功放电路怎么调。你也不需要知道。

在 C++ 里,ADT 可以用类(class)来落地:用 public 成员函数描述基本操作(接口), 用 private 成员描述内部实现(数据与细节);教材还常配上模板(template)支持泛型、 用纯虚函数定义接口契约。但请记住一句话:类只是表达 ADT 的一种语法外壳。 ADT 真正规定的是「有哪些操作、每个操作是什么语义」,至于这些操作用成员函数写还是用普通函数写, ADT 并不关心。竞赛代码里没有类、没有模板、没有虚函数,我们直接用 「全局数组 + 一组自由函数」表达同一个 ADT:函数名与教材 ADT 里的操作一一对应。

1.5.2 线性表 ADT 的接口约定(C++)

下面这段代码就是「线性表」这个 ADT 的完整规格说明。开头注释里那段用伪码写的形式化描述, 是教材里描述 ADT 的标准格式:数据对象 D、数据关系 R、基本操作 P;紧接着的每一个自由函数, 就是这份约定里的一个基本操作。

// list_adt.cpp —— 线性表的 ADT:先把「能做哪些事」定下来,不管「怎么做」
#include <bits/stdc++.h>
using namespace std;

/* ADT 的规格说明(教材用伪码写,这里保留原味):
     ADT List {
       数据对象:D = { a_i | a_i ∈ ElemSet, i = 1..n, n ≥ 0 }
       数据关系:R = { <a_(i-1), a_i> | i = 2..n }        // 一对一的前驱后继
       基本操作:InitList / ListLength / GetElem / ListInsert / ListDelete / ListTraverse
     } ADT List

   注意:上面一个字都没提「数据放数组还是链表、容量怎么扩」——
   那些属于实现,不属于 ADT。接口一旦定下,实现可以有好几份。 */

const int MAXN = 100005;
int a[MAXN], n = 0;                       // 存储细节:调用方看不见这些

void initList()            { n = 0; }     // 清空
int  listLength()          { return n; }  // 求长度
int  getElem(int i)        { return a[i - 1]; }        // 取第 i 个
bool listInsert(int i, int x) {                        // 在第 i 个位置插入 x
    for (int k = n; k >= i; --k) a[k] = a[k - 1];
    a[i - 1] = x; ++n; return true;
}
bool listDelete(int i, int &e) {                       // 删除第 i 个,用 e 带回其值
    e = a[i - 1];
    for (int k = i - 1; k < n - 1; ++k) a[k] = a[k + 1];
    --n; return true;
}
void listTraverse() {                                  // 依次访问每个元素
    for (int i = 0; i < n; ++i) printf("%d%c", a[i], i + 1 == n ? '\n' : ' ');
}

int main() {
    initList();
    for (int i = 1; i <= 5; ++i) listInsert(listLength() + 1, i * 10);
    printf("长度 %d,第 3 个是 %d,内容:", listLength(), getElem(3));
    listTraverse();
    int e; listDelete(1, e);
    printf("删掉 %d 后剩 %d 个:", e, listLength());
    listTraverse();
    return 0;
}

1.5.3 「抽象」到底体现在哪里

判断一个设计是否「抽象」,有一个很实用的测试:把实现整个换掉,调用方的代码需要改几行? 如果一行都不用改,说明抽象是干净的;如果要改十处,那说明调用方偷偷依赖了实现细节。

// adt_client.cpp —— 体会 ADT 的作用:内部实现全换了,调用方一行都不用改
//
// 同一个线性表,用两套完全不同的存储各实现一遍:
//   数组版:元素在连续内存里         链表版:结点散在堆上,靠指针串起来
// 而调用方 use() 只认操作名和语义,根本不知道背后是哪种写法。
#include <bits/stdc++.h>
using namespace std;

namespace arrImpl {                    // 实现一:顺序存储
    int a[1005], n = 0;
    void init() { n = 0; }
    int  size() { return n; }
    void insert(int i, int x) {        // 第 i 个位置插入:后继整体后移 O(n)
        for (int k = n; k >= i; --k) a[k] = a[k - 1];
        a[i - 1] = x; ++n;
    }
    void traverse() { for (int i = 0; i < n; ++i) printf("%d ", a[i]); }
}

namespace linkImpl {                   // 实现二:链式存储(带头结点)
    struct Node { int val; Node *nxt; };
    Node *head = nullptr;
    void init() { head = new Node{0, nullptr}; }
    int  size() { int c = 0; for (Node *p = head->nxt; p; p = p->nxt) ++c; return c; }
    void insert(int i, int x) {        // 先走到第 i-1 个结点,再改两条指针
        Node *p = head;
        for (int k = 1; k < i; ++k) p = p->nxt;
        p->nxt = new Node{x, p->nxt};
    }
    void traverse() { for (Node *p = head->nxt; p; p = p->nxt) printf("%d ", p->val); }
}

/* ★ 下面这一行决定用哪个实现:改成 linkImpl 即可,其余一个字都不用动。
     用「命名空间 + 别名」表达「同一组操作」,比类加虚函数省事得多。 */
namespace impl = arrImpl;

/* 调用方:只用了 ADT 约定的那几个操作名,看不到、也没用到任何实现细节。
   所以 impl 指向谁,这段代码都不用改 —— 这就是抽象的价值。 */
void use() {
    impl::init();
    for (int i = 1; i <= 5; ++i) impl::insert(i, i * i);
    printf("元素个数 = %d,内容:", impl::size());
    impl::traverse();
    printf("\n");
}

int main() {
    use();                             // 输出:元素个数 = 5,内容:1 4 9 16 25
    return 0;
}

抽象之一:隐藏表示

调用方只会调用那几个操作函数,看不到内部的数组 a、计数器 n、 链表头 head,也不知道数据是连续放的还是散着放的。 表示的改变被隔离在实现里,不会像涟漪一样扩散到全工程。

抽象之二:定下数据对象

ADT 的第一部分「数据对象」规定了这个类型里能放哪些数据。教材用 template <typename T> 把它做成泛型,一套代码同时服务 intstring、自定义的 Student;竞赛里题目只考一种元素类型, 直接写 int / long long 就够了——这层抽象由题面替你定死。

抽象之三:分离语义与代价

ADT 规定了操作的语义(做什么),而代价(多快)交给实现。 于是「同一个 GetElem(i) 在顺序表里是 O(1)、在链表里是 O(n)」, 这正是第 1.4 节的结论。

工程视角 抽象数据类型是「面向接口编程」在数据结构课里的第一次登场。真实项目里,一个模块的接口 设计得好不好,几乎决定了它三年后还能不能被维护。当你想不清楚怎么实现时, 先把 ADT 写出来——把「要提供哪些操作」列清楚了,实现往往是水到渠成的事。

1.6 算法及其特性

1.6.1 算法的定义

算法(algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列, 其中每条指令表示一个或多个操作。更通俗地说:算法就是「把输入变成输出的一套确定步骤」。

程序 = 数据结构 + 算法,这句话是 Pascal 之父沃斯(Niklaus Wirth)说的, 它点明了一个事实:数据结构负责「把数据摆好」,算法负责「按步骤处理」, 两者缺一不可。只有好的结构没有算法,数据是死物;只有算法没有合适的结构, 再聪明的算法也会被低效的存储拖垮。

1.6.2 算法的五大特性

特性含义不满足时会发生什么典型反例
有穷性
finiteness
算法必须在执行有穷步之后结束,且每一步都在有穷时间内完成 程序永远不返回,用户等到天荒地老 用 while 循环求「所有自然数之和」
确定性
definiteness
每条指令有确切含义,无二义性;相同输入在任何时候都产生相同输出 同一份数据跑两次结果不同,无法调试 「若 x 较大则……」却没说多大算较大
可行性
effectiveness
每一步都能机械地执行,并且能在有限时间内完成;可以只由基本运算构成 写出计算机根本做不到的步骤 「若方程无实根,则跳过」这类无法机械判定的描述
输入
input
0 个或多个输入(0 个也算:如求 1+2+…+100 的固定算式)
输出
output
必须有 1 个或多个输出,没有输出的算法毫无意义 算完什么也不留下,等于白算 只做计算却从不使用结果的代码
最容易搞混的两个:有穷性 vs 可行性 有穷性说的是「整个算法会结束」,是宏观的;可行性说的是「每一步都能做到」, 是微观的。一个算法可以每一步都可行,但整体不终止(死循环),此时它违反的是有穷性而不是可行性。 反过来,如果某一步是「求出任意大整数的精确阶乘并立刻打印」,在有限字长的机器上这一步不可行, 哪怕它会结束,也违反可行性。

1.6.3 算法与程序的区别

很多人把两者当成同义词,其实它们有明确区别:

比较维度算法 algorithm程序 program
存在形式一种解决问题的方法 / 思想,可以脱离语言存在用某种程序设计语言写成的具体代码
有穷性必须满足,否则不能叫算法可以不满足:操作系统、服务器主循环会一直运行
描述精度可以用自然语言、流程图、伪码描述必须严格符合语言的语法与语义
是否含 IO / 交互只关心「计算」,不关心界面与文件可以包含输入输出、图形界面、网络通信
关系算法 + 数据结构 + 语言实现 = 程序;一个算法可以有无数个程序实现

一句话记住:程序可以永远不结束,算法不行。这也是为什么我们能在纸上分析算法, 却往往难以在纸上分析一个完整程序。

1.6.4 评价算法的四个维度

同一个问题往往有很多算法,怎么比?教材给了四个维度,注意它们是有优先级的:

  1. 正确性(correctness)——最高优先级。算法应当能正确处理合法输入,并且对典型、 苛刻、边界输入都能给出符合规格说明的结果。一个跑得飞快但答案是错的算法,价值是负的。
  2. 可读性(readability)——第二优先级。算法首先是给人看的,其次才是给机器执行的。 可读性差的代码无法维护、无法调试、无法被别人复用。变量名 a1a2tmp2 泛滥的代码,两周后连作者自己都看不懂。
  3. 健壮性(robustness)——面对非法输入时的表现。用户输入了负数、字符串、空指针, 算法应该给出恰当反应(报错、返回错误码、使用默认值),而不是崩溃或悄悄算出一个错答案。
  4. 效率(efficiency)——包括时间效率(运行快慢)与存储效率(占用空间大小), 两者往往互相矛盾:想快就得多花内存(空间换时间),想省内存就得多算几遍(时间换空间)。

为什么把效率排在最后?因为前三个不达标时,效率毫无意义。但反过来说, 当输入规模达到 10⁶、10⁷ 时,效率就成了唯一的瓶颈——一个 O(n²) 的「正确」算法 在 n = 10⁶ 时要跑上万亿次操作,根本等不到它输出正确答案。这也是本讲后半部分 要花大力气研究复杂度分析的原因。

1.7 算法效率的度量:从掐秒表到渐近分析

1.7.1 事后统计法 vs 事前分析估算法

想知道一个算法快不快,最直接的办法就是跑一遍看时间。这叫事后统计法。 它直观,但作为研究方法几乎不可用,原因有四条:

  1. 必须先把程序写出来并跑起来,如果算法本身是错的或写不出来,就无从统计;
  2. 时间依赖于机器硬件(CPU 主频、缓存大小)、编译选项-O0 还是 -O2)、 运行时环境(有没有别的进程抢 CPU),换个环境结论就变了;
  3. 测试数据规模太小看不出差别,规模太大又等不起;不测到 n = 10⁶ 以上, 往往分辨不出 O(n log n) 和 O(n²);
  4. 只能比较「已实现的几个算法」,无法预测「换一种思路会不会更好」。

所以我们改用在纸面上就能做的事前分析估算法:认为算法运行时间正比于其中基本操作的执行次数, 只考察当问题规模 n 增大时这个次数的增长趋势,忽略常数与低阶项。 这就是「渐近分析」,也是整门课衡量算法优劣的统一语言。

对比项事后统计法事前分析估算法
时机程序写完之后算法设计阶段即可分析
依赖条件硬件、编译器、测试数据、运行环境只依赖算法本身的控制结构
结论形式具体毫秒数(会随环境变化)渐近量级,如 O(n log n)
能否预测大规模行为不能能,正是它的目的
典型用途工程调优时最后的实测验证算法选型、考试分析、论文比较

1.7.2 语句频度:把「时间」换成一个可以数的量

语句频度(statement frequency)指一条语句在算法中被重复执行的次数。 算法中所有语句的频度之和记作 T(n),它是问题规模 n 的函数。我们约定: T(n) 越大,算法执行时间越长(两者成正比,比例常数由机器决定,我们不去管它)。

来看一个最经典的例子,请顺便留意计数口径的问题:

// count_ops.cpp —— 亲手统计语句频度,观察 T(n)/n^2 收敛到常数
#include <iostream>
#include <iomanip>

// 计数口径:统计「条件判断 + 循环体」这两类语句的执行次数
long long T(long long n) {
    long long c = 0;
    ++c;                                  // 语句 1:long long x = 0;
    for (long long i = 0; i < n; ++i) {
        ++c;                              // 语句 2:外层条件判断(成立)  执行 n 次
        for (long long j = 0; j < n; ++j) {
            ++c;                          // 语句 3:内层条件判断(成立)  执行 n^2 次
            ++c;                          // 语句 4:循环体 x++            执行 n^2 次
        }
        ++c;                              // 语句 5:内层条件判断(不成立)执行 n 次
    }
    ++c;                                  // 语句 6:外层条件判断(不成立)执行 1 次
    return c;
}

int main() {
    std::cout << std::fixed << std::setprecision(4);
    long long ns[] = {1, 2, 5, 10, 100, 1000, 10000};
    for (long long n : ns) {
        long long t = T(n);
        std::cout << "n = " << std::setw(6) << n
                  << "   T(n) = " << std::setw(12) << t
                  << "   T(n)/n^2 = " << (double)t / (double)(n * n) << '\n';
    }
    return 0;
}
// 输出节选:n=1000 时 T(n)=2002002,T(n)/n^2 = 2.0020 —— 比值稳定在 2 附近,
// 说明 T(n) 与 2n^2 同阶,记作 T(n) = O(n^2)。低阶项 2n+2 在 n 变大后被彻底淹没。
语句执行次数(语句频度)说明
long long c = 0;1只执行一次
外层 for 条件判断 i < n(成立)n每轮外层循环一次
外层 for 条件判断(不成立、退出)1循环退出时多判一次
内层 for 条件判断 j < n(成立)外层 n 轮 × 每轮 n 次
内层 for 条件判断(不成立)n外层每轮多判一次
循环体 x++真正干活的部分
合计 T(n) = 2n² + 2n + 2最高阶项:n²
为什么不同教材算出的式子不一样? 同一段代码,有的教材写成 T(n) = 3n² + 2n + 1,有的写成 2n² + 2n + 2,还有的干脆只算循环体得到 n²。 差别就在计数口径:算不算 for 的初始化?算不算最后一次失败的判断? 但请注意:无论怎么数,最高阶项都是 n²,系数都是常数。 这正是大 O 记号干脆把系数和低阶项全部扔掉的理由——它们本就不携带「增长趋势」的信息。

1.7.3 大 O 记号的严格定义

上面我们反复说「同阶」,现在把它变成一个可以写进证明里的定义。这是本章必须逐字记住的第二个定义:

定义 1-2 大 O 记号(渐进上界) 设 f(n) 和 g(n) 是定义在正整数集上的两个函数。若存在正常数 c正整数 n₀, 使得当 n ≥ n₀ 时,恒有
0 ≤ f(n) ≤ c · g(n)
则称 f(n) 的阶不高于 g(n),记作 f(n) = O(g(n))

把定义拆成三块来看,每一块都有它的用意:

所以大 O 的本质是:g(n) 是 f(n) 的一个渐进上界(asymptotic upper bound), f 的增长不会快于 g。注意「上界」不等于「恰好等于」:

3n² + 2n + 1 = O(n²) ✔ (取 c = 6、n₀ = 1 即可:3n²+2n+1 ≤ 6n² 对 n ≥ 1 成立)
3n² + 2n + 1 = O(n³) ✔ (上界可以很松,c = 1、n₀ = 6)
3n² + 2n + 1 = O(2ⁿ) ✔ (更松的上界,没人这么写,但定义上成立)

既然 3n²+2n+1 同时等于 O(n²)、O(n³)、O(2ⁿ),那等号岂不是失效了?没错—— 大 O 里的「=」不是等价关系,而应当读作「属于」。 严格的写法是 f(n) ∈ O(g(n)),表示 f 这个函数落在 O(g) 这个函数集合里。 只是习惯上我们写等号,读作「是……阶的」。我们平时说「这个算法是 O(n²) 的」, 隐含的意思是「这是我们能给出的最紧的上界」。

1.7.4 顺带认识 Ω 与 Θ:下界与紧确界

大 O 只描述上界,那么「至少要花多少时间」呢?这就要用到 Ω;而当我们既能给出上界又能给出下界时, 就用 Θ 把两者合起来。

记号名称形式化定义直观含义例子
O 渐进上界
upper bound
存在 c > 0、n₀ > 0,使 n ≥ n₀ 时 0 ≤ f(n) ≤ c·g(n) f 增长不快于 g(最坏也就这样) 冒泡排序 = O(n²)
Ω 渐进下界
lower bound
存在 c > 0、n₀ > 0,使 n ≥ n₀ 时 0 ≤ c·g(n) ≤ f(n) f 增长不慢于 g(至少要这么多) 比较排序 = Ω(n log n)
Θ 渐进紧确界
tight bound
存在 c₁, c₂ > 0、n₀ > 0,使 n ≥ n₀ 时 c₁·g(n) ≤ f(n) ≤ c₂·g(n) f 与 g 同阶(把 f 夹在两条 c·g 之间) 归并排序 = Θ(n log n)
o / ω 严格上界 / 严格下界 任意 c > 0 都成立(相当于不取等号) f 严格地比 g 低阶 / 高阶 n = o(n log n),n log n = ω(n)

三者的关系一句话说清:Θ(g) = O(g) ∩ Ω(g)。 如果一个算法既满足 f = O(g) 又满足 f = Ω(g),那么 f = Θ(g)。 例如 3n² + 2n + 1:它既是 O(n²) 又是 Ω(n²),所以是 Θ(n²)——这才是关于它最准确的描述。

那为什么本课程(以及绝大多数工程讨论)几乎只用大 O?因为估计算法时我们最怕的是「最坏情况会有多糟」, 上界正是回答这个问题的;并且很多时候下界并不容易证明。 例如第 12 讲要讲的比较排序下界 Ω(n log n),就是靠一棵决策树的叶子数才证出来的, 那是全课程里少见的高难度证明。

c·g(n) f(n) n0 f(n) = O(g(n)):上界 n ≥ n0 后 f 被 c·g 压住 c·g(n) f(n) n0 f(n) = Ω(g(n)):下界 n ≥ n0 后 f 始终高于 c·g c2·g(n) f(n) c1·g(n) n0 f(n) = Θ(g(n)):紧确界 f 被两条 c·g 夹在中间
图 1-12 O、Ω、Θ 的几何含义:只有当 n ≥ n₀ 时,不等式才开始稳定成立

1.7.5 推导大 O 的三条法则

有了定义,实际计算时并不需要每次都去找 c 和 n₀。下面三条法则可以让你在几秒内写出答案, 它们全都可以由定义直接证明。

法则一 加法法则:只保留最高阶项

若 T(n) = T₁(n) + T₂(n),且 T₁(n) = O(f(n))、T₂(n) = O(g(n)), 则 T(n) = O(max(f(n), g(n)))

为什么?设 f 是较高阶的那个,那么 f(n) + g(n) ≤ f(n) + f(n) = 2f(n)(当 n 足够大时 g 不超过 f), 取 c = 2 即可。这说明低阶项被高阶项吸收T(n) = n³ + 100n² + 5000n,当 n = 10⁴ 时 n³ = 10¹²,而后两项加起来才 1.5×10⁷, 占比百万分之十五,完全可以忽略。

法则二 乘法法则:忽略常数系数

对任意正常数 c,有 O(c · f(n)) = O(f(n))

因为 c 只是个放大倍数,不改变增长趋势:3n²n²/2100n² 全是 O(n²)。这条法则还告诉我们嵌套循环的复杂度是各层相乘: 外层 O(f)、内层 O(g),总复杂度 O(f·g)。例如外层 n 次、内层 log n 次,结果就是 O(n log n)。

法则三 常数一律记为 O(1)

无论一个操作要执行 1 次还是 1000 次,只要这个次数与 n 无关,就是 O(1),称为常量级。

请特别注意:O(1) 不是「只执行一次」。两条语句是 O(1), 一万条不含循环的语句也是 O(1);哈希表的一次查找平均也是 O(1)。 同理,O(n) 里的 n 不一定是数组长度——在图算法里它经常是顶点数 V 或边数 E, 在串算法里是串长 m、n。写复杂度时必须说清 n 指的是什么。

速算流程(考试直接用) 第一步:找出代码里与 n 有关的循环 / 递归,写出频度表达式; 第二步:把所有系数、低阶项、常数项删掉,只留最高阶项; 第三步:检查有没有嵌套相乘、有没有对数因子(循环变量倍增 / 折半)。 三步走完,答案自然落地。接下来我们就用 8 道例题把这套流程练到条件反射。

1.8 五道例题:手把手推导大 O

下面 5 道例题由浅入深,覆盖了考试和面试中 95% 的复杂度分析场景。每道题的格式都是固定的: 代码 → 逐行计数表 → 频度求和 → 化简得阶。请务必自己先遮住答案推一遍, 推不出来再看我的过程——只看不推,等于没学。

本题组的分析口径(三句话约定) ① 只统计与规模 n 有关的语句;for 的条件判断执行次数 = 循环执行次数 + 1(最后一次判假); ② 变量初始化、单条赋值、比较、四则运算都视为 O(1) 的基本操作; ③ 得到精确频度表达式后,删系数、删低阶、只留最高阶,得到大 O。

例 1 双层嵌套 —— O(n²)

// ex1_double_loop.cpp —— 双层嵌套:总次数 = 外层 × 内层
#include <iostream>

long long countPairs(int n) {
    long long cnt = 0;                       // 1
    for (int i = 1; i <= n; ++i) {           // 条件判断 n + 1 次
        for (int j = 1; j <= n; ++j) {       // 条件判断 n * (n + 1) 次
            ++cnt;                           // n * n 次
        }
    }
    return cnt;
}

// 变体:内层依赖外层(三角形循环),次数减半但阶不变
long long countTriples(int n) {
    long long cnt = 0;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= i; ++j)         // 内层次数 = i
            ++cnt;                           // 总次数 = 1 + 2 + ... + n = n(n+1)/2
    return cnt;
}

int main() {
    std::cout << countPairs(100) << '\n';    // 10000
    std::cout << countTriples(100) << '\n';  // 5050
    return 0;
}
语句频度化简后
cnt = 011
外层条件判断 i <= nn + 1n
内层条件判断 j <= nn × (n + 1) = n² + n
++cntn × n = n²
T(n) = 2n² + 2n + 2O(n²),平方阶

变体思考countTriples 的总次数是 n(n+1)/2 = 0.5n² + 0.5n, 比 n² 少了一半,但阶仍然是 O(n²)系数减半不改变阶—— 这是初学者最容易犯的错:看到「内层从 j ≤ i 变成 j ≤ n/2」就以为复杂度降级了, 其实还是同一量级。只有当「n 出现在指数或对数位置」时,改动才可能改变阶。

例 2 循环变量倍增 —— O(log n)

// ex2_double_step.cpp —— 倍增型循环:循环次数是对数级的
#include <iostream>

int steps(int n) {
    int cnt = 0;
    for (int i = 1; i < n; i *= 2) ++cnt;    // i 依次为 1, 2, 4, 8, ..., 2^k
    return cnt;
}

int stepsDiv(int n) {
    int cnt = 0;
    for (int i = n; i > 1; i /= 2) ++cnt;    // i 依次为 n, n/2, n/4, ...
    return cnt;
}

int main() {
    std::cout << steps(1000) << '\n';         // 10:因为 2^10 = 1024 > 1000
    std::cout << stepsDiv(1000) << '\n';      // 9
    return 0;
}

关键是找出循环变量 i 的通项,再让它满足退出条件:

第 k 次迭代时 i = 2k(k 从 0 开始)→ 退出条件 2k ≥ n → k ≥ log2n → 循环次数 ≈ log2n + 1
迭代次数 ki 的值是否继续
011 < n,继续
12继续
24继续
k2k当 2k ≥ n 时退出
T(n) = ⌊log₂n⌋ + 1O(log n),对数阶

变体思考:把 i *= 2 换成 i *= 3,次数变为 log₃n。 由换底公式 log₃n = log₂n / log₂3,只差一个常数因子,所以大 O 记号里对数的底数可以省略, 一律写成 O(log n)。这是面试常问的一个细节。另外,i /= 2 的写法次数为 ⌊log₂n⌋(n = 1000 时为 9),与倍增写法一致,同为 O(log n)。

例 3 循环变量自乘(开方型)—— O(log log n)

// ex3_sqrt.cpp —— 循环变量以自身为倍数增长 / 自身开方:双对数级
#include <iostream>
#include <cmath>

int stepsSquare(int n) {
    int cnt = 0;
    for (int i = 2; i < n; i = i * i) ++cnt;   // i: 2, 4, 16, 256, 65536, ...
    return cnt;
}

int stepsSqrt(int n) {
    int cnt = 0;
    for (int i = n; i > 1; i = (int)std::sqrt((double)i)) ++cnt;  // i: n, √n, n^(1/4), ...
    return cnt;
}

int main() {
    std::cout << stepsSquare(1000000) << '\n';   // 4:2 → 4 → 16 → 256 → 65536
    std::cout << stepsSqrt(1000000) << '\n';     // 4:10^6 → 10^3 → 31 → 5 → 2
    return 0;
}

这类循环的规律是「每次把指数翻倍」,通项里会出现 2 的 2 的幂:

i 的取值:21, 22, 24, 28, … 即第 k 次迭代时 i = 22k
退出条件 22k ≥ n ⟺ 2k ≥ log2n ⟺ k ≥ log2log2n
迭代次数 ki 的值与 n 的关系
02
14 = 2²
216 = 2⁴
3256 = 2⁸
465536 = 2¹⁶已超过 10⁶,退出
k22k当 22k ≥ n 时退出
T(n) = ⌊log₂log₂n⌋ + 1O(log log n),双对数阶

为什么它值得单独记一档?因为 O(log log n) 增长慢得惊人:n = 10⁶ 时才 4 次, n = 10⁹ 时才 5 次,n 取到宇宙原子总数(约 10⁸⁰)也不过 8 次。 埃拉托斯特尼筛法(埃氏筛)求 n 以内全部素数的复杂度是 O(n log log n), 其中那个 log log n 正来自这种「标记倍数」的循环结构——第 13 讲会再见到它。

例 4 递归式 T(n) = 2T(n/2) + O(n) —— O(n log n)

这是分治法的典型形态:把问题一分为二(2 个规模 n/2 的子问题), 再花线性时间把两半结果合起来。归并排序、快速排序的平均情况都是这个式子。

这段代码怎么读(先看这里,能省十分钟) 它只有三步,记住这三个字就够了:(从中间切开)、(两半各自递归去排)、 (把两个已经有序的半段并成一个有序段)。递归看着绕,其实每一层都在重复这三步。
为了让你一眼看穿它,代码里只放了 4 个数,而且把每次「分」「合」都打印了出来。 建议直接复制去编译跑一遍:输出的缩进就是递归树的形状, 你会看到「一路分到底,再一层层合上来」的完整顺序 —— 比盯着代码空想快得多。
// ex4_merge_sort.cpp —— 归并排序:T(n) = 2T(n/2) + Θ(n) = O(n log n)
//
// 只用 4 个数,每次「分」「合」都打印出来(运行结果附在文件末尾),方便对照递归树。
// 读之前只需要记住一个约定:
//   区间写成 [l, r),意思是「从下标 l 开始、到下标 r 之前为止」——左边算、右边不算。
//   好处是区间长度直接就是 r - l,不用到处写 +1 / -1,不容易错。
#include <iostream>
#include <vector>

std::vector<int> a = {5, 2, 9, 1};   // 要排序的数组(只有 4 个数,递归总共 2 层)
std::vector<int> buf(a.size());      // 合并时的临时缓冲区,大小和 a 一样(后面 1.11 讲空间复杂度会用到它)
int depth = 0;                       // 当前递归到第几层;只用来控制打印缩进,与算法无关

void pad() { for (int i = 0; i < depth; ++i) std::cout << "  "; }

/* 打印 a 的下标 [l, r) 这一段,元素之间用空格隔开(末尾不留多余空格) */
void printRange(int l, int r) {
    for (int t = l; t < r; ++t) {
        if (t > l) std::cout << " ";
        std::cout << a[t];
    }
}

/* 把区间 [l, r) 里的数排好序。
   [l, r) 的意思是「从下标 l 开始,到下标 r 之前为止」——左边算、右边不算。
   这样约定的好处:区间长度直接就是 r - l,不用到处写 +1 / -1,不容易错。 */
void mergeSort(int l, int r) {
    int len = r - l;                 // 这个区间里有几个数

    /* 【递归基】区间里只剩 0 个或 1 个数 —— 它自己就是有序的,直接返回。
       注意:这里「一次比较都不做」。只有 1 个数时,没有任何两个数需要比大小。
       (真正「比较一次」的是长度为 2 的那一层,见下面合的部分。) */
    if (len <= 1) return;

    /* 【分】从正中间切开:左半是 [l, m),右半是 [m, r) */
    int m = l + len / 2;
    pad();
    std::cout << "分 [" << l << "," << r << ") → 左[" << l << "," << m
              << ") 右[" << m << "," << r << ")\n";

    /* 【治】两半各自排好序。写法就是「调用自己」,只是区间小了一半 */
    ++depth;                         // 进下一层,打印时多缩进两格
    mergeSort(l, m);
    mergeSort(m, r);
    --depth;                         // 回到本层

    /* 【合】此刻左右两半都「已经有序」了,把它们并成一个有序区间:
       i 指左半的第一个数、j 指右半的第一个数,每次挑小的那个搬进 buf。 */
    int i = l, j = m, k = l;
    while (i < m && j < r) {         // 两半都还有数:比一比,搬小的
        if (a[i] <= a[j]) buf[k++] = a[i++];
        else              buf[k++] = a[j++];
    }
    while (i < m) buf[k++] = a[i++];   // 右半搬完了,左半剩下的直接接上(不用再比)
    while (j < r) buf[k++] = a[j++];   // 左半搬完了,右半剩下的直接接上(不用再比)

    for (int t = l; t < r; ++t) a[t] = buf[t];   // 把排好的这一段搬回 a

    pad();
    std::cout << "合 [" << l << "," << r << ") → ";
    printRange(l, r);
    std::cout << "\n";
}

/* 对照递推式 T(n) = 2T(n/2) + cn,看上面代码的三个部分:
     「分」那一行          → 一次加法一次除法,常数时间 O(1)(递推式里忽略不计)
     两次 mergeSort 调用   → 2 × T(n/2)   ← 式子里的 2T(n/2) 说的就是它
     「合」那几行          → 每个元素都被搬进 buf 一次、搬回 a 一次,总次数与 n 成正比 Θ(n)
                            ← 式子里的 cn 说的就是它
   所以 T(n) = 2T(n/2) + cn。 */
int main() {
    std::cout << "原始数组:";
    printRange(0, (int)a.size());
    std::cout << "\n\n";

    mergeSort(0, (int)a.size());

    std::cout << "\n排序结果:";
    printRange(0, (int)a.size());
    std::cout << "\n";
    return 0;
}

/* ------- 运行结果(缩进就是递归树的层次,建议自己跑一遍对照) -------
原始数组:5 2 9 1

分 [0,4) → 左[0,2) 右[2,4)
  分 [0,2) → 左[0,1) 右[1,2)
  合 [0,2) → 2 5
  分 [2,4) → 左[2,3) 右[3,4)
  合 [2,4) → 1 9
合 [0,4) → 1 2 5 9

排序结果:1 2 5 9
   从输出能一眼看出两件事:
     ① 递归的顺序是「一路分到底,再一层层合上来」;
     ② 最底下的两次「合」都是把两个单独的数并起来 —— 那一次正好比较 1 次。
   ----------------------------------------------------------------- */

递归树展开最直观:每一层的合并总代价都是 n,层数是对数级。

层次 k子问题个数每个子问题规模本层归并代价
01n1 × n = n
12n/22 × n/2 = n
24n/44 × n/4 = n
k2kn/2k2k × n/2k = n
log₂n − 1
最后一次归并
n/22 n/2 次比较 + n 次搬移 = n
每对半段各 1 个元素,比较 1 次定序
log₂n
触底,不再归并
n1 不产生归并代价
if (len <= 1) return;len = r - l)直接返回,比较 0 次、搬移 0 次
做归并的是 k = 0 … log₂n − 1 这 log₂n 层,每层 n ⇒ T(n) = n·log₂n + O(n) O(n log n)
最容易卡住的一点:那「最后一次比较」到底发生在哪一层? 看代码:if (len <= 1) return;len 就是区间长度 r - l) 所以规模 1 的那些调用一次比较都不做, 它们只是递归的「底」。真正做最后一次比较的是规模 2 那一层 —— 把两个各含 1 个元素的半段并起来, while 恰好比较 1 次就定下谁在前;这样的配对数有 n/2 对。 也就是说:归并只发生在规模 n、n/2、…、2 这 log₂n 层,规模 1 那层不参与归并。
这一点在代码里就能直接看到:把上面那段程序跑起来,最后两次「合」的输出是 合 [0,2) → 2 5合 [2,4) → 1 9 —— 每次都是把两个单独的数并起来, 各比较 1 次;而长度为 1 的区间压根没有输出,因为它一进来就返回了。
再看 n = 8 的实测(在 mergeSort 里加计数器跑出来的): 规模 8 归并 1 次、规模 4 归并 2 次、规模 2 归并 4 次,规模 1 的调用 8 次; 后者的比较次数是 0,「比较 1 次」的那 4 次全部记在规模 2 那一层。
对比例 5(二分查找):它的递归基 T(1) = c 本身就要做 1 次比较, 所以那一层的代价不能省,才有 T(n) = c(log₂n + 1) 里的「+ 1」。 两个例子的「+ 1」有无,差别就在这里。
T(n) = 2T(n/2) + cn = 4T(n/4) + 2cn = … = 2kT(n/2k) + k·cn, 令 n/2k = 1 得 k = log₂n,故 T(n) = n·T(1) + cn·log₂n = O(n log n)

为什么它比 O(n²) 强这么多?因为每层只做 n 次合并,而层数只有 log₂n。 n = 10⁶ 时,n log n ≈ 2×10⁷,n² = 10¹²,相差 5 万倍。 第 12 讲会讲主定理(Master Theorem),用它可以一眼写出这类递推式的解, 不必每次画树。

例 5 递归式 T(n) = T(n/2) + O(1) —— O(log n)

这是「每次砍掉一半」的形态,二分查找是它的标准代表。 它比例 4 更妙的地方在于:每层只需常数时间,所以只剩层数一个因子。

// ex5_binary_search.cpp —— 二分查找:T(n) = T(n/2) + O(1) = O(log n)
#include <iostream>
#include <vector>

// 递归版:递推式 T(n) = T(n/2) + O(1)
int bsearchRec(const std::vector<int>& a, int l, int r, int key) {
    if (l > r) return -1;                              // 递归基:O(1)
    int m = l + (r - l) / 2;                           // O(1),写成这样可防 l+r 溢出
    if (a[m] == key) return m;                         // O(1)
    if (a[m] < key) return bsearchRec(a, m + 1, r, key);   // 只走一边:T(n/2)
    return bsearchRec(a, l, m - 1, key);                   // 只走一边:T(n/2)
}

// 迭代版:循环次数 = 把 n 折半到 1 需要几步 = log2(n)
int bsearchIter(const std::vector<int>& a, int key) {
    int l = 0, r = (int)a.size() - 1;
    while (l <= r) {
        int m = l + (r - l) / 2;
        if (a[m] == key) return m;
        if (a[m] < key) l = m + 1;
        else            r = m - 1;
    }
    return -1;
}

int main() {
    std::vector<int> a = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19};
    std::cout << bsearchRec(a, 0, (int)a.size() - 1, 7) << '\n';   // 3
    std::cout << bsearchIter(a, 19) << '\n';                     // 9
    std::cout << bsearchIter(a, 8) << '\n';                      // -1(不存在)
    return 0;
}
递归层次 k剩余规模本层代价累计代价
0ncc
1n/2c2c
2n/4c3c
kn/2kc(k+1)c
log₂n(触底)1c(log₂n + 1)c
T(n) = c·(log₂n + 1)O(log n)

注意一个前提:二分查找要求数据有序且支持随机访问。所以在链表上做二分查找是没有意义的 ——找到中点本身就要 O(n),总复杂度反而退化成 O(n log n)。 这又是一个「存储结构决定算法能否成立」的例子。

1.8.1 五道例题速查表

题号代码形态频度 / 递推式推导关键结果
例 1双层嵌套T(n) = 2n² + 2n + 2外层 n × 内层 nO(n²)
例 2i *= 2 / i /= 2k ≈ log₂n + 1通项 i = 2kO(log n)
例 3i = i * i / i = √ik ≈ log₂log₂n + 1通项 i = 22kO(log log n)
例 4归并排序 T(n) = 2T(n/2) + cnT(n) = cn·log₂n + O(n)归并在规模 n、n/2、…、2 这 log₂n 层,每层 cn;规模 1 那层只 return,不归并O(n log n)
例 5二分查找 T(n) = T(n/2) + cT(n) = c(log₂n + 1)每层常数代价 × log 层O(log n)
把它们连成一句话 看到循环就问「循环体执行多少次」:与 n 成正比 → O(n);是嵌套的乘积 → 相乘; 变量倍增或折半 → 出现 log;变量自乘或开方 → 出现 log log。 看到递归就问「分几个子问题、每个规模多大、合并要多久」: 一个子问题规模减 1 → O(n);一个子问题规模减半 → O(log n); 两个子问题各减半 + 线性合并 → O(n log n)。

1.9 最好、最坏与平均时间复杂度

1.9.1 为什么同一个算法会有三个复杂度

算法运行时间不仅与问题规模 n 有关,还与输入数据的具体形态有关。 同样是顺序查找,要找的元素恰好在第一个位置,与恰好在最后一个位置,比较次数差了 n 倍。 如果只给一个笼统的「复杂度」,这个信息就丢失了。于是我们分三种口径:

在没有特别说明时,我们平常说的「这个算法是 O(?) 的」,默认指最坏情况。 因为最坏情况最容易分析(构造一个最差输入即可),而且是唯一能给用户承诺的指标。

1.9.2 顺序查找:把平均情况算到底

顺序查找(sequential search)从表头开始逐个比较,找到就返回下标。 设表长为 n,数组下标 1 ~ n,要找的元素为 key。分析前先做两条假设:

  1. 表中元素互不相同;
  2. 每个元素被查找的概率相等,即 pi = 1/n(等概率假设)。

查找第 i 个元素时,需要比较 i 次。于是成功查找的平均查找长度为:

ASL成功 = Σi=1..n pi · ci = Σi=1..n (1/n) · i = (1/n) · n(n+1)/2 = (n+1)/2

当 n 很大时,(n+1)/2 与 n 同阶,所以平均时间复杂度是 O(n)。 注意这里必须区分「平均比较次数」与「渐进复杂度」: 平均比较次数是 (n+1)/2(n = 8 时约 4.5 次),而渐近阶是 O(n),两者并不矛盾。

三种情况放在一起看:

情况触发输入比较次数渐进复杂度
最好要找的元素恰在第 1 个位置1O(1)
平均等概率分布,期望值(n+1)/2O(n)
最坏要找的元素在第 n 个位置,或根本不存在n(失败时 n 次)O(n)

如果概率不相等呢?那就老老实实按 pi 加权。例如已知 「查第 1 个元素的概率是 1/2,其余 n−1 个各为 1/(2(n−1))」,则 ASL = 1×(1/2) + Σi=2..n i/(2(n−1)) ≈ n/4,比等概率时快一倍。 这提示我们一个工程技巧:把最常查的元素放在表头,平均查找长度会显著下降 (「自适应线性表」就是这个思想,在第 10 讲会展开)。

0 2 4 6 8 比较次数 1 2 3 4 5 6 7 8 元素在表中的位置 i ASL = (n+1)/2 = 4.5(n = 8 时) 最好:1 次 最坏:8 次 查找第 i 个元素需比较 i 次
图 1-13 顺序查找:位置 i 需要 i 次比较,等概率下的平均值正好是 (n+1)/2

1.9.3 快速排序:三种情况天差地别

快速排序每轮选一个基准(pivot),把序列划分成「小于基准」和「大于基准」两段,再递归处理。 设一趟划分的代价为 O(n),则总复杂度取决于划分是否均衡

这就是为什么工程实现要花力气「避免最坏情况」:随机选基准(随机化快排)、 三数取中小区间改用插入排序(内省排序)都是为了让最坏情况几乎不出现。 第 12 讲会给出完整实现与对抗测试。

1.9.4 冒泡排序:可以提前结束的排序

冒泡排序每轮把当前最大值「冒」到末尾,共进行 n−1 轮,每轮比较 n−i 次。 总比较次数为 Σi=1..n−1(n−i) = n(n−1)/2,所以最坏与平均都是 O(n²)。 但加上「本轮无交换则提前退出」的优化后,最好情况出现了:

情况输入特征比较次数交换次数渐进复杂度
最好已经有序(如 1,2,3,…,n)n−10O(n)
平均随机排列≈ n(n−1)/4≈ n(n−1)/4O(n²)
最坏完全逆序(如 n,…,2,1)n(n−1)/2n(n−1)/2O(n²)

冒泡排序还有一个隐藏优点:稳定(相同关键字的相对次序不变),因为相邻比较时用的是严格 > 而不是 。这也是「同一量级的算法之间还要比稳定性」的典型场景。

考点 三种复杂度在考试中的常见陷阱
  • 题目说「时间复杂度为 O(n²) 的排序算法」——如果只写最坏情况,冒泡、插入、选择、快排都符合; 但若问「最好情况能达到 O(n)」,只有冒泡(带优化)与直接插入可以,简单选择排序不行(它总是 n(n−1)/2 次比较)。
  • 「平均情况」必须说明概率假设。不说明假设就谈平均,是伪命题。
  • 最好情况的复杂度不能用来评价算法优劣——快排最好情况 O(n log n) 与归并排序相同, 但快排的最坏是 O(n²),归并的最坏仍是 O(n log n),这才是两者的分水岭。

1.10 常见复杂度增长对比

把常见的八种复杂度放在同一张表里,代入 n = 10、100、1000、10⁶ 四个规模, 能直观感受到「阶」这个概念的威力。下面的估算按「普通计算机每秒执行约 10⁸ 次基本操作」换算, 换算结果只是为了建立量级感,不必当成精确结论。

复杂度n = 10n = 100n = 1000n = 10⁶n = 10⁶ 时的直观感受
O(1)1111瞬间完成,与规模完全无关
O(log n)371020瞬间完成,n 翻倍只多 1 次
O(n)1010010³10⁶约 0.01 秒,轻松通过
O(n log n)3366499662×10⁷约 0.2 秒,排序算法的目标线
O(n²)10010⁴10⁶10¹²约 2.8 小时,不可接受
O(n³)10³10⁶10⁹10¹⁸约 317 年,必须换算法
O(2ⁿ)10241.3×10³⁰10³⁰¹天文数字n = 100 就已经算不完了
O(n!)3.6×10⁶9.3×10¹⁵⁷巨大巨大只能处理 n ≤ 10 左右的规模

请特别记住三条「红线」,它们决定了竞赛与工程中的算法选型:

1 10 100 10³ 10⁴ 10⁵ 10⁶ 运算次数(对数刻度) 1 4 7 10 13 16 数据规模 n(对数刻度纵轴,便于在同一张图里比较 1 与 10⁶) O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ) 图例 纵轴取对数后, 曲线越陡 = 增长 越快。2ⁿ 呈直线 上升,说明它是 指数级爆炸。
图 1-14 六种常见复杂度的增长曲线对比(纵轴为对数刻度):n = 16 时 2ⁿ 已达 65536,而 log₂n 只有 4
怎么看这张图 纵轴是对数刻度,所以「看起来差不多高」其实差别巨大: 在 n = 16 处,O(1) 是 1 次、O(log n) 是 4 次、O(n) 是 16 次、O(n log n) 是 64 次、 O(n²) 是 256 次、O(2ⁿ) 是 65536 次。把横轴拉到 n = 10⁶, O(n²) 与 O(n log n) 的差距会扩大到 5 万倍——这就是为什么「降一个阶」比「优化常数」重要得多。
// growth_timing.cpp —— 亲手实测三种复杂度的增长(-O2 编译,观察列之间的倍数关系)
#include <iostream>
#include <chrono>

int main() {
    std::cout << "n\t\tO(n)\t\tO(n log n)\tO(n^2)\n";
    for (int n = 1000; n <= 16000; n *= 2) {
        volatile long long sink = 0;                 // volatile:阻止编译器优化掉整个循环

        auto t0 = std::chrono::steady_clock::now();
        long long s1 = 0;
        for (int i = 0; i < n; ++i) s1 += i;         // O(n)

        auto t1 = std::chrono::steady_clock::now();
        long long s2 = 0;
        for (int i = 0; i < n; ++i)                  // O(n log n):外层 n,内层 log n
            for (int j = 1; j < n; j *= 2) s2 += j;

        auto t2 = std::chrono::steady_clock::now();
        long long s3 = 0;
        for (int i = 0; i < n; ++i)                  // O(n^2)
            for (int j = 0; j < n; ++j) s3 += i * j;

        auto t3 = std::chrono::steady_clock::now();
        sink = s1 + s2 + s3;
        (void)sink;

        auto ms = [](auto a, auto b) {               // C++14 泛型 lambda
            return std::chrono::duration<double, std::milli>(b - a).count();
        };
        std::cout << n << "\t\t" << ms(t0, t1) << "\t"
                  << ms(t1, t2) << "\t\t" << ms(t2, t3) << '\n';
    }
    return 0;
}
// 典型输出(不同机器数值不同,但规律一致):
// n        O(n)      O(n log n)   O(n^2)
// 1000     0.002     0.003        0.9
// 2000     0.003     0.007        3.6      ← O(n^2) 列大约 ×4,因为它随 n^2 增长
// 4000     0.006     0.015        14.3
// 8000     0.012     0.031        57.2
// 16000    0.024     0.066        228.9

1.11 空间复杂度

1.11.1 定义:只算「额外」的那部分

空间复杂度(space complexity)记作 S(n),是对一个算法在运行过程中 临时占用存储空间大小的量度,同样用大 O 记号表示。 一个算法在内存里占的地方可以分成三块,我们必须分清哪块算、哪块不算:

组成部分内容是否计入 S(n)原因
程序代码本身编译后的指令、常量不计入与问题规模 n 无关,是个固定开销
输入数据待处理的数组、矩阵、字符串不计入否则任何算法都至少是 O(n),无法比较优劣
辅助空间临时变量、递归栈帧、缓冲区、辅助数组计入这才是「算法自己额外花的钱」

所以严格来说,S(n) 指的是辅助空间(auxiliary space)。 例如「把数组原地反转」:输入数组 n 个元素不算,只用了一个临时变量 t, 于是 S(n) = O(1);而「归并排序」需要一个与原数组等长的缓冲区 buf, 所以 S(n) = O(n)。

1.11.2 原地算法(in-place algorithm)

如果算法所需的辅助空间与问题规模 n 无关,即 S(n) = O(1), 就称它为原地算法。这在实际工程中价值极高:处理 10⁸ 个整数时, 原地算法可以直接在用户给的内存上操作,而非原地算法可能要再申请几百 MB。

易错:数组反转到底是 O(1) 还是 O(n)?swap(a[i], a[n-1-i]) 的双指针法反转数组,辅助空间是 O(1), 尽管它改了 n 个元素、执行了 n/2 次交换——时间与空间是两码事。 用「另开一个数组倒着存」的办法则是 O(n) 空间。 考试喜欢在这里设陷阱:问「时间 O(n)、空间 O(1) 的反转算法」,答案就是双指针法。

1.11.3 递归的栈空间必须计入

这是空间复杂度里最容易漏掉的一项。每次函数调用,系统都要在运行时栈上压入一个 栈帧(stack frame),里面保存参数、局部变量和返回地址。函数返回时栈帧才弹出。 所以递归算法的空间复杂度至少等于最大递归深度

S递归(n) = O(最大递归深度) + O(每层额外申请的堆空间)

以递归求阶乘为例:fact(4) 会依次调用 fact(3)fact(2)fact(1)四个栈帧同时存在,直到最内层返回才开始逐个弹出。因此最大深度为 n,S(n) = O(n)。

// factorial_space.cpp —— 同样是 O(n) 时间,空间却可能差一个量级
#include <iostream>

// 递归版:时间 O(n),空间 S(n) = O(n) —— n 个栈帧同时存在
long long factRec(int n) {
    if (n <= 1) return 1;              // 递归基
    return n * factRec(n - 1);         // 本层必须等下层返回,栈帧一直压着
}

// 迭代版:时间 O(n),空间 S(n) = O(1) —— 只用了 r、i 两个变量
long long factIter(int n) {
    long long r = 1;
    for (int i = 2; i <= n; ++i) r *= i;
    return r;
}

int main() {
    std::cout << factRec(10) << '\n';    // 3628800
    std::cout << factIter(20) << '\n';   // 2432902008176640000
    // factRec(1000000);                 // 栈溢出 Stack Overflow:O(n) 栈空间也会爆
    return 0;
}
运行时栈(每进入一层递归就压入一个栈帧) 递归返回时自底向上逐层弹栈 fact(4) n = 4 保存返回地址 → main fact(3) n = 3 保存返回地址 → fact(4) fact(2) n = 2 保存返回地址 → fact(3) fact(1) n = 1 命中递归基,开始返回 调用深度增加 ← 栈底(先压入) ← 栈顶(后压入) ① fact(1) 触底 → 返回 1 ② fact(2) = 2 × 1 = 2 ③ fact(3) = 3 × 2 = 6 ④ fact(4) = 4 × 6 = 24 最大栈深度 = n ⟹ S(n) = O(n)
图 1-15 递归求 n! 时的调用栈:任一时刻栈里有 n 个栈帧,所以 S(n) = O(n)

再看斐波那契数列,它是「时间与空间反差」的最佳教材。朴素递归写法 fib(n) = fib(n−1) + fib(n−2) 会展开成一棵指数级的递归树, 时间 O(2ⁿ);但注意:它同一时刻只保留一条从根到叶的路径, 算完左子树再去算右子树时,左子树的栈帧早已弹出。所以它的空间只有 O(n)

这个「时间指数、空间线性」的反差非常反直觉,也是理解递归调用机制的试金石。 记住判断栈空间的方法:不是在递归树上有多少个结点,而是任意时刻同时存活多少层

// fib_space.cpp —— 斐波那契:朴素递归时间 O(2^n)、空间 O(n);记忆化后时间 O(n)
#include <iostream>
#include <vector>

// 朴素递归:子问题重复计算,时间 O(2^n);但递归树任意时刻只走一条路径,空间 O(n)
long long fibNaive(int n) {
    if (n < 2) return n;                        // fib(0)=0, fib(1)=1
    return fibNaive(n - 1) + fibNaive(n - 2);
}

// 记忆化搜索:用 O(n) 的表换掉重复计算,时间降到 O(n),空间 O(n)(表 O(n) + 栈 O(n))
long long fibMemo(int n, std::vector<long long>& memo) {
    if (n < 2) return n;
    if (memo[n] != -1) return memo[n];          // 命中缓存,直接返回,不再往下递归
    return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}

// 递推写法(滚动变量):时间 O(n),空间 O(1) —— 空间优化的极致
long long fibIter(int n) {
    long long a = 0, b = 1;                     // 只保留最近两项
    for (int i = 0; i < n; ++i) { long long t = a + b; a = b; b = t; }
    return a;
}

int main() {
    std::cout << fibNaive(30) << '\n';          // 832040:n 再大就明显变慢
    std::vector<long long> memo(91, -1);
    std::cout << fibMemo(90, memo) << '\n';     // 2880067194370816120
    std::cout << fibIter(90) << '\n';          // 同上
    return 0;
}
f(5) f(4) f(3) f(3) f(2) f(2) f(1) f(1) f(0) 叶子 时间:每层子问题翻倍 总结点数 ≈ 2ⁿ,重复计算极多 ⟹ T(n) = O(2ⁿ) 空间:同一时刻只保留 一条根到叶的路径 ⟹ S(n) = O(n) 优化:f(3) 被算了 2 次, f(2) 被算了 3 次 —— 记忆化 或递推可把时间降到 O(n) (第 13 讲动态规划的主角)
图 1-16 斐波那契朴素递归的调用树:结点数指数级(时间),但深度只有 n(空间)

1.11.4 S(n) 的常见形态

量级典型来源具体例子能否优化
O(1)只用几个临时变量冒泡 / 插入 / 选择 / 堆排序、双指针反转数组、迭代求阶乘已最优
O(log n)递归深度为对数级二分查找递归版、快速排序的期望栈深、递归求快速幂可改写为迭代降到 O(1)
O(n)与原数据等长的辅助结构归并排序缓冲、BFS 队列、阶乘 / 斐波那契递归栈、哈希表、一维 DP 数组部分可原地化或滚动数组
O(n + k)与值域有关的桶计数排序、基数排序、桶排序取决于值域 k
O(n²)二维表 / 稠密图存储邻接矩阵、二维 DP 表、Floyd 算法距离矩阵稀疏图改用邻接表 O(V+E)
O(2ⁿ)枚举所有子集状压 DP 的状态数组、子集枚举只能改变算法思路

最后强调一个实用的判断顺序:分析一个算法时,先看时间,再看空间。 因为绝大多数场合时间是瓶颈;只有当内存成为硬约束(嵌入式、超大矩阵、流式数据)时, 空间才上升为第一指标。而「时间换空间」与「空间换时间」的取舍, 贯穿了本课程几乎每一章的算法设计——哈希表(空间换时间)、滚动数组(时间换空间)、 记忆化搜索(空间换时间)都是这个思想的产物。

1.12 专业术语体系:43 条中英对照

数据结构是一门「术语密度」很高的课:同一件事在不同教材里可能叫「物理结构」也叫「存储结构」, 同一类结点在链表里叫 node、在树里叫 vertex。下面这张表把全课程最常用的 43 个术语一次性打好底, 后面 14 讲遇到时可以直接回来查。建议打印出来贴在书桌前,每学一章划掉几个。

数据
data
信息的载体,是能输入到计算机中并被程序识别和处理的符号集合。可以是数值、字符、图像、声音。
数据元素
data element
数据的基本单位,在程序中作为一个整体处理;在链表 / 树 / 图中也称作结点(node),在数据库中称作记录(record)
数据项
data item
构成数据元素的、不可再分割的最小单位,也叫字段(field)。一个数据元素由若干数据项组成。
数据对象
data object
性质相同的数据元素的集合,是数据的一个子集。强调「有哪些成员」,不强调成员间的关系。
数据结构
data structure
相互之间存在一种或多种特定关系的数据元素的集合。= 逻辑结构 + 存储结构 + 数据的运算。
逻辑结构
logical structure
数据元素之间抽象的关系,分集合、线性、树形、图形四类。与计算机、语言、内存无关。
存储结构 / 物理结构
storage / physical structure
逻辑结构在计算机存储器中的表示,包括元素本身的表示与元素之间关系的表示。
顺序存储
sequential storage
用物理位置相邻表示逻辑相邻,关系不占额外空间,支持随机访问 O(1),插入删除需搬移元素。
链式存储
linked storage
用指针显式指出后继(或前驱)的地址,物理位置任意,按位查找 O(n),已知位置时插删 O(1)。
索引存储
indexed storage
在数据表之外另建一张「关键字 → 地址」的索引表,先查索引再取记录,以空间和维护开销换检索速度。
散列存储
hash storage
由散列函数 h(key) 直接算出存储地址,平均查找 O(1),但失去顺序性、需处理冲突。
抽象数据类型
ADT, Abstract Data Type
一个数学模型以及定义在该模型上的一组操作,只规定「做什么」不规定「怎么做」,由数据对象、数据关系、基本操作三部分组成。
封装
encapsulation
把数据表示与操作实现隐藏在模块内部,只暴露接口。ADT 在 C++ 中常用 class 的 public / private 实现,用一组自由函数表达同一个 ADT 也可以。
算法
algorithm
对特定问题求解步骤的描述,是指令的有限序列;具备有穷性、确定性、可行性、输入、输出五大特性。
有穷性
finiteness
算法必须在执行有穷步后终止,且每一步都在有穷时间内完成。这是算法区别于程序的关键。
确定性
definiteness
每条指令含义确切、无二义性;相同输入在任何时刻都产生相同的输出。
可行性
effectiveness
算法中的每一步都能机械地执行,并且能在有限时间内完成(微观层面的「做得到」)。
输入 / 输出
input / output
算法有 0 个或多个输入,但必须有 1 个或多个输出;没有输出的算法没有意义。
语句频度
statement frequency
一条语句在算法中被重复执行的次数。所有语句频度之和记作 T(n),是问题规模 n 的函数。
问题规模
problem size
刻画输入量大小的量,通常记作 n。数组是元素个数,图是顶点数 V 与边数 E,串是串长。
时间复杂度
time complexity
算法执行时间随问题规模增长的趋势,用 T(n) 的渐近阶表示,是事前分析估算的核心指标。
空间复杂度
space complexity
算法运行过程中临时占用的辅助空间随规模增长的趋势,记作 S(n),不含输入数据本身。
渐进记号
asymptotic notation
描述函数在 n → ∞ 时增长量级的记号体系,包括 O(上界)、Ω(下界)、Θ(紧确界)、o、ω。
大 O 记号
big-O notation
存在正常数 c 与 n₀,使 n ≥ n₀ 时 0 ≤ f(n) ≤ c·g(n),记 f(n) = O(g(n))。表示渐进上界,等号应读作「属于」。
上界 / 下界 / 紧确界
upper / lower / tight bound
O 给天花板,Ω 给地板,Θ 把两者夹住(Θ = O ∩ Ω)。上界可以很松,工程上习惯用最紧的那个。
最好 / 最坏 / 平均情况
best / worst / average case
同一算法在不同输入形态下的复杂度。默认讨论的是最坏情况,平均情况必须先声明概率假设。
平均查找长度
ASL, Average Search Length
查找过程中关键字比较次数的期望值,ASL = Σ pᵢ·cᵢ。顺序查找等概率下为 (n+1)/2。
均摊分析
amortized analysis
把偶尔出现的高代价操作平摊到一连串操作上。动态数组扩容的单次代价是 O(n),但均摊到每次插入是 O(1)。
原地算法
in-place algorithm
只用 O(1) 辅助空间的算法。注意递归版即使只改几个变量,也可能因栈深而失去 O(1) 空间。
辅助空间
auxiliary space
除输入数据与程序代码之外,算法额外占用的存储空间,才是空间复杂度真正统计的对象。
递归 / 递归基 / 递归深度
recursion / base case / depth
函数直接或间接调用自身;递归基是终止条件;递归深度决定栈空间大小。
栈帧
stack frame
每次函数调用在运行时栈上压入的记录,保存参数、局部变量与返回地址。递归的空间代价主要来自它。
递归树
recursion tree
把递归展开成树形结构来求递推式解的方法:每层代价相加,层数决定因子(如归并排序每层 n、共 log n 层)。
分治法
divide and conquer
把问题分成若干规模更小的同类子问题,分别求解后合并。归并排序、二分查找、快速排序都是它的实例。
主定理
master theorem
直接求解 T(n) = aT(n/b) + f(n) 这类递推式的公式,第 13 讲会给出三种情况的判定方法。
时空权衡
time-space tradeoff
用更多内存换更快速度(哈希表、记忆化搜索),或用更多计算省内存(滚动数组、重新计算)。
随机访问 / 顺序访问
random / sequential access
随机访问指任意位置的存取代价相同(数组,O(1));顺序访问必须从头逐个走(链表,O(n))。
存储密度
storage density
数据本身占用的空间 / 结点占用的总空间。顺序存储密度为 1,链式存储因指针而小于 1。
前驱 / 后继
predecessor / successor
线性结构中某元素的前一个 / 后一个元素。树中对应「父结点 / 孩子结点」,图中对应「邻接点」。
头结点 / 头指针 / 哨兵
head node / head pointer / sentinel
头指针指向链表的第一个结点;头结点是带头结点链表中附加在首元素之前的虚结点,可统一空表与非空表的操作;哨兵是嵌在边界、免去越界判断的特殊值。
稳定性
stability
排序后关键字相同的元素相对次序是否保持不变。稳定:冒泡、插入、归并、基数;不稳定:快排、选择、堆、希尔。
稠密图 / 稀疏图
dense / sparse graph
边数接近 V² 称稠密图(用邻接矩阵),边数远小于 V² 称稀疏图(用邻接表,空间 O(V+E))。
指针 / 游标
pointer / cursor
指针存放地址;游标是用数组下标模拟指针的技术,用于静态链表等不能使用指针的场合。

1.13 学习建议

1.13.1 结构决定算法:先选结构,再谈算法

初学者常见的路径是「拿到题目直接想算法」,结果写完才发现:数据量 10⁵,而自己的做法是 O(n²)。 高手的路径恰好相反:先看数据的规模与操作的类型,据此确定数据结构;结构定了,算法几乎是自然长出来的

举个例子:题目要求「维护一个集合,支持插入、删除、查询最小值」。 如果你用无序数组:查询最小值 O(n);用有序数组:插入 O(n); 用二叉堆:三种操作全是 O(log n);用哈希表:查询最小值退化成 O(n)(因为哈希表不维护顺序)。 算法没变,变的是结构;结构一变,复杂度就变了。 再比如「频繁查询区间和」用前缀和(O(1) 查询)、「频繁区间修改 + 区间查询」用树状数组或线段树, 也都是结构决定算法的典型。

所以每学一个新结构,请固定问自己三个问题: ① 它支持哪些操作?② 每个操作的复杂度是多少?③ 什么样的场景该用它、什么样的场景千万别用? 能答出这三问,这个结构才算真的学会了。

1.13.2 画图胜过背书

数据结构是「看得见」的学科。指针怎么指、树怎么长、栈怎么压、图的边怎么连, 全都可以画出来。我强烈建议你准备一个方格本,每学一个结构就画三张图:

  1. 结构图:元素在内存里长什么样,指针指向谁,地址是怎么排的;
  2. 操作图:执行一次插入 / 删除 / 旋转时,哪几条指针被改动,改动前后各是什么样(画两遍);
  3. 边界图:空结构、只有一个元素、在头部操作、在尾部操作时,图会变成什么样。 这一张最容易被忽略,也最容易在考场上丢分。

背定义只能应付名词解释,画图才能应付代码题。当你闭着眼睛能在脑海里「看见」指针的移动时, 写代码就只是把脑海里的图翻译成 C++ 而已。

1.13.3 动手实现比看代码重要十倍

看别人写的链表代码,你会觉得「不过如此」;等自己动手写,才会发现: 头指针什么时候要改、删除时怎么保证不断链、释放内存的顺序为什么不能颠倒—— 这些细节只有踩过坑才会记住。建议的学习闭环是:

另外请养成先写测试再写实现的习惯。数据结构的代码往往不长,但指针操作极易出错; 有测试兜底,你才敢放心重构。

第 01 讲 绪论(本章) 概念 · 结构 · 复杂度 是全课程共同的语言 ① 线性结构家族(第 02 ~ 06 讲):把「一对一」的关系玩到极致 第 02 讲线性表 第 03 讲 第 04 讲队列 第 05 讲串 KMP · BM 第 06 讲数组与矩阵 ② 非线性结构(第 07 ~ 09 讲):一对多与多对多的世界 第 07 讲树与二叉树 · 赫夫曼 · 并查集 第 08 讲图:术语与存储结构 第 09 讲生成树与最短路径 ③ 查找与排序(第 10 ~ 12 讲):复杂度分析的主战场 第 10 讲查找与哈希表(ASL 计算) 第 11 讲八大排序算法图解 第 12 讲排序体系与下界 Ω(n log n) ④ 算法范式与实战(第 13 ~ 15 讲):把结构与算法组装成方案 第 13 讲分治 · 贪心 · 回溯 · 动态规划 第 14 讲洛谷题单:例题与作业 第 14 讲综合自测与速查手册 本章打底的逻辑结构、存储结构与复杂度分析,会在后面每一讲反复出现——它是全课程的地基。
图 1-17 本章与后续 14 讲的衔接关系:一条从「关系分类」到「算法范式」的主线

1.14 本章小结

一、概念层

  • 数据 ⊇ 数据对象 ⊇ 数据元素 ⊇ 数据项,层级关系不能颠倒;
  • 数据结构 = 相互之间存在一种或多种特定关系的数据元素的集合 = 逻辑结构 + 存储结构 + 数据的运算;
  • 逻辑结构四类:集合(无关系)、线性(一对一)、树形(一对多)、图形(多对多); 再粗分为线性结构与非线性结构;
  • 存储结构四种:顺序(关系免费)、链式(关系用指针)、索引(加索引表)、散列(算地址,丢顺序)。

二、抽象层

  • 抽象数据类型 ADT = 数据对象 + 数据关系 + 基本操作,只规定「做什么」;
  • ADT 使逻辑结构与存储结构解耦:换实现不改调用方代码;
  • 同一个 GetElem(i),顺序表是 O(1)、链表是 O(n)——语义相同,代价不同。

三、度量层

  • 用事前分析估算代替事后统计;以语句频度 T(n) 度量时间;
  • 大 O 是渐进上界:存在 c、n₀ 使 n ≥ n₀ 时 0 ≤ f(n) ≤ c·g(n);Ω 是下界,Θ 是紧确界;
  • 三条法则:只留最高阶项、忽略常数系数、常数记 O(1);嵌套循环相乘;
  • 五道例题的结论:双层嵌套 O(n²)、倍增 / 折半 O(log n)、自乘 / 开方 O(log log n)、 2T(n/2)+cn 是 O(n log n)、T(n/2)+c 是 O(log n);
  • 最好 / 最坏 / 平均要分清,默认说最坏;顺序查找等概率 ASL = (n+1)/2;
  • 空间复杂度只算辅助空间;递归栈深度必须计入,S(n) 常见 O(1) / O(log n) / O(n) / O(n²)。

四、方法层

  • 结构决定算法:先看数据规模与操作类型,再选结构,最后写算法;
  • 画图胜过背书:结构图、操作图、边界图三张图缺一不可;
  • 动手实现比看代码重要:写完还要测边界、查内存、对照标准实现。

1.15 易错点与考点

易错点 1 把逻辑结构与存储结构混为一谈 错:「链表是一种逻辑结构」。对:线性表是逻辑结构,链表是它的一种存储实现。 逻辑结构只有四类(集合、线性、树、图),而「链表」「顺序表」「邻接表」「邻接矩阵」都是存储层面的概念。 考试常出「下列属于逻辑结构的是……」的选择题,选项里混入「顺序表」「散列表」来迷惑你。
易错点 2 以为 O(1) 就是「只执行一次」 一千条不含循环的语句仍是 O(1);哈希表的一次查找平均也是 O(1)。 O(1) 的真正含义是执行次数与问题规模 n 无关。 同理,「O(n) 的 n」到底是什么,必须结合上下文说清(元素个数?顶点数?串长?)。
易错点 3 用「系数变小」冒充「复杂度降低」 三角形双层循环 n(n+1)/2 与满嵌套 n² 是同一个阶,都写 O(n²); 把 i *= 2 改成 i *= 3 仍写 O(log n)。 只有改变了「与 n 的函数关系」(如从 n² 变成 n log n)才算降阶。 反过来,如果面试官说「优化一下」,你把 O(n²) 的常数从 5 降到 2,那只是常数级优化, 规模一大依然无济于事。
易错点 4 忘记递归的栈空间 递归求阶乘的时间是 O(n),空间也是 O(n),不是 O(1)。 判断递归空间的口诀是「看同时存活多少层,而不是看总共调用多少次」: 斐波那契朴素递归调用次数是 2ⁿ 级,但同时只有一条路径,所以空间仍是 O(n)。 反过来,若递归里再申请长度为 n 的数组,那空间就是两者相加。
考点 1 大 O 的定义与证明 考试常见问法:「证明 3n² + 2n + 1 = O(n²)」。 标准答法:取 c = 6、n₀ = 1,当 n ≥ 1 时 3n² + 2n + 1 ≤ 3n² + 2n² + n² = 6n²,故由定义得证。 要点是必须显式给出 c 和 n₀,只写「显然」是不给分的。
考点 2 循环与递归的复杂度速判
  • 循环变量按等差变化 → 次数与 n 成正比 → O(n);
  • 循环变量倍增 / 折半 → 出现 log → O(log n) 或 O(n log n)(嵌套时);
  • 循环变量自乘 / 开方 → 出现 log log → O(log log n);
  • 嵌套循环看「内层次数是否依赖外层」,依赖就要求和解三角形,但阶通常不变;
  • 递归看三件事:分几个子问题、规模缩到多少、合并要多久
考点 3 顺序存储 vs 链式存储的对比表(必背) 按位查找:O(1) vs O(n);已知位置插入删除:O(n) vs O(1); 存储密度:1 vs <1;空间分配:预分配连续 vs 动态申请任意; 是否支持随机访问:是 vs 否。这六条几乎每年都考。

1.16 工程视角:大 O 之外,真实机器还在乎什么

大 O 有个刻意的取舍:常数因子与低阶项统统丢掉,只留「随 n 增长的趋势」。 于是就有了那个危险的错觉——两个都写成 O(n) 的算法,在真实机器上可以差 5 倍、10 倍甚至更多。 本节补上三把尺子:内存层次缓存局部性数据规模分布

1.16.1 内存层次:主存比 L1 慢约 100 倍,磁盘又比主存慢约 10 万倍

CPU 做一次整数加法只要 0.3 ns,从主存取一个数却要 80~120 ns——一条加法的时间够执行三百条。 硬件把存储做成金字塔:越小越快越贵,越大越慢越便宜,每层都缓存下一层的副本。 必须记住的数量级是:L1 约 1 ns,L2 约 4 ns,L3 约 20 ns,主存约 100 ns,SSD 约 100 μs, 机械硬盘一次寻道约 10 ms。把一次 L1 命中当作 1 秒,一次主存访问就是 1 分 40 秒。 大 O 里这些都记作「一次访问」,代价却相差七个数量级。

CPU 几十年涨了上千倍,DRAM 延迟只从约 60 ns 挪到约 80 ns:1980 年代一次主存访问约等于 一条指令的时间,今天等于几百条——这条鸿沟就是内存墙(memory wall)。 它带来本节的中心命题:「执行了多少条指令」往往不是瓶颈,「访问内存的模式」才是

内存层次金字塔:越往上越快、越小、越贵 典型容量 典型延迟 相对 L1 寄存器 L1 缓存 L2 缓存 L3 缓存 主存 DRAM SSD 磁盘 几百字节 32~64 KB 256 KB~2 MB 8~64 MB 8~128 GB 256 GB~4 TB 4 TB 以上 < 0.3 ns 约 1 ns 约 4 ns 约 20 ns 约 100 ns 约 100 μs 寻道约 10 ms 0.3× 20× 约 100× 约 10⁵× 约 10⁷× ① 行优先:访存连续 miss 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 一条 cache line = 64 字节 = 16 个 int ② 列优先:每步跨 4096 字节 a[0][j] miss a[1][j] miss ×64 条缓存行 a[2][j] miss a[3][j] miss 同样多的加法:连续走 1 次访存换 16 个元素,跳着走 1 次只换 1 个。
图 1-18 内存层次金字塔与各级延迟数量级(上);行优先连续访问与列优先跨 4096 字节访问的缓存行命中对比(下)

1.16.2 缓存行与局部性:一次搬 64 字节,顺序与跳跃实测差 5 倍以上

CPU 与内存之间的搬运单位不是单个变量,而是缓存行(cache line):x86-64 上一次 64 字节。 一个 int 只占 4 字节,所以读 a[0] 时硬件会顺手把 a[15] 一并搬进 L1—— 这就是空间局部性;再加上时间局部性(刚用过的马上还会用),缓存才得以生效。 反过来,跳跃访问时一条缓存行只用到 4 字节,命中率跌到 1/16。 下面这段代码把两种走法放在同一份数据上对比,变化的只是两层循环的次序。

// cache_locality.cpp —— 缓存局部性与常数因子实测
// 编译:g++ -std=c++17 -O2 cache_locality.cpp -o cache_locality
// 实验一:1024x1024 数组按行 / 按列遍历求和(加法次数相同)
// 实验二:小数组上插入排序 vs 手写快排(大 O 更差的反而更快)
#include <bits/stdc++.h>
using namespace std;
using namespace std::chrono;

const int N = 1024;          // 4 MB,超过常见 L2
int a[N][N];                 // 全局数组,不占栈
int pool[200000];
int buf[N];                  // 每次从这里拷 n 个数
long long g_check = 0;       // 防止被优化掉
unsigned g_seed = 20260916u;

int nextRand() {
    g_seed = g_seed * 1103515245u + 12345u;
    return (int)((g_seed >> 16) & 0x7fff);
}

/* ---------- 实验一:行优先 vs 列优先 ---------- */

// 行优先:一条 cache line(64 字节)喂 16 个 int
long long sumByRow() {
    long long s = 0;
    for (int i = 0; i < N; ++i)
        for (int j = 0; j < N; ++j)
            s += a[i][j];
    return s;
}

// 列优先:相邻访问相隔 4096 字节 = 64 条 cache line
long long sumByCol() {
    long long s = 0;
    for (int j = 0; j < N; ++j)
        for (int i = 0; i < N; ++i)
            s += a[i][j];
    return s;
}

/* ---------- 实验二:插入排序 vs 快排 ---------- */

void insertionSort(int* v, int n) {
    for (int i = 1; i < n; ++i) {
        int key = v[i], j = i - 1;
        while (j >= 0 && v[j] > key) { v[j + 1] = v[j]; --j; }   // 连续回扫
        v[j + 1] = key;
    }
}

// 三数取中 + Hoare 划分,不做小区间优化
void quickSort(int* v, int lo, int hi) {
    if (lo >= hi) return;
    int mid = lo + (hi - lo) / 2;
    if (v[mid] < v[lo]) swap(v[mid], v[lo]);
    if (v[hi]  < v[lo]) swap(v[hi],  v[lo]);
    if (v[hi]  < v[mid]) swap(v[hi], v[mid]);
    int pivot = v[mid];
    int i = lo, j = hi;
    while (i <= j) {
        while (v[i] < pivot) ++i;
        while (v[j] > pivot) --j;
        if (i <= j) { swap(v[i], v[j]); ++i; --j; }
    }
    quickSort(v, lo, j);
    quickSort(v, i, hi);
}

double benchInsert(int n, int repeat) {
    long long guard = 0;
    auto t0 = steady_clock::now();
    for (int r = 0; r < repeat; ++r) {
        memcpy(buf, pool + (r * n) % 100000, n * sizeof(int));
        insertionSort(buf, n);
        guard += buf[0] + buf[n - 1];
    }
    auto t1 = steady_clock::now();
    g_check += guard;
    return duration<double, micro>(t1 - t0).count() / repeat;
}

double benchQuick(int n, int repeat) {
    long long guard = 0;
    auto t0 = steady_clock::now();
    for (int r = 0; r < repeat; ++r) {
        memcpy(buf, pool + (r * n) % 100000, n * sizeof(int));
        quickSort(buf, 0, n - 1);
        guard += buf[0] + buf[n - 1];
    }
    auto t1 = steady_clock::now();
    g_check += guard;
    return duration<double, micro>(t1 - t0).count() / repeat;
}

int main() {
    for (int i = 0; i < 200000; ++i) pool[i] = nextRand();
    for (int i = 0; i < N; ++i)
        for (int j = 0; j < N; ++j)
            a[i][j] = nextRand() & 0x7ff;

    sumByRow();  sumByCol();                  // 预热

    long long sRow = 0, sCol = 0;
    double rowMs = 1e9, colMs = 1e9;
    for (int r = 0; r < 3; ++r) {             // 各测 3 遍取最快
        auto t0 = steady_clock::now();
        sRow = sumByRow();
        auto t1 = steady_clock::now();
        sCol = sumByCol();
        auto t2 = steady_clock::now();
        rowMs = min(rowMs, duration<double, milli>(t1 - t0).count());
        colMs = min(colMs, duration<double, milli>(t2 - t1).count());
    }

    printf("=== 实验一:1024x1024 int 求和,-O2 ===\n");
    printf("行优先 sum = %lld,最快 %.3f ms\n", sRow, rowMs);
    printf("列优先 sum = %lld,最快 %.3f ms\n", sCol, colMs);
    printf("列优先 / 行优先 = %.2f 倍\n\n", colMs / rowMs);

    printf("=== 实验二:插入排序 vs 手写快排 ===\n");
    int sizes[3] = {8, 16, 32};
    for (int k = 0; k < 3; ++k) {
        int n = sizes[k];
        int repeat = 200000 / n;              // 规模越大重复越少
        double ti = benchInsert(n, repeat);
        double tq = benchQuick(n, repeat);
        printf("n = %2d:插入 %7.3f us,快排 %7.3f us,倍数 %.2f\n",
               n, ti, tq, tq / ti);
    }
    printf("校验和 = %lld\n", g_check);
    return 0;
}

在作者机器上(g++ 15.2,-O2,各测三遍取最快),实验一输出:

遍历方式加法次数复杂度实测最快耗时相对倍数
行优先(i 外 / j 内,访存连续)1 048 576O(N)0.107 ms1.00×
列优先(j 外 / i 内,步长 4096 字节)1 048 576O(N)0.576 ms5.4×

加法次数一模一样,只把 ij 换个位置就差 5 倍多 (换台机器重复跑会在 4~6 倍之间波动,但差距不会消失):行优先时 16 次连续加法 只付一次「搬缓存行」的钱,预取器还能顺着固定步长提前搬后面几条;列优先时每读一个 int 就新搬 64 字节,其中 60 字节当场作废。这正是第 06 讲二维数组「行优先存储」的性能后果。

工程上这条规律被反复利用:矩阵乘法分块(blocking)让子矩阵整块装进 L1,数据库分成行存与列存。 陷阱是:局部性优化依赖具体机器——L1 大小、有无向量指令、缓存行是 64 还是 128 字节, 都会改变最优分块大小,必须实测;而拆循环、重排数组会让代码变难读。

1.16.3 大 O 相同 ≠ 实际一样快:常数因子里装着什么

局部性只是常数因子的一个来源,第二个实验给出了另一个: n = 8 时插入排序比手写快排快约 1.4 倍,n = 16 与 n = 32 时快 1.8~2 倍, 尽管插入排序是 O(n²)、快排是 O(n log n)。原因全在被大 O 丢掉的那些项里:

这是标准库真实采用的工程决定。第 12 讲会讲到的内省排序(introsort)就是: 整体用快排,区间长度降到 16 以下就切换成插入排序,递归层数超过 2·log₂n 再换堆排序兜底。 std::sort、Java 的 Arrays.sort、Python 的 Timsort 里都埋着同一个阈值: 第 11 讲讲插入排序时它是最慢的 O(n²),到了工程实现里却成了高性能排序的最后一块拼图。

1.16.4 O(n²) 在小 n 上真的会赢:要看规模分布,不能只看阶

插入排序的代价约为 c₁·n²/4,归并排序约为 c₂·n·log₂n,交叉点落在 n ≈ 32~64: n 小于约 32 时,O(n²) 的插入排序比 O(n log n) 的归并排序还快。 上面 n = 32 那一行的 1.8 倍就是证据。

第 10 讲讲哈希冲突时「桶内用链表顺序查找」之所以是主流,正因为绝大多数桶是空的或只装一个元素; 第 07 讲的 B+ 树在结点内部也用二分查找,而不是再挂一棵子树。陷阱在于:n 会长大—— 今天每桶 1 个元素,明天哈希函数一退化就可能是 100 个。阈值要来自实测,最坏情况仍要可控, 所以 std::sort 切到插入排序之后还要留堆排序兜底。

1.16.5 空间复杂度的工程代价:栈会溢出,工作集会掉出缓存

第一条硬边界是递归栈。递归深度决定同时存活的栈帧数量,而线程栈有限:Linux 默认 8 MB, Windows 默认 1 MB,嵌入式上常常只有几 KB。一个栈帧按 48~64 字节算,8 MB 约撑得住 10 万~15 万层; 可一旦递归深度是 O(n),n = 10⁶ 就会当场栈溢出(stack overflow)。第 07 讲讲二叉树遍历时 那种「递归多优雅」的印象,一旦树退化成链,就必须改写成显式维护栈的迭代版本—— 第 03 讲的数据结构在这里从教学道具变成救命工具。

第二条是空间换时间的性价比,前提是「多出来的那块内存真的装得下,并且用得上」:

陷阱是:递归改迭代并不总是划算,显式维护栈会带来新的出错点。

1.16.6 一张表:工程选型要同时看的五个维度

请注意:表里任何单独一列都不足以做决定——大 O 最漂亮的一行可能缓存最不友好, 最快的一版可能多占一倍内存;「大 O 分析」只回答一个问题——规模再涨十倍,它会不会崩

方案大 O 分析实测耗时缓存友好度额外内存适用规模
数组行优先遍历 O(N),N = 元素个数 0.107 ms(本机实测) :连续访问,预取器有效 O(1) 任意规模
同一数组按列遍历 O(N),与上一行相同 0.576 ms,慢 5.4 倍(本机实测) :每步跨 64 条缓存行 O(1) 需改成分块遍历
链表逐个结点遍历 O(N) 约为数组的 5~10 倍(第 02 讲 2.10 的实测) :指针追逐,地址依赖上次访存 每结点多一个指针 频繁插删、中小规模
插入排序
小数组
O(n²) 0.310 μs(n = 32,本机实测) :在连续区间内回扫 O(1),原地 n ≤ 32 或近似有序
手写快排
无小区间优化
O(n log n) 0.563 μs(n = 32,慢 1.8 倍,本机实测) :划分时两端往中间交换 O(log n) 递归栈 n ≥ 64 的大数组
哈希表查找 平均 O(1) 常数最小(本节未实测) :按哈希值随机落点 约 2n 个槽位 内存充足、无需范围查询
考点小结:大 O 之外的三句话

① 内存层次:寄存器(不到 1 ns)→ L1(约 1 ns)→ L2(约 4 ns)→ L3(约 20 ns)→ 主存(约 100 ns)→ SSD(约 100 μs)→ 磁盘(寻道约 10 ms)。每降一层就差一个数量级以上, 这条鸿沟叫「内存墙」,它让访存模式往往比指令条数更能决定性能

② 缓存行 64 字节:顺序访问一次搬进来的 16 个 int 全部有用,跳跃访问只用到 4 字节; 同样的加法次数,只把两层循环换个次序,本节实测就差 5.4 倍

③ 规模决定选型:n ≤ 32 时插入排序比手写快排快 1.4~2 倍,所以标准库都在小区间切回插入排序; 交叉点由常数因子决定,只能实测。大 O 丢掉的常数因子里,装着访存次数、缓存命中率与递归开销。

这一节不直接考计算题,却是理解后面十几讲「为什么非要这么实现」的钥匙: 第 02 讲顺序表与链表的取舍、第 06 讲的数组存储顺序、第 10 讲的哈希桶设计、 第 11 讲与第 12 讲的排序实现,背后都是同一件事——让数据待在该待的那一层内存里

1.17 概念自测(8 题)

先自己在本子上写答案,再展开对照。答案里有推导过程的,请重点看推导思路而不是结论。

  1. 数据元素与数据项有什么区别?请各举一例。
    查看参考答案

    数据元素是数据的基本单位,在程序中作为一个整体被处理; 数据项是构成数据元素的、不可再分割的最小单位。 关系是「数据元素由若干数据项组成」。

    例:在学生成绩表中,「张三、学号 1001、计科 1 班、高数 92」这一整条记录是一个数据元素; 其中的「学号 1001」「姓名张三」「高数 92」分别是数据项。 注意:当数据元素只含一个数据项时(如一个整数数组元素 a[3] = 7),两者在形态上重合,但层级关系不变。

  2. 数据结构的三要素是什么?为什么说「逻辑结构独立于存储结构」?
    查看参考答案

    三要素:逻辑结构 + 存储结构(物理结构) + 数据的运算

    「独立」的理由:逻辑结构描述的是元素之间抽象的数学关系(一对一、一对多、多对多), 定义里根本不涉及「地址」「字节」「内存」这些概念,因此它与计算机、编程语言、机器字长都无关。 同一个线性表,既可以用顺序存储,也可以用链式存储、静态链表甚至散列存储来实现, 逻辑语义完全一样。

    这个独立性的直接价值有两个:一是让我们能脱离具体机器分析算法(这正是大 O 记号成立的前提); 二是让「换实现不改接口」成为可能(ADT 的价值)。

  3. 判断并说明理由:顺序存储一定比链式存储快。
    查看参考答案

    错误。两者各有优势,取决于具体的运算:

    • 按位查找第 i 个元素:顺序表 O(1)(地址可算),链表 O(n)(必须顺着走)——顺序表快;
    • 已知位置插入 / 删除:链表 O(1)(只改指针),顺序表 O(n)(要搬移后继元素)——链表快;
    • 空间:顺序表存储密度为 1 但需预分配连续空间、可能扩容搬移;链表密度小于 1 但按需分配。 当元素很大而表很稀疏时,链表反而更省内存。

    正确说法是:读多写少的场景选顺序表,频繁在中间插删且已持有位置信息的场景选链表

  4. 求下列代码的时间复杂度:for (int i = 1; i <= n; i *= 3) for (int j = 1; j <= i; j += 2) ++cnt;
    查看参考答案

    外层循环:i 取值为 1, 3, 9, 27, …,即 3k,退出条件是 3k > n, 所以外层执行次数为 ⌊log₃n⌋ + 1

    内层循环:当外层为 i 时,j 从 1 开始每次加 2,执行约 i/2 次。

    总次数 = Σk=0..log₃n (3k/2) = (1/2)·(3log₃n+1 − 1)/2 = (1/2)·(3n − 1)/2 ≈ 0.75n = O(n)

    结论:O(n)。这是一个很有迷惑性的题——外层看着像对数,但内层次数与 i 成正比, 而 i 是指数增长的,指数求和的结果由最后一项主导,最终是 O(n)。

  5. 顺序查找中,若每个元素被查找的概率相等,平均查找长度 ASL 是多少?请写出推导。
    查看参考答案

    设表长为 n,查找第 i 个元素需要比较 i 次;等概率时 pi = 1/n。则

    ASL = Σi=1..n pi·ci = (1/n)·Σi=1..n i = (1/n)·n(n+1)/2 = (n+1)/2

    所以平均比较次数约为 n/2,平均时间复杂度为 O(n)。 注意区分:最好情况是 1 次(O(1)),最坏情况是 n 次(O(n),查找失败时也是 n 次)。

    延伸:若各元素被查概率不等,则 ASL = Σ pi·ci 需按实际分布计算。 工程上的优化手段就是把高频元素前移(自适应线性表)。

  6. 递归式 T(n) = 2T(n/2) + O(n) 的解是什么?为什么归并排序的递归树每层代价都是 n?
    查看参考答案

    T(n) = O(n log n)。

    递归树第 k 层有 2k 个子问题,每个规模为 n/2k, 而每个子问题的合并代价与它的规模成正比,所以第 k 层的总代价 = 2k × c·(n/2k) = c·n, 与层数无关,恒为 n。这说明「分得越细,问题越多,但每个问题越小,总量恰好抵消」。

    树高:n/2k = 1 时触底,得 k = log₂n。 但要数清楚:归并只发生在 k = 0 … log₂n − 1 这 log₂n 层(子问题规模 n、n/2、…、2); 触底那一层的子问题规模是 1,mergeSort 直接 return,不做归并、不产生代价 (比较 0 次、搬移 0 次)。所以「每层代价都是 n」这句话只对那 log₂n 层成立。 因此 T(n) = cn·log₂n + O(n) = O(n log n)。

    补充一句:这里没有「+ 1」这一层,是因为规模 1 的递归基什么都不用做; 对比二分查找 T(n) = T(n/2) + c:它的 T(1) = c 本身就要做 1 次比较, 那一层躲不掉,所以才写成 c(log₂n + 1)。

    对照记忆:如果合并代价降为 O(1)(如二分查找只走一边),就退化成 T(n) = T(n/2) + O(1) = O(log n)。

  7. 递归求阶乘 fact(n) = n * fact(n-1) 的空间复杂度是多少?为什么不是 O(1)?
    查看参考答案

    S(n) = O(n)。

    每进入一层递归,系统就在运行时栈上压入一个栈帧(保存参数 n、局部变量与返回地址)。 在 fact(1) 触底之前,n 个栈帧同时存在,直到开始逐层返回才依次弹出。 因此最大栈深度为 n,辅助空间为 O(n)。

    对比:同样功能的迭代版本只用 ri 两个变量,S(n) = O(1)。 这也解释了为什么 n 很大时递归会栈溢出(Stack Overflow)——工程代码在深度不可控时优先写迭代。

  8. 大 O 是上界还是下界?3n² + 2n + 1 = O(n³) 成立吗?既然成立,为什么我们通常写 O(n²)?
    查看参考答案

    大 O 表示渐进上界:存在正常数 c 与 n₀,使 n ≥ n₀ 时 0 ≤ f(n) ≤ c·g(n)。

    O(n³) 成立。取 c = 1、n₀ = 6:当 n ≥ 6 时有 3n² + 2n + 1 ≤ n³。 所以定义上没有问题——上界可以很松。

    之所以通常写 O(n²),是因为我们要的是最紧的上界:它携带的信息量最大。 写成 O(n³) 虽然不错,但会误导读者以为算法在 n = 10⁶ 时要跑 10¹⁸ 次。 另外注意,大 O 里的等号不是等价关系而是「属于」(f ∈ O(g)), 否则 3n²+2n+1 = O(n³) 与 n³ ≠ 3n²+2n+1 会构成矛盾。

下一讲预告 第 02 讲我们正式进入第一个数据结构——线性表。 你会看到顺序表的插入删除如何搬移元素、链表如何用指针把散落的内存串成一串、 头结点与哨兵如何让边界代码变干净,以及单链表反转、快慢指针判环这两个经典面试题的完整推导。 本章的「顺序 O(1) vs 链式 O(n)」会在那里被彻底演算一遍。