数据结构之树

树是一种重要的非线性数据结构,广泛应用于数据存储、检索和排序等场景。相较于线性结构(如数组、链表),树结构可以实现 O(logn) 级别的高效查找,同时支持动态存储。二叉树作为树结构的核心分支,以其简洁的结构和高效的操作特性,成为数据结构学习的重点。

一、树与二叉树的核心概念

在深入代码之前,我们先梳理树与二叉树的基础定义和特性,为后续的代码实现奠定理论基础。

1.1 树的基本定义

树是 n(n≥0) 个结点的有限集合,满足以下条件:

  • 当 n=0 时,称为空树
  • 当 n>1 时,有且仅有一个根结点,其余结点可分为 m 个互不相交的有限子集,每个子集都是一棵独立的子树。

树的关键术语

  • 结点的度:结点拥有的子树个数;
  • 叶结点:度为 0 的结点(无子女结点);
  • 分支结点:度不为 0 的结点;
  • 树的度:树中所有结点的度的最大值;
  • 树的深度(高度):从根结点开始计数,根为第 1 层,根的子结点为第 2 层,以此类推。

1.2 二叉树的定义与特点

二叉树是一种特殊的树结构,其每个结点最多只有两棵子树,分别称为左子树右子树

二叉树的核心特点:

  1. 每个结点的度最大为 2;
  2. 左子树和右子树是有序的,次序不能颠倒;
  3. 即使只有一棵子树,也必须区分是左子树还是右子树。

1.3 特殊的二叉树

  • 斜树:所有结点都只有左子树的称为左斜树,所有结点都只有右子树的称为右斜树(斜树的结构等价于链表,查找效率退化为 O(n));
  • 满二叉树:所有分支结点都存在左右子树,且所有叶结点都在同一层;
  • 完全二叉树:对一棵有 n 个结点的二叉树按层序编号,若编号 i(1≤i≤n) 的结点与同样深度的满二叉树中编号 i 的结点位置完全相同,则为完全二叉树(完全二叉树是效率很高的数据结构,堆就是基于完全二叉树实现的)。

1.4 二叉树的重要特性

  1. 第 i 层上最多有 2i−1 个结点(i≥1);
  2. 深度为 k 的二叉树至多有 2k−1 个结点(k≥1);
  3. 任意二叉树中,叶结点数 n0 与度为 2 的结点数 n2 满足:n0=n2+1;
  4. 有 n 个结点的完全二叉树深度为 ⌊log2n⌋+1。

1.5 二叉树的前序、中序、后序遍历概念

二叉树的深度优先遍历 核心是围绕根结点、左子树、右子树的访问顺序展开,根据根结点的访问时机不同,分为前序、中序、后序三种遍历方式,这三种遍历是二叉树操作的基础。

遍历方式 核心顺序 访问逻辑 核心特点
前序遍历 根 → 左 → 右 先访问当前根结点,再递归遍历左子树,最后递归遍历右子树 根结点最先被访问,可用于快速复制二叉树结构
中序遍历 左 → 根 → 右 先递归遍历左子树,再访问当前根结点,最后递归遍历右子树 对于二叉搜索树,中序遍历结果是有序序列
后序遍历 左 → 右 → 根 先递归遍历左子树,再递归遍历右子树,最后访问当前根结点 根结点最后被访问,可用于安全销毁二叉树(先释放子结点再释放根)

二、二叉树的存储结构

二叉树的存储结构分为顺序存储链式存储两种,两种方式各有优劣:

存储方式 优点 缺点
顺序存储 访问结点速度快,无需指针 空间利用率低,适合完全二叉树,不适合斜树
链式存储 空间利用率高,动态分配内存,灵活性强 需额外存储指针,访问结点需遍历指针

本文采用链式存储实现,通过指针连接结点,灵活性更高。

2.1 链式存储的结点结构定义

链式存储的二叉树结点包含三部分:数据域、左子树指针、右子树指针。同时,为了支持非递归遍历,我们还需要定义链式栈结构。

