【数据结构】【学习笔记】二叉树

二叉树是一种很常用的数据结构,其非常适合计算机做递归、搜索和分治。在许多问题中都有所应用,比如在搜索问题中使用BST,在严格平衡搜索中使用AVL树,在工程中的Map/Set使用红黑树,在优先队列中使用堆,在数据库索引中使用B+树等。

在下文,从定义、特性、实现和具体题目来了解下二叉树。

⚙️定义

要认识二叉树的话,首先要认识树是什么。

💡树的定义
树是 \(n(n\geq0)\) 个结点的有限集合,满足:

  1. \(n=0\),则为空树。
  2. \(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的字典存放节点,可以快速获取节点,在返回结果时,也可以利用这点找到父节点来返回,不用考虑怎么存放一个不会移动的父节点。

核心思路两步走

  1. 边遍历边建树:

    • 用一个 Map 维护 节点值 -> TreeNode* 的映射。
    • 每读取一条描述,如果 parent 或 child 还没创建,就 new 出来;然后直接根据 isLeft 把指针连上。
  2. 寻找根节点:

    • 根节点(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:

  1. 递归子问题定义:定义函数 GetPath(node),返回从 node 开始向下方延伸、且节点值与 node.val 相等的最长单向边数
  2. 计算左右延伸路径:
    • 递归计算左子树 left 和右子树 right。
    • 若左节点存在且 node.left.val == node.val,则左侧有效边长 leftPath = left + 1,否则为 0。
    • 若右节点存在且 node.right.val == node.val,则右侧有效边长 rightPath = right + 1,否则为 0。
  3. 更新全局最大值:以当前节点为最高顶点(拐点)的同值路径长度为 leftPath + rightPath,用它更新全局最大值 maxLen。
  4. 向上层父节点返回:因为父节点只能选择左或右一条分支向上连接——即路线不可分叉,所以向父节点返回 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 辅助整理。

posted @ 2026-08-26 21:15  SEHOD  阅读(8)  评论(0)    收藏  举报