数据结构和算法学习日志-第八章:树

第八章 树

思维导图:

第八章 第一节:树的定义和树的存储结构

1.树的定义和树的存储结构

1.1 树的定义

1.1.1 定义

树(Tree)是n(n>=0)个节点的有限集。当n=0时称为空树。在任意一棵非空树中:

有且仅有一个特定的被称为根(Root)的节点
当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1、T2、……、Tm,其中每个集合本身又是一棵树,并且称为根的子树(SubTree),如下图所示:

对于上面这棵树而言,A是它的根节点,左侧橙色部分和右侧黄色部分分别是这棵树的两个子树,而分别在以B、C为根节点的子树中还有子树,所以我们可以说树是递归定义的,树的特性对于它们的子树以及子树的子树而言同样是适用的。

对于树的定义有3点需要特别强调一下:

层次结构:树具有根节点、子节点和叶节点,呈现出一种分层的结构。
无环性:树是一种无环结构,即0任意两个节点之间只有唯一的一条路径。
单一根节点:树只有一个根节点,从根节点可以访问树中的所有其他节点。

1.2 节点的关系和分类

对于一棵树而言,里边有一些常用概念是需要大家掌握的:

节点(Node):树中的每个元素称为节点,即图中的A、B、C、...、H、I、J。
根节点(Root Node):树的顶层节点,没有父节点,即图中的节点A。
父节点(Parent Node):直接连接某个节点的上层节点。比如:
B、C节点的父节点为根节点A
E、F节点的父节点为节点C
G、H、I节点的父节点为根节点D
子节点(Child Node):由某个节点直接连接的下层节点。比如:
A节点的子节点为节点B、C
C节点的子节点为节点E、F
D节点的子节点为节点G、H、I
子孙节点:以某节点为根的子树中的任意节点都是该节点的子孙
叶子节点(Leaf Node):没有子节点的节点。图中的G、H、I、J就是叶子节点。
兄弟节点(Sibling Nodes):具有相同父节点的节点。
堂兄弟节点:在树中的层次相同,但是父节点不同。举例:
节点D和节点E、F互为堂兄弟节点
节点G、H、I和节点、J互为堂兄弟节点
层次(Level):从根开始定义,根为第一层,根的孩子为第二层,以此类推。图中相同颜色的节点表示相同的层次,从根节点向下一共四层。
路径(Path):从一个节点到另一个节点所经过的节点序列。
高度(Height):节点到叶节点的最长路径长度。
从根节点到叶子节点得到的高度就是树的高度
根节点A到叶子节点F的高度是3,到叶子节点G、H、I、J的高度是4,所以根节点的高度是4,树的高度也是4
深度(Depth):节点到根节点的路径长度。比如:
从AE深度为3,从AH深度为4
子树(Subtree):由一个节点及其所有后代节点组成的树。
度(Degree):节点的子节点数量,树的度是所有节点度的最大值。
叶子节点的度为0
树的度是树内各个节点度的最大值
节点E的度为1,节点A的度为2,节点D的度为3,树的度为3
有序树/无序树:如果树以及它的子树中所有子节点从左至右是有次序的,不能互换的,此时将这棵树称为有序树,否则称为无序树。
森林(Forest):m(m>=0)棵互不相交的树的集合。
线性表与树结构有很多地方是不同的,下表是关于二者的对比:

温馨提示:上表中所说的双亲(Parent)就是当前节点的父节点,只有一个而不是两个。

2. 树的存储结构

关于数到存储结构和线性表一样有两种:线性存储和链式存储。先看顺序存储结构,如果是线性表可以用一段连续的存储单元一对一的进行数据元素的存储,对于树这种一对多的结构应该如何进行存储呢?

在数据结构中,树的表示法有多种,常见的包括双亲表示法、孩子表示法和孩子兄弟表示法。每种表示法都有其优点和适用的场景。下面详细介绍这些表示法。

2.1 双亲表示法

双亲表示法是一种用数组来表示树的方法,在存储树节点的时候,在每个节点中附设一个指示器指示其双亲节点在数组中的位置。

基于上面的描述,我们需要定义出这样的一个树节点结构(假设节点存储的数据是整形):

typedef struct TreeNode {
    int value;     // 节点的值
    int parent;    // 父节点的索引
} TreeNode;

data:节点存储的数据
parent:父节点在数组中的位置,根节点没有父节点,用 -1 表示
我们可以通过一个表格来直观的描述一下节点在数组中的关系:

我们来看一段示例代码:

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

// 定义树节点结构
typedef struct TreeNode {
    int value;     // 节点的值
    int parent;    // 父节点的索引
} TreeNode;

// 添加节点的函数
void addNode(TreeNode** tree, int* size, int value, int parentIndex) {
    // 动态调整内存,增加一个节点的空间,所以是(*size + 1)
    TreeNode* temp = realloc(*tree, (*size + 1) * sizeof(TreeNode));
    //realloc 是 C 语言中用于调整已分配内存大小的函数。它的主要作用是可以扩展或缩小已经分配的内存块。
    if (temp == NULL) {
        printf("realloc failed \n");
        exit(1); // 返回错误代码,表示内存分配失败
    }
    *tree = temp; // 更新树指针
    (*tree)[*size].value = value;      // 设置新节点的值
    (*tree)[*size].parent = parentIndex; // 设置新节点的父节点索引
    (*size)++; // 增加树的大小
}

