第 07 讲

树与二叉树

前面六讲我们一直在跟「线性结构」打交道:线性表、栈、队列、串、数组,它们的共同点是每个元素最多有一个前驱和一个后继, 元素之间排成一条线。可现实世界里大量关系根本不是一条线——文件系统的目录、公司的组织架构、算术表达式的运算次序、 HTML 的标签嵌套,都是一对多的层次关系。要描述这种关系,就必须换一种结构:树(tree)。 本章先把树的术语体系立起来,再聚焦到最重要的一种树——二叉树(binary tree): 它的五大性质几乎是每张卷子的必考内容,四种遍历是后面所有树形算法(包括平衡树、堆、线段树、字典树)的地基, 最后用赫夫曼树并查集收尾,前者解决最优前缀编码,后者为下一讲的图论算法铺路。

预计 180 分钟 前置:第 02 讲线性表、第 04 讲队列、第 01 讲大 O 关键词:递归定义 · 五大性质 · 四种遍历 · 线索 · 赫夫曼编码 · 并查集
本章导读
  • 7.1 树的定义与术语体系 —— 术语多且易混(深度 vs 高度、堂兄弟 vs 兄弟),配一张标注齐全的图一次记住。
  • 7.2 树的三种存储结构 —— 双亲、孩子、孩子兄弟。记住一句话:孩子兄弟表示法把任意树变成了二叉树
  • 7.3 二叉树基础 —— 五种形态、斜树 / 满二叉树 / 完全二叉树、顺序存储与链式存储。
  • 7.4 二叉树的五大性质(本章最常考)—— 每条性质都给完整证明 + 一道应用例题。
  • 7.5 四种遍历(本章最基本)—— 前 / 中 / 后 / 层序,递归与非递归都要会写,还要会由序列还原二叉树。
  • 7.6 线索二叉树 —— 把空指针域变废为宝,让遍历不需要栈。
  • 7.7 树、森林与二叉树的转换 —— 三条互逆规则 + 两条必背遍历对应结论。
  • 7.8 赫夫曼树与最优前缀编码(本章重点)—— WPL、贪心构造、编码表、规范赫夫曼编码。
  • 7.9 并查集 —— 双亲表示法 + 路径压缩 + 按秩合并,为第 09 讲的图论算法做准备。
  • 7.10 ~ 7.13 C++ 实战细节、易错点、工程视角与考点自测。

7.1 树的定义与术语体系

7.1.1 树的递归定义:为什么必须递归地定义

线性表可以一句话说清:n 个元素的有限序列。树不行,因为树里边套着树。 我们只能这样定义它:

树的递归定义:树(tree)是 n(n ≥ 0)个结点(node)的有限集合 T。当 n = 0 时, 称为空树(empty tree);当 n > 0 时,它满足两个条件:
① 有且仅有一个特定的结点,称为根(root)
② 其余 n − 1 个结点可以分成 m(m ≥ 0)个互不相交的有限集合 T1, T2, …, Tm, 其中每一个集合本身又是一棵树,称为根的子树(subtree)

这个定义里有三个词是「锁死」的,考试经常在这里挖坑,我们逐个拆开看:

换个角度看,树其实是一个满足下面三条的有向图:有且仅有一个入度为 0 的结点(根)、 其余结点入度全部为 1、从根出发能到达所有结点。这三条等价于「n 个结点、n − 1 条边、连通、无环」, 这是第 09 讲判断生成树时会反复用到的结论。所以树的边数是固定的:n 个结点的树恰好有 n − 1 条边, 不多不少。这个「n − 1」在证明性质 3 时会成为主角。

树 vs 线性结构:一对一到一对多 线性结构中,除首尾元素外每个元素有唯一前驱和唯一后继(一对一); 树结构中,除根以外的每个结点有唯一前驱(双亲),但可以有任意多个后继(孩子)(一对多)。 再往上还有多对多的图(graph)——这正是第 08 讲的内容。整门课的逻辑结构主线就是:一对一 → 一对多 → 多对多。

7.1.2 术语体系:一张图看懂全部概念

树的术语是整章最「碎」的部分,硬背很容易串。下面这张图把 16 个术语全部标在了同一棵树上, 建议先看图记位置,再看后面的定义表补精确定义。

第 1 层 第 2 层 第 3 层 第 4 层 树的深度(高度)= 最大层次 = 4 A B C D E F G H I J 根结点(无双亲) A 是 B、C、D 的双亲(parent) B、C、D 是 A 的孩子(child) B、C、D 互为兄弟(sibling) E 与 F 是兄弟,E 与 G 是 堂兄弟(双亲同层但不同) A、B、F 是 J 的祖先(ancestor) J 是 A、B、F 的子孙(descendant) 绿色结点 = 叶子(度为 0) D 的度 = 2,C 的度 = 1 G、H、I、J 的度 = 0 → 叶子 A→B→F→J 的路径长度 = 3 B、C、D 是 A 的子树之根 A 的度 = 3,B 的度 = 2 B 的层次 = 2,深度 = 2 F 的层次 = 3,高度 = 2 (高度按「到最远叶子的边数」算) B 为根的子树
图 7-1 树的术语总览:一棵 4 层 10 结点的树,所有基本术语都标在图上
结点
树中的基本单位,包含数据元素和指向其子树的分支信息。图 7-1 中共有 10 个结点。
结点的度
结点拥有的子树个数,也就是它孩子的个数。A 的度是 3,C 的度是 1,G、H、I、J 的度是 0。
树的度
树中所有结点度的最大值。图 7-1 中 max{3,2,2,1,2,0,…} = 3,所以这棵树的度是 3,它是一棵「3 次树」。m 次树就是度为 m 的树。
叶子(终端结点)
度为 0 的结点,它没有孩子。图 7-1 中 G、H、I、J 是叶子。
分支结点(非终端结点)
度不为 0 的结点。A、B、C、D、E、F 都是分支结点。其中度 ≥ 1 但 < 树度的分支结点(如 C)有时叫「内部结点」。
孩子
结点子树的根称为该结点的孩子。B、C、D 都是 A 的孩子。
双亲(父结点)
与孩子相对。除根以外每个结点有且仅有一个双亲——这是树与图最本质的区别之一。
兄弟
同一个双亲的孩子之间互称兄弟。B、C、D 互为兄弟;E、F 互为兄弟。
堂兄弟
双亲在同一层、但不是同一个结点的结点互为堂兄弟。E(双亲 B,第 2 层)与 G(双亲 C,第 2 层)是堂兄弟。注意:堂兄弟的双亲同层不同点,兄弟的双亲是同一个点。
祖先
从根到该结点所经分支上的所有结点。J 的祖先是 A、B、F(以及 J 自己按某些教材的约定也计入,考试一般只问「除自己外」的祖先)。
子孙
以某结点为根的子树中的任一结点。B 的子孙是 E、F、J。
层次
根为第 1 层,根的孩子为第 2 层,依此类推。有的教材从第 0 层开始编号,做题时先看清约定,两者只差一个常数。
深度(高度)
结点的深度是从根到该结点所经过的边数 + 1(= 该结点的层次);树的深度是树中结点的最大层次。结点的高度是从该结点到最远叶子的最长路径边数。两者对整棵树来说数值相等(都等于层数),但对单个结点通常不等:F 的深度是 3,高度是 2。考试里若只说「深度」,通常指树的深度 = 层数。
有序树
结点的各子树从左到右有次序、不能互换。交换后就是另一棵树。二叉树、语法树都是有序树。
无序树
结点的子树之间没有顺序关系,交换任意两棵子树仍是同一棵树。一般的「家族关系树」是无序树。
森林
m(m ≥ 0)棵互不相交的树的集合。一棵树砍掉根,剩下的子树集合就是一个森林。树和森林是可以互相转化的,见 7.7 节。
路径与路径长度
从结点 X 到结点 Y 的路径是一个结点序列,其中相邻结点之间都有边相连(树中这条路径唯一)。路径长度 = 路径上边的条数。A 到 J 的路径是 A→B→F→J,长度为 3。
易错:深度、高度、层次、路径长度,四个数容易全搞混
  • 层次(level)从 1 开始数:根是第 1 层。有些书写「根在第 0 层」,那就整体减 1。
  • 结点的深度 = 从根走到它的边数 + 1 = 它的层次。
  • 结点的高度 = 从它走到最远叶子的边数(叶子高度为 0)。所以「结点的深度 + 高度」不一定等于树的深度。
  • 路径长度数的是不是结点。A 到 J 经过 4 个结点,但路径长度是 3。
记住一句话:深度从上往下数(1 基),高度从下往上数(0 基),路径长度只数边。

7.1.3 由定义直接推出的两条常用结论

树的定义非常强,强到可以直接推出两条后面反复要用的结论:

结论一:边数 = 结点数 − 1

每个非根结点恰好有一条边连向它的双亲(双亲存在且唯一),根没有这样的边。 于是把每条边都记在「它的下端结点」账上,正好一一对应:
边数 = n − 1

结论二:度数之和 = 边数

结点的度就是它发出的边的条数(一条边连向一个孩子)。把所有结点的度加起来, 每条边恰好在「上端结点」的度里被数了一次:
Σ 度(i) = 边数 = n − 1

把这两条拼起来,就得到一条万能的恒等式:树的结点数 = 所有结点的度数之和 + 1。 别看它简单,二叉树性质 3(n0 = n2 + 1)就是它的直接系。

7.2 树的存储结构

树是「一对多」的逻辑结构,但计算机的内存是「一格一格、一条一条」的线性空间。 所谓设计存储结构,本质就是回答一个问题:怎么用线性内存把这个一对多关系记下来? 下面三种表示法只是侧重点不同——分别把「找双亲」「找孩子」「把树变成二叉树」这三件事做成了 O(1) 或接近 O(1)。

7.2.1 双亲表示法:一组数组就够

思路最朴素:把结点按某种顺序(通常是层序)塞进一维数组,每个结点再存一个整数, 记下它双亲在数组中的下标。根的双亲记成 −1。

#include <iostream>
using namespace std;

/* ============================================================
   1) 双亲表示法(parent representation)
   结点只存"数据 + 双亲下标",用一整块连续数组保存,根的双亲记 -1。
   ============================================================ */
const int MAXN = 100;

struct PTNode {          // 一个结点
    char data;           // 数据域
    int  parent;         // 双亲在数组中的下标,根为 -1
};

struct PTree {
    PTNode nodes[MAXN];  // 结点数组
    int    n;            // 当前结点个数
};

/* 求双亲:O(1),这就是双亲表示法存在的唯一理由 */
int parentOf(const PTree& t, int i) { return t.nodes[i].parent; }

/* 求某结点的所有孩子:要扫全表,O(n)。
   想快就得再挂一张"孩子表",那就变成孩子表示法了。 */
void printChildren(const PTree& t, int i) {
    cout << t.nodes[i].data << " 的孩子: ";
    for (int k = 0; k < t.n; ++k)
        if (t.nodes[k].parent == i) cout << t.nodes[k].data << ' ';
    cout << "\n";
}

int main() {
    /* 手动构造图 7-2 那棵树:下标 0..9 对应 A..J */
    PTree t;
    t.n = 10;
    const char* d = "ABCDEFGHIJ";
    int par[10] = { -1, 0, 0, 0, 1, 1, 2, 3, 3, 5 };   // 每个结点的双亲下标
    for (int i = 0; i < 10; ++i) { t.nodes[i].data = d[i]; t.nodes[i].parent = par[i]; }

    cout << "F 的双亲是 " << t.nodes[parentOf(t, 5)].data << "\n";   // B
    printChildren(t, 0);                                          // B C D
    printChildren(t, 1);                                          // E F
    return 0;
}
双亲表示法:一个结构体数组,每个结点多存一个 parent 下标 下标 01 23 45 67 89 data A B C D E F G H I J parent -1 0 0 0 1 1 2 3 3 5 nodes[5].parent = 1 → F 的双亲是 nodes[1] = B ✓ 找双亲、找根 O(1) ✓ 存孩子个数(度)方便 ✓ 省空间 ✗ 找孩子要遍历整个数组 O(n) ✗ 求某结点的所有子孙很麻烦 注意:数组下标之间的父子关系是"隐式"的(靠 parent 值连接),结点在数组里可以任意排列,不要求层序。
图 7-2 双亲表示法:用 parent 下标把「找双亲」变成 O(1)

双亲表示法最大的价值在于:它能 O(1) 地向上走。凡是算法里频繁需要「找父亲 / 找根」的场景, 都会采用这种表示法——最典型的就是 7.9 节的并查集

7.2.2 孩子表示法:把每个结点的孩子串成链表

既然双亲表示法找孩子慢,那就反着来:给每个结点挂一条链表,链表里存它所有孩子的下标。 这就是孩子表示法(child representation),也叫「孩子链表」。

C++ 里最简单的写法是「数组 + vector」: vector<int> ch[n]ch[i] 里按从左到右的顺序存着结点 i 的所有孩子下标。 如果要手写链表(考试常见),则用「结点数组 + 孩子链表结点」两张表。

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   2) 孩子表示法(child representation)—— 每个结点挂一条孩子链表
   这里用 vector 代替手写链表,语义完全一致,还免去了内存管理。
   ============================================================ */
struct CTree {
    vector<char>              data;   // 结点数据
    vector<vector<int>> child;  // child[i] = 结点 i 的所有孩子下标(从左到右有序)
    int root;                          // 根的下标

    explicit CTree(int n) : data(n), child(n), root(0) {}

    /* 找孩子:直接返回引用,O(度) 而不是 O(n),这就是孩子表示法的意义 */
    const vector<int>& childrenOf(int i) const { return child[i]; }
};

/* 求双亲:孩子表示法的软肋,只能扫所有链表,最坏 O(n + 边数) */
int parentOf(const CTree& t, int x) {
    for (int i = 0; i < (int)t.child.size(); ++i)
        for (int c : t.child[i])
            if (c == x) return i;
    return -1;
}

/* 求根:入度为 0 的那个结点。用一个标记数组,O(n + 边数) */
int findRoot(const CTree& t) {
    vector<bool> isChild(t.data.size(), false);
    for (int i = 0; i < (int)t.child.size(); ++i)
        for (int c : t.child[i]) isChild[c] = true;
    for (int i = 0; i < (int)t.data.size(); ++i)
        if (!isChild[i]) return i;
    return -1;
}

int main() {
    /* 构造图 7-2 的树:0=A 1=B 2=C 3=D 4=E 5=F 6=G 7=H 8=I 9=J */
    CTree t(10);
    const char* d = "ABCDEFGHIJ";
    for (int i = 0; i < 10; ++i) t.data[i] = d[i];
    t.child[0] = { 1, 2, 3 };   // A -> B C D
    t.child[1] = { 4, 5 };      // B -> E F
    t.child[2] = { 6 };         // C -> G
    t.child[3] = { 7, 8 };      // D -> H I
    t.child[5] = { 9 };         // F -> J
    t.root = findRoot(t);

    cout << "根是 " << t.data[t.root] << "\n";                 // A
    cout << "F 的双亲是 " << t.data[parentOf(t, 5)] << "\n";   // B
    for (int c : t.childrenOf(0)) cout << t.data[c] << ' ';     // B C D
    cout << "\n";
    return 0;
}
孩子表示法:结点数组(左)+ 每个结点的孩子链表(右) 0 A 1 B 2 C 3 D 4 E 5 F (数组下标即结点编号) 1 2 3 A 的孩子:B、C、D(从左到右有序) 4 5 B 的孩子:E、F 6 C 的孩子:G 7 8 D 的孩子:H、I E 是叶子,孩子链表为空 9 F 的孩子:J ✓ 找孩子 O(度),且天然保持「从左到右」的顺序 ✓ 求结点的度 O(1)(数链表长度) ✗ 找双亲要扫全表 O(n) ✗ 指针/容器开销比双亲表示法大
图 7-3 孩子表示法:结点数组 + 孩子链表,把「找孩子」变成 O(度)
工程变体:双亲孩子表示法 两种表示法各有软肋,于是有了折中方案:在结点数组里同时存 parent 下标和孩子链表头指针。 这样找双亲 O(1)、找孩子 O(度),代价只是每个结点多一个整数的空间。 实际工程(例如编译器里的语法树、文件系统的 inode 表)往往就是这么干的。

7.2.3 孩子兄弟表示法:把树变成二叉树

第三种表示法是全章最重要的一个转折点。它的观察非常巧妙: 与其直接记录「谁是孩子」,不如记录两条关系——「我的第一个孩子是谁」和「我的下一个兄弟是谁」。

因为「第一个孩子」唯一,「下一个兄弟」也唯一,所以每个结点只要两个指针就够了, 而且结点的结构变成了和二叉树链表结点一模一样的两指针结构:

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   3) 孩子兄弟表示法(左孩子右兄弟)—— 也叫"二叉链表表示法"
   每个结点只有两个指针:
       firstChild :指向第一个孩子(最左边的孩子)
       nextSibling:指向右边紧邻的下一个兄弟
   结点结构与二叉树结点完全相同,因此"树"被存成了"二叉树"。
   ============================================================ */
struct CSNode {
    char     data;
    CSNode*  firstChild;    // 左指针:第一个孩子
    CSNode*  nextSibling;   // 右指针:下一个兄弟
    CSNode(char d) : data(d), firstChild(nullptr), nextSibling(nullptr) {}
};

/* ---------- 求某结点的度:沿 firstChild 的同级链数一遍 ---------- */
int degreeOf(CSNode* p) {
    int d = 0;
    for (CSNode* c = p->firstChild; c; c = c->nextSibling) ++d;
    return d;
}

/* ---------- 求双亲:只能从根开始找,O(n) ---------- */
CSNode* parentOf(CSNode* root, CSNode* target) {
    if (!root) return nullptr;
    for (CSNode* c = root->firstChild; c; c = c->nextSibling) {
        if (c == target) return root;
        if (CSNode* r = parentOf(c, target)) return r;   // 递归到子树里找
    }
    return nullptr;
}

/* ---------- 用"树的层序序列 + 每结点的度"直接建树(不用指针操作) ----------
   输入:层序的结点值 vals,以及对应的度 deg(孩子个数),按层序依次给出。
   做法:用一个队列保存"已经建好、但孩子还没接完"的结点,依次分配孩子。 */
CSNode* buildByLevel(const vector<char>& vals, const vector<int>& deg) {
    if (vals.empty()) return nullptr;
    vector<CSNode*> node(vals.size());
    for (size_t i = 0; i < vals.size(); ++i) node[i] = new CSNode(vals[i]);

    int child = 1;                       // 下一个待分配的结点下标(根是 0)
    for (size_t i = 0; i < vals.size(); ++i) {
        CSNode* prev = nullptr;
        for (int k = 0; k < deg[i]; ++k) {
            if (child >= (int)vals.size()) break;
            if (k == 0) node[i]->firstChild = node[child];   // 第一个孩子挂在左指针
            else        prev->nextSibling = node[child];     // 其余孩子串在兄弟链上
            prev = node[child];
            ++child;
        }
    }
    return node[0];
}

int main() {
    /* 图 7-2 的树,按层序给出:A(3) B(2) C(1) D(2) E(0) F(1) G(0) H(0) I(0) J(0) */
    vector<char> v = { 'A','B','C','D','E','F','G','H','I','J' };
    vector<int>  d = {  3,  2,  1,  2,  0,  1,  0,  0,  0,  0 };
    CSNode* root = buildByLevel(v, d);

    cout << "A 的度 = " << degreeOf(root) << "\n";                 // 3
    cout << "B 的度 = " << degreeOf(root->firstChild) << "\n";     // 2
    cout << "J 的双亲 = " << parentOf(root, root->firstChild->firstChild->nextSibling
                                        ->firstChild)->data << "\n";  // F
    return 0;
}

对照图 7-4 仔细看:左指针画出的是竖着的「父子边」,右指针画出的是横着的「兄弟边」。 把兄弟边「旋转 45°压平」来看,整棵树就变成了一棵标准二叉树。 这正是教材上说的:孩子兄弟表示法把一棵普通树变成了一棵二叉树, 而它对应的二叉树恰好就是 7.7 节要讲的「树转二叉树」的结果——两者其实是同一个东西的两种说法。

孩子兄弟表示法:左指针 = 第一个孩子(实线竖边),右指针 = 下一个兄弟(虚线横边) A B C D E F G H I J 实线 = firstChild(左指针) 虚线 = nextSibling(右指针) A.firstChild = B,A 没有兄弟 → 右指针为空 B.nextSibling = C,C.nextSibling = D D.nextSibling = ∧(D 是最右的兄弟) E.nextSibling = F H.nextSibling = I 读法:从 A 出发,先沿 firstChild 往下(真正进入子树),再沿 nextSibling 往右(在同一层里横着走)。
图 7-4 孩子兄弟表示法:两种指针分别记录「第一个孩子」和「下一个兄弟」
表示法每结点存什么求双亲求孩子求度典型用途
双亲表示法数据 + parent 下标 O(1)O(n)O(n) 并查集、需要频繁向上走(找根 / 找祖先)的场景
孩子表示法数据 + 孩子链表 O(n)O(度)O(度) 需要频繁枚举孩子的场景(如树的动态规划)
双亲孩子表示法数据 + parent + 孩子链表 O(1)O(度)O(度) 空间换时间,工程中最常见
孩子兄弟表示法数据 + 左孩子 + 右兄弟(两个指针) O(n)O(1) 取第一个
O(度) 取全部
O(度) 把树 / 森林统一成二叉树,从而复用二叉树的全部算法
考点:为什么要研究孩子兄弟表示法 这句话每年都在考:孩子兄弟表示法用二叉链表存储了普通树,使树和二叉树之间建立起一一对应的关系。 由此带来的两个直接后果是:
① 所有针对二叉树的算法(遍历、求深度、求结点数、线索化)都能不加修改地用在普通树上;
② 树 / 森林与二叉树的相互转换有了统一的机械规则(7.7 节)。 另外要记住一个数字:孩子兄弟表示法下,每个结点只有 2 个指针域,n 个结点共有 2n 个指针域, 其中真正被使用的恰好是 n − 1 个(每条树边一个),因此空指针域有 n + 1 个—— 这个「n + 1」就是 7.6 节线索二叉树要利用的宝藏。

