每周学点大数据 | No.25二叉搜索树回顾(二)

时间:2022-05-07
本文章向大家介绍每周学点大数据 | No.25二叉搜索树回顾(二),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

No.25期

二叉搜索树回顾(二)

Mr. 王:既然提到了有序的状态,那么我们就来谈谈有根树的遍历问题。

小可:我知道,遍历就是访问一个数据集合中的所有数据节点。对树进行访问时,有什么特殊的地方吗?

Mr. 王:有根树和线性的数据结构不一样,遍历线性表只要按照线性表的顺序逐个去访问所有数据就可以了,访问到最后一个数据或者发现后面没有数据之后停止;但有根树是一个多叉的结构,为了能够有效地不漏掉访问每一个节点,我们必须给这种访问指定一个顺序。

在经典的树的遍历算法中,定义了三种顺序——先序、中序和后序。三种情况都可以对整棵二叉树进行遍历,但顺序却是完全不同的。先序遍历对于树的每一棵子树,都先访问它的根,再访问它的左、右两个子树。

中序遍历对于每一棵子树,都先访问它的左子树,然后访问它的根节点,最后再访问它的右子树。后序遍历是先访问一棵子树的根节点的左、右两棵子树,然后再访问它本身:

比如对于下面这棵二叉搜索树:

先序遍历的访问结果为:10,5,1,8,7,9,20,15,17

中序遍历的访问结果为:1,5,7,8,9,10,15,17,20

后序遍历的访问结果为:1,7,9,8,5,17,15,20,10

小可:我发现了一个问题:中序遍历的结果恰好是所有数据从小到大排列的顺序!

Mr. 王:嗯,的确是这样,当我们需要对一棵二叉搜索树上所有的节点进行排序时,对这棵二叉树进行中序遍历,就可以得到结果。这就是二叉搜索树可以有效地让数据进入一个有序的状态的原因。

我们可以简单地表示一下树的遍历算法。

先说先序遍历:

PreOrderVisit ( BinaryTree T)
{
if T is not empty
{
Visit the root of T
PreOrderVisit (T.LeftSubTree)
PreOrderVisit (T.RightSubTree)
}
}

小可:这个算法的描述好简单啊。

Mr. 王:是的,这是一个递归版本。其描述非常简单,我们就依照描述算法的思想来书写算法,只要这棵子树不空,我们就访问它的根,然后对其左、右子树执行同样的操作。相应地,我们也可以写出中序遍历和后序遍历的递归版本。

InOrderVisit ( BinaryTree T)
{
if T is not empty
{
PreOrderVisit (T.LeftSubTree)
Visit the root of T
PreOrderVisit (T.RightSubTree)
}
}P
reOrderVisit ( BinaryTree T)
{
if T is not empty
{
PreOrderVisit (T.LeftSubTree)
PreOrderVisit (T.RightSubTree)
Visit the root of T
}
}

小可:这也和我们描述算法的思想非常像,只要和先序遍历换一下访问根节点的顺序就可以实现了。可是,如果我对递归的理解不够深的话,能不能写一个非递归版本呢?

Mr. 王:非递归版本会相对复杂一些,需要借助一个基础数据结构——栈。这里我以中序遍历为例,因为它能直接输出二叉树上节点构成的序列。先序遍历和后序遍历的非递归版本就作为作业留给你回去试试吧。

InOrderVisit(BinaryTree T)
{
stack S ;
while T is not empty or S is not empty
{
if T is not empty
{
S.push(T)
T=T.LeftSubTree;
}
else
{
T=S.top
S.pop()
Visit(T.root)
T=T.RightSubTree;
}
}
}

非递归版本在程序设计上要复杂得多,不过其实不难看出,这里定义的栈,就起到了前面所介绍的递归调用栈的作用,我们只是手动地实现了计算机在递归中自动完成的一些工作。

可是在外存中,如果采用一棵单纯的二叉搜索树,又会如何呢?如果数据是零散、不连续地存储在磁盘上的,那么二叉搜索树在外存中也是以O(log2N) 的复杂度进行的。不过在硬盘中,这个值可是相当大的,由于访问硬盘的时间效率非常低,所以这个值是不能忍受的。

