第 4.0 章 树

Tree

从线性结构到层级结构

线性表、栈和队列描述“一前一后”的关系。

树描述“一对多”的层级关系。

典型场景:

  • 文件系统目录
  • 组织结构
  • 表达式语法树
  • 数据库索引
  • 编译器抽象语法树
  • Huffman 编码树

学习目标

  • 掌握普通树和二叉树的定义
  • 掌握根、父子、兄弟、叶、度、层次、高度等术语
  • 理解二叉树的基本性质和完全二叉树数组公式
  • 掌握二叉树的链式、数组和游标表示
  • 掌握先序、中序、后序、层序遍历
  • 根据先序+中序或后序+中序构造二叉树
  • 理解树、森林与二叉树的转换
  • 理解线索二叉树和 Huffman 树的核心思想

路线图

G tree 普通树 binary 二叉树 tree->binary property 性质与存储 binary->property traversal 遍历 property->traversal build 构造 traversal->build forest 树/森林转换 build->forest thread 线索树 forest->thread huffman Huffman 树 thread->huffman

普通树定义

\(T\) 是结点的有限集合。

树可以为空。

若树非空,则存在一个特殊结点 \(r\),称为根;其余结点被划分为若干棵互不相交的非空子树:

\[ T_1,T_2,\ldots,T_k \]

递归定义是树结构的核心。

树的术语

线性结构与树结构

维度 线性结构 树结构
关系 一对一前后关系 一对多父子关系
入口 通常是表头或栈顶
基本移动 前驱/后继 父、孩子、兄弟
典型遍历 从左到右 深度优先或广度优先

二叉树定义

二叉树是有限结点集合。

当二叉树非空时:

  • 有一个根结点
  • 其余结点分成左子树和右子树
  • 左子树和右子树也都是二叉树

左、右子树有顺序,因此只有左孩子和只有右孩子是不同结构。

二叉树与普通树的区别

维度 普通树 二叉树
孩子数 任意多个 最多两个
子树是否有序 常视为无序 左右有序
空子树 通常不显式出现 左空与右空要区分
存储方式 孩子链表、长子兄弟 左右孩子指针

表达式二叉树

G plus + mul * plus->mul div / plus->div a a mul->a b b mul->b c c div->c d d div->d

该树表示:

\[ (a*b)+(c/d) \]

二叉树性质

二叉树边数性质

任何含 \(n>0\) 个结点的二叉树都有:

\[ n-1 \]

条边。

原因:除根结点外,每个结点恰好有一条来自父结点的边。

第 i 层结点上界

根在第 \(0\) 层时,第 \(i\) 层最多有:

\[ 2^i \]

个结点。

高度为 \(h\) 的二叉树最多有:

\[ 1+2+\cdots+2^h=2^{h+1}-1 \]

个结点。

叶结点与二度结点

设二叉树中:

  • \(n_0\):度为 0 的结点数
  • \(n_1\):度为 1 的结点数
  • \(n_2\):度为 2 的结点数

则:

\[ n_0=n_2+1 \]

证明关键是边数:

\[ n-1=n_1+2n_2 \]

又有:

\[ n=n_0+n_1+n_2 \]

满二叉树与完全二叉树

满二叉树:高度为 \(h\),且结点数达到最大值:

\[ 2^{h+1}-1 \]

完全二叉树:按层从左到右编号时,结点编号连续,没有中间空洞。

完全二叉树是堆结构的基础。

完全二叉树数组公式

若使用 0-based 编号:

\[ parent(i)=\left\lfloor \frac{i-1}{2}\right\rfloor \]

\[ left(i)=2i+1,\quad right(i)=2i+2 \]

若使用 1-based 编号:

\[ parent(i)=\left\lfloor \frac{i}{2}\right\rfloor \]

\[ left(i)=2i,\quad right(i)=2i+1 \]

二叉树存储一:数组表示

数组表示适合完全二叉树。

若普通二叉树缺很多结点,数组表示会浪费空间。

结构 数组表示是否适合
完全二叉树 适合,公式简单
接近完全的二叉树 通常可接受
稀疏二叉树 空位过多,不适合