7.3 二叉树:定义、形态与存储

7.3.1 二叉树的定义与五种基本形态

二叉树(binary tree)是 n(n ≥ 0)个结点的有限集合,它或者为空集(n = 0), 或者由一个根结点和两棵互不相交的、分别称为根的左子树右子树的二叉树组成。

把这句话和树的定义对照着读,你会发现二叉树的定义多限制了两点:每个结点至多两棵子树, 而且这两棵子树有左右之分,次序不能颠倒。这两点决定了二叉树的一切特殊性质。 由定义直接可以枚举出二叉树只有五种基本形态:

∅(空树) ① 空二叉树 A ② 只有根结点 A B 无右子树 ③ 只有左子树 A B 无左子树 ④ 只有右子树 A B C ⑤ 左右子树都有 记忆口诀:空 / 独根 / 只左 / 只右 / 双全。③ 与 ④ 是两棵不同的二叉树,因为左右有序。
图 7-5 二叉树的五种基本形态
最大的概念坑:二叉树 ≠ 度为 2 的有序树 很多同学把这两个概念当成一回事,考试专挑这里出判断题。它们的差别只有一条,但很致命:
二叉树可以是空树,而且允许结点只有一棵子树;度为 2 的有序树要求每个结点要么没有孩子,要么恰好有 2 个孩子, 不存在「只有一个孩子」的结点,而且它不是空树。
换句话说:一棵二叉树只要有一个「独生子」结点,它作为树来看度就是 1,树的度就不是 2。 所以「二叉树」是按每个结点的孩子数上限为 2 且左右有序定义的, 而「度为 2 的树」是按所有结点度的最大值恰好等于 2定义的。两者只有在「所有分支结点都恰好有 2 个孩子」时才重合, 这种二叉树恰好就是下一小节的满二叉树

7.3.2 斜树、满二叉树与完全二叉树

斜树
所有结点都只有左孩子(左斜树)或都只有右孩子(右斜树)的二叉树。 斜树每一层只有 1 个结点,因此 n 个结点的斜树深度为 n,是同一结点数下深度最大的二叉树, 退化成了一条「链表」。它也是顺序存储的噩梦(见 7.3.4)。
满二叉树
深度为 k 且含有 2k − 1 个结点的二叉树。 等价说法:每一层的结点数都达到了最大值,即第 i 层恰有 2i−1 个结点; 也等价于「所有分支结点都有左右两个孩子,且所有叶子都在最下一层」。 n 个结点的满二叉树,叶子数 = (n+1)/2,分支结点数 = (n−1)/2。
完全二叉树
深度为 k、有 n 个结点的二叉树,当且仅当它的每一个结点都与深度为 k 的满二叉树中编号 1 ~ n 的结点一一对应时, 称为完全二叉树。通俗判断法:把结点按层序(从上到下、从左到右)编号后,编号序列不留空—— 即「只有最下面两层结点的度可以小于 2,且最下面一层的结点都集中在最左边」。
满二叉树(k=3,7 个结点,每层都满) 1 2 3 4 5 6 7 编号 1~7 连续无空缺,同时也是完全二叉树 完全二叉树(k=3,6 个结点,末层靠左) 1 2 3 4 5 6 7 ← 空缺(编号不连续) 有 6 个结点,深度仍是 ⌊log2 6⌋+1 = 3;不存在「只有右孩子」的结点 非完全二叉树 1 2 3 4 结点 2 只有左孩子,却出现了右孩子结点 4 → 层序编号断档,不是完全二叉树
图 7-6 满二叉树、完全二叉树与非完全二叉树的对照(编号即层序序号)
考点:完全二叉树的四条判定口径
  1. 编号法(定义):按层序给结点编号 1..n,与同深度满二叉树的编号完全一致,不出现空缺。
  2. 度法至多只有一个度为 1 的结点(因为编号断档只可能断在「最后的那个父结点只分到一个左孩子」处), 且该结点的孩子一定是左孩子
  3. 叶子法:叶子只可能出现在最后两层,且最下一层的叶子一定连续地排在左边
  4. 程序法:层序遍历,遇到第一个「孩子不全」的结点后,后面所有结点都必须是叶子;一旦发现后面还有结点带 孩子(尤其是只有右孩子),就不是完全二叉树。见 7.5.7 的代码。
还要记住一条推论:完全二叉树中若某个结点没有左孩子,它一定是叶子若某个结点有右孩子,它一定有左孩子按层序编号后,编号 > ⌊n/2⌋ 的结点全是叶子

7.3.3 顺序存储:完全二叉树的天然表示法

二叉树是「一对二」的结构,用数组存再合适不过:把结点按层序依次放进数组, 那么父子关系可以直接用下标算术算出来,连指针都不用。 这正是第 10 讲的堆(heap)和线段树都用数组存树的原因。

A B C D E F 一棵完全二叉树(6 个结点) ① 0 基编号:A=0 B=1 C=2 D=3 E=4 F=5 ② 1 基编号:A=1 B=2 C=3 D=4 E=5 F=6 0 基顺序存储(C++ 下标) 下标 i tree[i] 0 1 2 3 4 5 A B C D E F 左孩子 2i+1 右孩子 2i+2 双亲 ⌊(i−1)/2⌋ 例:i=2(C)→ 左孩子 5(F),右孩子 6(越界,说明 C 只有左孩子) 1 基顺序存储(教材 / 考研常用) 下标 i tree[i] 1 2 3 4 5 6 A B C D E F 左孩子 2i 右孩子 2i+1 双亲 ⌊i/2⌋ (tree[0] 空着不用,作为哨兵)
图 7-7 完全二叉树的顺序存储:0 基与 1 基两套下标公式对照
关系0 基下标(C/C++ 数组)1 基下标(教材 / 考研)边界条件
结点 i 的左孩子2i + 12i0 基:2i+1 < n;1 基:2i ≤ n
结点 i 的右孩子2i + 22i + 10 基:2i+2 < n;1 基:2i+1 ≤ n
结点 i 的双亲(i − 1) / 2(整数除法)⌊i / 2⌋根:0 基时 i = 0;1 基时 i = 1
是否为叶子(1 基)i > ⌊n/2⌋只有完全二叉树能用这条!
结点 i 所在层次(1 基)⌊log2 i⌋ + 1对完全二叉树严格成立
是否只有左孩子(1 基)2i = nn 为偶数时,结点 n/2 只有左孩子

7.3.4 普通二叉树用顺序存储有多浪费

顺序存储的公式之所以成立,全靠「层序编号不留空」这条约束。一旦换成普通二叉树, 为了让下标公式仍然有效,就只能把空缺的位置也留出来(通常填一个特殊标记,如 '#' 或 0)。 可空缺有多少呢?看最坏情况:

最坏情况分析:深度为 k 的右斜树 一棵深度为 k 的右斜树只有 k 个结点,但它占用的层序编号却是 1, 3, 7, 15, …, 2k − 1——因为每一层都只剩最右边那个位置。 于是数组长度必须开到 2k − 1,而真正存了数据的只有 k 个位置。

空间浪费率 = (2k − 1 − k) / (2k − 1)。取 k = 10: (1023 − 10)/1023 ≈ 99.02%;取 k = 20:浪费率高达 99.998%,几乎把整块内存都用来放空隙了。

结论:顺序存储只适合完全二叉树(以及接近满的二叉树、堆)。 一般的二叉树必须用链式存储,否则空间复杂度会从 O(n) 恶化到 O(2k) = O(2n)。 一句话记忆:顺序存储的空间代价取决于「树的形状」,而不是「结点的个数」。

链式存储才是二叉树的通用表示法。二叉链表的结点里放数据加左右孩子指针, 它是本章所有算法的默认存储结构:

① 二叉链表结点(2 个指针域) lchild data rchild ← 指向左孩子 → 指向右孩子;没有则为 nullptr n 个结点共 2n 个指针域,其中 n−1 个指向孩子(每条边一个),剩余 n+1 个是空指针 → 7.6 节线索化。 ② 三叉链表结点(多一个 parent 指针) lchild data parent rchild ← 多一个指向双亲的指针,向上走变成 O(1), 代价是每个结点多花一个指针的空间,且建树/改树时要同步维护它 两种链表对比 二叉链表:省空间、实现简单      找双亲需从根搜索 O(n)      空指针 n+1 个 三叉链表:找双亲 O(1)      空指针 2n+2 个(parent 也可能为空)      常用于需要「回溯到父结点」的算法 考场建议:默认写二叉链表;只有题目 明确要求「求双亲」时才用三叉链表。
图 7-8 二叉链表与三叉链表的结点结构对比

7.3.6 建树工具箱:从数组、先序序列、层序序列建树

考试里「给你序列,让你建树」的题非常多。下面这段代码一口气给出三个建树工具: 用数组建完全二叉树、用带 #先序序列建任意二叉树、用层序序列建树。 这三个函数是后面所有算法实验的入口,建议直接背下来。

#include <iostream>
#include <vector>
#include <string>
#include <queue>
using namespace std;

/* ============================================================
   二叉树结点(二叉链表)
   ============================================================ */
struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ---------- 工具 1:从数组建"完全二叉树" ----------
   arr 按层序存放,arr[i] 的左孩子是 arr[2i+1],右孩子是 arr[2i+2](0 基)
   值为 '#' 表示该位置为空(用于稀疏的完全二叉树) */
TreeNode* buildComplete(const vector<char>& arr) {
    int n = arr.size();
    if (n == 0 || arr[0] == '#') return nullptr;
    vector<TreeNode*> node(n, nullptr);
    for (int i = 0; i < n; ++i)
        if (arr[i] != '#') node[i] = new TreeNode(arr[i]);
    for (int i = 0; i < n; ++i) {
        if (!node[i]) continue;
        int l = 2 * i + 1, r = 2 * i + 2;
        if (l < n) node[i]->left  = node[l];
        if (r < n) node[i]->right = node[r];
    }
    return node[0];
}

/* ---------- 工具 2:从"带 # 的先序序列"建树 ----------
   例如 "ABD##E##C#F##" 表示:A 的左子树是 B,B 的左右孩子是 D、E,
   D、E 都是叶子;A 的右子树是 C,C 没有左孩子,右孩子是 F。
   '#' 表示空子树,因此序列长度一定是 2n+1。 */
TreeNode* buildByPreorder(const string& s, int& pos) {
    if (pos >= (int)s.size()) return nullptr;
    char c = s[pos++];
    if (c == '#') return nullptr;              // 空子树
    TreeNode* root = new TreeNode(c);
    root->left  = buildByPreorder(s, pos);      // 先建左子树
    root->right = buildByPreorder(s, pos);      // 再建右子树
    return root;
}

/* ---------- 工具 3:从"层序序列"建树 ----------
   vals[i] 为 '#' 表示空结点;用队列保存"孩子还没接完"的结点。
   注意:这里必须为每个位置都给出取值(哪怕孩子为空也要占一个 '#'
   或直接跳过),否则无法确定父子对应关系。 */
TreeNode* buildByLevelOrder(const vector<char>& vals) {
    if (vals.empty() || vals[0] == '#') return nullptr;
    TreeNode* root = new TreeNode(vals[0]);
    queue<TreeNode*> q;
    q.push(root);
    size_t i = 1;
    while (!q.empty() && i < vals.size()) {
        TreeNode* cur = q.front(); q.pop();
        if (i < vals.size()) {                       // 左孩子
            if (vals[i] != '#') { cur->left = new TreeNode(vals[i]); q.push(cur->left); }
            ++i;
        }
        if (i < vals.size()) {                       // 右孩子
            if (vals[i] != '#') { cur->right = new TreeNode(vals[i]); q.push(cur->right); }
            ++i;
        }
    }
    return root;
}

/* 中序遍历,用来验证三种建树结果是否一致 */
void inorder(TreeNode* r, string& out) {
    if (!r) return;
    inorder(r->left, out);
    out += r->val;
    inorder(r->right, out);
}

/* 释放整棵树:必须用后序,否则会访问已释放结点的孩子指针 */
void destroy(TreeNode* r) {
    if (!r) return;
    destroy(r->left);
    destroy(r->right);
    delete r;
}

int main() {
    /* 工具 1:数组 {A,B,C,D,E,#,F} 建出的树,中序应为 D B E A C F */
    vector<char> arr = { 'A','B','C','D','E','#','F' };
    TreeNode* t1 = buildComplete(arr);
    string s1; inorder(t1, s1);
    cout << "由数组建树      中序 = " << s1 << "\n";      // DBEACF

    /* 工具 2:先序序列建树,同一棵树 */
    int pos = 0;
    TreeNode* t2 = buildByPreorder("ABD##E##C#F##", pos);
    string s2; inorder(t2, s2);
    cout << "由先序序列建树  中序 = " << s2 << "\n";      // DBEACF

    /* 工具 3:层序序列建树,同一棵树 */
    vector<char> lv = { 'A', 'B', 'C', 'D', 'E', '#', 'F' };
    TreeNode* t3 = buildByLevelOrder(lv);
    string s3; inorder(t3, s3);
    cout << "由层序序列建树  中序 = " << s3 << "\n";      // DBEACF

    destroy(t1); destroy(t2); destroy(t3);
    return 0;
}
为什么先序序列建树要带 # ,而层序不用? 先序建树是纯递归的:读一个字符就知道当前子树是空还是有一个根,然后递归读左、递归读右。 为了让递归知道「这里没有子树」,必须用 # 显式占位。 层序建树是用队列推的:队列里每个结点依次认领两个位置(先左后右), 空的位置用 # 跳过即可,不需要递归,所以序列的形态更自由。 两者的共同点是:序列里都必须体现出「空」的信息,否则树的形状无法唯一确定。

7.4 二叉树的五大性质(重点,每条都要会证)

二叉树的性质之所以能成为「必考」,是因为它们是纯粹的推理题:不写代码、不看图, 只给几个数就能算出答案。下面五条性质我们逐条证明,每条后面跟一道典型例题。 考试要求:不仅要背结论,还要能写出证明过程——尤其是性质 3,几乎年年考证明。

7.4.1 性质 1:第 i 层至多有 2i−1 个结点

性质 1:在二叉树的第 i 层上至多有 2i−1 个结点(i ≥ 1)。

证明(数学归纳法)

  1. 基础:i = 1 时,第 1 层只有根结点,共 1 个,而 21−1 = 20 = 1,成立。
  2. 归纳假设:设第 i − 1 层上至多有 2i−2 个结点。
  3. 归纳步骤:第 i 层的结点全是第 i − 1 层结点的孩子。二叉树的每个结点至多有两个孩子, 所以第 i 层的结点数至多是第 i − 1 层的 2 倍,即 ≤ 2 × 2i−2 = 2i−1
  4. 由归纳原理,对一切 i ≥ 1 成立。∎

注意证明里用的关键条件是「每个结点至多两个孩子」。这就是为什么性质 1 对二叉树成立, 而对一般的 m 次树要改成 mi−1(第 i 层最多 mi−1 个结点)。

例题 1 一棵二叉树的第 5 层最多有多少个结点?若要求第 5 层「恰好」达到这个最大值,需要什么条件?
:由性质 1,第 5 层最多 25−1 = 24 = 16 个结点。 要达到这个值,第 4 层的 8 个结点必须全都有两个孩子(即前 4 层构成满二叉树)。 换句话说,「某一层达到最大值」需要它上一层是满的

7.4.2 性质 2:深度为 k 的二叉树至多有 2k − 1 个结点

性质 2:深度为 k 的二叉树至多有 2k − 1 个结点(k ≥ 1), 且结点数恰好达到 2k − 1 的二叉树就是满二叉树

证明:把每一层的最大结点数加起来(等比数列求和):

每一项的上界来自性质 1,求和用等比数列公式 (2k − 1)/(2 − 1) = 2k − 1。∎

推论(考试超高频):n 个结点的满二叉树,叶子数 = (n + 1)/2,分支结点数 = (n − 1)/2。 推导:满二叉树有 n0 = 2k−1 个叶子、n2 = 2k−1 − 1 个度为 2 的结点 (可由性质 3 得出),两者之和正好是 2k − 1 = n。

例题 2 一棵深度为 6 的满二叉树有多少个结点?多少个叶子?
:结点数 26 − 1 = 63;叶子在第 6 层,共 26−1 = 32 个。 也可以用推论:叶子 = (63 + 1)/2 = 32,分支结点 = (63 − 1)/2 = 31,两者之和正好 63。

7.4.3 性质 3:n0 = n2 + 1(最重要的一个证明)

性质 3:对任何一棵非空二叉树,若叶子数为 n0、度为 1 的结点数为 n1、 度为 2 的结点数为 n2,则 n0 = n2 + 1

这条结论惊人地干净:叶子数永远比双分支结点数多 1,与 n1 无关、与树的形状无关。 证明的核心只有一个工具——「结点数 = 度数之和 + 1」,也就是 7.1.3 节推出的恒等式。

证明(数边法,考试标准写法)

  1. 设二叉树共有 n 个结点,则 n = n0 + n1 + n2。(按度数分类,不重不漏)
  2. 二叉树是一棵树,所以边数(分支数)= n − 1
  3. 另一方面,从「结点发出多少条边」来看,度数为 1 的结点发出 1 条边,度数为 2 的结点发出 2 条边, 叶子不发出边,所以总边数 = n1 + 2n2
  4. 两条式子都等于边数,于是 n − 1 = n1 + 2n2
  5. 把第 1 步代入:n0 + n1 + n2 − 1 = n1 + 2n2, 两边消去 n1n0 − 1 = n2,即 n0 = n2 + 1。∎
一句话记住证明思路 「总边数」算两遍:一遍从结点数算(n − 1),一遍从出度算(n1 + 2n2), 两个结果相等就出来了。这个套路还能推广到 m 次树: n0 = 1 + n2 + 2n3 + 3n4 + … + (m−1)nm, 即「叶子数 = 1 + Σ(度 − 1) × 该度结点数」。很多竞赛题直接用这条推广式。
例题 3(经典送分题) 一棵二叉树有 20 个叶子结点,求度为 2 的结点数。
:由性质 3,n2 = n0 − 1 = 20 − 1 = 19
变形:若该二叉树共有 50 个结点,求度为 1 的结点数。
n1 = n − n0 − n2 = 50 − 20 − 19 = 11。 注意 n1 必须是非负整数——如果算出来是负数,说明题目数据自相矛盾(这种「判断是否存在」的题也很常见)。

7.4.4 性质 4:n 个结点的完全二叉树深度为 ⌊log2 n⌋ + 1

性质 4:具有 n 个结点的完全二叉树的深度为 ⌊log2 n⌋ + 1。 等价的三种写法:⌈log2(n+1)⌉ = ⌊log2(n+1)⌋(当 n+1 是 2 的幂时)= ⌊log2 n⌋ + 1。

证明(夹逼法,看清「恰好」是怎么来的)

  1. 设完全二叉树的深度为 k。由性质 2,深度为 k 的二叉树最多有 2k − 1 个结点,所以 n ≤ 2k − 1,即 n + 1 ≤ 2k
  2. 关键的一步:完全二叉树的结点是「从上到下、从左到右」连续编号的, 所以只要深度是 k,前 k − 1 层的全部位置必然都有结点——如果前 k − 1 层缺了任何一个位置, 编号就会断档,那它就不是完全二叉树了。既然前 k − 1 层是满的,结点数就严格大于 2k−1 − 1:n > 2k−1 − 1,即 n + 1 > 2k−1
  3. 把两式合起来:2k−1 < n + 1 ≤ 2k
  4. 两边取以 2 为底的对数:k − 1 < log2(n+1) ≤ k, 说明 k 就是「不小于 log2(n+1) 的最小整数」,即 k = ⌈log2(n+1)⌉。
  5. 对整数 n,⌈log2(n+1)⌉ 与 ⌊log2 n⌋ + 1 恒等(可分别验证 n = 2k − 1 与 n = 2k−1 这两个边界),故深度 = ⌊log2 n⌋ + 1。∎

为什么普通二叉树没有这个公式?因为第 2 步「前 k−1 层全满」对普通二叉树不成立: 一棵 10 个结点的右斜树深度是 10,而 ⌊log2 10⌋ + 1 = 4。 所以 性质 4 是「完全二叉树」四个字换来的特权,做题时一定要先确认题目说的是完全二叉树。

