《算法笔记》——第九章 树 学习记录

树的性质

由于机考的性质,读者不需要对树的许多理论知识都了如指掌,下面只给出几个比较实用的概念和性质,希望读者能把它们记住,其中性质①⑤经常被用来出边界数据:

  1. 树可以没有结点,这种情况下把树称为空树(empty tree)。
  2. 树的层次(layer) 从根结点开始算起,即根结点为第一层,根结点子树的根结点为第二层,以此类推。
  3. 把结点的子树棵数称为结点的度(degree),而树中结点的最大的度称为树的度(也称为树的宽度)。
  4. 由于一条边连接两个结点,且树中不存在环,因此对有n个结点的树,边数一定是n-1。且满足连通、边数等于顶点数减1的结构一定是一棵树。
  5. 叶子结点被定义为度为0的结点,因此当树中只有一个结点(即只有根结点)时,根结点也算作叶子结点。
  6. 结点的深度(depth)是指从根结点(深度为1)开始自顶向下逐层累加至该结点时的深度值;结点的高度(height)是指从最底层叶子结点(高度为1)开始自底向上逐层累加至该结点时的高度值。树的深度是指树中结点的最大深度,树的高度是指树中结点的最大高度。对树而言,深度和高度是相等的,但是具体到某个结点来说深度和高度就不一定相等了。
  7. 多棵树组合在一起称为森林(forest), 即森林是若干棵树的集合。

读者对树只要有这些理解就可以了,更需要关心的是下面要介绍的二叉树,这是重点。

二叉树

首先直接给出二叉树的递归定义:

  1. 要么二叉树没有根结点,是一棵空树。
  2. 要么二叉树由根结点、左子树、右子树组成,且左子树和右子树都是二叉树。

那么,什么是递归定义呢?其实递归定义就是用自身来定义自身。例如之前反复提及的斐波那契数列,它的定义为F[n]= F[n-1]+ F[n- 2],这里其实就是递归定义,即用自身序列的元素(F[n-1]与 F[n-2])来定义这个序列本身(即F[n])。
更通俗的解释是: 一个家族里面,可以把爷爷说成父亲的父亲,而曾祖父则为父亲的父亲的父亲,这样家族里自己的直系血缘的男性都可以用“父亲”这样的递归定义来定义了。

在前面讲解递归时已经解释过,一个递归函数必须存在两个概念:递归边界和递归式,其中递归式用来将大问题分解为与大问题性质相同的若千个小问题,递归边界则用来停止无休止的递归。

那么二叉树的递归定义也是这样:一是递归边界,二是递归式。

二叉树中任何一个结点的左子树既可以是一棵空树,也可以是一棵有左子树和右子树的二叉树;结点的右子树也既可以是一棵空树,又可以是一棵有左子树和右子树的二叉树,这样直到到递归边界,递归定义结束。

读者需要注意区分二叉树与度为2的树的区别。对树来说,结点的子树是不区分左右顺序的,因此度为2的树只能说明树中每个结点的子结点个数不超过2。而二叉树虽然也满足每个结点的子结点个数不超过2,但它的左右子树是严格区分的,不能随意交换左子树和右子树的位置,这就是二叉树与度为2的树最主要的区别。

下面介绍两种特殊的二叉树。

  1. 满二叉树:每一层的结点个数都达到了当层能达到的最大结点数。
  2. 完全二叉树:除了最下面一层之外,其余层的结点个数都达到了当层能达到的最大结点数,且最下面一层只从左至右连续存在若干结点,而这些连续结点右边的结点全部不存在。

为什么花费这么多篇幅来介绍二叉树的递归定义呢?这是因为应在二叉树的很多算法中都需要直接用到这种递归的定义来实现算法。因此,读者应能仔细体会一下二叉树的这个递归定义。

