【数据结构】【学习笔记】二叉树
二叉树是一种很常用的数据结构,其非常适合计算机做递归、搜索和分治。在许多问题中都有所应用,比如在搜索问题中使用BST,在严格平衡搜索中使用AVL树,在工程中的Map/Set使用红黑树,在优先队列中使用堆,在数据库索引中使用B+树等。
在下文,从定义、特性、实现和具体题目来了解下二叉树。
⚙️定义
要认识二叉树的话,首先要认识树是什么。
💡树的定义
树是 \(n(n\geq0)\) 个结点的有限集合,满足:
- 若 \(n=0\),则为空树。
- 若 \(n>0\),则:
- 仅有一个根结点;
- 其余结点分成 \(m\) 个互不相交的集合 \(T_1,T_2,...,T_m\),其中 \(m≥0\);
- 每个 \(T_i\) 又是一棵树。
可以看出树是一种递归的定义。
那么二叉树则是每个节点至多只有2棵子树的树——即二叉树中不存在度大于2的节点,并且两个子节点有左右之分。
为了后续有更清晰的了解,这里解释下树的各个名词含义。
| 名称 | 含义 |
|---|---|
| 节点 | 树中最小的独立单元,在数据结构中包含一个数据元素和若干个指向其子树的分支。 |
| 节点的度 | 节点拥有的子树数量。 |
| 树的度 | 树中最大的节点度。 |
| 叶子 | 度为0的节点。 |
| 非叶子节点 | 度不为0的节点。 |
| 内部节点 | 除根节点以外的非叶子节点。 |
| 双亲和孩子 | 节点的子树的根为此节点的孩子,反之,此节点为节点的子树的根的双亲。 |
| 兄弟 | 同一个双亲的孩子节点互称兄弟。 |
| 祖先 | 从根到该节点所经过的分支上的所有节点。 |
| 子孙 | 以某节点为根的子树中的任一节点,都称为该节点的子孙。 |
| 层次 | 从根开始定义,根为第一层,根的孩子为第二层,依次类推叠加。即,\(\text{树中任一节点的层次} = \text{其双亲节点的层次}+1\)。 |
| 堂兄弟 | 双亲在同一层的节点互为堂兄弟。 |
| 树的深度/高度 | 树中节点的最大层次。 |
注:不同教材可能从 0 层开始计算,因此深度/高度的具体数值定义可能存在差异,做题时以题目定义为准。
拓展:
- 有序树:若树中节点的各子树从左到右是有次序的——即不可调换,则为有序树。否则为无序树。(和有序图无序图类似)
- 森林:是 \(m(m\geq0)\) 棵互不相交的树的集合。
🔎特性
遍历
前序遍历:根左右
中序遍历:左根右
后序遍历:左右根
1. 前序遍历
遍历顺序:根 \(\rightarrow\) 左 \(\rightarrow\) 右
前序遍历是自顶向下(Top-down)
适用场景:父节点的信息需要传递给子节点。
典型应用:树的序列化与反序列化、深拷贝一棵树、打印文件目录结构。
用栈辅助实现
function preOrderTraversal(root: TreeNode | null): number[] {
if (!root) return [];
const res: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
res.push(node.val);
// 注意:先压右后压左,这样出栈顺序就是先左后右
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return res;
}
2. 中序遍历
遍历顺序:左 \(\rightarrow\) 根 \(\rightarrow\) 右
适用场景:处理有顺序要求的树结构。
典型应用:常用于寻找 BST 中的第 \(k\) 小元素、验证二叉搜索树。
注:对二叉搜索树(BST)进行中序遍历,得到的结果必定是一个严格升序的有序数组。
指针一路向左一路压栈
function inOrderTraversal(root: TreeNode | null): number[] {
const res: number[] = [];
const stack: TreeNode[] = [];
let cur = root;
while (cur !== null || stack.length > 0) {
// 依次将左子节点全部压入栈
while (cur !== null) {
stack.push(cur);
cur = cur.left;
}
// 弹出并处理,然后转向右节点
cur = stack.pop()!;
res.push(cur.val);
cur = cur.right;
}
return res;
}
3. 后序遍历
遍历顺序:左 \(\rightarrow\) 右 \(\rightarrow\) 根
适用场景:必须先获取左右子树的计算结果,才能算出当前节点的结果。
典型应用:计算二叉树的最大深度/高度、计算子树节点和、求二叉树的最长路径、销毁/释放树节点内存(防止先删父节点导致子节点找不到)。
用一个栈和一个记录上一个节点的指针
function postorderTraversal(root: TreeNode | null): number[] {
const res: number[] = [];
const stack: TreeNode[] = [];
let cur = root;
let prev: TreeNode | null = null; // 记录上一次访问的节点
while (cur !== null || stack.length > 0) {
// 1. 一路向左,将所有左节点压栈
while (cur !== null) {
stack.push(cur);
cur = cur.left;
}
// 2. 看一眼栈顶节点(先不弹出)
const top = stack[stack.length - 1];
// 3. 如果右子树存在,且刚才没访问过,说明应该先去处理右子树
if (top.right !== null && top.right !== prev) {
cur = top.right;
} else {
// 4. 右子树处理完了(或不存在),访问当前节点并弹出
res.push(top.val);
prev = stack.pop()!; // 标记当前节点已访问
}
}
return res;
}
或者利用前序一样的方法,只不过把左右调转,并且结果反转一下
function postorderTraversal(root: TreeNode | null): number[] {
if (!root) return [];
const res: number[] = [];
const stack: TreeNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
res.push(node.val); // 先存根
// 注意:这里先压左、后压右,弹出的顺序就会变成“先右后左”
if (node.left) stack.push(node.left);
if (node.right) stack.push(node.right);
}
// 此时 res 里的顺序是 [根, 右, 左],反转后变为 [左, 右, 根]
return res.reverse();
}
🤓实现
template <typename T>
struct TreeNode {
T val;
TreeNode *left;
TreeNode *right;
TreeNode(T x) : val(x), left(nullptr), right(nullptr) {}
};
❔题目
1. 判断跟结点是否等于子结点之和
解题思路
很简单,这个只有3个节点——根、左孩子、右孩子。
那么,说明只要直接检查左孩子和右孩子之和是否等于根即可。
实现
function checkTree(root: TreeNode | null): boolean {
return root.left.val + root.right.val === root.val;
};
public class Solution {
public bool CheckTree(TreeNode root) {
return root.left.val + root.right.val == root.val;
}
}
class Solution {
public:
bool checkTree(TreeNode* root) {
return root->left->val + root->right->val == root->val;
}
};
复杂度分析
- 时间复杂度: \(O(1)\)
- 空间复杂度: \(O(1)\)
2. 根据描述创建二叉树
解题思路
要根据一个结构为[parent, child, isLeft]的数组,构造一个二叉树,其中,在数组中第i个元素,parent代表i的父节点,child代表i的子节点,isleft代表此节点是否是左孩子,1则是,否则不是。
原始思路:
先存数字关系,再递归建树。
但是实际上,在遍历数组时直接用哈希表实时创建 TreeNode* 并连线,一步到位最简单。
用值为key,节点为value的字典存放节点,可以快速获取节点,在返回结果时,也可以利用这点找到父节点来返回,不用考虑怎么存放一个不会移动的父节点。
核心思路两步走:
-
边遍历边建树:
- 用一个 Map 维护 节点值 -> TreeNode* 的映射。
- 每读取一条描述,如果 parent 或 child 还没创建,就 new 出来;然后直接根据 isLeft 把指针连上。
-
寻找根节点:
- 根节点(Root)的唯一特征是:它是所有节点的父节点,但绝不会作为任何节点的子节点(Child)出现。
- 用一个 Set 记录所有出现过的 child。最后遍历所有的 parent,谁没在 Set 里出现过,谁就是真正的根节点。
实现
function createBinaryTree(descriptions: number[][]): TreeNode | null {
const nodes = new Map<number, TreeNode>();
const children = new Set<number>();
for (const [parentVal, childVal, isLeft] of descriptions) {
if (!nodes.has(parentVal)) {
nodes.set(parentVal, new TreeNode(parentVal));
}
if (!nodes.has(childVal)) {
nodes.set(childVal, new TreeNode(childVal));
}
const parentNode = nodes.get(parentVal)!;
const childNode = nodes.get(childVal)!;
if (isLeft === 1) {
parentNode.left = childNode;
} else {
parentNode.right = childNode;
}
children.add(childVal);
}
for (const [parentVal] of descriptions) {
if (!children.has(parentVal)) {
return nodes.get(parentVal)!;
}
}
return null;
}
public class Solution {
public TreeNode CreateBinaryTree(int[][] descriptions) {
var nodes = new Dictionary<int, TreeNode>();
var children = new HashSet<int>();
foreach (var d in descriptions) {
int parentVal = d[0], childVal = d[1], isLeft = d[2];
if (!nodes.ContainsKey(parentVal)) {
nodes[parentVal] = new TreeNode(parentVal);
}
if (!nodes.ContainsKey(childVal)) {
nodes[childVal] = new TreeNode(childVal);
}
if (isLeft == 1) {
nodes[parentVal].left = nodes[childVal];
} else {
nodes[parentVal].right = nodes[childVal];
}
children.Add(childVal);
}
foreach (var d in descriptions) {
int parentVal = d[0];
if (!children.Contains(parentVal)) {
return nodes[parentVal];
}
}
return null;
}
}
class Solution {
public:
TreeNode* createBinaryTree(vector<vector<int>>& descriptions) {
unordered_map<int, TreeNode*> nodes; // 节点值 -> TreeNode 指针
unordered_set<int> children; // 记录所有作为 child 出现过的节点值
for (const auto& d : descriptions) {
int parentVal = d[0];
int childVal = d[1];
bool isLeft = d[2];
// 1. 如果节点不存在,直接创建
if (!nodes.count(parentVal)) {
nodes[parentVal] = new TreeNode(parentVal);
}
if (!nodes.count(childVal)) {
nodes[childVal] = new TreeNode(childVal);
}
// 2. 建立指针连接
if (isLeft) {
nodes[parentVal]->left = nodes[childVal];
} else {
nodes[parentVal]->right = nodes[childVal];
}
// 3. 标记该节点为子节点
children.insert(childVal);
}
// 4. 遍历 parent,找到没在 children 里出现过的节点(即根节点)
for (const auto& d : descriptions) {
int parentVal = d[0];
if (!children.count(parentVal)) {
return nodes[parentVal];
}
}
return nullptr;
}
};
复杂度分析
- 时间复杂度:\(O(N)\)
只需遍历两次 descriptions 数组(建树 \(O(N)\) + 查找根节点 \(O(N)\))。 - 空间复杂度:\(O(N)\)
哈希表与 Set 存储所有节点指针和子节点编号。
3. 最长同值路径
解题思路
要找一个最长的连续等值的节点路径长度。
原始思路:
用 BFS + 哈希表按节点值分组。但是完全写不下去了。
但是用这种方法会把树的父子层级关系打散,后续很难再重新判定哪些节点是连续相连的。
二叉树路径问题最经典的解法是树形 DP 或 后序遍历 DFS。
解题思路——后序 DFS
对于二叉树中的任意一个节点 node:
- 递归子问题定义:定义函数 GetPath(node),返回从 node 开始向下方延伸、且节点值与 node.val 相等的最长单向边数。
- 计算左右延伸路径:
- 递归计算左子树 left 和右子树 right。
- 若左节点存在且 node.left.val == node.val,则左侧有效边长 leftPath = left + 1,否则为 0。
- 若右节点存在且 node.right.val == node.val,则右侧有效边长 rightPath = right + 1,否则为 0。
- 更新全局最大值:以当前节点为最高顶点(拐点)的同值路径长度为 leftPath + rightPath,用它更新全局最大值 maxLen。
- 向上层父节点返回:因为父节点只能选择左或右一条分支向上连接——即路线不可分叉,所以向父节点返回 Math.Max(leftPath, rightPath)。
实现
function longestUnivaluePath(root: TreeNode | null): number {
if (!root) return 0;
let maxLen = 0;
function getPath(node: TreeNode | null, parentVal: number): number {
if (!node) return 0;
// 后序遍历:向子节点传递当前的 node.val
const leftLen = getPath(node.left, node.val);
const rightLen = getPath(node.right, node.val);
// 更新全局拐点最长路径
maxLen = Math.max(maxLen, leftLen + rightLen);
// 值不匹配时断开连接,向父节点返回 0
if (node.val !== parentVal) return 0;
// 匹配时返回单侧最长链长 + 1
return 1 + Math.max(leftLen, rightLen);
}
getPath(root, root.val);
return maxLen;
}
public class Solution {
private int maxLen = 0;
public int LongestUnivaluePath(TreeNode root) {
if(root == null)
return 0;
// 重置最长长度
maxLen = 0;
// 寻找最长长度
GetPath(root, root.val);
return maxLen;
}
private int GetPath(TreeNode root,int parentVal){
if(root == null)
return 0;
// 后序遍历:先算子树
int leftLen = GetPath(root.left,root.val);
int rightLen = GetPath(root.right,root.val);
// 以当前节点为拐点的同值路径总长度
maxLen = Math.Max(maxLen, leftLen+rightLen);
// 只要判断现在的值和原父值不一样,就需要清零
if(root.val != parentVal)
return 0;
// 向父节点只能返回单侧最长路径
return 1 + Math.Max(leftLen,rightLen);
}
}
class Solution {
int maxLen = 0;
int getPath(TreeNode* root, int parentVal) {
if (!root) return 0;
// 后序遍历:向子节点传递当前的 root->val
int leftLen = getPath(root->left, root->val);
int rightLen = getPath(root->right, root->val);
// 更新全局拐点最长路径
maxLen = max(maxLen, leftLen + rightLen);
// 如果当前值与父节点值不同,断开连接,向父节点返回 0
if (root->val != parentVal) return 0;
// 相同则返回单侧最长链长 + 1
return 1 + max(leftLen, rightLen);
}
public:
int longestUnivaluePath(TreeNode* root) {
if (!root) return 0;
maxLen = 0;
getPath(root, root->val);
return maxLen;
}
};
复杂度分析
- 时间复杂度:\(O(N)\)
其中 \(N\) 为树中的节点总数,每个节点仅被访问一次。 - 空间复杂度:\(O(H)\)
其中 \(H\) 为树的高度,递归调用的栈空间开销(最坏情况链状树为 \(O(N)\),平衡树为 \(O(\log N)\))。
引用
[1] 严蔚敏,李冬梅,吴伟民. 数据结构(C语言版)(第2版)[M]. 微信读书版
[2] 力扣探索模式
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号