【数据结构】4. 树与二叉树

目录

4.1 树的基本概念

4.1.1 树的定义

树是 N(\(N\ge 0\))个结点的有限集合,N=0 时,称为空树,这是一种特殊情况。
在任意一棵非空树中应满足:

  1. 有且仅有一个特定的称为的结点。
  2. 当 N>1 时,其余结点可分为 m(m>0)个互不相交的有限集合 \(T_1,T_2,\cdots,T_m\),其中每一个集合本身又是一棵树,并且称为根结点的子树

显然树的定义是递归的,是一种递归的数据结构。
树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:

  1. 树的根结点没有前驱结点,除根结点之外的所有结点有且只有一个前驱结点
  2. 树中所有结点可以有零个或多个后继结点。

树适合于表示具有层次结构的数据
树中的某个结点(除根结点外)最多只和上一层的一个结点(即其父结点)有直接关系,根结点没有直接上层结点,因此在 n 个结点的树中有 n-1 条边。
而树中每个结点与其下一层的零个或多个结点(即其子女结点)有直接关系。

4.1.2 基本术语

下面结合图 4-1 中的树来说明一些基本术语和概念。

分享图片

  1. 考虑结点 K。
    根 A 到结点 K 的唯一路径上的任意结点,称为结点 K 的祖先结点
    如结点 B 是结点 K 的祖先结点,而结点 K 是结点 B 的子孙结点
    路径上最接近结点 K 的结点 E 称为 K 的双亲结点,而 K 为结点 E 的孩子结点
    根 A 是树中唯一没有双亲的结点。
    有相同双亲的结点称为兄弟结点,如结点 K 和结点 L 有相同的双亲结点 E,即 K 和 L 为兄弟结点。
  2. 树中一个结点的子结点个数称为该结点的,树中结点的最大度数称为树的度
    如结点 B 的度为 2,结点 D 的度为 3,树的度为 3。
  3. 度大于 0 的结点称为分支结点(又称非终端结点);
    度为 0(没有子女结点)的结点称为叶子结点(又称终端结点)。
  4. 结点的深度、高度和层次。
    结点的层次从树根开始定义,根结点为第 1 层(有些教材中将根结点定义为第 0 层),它的子结点为第 2 层,依此类推。
    结点的深度是从根结点开始自顶向下逐层累加的。
    结点的高度是从叶结点开始自底向上遂层累加的。
    树的高度(又称深度)是树中结点的最大层数。
    图 4-1 中树的高度为 4。
  5. 有序树和无序树:
    树中结点的子树从左到右是有次序的,不能交换,这样的树叫做有序树
    有序树中,一个结点其子结点按从左到右顺序出现是有关联的。
    反之则称为无序树。在图 4-1中,若将子结点位置互换,则变成一棵不同的树。
  6. 路径和路径长度:树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数
    在图 4-1 中,结点 A 和结点 K 的路径长度为 3,中间经过结点 B 和结点 E。

    注意:
    由于树中的分支是有向的,即从双亲结点指向孩子结点,所以树中的路径是从上向下的,同一双亲结点的两个孩子结点之间不存在路径。

  7. 森林:森林是 m(\(m\ge O\))棵互不相交的树的集合。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。
    反之,只要给 n 棵独立的树加上一个结点,并把这 n 棵树作为该结点的子树,则森林就变成了树。

    注意:
    上述板念无需刻意记忆,根据实例理解即可。
    考研不大可能直接考查概念,都是结合具体的题目考查。
    做題时,遇到不熟悉的概念可以再翻书,练习得多自然就记住了。

4.1.3 树的性质

树具有如下最基本的性质:

  1. 树中的结点数等于所有结点的度数加 1。
  2. 度为 m 的树中第 i 层上至多有 \(m^{i-1}\) 个结点(\(i\ge 1\))。
  3. 高度为 h 的 m 叉树至多有 \(\frac{m^h-1}{m-1}\) 个结点。
  4. 具有 n 个结点的 m 叉树的最小高度为 \(\lceil \log_m(n(m-1)+1)\rceil\)

4.2 二叉树的概念

4.2.1 二叉树的定义及其主要特性

(1)二叉树的定义

二叉树是另一种树形结构,其特点是每个结点至多只有两棵子树(即二叉树中不存在度大于 2 的结点),并且,二叉树的子树有左右之分,其次序不能任意颠倒。
与树相似,二叉树也以递归的形式定义。二叉树是 n(\(n\ge 0\))个结点的有限集合:

  1. 或者为空二叉树,即 n=0。
  2. 或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。
    左子树和右子树又分别是一棵二叉树。

二叉树是有序树,若将其左、右子树颠倒,就成为另一棵不同的二叉树。
即使树中结点只有一棵子树,也要区分它是左子树还是右子树。
二叉树的 5 种基本形态如图 4-2 所示:

分享图片

二叉树与度为 2 的有序树的区别:

  1. 度为 2 的树至少有 3 个结点,而二叉树可以为空:
  2. 度为 2 的有序树的孩子结点的左右次序是相对于另一孩子结点而言的,如果某个结点只有一个孩子结点,这个孩子结点就无须区分其左右次序,而二叉树无论其孩子数是否为 2,均需确定其左右次序,也就是说二叉树的结点次序不是相对于另一结点而言,而是确定的。