最后从二叉树的角度来理解一下几个树的概念:

  1. 层次:如果把二叉树看成家谱,那么层次就是辈分。
  2. 孩子结点、父亲结点、兄弟结点、祖先结点、子孙结点:一个结点的子树的根结点称为它的孩子结点,而它称为孩子结点的父亲结点。与该结点同父亲的结点称为该结点的兄弟结点(同一层次非同父亲的结点称为堂兄弟结点)。如果存在一条从结点X到结点Y的从上至下的路径,那么称结点X是结点Y的祖先结点,结点Y是结点X的子孙结点。注意:自己既是自己的祖先结点,也是自己的子孙结点。

二叉树的存储

一般来说,二叉树使用链表来定义。和普通链表的区别是,由于二叉树每个结点有两条出边,因此指针域变成了两个,分别指向左子树的根结点地址和右子树的根结点地址。如果某个子树不存在,则指向NULL,其他地方和普通链表完全相同,因此又把这种链表叫作二叉链表,其定义方式如下:

struct Node
{
	typename data;
	Node* lchild;
	Node* rchild;
}

由于在二叉树建树前根结点不存在,因此其地址一般设为NULL:

Node* root=NULL;

而如果需要新建结点(例如往二叉树中插入结点的时候),就可以使用下面的函数:

Node* newNode(int v)
{
	Node* node = new Node;
	node->data = v;
	node->lchild = node->rchild = NULL;
	return node;
}

二叉树的常用操作有以下几个:二叉树的建立,二叉树结点的查找、修改、插入与删除,其中删除操作对不同性质的二叉树区别比较大,因此不在本节介绍。本节主要介绍查找、修改、插入、建树的通用思想。

二叉树结点的查找、修改

查找操作是指在给定数据域的条件下,在二叉树中找到所有数据域为给定数据域的结点,并将它们的数据域修改为给定的数据域。

需要使用递归来完成查找修改操作。还记得二叉树的递归定义吗?其中就包含了二叉树递归的两个重要元素:递归式和递归边界。在这里,递归式是指对当前结点的左子树和右子树分别递归,递归边界是当前结点为空时到达死胡同。例如查找修改操作就可以用这样的思路,即先判断当前结点是否是需要查找的结点:如果是,则对其进行修改操作;;如果不是,则分别往该结点的左孩子和右孩子递归,直到当前结点为NULL为止。于是就有下面的代码
(数据域以int型为例,下同):

void search(Node *root, int x, int newdata)
{
	if(root == NULL)
		return;
	
	if(root->data == x)
		root->data = newdata;
	
	search(root->lchild,x,newdata);
	search(root->rchild,x,newdata);
}

二叉树结点的插入

由于二叉树的形态很多,因此在题目不说明二叉树特点时是很难给出结点插入的具体方法的。但是又必须认识到,结点的插入位置一般取决于数据域需要在二叉树中存放的位置(这与二叉树本身的性质有关),且对给定的结点来说,它在二叉树中的插入位置只会有一个(如果结点有好几个插入位置,那么题目本身就有不确定性了)。

因此可以得到这样一个结论,即二叉树结点的插入位置就是数据域在二叉树中查找失败的位置。而由于这个位置是确定的,因此在递归查找的过程中一定是只根据二叉树的性质来选择左子树或右子树中的一棵子树进行递归,且最后到达空树(死胡同)的地方就是查找失败的地方,也就是结点需要插入的地方。由此可以得到二叉树结点插入的代码:

void insert(Node* &root, int x)
{
	if(root == NULL)
	{
		root = newNode(x);
		return;
	}
	
	if(由二叉树的性质,x应该插在左子树)
		insert(root->lchild, x);
	else
		insert(root->rchild, x);
}

在上述代码中,很关键的一点是根结点指针root使用了引用&。引用的作用在前面已经介绍过,即在函数中修改root会直接修改原变量。这么做的原因是,在insert函数中新建了结点,并把新结点的地址赋给了当层的root。

如果不使用引用,root = new Node这个语句对root的修改就无法作用到原变量(即上一层的root->lchild与root->rchild)上去,也就不能把新结点接到二叉树上面,因此insert函数必须加引用。

