科普文:算法和数据结构系列【非线性结构:树、森林、图概叙】

概叙

科普文:算法和数据结构系列【算法和数据结构概叙】-CSDN博客

科普文:算法和数据结构系列【非线性数据结构:树Tree和堆Heap的原理、应用、以及java实现】-CSDN博客

科普文:算法和数据结构系列【非线性数据结构:图Graph的原理、应用、以及java实现】-CSDN博客

科普文:算法和数据结构系列【树:4叉树、N叉树】-CSDN博客

科普文:算法和数据结构系列【二叉树总结-上篇:满二叉树、完全二叉树、大顶堆/小顶堆、二叉搜索树、自平衡二叉树、红黑树总结】-CSDN博客

科普文:算法和数据结构系列【二叉树总结-下篇:满二叉树、完全二叉树、大顶堆/小顶堆、二叉搜索树、自平衡二叉树、红黑树总结】-CSDN博客

科普文:算法和数据结构系列【数据库喜欢的数据结构:B-树、B+树原理、应用、以及java实现示例】-CSDN博客

科普文:算法和数据结构系列【B+树java示例代码解读】-CSDN博客

科普文:算法和数据结构系列【压缩和通信利器:哈夫曼树(Huffman Tree)java示例代码解读】-CSDN博客

原本想开始梳理后面的算法:图算法、动态规划算法、分治算法、分治算法、贪心算法。

科普文:算法和数据结构系列【排序算法:常见10种排序算法的原理、应用、以及java实现】-CSDN博客

科普文:算法和数据结构系列【查找算法:常见7种查找算法的原理、应用、以及java实现】-CSDN博客

为了更好的理解图算法,我们先小结一下树、森林、图这三种数据结构。

:n(n ≥ 1)个节点的有限集,n = 0时称为空树,且其内部各子树间没有交叉(没有公用节点)。

树也是一种特殊的图,它满足以下条件:树是连通的无圈图,即树中的任意两个顶点之间都存在唯一一条路径,并且树中没有回路。

所以树是图的一种,树和图的区别就在于:树是没有环的,而图是可以有环的

树具有明显的层次性,由根节点和若干子树构成,子树又有更小的子树构成。在树中,每个节点最多只和上一层的节点有直接的关系(称为双亲结点),而与其下一层的多个节点有直接关系(称为孩子结点)。没有孩子结点的节点被称之为叶子节点。树在计算机科学的数据结构中有着广泛的应用,如二叉查找树、堆、Trie树等‌。

森林:由m(m>=0)棵互不相交的树的集合。也就是说,森林是由零个或多个互不相交的树组成的集合。每棵树都是独立的,没有共享节点或连接。森林可以看作是对多棵独立树的一种统一表示和管理‌。

‌:由顶点和边组成,顶点之间通过边相连。是比树和森林更加复杂的数据结构,万物皆可图。

与树和森林不同,图中的顶点之间的关系是任意的,任何两个顶点都可能相关。

因此,图能够用来解决现实世界中一些极其复杂的问题。图可以是连通的,也可以是不连通的;可以是无向的,也可以是有向的。图的结构比树和森林更为复杂,但也更为灵活和强大‌。

树Tree

树是一种很特别的数据结构,树这种数据结构叫做“树”就是因为它长得像一棵树。但是这棵树画成的图长得却是一棵倒着的树,根在上,叶在下。

定义:n(n ≥ 1)个节点的有限集,n = 0时称为空树,且其内部各子树间没有交叉(没有公用节点)。

树的术语

叶节点:度为0的节点称为叶节点;没有子节点的节点;

分支节点:度不为0的节点;

父节点(双亲结点):若一个节点含有子节点,则这个节点称为其子节点的父节点;

子节点(孩子节点):一个节点含有的子树的根节点称为该节点的子节点;

根节点:没有父节点的分支节点。

兄弟节点:具有相同父节点的节点互称为兄弟节点;

堂兄弟节点:双亲在同一层的节点互为堂兄弟;

