本系列笔记分为三篇,系统总结了王道408数据结构课程的内容,加入了大量个人思考。
数据结构笔记——线性表、栈、队列、串(王道408)
数据结构笔记——树、图(王道408)
数据结构笔记——查找、排序(王道408)
数据结构的笔记相比于其他3门,笔记的重要性要低很多,毕竟对于选择408的同学来说,大二时候应该有足够的时间学习,所以基础是比较好的,再加上csdn上一大堆数据结构和算法的帖子,我再重复造轮子也没啥意思了。
所以我这篇文章不打算写的很细节,就是单纯地把思路提纯出来,并附上自己的理解,再搭配思维导图就行了,而不去记录过于细节的知识。

除了根节点,任何节点有且仅有1前驱:

节点之间的关系:
术语:




二叉树是一个纯粹的递归定义,二叉树只有两种状态:
具体来讲,有5种细分状态:
通过度就可以区分节点的状态,后面说的所谓度为几,其实就是说xx状态的节点。

完全二叉树:节点是从上到下,从左到右连续排列的,没有空隙
满二叉树:最后一层是满的完全二叉树
直接特性就是,如果n层有节点,代表n-1层一定是排满的,且n层这个节点左边也是排满的
接下来解读二者差别:
仅有一个1度,然后全是0度。如果有两个1度,就会打破完全二叉树连续排列的定义。向下取整
二叉树推导:
完全二叉树推导:
纵观这些公式,关键在于几点:

顺序存储,先说完全二叉树:
一般的二叉树,如果在数组中连续存储,就会破坏位序关系
如果按位序存储,仍然有很多不便。一来节点状态无法通过n来判断了,只能新增bool变量,二来还会浪费空间,因此二叉树其实一般是用链式结构储存的。

链式储存:
假设有n个节点,那么总共就有2n个链域,除根节点外每有一个节点就会有一条边,每条边都会占用一个链域,总共n-1条边
因此就会剩下2n-(n-1)=n+1个空链域
这些链域可以用来逆向构建线索二叉树
在只有两个链域的情况下,找父节点要从根节点遍历,比较麻烦,因此要频繁查找父节点的话,还应该加入parent链域。


所谓的先序,其实是先根序

我此前是直接在脑子里去遍历,就是在脑子里模拟递归调用栈,虽然也能做,但是费脑子,不如下面的省劲:
逐层去展开,就像是前面从中缀转前缀/后缀的时候
先留出括号,然后将未处理部分在下一层继续展开

二叉树,可以理解为一个代表中缀表达式的结构,分支节点上是运算符,叶节点是操作数,树结构代表了运算先后顺序
对其进行先序遍历,就是把符号放在前面,也就是前缀表达式,后序就是后缀表达式。注意,中缀的时候,需要加括号,之前我也写过一道这个算法题,我记得大致是从层次里提取括号。

至于递归代码,就很简单了:
T!=NULL是判断条件,如果空就不操作,非空就进行遍历。
visit代表对根结点的访问
根据前中后序遍历,可以在对左右孩子的递归调用之间找到一个合适的位置。

有的考题会让你分析递归过程,前进回退,其实无论是哪种遍历,路径都是一模一样的,每个节点都被经过三次(算上左右孩子为空的情况):
前中后序遍历的区别就在于,是在哪一次经过的时候访问节点,先序就是在1,中序就是在2,后序就是3,到时候画一条路,然后脑补在该访问的时候写出节点就好。
需要注意,NULL孩子也算孩子,即使孩子为空,也要象征性地访问一下,一来是程序逻辑,二来是为了你好写顺序。


思路很简单,就是用队列。
访问当前一层的时候,把下一层可以加的孩子都按序加进来。
直到最后队列为空


前/后序+中序的逻辑一样,都是先找到根节点
然后去中序里面切分,对应回去将前/后序序列分割
如此就完成了一段的切分,然后递归地去切分剩下两段,类似于快排的感觉。

层序的话,仍然是利用层序找根节点,然后用中序切割。
层序找根节点,是从前往后,以层为单位去找的,那么每层要考虑几个根节点呢?这就得看中序的切分情况了
中序切分后,该层所有根节点,只要有孩子就+1,最终数量就是下次要考虑的根节点数目比如下图:

又以下图为例:

这一部分是难点,需要深入理解先中后序的逻辑,灵活利用展开的方法去分析,并且还要熟悉在树上遍历的过程。