那么为什么前面的search函数不需要加引用呢?这是因为search
函数中修改的是指针root指向的内容,而不是root本身,而对指针指向的结点内容的修改是不需要加引用的。

那么,如何判断是否要加引用呢? 一般来说,如果函数中需要新建结点,即对二叉树的结构做出修改,就需要加引用;如果只是修改当前已有结点的内容,或仅仅是遍历树,就不用加引用。

至于判断不出来的情况,不妨直接试一下加引用和不加引用的区别再来选择。

最后再特别提醒一句,在新建结点之后,务必令新结点的左右指针域为NULL,表示这个新结点暂时没有左右子树。

二叉树的创建

二叉树的创建其实就是二叉树结点的插入过程,而插入所需要的结点数据域一般都会由题目给出,因此比较常用的写法是把需要插入的数据存储在数组中,然后再将它们使用insert函数一个个插入二叉树中,并最终返回根结点的指针root。

而等读者熟悉之后,可能更方便的写法是直接在建立二叉树的过程中边输入数据边插入结点。代码如下:

Node* Create(int data[], int n)
{
	Node* root = NULL;
	for(int i=0;i<n;i++)
		insert(root,data[i]);
	return root;
}

二叉树存储结构图示

很多初学者不理解递归边界中root == NULL这样的写法,并且把它当作未经消化的东西去死记,也搞不清到底*root == NULL跟root = NULL有什么区别。这些都是由于不清楚:二叉树到底是个什么样的存储方式导致的,下面通过图9-4来说明这一点。

如图9-4所示,左边概念意义的二叉树在使用二叉链表存储之后形成了箭头右边的图。对每个结点,第一个部分是数据域,数据域后面紧跟两个指针域,用以存放左子树根结点的地址和右子树根结点的地址。如果某棵子树是空树,那么显然也就不存在根结点,其地址就会是NULL,表示不存在这个结点。因此图中C的左子树、DEF的左子树和右子树都为空树,故C的左指针域、DEF的左指针域与右指针域都为NULL。

在递归时,总是往左子树根结点和右子树根结点递归。此时如果子树是空树,那么root一定是NULL,表示这个结点不存在。而所谓的root == NULL的错误就很显然了,因为root的含义是获取地址root指向的空间的内容,但这无法说明地址root是否为空,也即无法确定是否存在这个结点,因此*root == NULL的写法是错误的。

通过上面的讲解,读者需要明白root==NULL与*root=NULL的区别,也即结点地址为NULL与结点内容为NULL的区别(也相当于结点不存在与结点存在但没有内容的区别),这在写程序时是非常重要的,因为在二叉链表中一般都是判定结点是否存在,所以一般都是root == NULL。

完全二叉树的存储结构

对完全二叉树来说,除了采用二叉链表的存储结构外,还可以有更方便的存储方法。对一棵完全二叉树,如果给它的所有结点按从上到下、从左到右的顺序进行编号(从1开始)。

通过观察可以注意到,对完全二叉树当中的任何一个结点(设编号为x),其左孩子的编号一定是2x,而右孩子的编号一定是2x+1。

也就是说,完全二叉树可以通过建立一个大小为2k的数组来存放所有结点的信息,其中k为完全二叉树的最大高度,且1号位存放的必须是根结点。这样就可以用数组的下标来表示结点编号,且左孩子和右孩子的编号都可以直接计算得到。

事实上,如果不是完全二叉树,也可以视其为完全二叉树,即把空结点也进行实际的编号工作。但是这样做会使整棵树是一条链时的空间消耗巨大(对k个结点就需要大小为2k的数组),因此很少采用这种方法来存放一般性质的树。

不过如果题目中已经规定是完全二叉树,那么数组大小只需要设为结点上限个数加1即可,这将会大大节省编码复杂度。

除此之外,该数组中元素存放的顺序恰好为该完全二叉树的层序遍历序列。而判断某个结点是否为叶结点的标志为:该结点(记下标为root)的左子结点的编号root * 2大于结点总个数n。判断某个结点是否为空结点的标志为:该结点下标root大于结点总个数n。