节点的祖先:从根到该节点所经分支上的所有节点;

子孙:以某节点为根的子树中任一节点都称为该节点的子孙;

节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;

节点的度:一个节点含有的子树的个数称为该节点的度;

树的度:一棵树中,最大的节点的度称为树的度(度为3的树和3叉树);

树的高度或深度:树中节点的最大层次;

度为3的树和3叉树

度为m的树:指的是一棵树中度数最多的结点它的度数为m的树,因此该树中至少有1个结点的度为m,所以它肯定是非空树;

m叉树:指的是一颗树的度最多为m,因此该树中可以没有度为m的结点,所以m叉树也可以是空树; 

有序树(Ordered Tree):树中的结点的子树从左到右是由次序的不能交换。前面的搜索树(即排序树)都是有序树,包括搜索树的子类树:AVL树、红黑树、哈夫曼树、B类型树(B-Tree、B+Tree、B*-Tree)。

  • 二叉树 Binary tree:每个节点最多含有两个子树的树称为二叉树。

    • 完全二叉树:一个二叉树的深度为d,除了d层外,其他各层的节点数目均达到最大值。
    • 满二叉树:所有叶节点都在最底层的完全二叉树
    • 平衡二叉树(ALV树):当且仅当任何节点的两棵子树的高度差不大于1的二叉树。
    • 排序二叉树(又称二叉查找树、二叉搜索树,有序二叉树)
    • 霍夫曼树(Huffman Coding又称哈夫曼树或最优二叉树):带权路径最短的二叉树。霍夫曼编码使用变长编码表对源符号进行编码,典型应用图文压缩。
    • B树 一种对写操作进行优化的自平衡的二叉查找树,能够保持数据有序,拥有多于两个子树。典型应用mysql索引。
    • 红黑树,应用于jdk TreeSet中,是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。

无序树/自由树:树中的结点没有次序可以交换,所以也叫自由树。

树的存储结构

由于普通的树结构并不像二叉树那么规则,可能是多叉树的组合,因此很难用常规的线性结构来存储。因此树结构的存储需要将树家族中的关系剥离出来进行存储,保存了每个结点之间的关系,整个树结构也就能依次进行恢复。

这就好比家族中的族谱一样,记录的是我们和双亲以及兄弟姐妹的关系。

对于树而言,则根据存储关系的不同,可分为双亲表示、孩子表示以及孩子兄弟表示三种存储方法。

双亲表示法

所以在使用孩子表示法来存储树的结构时,常使用数组+链表的结构。这种结构是不是很常见,跟解决哈希冲突的链地址法有异曲同工之意。在这样的链式结构中,用指针指示出结点的每个孩子,每个孩子的位置通过链表依次相连,这样就十分方便与查找每个结点的子孙。

只不过问题依旧,若要找出寻找某个结点的双亲则同样需要遍历所有链表。不过,既然双亲表示和孩子表示都有了,简单粗暴的合并一下不就可以相互补充,共同进退吗。

双亲表示法:采用一维连续线性表存储,为每个结点增设一个伪指针,也就是数组中的下标指示其双亲在数组中的位置。

 

图中根结点的下标为0,其伪指针为-1。

代码描述如下:

    typedef int ElemType;
    #define MAX_SIZE 100
    typedef struct
    {
    	ElemType data;						//数据
      	int parent;							//祖先指针
    }PTNode;
    typedef struct
    {
    	PTNode nodes[MAX_SIZE];
      	int n;								//结点数目
    }PTree;

孩子表示法

树的双亲表示法的缺点显而易见,所以最直接的解决办法就是干脆存孩子结点算了。

还别说,孩子表示法就是这样一种表示方法。但是相较于双亲结点的存储,存储孩子结点有一个需要考虑的问题,就是某个结点的双亲结点最多只有一个,但是其孩子结点可能有多个。如果每个孩子结点都存储在数组里,这样的方式不是一个明智的选择,并且也没有必要。

孩子表示法:每个结点的孩子结点都用链表链接起来组成一个线性结构。

代码描述如下

    #define MAX_SIZE 100
    typedef int ElemType;
    typedef struct PNode
    {	
    	int son;					//孩子结点的下标
      	struct PNode* next;			//下一个孩子结点
    }PNode;
    
    typedef struct TNode
    {
    	ElemType data;
      	PNode* child;				//孩子结点的指针
    }PTree[MAX_SIZE];

孩子兄弟表示法

本来有了双亲孩子表示法就已经足够用来存储树中的数据信息了,为什么还要来一个孩子兄弟法呢?

其实不然,孩子兄弟表示法反而是一种很有意思且很有价值的表示方式。

在孩子兄弟表示法中,我们约定只存储每个结点的第一个孩子结点和下一个兄弟结点。不仅如此,结点的存储是通过链表进行的。

看起来似乎有些诡异的形状,每个结点都作为链表的一个节点,通过两个指针分别指向第一个孩子结点和下一个兄弟结点。为了防止大家看不懂,我举个例子。拿结点B来说,它的第一个孩子结点是E,而它的下一个兄弟是与它处于同一层级的C。因此结点B的两个指针分别指向了E和C。

孩子兄弟表示法这样看起来似乎很鸡肋,但是假如我们调整一下右边这个图,就能看出其中的蹊跷了。

孩子兄弟表示法实际上就是将一颗普通的树转换成了二叉树的形式。所以说二叉树为什么这么重要,因为万变不离其中呀。看到这,其实也透露出树和二叉树之间的转换关系,许多二叉树上的性质和操作也可以借此运用在普通的树结构中。

孩子兄弟表示法:又称二叉树表示法,孩子兄弟表示法有三部分内容:结点数据,指向第一个孩子的指针和指向下一个兄弟的指针。

代码描述如下

    typedef int ElemType;
    typedef struct CNode
    {
    	ElemType data;
      	struct CNode* firstChild, *nextChild;
    }CNode,* CTree;

树的表现方法

树一共有三种表现方法,分别是图像表现法、符号表现法和遍历表现法

图像表示法

  • 这是最常见的树的表现方法,通过直观的图形方式展示树的层次和结构。
  • 在图像中,树的根节点位于顶部,子节点依次向下排列,形成类似倒挂的树形结构。
  • 这种方法简单明了,易于理解和展示树的层次关系‌。

图像表现法:参考树的定义,直接画出数。简单直观,但是如果节点很多,表示起来就不方便了,画图毕竟有限。

符号表现法(孩子兄弟表示法)

  • 符号表现法是一种更为抽象的表现方式,不依赖于图形。
  • 它通过特定的符号(如圆括号和逗号)来表示树中的节点和它们之间的关系。
  • 例如,可以用“(根节点(子节点1(子节点1.1,子节点1.2)),子节点2)”来表示一个具有根节点和两个子节点的树结构。
  • 这种方法在文本环境中特别有用,尤其是在无法直接绘制图形的情况下‌。

符号表现法没有其它两种方法那么常见,但是更为实用,特别是在比赛中输入时不能输入图片,那么可以用到符号表现法

同层子树与它的根结点用圆括号括起来,同层子树之间用逗号隔开,最后用闭括号括起来。

如上文中的图就可以表现为;(1(2(6,7(14),8(15(19))),3(9(16)),4(10,11(17(20)),12),5(13(18(21)))))

遍历表现法(主要针对二叉树)

  • 遍历表现法是通过遍历树中的节点来表现树的结构。遍历表现法是二叉树特有的一种遍历。
  • 对于二叉树,常见的遍历方式有前序遍历、中序遍历、后序遍历、层次遍历。
  • 每种遍历方式都会按照特定的顺序访问树中的节点,并可以通过这种顺序来重建或理解树的结构。
  • 需要注意的是,遍历表现法通常用于二叉树,对于其他类型的树可能不适用或需要特殊的遍历方式‌。

树的特性 

  • 树中结点数等于所有结点的度数加1;
  • 度为m的树第i层上至多m^(i-1)个结点;
  • 高度为h的m叉树至多有(m^h-1)/(m-1)个结点;
  • 具有n个结点的m叉树的最小高度为(取上限(log_m(n(m-1)+1)))。

1.树中的结点数等于所有结点的度数之和加1;

如果我要计算一棵树结点的总数,我只需要把根结点及其下方的所有结点加起来就可以了。

为了方便理解,我们以下面这棵树为例:

在这个例子中,每一层结点的度数都会少1,而每一层的结点数都是上一层的结点数与度数的乘积,因此我们不难得到这棵树的,即:

从第二层开始的结点数实际上都是上一层度数的总和,

这里如果我们将每个度数的总和记为

那我们不难得到所有度数的总和为:

由上面两个式子联立可得

即:

除了上面的方式来理解这条性质,我们还可以通过度与结点的一一对应来进行理解。

在有n个结点的树中,从叶结点开始往上走,每一个结点都只与一个上层结点有直接联系,因此我们可以得到结论:

  • 每一个结点对应一条边,也就是一个度;

而根结点没有与之对应的上层结点,也就是根结点的上方没有变,因此我们可以得到结论:

  • n个结点只有n-1条边,也就是n-1个度

根据这两个结论我们不难得到总的结点数n与总度数的关系为:

2.度为m的树第i层上至多m^(i-1)个结点(i>=1);

对于度为m的树,它至少是有m+1个结点,即根结点的度为m,而它的子节点的度为0;

在最多的情况下该树的每一层的每一个结点的度都为m,因为每一层的结点数都是上一层的结点数与度数的乘积,而这棵树中所有的结点的度数都为m因此从第二层开始,每一层的结点数都是上一层的结点数×m,如下所示:

在图中,我们可以很直观的感受到,第 i 层的结点数最多是: m^(i-1)个结点(i>=1)

3.高度为h的m叉树至多有(m^h-1)/(m-1)个结点;

4.高度为h的m叉树至少有h个结点,高度为h,度为m的树,至少有个结点;

这条性质的第一点这里我们就不需要再次证明了,我们来看一下性质的第二点,高度为h,度为m的树,在结点最少的情况下就是只有一个结点的度为m,其它结点的度都为1,如下所示:

 在这棵树中,我们不难看出前h-1层的结点数就为 h-1 ,最后一层的结点数为m  ,因此总结点数为m+h-1 ;

5.具有n个结点的m叉树的最小高度为(取上限(log_m(n(m-1)+1)))。

在一棵m叉树中,如果要使树的高度最少,那就需要每一层的结点数都达到最大,即第i层的结点数为m^(i-1),如果我们设最小高度为h则我们可以得到总结点数为 n=(m^h-1)/(m-1)。

现在我们已经知道了总结点数为n,高度是未知的,因此我们可以将上式进行一个简单的变形:

在这些性质中,每一条性质都是相辅相成的,因此对于树的性质这个知识点,我们需要的更多的是理解,直到这些性质是怎么得到的,之后在遇到习题时即使忘记了其中的某条性质,我们也能顺手将其推导出来。

树的遍历

 详见:科普文:算法和数据结构系列【非线性数据结构:树Tree和堆Heap的原理、应用、以及java实现】-CSDN博客

为了方便阅读,还是将树的遍历再贴一边。

 前序遍历(DFS)

访问顺序:根节点->左子树->右子树

步骤:(1)访问根结点;(2)前序遍历左子树;(3)前序遍历右子树。

示意图

中序遍历(DFS)

访问顺序:左子树->根节点->右子树

步骤:(1)中序遍历左子树;(2)访问根结点;(3)中序遍历右子树。

示意图

后序遍历(DFS)

访问顺序:左子树->右子树->根节点

步骤:(1)后序遍历左子树;(2)后序遍历右子树;(3)访问根结点;

示意图

层次遍历/逐层遍历(BFS)

