xiaobenchi

导航

数据结构之树

树

​ 对于大量的输入数据,链表的线性访问时间太慢,不宜使用。有一种简单的数据结构,其大部分操作

的运行时间平均为O(logN)。

​ 这种数据结构叫做二叉查找树(binary search tree)。二叉查找树是两种库集合类TreeSet和TreeMap

实现的基础。

预备知识

​ 树(tree)可以用几种方式定义。定义树的一种自然的方式是递归的方式。一棵树是一些节点的集合。

​ 一般的树

树的实现

​ 实现树的一种方法可以是在每一个节点除数据外还要有一些链,使得该节点的每一个儿子都有一个链指向他。然而由于每个节点的儿子数可以变换很大并且事先不知道,因此在数据结构中建立到各(儿)子节点直接的链接是不可行的,因为这样会产生太多浪费的空间。实际的解决方法很简单:将每个节点的所有儿子都放在树节点的链表中。

​ 向下的箭头是指向firstChild(第一儿子)的链,水平的箭头是指向nextSibling(下一兄弟)的链。

树的表示

树的遍历及应用

​ 包括UNIX和DOS等在内的很多操作系统的目录结构就是树

目录结构

先序遍历

​ 在先序遍历中,对节点的处理工作是在他的诸儿子节点被处理之前(pre)进行的。

后序遍历

​ 后序遍历中,一个节点处的工作是在他的诸儿子节点被计算之后进行的

二叉树

二叉树

实现

​ 因为一个二叉树节点最多有两个子节点,所以我们可以保存直接链接到他们的链。树节点的声明在结构上类似于双链表的声明,在声明中,节点就是由element(元素)的信息加上两个到其他节点的引用(left和right)组成的结构

//二叉树节点类
class BinaryNode{
    //Friendly data;accessible by other package routines
    Object element; //the data in the node
    BinaryNode left;
    BinaryNode right;
}

​ 二叉树有许多与搜索无关的应用。二叉树的主要用处之一是在编译器的设计领域。

例子: 表达式树

表达式树

​ 上图是使用了二叉树的中序遍历

构造表达式树

​ 现在给出一种算法来把后缀表达式转变成表达树

构造表达式树_1

构造表达式树_2

构造表达式树_3

查找ADT——二叉查找树

​ 二叉树的一个重要的应用是它们在查找中的使用。假设树中的每个节点存储一项数据。在我们的例子中,虽然任意复杂的项在Java中都容易处理,但为简单起见还是假设它们为整数。还将假设所有的项都是互异的。

二叉查找树定义

​ 对于树中的每个节点X,它的左子树中所有项的值小于X中的项,而它的右子树中的所有项的值大于X中的值。

两棵二叉树

​ 两棵二叉树中只有左边的是查找树

//BinaryNode类
private static class BinaryNode<AnyType>{
    //Constructors
    BinaryNode(AnyType theElement){
        this(theElement,null,null);
    }
    
    BinaryNode(AnyType theElement, BinaryNode<AnyType> lt, BinaryNode<AnyType> rt){
        element = theElement;left = lt; right = rt;
    }
    
    AnyType element; //The data in the Node
    BinaryNode<AnyType> left;
     BinaryNode<AnyType> left;
}

二叉查找树

contains方法

​ 如果在树T中存在含有项X的节点,那么这个操作需要返回true,否则返回false。

contains方法

findMin方法和findMax方法

​ 这两个private例程分别返回树中包含最小元和最大元的节点的引用。为执行findMin,从根开始并且只要有左儿子就向左执行。终点就是最小的元素。findMax就是向右。

find方法

insert方法

​ insert

remove方法

​ 正如许多数据结构一样,最困难的操作是remove(删除)。一旦我们发现要被删除的节点,就需要考虑几种可能的情况。

​ 如果节点是一片树叶,那么它可以立即被删除。如果一个节点有一个儿子,则该节点可以在其父节点调整自己的链以绕过该节点后被删除。

​ 复杂的情况是处理具有两个儿子的节点。一般的删除策略是用其右子树的最小数据(很容易找到)代替该节点的数据,并且递归的删除那个节点(现在它是空的),因为右子树中的最小节点不可能有左儿子,所以第二次的remove要容易。

image-20220401172719901

​ 如果删除的次数不多,通常使用的策略是懒惰删除(lazy delete):当一个元素要被删除的时候,它仍留在树中,而只是被标记为删除。

平均情况分析