二叉树的遍历

二叉树的遍历是指通过一定顺序访问二叉树的所有结点。遍历方法一般有四种:先序遍历、中序遍历、后序遍历及层次遍历,其中,前三种一般使用深度优先搜索(DFS)实现,而层次遍历一般用广度优先搜索(BFS)实现。

先来看前三种遍历方法。前面给出过二叉树的递归定义,这种定义方式将在这里很好地和遍历方法融合在一起。把一棵二叉树分为三个部分: 根结点、左子树、右子树,且对左子树和右子树同样进行这样的划分,这样对树的遍历就可以分解为对这三部分的遍历。读者首先要记住一点,无论是这三种遍历中的哪一种,左子树一定先于右子树遍历,且所谓的“先中后”都是指根结点root在遍历中的位置,因此先序遍历的访问顺序是根结点→左子树→右子树,中序遍历的访问顺序是左子树→根结点→右子树,后序遍历的访问顺序是左子树→右子树→根结点。

先序遍历

对先序遍历来说,总是先访问根结点root,然后才去访问左子树和右子树,因此先序遍历的遍历顺序是根结点→左子树→右子树。

为了实现递归的先序遍历,需要得到两样东西:递归式和递归边界。其中递归式已经可以由先序遍历的定义直接得到,即先访问根结点(可以做任何事情),再递归访问左子树,最后递归访问右子树。

那么这样一直递归访问左子树和右子树,递归边界是什么呢?二叉树的递归定义中的递归边界是二叉树为一棵空树,这一点同样可以用在这里,即在递归访问子树时,如果碰到子树为空,那么就说明到达了死胡同。这样即得到了递归式和递归边界,由此可以写出先序遍历的代码:

void preorder(Node* root)
}
	if(root == NULL)
		return;
	printf("%d\n",root->data);
	preorder(root->lchild);
	preorder(root->rchild);
}

由于先序遍历先访问根结点,因此对一棵二叉树的先序遍历序列,序列的第一个一定是根结点。例如上面的例子中,对整棵二叉树,A是先序遍历序列的第一一个,因此A是根结点;而对A的左子树中的三个结点,它们的先序遍历序列是BDE,这样B是第一个,因此B是这棵子树的根结点。

中序遍历

对中序遍历来说,总是先访问左子树,再访问根结点(即把根结点放在中间访问),最后访问右子树,因此中序遍历的遍历顺序是左子树→根结点→右子树。

中序遍历的实现思路和先序遍历完全相同,只不过把根结点的访问放到左子树和右子树中间了,因此直接给出代码:

void inorder(Node* root)
}
	if(root == NULL)
		return;
	inorder(root->lchild);
	printf("%d\n",root->data);
	inorder(root->rchild);
}

由于中序遍历总是把根结点放在左子树和右子树中间,因此只要知道根结点,就可以通过根结点在中序遍历序列中的位置区分出左子树和右子树。

至于如何事先知道根结点,可以用前面介绍的先序遍历序列(后面介绍的后序遍历序列也可以),这是因为先序遍历序列的第一个一定是根结点,且对子树来说也满足这个性质。于是就可以用递归来遍历所有子树,然后根据先序遍历和中序遍历各自的特性来确定整棵二叉树。

后序遍历

对后序遍历来说,总是先访问左子树,再访问右子树,最后才访问根结点(即把根结点放在最后访问),因此后序遍历的遍历顺序是左子树→右子树→根结点。

后序遍历的实现和上面两种遍历是完全一样的,只是把根结点放在最后访问,因此同样直接给出代码:

void postorder(Node* root)
}
	if(root == NULL)
		return;
	postorder(root->lchild);
	postorder(root->rchild);
	printf("%d\n",root->data);
}

后序遍历总是把根结点放在最后访问,这和先序遍历恰好相反,因此对后序遍历序列来说,序列的最后一个一定是根结点

