6.3 树和二叉树

二叉树(Binary Tree)的递归定义如下:二叉树要么为空,要么由根结点(root)、左子树(left subtree)和右子树(right subtree)组成,而左子树和右子树分别是一棵二叉树。注意,在计算机中,树一般是“倒置”的,即根在上,叶子在下。

树(tree)和二叉树类似,区别在于每个结点不一定只有两棵子树。本书就是树状结构,根结点有12棵子树:第1章、第2章、第3章、……、第12章,而第1章又有5棵子树:1.1、1.2、……、1.5。

不管是二叉树还是树,每个非根结点都有一个父亲(father),也称父结点。

6.3.1 二叉树的编号

例题6-6 小球下落(Dropping Balls, UVa 679)

有一棵二叉树,最大深度为D,且所有叶子的深度都相同。所有结点从上到下从左到右编号为1, 2, 3,…, 2D-1。在结点1处放一个小球,它会往下落。每个内结点上都有一个开关,初始全部关闭,当每次有小球落到一个开关上时,状态都会改变。当小球到达一个内结点时,如果该结点上的开关关闭,则往左走,否则往右走,直到走到叶子结点,如图6-2所示。

图6-2 所有叶子深度相同的二叉树

一些小球从结点1处依次开始下落,最后一个小球将会落到哪里呢?输入叶子深度D和小球个数I,输出第I个小球最后所在的叶子编号。假设I不超过整棵树的叶子个数。D≤20。输入最多包含1000组数据。

样例输入:

4 2

3 4

10 1

2 2

8 128

16 12345

样例输出:

12

7

512

3

255

36358

【分析】

不难发现,对于一个结点k,其左子结点、右子结点的编号分别是2k和2k+1。这个结论非常重要,请读者引起重视。

提示6-11:给定一棵包含2d个结点(其中d为树的高度)的完全二叉树,如果把结点从上到下从左到右编号为1,2,3……,则结点k的左右子结点编号分别为2k和2k+1。

这样,不难写出如下的模拟程序:


#include<cstdio>
#include<cstring>
const int maxd = 20;
int s[1<<maxd];                                //最大结点个数为2maxd-1
int main() {
  int D, I;
  while(scanf("%d%d", &D, &I) == 2) {
    memset(s, 0, sizeof(s));                         //开关
    int k, n = (1<<D)-1;                       //n是最大结点编号
    for(int i = 0; i < I; i++){                   //连续让I个小球下落
      k = 1;
      for(;;) {
        s[k] = !s[k];
        k = s[k] ? k*2 : k*2+1;                      //根据开关状态选择下落方向
        if(k > n) break;                          //已经落“出界”了
      }                                             
    }                                               
    printf("%d\n", k/2);                             //“出界”之前的叶子编号
  }
  return 0;
}

尽管在本题中,每个小球都是严格下落D-1层,但用“if(k > n) break”的方法判断“出界”更具一般性,所以上面的代码采用了这种方法。

这个程序和前面用数组模拟盒子移动的程序有一个共同的缺点:运算量太大。由于I可以高达2D-1,每组测试数据下落总层数可能会高达219*19=9961472,而一共可能有10000组数据……

每个小球都会落在根结点上,因此前两个小球必然是一个在左子树,一个在右子树。一般地,只需看小球编号的奇偶性,就能知道它是最终在哪棵子树中。对于那些落入根结点左子树的小球来说,只需知道该小球是第几个落在根的左子树里的,就可以知道它下一步往左还是往右了。依此类推,直到小球落到叶子上。

如果使用题目中给出的编号I,则当I是奇数时,它是往左走的第(I+1)/2个小球;当I是偶数时,它是往右走的第I/2个小球。这样,可以直接模拟最后一个小球的路线:


while(scanf("%d%d", &D, &I) == 2){
  int k = 1;
  for(int i = 0; i &lt; D-1; i++)
    if(I%2) { k = k*2; I = (I+1)/2; }
    else { k = k*2+1; I /= 2; }
  printf("%d\n", k);
}