首先这里区分一下,这里说的前驱和后继,都是特指在中序遍历序列中的前驱后继,而不是二叉树上的前驱后继
一般情况下,我们遍历一颗二叉树,一定要从根节点开始,否则后面会有一些节点漏掉。比如下图,要想找到p的(中序)前驱和(中序)后继,要用q和pre指针进行一前一后的移动,从根节点开始把整个树遍历一次。

假如有一种情况,需要反复利用中序遍历序列,那么每次都从根节点获取中序遍历序列就很麻烦,于是有人直接利用线索二叉树,把中序遍历序列的信息储存到空链域里,这就是线索化的本质。姑且不论线索如何生效,你只知道利用线索就可以从中序遍历序列中的一个节点开始遍历出后面完整的中序遍历序列。
在上图中,完全可以从B开始,就可以依次遍历BEAFC,不用担心丢了某个节点。
按这个思路,按照线索可以直接遍历中序,加速遍历,甚至线索里本身就存着p在中序序列的前驱后继,你直接输出就行。
这就是线索化的意义,下图为线索化的过程,先遍历出一个中序序列,然后把中序序列前驱后继的信息填充到线索里,左对应前驱,右是后继,有n+1个空链域,因此有n+1个线索。为了区分链域储存的到底是线索还是孩子,需要用两个tag区分。
此处疑惑的点是,有的节点有后继但是没有链域存放后继,这个后面有解决办法。你姑且知道,有线索,就可以从中提取中序的前驱后继

下图为标准的储存结构图像,先序和后序的思路同中序。


二叉树线索化,本质就是把这个节点的左孩子链接到中序前驱,右孩子连到中序后继。
因此当务之急是在中序遍历的过程中,维护前驱和后继信息,其实在最开始已经说了,就是用一个pre指针(全局变量),pre是q的前驱,而q是pre的后继,此乃相对性
已经有信息了,那么在visit过程中就直接修改链域就好
总的来说加判断可以涵盖一切情况,因此就统一加判断,只有前序和中序情况下,才可以不判断。下图为具体的代码,左上角为主函数:

先序线索化基本照搬,就是在先序遍历的过程中,去添加线索。
但是这里有一个bug,对于没有左孩子的节点(比如D),按道理说,添加完线索以后就没他什么事情了,而且访问过以后,也没有机会再访问第二次了。
但是在先序线索化过程中,会先visit,添加前驱线索,之后左孩子的链域就非空了,此时如果还按照原有逻辑,就又会跳回到前驱,开始进行遍历,这就是一个死循环。
破解办法很简单,本来左孩子的链域应该是空的,此时有了指针,可以用tag来判断原来是否为空,只有tag=0(原来非空,真正的有左子树),这才能跳转到左孩子的节点。

总评:这一章是线索二叉树的核心难点,尤其是三叉链表情况下的别扭题。
我的建议是,一切的一切,要抓准先序中序后续的本质特性,比如先序就是“根左右”,你到时候现场推导判断条件,灵活且不容易出错。

之所以用中序为例子,是因为中序线索化是完美的,“左根右”,有根节点,找前驱就去左孩子里面找,找后继就去右孩子里找,而先序只能找后继,后序只能找前驱。
先说后继,判断rtag,两种情况:
既然能找到中序后继,一次快速的中序遍历就很简单了:
这种方法的特色在于,找后继过程中,可以通过线索大大加速遍历速度,复杂度为O(1)

中序线索的前驱思路和后继一样,整体是镜像的感觉,左变右,有前驱线索就用线索,没有就找左子树右下角节点。
LastNode函数,就是一直去找右孩子,直到rtag=1
因此也衍生出逆向遍历中序线索的方式。

中序最为规整,符合直观,先序后序就比较别扭,容易混乱,本质上是先序会浪费链域,比如本来就有左孩子,然后你右链域如果为空,还要再指向左孩子,这就是浪费,这也就是其前驱后继只能二选一的原因,浪费了信息,自然就无法实现全部的功能。

回归正题,先序找后继,后序找前驱,反之则不一定,容易记混,而且实际生产也没用,要我说还不如深入理解,临场画图,更加保险
先序找后继,本质思路就是“根左右”:

前驱不一定有,逻辑如下:
但是很明显,这里可以加考点,如果用三叉链表,允许找到父节点,那么事情会有转机,当然也特别麻烦(为考而考),一切的关键是理解先序遍历的根左右思想
根左右)右”,则父(根)节点为前驱根左右)”,那么父节点当前驱根左右)”,那么就要在父节点左子树里,走一遍先序遍历,用其最后一个节点当前驱。这一步要求你对先序遍历极其熟悉(可以简化,有右找右,没右找左,右左都没,就是最后一个)说实话真的有点大冰,建议直接画图思考,把这几种情况都画出来,这么多背不会的。

