计算机程序的思维逻辑 (42) - 排序二叉树

40节介绍了HashMap,41节介绍了HashSet,它们的共同实现机制是哈希表,一个共同的限制是没有顺序,我们提到,它们都有一个能保持顺序的对应类TreeMap和TreeSet,这两个类的共同实现基础是排序二叉树,为了更好的理解TreeMap/TreeSet,本节我们先来介绍排序二叉树的一些基本概念和算法。

基本概念

先来说树的概念,现实中,树是从下往上长的,树会分叉,在计算机程序中,一般而言,与现实相反,树是从上往下长的,也会分叉,有个根节点,每个节点可以有一个或多个孩子节点,没有孩子节点的节点一般称为叶子节点。

二叉树是一个树,但,每个节点最多有两个孩子节点,一左一右,左边的称为左孩子,右边的称为右孩子,我们看两个例子,如下所示:


这两棵树都是二叉树,左边的根节点为5,除了叶子节点外,每个节点都有两个孩子节点,右边的根节点为7,有的节点有两个孩子节点,有的只有一个。

树有一个高度或深度的概念,是从根到叶子节点经过的节点个数的最大值,左边树的高度为3,右边的为5。

排序二叉树也是二叉树,但,它没有重复元素,而且是有序的二叉树,什么顺序呢?对每个节点而言:

  • 如果左子树不为空,则左子树上的所有节点都小于该节点
  • 如果右子树不为空,则右子树上的所有节点都大于该节点

上面的两颗二叉树都是排序二叉树。比如说左边的树,根节点为5,左边的都小于5,右边的都大于5。再看右边的树,根节点为7,左边的都小于7,右边的都大于7,在以3为根的左子树中,其右子树的值都大于3。

排序二叉树有什么优点?如何在树中进行基本操作如查找、遍历、插入和删除呢?我们来看一下基本的算法。

基本算法

查找

排序二叉树有一个很好的优点,在其中查找一个元素是很方便、也很高效的,基本步骤为:

  1. 首先与根节点比较,如果相同,就找到了
  2. 如果小于根节点,则到左子树中递归查找
  3. 如果大于根节点,则到右子树中递归查找

这个步骤与在数组中进行二分查找或者说折半查找的思路是类似的,如果二叉树是比较平衡的,类似上图中左边的二叉树,则每次比较都能将比较范围缩小一半,效率很高。

此外,在排序二叉树中,可以方便的查找最小最大值,最小值即为最左边的节点,从根节点一路查找左孩子即可,最大值即为最右边的节点,从根节点一路查找右孩子即可。

遍历

排序二叉树也可以方便的按序遍历,用递归的方式,用如下算法即可按序遍历:

  1. 访问左子树
  2. 访问当前节点
  3. 访问右子树

比如,遍历访问下面的二叉树:


从根节点开始,但先访问根节点的左子树,一直到最左边的节点,所以第一个访问的是1,1没有右子树,返回上一层,访问3,然后访问3的右子树,4没有左子树,所以访问4,然后是4的右子树6,依次类推,访问顺序就是有序的:1 3 4 6 7 8 9。

不用递归的方式,也可以实现按序遍历,第一个节点为最左边的节点,从第一个节点开始,依次找后继节点。给定一个节点,找其后继节点的算法为:

  • 如果该节点有右孩子,则后继为右子树中最小的节点。
  • 如果该节点没有右孩子,则后继为父节点或某个祖先节点,从当前节点往上找,如果它是父亲节点的右孩子,则继续找父节点,直到它不是右孩子或父节点为空,第一个非右孩子节点的父亲节点就是后继节点,如果找不到这样的祖先节点,则后继为空,遍历结束。

文字描述比较抽象,我们来看个图,以上图为例,每个节点的后继如下图绿色箭头所示:

对每个节点,对照算法,我们再详细解释下:

  • 第一个节点1没有右孩子,它不是父节点的右孩子,所以它的后继节点就是其父节点3。
  • 3有右孩子,右子树中最小的就是4,所以3的后继节点为4。
  • 4有右孩子,右子树中只有一个节点6,所以4的后继节点为6。
  • 6没有右孩子,往上找父节点,它是父节点4的右孩子,4又是父节点3的右孩子,3不是父节点7的右孩子,所以6的后继节点为3的父节点7。
  • 7有右孩子,右子树中最小的是8,所以7的后继节点为8。
  • 8没有右孩子,往上找父节点,它不是父节点9的右孩子,所以它的后继节点就是其父节点9。
  • 9没有右孩子,往上找父节点,它是父节点7的右孩子,接着往上找,但7已经是根节点,父节点为空,所以后继为空。

怎么构建排序二叉树呢?可以在插入、删除元素的过程中形成和保持。

插入