至于在知道根结点之后怎样确定左子树和右子树,同样可以利用中序遍历序列的性质,即在知道根结点后很自然地将左子树和右子树分开,于是就可以对左子树和右子树分别递归来重复上面的步骤,最后也能得到一棵完整的二叉树。

总的来说,无论是先序遍历序列还是后序遍历序列,都必须知道中序遍历序列才能唯一地确定一棵树。这是因为,通过先序遍历序列和后序遍历序列都只能得到根结点,而只有通过中序遍历序列才能利用根结点把左右子树分开,从而递归生成一棵二叉树。当然,这个做法需要保证在所有元素都不相同时才能使用。

层序遍历

层序遍历是指按层次的顺序从根结点向下逐层进行遍历,且对同一层的结点为从左到右遍历。这个过程和BFS很像,因为BFS进行搜索总是以广度作为第一关键词,而对应到二叉树中广度又恰好体现在层次上,因此层次遍历就相当于是对二叉树从根结点开始的广度优先搜索,其基本思路如下:

  1. 将根结点root加入队列q。
  2. 取出队首结点,访问它。
  3. 如果该结点有左孩子,将左孩子入队。
  4. 如果该结点有右孩子,将右孩子入队。
  5. 返回②,直到队列为空。
void LayerOrder(Node* root)
{	
	queue<Node*> q;
	q.push(root);
	while(!q.empty())
	{
		Node* now = q.front();
		q.pop();
		printf("%d",now->data);
		if(now->lchild != NULL) q.push(now->lchild);
		if(now->rchild != NULL) q.push(now->rchild);
	}
}

可以发现,这里使用的队列中元素是node型而不是node型。这是因为在之前讲解广度优先搜索时提到过,队列中保存的只是原元素的一个副本,因此如果队列中直接存放node型,当需要修改队首元素时,就会无法对原元素进行修改(即只修改了队列中的副本),故让队列中存放node型变量的地址,也就是node型变量。这样就可以通过访问地址去修改原元素,就不会有问题了。

另外还需要指出,很多题目当中要求计算出每个结点所处的层次,这时就需要在二叉树结点的定义中添加一个记录层次layer的变量:

struct Node
{
	int data;
	int layer;
	Node* lchild;
	Node* rchild;
};

需要在根结点入队前就先令根结点的layer为1来表示根结点是第一层(也可以令根结点的层号为0,由题意而定),之后在now→lchild和now→rchild入队前,把它们的层号都记为
当前结点now的层号加1,即

void LayerOrder(Node* root)
{	
	queue<Node*> q;
	root->layer=1;
	q.push(root);
	while(!q.empty())
	{
		Node* now = q.front();
		q.pop();
		printf("%d",now->data);
		if(now->lchild != NULL) 
		{
			now->lchild->layer = now->layer+1;
			q.push(now->lchild);
		}
		if(now->rchild != NULL)
		{
			now->rchild->layer = now->layer+1;
			q.push(now->rchild);
		}
	}
}

最后解决一个重要的问题:给定一棵二叉树的先序遍历序列和中序遍历序列,重建这棵二叉树。

假设已知先序序列为\(pre_1、pre_2、\cdots、pre_n\),中序序列为\(in_1、in_2、\cdots 、in_n\)。那么由先序序列的性质可知,先序序列的第一个元素 \(pre_1\) 是当前二叉树的根结点。再由中序序列的性质可知,当前二叉树的根结点将中序序列划分为左子树和右子树。因此,要做的就是在中序序列中找到某个结点\(in_k\),使得\(in_k == pre_1\),这样就在中序序列中找到了根结点。

易知左子树的结点个数numLeft=k-1。于是,左子树的先序序列区间就是[2, k],左子树的中序序列区间是[1,k- 1];右子树的先序序列区间是[k+ 1, n],右子树的中序序列区间是[k+1,n],接着只需要往左子树和右子树进行递归构建二叉树即可。