将以下代码放在 tree.h 头文件中:

复制代码
#ifndef _TREE_H_
#define _TREE_H_

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 二叉树结点结构
typedef struct treenode
{
    // 结点数据,本文以字符为例
    char data;
    // 左子树指针
    struct treenode *left;
    // 右子树指针
    struct treenode *right;
} TREENODE;

// 栈结点结构(用于非递归遍历)
typedef struct stacknode
{
    TREENODE *treeNode;
    struct stacknode *next;
} StackNode;

// 栈结构(链式栈)
typedef struct
{
    StackNode *top;
    int size;
} LinkStack;

// 栈操作函数声明
LinkStack *CreateLinkStack();
int PushStack(LinkStack *stack, TREENODE *node);
TREENODE *PopStack(LinkStack *stack);
int IsEmptyStack(LinkStack *stack);
void DestroyLinkStack(LinkStack *stack);

// 二叉树操作函数声明
void CreateRoot(TREENODE **root, char *data, int *inx);
void PreOrderTraverse(TREENODE *T);
void InOrderTraverse(TREENODE *T);
void PostOrderTraverse(TREENODE *T);
void PreOrderTraverseNonRecursive(TREENODE *T);
void InOrderTraverseNonRecursive(TREENODE *T);
void PostOrderTraverseNonRecursive(TREENODE *T);
void LevelOrderTraverse(TREENODE *root);
void DestroyTree(TREENODE *root);

#endif

三、二叉树的构建:基于前序遍历的递归创建

构建二叉树的常用方法是前序遍历序列 + 空结点标记 ,本文使用 # 表示空结点。例如序列 abd##e##c#fh### 对应的二叉树结构如下:

复制代码
        a
      /   \
     b     c
    / \     \
   d   e     f
            /
           h

3.1 核心思路

递归创建二叉树的逻辑严格遵循前序遍历的根 - 左 - 右顺序

  1. 读取序列中的一个字符;
  2. 若字符为 #,则当前结点为空;
  3. 若字符不为 #,则分配内存创建结点,存入数据,再递归创建左子树和右子树。

3.2 构建代码实现

优化点:将前序序列和索引作为参数传入,避免使用全局变量,提高代码可复用性。

复制代码
#include "tree.h"
#include "linkque.h"

// 递归创建二叉树
void CreateRoot(TREENODE **root, char *data, int *inx)
{
    char c = data[(*inx)++];
    // 空结点,直接返回
    if ('#' == c)
    {
        *root = NULL;
        return;
    }
    else
    {
        // 分配结点内存
        *root = (TREENODE *)malloc(sizeof(TREENODE));
        if (NULL == *root)
        {
            printf("CreateRoot malloc error\n");
            return;
        }
        // 存入当前结点数据
        (*root)->data = c;
        // 递归创建左子树
        CreateRoot(&(*root)->left, data, inx);
        // 递归创建右子树
        CreateRoot(&(*root)->right, data, inx);
    }
}

四、二叉树的遍历算法

二叉树的遍历是指按某种规则访问树中所有结点,且每个结点仅被访问一次。常见的遍历方式分为深度优先遍历 (前序、中序、后序)和广度优先遍历(层序)两类,本文同时实现递归和非递归版本。

4.1 链式栈实现(非递归遍历依赖)

非递归遍历需要借助栈来模拟递归调用栈,以下是链式栈的完整实现:

复制代码
#include "tree.h"

// 创建空栈
LinkStack *CreateLinkStack()
{
    LinkStack *stack = (LinkStack *)malloc(sizeof(LinkStack));
    if (NULL == stack)
    {
        printf("CreateLinkStack malloc error\n");
        return NULL;
    }
    stack->top = NULL;
    stack->size = 0;
    return stack;
}

