思维导图+精炼的个人思考
数据结构的笔记相比于其他3门,笔记的重要性要低很多,毕竟对于选择408的同学来说,大二时候应该有足够的时间学习,所以基础是比较好的,再加上csdn上一大堆数据结构和算法的帖子,我再重复造轮子也没啥意思了。
所以我这篇文章不打算写的很细节,就是单纯地把思路提纯出来,并附上自己深入的理解,再搭配思维导图就行了,而不去记录过于细节的知识。
节点之间的关系:
术语:
具体来讲,有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以内,牛逼。