Introduction
同学们已经学习过 C++、Java 等程序设计语言,熟悉了:
这些知识让我们能够写出程序。
虽然这些系统的目标和功能完全不同,但是它们都在反复处理几个基本问题:顺序、层级、连接、查找、优先级以及计算成本。
数据结构(data structure)
组织数据,表达数据之间的关系
算法(algorithm)
组织计算过程,系统地解决问题
数据结构与算法二者相辅相成:数据组织形式和计算方法互相耦合
本课程接下来要做的,就是把复杂系统里反复出现的问题抽象成少数可复用的结构与算法,并学会根据工作负载和约束进行选择。
完成本章后,应能回答:
数据结构通常不是凭空选择的,而是从问题中长出来的。
角色来到一个危险位置:
现在可以做什么?
同一个局面下,可能有不止一种合理选择。
把当前游戏局面看成一个状态。
每一种可选动作,都会产生一个新的局面。
而新的局面,又会继续产生新的选择。
于是,所有可能的未来会逐层展开。
这就是一棵决策树。
图书馆里有很多书。
如果我们只是想一本一本地浏览
如果读者给出一本书的 ISBN,希望马上找到它呢?
如果读者提出一个更复杂的问题:找 2020 年以后出版、数据库方向、当前可借阅的书,并按出版时间排序?
还是这些书,但需要支持的操作已经完全不同。
假设这些书已经排成一个确定的顺序。
最直接的处理方式,就是从第一本开始,一本一本向后访问。
这时真正重要的,不是书的 ISBN 或作者,而是:谁在谁前面,谁在谁后面。
把书的具体内容暂时拿掉,只保留元素之间的前后关系。
这种一前一后的逻辑关系,就是 线性表。
现在读者给出一本书的 ISBN:978-7-111-68181-6,希望马上找到它。
顺序查找只能从第一本开始:先比较第 1 本,不匹配。
继续比较第 2 本,仍然不匹配。
再比较第 3 本,终于命中目标。
如果馆藏不是几本,而是 100 万本,这种逐本扫描就太慢了。
换一种思路:取 ISBN 末四位 1816,计算 H(1816)=1816 mod 7=3。
计算结果告诉我们直接检查 桶(bucket)3;这里保存的正是目标图书。
这种根据关键字(key)计算存储位置,再定位值(value)的结构,就是散列表(Hash Table)。
现在读者提出:找 2020 年以后出版、数据库方向、当前可借阅的书,并按出版时间排序。
这时,一个 ISBN 已经不够了:每本书还要同时保存分类、年份、状态等多个属性。
查询也不再只是“找到一个关键字”,而是要做多条件过滤,再进行排序。
这已经不是一种单独的数据结构能够完整解决的问题。
我们需要更高层的软件系统:数据库管理系统(Database Management System,DBMS)。它内部仍会综合使用索引、B+ 树、散列表、排序等数据结构与算法。
城市里有 5 个地点 A–E,由 7 条道路连接起来。
如果每条道路都有通行能力,从 A 到 B,单位时间内最多能通过多少车流?
如果想把 A–E 五个地点都访问到,能不能做到不重复、不遗漏?
如果任务变成道路巡检,7 条路都必须走到,能不能做到每条道路恰好一次?
真实城市里道路上还有持续变化的车流。红绿灯怎样协同调度,才能让整个路网的总通行效率更高?
栈、队列、树和图会在系统软件和应用软件中反复出现。
Linux
栈、队列、树、调度
Redis
散列、跳表、压缩列表
PostgreSQL
B+ 树、排序、连接
Neo4j
图存储与路径查询
Kafka
队列、日志、分区
数据是信息的载体,是能被计算机输入、识别、存储和处理的符号集合。
常见数据可以粗略分为:
int、float、复数、坐标、概率数据结构关注的不只是一个个数据项,还关注它们之间的关系和围绕这些关系设计的操作。
数据结构至少同时包含三件事:
只谈“存了哪些元素”是不够的。没有关系,就不知道如何遍历;没有操作,就不知道结构要支持什么成本。
线性结构中,除首尾外,每个元素通常只有一个直接前驱和一个直接后继。
非线性结构中,一个元素可以连接多个元素。
分析一个数据结构时,要区分三个问题:
同一个逻辑结构可以有多种物理实现。
同一个操作在不同实现上的代价可能完全不同。
同一个线性表抽象数据类型,可以有不同物理实现。考虑一个具体任务:
S1, S2, S3, S42 插入新记录 Sx关键问题是:逻辑上的一次 insert(2, Sx),在不同物理结构中会变成哪些底层动作?
一个数据类型由两部分组成:\(\text{数据类型}=\text{值集合}+\text{操作集合}\)
例如 int:
+、-、*、/、%、比较等程序语言会提供基础类型,也支持构造结构类型,例如数组、结构体、类和容器。
抽象数据类型关注的是“能做什么”的接口,而不是“怎么做”的实现。
例如线性表抽象数据类型:
自然数的抽象数据类型可以只描述对象和操作,而不暴露机器内部如何表示整数。
抽象数据类型是行为契约。
面向对象把数据和操作组织进对象与类中。
例如矩形对象可以包含左上角、右下角、边框颜色、填充颜色,并支持移动、缩放、改色等操作。
一个算法应满足五个基本性质:
程序是算法在某种语言和机器环境中的实现;算法是更抽象的计算过程。
算法描述解决问题的步骤。程序把算法落实到具体语言、库、类型系统和运行环境中。
例如二分查找的算法思想是“每次比较中点并丢弃一半区间”。
实际程序还要处理:
算法分析通常先讨论抽象过程,再检查程序实现是否忠实表达了这个过程。
选择排序要解决的任务是:把数组从小到大排列。
它每一轮遵循同一个步骤:
这个过程也解释了为什么选择排序需要大量比较;下一章会把比较次数精确数出来。
同一个排序问题,可以有不同算法。
如果逐个比较并选择最小值,比较次数大约为:
\[
(n-1)+(n-2)+\cdots+2+1=\frac{n(n-1)}{2}
\]
这说明它的增长速度是 \(O(n^2)\)。更高效的比较排序可以达到 \(O(n\log n)\)。
算法分析至少关注:
算法分析会反复使用这些工具:
本章不要求一次掌握所有细节,但要知道它们会服务于算法分析。
递归函数必须同时满足:
如果只有递归调用而没有终止条件,程序不会停。
如果有终止条件但调用没有靠近它,程序仍然可能不会停。
递归调用也是函数调用。一个没有递归的函数调用例子,可以展示栈帧(stack frame)如何保存执行现场。
关键事实:
main 的栈帧保存本次调用自己的局部状态和返回位置;函数调用不会覆盖调用者,而是在栈顶增加新的执行上下文。
main 要得到 f(3) 才能完成对 ans 的赋值,因此调用者会暂停在这条语句,等被调函数返回后再从这里继续。
f 的方法头建立新的调用边界:参数 x=3 属于这一次调用,后面的局部状态都保存在同一个 f 栈帧中。
total 属于这一次 f 调用,并会跨越后面的两次 g 调用持续存在;嵌套调用结束后,f 仍要靠它保存累计结果。
i 和 total 都保存在 f 的栈帧里,所以即使中途进入 g,这一轮循环的状态也不会丢失。
f 需要 g(3) 的结果才能完成当前累加;新的 g 栈帧只负责这一子计算,f 保留原状态等待返回。
g 的方法头把实参 3 绑定为本次调用的参数 y;这个调用拥有独立栈帧,不会覆盖 f 的局部变量。
z 是 g 这次调用的局部状态,只服务于 g(3);函数返回后它会随栈帧一起消失,真正传回调用者的是返回值。
g 已经把自己的局部计算压缩成返回值 8;这个值是调用者恢复当前表达式所需要的唯一结果。
f 的调用点后,g 的栈帧已完成使命;控制权恢复到原来尚未完成的累加语句。
f 用返回值完成本轮累加,total=8 成为下一轮要继续维护的累计状态;这说明调用返回后,调用者会接着完成原表达式。
i=1。调用栈恢复的不只是函数名,而是调用者精确到循环位置和局部变量的完整上下文。
g 会创建新的栈帧;它与上一轮的 g(3) 已无共享局部状态,但仍把结果交回同一个 f 栈帧。
g 方法时,实参 4 绑定成新的 y;相同方法的两次调用因此可以拥有彼此独立的局部状态。
g(4) 有自己独立的 z=5。同一个函数被多次调用时,每次调用都拥有独立局部变量,这正是栈帧隔离状态的意义。
g 的局部细节,只需要它的结果。
f 后,第二个 g 栈帧也可以释放;f 恢复到第二轮尚未完成的累加。
total 从 8 更新为 18,把两次子调用的贡献合并进同一局部状态;这就是 f 最终要返回的累计结果。
i=2 时循环条件失败,说明所有子调用都已完成;此时 f 的局部状态已经足以产生函数结果。
f 把内部循环和两次 g 调用的细节封装成返回值 18,调用者只观察到这个函数结果。
main 后,f 的栈帧可以释放;main 恢复到最初等待返回值的那条赋值语句。
ans 后,这次函数调用才真正完成了它在调用者表达式中的作用;结果从被调函数转化为调用者自己的局部状态。
factorial(n) 的契约是返回非负整数 n 的阶乘;递归体必须让参数持续减小并最终落到基例。
n 减 1,只有从非负整数出发,这个“问题规模严格变小”的过程才保证最终到达基例,而不会无限向负数继续。
0!=1、1!=1 是递推关系的锚点,也是递归算法真正能够停下来的原因。到达这里后不再产生更小子问题,计算从“继续分解”切换到“逐层合并答案”。
n! = n × (n-1)! 把原问题变成一个规模更小、结构完全相同的子问题;当前这一层只需要记住自己的 n,等子问题返回后再完成乘法。因此共有 O(n) 层调用,时间和递归栈深度都随 n 线性增长。
factorial(4) 不能立刻完成 4×?:它先把 factorial(3) 作为更小的同类问题交给下一层,同时保留“回来以后还要乘 4”这件未完成的工作。递归栈保存的正是这些尚未完成的计算上下文。
factorial(3) 再把问题缩成 factorial(2)。此时外层的 4× 和这一层的 3× 都处于等待状态;栈深度因此对应“还有多少层子问题尚未返回”。
factorial(2) 继续缩到 factorial(1)。参数按 4→3→2→1 严格减小,这不仅描述执行过程,也给出了递归会终止的关键理由。
n=1 是递推链的锚点:这里已经知道答案,不再产生新的递归调用。计算从“向下分解问题”切换为“向上合并子问题结果”。
1 让 n=2 那层原本悬而未决的 2×factorial(1) 第一次可以完成。递归返回按后进先出的顺序展开,因此最后创建的子问题最先被解决并交回上一层。
n=2 只做本层负责的组合工作:把自己的因子 2 与子问题结果 1 相乘得到 2。每一层都不需要知道更深层怎样求解,只依赖它返回的结果。
n=3 后,n=2 这一层的任务已经完成。栈因此既记录调用历史,也保存“哪个更大的问题正在等待这个结果”。
n=3 用同一条递推规则完成 3×2=6。向上返回的过程正是在反向重建原问题:下降阶段负责缩小规模,回程阶段负责合并答案。
n=4。返回值沿调用链反向传播,而每一层保存的局部 n 决定这一层应该怎样继续未完成的计算。
4×6=24。递归用相同的规则逐层缩小问题,再逐层把局部结果组合成原问题答案。
factorial(n),最多同时保留约 n 个调用层,因此虽然每层只做常数次额外运算,递归仍需要 O(n) 的辅助栈空间。
标准定义是:\(fib(0)=0,\quad fib(1)=1,\quad fib(n)=fib(n-1)+fib(n-2)\)
fib(n) 按递推定义返回第 n 个 Fibonacci 数;方法体会把一个问题拆成两个更小但重叠的子问题。
n-1、n-2 递归始终朝 0/1 靠近,而不是沿无效输入继续展开。
F(0)、F(1) 是递推关系的两个锚点。所有分支最终都必须落到这两个已知值,递归树才有可合并回原问题的叶子答案。
F(n)=F(n-1)+F(n-2) 产生两个重叠子问题;同一个 F(k) 会在不同分支被反复求解,这正是朴素递归出现指数级重复工作的根源。
前面的示例只是为了演示递归。
Fibonacci 真的该这么算吗?
核心问题是:怎么通过递归把问题降维
数据结构通常会被写成类或模板类。例如下面这个简单的对 int 的封装类,同时在 main 中展示如何构建和读写对象。
public class IntCell {
private int value;
public IntCell(int value) { this.value = value; }
public int read() { return value; }
public void write(int value) { this.value = value; }
public static void main(String[] args) {
IntCell cell = new IntCell(7);
cell.write(11);
System.out.println(cell.read());
}
}IntCell 把一个整数状态封装在对象内部,只允许通过公开操作读取或修改。
value 被封装成对象内部状态,构造器负责建立初始表示不变量。以后即使内部字段布局改变,只要对象仍能维持同一抽象语义,调用者就不需要跟着修改。
read/write 把“允许怎样观察和修改状态”固定成接口边界;验证、日志或其他约束都可以以后加入实现,而不必把这些细节泄漏给使用者。
main(…) 只负责串联示例输入与数据结构操作,不属于本页要分析的核心算法。
main 只依赖构造器和 read/write,而不直接碰字段。这正是抽象数据类型与封装的价值:客户端面向操作语义编程,实现细节可以独立演化。
数据结构通常不关心元素的具体类型,而关心元素支持哪些操作。
例如“找最大值”只需要元素能比较大小,也就是 T extends Comparable<? super T>。
static <T extends Comparable<? super T>> T findMax(List<T> values) {
Iterator<T> iterator = values.iterator();
if (!iterator.hasNext()) {
throw new IllegalArgumentException("values must not be empty");
}
T best = iterator.next();
while (iterator.hasNext()) {
T candidate = iterator.next();
if (candidate.compareTo(best) > 0) {
best = candidate;
}
}
return best;
}findMax(…) 返回整个序列的最大元素;扫描过程中只需维护“已看前缀的最大值”这一不变量。
best 始终代表“已经看过的前缀”中的最大值。
best 压过的元素以后无需再比较。
best,因此每轮结束后“前缀最大值”不变量继续成立。
best 因而是全局最大值;每个元素只比较一次。
如果不用泛型,相似的函数往往会为不同类型重复写多份:
maxInt(…) 返回两个候选中的较大者;算法只依赖一次比较而不改变输入。
maxDouble(…) 返回两个候选中的较大者;算法只依赖一次比较而不改变输入。
double 后决策过程完全没变;复制实现只是在重复维护同一个算法。
泛型把“类型”也变成参数:
maxValue(…) 返回两个候选中的较大者;算法只依赖一次比较而不改变输入。
后面章节的代码会集中使用这些语言工具:
| 语法或库 | 典型写法 | 用途 |
|---|---|---|
| 泛型 | class Stack<T>、T value |
写可复用容器和算法 |
| 链接 | Node<T> next |
用对象引用表示链式结构和树结构连接 |
| 空引用 | null |
表示没有后继、孩子或根 |
| 容器类 | ArrayList, LinkedList |
保存顺序数据或辅助存储 |
| 类型推断 | var it = ... |
简化迭代器和复杂类型 |
| 比较器 | Comparator<T> cmp 或 Lambda 表达式(lambda expression) |
定制排序、堆和最优选择规则 |
| 可选返回 | Optional<T> |
表示查找可能失败 |
| 枚举 | enum Tag |
表达有限状态并避免名字污染 |
Java 容器以对象引用保存元素,例如 ArrayList 内部用连续数组存放元素。
ArrayList 用连续槽位保存元素引用;末尾追加只扩展逻辑序列,不改变已有元素的身份。
程序错误可以粗略分为:
数据结构代码需要明确异常边界:
popdeleteMin数据结构的失败情况要明确表达。Java 中常见做法有两类:
Optional<T>pop(…) 必须返回并移除当前栈顶;空栈时没有合法结果,不能继续修改结构。
pop 把“取栈顶”和“删除栈顶”绑定为一次后进先出操作,返回的正是最近压入的元素。
课程代码应尽量把“算法核心”和“输入输出”分开。
这样做可以让同一个数据结构脱离具体输入来源,在不同程序中复用。
顺序文件读取的模式是:打开、逐行读取、解析、处理、关闭。
static List<Integer> readIntegers(Path path) throws IOException {
ArrayList<Integer> result = new ArrayList<>();
try (BufferedReader reader = Files.newBufferedReader(path)) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
result.add(Integer.parseInt(trimmed));
}
}
}
return result;
}readIntegers(…) 按文件顺序解析有效整数并返回列表;输入资源生命周期与结果列表分离。
try 语句(try-with-resources)把“读取结束就关闭”写成结构保证,不需要调用者记得清理。
大型课程代码需要稳定组织方式:
src/:数据结构源码目录src/main/java:主程序和示例程序src/test/java:测试入口package ds:避免名字冲突NoSuchElementException、IllegalStateException:表达容器下溢和上溢这种组织方式把算法、输入输出和测试边界分开,使代码更容易编译、测试、复用和维护。
下一章会把“算法效率与资源代价”变成可计算的问题:
从这里开始,程序不仅要能运行,还要能解释为什么正确、为什么高效。