// 入栈操作
int PushStack(LinkStack *stack, TREENODE *node)
{
    if (NULL == stack || NULL == node)
    {
        return 1;
    }
    StackNode *newNode = (StackNode *)malloc(sizeof(StackNode));
    if (NULL == newNode)
    {
        printf("PushStack malloc error\n");
        return 1;
    }
    newNode->treeNode = node;
    newNode->next = stack->top;
    stack->top = newNode;
    stack->size++;
    return 0;
}

// 出栈操作
TREENODE *PopStack(LinkStack *stack)
{
    if (NULL == stack || IsEmptyStack(stack))
    {
        return NULL;
    }
    StackNode *tmp = stack->top;
    TREENODE *treeNode = tmp->treeNode;
    stack->top = tmp->next;
    free(tmp);
    stack->size--;
    return treeNode;
}

// 判断栈是否为空
int IsEmptyStack(LinkStack *stack)
{
    return (stack == NULL || stack->size == 0);
}

// 销毁栈
void DestroyLinkStack(LinkStack *stack)
{
    if (NULL == stack)
    {
        return;
    }
    while (!IsEmptyStack(stack))
    {
        PopStack(stack);
    }
    free(stack);
}

4.2 深度优先遍历:递归实现

深度优先遍历的核心是递归思想,通过不断深入子树完成遍历,三种遍历方式的区别在于访问根结点的时机,完全契合前面定义的遍历概念。

4.2.1 前序遍历(根 - 左 - 右)

遍历顺序:先访问根结点 → 递归遍历左子树 → 递归遍历右子树

复制代码
void PreOrderTraverse(TREENODE *T)
{
    if (NULL == T)
    {
        return;
    }
    // 访问根结点
    printf("%c ", T->data);
    // 遍历左子树
    PreOrderTraverse(T->left);
    // 遍历右子树
    PreOrderTraverse(T->right);
}

对于示例序列构建的二叉树,前序遍历结果:a b d e c f h

4.2.2 中序遍历(左 - 根 - 右)

遍历顺序:递归遍历左子树 → 访问根结点 → 递归遍历右子树

复制代码
void InOrderTraverse(TREENODE *T)
{
    if (NULL == T)
    {
        return;
    }
    // 遍历左子树
    InOrderTraverse(T->left);
    // 访问根结点
    printf("%c ", T->data);
    // 遍历右子树
    InOrderTraverse(T->right);
}

示例二叉树的中序遍历结果:d b e a c h f

4.2.3 后序遍历(左 - 右 - 根)

遍历顺序:递归遍历左子树 → 递归遍历右子树 → 访问根结点

复制代码
void PostOrderTraverse(TREENODE *T)
{
    if (NULL == T)
    {
        return;
    }
    // 遍历左子树
    PostOrderTraverse(T->left);
    // 遍历右子树
    PostOrderTraverse(T->right);
    // 访问根结点
    printf("%c ", T->data);
}

示例二叉树的后序遍历结果:d e b h f c a

4.3 深度优先遍历:非递归实现

递归遍历虽简洁,但递归深度过大时可能导致栈溢出,非递归遍历通过手动模拟栈调用,更贴近底层实现,稳定性更高。其核心逻辑依然遵循前序、中序、后序的访问顺序规则。

4.3.1 非递归前序遍历

核心思路

  1. 根结点入栈,循环弹出栈顶结点并访问(符合 "根优先" 的前序规则);

  2. 先将右子结点入栈(栈先进后出,保证左子树先遍历),再将左子结点入栈;

  3. 重复直到栈为空。

    void PreOrderTraverseNonRecursive(TREENODE *T)
    {
    if (NULL == T)
    {
    return;
    }
    LinkStack *stack = CreateLinkStack();
    PushStack(stack, T);

    复制代码
     while (!IsEmptyStack(stack))
     {
         TREENODE *node = PopStack(stack);
         // 访问当前结点
         printf("%c ", node->data);
         // 右子结点先入栈(后遍历)
         if (node->right != NULL)
         {
             PushStack(stack, node->right);
         }
         // 左子结点后入栈(先遍历)
         if (node->left != NULL)
         {
             PushStack(stack, node->left);
         }
     }
     DestroyLinkStack(stack);

    }