事实上,如果递归过程中当前先序序列的区间为[preL,preR],中序序列的区间为[inL,inR],那么左子树的结点个数为numLeft=k-inL。这样左子树的先序序列区间就是[preL + 1,
preL + numLet],左子树的中序序列区间是[inL, k - 1];右子树的先序序列区间是[preL + numLeft + 1, preR],右子树的中序序列区间是[k+ 1, inR]。

那么,如果一直这样递归下去,什么时候是尽头呢?这个问题的答案是显然的,因为只要先序序列的长度小于等于0时,当前二叉树就不存在了,于是就能以这个条件作为递归边界。

Node* create(int prel,int prer,int inl,int inr)
{
	if(prel > prer) return NULL;
	
	Node* root = new Node;
	root->data = pre[prel];
	int k;
	for(k=inl;k<=inr;k++)
		if(in[k] == pre[prel])
			break;
	int numLeft = k-inl;
	root->lchild = create(prel+1,prel+numLeft,inl,k-1);
	root->rchild = create(prel+numLeft+1,prer,k+1,inr);
	return root;
}

通过上面的代码就构建出了一棵二叉树。至于构建出来之后需要求解后序遍历序列或是层序遍历序列或是其他的东西,则视题目要求而定。另外,给定后序序列和中序序列也可以构建一棵二叉树,做法是一样的。

最后请读者思考,如何通过中序遍历序列和层序遍历序列重建二叉树? (顺便给出一个结论:中序序列可以与先序序列、后序序列、层序序列中的任意一个来构建唯一的二叉树,而后三者两两搭配或是三个一起上都无法构建唯一的二叉树。 原因是先序、后序、层序均是提供根结点,作用是相同的,都必须由中序序列来区分出左右子树。)

如何用后序遍历序列和中序遍历序列来重建二叉树。

如图9-12所示,假设递归过程中某步的后序序列区间为[postL,postR],中序序列区间为[inL, inR],那么由后序序列性质可知,后序序列的最后一个元素post[postR]即为根结点。

接着需要在中序序列中寻找一个位置k,使得in[k]==post[postR],这样就找到了中序序列中的根结点。

易知左子树结点个数为numLeft= k - inL。于是左子树的后序序列区间为[postL, postL+numLeft-1],左子树的中序序列区间为[inL, k - 1];右子树的后序序列区间为[postL+numLeft,postR-1],右子树的中序序列区间为[k + 1, inR]。

二叉树的静态实现

本节适用于能理解前面的所有内容,但对指针的写法不太有自信的读者。通过下面的学习,读者应能完全不使用指针,而简单使用数组来完成二叉树的上面所有操作。

在定义二叉树时,采用的是二叉链表的结构,如下所示:

struct Node
{
	typename data;
	Node* lchild;
	NOde* rchild;
};

在这个定义中,为了能够实时控制新生成结点的个数,结构体node中的左右指针域都使用了指针,但是指针对于一些刚入门的读者来说可能容易犯错,因此有必要想办法避免指针的使用,采用的方法就是使用静态的二叉链表。

所谓的静态二叉链表是指,结点的左右指针域使用int型代替,用来表示左右子树的根结点在数组中的下标。为此需要建立一个大小为结点上限个数的node型数组,所有动态生成的结点都直接使用数组中的结点,所有对指针的操作都改为对数组下标的访问

于是,结点node的定义变为如下:

struct Node
{
	typename data;
	int lchild;
	int rchild;
}node[maxn];

在这样的定义下,结点的动态生成就可以转变为如下的静态指定:

int index=0;
int newNode(int v)
{
	node[index].data=v;
	node[index].lchild=-1;
	node[index].rchild=-1;
	return index++;
}

下面给出二叉树的查找、插入、建立的代码,读者会发现这其实就是在之前使用指针的代码上进行了少量的修改,读者不妨将它们与原先的写法对比一下。

//查找
void search(int root, int x, int index)
{
	if(root == -1) return;
	
	if(node[root].data == x)
		node[root].data = newdata;
	
	search(node[root].lchild,x,newdata);
	search(node[root].rchild,x,newdata);
}

