vlambda博客
学习文章列表

数据结构 · 树(二叉树)

1. 二叉树

1. 1 二叉树定义

    在一棵普通树中,每个节点可以有任意数量的子节点,而二叉树是一种特殊类型的树数据结构。对于二叉树,每个节点可以有0个子节点,或1个或2个,但是不能超过2个(最多只能是2个子节点),一个被称为左子节点(左子树),另外一个称为右子节点(右子树)。它是一种非常有用的数据结构,广泛用于快速存储排序、快速检索排序等场景。许多高效的检索算法(如: 二分搜索数、红黑树)都是基于二叉树衍生而来。

     二叉树的正式定义:

  • 要么是空树(无节点),或者


  • 由根节点(root)和一个分别称为左子树与右子树的节点组成。


    根据二叉树的定义,可知其具有以下几种形态:

 二叉树几种形态

1. 2 二叉树图示法

     二叉树的典型图示法表示本质上是一棵颠倒的树。从根(root)节点开始,对于根节点的左子树与右子树,它们又可以/可能有自己的子节点。理想情况下,树的结构应为一棵完美平衡的树,即每个节点的左边和右边均是相同数量的子节点,完美平衡的树允许以最快的平均速度插入数据或是检索数据。而最坏的情况是树中的每个节点只有一个子节点,对于这样的树,从速度、效率上来讲,犹如一个链表。

数据结构 · 树(二叉树)

 二叉树形状

2. 二叉树性质

      二叉树作为树抽象数据类型中的一种特殊树结构,必然有着众多的独有特性。正因为这些性质,赋予了二叉树高效的插入与检索性能。以下8个关于二叉树的性质摘自《数据结构 · C语言版》一书。
     性质1: 在二叉树的第i(i >= 1)层上至多有2i-1 (2的i-1次方) 个节点。
     比如对于下图第4层的二叉树,则最多有24-1 = 23 = 8个(这是完全二叉的情况),不会超过8个节点。

数据结构 · 树(二叉树)

 二叉树
          性质2: 深度为k(k >= 1)的二叉树上至多有2k-1 (2的k次方 - 1) 个节点。
     性质3:任意一棵二叉树中,叶子节点的数目(用 t 表示)总数比度为2的节点的数目(用d表示)多一个, 即 t = d + 1.
     性质4: 具有n个节点的完全二叉树的深度为 log2n + 1.
     性质5: 有n个节点的完全二叉树,若按照从上至下、从左向右的顺序对二叉树中的所有节点从1开始顺序编号,则对于序号为i(1<= i <= n)的节点,有:
  • 其双亲节点的编号为 i/2(1 < i <= n);
  • 其左子树节点的编号为 2i(1 <= i <= n/2);
  • 其右子树节点的编号为 2i + 1(1 <= i <= (n-1) / 2).
     性质6: 当节点个数n为偶数时候,完全二叉树中有且仅有一个度为1的节点;当节点个数n为奇数时候,完全二叉树中没有度为1的节点, 即t = (n + 1) % 2.
     性质7: 在完全二叉树中编号大于 n/2 的节点均为叶子节点。备注:n个节点数量。
     性质8: 具有n个节点的二叉链表中一定有n+1个空链域。

3. 二叉树分类

      哈希、网络流量的路由数据、数据压缩和二进制搜索树均属于二叉树的应用程序。根据特性的差异,二叉树又可细分为其他多种类型,常见的二叉树抽象数据类型(ADT)有以下五种。

3. 1 退化二叉树(Degenerate Binary Tree)

     所谓退化二叉树是指该二叉树中,每个节点只有一个子节点。这样的二叉树无论外形,还是性能方面,均类似于链表。如下图所示:

数据结构 · 树(二叉树)

 退化二叉树

3. 2 平衡二叉树(Balanced Binary Tree)

     只有平衡良好的搜索树才能提供最优的搜索性能。这个在后面的搜索算法中会着重讲解该原理。所谓平衡二叉树,即“任何一个节点的两个子树的高度最多相差一个。”通俗讲就是每个节点的左子树和右子树的高度最多可相差1。下图便是一个标准的平衡二叉树。

数据结构 · 树(二叉树)

 平衡二叉树
      注意,平衡二叉树定义中的“平衡”,不包括“结点左右子树的大小。” 下图中的二叉树均为合理且满足定义的平衡二叉树。左图中的子树比右子树大,但是两个子树的高度差为1。右图是左右子树大小相同的高度差为0的平衡二叉树。

数据结构 · 树(二叉树)

 平衡二叉树

     定义中指明“高度最多可相差一个。”为何不是高度相等?难道不应该要求零高度差来达到完美的平衡吗?” 让我们来看这样一个例子,对于简单的两节点的树,如下图:

数据结构 · 树(二叉树)

 两节点树形状

      对于上图中的左图,左子树是单个节点,高度为1;右“子树”为空,因此高度是零。或对于右图,右子树是单节点,高度为1,而左“子树”为空,高度为0。对于这样的树,无法使其达到零高度差的完美平衡,除非另添加一个“假”节点(第3节点)。但是这样勉强的为了达到完美平衡而增加一个非真实有用的数据节点并不会让我们从中受益,此外,还会多耗费一个节点的内存空间。

     像下面这样任何一个节点的两个子树高度差超过1的二叉树, 则不是平衡二叉树。

数据结构 · 树(二叉树)

 非平衡二叉树

3. 3 满二叉树(Perfect Binary Tree)

      满二叉树又称为“完美二叉树。”因为它除了叶子节点外,所有的内部节点都有严格的两个子节点,并且每一个外部或叶节点在一个树的相同层次或相同深度。高度为h的完美二叉树有2h - 1个节点。下图是一个满二叉树(完全二叉树)。

数据结构 · 树(二叉树)

 满二叉树

3. 4 完全二叉树(Complete Binary Tree)

      完全二叉树是一种特殊的二叉树。对于一棵二叉树,假如其深度位d(d > 1),除了第d层(即叶子节点层)外外,其他各层的节点树目均已达到最大值,且第d层所有节点从左往右连续紧密排列。如下图所示:

数据结构 · 树(二叉树)

 完全二叉树

3. 5 全二叉树(Full Binary Tree)

      对于全二叉树,其定义是:
A binary tree in which each node has exactly zero or two children.
即:一棵二叉树,其中每个节点有0个或两个子节点。
它是一种特殊的二叉树,它要么有零个子树,要么有两个子树。这意味着该二叉树中的所有节点要么有其父节点的两个子节点,要么父节点本身就是叶节点或外部节点。
     换句话说,全二叉树是唯一的二叉树,除了外部节点外每个节点都有两个子节点。当它包含一个子树时,这样的二叉树将不是全二叉树。这里,叶节点的数量等于内部节点的数量加1。即 L= I + 1,其中L是叶节点数,I是内部节点数。

数据结构 · 树(二叉树)

 全二叉树

4. 二叉树遍历  