4.3.2 非递归中序遍历

核心思路

  1. 从根结点开始,将所有左子结点入栈,直到左子结点为空(符合 "先左" 的中序规则);

  2. 弹出栈顶结点并访问,然后处理其右子树;

  3. 重复直到栈为空且当前结点为 NULL。

    void InOrderTraverseNonRecursive(TREENODE *T)
    {
    if (NULL == T)
    {
    return;
    }
    LinkStack *stack = CreateLinkStack();
    TREENODE *cur = T;

    复制代码
     while (cur != NULL || !IsEmptyStack(stack))
     {
         // 左子结点全部入栈
         while (cur != NULL)
         {
             PushStack(stack, cur);
             cur = cur->left;
         }
         // 弹出栈顶结点并访问
         cur = PopStack(stack);
         printf("%c ", cur->data);
         // 处理右子树
         cur = cur->right;
     }
     DestroyLinkStack(stack);

    }

4.3.3 非递归后序遍历

核心思路(双栈法)

  1. 栈 1 存储待处理结点,栈 2 存储访问顺序;

  2. 根结点入栈 1,弹出后入栈 2,再将左、右子结点依次入栈 1;

  3. 栈 1 为空时,依次弹出栈 2 结点并访问(栈 2 顺序为根 - 右 - 左,弹出后为左 - 右 - 根,符合后序规则)。

    void PostOrderTraverseNonRecursive(TREENODE *T)
    {
    if (NULL == T)
    {
    return;
    }
    LinkStack *stack1 = CreateLinkStack();
    LinkStack *stack2 = CreateLinkStack();
    PushStack(stack1, T);

    复制代码
     while (!IsEmptyStack(stack1))
     {
         TREENODE *node = PopStack(stack1);
         PushStack(stack2, node);
         // 左子结点先入栈1
         if (node->left != NULL)
         {
             PushStack(stack1, node->left);
         }
         // 右子结点后入栈1
         if (node->right != NULL)
         {
             PushStack(stack1, node->right);
         }
     }
    
     // 遍历栈2,输出后序结果
     while (!IsEmptyStack(stack2))
     {
         TREENODE *node = PopStack(stack2);
         printf("%c ", node->data);
     }
    
     DestroyLinkStack(stack1);
     DestroyLinkStack(stack2);

    }

4.4 广度优先遍历:层序遍历(队列实现)

层序遍历的顺序是从上到下、从左到右访问每一层的结点,其实现依赖队列的 "先进先出" 特性,与深度优先遍历的前序、中序、后序规则完全不同。

4.4.1 链式队列的实现

层序遍历依赖队列结构,我们需要先实现一个链式队列(对应 linkque.hlinkque.c)。

队列头文件 linkque.h

复制代码
#ifndef _LINKQUE_H_
#define _LINKQUE_H_
#include "tree.h"

// 队列数据类型为二叉树结点
typedef TREENODE DATATYPE;

// 队列结点结构
typedef struct quenode
{
    DATATYPE data;
    struct quenode *next;
} LinkQueNode;

// 队列结构(带头尾指针)
typedef struct
{
    LinkQueNode *head;
    LinkQueNode *tail;
    // 队列长度
    int clen;
} LinkQue;

// 函数声明
LinkQue * CreateLinkQue();
int EnterLinkQue(LinkQue *lq,DATATYPE* newnode);
int QuitLinkQue(LinkQue*lq);
DATATYPE* GetHeadLinkQue(LinkQue *lq);
int GetSizeLinkQue(LinkQue *lq);
int IsEmptyLinkQue(LinkQue *lq);
int DestroyLinkQue(LinkQue * lq);

#endif

队列实现文件 linkque.c

复制代码
#include "linkque.h"