在排序二叉树中,插入元素首先要找插入位置,即新节点的父节点,怎么找呢?与查找元素类似,从根节点开始往下找,其步骤为:

  1. 与当前节点比较,如果相同,表示已经存在了,不能再插入。
  2. 如果小于当前节点,则到左子树中寻找,如果左子树为空,则当前节点即为要找的父节点。
  3. 如果大于当前节点,则到右子树中寻找,如果右子树为空,则当前节点即为要找的父节点。

找到父节点后,即可插入,如果插入元素小于父节点,则作为左孩子插入,否则作为右孩子插入。

我们来看个例子,依次插入7, 3, 4, 1, 9, 6, 8的过程,这个过程如下图所示:

删除

从排序二叉树中删除一个节点要复杂一些,有三种情况:

  1. 节点为叶子节点
  2. 节点只有一个孩子
  3. 节点有两个孩子

我们分别来看下。

如果节点为叶子节点,则很简单,可以直接删掉,修改父节点的对应孩子为空即可。

如果节点只有一个孩子节点,则替换待删节点为孩子节点,或者说,在孩子节点和父节点之间直接建立链接。比如说,在下图中,左边二叉树中删除节点4,就是让4的父节点3与4的孩子节点6直接建立链接。

如果节点有两个孩子,则首先找该节点的后继(根据之前介绍的后继算法,后继为右子树中最小的节点,这个后继一定没有左孩子),找到后继后,替换待删节点为后继的内容,然后再删除后继节点。后继节点没有左孩子,这就将两个孩子的情况转换为了叶子节点或只有一个孩子的情况。

比如说,在下图中,从左边二叉树中删除节点3,3有两个孩子,后继为4,首先替换3的内容为4,然后再删除节点4。

平衡的排序二叉树

从前面的描述中可以看出,排序二叉树的形状与插入和删除的顺序密切相关,极端情况下,排序二叉树可能退化为一个链表,比如说,如果插入顺序为:1 3 4 6 7 8 9,则排序二叉树形状为:


退化为链表后,排序二叉树的优点就都没有了,即使没有退化为链表,如果排序二叉树高度不平衡,效率也会变的很低。

平衡具体定义是什么呢?有一种高度平衡的定义,即任何节点的左右子树的高度差最多为一。满足这个平衡定义的排序二叉树又被称为AVL树,这个名字源于它的发明者G.M. Adelson-Velsky 和 E.M. Landis,在他们的算法中,在插入和删除节点时,通过一次或多次旋转操作来重新平衡树。

在TreeMap的实现中,用的并不是AVL树,而是红黑树,与AVL树类似,红黑树也是一种平衡的排序二叉树,也是在插入和删除节点时通过旋转操作来平衡的,但它并不是高度平衡的,而是大致平衡的,所谓大致是指,它确保,对于任意一条从根到叶子节点的路径,没有任何一条路径的长度会比其他路径长过两倍。红黑树减弱了对平衡的要求,但降低了保持平衡需要的开销,在实际应用中,统计性能高于AVL树。

为什么叫红黑树呢?因为它对每个节点进行着色,颜色或黑或红,并对节点的着色有一些约束,满足这个约束即可以确保树是大致平衡的。

对AVL树和红黑树,它们保持平衡的细节都是比较复杂的,我们就不介绍了,我们需要知道的就是,它们都是排序二叉树,都通过在插入和删除时执行开销不大的旋转操作保持了树的高度平衡或大致平衡,从而保证了树的查找效率。

小结

本节介绍了排序二叉树的基本概念和算法。

排序二叉树保持了元素的顺序,而且是一种综合效率很高的数据结构,基本的保存、删除、查找的效率都为O(h),h为树的高度,在树平衡的情况下,h为log2(N),N为节点数,比如,如果N为1024,则log2(N)为10。

基本的排序二叉树不能保证树的平衡,可能退化为一个链表,有很多保持树平衡的算法,AVL树是第一个,能保证树的高度平衡,但红黑树是实际中使用更为广泛的,虽然只能保证大致平衡,但降低了维持树平衡需要的开销,整体统计效果更好。

与哈希表一样,树也是计算机程序中一种重要的数据结构和思维方式。为了能够快速操作数据,哈希和树是两种基本的思维方式,不需要顺序,优先考虑哈希,需要顺序,考虑树。除了容器类TreeMap/TreeSet,数据库中的索引结构也是基于树的(不过基于B树,而不是二叉树),而索引是能够在大量数据中快速访问数据的关键。

理解了排序二叉树的基本概念和算法,理解TreeMap和TreeSet就比较容易了,让我们在接下来的两节中探讨这两个类。

---------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

时间: 2024-12-25 20:35:02

计算机程序的思维逻辑 (42) - 排序二叉树的相关文章