int main() {
    TreeNode* tree = NULL; // 初始化树指针为 NULL
    int size = 0;          // 初始化树的大小为 0

    // 创建根节点,根节点的父节点索引为 -1
    addNode(&tree, &size, 1, -1); // 索引 0
    addNode(&tree, &size, 2, 0);  // 索引 1
    addNode(&tree, &size, 3, 0);  // 索引 2
    addNode(&tree, &size, 4, 1);  // 索引 3
    addNode(&tree, &size, 5, 1);  // 索引 4

    // 输出树的结构
    for (int i = 0; i < size; ++i) {
        printf("Node value: %d", tree[i].value); // 输出当前节点的值
        if (tree[i].parent != -1) {
            // 如果不是根节点,输出其父节点的值
            printf(", Parent value: %d\n", tree[tree[i].parent].value);
        }
        else {
            // 如果是根节点,输出提示信息
            printf(", This is the root node.\n");
        }
    }

    // 释放动态分配的内存
    free(tree);

    return 0; // 程序结束
}

输出的结果如下:

Node value: 1, This is the root node.
Node value: 2, Parent value: 1
Node value: 3, Parent value: 1
Node value: 4, Parent value: 2
Node value: 5, Parent value: 2

通过测试输出的日志信息可知,我们可以快速的通过每个节点中的parent域找到它们的父节点(也就是双亲节点),时间复杂度为O(1)。但是如果我们想知道当前节点的子节点是谁,就需要遍历整棵树才能得到结果。

如果我们对上面的TreeNode结构进行优化给它添加用于描述孩子节点位置的成员就可以快速找到当前节点的子节点了:

struct TreeNode
{
    int data;
    int parent;
    int child1;
    int child2;
        ...
        ...
        ...
};

但是此时问题来了,对于一棵树中的节点而言,我怎么知道它有多少个子节点呢?如果child成员定义的太多会浪费存储空间,如果定义的太少就不能存储所有的子节点信息。这该如何是好呢?

2.2 孩子表示法

孩子表示法是一种为每个节点存储其所有子节点的表示方法,在存储的时候可以使用数组也可以使用链表。

孩子表示法中,每个节点都有一个指针列表或数组,指向它的所有子节点。我们可以使用 std::vector 来存储子节点指针。关于节点结构可以这样定义:

// 定义树的节点结构
typedef struct TreeNode {
    int value;                // 节点的值
    struct TreeNode** children; // 指向子节点的指针数组
    int childCount;          // 子节点数量
} TreeNode;

value:节点的值,可以根据实际需求修改为其他数据类型
children:子节点列表,存储的是当前节点所有的子节点

如果想要根据上面的树节点结构构造这样一棵树,代码应该怎么写呢?

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

// 定义树的节点结构
typedef struct TreeNode {
    int value;                // 节点的值
    struct TreeNode** children; // 指向子节点的指针数组
    int childCount;          // 子节点数量
} TreeNode;

// 创建新节点的函数
TreeNode* createNode(int val) {
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode)); // 动态分配内存
    if (!node) { // 检查内存分配是否成功
        fprintf(stderr, "Memory allocation failed\n");
        exit(EXIT_FAILURE); // 失败则退出程序
    }
    node->value = val; // 设置节点值
    node->children = NULL; // 初始化子节点指针为 NULL
    node->childCount = 0; // 初始化子节点计数为 0
    return node; // 返回新创建的节点
}

// 添加子节点的函数
void addChild(TreeNode* parent, TreeNode* child) {
    parent->childCount++; // 增加子节点计数
    // 重新分配内存以容纳新的子节点
    TreeNode** temp = (TreeNode**)realloc(parent->children,
        parent->childCount * sizeof(TreeNode*));
    if (!temp) { // 检查内存重新分配是否成功
        fprintf(stderr, "Memory allocation failed\n");
        exit(EXIT_FAILURE); // 失败则退出程序
    }
    parent->children = temp; // 更新父节点的子节点指针
    parent->children[parent->childCount - 1] = child; // 将新子节点添加到数组中
}

// 递归打印树的结构
void printTree(TreeNode* node, int level) {
    if (!node) return; // 如果节点为空,返回
    // 打印当前节点的值,前面加上缩进
    for (int i = 0; i < level; i++) {
        printf("  "); // 根据层级打印缩进
    }
    printf("Node value: %d\n", node->value); // 打印节点值
    // 递归打印每个子节点
    for (int i = 0; i < node->childCount; i++) {
        printTree(node->children[i], level + 1);
    }
}

// 释放树的内存
void freeTree(TreeNode* node) {
    if (!node) return; // 如果节点为空,返回
    // 递归释放每个子节点的内存
    for (int i = 0; i < node->childCount; i++) {
        freeTree(node->children[i]);
    }
    free(node->children); // 释放子节点指针数组的内存
    free(node); // 释放当前节点的内存
}