访问顺序:第0层->第1层->……->第n层(每层从左至右依次处理)

步骤

(1)初始化:创建一个空队列,将根节点加入队列;

(2)遍历:当队列不为空时:

从队列中取出一个节点,并访问该节点的值;

如果该节点有左子节点,将左子节点加入队列;

如果该节点有右子节点,将右子节点加入队列;

(3)重复步骤2,直到队列为空;

示意图

森林

森林:由m(m>=0)棵互不相交的树的集合称为森林。

森林的遍历

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

  • 先序遍历森林,先访问森林中第一棵树的根节点,再先序遍历第一棵树根节点的子树森林,最后先序遍历除第一棵树之外剩余的树所构成的森林。
  • 中序遍历森林,先中序遍历森林中第一棵树的子树森林,然后访问第一棵树的根节点,最后中序遍历除第一棵树之外剩余的树所构成的森林。

树、森林、二叉树间的转换

树、森林和二叉树本质上都是类似的结构,因此相互之间可以进行转换。任意一个森林或者一棵树都可以对应表示为一颗二叉树,而任何一颗二叉树也能够对应到一个森林或一棵树上。

树转换为二叉树,我们在前面已经介绍过,就是通过树的孩子兄弟表示法。通过孩子兄弟法进行表示时,每一个树都可以用一颗唯一的二叉树来表示。但是转换过来的二叉树却有一个非常显著的特点。仔细观察。

树转换为二叉树的规则是:每个节点左指针指向它的第一个孩子结点,右指针指向它的相邻的兄弟结点,故表示为“左孩子右兄弟”。

森林转化为二叉树的规则和树转换为二叉树的规则相似:先将森林中的每一棵树转化为二叉树,再将第一棵树作为转化后的二叉树的根,第一棵树的坐姿树作为转化后的二叉树的左子树,第二课二叉树作为二叉树的右子树,第三棵二叉树作为转化后的二叉树的根的右子树的右子树。 

从根节点开始,若右孩子存在,则把与右孩子结点的连线删除。再查看分离后的二叉树,若其根节点的右孩子存在,则连线删除…。直到所有这些根节点与右孩子的连线都删除为止。将每棵分离后的二叉树转换为树。

(1)树转换为二叉树

(i) 加线;在所有兄弟结点之间加一条连线。

(ii) 去线;每个结点只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。

(iii) 层次调整:以树的根结点为轴心,将整棵树顺时针旋转一定角度使其结构层次分明。

(2)森林转二叉树

(i) 将每棵树转换为二叉树。

(ii) 第一棵二叉树不动,从第二棵开始依次把后一棵二叉树的根结点作为前一棵根结点的右孩子,连线。

(3)二叉树转换成树

(i) 加线;左孩子的n个右孩子结点都作为此结点的孩子。将该结点与这些右孩子结点用线连接起来。

(ii) 去线;删除原二叉树中所有结点与其右孩子结点的连线。

(4)二叉树转森林

(i) 从根结点开始,若存在右孩子,则删除与右孩子的连线。

(ii) 再查看分离后的二叉树,若存在右孩子,则连线删除,直到所有右孩子连线删除为止。

(iii) 再将分离的二叉树都转换为树即可。

图Graph

图G由顶点集V和边集E组成,记为G = (V, E),其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。若V={v1, v2, v3, ..., vn},则用|V|表示图G中顶点的个数,E = {(u, v) | u∈V, v∈V},用|E|表示图G中的边数。

图不可以为空,V一定是一个非空集,而E可以是一个空集。

有向图

若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v, w>,其中v、w是顶点,v成为弧尾,w称为弧头,<v, w>称为从顶点v到顶点w的弧,也称v邻接w,或w邻接自v。

如上图所示,对于<A, E> E为弧尾,A为弧头

无向图

若E是无向边(简称边)的优先级和时,则图G为无向图。边是顶点的无序对,记为(v, w) 或 (w, v),因为(v, w) = (w, v),其中v、w是顶点。可以说顶点w和顶点v互为临界点。边(v, w)依附于顶点v和w,或者说边(v, w)和顶点v、w相关联。