例题 4 一棵完全二叉树有 1000 个结点,求它的深度、叶子数、度为 1 的结点数。
:深度 = ⌊log2 1000⌋ + 1 = 9 + 1 = 10(因为 29 = 512 ≤ 1000 < 1024 = 210)。
度为 1 的结点:完全二叉树中至多一个,且当且仅当 n 为偶数时存在(此时结点 n/2 只有左孩子)。 1000 是偶数 → n1 = 1
叶子数:由 n = n0 + n1 + n2 与 n0 = n2 + 1 得 n = 2n2 + 2 → n2 = 499,n0 = 500。 (验算:500 + 1 + 499 = 1000 ✓)

7.4.5 性质 5:完全二叉树的下标性质

性质 5:对具有 n 个结点的完全二叉树,按层序从 1 开始编号,则对任意结点 i(1 ≤ i ≤ n):
① 若 i = 1,则 i 是根,无双亲;若 i > 1,则 i 的双亲是 ⌊i/2⌋
② 若 2i > n,则 i 无左孩子(i 为叶子);否则 i 的左孩子是 2i
③ 若 2i + 1 > n,则 i 无右孩子;否则 i 的右孩子是 2i + 1

证明(归纳法,直观版)

  1. 先证 ②:对层序编号做归纳。根编号 1,它的左孩子是编号 2 = 2 × 1 ✓。 假设结点 i 的左孩子编号是 2i,那么结点 i + 1 的左孩子应该是多少? 按层序编号的规则,结点按「父结点编号从小到大」的顺序依次认领孩子, 结点 i 的两个孩子占据了编号 2i 与 2i + 1,于是结点 i + 1 的孩子紧接着排在后面, 即 2i + 2 = 2(i + 1) ✓。归纳完成。
  2. 再证 ③:由 ② 立刻得到——两个孩子是连续编号的,右孩子自然是 2i + 1。
  3. 最后证 ①:既然左孩子是 2i、右孩子是 2i + 1,那么「谁是结点 j 的双亲」就是解方程 2i = j 或 2i + 1 = j,即 i = ⌊j/2⌋。∎

0 基版本(写代码时用):把 tree[1..n] 改成 tree[0..n−1], 只需把所有编号整体减 1 再化简:左孩子 2i + 1、右孩子 2i + 2、双亲 (i − 1) / 2。 建议自己动手推一遍这个换算;考试时若记混了两套公式,就用 i = 0(根)的特殊情况检验: 根没有双亲,(0 − 1)/2 在整数除法下等于 0,正好落在根自己身上。

例题 5 一棵完全二叉树按层序编号,结点 12 与结点 25 是什么关系?结点 30 的双亲是谁?
:⌊12/2⌋ = 6,⌊25/2⌋ = 12,所以结点 25 的双亲是 12,即 12 是 25 的双亲。 又因为 2 × 12 + 1 = 25,所以 25 是 12 的右孩子(若编号是 24 则是左孩子)。
结点 30 的双亲是 ⌊30/2⌋ = 15;又因为 2 × 15 = 30,所以 30 是 15 的左孩子。 口诀:双亲编号就是孩子编号整除 2;孩子编号是偶数则为左孩子,奇数则为右孩子。
性质结论证明工具常见考法
性质 1第 i 层至多 2i−1 个结点数学归纳法问某一层最多几个结点;m 次树的推广
性质 2深度 k 至多 2k − 1 个结点等比数列求和 + 性质 1给深度求结点数;判断是否为满二叉树
性质 3n0 = n2 + 1结点数 = 度数之和 + 1给叶子数求 n2;判断数据是否合理;证明题
性质 4深度 = ⌊log2 n⌋ + 1夹逼 + 归纳(完全二叉树专用)给 n 求深度;给深度求 n 的范围
性质 5双亲 ⌊i/2⌋,左 2i,右 2i+1层序编号归纳给编号问关系;判断是否为完全二叉树;堆的实现

7.5 二叉树的四种遍历(重点)

7.5.1 遍历的本质:把非线性结构线性化

树是非线性结构:一个结点有两条「往下的路」,程序没法像数组那样「顺着下标走一遍」。 要想系统地访问到每个结点、且每个结点恰好访问一次,就必须人为规定一个次序。 这个次序就是遍历(traversal)

遍历的本质 = 把非线性的树结构线性化成一个序列,
从而把一个「二维」的问题化归成我们已经会处理的「一维」问题。

这句话不是空话,它直接解释了三件事:
① 为什么树能存进一维数组——先序 / 层序序列配上空标记就能唯一确定树形;
② 为什么树的很多算法都长一个样——都是「递归处理根和左右子树」,只是「访问根」的位置不同;
③ 为什么「已知两种遍历序列就能还原二叉树」——序列携带了完整的结构信息。

既然二叉树只有「根 D、左子树 L、右子树 R」三部分,那么按「先访问谁」排列, 就有 3! = 6 种次序。其中规定必须先左后右(左子树永远先于右子树)后,只剩三种:

名称次序记忆口诀根的位置
先序遍历(preorder, DLR)根 → 左子树 → 右子树根左右根在最
中序遍历(inorder, LDR)左子树 → 根 → 右子树左根右根在中间
后序遍历(postorder, LRD)左子树 → 右子树 → 根左右根根在最
层序遍历(level order, BFS)第 1 层 → 第 2 层 → … 每层从左到右从上到下、从左到右—(借助队列
「先 / 中 / 后」到底先的是什么? 「先序 / 中序 / 后序」里的「序」指的是根被访问的时机,不是左子树或右子树的时机—— 左右子树的相对次序永远是先左后右。所以先序 = 先访问根,中序 = 根在中间访问,后序 = 最后访问根。 很多同学写成「右左根」这种,那就不是标准遍历了。

7.5.2 手工遍历练习:一棵树写出四种序列

下面这棵树是本章的「主角」,后面的序列还原、遍历应用、线索化都用它。 请先自己在纸上写出四种序列,再展开答案核对。

A B C D E F G C 只有右孩子 F E 只有左孩子 G,F 是叶子 注意:F 是 C 的孩子, 中序里 F 排在 C 后面。 根 A
图 7-9 本章主角树:先序 ABDECFG,中序 DBEAFCG,后序 DEBGFCA,层序 ABCDEFG
点击查看四种遍历的手推过程(务必先自己写一遍)

先序遍历(根左右):先访问 A;递归左子树(以 B 为根):访问 B, 递归 B 的左子树(以 D 为根):访问 D(叶子,无子树);回到 B 递归 B 的右子树(以 E 为根): 访问 E,递归 E 的左子树(G):访问 G;E 无右子树;回到 A 递归右子树(以 C 为根): 访问 C,C 无左子树,递归右子树(F):访问 F。
ABDECFG。观察:先序序列的第一个字符一定是根

中序遍历(左根右):A 先递归左子树(B):B 先递归左子树(D):访问 D; 再访问 B;再递归 B 的右子树(E):E 先递归左子树(G):访问 G;再访问 E; 回到 A:访问 A;再递归右子树(C):C 无左子树,直接访问 C;再递归 C 的右子树(F):访问 F。
DBEAFCG。观察:中序序列里根把它分成左右两段——左边是左子树的全部结点,右边是右子树的全部结点。

后序遍历(左右根):处理 A 的左子树(B):D、G、E、B;处理 A 的右子树(C):F、C;最后 A。
逐步展开:D(B 的左孩子,是叶子)→ G(E 的左孩子,是叶子)→ E → B → F(C 无左子树,右孩子是叶子)→ C → A。
DEBGFCA。观察:后序序列的最后一个字符一定是根

层序遍历(队列):A 出队并访问 → 把 B、C 入队 → B 出队访问 → 把 D、E 入队 → C 出队访问 → C 的左孩子为空,把 F 入队 → D 出队访问(无孩子)→ E 出队访问 → 把 G 入队 → F 出队访问 → G 出队访问。
ABCDEFG(恰好与字母顺序一致,所以做题时别只看答案,要能说清过程)。

7.5.3 四种遍历的动画演示

先看动画,再回来看代码。四个动画分别对应四种遍历,都会高亮「当前正在访问的结点」, 把「已经输出到序列里的结点」标成绿色,并在下方实时显示访问序列, 右侧还会显示递归栈 / 队列的内容。建议对照上面的手推结果逐帧核对。

① 先序遍历(根左右):先把根处理掉,再一路向左,最后回到右边。注意「入栈即访问」的节奏。

② 中序遍历(左根右):先钻进最左边,再出来访问根。注意根总是在「左子树全部走完之后」才出现。

③ 后序遍历(左右根):根最后才被访问,所以每个结点都要等它的两棵子树都处理完——注意观察「结点被重复经过但不输出」的现象。

④ 层序遍历(队列):没有递归,全靠一个队列。右侧会显示队列中当前的结点,请仔细观察「出队一个、入队它的孩子」这个节奏。

观察结论:前中后序的「走法」其实是同一条路线 把前三个动画的「当前结点」轨迹连起来看,你会发现它们走过的路线完全相同 (都是「沿左孩子一路向下,到底后回退,再进入右孩子」的那条曲线), 区别只在于「什么时候动手访问」——第一次经过时访问就是先序,第二次经过时访问就是中序, 第三次经过时访问就是后序。
这个「每个结点被经过 3 次」的观点,正是理解 7.6 节线索二叉树的关键: 线索就是把遍历时用到的前驱 / 后继关系直接写进那些空指针域里。

7.5.4 递归实现:三种遍历只差一行代码的位置

递归实现几乎是「照抄定义」,因为二叉树本身就是递归定义的。请特别注意三行代码的相对位置—— 它们的顺序一变,遍历方式就变了。

#include <iostream>
#include <vector>
#include <string>
using namespace std;

struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ============================================================
   三种递归遍历:只有"访问根"这一行的位置不同
   ============================================================ */

void preorder(TreeNode* root, vector<char>& out) {
    if (!root) return;                       // 递归出口:空子树
    out.push_back(root->val);                // ★ 位置 1:先访问根  → 根左右
    preorder(root->left, out);               // 再递归左子树
    preorder(root->right, out);              // 最后递归右子树
}

void inorder(TreeNode* root, vector<char>& out) {
    if (!root) return;
    inorder(root->left, out);                // 先递归左子树
    out.push_back(root->val);                // ★ 位置 2:中间访问根  → 左根右
    inorder(root->right, out);
}

void postorder(TreeNode* root, vector<char>& out) {
    if (!root) return;
    postorder(root->left, out);
    postorder(root->right, out);
    out.push_back(root->val);                // ★ 位置 3:最后访问根  → 左右根
}

/* ---------- 调试用:从"带 # 的先序序列"建树 ---------- */
TreeNode* build(const string& s, int& pos) {
    char c = s[pos++];
    if (c == '#') return nullptr;
    TreeNode* r = new TreeNode(c);
    r->left  = build(s, pos);
    r->right = build(s, pos);
    return r;
}

void printVec(const vector<char>& v) {
    for (char c : v) cout << c;
    cout << "\n";
}

int main() {
    /* 图 7-9 的主角树,先序带 # 的完整形态 */
    string s = "ABD##EG###C#F##";
    int pos = 0;
    TreeNode* root = build(s, pos);

    vector<char> a, b, c;
    preorder(root, a);  cout << "先序: "; printVec(a);   // ABDECFG
    inorder(root, b);   cout << "中序: "; printVec(b);   // DBEAFCG
    postorder(root, c); cout << "后序: "; printVec(c);   // DEBGFCA
    return 0;
}

复杂度:每个结点恰好被访问一次,每次访问做 O(1) 的工作,所以时间 O(n); 空间是递归栈的深度,最好 O(log n)(完全二叉树),最坏 O(n)(斜树)。 这一点很关键——递归遍历的空间复杂度取决于树的高度,而不是结点总数

7.5.5 非递归实现:用栈模拟递归

递归虽好,但有两个现实问题:① 树很深时(斜树)会撑爆系统栈,直接段错误; ② 有些场合(内存受限的嵌入式环境)不允许递归。所以必须会写非递归版。

本质:递归靠「函数调用栈」记住「回来之后该干什么」。 非递归就是自己开一个栈,把这个「待办事项」显式存起来

#include <iostream>
#include <vector>
#include <string>
#include <stack>
#include <algorithm>
using namespace std;

struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ---------- 先序非递归:栈。压栈顺序 = 先右后左 ---------- */
vector<char> preorderIter(TreeNode* root) {
    vector<char> out;
    if (!root) return out;
    stack<TreeNode*> st;
    st.push(root);
    while (!st.empty()) {
        TreeNode* p = st.top(); st.pop();
        out.push_back(p->val);                       // 出栈即访问
        if (p->right) st.push(p->right);              // 右孩子先入栈
        if (p->left)  st.push(p->left);               // 左孩子后入栈 → 先出栈
    }
    return out;
}

/* ---------- 中序非递归:一路向左压栈,弹栈访问后转向右子树 ---------- */
vector<char> inorderIter(TreeNode* root) {
    vector<char> out;
    stack<TreeNode*> st;
    TreeNode* p = root;
    while (p || !st.empty()) {
        while (p) { st.push(p); p = p->left; }       // 阶段 1:左到底,沿途全部入栈
        p = st.top(); st.pop();                      // 阶段 2:弹出栈顶
        out.push_back(p->val);                       //         访问它
        p = p->right;                                // 阶段 3:转向右子树,重复
    }
    return out;
}

/* ---------- 后序非递归(写法 A):双栈法 ----------
   思路:模仿先序,但压栈顺序改成"先左后右",得到的是"根右左",
   把它整个反转就是"左右根" = 后序。 */
vector<char> postorderIter2Stack(TreeNode* root) {
    vector<char> out;
    if (!root) return out;
    stack<TreeNode*> st;
    st.push(root);
    while (!st.empty()) {
        TreeNode* p = st.top(); st.pop();
        out.push_back(p->val);                       // 此时得到的是"根右左"
        if (p->left)  st.push(p->left);               // 注意:与先序相反
        if (p->right) st.push(p->right);
    }
    reverse(out.begin(), out.end());                 // 反转 → 左右根
    return out;
}

/* ---------- 后序非递归(写法 B):单栈 + last 指针 ----------
   last 记录"上一个被访问的结点"。弹栈前判断:
   若右子树存在且右子树不是刚访问完的那个,说明右子树还没走,先转过去。 */
vector<char> postorderIter1Stack(TreeNode* root) {
    vector<char> out;
    stack<TreeNode*> st;
    TreeNode* p = root;
    TreeNode* last = nullptr;
    while (p || !st.empty()) {
        while (p) { st.push(p); p = p->left; }        // 左到底
        TreeNode* top = st.top();
        if (top->right && top->right != last) {
            p = top->right;                           // 右子树还没走,先去走
        } else {
            out.push_back(top->val);                  // 左右都走完了,可以访问根
            last = top;
            st.pop();
        }
    }
    return out;
}

TreeNode* build(const string& s, int& pos) {
    char c = s[pos++];
    if (c == '#') return nullptr;
    TreeNode* r = new TreeNode(c);
    r->left = build(s, pos);
    r->right = build(s, pos);
    return r;
}

void printVec(const vector<char>& v) {
    for (char c : v) cout << c;
    cout << "\n";
}

int main() {
    string s = "ABD##EG###C#F##";
    int pos = 0;
    TreeNode* root = build(s, pos);

    printVec(preorderIter(root));            // ABDECFG
    printVec(inorderIter(root));             // DBEAFCG
    printVec(postorderIter2Stack(root));     // DEBGFCA
    printVec(postorderIter1Stack(root));     // DEBGFCA(两种写法结果必须一致)
    return 0;
}

层序遍历必须借助队列(先进先出,保证「同一层的结点按从左到右的顺序排队」)。 它的代码短得出奇,但用途极广:求树宽、按层输出、判断完全二叉树都要用它。

#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <algorithm>
using namespace std;

struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ---------- 层序遍历(BFS):队列,出队一个就把它的孩子入队 ---------- */
vector<char> levelOrder(TreeNode* root) {
    vector<char> out;
    if (!root) return out;                      // ★ 一定要判空!
    queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        TreeNode* p = q.front(); q.pop();
        out.push_back(p->val);                   // 访问
        if (p->left)  q.push(p->left);           // 左孩子入队(保证同层从左到右)
        if (p->right) q.push(p->right);          // 右孩子入队
    }
    return out;
}

/* ---------- 分层输出:每层单独收集成一个 vector ---------- */
vector<vector<char>> levelOrderByLevel(TreeNode* root) {
    vector<vector<char>> res;
    if (!root) return res;
    queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        int sz = q.size();                       // ★ 关键:先固定住"这一层有多少个"
        vector<char> cur;
        for (int i = 0; i < sz; ++i) {           // 只处理这一层的 sz 个
            TreeNode* p = q.front(); q.pop();
            cur.push_back(p->val);
            if (p->left)  q.push(p->left);
            if (p->right) q.push(p->right);
        }
        res.push_back(cur);
    }
    return res;
}

/* ---------- 求树的最大宽度(结点数最多的那一层的结点数) ---------- */
int maxWidth(TreeNode* root) {
    if (!root) return 0;
    queue<TreeNode*> q;
    q.push(root);
    int best = 0;
    while (!q.empty()) {
        int sz = q.size();
        best = max(best, sz);
        for (int i = 0; i < sz; ++i) {
            TreeNode* p = q.front(); q.pop();
            if (p->left)  q.push(p->left);
            if (p->right) q.push(p->right);
        }
    }
    return best;
}

TreeNode* build(const string& s, int& pos) {
    char c = s[pos++];
    if (c == '#') return nullptr;
    TreeNode* r = new TreeNode(c);
    r->left = build(s, pos);
    r->right = build(s, pos);
    return r;
}

int main() {
    string s = "ABD##EG###C#F##";
    int pos = 0;
    TreeNode* root = build(s, pos);

    for (char c : levelOrder(root)) cout << c;           // ABCDEFG
    cout << "\n";
    for (auto& lv : levelOrderByLevel(root)) {
        for (char c : lv) cout << c;
        cout << " | ";
    }
    cout << "\n";                                        // A | BC | DEF | G |
    cout << "最大宽度 = " << maxWidth(root) << "\n";      // 3
    return 0;
}

7.5.6 已知遍历序列还原二叉树

这是遍历部分最爱考的题型。先记住三条结论,再学怎么做:

已知序列组合能否唯一确定二叉树原因
先序 + 中序 先序的第一个是根;拿这个根去中序里一劈两半,就知道左右子树各有哪些结点;再对每半递归。
后序 + 中序 后序的最后一个是根;其余同上(根在中序里劈开左右子树)。
层序 + 中序 层序的第一个是根,同样去中序里劈开;剩下的层序序列按左右子树分流即可。
先序 + 后序不能 两者都只能定位「根」,无法区分「只有左孩子」和「只有右孩子」的情形。
先序 + 层序 / 后序 + 层序一般能,但没有中序那么直接 需要额外推导,考得少;实战中不建议硬记。
为什么「先序 + 后序」无法唯一确定?一个反例说清 考虑两棵不同的二叉树,它们都有 2 个结点 A、B,先序序列都是 AB、后序序列都是 BA
A 的左孩子是 B:先序 A B ✓,后序 B A ✓;
A 的右孩子是 B:先序 A B ✓,后序 B A ✓。
两棵树结构不同,但先序、后序完全相同!根因:先序与后序都只表达了「根在哪、子树边界在哪」, 而「子树是左还是右」这个信息只有中序才有(中序里根左边的结点必然在左子树,右边的必然在右子树)。
考试结论:先序 + 后序只能确定结点的祖先 / 后代关系,能确定的树形数量等于 「每个只有一个孩子的结点都有 2 种选择」,即若这样的结点有 m 个,则有 2m 种可能的二叉树。

下面用主角树(先序 ABDECFG,中序 DBEAFCG)演示还原过程的每一步。 动画会把「当前处理的先序区间、中序区间」以及「哪一段是左子树、哪一段是右子树」全部标出来。

分治思路可以概括成三句话,代码就照着这三句话写:

  1. 定位根:先序区间的第一个元素 pre[pl] 就是当前子树的根。
  2. 划分区间:在中序序列里找到这个根的位置 k,则中序区间被分成 [il, k-1](左子树)与 [k+1, ir](右子树);左子树的结点数是 k - il, 据此把先序区间也切成 [pl+1, pl+k-il](左)与 [pl+k-il+1, pr](右)。
  3. 递归:对两个子区间分别递归,返回值接到根的左右指针上。
#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   由「先序 + 中序」序列重建二叉树(竞赛标准写法:全局数组 + 递归)
   ------------------------------------------------------------
   先序定根,中序分左右:
       pre[pl..pr] 是当前子树的先序区间,in[il..ir] 是对应的中序区间
       ① 根 = pre[pl]
       ② 在中序里找到根的位置 k ⇒ 左子树结点数 leftLen = k - il
          左子树:先序 [pl+1, pl+leftLen]   中序 [il, k-1]
          右子树:先序 [pl+leftLen+1, pr]   中序 [k+1, ir]
       ③ 对两个子区间递归,返回值直接接到根的左右孩子上
   用桶 pos[c] 把「在中序里找根」从 O(n) 降到 O(1)(字符可以直接开 128 的桶)
   时间复杂度 O(n),空间 O(n)

   二叉树的静态数组表示:lc[u] / rc[u] 存左右孩子的下标,0 表示空子树
   ============================================================ */

