二叉树相关(上)

树,二叉树和二叉查找树

之前介绍过链表,栈与队列。这些数据结构都是线性且一维的。我们为了打破这种限制,创建一个新的数据结构-树。程序世界中的树与自然界中的树是倒过来的:根在顶,叶子在底。根节点只有子节点,没有父节点;而叶子节点没有子节点,或者子节点是空结构。
树的递归定义:

  1. 空结构是一棵空树;
  2. 如果t1,t2…tk是不相交的树,则以它们的根作为子节点的数据结构也是一棵树;
  3. 只有通过(1)(2)产生的数据结构才是树。
    几种不同的树结构

二叉树的实现

之前我们曾经探讨过查找算法及其相应的复杂度。就算是一维的链表,也有自组织链表以及跳跃链表等多种方式降低查找所需消耗。这里将要介绍的二叉树是一种常用的数据结构,如果按照特定排序,将有效减少查找开销。
根据二叉树的定义,每一个父节点都有两个子节点,即节点的层次是从根到该节点所经过的弧的个数,根的层数是1,其非空子节点的层数是2。如果除最后一个层次外所有节点都有两个子节点,那么该树称为完全二叉树(complete binary tree)
对于非空二叉树,若其所有的非终端节点都有两个非空子节点,则叶节点数m与非终端节点数k的关系是: m = k + 1 m=k+1 m=k+1
本文主要讨论查找二叉树,即有序二叉树。对于树中的某个非终端节点n,左子节点中的值小于n,右子节点中的值大于n。前提是避免将相同值的副本放在同一颗树中。这样的查找方式比链表更佳优化。
下面介绍通用二叉树的实现,假定节点由一个信息跟两个子节点指针组成:

#include <stack>
#include <queue>
#include <iostream>
using namespace std;

template<class T>
class Stack : public stack<T> {
public:
    T pop() {
        T tmp = stack<T>::top();
        stack<T>::pop();
        return tmp;
    }
}; //重定义栈的弹出。使之可以返回栈顶元素。

template<class T>
class Queue : public std::queue<T> {
public:
    T dequeue() {
        T tmp = queue<T>::front();
        queue<T>::pop();
        return tmp;
    }
    void enqueue(const T& el) {
        push(el);
    }
}; //重定义队列的弹出。使之可以返回队首元素。

template<class T>
class BSTNode {
public:
    BSTNode() { 
        left = right = 0; 
    }
    BSTNode(const T& e, BSTNode<T> *l = 0, BSTNode<T> *r = 0) {
        el = e; left = l; right = r; 
    }
    T el; //节点信息
    BSTNode<T> *left, *right; //左节点,右节点指针。
}; //二叉树节点类

template<class T>
class BST {
public:
    BST() { 
        root = 0; 
    }
    ~BST() { 
        clear();
    }
    void clear() {
        clear(root); 
        root = 0;
    }
    bool isEmpty() const { 
        return root == 0; 
    }
    void preorder() { 
        preorder(root);   
    }
    void inorder() { 
        inorder(root); 
    }
    void postorder() { 
        postorder(root);  
    }
    void insert(const T&);
    void recursiveInsert(const T& el) { 
        recursiveInsert(root,el);
    }
    T* search(const T& el) const { 
        return search(root,el);
    }
    T* recursiveSearch(const T& el) const { 
        return recursiveSearch(root,el);
    }
    void deleteByCopying(BSTNode<T>*&);
    void findAndDeleteByCopying(const T&);
    void deleteByMerging(BSTNode<T>*&);
    void findAndDeleteByMerging(const T&);
    void iterativePreorder();
    void iterativeInorder();
    void iterativePostorder();
    void breadthFirst();
    void MorrisPreorder();
    void MorrisInorder();
    void MorrisPostorder();
    void balance(T*,int,int);
protected:
    BSTNode<T>* root; //根节点指针。
    void clear(BSTNode<T>*); //清除节点分配的空间,在析构函数中运行。
    void recursiveInsert(BSTNode<T>*&, const T&);
    T* search(BSTNode<T>*, const T&) const;
    T* recursiveSearch(BSTNode<T>*, const T&) const;
    void preorder(BSTNode<T>*);
    void inorder(BSTNode<T>*);
    void postorder(BSTNode<T>*);
    virtual void visit(BSTNode<T>* p) { 
        cout << p->el << ' '; 
    }
}; //二叉树实现的class。

二叉树的查找

本节直接基于上一节的代码实现二叉树查找。

template<class T>
T* BST<T>::search(BSTNode<T>* p, const T& el) const {
    while (p != 0)
    {
        if (el == p->el)
             return &p->el; //找到了目标。
        else if (el < p->el)
             p = p->left; //当前节点信息小于目标,查找左子节点。
        else p = p->right; //当前节点信息小于目标,查找右子节点。
    }
    return 0; //没有找到节点。
}