后序线索,只能找前驱,如果要找后继,同样麻烦。
后序前驱:
后续后继,因为是“左右根”,无法通过左右子树找根的后继,同样需要三叉链表配合:
根)()根”,p的后继是父节点根)(左右根)根”,p的后继是父节点右子树后序遍历序列中第一个输出的节点(简化逻辑就是,有左就左,没左找右,没左没右,就是后继)再次吐槽,真的是有大冰,建议推导两三次,就不用记了,直接临场速推。


树的难点在于,因为度是没限制的,所以链域给几个是不确定的,树的所有结构都是为了解决这个核心问题而创造的。
最开始双亲表示法的思路:
因为结构比较简陋,因此一般用数组存,也只能用数组存,因为找孩子节点只能用遍历实现(查)
增删如何实现呢?增,在最后增加元素就行,删就是直接把链域抹-1就行,但是更好的方法是把最后一个元素挪到这个位置覆盖,因为这样可以缩减数组长度,提高遍历速度。

双亲表示是纯数组表示,而孩子表示法是数组+链表的混合存储结构(其实就是图的邻接表结构用在了树上)
延续链域不确定的思路,解决不确定的数量自然会想到链表,因此用一串链表表示一个节点的所有孩子:
这个结构其实是图的结构,优劣势这里就不讲了,简单来说就是找孩子简单找双亲难。

个人认为,孩子表示法可以和双亲结合,只需要加一列双亲指针就好,这是树比较完美的储存办法

最后就是孩子兄弟表示法,纯链式存储。
纯链式存储怎么能解决链域不定的问题呢?换个思路,链域里不储存所有孩子的指针,而是储存一个孩子+右兄弟,这样链域就固定为两个了,可以用二叉树储存了。
从视图来看,新的二叉树就是就是原有的树顺时针旋转45°,然后用孩子兄弟法重新连线后的结果。
逆过来转换就是左转,然后重新连线。
为了防止出错,可以用两种颜色的笔区分孩子链域和兄弟链域,又或者你直接记住左分叉是孩子链域,右分叉是兄弟链域就好。

森林和二叉树,只需要补兄弟边就好,每一颗树的根,合起来在同一层,互为兄弟。
之后就是经典的旋转转化了,还原的时候,几何上应该从右边开始画线,一层一层往左边推。

对应关系,森林->树->二叉树,二叉树是宇宙万法的尽头,硬要你写代码的话,一切代码都可以用二叉树实现。

树只有先序和后序,不存在中序的说法,因为无法确定孩子数量,所以干脆就不往中间排,要么先访问再依次递归孩子,要么先递归完孩子再访问。
数的初始逻辑结构和其二叉树形态有所不同,因此遍历序列也不相同,对应关系:
先序中序


森林遍历涉及二层递归,这里先简单理解下。先序还好,主要是中序,为什么树没有中序,森林这里就是中序了呢?因为可以以一个节点,切分出两片森林,这才可以把根节点放在中间,即中序。理论上森林还有后序,但是过于阴间,就没有放出来。
写代码的话,比较复杂,直接换个思路,用效果等价:
实在考出来写代码,也可以取巧,先把森林转化为二叉树储存结构,然后把森林的遍历等效到树的遍历,再对标到二叉树的遍历,比如森林中=树后=二叉树中,这样也可以写代码。


首先说带权路径长度(WPL,Weighted Path Length)
一条路径的WPL=经过的边数×终点权值
一棵树的WPL=所有叶节点的WPL

哈夫曼树就是WPL最小的二叉树,给定一些带权叶节点,这里首先定义一下树的总权值=叶节点权值和,构造一颗哈夫曼树过程如下:
哈夫曼树的特点如下:

哈夫曼树的应用是可变长度编码
权值可以理解为使用频率,或者重要程度。重要的,就应该放在上面,以更少的消耗编码:
如下图,C最重要,因此只用1个bit,ABD长度依次增加。
这种不定长编码的问题在于,可能会有歧义,因为无法定长分节解析,但是恰巧哈弗曼编码无歧义,为前缀编码,因此可以使用异步方式解析。如何解码呢?其实就是把这一串序列送到哈夫曼树里面,走到叶节点就解析一个字符,然后下一个序列继续从根开始。这个过程其实就是一个FDA,和编译原理有共同之处
如此,从概率来看最后编码和解码的总消耗都是最少的(实际上不见得最少)