计算机程序的思维逻辑 (21) - 内部类的本质

内部类 之前我们所说的类都对应于一个独立的Java源文件,但一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类. 为什么要放到别的类内部呢?一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁. 不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的, 每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件. 也就是说,每个内部

计算机程序的思维逻辑 (22) - 代码的组织机制

使用任何语言进行编程都有一个类似的问题,那就是如何组织代码,具体来说,如何避免命名冲突?如何合理组织各种源文件?如何使用第三方库?各种代码和依赖库如何编译连接为一个完整的程序? 本节就来讨论Java中的解决机制,具体包括包.jar包.程序的编译与连接,从包开始. 包的概念 使用任何语言进行编程都有一个相同的问题,就是命名冲突,程序一般不全是一个人写的,会调用系统提供的代码.第三方库中的代码.项目中其他人写的代码等,不同的人就不同的目的可能定义同样的类名/接口名,Java中解决这个问题的方法就是包

计算机程序的思维逻辑 (23) - 枚举的本质

前面系列,我们介绍了Java中表示和操作数据的基本数据类型.类和接口,本节探讨Java中的枚举类型. 所谓枚举,是一种特殊的数据,它的取值是有限的,可以枚举出来的,比如说一年就是有四季.一周有七天,虽然使用类也可以处理这种数据,但枚举类型更为简洁.安全和方便. 下面我们就来介绍枚举的使用,同时介绍其实现原理. 基础 基本用法 定义和使用基本的枚举是比较简单的,我们来看个例子,为表示衣服的尺寸,我们定义一个枚举类型Size,包括三个尺寸,小/中/大,代码如下: public enum Size {

计算机程序的思维逻辑 (29) - 剖析String

上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比较简单直接的,我们来看下. 基本用法 可以通过常量定义String变量 String name = "老马说编程"; 也可以通过new创建String String name = new String("老马说编程"); String可以直接使用+和+=运算符,如: S

计算机程序的思维逻辑 (28) - 剖析包装类 (下)

本节探讨Character类,它的基本用法我们在包装类第一节已经介绍了,本节不再赘述.Character类除了封装了一个char外,还有什么可介绍的呢?它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char,本节详细介绍这些方法以及相关的Unicode知识. 在介绍这些方法之前,我们需要回顾一下字符在Java中的表示方法,我们在第六节.第七节.第八节介绍过编码.Unicode.char等知识,我们先简要回顾一

计算机程序的思维逻辑 (25) - 异常 (下)

上节我们介绍了异常的基本概念和异常类,本节我们进一步介绍对异常的处理,我们先来看Java语言对异常处理的支持,然后探讨在实际中到底应该如何处理异常. 异常处理 catch匹配 上节简单介绍了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一个异常类型,比如说: try{ //可能触发异常的代码 }catch(NumberFormatException e){ System.out.println("not valid number"); }

计算机程序的思维逻辑 (45) - 神奇的堆

前面几节介绍了Java中的基本容器类,每个容器类背后都有一种数据结构,ArrayList是动态数组,LinkedList是链表,HashMap/HashSet是哈希表,TreeMap/TreeSet是红黑树,本节介绍另一种数据结构 - 堆. 引入堆 之前我们提到过堆,那里,堆指的是内存中的区域,保存动态分配的对象,与栈相对应.这里的堆是一种数据结构,与内存区域和分配无关. 堆是什么结构呢?这个我们待会再细看.我们先来说明,堆有什么用?为什么要介绍它? 堆可以非常高效方便的解决很多问题,比如说:

计算机程序的思维逻辑 (43) - 剖析TreeMap

40节介绍了HashMap,我们提到,HashMap有一个重要局限,键值对之间没有特定的顺序,我们还提到,Map接口有另一个重要的实现类TreeMap,在TreeMap中,键值对之间按键有序,TreeMap的实现基础是排序二叉树,上节我们介绍了排序二叉树的基本概念和算法,本节我们来详细讨论TreeMap. 除了Map接口,因为有序,TreeMap还实现了更多接口和方法,下面,我们先来看TreeMap的用法,然后探讨其内部实现. 基本用法 构造方法 TreeMap有两个基本构造方法: public

计算机程序的思维逻辑 (40) - 剖析HashMap

前面两节介绍了ArrayList和LinkedList,它们的一个共同特点是,查找元素的效率都比较低,都需要逐个进行比较,本节介绍HashMap,它的查找效率则要高的多,HashMap是什么?怎么用?是如何实现的?本节详细介绍. 字面上看,HashMap由两个单词组成,Hash和Map,这里Map不是地图的意思,而是表示映射关系,是一个接口,实现Map接口有多种方式,HashMap实现的方式利用了Hash. 下面,我们先来看Map接口,接着看如何使用HashMap,然后看实现原理,最后我们总结分