单次查找的复杂度是到达该节点的路径长度+1,复杂度取决于树的形状以及节点在树中的位置。查找算法的复杂性是由查找过程中的比较次数来决定。
内部路径长度(Internal Path Length)是所有节点的所有路径长度的总和。计算公式:对于所有的层次i,计算 ∑ ( i − 1 ) l i \sum(i-1)l_i (i1)li l i l_i li是层次为 i i i的节点数目,即节点数-1,即为该层路径数。平均路径长度(average path length)由 I P L / n IPL/n IPL/n得到,该值取决于树的形状。最坏情况下,退化为链表:
p a t h w o r s t = 1 / n ∑ i = 1 n ( i − 1 ) = ( n − 1 ) / 2 = O ( n ) path_{worst}=1/n\displaystyle\sum_{i=1}^{n}(i-1)=(n-1)/2=O(n) pathworst=1/ni=1n(i1)=(n1)/2=O(n)
查找过程将耗费n个时间单位。

最好情况:所有叶子节点位于最多两个层次中,只有倒数第二层上的节点可以有一个子节点。我们在这里用高度为h完全二叉树来近似计算:
I P L = ∑ i = 1 h − 1 i 2 i IPL=\displaystyle\sum_{i=1}^{h-1}i2^i IPL=i=1h1i2i
∑ i = 1 h − 1 2 i = 2 h − 2 \displaystyle\sum_{i=1}^{h-1}2^i=2^h-2 i=1h12i=2h2
可以得到:
I P L = 2 I P L − I P L = ( h − 1 ) 2 h − ∑ i = 1 h − 1 2 i = ( h − 2 ) 2 h + 2 IPL=2IPL-IPL=(h-1)2^h-\displaystyle\sum_{i=1}^{h-1}2^i=(h-2)2^h+2 IPL=2IPLIPL=(h1)2hi=1h12i=(h2)2h+2

由于完整二叉树节点数为: n = 2 h − 1 n=2^h-1 n=2h1可得:
p a t h b e s t = I P L / n = ( ( h − 2 ) 2 h + 2 ) / ( 2 h − 1 ) ≈ h − 2 path_{best}=IPL/n=((h-2)2^h+2)/(2^h-1)≈h-2 pathbest=IPL/n=((h2)2h+2)/(2h1)h2

该树的高度 h = l g ( n + 1 ) h=lg(n+1) h=lg(n+1),因此 p a t h b e s t = l g ( n + 1 ) − 2 path_{best}=lg(n+1)-2 pathbest=lg(n+1)2;在完全平衡树中平均路径长度 A P L = l g ( n + 1 ) − 2 = O ( l g n ) APL=lg(n+1)-2=O(lgn) APL=lg(n+1)2=O(lgn)

那么新的问题是,最终的结果一定是最好情况和最坏情况中间的某个值。关于一个随机二叉树来说,经过一个复杂的数学计算可以证明 ,最好情况下的平均路径长度和期望情况下的路径长度只相差27.85%,因此在大多数情况中,即便不进行平衡,在二叉树中的查找也十分高效。但不能忽略极端情况-----二叉树被拉成了链表,此时的复杂度O(n)是不能被接受的。【如果对该数学过程感兴趣,可以查阅相关资料,笔者使用《C++数据结构与算法第四版》】

树的遍历

遍历是把每一个节点访问一次,可以解释为把所有的节点放在一条线上,或者将树线性化。而遍历的方式繁多,有n个节点的树,就有n!种遍历方式。这里介绍两种常用的遍历方式:广度优先遍历深度优先遍历

典型二叉树

广度优先遍历

这种方式从最底部到最高层,或反之;从左到右,或反之。这样就有4种访问方式了(上下,左右各两种)。如果采用从上到下,从左往右的广度优先遍历:上面的序列将会是:15,10,24,5,12,18,30。下面实现最典型的从上到下,从左往右的广度遍历:

template<typename T>
void BST<T>::breadthFirst()
{
	Queue<BSTNode<T> *>queue;
	BSTNode<T> *p = root;
	if(p != 0)
	{
		queue.enqueue(p); //先把根节点放进队列。
		while(!queue.empty())
		{ 
			p = dequeue();
			visit(p); //弹出并访问。
			if(p->left != 0)
			queue.enqueue(p->left); //将左子节点放入队尾。
			if(p->right != 0)
			queue.enqueue(p->right); // 将右子节点放入队尾。
		}
	}
}

深度优先遍历

深度优先遍历将尽可能地向左或向右进行。这样的遍历有3个任务:

  • V 访问节点
  • L 遍历左子树
  • R 遍历右子树

这3个任务在每个节点上都以相同的顺序执行。如果确定先左后右,那么又有3种遍历方式:

  • VLR 先序树遍历
  • LVR 中序树遍历
  • LRV 后序树遍历