const int N = 100005;             /* 结点数上限 */
char pre[N], in[N], post[N];      /* 先序、中序、后序序列(都按 1..n 存,1 基好写区间) */
int  pos[128];                    /* pos[c] = 字符 c 在「中序」里的下标,O(1) 找根 */
char val[N];                      /* 每个结点的值 */
int  lc[N], rc[N];                /* 左、右孩子的下标,0 表示空 */
int  q[N];                        /* 层序遍历用的手写队列 */
int  tot;                         /* 已经用掉的结点编号(从 1 开始) */
int  n;                           /* 结点个数 */

/* 申请一个新结点:静态数组版的 new */
int newNode(char c) {
    ++tot;
    val[tot] = c;
    lc[tot] = rc[tot] = 0;
    return tot;
}

/* 先序 + 中序:返回当前子树的根,0 表示空子树 */
int build(int pl, int pr, int il, int ir) {
    if (pl > pr || il > ir) return 0;                      /* 空子树 */
    char rootVal = pre[pl];                                /* ① 先序区间的第一个就是根 */
    int  k = pos[(int)rootVal];                            /* ② 根在中序中的位置 */
    int  leftLen = k - il;                                 /*    左子树结点个数 */
    int  u = newNode(rootVal);
    lc[u] = build(pl + 1, pl + leftLen, il, k - 1);        /* ③ 递归左子树 */
    rc[u] = build(pl + leftLen + 1, pr, k + 1, ir);        /*    递归右子树 */
    return u;
}

/* 后序 + 中序:只需把「先序取第一个」换成「后序取最后一个」,其余一模一样 */
int buildFromPost(int pl, int pr, int il, int ir) {
    if (pl > pr || il > ir) return 0;
    char rootVal = post[pr];                               /* ★ 后序的最后一个才是根 */
    int  k = pos[(int)rootVal];
    int  leftLen = k - il;
    int  u = newNode(rootVal);
    lc[u] = buildFromPost(pl, pl + leftLen - 1, il, k - 1);
    rc[u] = buildFromPost(pl + leftLen, pr - 1, k + 1, ir);
    return u;
}

/* ---------- 校验:对建出来的树做一次层序遍历(手写队列,竞赛常用) ---------- */
int levelOrder(int root, char out[]) {
    int len = 0;
    if (root == 0) return len;
    int head = 0, tail = 0;
    q[tail++] = root;
    while (head < tail) {
        int u = q[head++];
        out[len++] = val[u];
        if (lc[u]) q[tail++] = lc[u];
        if (rc[u]) q[tail++] = rc[u];
    }
    return len;
}

/* 顺便打印先序 / 中序,肉眼确认建出来的树与输入序列一致 */
void printPre(int u) { if (!u) return; putchar(val[u]); printPre(lc[u]); printPre(rc[u]); }
void printIn (int u) { if (!u) return; printIn(lc[u]); putchar(val[u]); printIn(rc[u]); }

/* 把字符串按 1 基装进数组(等价于竞赛里的 cin >> (pre + 1)) */
void load(const char* s, char dst[]) {
    n = (int)strlen(s);
    for (int i = 1; i <= n; ++i) dst[i] = s[i - 1];
}

int main() {
    load("ABDECFG", pre);
    load("DBEAFCG", in);
    load("DEBFGCA", post);
    for (int i = 1; i <= n; ++i) pos[(int)in[i]] = i;      /* 预处理:值 → 中序下标 */

    char out[N];

    tot = 0;
    int t1 = build(1, n, 1, n);
    int len1 = levelOrder(t1, out);
    printf("pre+in  -> 层序 ");
    for (int i = 0; i < len1; ++i) putchar(out[i]);
    printf("  先序 "); printPre(t1);
    printf("  中序 "); printIn(t1);
    printf("\n");                                          /* 层序 ABCDEFG */

    tot = 0;
    int t2 = buildFromPost(1, n, 1, n);
    int len2 = levelOrder(t2, out);
    printf("post+in -> 层序 ");
    for (int i = 0; i < len2; ++i) putchar(out[i]);
    printf("\n");                    /* ABCDEFG:两种方式得到同一棵树 */
    return 0;
}
考点:还原题的三个高频陷阱
  1. 先序 + 中序的根在中序里可能重复出现吗?不会——二叉树里通常约定结点值互不相同。 如果题目里出现了相同值,还原结果就不唯一,此时要按「最左匹配」或题目指定规则处理。
  2. 先序 + 后序给的序列是不是根?先序的第一个、后序的最后一个一定是根,两者必须相同, 否则序列非法。同理,先序与中序的元素集合必须完全一致,长度也必须相同。
  3. 只给中序 + 后序,问你能不能建树?能。方法一模一样,只是「取根」的位置从最前换到最后, 并且递归顺序要改成先建右子树再建左子树(因为后序从后往前是「根 → 右子树 → 左子树」)。

7.5.7 遍历的七大应用(每个都要会默写)

学遍历不是为了背序列,而是为了用遍历的框架去解决一切树上的问题。 下面这七个函数几乎是所有二叉树习题的「零件库」,请把它们的代码骨架刻进肌肉记忆。

问题用哪种遍历最顺手核心思路
求结点个数任意(后序最自然)1 + 左子树个数 + 右子树个数
求树的深度后序1 + max(左深度, 右深度)(空树深度为 0)
求叶子个数后序左右都空则算 1,否则递归求和
交换左右子树后序(或先序)递归交换每个结点的两个指针
判断两棵树是否相同先序 / 后序同空 → 真;一空一非空 → 假;值不等 → 假;否则递归比较左右
求第 k 层的结点数先序(带层号参数)到第 k 层就计数;也可以用层序遍历直接数第 k 层
判断是否为完全二叉树层序 + 标记法一旦遇到「孩子不全」的结点,后面必须全是叶子
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <algorithm>
using namespace std;

struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ---------- 1. 统计结点总数:后序,O(n) ---------- */
int countNodes(TreeNode* r) {
    if (!r) return 0;
    return 1 + countNodes(r->left) + countNodes(r->right);
}

/* ---------- 2. 求树的深度:后序,O(n) ----------
   注意空树深度为 0,叶子深度为 1。 */
int depth(TreeNode* r) {
    if (!r) return 0;
    return 1 + max(depth(r->left), depth(r->right));
}

/* ---------- 3. 统计叶子结点个数 ---------- */
int countLeaves(TreeNode* r) {
    if (!r) return 0;
    if (!r->left && !r->right) return 1;               // 左右都空 → 是叶子
    return countLeaves(r->left) + countLeaves(r->right);
}

/* ---------- 4. 交换所有结点的左右子树 ---------- */
void swapChildren(TreeNode* r) {
    if (!r) return;
    swap(r->left, r->right);                          // 交换本结点
    swapChildren(r->left);                            // 继续处理(已经换过位置的)左右子树
    swapChildren(r->right);
}

/* ---------- 5. 判断两棵二叉树是否完全相同 ---------- */
bool isSame(TreeNode* a, TreeNode* b) {
    if (!a && !b) return true;                        // 都空 → 相同
    if (!a || !b) return false;                       // 一个空一个不空 → 不同
    if (a->val != b->val) return false;                // 根的值不同 → 不同
    return isSame(a->left, b->left) && isSame(a->right, b->right);
}

/* ---------- 6. 求第 k 层的结点个数(根算第 1 层) ---------- */
int countLevelK(TreeNode* r, int k) {
    if (!r || k < 1) return 0;
    if (k == 1) return 1;                             // 到达目标层
    return countLevelK(r->left, k - 1) + countLevelK(r->right, k - 1);
}

/* ---------- 7. 判断是否为完全二叉树:层序 + 标记法 ----------
   思路:层序遍历,一旦遇到"孩子不全"的结点(缺左或缺右),
   就把 seenIncomplete 置为 true;之后遇到的任何结点都必须是叶子,
   否则编号就断档了,不是完全二叉树。
   还要特别注意:只有右孩子没有左孩子,直接判定失败。 */
bool isComplete(TreeNode* root) {
    if (!root) return true;                           // 空树约定为完全二叉树
    queue<TreeNode*> q;
    q.push(root);
    bool seenIncomplete = false;                      // 是否已经遇到过"孩子不全"的结点
    while (!q.empty()) {
        TreeNode* p = q.front(); q.pop();
        if (p->left) {
            if (seenIncomplete) return false;         // 断档之后又出现了孩子 → 失败
            q.push(p->left);
        } else {
            seenIncomplete = true;                    // 没有左孩子 → 从这里开始断档
        }
        if (p->right) {
            if (seenIncomplete) return false;         // 包含了"只有右孩子"的非法情形
            q.push(p->right);
        } else {
            seenIncomplete = true;
        }
    }
    return true;
}

/* ---------- 附加:销毁整棵树(必须后序) ---------- */
void destroy(TreeNode* r) {
    if (!r) return;
    destroy(r->left);
    destroy(r->right);
    delete r;
}

TreeNode* build(const string& s, int& pos) {
    char c = s[pos++];
    if (c == '#') return nullptr;
    TreeNode* r = new TreeNode(c);
    r->left = build(s, pos);
    r->right = build(s, pos);
    return r;
}

int main() {
    int pos = 0;
    TreeNode* root = build("ABD##EG###C#F##", pos);   // 图 7-9 的主角树

    cout << "结点数   = " << countNodes(root) << "\n";   // 7
    cout << "深度     = " << depth(root) << "\n";        // 4
    cout << "叶子数   = " << countLeaves(root) << "\n";  // 3(D、G、F)
    cout << "第 3 层   = " << countLevelK(root, 3) << "\n"; // 3(D、E、F)
    cout << "是完全二叉树? " << (isComplete(root) ? "是" : "否") << "\n";  // 否

    int pos2 = 0;
    TreeNode* same = build("ABD##EG###C#F##", pos2);
    cout << "两棵树相同? " << (isSame(root, same) ? "是" : "否") << "\n"; // 是

    swapChildren(root);                                // 交换后层序应为 ACBFGED
    vector<char> lv;
    {   /* 内联层序遍历,避免与其它文件重名 */
        queue<TreeNode*> q; q.push(root);
        while (!q.empty()) {
            TreeNode* p = q.front(); q.pop();
            lv.push_back(p->val);
            if (p->left) q.push(p->left);
            if (p->right) q.push(p->right);
        }
    }
    cout << "交换左右子树后层序: ";
    for (char c : lv) cout << c;
    cout << "\n";

    destroy(root); destroy(same);
    return 0;
}
「递归三问」:写任何树的递归函数前先问自己
  1. 出口是什么?通常是「结点为空」时返回什么(返回 0?false?还是什么都不做?)。 这一条决定函数在空树上是否正确,考试扣分大多扣在这里。
  2. 要什么信息?把「以 root 为根的子树」需要向父结点汇报的信息列出来(个数、深度、是否平衡……), 这就是递归函数的返回值语义。
  3. 怎么合并?左右子树的结果怎么加上根本身的信息得到当前结果。 如果发现需要汇报的信息不止一个(例如「是否平衡」还要「高度」),有两种解法: 返回结构体 / pair,或者用引用参数「带出」额外信息。

7.6 线索二叉树

7.6.1 为什么需要线索:n + 1 个空指针的浪费

先算一笔账。二叉链表有 n 个结点,每个结点 2 个指针域,共 2n 个指针域。 其中真正被用上的只有「连向孩子」的那些,恰好每条树边对应一个,即 n − 1 个。 于是空指针域有:

也就是说,整整一半以上的指针是空的(当 n 较大时,空指针占比接近 1/2)。 与此同时,我们在遍历时还得额外开一个栈(或者递归栈)来记住「从哪来、该回哪去」—— 而这些信息其实恰好就是「前驱 / 后继」关系。两边一对照,想法自然就出来了:

线索化的本质:把遍历时得到的前驱 / 后继信息,
写进那些本来空着的指针域,让「找前驱 / 找后继」变成 O(1),遍历不再需要栈。

具体做法是:给每个结点加两个标志位 ltagrtag

ltag
ltag = 0left 指向左孩子(原本的含义); ltag = 1left 指向该结点在遍历序列中的前驱(我们称这根指针为「前驱线索」)。
rtag
rtag = 0right 指向右孩子rtag = 1right 指向该结点在遍历序列中的后继(称为「后继线索」)。
线索链表
加上线索的二叉链表。相应地,树就叫线索二叉树(threaded binary tree); 按线索化的次序不同,分为先序线索二叉树中序线索二叉树后序线索二叉树
线索化
把二叉树变成线索二叉树的过程。做法就是一次遍历,在「访问结点」时顺手把空指针改成线索。

下图是主角树的中序线索化结果。对照中序序列 DBEAFCG 看: D 的后继是 B、G 的前驱是 E、G 的后继是 F……每一条线索都对应序列里相邻的一对结点。

中序线索二叉树:实线 = 孩子指针(tag=0),虚线 = 线索(tag=1) A B C D E F G 中序序列:D B E A F C G D.right → B(后继) G.right → F(后继) G.left → E(前驱) E.left → B(前驱) F.left → C(前驱) F.right → C(后继) 这棵树共有 7 个结点 → 空指针 8 个, 其中首结点的 left、末结点的 right 无处可指,通常置空或指向头结点。
图 7-10 中序线索二叉树:虚线即线索,指向中序序列中的前驱与后继

7.6.2 三种线索化的规则

线索化的规则可以用一句话统一概括:按某种次序遍历二叉树,遍历过程中把「上一个访问的结点」记成 pre, 遇到当前结点 p 有空指针域时,就用 pre 和 p 互相连线。具体来说:

「按什么次序遍历」决定了得到哪种线索二叉树,三种次序的差别如下表。 中序线索二叉树是考得最多、也是唯一能完美 O(1) 双向找前驱后继的一种(后序线索找后继通常还需要知道双亲)。

种类线索的含义找前驱找后继说明
中序线索 指向中序序列中的前驱 / 后继 O(1)(沿左子树的「最右下」找) O(1)(沿右子树的「最左下」找) 最实用:中序遍历可以完全不用栈,也不需要递归
先序线索 指向前序序列中的前驱 / 后继 不易(需双亲) O(1)(有左孩子则左孩子就是后继) 「前驱」要区分结点是否为双亲的左孩子,需借助三叉链表
后序线索 指向后序序列中的前驱 / 后继 O(1)(有右孩子则右孩子就是前驱) 不易(需双亲) 与先序对称:找后继需要知道双亲
考点:中序线索树中找前驱 / 后继的三句话 设 p 是中序线索二叉树中的任意结点:
找后继:① 若 rtag == 1,则 p->right 直接就是后继; ② 若 rtag == 0(有右子树),则后继是右子树中最左下的结点(一路向左走到头)。
找前驱:① 若 ltag == 1,则 p->left 直接就是前驱; ② 若 ltag == 0(有左子树),则前驱是左子树中最右下的结点(一路向右走到头)。
记忆口诀:有线索直接用;无线索时「后继找右下、前驱找左下」——等等,是「后继找右子树的最左下,前驱找左子树的最右下」。 这两句话对称得容易记混,建议用一个具体例子(图 7-10 的 A)验证:A 的右子树是 C,C 的最左下结点是 F, 而中序序列 DBEAFCG 里 A 的后继确实是 F ✓。

7.6.3 中序线索化的 C++ 实现

代码只有二十来行,但有一个细节必须小心:递归线索化左子树时, 原来的 p->left 可能已经被改成了前驱线索,所以要先判断 ltag == 0 再递归, 否则会顺着线索跑到别的子树上去,造成死循环或错误。

#include <iostream>
#include <string>
#include <vector>
using namespace std;

/* ============================================================
   中序线索二叉树
   ltag / rtag = 0 表示指针指向孩子;= 1 表示指针是线索
   ============================================================ */
struct ThreadNode {
    char        val;
    ThreadNode* left;
    ThreadNode* right;
    int         ltag;      // 0: left 是左孩子   1: left 是前驱线索
    int         rtag;      // 0: right 是右孩子  1: right 是后继线索
    explicit ThreadNode(char v)
        : val(v), left(nullptr), right(nullptr), ltag(0), rtag(0) {}
};

/* ---------- 建树工具(普通二叉树) ---------- */
ThreadNode* build(const string& s, int& pos) {
    char c = s[pos++];
    if (c == '#') return nullptr;
    ThreadNode* r = new ThreadNode(c);
    r->left  = build(s, pos);
    r->right = build(s, pos);
    return r;
}

/* ============================================================
   中序线索化核心:一次中序遍历,pre 始终指向"刚访问过的那个结点"
   ============================================================ */
void inorderThread(ThreadNode* p, ThreadNode*& pre) {
    if (!p) return;

    inorderThread(p->left, pre);          // ① 递归左子树(此时 p->left 一定还是孩子指针)

    /* ② 处理当前结点的左空指针 → 前驱线索 */
    if (!p->left) { p->left = pre; p->ltag = 1; }

    /* ③ 处理前驱结点的右空指针 → 后继线索(此时 pre 就是 p 的前驱) */
    if (pre && !pre->right) { pre->right = p; pre->rtag = 1; }

    pre = p;                              // ④ p 成为下一个结点的前驱

    /* ⑤ 递归右子树。注意:此处必须用 rtag 判断!
       因为如果 p 没有右孩子,上面的步骤已经把 p->right 改成了后继线索,
       直接递归会顺着线索跑到别的子树里,造成死循环。 */
    if (p->rtag == 0) inorderThread(p->right, pre);
}

/* ---------- 封装的线索化入口:带一个"头结点" ----------
   头结点是常用的工程技巧:它的 left 指向根,right 指向中序最后一个结点,
   于是"第一个结点的前驱"和"最后一个结点的后继"都有地方指了。 */
ThreadNode* createInorderThread(ThreadNode* root) {
    ThreadNode* head = new ThreadNode('\0');    // 头结点,值无意义
    head->ltag = 0; head->rtag = 1;
    if (!root) { head->left = head; head->right = head; return head; }
    head->left = root;
    ThreadNode* pre = head;                     // pre 从头结点开始,天然处理"第一个结点无前驱"
    inorderThread(root, pre);
    pre->right = head; pre->rtag = 1;           // 最后一个结点的后继指向头结点
    head->right = pre;                          // 头结点的右指针指向中序最后一个结点
    return head;
}

/* ============================================================
   在中序线索树上找后继:不用栈、不用递归,O(1)
   ============================================================ */
ThreadNode* inorderNext(ThreadNode* p) {
    if (p->rtag == 1) return p->right;          // 有后继线索,直接用
    ThreadNode* q = p->right;                   // 否则后继 = 右子树"最左下"的结点
    while (q->ltag == 0 && q->left) q = q->left;
    return q;
}

/* ---------- 在中序线索树上找前驱(对称) ---------- */
ThreadNode* inorderPrev(ThreadNode* p) {
    if (p->ltag == 1) return p->left;
    ThreadNode* q = p->left;                    // 前驱 = 左子树"最右下"的结点
    while (q->rtag == 0 && q->right) q = q->right;
    return q;
}

/* ---------- 利用线索做中序遍历:空间 O(1),没有栈也没有递归 ---------- */
vector<char> inorderByThread(ThreadNode* head) {
    vector<char> out;
    ThreadNode* p = head->left;                 // 从头结点出发到根
    while (p && p->ltag == 0 && p->left) p = p->left;   // 走到中序第一个结点(最左下)
    while (p && p != head) {
        out.push_back(p->val);
        p = inorderNext(p);                     // 顺着后继线索一路走
    }
    return out;
}

int main() {
    int pos = 0;
    ThreadNode* root = build("ABD##EG###C#F##", pos);   // 图 7-9 的主角树
    ThreadNode* head = createInorderThread(root);

    cout << "中序遍历(靠线索,无栈无递归): ";
    for (char c : inorderByThread(head)) cout << c;      // DBEAFCG
    cout << "\n";

    /* 验证"找前驱/后继":找出中序第一个结点 D,然后一路顺着后继走 */
    ThreadNode* first = head->left;
    while (first->ltag == 0 && first->left) first = first->left;
    cout << "第一个结点是 " << first->val << "\n";         // D
    cout << "D 的后继是 " << inorderNext(first)->val << "\n";  // B
    ThreadNode* last = head->right;                      // 头结点右指针 = 中序最后一个结点
    cout << "最后一个结点是 " << last->val << "\n";         // G
    cout << "G 的前驱是 " << inorderPrev(last)->val << "\n";   // E
    return 0;
}
线索化的两个经典 bug
  1. 递归右子树时忘了判断 rtag:如果结点没有右孩子,它的 right 已经被改成了后继线索, 此时直接 inorderThread(p->right, pre) 就会顺着线索跑进别的子树,结果是无限递归或序列错乱。 正确写法是 if (p->rtag == 0) inorderThread(p->right, pre);
  2. 没处理「首结点无前驱、末结点无后继」:中序第一个结点的前驱是空的,最后一个结点的后继也是空的。 如果直接写 pre->right = p(pre 为空时)就会崩。两个标准做法: ① 加一个头结点(上面的代码采用这种,工程里最常见); ② 让 pre 初值为 nullptr,每次使用前判空。
易错:线索二叉树里「left/right」是双重身份 在普通二叉链表里,left == nullptr 就表示「没有左孩子」; 但在线索二叉树里,left != nullptr可能只是前驱线索而不是孩子。 所以写代码时永远不能再写 if (p->left) 来判断有没有左孩子, 必须写成 if (p->ltag == 0)。这是考卷上最爱设的陷阱,也是实际编码最容易踩的坑。