简单图、多重图

一个图G如果满足:

  1. 不存在重复边
  2. 不存在顶点到自身的边

那么,称图G为简单图。

若图G中某两个顶点之间的边数大于1条,有允许顶点通过一条边与自身相连,则称图G为多重图。

顶点的度、入度、出度

对于无向图:顶点的度是指依附于该顶点的边的条数,记为TD(V)
$$
\sum_{i=1}^{n}{TD(v_i)} = 2 |E|
$$
对于有向图:

入度是以顶点v为终点的有向边的数目,记为ID(V)

出度是以顶点v为起点的有向边的数目,记为OD(V)

顶点v的度等于其入度和出度之和,即TD(V) = ID(V) + OD(V)

在具有n个顶点、e条边的有向图中:
$$
\sum_{i=1}^{n}ID(v_i) = \sum_{i=1}^{n}OD(v_i) = e
$$

路径、路径长度和回环

顶点vp到顶点vq之间一条路径是指顶点序列。当然,关联的边也可以理解为路径的构成元素。

路径上边的数目称为路径长度。

第一个顶点和最后一个顶点相同的路径称为回路或环

图的存储及基本操作

邻接矩阵法

0表示无边

1表示有边

对于无向图来说,第i个结点的度就是第i行或第i列非零元素的个数。

对于有向图来说,第i个结点的出度是第i行非零元素的个数;第i个结点的入度是第i列所对应元素的个数。

#define MaxVertexNum 100
typedef struct {
    // 顶点表
    char Vex[MaxVertexNum];
    // 邻接矩阵,边表
    bool Edge[MaxVertexNum][MaxVertexNum];
    // 图的当前顶点数和边数/ 弧数
    int vexNum, arcNum;
}MGraph;
邻接矩阵法存储带权图

在对应位置写两个顶点之间对应的权值。如果两个顶点不存在边,则存放一个无穷大值。

#define MaxVertexNum 100
// 宏定义常量“无穷”
#define INFINITY 99999
// 顶点数据类型
typedef char VertextType;
// 带权图中边上权值的数据类型
typedef int EdgeType;

typedef struct {
    VertexType Vex[MaxVertexNum];
    EdgeType Edge[MaxVertexNum][MaxVertexNum];
    int vexnum, arcnum;
}MGraph;
邻接矩阵法的性能分析

空间复杂度:
$$
O(|V|^2)
$$
只和定点数相关,和实际的边数无关。

适合用于稠密图。

无向图的邻接矩阵是对称矩阵,可以使用压缩存储(只存储上三角或下三角)的方法进行存储。

邻接矩阵法的性质

设图G的邻接矩阵为A,则An的元素An[i][j]等于由顶点i到顶点j的长度为n的路径的数目。

邻接表法

顺序 + 链式 存储方式

 

在无向图中,遍历某个顶点的边链表,边链表的个数就是其顶点的度。

而在有向图中:遍历某个顶点的边链表,边链表的个数就是其顶点的出度;如果需要求顶点的入度,需要遍历所有顶点,事件花费较高。

图的邻接表表示方式不唯一。

邻接表法性能分析

对于有向图,其空间复杂度为:
$$
O(|V|+|E|)
$$
对于无向图,其空间复杂度为:
$$
O(|V| + 2|E|)
$$

十字链表(理解)

存储有向图

邻接多重表(理解)

存储无向图

空间复杂度为 O(|V| + |E|)

图的基本操作

  • Adjacent(G, x, y)——判断图G是否存在边<x, y>或(x, y)
  • Neighbors(G, x)——列出图G中与结点x邻接的边
  • InsertVertex(G, x)——在图G中插入顶点x
  • DeleteVertex(G, x)——在图G中删除顶点x
    • AddEdge(G, x, yS)——若无向边(x, y)或有向边<x, y>不存在,则向图G中添加该边

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值