Tree
这是文件系统中的一个目录:
/course/chapter4/examples/TreeDemo.java
文件系统要回答:
chapter4 的父目录是谁chapter4 下有哪些直接子目录course 下面一共有多少层chapter4 时,哪些内容属于它的整个子树如果只用“前一个 / 后一个”的线性关系,这些问题会变得非常别扭。真正需要表达的是:一个对象可以同时拥有多个直接后继,而且这些关系会继续递归展开。
当关系从“一前一后”变成“一对多”,数据应该怎样组织?
同一种结构模式还反复出现在:
这些场景都需要表示递归的层级关系。
本章将从普通树和二叉树(binary tree)出发,进一步研究树怎样存储、怎样遍历、怎样由遍历序列恢复,以及如何利用树的结构设计更高效的算法。
树 \(T\) 是结点的有限集合。
树可以为空。
若树非空,则存在一个特殊结点 \(r\),称为根;其余结点被划分为若干棵互不相交的非空子树:
\(T_1,T_2,\cdots,T_k\)
递归定义是树结构的核心。
从图论角度看,非空树就是一个无向、连通、无环图。
若树有 \(n\) 个结点,则边数一定是
\(|E|=n-1\)
原因:除根结点外,每个结点恰好有一条来自父结点的边。
以根为起点,按深度奇偶性给结点分组:
树中每条边都连接相邻两层,因此总是跨越 \(U\) 与 \(V\):树一定是二部图。
这里“无环”很关键:从根到每个结点的简单路径唯一,因此结点的深度奇偶不会产生冲突。
因为树原本没有环,加一条边会产生唯一一个环;如果这个环是奇环,图就不再是二部图。
结点的度:一个结点拥有的孩子个数。
若结点 \(v\) 有 \(k\) 个孩子,则 \(\deg(v)=k\)。
因此:
树的度:整棵树中所有结点度数的最大值,即 \(\deg(T)=\max_{v\in T}\deg(v)\)。
注意:结点的度和树的度
| 维度 | 线性结构 | 树结构 |
|---|---|---|
| 关系 | 一对一前后关系 | 一对多父子关系 |
| 入口 | 通常是表头或栈顶 | 根 |
| 移动方向 | 前驱/后继 | 父、孩子、兄弟 |
| 典型遍历 | 从左到右 | 深度优先或广度优先、二叉树的前序中序后序 |
递归定义: 二叉树或者是空树,或者由一个根结点和两棵互不相交的二叉树——左子树与右子树——构成。
左、右子树有顺序:只有左孩子和只有右孩子是不同结构。
| 维度 | 普通树 | 二叉树 |
|---|---|---|
| 孩子数 | 任意多个 | 最多两个 |
| 子树是否有序 | 常视为无序 | 左右有序 |
| 空子树 | 通常不显式出现 | 左空与右空要区分 |
| 存储方式 | 孩子链表、长子兄弟 | 左右孩子指针 |
该树表示:
\[ (a*b)+(c/d) \]
设根在第 \(0\) 层,第 \(i\) 层最多有:
\(2^i\)
个结点。
高度为 \(h\) 的二叉树最多有:
\(1+2+\cdots+2^h=2^{h+1}-1\)
个结点。
在一棵非空二叉树中,记 \(n_0\)、\(n_1\)、\(n_2\) 分别为叶结点、一度结点、二度结点的个数。
要验证: \(n_0=n_2+1\)。
从只有根结点的树开始逐步扩展,可以直接观察一度结点和二度结点对叶结点数量的不同影响。
本课程后续涉及数组下标时统一使用 0-based;1-based 只在这里作为常见写法进行对照。
BinaryNode 保存一个元素和左右孩子引用,是二叉树所有结构关系的基本单元。
BinaryNode(element) 创建一个尚未连接孩子的新结点;元素先确定,左右孩子默认保持为空,之后再由树操作建立链接。
| index | data | left | right |
|---|---|---|---|
| 0 | A | 1 | -1 |
| 1 | B | 2 | 3 |
| 2 | C | -1 | -1 |
| 3 | D | 4 | 5 |
| 4 | E | -1 | 6 |
| 5 | F | -1 | -1 |
| 6 | G | -1 | -1 |
游标表示用数组下标模拟指针。
树的渐近复杂度常写成 \(O(h)\) 或 \(O(n)\),但真实运行还取决于结点如何放在内存里。
BinaryTree 类接口二叉树作为一个ADT,我们需要三类能力:管理树的结构、按不同顺序遍历、询问树的结构属性。
BinaryTree 类接口代码isEmpty() 与 rootElement() 只读取当前根入口,不改变树的结构。
makeTree(…) 和 breakTree() 改变树的组成关系,分别负责组合与拆分。
height() 返回结构性质;它取决于左右子树的高度。
BinaryTree 基本实现:根入口与查询static class BinaryTree<T> implements BinaryTreeApi<T> {
private BinaryNode<T> root;
BinaryTree() {}
private BinaryTree(BinaryNode<T> root) {
this.root = root;
}
public boolean isEmpty() {
return root == null;
}
public T rootElement() {
if (root == null) {
throw new NoSuchElementException("tree is empty");
}
return root.element;
}BinaryTree 的整个结构只由一个根入口拥有;从 root 可达的全部结点就是这棵树。
root 是唯一结构入口;无参构造器让它保持 null,于是自然得到一棵空树。
BinaryTree(root) 用已有根结点接管一棵树;之后所有操作都从这个入口出发。
isEmpty() 只检查 root 是否为空,时间复杂度为 O(1)。
rootElement() 读取根结点元素,不改变树的结构。
MakeTreemakeTree(A, left, right) 把两颗子树的拥有权转移到一个新根下面。
MakeTree 代码public void makeTree(T element, BinaryTree<T> left, BinaryTree<T> right) {
Objects.requireNonNull(left, "left");
Objects.requireNonNull(right, "right");
if (this == left || this == right || left == right) {
throw new IllegalArgumentException("trees must be distinct owners");
}
BinaryNode<T> newRoot = new BinaryNode<>(element);
newRoot.left = left.root;
newRoot.right = right.root;
root = newRoot;
left.root = null;
right.root = null;
}makeTree(…) 把一个根元素和两棵子树连接成新树,同时转移子树所有权。
BinaryTree 必须是不同拥有者;否则组合会让同一结构既当输入又当输出,造成别名和重复所有权。
BreakTreebreakTree 做的是 makeTree 的逆过程:把根数据、左子树、右子树分别交还出去,同时让原树变空。
BreakTree 代码public TreeParts<T> breakTree() {
if (root == null) {
throw new NoSuchElementException("tree is empty");
}
BinaryNode<T> oldRoot = root;
BinaryTree<T> left = new BinaryTree<>(oldRoot.left);
BinaryTree<T> right = new BinaryTree<>(oldRoot.right);
oldRoot.left = null;
oldRoot.right = null;
root = null;
return new TreeParts<>(oldRoot.element, left, right);
}breakTree(…) 拆出根元素和左右子树,使原树清空而结点关系仍由返回结果保留。
BinaryTree;这一步只是重新指定拥有者,并没有复制节点。
遍历的核心问题是:根结点相对左、右子树,什么时候访问? 先序是“根→左→右”,中序是“左→根→右”,后序是“左→右→根”;层序不按递归子树,而是按距离根的层次推进。
先用同一棵具体树走一遍调用栈。重点看:进入一个非空结点时立刻访问,然后才递归左右子树。
五行正好对应“空就返回 → 访问根 → 左 → 右”。
preorder(…) 按“根→左→右”访问每个结点;递归边界只在空子树处停止。
中序最容易混淆,因为进入结点时不能马上访问。必须先把左子树处理完,返回到当前结点后才访问它。
visit(node) 恰好夹在两次递归之间。
inorder(…) 按“左→根→右”访问;对二叉搜索树(Binary Search Tree,BST)而言输出顺序就是关键字升序。
后序与先序正好相反:当前结点要一直等到左右子树都处理完才访问,因此特别适合“先得到孩子结果,再计算父结点”的问题。
visit(node) 放在两次递归之后。
postorder(…) 按“左→右→根”访问;父结点只在两个子树都处理完以后出现。
层序不靠递归,而是靠队列保持“这一层先于下一层”。先看队列如何随访问过程变化:
重复执行:出队一个结点、访问、把非空孩子接到队尾。
void levelOrder(BinaryNode<T> root) {
if (root == null) {
return;
}
Deque<BinaryNode<T>> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
BinaryNode<T> node = queue.poll();
output.add(node.element);
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}levelOrder(…) 用队列按层访问结点;孩子只在父结点出队后加入后续待访问集合。
递归版其实一直在借用系统调用栈。非递归版只是把这件事显式写出来:stack 保存“已经走到、但要等左子树完成后才能访问”的结点。
能向左就压栈;走到空以后弹栈访问,再转向右子树。
void inorderIterative(BinaryNode<T> root) {
Deque<BinaryNode<T>> stack = new ArrayDeque<>();
BinaryNode<T> current = root;
while (current != null || !stack.isEmpty()) {
while (current != null) {
stack.push(current);
current = current.left;
}
current = stack.pop();
output.add(current.element);
current = current.right;
}
}inorderIterative(…) 用显式栈保存尚未访问的祖先,模拟递归中序遍历的返回路径。
current 指向正在继续向左探索的子树入口。
树高也是一个典型的“先得到孩子结果,再计算父结点”的递归问题,所以计算顺序本质上是后序的。
空树取 -1 后,叶结点会自然得到 0。
height(…) 返回子树高度;空树给出基值,非空结点取两个孩子高度的较大者再加 1。
-1,递归边界就与“叶子高度为 0”自然衔接,不需要额外判断叶节点。
现在把遍历问题反过来:树已经丢了,只剩两条遍历序列,能不能把原来的树恢复出来?
已知同一棵二叉树的:
A B D E C F GD B E A F C G要求恢复每个结点的左右孩子关系。难点在于:序列里只有访问顺序,没有任何括号或指针告诉我们“哪一段属于左子树、哪一段属于右子树”。
A B D E C F GD B E A F C G先序和中序分别提供了什么:
A。D B E | A | F C G,所以左子树只可能由 D B E 构成,右子树只可能由 F C G 构成。A 后面的前 3 个元素 B D E 必须属于左子树;剩余 C F G 属于右子树。所以真正的递归依据是:先序负责找根,中序负责切边界,左子树大小负责把先序也切成两段。
当前递归子问题的 Pre / In 两段,以及这一轮如何确定根和左右范围。
代码只是在机械地实现刚才的三个信息:根在哪里、左子树有多大、左右递归区间分别是什么。
static BinaryNode<Character> build(String preorder, String inorder) {
Objects.requireNonNull(preorder, "preorder");
Objects.requireNonNull(inorder, "inorder");
if (preorder.length() != inorder.length()) {
throw new IllegalArgumentException("traversals must have the same length");
}
if (preorder.isEmpty()) {
return null;
}
Map<Character, Integer> inorderIndex = new HashMap<>();
for (int i = 0; i < inorder.length(); i++) {
if (inorderIndex.put(inorder.charAt(i), i) != null) {
throw new IllegalArgumentException("keys must be unique");
}
}
return build(preorder, 0, preorder.length() - 1, 0, inorder.length() - 1, inorderIndex);
}
private static BinaryNode<Character> build(
String preorder,
int preLeft,
int preRight,
int inLeft,
int inRight,
Map<Character, Integer> inorderIndex) {
if (preLeft > preRight) {
return null;
}
char rootValue = preorder.charAt(preLeft);
Integer split = inorderIndex.get(rootValue);
if (split == null || split < inLeft || split > inRight) {
throw new IllegalArgumentException("inconsistent traversals");
}
int leftSize = split - inLeft;
BinaryNode<Character> root = new BinaryNode<>(rootValue);
root.left = build(
preorder, preLeft + 1, preLeft + leftSize, inLeft, split - 1, inorderIndex);
root.right = build(
preorder, preLeft + leftSize + 1, preRight, split + 1, inRight, inorderIndex);
return root;
}build(…) 根据遍历序列确定根并递归划分左右子树,重建出的边界必须与序列位置一致。
leftSize 把中序切分转换成先序长度;左右递归因此拿到同一批节点,返回后再接到当前根即可。
下面结论默认所有结点关键字唯一。如果允许重复关键字,而又没有额外的结点身份信息,即使带中序也可能无法唯一定位根的位置。
| 已知序列 | 是否唯一 | 真正提供的信息 |
|---|---|---|
| 先序 + 中序 | 是 | 先序给根;中序切左右子树 |
| 后序 + 中序 | 是 | 后序给根;中序切左右子树 |
| 先序 + 后序 | 通常否 | 都能给根,但通常不能判断单个孩子在左还是右 |
| 层序 + 中序 | 是 | 层序先给当前根;中序切分后再按结点归属过滤左右层序 |
继续沿用前一页已经恢复出的那棵树,直接检查两条序列如何把结构唯一确定:
Pre = A B D E C F G:第一个 A 唯一确定根。In = D B E | A | F C G:A 左边只能属于左子树,右边只能属于右子树。A 后面的 3 个元素只能是左子树的 B D E。对同一棵树:
Post = D E B F G C AIn = D B E A F C G后序与先序只是在“根在哪里”这一点不同:后序最后一个元素一定是当前根,所以这里先确定 A。
在中序中仍然得到:
D B E | A | F C G
因此左子树结点集合是 {D,B,E},右子树是 {F,C,G}。再到后序中按这两个集合切分:
D E B | F G C | A
左段最后一个 B 是左子树根,右段最后一个 C 是右子树根,递归继续,结构仍然唯一。
只要两个结点就足够构造反例。已知:
Pre = A BPost = B A下面两棵树的两条序列完全相同:
先序只能告诉我们 A 在 B 前面,后序只能告诉我们 B 在 A 前面;两条序列都没有提供信息说明 B 到底是 A 的左孩子还是右孩子。
所以“先序 + 后序通常不能唯一构造”不是口诀,而是因为它们缺少像中序那样的左右分界信息。
同一棵树的序列为:
Level = A B C D E F GIn = D B E A F C G层序的第一个元素 A 是整棵树的根。中序按 A 切开:
{D,B,E}{F,C,G}然后不需要猜连续区间,而是按集合过滤剩余层序:
B C D E F G 中保留左集合 → B D EC F G于是左子树的层序首元素 B、右子树的层序首元素 C 又分别成为两个子问题的根;配合各自的中序区间继续递归,结构唯一。
普通树的问题是:一个结点可能有很多孩子,而普通二叉结点只有两个指针。长子兄弟表示把它们重新解释为 firstChild(进入自己的第一个孩子)和 nextSibling(移动到同一父结点的下一个兄弟),也就是:左边深入孩子,右边沿兄弟移动。
TreeNode 保存结点元素和孩子集合,用引用关系表达一般树的层级结构。
firstChild 进入自己的孩子链,nextSibling 沿同一父节点的兄弟链前进;因此每个节点仍只需要两个引用。
TreeNode(element) 创建一个尚未接入孩子链或兄弟链的新结点;两个结构链接都从空状态开始。
把长子兄弟结点直接改名即可得到二叉树表示:
| 普通树语义 | 二叉树语义 |
|---|---|
firstChild |
left |
nextSibling |
right |
例如 A 的第一个孩子是 B,所以 A.left = B;B、C、D 原来是兄弟,所以二叉表示中形成 B.right = C、C.right = D。
这个转换不改变结点集合,只改变边的解释方式。
森林可以看作若干棵树的根互为兄弟。
转换规则:
森林遍历可以转化为二叉树遍历。
先根遍历:
后根遍历:
广度优先遍历使用队列。
含 \(n\) 个结点的二叉链表有 \(2n\) 个孩子指针域。
实际边数只有 \(n-1\)。
因此空指针数为:
\[ 2n-(n-1)=n+1 \]
线索二叉树用这些空指针保存遍历序列中的前驱或后继。
中序线索树中:
left == null,可让 left 指向中序前驱right == null,可让 right 指向中序后继Tag 只有 CHILD 与 THREAD 两种取值,用来明确一个引用当前表示真实孩子还是中序线索。
ThreadNode 用标记区分真实孩子与线索,使空链接能够保存遍历前驱/后继。
left/right 不再只表示孩子:每个方向都配一个标记,明确当前引用究竟是 CHILD 还是中序 THREAD;没有标记就无法安全区分两种语义。
ThreadNode(element) 创建一个尚未线索化的结点;左右链接为空且两个标记初始都按真实孩子语义解释。
THREAD。
线索化以后,中序遍历只需要两个动作:first(p) 沿真正的左孩子一直走到当前子树中序序列的第一个结点;next(p) 若右指针是线索就直接到后继,否则进入右子树再找 first。因此遍历从 p = first(root) 开始,再不断执行 p = next(p)。
static <T> ThreadNode<T> first(ThreadNode<T> node) {
if (node == null) {
return null;
}
while (node.leftTag == Tag.CHILD && node.left != null) {
node = node.left;
}
return node;
}
static <T> ThreadNode<T> next(ThreadNode<T> node) {
if (node == null) {
return null;
}
if (node.rightTag == Tag.THREAD) {
return node.right;
}
return first(node.right);
}first(…) 返回线索二叉树中序序列的第一个结点,也就是不断沿真实左孩子到达的最左结点。
first 直接返回空;后续逻辑只处理真实节点。
CHILD 类型的左引用一直下降;遇到 THREAD 或空左边时停止,此时当前节点就是该子树中序序列的第一个节点。
next(…) 根据右线索或右子树最左结点找到中序后继,不再依赖递归栈。
next 先看右引用语义:THREAD 直接给出后继;若右边是真实子树,后继就是那棵子树的 first。
一个文件按字节(byte)看,每个位置都有 256 种可能值。
如果什么都不利用,最直接的方法就是固定长度:
3529475 _2 256 = 8 3529475
但真实文件里的字节通常不是等概率出现。
于是自然的问题是:
经常出现的字节,能不能用更短的位(bit)串表示?
信息论里常用
\[ I(x)=-\log_2 p(x) \]
衡量一个事件的信息量。
平均信息量称为熵:
\[ H(X)=-\sum_x p(x)\log_2p(x) \]
它提示了一个方向:码长应该接近 \(-\log_2p(x)\) 。
假设直接规定:
A -> 0B -> 01那么看到位串 01 时,开头的 0 到底是完整的 A,还是 B 的开头?
所以变长编码必须保证:
任何一个码字都不是另一个码字的前缀。
把所有符号放在二叉树的叶子上,就会天然得到前缀码(prefix-free code):左边记 0,右边记 1,根到叶子的路径就是码字。
设符号 \(i\) 的出现次数为 \(w_i\),码长为 \(l_i\)。
压缩后总位数正比于:
\[ \sum_i w_i l_i \]
在树上,\(l_i\) 就是叶子深度,因此得到带权外路径长度:
\[ WPL=\sum_i w_i l_i \]
这不是“让树尽量矮”,而是:
让高频叶子尽量靠近根,让低频叶子承担更深的路径。
Huffman 算法会自动完成这种分配。
每一轮都做同一件事:取当前权值最小的两棵树 → 合并 → 新根权值求和 → 按权值重新插回候选表。
static HuffmanNode buildHuffman(int[] weights) {
PriorityQueue<HuffmanNode> queue = new PriorityQueue<>(
Comparator.comparingLong((HuffmanNode node) -> node.weight)
.thenComparingInt(node -> node.order));
int nextOrder = 0;
for (int weight : weights) {
if (weight <= 0) {
throw new IllegalArgumentException("weight must be positive");
}
queue.offer(new HuffmanNode(weight, nextOrder++, null, null));
}
while (queue.size() > 1) {
HuffmanNode left = queue.remove();
HuffmanNode right = queue.remove();
queue.offer(new HuffmanNode(
left.weight + right.weight, nextOrder++, left, right));
}
return queue.poll();
}buildHuffman(…) 反复合并当前权值最小的两棵树,直到得到总代价最小的 Huffman 树。
order 只在权重相同时稳定打破平局,不改变 Huffman “选两个最小权重”的贪心准则。
| 符号 | Huffman 编码 | 码长(位) | 频率 | 信息量(位) |
|---|---|---|---|---|
0x20 space |
00 |
2 | 0.1979 | 2.34 |
0x65 e |
100 |
3 | 0.1302 | 2.94 |
0x74 t |
010 |
3 | 0.0969 | 3.37 |
0x61 a |
1111 |
4 | 0.0854 | 3.55 |
0x6F o |
1110 |
4 | 0.0792 | 3.66 |
0x6E n |
1101 |
4 | 0.0740 | 3.76 |
0x69 i |
1011 |
4 | 0.0708 | 3.82 |
| 其余 249 种 | - | 9–12 | 0.0001–0.0011 | 9.77–13.23 |
\(I(x)=-\log_2 p(x)\);频率越高 → 信息量越小 → Huffman 码通常越短。
0/1 的具体写法可随左右子树交换而变化,真正关键的是码长。
对已知符号概率、逐符号、二进制前缀码,Huffman 是最优的。
但这个“最优”有明确边界:
如果允许一次编码一段符号,或者让概率随上下文(context)改变,就可能进一步逼近熵极限(entropy limit)。
现代视频编码通常先做:
帧内 / 帧间预测 → 变换与量化 → 概率建模 → 熵编码(entropy coding)
最后常用算术编码(arithmetic coding)或区间编码(range coding)一类方法;上下文自适应编码(context-adaptive coding)还会让概率随上下文变化。
核心变化是:先去掉可预测结构,再编码剩余不确定性。
如果编码器(encoder)有一个学习得到的预测模型,就可以根据已经看到的内容预测:
\(p(x_t\mid \text{上下文})\)
那么每个位置都可以使用不同的概率分布。
一个典型流程是:
\(\text{预测 / 运动补偿}\rightarrow p(x_t\mid \text{上下文})\rightarrow \text{算术 / 区间编码器}\rightarrow \text{码流}\)
这时真正决定压缩率的,不只是“选哪种编码”,而是:
概率模型预测得有多准,剩下的不确定性就有多小。
Huffman 仍然非常重要,因为它把“频率决定码长”的核心思想讲得最清楚;现代压缩系统则把这个思想推广到了更细粒度、更动态的概率模型。