数据结构与算法之美_23_二叉树基础(上):什么样的二叉树适合用数组来存储?

旧街凉风 提交于 2020-04-21 20:33:34

前面我们学习的都是线性表结构,栈、队列等等。今天来学习一种非线性表结构,树。

二叉树有几种存储方式?什么样的二叉树适合用数组来存储?

树(Tree)

image

“树”这种数据结构很像我们生活中的“树”,这里面每个元素叫作“节点”;用来连接相邻节点之间的关系,叫作“父子关系”。

下面的图中,A 节点就是 B 节点的父节点,B 节点是 A 节点的子节点。B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点。我们把没有父节点的节点叫作根节点,也就是图中节点 E。把没有子节点的节点叫作叶子结点或者叶节点,比如图中的 G、H、I、J、K、L 都是叶子节点。

image

树还有三个比较相似的概念:高度、深度、层。

  • 节点的高度:节点到叶子节点的最长路径(边数)
  • 节点的深度:根节点到这个节点所经历的边的个数
  • 节点的层数:节点的深度 + 1
  • 树的高度:根节点的高度

image

小窍门:

  • “高度”:这个概念就是从下往上度量,比如我们要度量第 10 层楼的高度,起点是地面。所以,树的这种结构的高度也是一样,从最底层开始计数,并且计数的起点是 0。
  • “深度”:这个概念在生活中是从上往下度量的,比如水中的鱼的深度,是从水平面开始度量的。所以,树这种数据结构的深度也是类似的,从根节点开始度量,并且计数起点也是 0。
  • “层”:与深度的计算类似,不过,计数的起点是 1,也就是说根节点位于第 1 层。

二叉树(Binary Tree)

二叉树,每个节点最多有两个“叉”,也就是两个节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

image

这个图中,编号 2 和编号 3比较特殊。

编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树。

编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树。

image

满二叉树的特征非常明显,但是完全二叉树却并没有特别特殊的地方,而且为什么偏偏把最后一层的叶子节点靠左排列的叫作完全二叉树呢?如果靠右排列就不能叫作完全二叉树了吗?这个定义的由来或者说目的在哪里?

要理解完全二叉树的由来,需要先了解,如何表示(或存储)一颗二叉树?

一种是基于指针或者引用的二叉树链式存储法,一种是基于数组的顺序存储法。

  • 链式存储法:每个节点有三个字段,一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式比较常用。大部分二叉树代码都是通过这种结构来实现的。

image

  • 基于数组的顺序存储法:把根节点存储在下标 i = 1 的位置,那左节点存储在下标为 2 * i = 2 的位置,右节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左节点存储在 2 * i = 4 的位置,右节点存储在 2 * i + 1 = 5 的位置。

image

总结下,如果节点 X 存储在数组下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i / 2 的位置存储的就是它的父节点。通过这种方式,我们只要知道根节点的存储位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。

刚才举的例子是一颗完全二叉树,所以仅仅会“浪费”一个下标为 0的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。如下图所示的例子。

image

所以,如果某颗二叉树是一颗完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么要单独拎出来讲完全二叉树的原因,也是为什么完全二叉树要求的最后一层的子节点都靠左的原因。

在以后讲堆和堆排序时,就会发现,堆实际上也是一种完全二叉树,最常用的存储方式就是数组。

二叉树的遍历

二叉树遍历时非常重要的操作,也是非常常见的面试题。

经典的方法有三种:

  • 前序遍历:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
  • 中序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
  • 后序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点它本身。

image

实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。

写递归代码的关键,就是写出递推公式,而写递推公式的关键是,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再看如何利用 B、C 来解决 A。

前序遍历的递推公式
preOrder(r) = print r -> preOrder(r->left) -> preOrder(r->right)

中序遍历的递推公式
inOrder(r) = inOrder(r->left) -> print r -> inOrder(r->right)

后续遍历的递推公式
postOrder(r) = postOrder(r->left) -> postOrder(r->right) -> print r

以下是三种遍历的代码:

// 前序遍历
void preOrder(Node root){
    if(root == null){
        return;
    }
    print(root);
    preOrder(root->left);
    preOrder(root->right);
}

// 中序遍历
void inOrder(Node root){
    if(root == null){
        return;
    }
    inOrder(root->left);
    print(root);
    inOrder(root->right);
}

// 后序遍历
void postOrder(Node root){
    if(root == null){
        return;
    }
    postOrder(root->left);
    postOrder(root->right);
    print(root);
}

从前中后序的遍历的顺序图中,可以看出,每个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比。即二叉树遍历的时间复杂度是 O(n)。

解决开篇 & 内容小结

树的几个常用的概念:根节点、叶子节点、子节点、兄弟节点、节点的高度、深度、层数,以及树的高度。

最常用的树就是二叉树。二叉树的每个节点最多有两个子节点,分别是左子节点和右子节点。二叉树中,有两种比较特殊的树,满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况。

二叉树可以使用链式存储法,也可以使用数组顺序存储法。数组顺序存储法的方式比较适合完全二叉树,其他类型的二叉树用数组存储会比较浪费存储空间。除此之外,二叉树里非常重要的操作就是前、中、后序遍历操作,遍历的时间复杂度是 O(n),需要理解并且能用递归代码来实现。

课后思考

  1. 给定一组数据,比如 1、3、5、6、9、10,可以构建出多少种不同的二叉树?

答: 确定两点: 1)n个数,即n个节点,能构造出多少种不同形态的树? 2)n个数,有多少种不同的排列? 当确定以上两点,将【1)的结果】乘以 【2)的结果】,即为最终的结果。 但是有一个注意的点: 如果n中有相等的数,产生的总排列数就不是n!

参考:

  1. 二叉树的遍历除了前、中、后序,实际上还有另一种遍历方式,按照层遍历,如何实现?

答:层序遍历,借用队列辅助即可,根节点先入队列,然后循环从队列中pop节点,将pop出来的节点的左子节点先入队列,右节点后入队列,依次循环,直到队列为空,遍历结束。

代码

Github 二叉树的遍历

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!