一、树与二叉树:从现实到代码的映射

说实话,我刚学数据结构那会儿,对树这种结构挺不以为然的。链表、数组多直观啊,树这玩意儿弯弯绕绕的,到底有什么用?直到我在一个项目里需要处理文件系统的目录结构——好家伙,这不就是一棵活生生的树吗?

树结构在嵌入式里其实很常见。比如你解析一个JSON配置文件,或者管理一个菜单系统,甚至做哈夫曼编码压缩数据,背后都是树。今天我们就从最基础的说起。

1.1 树的定义

树是一种非线性的数据结构。它由节点和边组成,有一个根节点,其他节点往下分叉。每个节点可以有多个子节点,但只有一个父节点(根节点除外)。

我习惯用家谱来理解树:

  • 根节点:老祖宗,没有父节点
  • 叶子节点:没有子节点的节点,相当于家族里的末代
  • 子树:任何一个节点往下看,都是一棵小树
  • 深度:从根到某个节点的层数
  • 高度:从某个节点到最远叶子节点的距离

关键点:树是递归定义的。一棵树的每个子树本身也是一棵树。这个递归特性,直接决定了后面遍历算法的写法。

1.2 二叉树——最常用的树

二叉树就是每个节点最多有两个子节点,分别叫左孩子和右孩子。你想想看,这多好处理?两个分支,代码写起来特别顺手。

我在项目中用得最多的就是二叉树。为什么?因为它的结构简单,内存开销可控,而且很多复杂结构(比如堆、二叉搜索树、表达式树)都是基于它。

二叉树的五种基本形态:

  • 空树
  • 只有根节点
  • 只有左子树
  • 只有右子树
  • 左右子树都有

嗯,这里要注意:空树也是树。很多递归算法的终止条件就是判断节点是否为NULL。

1.3 二叉树的遍历

遍历说白了就是按某种顺序把树里的每个节点都访问一遍。二叉树的遍历有四种经典方式,我一个个说。

1.3.1 前序遍历(Pre-order)

顺序:根 → 左 → 右

先访问当前节点,再递归遍历左子树,最后递归遍历右子树。

void preorder(TreeNode* root) {
    if (root == NULL) return;
    printf("%d ", root->val);   // 访问根
    preorder(root->left);       // 遍历左
    preorder(root->right);      // 遍历右
}

我记得有一次调试一个菜单系统,需要把整个菜单树打印出来。前序遍历正好合适——先打印当前菜单项,再进入子菜单。这就是前序的典型应用。

1.3.2 中序遍历(In-order)

顺序:左 → 根 → 右

先遍历左子树,再访问根节点,最后遍历右子树。

void inorder(TreeNode* root) {
    if (root == NULL) return;
    inorder(root->left);        // 遍历左
    printf("%d ", root->val);   // 访问根
    inorder(root->right);       // 遍历右
}

中序遍历对二叉搜索树特别有用。你想想看,如果一棵二叉搜索树用中序遍历,得到的结果就是有序的。我在做数据排序时经常用这个特性。

1.3.3 后序遍历(Post-order)

顺序:左 → 右 → 根

先遍历左右子树,最后访问根节点。

void postorder(TreeNode* root) {
    if (root == NULL) return;
    postorder(root->left);      // 遍历左
    postorder(root->right);     // 遍历右
    printf("%d ", root->val);   // 访问根
}

后序遍历常用于释放树的内存。你总不能先把根节点释放了,再去找子节点吧?那会出大问题。我曾经就犯过这个错——先free了根节点,然后程序就崩了。正确的做法是先释放子节点,最后释放根节点,这就是后序遍历的思路。

1.3.4 层序遍历(Level-order)

层序遍历是按层级从上到下、从左到右依次访问。它需要借助队列来实现。

void levelorder(TreeNode* root) {
    if (root == NULL) return;
    Queue* q = createQueue();
    enqueue(q, root);
    
    while (!isEmpty(q)) {
        TreeNode* node = dequeue(q);
        printf("%d ", node->val);
        if (node->left) enqueue(q, node->left);
        if (node->right) enqueue(q, node->right);
    }
}

层序遍历在嵌入式里有个很实用的场景——打印树形结构。比如你要在串口上输出一个目录树,层序遍历配合缩进,效果特别好。

小技巧:如果你记不住三种深度优先遍历的顺序,记住一个口诀——「前根、中根、后根」,看根的位置在哪,就是哪种遍历。

1.4 二叉搜索树(BST)

二叉搜索树是二叉树的一种特殊形式。它的规则很简单:

  • 左子树所有节点的值 小于 根节点的值
  • 右子树所有节点的值 大于 根节点的值
  • 左右子树也分别是二叉搜索树

说白了,BST就是一棵「有序」的二叉树。查找一个元素的时间复杂度是O(log n),前提是树是平衡的。

BST的查找

TreeNode* searchBST(TreeNode* root, int target) {
    if (root == NULL || root->val == target)
        return root;
    if (target < root->val)
        return searchBST(root->left, target);
    else
        return searchBST(root->right, target);
}

这个代码很简洁,对吧?每次比较都能排除一半的节点。我在做嵌入式配置表查找时,就经常用BST来加速。

BST的插入

TreeNode* insertBST(TreeNode* root, int val) {
    if (root == NULL) {
        return createNode(val);
    }
    if (val < root->val)
        root->left = insertBST(root->left, val);
    else if (val > root->val)
        root->right = insertBST(root->right, val);
    return root;
}

注意:BST的插入和查找都依赖比较。如果插入的数据是递增或递减的,BST会退化成链表,时间复杂度变成O(n)。我曾经在一个项目中就踩过这个坑——数据是按时间顺序插入的,结果树变成了一根斜线,查找效率还不如数组。后来我改用平衡树(AVL或红黑树)才解决问题。

1.5 知识体系总览

下面这张图把本章的核心内容串起来了。你可以看到树的基本概念、二叉树的四种遍历方式,以及二叉搜索树的增删查操作。建议你对照着图,把代码自己敲一遍。

树与二叉树 · 知识体系 树 (Tree) 二叉树 二叉搜索树 前序遍历 中序遍历 后序遍历 层序遍历 查找 插入 删除 核心特性:递归定义 · 非线性结构 · 分层组织 遍历本质:深度优先(前/中/后序) vs 广度优先(层序) BST关键:左小右大 · 中序有序 · 平衡性决定性能 图:树与二叉树知识体系总览

1.6 小结

树和二叉树是嵌入式开发中绕不开的数据结构。我个人觉得,理解树的关键在于两点:一是递归思想,二是遍历顺序。递归搞明白了,树的代码写起来就像呼吸一样自然。

二叉搜索树则是在二叉树的基础上加了有序性。它让查找变得高效,但也带来了平衡性的问题。这个我们后面会专门讲。

最后送你一句话:代码是写给人看的,顺便能在机器上跑。树的代码尤其如此——结构清晰、逻辑递归,读起来就是一种享受。

课后练习

  1. 用非递归方式实现前序、中序、后序遍历(提示:用栈)
  2. 实现一个函数,判断一棵二叉树是否是二叉搜索树
  3. 写一个层序遍历,按层级输出节点,每层换行

公众号:蓝海资料掘金营,微信deep3321