当前位置: 首页 > news >正文

数据结构:二叉树的表示方式(Representation of Binary Trees)

目录

链式表示法 (Linked Representation)

步骤1:定义“节点”这个基本单元

步骤2:创建节点并建立连接

步骤3:封装成函数(代码的演进)

数组表示法 (Array / Sequential Representation)

步骤1:发现规律

步骤2:用数组存储

步骤3:处理非完全二叉树

最终结论:如何选择?


我们正式进入实践环节。知道了树的理论概念后,下一步就是思考:如何在计算机的内存中把这个抽象的“树”结构给搭建出来?

计算机内存本质上是线性的,像一条长长的街道,每个位置都有一个地址。而树是非线性的、分叉的结构。

所以,我们面临的核心问题是:如何用线性的内存来表达非线性的树状关系?

从第一性原理出发,我们有两种根本不同的方法来解决这个问题。


链式表示法 (Linked Representation)

第一性原理: 抓住树最核心的关系——“连接”。一个父节点“连接”到它的子节点。

在C/C++中,什么工具最擅长表示这种“指向”或“连接”的关系,尤其当被连接的对象在内存中位置不固定时?

答案是 指针 (Pointer)

这种方法是最直观、最符合树的逻辑定义的。

步骤1:定义“节点”这个基本单元

我们首先需要设计一个“零件”。这个零件,也就是树的节点,需要包含什么信息?

  1. 它自身存储的数据 (Data)。

  2. 一个指向其左孩子的“连接”。

  3. 一个指向其右孩子的“连接”。

好了,让我们把这个设计草图翻译成C/C++代码。

// 步骤 1: 定义一个树节点的结构体
// 这是我们构建一棵树所需的最基本的“砖块”
struct TreeNode {int data;         // 1. 用于存储节点自身的数据,这里以整数为例TreeNode* left;   // 2. 一个指针,用于“指向”左孩子节点TreeNode* right;  // 3. 一个指针,用于“指向”右孩子节点
};

代码解读:

  • TreeNode* left; 这一行是精髓。它定义了一个名为 left 的成员,它的类型是 TreeNode*,即“指向TreeNode结构体的指针”。它可以存储另一个TreeNode结构体的内存地址。

  • 如果一个节点没有左孩子或右孩子怎么办?很简单,让对应的指针指向一个特殊的地方,即 NULL (或在C++11及以后版本中用 nullptr)。这就像一条路的尽头,告诉我们“这里没路了”。


步骤2:创建节点并建立连接

现在我们有了“砖块”,怎么把它们砌成一棵小树呢?比如,我们要创建下面这棵树:

    10 (根)/  \20  30

我们不能只在函数里简单地创建变量 TreeNode node;,因为这样的变量在函数结束后就会被销毁。

我们需要在一种叫做“堆 (Heap)”的内存区域里创建节点,这样它们才能一直存在,直到我们手动释放。在C++里我们用 new,在C里用 malloc

 root↓
+-------+-------------+--------------+
|  A    | left:ptrB   | right:ptrC   |
+-------+-------------+--------------+↓                       ↓
+-------+-------------+--------------+
|  B    |   NULL      |    NULL      |
+-------+-------------+--------------++-------+-------------+--------------+
|  C    |   NULL      |    NULL      |
+-------+-------------+--------------+
// 步骤 2: 尝试创建并连接节点// 首先,创建根节点。它现在是孤立的。
TreeNode* root = new TreeNode; // 在堆内存中申请一个TreeNode的空间
root->data = 10;
// 重要!新创建的节点,它的孩子指针是未初始化的,必须手动设为NULL
root->left = NULL;
root->right = NULL;// 然后,创建它的左孩子,它目前也是孤立的
TreeNode* leftChild = new TreeNode;
leftChild->data = 20;
leftChild->left = NULL;
leftChild->right = NULL;// 创建右孩子
TreeNode* rightChild = new TreeNode;
rightChild->data = 30;
rightChild->left = NULL;
rightChild->right = NULL;// 最后,建立“连接”
root->left = leftChild;   // 将root节点的left指针指向leftChild节点的地址
root->right = rightChild; // 将root节点的right指针指向rightChild节点的地址

root->left = leftChild; 这一步是连接的关键。它把 leftChild 这个指针变量里存储的内存地址,复制到了 root 所指向的那个结构体的 left 成员里。从此,通过 root 就可以找到它的孩子们了。


步骤3:封装成函数(代码的演进)

手动创建和连接非常繁琐且容易出错。一个好的实践是把“创建并初始化节点”这个重复性工作封装成一个函数。