int main() {
    // 创建树的节点
    TreeNode* root = createNode(1);
    TreeNode* child1 = createNode(2);
    TreeNode* child2 = createNode(3);
    TreeNode* child3 = createNode(4);
    TreeNode* child4 = createNode(5);
    TreeNode* child5 = createNode(6);
    TreeNode* child6 = createNode(7);
    TreeNode* child7 = createNode(8);

    // 构建树结构
    addChild(root, child1); // 将 child1 添加为 root 的子节点
    addChild(root, child2); // 将 child2 添加为 root 的子节点
    addChild(root, child3); // 将 child3 添加为 root 的子节点
    addChild(child2, child4); // 将 child4 添加为 child2 的子节点
    addChild(child2, child5); // 将 child5 添加为 child2 的子节点
    addChild(child1, child6); // 将 child6 添加为 child1 的子节点
    addChild(child3, child7); // 将 child7 添加为 child3 的子节点

    // 打印树结构
    printTree(root, 0); // 从根节点开始打印

    // 释放内存
    freeTree(root); // 释放整棵树的内存

    return 0; // 程序结束
}

在上面的程序中先创建了若干个树节点,然后通过 addChild 函数将子节点添加到了父节点对应的vector容器中,通过这种方式能够非常轻松的基于父节点找到它所有的子节点,但是想要通过子节点访问其父节点就变得麻烦了。那么有没有一种方法既可以快速的通过子节点访问到它的父节点并且能够通过父节点快速访问到它的所有的子节点呢?

当然有,就是孩子双亲表示法,也就是在孩子表示法的节点基础上再添加一个指向双亲的数据域:

struct TreeNode 
{
    int value; 
    TreeNode* parent;
    std::vector<TreeNode*> children;
};

value:节点的值,可以根据实际需求修改为其他数据类型
parent:记录当前节点的父节点的位置(地址)。
children:子节点列表,存储的是当前节点所有的子节点

做了这样的修改之后,可以在程序中再添加一个setParent方法,用于给各个节点设置父节点(根节点的父节点可以指定为 nullptr),代码比较简单,此处就略过了,可以自己私下写一写。

2.3 孩子兄弟表示法

在孩子兄弟表示法中,树被转化为了一种特殊的树,我们可以做这样的约定:每个节点的左侧子节点表示该节点的第一个子节点,而右侧子节点表示该节点的下一个兄弟节点。也就是说在内存中这棵树的存储结构和实际的逻辑结构是不一样的。

根据描述我们可以定义这样的一个树节点:

// 定义树节点结构体
typedef struct TreeNode {
    int value;                     // 节点的值
    struct TreeNode* firstChild;   // 指向第一个子节点
    struct TreeNode* nextSibling;   // 指向下一个兄弟节点
} TreeNode;

value:节点的值,可以根据实际需求修改为其他数据类型

firstChild:指向第一个子节点(地址)

nextSibling:指向下一个兄弟节点(地址)

如果想要在内存中存储这样的一棵树,我们需要使用孩子兄弟表示法对其进行转换可以得到下面这个图:

在上面的图中红色线表示节点之间的关系为父子,绿色的线表示节点之间的关系为兄弟,但是这样看起来似乎还是不太直观,我们来换一种画法:

图中的左侧节点(橙色)表示和父节点之间原来的实际关系为父子,右侧节点(黄色)表示节点和父节点之间原来的实际关系为兄弟。可见内存中存储的树结构和实际的树结构已经完全不一样了,但是树节点中存储的属性,我们完全可以把原来的树还原出来。

示例C++代码如下:

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

// 定义树节点结构体
typedef struct TreeNode {
    int value;                     // 节点的值
    struct TreeNode* firstChild;   // 指向第一个子节点
    struct TreeNode* nextSibling;   // 指向下一个兄弟节点
} TreeNode;

// 创建新的树节点
TreeNode* createNode(int val) {
    // 分配内存并检查是否成功
    TreeNode* newNode = (TreeNode*)malloc(sizeof(TreeNode));
    if (!newNode) { // 检查内存分配是否成功
        fprintf(stderr, "Memory allocation failed\n");
        exit(EXIT_FAILURE); // 失败则退出程序
    }

    newNode->value = val;        // 设置节点的值
    newNode->firstChild = NULL;  // 初始化第一个子节点为 NULL
    newNode->nextSibling = NULL;  // 初始化下一个兄弟节点为 NULL

    return newNode; // 返回新创建的节点
}

// 添加子节点的函数
void addChild(TreeNode* parent, TreeNode* child) {
    // 如果父节点没有子节点,则将新节点设置为第一个子节点
    if (!parent->firstChild) {
        parent->firstChild = child;
    }
    else {
        // 否则,找到最后一个兄弟节点,并将新节点添加到最后
        TreeNode* sibling = parent->firstChild;
        while (sibling->nextSibling) {
            sibling = sibling->nextSibling; // 遍历到最后一个兄弟节点
        }
        sibling->nextSibling = child; // 将新节点添加为最后一个兄弟
    }
}

