数据结构学习(C++)—二叉树

来源:互联网 发布:js中focus 编辑:程序博客网 时间:2024/06/14 06:22

数据结构学习(C++)—二叉树

 happycock(原作)
【1】

这些天参与了CSDN论坛的讨论,改变了我以前的一些看法。回头看我以前的东西,我虽对这本书很不满,但我还是按照它的安排在一点点的写;这样就导致了,我过多的在意书中的偏漏,我写的更多是说“这本书怎样”,而偏离了我写这些的初衷——给正在学习数据结构的人一些帮助。正像我在前面所说的,虽然现有的教科书都不是很合理,但如果仅仅是抱怨这点,那无异于泼妇骂街。虽然本人的水平连初级都够不上,但至少先从我做一点尝试,以后这门课的教授方法必将一点点趋于合理。

因此,后面不在按照书上的次序,将本着“实际应用(算法)决定数据结构”的思想来讲解,常见教科书上有的,基本不再重点叙述(除了重点,例如AVL树的平衡旋转),——因此,在看本文的同时,一定要有一本教科书。这只是一个尝试,希望大家多提宝贵意见。

因为现实世界中存在这“树”这种结构——族谱、等级制度、目录分类等等,而为了研究这类问题,必须能够将树储存,而如何储存将取决于所需要的操作。这里有个问题,是否允许存在空树。有些书认为树都是非空的,因为树表示的是一种现实结构,而0不是自然数;我用过的教科书都是说可以有空树,当然是为了和二叉树统一。这个没有什么原则上的差别,反正就是一种习惯。

二叉树

二叉树可以说是人们假想的一个模型,因此,允许有空的二叉树是无争议的。二叉树是有序的,左边有一个孩子和右边有一个的二叉树是不同的两棵树。做这个规定,是因为人们赋予了左孩子和右孩子不同的意义,在二叉树的各种应用中,你将会清楚的看到。下面只讲解链式结构。看各种讲数据结构的书,你会发现一个有趣的现象:在二叉树这里,基本操作有计算树高、各种遍历,就是没有插入、删除——那树是怎么建立起来的?其实这很好理解,对于非线性的树结构,插入删除操作不在一定的法则规定下,是毫无意义的。因此,只有在具体的应用中,才会有插入删除操作。

节点结构

数据域、左指针、右指针肯定是必须的。除非很少用到节点的双亲,或者是资源紧张,建议附加一个双亲指针,这将会给很多算法带来方便,尤其是在这个“空间换时间”的时代。

template

struct BTNode

{

    BTNode(T data = T(), BTNode* left = NULL, BTNode* right = NULL, BTNode* parent = NULL)

        : data(data), left(left), right(right), parent(parent) {}

    BTNode *left, *right, *parent;

    T data;

};

基本的二叉树类

template

class BTree

{

public:

    BTree(BTNode *root = NULL) : root(root) {}

    ~BTree() { MakeEmpty(); }

    void MakeEmpty() { destroy(root); root = NULL; }

protected:

    BTNode *root;

private:

    void destroy(BTNode* p)

    {

        if (p)

        {

            destroy(p->left);

            destroy(p->right);

            delete p;

        }

    }

}

二叉树的遍历

基本上有4种遍历方法,先、中、后根,逐层。当初我对这个很迷惑,搞这么多干什么?到了后面才明白,这是不同的应用需要的。例如,判断两个二叉树是否相等,只要子树根节点不同,那么就不等,显然这时要用先序遍历;而删除二叉树,必须先删除左右子树,然后才能删除根节点,这时就要用后序遍历。

实际上,搞这么多遍历方法,根本原因是在内存中储存的树是非线性结构。对于用数组储存的二叉树,这些名目繁多的方法都是没有必要的。利用C++的封装和重载特性,这些遍历方法能很清晰的表达。

1.         前序遍历

public:

void PreOrder(void (*visit)(T &data) = print) { PreOrder(root, visit); }

private:

void PreOrder(BTNode* p, void (*visit)(T &data))

{

if (p){ visit(p->data); PreOrder(p->left, visit); PreOrder(p->right, visit); }

}

2.         中序遍历

public:

       void InOrder(void (*visit)(T &data) = print) { InOrder(root, visit); }

private:

void InOrder(BTNode* p, void (*visit)(T &data))

{

       if (p){ InOrder(p->left, visit);       visit(p->data);       InOrder(p->right, visit); }

       }

3.         后序遍历

public:

       void PostOrder(void (*visit)(T &data) = print) { PostOrder(root, visit); }

private:

void PostOrder(BTNode* p, void (*visit)(T &data))

{

       if (p){ PostOrder(p->left, visit); PostOrder(p->right, visit); visit(p->data); }

       }

4.         层次遍历