(2)几个特殊的二叉树

  1. 满二叉树:一棵高度为 h,并且含有 \(2^h-1\) 个结点的二叉树称为满二叉树,即树中的每一层都含有最多的结点,如图 4-3(a) 所示。
    满二叉树的叶子结点都集中在二叉树的最下一层,并且除叶子结点之外的每个结点度数均为 2。
    可以对满二叉树按层序编号:约定编号从根结点(根结点编号为 1)起,自上而下,自左向右。
    这样每个结点对应一个编号,对于编号为 i 的结点,如果有双亲,其双亲为 \(\lfloor i/2 \rfloor\),如果有左孩子,则左孩子为 2i;如果有右孩子,则右孩子为 2i+1。

    分享图片

  2. 完全二叉树:设一个高度为 h,有 n 个结点的二叉树,当且仅当其每一个结点都与髙度为 h 的满二叉树中编号为 \(1\sim n\) 的结点一一对应时,称为完全二叉树,如图 4-3(b) 所示。
    这种树的特点如下:
    1. \(i\le\lfloor n/2\rfloor\),则结点 i 为分支结点,否则为叶子结点。
    2. 叶子结点只可能在层次最大的两层上出现。
      对于最大层次中的叶子结点,都依次排列在该层最左边的位置上。
    3. 如果有度为 1 的结点. 只可能有一个,且该结点只有左孩子而无右孩子(重要特征)
    4. 按层序编号后,一旦出现某结点(其编号为 i)为叶子结点或只有左孩子,则编号大于 i 的结点均为叶子结点。
    5. 若 n 为奇数,则每个分支结点都有左子女和右子女;若 n 为偶数,则编号最大的分支结点(编号为 n/2)只有左子女,没有右子女,其余分支结点左、右子女都有。
  3. 二叉排序树
    或者是空二叉树,或者是具有如下性质的二叉树:
    左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字。
    左子树和右子树又各是一棵二叉排序树。
  4. 平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过 1。

(3)二叉树的性质

  1. 非空二叉树上叶子结点数等于度为 2 的结点数加 1,即 \(N_0 = N_2+1\)
    证明:设度为 0、1 和 2 的结点个数分别为 \(N_0\)\(N_1\)\(N_2\),结点总数 \(N=N_0+N_1+N_2\)
    再看二叉树中的分支數,除根结点外,其余结点都有一个分支进入,设 B 为分支总数,则 N=B+1。
    由于这些分支是由度为 1 或 2 的结点射出的,所以又有 \(B=N_1+2N_2\)
    于是得:\(N_0+N_1+N_2=N_1+2N_2+1\),则 \(N_0=N_2+1\)

    注意:该结论经常在选择題中用到,希望考生牢记并灵活应用。

  2. 非空二叉树上第 K 层上至多有 \(2^{k-1}\) 个结点(\(K\ge 1\))。
  3. 高度为 H 的二叉树至多有 \(2^H-1\) 个结点(\(H\ge 1\))。
  4. 对完全二叉树按从上到下、从左到右的顺序依次编号 \(1,2,N\),则有以下关系:
    • \(i\gt 1\) 时,结点 i 的双亲结点编号为 \(\lfloor i/2\rfloor\),即
      当 i 为偶数时,其双亲结点的编号为 i/2,它是双亲结点的左孩子;
      当 i 为奇数时,其双亲结点的编号为 (i-1)/2,它是双亲结点的右孩子。
    • \(2i\le N\) 时,结点 i 的左孩子编号为 2i,否则无左孩子。
    • \(2i+1\le N\)时,结点 i 的右孩子编号为 2i+1,否则无右孩子。
    • 结点 i 所在层次(深度)为 \(\lfloor log_2{i}\rfloor+1\)
  5. 具有 N 个(\(N\gt 0\))结点的完全二叉树的高度为 \(\lceil log_2{(N+1)}\rceil\)\(\lfloor log_2{N}\rfloor+1\)

4.2.2 二叉树的存储结构

(1)顺序存储结构

二叉树的顺序存储结构就是用一组地址连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素,
即将完全二叉树上编号为 i 的结点元素存储在某个数组下标为 i-1 的分量中,然后通过一些方法确定结点在逻辑上的父子和兄弟关系。

依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映出结点之间的逻辑关系,这样既能最大可能地节省存储空间,又可以利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。

但对于一般的二叉树,为了让数组下标能反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点让其每个结点与完全二叉树上的结点相对照,再存储到一维数组的相应分量中。
然而,在最坏的情况下,一个高度为 H 且只有 H 个结点的单支树却需要占据接近 \(2^H-1\) 个储单元。
二叉树的顺序存储结构如图 4-4 所示,其中 0 表示并不存在的空结点。

分享图片

注意:
这种存储结构显然要从数组下标 1 开始存储树中的结点,如果从数组下标 0 开始存储,则不满足性质 4 的描述,(比如结点 A 存储在 0 下标位置上时,則无法根据性质 4 来计算出其孩子结点在數组中的位置),这是考生在书写程序的时候容易忽略的。
注意区别树的顺序存储结构与二叉树的顺序存储结构。
在树的顺序存储结构中,数组下标代表结点的编号,下标上所存的内容指示了结点之间的关系。
而在二叉树的顺序存储结构中,数组下标既代表了结点的编号,也指示了树中各结点之间的关系,这种关系借助完全二又树的性质反应,当然,二叉树属于树,因此二叉树都可以用树的存姑结构来存储,但是树却不都能用二叉树的存储结构来存储。

(2)链式存储结构

由于顺序存储对空间利用率较低,因此,一般二叉树都采用链式存储结构。
链式结构是指用—个链表来存储一棵二叉树,二叉树中每一个结点用链表的一个链结点来存储。
在二叉树中,结点结构通常包括若干数据域及若干个指针域。
二叉链表至少包含 3 个域: 数据域 data、 左指针域 lchild 和右指针域 rchild,如图 4-5 所示。

分享图片

图 4-6 所示为常用的二叉链表的存储结构。
而实际在不同的应用中,还可以增加某些指针域,如增加指向父结点的指针,则变为三叉链表的存储结构。

分享图片

二叉树的链式存储结构描述如下:

typedef struct BiTNode {
    ElemType data; //数据域
    struct BiTNode *lchild,*rchild; //左、 右孩子指针
} BiTNode,*BiTree;

使用不同的存储结构,实现二叉树操作的算法也会不同,因此,要根据实际应用的场合(二叉树的形态和需要进行的运算)来选择合适的存储结构。
容易验证,在含有 n 个结点的二叉链表中含有 n+1 个空链域(重要结论,经常出现在选择题中)。
在下一节中,我们将利用这些空链域来组成另一种链表结构—线索链表。

4.3 二叉树的遍历和线索二叉树

4.3.1 二叉树的遍历