// 打印树的函数
void printTree(TreeNode* node) {
    if (node == NULL) return; // 如果节点为空,直接返回

    // 打印当前节点的值
    printf("当前节点为: %d", node->value);
    if (node->firstChild) {
        // 如果当前节点有子节点,打印子节点的值
        printf(", %d的子节点为: ", node->value);
        printf("%d", node->firstChild->value);
        TreeNode* sibling = node->firstChild->nextSibling; // 获取第一个子节点的下一个兄弟
        while (sibling) {
            printf(", %d", sibling->value); // 打印所有兄弟节点的值
            sibling = sibling->nextSibling; // 继续遍历兄弟节点
        }
    }
    else {
        // 如果没有子节点,输出相应信息
        printf(", %d没有子节点!", node->value);
    }
    printf("\n"); // 换行输出

    // 递归打印子节点和下一个兄弟节点
    printTree(node->firstChild);
    printTree(node->nextSibling);
}

// 释放树的内存
void freeTree(TreeNode* node) {
    if (node == NULL) return; // 如果节点为空,直接返回

    // 递归释放子节点
    freeTree(node->firstChild);
    // 递归释放下一个兄弟节点
    freeTree(node->nextSibling);
    free(node); // 释放当前节点的内存
}

int main() {
    // 创建节点
    TreeNode* root = createNode(1); // 根节点
    TreeNode* child1 = createNode(2); // 第一个子节点
    TreeNode* child2 = createNode(3); // 第二个子节点
    TreeNode* child3 = createNode(4); // 第三个子节点
    TreeNode* child1_1 = createNode(5); // 第一个子节点的子节点
    TreeNode* child2_1 = createNode(6); // 第二个子节点的子节点
    TreeNode* child2_2 = createNode(7); // 第二个子节点的子节点
    TreeNode* child2_3 = createNode(8); // 第二个子节点的子节点
    TreeNode* child3_1 = createNode(9); // 第三个子节点的子节点

    // 构建树
    addChild(root, child1); // 将 child1 添加为 root 的子节点
    addChild(root, child2); // 将 child2 添加为 root 的子节点
    addChild(root, child3); // 将 child3 添加为 root 的子节点
    addChild(child1, child1_1); // 将 child1_1 添加为 child1 的子节点
    addChild(child2, child2_1); // 将 child2_1 添加为 child2 的子节点
    addChild(child2, child2_2); // 将 child2_2 添加为 child2 的子节点
    addChild(child2, child2_3); // 将 child2_3 添加为 child2 的子节点
    addChild(child3, child3_1); // 将 child3_1 添加为 child3 的子节点

    // 打印树结构
    printTree(root);

    // 释放内存
    freeTree(root);

    return 0; // 程序结束
}

程序输出的结果如下:

当前节点为: 1, 1的子节点为: 2, 3, 4
当前节点为: 2, 2的子节点为: 5
当前节点为: 5, 5没有子节点!
当前节点为: 3, 3的子节点为: 6, 7, 8
当前节点为: 6, 6没有子节点!
当前节点为: 7, 7没有子节点!
当前节点为: 8, 8没有子节点!
当前节点为: 4, 4的子节点为: 9
当前节点为: 9, 9没有子节点!

``
在程序中构建的树和上面的例子是一样的,我们是是通过孩子兄弟表示法进行了存储,并且在打印的时候又还原了这棵树,通过对比可以确认结果是没问题的。

孩子兄弟表示法就是充分利用了二叉树的特性和算法来处理一棵非二叉树,那么,什么样的树可以被称之为二叉树呢?

第八章 第二节:二叉树以及它的形态、性质、存储结构和遍历

1. 二叉树

‌二叉树是一种树形数据结构,其中每个节点最多有两个子节点,分别称为‌左子节点和‌右子节点。二叉树由一个根节点和两棵互不相交的子树组成,这两棵子树分别称为根的左子树和右子树。二叉树的定义可以递归地描述:二叉树是一个有限的节点集合,这个集合可以是空集(即没有节点),或者由一个根节点和两棵互不相交的二叉树组成。‌

1.1 二叉树的特点和形态

二叉树有三个特点依次是:

每个节点最多有两棵子树,所以二叉树中不存在度大于2的节点
左子树和右子树是有顺序的,次序不能颠倒,也就是说二叉树是有序树
即使树中某节点只有一棵子树,也要区分它是左子树还是右子树
基于以上描述,我们对二叉树的形态做了归纳,一共有五种形态,分别是:

空二叉树
只有一个根节点
根节点只有左子树
根节点只有右子树
根节点既有左子树,又有右子树
以上五种形态应该是比较容易理解的,如果加大难度,大家可以思考一下,如果是有三个节点的二叉树,它一共有几种形态呢?

是的,一共有五种形态,上图中第一种和第二种二叉树比较特殊,它们只有左子树或者只有右子树,和线性表已经无异了。

1.2 特殊的二叉树

斜树
斜树顾名思义一定要是斜的,它一共有两种形态,就是上图中的1和2。

所有节点都只有左子树的二叉树叫左斜树
所有节点都只有右子树的二叉树叫右斜树
斜树有很明显的特点,就是每一层都只有一个节点,节点的个数与二叉树的深度相同。