这样,程序的运算量就与小球编号无关了,而且节省了一个巨大的s数组。

6.3.2 二叉树的层次遍历

例题6-7 树的层次遍历(Trees on the level, Duke 1993, UVa 122)

输入一棵二叉树,你的任务是按从上到下、从左到右的顺序输出各个结点的值。每个结点都按照从根结点到它的移动序列给出(L表示左,R表示右)。在输入中,每个结点的左括号和右括号之间没有空格,相邻结点之间用一个空格隔开。每棵树的输入用一对空括号“()”结束(这对括号本身不代表一个结点),如图6-3所示。

图6-3 一棵二叉树

注意,如果从根到某个叶结点的路径上有的结点没有在输入中给出,或者给出超过一次,应当输出-1。结点个数不超过256。

样例输入:

(11,LL) (7,LLL) (8,R) (5,) (4,L) (13,RL) (2,LLR) (1,RRR) (4,RR) ()

(3,L) (4,R) ()

样例输出:

5 4 8 11 13 4 7 2 1

-1

【分析】

受6.3.1节的启发,是否可以把树上的结点编号,然后把二叉树储存在数组中呢?很遗憾,这样的方法在本题中是行不通的。题目中已限制结点最多有256个。如果各个结点形成一条链,最后一个结点的编号将是巨大的!就算用高精度保存编号,数组也开不下。

看来,需要采用动态结构,根据需要建立新的结点,然后将其组织成一棵树。首先,编写输入部分和主程序:


char s[maxn];                                 //保存读入结点
bool read_input(){
  failed = false;
  root = newnode();                           //创建根结点
  for(;;){
    if(scanf("%s", s) != 1) return false;     //整个输入结束
    if(!strcmp(s, "()")) break;               //读到结束标志,退出循环
    int v;
    sscanf(&s[1], "%d", &v);                  //读入结点值
    addnode(v, strchr(s, ',')+1);             //查找逗号,然后插入结点
  }
  return true;
}