所谓二叉树的遍历,是指按某条搜索路径访问树中的每个结点,使得每个结点均被访问一次,而且仅被访问一次。
由二叉树的递归定义可知,遍历一棵二叉树便要决定对根结点 N、左子树 L 和右子树 R 的访问顺序。
按照先遍历左子树再遍历右子树的原则,常见的遍历次序有先序(NLR)、中序(LNR)和后序(LRN)三种遍历算法。
其中,指的是根结点在何时被访问

(1)先序遍历(PreOrder)

先序遍历的操作过程为:
如果二叉树为空,什么也不做。
否则:

  1. 访问根结点:
  2. 先序遍历左子树:
  3. 先序遍历右子树。

对应的递归算法如下:

void PreOrder(BiTree T) {
    if(T!=NULL) {
        visit(T); //访问根结点
        PreOrder(T->lchild); //递归遍历左子树
        PreOrder(T->rchild); //递归遍历右子树
    }
}

对于图 4-4 所示的二叉树,先序遍历所得到的结点序列为:1 2 4 6 3 5。

(2)中序遍历(InOrder)

中序遍历的操作过程为:
如果二叉树为空,什么也不做。
否则:

  1. 中序遍历左子树;
  2. 访问根结点;
  3. 中序遍历右子树。

对应的递归算法如下:

void InOrder(BiTree T) {
    if(T!=NULL) {
        InOrder(T->lchild);
        visit(T);
        InOrder(T->rchild);
    }
}

对于图 4-4 所示的二叉树,中序遍历所得到的结点序列为:2 6 4 1 3 5。

(3)后序遍历(PostOrder)

后序遍历的操作过程为:
如果二叉树为空,什么也不做。
否则:

  1. 后序遍历左子树;
  2. 后序遍历右子树;
  3. 访问根结点。

对应的递归算法如下:

void PostOrder(BiTree T) {
    if(T!=NULL) {
        PostOrder(T->lchild); //递归遇历左子树
        PostOrder(T->rchild); //递归遍历右子树
        visit(T); //访问根结点
    }
}

对于图 4-4 所示的二叉树,后序遍历所得到的结点序列为:6 4 2 5 3 1。

三种遍历算法中递归遍历左、右子树的顺序都是固定的,只是访问根结点的顺序不同。
不管采用哪种遍历算法,每个结点都访问一次且仅访问一次,故时间复杂度都是 \(\mathcal{O}(n)\)
在递归遍历中,递归工作找的栈深恰好为树的深度,所以在最坏的情况下,二叉树是有 n 个结点且深度为 n 的单
支树,遍历算法的空间复杂度为 \(\mathcal{O}(n)\)

注意:
以上三种遍历方式以及算法描述是简单易懂的,读者需要将它们作为模板来记忆,考研中的很多题目都是基于这 3 个模板而延伸出来的。

(4)递归算法和非递归算法的转换

可以借助栈,将二叉树的递归遍历算法转换为非递归算法,下面以中序遍历为例给出中序遍历的非递归算法。
先扫描(并非访问)根结点的所有左结点并将它们一一进栈。
然后出栈一个结点 *p ( 显然结点 *p 没有左孩子结点或者左孩子结点均已访问过),则访问它。
然后扫描该结点的右孩子结点,将其进栈,再扫描该右孩子结点的所有左结点并一一进栈,如此继续,直到栈空为止。
中序遍历的非递归算法如下:

void In0rder2(BiTree T) {
//二叉树中序遍历的非递归算法,算法需要借助一个栈
    InitStack(S); //初始化栈
    BiTree p = T; //p 是遍历指针
    while(p || !IsEmpty(S)) { //栈不空或 p 不空时循环
        if(p) { //根指针进栈,遍历左子树
            Push(S,p); //每遇到非空二叉树先向左走
            p = p->lchild;
        } else { //根指针退栈,访问根结点,遍历右子树
            Pop(S,p); visit(p); //退找,访问根结点
            p = p->rchild; //再向右子树走
        }
    }
}

显然非递归算法的执行效率要高于递归算法。
类似地可以得到先序遍历与后序遍历的非递归算法,其中后序遍历的非递归算法较复杂,留给读者思考。

(5)层次遍历

分享图片

如图 4-7 所示为二叉树的层次遍历,即按照箭头所指方向,按照 1、2、3、4 的层次顺序,对二叉树中各个结点进行访问。
要进行层次遍历需要借助一个队列。
先将二叉树根结点入队,然后出队,访问该结点,如果它有左子树,则将左子树根结点入队;如果它有右子树,则将右子树根结点入队。
然后出队,对出队结点访问,如此反复,直到队列为空。
二叉树的层次遍历算法如下:

void LevelOrder(BiTree T) {
    InitQueue(Q); //初始化辅助队列
    BiTree p;
    EnQueue(Q,T); //将根结点入队
    while(!IsEmpty(Q)){ //队列不空循环
        DeQueue (Q,p); //队头元素出队
        visit(p); //访问当前 P 所指向结点
        if(p->lchild != NULL)
            EnQueue(Q,p->lchild); //左子树不空,则左子树入队列
        if(p->rchild != NULL)
            EnQueue(Q,p->rchild); //右子树不空,则右子树入队列
    }
}

对于上述二叉树层次遍历的算法,读者在复习过程当中应该将其作为一个模板,在熟练掌握其执行过程的基础上来记忆,并能够达到熟练默写的程度。
这样才能将层次遍历模板应用于各种题目之中。

注意:
遍历是二叉树各种操作的基础,可以在遍历的过程中对结点进行各种操作,例如,对于一棵已知树求结点的双亲、求结点的孩子结点、求二叉树的深度、求二又树的叶子结点个数、判断两棵二叉树是否相同等。
所有这些操作都建立在二叉树遍历的基础上,因此,必须掌握二叉树的各种遍历过程,并能灵活运用以解决各种问题。

(6)由遍历序列构造二叉树

由二叉树的先序序列和中序序列可以唯一地确定一棵二叉树。
在先序遍历序列中,第一个结点一定是二叉树的根结点,
而在中序遍历中,根结点必然将中序序列分割成两个子序列,前个子序列就是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列。
根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。
在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。
如此递归地进行下去,便能唯一地确定这棵二叉树。

同理,由二叉树的后序序列和中序序列也可以唯一地确定一棵二叉树,因为后序序列的最后一个结点就如同先序序列的第一个结点,可以将中序序列分割成两个子序列,然后采用类似的方法递归地进行划分,就可以得到一棵二叉树。
由二叉树的层序序列和中序序列也可以唯一地确定一棵二叉树,实现方法留给读者思考。
需要注意的是,如果只知道二叉树的先序序列和后序序列,则无法唯一确定一棵二义树。

例如,求先序序列(ABCDEFGHI)和中序序列(BCAEDGHFI) 所确定的二叉树。
首先,由先序序列可知 A 为二叉树的根结点。
中序序列中 A 之前的 BC 为左子树的中序序列,EDGHFI 为右子树的中序序列。
然后由先序序列可知 B 是左子树的根结点,D 是右子树的根结点。
依此类推,就能将剩下的结点继续分解下去,最后得到的二叉树如图 4-8(c)。

分享图片

4.3.2 线索二叉树

(1)线索二叉树的基本概念

从上一节的介绍可知,遍历二叉树就是以一定的规则将二叉树中的结点排列成一个线性序列,从而得到二叉树结点的各种遍历序列。
其实质就是对一个非线性结构进行线性化操作,使在这个访问序列中每一个结点(除第一个和最后一个)都有一个直接前驱和直接后继。
传统的链式存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继。
通过观察,我们发现在二叉链表表示的二叉树中存在大童的空指针,若利用这些空链域存放指向其直接前驱或后继的指针,则可以更方便地运用某些二叉树操作算法。
引入线索二叉树是为了加快査找结点前驱和后继的速度。

前面提到,在有 N 个结点的二叉树中,有 N+1 个空指针。
这是因为每一个叶结点有 2 个空指针,而每一个度为 1 的结点有 1 个空指针,总的空指针数为 \(2N_0+N_1\),又有 \(N_0=N_2+1\),所以,总
的空指针为 \(N_0+N_1+N_2+1 = N+1\)
在二叉树线索化时,通常规定:
若无左子树,令 lchild 指向其前驱结点;若无右子树,令 rchild指向其后继结点。
如图 4-9 所示,还需要增加两个标志域表明当前指针域所指对象是指向左(右)子结点还是直接前驱(后继)。

分享图片

其中标志域含义如下:

  • ltag
    • 0 lchild域指示结点的左孩子
    • 1 lchild域指示结点的前驱
  • rtag
    • 0 rchild域指示结点的右孩子
    • 1 rchild域指示结点的后继

线索二叉树的存储结构描述如下:

typedef struct ThreadNode {
    ElemType data; //数据元素
    struct ThreadNode *lchild,*rchild; //左、 右孩子指针
    int ltag,rtag; //左、 右线索标志
} ThreadNode,*ThreadTree;

以这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表
其中指向结点前驱和后继的指针,叫做线索
加上线索的二叉树称为线索二叉树
对二叉树以某种次序遍历使其变为线索二叉树的过程叫做线索化

(2)线索二叉树的构造

对二叉树的线索化,实质上就是遍历一次二叉树,只是在遍历的过程中,检査当前结点左、右指针域是否为空,若为空,将它们改为指向前驱结点或后继结点的线索。
以中序线索二叉树的建立为例,指针 pre 指向中序遍历时上一个刚刚访问过的结点,用它来表示各结点访问的前后关系,如图 4-10 所示。

分享图片

通过中序遍历对二叉树线索化的递归算法如下:

void InThread(ThreadTree Sp,ThreadTree &pre) {
//中序遍历对二叉树线索化的递归算法
    if(p != NULL) {
        InThread(p->lchild,pre); //递归,线索化左子树
        if(p->lchild == NULL){ //左子树为空,建立前驱线索
            p->lchild = pre;
            p->ltag = 1;
        }
        if(pre != NULL && pre->rchild==NULL) {
            pre->rchild = p; //建立前驱结点的后继线索
            pre->rtag = 1;
        }
        pre = p; //标记当前结点成为刚刚访问过的结点
        InThread(p->rchild,pre); //递归,线索化右子树
    }//if(p!=NULL)
}

通过中序遍历建立中序线索二叉树的主过程算法如下:

void CreatelnThread(ThreadTree T) {
    ThreadTree pre NULL;
    if(T != NULL){ //非空二叉树,线索化
        InThread(T,pre); //线索化二叉树
        pre->rchild = NULL; //处理遇历的最后一个结点
        pre->rtag = 1;
    }
}

有时为了方便,仿照线性表的存储结构,在二叉树的线索链表上也添加一个头结点(见图 4-11),并令其 lchild 域的指针指向二叉树的根结点,其 rchild 域的指针指向中序遍历时访问的最后一个结点;
反之,令二叉树中序序列中的第一个结点的 lchild 域的指针和最后一个结点 rchild域的指针均指向头结点。
这好比为二叉树建立了一个双向线索链表,既可以从第一个结点起顺后继进行遍历,也可从最后一个结点起顺前驱进行遍历。

分享图片

(3)线索二叉树的遍历