并查集就是集合,集合之间互不相交,我们此处要用数据结构描述这种互不相交的关系。
基于这个关系,还应该实现两个基本功能:
这两种方法,用双亲表示法都是最有效率的,实现也很容易。
其实也可以用链表,我也做过链表的题,但是因为链表找到头比较慢,要O(n),而树结构分叉,高度大概只有O(logN),查一次要的迭代次数更少,因此时间效率上,双亲表示法完胜
现在唯一的技术难点其实就是如何用一个数组储存一个森林呢?本质就在于要同时维持多个根节点,但是这个问题其实不是问题,因为不同的根节点可以通过下标来区分,反正我们只有并查操作,只需要找双亲,所以这样储存完全没问题,就这么简单粗暴。
说白了,把操作限制在并查,就可以针对性的使用高效率的结构实现,操作越复杂,耗时越多,还需要用更精巧复杂的结构来降低操作的损耗,这是不变的道理



优化效率,就要分析复杂度。
并集为O(1),改下指针就行,查需要逆向迭代,如果所有元素串成一条线,那么最坏就是O(n),反之,如果平铺开来,尽可能降低高度,就可以提高效率。
注意,双亲法是树,不限制孩子个数,所以每次并集都是直接并到根节点上,现在我们要优化这个过程:
仔细分析Union过程,在把一颗高度为n的树插到另一颗树上时,这颗子树算上根节点,总高度其实是变成了n+1。
假如有三个节点ABC,把C插到B上,再把B插到A上,总高度就是3
换个思路,如果把C插到B,然后把A插到B,总高度只有2,在第二次插入过程中,总高度并没有变化。
具体逻辑如下:
但是呢,我们对高度其实并没有一个衡量,所以只能用挂载节点的数量(元素数量)来近似高度,既然无法判断高矮,判断大小也是可以的,具体操作如下:

但是你以为这就完了吗?如果你从离散的叶节点开始合并,你会发现最后的长度无论如何都不会超过这个数,即时间复杂度最好O(1),最坏都是O(logN)

前面说的是在并集过程中对于高度的优化
现在进一步,在查集的过程中进一步优化,属实是脑洞大开,令人拍案:
如此,每次查询,都可以进行一次压缩,越查越快,可以对抗并集导致的高度增加,配合并集优化,可以使得一般情况下高度维持在4以内,牛逼。


图是离散数学里的东西,所以一开始会有很多形式逻辑,比较琐碎:
(A,B)=(B,A)!=,走路径的时候不可逆向
任意两个顶点等价类分量

这一部分和离散数学关系比较大。
邻接矩阵是图储存的最简单的方法:

关于入度出度:
带权图,只需要把权值放到边矩阵中即可,0和inf都可以代表不连接。

虽然邻接矩阵比较原始,但是仍然有好的性质,比如计算闭包
A
n
A^n
An,这个矩阵中的A[i,j]元素,代表从i到j,长度为n的路径数目。
大白话说就是,原始的矩阵是直达,二次方就是中转一次,n次方就是中转n-1次。
引申一下,如果是一个连通图, ∑ A n \sum A^n ∑An中肯定没有0,这代表任何两个元素都是有路的,而数值就是路径的数量(不论长短)


储存结构:
边信息并不直接储存在数据结构中,而是以链表的形式散布在内存中,链表的头指针就在节点数组中。

关于度:
关于唯一性:
链表顺序不定,因此表示不唯一,后面凡是加了链表进来的,都不唯一

这个和树的孩子表示法其实一样,或者说孩子表示法其实是用图的方法去降维打击树了
但是邻接表,其实真就只适合树,对于图来说,有大缺陷:
无论储存有向图还是无向图,都有大缺陷,而树,恰恰只有出度是不确定的,压根不用在意是否丢失入度信息
即使考虑入度,也只有一个,完全可以再加一排双亲信息就好

关于十字链表,其实我们之前已经用过了,这张图非常直观,十字链表里储存了完整的行列信息。
邻接矩阵是矩阵,因此也可以压缩,完全可以套用下面这张图。

当然,具体设计是有一些改动的:
首先要把行列和图对上:
然后看数据结构:
需要注意的是:

对于无向图,使用邻接多重表
可以理解为精简化的十字链表,只看连接,不看出入,甚至左右顺序都可以是反的,(2,4)和(4,2)有一个就行,也就是说,我们前面十字链表可以按照行列遍历,但是这里不能蛮干,需要多加一个判断,判断这个节点是在左边还是右边,进而选择对应链域遍历。
举个左右切换的例子
下图中,E(4)节点为例,找到其所有边:
4在左边,因此用橙色指针找下一个,为(2,4)节点4在右边,因此用绿色指针找下一个,图中没有
总之,邻接多重表遍历是要看左右的,而十字链表就直接行列遍历就行
邻接多重表额外加的判断就是消除冗余信息(同时消除左右信息)后必然要付出的代价
当然,删除了左右信息,也是去掉了限制,在增删的时候,也会有意外的效果。


实际考察,考察邻接矩阵和邻接表的情况较多,因此以这两个数据结构举例。


思路:
需要注意细节:

考虑到非连通图,需要反复检查visited数组中是否有false,对于每个连通分量,都会调用一次BFS

复杂度:
广度优先遍历是逐层且无回路的,因此可以按照层序原则组织成一个生成树,对于多个连通分量,就是生成森林。
对于邻接表,因为遍历序列不唯一,所以生成森林结构也不唯一,邻接矩阵则唯一


深度优先遍历表面上是逐层圈的逻辑,但是因为没有队列作为记忆辅助,所以是采用递归的方法辅助的,因此就会产生尾递归的效果,实际路径和先序遍历一样。
其实DFS和BFS代码逻辑略有差距
BFS是在遍历循环的时候访问,根节点在出队前已经被访问过
DFS是递归的,在函数之初访问,遍历循环的时候仅仅是递归调用
对于非连通图情况,写法和BFS一致

复杂度:
分析递归序列比较麻烦,可以自己维持一个递归调用栈防止出错

核心思想:
n个节点,选择n-1次就可以把所有节点纳入整体,也就是最小生成树

有效连通次数也是n-1
无效:指两端已经联通,再连通就成环了

Prim算法针对顶点,扫描n-1个顶点,每个顶点扫描两轮,Kruskal算法针对边,与并查集有关,判断E轮,每轮判断logE次,因此复杂度如下
(具体分析略,有空补)



目标:求得一个源头到所有节点的路径长度:
路径两个要素:
用两个数组来储存,具体思路如下:
效果就是,从源头开始,BFS构建一个生成树,每一层的路径长度都要+1
而且可以从终点逆向找到全路径,比如8,倒着可以找到762

考虑权值后,转站次数并不能完全代表成本,有可能有一条路转了很多次,但是却最快,因此BFS算法失效。
Dijkstrea算法计算的是带权路径长度,和Prim算法过程很类似
首先明白数组的意义:

思路:在n-1轮循环中,每次循环都确定一条最短路径
注意,带负权值的图不可以用Dijkstera,因为判断最小值那一步失效。
Floyd算法是一个典型的动态规划问题,新状态基于原有的状态求得,经过n-1轮迭代后获得最终状态。
看下图,先说数据结构意义:
下图为k=-1的时候,即初始直达状态
之后进行n论迭代,每次都加入一个新的节点,遍历A矩阵的所有格子,比较增加这个节点中转后是否路径可以更短,如果可以,就把这个最新的前驱写入path矩阵里

问题来了,如何根据结果进行分析呢?
最终排列出来的节点列表,相邻两两之间是直达的
算法复杂度,n个节点,每个节点 n 2 n^2 n2次,总共三次方
最后,Floyd可以解决负权问题,但是不能解决负权回路问题。
我们用二叉树描述中缀式,但是二叉树里面往往有公用的部分:
这些都可以合并,只需要让指针同时指向这个部分即可,因为是DAG,所以并不会产生歧义
合并的技巧:



拓扑排序思路如上,这里探究一下具体代码:
时间复杂度=每个节点+每个边,因此邻接表是V+E,而邻接矩阵是
V
2
V^2
V2

逆拓扑排序考虑入度,逻辑一致,时间复杂度有所不同。
邻接表计算入边的成本是遍历全表,太高,因此思路有三:
重点说DFS,在没有环路的前提下,把print插入到递归最后,就会变成尾递归,以下图为例:
最终就是逆拓扑排序,当然,有环路的情况下要加一个visited数组,如果重复,那么就报错。


这里区分AOV和AOE网:
可以看到,AOE网,不仅仅受到顺序的制约,还要受到时间的制约,先来者要等后来者。
当一个里程碑所需的条件全部达成,当前里程碑后续的任务则开始计时
AOE网错综复杂,如何寻找到决定性的考虑因素呢?其实就是找最慢的那一条路,其他的路一定都要等这条路,这条路就是关键路径,上面的都是关键活动
关键路径总成本就代表整个工程的耗时


然后来具体说一下如何计算:

最后的最后,讨论一下特性: