树与二叉树基础

说实话,很多初学者一听到「树」这个数据结构,脑子里就浮现出家里那棵歪脖子树。其实编程里的树,更像是一个倒挂的家族谱——根在上,枝叶在下。我当年刚接触时也绕了好一阵子,后来发现,只要抓住几个核心概念,树其实比链表还直观。

树的定义与术语

树是一种非线性的数据结构。它由 n(n≥0)个节点组成,节点之间有明确的层次关系。如果 n=0,我们叫它空树。

来看几个关键术语,这些你在面试和项目中都会频繁碰到:

  • 根节点:树的最顶层节点,一棵树只有一个根。就像公司的CEO,所有人都向他汇报。
  • 父节点与子节点:如果节点 A 指向节点 B,A 就是父节点,B 就是子节点。注意,一个父节点可以有多个子节点,但一个子节点只能有一个父节点。
  • 叶子节点:没有子节点的节点,也叫终端节点。就像公司里的一线员工。
  • 兄弟节点:拥有同一个父节点的节点之间互称兄弟。
  • 节点的度:一个节点拥有的子节点个数。比如一个节点有3个子节点,它的度就是3。
  • 树的度:树中所有节点的度的最大值。
  • 深度与高度:深度是从根节点往下数,高度是从叶子节点往上数。我习惯用「深度」表示层数,根节点深度为1。

一个小经验:我在做文件系统解析时,经常需要计算目录的深度。如果你用递归实现,记得控制递归深度,否则栈溢出会让你头疼一整天。

二叉树的定义与性质

二叉树是树家族里最常用的结构。它的特点是:每个节点最多有两个子节点,分别叫左孩子和右孩子。说白了,就是每个节点最多生两个娃。

为什么二叉树这么火?因为它的结构简单,存储和遍历都容易实现。我做过一个表达式求值引擎,就是用二叉树来存储运算符和操作数的,跑起来特别稳。

二叉树有几个重要性质,我建议你记牢:

性质 说明
性质1 第 i 层最多有 2^(i-1) 个节点(i≥1)
性质2 深度为 k 的二叉树最多有 2^k - 1 个节点
性质3 叶子节点数 = 度为2的节点数 + 1
性质4 具有 n 个节点的完全二叉树深度为 ⌊log₂n⌋ + 1

嗯,这里要注意性质3。我曾经在调试红黑树时,用这个性质来验证树的平衡性,帮了大忙。

二叉树的存储结构

二叉树怎么存?主要有两种方式:

1. 顺序存储(数组)

用数组来存二叉树,根节点放在下标1(或0),左孩子放在 2i,右孩子放在 2i+1。这种方式适合完全二叉树,空间利用率高。但如果是斜树,数组里会空出大量位置,浪费内存。

// 顺序存储示例
// 假设二叉树:根节点值为'A',左孩子'B',右孩子'C'
char tree[10];
tree[1] = 'A';  // 根节点
tree[2] = 'B';  // 左孩子
tree[3] = 'C';  // 右孩子
// 访问左孩子:tree[2*i],右孩子:tree[2*i+1]

2. 链式存储(链表)

这是最常用的方式。每个节点包含数据域、左指针和右指针。我个人习惯用这种方式,因为它灵活,不浪费空间。

typedef struct TreeNode {
    int data;               // 数据域
    struct TreeNode *left;  // 左孩子指针
    struct TreeNode *right; // 右孩子指针
} TreeNode;

// 创建一个节点
TreeNode* createNode(int data) {
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    node->data = data;
    node->left = NULL;
    node->right = NULL;
    return node;
}

避坑指南:我曾经在嵌入式项目中用链式存储二叉树,忘了释放内存,结果系统跑了三天就崩了。记住:malloc 和 free 要成对出现,尤其是树这种递归结构,释放时要用后序遍历。

二叉树的遍历

遍历,说白了就是把树里的每个节点都访问一遍。根据访问根节点的时机,分为三种:

前序遍历(根左右)

先访问根节点,再遍历左子树,最后遍历右子树。我常用前序来复制一棵树。

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

中序遍历(左根右)

先遍历左子树,再访问根节点,最后遍历右子树。对于二叉搜索树,中序遍历的结果是有序的。这个特性我在做数据库索引时用过很多次。

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

后序遍历(左右根)

先遍历左子树,再遍历右子树,最后访问根节点。后序遍历常用于删除树或计算树的高度。

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

注意:递归遍历虽然代码简洁,但如果树很深(比如超过1000层),递归会导致栈溢出。我在嵌入式裸机开发中遇到过这个问题,后来改成了非递归(用栈模拟)才解决。

知识体系总览

下面这张图帮你理清本章的核心脉络:

树与二叉树基础 树的定义与术语 根节点 · 叶子节点 · 父/子节点 · 度 · 深度 二叉树的定义与性质 最多两个子节点 · 4条重要性质 二叉树的存储结构 顺序存储(数组) · 链式存储(链表) 二叉树的遍历 前序遍历(根左右) 中序遍历(左根右) 后序遍历(左右根) 递归实现 · 非递归实现 · 应用场景

你看,树的知识点其实就这么多。从定义到性质,从存储到遍历,每一步都环环相扣。我个人觉得,学树的关键在于动手画图——你拿张纸,画一棵二叉树,然后用手模拟遍历过程,比看十遍代码都管用。

好了,这一章就到这里。记住:树是递归定义的,所以递归思维很重要。但也要警惕递归的陷阱,该用栈模拟时就果断用栈。


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