//插入
void insert(int &root, int x)
{
	if(root == -1) 
	{
		root = newNode(x);
		return;
	}
	
	if(由二叉树的性质x应该插在左子树)
		insert(node[root].lchild,x);
	else 
		insert(node[root].rchild,x);
}

//二叉树的建立
int create(int data[],int n)
{
	int root=-1;
	for(int i=0;i<n;i++)
		insert(root,data[i]);
	return root;
}

//先序遍历
void preorder(int root)
{
	if(root == -1)
		return;
	
	printf("%d\n",node[root].data);
	preorder(node[root].lchild);
	preorder(node[root].rchild);
}

//中序遍历
void inorder(int root)
{
	if(root == -1)
		return;
	inorder(node[root].lchild);
	printf("%d\n",node[root].data);
	inorder(node[root].rchild);
}

//后序遍历
void postorder(int root)
{
	if(root == -1)
		return;
	postorder(node[root].lchild);
	postorder(node[root].rchild);
	printf("%d\n",node[root].data);
}

//层序遍历
void LayerOrder(int root)
{
	queue<int> q;
	q.push(root);
	
	while(!q.empty())
	{
		int now = q.front();
		q.pop();
		printf("%d ",node[now].data);
		if(node[now].data != -1) q.push(node[now].lchild);
		if(node[now].data != -1) q.push(node[now].rchild);
	}
}

树的遍历

本节讨论的“树”是指一般意义上的树,即子结点个数不限且子结点没有先后次序的树,而不是上文中讨论的二叉树。

首先来回顾二叉树的结点的定义,可以注意到它是由数据域和指针域组成的,其中左指针域指向左子树根结点的地址,右指针域指向右子树根结点的地址。借鉴这种定义方法,对一棵一般意义的树来说,可以仍然保留其数据域的含义,而令指针域存放其所有子结点的地址(或者为其开一个数组,存放所有子结点的地址)。

不过这听起来有点麻烦,所以还是建议在考试中使用其静态写法,也就是用数组下标来代替所谓的地址。当然这需要事先开一个大小不低于结点上限个数的结点数组,因此结构体node的定义会类似于下面这样:

struct Node
{
	typename data;
	int child[maxn];
}node[maxn];

在上面的定义中,由于无法预知子结点个数,因此child数组的长度只能开到最大,而这对一些结点个数较多的题目来说显然是不可接受的(开辟的空间大小会超过题目限制),因此需要使用STL中的vector,即长度根据实际需要而自动变化的“数组”。

于是结构体node的定义将会变为如下的形式:

struct Node
{
	typename data;
	vector<int> child;
}node[maxn];

与二叉树的静态实现类似,当需要新建一个结点时,就按顺序从数组中取出一个下标即可,如下所示:

int index=0;
int newNode(int v)
{
	node[index].data=v;
	node[index].child.clear();
	return index++;
}

不过在考试中涉及树(非二叉树)的考查时,一般都是很人性化地给出了结点的编号,并且编号一定是0, 1,.,N-1(其中N为结点个数)或是1,2,..,N。在这种情况下,就不需要newNode函数了,因为题目中给定的编号可以直接作为node数组的下标使用,非常方便。

需要特别指出的是,如果题目中不涉及结点的数据域,即只需要树的结构,那么上面的结构体可以简化地写成vector数组,即“vector child[maxn]”。

显然,在这个定义下,child[0]、child[1]、...、child[maxn-1]中的每一个都是一个vector,存放了各结点的所有子结点下标。事实上,下一章将会提到,这种写法其实就是图的邻接表表示法在树中的应用。

树的先根遍历

读者应该还记得二叉树的先序遍历,对一棵一般意义的树来说,也可以采用类似的思路来对树进行遍历,即总是先访问根结点,再去访问所有子树。

显然,这是一个递归访问的概念,因为对根结点的子树来说,同样可以分为根结点和若干子树。这种遍历方式被称为树的先根遍历。