下面采用递归的方式来实现深度优先遍历:

template<class T>
void BST<T>::inorder(BSTNode<T> *p) {
     if (p != 0) {
         inorder(p->left);
         visit(p);
         inorder(p->right);
     }
} //中序树遍历

template<class T>
void BST<T>::preorder(BSTNode<T> *p) {
    if (p != 0) {
        visit(p);
        preorder(p->left);
        preorder(p->right);
    }
} // 前序树遍历

template<class T>
void BST<T>::postorder(BSTNode<T>* p) {
    if (p != 0) {
        postorder(p->left);
        postorder(p->right);
        visit(p);
    }
} //后序树遍历

之前学习过递归在许多情况下效率远远低于迭代,尤其是在函数体中使用了两次递归。我们尝试去掉递归(实质上系统和硬件也会这么干,最终生成栈+迭代的形式)。
首先是前序树遍历的非递归实现:

template<class T>
void BST<T>::iterativePreorder() {    
    Stack<BSTNode<T>*> travStack;
    BSTNode<T> *p = root;
    if (p != 0) {
        travStack.push(p);
        while (!travStack.empty()) {
            p = travStack.pop();
            visit(p);
            if (p->right != 0)
                 travStack.push(p->right);
            if (p->left != 0) // 左子节点比右子节点后压入。
                 travStack.push(p->left);
        }
    }
}

与递归不同的是,这里不能单独通过调换api顺序的方式改变前,中,后序。因为读取的节点已经确定,无论如何变动都是前序。
后序非递归实现:

template<class T>
void BST<T>::iterativePostorder() {    
    Stack<BSTNode<T>*> travStack;
    BSTNode<T>* p = root, *q = root;
    while (p != 0) {
        for ( ; p->left != 0; p = p->left)
            travStack.push(p);
        while (p->right == 0 || p->right == q) {//这里的q是为了标记已经识别过的右子节点,出栈的时候可以直接跳过。
            visit(p);
            q = p;
            if (travStack.empty())
                 return;
            p = travStack.pop();
        }
        travStack.push(p);
        p = p->right;
     }
}

中序的实现也很复杂,以下只给出一种可能。如果可能,请使用递归版本,逻辑清晰易于理解。
中序非递归实现:

template<class T>
void BST<T>::iterativeInorder() {    
    Stack<BSTNode<T>*> travStack;
    BSTNode<T> *p = root;
    while (p != 0) {
        while (p != 0) {                 // stack the right child (if any)
            if (p->right)                // and the node itself when going
               travStack.push(p->right); // to the left;
            travStack.push(p);
            p = p->left;
        }
        p = travStack.pop();             // pop a node with no left child
        while (!travStack.empty() && p->right == 0) { // visit it and all nodes
            visit(p);                                 // with no right child;
            p = travStack.pop();
        }
        visit(p);                        // visit also the first node with
        if (!travStack.empty())          // a right child (if any);
             p = travStack.pop();
        else p = 0;
    }
}

关于二叉树的非递归算法,这篇文章有较为详细的介绍,本篇限于篇幅引用例程,原理表过不提,也可以根据代码推导出思路。

不使用栈的深度优先遍历

线索树
前面的内容介绍了递归与非递归实现的遍历函数,它们都使用了栈存储节点信息。极端情况下,栈将会占用极大的内存,因此可以在给定的节点中加入线索(thread) 来指示该节点的前驱和后继节点指针。使用这样节点的树称为线索树(thread tree)
完整的树,单个节点除了维护左右子节点指针外,还需要维护前驱节点和后继节点两个指针。这样一共4个指针会造成极大浪费。简单考虑,我们采用一个标志来重载。

template<class T>
class ThreadedNode {
public:
    ThreadedNode() { 
        left = right = 0; 
    }
    ThreadedNode(const T& el, ThreadedNode *l = 0, ThreadedNode *r = 0) {
        key = el; left = l; right = r; successor = 0; 
    }
    T key; //节点信息。可以是字符,数字等。
    ThreadedNode *left, *right; //左右子节点指针。
    unsigned int successor : 1; //标志位。
};
successor* right
0* rightchild
1* nextnode

注:successor为1时,右指针重载为指向下一个要遍历的节点的指针。
黑箭头表示遍历的方向
首先扫描左子节点的终点,然后依次遍历回到上一个有右节点的位置,并相应设置好前驱节点(prev)指针。如果该节点的前驱节点successor为0,说明当前节点是一个右子节点。若successor为1,则* right指针指向后继节点。
遍历过程代码如下:

template<class T>
void ThreadedTree<T>::inorder() {
    ThreadedNode<T> *prev, *p = root;
    if (p != 0) {                 // process only non-empty trees;
        while (p->left != 0)      // go to the leftmost node;
            p = p->left;
        while (p != 0) {
            visit(p);
            prev = p;
            p = p->right;         // go to the right node and only
            if (p != 0 && prev->successor == 0) // if it is a descendant
                while (p->left != 0)   // go to the leftmost node,
                    p = p->left;  // otherwise visit the successor;
        }
    }
}

对于先序遍历,可以调整遍历和访问子节点的顺序。对于后序遍历会复杂一些。可以自行搜索相关资料。

通过树的转换进行遍历
线索树将栈转化为树的一部分,然而仍有一些算法不需要栈或者线索也可以遍历树,通常这些算法会暂时地修改树使之不具有树结构,在遍历结束前需要恢复。这里介绍一个中序遍历 – Morris算法。
该算法临时性地取消了左子树,从而在访问该节点后可以直接处理右子树,算法思路如下:

MorrisInorder()
{
	while没有结束
		if没有左子节点
		  访问该节点;
		  转向右子树;
		else让该节点“连带之后的节点”成为其左子树节点中最右侧节点的右子节点;
		  转向该左子树;
}

我们必须保留某些信息以将树恢复到原有形式,可以保留被迁移的节点的左指针来完成。

template<class T>
void BST<T>::MorrisInorder() {   
    BSTNode<T> *p = root, *tmp;
    while (p != 0)
        if (p->left == 0) {
             visit(p);
             p = p->right;
        }
        else {
             tmp = p->left;
             while (tmp->right != 0 &&// go to the rightmost node of
                    tmp->right != p)  // the left subtree or
                  tmp = tmp->right;   // to the temporary parent of p;
             if (tmp->right == 0) {   // if 'true' rightmost node was
                  tmp->right = p;     // reached, make it a temporary
                  p = p->left;        // parent of the current root,
             }
             else {                   // else a temporary parent has been
                  visit(p);           // found; visit node p and then cut
                  tmp->right = 0;     // the right pointer of the current
                  p = p->right;       // parent, whereby it ceases to be
             }                        // a parent;
        }
}

插入

查找操作不会修改树。它通过扫描访问树的某些或者所有键值,但不会发生变化。而某些操作,增删改,合并树,以及平衡树结构并降低其高度则不然。本节讨论插入。
插入可以先用查找的策略,然后比较新节点键值和当前检查点的大小,大则寻找右子节点,小则寻找左节点。如果当前节点的相应子节点为空,停止扫描,让新节点称为当前节点的对应子节点。
插入新节点
总结一下之前讨论过的遍历查找方法:

  1. 广度优先遍历(队列)
  2. 深度优先遍历(递归,栈,线索树,树转换)

首先是最简单的插入算法,它不需要遍历,而是每次选择一个单子树往下,发现空节点就插入。
普通的插入算法如下:

template<typename T>
void BST<T>::insert(const T& el)
{
	BSTNode<T> *p = root, *prev = 0;
	while(p!=0)
	{
		prev = p;
		if(el < p->el) //已经进行过重载
		p = p->left;
		else
		p = p->right;
	}
	if(root == 0)
		root = el;
	else if(el < prev->el)
		prev->left = newBSTNode<T>(el);
	else prev->right = newBSTNode<T>(el);
}

前面我们介绍过线索树,那么这里我们将之前的内容串联起来。因为线索树中有一个successor标志位。我们可以在创建(一个个节点插入)的过程中将successor标志位利用起来,代码如下:

template<class T>
void ThreadedTree<T>::insert(const T& el) {
    ThreadedNode<T> *p, *prev = 0, *newNode;
    newNode = new ThreadedNode<T>(el);
    if (root == 0) {              // tree is empty;
        root = newNode;
        return;
     }
     p = root;                   // find a place to insert newNode;
     while (p != 0) {
         prev = p;
         if (p->key > el)
              p = p->left;
         else if (p->successor == 0) // go to the right node only if it
              p = p->right;    // is a descendant, not a successor;
         else break;              // don't follow successor link;
     }
     if (prev->key > el) {        // if newNode is left child of
          prev->left  = newNode;  // its parent, the parent becomes
          newNode->successor = 1; // also its successor;
          newNode->right = prev;
     }
     else if (prev->successor == 1) {// if the parent of newNode
          newNode->successor = 1; // is not the right-most node,
          prev->successor = 0;    // make parent's successor
          newNode->right = prev->right; // newNode's successor,
          prev->right = newNode;
     }
     else prev->right = newNode;  // otherwise it has no successor;
}

这段代码的核心思维是:从左节点开始,如果左子节点为空,那么将新节点插入,并将后继节点指向母节点。如果需要放到右节点,那么除了将新节点插入以外,将母节点的后继节点作为新节点的后继节点。

删除