// 步骤 3: 将节点创建过程封装成一个辅助函数
#include <iostream> // 引入用于输出// (这里是步骤1定义的TreeNode结构体)
struct TreeNode {int data;TreeNode* left;TreeNode* right;
};// 辅助函数:传入一个值,返回一个创建好的、指向新节点的指针
TreeNode* createNode(int value) {TreeNode* newNode = new TreeNode; // 申请内存newNode->data = value;            // 设置数据newNode->left = NULL;             // 初始化左右孩子为NULLnewNode->right = NULL;return newNode;                   // 返回新节点的地址
}// 最终的、更简洁的建树代码
int main() {// 使用辅助函数,代码变得清晰多了TreeNode* root = createNode(10);root->left = createNode(20);root->right = createNode(30);// 我们可以验证一下连接是否成功std::cout << "根节点的值: " << root->data << std::endl;std::cout << "左孩子的值: " << root->left->data << std::endl;std::cout << "右孩子的值: " << root->right->data << std::endl;// TODO: 释放内存 (暂时省略,但实际项目中非常重要)return 0;
}

链式表示法总结

  • 优点:

    • 灵活: 插入、删除节点非常方便,只需改变指针即可,不需要移动大量数据。

    • 空间: 大小不受限制,只要内存足够,理论上可以无限增长。

  • 缺点:

    • 空间开销: 每个节点都需要额外的空间来存储左、右两个指针。

    • 访问效率: 节点在内存中可能是分散的,不连续,这可能导致CPU缓存命中率降低。

    • 寻父困难: 从一个子节点出发,无法直接找到其父节点(除非我们在结构体中再增加一个parent指针)。


数组表示法 (Array / Sequential Representation)

第一性原理: 能否不用指针,而是通过数学计算来找到父子关系?

如果我能把树的节点整齐地排放在一个数组里,并且能通过一个节点的数组下标 i,直接算出它孩子的下标和父亲的下标,那就不需要指针了。

这种想法要成为现实,需要一个前提:树的结构必须非常有规律,不能有“空隙”。

哪种树最符合这个要求?完全二叉树 (Complete Binary Tree)

步骤1:发现规律

让我们画一棵完全二叉树,并把它的节点按层序(从上到下,从左到右)在数组中编号,从下标 0 开始。

        节点值: A         数组下标: 0/     \B       C         数组下标: 1, 2/ \     / \D   E   F   G       数组下标: 3, 4, 5, 6

我们来寻找下标之间的数学关系:

  • 找孩子:

    • 节点0 (A) 的孩子是 1 (B) 和 2 (C)。 1 = 2*0 + 1, 2 = 2*0 + 2

    • 节点1 (B) 的孩子是 3 (D) 和 4 (E)。 3 = 2*1 + 1, 4 = 2*1 + 2

    • 节点2 (C) 的孩子是 5 (F) 和 6 (G)。 5 = 2*2 + 1, 6 = 2*2 + 2

  • 规律: 对于任意下标为 i 的节点:

    • 左孩子的下标是 2*i + 1

    • 右孩子的下标是 2*i + 2

  • 找父亲:

    • 节点1 (B) 和 2 (C) 的父亲是 0 (A)。 (1-1)/2 = 0, (2-1)/2 = 0 (整数除法)。

    • 节点3 (D) 和 4 (E) 的父亲是 1 (B)。 (3-1)/2 = 1, (4-1)/2 = 1

  • 规律: 对于任意下标为 i (且i>0) 的节点:

    • 父节点的下标是 (i-1) / 2

+----+----+----+----+----+----+----+
| A  | B  | C  | D  | E  | F  | G  |
+----+----+----+----+----+----+----+0    1    2    3    4    5    6   ← 数组索引

步骤2:用数组存储

有了这些雷打不动的公式,我们就可以用一个简单的数组来表示一棵(完全)二叉树了。

// 步骤 2: 将树 {10, 20, 30} 存入数组
// 10是根,在下标0
// 20是10的左孩子,在下标 2*0+1 = 1
// 30是10的右孩子,在下标 2*0+2 = 2
int treeArray[100] = {10, 20, 30}; // 假设数组足够大
int treeSize = 3;// 演示如何通过计算来遍历
int root_idx = 0;
std::cout << "根节点的值: " << treeArray[root_idx] << std::endl;// 检查左孩子是否存在并访问
int left_child_idx = 2 * root_idx + 1;
if (left_child_idx < treeSize) {std::cout << "左孩子的值: " << treeArray[left_child_idx] << std::endl;
}// 检查右孩子是否存在并访问
int right_child_idx = 2 * root_idx + 2;
if (right_child_idx < treeSize) {std::cout << "右孩子的值: " << treeArray[right_child_idx] << std::endl;
}