维基百科」对于树的遍历是这样定义的:
“In computer science, tree traversal (also known as tree search) is a form of graph traversal and refers to the process of visiting (checking and/or updating) each node in a tree data structure, exactly once. Such traversals are classified by the order in which the nodes are visited.” — Wikipedia
翻译:在计算机科学中,树遍历(也称为树搜索)是图形遍历的一种形式,指的是访问(检查和/或更新)树数据结构中的每个节点的过程,只访问一次。这种遍历是按照访问节点的顺序进行分类的
蓝色部分标注的文字,值得仔细阅读,切不可囫囵吞枣、走马观花。二叉树是层次结构,因此,按照访问节点的顺序将树遍历算法大致分为以下两类:
  •  深度优先搜索(DFS)算法
      它从根节点开始,首先访问所选节点尽可能深的一个分支的所有节点,在回溯之前,它以类似的方式访问所有其他分支。根据访问根(root)节点顺序的不同,又可以细分为:先序遍历(Preorder Traversal)、中序遍历(Inorder Traversal)和后序遍历(Postorder Traversal
  • 广度优先搜索(BFS)算法
      从根节点开始,并访问当前深度的所有节点,然后再移动到树中的下一个深度。

4. 1 先序遍历(Preorder Traversal)

      二叉树的先序遍历按照以下三个流程:
  •  先访问根节点(root)
  •  访问根节点的左子树(Left-Tree)
  •  访问根节点的右子树(Right-Tree)
     如图所示:

数据结构 · 树(二叉树)

 先序遍历 - 此图来自 towardsdatascience.com

4. 2 中序遍历(Inorder Traversal)

     二叉树的中序遍历按照以下三个流程:

  •  先访问左子树(Left-Tree)
  •  接着访问根节点(root)
  • 最后访问根节点的右子树(Right-Tree)
     如图所示:

数据结构 · 树(二叉树)

 中序遍历 - 此图来自 towardsdatascience.com

4. 3 后序遍历(Postorder Traversal)

      二叉树的中序遍历按照以下三个流程:
  •  先访问左子树(Left-Tree)
  •  接着访问右子树(Right-Tree)
  •  最后访问根节点(root)
     如图所示:

数据结构 · 树(二叉树)

 后序遍历 - 此图来自 towardsdatascience.com

5. 二叉树存储

        二叉树的表示与实现,同样有两种方式,分别是:动态节点表示(链表)、数组表示(顺序表示)。本节先详细讲解使用数组的顺序存储方式来实现二叉树的存取,下节再对二叉树的链表实现方式进行分析与探讨。

     当用数组来实现二叉树时候,通常节点的编号(下标)从0 - (n - 1),或 1 - n 开始。如下图所示:

数据结构 · 树(二叉树)

 二叉树顺序存储(数组表示)

      更多时候,选择节点的下标从1开始,数组中下标0的位置用于存储树的节点数量 。因为用数组来表示二叉树时候,需要知道节点的总数,然后根据二叉树的相关性质来推导出其左、右子树节点关系与位置。

当节点编号从0开始时,存储到数组中的示意图如下:

数据结构 · 树(二叉树)

  二叉树数组实现-下标从1开始

当节点编号从1开始时,存储到数组中的示意图如下:

 二叉树数组实现-下标从1开始
      从上图可以看到,对于一个非“满二叉树”的树,我们需要在其中添加一些“虚节点”,以使这棵树变成“满二叉树”。然后将该二叉树按照“从上到下,从左至右”的顺序与规律将树中所以节点存储在数组中。由此可知,使用顺序存储方式来实现二叉树时候,对内存空间的造成了极大的浪费。特别是当树中每个节点只有一个单支树(即每个节点的左、右子树均只有一个节点)时候。

当二叉树的节点编号从1开始时,则编号1为二叉树的根节点(root),根据二叉树的 性质5和递归算法 配合使用则可以分别使用“先序、中序与后序”的方式遍历该二叉树。
     这里的示例代码中,使用上图中的二叉树进行demo演示。按照从上往下、从左至右的顺序将该二叉树中的各节点存储到数组后,如下:
const char binary_tree[] = {'\0', 'A', 'B', 'C', 'D', 'E', '\0', 'F'};

该二叉树的节点数量为7个,定义一个全局遍历:int g_tree_node_num = 7; 然后根据二叉树的 性质5 ,分别使用函数 get_left_child_node 和 get_right_child_node 来递归获取根节点的左、右子树,直到遍历到叶子节点(即'\0')情况。
     完整示例代码如下:
#include <stdio.h>#include <stdlib.h>#include <assert.h>
int g_tree_node_num = 7;
/* * fn: get_left_child_node * desc: 获取node_index的左节点索引编号 * date: 2020/12/16/02:40:08 * author: lixiaogang5 * mail: [email protected]*/int get_left_child_node(const char *bin_tree, int node_index){ if('\0' != bin_tree[node_index] && (2 * node_index) <= g_tree_node_num) { return 2 * node_index; }
return -1;}
/** fn: get_right_child_node* desc: 获取node_index的右节点索引编号* date: 2020/12/16/02:41:57* author: lixiaogang5* mail: [email protected]*/int get_right_child_node(const char *bin_tree, int node_index){ if('\0' != bin_tree[node_index] && ((2 * node_index) + 1) <= g_tree_node_num) { return (2 * node_index) + 1; }
return -1;}

/** fn: preorder_traversal* desc: 先序遍历* date: 2020/12/16/02:54:13* author: lixiaogang5* mail: [email protected]*/void preorder_traversal(const char *bin_tree, int node_index){ //判断是否为空节点, 以及索引(编号)是否有效 if(node_index > 0 && '\0' != bin_tree[node_index]) { //1. 打印根节点 printf("%c ", bin_tree[node_index]); //2. 访问左子树 preorder_traversal(bin_tree, get_left_child_node(bin_tree, node_index)); //3. 访问右子树 preorder_traversal(bin_tree, get_right_child_node(bin_tree, node_index)); }}
/** fn: inorder_traversal* desc: 中序遍历* date: 2020/12/16/03:00:10* author: lixiaogang5* mail: [email protected]*/void inorder_traversal(const char *bin_tree, int node_index){ //判断是否为空节点, 以及索引(编号)是否有效 if(node_index > 0 && '\0' != bin_tree[node_index]) { //1. 访问左子树 inorder_traversal(bin_tree, get_left_child_node(bin_tree, node_index)); //2. 打印根节点 printf("%c ", bin_tree[node_index]); //3. 访问右子树 inorder_traversal(bin_tree, get_right_child_node(bin_tree, node_index)); }}
/** fn: postorder_traversal* desc: 后序遍历* date: 2020/12/16/03:05:10* author: lixiaogang5* mail: [email protected]*/void postorder_traversal(const char *bin_tree, int node_index){ //判断是否为空节点, 以及索引(编号)是否有效 if(node_index > 0 && '\0' != bin_tree[node_index]) { //1. 访问左子树 postorder_traversal(bin_tree, get_left_child_node(bin_tree, node_index)); //2. 访问右子树 postorder_traversal(bin_tree, get_right_child_node(bin_tree, node_index)); //3. 打印根节点 printf("%c ", bin_tree[node_index]); }}int main(int argc, char *argv[]){ const char binary_tree[] = {'\0', 'A', 'B', 'C', 'D', 'E', '\0', 'F'};
//1. 先序遍历(binary_tree数组下标编号1为二叉树的根(root)节点. const int node_index = 1; puts("Preorder Traversal:"); preorder_traversal(binary_tree, node_index);
//2. 中序遍历 printf("\nPreorder Traversal:\n"); inorder_traversal(binary_tree, node_index);
//3. 后序遍历 printf("\nPostorder Traversal:\n"); postorder_traversal(binary_tree, node_index); return 0;}


打印结果:

 demo打印结果
除了二叉树的遍历打印外,还有获取二叉树的高度,统计叶子节点个数等常见操作。