删除操作的复杂度取决于要删除的节点在树中的位置。删除中间节点比删除叶子节点困难很多。删除共分为三种情况

  1. 要删除的节点是一个叶子节点,这种情况最为简单,设置空指针,并回收空间即可。
  2. 要删除的节点有一个子节点,操作父节点指针指向新的子节点,并回收空间。
  3. 要删除的节点有两个子节点,本节主要讨论这种比较复杂的情形:这里讲两种不同的方法。

合并删除

该解决方案将被删除节点的两个子树整合为一个子树。
合并删除图解
由于在该节点中左子树最大的节点仍然小于该节点的右子节点,所以可以将整个右子树转移到左子树中最大节点的右子节点后。

template<class T>
void BST<T>::findAndDeleteByMerging(const T& el) {    
    BSTNode<T> *node = root, *prev = 0;
    while (node != 0)
    {
        if (node->el == el)
             break;
        prev = node;
        if (el < node->el)
             node = node->left;
        else node = node->right;
    } //这个循环是为了找到node节点,即要删除的节点。
    if (node != 0 && node->el == el) //node不为空且找到了。
         if (node == root)
              deleteByMerging(root);
         else if (prev->left == node)
              deleteByMerging(prev->left);
         else deleteByMerging(prev->right);
    else if (root != 0) //该树不为空且,node为空或者node不等于要找的节点。
         cout << "el " << el << " is not in the tree\n";
    else cout << "the tree is empty\n"; //该树为空。
}

template<class T>
void BST<T>::deleteByMerging(BSTNode<T>*& node) //注意这个引用符号!!!
{   
    BSTNode<T> *tmp = node;
    if (node != 0)
    {
        if (!node->right) //如果该节点没有右子树,就用左子节点替换(左子节点为空也可)
             node = node->left;
        else if (node->left == 0) //如果有右子节点且无左子节点,可以用右子节点代替。
             node = node->right;
        else //如果左右子节点都有,那么就寻找左子树中最右的节点,并将该节点的右子节点指针指向原节点的右子节点。最后用原节点的左子节点替换原节点。
        {
             tmp = node->left;
             while (tmp->right != 0)
                tmp = tmp->right;
             tmp->right = node->right;
             tmp = node;
             node = node->left;
        }
        delete tmp;
     }
}

合并删除可能会导致树的高度增加,也可能导致树的高度下降,且新树有可能非常不平衡。因此这种算法效率不一定低,但并不完美。

复制删除

如果该节点有两个子节点,可以效仿之前的做法,找到左子树中的最右侧节点,然后转化为两个更简单的问题之一:

  1. 要删除的节点是叶子节点。
  2. 要删除的节点只有一个子节点。
    复制删除图解
    前面介绍了合并删除,是维持左子树不动的情况下将右子树嫁接到左子树最右侧节点之下。而这里介绍的复制删除则是将左子树下的最右侧节点替代原节点。这种方法降低了左子树的高度,右子树不受影响,但有一个缺陷:经历多次插入操作后,右子树比左子树繁茂,整个树将不再平衡。
    针对上述问题,可以对算法进行改进——交替进行左右子树的复制删除操作。关于其复杂度的计算过于复杂(J.Culberson证明),这里直接给出结论:
复制删除的形式IPL(内部路径长度)平均查找时间(平均路径长度)
插入及非对称删除 θ ( n n ) θ(n\sqrt{\smash{n}}) θ(nn ) θ ( n ) θ(\sqrt{\smash{n}}) θ(n )
插入及对称删除 θ ( n l g n ) θ(nlgn) θ(nlgn) θ ( l g n ) θ(lgn) θ(lgn)

树的平衡

之前强调过,通过树查找比通过链表查找快得多,但这并不总是成立。我们知道,树是可以退化为链表的,这样不仅浪费了额外的内存,还让查找过程变得繁琐。
高度平衡(height-balanced):如果任意节点两个子树的高度相差不超过1,或者简称为平衡的。并且一个典型的特征是,所有的叶子节点都处在一个或两个层次上,该树就是完全平衡的(perfectly balanced)
关于树记住一个关系:
设树的高度为h,则一个完全二叉树(也是平衡的)有如下表示:

  • 一层中的最大节点数: 2 h − 1 2^{h-1} 2h1
  • 该树包含的最大节点数: 2 h − 1 2^h-1 2h1

很容易得出,在一个完全平衡树中如果节点数为n,那么树的高度就是 ⌈ l g ( n ) ⌉ \lceil lg(n) \rceil lg(n),这个符号表示向上取整。
有很多方法可以对二叉树进行平衡,这里介绍重新创建和重新排序两类技术形成的树平衡算法。
当数据流到来时(这里以一个数组为例),指定中间元素为根。
平衡树构建过程
这里用排序算法对数组进行排序之后,每次取中间的作为当前层数的节点,然后再以两边的中心元素为下一层节点…一直到无法再细分。这样的结果可以保证二叉树的完全平衡。
采用递归方式实现代码如下:

