算法与数据结构——树(完结)

树的基本概念

树的定义

树是n(n>=0)个结点的有限集。当n=0时,称为空树。在任意一颗非空树中应满足:

  1. 有且仅有一个特定的称为根的结点。
  2. 当n>1时,其余节点可分为m个互不相交的有限集合,其中每个集合本身又是一棵树,并且称为根结点的子树。

由上可以看出,树是一种递归定义的数据结构。

同时,树也是一种分层结构,具有以下的两个特点:

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

基本术语

树中一个结点的孩子个数称为结点的度

树中最大的度数称为树的度

深度、高度和层次

结点的层次从树根开始定义,根结点为第1层,它的子结点为第二层。以此类推。

结点的深度是从根结点开始自顶向下逐层累加的。(默认是从1开始的)

结点的高度是从叶结点开始逐层累加的。

树的高度(深度)是树中结点的最大层数。即,总共有多少层。

路径和路径长度

树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的。路径只能是从上往下的。

路径长度是路径上所经过的边的个数。

有序树和无序树

无序树——从逻辑上看,树中结点的各子树从左至右是有次序的,不能互换。

无序树——从逻辑上看,树中结点的各子树从左至右是无次序的,可以互换。

森林

m棵互不相交的树的集合。

比如,可以用森林来表示全中国所有家庭的家谱。

树的性质

  1. 结点数 = 总度数 + 1
  2. 度为m的数与m叉树

度为m的树要求必须有一个结点,这个结点有三个子结点。

m叉树,它规定一个结点最多有m个孩子。但是,存在每一个结点都不足m个孩子的情况。

  1. 度为m的树第i层最多有m^(i-1)个结点。

  2. 高度为h的m叉树至多有
    $$
    \frac{m^h-1}{m-1}
    $$
    个结点。

  3. 高度为h的m叉树至少有h个结点;高度为h、度为m的树至少有 h + m - 1个结点。

  4. 具有n个结点的m叉树最小高度为
    $$
    \log_m{(n(m-1) + 1)}
    $$
    计算结果向上取整。

二叉树

二叉树的定义及其主要特征

二叉树的定义

​ 二叉树是另一种树形结构,其特点是每个结点至多只有两棵子树(即二叉树中不存在度大于2的结点),并且二叉树的子树有左右之分,其次序不能颠倒。

​ 与树相似,二叉树也以递归的形式定义。二叉树是n个结点的优先集合:

  1. 或者为空二叉树,即n=0

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

    二叉树是一个有序树,如将其左、右子树颠倒,则成为另一棵不同的二叉树。

几个特殊的二叉树

满二叉树

一棵高度为h,且含有 2^h - 1 个结点的二叉树

特点

  1. 只有最后一层有叶子结点
  2. 不存在度为1的结点
  3. 按层序从1开始编号,结点 i 的右孩子为 2i,右孩子为 2i+1;结点i的父节点为 i / 2 (向下取整)
完全二叉树

当且仅当其每个结点都与高度为h的满二叉树中编号为 1 ~ h的结点一一对应时,称之为完全二叉树。

特点

  1. 只有最后两层可能有叶子结点
  2. 最多只有一个度为1的结点
  3. 同满二叉树的 3
  4. i <= n /2 ( 向下取整 ) 为分支节点, i > n /2 (向下取整)为叶子结点。
二叉排序树

左子树上所有结点的关键字均小于根结点的关键字

右子树上所有结点的关键字均大于根结点的关键字

左子树和右子树又各是一棵二叉排序树

平衡二叉树

树上任一结点的左子树右子树深度之差不超过1

二叉树的性质

非空二叉树上的叶子结点数量等于度为2的结点数加1,即:
$$
n_0 = n_2 + 1
$$
二叉树第i层存在的结点个数为
$$
2^{i-1}
$$
高度为h的二叉树至多有的结点个数为
$$
2^h - 1
$$
完全二叉树的高度h可以通过以下两个公式进行计算
$$
log_2{n+1} (向上取整)

log_2{n} + 1 (向下取整)
$$

二叉树的存储结构

顺序存储结构

#define MAXSIZE 100
struct TreeNode {
    ElemType value;
    bool isEmpty;
}
TreeNode t[MAXSIZE];

定义一个长度为MAXSIZE的数组t,按照从上至下、从左至右的顺序依次存储完全二叉树的各个结点。

最坏情况:高度为h且只有h个结点的单支树(所有结点只有右孩子),也至少需要2^h - 1个存储单元。