void LevelOrder(void (*visit)(T &data) = print)

{

       queue< BTNode* > a; BTNode* p = root;//记得#include

       while (p)

       {

              visit(p->data);

              if (p->left) a.push(p->left); if (p->right) a.push(p->right);

              if (a.empty()) break; p = a.front(); a.pop();

       }

       }

附注:缺省的visit函数print如下

private:

       static void print(T &data) { cout << data << ' '; }

5.         不用栈的非递归中序遍历

当有parent指针时,可以不用栈实现非递归的中序遍历,书上提到了有这种方法,但没给出例程。

public:

BTNode* next()

{

       if(!current) return NULL;

       if (current->right) { current = current->right; while (current->left) current = current->left; }

       else

       {

              BTNode* y = current->parent;

              while (y && current == y->right) {current = y; y = y->parent; }

              current = y;

       }

       return current;

}

private:

BTNode* current;

上面的函数能使current指针向前移动一个位置,如果要遍历整棵二叉树,需要使current指向中序序列的第一个节点,例如下面的成员函数:

public:

void first() { current = root; while (current->left) current = current->left; }

【2】 

线索化二叉树

这是数据结构课程里第一个碰到的难点,不知道你是不是这样看,反正我当初是费了不少脑细胞——当然,恼人的矩阵压缩和相关的加法乘法运算不在考虑之列。我费了不少脑细胞是因为思考:他们干什么呢?很欣喜的看到在这本黄皮书上,这章被打了*号,虽然我不确定作者是不是跟我一个想法——线索化二叉树在现在的PC上是毫无用处的!——不知我做了这个结论是不是会被人骂死,^_^

为了证明这个结论,我们来看看线索化二叉树提出的缘由:第一,我们想用比较少的时间,寻找二叉树某一个遍历线性序列的前驱或者后继。当然,这样的操作很频繁的时候,做这方面的改善才是有意义的。第二,二叉树的叶子节点还有两个指针域没有用,可以节省内存。说真的,提出线索化二叉树这样的构思真的很精巧,完全做到了“废物利用”——这个人真应该投身环保事业。但在计算机这个死板的东西身上,人们的精巧构思往往都是不能实现的——为了速度,计算机的各个部件都是整齐划一的,而构思的精巧往往都是建立在组成的复杂上的。

我们来看看线索化二叉树究竟能不能达到上面的两个目标。

求遍历后的线性序列的前驱和后继。前序线索化能依次找到后继,但是前驱需要求双亲;中序线索化前驱和后继都不需要求双亲,但是都不很直接;后序线索化能依次找到前驱,但是后继需要求双亲。可以看出,线索化成中序是最佳的选择,基本上算是达到了要求。

节省内存。添加了两个标志位,问题是这两个位怎么储存?即使是在支持位存储的CPU上,也是不能拿位存储器来存的,第一是因为结构体成员的地址是在一起的,第二是位存储器的数目是有限的。因此,最少需要1个字节来储存这两个标志位。而为了速度和移植,一般来说,内存是要对齐的,实际上根本就没节省内存!然而,当这个空间用来储存双亲指针时,带来的方便绝对不是线索化所能比拟的,前面已经给出了无栈的非递归遍历。并且,在线索化二叉树上插入删除操作附加的代价太大。

综上,线索化最好是中序线索化(前序后序线索化后还得用栈,何必要线索化呢),附加的标志域空间至少1个字节,在32位的CPU会要求对齐到4字节,还不如存储一个双亲指针,同样能达到中序线索化的目的,并且能带来其他的好处。所以,线索化二叉树在现在的PC上是毫无用处的!

由于对其他体系不太了解,以下观点姑妄听之。在内存空间非常充裕的现在,一个节点省23个字节实在是没什么意思(实际上由于对齐还省不出来);而在内存非常宝贵的地方(比如单片机),会尽量避免使用树结构——利用其他的方法。所以,现在看来,线索化二叉树真的是毫无用处了。

二叉搜索树

这恐怕是二叉树最重要的一个应用了。它的构想实际是个很自然的事情——查找值比当前节点小转左,大转右,等则查到,到头了就是没找着。越自然的东西越好理解,也就越不需要我废话。在给出BST的实现之前,我们要在二叉树的类中添加一个打印树状结构的成员函数,这样,就能清楚的看出插入和删除过程。

public:

void print()