二叉树存储二:链式表示

template <class T>
struct BinaryNode {
  T data;
  BinaryNode* left = nullptr;
  BinaryNode* right = nullptr;

  explicit BinaryNode(const T& value) : data(value) {}
  BinaryNode(const T& value, BinaryNode* l, BinaryNode* r)
      : data(value), left(l), right(r) {}
};

链式表示不需要保存空位,适合一般二叉树。

二叉树存储三:游标表示

index data left right
0 A 1 -1
1 B 2 3
2 C -1 -1
3 D 4 5
4 E -1 6
5 F -1 -1
6 G -1 -1

游标表示用数组下标模拟指针。

运行时性能:树的表示会影响机器代价

树的渐近复杂度常写成 \(O(h)\)\(O(n)\),但真实运行还取决于结点如何放在内存里。

  • 数组表示连续,父子下标可直接计算,硬件预取和 cache locality 较好
  • 链式表示灵活,但指针跳转可能跨越多个 cache line
  • 游标表示把“指针”换成数组下标,能减少碎片并便于序列化
  • 递归遍历会使用调用栈;树高过大时可能接近栈空间限制

操作系统管理虚拟内存时,分散节点可能带来更多页访问;数据库和文件系统中的树结构通常会把多个 key 放入一个页内,以减少 I/O 次数。

BinaryTree 类接口

template <class T>
class BinaryTree {
 public:
  BinaryTree() = default;
  ~BinaryTree();

  bool empty() const { return root_ == nullptr; }
  void makeTree(const T& data, BinaryTree& left, BinaryTree& right);
  void breakTree(T& data, BinaryTree& left, BinaryTree& right);

  void preorder(void (*visit)(BinaryNode<T>*));
  void inorder(void (*visit)(BinaryNode<T>*));
  void postorder(void (*visit)(BinaryNode<T>*));
  void levelOrder(void (*visit)(BinaryNode<T>*));
  int height() const;

 private:
  BinaryNode<T>* root_ = nullptr;
};

MakeTree

template <class T>
void BinaryTree<T>::makeTree(const T& data,
                             BinaryTree<T>& left,
                             BinaryTree<T>& right) {
  root_ = new BinaryNode<T>(data, left.root_, right.root_);
  left.root_ = nullptr;
  right.root_ = nullptr;
}

把两棵树接到新根下,原来的两棵树不再拥有这些结点。

BreakTree

template <class T>
void BinaryTree<T>::breakTree(T& data,
                              BinaryTree<T>& left,
                              BinaryTree<T>& right) {
  if (root_ == nullptr) {
    throw std::underflow_error("empty tree");
  }
  data = root_->data;
  left.root_ = root_->left;
  right.root_ = root_->right;
  delete root_;
  root_ = nullptr;
}

拆分时也要明确所有权转移,避免重复释放。

二叉树遍历

递归先序遍历

template <class T>
void preorder(BinaryNode<T>* node) {
  if (node == nullptr) return;
  visit(node);
  preorder(node->left);
  preorder(node->right);
}

顺序:根、左子树、右子树。

递归中序遍历

template <class T>
void inorder(BinaryNode<T>* node) {
  if (node == nullptr) return;
  inorder(node->left);
  visit(node);
  inorder(node->right);
}

顺序:左子树、根、右子树。

递归后序遍历

template <class T>
void postorder(BinaryNode<T>* node) {
  if (node == nullptr) return;
  postorder(node->left);
  postorder(node->right);
  visit(node);
}

顺序:左子树、右子树、根。

层序遍历

template <class T>
void levelOrder(BinaryNode<T>* root) {
  if (root == nullptr) return;
  std::queue<BinaryNode<T>*> q;
  q.push(root);
  while (!q.empty()) {
    BinaryNode<T>* node = q.front();
    q.pop();
    visit(node);
    if (node->left != nullptr) q.push(node->left);
    if (node->right != nullptr) q.push(node->right);
  }
}

层序遍历的核心数据结构是队列。

非递归中序遍历

