C01 · 第 1 章 · 第一编 数据结构
序列、栈、队列、树与堆
从抽象数据类型、表示不变量和操作成本出发,比较动态数组与链表,实现栈和队列,递归遍历二叉树,分析二叉搜索树退化与平衡必要性,并以二叉堆实现优先队列。
报告页面错误本章目标
- 区分抽象数据类型、接口语义、具体表示和表示不变量,不把某种实现成本写成接口保证。
- 比较动态数组与链表的访问、插入、删除和遍历成本,并解释摊还追加。
- 用栈和队列的 LIFO/FIFO 契约设计算法,处理空结构和容量边界。
- 递归遍历二叉树,使用搜索树顺序不变量分析操作成本与退化情况。
- 用堆序和完全树不变量实现优先队列,跟踪上浮、下沉和复杂度。
本页目录
接口语义先于存储形状
抽象数据类型规定“能做什么”和“结果意味着什么”,具体实现规定“如何存”。例如栈接口要求最后压入的元素最先弹出,但可以用动态数组或链表实现。接口不应承诺内存连续,也不应暴露内部节点。
每个实现还需要表示不变量。动态数组要求逻辑长度不超过容量,前 个槽恰好存放序列元素;链表要求头指针沿 next 最终到达空节点且没有意外环;二叉搜索树要求每个子树的键满足顺序政策;堆要求父节点优先级不劣于子节点。操作只有在开始时假设不变量、结束时恢复不变量,后续接口才可信。
成本模型要说明计数单位。本章用随机访问、比较、指针跟随和元素移动等基本操作的渐近数量描述时间,用额外槽或节点数量描述空间。大 给增长上界,不是某台机器的秒数;缓存和分配器会影响实测,但不改变接口正确性。
表示不变量也是定位错误的工具。可以在每个公开操作前写清前置条件,在返回前检查逻辑长度、首尾引用、父子次序和容量关系。以队列为例,空队列应同时满足长度为零与首尾状态一致;入队后长度增加一,原有元素相对顺序不变;出队只移除最早元素。随机操作序列测试可把实现结果同一个简单参考模型逐步比较,并在每一步检查不变量。这样的检查不能替代复杂度证明,却能把“返回值偶然正确”和“内部状态可继续安全使用”区分开。分析成本时还要计入为恢复不变量而发生的移动、旋转或交换,不能只数接口表面的一次调用。
动态数组与链表
动态数组把序列元素放在可按索引定位的连续逻辑区域。已知索引访问通常为 ;在中间插入或删除需要移动后缀,最坏为 。容量满时申请更大区域并复制已有元素,单次扩容是 ,但若容量按固定大于 1 的比例增长,连续末尾追加的总复制次数形成几何级数,所以每次追加的摊还成本为 。摊还不表示每次都快,某一次仍可能触发复制。
单向链表节点保存值和后继引用。给定头节点寻找第 个元素需要沿链前进,成本 ;若已持有待插位置的前驱节点,插入可改常数个引用,成本 。只给一个键而没有节点引用时,仍要先线性搜索。双向链表增加前驱引用,使已知节点删除和反向移动方便,但占用更多引用并增加维护不变量。
要依次读取 个值,并让每个新值出现在结果最前。若用动态数组每次在索引 0 插入,第 次要移动已有 个元素,总移动数
若用持有头引用的单向链表,每次创建节点并令其 next 指向旧头,共改常数个引用,总成本 。如果最终还要频繁按索引访问,可先把输入末尾追加到动态数组,再做一次反转,同样是 。这说明应根据完整操作序列选择实现,而不是看到“插入”二字就固定用链表。
链表不是自动优于数组。顺序遍历时数组通常具有更简单的地址模式,链表则需要跟随引用;但这些常数和硬件效果必须测量。本章能可靠承诺的是渐近操作数和表示语义。
栈与队列是操作顺序契约
栈遵循后进先出:push 加入顶部,pop 删除并返回顶部,peek 只查看。队列遵循先进先出:enqueue 在尾部加入,dequeue 从头部删除。两者对空结构操作都要定义失败,不能随意返回一个可能与合法元素冲突的特殊值。
用动态数组实现栈时,末尾 push 具有摊还 ,末尾 pop 为 。队列若每次从数组头删除会移动全部剩余元素;可用环形缓冲区维护 head、tail 和 size,使入队出队为 。环形实现的不变量要区分空与满,单靠 head 等于 tail 不够时需额外长度或保留空槽。
扫描文本,遇到左括号压栈,遇到右括号时检查顶部是否为匹配类型:
def brackets_balanced(text):
opening = {"(": ")", "[": "]", "{": "}"}
stack = []
for position, symbol in enumerate(text):
if symbol in opening:
stack.append((symbol, position))
elif symbol in opening.values():
if len(stack) == 0:
return Failure("closing bracket without opener", position)
left, left_position = stack.pop()
if opening[left] != symbol:
return Failure("mismatched bracket", position)
if len(stack) != 0:
left, position = stack[-1]
return Failure("unclosed bracket", position)
return Success()
输入 {[()]} 成功;] 在位置 0 失败;([)] 在第三个字符发现类型不匹配;(() 扫描结束后仍有左括号。每个字符最多压入和弹出一次,时间
,最坏额外空间 。栈接口足以证明算法,不依赖它内部是不是数组。
队列常用于按到达次序或按层处理对象。图的广度优先遍历会把新发现顶点入队,并按发现顺序出队;若误用栈,就会得到深度优先式顺序,算法性质随之改变。
二叉树与递归遍历
二叉树节点至多有 left、right 两个孩子。树是递归结构:空树是基本情形,非空树由根和两个子树组成。遍历也自然递归:
def inorder(node, visit):
if node is None:
return
inorder(node.left, visit)
visit(node.value)
inorder(node.right, visit)
前序在子树前访问根,后序在子树后访问根,中序把根放在左右子树之间。每个节点只访问一次,时间 ;递归调用栈深度等于树高 ,额外空间 。退化链会有 ,不仅搜索慢,也可能超过递归深度或内存边界。显式栈可避免语言调用栈限制,但仍需 状态。
二叉搜索树与平衡必要性
设键互异,二叉搜索树不变量为:任一节点左子树键都小于该键,右子树键都大于该键。若允许重复,必须另定“计数存在节点内”或“相等总去一侧”的政策,并让插入、搜索和删除一致。
搜索从根开始,每次比较后只进入一个子树,成本 。插入先按同一路径找到空位置,再接入叶节点。删除有三种情况:无孩子直接移除;一个孩子让父节点绕过它;两个孩子可用中序后继替换,再删除后继。每步都要恢复顺序不变量。
依次插入 ,得到高度为 3 层的完全形状。搜索 5 的比较路径是 ,三次命中。中序遍历输出 ,可作为顺序不变量检查。
若依次插入 ,普通未平衡搜索树退化为只含右孩子的链,高度为 7,搜索 7 需要七次比较。两个树保存同一集合,接口结果相同,但操作最坏成本不同。不能因为数据结构叫“树”就宣称搜索必为 。
平衡搜索树通过旋转等局部变换把高度限制为 。旋转保持中序键序,却改变父子形状;具体 AVL、红黑或其他平衡政策是实现选择。映射接口只应承诺键值语义和经过证明的复杂度界,不要求调用者依赖某次旋转后的根键。
堆与优先队列
优先队列接口支持 insert、peek-min 和 extract-min。二叉最小堆用完全二叉树表示,并保持每个父键不大于孩子键。完全形状让它可紧凑存于数组;从零开始索引时,节点 的孩子是 ,非根父节点是 。
插入先放到数组末尾保持完全形状,再与父节点比较并上浮,最多经过树高 。取最小值直接读根,成本 。删除最小值时用末元素填根,再与较小孩子交换并下沉,成本 。堆序只保证祖先不大于后代,不保证数组整体排序。
初始堆数组
满足堆序。插入 3 后先放末尾:
3 的父节点是索引 2 上的 4,交换得
此时父节点 2 不大于 3,上浮结束。执行 extract-min 删除 2,以末元素 4 填根:
根与较小孩子 3 交换,得到
恢复堆序。两次操作都只沿一条根叶路径。若错误地与较大孩子交换,另一侧较小孩子仍会违反堆序。
从 个无序元素逐个插入建堆需 上界;自底向上对所有内部节点下沉可在 完成,因为多数节点靠近叶部、下沉距离短。这个线性结论需要按各层节点数求和,不能只用“有 个节点,每个都可能下沉 ”的松上界否定。
不要混淆接口、表示和用途
栈、队列与优先队列是抽象接口;数组、链表、树和堆是表示。一个动态数组可以实现序列或栈,链表可以实现队列,平衡搜索树和哈希表都能实现映射,但它们的顺序能力与成本不同。
选择时先列出操作频率、是否需要顺序、最坏界还是期望界、内存上限和失败行为。若算法需要反复取最小值,排序一次或使用堆哪个更好取决于后续插入次数;若只需一次最小值,线性扫描可能更简单。数据结构名称不能替代工作负载分析。
常见误区
“链表任意位置插入都是常数时间。”只有已持有位置节点或前驱引用时成立;按索引或键找到位置仍可能线性。
“二叉搜索树搜索总是对数时间。”成本是 ;未平衡树在有序插入下可退化到 。
“堆数组已经排序。”堆只保证父子优先关系,兄弟和不同子树间没有全序位置保证。
练习:不变量与操作成本
说明容量翻倍为什么使连续 次末尾追加具有摊还 成本。
查看提示
查看解答
比较“给定前驱节点插入”和“给定索引插入”单向链表的成本。
查看提示
查看解答
同样依次加入 A、B、C,写出栈弹出和队列出队顺序,并说明空结构行为。
查看提示
查看解答
证明二叉树中序遍历的时间为 、递归额外空间为 。
查看提示
查看解答
解释同一组互异排序键为何能形成高度 或 的搜索树。
查看提示
查看解答
在最小堆 中插入 1,再删除最小值,写出每次交换。
查看提示
查看解答
知识连接与资源
- 编程与科学计算综合项目 提供容器、函数契约和边界处理。
- 计数原理 支持层数、节点和操作次数求和。
- 哈希表、图与并查集 将使用桶序列、遍历队列和树形集合。
- 分治、贪心与排序 将使用递归树、堆和优先选择。
- 数据结构与算法综合复习 汇总不变量、正确性和复杂度选择。
Introduction to Algorithms
Erik Demaine, Jason Ku, Justin Solomon
用于核对 C01 的序列与树、散列和图、分治与排序、动态规划、图算法及复杂度分析。
打开官方来源MIT OpenCourseWare 6.006 覆盖序列、树、堆和渐近分析,可用于核对本章接口、不变量与复杂度。本章不把语言容器的某个内部实现写成抽象数据类型的永久语义。