​ 直观上,我们希望前一节的所有操作都花费O(logN)的时间,因为我们用常数时间在数中降低了一层,这样一来,对其进行操作的树大致减少一半左右。因此,所有操作的运行时间都是O(d),其中d是包含所访问的项的节点的深度。

​ 假设所有 的插入序列都是等可能的,则树的所有节点的平均深度为O(logN)

AVL树

​ AVL树是带有平衡条件的二叉查找树。这个平衡条件必须要容易保持,而且它保证树的深度必须是O(logN)。最简单的想法是要求左右子树具有相同的高度。但是这种要求很严格,不易实现。

​ 一颗AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树(空树的高度定义为-1)。

AVL树

​ 除去可能的插入外(我们将假设懒惰删除),所有的树操作都可以以时间O(logN)执行。当进行插入操作时,我们需要更新通向根节点路径上那些节点的所有平衡信息,而插入操作隐含着困难的原因在于插入一个节点可能回破坏AVL树的特性。如果发生这种情况,那么就要在这一步插入完成之前恢复平衡的性质。事实上,这总可以通过对树进行简单的修正来做到,我们称其为旋转(rotation)。

​ 在插入以后,只有那些从插入点到根节点的路径上的节点的平衡可能被改变,因为只有这些节点的子树可能发生变化。当我们沿着这条路径上行到根并更新平衡信息时,可以发现一个节点,它的新平衡破坏了AVL条件。我们将指出如何在第一个这样的节点(即最深的节点)重新平衡这棵树。

​ 我们把必须重新平衡的节点叫α。由于任意节点最多有两个儿子,因此出现高度不平衡就需要α点的两颗子树高度差2.从而会产生如下四种情况:

  1. 对α的左儿子的左子树进行一次插入。

  2. 对α的左儿子的右子树进行一次插入。

  3. 对α的右儿子的左子树进行一次插入。

  4. 对α的右儿子的右子树进行一次插入。

    上述的四种情况存在镜像对称,所以理论上只有两种情况,当然,从编程的角度来说仍旧有四种情况。

    第一种情况是插入发生在“外边”的情况(即左-左或右-右),该情况通过对树的一次单旋转(single rotation)而完成调整。第二种情况是发生在“内部的情形”,该情况需要通过稍微复杂些的双旋转(double rotation)而完成调整。

单旋转

单旋转

​ 如图所示,旋转前的图在左边,而旋转后的图在右边。节点k2不满足AVL树的性质,因为它的左子树比右子树深两层。在插入之前k2满足AVL性质,但是在插入之后这种性质就被破坏了。子树X已经长出一层,使得它比子树Z深出两层。Y不可能与新X在同一水平上,因为那样k2在插入之前就已经失去平衡了;Y也不可能与Z在同一层上,因为那样k1就会是在通向根的路径上破坏AVL平衡的第一个节点。

​ 为了使树恢复平衡,我们把X上移一层,并把Z下移一层。注意,此时实际上超出了AVL性质的要求。为此,我们重新安排节点以形成一棵等价的树。

​ 抽象的形容就是:把树想象成柔软灵活的,抓住子节点k1,闭上你的双眼,使劲摇动它,在重力作用下,k1成为了新的根。二叉查找树的性质告诉我们,在原树中k2>k1,于是在新树中k2变成了k1的右儿子,X和Z仍然分别是k1的左儿子和k2的右儿子。子树Y包含原树中介于k1和k2之间的那些节点,可以将它放在新树中k2的左儿子的位置上。这样,所有对顺序的要求都得到满足。

单旋转例子

双旋转

单旋转不能修复的情形

单旋转失效

左右双旋修复

双旋修复

编码实现

AVL树节点声明

AVL树高度

AVL树的插入

单旋转

双旋转

AVL树的删除

伸展树

​ 伸展树(splay tree)保证从空树开始连续M次对树的操作最多花费O(MlogN)时间。

​ 伸展树基于这样的事实:对于二叉查找树来说,每次操作最坏运行时间O(N)并不坏,只要它相对不常发生就行。任何一次访问,即使花费O(N),仍然可能非常快。二叉查找树的问题在于,虽然一系列访问整体都是坏的操作有可能发生,但是很罕见。此时,累积的运行时间很重要。