{

       queue< BTNode* > a; queue flag; ofstream outfile("out.txt");

       BTNode* p = root; BTNode zero; bool v = true;

       int i = 1, level = 0, h = height();

       while (i < 2<

       {

              if (i == 1<

              {

                     cout << endl << setw(2 <<(h - level)); level++;

                     if (v) cout << p->data;

                     else cout << ' ';

              }

              else

              {

                     cout << setw(4 <<(h - level + 1));

                     if (v) cout << p->data;

                     else cout << "  ";

              }

              if (p->left) { a.push(p->left); flag.push(true); }

              else { a.push(&zero); flag.push(false); }

              if (p->right) { a.push(p->right); flag.push(true); }

              else { a.push(&zero); flag.push(false); }

              p = a.front(); a.pop(); v = flag.front(); flag.pop(); i++;

       }

       cout << endl;

}

打印树状结构的核心是按层次遍历二叉树,但是,二叉树有许多节点缺左或右子树,连带的越到下面空隙越大。为了按照树的结构打印,必须把二叉树补成完全二叉树,这样下面的节点就知道放在什么位置了——a.push(&zero);但是这样的节点不能让它打印出来,所以对应每个节点,有一个是否打印的标志,按理说pair结构很合适,为了简单我用了并列的两个队列,一个放节点指针——a,一个放打印标志——flag。这样一来,循环结束的标志就不能是队列空——永远都不可能空,碰到NULL就补一个节点——而是变成了到了满二叉树的最后一个节点2^(height+1)-1。——黄皮书对于树高的定义是,空树为的高度为-1。

对于输出格式,注意的是到了第1、2、4、8号节点要换行,并且在同一行中,第一个节点的域宽是后序节点的一半。上面的函数在树的层次少于等于5(height<=4)的时候能正常显示,再多的话就必须输出到文件中去ofstream outfile("out.txt");——如果层次再多的话,打印出来也没什么意义了。

二叉搜索树的实现

实际上就是在二叉树的基础上增加了插入、删除、查找。

#include "BaseTree.h"

template

class BSTree : public BTree

{

public:

       BTNode* &find(const T &data)

       {

              BTNode** p = &root; current = NULL;

              while(*p)

              {

                     if ((*p)->data == data) break;

                     if ((*p)->data < data) { current = *p; p = &((*p)->right); }

                     else { current = *p; p = &((*p)->left); }

              }

              return *p;

       }

       bool insert(const T &data)

       {

              BTNode* &p = find(data); if (p) return false;

              p = new BTNode(data, NULL, NULL, current); return true;

       }

       bool remove(const T &data)

       {

              return remove(find(data));

       }

private:

bool remove(BTNode* &p)

{

       if (!p) return false; BTNode* t = p;

       if (!p->left || !p->right)

       {

              if (!p->left) p = p->right; else p = p->left;

              if (p) p->parent = current;

              delete t; return true;

       }

       t=p->right;while(t->left) t=t->left;p->data=t->data;current=t->parent;

       return remove(current->left==t?current->left:current->right);

       }

};

以上代码有点费解,有必要说明一下——非线性链式结构操作的实现都是很让人费神。insert和remove都是以find为基础的,因此必须让find能最大限度的被这两个操作利用。

l         对于insert,需要修改查找失败时的指针内容,显然这是个内部指针(在双亲节点的内部,而不是象root和current那样在节点外面指向节点),这就要求find返回一个内部指针的引用。但是C++的引用绑定到一个对象之后,就不能再改变了,因此在find内部的实现是一个二重指针。insert操作还需要修改插入的新节点的parent指针域,因此在find中要产生一个能被insert访问的指向find返回值所在节点的指针,这里用的是current。实际上find返回的指针引用不是current->left就是current->right。这样一来,insert的实现就非常简单了。

l         对于remove,需要修改查找成功时的指针内容,同样是个内部指针。在find的基础上,很容易就能得到这个内部指针的引用(BTNode* &p = find(data)

Ø         在p->left和p->right中至少有一个为NULL的情况下,如果p->left ==NULL,那么就重连右子树p = p->right,反之,重连左子树p = p->left。注意,左右子树全空的情况也包含在这两个操作中了——在p->left ==NULL的时候重连右子树,而这时p->right也是NULL——因此不必列出来。如果重连后p不为空,需要修改p->parent = current。

Ø         若p->left和p->right都不为空,可以转化为有一个为空。例如一个中序有序序列[1,2,3,4,5],假设3既有左子树又有右子树,那么它的前驱2一定缺右子树,后继4一定缺少左子树。【注1】这样一来删除节点3就等效成从[1,2,3(4),4,5]删除节点4。这样就可以利用上面的在p->left和p->right中至少有一个为NULL的情况下的方法了。还是由于C++的引用不能改变绑定对象,这里是用利用递归来解决的,还好最多只递归一次。如果用二重指针又是满天星星了,这就是明明是尾递归却没有消去的原因。

【注1】这是因为,如果3既有左子树又有右子树,那么2一定在3的左子树上,4一定在3的右子树上;如果2有右子树,那么在2和3之间还应该有一个节点;如果4有左子树,那么3和4之间也应该还有一个节点。

【闲话】上面关于remove操作p->left和p->right都不为空的处理方法的讲解,源于严蔚敏老师的课件,看完后我豁然开朗,真不知道为什么她自己那本《数据结构(C语言版)》这里写的那么难懂,我是死活没看明白。

对该文的评论

ZhangYv(2003-8-11 16:50:28) 
正因为把树退化成链表所以遍历时候才快.删除和插入结点确实慢了很多.
  1
  / \ 
2  3
/ \ 
4  5
提醒一下,先序遍历中5的前驱为4,并不是父结点.
happycock(2003-8-11 9:46:02) 
1。线索化只能存在一种,要么是中序的,要么是前序或者后序。
2。频繁的寻找前驱和后继在一般是在中序序列中——在前序序列中找前驱是需要双亲信息的,后序反之
3。在中序线索化之后,在有些节点同样不能直接找到前驱和后继,仍然要沿着子树往下走。
4。删除和插入节点时,修改线索指针并不是很容易的事,需要修改线索和标志。
线索化只是将非线性的变成了线性的序列,从结构上来看,就是将树退化成了单链表。

ZhangYv(2003-8-10 22:44:25) 
在一般情况下是无法直接查找一个结点的在"某种(或任意)遍历下的前驱和后继"结点,利用线索二叉树的"线索标志"可以.利用"线索的标志"和parent指针起到的作用是不同的.
    1
  / \ 
  2  3
/ \ 
4  5
结点5的par指向结点2,而线索指针指向1,前者找父结点,后者找某种遍历次序下的后继结点,两者的作用根本就是同的怎能混为一谈?加线索自然有的好处,主要是为了查找前驱和后继方便,如果频繁的查找就需要先线索话二叉树,需要牺牲n个或者2*n个标记空间来换取时间的提高.
其他就不多说了,我不觉得我很顽固...

happycock(2003-8-9 22:28:20) 
真的很顽固,要想不用栈必须要线索化成中序,同样的储存空间,parent比线索化要来得方便的多。线索化是要退出舞台了。

ZhangYv(2003-8-9 9:54:40) 
嘿嘿,看来还得修正一个大大的错误.线索化二叉树可以使遍历二叉树真正非递归化,就是不需要自己模拟或者系统分配栈.可以大大提高遍历得效率,如果对该树进行遍历的运算较频繁,就应该线索化树.
happycock(2003-8-6 12:40:02) 
修正一个错误。
bool remove(BTNode<T>* &p)
{
        if (!p) return false; BTNode<T>* t = p;
        if (!p->left ¦¦ !p->right)
        {
                if (!p->left) p = p->right; else p = p->left;
                if (p) p->parent = current;
                delete t; return true;
        }
        t=p->right;while(t->left) t=t->left;p->data=t->data;current=t->parent;
        return remove(current->left==t?current->left:current->right);
}

【3】

递归遍历与非递归遍历

前面写过一些关于递归的文章,因为那时还没有写到树,因此也举不出更有说服力的例子,只是阐述了“递归是一种思想”,正像网友评价的,“一篇入门的文章”。但只要能能让你建立“递归是一种思想”这个观念,我的努力就没有白费。现在,讲完了二叉搜索树,终于有了能说明问题的例子了。按照前面提供的代码,应该能调试通过下面的程序。

#include

using namespace std;

#include

#include

#include "BSTree.h"

#include "Timer.h"

#define random(num)     (rand() % (num))

#define randomize()     srand((unsigned)time(NULL))

#define NODENUM 200000//node number

int data[NODENUM];

void zero(int &t) { t = 0; }

int main()

{

       BSTree a; Timer t; randomize(); int i;

       for (i = 0; i < NODENUM; i++) data[i] = i;

       for (i = 0; i < NODENUM; i++) swap(data[i], data[random(NODENUM)]);//random swap

       t.start(); for (i = 0; i < NODENUM; i++) a.insert(data[i]);

       cout << "Insert time: " << t.GetTime() << "\tNode number: " << NODENUM << endl;

       t.start(); for (a.first(); a.get() != NULL; a.next()) a.get()->data = 0;

       cout << "Non-Stack time: " << t.GetTime() << endl;

       t.start(); a.LevelOrder(zero); cout << "LevlOrder time: " << t.GetTime() << endl;

       t.start(); a.PreOrder(zero); cout << " PreOrder time: " << t.GetTime() << endl;

       t.start(); a.InOrder(zero); cout << "  InOrder time: " << t.GetTime() << endl;

       t.start(); a.PostOrder(zero); cout << "PostOrder time: " << t.GetTime() << endl;

       return 0;

}

以下是timer.h的内容

#ifndef Timer_H

#define Timer_H

#include

class Timer

0 0