// 创建空队列
LinkQue *CreateLinkQue()
{
    LinkQue *lq = (LinkQue *)malloc(sizeof(LinkQue));
    if (NULL == lq)
    {
        printf("CreateLinkQue malloc error\n");
        return NULL;
    }
    lq->head = NULL;
    lq->tail = NULL;
    lq->clen = 0;
    return lq;
}

// 入队操作
int EnterLinkQue(LinkQue *lq, DATATYPE *newdata)
{
    LinkQueNode *newnode = (LinkQueNode *)malloc(sizeof(LinkQueNode));
    if (NULL == newnode)
    {
        printf("EnterLinkQue malloc error\n");
        return 1;
    }
    memcpy(&newnode->data, newdata, sizeof(DATATYPE));
    newnode->next = NULL;

    if (IsEmptyLinkQue(lq))
    {
        // 空队列,头尾指针指向新结点
        lq->head = newnode;
        lq->tail = newnode;
    }
    else
    {
        // 新结点加入队尾
        lq->tail->next = newnode;
        lq->tail = newnode;
    }
    lq->clen++;
    return 0;
}

// 获取队列长度
int GetSizeLinkQue(LinkQue *lq)
{
    return lq->clen;
}

// 判断队列是否为空
int IsEmptyLinkQue(LinkQue *lq)
{
    return 0 == lq->clen;
}

// 销毁队列
int DestroyLinkQue(LinkQue *lq)
{
    int size = GetSizeLinkQue(lq);
    int i = 0 ;
    for(i=0;i<size;i++)
    {
        QuitLinkQue(lq);
    }
    free(lq);
    return 0;
}

// 获取队首元素
DATATYPE *GetHeadLinkQue(LinkQue *lq)
{
    if(IsEmptyLinkQue(lq))
    {
        return NULL;
    }
    return &lq->head->data;
}

// 出队操作
int QuitLinkQue(LinkQue *lq)
{
    if(IsEmptyLinkQue(lq))
    {
        return 1;
    }
    LinkQueNode* tmp = lq->head;
    lq->head = lq->head->next;
    // 队列为空时,尾指针置空
    if(NULL == lq->head)
    {
        lq->tail = NULL;
    }
    free(tmp);
    lq->clen--;
    return 0;
}
4.4.2 层序遍历代码实现
复制代码
void LevelOrderTraverse(TREENODE *root)
{
    if (NULL == root)
    {
        return;
    }
    // 创建空队列
    LinkQue *lq = CreateLinkQue();
    // 根结点入队
    EnterLinkQue(lq, root);

    while (!IsEmptyLinkQue(lq))
    {
        // 获取队首结点
        DATATYPE *tmp = GetHeadLinkQue(lq);
        // 访问队首结点
        printf("%c ", tmp->data);
        // 左子结点入队
        if (NULL != tmp->left)
        {
            EnterLinkQue(lq, tmp->left);
        }
        // 右子结点入队
        if (NULL != tmp->right)
        {
            EnterLinkQue(lq, tmp->right);
        }
        // 队首结点出队
        QuitLinkQue(lq);
    }
    // 销毁队列
    DestroyLinkQue(lq);
}

示例二叉树的层序遍历结果:a b c d e f h

五、二叉树的销毁:防止内存泄漏

链式存储的二叉树在使用完毕后,需要手动释放所有结点的内存,避免内存泄漏。销毁逻辑采用后序遍历的思想:先销毁左子树 → 再销毁右子树 → 最后释放根结点,这正是后序遍历 "根最后访问" 特性的典型应用。

复制代码
void DestroyTree(TREENODE *root)
{
    if (NULL == root)
    {
        return;
    }
    // 销毁左子树
    DestroyTree(root->left);
    // 销毁右子树
    DestroyTree(root->right);
    // 释放当前结点
    free(root);
    root = NULL; // 避免野指针
}

六、完整测试代码与运行结果

6.1 main 函数测试

