MinicitY Software Engineer

算导注解Chapter[12&13&14]


Preview

先修知识:

一棵树不存在回路。其中除了根结点以外所有的结点都有父结点(或者说父结点为NIL),除了叶子以外所有的结点都有子结点(或者说子结点为NIL)。二叉树描述的是包含叶子和根在内的所有结点,都最多只有两个子结点(或者子树)的树结构。二叉树体系内最重要的一种叫做二叉搜索树(又叫二叉查找树),再往下分,二叉搜索树又包括平衡二叉查找树(AVL树)和红黑树等等。


扩张数据结构一般要走下列四个步骤:

  1. 选择一种基础数据结构
  2. 确定基础数据结构中要维护的附加信息
  3. 检验基础数据结构上的基本修改操作能否维护附加信息
  4. 设计一些新操作

举个例子,选取一个完全二叉树。我要维护的是结点的关键字逐层往上递减这个性质,因此修改内部代码之后,再设计一个新操作EXTRACT-MIN,就能把这个完全二叉树扩张成为一个最小堆了。


二叉搜索树

可以假定一棵二叉搜索树中的所有结点必然拥有下列属性:left, right, p ——它们分别指向结点的左孩子(left child)、右孩子(right child)和父结点(parent)。对于根结点和叶子而言,父结点/子结点指向NIL。另外,二叉搜索树还有这样的性质: node.left.key <= node.key <= node.right.key 这里的left和right指左右子树中所有的结点

遍历

既然是一颗用来查找的树,那就必须要遍历了。二叉树中有三种常见的遍历:中序遍历(inorder)、前序遍历(preorder)和后序遍历(postorder)

遍历具体方式不再赘述,直接给个图:

考虑到二叉搜索树的性质,想把树中的关键字从小到大遍历出来,就肯定要用中序遍历了。

INORDER-TREE-WALK(x)//中序遍历的伪代码
if (x != NIL){
 INORDER-TREE-WALK(x.left)
 print x.key
 INORDER-TREE-WALK(x.right)
}

我们假设处理一颗空的结点(只有一个结点NIL)需要一个非常小的常数时间c,而print一个结点的关键字需要常数时间d。显然地,一颗结点数为n的非空二叉树,应该有一个根节点和⌈n/2⌉个叶结点,其中根节点的父结点指向NIL,叶结点的两个子结点也指向NIL,所以这个非空二叉树要处理(n+1)个空结点。遇到空结点耗费c而遇到非空结点耗费d,因此我们得到中序遍历的T(n)上界: T(n) <= c*(n+1) + dn,也就是说,调用中序遍历需要Θ(n)时间。

查询

如果我们想查询一个元素,就没有必要先遍历再查询了。利用被查询元素的关键字的大小比较,可以很快地在二叉搜索树中找到位置。

TREE-SEARCH(x, k)//x是当前结点,k是被查询元素的关键字
 if(x == NIL || k == x.key)//如果查到了尽头或者查到了正确解
  return x
 if(k < x.key)
  return TREE-SEARCH(x.left, k)//姑且也算递归吧……
 else
  return TREE-SEARCH(x.right, k)

查询时间为O(h),其中h是这棵树的高度。我们之前在堆排序中就提到过,如果是堆这样的结构的话,树高应该就是logn;然而树的形状可以很多变所以只能说查询时间为Ω(logn), O(n)。

当然,这个伪代码也可以写成迭代的形式——

ITERATIVE-TREE-SEARCH(x, k)
 while(x != NIL && k != x.key){
  if (k < x.key)
   x = x.left
  else
   x = x.right
}
 return x

如果要查询最大或最小的结点,往右或者往左一直迭代就是了。如果要查询一个结点的前驱和后继,就需要分情况讨论(以查询后继为例子):

  1. 存在右子树,则迭代查询右子树的最小结点

  2. 不存在右子树,且这个结点是父结点的左子结点,则返回父结点

  3. 不存在右子树,且这个结点是父结点的右子节点,则需要用到循环:

    y = x.p
    while(y != NIL && x == y.right)
     x = y
     y = y.p
    return y
    

还是上个图吧。

当然,如果树内存在相同的关键字,我们依然用上面的讨论来定义一个结点的前驱和后继。

插入和删除

插入和删除的重点在于如何维持二叉搜索树的性质的成立。先来看插入。

TREE-INSERT(T, z)//对树T插入一个结点z
 y = NIL
 x = T.root//x为结点z的暂时插入目标
 while(x != NIL){
  y = x//y一直跟随x
  if(z.key < x.key)
   x = x.left
  else
   x = x.right   
}
//此时x应该为某个叶子的一个空子结点
 z.p = y
 if(y == NIL)//也就意味着树T为空
  T.root = z
 else if(z.key < y.key)
  y.left = z
 else
  y.right = z