7.7 树、森林与二叉树的转换

7.7.1 三条互逆的转换规则

7.2.3 节已经埋下伏笔:孩子兄弟表示法用两个指针就存下了一棵普通树。 既然存储结构已经和二叉树一模一样,那么「树」与「二叉树」之间就必然存在一一对应关系。 转换规则其实只有一句话:把「第一个孩子」当作左孩子,把「下一个兄弟」当作右孩子

树 → 二叉树:左孩子右兄弟(加线 → 抹线 → 旋转) ① 原树 A B C D E F G B、C、D 是 A 的三个孩子,E、F 是 B 的两个孩子 ② 在兄弟之间加虚线,再把「非第一个孩子」连向双亲的线抹掉 A B C D E F G 加线:同一双亲的相邻兄弟连起来(B—C、C—D、E—F) 抹线:每个结点只保留「与第一个孩子的连线」,其余与双亲的连线全抹掉 ③ 结果:一棵二叉树(左 = 孩子,右 = 兄弟) A B C E D F G A 的左孩子 B(第一个孩子),B 的右孩子 C(下一个兄弟) C 的右孩子 D;B 的左孩子 E,E 的右孩子 F D 的左孩子 G(D 的第一个孩子) 要点:转换后,原树中每个结点的「孩子链」变成了二叉树里的一条「右指针链」; 二叉树中「某结点一直沿右走到底」得到的就是它在原树里的所有兄弟(含自己)。
图 7-11 树转二叉树的「加线 — 抹线 — 旋转」三步法

把上图的做法推广到森林,规则同样机械:

转换方向操作规则一次性记住的说法
树 → 二叉树 ① 在树中所有相邻兄弟之间加一条连线;
② 对每个结点,只保留它与第一个孩子的连线,抹掉它与其它孩子的连线;
③ 以根为轴心把整棵树顺时针旋转约 45°,让层次关系变成二叉树的左右关系。
左孩子右兄弟
森林 → 二叉树 ① 先把森林中每一棵树各自转成二叉树;
② 从第一棵树的根开始,把后一棵树的根作为前一棵树根的右孩子串起来。
各树转二叉树,根与根用右链相连
二叉树 → 树 若结点 x 是其双亲的左孩子,就把 x 的右孩子、右孩子的右孩子…… 全部与 x 的双亲连起来,最后抹掉所有结点与其右孩子的连线。 右链拆开挂到双亲上
二叉树 → 森林 反复断开根结点与其右孩子的连线,每断开一次就得到一棵树;直到没有右孩子为止。 沿根的右链一路劈开

下面是「树 → 二叉树」的动画演示。左侧是原树,右侧是逐步长出来的二叉树, 动画会依次完成「加兄弟线」「抹掉非长子的连线」「旋转定型」三个阶段。

7.7.2 必背结论:树与森林的遍历对应关系

转换规则本身不难记,难的是考试里那句「树的后序遍历 = 对应二叉树的中序遍历」—— 很多人第一次看到都会怀疑是不是印错了。我们用一个可以直接验证的方法说明它。

考点:两条对应结论(每年必考)
树 / 森林的遍历等价的二叉树遍历为什么
树的前序遍历(先访问根,再依次前序遍历每棵子树) 二叉树的前序遍历 都是「先根,再从左往右进入各子树」,访问顺序完全一致。
树的后序遍历(先依次后序遍历每棵子树,最后访问根) 二叉树的中序遍历 树的后序是「先走完所有孩子,再访问根」;在二叉树里,所有孩子挂在左子树的右链上, 中序恰好是「走完整个左子树(= 所有孩子),再访问根」,两者一致。
森林的前序遍历 二叉树的前序遍历 森林各树之间是用右链连的,前序会先走完一棵树再走右链进入下一棵,与森林的「逐棵前序」一致。
森林的后序遍历 二叉树的中序遍历 同理。
注意这里的不对称:「前序对前序」,「后序对中序」。 绝对不要记成「后序对后序」——二叉树的右链承载的是「兄弟」信息, 所以在二叉树里走右链等价于在树里「换个兄弟继续」, 而这恰好落在中序「访问完根之后」的位置上。

另外还要知道树的层序遍历:它和二叉树的层序遍历没有上述对应关系, 因为转换会改变结点的层次(兄弟被压到了下一层)。树的层序遍历要自己用队列做: 根入队,出队时把它的所有孩子(而不是两个孩子)依次入队。

#include <iostream>
#include <vector>
#include <string>
#include <queue>
using namespace std;

/* ============================================================
   树 <-> 二叉树 的相互转换
   统一用"左孩子右兄弟"的结点结构,从而两种形态共用一份数据
   ============================================================ */
struct Node {
    char  val;
    Node* child;      // 树:第一个孩子      二叉树:左孩子
    Node* sibling;    // 树:下一个兄弟      二叉树:右孩子
    explicit Node(char v) : val(v), child(nullptr), sibling(nullptr) {}
};

/* ---------- 树的前序遍历 = 二叉树的前序遍历 ---------- */
void treePre(Node* r, vector<char>& out) {         // 对树:根 → 各子树
    if (!r) return;
    out.push_back(r->val);
    for (Node* c = r->child; c; c = c->sibling) treePre(c, out);
}
void btPre(Node* r, vector<char>& out) {           // 对二叉树:根 → 左 → 右
    if (!r) return;
    out.push_back(r->val);
    btPre(r->child, out);
    btPre(r->sibling, out);
}

/* ---------- 树的后序遍历 = 二叉树的中序遍历 ---------- */
void treePost(Node* r, vector<char>& out) {        // 对树:各子树 → 根
    if (!r) return;
    for (Node* c = r->child; c; c = c->sibling) treePost(c, out);
    out.push_back(r->val);
}
void btIn(Node* r, vector<char>& out) {            // 对二叉树:左 → 根 → 右
    if (!r) return;
    btIn(r->child, out);
    out.push_back(r->val);
    btIn(r->sibling, out);
}

/* ---------- 树的层次遍历:出队一个,把它的"所有孩子"入队 ---------- */
vector<char> treeLevel(Node* root) {
    vector<char> out;
    if (!root) return out;
    queue<Node*> q; q.push(root);
    while (!q.empty()) {
        Node* p = q.front(); q.pop();
        out.push_back(p->val);
        for (Node* c = p->child; c; c = c->sibling) q.push(c);   // 注意是所有孩子
    }
    return out;
}

/* ---------- 森林 → 二叉树:把各棵树的根用右链串起来 ---------- */
Node* forestToBinary(const vector<Node*>& roots) {
    if (roots.empty()) return nullptr;
    for (size_t i = 0; i + 1 < roots.size(); ++i)
        roots[i]->sibling = roots[i + 1];        // 前一棵树的根 → 右孩子 = 后一棵树的根
    return roots[0];
}

/* ---------- 二叉树 → 森林:沿根的右链依次劈开 ---------- */
vector<Node*> binaryToForest(Node* root) {
    vector<Node*> res;
    Node* p = root;
    while (p) {
        Node* nxt = p->sibling;                  // 先存下一条右链
        p->sibling = nullptr;                    // 断开
        res.push_back(p);
        p = nxt;
    }
    return res;
}

void print(const char* tag, const vector<char>& v) {
    cout << tag;
    for (char c : v) cout << c;
    cout << "\n";
}

int main() {
    /* 构造图 7-11 的树:A(B(E,F), C, D(G)) */
    Node* A = new Node('A'); Node* B = new Node('B'); Node* C = new Node('C');
    Node* D = new Node('D'); Node* E = new Node('E'); Node* F = new Node('F');
    Node* G = new Node('G');
    A->child = B;  B->sibling = C;  C->sibling = D;      // A 的孩子链:B → C → D
    B->child = E;  E->sibling = F;                        // B 的孩子链:E → F
    D->child = G;                                        // D 的孩子链:G

    vector<char> a, b;
    treePre(A, a);  print("树的前序   : ", a);            // ABEFCDG
    btPre(A, b);    print("二叉树的前序: ", b);           // ABEFCDG(相同!)

    vector<char> c, d;
    treePost(A, c); print("树的后序   : ", c);            // EFBCGDA
    btIn(A, d);     print("二叉树的中序: ", d);           // EFBCGDA(相同!)

    print("树的层序   : ", treeLevel(A));                 // ABCDEFG

    /* 森林 → 二叉树 → 森林 */
    Node* X = new Node('X'); Node* Y = new Node('Y');
    X->child = Y;
    vector<Node*> fo = { A, X };                          // 森林:两棵树
    Node* bin = forestToBinary(fo);
    cout << "森林转二叉树后,根的右链长度 = " << binaryToForest(bin).size() << "\n";  // 2
    return 0;
}

7.8 赫夫曼树与最优前缀编码(重点)

7.8.1 路径长度与带权路径长度 WPL

前面讲的是「树长什么样」,现在换个角度:给叶子结点赋上权值,然后问 「怎么把树造成让所有叶子的『代价之和』最小」。这就是赫夫曼树要解决的问题。 先精确定义两个量:

路径长度
从树中一个结点到另一个结点所经过的边数
结点的带权路径长度
到该结点的路径长度 × 该结点的权值,即 depth(v) × weight(v)(根深度记 0)。
树的带权路径长度(WPL
Weighted Path Length:树中所有叶子结点的带权路径长度之和。
其中 n 是叶子个数,wi 是第 i 个叶子的权值,li 是该叶子到根的路径长度(层数 − 1)。 只有叶子参与计算——这是最容易出错的地方。
赫夫曼树(最优二叉树)
在给定 n 个权值作为 n 个叶子结点的所有二叉树中, WPL 最小的那棵(可能不唯一),称为赫夫曼树(Huffman tree),也叫最优二叉树。
同一组权值 {2, 3, 4},两种不同形状的二叉树,WPL 差别巨大 ① 权值大的放深层 → WPL 大 2 3 4 WPL = 2×2 + 3×1 + 4×2 = 15 ② 权值大的放浅层 → WPL 小(赫夫曼树) 4 3 2 WPL = 4×1 + 3×1 + 2×2 = 11(更小) 规律: 权越大的叶子离根越近, WPL 就越小。 赫夫曼算法就是 按这个规律贪心构造。
图 7-12 WPL 的含义:权值大的叶子应尽量靠近根

7.8.2 赫夫曼算法:每次合并两个最小的

一句话本质:把所有叶子当作一堆独立的树,反复取出权值最小的两棵合并成一棵新树, 新树的权是两者之和,直到只剩一棵树。

算法步骤(n 个叶子,共需 n − 1 次合并):

  1. 把 n 个权值 w1, w2, …, wn 看成 n 棵只有一个根结点的树,组成森林 F。
  2. 从 F 中选出根结点权值最小的两棵树,作为左、右子树构造一棵新树,新树的根权值为两者之和。
  3. 从 F 中删除这两棵树,把新树加入 F。
  4. 重复 2、3,直到 F 中只剩一棵树。这棵树就是赫夫曼树。

下面用权值集合 {2, 3, 4, 7, 8, 9}(总和 33)完整手推一遍。 请务必自己先算一遍再看表,这个例子在考卷上出现的频率极高。

步骤当前森林中的树根权值(已排序)取出的两个最小合并得到本步增加的 WPL累计 WPL
初始2, 3, 4, 7, 8, 90
4, 7, 8, 92, 35+55
7, 8, 94, 59+914
9, 97, 815+1529
159, 918+1847
(只剩一棵)15, 1833+3380
两个必须掌握的验算技巧 技巧一:WPL = 所有「合并出来的新结点」权值之和。 上表最后一列 5 + 9 + 15 + 18 + 33 = 80,就是最终的 WPL。 为什么?因为每个叶子每被合并一次,它就离根更远一层, 合并时的「和」恰好等于「这一层所有相关叶子的权值之和」,累加起来正好是 Σwili这个技巧能让你在考场上 30 秒算出 WPL,不必画完整棵树。
技巧二:交叉验算。把最终树画出来,逐个叶子数深度: a(2) 深度 4、b(3) 深度 4、c(4) 深度 3、d(7) 深度 2、e(8) 深度 3、f(9) 深度 3,于是 WPL = 2×4 + 3×4 + 4×3 + 7×2 + 8×3 + 9×3 = 8 + 12 + 12 + 14 + 24 + 27 = 80 ✓ 两种算法结果一致,说明构造过程没有出错。

下面动画逐帧演示这五步合并,并实时累加 WPL。注意观察「每次合并后森林中权值的排序变化」。

考点:赫夫曼树的三个「不」与一个「唯一」
  • 赫夫曼树不唯一:当有两个权值相同的结点时,谁在左谁在右都可以,得到的是不同的树。 例如把 2 放在左边还是右边,是两棵不同的赫夫曼树。
  • WPL 唯一:无论怎么选(只要每次都取最小的两个),最终 WPL 都相同,都是最小值。 这就是「赫夫曼树不唯一,但 WPL 唯一」的含义。
  • 没有度为 1 的结点:由构造过程可知,每次合并都是两个结点合成一个父结点, 所以赫夫曼树中不存在度为 1 的结点(n1 = 0)。 于是对它用性质 3 就得到:n 个叶子的赫夫曼树共有 2n − 1 个结点 (因为 n2 = n − 1,加上 n 个叶子 = 2n − 1)。这个结论必背。
  • 权值都在叶子上:赫夫曼树的非叶结点的权只是「合并出来的和」, 在编码问题里它们不承载字符,所以算 WPL 时不参与

7.8.3 为什么赫夫曼树是最优的

严格的证明要用「贪心选择性质 + 最优子结构」的交换论证,这里给出直观而不失严谨的说明。 先看两条关于最优树的显然性质:

  1. 权值越大的叶子离根越近。 反证:如果存在两个叶子 x、y 满足 wx > wy 但 depth(x) > depth(y), 把两个叶子的位置对调,WPL 的变化量是 wx·depth(y) + wy·depth(x) − wx·depth(x) − wy·depth(y) = (wx − wy)(depth(y) − depth(x)) < 0, 即 WPL 变小了,与最优性矛盾。
  2. 最优树一定是一棵「满」的树,没有度为 1 的结点。 反证:若存在度为 1 的结点,把它的唯一孩子直接接到它的位置上,所有后代深度减 1,WPL 严格变小。

贪心选择性质:设 w1、w2 是最小的两个权值,则存在一棵最优树, 其中 w1 与 w2兄弟,且深度最大。
论证:由性质 1 与 2 可知,深度最大的叶子必然成对出现(否则会有度为 1 的结点), 取其中两个叶子 a、b,深度都是 L。交换论证:把 w1 换到 a 的位置、w2 换到 b 的位置, WPL 的变化量是 (w1 − wa)L + (w2 − wb)L = L[(w1 + w2) − (wa + wb)] ≤ 0, 因为 w1、w2 是最小的两个,所以 w1 + w2 ≤ wa + wb。 也就是说,把最小的两个放到最深处不会让结果变差,于是可以放心地先把它们合并

最优子结构:合并 w1、w2 得到新权值 w' = w1 + w2 之后, 原问题变成了「用 {w', w3, …, wn} 这 n − 1 个权值构造最优树」的同型子问题WPL(原) = WPL(子问题) + w1 + w2。 因为 w1 + w2 是固定开销,最小化原 WPL 等价于最小化子问题的 WPL。 于是对子问题继续贪心即可,这正是一个标准的贪心 + 归纳结构。

直观理解:为什么「每次取最小两个」是对的 把合并想象成「给叶子买鞋」:每合并一次,被合并的那些叶子就集体往下走一层, 代价是「它们的总权值」。既然往下走是不可避免的,那就让权值最小的那些先下去、多下去, 权值大的尽量少走。最小的两个一定走到最深,这个直觉与交换论证的结论完全一致。

7.8.4 赫夫曼编码:让常用字符用短码

赫夫曼树最著名的应用是数据压缩。想法非常自然:一篇文章里不同字符出现的频率差别极大 (英文里 e 出现得最多,z 最少),如果所有字符都用同样长度的编码(如 ASCII 的 8 位), 那就是对高频字符的浪费。要让总长度最短,就该让高频字符用短编码、低频字符用长编码—— 这恰好就是「构造一棵 WPL 最小的二叉树」!

前缀编码
一组编码中,任何一个编码都不是另一个编码的前缀。 例如 {0, 10, 110, 111} 是前缀编码;而 {0, 01, 011} 不是(0 是 01 的前缀)。
为什么前缀编码能唯一译码
译码时从左往右逐位读:一旦读到的位串等于某个字符的编码,就立刻确定这个字符, 然后从头开始读下一段。因为没有任何编码是别人的前缀,所以中途绝不会出现「读到的这一段既可能是 A 也可能是 B 的前半截」的歧义, 译码路径唯一。
赫夫曼编码
以字符出现频率为权值构造赫夫曼树,令左分支为 0、右分支为 1, 则从根到每个叶子路径上的 0/1 序列就是该字符的编码。它一定是前缀编码—— 因为字符只放在叶子上,任何一条从根到叶的路径都不会是另一条路径的前缀。
平均码长
编码一个字符的平均位数 = WPL / Σwi(总权值)。 对赫夫曼编码,这个平均值是所有前缀编码中最小的

回到刚才的例子:字符 a~f 的频率分别是 2, 3, 4, 7, 8, 9,总频率 33。 把 7.8.2 节构造出的赫夫曼树每个左分支标 0、右分支标 1,从根走到叶子,就得到编码表。 下表把两种编码的总位数放在一起对比(等长编码需要 ⌈log2 6⌉ = 3 位)。 请注意:这里约定「权值相同时,先被合并出来的那棵树作左孩子」, 这样左右不再随意,编码表就是唯一确定的(这也是 7.8.5 节要讲的「唯一化」问题)。

字符频率 w赫夫曼编码码长 l贡献 w × l等长编码(3 位)等长贡献 3w
a21110480006
b311114120019
c411031201012
d70021401121
e80121610024
f91021810127
合计(= WPL)80合计99
WPL = 8 + 12 + 12 + 14 + 16 + 18 = 80,与「内部结点权值之和」 5 + 9 + 15 + 18 + 33 = 80 完全一致 ✓
平均码长 = 80 / 33 ≈ 2.42 位/字符; 等长编码总长 = 33 × 3 = 99 位; 赫夫曼编码只需 80 位,节省 19 位 ≈ 19.2%。

这里有一个必须专门讲清楚的细节:为什么频率最低的 a、b 拿到了 4 位码,而频率居中的 d、e、f 只有 2 位? 因为这棵树的形状是「一边深、一边浅」:合并过程 2+3=5 → 4+5=9 → 7+8=15 → 9+9=18 → 15+18=33 中, a、b 最早被合并,所以在树里被「压」到了最深处(第 5 层,码长 4); 而 7、8、9 这三棵子树直到最后才被合并,离根最近(第 3 层,码长 2)。 这正是 7.8.1 节那条规律的体现:权越大的叶子离根越近。 但请注意一个反直觉的现象:d(7)、e(8)、f(9) 的权值互不相同,码长却都是 2—— 码长只由树形决定,并不严格随频率单调,千万不要用「按频率排序」去猜码长

把这个编码表用起来看看效果。假设原文一共 33 个字符(a 出现 2 次、b 3 次、c 4 次、d 7 次、e 8 次、f 9 次):

赫夫曼编码题的三个「必查项」绝不能凭感觉给码长。必须把树画出来、从根往下逐叶数深度。 码长只由树形决定:权值最小的两个叶子一定在最深层附近,权值最大的叶子一定在最浅层附近, 但码长并不严格随频率单调——本节的 d(7)、e(8)、f(9) 码长完全相同就是例子。
用两种方法交叉验算 WPLΣ wi li(按叶子) 与 Σ 内部结点权值(按合并结果)必须一致。 本节 8+12+12+14+16+18 = 80 ✓ 与 5+9+15+18+33 = 80 ✓ 完全吻合。
检查前缀性质:把所有编码两两比较,任何一个都不能是另一个的前缀。 更好的检查方法是「把这组编码还原成一棵树」: 如果某个编码是另一个的前缀,就说明有字符被放在了内部结点上,那一定不是合法的前缀编码, 更不可能是赫夫曼编码。

下面动画演示赫夫曼编码的生成过程:先构造树,再沿树从根到叶读出每个字符的 0/1 编码, 最后把赫夫曼编码总位数与等长编码总位数放在一起比较。

7.8.5 规范赫夫曼编码与「按字典序的赫夫曼编码」

赫夫曼编码有一个工程上的小麻烦:它不唯一。左右孩子可以互换,导致编码不同; 解码方如果没有拿到编码表,就无法解码。解决办法是约定一套标准规则, 让双方各算各的也能算出完全一样的编码。这就是规范赫夫曼编码(canonical Huffman code)

规范编码的两条规则(工程与竞赛中通用的约定):

  1. 码长分配规则:先算出每个字符的码长(这一步用赫夫曼算法,只保留长度信息,不关心左右)。
  2. 字典序赋值规则:按 (码长, 字符) 排序—— 先按码长从小到大排,码长相同的按字符的字典序排; 然后依次赋码:
    • 第一个(最短码长中的最小编号字符)赋 全 0
    • 设当前码长为 L、当前编码为 c,则下一个字符的编码是 把 c 加 1(作为 L 位二进制数),再在末尾补 0 直到长度等于它的码长
    • 若下一个字符的码长与当前相同,就只加 1 不补 0

