树
树
树是由一个个节点组成的,最上层的节点叫做根节点,节点可以衍生出节点,相互之间是父子关系,同样,属于同一个父节点的叫兄弟节点,如果一个节点没有子节点,那么这个节点叫叶子节点或者叶节点,注意,树有一个很明显的特征:任意三个及以上的节点都不能形成闭合,即同一层的节点不连通,兄弟节点之间不能共享节点
树还有以下几个概念
注意,深度和高度都从0开始数起
有一个小窍门,就是类比“高度”“深度”“层”这几个名词在生活中的含义。在我们的生活中,“高度”这个概念,其实就是从下往上度量,比如我们要度量第 10 层楼的高度、第 13 层楼的高度,起点都是地面。所以,树这种数据结构的高度也是一样,从最底层开始计数,并且计数的起点是 0。
“深度”这个概念在生活中是从上往下度量的,比如水中鱼的深度,是从水平面开始度量的。所以,树这种数据结构的深度也是类似的,从根结点开始度量,并且计数起点也是 0。
“层数”跟深度的计算类似,不过,计数起点是 1,也就是说根节点位于第 1 层。
二叉树(Binary Tree)
二叉树,每个节点最多只能有两个子节点。
编号2这种全部排满的,叫做满二叉树,编号3这种除了最后一层,其它层都排满了,并且最后一层的叶子节点都连续排列(即叶子节点之间没有空位)的叫做完全二叉树。
怎样存储一棵二叉树
1.链表法,大部分情况都会使用这种方法存储二叉树
2.数组法
为什么完全二叉树最后一层的叶子节点不能中断呢?因为完全二叉树这个概念是为了省内存提出的,即数组只浪费掉第一个元素的空间,其他全部占满,所以最后一层的叶子节点们必须是排满的,这样在数组中就会表现成连续填满,数组只会浪费第一个元素的空间。
数组法中,如果某个结点的编号是x,那么其左节点的编号是 2 * x,右节点是2 * x + 1,父节点的编号是x / 2(如果是右节点,则去小数取整)。
一般为了方便,会把根节点放在下标为1的空间,所以会浪费下标为0的空间。
而如果数组来存储非完全二叉树,那么势必会造成部分空间空档,会浪费一些内存
二叉树的遍历
经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。
前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。并且我们可以由图看出,每个节点都只会被最多访问两次,所以只有2n,不存在n的高阶,即时间复杂度是O(n)。
二叉查找树(Binary Search Tree)
二叉查找树,任意一个节点a的左子树的每个节点的值都要小于节点a,右子树的每个节点的值都要大于节点a,并且不允许有重复的值。
二叉查找树支持查找,插入,删除操作
①二叉查找树的查找是这样的:我们把当前节点取出来和要查找的节点比较,如果不等于,那么要是查找的节点比当前节点小,就进入到当前节点的左子节点,否则进入右子节点,就这样一直找下去,直到找到或者当前节点为空时停止,返回找到的节点或者null。
②二叉查找树的插入操作
二叉查找插入是这样的:当当前节点比要插入的元素大,并且当前节点的左子节点为空,就插入,如果不为空,就继续按这个规则找下去,直到找到有空的,同理,如果当前节点比要插入的元素小,并且当前节点右子节点为空,就插入,否则继续这样找。
③二叉查找树的删除操作
由于一个节点的子节点情况有三种,所以我们要分情况讨论
第一种情况,就是如果要删除的节点没有子节点,那直接父节点指向被删除节点的指针置为空即可。
第二种情况,如果要删除的节点有一个子节点,那么由于左子树全部小于被删除的节点,右子树全部大于被删除的节点,所以父节点必定大于被删除节点的左子树的所有节点,同理,小于被删除节点的右子树的所有节点,那么我们只要将父节点的指针跳过被删除的节点,去指向被删除的节点的子节点即可。
第三种情况,如果要删除的节点有两个节点,会比较麻烦,我们先看被删除的节点当前是怎样的状态,该节点的父节点必定大于或者小于被删除的节点,以及被删除节点的所有子树,而该节点的左右子树,一个是所有节点小于被删除的节点,一个是所有节点大于被删除的节点。而我们如果要删除了目标节点,那么肯定还要保持树的规则不变,而且不能破坏结构,所以我们可以想出这样一种删除节点的方法:
首先,我们需要在删除节点后,用另一个合适的节点来填补这个空缺,保持树的结构不被破坏,然后这个前来填补的节点要满足以下条件:
比被删除的节点的左子树的所有节点大,比被删除的节点的右子树的所有节点小。
于是我们按上述思路就找到了合适的节点,即被删除的节点的右子树中的最小节点,将它替换到被删除节点的位置。
实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。
二叉查找树除了以上的三个操作,还有其他的操作,可以支持快速地查找最大节点和最小节点、前驱节点和后继节点,而且还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。因此,二叉查找树也叫作二叉排序树。
支持重复数据的二叉查找树
很多时候,在实际的软件开发中,我们在二叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值(key)来构建二叉查找树。我们把对象中的其他字段叫作卫星数据。前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理呢?
我们让二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
平衡二叉查找树
严格来说,平衡二叉树中,每个节点的左右子树,高度相差不超过1。完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。
严格来说,平衡二叉查找树则是满足了二叉查找树特点的平衡二叉树。但是很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于 1),比如我们下面要讲的红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。
发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。所以,平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。
所以,如果我们现在设计一个新的平衡二叉查找树,只要树的高度不比 log2n 大很多(比如树的高度仍然是对数量级的),尽管它不符合我们前面讲的严格的平衡二叉查找树的定义,但我们仍然可以说,这是一个合格的平衡二叉查找树。
红黑树
平衡二叉查找树中最常用到的就是红黑树。一颗红黑树只有红黑两种节点,并且需要满足以下条件:
1.根节点是黑节点。
2.每个叶子节点都是黑色的空节点(NIL),也就是叶子节点不存储数据。
3.任意相邻节点不能同时为红色,也就是红色节点之间是被黑色节点隔开的。
4.每个节点,从该节点到达其可以到达的叶子节点的所有路径,都包含相同数目的黑色节点。
红黑树是近似平衡的,平衡二叉查找树的初衷是为了解决二叉查找树在动态更新时因为变得不平衡而导致的性能退化问题,所以,平衡可以理解为性能不退化,近似平衡可以理解为使性能退化得不太严重。
递归树
对于一些求复杂度较难的算法,比如归并和快排这些,可以借助递归树来更简便地分析递归算法的时间复杂度。
二叉查找树对比散列表的优势
第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。
第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
Trie树
Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
trie树,本质就是把字符串按字符分类,即把字符串的公共前缀提取出来。我们用搜索引擎时,当打出一些字后,会出现一堆相关的字符串,这就是利用trie树完成的
假设我们有how,hi,her,hello,so,see这六个字符串,那么trie树的建立过程如下
trie树根节点不存储数据,树中每条从根节点到达红色节点的路径完全匹配某个字符串。
如果我们要查找某个字符串,那就先把字符串分割成一个个字符,从第一个字符开始匹配,比如her,那就是先从h开始,然后找到e,再找到r,如果只是he,那么就是先h,然后找到e。
从图中我们可以看出,trie树不是只能查找完全匹配的字符串,他还可以只查找部分匹配的字符串,这意味着什么,我们知道BF算法和RK算法的话如果要详细搜索某一字符串,要完完整整地给它打出来,然后才能作为模板,可是trie树就只需要你打出部分字符串就有机会猜到你想找的字符串,正是这个特性使得它能在搜索引擎中大放异彩。
如何实现一棵Trie树?
要想实现某个数据结构,肯定还是先分析出它要实现什么操作以及怎么样存储。
Trie树的操作其实就是两个:把字符串加到树中,另一个是匹配字符串。
Trie树是多叉树,它的存储我们可以参考二叉树,二叉树是由左右两个指针来连接父节点和子节点,那么多叉树可以仿照这个思路,把子节点的指针放到一个数组。
这个数组是有讲究的,放得好才能方便匹配,假设我们现在字符串集里只有a~z26个英文字母,那么我们先把根节点指向其子节点组成的数组,这个数组0下标代表a,1下标代表b,这样递推下去,然后每个元素(即每个节点)都储存自己的子节点数组,然后由这个数组开始,如果我们可以通过子节点与父节点之间的ASCII码差值来确定某个字符该放的位置,比如我们现在第一个字符是a,第二个字符是d,它们的ASCII码差值是3,那么我们可以把d放在数组下标为3的内存,这样一下就能知道模式串的a是否有与主串的a的下一个字符所匹配的子节点(字符)。
这样的话Trie树在加入字符串的时候要遍历所有要加入字符串,时间复杂度是O(n),但在查询时,设查询的字符串的长度为k那么就是O(k * 1),,也就是O(k),除非你想查询的字符串长度非常非常大,不然就相当于O(1)的时间复杂度,会有很高的效率。
Trie树的内存消耗
我们可以看到,当重复前缀多的时候,Trie树省内存,但是重复前缀不多的时候,那么Trie树就很耗费内存了。
我们可以把每个节点指向的子节点数组不预先设置好,而且每有一个新字符串加入,才加到子节点数组,那么子节点数组保存的都是实际存在的数据了,查询时通过二分查找可以很快,但是插入时就会慢一些。
我们还可以用跳表,红黑树,散列表等等来代替普通数组去保存子节点。
Trie适用场景
在刚刚讲的这个场景,在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有极其严苛的要求。
第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
第三,如果要用 Trie 树解决问题,那我们就要自己从零开始实现一个 Trie 树,还要保证没有 bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
第四,我们知道,通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。
综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。
但是这不代表Trie没用,实际上,Trie 树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie 树比较适合的是查找前缀匹配的字符串,比如搜索引擎中,你打出部分字符就能出现一堆可能是你想要查找的字符串的情况,还有自动输入补全,比如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构