二叉树的顺序存储结构,只适合存储完全二叉树

链式存储结构

typedef struct BiTNode {
    ElemType data;
    struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree

n个结点的二叉链表共有n +1 个空链域。

问题:找到一个结点p的父节点,只能从头开始遍历。如果当链表体量十分大的时候,这种操作是十分耗时的。

这个时候,可以使用三叉链表,方便找到父结点

二叉树的遍历

什么是遍历

遍历:按照某种次序把所有结点都访问一遍。

层次遍历:基于树的层次特性确定的次序规则。

先中后序遍历:基于树的递归特性所确定的次序规则。

二叉树的深度遍历

先序遍历:根左右(NLR)

中序遍历:左根右(LNR)

后序遍历:左右根(LRN)

这些遍历方式是深度优先的遍历方式。

所以,当一个子树下面又包含它的左子树和右子树时,应优先遍历孩子结点。

如上图所示,分别按照先、中、后的次序进行遍历

先序遍历:A B C

中序遍历:B A C

后序遍历:B C A

掌握之后,我们再来看一个更为复杂的例子。

先序遍历:A B D E C F G

中序遍历:D B E A F C G

在中序遍历的时候,访问B结点之前应该先访问D,所以D在B前面。

后序遍历:DE B F G C A

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

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

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

求一棵树的深度,可以求出左子树和右子树的深度

int treeDepth(BiTree T) {
    if (T == NULL) {
        return 0;
    }
    int l = treeDepth(T->lchild);
    int r = treeDepth(T->rchild);
    return l > r ? l+1 : r+1;
}

二叉树的层序遍历

也就是广度优先遍历算法。

算法思想:

  1. 初始化一个辅助队列
  2. 根结点入队
  3. 若队列非空,则对头结点出队,并且将其左右孩子插入到队尾
  4. 重复3的操作直至队列为空
代码实现
// 二叉树的结点(链式存储)
typedef struct BiTNode {
    char data;
    struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;

// 链式队列结点
typedef struct LinkNode {
    BiTNode *data;
    struct LinkNode *next;
}LinkNode;
// 层序遍历
void LevelOrder(BiTree T) {
    LinkQueue Q;
    InitQueue(Q);
    
    BiTree p;
    EnQueue(Q, T);
    while(!isEmpty(Q)) {
        // 队头结点出队
        DeQueue(Q, p);
        visit(p);
        if (p->lchild != NULL)
            EnQueue(Q, p->lchild);
        if (p->rchild != NULL)
            EnQueue(Q, p->rchild);
    }
}

由遍历序列构造二叉树

一个中序遍历序列可能对应多种二叉树形态。

其它遍历方式也会有相同的结果。

所以给定一个序列,不能唯一确定一棵二叉树。

由两种不同的序列可以推出二叉树。

一定要有中序遍历的序列才可以唯一的推出一棵二叉树。

前序 + 中序

前序序列的第一个是根结点。看中序序列,找到第一个根结点所在的位置,它前面的左右元素都在根结点的左边,它后面的所有元素都在根结点的右边。划分好第一个之后,再找左右子树根结点的位置以及在此根结点左右两边的元素。以此类推。。。

后序 + 中序

与前序序列思想相同,从后往前找根结点。

后序 + 中序

线索二叉树

线索二叉树是为了解决找前驱和后继不方便和遍历操作必须从根开始的问题。

中序线索化

// 线索二叉树结点
typedef struct ThreadNode {
    ElemType data;
    struct ThreadNode *lchild, *rchild;
    // 左右线索标志
    int ltag, rtag;
}ThreadNode, *ThreadTree;

// 中序遍历二叉树,一遍遍历一遍线索化
void InThread(ThreadTree T) {
    if(T != NULL) {
        InThread(T->lchild);
        visit(T);
        InThread(T->rchild);
    }
}

void visit(ThreadNode *q) {
    if (q->lchild == NULL) {
        // 左子树为空,建立前驱线索
        q->lchild = pre;
        q->ltag = 1;
    }
    if (pre != NULL && pre->rchild == NULL) {
        pre->rchild = q;
        pre->rtag = 1;
    }
    pre = q;
}

// 全局变量pre,指向当前访问节点的前驱
ThreadNode *pre = NULL;

// 中序线索化二叉树T
void CreateInThread(ThreadTree T) {
    // pre初始化为NULL
    pre = NULL;
    if (T != NULL) {
        InThread(T);
        if(pre->rchild == NULL) {
            // 处理遍历的最后一个结点
            pre->rtag = 1;
        }
    }
}

先序线索化

注意“转圈“问题

此时,要访问q结点的下一个结点,而已经将q结点的左孩子指针指向B。如果这样的话,会在B、D之间一直转圈。

void PreThread(ThreadTree T) {
    if(T != NULL) {
        // 处理根结点
        visit(T);
        // 若lchild不是前驱线索
        if (T->ltag == 0) {
            PreThread(T->lchild);
        }
        PreThread(T->rchild);
    }
}

void visit(ThreadNode *q) {
    if (q->lchild == NULL) {
        q->lchild = pre;
        q->ltag = 1;
    }
    
    if (pre != NULL && pre->rchild ==NULL) {
        pre->rchild = q;
        pre->rtag = 1;
    }
    pre = q;
}

// 全局变量pre,指向当前访问结点的前驱
ThreadNode *pre = NULL;

// 先序线索化二叉树T
void CreatePreThread(ThreadTree T) {
    pre = NULL;
    if (T != NULL) {
        // 先序线索化二叉树
        PreThread(T);
        if (pre->rchild == NULL) {
            // 处理遍历最后一个结点
            pre->rtag = 1;
        }
    }
        
    
}

后续线索化

不存在”转圈“问题

树的存储结构

双亲表示法(存储结构)

每个结点保存指向双亲的指针。

如上图所示,可以定义如下的数据结构。

// 树中最多的结点个数
#define MAX_TREE_SIZE 100

// 树的结点定义
typedef struct {
    // 数据元素
    ElemType data;
    // 双亲位置域
    int parent;
}PTNode;
// 树的类型定义
typedef struct {
    // 双亲表示
    PTNode nodes[MAX_TREE_SIZE];
    // 结点数
    int n;
}PTree;

如果删除的元素不是叶子结点,则需要从头遍历找寻它的所有的孩子。

孩子表示法

struct CTNode {
    // 孩子结点在数组当中的位置
    int child;
    // 下一个结点
    strct CTNode *next;
}

typedef struct {
	ElemType data;
    struct CTNode *firstChild;
}CTBox;

typedef struct {
    CTBox nodes[MAX_TREE_SIZE];
    // 结点数和根的位置
    int n, r;
}CTree;

孩子兄弟表示法

// 孩子兄弟表示法
typedef struct CSNode {
    ElemType data;
    // 第一个孩子指针
    struct CSNode *firstChild;
    // 右兄弟指针
    struct CSNode *nextChild;
}CSNode, *CSTree;

树和二叉树的转换

森林与二叉树的转换

本质:用二叉链表存储森林。

树的遍历

先根遍历

若树非空,先访问根结点,再依次对每棵子树进行先根遍历。

// 树的先根遍历
void PreOrder(TreeNode *R) {
    if (R != NULL) {
        // 访问根结点
        visit(R);
        while (R还有下一棵子树T) {
            // 先根遍历下一棵子树
            PreOrder(T);
        }
    }
}

树的先根遍历序列与这棵树相应二叉树的先序序列相同。

后根遍历和仙根遍历又称为深度优先遍历。

后根遍历

若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。

// 树的后根遍历
void PostOrder(TreeNode *R) {
    if (R != NULL) {
        while (R还有下一棵子树T)
            PostOrder(T);
        // 访问根结点
       	visit(R);
    }
}

树的后根遍历序列与这棵树相应二叉树的中序序列相同。

层次遍历

使用队列实现

  1. 若树非空,则根结点入队
  2. 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队。
  3. 重复执行操作2直到队列为空为止。

又称为广度优先遍历

森林的遍历

森林是m棵互不相交的树的集合。每棵树去掉根结点后,其各个子树又组成森林。

先序遍历森林

若森林非空,则按如下规则进行遍历:

  1. 访问森林中第一棵树的根结点。
  2. 先序遍历第一棵树中根结点的子树森林
  3. 先序遍历除去第一棵树之后剩余的树构成的森林

此方法的效果等同于依次对各个树进行先根遍历的结果。

第二种方法:

将森林转换成二叉树,森林的先序遍历序列和二叉树的先序遍历序列是相同的。

中序遍历森林

若森林非空,则按如下规则进行遍历:

  1. 中序遍历森林中第一棵树的根结点的子树森林。
  2. 访问第一棵树的根结点。
  3. 中序遍历除去第一棵树之后剩余的树构成的森林。

效果上等同于依次对各个树进行后根遍历。

第二种方法:

将森林转换成二叉树,森林的中序遍历序列和二叉树的中序遍历序列是相同的。

树与二叉树的应用

二叉排序树

二叉排序树,又称二叉查找树(BST)。

定义

一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:

左子树上所有结点的关键字均小于根结点的关键字。

右子树上所有结点的关键字均大于根结点的关键字。

左子树和右子树又各是一棵二叉树。

如果对该子树进行中序遍历,可以得到一个递增的有序序列。

二叉排序树的查找

若树非空,目标值与根结点的值进行比较:

如相等,则查找成功。

若小于根结点,则在左子树上查找,否则在右子树上查找。

查找成功,返回结点指针;查找失败则返回NULL。

代码如下:

// 二叉排序树结点
typedef struct BSTNode {
    int key;
    struct BSTNode *lchild, *rchild;
}BSTNode, *BSTree;

// 在二叉排序树中查找值为key的结点
BSTNode *BST_Search(BSTree T, int key) {
    // 若树空或等于根结点的值,则结束循环
    while(T != NULL && key != T->key) {
        if (key < T->key)
            T = T->lchild;
        else
            T = T->rchild;
    }
    return T;
}

二叉排序树的插入

若原二叉树为空,则直接插入结点;否则,若关键字k小于根结点的值,则插入到左子树,若关键字k大于根结点的值,则插入到右子树。

// 在二叉排序树插入关键字为k的新结点
int BST_Insert(BSTree &T, int k) {
    if(T == NULL) {
        T = (BSTree)malloc(sizeof(BSTNode));
        T->key = k;
        T->lchild = T->rchild = NULL;
    }
    else if (k == T->key)
        // 若存在相同关键字的结点,插入失败
        return 0;
    else if(k < T->key)
        return BST_Insert(T->lchild, k);
    else if(k > T->key)
        return BST_Insert(T-0>rchild, k);
}

二叉排序树的构造

// 按照str[] 中的关键字序列建立二叉排序树
void Create_BST(BSTree &T, int str[], int n) {
    T = NULL;
    int i = 0;
    while(i<n) {
        // 依次将每个关键字插入到二叉排序树中
        BST_Insert(T, str[i]);
        i++;
    }
}

二叉树的删除

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

z的后继:z的右子树中最左下的结点。

查找效率分析

查找长度——在查找运算中,需要对比关键字的次数成为查找长度,反应了查找操作时间复杂度。

查找成功的平均查找长度ASL(Average Search Length)

ASL = (1 + 2*2 + 3*4 + 4*1) / 8 = 2.625

查找失败平均查找长度ASL(Average Search Length)

ASL = (3*7 + 4*2) / 8 = 3.22

平衡二叉树

平衡二叉树(Balanced Binary Tree),简称平衡树(AVL)。

定义

树上任意结点的左子树和右子树的高度之差不超过1。

结点的平衡因子 = 左子树高 - 右子树高

调整最小不平衡子树

查找效率分析

若树高为h,最坏情况下,查找一个关键字最多需要对比h次,即查找操作的事件复杂度不可能超过O(h)

平衡二叉树——书上任意结点的左子树和右子树的高度之差不超过1.

假设以n_h表示深度为h的平衡树中含有最少结点数。
$$
n_h = h_{h-1} + n_{h-2} + 1
$$
可以证明含有n个结点的平衡二叉树的最大深度为
$$
O(log_2{n})
$$
此式也就为平衡二叉树平均查找长度

哈夫曼树

概念

结点的权:有某种现实含义的数值(如:表示结点的重要性)

结点的带权路径长度:从树的根到该节点的路径长度(经过的边数)与该节点上的权值的乘积。

树的带权路径长度:树中所有叶子结点的带权路径长度之和(WPL, Weighted Path Length)

定义

在含有n个带权叶子结点的二叉树中,其中带权路径长度(WPL)最小的二叉树成为哈夫曼树,也称最优二叉树

哈夫曼树的构造

给定n个权值分别为w1, w2, w3, ..., wn的结点,构造哈夫曼树的算法描述如下:

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

由哈夫曼树的定义我们可以知道:

  1. 每个初始结点最终都会成为叶子结点,且权值越小的结点到根结点的路径长度越大。
  2. 哈夫曼树的结点总数为 2n-1
  3. 哈夫曼树中不存在度为1的结点
  4. 哈夫曼树并不唯一,但WPL是相同的。

哈夫曼编码

若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码

由哈夫曼树得到哈夫曼编码——字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树。

posted @ 2022-06-18 21:35  Gazikel  阅读(217)  评论(0编辑  收藏  举报