用我们的例子演示:字符 a~f 的频率是 2, 3, 4, 7, 8, 9, 由赫夫曼算法得到的码长是 a=4, b=4, c=3, d=2, e=2, f=2注意 e、f 的码长是 2 而不是 3——这就是为什么必须先老老实实跑一遍赫夫曼算法, 不能用「频率排序」去猜码长)。按 (码长, 字符) 排序后依次赋码:

步骤字符码长 L操作得到的规范编码
1d2第一个(最短码长中最小的字符)→ 赋全 000
2e2码长相同 → 只加 1:00 + 101
3f2码长相同 → 只加 1:01 + 110
4c3码长变大 → 10 + 1 = 11,末尾补 0 到 3 位110
5a4码长变大 → 110 + 1 = 111,末尾补 0 到 4 位1110
6b4码长相同 → 只加 1:1110 + 11111
规范赫夫曼编码:d=00, e=01, f=10, c=110, a=1110, b=1111
WPL = 7×2 + 8×2 + 9×2 + 4×3 + 2×4 + 3×4 = 14 + 16 + 18 + 12 + 8 + 12 = 80 ✓ 与赫夫曼编码完全一致

有意思的是,这一组规范编码与 7.8.4 节表格里的赫夫曼编码一模一样。这不是巧合: 7.8.4 节约定的「权值相同时先合并出来的作左孩子、左 0 右 1」, 恰好使字典序小的字符拿到了字典序小的编码。但如果把左右子树互换, 得到的编码就变成 a=0001, b=0000, c=001, d=11, e=10, f=01—— WPL 仍然是 80(最优),但编码已经乱序了。规范编码的价值就在这里: 它不依赖「谁在左谁在右」,只要双方用同一份码长表,就一定算出同一份编码。 这正是 DEFLATE(zip / gzip / PNG 用的压缩格式)等真实压缩格式的做法: 传输时只传「每个字符的码长」,编码本身由规范规则在两端各自算出来。

考点:什么时候需要「唯一」的编码? 考试与竞赛里常见的三问:
「求赫夫曼编码」:如果题目没有额外规定,只需给出任意一种合法结果,或直接给码长 + WPL。 因为赫夫曼树不唯一,编码也不唯一,但 WPL / 平均码长唯一
「求字典序最小的赫夫曼编码」:这是国内教材与 OJ 的常见约定。做法有两条常见口径,务必看清题目:
  • 口径 A(最常见):构造时若有权值相同的树,优先合并权值相同中「最小字符」较小的那两棵; 并且约定权值小的作为左孩子。这样得到的树本身是唯一的,编码也唯一。
  • 口径 B:先不管左右算出码长,再用上面的规范赫夫曼编码规则按 (码长, 字符) 字典序赋值。
两种口径得到的编码可能不同,但码长分布与 WPL 一定相同——这也是所有这类题目的「保底分数点」: 就算左右放反了,只要码长对,WPL 就对。
「已知各字符编码,判断是否为赫夫曼编码」:判断方法是 「把编码当成叶子建立前缀树,看这棵树是否满足:所有内部结点的权值等于其孩子权值之和, 且不存在度为 1 的结点,且每个内部结点的权值都 ≤ 同层其它候选」——更简单的判法是 直接由编码反推构造过程,看每一步能否取到最小的两个。实践中常用 Kraft 不等式 Σ 2−li ≤ 1 先做必要条件过滤。

7.8.6 构造赫夫曼树 + 生成编码的完整实现

实现要点有三条:① 用小根堆(priority_queuegreater每次取最小的两个, 复杂度 O(n log n);② 编码用从叶子往上回溯的方式生成(避免从根递归传字符串); ③ 因为要判断「左 0 右 1」,回溯时要记录当前结点是双亲的左孩子还是右孩子,所以结点里要存一个 parent 字段——写成数组版就是双亲的下标 t[i].par

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   赫夫曼树 + 赫夫曼编码(竞赛写法:静态数组建树 + 小根堆合并)
   ------------------------------------------------------------
   结点用数组 t[] 存:前 n 个是叶子,后面是合并出来的 n-1 个内部结点
       t[i].w    权值(叶子是频率;内部结点是两个孩子权值之和)
       t[i].l/r  左 / 右孩子下标(-1 表示没有)
       t[i].par  双亲下标(-1 表示根)—— 专门为了「从叶子往上回溯」求编码
       t[i].ch   该叶子对应的字符(内部结点无意义)

   合并顺序:每次取权值最小的两棵树合并成新结点,用小根堆维护,
             共合并 n-1 次,复杂度 O(n log n)。
   两个重要结论(算出来一定相等,本例都是 80):
       ① WPL = Σ 叶子权值 × 叶子深度(也就是码长)
       ② WPL = 所有非叶(内部)结点权值之和   ← 边合并边累加即可,竞赛最常用
   ============================================================ */

const int N = 5005;                  /* 叶子数上限:结点总数 2N-1 不超过数组 */
struct Node {
    int  w;                          /* 权值 */
    int  l, r;                       /* 左、右孩子下标,-1 表示空 */
    int  par;                        /* 双亲下标,-1 表示根 */
    char ch;                         /* 该叶子对应的字符 */
};
Node t[2 * N];
int  n;                              /* 叶子个数 */

/* 小根堆里存 (权值, 结点下标):按权值升序取最小;
   权值相同时按下标升序,保证每次合并的结果可复现 */
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;

/* 建树:chars / w 是 n 个叶子的字符与频率,返回根的下标 */
int buildHuffman(const char chars[], const int w[]) {
    while (!pq.empty()) pq.pop();
    for (int i = 0; i < n; ++i) {
        t[i].w = w[i]; t[i].l = t[i].r = t[i].par = -1; t[i].ch = chars[i];
        pq.push(make_pair(t[i].w, i));
    }
    int tot = n;                             /* 下一个可用的内部结点下标 */
    while (pq.size() > 1) {                  /* 一共合并 n-1 次 */
        int a = pq.top().second; pq.pop();   /* 先取出的是较小的 → 做左孩子(左 0 右 1) */
        int b = pq.top().second; pq.pop();
        t[tot].w = t[a].w + t[b].w;
        t[tot].l = a; t[tot].r = b; t[tot].par = -1; t[tot].ch = 0;
        t[a].par = t[b].par = tot;           /* 孩子记住双亲,方便往上回溯 */
        pq.push(make_pair(t[tot].w, tot));
        ++tot;
    }
    return tot - 1;                          /* 最后合并出来的就是根 */
}

/* 从叶子往上回溯到根求编码:左 0 右 1。
   回溯拿到的是「从下往上」的序列,最后要反转一次。
   (从根 DFS 往下传字符串也行,但回溯法不用递归传参,代码更短) */
string codeOf(int leaf) {
    string s;
    int cur = leaf;
    while (t[cur].par != -1) {
        int p = t[cur].par;
        s += (t[p].l == cur) ? '0' : '1';
        cur = p;
    }
    reverse(s.begin(), s.end());
    return s;
}

/* 法一:WPL = Σ 权值 × 深度(叶子顺着 par 往上数深度) */
long long wplByLeaves() {
    long long sum = 0;
    for (int i = 0; i < n; ++i) {
        int d = 0, cur = i;
        while (t[cur].par != -1) { ++d; cur = t[cur].par; }
        sum += 1LL * d * t[i].w;
    }
    return sum;
}

/* 法二:WPL = 所有内部结点(非叶结点)权值之和 */
long long wplByInternal(int root) {
    long long sum = 0;
    for (int i = n; i <= root; ++i) sum += t[i].w;
    return sum;
}

/* 只求 WPL 时连树都不用建:小根堆里合并 n-1 次,把每次的合并结果加起来 */
long long wplByHeap(int w[]) {
    priority_queue<int, vector<int>, greater<int>> q;
    for (int i = 0; i < n; ++i) q.push(w[i]);
    long long wpl = 0;
    while (q.size() > 1) {
        int a = q.top(); q.pop();
        int b = q.top(); q.pop();
        wpl += a + b;                        /* 技巧:WPL = 所有合并结果之和 */
        q.push(a + b);
    }
    return wpl;
}

int main() {
    const char ch[6] = { 'a', 'b', 'c', 'd', 'e', 'f' };
    int w[6] = { 2, 3, 4, 7, 8, 9 };
    n = 6;

    int root = buildHuffman(ch, w);

    int totalW = 0;
    for (int i = 0; i < n; ++i) totalW += w[i];

    int totalBits = 0;
    for (int i = 0; i < n; ++i) {
        string code = codeOf(i);
        int bits = (int)code.size() * w[i];
        totalBits += bits;
        printf("%c  频率 %d  编码 %s  码长 %d  贡献 %d\n",
               ch[i], w[i], code.c_str(), (int)code.size(), bits);
    }

    int equalLen = 3;                                    /* ceil(log2 6) = 3 位等长编码 */
    printf("赫夫曼总位数 = %d\n", totalBits);
    printf("等长编码总位数 = %d\n", totalW * equalLen);
    printf("WPL(按叶子) = %lld   WPL(按内部结点) = %lld   WPL(小根堆直算) = %lld\n",
           wplByLeaves(), wplByInternal(root), wplByHeap(w));       /* 都是 80 */
    printf("平均码长 = %.4f\n", (double)totalBits / totalW);        /* 80/33 ≈ 2.4242 */
    return 0;
}

如果题目只要求码长而不要求具体编码,还有个经典的 O(n log n) 双数组优化写法: 先把权值排序,然后用两个队列——一个存原始叶子(已排序),一个存合并出的新结点(天然有序)—— 每次从两个队列的队头里挑出较小的两个。这样就把堆的 log 因子变成了纯线性扫描。

#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;

/* ============================================================
   赫夫曼 WPL 的 O(n log n) 写法:排序 + 两个队列
   关键观察:合并产生的新结点权值是"单调不减"的,
   所以不需要堆,用两个队列的队头比较就能每次拿到最小值。
   (q1 存原始叶子,已排序;q2 存合并出的新结点,天然有序)
   ============================================================ */
long long huffmanWPL(vector<int> w) {
    sort(w.begin(), w.end());                        // 唯一的 O(n log n) 来自排序
    queue<long long> q1, q2;
    for (int x : w) q1.push(x);

    auto pickMin = [&]() -> long long {              // 从两个队头里取最小的
        long long v;
        if (q1.empty()) { v = q2.front(); q2.pop(); return v; }
        if (q2.empty()) { v = q1.front(); q1.pop(); return v; }
        if (q1.front() <= q2.front()) { v = q1.front(); q1.pop(); }
        else                          { v = q2.front(); q2.pop(); }
        return v;
    };

    long long wpl = 0;
    int n = w.size();
    for (int i = 0; i < n - 1; ++i) {                // 共合并 n-1 次
        long long a = pickMin();
        long long b = pickMin();
        wpl += a + b;                                // 技巧 1:WPL = 所有合并结果之和
        q2.push(a + b);
    }
    return wpl;
}

int main() {
    vector<int> w = { 2, 3, 4, 7, 8, 9 };
    cout << "WPL = " << huffmanWPL(w) << "\n";       // 80
    vector<int> w2 = { 5, 7, 2, 3, 11 };
    cout << "WPL = " << huffmanWPL(w2) << "\n";      // 见 7.13 节自测题第 4 题
    return 0;
}

7.9 并查集:用双亲表示法的森林管理集合

7.9.1 为什么树还能用来「分组」

并查集(disjoint set union, DSU)解决的是这样一类问题: 有 n 个元素,需要支持两种操作—— 合并两个集合(union)和查询两个元素是否属于同一集合(find)。 典型场景:判断无向图中的连通性、Kruskal 最小生成树(第 09 讲)、 亲戚关系、网络连通、等式约束的传递闭包。

表示方法非常巧妙:每个集合用一棵树表示,整片森林就是一个并查集; 树的就是该集合的代表元,树中每个结点只存双亲的下标—— 这正好就是 7.2.1 节的双亲表示法!于是:

下面动画演示并查集的两个关键优化。路径压缩find 顺路把经过的结点直接挂到根上, 按秩合并让矮树挂到高树上,两者合起来把单次操作的均摊复杂度压到 O(α(n))—— α 是反阿克曼函数,对任何现实中的 n(哪怕 n 是 1080)都不超过 5,可以认为是常数

7.9.2 完整实现:路径压缩 + 按秩合并

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   并查集(Disjoint Set Union,DSU)—— 竞赛标准写法
   ------------------------------------------------------------
   用「双亲表示法」的森林表示若干个集合:fa[i] == i 说明 i 是根(集合代表元)
       find(x)   :沿 fa 一路往上走到根;顺路把路径上所有点直接挂到根
                   → 路径压缩(递归一行搞定)
       unite(a,b):先 find 到两个根,再把一棵树挂到另一棵下面
                   → 按大小合并(小树挂到大树上)
                     或 按秩合并(矮树挂到高树上),两者等价,任选其一
   两个优化都用上,单次操作的均摊复杂度 O(α(n)):α 是反阿克曼函数,
   对任何现实中的 n(哪怕 10^80)都不超过 5,可以认为是常数。
   ============================================================ */

const int N = 100005;              /* 元素个数上限 */
int fa[N];                         /* 双亲下标:fa[i] == i 说明 i 是根 */
int siz[N];                        /* 只对根有意义:集合的元素个数(按大小合并用) */
int rnk[N];                        /* 只对根有意义:树高的上界(按秩合并用) */
int sets;                          /* 当前集合个数 */

/* 初始化:n 个元素各自成一个集合
   易错点:别忘了 siz[i] = 1,否则按大小合并会把新集合当成空集合 */
void init(int n) {
    for (int i = 1; i <= n; ++i) { fa[i] = i; siz[i] = 1; rnk[i] = 0; }
    sets = n;
}

/* ---------- 查:路径压缩(递归版,竞赛最常用的一行写法) ----------
   把查找路径上所有结点的双亲都直接改成根,树被「压扁」,后续查找几乎 O(1) */
int find(int x) {
    return fa[x] == x ? x : fa[x] = find(fa[x]);      /* ★ 递归赋值 = 路径压缩 */
}

/* ---------- 查:路径压缩(迭代版,避免深树时递归爆栈) ---------- */
int findIter(int x) {
    int root = x;
    while (fa[root] != root) root = fa[root];         /* 第一趟:先找到根 */
    while (fa[x] != root) {                           /* 第二趟:把路径上的点全挂到根 */
        int nxt = fa[x];
        fa[x] = root;
        x = nxt;
    }
    return root;
}

/* ---------- 并:按大小合并(小树挂到大树上) ---------- */
void unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return;                             /* 已经在同一集合,无需合并 */
    if (siz[ra] < siz[rb]) swap(ra, rb);              /* 保证 ra 是较大的那个根 */
    fa[rb] = ra;                                      /* rb 挂到 ra 下面 */
    siz[ra] += siz[rb];
    --sets;
}

/* ---------- 并:按秩合并(矮树挂到高树上,另一种等价优化) ---------- */
void uniteByRank(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return;
    if (rnk[ra] < rnk[rb]) swap(ra, rb);
    fa[rb] = ra;
    if (rnk[ra] == rnk[rb]) ++rnk[ra];                /* 秩相同时,合并后树高 +1 */
    --sets;
}

/* 判两个元素是否属于同一集合(Kruskal 判环用的就是它) */
bool same(int a, int b) { return find(a) == find(b); }

int main() {
    init(8);                                          /* 8 个元素 1..8 */
    unite(1, 2);
    unite(3, 4);
    unite(2, 4);                                      /* {1,2,3,4} 合成一个集合 */
    unite(5, 6);
    unite(7, 8);

    printf("各元素的双亲: ");
    for (int i = 1; i <= 8; ++i) printf("%d ", fa[i]);
    printf("\n");              /* 注意 4 还挂在 3 下面:合并时只把"根"挂到根上 */

    for (int i = 1; i <= 8; ++i) find(i);  /* 对全部元素做一次 find:顺路压缩 */
    printf("再次压缩后: ");
    for (int i = 1; i <= 8; ++i) printf("%d ", fa[i]);
    printf("\n");                          /* 4 的双亲也直接变成了根,树被压扁 */

    printf("1 和 4 同集合? %s\n", same(1, 4) ? "是" : "否");   /* 是 */
    printf("1 和 5 同集合? %s\n", same(1, 5) ? "是" : "否");   /* 否 */
    printf("当前集合个数 = %d\n", sets);                       /* 3 */

    /* 换成「按秩合并」做同样的事,结果完全一样:两种优化可以互换 */
    init(8);
    uniteByRank(1, 2); uniteByRank(3, 4); uniteByRank(2, 4);
    uniteByRank(5, 6); uniteByRank(7, 8);
    printf("按秩合并: 集合个数 = %d, 1 和 4 同集合? %s\n",
           sets, same(1, 4) ? "是" : "否");                   /* 3, 是 */

    printf("迭代版 findIter(4) = %d(与 find 结果相同,且不递归)\n", findIter(4));
    return 0;
}
实现方式单次 find 最坏n 次操作总计说明
朴素(直接挂)O(n)(退化成链)O(n²) union 时永远把 y 挂到 x 下,遇到「1 并 2、2 并 3、3 并 4…」就成了一条链
只加按秩合并O(log n)O(n log n) 树高被限制在 log n,因为每次只有秩相同时高度才增加
只加路径压缩均摊 O(log n)O(n log n) 单次可能 O(n),但均摊下来很好
两者都用均摊 O(α(n))O(n α(n)) ≈ O(n) α 是反阿克曼函数,n < 1080 时 α(n) ≤ 4,实际就是常数
易错:并查集的三个细节
  1. 路径压缩会破坏「树的父子关系」语义。压缩之后 fa[x] 不再是 x 在原来那棵树里的双亲, 而只是「指向根的跳板」。所以并查集只能回答「是否同集合」, 不能用来求「原树中的深度 / 父亲是谁 / 两点距离」。
  2. 路径压缩与「按秩合并」的秩会失真。压缩后树高变小,但 rnk 数组不会同步更新—— 这没关系,rnk 从此只作为「合并时的一个启发式上界」,不影响正确性。
  3. 递归版 find 在极端情况下会爆栈。虽然路径压缩让树很浅,但如果从不做路径压缩、 只做 unite,或者一开始构造了长链,第一次 find 仍可能递归很深。 保险起见可以写迭代版(上面的 findIter),或者手动加大栈。

7.9.3 它为什么在这里出现:为 Kruskal 铺路

并查集在本课程里最重要的用途,是第 09 讲的 Kruskal 最小生成树算法。 Kruskal 的做法是「把所有边按权值从小到大排序,依次尝试加入, 若这条边的两个端点已经在同一个连通块里,就跳过(否则会成环), 否则加入」。这里「两个端点是否已在同一个连通块」正是 same(u, v), 「加入这条边」正是 unite(u, v)

用一个具体的片段感受一下它有多简洁(完整版在第 09 讲给出):

#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
using namespace std;

/* 并查集(双亲表示法 + 路径压缩 + 按秩合并):算法与 7.9.2 节完全一致,
   这里只是把 fa / rnk 装进 struct,方便当成参数在函数间传递 */
struct DSU {
    vector<int> parent, rnk;
    explicit DSU(int n) : parent(n), rnk(n, 0) { iota(parent.begin(), parent.end(), 0); }
    int find(int x) { return parent[x] == x ? x : (parent[x] = find(parent[x])); }
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;
        if (rnk[rx] < rnk[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rnk[rx] == rnk[ry]) ++rnk[rx];
        return true;
    }
};

struct Edge { int u, v, w; };

/* Kruskal 的骨架:排序 + 并查集判环,O(E log E) */
long long kruskal(int n, vector<Edge> edges, int& usedEdges) {
    sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) { return a.w < b.w; });
    DSU d(n);
    long long mst = 0;
    usedEdges = 0;
    for (const Edge& e : edges) {
        if (d.unite(e.u, e.v)) {        // 不在同一集合 → 这条边安全,加入生成树
            mst += e.w;
            ++usedEdges;
            if (usedEdges == n - 1) break;   // n 个点的生成树恰好 n-1 条边
        }
    }
    return mst;
}

int main() {
    /* 4 个点、5 条边的最小生成树:答案 6(边 0-1 w=1、1-2 w=2、2-3 w=3) */
    vector<Edge> es = { {0,1,1}, {1,2,2}, {2,3,3}, {0,3,10}, {1,3,7} };
    int used = 0;
    cout << "最小生成树权值 = " << kruskal(4, es, used)
         << ",用了 " << used << " 条边\n";
    return 0;
}
考点:并查集 + 树的两个身份 请注意并查集在概念上的「两层身份」:
逻辑上,它是一个「集合的集合」(等价类划分);
实现上,它是一片用双亲表示法存储的森林——每个集合是一棵树,根是代表元。
所以本章的两大主线在这里汇合:7.2.1 的存储结构解决了「怎么表示」, 7.1 的树的基本概念提供了「根 / 双亲 / 路径」这套术语, 而路径压缩这个技巧则同时用到了「路径」和「双亲指针可以随意改写」这两个性质。 这也是「为什么树这一章要放在图论之前」的直接答案。