一个简单直观的办法,是按照深度优先搜索的顺序进行分割。

小可:这就是把一棵BFS 子树中的部分放在一个磁盘块里。

Mr. 王:那么此时,一个块中所包含的数据个数为O(log2B)。于是每个块中的子树高度就是O(log2N)/O(log2B)=O(logBN)。从块的角度不难看出,这棵树变矮了,由O(log2N) 变成了O(logBN)。但是就实际情况而言,这棵磁盘搜索树在磁盘上会产生一些问题。

小可:什么问题呢?

Mr. 王:在内存中,如果二叉搜索树在添加元素的过程中是不平衡的,并且不平衡达到了一定的程度,那么整棵二叉搜索树就会退化为一个线性表。这样log 的复杂性就不复存在了,退化成了O(N)。所以,此时我们要记得将树调整为平衡的树。这种数据结构在内存算法中就叫作“红黑树”。

小可:这里不太懂……

Mr. 王:前面我们提到过关于内存中的二叉树,其实建立一棵二叉树的过程也是挺容易的,和查找一个节点相似。我们只需要在二叉树中查找这个节点,当然一般树中是没有这个节点的,于是就会把这个节点插入到最后找到的那个叶子那里。

如果这个插入值比叶子的值小,就放在左边,否则放在右边。虽然这种算法非常简单,但是却存在一个很严重的缺陷。比如我们现在要建立一棵二叉查找树,应该怎么做呢?

小可:我觉得只需要把第一个值确定为根,然后用前面的方法不断地将后面的值插入到树中即可。

Mr. 王:好,我们用这组数据进行一个测试:1,4,7,13,22,37,64,100。

小可得意地说:这还不简单吗?先以1 为根,然后将4 作为1 的右儿子插入到树中,7 再做4 的右子树,13 再做7 的右子树,22,怎么还是右子树……

Mr. 王:发现问题了吧?我举了一个很极端的例子,当这些数据出现的顺序是有序的或者接近有序时,就会产生这样的问题,树朝一个方向偏得厉害。你来试试查找100 这个数据。

小可:先访问1,然后访问4,再访问7……最后访问到100。

Mr. 王:复杂度如何?

小可:一共有8 个节点,竟然访问了8 次才找到,这不就是O(n) 了吗?

Mr. 王:这样的二叉查找树,已经和一个链表没什么区别了。而且复杂度已经高于O(logn)的界限。所以说这种简单的二叉查找树,在实际中还是会存在复杂度达不到理想情况的问题。

小可:那么要怎么解决呢?

Mr. 王:其实不难发现,出现这种情况的原因是树的高度太高了,远远大于理论上的logn,为什么会造成树太高了呢?

小可:因为不断地朝一边添加节点。

Mr. 王:这就是所谓的“不平衡”,对于不平衡的树查找效率就会大大降低。所以计算机科学家们想出了很多方法,在内存算法中,有些很好的平衡结构,比如AVL 树、红黑树、Treap 等,这些方法都很成功地对树进行了平衡调整。

如果感兴趣的话,你可以在课后了解一下这些平衡数据结构。不过问题来了,在内存中平衡旋转是很容易实现的,可是在磁盘中则不然。我们能实现高效的BFS 查找,是由于BFS 块是填满的。

在进行了红黑树那样的旋转后,会造成BFS 块的大小不一,有的很大,有的很小,而树的叶子也就变得零散。所以用这种朴素的方法在磁盘上运行是不太现实的。我们要基于这种思想,对这种方法进行改进。

下期精彩预告:

经过学习,我们掌握了以中序遍历为例的有根数的遍历问题,在下一期中,我们将介绍一种性质非常好的数据结构—外存数据结构B树,通过例子来讲解它的插入和删除。更多精彩内容,敬请关注灯塔大数据,每周五不见不散呦!

内容来源:灯塔大数据