中序线索化二叉树主要是为访问运算服务的,这种遍历不再需要借助栈,因为它的结点中隐含了线索二叉树的前驱和后继信息。
利用线索二叉树,可以实现二叉树遍历的非递归算法。
不含头结点的线索二叉树的遍历算法如下:

  1. 求中序线索二叉树中中序序列下的第一个结点:
    ThreadNode *Firstnode(ThreadNode *p) { while(p->ltag==0) p=p->lchild; //最左下结点(不一定是叶结点) return p; }
  2. 求中序线索二叉树中结点 p 在中序序列下的后继结点:
    ThreadNode *Nextnode(ThreadNode *p) { if(p_>rtag==0) return Firstnode(p->rchild); else return p->rchild; //rtag~l 直接返回后继线索 }
    请读者自行分析并完成求中序线索二叉树的最后一个结点和结点 p 前驱结点的运算。
  3. 利用上面两个算法,可以写出不含头结点的中序线索二叉树的中序遍历的算法:
    void Inorder(ThreadNode *T) { for(ThreadNode *p=Firstnode(T); p!=NULL; p=Nextnode(p)) visit(p); }

4.4 树、森 林

4.4.1 树的存储结构

树的存储方式有多种,既可以采用顺序存储结构,也可以采用链式存储结构,但无论采用何种存储方式,都要求能唯一地反映出树中各结点之间的逻辑关系,这里介绍 3 种常用的存储结构。

  1. 双亲表示法
    这种存储方式采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。
    如图 4-13 所示,根结点下标为 0,其伪指针域为 -1。

    分享图片

双亲表示法的存储结构描述如下:

#define MAX_TREE_SIZE 100
typedef struct {
    ElemType data;
    int parent;
} PTNode;
typedef struct { //树的类型定义
    PTNode nodes[MAX_TREE_SIZE]; //双亲表示
    int n; //结点数
} PTree;

该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质,可以很快得到每个结点的双亲结点,但是求结点的孩子时却需要遍历整个结构。

  1. 孩子表示法
    孩子表示法是将每个结点的孩子结点都用单链表链接起来形成一个线性结构,则 N 个结点就
    有 N 个孩子链表(叶子结点的孩子链表为空表),如图 4-14(a) 所示。
    对于这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历 N 个结点中孩子链表指针域所指向的 N 个孩子链表。
    3.孩子兄弟表示法
    孩子兄弟表示法又称为二叉树表示法,即以二叉链表作为树的存储结构。
    孩子兄弟表示法是使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针和指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点),如图 4-14(b) 所示。

    分享图片

孩子兄弟表示法的存储结构描述如下:

typedef struct CSNode {
    ElemType data; //数据域
    struct CSNode *firstchild,*nextsibling; //第一个孩子和右兄弟指针
} CSNode,*CSTree;

这种存储表示法比较灵活。
其最大的优点就是可以方便地实现树转换为二叉树的操作,易于査找结点的孩子等,但缺点是从当前结点査找其双亲结点比较麻烦。
如果为每个结点增设一个 parent 域指向其父结点,则査找结点的父结点也很方便。

4.4.2 树、森林与二叉树的转换

由于二叉树和树都可以用二叉链表作为存储结构,则以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找到唯一的一棵二叉树与之对应。
从物理结构上看,树的孩子兄弟表示法与二叉树的二叉链表表示法相同,即每个结点共有两个指针,分别指向结点第一个孩子和结点的下一兄弟结点,而二叉链表中使用双指针。
因此,就可以用同一存储结构的不同解释将一棵树转换为二叉树。

树转换为二叉树的规则:每个结点左指针指向它的第一个孩子结点,右指针指向它在树中的相邻兄弟结点,可表示为“左孩子右兄弟”。
由于根结点没有兄弟,所以由树转换而得的二叉树没有右子树,如图 4-15 所示:

分享图片

将森林转换为二叉树的规则与树类似。
先将森林中的每一棵树转换为二叉树,再将第一棵树的根作为转换后的二叉树的根,第一棵树的左子树作为转换后二叉树根的左子树,第二棵树作为
转换后二叉树的右子树,第三棵树作为转换后二叉树根的右子树的右子树……依此类推,就可将森林转换为二叉树。

二叉树转换为森林的规则: 若二叉树非空,则二叉树根及其左子树为第一棵树的二叉树形式,二叉树根的右子树又可以看做是一个由除第一棵树外的森林转换后的二叉树,应用同样的方法,直到最后产生一棵没有右子树的二叉树为止,这样就得到了原森林,如图 4-16 所示。
二叉树转换为树的规则与此类似,二叉树转换为树或森林是唯一的。

分享图片

树转换成二叉树的画法:

  1. 在兄弟结点之间加一连线;
  2. 对每一个结点,只保留它与第一个子结点的连线,与其他子结点的连线全部抹掉;
  3. 以树根为轴心,顺时针旋转 \(45^\circ\)

森林转换成二叉树的画法:

  1. 将每棵树的根相连;
  2. 将森林中的每棵树转换成相应的二叉树;
  3. 以第一棵树的根为轴心顺时针旋转 \(45^\circ\)(结合综合应用题第 2 题模拟)。

4.4.3 树和森林的遍历

树的遍历操作是以某种方式访问树中每一个结点,且仅访问一次。
树的遍历操作主要有先根遍历和后根遍历。

  1. 先根遍历:若树非空,则先访问根结点,再按从左到右的顺序遍历根结点的每一棵子树。
    其访问顺序与这棵树相应二叉树的先序遍历顺序相同。
  2. 后根遍历:若树非空,则按从左到右的顺序遍历根结点的每一棵子树,之后再访问根结点。
    其访问顺序与这棵树相应二叉树的中序遍历顺序相同(结合图 4-15 自行模拟)。
    另外,树也有层次遍历,与二叉树的层次遍历思想基本相同,即按层序依次访问各结点。
    按照森林和树相互递归的定义,可得到森林的两种遍历方法。
    1. 先序遍历森林。
      若森林为非空,则按如下规则进行遍历:
      • 访问森林中第一棵树的根结点。
      • 先序遍历第一棵树中根结点的子树森林
      • 先序遍历除去第一棵树之后剩余的树构成的森林
    2. 中序遍历森林。若森林为非空,则按如下规则进行遍历:
      • 中序遍历森林中第一棵树的根结点的子树森林。
      • 访问第一棵树的根结点。
      • 中序遍历除去第一棵树之后剩余的树构成的森林。

树和森林的遍历:可采用对应二叉树的遍历算法来实现,见表 4-1。

分享图片

4.4.4 树的应用—并查集

并査集是一种简单的集合表示,它支持以下 3 种操作:

  1. Union(S,Rootl,Root2):把集合 S 中的子集合 Root2 并入子集合 Rootl 中。
    要求 Root1 和 Root2 互不相交,否则不执行合并。
  2. Find(S,x):査找集合 S 中单元素 x 所在的子集合,并返回该子集合的名字。
  3. Initial(S):将集合 S 中每一个元素都初始化为只有一个单元素的子集合。

通常用树(森林)的双亲表示作为并査集的存储结构,每个子集合以一棵树表示。
所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内。
通常用数组元素的下标代表元素名,根结点的下标代表子集合名,根结点的双亲结点为负数。

例如,若设有一个全集合为 S={0,1,3,4,5,6,7,8,9},初始化时每个元素自成一个单元素 |
子集合,每个子集合的数组值为 -1,如图 4-17 所示。

分享图片

经过一段时间的计算,这些子集合合并成 3 个更大的子集合:\(S_1=\{0,8\}\)\(S_2=\{1,9\}\)\(S_3=\{2,5\}\),此时并査集的树形表示和存储结构如图 4-18 所示。

分享图片

为了得到两个子集合的并,只要将其中一个子集合根结点的双亲指针指向另一个集合的根结点即可。
因此,\(S_1 \cup S_2\) 可以具有如图 4-19 所示的表示。
在采用树的双亲指针数组表示作为并査集的存储表示时,集合元素的编号从 0 到 size-1。
其中 size 是最大元素的个数。下面是并査集主要运算的实现。

分享图片

并査集的结构定义如下:

#define SIZE 100
int UFSets[SIZE]; //集合元素数组(双亲指针数组)

并查集的初始化操作(S 即为并査集)。

void Initial(int S[]) {
    for(int i=0; i<size; i++) //每个自成单元素集合
        S[i] = -1;
}

Find 操作(函数在并査集 S 中査找并返回包含元素 x 的树的根)。

int Find(int S[],int x){
    while(S[x]>=0) //循环寻找 x 的根
        x=S[x];
    return x; //根的 s[]小于 0
}

Union 操作(函数求两个不相交子集合的并集)。

void Union(int S[],int Root1,int Root2) {
//要求 Rootl 与 Root2 是不同的, 且表示子集合的名字
    S[Root2] = Root1; //将根 Root2 连接到另一根 Rootl 下面
}

4.5 树与二叉树的应用

4.5.1 二叉排序树

(1)二叉排序树的定义

二叉排序树(简称 BST),也称为二叉査找树。
二叉排序树或者是一棵空树,或者是一棵具有下列特性的非空二叉树:

  1. 若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字值。
  2. 若右子树非空,则右子树上所有结点关键字值均大于根结点的关键字值。
  3. 左、右子树本身也分别是一棵二叉排序树。

由此定义可知,二叉排序树是一个递归的数据结构,可以方便地使用递归算法对二叉排序树进行各种运算。
图 4-20 所示为一棵二叉排序树。
根据二叉排序树的定义,有 左子树结点值 < 根结点值 < 右子树结点值,所以,对二叉排序树进行中序遍历,可以得到一个递增的有序序列。
例如,图 4-20 的二叉排序树的中序遍历序列为 123468。

分享图片

(2)二叉排序树的查找

二叉排序树的査找是从根结点开始,沿某一个分支逐层向下进行比较的过程。
若二叉排序树非空,将给定值与根结点的关键字比较,若相等,则査找成功;若不等,则当根结点的关键字大于给定关键字值时,在根结点的左子树中査找,否则在根结点的右子树中査找。
这显然是一个递归的过程。

二叉排序树的非递归査找算法:

BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p) {
//査找函数返回&向关键字值为 key 的结点指针,若不存在,返回 NULL
    p = NULL; //p 指向被査找结点的双亲,用于插入和删除操作中
    while(T!=NULL && key!=T->data) {
        p = T;
        if(key<T->data)
            T = T->lchild;
        else
            T = T->rchild;
    }
    return T;
}