template<class T>
void BST<T>::balance (T data[], int first, int last)
{
    if (first <= last)
    {
        int middle = (first + last)/2; //取中间元素
        insert(data[middle]); //二叉树的插入操作
        balance(data,first,middle-1); //左侧选取中间节点继续平衡
        balance(data,middle+1,last); //右侧选取中间节点继续平衡
    }
}

DSW算法

前边讨论的创建平衡树的算法效率比较低,因为需要使用额外的有序数组。这个算法在重建的时候需要通过中序遍历将元素取出,然后重建该树。下面介绍一种几乎不需要存储中间变量,也不需要排序DSW算法
旋转是该算法中非常重要的一部分:
CH围绕PAR右旋转
伪代码如下:

rotateRight(GR, PAR, CH)
{
	if PAR不是根节点 //GR非空
		CH成为GR的子节点;
	CH的右子树成为PAR的左子树;
	PAR成为CH的右节点;
} //右旋转

旋转有左旋转和右旋转两种形式。这种操作不会影响树的主要性质,该树依然是查找树。一般来说,DSW算法将任意二叉树转化了链表树,成为主干主链,然后围绕主链中第二个节点的父节点反复旋转,逐渐转化为完全平衡树。

创建主链的过程如下:

creatBackbone(root)
{
	tmp = root;
	while (tmp != 0)
		if tmp有左子节点;
			围绕tmp旋转该子节点; //即左子节点成为当前节点的父节点。
			tmp设置为刚成为父节点的子节点;
		else 将tmp设置为它的右子节点;
}

容易得到,此过程最好,最坏情况的时间复杂度都是O(N)。
通过此过程可以一步一步将作为左子树的节点整合进主链,形成一个只有右子树的查找树结构(单链)。此时进入第二个过程,将链转化为完全平衡树。
接下来需要对主链节点进行左旋转,仍然可以参考右旋转的图,只不过左旋转可以视作右旋转的逆向
CH节点围绕PAR节点做左旋转

rotateLeft(GR, PAR, CH)
{
	if PAR不是根节点 //GR非空
		PAR成为CH的子节点;
	CH的左子树成为PAR的右子树;
	PAR成为CH的左节点;
} //左旋转

主链到完整二叉树的演变过程
首先顺着主链向下操作,计算当前的节点数n与最接近的完全二叉树中的节点数 2 ⌊ l g ( n + 1 ) ⌋ − 1 2^{\lfloor lg(n+1) \rfloor}-1 2lg(n+1)1的差,这是为了计算最后一层多出来的节点。并单独进行处理。计算出节点数及其差值后,首先对主链进行预处理操作,之后再对预处理完成后的主链进行操作。
这里的操作是指:从主链的第二个节点开始,每隔一个节点进行一次旋转。操作的次数由伪代码中罗列的条件给出:

creatPerfectTree()
{
	n = 节点数;
	m = 最接近的完整二叉树的节点数;
	从主链顶端进行n-m次旋转;//预处理。
	while(m>1)
		m = m/2;
		从主链开始做m次旋转;//每间隔一个节点做一次。
}

对于第二阶段,可以通过追踪while中执行旋转的迭代次数进而求出旋转次数表示的复杂度:

( 2 l g ( m + 1 ) − 1 − 1 ) + . . . + 15 + 7 + 3 + 1 = ∑ i = 1 l g ( m + 1 ) − 1 ( 2 i − 1 ) = m − l g ( m + 1 ) (2^{lg(m+1)-1}-1)+...+15+7+3+1=\displaystyle\sum_{i=1}^{lg(m+1)-1}(2^i-1)=m-lg(m+1) (2lg(m+1)11)+...+15+7+3+1=i=1lg(m+1)1(2i1)=mlg(m+1)

所以总旋转次数如下:

n − m + ( m − l g ( m + 1 ) = n − l g ( m − 1 ) = n − ⌊ l g ( n + 1 ) ⌋ n-m+(m-lg(m+1)=n-lg(m-1)=n-\lfloor lg(n+1) \rfloor nm+(mlg(m+1)=nlg(m1)=nlg(n+1)

所以旋转的次数是O(n)。因此通过DSW算法进行全局平衡所需的时间随n线性增长,并且不需要额外的存储空间(相对于栈,更加节省空间)。

AVL树

前面讨论了全局建立平衡树或者重建平衡树的办法。此处引进AVL树(也叫可容许树)的概念。它要求每个节点的左右子树高度差最大为1。
注意:AVL是高度平衡树,而完全平衡树还要求所有叶子节点必须处在一个或两个高度上。
A V L h AVL_h AVLh是高度为h的AVL树的最小节点数,根据AVL树的定义,有:

A V L h = A V L h − 1 + A V L h − 2 + 1 AVL_h=AVL_{h-1}+AVL_{h-2}+1 AVLh=AVLh1+AVLh2+1
在这里插入图片描述
根据该公式可以推导出具有n个节点的AVL树高度h的上下限取决于节点数n(证明太难这里略过):

l g ( n + 1 ) ≤ h < 1.44 l g ( n + 2 ) − 0.328 lg(n+1)≤h<1.44lg(n+2)-0.328 lg(n+1)h<1.44lg(n+2)0.328

注意:AVL树高度受限于O(lgn)最坏情况下的查找次数为O(lgn)次。而对于高度相同的完全平衡二叉树,其h= O ( ⌈ l g ( n + 1 ) ⌉ ) O(\lceil lg(n+1) \rceil ) O(lg(n+1))所以经过实践,AVL在多数情况下比完全平衡二叉树效率更高。

插入操作:
如果出现节点左右子树差值大于1时,树就需要平衡。下面讨论2种常见的情况(关于左右子树对称的情形考虑在内,一共就有4种情况这里不再赘述)。
情况1:在右子节点的右子树插入一个节点,如果导致树失衡,可以通过一下操作进行平衡。插入情形下的AVL平衡
另一种情况是在右节点的左子树中插入节点。这样的处理过程略微麻烦一些。为了树再度平衡需要进行双重旋转。
插入情形下的AVL平衡
那么,上面解决了树的平衡问题,都是找到一个平衡因子不符合要求的节点,并将它及以下的节点视为独立子树。并且,不需要对该节点的前驱进行额外操作,因为该子树的总高度没有发生改变,对于这个节点的父节点,一直到根节点都不会有任何影响。

下面讨论如何找到那个关键的蓝色节点:
可以从插入的节点往根节点的方向(向上)更新节点的平衡因子,如果之前平衡因子是±1,那么修改为±2。这样这个平衡因子为2的点就是关键的蓝色节点,所有的操作都在这个节点的子树上进行。这个节点以上的任何节点都不用操作。

更新平衡因子的伪代码如下:

updateBalanceFactors()
{
	Q = 刚插入的节点;
	P = Q的父节点;
	if Q是P的左子节点
		P->balanceFactor--;
	else
		P->balanceFactor++;
	while(P非根节点且P的平衡因子不等于±2)
	{
		Q = P;
		P = P的父节点;
		if Q->(balanceFactor == 0)
			return; //这里一定是从1变为0,也就是说该节点往上的节点都没有受到影响。
		if Q是P的左子节点
			P->balanceFactor--;
		else p->balanceFactor++;
	}
	if (P->balanceFactor == ±2)
		以P为根节点进行旋转平衡;//这里可以分别对应4种情况进行旋转操作
}

根据以上算法,如果往上一直到根节点,都没有出现平衡因子为正负2的情况,那么将这条路径上的平衡因子更新即可,不需要做旋转操作。

删除操作:
现在我们来讨论删除的情况,这个比插入操作略微复杂。之前介绍过两种删除操作,我们选用对维持树平衡更有利的拷贝删除 deleteByCopying()。从树中删除节点后,路径上的节点平衡因子都需要更新。对于±2的节点来说,必须执行旋转操作来恢复平衡。注意:插入操作遇到±2的节点就可以停止往上搜索了,但删除操作不行,在最坏情况下,该路径上的每一个节点都需要旋转,设该树有n个节点,则有可能最多旋转 O ( l g n ) O(lgn) O(lgn)次才能重新实现树的平衡。
删除节点之后需要旋转的情况一共8种,考虑对称性我们考虑4种。
细节不表,我们直接上图:
删除操作的情况
前面提到过,进行插入和删除操作最多需要1.44lg(n+2)次查找。另外插入操作只需要一次单旋转或双重旋转即可,删除操作在最坏情况下需要1.44lg(n+2)次旋转。前面也提到,平均情况下需要lg(n)+0.25次查找,删除操作的旋转次数减小为这个数,插入操作只需要一次操作。室验还表明78%的情况下删除操作不需要重建平衡,而只有53%的插入操作不会使得树失衡。更复杂的删除操作,其对平衡性的影响反而比插入操作更小。

下面介绍一个扩展:允许AVL树的高度差∆>1,则此后最坏情况下树的高度增加为:

h = { 1.81 l g n − 0.71 ∆ = 2 2.15 l g n − 1.13 ∆ = 3 h= \begin{cases} 1.81lgn-0.71 &\text{} ∆=2 \\ 2.15lgn-1.13 &\text{} ∆=3 \end{cases} h={1.81lgn0.712.15lgn1.13=2=3

试验表明,与纯AVL树相比,扩展后的访问节点平均次数增加了一半,但重新构造的次数可以减少1/10。

自适应树

平衡树主要关心使树不要倾向任何一边,如果新节点威胁到树的平衡,之前介绍的AVL方法(局部重构),DSW(全局重构)纠正这一问题。平衡树可以提高效率,但并不是唯一可用的方法。
我们提出另一个办法,该方案取决于某个元素的常用程度(使用频率)。设计一种“优先树”,每个元素设置一个字段记录使用次数,扫描树之后将最长用的节点向根节点移动。长此以往,长期使用的元素将占据根节点下的前几层。

自重新构造树(self-restructuring tree)

介绍一种策略:

  1. 单一旋转:也就是前边提到的单次旋转的一种,将子节点围绕父节点旋转,并将父节点转变为子节点的子节点,子节点取代父节点。
  2. 移动到根部:重复单一旋转直到被访问的元素位于根部为止。

记住一个结论:已经确定将节点移动到根部的效率是在优化树中访问节点的2ln2倍,即复杂度为(2ln2)lgn。这一结果在任何概率分布下都成立。平均来说,单一旋转技术的平均查找时间为 Π ∗ n \sqrt{\varPi*n} Πn

张开策略(splaying)

之前“移动到根部”的一个改良版本为“张开策略”,该策略根据子节点、父节点和祖父节点之间的链接关系的顺序,成对地使用单一旋转。这里分为三种情况:
假设:被访问节点为R,其父节点为Q,祖父节点为P(如果有的话),

  1. 节点R的父节点Q是根节点。
  2. 同构配置(Homogeneous configuration),节点R,Q同时为其父节点的左子或右子节点。
  3. 异构配置(Heterogeneous configuration),节点R,Q分别为其父节点的左子和右子节点。
    该算法的伪代码如下:
splaying()
{
	while(R不是根节点)
	{
		if R的父节点是根节点;
			进行单一张开操作,使R围绕其父节点进行旋转(下图a);
		else if R与其前驱同构;
			进行一次同构张开操作,先围绕P旋转Q,后围绕Q旋转R(下图b);
		else //R与其前驱同构
			进行一次异构张开操作,先围绕Q旋转R,后围绕P旋转R(下图c);
	}
}

下面图解,对称情况自行考虑:
张开策略全解
“张开策略”是两次旋转的结合(被访问节点是根节点的情况除外),但和自适应树不同的是,如果同构,“张开”策略会先旋转父节点与祖父节点,这样有利于维持树的平衡。
对“张开”技术访问节点的效率进行摊销分析:
假设有一个二叉查找树t,令根为x的子树中节点数目为 n o d e ( x ) node(x) node(x), r a n k ( x ) = l g ( n o d e ( x ) ) rank(x)=lg(node(x)) rank(x)=lg(node(x)),因此 r a n k ( r o o t ( t ) ) = l g ( n ) rank(root(t))=lg(n) rank(root(t))=lg(n),并且:

p o t e n t i a l ( t ) = ∑ x 是 t 的 一 个 节 点 r a n k ( x ) potential(t)=\sum_{x是t的一个节点}rank(x) potential(t)=xtrank(x)

又因为x为t的子树,所以很明显:

n o d e ( x ) + 1 ≤ n o d e s ( p a r e n t ( x ) ) node(x)+1≤nodes(parent(x)) node(x)+1nodes(parent(x)),因此, r a n k ( x ) < n o d e s ( p a r e n t ( x ) ) rank(x)<nodes(parent(x)) rank(x)nodes(parent(x))

我们对于访问x节点的摊销成本定义为函数:

a m C o s t ( x ) = c o s t ( x ) + p o t e n t i a l s ( t ) + p o t e n t i a l o ( t ) amCost(x)=cost(x)+potential_s(t)+potential_o(t) amCost(x)=cost(x)+potentials(t)+potentialo(t)

该公式基于访问前后树的潜在成本的变化定义对x节点进行“张开”操作的摊销成本。因为每次访问,只会改变祖孙三个节点的rank,其他节点的复杂度不会改变

访问引理(Sleator和Tarjan, 1985):对于在x处“张开”的摊销成本:

a m C o s t ( x ) < 3 ( l g ( n ) − r a n k ( x ) ) + 1 amCost(x)<3(lg(n)-rank(x))+1 amCost(x)<3(lg(n)rank(x))+1

使用“张开”技术重新构造的树中访问节点的摊销成本等于O(lg(n)),与平衡树中最坏的情况相同。但是,单一访问,它的最坏情况仍可能是O(n),只有在足够多的次数m下,复杂度才可以视为O(mlg(n))与平衡树的效率相当。
“张开”关注元素本身胜过树的形状,在根部与底部频率相差不大的情况下“张开”可能不是最好的选择,我们可以改良“张开方法”。
“半张开(semisplaying)”是一个改进版,对于同构的“张开”,该策略单步只需要一次旋转,然后继续张开被访问节点的父节点,不张开节点本身。它并不要求将节点强行送上根部,仅仅向靠近根部的方向靠拢。【参见图b】

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值