程序不难理解:不停读入结点,如果在读到空括号之前文件结束,则返回0(这样,在main函数里就能得知输入结束)。注意,这里两次用到了C语言中字符串的灵活性——可以把任意“指向字符的指针”看成是字符串,从该位置开始,直到字符“\0”。例如,若读到的结点是(11,LL),则&s[1]所对应的字符串是“11,LL)”。函数strchr(s, ',’)返回字符串s中从左往右第一个字符“,”的指针,因此strchr(s, ',')+1所对应的字符串是“LL)”。这样,实际调用的是addnode(11, "LL)")。

接下来是重头戏了:二叉树的结点定义和操作。首先,需要定义一个称为Node的结构体,并且对应整棵二叉树的树根root:


//结点类型
struct Node{
  bool have_value;                             //是否被赋值过
  int v;                                       //结点值
  Node *left, *right;
  Node():have_value(false),left(NULL),right(NULL){} //构造函数
};
Node* root;                                     //二叉树的根结点

由于二叉树是递归定义的,其左右子结点类型都是“指向结点类型的指针”。换句话说,如果结点的类型为Node,则左右子结点的类型都是Node *。

提示6-12:如果要定义一棵二叉树,一般是定义一个“结点”类型的struct(如叫Node),然后保存树根的指针(如Node* root)。

每次需要一个新的Node时,都要用new运算符申请内存,并执行构造函数。下面把申请新结点的操作封装到newnode函数中:

Node* newnode() { return new Node(); }

提示6-13:可以用new运算符申请空间并执行构造函数。如果返回值为NULL,说明空间不足,申请失败。

接下来是在read_input中调用的addnode函数。它按照移动序列行走,目标不存在时调用newnode来创建新结点。


void addnode(int v, char* s){
  int n = strlen(s);
  Node* u = root;                                  //从根结点开始往下走
  for(int i = 0; i < n; i++)
    if(s[i] == 'L'){
      if(u->left == NULL) u->left = newnode();     //结点不存在,建立新结点
      u = u->left;                                 //往左走
    } else if(s[i] == 'R'){
      if(u->right == NULL) u->right = newnode();
      u = u->right;    
    }                                     //忽略其他情况,即最后那个多余的右括号
  if(u->have_value) failed = true;        //已经赋过值,表明输入有误
  u->v = v;
  u->have_value = true;                            //别忘记做标记
}

这样一来,输入和建树部分已经结束,接下来需要按照层次顺序遍历这棵树。此处使用一个队列来完成这个任务,初始时只有一个根结点,然后每次取出一个结点,就把它的左右子结点(如果存在)放进队列。


bool bfs(vector<int>& ans){
  queue<Node*> q;
  ans.clear();
  q.push(root);                              //初始时只有一个根结点
  while(!q.empty()){
    Node* u = q.front(); q.pop();
    if(!u->have_value) return false;         //有结点没有被赋值过,表明输入有误
    ans.push_back(u->v);                     //增加到输出序列尾部
    if(u->left != NULL) q.push(u->left);     //把左子结点(如果有)放进队列
    if(u->right != NULL) q.push(u->right);   //把右子结点(如果有)放进队列
  }
  return true;                               //输入正确
}

这样遍历二叉树的方法称为宽度优先遍历(Breadth-First Search,BFS)。后面将看到,BFS在显示图和隐式图算法中扮演着重要的角色。

提示6-14:可以用队列实现二叉树的层次遍历。这个方法还有一个名字,叫做宽度优先遍历(Breadth-First Search,BFS)。

上面的程序在功能上是正确的,但有一个小小的技术问题:在输入一组新数据时,没有释放上一棵二叉树所申请的内存空间。一旦执行了root = newnode(),就再也无法访问到那些内存了,尽管那些内存物理上仍然存在。

当然,从技术上说,还是可以访问到那些内存的,如果能“猜到”那些地址。之所以说“访问不到”,是因为丢失了指向这些内存的指针。如果读者觉得这难以理解,想象一下丢失电话号码以后的情形:理论上仍然可以像以前一样给朋友们打电话,只是没有了电话簿,查不到他们的号码了。

有一个专业术语用来描述这样的情况:内存泄漏(memory leak)——它意味着有些内存被白白浪费了。在实际运行的过程中,一般很难看出这个问题:在很多情况下,内存空间都不会很紧张,浪费一些空间后,程序还是可以正常运行;况且在整个程序结束后,该程序占用的空间会被操作系统全部回收,包括泄漏的那些。

提示6-15:如果程序动态申请内存,请注意内存泄漏。程序执行完毕后,操作系统会回收该程序申请的所有内存(包括泄漏的),所以在算法竞赛中内存泄漏往往不会造成什么影响。但是,从专业素养的角度考虑,请从现在开始养成好习惯,对内存泄漏保持警惕。

下面是释放一棵二叉树的代码(2),请在“root = newnode()”之前加一行“remove_tree(root)”:


void remove_tree(Node* u) {
  if(u == NULL) return;         //提前判断比较稳妥
  remove_tree(u->left);         //递归释放左子树的空间
  remove_tree(u->right);        //递归释放右子树的空间
  delete u;                     //调用u的析构函数并释放u结点本身的内存
}

二叉树并不一定要用指针实现。接下来,把指针完全去掉。首先还是给每个结点编号,但不是按照从上到下从左到右的顺序,而是按照结点的生成顺序。用计数器cnt表示已存在的结点编号的最大值,因此newnode函数需要改成这样:


    const int root = 1;
    void newtree() { left[root] = right[root] = 0; have_value[root] = false; cnt
= root; }
    int newnode() { int u = ++cnt; left[u] = right[u] = 0; have_value[root] =
false; return u; }

上面的newtree()是用来代替前面的“remove_tree(root)”和“root = newnode()”两条语句的:由于没有了动态内存的申请和释放,只需要重置结点计数器和根结点的左右子树了。

接下来,把所有的Node*类型改成int类型,然后把结点结构中的成员变量改成全局数组(例如,u->left和u->right分别改成left[u]和right[u]),除了char*外,整个程序就没有任何指针了。

提示6-16:可以用数组来实现二叉树,方法是用整数表示结点编号,left[u]和right[u]分别表示u的左右子结点的编号。

虽然包括笔者在内的很多选手更喜欢用数组方式实现二叉树(因为编程简单,容易调试),但仍然需要具体问题具体分析。例如,用指针直接访问比“数组+下标”的方式略快,因此有的选手喜欢用“结构体+指针”的方式处理动态数据结构,但在申请结点时仍然用这里的“动态化静态”的思想,把newnode函数写成:

Node* newnode(){ Node* u = &node[++cnt]; u->left = u->right = NULL;u->have_value = false; return u;}

其中,node是静态申请的结构体数组。这样写的坏处在于“释放内存”很不方便(想一想,为什么)。如果反复执行新建结点和删除结点,cnt会一直增加,但是用完的内存却无法重用。在大多数算法竞赛题目中,这并不会引起问题,但也有一些对内存要求极高的题目,对内存的一点浪费就会引起“内存溢出”错误。常见的解决方案是写一个简单的内存池(memory pool),具体来说就是维护一个空闲列表(free list),初始时把上述node数组中所有元素的指针放到该列表中,如下所示:


queue<Node*> freenodes;
Node node[maxn];

void init() {
  for(int i = 0; i < maxn; i++)
    freenodes.push(&node[i]); //初始化内存池
}

Node* newnode() {
  Node* u = freenodes.front();
  u->left = u->right = NULL; u->have_value = false; //重新初始化该结点
  freenodes.pop();
  return u;
}

void deletenode(Node* u) {
  freenodes.push(u);
}

提示6-17:可以用静态数组配合空闲列表来实现一个简单的内存池。虽然在大多数算法竞赛题目中用不上,但是内存池技术在高水平竞赛以及工程实践中都极为重要。

6.3.3 二叉树的递归遍历

对于二叉树T,可以递归定义它的先序遍历、中序遍历和后序遍历,如下所示:

PreOrder(T)=T的根结点+PreOrder(T的左子树)+PreOrder(T的右子树)

InOrder(T)=InOrder(T的左子树)+T的根结点+InOrder(T的右子树)

PostOrder(T)=PostOrder(T的左子树)+PostOrder(T的右子树)+T的根结点

   

图6-4 另一棵二叉树

其中,加号表示字符串连接运算。例如,对于如图6-4所示的二叉树,先序遍历为DBACEGF,中序遍历为ABCDEFG。

这3种遍历都属于递归遍历,或者说深度优先遍历(Depth-First Search,DFS),因为它总是优先往深处访问。

提示6-18:二叉树有3种深度优先遍历:先序遍历、中序遍历和后序遍历。

例题6-8 树(Tree, UVa 548)

给一棵点带权(权值各不相同,都是小于10000的正整数)的二叉树的中序和后序遍历,找一个叶子使得它到根的路径上的权和最小。如果有多解,该叶子本身的权应尽量小。输入中每两行表示一棵树,其中第一行为中序遍历,第二行为后序遍历。

样例输入:

3 2 1 4 5 7 6

3 1 2 5 6 7 4

7 8 11 3 5 16 12 18

8 3 11 7 16 18 12 5

255

255

样例输出:

1

3

255

【分析】

后序遍历的第一个字符就是根,因此只需在中序遍历中找到它,就知道左右子树的中序和后序遍历了。这样可以先把二叉树构造出来,然后再执行一次递归遍历,找到最优解。

提示6-19:给定二叉树的中序遍历和后序遍历,可以构造出这棵二叉树。方法是根据后序遍历找到树根,然后在中序遍历中找到树根,从而找出左右子树的结点列表,然后递归构造左右子树。

代码如下:(另外,也可以在递归的同时统计最优解,不过程序稍微复杂一点,留给读者练习。)


    #include<string>
    #include<iostream>
    #include<sstream>
    #include<algorithm>
    using namespace std;

    //因为各个结点的权值各不相同且都是正整数,直接用权值作为结点编号
    const int maxv = 10000 + 10;
    int in_order[maxv], post_order[maxv], lch[maxv], rch[maxv];
    int n;

    bool read_list(int* a) {
      string line;
      if(!getline(cin, line)) return false;
      stringstream ss(line);
      n = 0;
      int x;
      while(ss >> x) a[n++] = x;
      return n > 0;
    }

    //把in_order[L1..R1]和post_order[L2..R2]建成一棵二叉树,返回树根
    int build(int L1, int R1, int L2, int R2) {
      if(L1 > R1) return 0; //空树
      int root = post_order[R2];
      int p = L1;
      while(in_order[p] != root) p++;
      int cnt = p-L1; //左子树的结点个数
      lch[root] = build(L1, p-1, L2, L2+cnt-1);
      rch[root] = build(p+1, R1, L2+cnt, R2-1);
      return root;
    }

    int best, best_sum; //目前为止的最优解和对应的权和

    void dfs(int u, int sum) {
      sum += u;
      if(!lch[u] && !rch[u]) { //叶子
        if(sum < best_sum || (sum == best_sum && u < best)) { best = u; best_sum
= sum; }
      }
      if(lch[u]) dfs(lch[u], sum);
      if(rch[u]) dfs(rch[u], sum);
    }

    int main() {
      while(read_list(in_order)) {
        read_list(post_order);
        build(0, n-1, 0, n-1);
        best_sum = 1000000000;
        dfs(post_order[n-1], 0);
        cout << best << "\n";
      }
      return 0;
    }

例题6-9 天平(Not so Mobile, UVa 839)

输入一个树状天平,根据力矩相等原则判断是否平衡。如图6-5所示,所谓力矩相等,就是WlDl=WrDr,其中WlWr分别为左右两边砝码的重量,D为距离。

采用递归(先序)方式输入:每个天平的格式为WlDlWrDr,当WlWr为0时,表示该“砝码”实际是一个子天平,接下来会描述这个子天平。当Wl=Wr=0时,会先描述左子天平,然后是右子天平。

样例输入:

1

0 2 0 4

0 3 0 1

1 1 1 1

2 4 4 2

1 6 3 2

其正确输出为YES,对应图6-6。

 

  图6-5 天平     图6-6 正确输出  

【分析】

在解决这道题目之前,请先弄清楚题目的意思,尤其建议读者把样例输入画出来,以确保正确理解输入格式。

提示6-20:当题目比较复杂时,建议先手算样例或者至少把样例的图示画出来,以免误解题意。

这道题目的输入就采取了递归方式定义,因此编写一个递归过程进行输入比较自然。事实上,在输入过程中就能完成判断。由于使用引用传值,代码非常精简。

本题极为重要,请读者在继续阅读之前确保完全理解了下面的程序。


#include<iostream>
using namespace std;
//输入一个子天平,返回子天平是否平衡,参数W修改为子天平的总重量
bool solve(int& W) {
  int W1, D1, W2, D2;
  bool b1 = true, b2 = true;
  cin >> W1 >> D1 >> W2 >> D2;
  if(!W1) b1 = solve(W1);
  if(!W2) b2 = solve(W2);
  W = W1 + W2;
  return b1 && b2 && (W1 * D1 == W2 * D2);
}

int main() {
  int T, W;
  cin >> T;
  while(T--) {
    if(solve(W)) cout << "YES\n"; else cout << "NO\n";
    if(T) cout << "\n";
  }
  return 0;
}

例题6-10 下落的树叶(The Falling Leaves, UVa 699)

   

图6-7 结点权值

给一棵二叉树,每个结点都有一个水平位置:左子结点在它左边1个单位,右子结点在右边1个单位。从左向右输出每个水平位置的所有结点的权值之和。如图6-7所示,从左到右的3个位置的权和分别为7,11,3。按照递归(先序)方式输入,用-1表示空树。

样例输入:

5 7 -1 6 -1 -1 3 -1 -1

8 2 9 -1 -1 6 5 -1 -1 12 -1 -1 3 7 -1 -1 -1

-1

样例输出:

Case 1:

7 11 3

Case 2:

9 7 21 15

【分析】

本题和例题6-9很相似,但是实现细节比例题6-9略多,读者可以参考代码(这是一个不错的阅读练习)。为了节省篇幅,下面略去了唯一的全局变量int sum[maxn]。


    //输入并统计一棵子树,树根水平位置为p
    void build(int p) {
      int v; cin >> v;
      if(v == -1) return;                     //空树
      sum[p] += v;
      build(p - 1); build(p + 1);
    }

    //边读入边统计
    bool init() {
      int v; cin >> v;
      if(v == -1) return false;
      memset(sum, 0, sizeof(sum));
      int pos = maxn/2;                       //树根的水平位置
      sum[pos] = v;
  build(pos - 1); build(pos + 1);
}

int main( ) {
  int kase = 0;
  while(init( )) {
    int p = 0;
    while(sum[p] == 0) p++;                 //找最左边的叶子
    cout << "Case " << ++kase << ":\n" << sum[p++];//因为要避免行末多余空格
    while(sum[p] != 0) cout << " " << sum[p++];
    cout << "\n\n";
  }
  return 0;
}

6.3.4 非二叉树

例题6-11 四分树(Quadtrees, UVa 297)

如图6-8所示,可以用四分树来表示一个黑白图像,方法是用根结点表示整幅图像,然后把行列各分成两等分,按照图中的方式编号,从左到右对应4个子结点。如果某子结点对应的区域全黑或者全白,则直接用一个黑结点或者白结点表示;如果既有黑又有白,则用一个灰结点表示,并且为这个区域递归建树。

图6-8 四分树

给出两棵四分树的先序遍历,求二者合并之后(黑色部分合并)黑色像素的个数。p表示中间结点,f表示黑色(full),e表示白色(empty)。

样例输入:

3

ppeeefpffeefe

pefepeefe

peeef

peefe

peeef

peepefefe

样例输出:

There are 640 black pixels.

There are 512 black pixels.

There are 384 black pixels.

【分析】

由于四分树比较特殊,只需给出先序遍历就能确定整棵树(想一想,为什么)。只需要编写一个“画出来”的过程,边画边统计即可。


#include<cstdio>
#include<cstring>

const int len = 32;
const int maxn = 1024 + 10;
char s[maxn];
int buf[len][len], cnt;

//把字符串s[p..]导出到以(r,c)为左上角,边长为w的缓冲区中
//2 1
//3 4
void draw(const char* s, int& p, int r, int c, int w) {
  char ch = s[p++];
  if(ch == 'p') {
    draw(s, p, r,     c+w/2, w/2); //1
    draw(s, p, r,     c    , w/2); //2
    draw(s, p, r+w/2, c    , w/2); //3
    draw(s, p, r+w/2, c+w/2, w/2); //4
  } else if(ch == 'f') { //画黑像素(白像素不画)
    for(int i = r; i < r+w; i++)
      for(int j = c; j < c+w; j++)
       if(buf[i][j] == 0) { buf[i][j] = 1; cnt++; }
  }
}
int main( ) {
  int T;
  scanf("%d", &T);
  while(T--) {
    memset(buf, 0, sizeof(buf));
    cnt = 0;
    for(int i = 0; i < 2; i++) {
      scanf("%s", s);
      int p = 0;
      draw(s, p, 0, 0, len);
    }
    printf("There are %d black pixels.\n", cnt);
  }
  return 0;
}