7.10 C++ 实战:坑与写法

树这一章的代码写起来不难,但「能过编译」和「能跑对」之间隔着一堆坑。 下面这些是本讲配套实验里出现频率最高的错误,请逐条对照自己的代码检查。

坑 1:递归深度过大导致栈溢出(Stack Overflow) 二叉树的递归遍历空间复杂度是 O(树高)。对完全二叉树,树高是 log n,毫无压力; 但如果输入是一棵斜树(例如单调递增的序列依次插入),树高就是 n。 此时 n = 100000 的递归遍历会直接段错误——Windows 下默认栈只有 1 MB 左右, 每层递归栈帧按 48~64 字节算,大约 1.5 万层就崩。
对策:① 数据规模大且树可能退化时,改用非递归遍历(显式栈在堆上,容量大得多); ② 或者在算法层面保证平衡(第 10 讲的 AVL / 红黑树); ③ 竞赛里可以手动开大栈(Linux 下 ulimit -s,或把递归改成迭代)。
注意:std::stack 也在堆上,所以「深度 100 万的斜树」用显式栈是安全的,用递归是不安全的。
坑 2:new 与 delete 不配对,内存泄漏 树的结点都是 new 出来的,每个 new 都要有对应的 delete。 释放整棵树必须用后序遍历
#include <iostream>
using namespace std;

/* 为了让这段示例可以独立编译,先给出结点定义。
   实际工程里它来自公共头文件。 */
struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ✗ 错误:先删了根,就再也找不到它的孩子了 → 后序之外的顺序都会漏删 */
void destroyWrong(TreeNode* r) {
    if (!r) return;
    delete r;                  // 灾难:子树指针随 r 一起没了,内存泄漏
}

/* ✓ 正确:后序 —— 先删左右子树,最后删自己 */
void destroy(TreeNode* r) {
    if (!r) return;
    destroy(r->left);
    destroy(r->right);
    delete r;
}

int main() {
    /* 手工搭一棵 A(B, C) 的小树,然后正确地释放它 */
    TreeNode* r = new TreeNode('A');
    r->left = new TreeNode('B');
    r->right = new TreeNode('C');

    destroy(r);                // ✓ 用后序释放,不会有任何泄漏
    // destroyWrong(r);        // ✗ 千万别这么写:B、C 两块内存会永远丢失
    cout << "released\n";
    return 0;
}
现代 C++ 里更推荐用智能指针std::unique_ptr<TreeNode>), 或者干脆用 vector 静态数组存结点(竞赛与 OJ 的标准做法,见 7.8.6 的赫夫曼实现)。 后者还有一个额外好处:数组下标可以当结点 ID 用,彻底告别悬空指针
坑 3:指针悬空(dangling pointer) 三种典型场景:
delete p 之后没有把 p 置空,后面又 if (p) 判断并通过( delete 不会把指针变成 nullptr),接着访问 p->val 就是未定义行为。 习惯:delete p; p = nullptr; 永远成对写。
② 函数返回了局部变量的地址(如返回 TreeNode node;&node)。
③ 「浅拷贝」问题:默认拷贝构造只是复制指针,两个对象析构时对同一块内存 delete 两次, 直接崩溃。对策:需要拷贝的树请自己写深拷贝(递归复制每个结点),或者禁用拷贝构造。
坑 4:层序遍历忘记判空 最常见的崩法:
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

struct TreeNode {
    char      val;
    TreeNode* left;
    TreeNode* right;
    explicit TreeNode(char v) : val(v), left(nullptr), right(nullptr) {}
};

/* ✗ 错误:root 为空时还会执行一次 p->val,空指针解引用 → 崩溃 */
vector<char> bad(TreeNode* root) {
    vector<char> out;
    queue<TreeNode*> q;
    q.push(root);                     // 把 nullptr 也塞进去了
    while (!q.empty()) {
        TreeNode* p = q.front(); q.pop();
        out.push_back(p->val);        // ← 空指针解引用!
        if (p->left)  q.push(p->left);
        if (p->right) q.push(p->right);
    }
    return out;
}

/* ✓ 正确:进函数先判空;也不要把 nullptr 压进队列 */
vector<char> good(TreeNode* root) {
    vector<char> out;
    if (!root) return out;            // ★ 第一行就判空
    queue<TreeNode*> q; q.push(root);
    while (!q.empty()) {
        TreeNode* p = q.front(); q.pop();
        out.push_back(p->val);
        if (p->left)  q.push(p->left);
        if (p->right) q.push(p->right);
    }
    return out;
}

int main() {
    cout << good(nullptr).size() << "\n";   // 0:空树安全返回
    // bad(nullptr);                        // ✗ 一旦对空树调用就会崩溃
    TreeNode* r = new TreeNode('A');
    r->left = new TreeNode('B');
    r->right = new TreeNode('C');
    for (char c : good(r)) cout << c;       // ABC
    cout << "\n";
    return 0;
}
注意「把 nullptr 也压进队列」是个更隐蔽的变体:不会立刻崩, 但会让后续逻辑(比如判断完全二叉树时的计数)全部错乱。
坑 5:完全二叉树下标 0 基与 1 基混用 这是本章最「杀人于无形」的坑,因为它不会崩,只是算错:
#include <iostream>
#include <vector>
using namespace std;

/* 1 基公式用在 0 基数组上 —— 编译能过,结果全错 */
int leftChild1Based(int i)  { return 2 * i; }        // 只对 tree[1..n] 成立
int parent1Based(int i)     { return i / 2; }        // 只对 tree[1..n] 成立

/* 0 基版本 */
int leftChild0Based(int i)  { return 2 * i + 1; }
int rightChild0Based(int i) { return 2 * i + 2; }
int parent0Based(int i)     { return (i - 1) / 2; }  // i=0 时得 0(根自己)

/* ★ 最稳的写法:干脆空出 tree[0],从 1 开始存,与教材公式完全一致。
   此时双亲 = i/2,左孩子 = 2i,右孩子 = 2i+1,绝不会记混。 */
vector<int> heap(1);          // heap[0] 空着不用

int main() {
    /* 用 0 基数组存 {A,B,C,D,E,F,G} 这棵完全二叉树 */
    vector<char> tree = { 'A','B','C','D','E','F','G' };
    int n = tree.size();

    /* 用 0 基公式找 D(下标 3) 的双亲和孩子 */
    int i = 3;
    cout << "D 的双亲 = " << tree[parent0Based(i)] << "\n";        // B
    if (leftChild0Based(i) < n)  cout << "左孩子 " << tree[leftChild0Based(i)] << "\n";
    if (rightChild0Based(i) < n) cout << "右孩子 " << tree[rightChild0Based(i)] << "\n";

    /* 反例:把 1 基公式用在同一个 0 基数组上,结果就错了 */
    cout << "误用 1 基公式得到 D 的双亲 = " << tree[parent1Based(i)] << "\n";   // 错!得到 C

    /* 想要 1 基语义,就老老实实空出 heap[0] */
    heap = { 0, 'A','B','C','D','E','F','G' };       // heap[1..7] 才是数据
    cout << "1 基写法下 heap[3] = " << (char)heap[3]
         << ",其双亲 heap[3/2] = " << (char)heap[3 / 2] << "\n";       // B
    return 0;
}
建议:如果一个程序里既要写堆(1 基)又要写遍历(0 基), 就在变量名里带上 idx0 / idx1, 并在每个用到处写一行注释标明基准,别指望自己记得住。
坑 6:线索化时顺着线索递归(会导致死循环) 7.6.3 节已经详细讲过,这里再强调一次:线索化后 left / right 可能已经不是孩子指针,判断「有没有孩子」必须用 ltag / rtag,不能用指针是否为空。 同理,在线索树上求深度、求结点数时,如果按普通二叉树那样递归,就会顺着线索绕圈, 最终 栈溢出或死循环。线索树上的算法要专门写。
坑 7:赫夫曼树里把内部结点也算进 WPL WPL 的定义是「所有叶子的带权路径长度之和」。 赫夫曼树的内部结点权值是合并出来的和,它们不是叶子,不参与 WPL 计算。 有人看到「所有结点权值之和」刚好等于一个好看的数就当成 WPL, 那就错了。正确的两种算法是:Σ(叶子权 × 叶子深度)Σ(所有内部结点的权值)——注意后者的求和范围是内部结点,不是全部结点。
坑 8:把「结点的深度」和「树的高度」当成一回事 写递归求深度时,空树的深度必须返回 0(不是 −1,也不是 1),叶子返回 1。 用 1 + max(depth(left), depth(right)) 时,如果空树返回 −1, 叶子就变成 0 了,整棵树的高度会少算 1。先定义清楚再写代码: 本课程约定「空树深度 0、只有根的树深度 1」。
易错点错误写法 / 错误理解正确做法
递归深度对可能退化成斜树的输入用递归遍历 改用显式栈的非递归遍历,或保证树平衡;显式栈在堆上,容量远大于系统栈
内存释放delete 根再找孩子 后序释放:先递归删左右子树,最后删自己
悬空指针delete p; 后继续用 p delete p; p = nullptr; 成对写;需要拷贝就写深拷贝
层序遍历不判空就把 root 压进队列 函数第一行 if (!root) return {};,且不要往队列里压 nullptr
下标基准0 基数组上套用 1 基公式 2i / i/2 0 基用 2i+1 / 2i+2 / (i−1)/2;或干脆空出 tree[0] 用 1 基
线索化递归if (p->right) 判断有无右孩子 必须用 if (p->rtag == 0),否则会顺着线索绕圈
WPL 计算把所有结点的权值都乘深度相加 只有叶子参与;或等价地累加所有内部结点(合并结果)的权值
深度定义空树深度返回 −1 或 1 约定「空树 0、单结点 1」,并在整个项目里保持一致
遍历判据用「先序 + 后序」还原二叉树 必须要有中序;只有先序 + 后序时答案有 2m

7.11 工程视角:树结构在真实系统里撑起了什么

前面十节我们把树从定义一路做到了并查集。你可能会问:这些看起来像是为考试才设计出来的结构, 在真实系统里到底出现在哪?答案是几乎无处不在,只是藏在你平时看不见的地方。 这一节把本章的每种树都对号入座,并对每个落点回答三个问题: 用的是什么结构 → 为什么非它不可 → 代价是什么

7.11.1 数据库索引:为什么是 B+ 树,而不是二叉搜索树

先记住两个数量级,它们决定了这一小节的每一个结论。

操作典型耗时说明
一次内存访问约 100 ns(10−7 秒) CPU 直接在内存里取一个字节
一次磁盘随机读约 10 ms(10−2 秒) 含寻道与旋转延迟;SSD 约 0.1 ms,仍比内存慢 3 个数量级

两者相差 105 倍,即 5 个数量级内存读十万次,才抵得上磁盘读一次。 所以索引优化的从来不是比较次数,而是磁盘 I/O 次数——第 01 讲的 O(log n) 到了磁盘上, 还要再乘一个「每次读几次盘」的系数。

二叉搜索树(BST)每次比较排除一半数据,O(log2 n),确实漂亮。但放到磁盘上, 它的问题不是「矮」,而是「瘦」:一个结点只存 1 个键 + 2 个指针,在 4 KB 的页里 只占十几个字节却独占一页——读一个结点 = 一次磁盘 I/O,而这一次 I/O 只换来 1 个键。于是:

1 亿行数据:树高 ≈ log2(108) ≈ 27 层
→ 最坏 27 次随机磁盘 I/O ≈ 27 × 10 ms ≈ 270 ms,只为查一行

这 27 个结点在磁盘上东一个西一个,每次都要重新寻道。更糟的是:若插入的是有序主键又没有平衡机制, BST 会退化成斜树(7.10 的坑 1),27 层变成 1 亿层——崩溃的东西从函数栈换成了系统响应时间。

B+ 树(B-plus tree)的解法是把「瘦」改成「胖」:让一个结点就等于一个磁盘页。 这一句话解释了它的全部设计:

① 二叉搜索树:4 层 → 查一次最坏 4 次磁盘 I/O ② B+ 树:2 层 → 查一次只要 2 次磁盘 I/O 50 25 75 37 12 62 30 I/O 1 I/O 2 I/O 3 I/O 4 红色是查 30 的路径:4 个结点 = 4 次 I/O 每个结点独占一页,页利用率不到 1% 50 150 250 根页(第 1 次 I/O) 51237 506288 150180240 250300400 查 180:第 1 次 I/O 读根页,第 2 次读第 3 个叶子(绿色) 一个结点 = 一个磁盘页(4 KB / 16 KB),一页能塞 300 多个键 → 扇出从 2 变成 300+,树高从二十多层压到 3~4 层 叶子用链表串起来:范围查询顺着它顺序扫 同样查一条记录:左边 4 次随机 I/O ≈ 40 ms,右边 2 次 ≈ 20 ms;数据量再翻 340 倍,左边树高 +9 层,右边只 +1 层。
图 7-13 二叉搜索树与 B+ 树的树高、磁盘 I/O 次数对比:结点越「胖」,树越矮,读盘越少

下面这段代码不实现 B+ 树的插入删除,只按页内布局算出「一个结点能装多少个键」, 再由此推出「几层能撑多少行」——「树高 3 层撑住几千万行」就是这么几个除法的结果。

// btree_page_layout.cpp —— 一个页能装多少个键?树高为什么只有 3~4 层?
#include <iostream>
#include <cmath>
using namespace std;

/* B+ 树的结点在磁盘上就是一个「页」,即一次磁盘 I/O 读进来的单位。
   页内布局:[ 页头 ][ 键 ]…[ 键 ][ 孩子指针 ]…[ 孩子指针 ] */
struct PageLayout {
    int pageBytes;    // 页大小:4KB = 4096,InnoDB 默认 16KB
    int headerBytes;  // 页头(页号、校验和等)开销
    int keyBytes;     // 一个键占几字节(int 主键 = 4)
    int ptrBytes;     // 一个孩子指针占几字节(64 位 = 8)
    int rowBytes;     // 叶子层行指针占几字节
};

/* 内部结点:K 个键 + (K+1) 个孩子指针 + 页头 ≤ pageBytes
   → K ≤ (pageBytes − headerBytes − ptrBytes) / (keyBytes + ptrBytes)
   K 是「一页放几个键」,孩子个数 K + 1 就是扇出。 */
int internalKeys(const PageLayout& p) {
    int rest = p.pageBytes - p.headerBytes;
    return (rest - p.ptrBytes) / (p.keyBytes + p.ptrBytes);
}

/* 叶子结点:只有键 + 行指针,没有孩子指针 */
int leafRows(const PageLayout& p) {
    return (p.pageBytes - p.headerBytes) / (p.keyBytes + p.rowBytes);
}

// 按亿 / 万打印
void show(const char* name, long long rows) {
    if (rows >= 100000000)
        cout << name << " = " << rows << " 行 ≈ " << rows / 100000000
             << " 亿 " << rows % 100000000 / 10000 << " 万行\n";
    else
        cout << name << " = " << rows << " 行 ≈ " << rows / 10000 << " 万行\n";
}

int main() {
    PageLayout p4k  = { 4096,  16, 4, 8, 8 };   // 4KB 页
    PageLayout p16k = { 16384, 16, 4, 8, 8 };   // 16KB 页

    int k4 = internalKeys(p4k);
    int f4 = k4 + 1;                            // 扇出 = 孩子个数
    int rows4 = leafRows(p4k);
    cout << "【4KB 页】最多 " << k4 << " 个键、扇出 " << f4
         << ",叶子最多 " << rows4 << " 行\n";
    show("  3 层", (long long)f4 * f4 * rows4);
    show("  4 层", (long long)f4 * f4 * f4 * rows4);

    int k16 = internalKeys(p16k);
    int f16 = k16 + 1;
    int rows16 = leafRows(p16k);
    cout << "【16KB 页】最多 " << k16 << " 个键、扇出 " << f16
         << ",叶子最多 " << rows16 << " 行\n";
    show("  3 层", (long long)f16 * f16 * rows16);

    /* 二叉搜索树呢?它每个结点只有 2 个孩子,树高 ≈ log2(行数)。 */
    long long rows = (long long)f4 * f4 * rows4;
    int h = (int)ceil(log2((double)rows));
    cout << "二叉搜索树存 " << rows << " 行要 " << h
         << " 层 → 最坏 " << h << " 次 I/O\n";
    cout << "B+ 树只要 3 次。按一次随机 I/O 10ms 算:"
         << h * 10 << "ms  vs  30ms\n";
    cout << "I/O 次数由树高决定,树高由扇出决定 —— 这就是 B+ 树「矮」的秘密。\n";
    return 0;
}

结论:4 KB 页 + int 主键 → 一个内部结点 339 个键、扇出 340 → 3 层索引 3930 万行; 换成 16 KB 页扇出 1364,3 层撑到 25 亿行;而同样 3930 万行,二叉搜索树要 26 层。 真实索引「只有 3~4 层」不是数据少,而是每一层都被撑得极宽——把「应该很快吧」变成 「3 次 I/O、30 毫秒」这样能对账的数字,正是工程判断力的来源。

第 02 讲留了一个问题:为什么主键索引既不用链表也不用顺序表?现在可以回答了:

候选结构点查 WHERE id = 5范围查询 BETWEEN插入 / 删除为什么不做磁盘索引
有序顺序表(第 02 讲)页内折半很快,但比较分散在 log n 个页上 好(区间连续)O(n) 次移动 = 大量页被改写 插入一行要挪半个表,表大了放不下
链表(第 02 讲)O(n) 次结点访问,几乎每次都是随机 I/O 差(得先走到起点)O(1)(已走到位置时) 查找退化成全表扫描
哈希表(第 10 讲)O(1),最快不支持(哈希打散了顺序) O(1)做不了 BETWEEN / ORDER BY / >
B+ 树O(log扇出 n) ≈ 3~4 次 I/O 强项:叶子链表顺序扫O(log n),只改一两个页 ——(它就是答案)
B 树与 B+ 树:区别只在「数据放哪一层」 B 树(B-tree)同样是多路平衡查找树,结点同样做成页,树高同样很低。区别在于: B 树的每个结点都存数据,B+ 树只有叶子层存数据、内部结点全是路标。后果有两个:
① B+ 树的内部结点不含数据,同样一页能装更多键 → 树更矮、I/O 更少;
② B+ 树的叶子连成链表,范围查询可以顺序扫;而 B 树的数据散落在各层, 范围查询只能靠中序遍历在树里上下跳,随机 I/O 一片一片。 数据库负载里范围查询占大头,所以 B+ 树胜出;而目录索引这种「点查多、改动频繁」的场景, B 树变体依然常见。

7.11.2 文件系统:目录树与 inode

Linux 里没有「文件夹」这个数据结构,只有一棵树:根是 /,目录是分支结点,文件是叶子。 cd /home/user/a.txt 就是在树上从根走一条路径:/homeusera.txt。7.1.1 那句「从根到结点的路径唯一」,在工程里就是绝对路径相对路径则是从当前结点出发的一条向下路径。

更关键的是 inode:ext4 里目录项只保存「名字 → inode 号」,真正的元数据(大小、权限、时间戳、 数据块地址)都在 inode 里。翻译成本章的术语:inode 是树的结点,目录项是指向结点的指针。 而每个目录都有的 ...,恰好就是 7.2.1 双亲表示法里的双亲指针 (. 指向自己,.. 指向双亲)。7.9 的并查集用的是同一套表示法: 「找根」就是沿双亲指针向上爬——你在 shell 里敲 cd .., 和 find(x) 里那句 x = parent[x],是同一个动作。 (顺带一提,硬链接就是两个目录项指向同一个 inode。)

7.11.3 并查集:不只是 Kruskal 的配角

7.9.3 说过,并查集在本课程里的主要用途是第 09 讲 Kruskal 最小生成树算法判环: 边按权值排序后依次取出,两端点已属同一集合就丢弃(会成环),否则 union 并收下这条边。 整个算法 O(e log e),瓶颈在排序。

但它的真实工作量远不止于此:并查集擅长的始终是把元素合并成等价类,并随时回答 「这两个元素是不是同一类」。凡是能翻译成这句话的场景,它几乎都是最优解:

领域元素是什么union 的含义find 回答什么
图论(第 09 讲)顶点这条边把两个连通块接上了两端是否已连通(会不会成环)
编译器 / 静态分析变量、类型变量「x 与 y 是同一个对象」「类型 T1 = T2」是否同一等价类、类型是否冲突
寄存器分配临时变量活跃区间不重叠,可共用一个寄存器能否合并以省一条搬移指令
图像处理像素相邻且颜色相近的像素属于同一块两个像素是否同一连通区域
网络管理主机两台主机被判定在同一子网主机属于哪个子网(等价类的代表元)
账号清洗用户记录两条记录被确认是同一个人是否同一实体

图像处理里的用法值得多说一句。连通区域标记(connected-component labeling) 是二值图像分析的第一步(数细胞、数零件、OCR 切字都要它):第一趟给前景像素临时标号, 发现相邻两个标号其实属于同一块就 union 合并;第二趟把标号换成 find(标号),得到最终编号。 几百万个前景像素能顺畅跑完,全靠并查集近似 O(1) 的均摊代价(7.9.2)。

7.11.4 赫夫曼编码:ZIP、gzip、JPEG、MP3 的共同底座