复制代码
#include "tree.h"
#include "linkque.h"

int main(int argc, char **argv)
{
    TREENODE *root = NULL;
    // 前序遍历序列,#表示空结点
    char data[] = "abd##e##c#fh###";
    int inx = 0;

    // 创建二叉树
    CreateRoot(&root, data, &inx);
    
    printf("===== 递归遍历 =====\n");
    printf("前序遍历:");
    PreOrderTraverse(root);
    printf("\n中序遍历:");
    InOrderTraverse(root);
    printf("\n后序遍历:");
    PostOrderTraverse(root);
    
    printf("\n===== 非递归遍历 =====\n");
    printf("前序遍历:");
    PreOrderTraverseNonRecursive(root);
    printf("\n中序遍历:");
    InOrderTraverseNonRecursive(root);
    printf("\n后序遍历:");
    PostOrderTraverseNonRecursive(root);
    
    printf("\n===== 层序遍历 =====\n");
    printf("层序遍历:");
    LevelOrderTraverse(root);
    printf("\n");

    // 销毁二叉树
    DestroyTree(root);
    root = NULL;
    return 0;
}

6.2 编译与运行

tree.htree.clinkque.hlinkque.cmain.c 放在同一目录下,使用以下命令编译:

复制代码
gcc -o binary_tree main.c tree.c linkque.c
./binary_tree

运行结果:

复制代码
===== 递归遍历 =====
前序遍历:a b d e c f h 
中序遍历:d b e a c h f 
后序遍历:d e b h f c a 
===== 非递归遍历 =====
前序遍历:a b d e c f h 
中序遍历:d b e a c h f 
后序遍历:d e b h f c a 
===== 层序遍历 =====
层序遍历:a b c d e f h 

七、递归 vs 非递归遍历:优缺点分析

遍历方式 递归实现 非递归实现
代码复杂度 简洁易懂,行数少 代码量稍多,需手动管理栈
性能 存在函数调用栈开销,深度大时易栈溢出 手动管理栈,开销更小,无栈溢出风险
适用场景 小规模二叉树,代码可读性优先 大规模二叉树,性能优先

八、总结

1、二叉树的深度优先遍历分为前序(根左右)、中序(左根右)、后序(左右根)三种,其区别在于根结点的访问时机;

2、二叉树的链式存储通过结点的左右指针实现动态扩展,相较于顺序存储更灵活;

3、递归遍历的核心是 "分治思想",非递归遍历通过栈模拟递归调用栈,避免栈溢出风险;

4、层序遍历依赖队列的 "先进先出" 特性,实现按层访问结点;

5、二叉树的销毁逻辑基于后序遍历,先释放子结点再释放根结点,可有效避免内存泄漏;

6、二叉树是许多高级数据结构的基础,掌握其遍历算法是学习后续内容的关键。

相关推荐
某林2122 小时前
SLAM 建图系统配置与启动架构
人工智能·stm32·单片机·嵌入式硬件·算法
不穿格子的程序员2 小时前
从零开始写算法——矩阵类题:图像旋转 + 搜索二维矩阵 II
线性代数·算法·矩阵
罗湖老棍子2 小时前
Knight Moves(信息学奥赛一本通- P1257)
c++·算法·bfs
小李小李快乐不已3 小时前
哈希表理论基础
数据结构·c++·哈希算法·散列表
AuroraWanderll3 小时前
C++11(二)核心突破:右值引用与移动语义(上)
c语言·数据结构·c++·算法·stl
CoderYanger3 小时前
第 479 场周赛Q1——3769. 二进制反射排序
java·数据结构·算法·leetcode·职场和发展
广府早茶3 小时前
机器人重量
c++·算法
sin_hielo3 小时前
leetcode 1925
数据结构·算法·leetcode
CoderYanger3 小时前
A.每日一题——1925. 统计平方和三元组的数目
java·开发语言·数据结构·算法·leetcode·哈希算法