例如,在图 4-20 中査找值为 4 的结点。
首先 4 与根结点 6 比较。
因为 4 小于 6,所以在根结点 6 的左子树中继续査找。
因为 4 大于 2,所以在结点 2 的右子树中査找,査找成功。

同样,二叉排序树的査找也可以用递归算法实现,递归算法比较简单,但执行效率较低。
具体的实现,留给读者思考。

(3)二叉排序树的插入

二叉排序树作为一种动态集合,其特点是树的结构通常不是一次生成的,而是在査找过程中,当树中不存在关键字等于给定值的结点时再进行插入。
由于二叉排序树是递归定义的,插入结点的过程是,若原二叉排序树为空,则直接插入结点;否则,若关键字 k 小于根结点关键字,则插入到左子树中,若关键字 k 大于根结点关键字,则插入到右子树中。

int BST_Insert(BiTree &T,KeyType k) {
//在二叉奋序树 T 中插入一个关键字为 k 的结点
    if(T == NULL){ //原树为空,新插入的记录为根结点
        T=(BiTree)malloc(sizeof(BSTNode));
        T->key = k;
        T->lchild = T->rhild = NULL;
        return 1; //返回 1,表示成功
    } else if(k == T->key) //树中存在相同关键字的结点
        return 0;
    else if(k<T->key) //插入到 T 的左子树中
        return BST_Insert(T->lchild,k);
    else //插入到 T 的右子树中
        return BST_Insert(T->rchild,k);
}

由此可见,插入的新结点一定是某个叶结点。
如图 4-21 所示,在一个二叉排序树先后依次插入结点 28 和结点 58,虚线表示的边是其査找的路径。

分享图片

(4)二叉排序树的构造

构造一棵二叉排序树就是依次输入数据元素,并将它们插入到二叉排序树中的适当位置上的过程。
具体过程是,每读入一个元素,就建立一个新结点,若二叉排序树非空,则将新结点的值与根结点的值比较,
如果小于根结点的值,则插入到左子树中,否则插入到右子树中;若二叉排序树为空,则新结点作为二叉排序树的根结点。