通过上图可以看到,不论是左斜树还是右斜树和线性表的的结构是完全相同的,我们可以这样理解:线性表结构是树的一种极特殊的表现形式。

满二叉树
满二叉树就是一棵完美的二叉树,也就是在一颗二叉树中所有分支节点都存在左子树和右子树,并且所有的叶子都在同一层。

上图为大家展示的就是一棵满二叉树。满二叉树有三个特点:

叶子节点只能出现在最下层
所有的非叶子节点的度一定都是 2
在同样深度的二叉树中,满二叉树的节点个数最多,叶子数量最多

完全二叉树

完全二叉树是满二叉树的简配版本,对于深度为k的二叉树,如果其节点个数为n,且每个节点都与深度为k的满二叉树中编号从1到n的节点一一对应,那么这棵二叉树就是完全二叉树。

具体来说,完全二叉树除了最后一层外,每一层上的节点数都达到了最大值,而在最后一层上,节点是连续缺少的,也就是只缺少右边的若干节点。满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满二叉树。

对于完全二叉树而言,有以下五个特点:

叶子节点只能出现在最下面两层
最下层的叶子一定集中在左侧部分连续位置
倒数第二层如有叶子节点,一定都在右部连续位置
如果节点度为1,那么该节点一定只有左孩子,不存在只有右子树的情况
同样节点数的二叉树,完全二叉树深度最小
基于完全二叉树这种结构特点,使得在查找和访问节点时,可以通过简单的数组索引或类似机制快速定位到任何节点,从而提高了数据处理的效率。‌

2. 二叉树的性质

二叉树有一些需要理解并且记住的特性,以便我们更好的使用它。

性质1:二叉树的第 i 层上至多有 2i-1(i≥1)个节点 。

第1层是根节点,只有一个,2i-1 = 20 = 1。
第2层最多有两个节点,22-1 = 21 = 2。
第3层最多有四个节点,23-1 = 22 = 4。
第4层最多有八个节点,24-1 = 23 = 8。

性质2:深度为 k 的二叉树中至多含有 2k-1 个节点(k≥1) 。

如果只有1层,只有一个根节点,至多有1个节点,2k-1 = 21-1 = 1
如果有2层,最多的节点数为 1+2 = 2k-1 = 22-1 = 3
如果有3层,最多的节点数为 1+2+4 = 2k-1 = 23-1 = 7
如果有4层,最多的节点数为 1+2+4+8 = 2k-1 = 24-1 = 15

性质3:若在任意一棵二叉树中,叶子节点有 n0 个,度为2的节点有 n2个,则 n0=n2+1 。

在上图中度为2的节点数为5,叶子节点数量为6,满足 n0=n2+1
在上图中去掉节点L,度为2的节点数为5,叶子节点数量为6,满足 n0=n2+1
在上图中去掉节点K、L,度为2的节点数为4,叶子节点数量为5,满足 n0=n2+1

性质4:具有n个节点的完全二叉树的深度为 ⌊log2n⌋+1(⌊ ⌋ 表示向下取整)。

如上图所示,完全二叉树有12个节点,深度为:⌊log212⌋+1 = 3+1 = 4
如果在上图基础上再增加4个节点,深度为:⌊log216⌋+1 = 4+1 = 5

性质5:若对一棵有 n 个节点的完全二叉树的节点按层序进行编号(从第1层到第 ⌊log2n⌋+1层,每层从左到右),那么,对于编号为i(1≤i≤n)的节点:

当i=1时,该节点为根,它无双亲节点 。

当i>1时,该节点的双亲节点的编号为i/2 。
    对于上图而言,编号为7(G)的节点,其父节点为 3(C)
    对于上图而言,编号为5(E)的节点,其父节点为 2(B)
    对于上图而言,编号为11(K)的节点,其父节点为 5(E)

若2i≤n,则有编号为2i的左节点,否则没有左节点 。
    上图中的G(7)、H(8)、I(9)、J(10)、K(11)、L(12) 乘以2 > n(12),它们没有左节点
    上图中的A(1)、B(2)、C(3)、D(4)、E(5)、F(6) 乘以2 ≤ n(12),它们有左节点

若2i+1≤n,则有编号为2i+1的右节点,否则没有右节点。
    上图中的节点F,2*(F)+1 = 13 > n(12),它没有右节点
    上图中的A(1)、B(2)、C(3)、D(4)、E(5) 乘以2 + 1 < n(12),它们有右节点

3. 二叉树的存储结构

3.1 顺序存储

二叉树的顺序存储结构就是用一维数组存储二叉树中的节点,并且通过数组是下标来描述节点之间的逻辑关系,比如双亲和孩子的关系,左右兄弟的关系等。下面通过一棵完全二叉树为大家举例说明:

如果从数组的1号位置开始存储数据,二叉树的节点在数组的位置应该是这样的:

假设父节点位置为 i,左孩子位置为 2i,右孩子位置为 2i+1
假设左孩子节点位置为 i,右兄弟位置为 i+1,父节点位置为 i/2
假设右孩子节点位置为 i,左兄弟位置为 i-1,父节点位置为 i/2