​ 伸展树的基本想法是,当一个节点被访问后,它就要经过一系列AVL树的旋转被推到根上。注意,如果一个节点很深,那么其路径上就存在许多也相对较深的节点,通过重新构造可以减少对所有这些节点的进一步访问所花费的时间。因此如果节点过深,那么我们要求重新构造具有平衡这棵树(到某种程度的作用)。除在理论上给出好的时间界外,这种方法还可能有实际的效用,因为在许多应用中当一个节点被访问时,它很可能不久后再被访问。研究表明,这种情况的发生比人们预想的要频繁的多。另外,伸展树还不要求保留高高度或平衡信息,因此它在某种程度上节省空间并简化代码。

一个简单的想法(不能直接使用)

​ 从底向上进行单旋转。

展开

​ 展开(splaying)的思路类似于上面介绍的旋转的想法,不过在旋转如何实施上我们稍微有些选择的余地。我们仍然从底部向上沿着访问路径旋转。令X是在访问路径上的一个(非根)节点,我们将在这个路径上实施旋转操作。如果X的父节点是树根,那么只要旋转X和树根。这就是沿着访问路径上的最后的旋转。否则,X就有父亲(P)和祖父(G),存在两种情况以及对称的情形要考虑。

第一种情况:之字型(zig-zag)情形

X是右儿子的形式,P是左儿子的形式(反之亦然),如果是这样的情况,那么我们执行一次就像AVL双旋转那样的双旋转。

之字形

第二种情况:一字型(zig-zig)情形

X和P或者都是左儿子,或者其对称的情形。

一字型

​ 展开操作不仅将访问的节点移动到根处,而且还把访问路径上的大部分节点的深度大致减少一半(某些浅的节点最多向下推后两层)。

​ 当访问路径长而导致超出正常查找时间的时候,这些旋转将对未来的操作有益。当访问耗时很少的时候,这些旋转则不那么有益甚至有害。

再探树的遍历

B树

​ 迄今为止,我们始终假设可以把整个数据结构存储到计算机的主存中。可是,如果数据更多装不下主存,这就意味着必须把数据结构放到磁盘上。此时,因为O模型不再适用,所以导致游戏规则发生了变化。

​ 磁盘的访问的操作是很耗时的,所以为了减少一次磁盘访问,我们愿意进行大量的计算。几乎在所有的情况下,控制运行时间的都是磁盘的访问次数。于是,如果把磁盘的访问次数减少一半,那么运行时间也将减半。

阶为M的B树是一颗具有下列特性的树

  1. 数据项存储在树叶上。
  2. 非叶节点存储直到M-1个关键字以指示搜索的方向:关键字i代表子树i+1中的最小的关键字。
  3. 树的根或者是一片树叶,或者其儿子数在2和M之间。
  4. 除根外,所有非树叶节点的儿子数在[M/2]和M之间。
  5. 所有的树叶节点都在相同的深度上并有[L/2]和L之间个数据项,L的确定稍后描述。

5阶B树

由于L是5,因此每片树叶有3到5个数据项。要求节点半满将保证B树不致退化成简单的二叉树。

​ 每个节点代表一个磁盘区块,于是我们根据所存储的项的大小选择M和L。

向B树中添加或删除

​ 首先考察插入,将被插入的叶子节点未满时,直接写入数据即可;若叶子已满,则需要分裂叶子。分裂不成功则需要向上分裂子节点,但在正常情况下,任何一个子节点的分裂都是很少见的。

​ 还有一些处理儿子过多的情况,一种方法是在相邻节点有空间时把一个儿子交给该邻节点领养。

​ 删除操作就是添加造作的逆操作,要注意节点的个数,也可以使用领养的机制尽量使每个节点丰满。

标准库中的集合与映射

​ List容器即ArrayList和LinkedList用于查找效率很低。因此Collections API提供了两个附加容器Set和Map,它们对诸如插入、删除和查找等基本操作提供有效的实现。

关于Set接口

​ Set接口代表不允许重复元的Collection。由接口SortedSet给出的一种特殊类型的Set保证其中的各项处于有序的状态。因为一个Set IS-A Collection,所以用于访问继承Collection的List的项的方法也对Set有效。

​ 由Set所要求的一些独特的操作是一些插入、删除以及(有效地)执行基本查找的能力。对于Set,add方法如果执行成功则返回true,否则返回false,因为被添加的项已经存在。保持各项以有序状态的Set的实现是TreeSet。TreeSet类的基本操作花费对数最坏情形时间。

​ 默认情况下,排序假设TreeSet中的项实现Comparable接口。另一种排序可以通过用Comparator实例化TreeSet来确定。例如,我们可以创建一个存储String对象的TreeSet。