template <class T>
void inorderIterative(BinaryNode<T>* root) {
  std::stack<BinaryNode<T>*> st;
  BinaryNode<T>* cur = root;
  while (cur != nullptr || !st.empty()) {
    while (cur != nullptr) {
      st.push(cur);
      cur = cur->left;
    }
    cur = st.top();
    st.pop();
    visit(cur);
    cur = cur->right;
  }
}

栈保存“已经进入但尚未访问”的祖先结点。

树高

template <class T>
int height(BinaryNode<T>* node) {
  if (node == nullptr) return -1;
  return 1 + std::max(height(node->left), height(node->right));
}

这里约定空树高度为 \(-1\),单结点树高度为 \(0\)

若约定空树高度为 \(0\),所有结果整体加一。

由先序和中序构造二叉树

构造算法

BinaryNode<char>* build(const std::string& preorder,
                        int preL, int preR,
                        const std::string& inorder,
                        int inL, int inR) {
  if (preL > preR) return nullptr;
  char rootValue = preorder[preL];
  int rootPos = inorder.find(rootValue, inL);
  int leftSize = rootPos - inL;
  auto* root = new BinaryNode<char>(rootValue);
  root->left = build(preorder, preL + 1, preL + leftSize,
                     inorder, inL, rootPos - 1);
  root->right = build(preorder, preL + leftSize + 1, preR,
                      inorder, rootPos + 1, inR);
  return root;
}

先序给根,中序给左右子树边界。

哪些序列能唯一构造

已知序列 是否唯一 原因
先序 + 中序 根由先序确定,左右边界由中序确定
后序 + 中序 根由后序最后一个元素确定
先序 + 后序 通常否 无法区分只有左子树还是只有右子树
层序 + 中序 可逐层确定根并用中序切分

普通树的长子兄弟表示

普通树可以转成二叉树形式:

  • firstChild 指向第一个孩子
  • nextSibling 指向下一个兄弟
template <class T>
struct TreeNode {
  T data;
  TreeNode* firstChild = nullptr;
  TreeNode* nextSibling = nullptr;
};

这也称为孩子兄弟表示。

树转二叉树

转换规则:

  • 每个结点的第一个孩子变成左孩子
  • 每个结点的下一个兄弟变成右孩子

G A A B B A->B firstChild C C B->C nextSibling D D C->D nextSibling

森林转二叉树

森林可以看作若干棵树的根互为兄弟。

转换规则:

  • 每棵树先转成孩子兄弟二叉树
  • 第一棵树的根作为二叉树根
  • 其余树的根沿右孩子链连接

森林遍历可以转化为二叉树遍历。

普通树遍历

先根遍历:

  1. 访问根
  2. 依次先根遍历每棵子树

后根遍历:

  1. 依次后根遍历每棵子树
  2. 访问根

广度优先遍历使用队列。

线索二叉树动机

\(n\) 个结点的二叉链表有 \(2n\) 个孩子指针域。

实际边数只有 \(n-1\)

因此空指针数为:

\[ 2n-(n-1)=n+1 \]

线索二叉树用这些空指针保存遍历序列中的前驱或后继。

中序线索树

中序线索树中:

  • left == nullptr,可让 left 指向中序前驱
  • right == nullptr,可让 right 指向中序后继
  • 需要额外标志位区分孩子指针和线索指针
enum class Tag { Child, Thread };

template <class T>
struct ThreadNode {
  T data;
  ThreadNode* left = nullptr;
  ThreadNode* right = nullptr;
  Tag leftTag = Tag::Child;
  Tag rightTag = Tag::Child;
};

中序线索遍历

ThreadNode<int>* first(ThreadNode<int>* p) {
  while (p->leftTag == Tag::Child) {
    p = p->left;
  }
  return p;
}

ThreadNode<int>* next(ThreadNode<int>* p) {
  if (p->rightTag == Tag::Thread) {
    return p->right;
  }
  return first(p->right);
}

中序线索树遍历不需要递归,也不需要栈。

Huffman 树

带权外路径长度

设每个外结点权值为 \(w_i\),从根到该外结点的路径长度为 \(l_i\)

带权外路径长度:

\[ WPL=\sum_i w_i l_i \]

Huffman 树使 \(WPL\) 最小。

Huffman 算法

struct HuffmanNode {
  int weight;
  HuffmanNode* left = nullptr;
  HuffmanNode* right = nullptr;
};

HuffmanNode* buildHuffman(std::vector<int> weights) {
  auto cmp = [](HuffmanNode* a, HuffmanNode* b) {
    return a->weight > b->weight;
  };
  std::priority_queue<HuffmanNode*, std::vector<HuffmanNode*>, decltype(cmp)> pq(cmp);
  for (int w : weights) pq.push(new HuffmanNode{w});
  while (pq.size() > 1) {
    HuffmanNode* a = pq.top(); pq.pop();
    HuffmanNode* b = pq.top(); pq.pop();
    pq.push(new HuffmanNode{a->weight + b->weight, a, b});
  }
  return pq.top();
}

优先队列让每次取两个最小权值高效完成。

Huffman 编码

给左边标 0,右边标 1,从根到叶子的路径就是该字符编码。

Huffman 编码是前缀码:

  • 任意字符编码都不是另一个字符编码的前缀
  • 解码时从根沿 bit 走到叶子即可输出字符
  • 高频字符路径短,低频字符路径长

多维分析

问题 时间 空间 关键结构
二叉树遍历 \(O(n)\) 递归栈 \(O(h)\) 栈或递归
层序遍历 \(O(n)\) 队列最坏 \(O(n)\) 队列
先序+中序构造 \(O(n)\)\(O(n^2)\) \(O(h)\) 哈希表可优化定位
线索树遍历 \(O(n)\) \(O(1)\) 额外空间 线索指针
Huffman 构造 \(O(n\log n)\) \(O(n)\) 优先队列

练习 1:叶结点公式

题目:证明二叉树中 \(n_0=n_2+1\)

答案:

\[ n=n_0+n_1+n_2 \]

边数既等于 \(n-1\),也等于:

\[ n_1+2n_2 \]

因此:

\[ n_0+n_1+n_2-1=n_1+2n_2 \]

得到:

\[ n_0=n_2+1 \]

练习 2:完全二叉树最大结点数

题目:完全二叉树第 6 层有 8 个叶结点,根为第 1 层,结点数最多是多少?

答案:

第 6 层最多有 \(2^5=32\) 个位置。

若第 6 层有 8 个叶结点且结点数最多,则这些叶结点应尽量靠右,前面位置尽量成为内部结点并拥有第 7 层孩子。

第 1 到 6 层满时有:

\[ 2^6-1=63 \]

第 6 层非叶最多 \(32-8=24\) 个,每个可有两个第 7 层孩子。

最大结点数:

\[ 63+48=111 \]

练习 3:树的叶结点数

题目:一棵度为 4 的树中,度为 4、3、2、1 的结点数分别为 20、10、1、10,求叶结点数。

答案:

设叶结点数为 \(n_0\)

总边数等于结点数减一:

\[ 4\cdot20+3\cdot10+2\cdot1+1\cdot10=n_0+20+10+1+10-1 \]

左侧为 \(122\)

因此:

\[ 122=n_0+40 \]

\[ n_0=82 \]

练习 4:递归统计叶结点

template <class T>
int countLeaves(BinaryNode<T>* node) {
  if (node == nullptr) return 0;
  if (node->left == nullptr && node->right == nullptr) return 1;
  return countLeaves(node->left) + countLeaves(node->right);
}

每个结点访问一次,时间 \(O(n)\)

练习 5:交换左右子树

template <class T>
void mirror(BinaryNode<T>* node) {
  if (node == nullptr) return;
  std::swap(node->left, node->right);
  mirror(node->left);
  mirror(node->right);
}

该算法会把整棵二叉树变成镜像。

练习 6:Huffman 性质判断

对于权值互不相同的字符构成的 Huffman 树:

  • 不一定是完全二叉树
  • 一定没有度为 1 的结点
  • 两个权值最小的结点一定是兄弟
  • 非叶结点权值不一定不小于下一层任一结点权值

正确结论是第二条和第三条。