如果从数组的0号位置开始存储数据,二叉树的节点在数组的位置应该是这样的:

假设父节点位置为 i,左孩子位置为 2i+1,右孩子位置为 2i+2
假设左孩子节点位置为 i,右兄弟位置为 i+1,父节点位置为 (i-1)/2
假设右孩子节点位置为 i,左兄弟位置为 i-1,父节点位置为 (i-1)/2

如果存储的二叉树不是完全二叉树,此时在数组中应该如何存储二叉树的节点呢?

在上面这棵二叉树中有很多节点缺失了,为了看起来更直观,我们使用灰色将其画了出来,在存储这棵普通的二叉树的时候有两种方式:

将A、B、C、F、G、L按顺序依次存储到数组中
使用完全二叉树的方式来存储这棵普通的二叉树数据
通过认真分析考虑之后,相信大家选择的都是后者,只有通过这种方式才能通过父节点找到左右孩子节点或者通过左右孩子节点找到它们的父节点。

通过上面的表可以清晰的看到使用顺序存储的方式存储二叉树会造成内存的浪费,尤其是当二叉树变成一棵右斜树的时候,浪费的内存空间是最多的。所以顺序存储结构一般只用于存储完全二叉树。

3.2 链式存储

既然顺序存储结果有弊端,我们在来看一下链式存储结果能不能弥补这个缺陷。由于二叉树每个节点最多只有两个孩子,所以为每个树节点设计一个数据域和两个指针域,通过这种方式组成的链表我们称其为二叉链表。

typedef struct BinaryTreeNode {
    char data; // 节点存储的数据
    struct BinaryTreeNode* left; // 指向左子树的指针
    struct BinaryTreeNode* right; // 指向右子树的指针
} BTreeNode;
data:存储节点数据,可以根据实际需求指定它的类型。

lchild:存储左侧孩子节点的地址。

rchild:存储右侧孩子节点的地址。

可以看到通过链式存储的方式存储二叉树不存在内存浪费的问题,它适用于任何形式的二叉树。使用二叉链表可以快速的通过父节点访问它的左右孩子节点,但是想要通过孩子节点访问父节点就比较麻烦了,解决方案也比较简单我们可以给链表节点再添加一个指针域,让它指向当前节点的父节点:

struct TreeNode
{
    int data;
    TreeNode* lchild;
    TreeNode* rchild;
    TreeNode* parent;
};
data:存储节点数据,可以根据实际需求指定它的类型。
lchild:存储左侧孩子节点的地址。
rchild:存储右侧孩子节点的地址。
parent:存储双亲节点(父节点)的地址。

通过这种节点组成的链表我们将其称之为三叉链表。

4. 二叉树的遍历

4.1 二叉树的遍历方法

二叉树的遍历是指从根节点出发,按照某种次序依次访问二叉树中的所有节点,使得每个节点被访问一次并且仅被访问一次。

二叉树的次序遍历不同于线性结构,线性结构比较简单通过循序从头到尾进行访问即可。由于树节点之间不存在唯一的前驱和后继关系,在访问一个节点之后,下一个被访问的节点就面临着不同的选择,选择不同,被访问到的节点的顺序也不一样。

由于树的递归定义的,所以树的遍历一般也是通过递归的方式实现,通过递归的方式对树进行遍历一共有三种方式:

前序遍历:先访问根节点,然后前序遍历左子树,最后前序遍历右子树
中序遍历:中序遍历左子树,然后访问根节点,最后中序遍历右子树
后序遍历:后序遍历左子树,然后后序遍历右子树,最后访问根节点

通过三种遍历方式的定义可知,前、中、后其实是相对于根节点而言的,并且二叉树的左子树和右子树的遍历也是递归的,同样遵循前序、中序、后序的规则,在遍历过程中如果树为空,直接返回,递归结束。

4.2.1前序遍历

了解了前序遍历的规则之后,我们来分析一下上面这棵二叉树通过前序遍历之后节点的访问顺序是什么?

先访问根节点A

然后遍历左子树
    访问根节点B
        遍历以B为根节点的左子树,遍历以D为根节点的左子树
            访问根节点D,访问左子树G,访问右子树H

最后遍历右子树
    访问根节点C
        遍历以C为根节点的左子树,遍历以E为根节点的左子树
            访问根节点E,访问左子树 nullptr,访问右子树I
        遍历以C为根节点的右子树,访问根节点F

所以通过前序遍历的方式,树节点的访问顺序为:A、B、D、G、H、C、E、I、F

4.2.2中序遍历

还是这棵树,如果换成中序遍历,节点的访问顺序就不一样了,我们来根据遍历的规则分析一下:

先遍历左子树
    遍历以B为根节点的左子树,遍历以D为根节点的左子树
        访问左叶子节点G,访问根节点D,访问右叶子节点H
    访问根节点的左孩子节点B

然后访问根节点A

最后遍历右子树
    遍历以C为根节点的左子树,遍历以E为根节点的左子树
        访问左叶子节点nullptr,访问根节点E,访问右叶子节点I
        访问根节点C,遍历右子树,访问根节点F

所以通过中序遍历的方式,二叉树节点的访问顺序为:G、D、H、B、A、E、I、C、F

