数据结构与算法
红黑树
红黑树是一种自平衡的二叉搜索树,它在插入和删除操作后能够通过旋转和重新着色来保持树的平衡。
红黑树的特点:
- 每个节点都有一个颜色,红色或黑色。
- 根节点是黑色的。
- 每个叶子节点(NIL节点)都是黑色的。
- 如果一个节点是红色的,则它的两个子节点都是黑色的。
- 从根节点到叶子节点或空子节点的每条路径上,黑色节点的数量是相同的。
红黑树通过这些特性来保持树的平衡,确保最长路径不超过最短路径的两倍,从而保证了在最坏情况下的搜索、插入和删除操作的时间复杂度都为O(logN)。
跳表
跳表是一种基于链表的数据结构,它通过添加多层索引来加速搜索操作。
跳表的特点:
- 跳表中的数据是有序的。
- 跳表中的每个节点都是包含一个指向下一层和右侧节点的指针。
跳表通过多层索引的方式来加速搜索操作。最底层是一个普通的有序链表,而上面的每一层都是前一层的子集,每个节点在上一层都有一个指针指向它在下一层的对应节点。这样,在搜索时可以通过跳过一些节点,直接进入目标区域,从而减少搜索的时间复杂度。
跳表的平均搜索、插入和删除操作的时间复杂度都为O(logN),与红黑树相比,跳表的实现更加简单,但空间复杂度稍高。跳表常用于需要高效搜索和插入操作的场景,如数据库、缓存等。
红黑树和AVL树相比查询性能好还是插入性能好一些?
1、查询性能的对比:
- AVL树: AVL树是严格的平衡二叉搜索树,它要求每个节点的左右子树的高度差(平衡因子)不超过1。这种严格的平衡特性使得AVL树的高度始终保持在O(log n),其中n是树中节点的数量。在进行查询操作时,由于树的高度相对较低且较为均匀,所以查找任意节点的时间复杂度稳定为O(log n)。这意味着在理想情况下,AVL树的查询效率非常高,能快速定位到目标节点。
- 红黑树: 红黑树是一种弱平衡的二叉搜索树,它通过颜色标记和特定的规则(如每个节点要么是红色,要么是黑色;根节点是黑色;每个叶子节点(NIL节点,空节点)是黑色;如果一个节点是红色的,则它的两个子节点都是黑色的;对每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数目的黑色节点)来维持大致的平衡。红黑树的高度通常比AVL树略高,其高度上限为2log(n + 1),因此查询操作的时间复杂度同样为(log n),但在实际应用中,由于树的高度相对较高,其查询性能可能会略逊于AVL树。
在查询性能上,AVL树由于其严格的平衡特性,表现会稍好于红黑树,但差距通常不大。
2、插入性能的对比:
- AVL树: 在插入新节点后,AVL树可能会破坏原有的平衡结构,需要通过旋转操作(单旋转或双旋转)来重新平衡树。由于AVL树对平衡的要求非常严格,插入操作后可能需要进行多次旋转来恢复平衡,特别是在树的高度较高时,插入操作可能会引发较多的旋转操作,导致插入性能受到一定影响。插入操作的平均时间复杂度虽然也是O(log n),但由于旋转操作的开销,实际插入效率相对较低。
- 红黑树: 红黑树在插入新节点后,同样可能会破坏树的平衡,但它只需要进行少量的颜色调整和最多两次旋转操作就能恢复平衡。红黑树的平衡规则相对宽松,使得在插入操作时不需要像AVL树那样频繁地进行旋转操作,因此插入性能相对较好。插入操作的平均时间复杂度同样为O(log n),但由于减少了旋转操作的次数,实际插入效率更高。
在插入性能上,红黑树由于其弱平衡特性,表现优于AVL树。
在实际应用中,如果查询操作频繁,对查询性能要求较高,且插入和删除操作相对较少,可以选择AVL树;如果插入和删除操作较为频繁,对插入性能有较高要求,同时查询性能也能接受一定的损耗,则红黑树是更好的选择。例如,Java中的TreeMap和TreeSet底层使用的就是红黑树,以兼顾插入、删除和查询操作的性能。
B+树的特点是什么?
- B+树是一种自平衡的多路查找树,所有叶节点都位于同一层,保证了树的平衡,使得搜索、插入和删除操作的时间复杂度为对数级别的。
- 非叶节点仅包含索引信息,不存储具体的数据记录,它们只用来引导搜索到正确的叶节点。非叶节点的子树指针与关键字数量相同,每个子树指针指向一个子树,子树中的所有键值都在某个区间内。
- 所有数据记录都存储在叶节点中,且叶节点中的数据是按关键字排序的。叶节点包含实际的数据和关键字,它们是数据存储和检索的实体单元。叶节点之间通过指针相互链接,形成一个链表,便于范围查询和顺序遍历。
B+树和B树的区别
对比项 | B树 | B + 树 |
---|---|---|
检索路径 | 查找数据时,可能在非叶子节点找到目标数据,路径长度不固定,查找可在任意节点终止 | 所有数据在叶子节点,查找必须走到叶子节点,路径长度固定(均等),查找总要到叶子节点结束 |
叶子节点结构 | 叶子节点之间无特别链接,彼此独立 | 叶子节点通过指针连接,形成有序链表,便于范围查询和顺序访问 |
非叶子节点内容 | 非叶子节点存储数据和索引 | 非叶子节点只存储索引,不存储实际数据,数据量较大时,层高更少,查找效率更高 |
高效地范围查询 | 进行范围查询时需中序遍历,性能较差 | 叶子节点采用双链表连接,适合基于范围的顺序查找 |
红黑树,b树,b+树有什么区别?
- 查询效率: 红黑树单点查询稳定在 O(log n),但树深度较高(如1亿数据需约30次查找)。B/B+树: 树高更低(相同数据量下树高度可能仅为红黑树的1/3),显著减少磁盘I/O次数。
- 范围查询: B+树的叶子节点通过指针形成链表,范围遍历效率极高(如查询 [A, Z] 只需找到起点后顺序遍历)。B树/红黑树: 范围查询需回溯父节点或频繁调整指针,效率较低。
- 插入/删除维护成本: 红黑树需频繁旋转和变色,维护规则复杂。B/B+树: 通过批量调整(分裂/合并节点)减少频繁操作,更适合海量数据。
- 场景选择: 如果数据在内存中 + 高频增删,选择红黑树更合适,如果数据在磁盘 + 随机/范围查询均需,选择B+树更合适。
堆是什么?
堆是一棵完全二叉树,这样实现的堆也被称为二叉堆。堆中节点的值都大于等于(或小于等于)其子节点的值,堆中如果节点的值都大于等于其子节点的值,称为大顶堆,如果都小于等于其子节点的值,称为小顶堆。
前缀树是什么?有什么应用?
前缀树(Trie Tree),也称为字典树、单词查找树或键树,是一种树形数据结构。它的核心思想是利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,从而提高查询效率。
前缀树的特点是:
- 根节点不包含字符:除根节点外的每一个节点都包含一个字符。
- 从根节点到某一节点:路径上经过的字符连接起来,即为该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
前缀树的应用场景:
- 字符串检索:比如在搜索引擎的搜索框中,当用户输入部分关键词时,搜索引擎可以利用前缀树快速找到以该关键词为前缀的所有相关词条,实现自动补全功能。又比如将所有正确的单词存储在前缀树中,当用户输入一个单词时,通过在前缀树中查找该单词是否存在,来判断其拼写是否正确。
- 路由表匹配: 在网络路由中,路由器需要根据 IP 地址的前缀来选择合适的路由。前缀树可以高效地实现 IP 地址的最长前缀匹配,从而快速确定数据包的转发路径。
- 词频统计:可以将文本中的所有单词插入到前缀树中,同时在节点中记录每个单词的出现次数。通过遍历前缀树,可以统计出文本中每个单词的词频。
LRU是什么,如何实现?
LRU是一种缓存淘汰算法,当缓存空间已满时,优先淘汰最长时间未被访问的数据。
- 使用哈希表存储数据的键值对,键为缓存的键,值为对应的节点。
- 使用双向链表存储数据节点,链表头部为最近访问的节点,链表尾部为最久未访问的节点。
- 当数据被访问时,如果数据存在于缓存中,则将对应节点移动到链表头部;如果数据不存在于缓存中,则将数据添加到缓存中,同时创建一个新节点并插入到链表头部。
- 当缓存空间已满时,需要淘汰最久未访问的节点,即链表尾部的节点。
上面这种思想方式,LRU 算法可以在 O(1) 的时间复杂度内实现数据的插入、查找和删除操作。每次访问数据时,都会将对应的节点移动到链表头部,保证链表头部的节点是最近访问的数据,而链表尾部的节点是最久未访问的数据。当缓存空间不足时,淘汰链表尾部的节点即可。
布隆过滤器怎么设计?
布隆过滤器可以用来解决类似的问题,具有运行快速,内存占用小的特点,它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。而高效插入和查询的代价就是,它是一个基于概率的数据结构,只能告诉我们一个元素绝对不在集合内,对于存在集合内的元素有一定的误判率。
布隆过滤器中总是会存在误判率,因为哈希碰撞是不可能百分百避免的。布隆过滤器对这种误判率称之为假阳性概率,即:False Positive Probability,简称为 fpp。在实践中使用布隆过滤器时可以自己定义一个 fpp,然后就可以根据布隆过滤器的理论计算出需要多少个哈希函数和多大的位数组空间。需要注意的是这个 fpp 不能定义为 100%,因为无法百分保证不发生哈希碰撞。
其基本原理如下:
- 初始化: 当我们创建一个布隆过滤器时,我们首先创建一个全由0组成的位数组 (bit array)。同时,我们还需选择几个独立的哈希函数,每个函数都可以将集合中的元素映射到这个位数组的某个位置。
- 添加元素: 在布隆过滤器中添加一个元素时,我们会将此元素通过所有的哈希函数进行映射,得到在位数组中的几个位置,然后将这些位置标记为1。
- 查询元素: 如果我们要检查一个元素是否在集合中,我们同样使用这些哈希函数将元素映射到位数组中的几个位置,如果所有的位置都被标记为1,那么我们就可以说该元素可能在集合中。如果有任何一个位置不为1,那么该元素肯定不在集合中。
通过其原理可以知道,我们可以提高数组长度以及 hash 计算次数来降低误报率,但是相应的 CPU、内存的消耗也会相应地提高,会增加存储和计算的开销。因此,布隆过滤器的使用需要在误判率和性能之间进行权衡。布隆过滤器有以下两个特点:
- 只要返回数据不存在,则肯定不存在。
- 返回数据存在,不一定存在。
布隆过滤器的误判率主要来源于哈希碰撞。因为位数组的大小有限,不同的元素可能会被哈希到相同的位置,导致即使某个元素并未真正被加入过滤器,也可能因为其他已经存在的元素而让所有哈希函数映射的位都变为了1,从而误判为存在。这就是布隆过滤器的"假阳性"错误。在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 A、B 两个数据最后定位到的位置是一模一样的。这时拿 B 进行查询时那自然就是误报了。
布隆过滤器的时间复杂度和空间复杂度: 对于一个 m (比特位个数) 和 k (哈希函数个数) 值确定的布隆过滤器,添加和判断操作的时间复杂度都是 O(k),这意味着每次你想要插入一个元素或者查询一个元素是否在集合中,只需要使用 k 个哈希函数对该元素求值,然后将对应的比特位标记或者检查对应的比特位即可。