void preorder(int root)
{
	printf("%d ",node[root].data);
	for(int i=0;i<node[root].child.size();i++)
		preorder(node[root].child[i]);
}

树的层序遍历

树的层序遍历的实现方法与二叉树类似,一般是使用一个队列来存放结点在数组中的下标,每次取出队首元素来访问,并将其所有子结点加入队列,直到队列为空。可以在二叉树层序遍历的基础上写出树的层序遍历的代码:

void LayerOrder(int root)
{
	queue<int> q;
	q.push(root);
	while(!q.empty())
	{
		int front=q.front();
		q.pop();
		printf("%d ",node[front].data);
		
		for(int i=0;i<node[front].child.size();i++)
			q.push(node[front].child[i]);
	}
}

同样的,如果需要对结点的层号进行求解,只需要在结构体node的定义中增加变量来记录结点的层号:

struct NOde
{
	int layer;
	int data;
	vector<int> child;
}

于是树的层序遍历就可以写成下面这样:

void LayerOrder(int root)
{
	queue<int> q;
	q.push(root);
	node[root].layer=0;
	while(!q.empty())
	{
		int front=q.front();
		q.pop();
		printf("%d ",node[front].data);
		
		for(int i=0;i<node[front].child.size();i++)
		{
			int child=node[front].child[i];
			node[child].layer=node[front].layer+1;
			q.push(child);
		}
	}
}

从树的遍历看DFS与BFS

  1. 深度优先搜索(DFS)与先根遍历
    还记得在讲解深度优先搜索时举的迷宫的例子吗?当时从入口出发,经过一系列岔道口和死胡同,最终找到了出口。事实上,可以把岔道口和死胡同都当作结点,并将它们的连接关系表示出来,就会得到图9-16所示的这棵树。

回忆当时进行DFS时给出的遍历序列(即ABDHIJEKLMCFG)会发现,如果采用树的先根遍历去遍历这棵树(即先访问根结点,再从左至右依次访问所有子树),将会得到同样的序列。

事实上,对所有合法的DFS求解过程,都可以把它画成树的形式,此时死胡同等价于树中的叶子结点,而岔道口等价于树中的非叶子结点,并且对这棵树的DFS遍历过程就是树的先根遍历的过程。

于是可以从中得到启发:碰到一些可以用DFS做的题目,不妨把一些状态作为树的结点,然后问题就会转换为直观的对树进行先根遍历的问题。如果想要得到树的某些信息,也可以借用DFS以深度作为第一关键 词的思想来对结点进行遍历,以获得所需的结果。

例如求解叶子结点的带权路径和(即从根结点到叶子结点的路径上的结点点权之和)时就可以把到达死胡同作为一条路径结束的判断。

另外,在讲解深度优先搜索时,提到了剪枝的概念,即在进行DFS的过程中对某条可以确定不存在解的子树采取直接剪断的策略,这就是把DFS从树的角度理解才产生的概念。

例如如果在图9-16中,通过分析题目具体条件,发现B的右子树中不可能存在问题的解,就可以把从B到E的“树枝”剪断,即不再往下递归访问以E为根的子树,这样将会在某些问题中极大降低计算量。但是剪枝使用的前提是必须保证剪枝的正确性,否则就可能因剪掉了有解的子树而最终获得了错误的答案。

  1. 广度优先搜索(BFS)与层序遍历
    通过前面的内容可知,广度优先搜索是以广度为第一关键词的。在使用BFS模拟迷宫问题的过程中,依然将迷宫的岔道口和死胡同都简化为结点,将迷宫的结构转换为树。

借用上面刚得到的迷宫树型图来分析,如果模仿层序遍历的方法来遍历这棵树,就可以得到当初BFS过程中得到的序列,即ABCDEFGHIJKLMN。

事实上,对所有合法的BFS求解过程,都可以像DFS中那样画出一棵树,并且将广度优先搜索问题转换为树的层序遍历的问题。

posted @ 2021-02-23 18:33  Dazzling!  阅读(148)  评论(0编辑  收藏  举报