4.2.3后序遍历

如果使用遍历的方式遍历这棵树,得到的遍历顺序又会发生变化,其顺序为:

先遍历左子树
    遍历以B为根节点的左子树,遍历以D为根节点的左子树
        访问左叶子节点G,访问右叶子节点H,访问根节点D
    遍历以B为根节点的右子树为 nullptr,访问根节点B

然后遍历右子树
    遍历以C为根节点的左子树,遍历以E为根节点的左子树
        访问左叶子节点nullptr,访问右叶子节点I,访问根节点E
        遍历右子树,访问根节点F,访问根节点C

最后访问根节点A

所以通过后序遍历的方式,二叉树节点的访问顺序为:G、H、D、B、I、E、F、C、A

4.2 二叉树的创建和遍历

4.2.1直接构建二叉树

想要构建一棵二叉树最简单的方式就是创建若干个节点,然后按照父子、兄弟关系把它们通过指针连接到一起,代码如下:

int main() 
{
    // 创建二叉树
    TreeNode* root = new TreeNode(1);
    TreeNode* node1 = new TreeNode(2);
    TreeNode* node2 = new TreeNode(3);
    TreeNode* node3 = new TreeNode(4);
    TreeNode* node4 = new TreeNode(5);
    TreeNode* node5 = new TreeNode(6);
    TreeNode* node6 = new TreeNode(7);

    root->left = node1;
    root->right = node2;
    node1->left = node3;
    node1->right = node4;
    node2->left = node5;
    node2->right = node6;

    cout << "先序遍历: ";
    // preOrderTraversal(root);
    cout << endl;

    cout << "中序遍历: ";
    // inOrderTraversal(root);
    cout << endl;

    cout << "后序遍历: ";
    // postOrderTraversal(root);
    cout << endl;

    delete root;
    delete node1;
    delete node2;
    delete node3;
    delete node4;
    delete node5;
    delete node6;

    return 0;
}

这种创建二叉树的方法特点是简单,但是不够灵活。

4.2.2通过终端输入构建二叉树

如果想要灵活地创建出一个二叉树,可以让用户通过终端输入的方式指定出节点的值以及节点和节点之间的关系,但是有一个细节需要进行处理,就是某些节点没有左孩子或者右孩子。因此,我们可以做这样的一个约定:如果节点的左孩子或者右孩子为空,那么在输入的时候就使用 # 来表示。

准备工作做好之后,我们就可以生成一棵二叉树了,假设二叉树的节点值是一个字符,对应的代码实现如下:

/ 创建先序树
void createPreOrderTree(BTreeNode** root) {
    char ch;
    scanf_s(" %c", &ch, 1); // 使用 scanf_s

    if (ch == '#') { // '#' 表示空节点
        *root = NULL;
    }
    else {
        *root = (BTreeNode*)malloc(sizeof(BTreeNode)); // 分配内存
        if (*root == NULL) {
            fprintf(stderr, "内存分配失败\n");
            exit(1);
        }
        (*root)->data = ch; // 设置节点值
        createPreOrderTree(&(*root)->left); // 递归创建左子树
        createPreOrderTree(&(*root)->right); // 递归创建右子树
    }
}

上面的代码中是通过先序的方式创建了一棵二叉树,即:先创建根节点,然后创建左子树,最后创建右子树。

如果想要通过中序或者后序的方式创建一棵二叉树,其实也非常简单,只需要对代码做很小的改动:


// 创建中序树
void createInOrderTree(BTreeNode** root) {
    char ch;
    scanf_s(" %c", &ch, 1); // 使用 scanf_s

    if (ch == '#') { // '#' 表示空节点
        *root = NULL;
    }
    else {
        *root = (BTreeNode*)malloc(sizeof(BTreeNode)); // 分配内存
        if (*root == NULL) {
            fprintf(stderr, "内存分配失败\n");
            exit(1);
        }
        createInOrderTree(&(*root)->left); // 递归创建左子树
        (*root)->data = ch; // 设置节点值
        createInOrderTree(&(*root)->right); // 递归创建右子树
    }
}

// 创建后序树
void createPostOrderTree(BTreeNode** root) {
    char ch;
    scanf_s(" %c", &ch, 1); // 使用 scanf_s

    if (ch == '#') { // '#' 表示空节点
        *root = NULL;
    }
    else {
        *root = (BTreeNode*)malloc(sizeof(BTreeNode)); // 分配内存
        if (*root == NULL) {
            fprintf(stderr, "内存分配失败\n");
            exit(1);
        }
        createPostOrderTree(&(*root)->left); // 递归创建左子树
        createPostOrderTree(&(*root)->right); // 递归创建右子树
        (*root)->data = ch; // 设置节点值
    }
}

现在我们已经可以根据意愿创建出一棵属于自己的二叉树,接下来就是对其进行遍历了。

4.2.3遍历二叉树

根据前面的讲解我们已经知道了遍历二叉树有三种方式,并且它们是递归的,在代码编写过程中我们要注意递归结束的条件即当前为空树的时候,想明白这些之后对应的代码实现就非常简单了:

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