步骤3:处理非完全二叉树

如果树不是完全二叉树怎么办?比如,节点10只有右孩子30。

    10\30
  • 原理:  我们必须维护公式的正确性。节点10(下标0)的左孩子(下标1)虽然不存在,但它的“位置”必须被占住,否则公式就乱了。

  • 解决方案:  用一个特殊值(哨兵值),比如 -1,来表示这个位置是空的。

        A/ \B   C/D数组:[A, B, C, D, -1, -1, -1]
// 步骤 3: 存储一个非完全二叉树
// 树: 10(根), 右孩子30.
// 数组表示:
// 下标0: 10
// 下标1 (10的左孩子): 空,用-1表示
// 下标2 (10的右孩子): 30
int treeArray[100] = {10, -1, 30};
int treeSize = 3;

数组表示法总结

  • 优点:

    • 节约空间: 不需要存储指针,对于满二叉树或完全二叉树,空间利用率极高。

    • 访问高效: 节点在内存中是连续的,CPU缓存友好。找父亲的操作极其简单快速 (O(1))。

  • 缺点:

    • 空间浪费: 对于非完全二叉树,特别是“又高又瘦”的树,会浪费大量数组空间来存储 -1。一个只有n个节点的右斜树可能需要 2n 大小的数组!

    • 不灵活: 增删节点可能需要移动大量元素,或者导致数组需要重新分配、拷贝,开销很大。


最终结论:如何选择?

特性链式表示法 (Linked)数组表示法 (Sequential)
空间效率指针开销大对完全/满二叉树极高,对稀疏树极低
时间效率增删灵活快速增删慢,查询(父节点)快
适用场景通用。绝大多数需要动态改变的树。特殊。结构固定的完全二叉树,如二叉堆 (Binary Heap)。

两种表示方法,源于我们解决“用线性内存表达非线性结构”这个核心矛盾的两种不同思路。

  • 链式法: “既然内存地址不连续,我就用指针把它们连接起来”,忠实于逻辑结构。

  • 数组法: “我不管逻辑结构,我强行定义一套数学规则,把所有节点映射到连续的数组下标上”,忠实于物理存储。

在绝大多数应用场景中,链式表示法因其灵活性而成为首选。

而数组表示法,则在像“堆排序”这样需要频繁寻找父节点且能保证树总是完全二叉树的特定算法中,大放异彩。

http://www.xdnf.cn/news/1303021.html

相关文章:

  • 【测试工具】JMeter基本使用及MySQL数据库压力测试
  • Baumer高防护相机如何通过YoloV8深度学习模型实现驾驶员疲劳的检测识别(C#代码UI界面版)
  • python的美食交流社区系统
  • @[TOC](计算机是如何⼯作的) JavaEE==网站开发
  • 前端性能优化工具Performance面板实战指南
  • 【swift开发】SwiftUI概述 SwiftUI 全面解析:苹果生态的声明式 UI 革命
  • 【C#补全计划】事件
  • 【2D】圆上数值积分(半径方向用高斯积分减少点数)
  • 综合案例:Python 函数知识整合 — 学生成绩管理系统
  • Python 类(Class)学习
  • 【新手入门】Android基础知识(一):系统架构
  • 【Golang】:流程控制语句
  • 【Vibe Coding 工程之 StockAnalyzerPro 记录】- EP1.先写 PRD
  • 【秋招笔试】2025.08.15饿了么秋招机考-第一题
  • P4069 [SDOI2016] 游戏 Solution
  • 微信小程序 拖拽签章
  • Git版本控制器
  • spring中异步任务注解@Async和@scheduled的使用
  • 2025年机械制造、机器人与计算机工程国际会议(MMRCE 2025)
  • Docker Compose 入门教程
  • MySQL、PolarDB、PolarDB-X、TableStore、MongoDB、TiDB、ClickHouse选型
  • docker入门
  • Java 调用 Python 脚本:实现 HelloWorld
  • 计算机视觉(opencv)实战五——图像平滑处理(均值滤波、方框滤波、高斯滤波、中值滤波)附加:视频逐帧平滑处理
  • 从根本上解决MAC权限问题(关闭sip)
  • SSL和TLS协议的消息认证码(MAC)
  • Android RxJava变换操作符详解
  • 使用SQLALCHEMY的outerjoin时的bug
  • 训练大模型的前提:数据治理工程:从原始数据到高质量语料的系统化治理实践
  • vector接口模拟实现及其原理