void Creat_BST(BiTree ST,KeyType str[],int n) {
    //用关键字数组 str[] 建立一个二叉排序树
    T=NULL; //初始时 bt 为空树
    int i=0;
    while(i<n){ / /依次将每个元素插入
        BST_Insert(T,str[i]);
        i++;
    }
}

(5)二叉排序树的删除

在二叉排序树中删除一个结点时,不能把以该结点为根的子树上的结点都删除,
必须先把被删除结点从存储二叉排序树的链表上摘下,将因删除结点而断开的二叉链表重新链接起来,同时确保二叉排序树的性质不会丢失。
删除操作的实现过程按 3 种情况来处理:

  1. 如果被删除结点 z 是计结点,则直接删除,不会破坏二叉排序树的性质。
  2. 若结点 z 只有一棵左子树或右子树,则让 z 的子树成为 z 父结点的子树,替代 z 的位置。
  3. 若结点 z 有左、右两棵子树,则令 z 的直接后继(或直接前驱)替代 z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。

如图 4-22 所示,在 3 种情况下分别删除结点 45、78、78。

分享图片

思考:若在二叉排序树中删除并插入某结点,得到的二叉排序树是否和原来的相同?

(6)二叉排序树的査找效率分析

对于高度为 H 的二叉排序树,其插入和删除操作的运行时间都是 \(\mathcal{O}(H)\)
但在最坏的情况下,即构造二叉排序树的输入序列是有序的,则会形成一个倾斜的单支树,此时二叉排序树的性能显著变坏,树的高度也增加为元素个数 N,如图 4-23 所示。
在等概率情况下,图 4-23(a) 的査找成功的平均査找长度为
\[ASL_a = (1+2x2+3x4+4x3)/10 = 2.9\]
而图 4-23(b) 的査找成功的平均査找长度为
\[ASL_b = (1+2+3+4+5+6+7+8+9+10)/10 = 5.5\]
由上可知,二叉排序树査找算法的平均査找长度,主要取决于树的高度,即与二叉树的形态有关。
如果二叉排序树是一个只有右(左)孩子的单支树(类似于有序的单链表),其平均査找长度和单链表相同,为 \(\mathcal{O}(n)\)
如果二叉排序树的左、右子树的高度之差的绝对值不超过 1,这样的二叉排序树称为平衡二叉树,它的平均査找长度达到 \(\mathcal{O}(\log_2 n)\)

从査找过程看,二叉排序树与二分査找相似。
就平均时间性能而言,二叉排序树上的査找和二分査找差不多。
但二分査找的判定树唯一,而二叉排序树不唯一,相同的关键字其插入顺序不同可能生成不同的二叉排序树,如图 4-23 所示。

分享图片

就维护表的有序性而言,二叉排序树无须移动结点,只需修改指针即可完成插入和删除操作,平均执行时间为 \(\mathcal{O}(\log_2 n)\)
二分査找的对象是有序顺序表,若有插入和删除结点的操作,所花的代价是 \(\mathcal{O}(n)\)
当有序表是静态査找表时,宜用顺序表作为其存储结构,而采用二分査找实现其査找操作;若有序表是动态査找表,则应选择二叉排序树作为其逻辑结构。

4.5.2 平衡二叉树(Balanced Binary Tree)

(1)平衡二又树的定义

为了避免树的高度增长过快,降低二叉排序树的性能,我们规定
在插入和删除二叉树结点时,要保证任意结点的左、右子树高度差的绝对值不超过 1,将这样的二叉树称为平衡二叉树,简称平衡树(AVL 树)。
定义结点左子树与右子树的高度差为该结点的平衡因子,则平衡二叉树结点的平衡因子的值只可能是-1、0 或 1。

因此,平衡二叉树可定义为它或者是一棵空树,或者是具有下列性质的二叉树:
它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度差的绝对值不超过 1。
如图 4-24(a) 所示是平衡二叉树,图 4-24(b) 所示是不平衡的二叉树。
结点中的值为该结点的平衡因子。

分享图片

(2)平衡二叉树的插入

二叉排序树保证平衡的基本思想:每当在二叉排序树中插入(或删除)一个结点时,首先要检査其插入路径上的结点是否因为此次操作而导致了不平衡。
如果导致了不平衡. 则先找到插入路径上离插入结点最近的平衡因子绝对值大于 1 的结点 A,再对以 A 为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。

注意:
每次调整的对象都是最小不平衡子树,即在插入路径上离插入结点最近的平衡因子的绝对值大于 1 的结点作为根的子树。如图 4-25 所示虚线框内为最小不平衡子树。

分享图片

平衡二叉树的插入过程前半部分与二叉排序树相同,但是在新结点插入后,如果造成了査找路径上某个结点不再平衡,需要做出相应的调整。
一般可将失去平衡后进行调整的规律归纳为下列 4 种情况:

  1. LL 平衡旋转(右单旋转)。
    由于在结点 A 的左孩子(L)的左子树(L)上插入了新结点,A 的平衡因子由 1 增至 2,导致以 A 为根的子树失去平衡,需要一次向右的旋转操作。
    将 A 的左孩子 B 向右上旋转代替 A 成为根结点,将 A 结点向右下旋转成为 B 的右子树的根结点,而 B 的原右子树则作为 A 结点的左子树。
    如图 4-26 所示,结点旁的数值代表结点的平衡因子,而用方块表示相应结点的子树,下方数值代表该子树的高度。

    分享图片

  2. RR 平衡旋转(左单旋转)。
    由于在结点 A 的右孩子(R)的右子树(R)上插入了新结点,A 的平衡因子由-1 减至-2,导致以 A 为根的子树失去平衡,需要一次向左的旋转操作。
    将 A 的右孩子 B 向左上旋转代替 A 成为根结点,将 A 结点向左下旋转成为 B 的左子树的根结点,而 B 的原左子树则作为 A 结点的右子树,如图 4-27 所示。

    分享图片

  3. LR 平衡旋转(先左后右双旋转)。
    由于在 A 的左孩子(L)的右子树(R)上插入新结点,A 的平衡因子由 1 增至 2,导致以 A 为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。
    先将 A 结点的左孩子 B 的右子树的根结点 C 向左上旋转提升到 B 结点的位置,然后再把该 C 结点向右上旋转提升到 A 结点的位置,如图 4-28 所示。

    分享图片

  4. RL 平衡旋转(先右后左双旋转)。
    由于在 A 的右孩子(R)的左子树(L)上插入新结点,A 的平衡因子由 -1 减至 -2,导致以 A 为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。
    先将 A 结点的右孩子 B 的左子树的根结点 C 向右上旋转提升到 B 结点的位置,然后再把该 C 结点向左上旋转提升到 A 结点的位置,如图 4-29 所示。

    分享图片