// 定义二叉树节点
typedef struct BinaryTreeNode {
    char data; // 节点存储的数据
    struct BinaryTreeNode* left; // 指向左子树的指针
    struct BinaryTreeNode* right; // 指向右子树的指针
} BTreeNode;

// 创建先序树
void createPreOrderTree(BTreeNode** root) {
    char ch;
    scanf_s(" %c", &ch, 1); // 使用 scanf_s

    if (ch == '#') { // '#' 表示空节点
        *root = NULL;
    }
    else {
        *root = (BTreeNode*)malloc(sizeof(BTreeNode)); // 分配内存
        if (*root == NULL) {
            fprintf(stderr, "内存分配失败\n");
            exit(1);
        }
        (*root)->data = ch; // 设置节点值
        createPreOrderTree(&(*root)->left); // 递归创建左子树
        createPreOrderTree(&(*root)->right); // 递归创建右子树
    }
}

// 创建中序树
void createInOrderTree(BTreeNode** root) {
    char ch;
    scanf_s(" %c", &ch, 1); // 使用 scanf_s

    if (ch == '#') { // '#' 表示空节点
        *root = NULL;
    }
    else {
        *root = (BTreeNode*)malloc(sizeof(BTreeNode)); // 分配内存
        if (*root == NULL) {
            fprintf(stderr, "内存分配失败\n");
            exit(1);
        }
        createInOrderTree(&(*root)->left); // 递归创建左子树
        (*root)->data = ch; // 设置节点值
        createInOrderTree(&(*root)->right); // 递归创建右子树
    }
}

// 创建后序树
void createPostOrderTree(BTreeNode** root) {
    char ch;
    scanf_s(" %c", &ch, 1); // 使用 scanf_s

    if (ch == '#') { // '#' 表示空节点
        *root = NULL;
    }
    else {
        *root = (BTreeNode*)malloc(sizeof(BTreeNode)); // 分配内存
        if (*root == NULL) {
            fprintf(stderr, "内存分配失败\n");
            exit(1);
        }
        createPostOrderTree(&(*root)->left); // 递归创建左子树
        createPostOrderTree(&(*root)->right); // 递归创建右子树
        (*root)->data = ch; // 设置节点值
    }
}

// 先序遍历
void preOrderTraversal(BTreeNode* root) {
    if (root) {
        printf("%c ", root->data); // 访问当前节点
        preOrderTraversal(root->left); // 遍历左子树
        preOrderTraversal(root->right); // 遍历右子树
    }
}

// 中序遍历
void inOrderTraversal(BTreeNode* root) {
    if (root) {
        inOrderTraversal(root->left); // 遍历左子树
        printf("%c ", root->data); // 访问当前节点
        inOrderTraversal(root->right); // 遍历右子树
    }
}

// 后序遍历
void postOrderTraversal(BTreeNode* root) {
    if (root) {
        postOrderTraversal(root->left); // 遍历左子树
        postOrderTraversal(root->right); // 遍历右子树
        printf("%c ", root->data); // 访问当前节点
    }
}

// 释放树的内存
void freeTree(BTreeNode* root) {
    if (root) {
        freeTree(root->left); // 释放左子树
        freeTree(root->right); // 释放右子树
        free(root); // 释放当前节点
    }
}

int main() {
    printf("前序输入二叉树(必须按照二叉树的结构进行输入,#代表空:)\n");

    BTreeNode* root = NULL; // 初始化根节点为 NULL
    createPreOrderTree(&root); // 创建先序树

    printf("先序遍历: ");
    preOrderTraversal(root); // 先序遍历
    printf("\n");

    printf("中序遍历: ");
    inOrderTraversal(root); // 中序遍历
    printf("\n");

    printf("后序遍历: ");
    postOrderTraversal(root); // 后序遍历
    printf("\n");

    // 释放分配的内存
    freeTree(root);

    return 0;
}

通过上面的三个遍历函数可以看到,函数体内部的代码是极其相似的,只是访问根节点的时机不同罢了,左右子树都是先遍历左子树再遍历右子树,对于子树的遍历也是相同的规则,因此使用递归是遍历二叉树最简单的一种处理方式。

4.2.4释放树节点

因为在上面的代码中二叉树的节点是动态创建的,所以在程序的最后还需要释放节点资源,关于释放的顺序应该是先释放子节点然后释放父节点,所以对应的遍历方式应该是后序遍历。最后,基于这种方式我们就可以通过递归的方式释放整棵二叉树。

对应的代码如下:

// 释放树的内存
void freeTree(BTreeNode* root) {
    if (root) {
        freeTree(root->left); // 释放左子树
        freeTree(root->right); // 释放右子树
        free(root); // 释放当前节点
    }
}

最后再次给大家强调一下,不论是使用先序遍历、中序遍历还是后序遍历的方式创建二叉树,在释放资源的时候一定使用的是后续遍历的方式,也就是从下往上,这一点相信大家能够想明白。

【补充1】线索二叉树

【补充2】树、森林与二叉树的转换

【补充3】哈夫曼树及其应用

posted on 2024-08-24 21:10  冰睛慕枫  阅读(113)  评论(0)    收藏  举报