//CaseInsensitiveCompare函数对象忽略大小写
Set<String> s = new TreeSet<>(new CaseInsensitiveCompare());
s.add("hello");
s.add("heLLo");
System.out.println("The size is:" + s.size());

关于Map接口

​ Map是一个接口,代表由关键字以及它们的值组成的一些项的集合。关键字必须是唯一的,但若干关键字可以映射到一些相同的值。因此值不必是唯一的。在SortedMap接口中,映射中的关键字保持逻辑上有序状态。Sort接口的一种实现是TreeMap类。Map的基本操作包括诸如isEmpty、clear、size等方法,而且最终要的是包含下列方法

boolean contains(KeyType key)
ValueType get(KeyType key)
ValueType put(KeyType key,VlaueType value)

​ 通过一个Map进行迭代要比Collection复杂,因为Map不提供迭代器,而是提供3种方法,将Map对象的视图作为Collection对象返回。由于这些视图本身就是Collection,因此它们可以被迭代。所提供的三种方法如下:

Set<KeyType> keySet()//返回简单的集合
Collection<ValueType> values()//返回简单的集合
Set<Map.Entry<KeyType,ValueType>> entrySet()//每一项均由被嵌套的接口Map.Entry表示

对于类型Map.Entry的对象,其现有方法包括访问关键字、关键字的值,以及改变关键字的值:

KeyType getKey()
ValueType getValue()
ValueType setValue(ValueType newVlaue)

TreeSet类和TreeMap类的实现

​ Java要求TreeSet和TreeMap支持基本的add、remove和contains操作以对数最坏情形时间完成。因此,基本的实现方法就是平衡二叉查找树。一般说来,我们并不使用AVL树,而是使用一些自顶向下的红黑树。

​ 实现TreeSet和TreeMap的一个重要问题就是提供对迭代类的支持。当然,在内部,迭代器保留到迭代中“当前“节点的一个链接。困难部分是到下一个节点的高效推进。存在几种可能的解决方案。其中一些方案的叙述如下:

  1. 在构造迭代器时,让每个迭代器把包含诸TreeSet的数组作为该迭代器的数据存储。这有不足,因为,因为我们还可以使用toArray,并不需要迭代器。
  2. 让迭代器保留存储通向当前节点的路径上的节点的一个栈。根据该信息,可以推出迭代器中的下一个节点,它或者是包含最小项的当前节点右子树上的节点,或者包含其左子树当前节点的最近祖先。这使得这个迭代器多少有些大,并导致迭代器的代码臃肿。
  3. 让查找树的每个节点除存储子节点外还要存储它的父节点。此时迭代器不至于那么大,但是在每个节点上需要额外的内存,并且迭代器的代码依然臃肿。
  4. 让每个节点保留两个附加的链:一个通向下一个更小的节点,另一个通向下一个更大的节点。这要占用空间,不过迭代器做起来非常简单,并且保留这些链也很容易。
  5. 只对那些具有null左链或null右链的节点保留附加的链。通过使用附加的布尔变量使得这些例程判断是一个左链正在被用作标准的二叉树左链还是一个通向下一个更小节点的链,类似的,对右链也有类似的判断。这种做法叫做线索树(threaed tree),用于许多平衡二叉树的实现中。

使用多个映射的实例

​ 我们想编写一个程序以找出通过单个字母的替换可以变成至少15个其他单词的单词。

​ 最直接了当的策略是使用一个Map对象,其中的关键字是单词,而关键字的值是用1字母替换能够从关键字变换得到的一系列单词。

image-20220404170353068

主要的问题是如何从包含89000个单词的数组构造Map对象。

//Returns true if word1 and word2 are the same length
//and differ in only one charecter.
//检查两个单词是否只有一个字母不一样
private static boolean oneCharOff(String word1,String word2){
    if(word1.length() != word2.length())
        return false;
    
    int diffs = 0;
    
    for(int i = 0; i < word1.length();i++){
        if(word1.charAt(i) != word2.charAt(i)){
            if(++diffs > 1)
                return false;
        }
    }
    return diffs == 1;
}

使用Map存储单词

该算法的问题在于速度慢。另一种方法是避免比较不同长度的单词,把单词按照长度分组,然后对各个分组运行刚刚的程序。

​ 为此,可以使用第二个映射。

posted on 2022-04-04 18:54  小迟在努力  阅读(68)  评论(0)    收藏  举报