注意:LR 和 RL 旋转时,究竞新结点插入在 C 的左子树还是右子树上,不影响旋转过程,而图 4-28 和图 4-29 中以插入 C 的左子树中为例。

(3)平衡二叉树的查找

在平衡二叉树上进行査找的过程和二叉排序树相同,因此,在査找的过程中和给定值进行比较的关键字个数不超过树的深度。
假设以 Nk 表示深度为 h 的平衡树中含有的最少结点数。
显然,\(N_0=0,N_1=1,N_2=2\),并且有 \(N_h = N_{h-1}+N_{h-2}+1\)
可以证明,含有 n 个结点平衡二叉树的最大深度为
\(\mathcal{O}(\log_2{n})\),因此,平衡二叉树的平均査找长度为 \(\mathcal{O}(\log_2{n})\)。如图 4-30 所示。

分享图片

注意:
该结论可用于求解给定结点数的平衡二叉树的查找所需的最多比较次数(或树的最大高度)。
如,问含有 12 个结点的平衡二叉树中查找某个结点的最多比较次数是?

4.5.3 哈夫曼(Huffman)树和哈夫曼编码

(1)哈夫曼树的定义

在许多实际应用中,树中结点常常被賦予一个表示某种意义的数值,称为该结点的
从树根结点到任意结点的路径长度(经过的边数)与该结点上权值的乘积称为该结点的带权路径长度
树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为
\(WPL = \sum_{i=1}^n w_i\times i_i\)
式中,\(w_i\) 是第 i 个叶结点所带的权值;\(i_i\) 是该叶结点到根结点的路径长度。
在含有 N 个带权叶子结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称为最优二叉树。

分享图片

例如,图 3-31 中的 3 棵二叉树,都有 4 个叶子结点 a、b、c、d,分别带权 7、5、2、4,它们的带权路径长度分别为

(a) WPL=7X2+5X2+2X2+4X2=36

(b) WPL=7X3+5X3+2X144X2=46

(c) WPL=7x]+5x2+2x3+4x3=35
其中图 4-31(c) 中树的 WPL 最小。可以验证,它恰为哈夫曼树。

(2)哈夫曼树的构造

给定 N 个权值分别为 \(w_1,w_2,w_N\) 的结点。
通过哈夫曼算法可以构造出最优二叉树,算法的描述如下:

  1. 将这 N 个结点分别作为 N 棵仅含一个结点的二叉树,构成森林 F。
  2. 构造一个新结点,并从 F 中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
  3. 从 F 中删除刚才选出的两棵树,同时将新得到的树加入 F 中。
  4. 重复步骤 2 和 3,直至 F 中只剩下一棵树为止。

从上述构造过程中可以看出哈夫曼树具有如下特点:

  1. 每个初始结点最终都成为叶结点,并且权值越小的结点到根结点的路径长度越大。
  2. 构造过程中共新建了 N-1 个结点(双分支结点),因此哈夫曼树中结点总数为 2N-1。
  3. 每次构造都选择 2 棵树作为新结点的孩子,因此哈夫曼树中不存在度为 1 的结点。

例如,权值{7,4}的哈夫曼树的构造过程如图 4-32 所示。

分享图片

(3)哈夫曼编码

对于待处理的一个字符串序列,如果对每个字符用同样长度的二进制位来表示,则称这种编码方式为固定长度编码。
若允许对不同字符用不等长的二进制位表示,则这种方式称为可变长度编码。
可变长度编码比固定长度编码好得多,其特点是对频率高的字符陚以短编码,而对频率较低的字符则陚以较长一些的编码,从而可以使字符平均编码长度减短,起到压缩数据的效果。
哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。

如果没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。
如 0、101 和 100 是前缀编码。
对前缀编码的解码也是很简单的,因为没有一个码是其他码的前缀。
所以,可以识别出第一个编码,将它翻译为原码,再对余下的编码文件重复同样的解码操作。
如 00101100 可被唯一地分析为 0、0、101 和 100。
由哈夫曼树得到哈夫曼编码是很自然的过程,首先,将每个出现的字符当做一个独立的结点,其权值为它出现的频度(或次数),构造出对应的哈夫曼树。
显然,所有字符结点都出现在叶结点中。
我们可以将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为 0 表示“转向左孩子”,标记为 1 表示 “转向右孩子”。
图 4-33 所示为一个由哈夫曼树构造哈夫曼编码的示例,矩形方块表示字符及其出现的次数。

分享图片

这棵哈夫曼树的 WPL 为
\(WPL = 1\times 45+3\times(13+12+16) +4\times(5+9)=224\)
此处的 WPL 可以看成是最终编码得到二进制编码的长度,共 224 位。
如果采用 3 位固定长度编码,则得到的二进制编码长度为 300 位。
哈夫曼编码共压缩了 25%的数据。 利用哈夫曼树可以设计出总长度最短的二进制前缀编码。

注意: 究竟 0 和 1 表示左子树还是右子树没有明确规定。 因此,左、右结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但是各哈夫曼树的带权路径长度相同且为最优。

相关文章

【啊哈!算法】算法3:最常用的排序——快速排序       ...
匿名组 这里可能用到几个不同的分组构造。通过括号内围绕的正...
选择排序:从数组的起始位置处开始,把第一个元素与数组中其...
public struct Pqitem { public int priority; ...
在编写正则表达式的时候,经常会向要向正则表达式添加数量型...
来自:http://blog.csdn.net/morewindows/article/details/6...