7.8 节用贪心构造了 WPL 最小的树,得到最优前缀编码;它在工程里的名字就是 赫夫曼编码(Huffman coding),今天几乎所有常见压缩格式都在用它:

为什么敢用变长编码?因为赫夫曼树给出的是前缀码(prefix code)没有任何一个码字是另一个码字的前缀。这直接保证了解码无歧义—— 解码就是从第一个比特开始沿树从根往下走(约定左 0 右 1),走到叶子就输出一个符号、再回到根; 因为不会有码字停在内部结点上,所以不存在「读到一半不知道要不要继续」的歧义。 换个说法:前缀码把「切分比特流」变成了「在树上走到叶子」, 7.5 的遍历与 7.8.4 的编码表在这里合成了一个每秒几千万次的循环。

代价也很清楚:

  1. 码表必须传给解码方,所以压缩文件头部总要存一份(或像 JPEG、MP3 那样用标准表); 文件很小时,码表开销会盖过省下的空间。
  2. 只有频率偏斜时才划算:低频符号可能被拉长到十几比特,若 256 种字节几乎均匀出现 (已加密数据),平均码长接近 8 比特,收益几乎为零。
  3. 不能随机访问:第 k 个符号的位置取决于前面所有符号。DEFLATE 的对策是分块: 每 32~64 KB 一块、各自建表,牺牲一点压缩率换回「可以按块定位」。

7.11.5 堆与优先队列:调度器、定时器与最短路

二叉堆是本章内容的「特例组合」:它是完全二叉树(7.3.2),所以能用数组顺序存储(7.3.3), 下标满足 7.4.5 的公式;同时只维护一条比 BST 弱得多的序关系——每个结点不大于它的孩子。 这条「弱序」换来的是:取最值 O(1)、插入删除 O(log n),且常数极小、内存连续

工程上,堆几乎就是「优先队列」:

代价要记牢:堆只保证堆顶是最值,其余元素之间没有任何有序关系。所以它不擅长按序遍历、 查找任意元素(O(n))、求中位数。想要「有序 + 任意查找」,得回到平衡搜索树或第 10 讲的哈希表。

7.11.6 五种树的工程分工:一张表看清该选谁

把上面五个落点收拢成一张表。注意最后两列——「是否适合磁盘」和「是否支持范围查询」 才是选型的关键维度,而不是「查找复杂度」。

结构主要用途单次查找适合磁盘?范围查询典型真实系统
普通二叉树
(无顺序约束)
表达式树、赫夫曼树、语法树 O(n)(只能遍历) 不适合 不支持 编译器的语法树、浏览器 DOM、赫夫曼树
二叉搜索树 BST 内存里的小规模有序表 平均 O(log n),最坏 O(n)(退化成斜树) 不适合(一次 I/O 只换 1 个键) 支持(中序遍历即有序) 工程里基本已被平衡树取代
平衡搜索树
(AVL / 红黑树)
内存中的有序容器 严格 O(log n) 不适合(结点太小) 支持(可做区间查询) C++ std::map/set、Java TreeMap、Linux 内核 CFS 与内存管理
B+ 树 磁盘上的有序索引 O(log扇出 n),即 3~4 次磁盘 I/O 适合(结点 = 磁盘页,一页几百个键) 强项:叶子链表顺序扫 MySQL InnoDB、PostgreSQL、SQLite、ext4 / NTFS 的目录索引
二叉堆 只要「最值」,不要全序 取最值 O(1),插入删除 O(log n);查任意元素 O(n) 不适合(外部排序用它做多路归并) 不支持(只有堆顶有序) 调度器与定时器、优先队列(Dijkstra / Prim)、std::priority_queue、堆排序

怎么记?一句话:约束越弱,代价越小;想要什么能力,就得付出对应的结构复杂度。 普通二叉树什么都不保证,所以什么都快不了;加「左小右大」成了 BST,却会退化成斜树; 再加「平衡」是红黑树,代价是插入删除要旋转;把结点放大成磁盘页就是 B+ 树, 用页内查找换树高骤降;堆放弃「有序」只留「最值」,于是在自己的赛道上无可替代。 没有最好的结构,只有最匹配操作集合的结构

考点:一句话回答「为什么数据库索引用 B+ 树,不用二叉搜索树」 磁盘 I/O 次数由树高决定,树高由结点的扇出决定:二叉搜索树每个结点只有 2 个分支, 几千万行数据树高二十多层,就要二十多次随机 I/O(每次约 10 ms,比内存慢 5 个数量级); B+ 树把一个结点做成一个磁盘页,一页塞几百个键,树高压到 3~4 层,3~4 次 I/O 就能定位任意一行; 再加上数据全在叶子层、叶子用链表串起来,范围查询顺着链表顺序扫——这正是 B+ 树胜过 B 树的关键。

7.12 本章小结

树这一章的内容看起来很多,但骨架非常清楚:一套术语 + 三种存储 + 五条性质 + 四种遍历 + 三大应用。 下面把最需要记住的东西压成几张表。

7.12.1 术语与结构速查

概念一句话定义关键数字 / 性质
n 个结点的有限集,n > 0 时有唯一根,其余结点分成互不相交的子树集合 n 个结点的树有 n − 1 条边;任意两点间路径唯一;无环
结点的度孩子个数树的度 = max(所有结点的度)
叶子 / 分支结点度为 0 / 度不为 0赫夫曼树中没有度为 1 的结点
层次 / 深度 / 高度层次从 1 数;深度 = 层次;高度 = 到最远叶子的边数 整棵树的深度 = 高度 = 最大层次
路径长度路径上的条数从根到第 k 层结点,路径长度 = k − 1
双亲表示法每个结点存 parent 下标找双亲 O(1);并查集用它
孩子表示法每个结点挂一条孩子链找孩子 O(度);求度 O(1)
孩子兄弟表示法左指针 = 第一个孩子,右指针 = 下一个兄弟 把树变成二叉树;空指针 n + 1 个

7.12.2 二叉树性质与公式速查

结论公式适用前提
第 i 层最多结点数2i−1二叉树(i ≥ 1)
深度 k 最多结点数2k − 1二叉树;取等号 ⇔ 满二叉树
叶子与双分支结点n0 = n2 + 1任何非空二叉树
m 次树的推广n0 = 1 + Σ(度 − 1)·n任何非空树
完全二叉树深度⌊log2 n⌋ + 1 = ⌈log2(n+1)⌉仅完全二叉树
完全二叉树下标(1 基)双亲 ⌊i/2⌋、左 2i、右 2i + 1完全二叉树(层序编号)
完全二叉树下标(0 基)双亲 ⌊(i−1)/2⌋、左 2i+1、右 2i+2完全二叉树(C++ 数组)
完全二叉树中度为 1 的结点n 为偶数时有 1 个,n 为奇数时有 0 个完全二叉树
赫夫曼树结点总数2n − 1(n 个叶子)赫夫曼树(无度为 1 的结点)
WPLΣ wi li = Σ 内部结点权值只算叶子 / 只累加内部结点

7.12.3 复杂度与算法对比

算法 / 操作时间空间关键点
前 / 中 / 后序遍历(递归)O(n)O(h),h 为树高,最坏 O(n)空间取决于树高而非结点数
前 / 中序非递归O(n)O(h)显式栈在堆上;中序要把左链一路压栈
后序非递归O(n)O(h)(双栈法 O(h))双栈法 = 「根右左」再反转;或单栈配 last 指针
层序遍历(BFS)O(n)O(w),w 为最大宽度,最坏 O(n)队列;分层时先固定 q.size()
由先序 + 中序还原O(n)O(n)先序定根、中序分左右;用桶数组(或哈希表)把「找根」从 O(n) 降到 O(1)
中序线索化O(n)O(h)(递归栈)递归右子树前必须判 rtag
线索树上找前驱 / 后继O(1) 或 O(h)O(1)有线索直接用;否则后继找右子树最左下
赫夫曼树构造(堆)O(n log n)O(n)小根堆 + 静态数组 + 从叶子回溯出编码
赫夫曼 WPL(排序 + 双队列)O(n log n)O(n)合并结果天然有序,可省掉堆
并查集 find / union均摊 O(α(n)) ≈ O(1)O(n)路径压缩 + 按大小(或按秩)合并,两者缺一不可(缺一个退化到 O(log n) 或更差)

7.12.4 一句话总结每一节

7.13 考点清单与自测题

考点清单(按出现频率排序)
  1. 性质 3 的证明与应用(n0 = n2 + 1):几乎每张卷子都有, 要会写「总边数算两遍」的证明,也要会拿它做计算题。
  2. 四种遍历序列的相互推导:给树写序列、给两种序列还原树、给序列反推另一种序列。 这一块必须能手推,不能只靠程序。
  3. 完全二叉树的判定与下标计算:给编号问关系、给结点数问深度 / 叶子数、判断某序列是否可能是完全二叉树的层序。
  4. 赫夫曼树的构造与 WPL 计算:从合并过程到编码表到总位数对比,一条龙都要会。
  5. 树的存储结构与转换:孩子兄弟表示法、树 / 森林 / 二叉树的相互转换、遍历对应结论。
  6. 遍历的非递归实现:后序非递归是重点(两种写法都要能写出来)。
  7. 遍历的应用:求深度、求叶子数、判断完全二叉树(层序 + 标记法)几乎每年都考代码填空。
  8. 线索二叉树:为什么线索化、ltag/rtag 的含义、中序线索树找前驱后继的规则。
  9. 并查集:路径压缩的作用与复杂度,常以「Kruskal 判环」的形式出现。

7.13.1 自测题(六道,答案折叠)

第 1 题(术语与性质):一棵树的度为 4,其中度为 1、2、3 的结点数分别是 8、10、2, 求这棵树的叶子结点数。

查看答案与解析

答案:15 个叶子。

解析:树的度是 4,说明还存在度为 4 的结点,设其个数为 x, 叶子数为 n0,总结点数为 n。两种做法都对:

做法一(用推广性质):由 7.1.3 节的恒等式「结点数 = 度数之和 + 1」, 以及「叶子数 = 1 + Σ(度 − 1)·n」,先算总度数:

总度数 = 8×1 + 10×2 + 2×3 + x×4 = 34 + 4x → n = 35 + 4x

又按结点分类:n = n0 + 8 + 10 + 2 + x = n0 + 20 + x。 两式联立:n0 + 20 + x = 35 + 4x → n0 = 15 + 3x。 题目没有给出度为 4 的结点个数 x,因此严格来说答案依赖于 x—— 这类题的标准出法是「度为 4 的结点有 2 个」之类,此时 n0 = 15 + 3×2 = 21

题目原意更正:如果题目说的是「度为 4 的树,度为 1、2、3、4 的结点数分别是 8、10、2、0」, 即最高度就是 3(树的度为 4 只是上界),那么 x = 0,n0 = 15考场对策:看到「树的度为 m」而没给 nm 时,先写出含 x 的通式, 再根据题目其它条件(总分枝数、总边数)定出 x;若题目只给度数分布,一般默认「树的度就是最大出现度数」, 即 nm ≥ 1 但未给出,此时应当用「叶子数 = 1 + Σ(度−1)n」直接算: n0 = 1 + 8×0 + 10×1 + 2×2 = 1 + 0 + 10 + 4 = 15

验算:n0 = 15,n = 15 + 8 + 10 + 2 = 35, 总度数 = 8 + 20 + 6 = 34 = n − 1 ✓(没有度为 4 的结点,树的度实际是 3)。

第 2 题(性质计算):判断下列数据是否可能存在,并说明理由:

  1. 一棵二叉树有 10 个结点,其中度为 2 的结点有 4 个;
  2. 一棵二叉树有 10 个结点,其中度为 2 的结点有 5 个;
  3. 一棵完全二叉树有 100 个结点,其中叶子有 51 个。
查看答案与解析

(1) 可能。由性质 3,n0 = n2 + 1 = 4 + 1 = 5; 于是 n1 = n − n0 − n2 = 10 − 5 − 4 = 1 ≥ 0,可行。 具体构造:根为 A,左子树是一棵 4 个结点的满二叉树、右子树是一棵 5 个结点的满二叉树。

(2) 不可能。由性质 3,n0 = 5 + 1 = 6, 于是 n1 = 10 − 6 − 5 = −1 < 0,矛盾。 (直观理解:度为 2 的结点太多,10 个结点养不起 6 个叶子。)

(3) 不可能。完全二叉树的 n1 只能是 0 或 1: 100 是偶数 → n1 = 1;由 n = n0 + n1 + n2 与 n0 = n2 + 1 得 100 = 2n2 + 2 → n2 = 49, n0 = 50,正确答案是 50 个叶子,不是 51。 顺带一提,它的深度 = ⌊log2 100⌋ + 1 = 6 + 1 = 7。

第 3 题(遍历与还原):已知一棵二叉树的先序序列是 ABDECF中序序列是 DBEACF。请还原这棵二叉树,并写出它的后序序列与层序序列。

查看答案与解析

还原过程(三步递归)

  1. 先序第一个是 A → 根是 A。在中序里找 A,位置下标 3(0 基), 于是左子树中序 = DBE(3 个结点),右子树中序 = CF(2 个结点)。
  2. 先序去掉 A 后是 BDECF;按左子树 3 个结点切分: 左子树先序 = BDE,右子树先序 = CF
  3. 左子树:先序 BDE + 中序 DBE → 根 B,中序里 B 左边是 D(左孩子), 右边是 E(右孩子)。所以 B 的左右孩子分别是 D、E。
  4. 右子树:先序 CF + 中序 CF → 根 C,中序里 C 左边为空(C 没有左子树), 右边是 F,所以 F 是 C 的右孩子

树形:A 的左孩子 B、右孩子 C;B 的左孩子 D、右孩子 E;C 没有左孩子,右孩子是 F。

后序(左右根)= D E B F C A → DEBFCA
层序(BFS)= A B C D E F → ABCDEF

易错提醒:F 是 C 的孩子!因为中序 CF 里 C 在 F 前面, 而中序是「左 根 右」,C 后面只剩 F,所以 F 只能是右子树的结点。 如果把 F 误当成左孩子,那么中序会变成 FC,与题目不符。

第 4 题(赫夫曼树与编码):字符 a~f 在文本中出现的次数依次是 2, 3, 4, 7, 8, 9。请构造赫夫曼树,求 WPL、每个字符的赫夫曼编码, 并与「3 位等长编码」比较总位数。

查看答案与解析

合并过程(每次取最小的两个):

2+3=5 → 4+5=9 → 7+8=15 → 9+9=18 → 15+18=33

WPL(两种算法交叉验算)

  • 按内部结点:5 + 9 + 15 + 18 + 33 = 80
  • 按叶子(各叶深度:a=4, b=4, c=3, d=2, e=2, f=2): 2×4 + 3×4 + 4×3 + 7×2 + 8×2 + 9×2 = 8 + 12 + 12 + 14 + 16 + 18 = 80

编码(一种合法结果,左 0 右 1):a=1110, b=1111, c=110, d=00, e=01, f=10。 注意编码不唯一(把任意结点的左右孩子互换都是合法的赫夫曼编码), 但码长分布与 WPL 唯一

与等长编码比较:6 个字符需要 ⌈log2 6⌉ = 3 位等长编码, 总位数 = 33 × 3 = 99;赫夫曼编码总位数 = WPL = 80。 节省 19 位,约 19.2%,压缩后是原来的 80.8%。

易错点:① 有人把 e 的码长也算成 3(因为它权值 8 比 9 小), 那样 WPL 会变成 87,与「内部结点求和」的 80 不符——用两种算法交叉验算是发现错误的最快方法。 ② 有人把内部结点也算进 WPL,得到一堆无意义的数。 ③ 忘了验证前缀性质。

第 5 题(树 ↔ 二叉树转换):已知一棵树 T 的先序遍历是 ABEFCGD, 后序遍历是 EFBGCDA。请画出这棵树,并写出它转换成的二叉树的前序与中序序列。

查看答案与解析

还原树 T:树的先序第一个是根 → A;后序最后一个也是根 → A ✓。 去掉 A 后,先序剩余 B E F C G D,后序剩余 E F B G C D。 我们需要把先序序列切成「各子树」的段。因为树的后序里每棵子树的根都排在它那一段的最后, 观察后序 E F B | G C | D 可以发现:B 出现在下标 2、C 出现在下标 4, 于是可以试着把 A 的孩子数量定为 3,对应的后序段是 EFBGCD, 对应先序段是 BEFCGD——每段的第一个字符就是该子树的根, 每段的最后一个字符(在后序里)也是同一个根,验证:B/B ✓、C/C ✓、D/D ✓。

树 T 的形状:A 有三个孩子 B、C、D(从左到右); B 有三个孩子 E、F(B 的子树先序 BEF、后序 EFB,说明 B 的孩子是 E、F); C 有一个孩子 G(先序 CG、后序 GC);D 没有孩子。 E、F、G 都是叶子。

验证:树的前序 = A, B, E, F, C, G, D = ABEFCGD ✓; 树的后序 = E, F, B, G, C, D, A = EFBGCDA ✓。

转换成二叉树后(左孩子右兄弟),由 7.7.2 节的两条结论直接得到:

二叉树的前序 = 树的前序 = ABEFCGD
二叉树的中序 = 树的后序 = EFBGCDA

不用背结论也能推:A 的左孩子是 B;B 的右孩子是 C、C 的右孩子是 D(兄弟链); B 的左孩子是 E、E 的右孩子是 F;C 的左孩子是 G。 对这棵二叉树做前序:A → B → E → F → C → G → D ✓; 做中序:E → F → B → G → C → D → A ✓。两条结论都对上了。

第 6 题(并查集):初始有 8 个元素 0~7,依次执行下列操作: union(0,1)union(2,3)union(1,3)union(4,5)union(6,7)。 请画出每一步之后的森林(用双亲数组表示),说明当前有几个集合, 并在最后一步执行 find(3) 后写出被路径压缩的结点。

查看答案与解析

初始parent = [0,1,2,3,4,5,6,7]rank = [0,0,0,0,0,0,0,0]8 个集合(每个元素自成一个集合)。

操作find 结果合并动作parent 数组集合数
union(0,1)0, 1rank 相同 → 1 挂到 0,rank[0] = 1 [0,0,2,3,4,5,6,7]7
union(2,3)2, 3rank 相同 → 3 挂到 2,rank[2] = 1 [0,0,2,2,4,5,6,7]6
union(1,3)0, 2rank 都是 1 → 2 挂到 0,rank[0] = 2 [0,0,0,2,4,5,6,7]5
union(4,5)4, 54 与 5 rank 都是 0 → 5 挂到 4 [0,0,0,2,4,4,6,7]4
union(6,7)6, 76 与 7 rank 都是 0 → 7 挂到 6 [0,0,0,2,4,4,6,6]3

最终 3 个集合:{0,1,2,3}(代表元 0)、{4,5}(代表元 4)、{6,7}(代表元 6)。

执行 find(3) 的路径压缩过程3 → parent[3] = 2 → parent[2] = 0 → parent[0] = 0(到根)。 查找路径是 3 → 2 → 0,路径压缩把路径上除根以外的结点(即 3 和 2) 的双亲直接改成根 0。压缩后:

parent 从 [0,0,0,2,4,4,6,6] 变成 [0,0,0,0,4,4,6,6]
集合 {0,1,2,3} 从「0 下面挂 1,1 下面挂 2、2 下面挂 3」的深度 2 的树, 变成了「0 下面直接挂 1、2、3」的深度 1 的星形

要点:① 路径压缩只改路径上结点的双亲,不改变集合的划分; ② 压缩后 parent[3] = 0 不再表示「3 的双亲是 0」这个原始树结构, 只表示「3 属于以 0 为代表的集合」——这就是 7.9.3 节强调的「语义被破坏」; ③ rank 数组在压缩后不再等于真实树高,它只是合并时的启发式依据, 不影响正确性

7.13.2 上机练习建议

光看不写是学不会树的。建议按下面的顺序把本章代码亲手敲一遍(每道都在 30 分钟以内):

序号任务用到的本节代码验收方式
1用三种方式建出图 7-9 的树,并打印四种遍历序列 7.3.6 三个建树工具 + 7.5.4 递归遍历 输出必须都是 ABDECFG / DBEAFCG / DEBGFCA / ABCDEFG
2把三种遍历都改成非递归版,与递归版结果对比 7.5.5六种序列两两一致
3随机生成 1000 棵树,用它验证「先序 + 中序能唯一还原」 7.5.6还原后再遍历,序列与原树一致
4实现七个遍历应用函数,对随机树做单元测试 7.5.7与暴力枚举的结果一致
5实现中序线索化,并用 O(1) 空间完成中序遍历 7.6.3输出与递归中序一致
6实现赫夫曼树与编码,随机权值下用两种方法验算 WPL 7.8.6两种 WPL 恒等;编码两两互不为前缀
7实现并查集,用「随机 union + 随机询问」对拍暴力版本 7.9.2询问结果与暴力 BFS 连通性判定一致
下一讲预告 下一讲把「一对多」升级成「多对多」:。你会看到邻接矩阵与邻接表两种存储结构, 还会发现本章的两个工具会在那里大放异彩——并查集用于 Kruskal 判环, BFS / DFS 的层次思想直接来自本章的层序遍历。 换句话说,把这一章的遍历写熟,图的两种搜索你就已经会了一半。