插入的关键在于使用双指针沿树下移,指针x记录了向下移动的简单路径,而指针y作为遍历指针始终保持指向x的父结点。插入的运行时间上界为O(h)。

再来看删除。删除一个结点z分为以下三种情况:

  1. 如果z没有子结点(或者说都是空子结点),那么就只用把它扔掉,并用NIL替代它父结点的子结点。

  2. 如果z只有一个子结点,那么扔掉z以后将这个子结点提到原本z的位置上,并修改相应父结点的子结点指针。

  3. 如果z有两个子结点(这是最麻烦的一种情况),那么先找z的后继结点x(从上面关于后继的分类讨论中我们知道肯定在z的右子树中),然后拿x替换掉z,扔掉z,再连接x与z的那两个原来的子结点;同时,对于x离去留下的空位,视作一个新的删除,并接着讨论这三种情况。

代码略了。因为删除最麻烦的情况无非是第三种一直持续从根到叶子,所以运行时间的上界仍然为O(h)。

随机构建二叉搜索树的部分就不说了,用处不大。

AVL树

书上没讲AVL,这里按学校的课件来扯一下。

AVL是一种高度平衡的二叉搜索树。除了要求本身是二叉搜索树以外,还要求每个结点的左右子树的高度之差的绝对值(平衡因子)最多为1。

AVL树图示(带平衡因子)

这里就讲一下AVL是怎么维护自身平衡的。一般而言,只需要将AVL视作一般的二叉搜索树就行了,只是当平衡因子大于1的时候我们需要对树进行旋转(rotation)。

我们只讨论插入的时候造成平衡因子大于1的情况。显然,在插入前,AVL树的平衡因子必然刚好为1,并且这只有两种情况:左树更高,右树更高;插入一个更高的树,也无非只有两种情况:插入左子节点,插入右子节点。因此,一共有2x2=4种情况需要考虑,也就是所谓的

left-left case; left-right case; right-left case; right-right case

图片来源 GeeksforGeeks

至于删除,其实也可以套用这四种模型一一考虑。当然,插入删除遍历等等操作对于AVL树而言依然等同于二叉搜索树,只是每次操作完后需要判断是否平衡。

红黑树

红黑树也许并没有想象中的困难……如果不看具体的插入、删除等操作的话。不过这些操作正常情况下哪儿又用得到呢。

红黑树的本质也是一颗二叉搜索树。它不如AVL那样高度平衡,但是却是「近似平衡」的。其中每一个结点都有一个储存位来保存结点的颜色。有如下规则:

  • 每个结点是红色或者黑色的。

  • 根结点必须是黑色的。

  • 叶结点必须是黑色的。

  • 红色结点的两个子结点必须是黑色的。

  • 一个结点到所有它的叶结点的路径上,黑色结点的数量都相同。这对所有非叶结点都有效。

图片来源 GeeksforGeeks

这里不会对红黑树的旋转、插入等操作具体分析,只需要知道红黑树的一大性质——一颗有n个内部结点的红黑树的高度至多为2log(n+1)——就行了。(人太懒了ORZ , 整个红黑树的java实现要800行左右的代码啊,太难了)

题目答案

第十二章

12.1-2

虽然本质都是树,但是二叉搜索树是 node.left < node < node.right , 而最小堆是 node < node.left < node.right && node.thisLevel < node.nextLevel,是不能用前序遍历的。这也就意味着,堆结构只能用「维护堆的性质」的堆排序来打印有序结点。因此,耗时O(nlogn),也就是n个结点乘以每次维护堆的logn用时是堆排序必须面对的事实。

12.2-3

/*
类似于上面的检查一个结点的后继,也是分为三种情况,
有左子树,没有左子树且是父结点的右子节点,
没有左子树且是父结点的左子结点(这个最复杂)
*/
TREE-PREDECESSOR(x)
 if(x.left != NIL)
  return TREE-MAXIMUM(x.left)
 y = x.p
 while(y != NIL && x == y.left)
  x = y
  y = y.p
 return y

12.2-7

重点肯定是查询后继结点所耗费的时间。看上面的查询后继结点的图。可以很容易知道,一条到叶节点的简单路径必然只会被遍历两次。

另外有欧拉定理r=e-v+2,因为这是平面图所以r=1,因此n个结点的树有n-1条简单路径,所以查询后继结点中的第一、三种情况加起来应该是2(n-1)次访问结点,而第二次情况每次访问1次结点。无论查询后继结点发生了多少次第一、二、三种情况,迭代加起来肯定是Θ(n)。

第十三、十四章节没有。


Comments

Content