数据结构基础
一. 概述
1. 理解
1.1 数据结构与算法的关系
数据结构是一门研究组织数据方式的学科,有了编程语言也就有了数据结构。
程序 = 数据结构 + 算法
数据结构是算法的基础
1.2 线性结构和非线性结构
线性结构
-
作为最常用的数据结构,特点是数据元素之间存在一对一的线性关系。
-
包含两种不同的存储结构:顺序存储结构(如数组) 和 链式存储结构(如链表)。
-
顺序存储的线性表称为顺序表,顺序表中的存储元素是连续的。
-
链式存储的线性表称为链表,链表的存储元素不一定是连续的,元素节点中存放数据元素以及相邻元素的地址信息。
-
线性结构常见的有:数组,链表,栈,队列,哈希表(散列表)。
非线性结构
- 树形结构:二叉树,AVL树,红黑树,B树,堆,Trie,哈夫曼树,并查集...
- 图形结构:邻接矩阵,邻接表...
2. 代码测试工具
2.1 测试某段代码的运行时间
2.2 断言工具
2.3 Integer工具
二. 复杂度
1. 算法的效率问题
使用不同算法,解决同一个问题,效率可能相差非常大
1.1 求第n个斐波拉契数
-
斐波那契数列的排列是:0,1,1,2,3,5,8,13,21,34,55,89,144...
-
它后一个数等于前面两个数的和

1.2 度量算法优劣的方法
事后统计
这种方法可行但是有两个问题:
- 一是要想对设计的算法的运行性能进行评测,需要实际运行该程序。
- 二是所得时间的统计量依赖于计算机的硬件、软件等环境因素, 这种方式,要在同一台计算机的相同状态下运行,才能比较那个算法速度更快。
事前估计
通过分析某个算法的时间复杂度,空间复杂度来判断哪个算法更优。
2 时间复杂度
2.1 理解
一般情况下,算法中的基本操作语句的重复执行次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n) / f(n) 的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作 T(n)=O( f(n) ),称O( f(n) ) 为算法的渐进时间复杂度,简称时间复杂度
T(n) 不同,但时间复杂度可能相同。 如:T(n)=n²+7n+6 与 T(n)=3n²+2n+2 它们的T(n) 不同,但时间复杂度相同,都为O(n²)。
2.2 大O表示法
一般用大O表示法来描述复杂度,它表示的是数据规模 n 对应的复杂度。如上述O( f(n) )
忽略常数、系数、低阶
-
9 => O(1)
-
2n + 3 => O(n)
-
n^2 + 2n + 6 => O(n^2 )
-
4n^3 + 3n^2 + 22n + 100 => O(n^3 )
注意:大O表示法仅仅是一种粗略的分析模型,是一种估算,能帮助我们短时间内了解一个算法的执行效率
2.4 对数阶的细节
对数阶一般省略底数:log2(n) = log2(9) * log9(n)
所以 log2(n)、log9(n)统称为logn
2.5 计算时间复杂度的方法
-
用常数1代替运行时间中的所有加法常数 T(n)=2n²+7n+6 => T(n)=2n²+7n+1
-
修改后的运行次数函数中,只保留最高阶项 T(n)=2n²+7n+1 => T(n) = 2n²
-
去除最高阶项的系数 T(n) = 2n² => T(n) = n² => O(n²)
2.6 常见的时间复杂度
- 常数阶O(1)
- 对数阶O(logn) //注意:底数不一定是2
- 线性阶O(n)
- 线性对数阶O(nlogn)
- 平方阶O(n^2)
- 立方阶O(n^3)
- k次方阶O(n^k)
- 指数阶O(2^n)

说明:
① 常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(logn)<Ο(n)<Ο(nlogn) <Ο(n2)<Ο(n3)< Ο(n^k) <Ο(2^n) <Ο(n^n),随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低
② 从图中可见,我们应该尽可能避免使用指数阶的算法
③ 对数阶一般忽略底数,所以log2n,log9n统称logn
2.7 时间复杂度练习
2.8 平均时间复杂度和最坏时间复杂度
平均时间复杂度是指所有可能的输入实例均以等概率出现的情况下,该算法的运行时间。
最坏情况下的时间复杂度称最坏时间复杂度。一般讨论的时间复杂度均是最坏情况下的时间复杂度。 这样做的原因是:最坏情况下的时间复杂度是算法在任何输入实例上运行时间的界限,这就保证了算法的运行时间不会比最坏情况更长
平均时间复杂度和最坏时间复杂度是否一致,和算法本身有关
2.9 均摊复杂度
什么情况下使用均摊复杂度:经过连续的多次复杂度比较低的情况后,出现个别复杂度比较高的情况。
案例:动态数组的扩容
2.10 复杂度震荡
什么是复杂度震荡:在一些特殊的情况下,某个级别的复杂度猛地蹿到了另一个级别,并且持续这一级别不恢复,则说明产生了复杂度震荡。
案例:动态数组扩容倍数、缩容时机设计不得当(扩容倍数*缩容倍数=1),有可能会导致复杂度震荡。
3. 空间复杂度
类似于时间复杂度的讨论,一个算法的空间复杂度(Space Complexity)定义为该算法所耗费的存储间,它也是问题规模n的函数。
空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。有的算法需要占用的临时工作单元数与解决问题的规模n有关,它随着n的增大而增大,当n较大时,将占用较多的存储单元,例如快速排序和归并排序算法就属于这种情况.
在做算法分析时,主要讨论的是时间复杂度。从用户使用体验上看,更看重的程序执行的速度。一些缓存产品(redis, memcache)和算法(基数排序)本质就是用空间换时间。
4. 算法的优化方向
-
用尽量少的存储空间
-
用尽量少的执行步骤(执行时间)
-
根据情况,可以选择空间换时间,也可以时间换空间
三. 线性结构
1. 动态数组ArrayList
1.1 理解
数组是一种顺序存储的线性表,所有元素的内存地址是连续的。

在很多编程语言中,数组都有个致命的缺点:无法动态修改容量。实际开发中,我们更希望数组的容量是可以动态改变的。
1.1 属性设计

1.2 接口设计
注意与ArrayList源码对比分析
1.3 图解方法
添加元素-add(E element)

添加元素-add(int index,E element)

删除元素-remove(int index)

如何扩容

1.4 实现
2. 单向链表LinkedList
2.1 理解
动态数组有个明显的缺点:可能会造成内存空间的大量浪费。能否用到多少就申请多少内存?链表可以办到这一点。
链表存储结构的特点:
- 链表是一种链式存储的线性表,通过指针域描述数据元素之间的逻辑关系,不需要地址连续的存储空间。
- 动态存储空间分配,即时申请即时使用。
- 访问第i个元素,必须顺序依此访问前面的1 ~ i-1的数据元素,也就是说是一种顺序存取结构。
插入/删除操作不需要移动数据元素。
注意:
① Java中如何实现“指针”:Java中的对象引用变量并不是存储实际数据,而是存储该对象在内存中的存储地址。
② 链表分为带头节点的链表和没有头节点的链表,根据实际的需求来确定。
2.2 图解方法

2.3 实现
3. 双向链表LinkedList
3.1 单向链表缺点
单向链表,查找的方向只能是一个方向,而双向链表可以向前或者向后查找。
单向链表不能自我删除,需要靠辅助节点 ,而双向链表,则可以自我删除,所以前面我们单链表删除节点,总是要先找到待删除节点的前一个节点。
3.2 实现
3.3 ArrayList与LinkedList对比
ArrayList开辟,销毁内存空间的次数相对较少,但可能造成内存空间浪费(缩容解决)。LinkedList开辟、销毁内存空间的次数相对较多,但不会造成内存空间的浪费。
如果频繁在尾部进行添加,删除操作,动态数组与双向链表均可选择。
如果频繁在头部进行添加,删除操作,建议选择使用双向链表。
如果有频繁的(在任意位置)添加,删除操作,建议选择双向链表。
如果有频繁的查询操作(随机访问操作),建议选择动态数组。
是否有了双向链表,单向链表就没任何用处了? => 并非如此,在哈希表的设计中就用到了单链表。
4. 循环链表LinkedList
4.1 单向循环链表
注意:单向循环链表相对于单链表(SingleLinkedList)只需修改添加和删除。
4.2 双向循环链表
注意:双向循环链表相对于双向链表(LinkedList)只用修改添加和删除。

4.3 约瑟夫问题 (单向循环链表的应用)
约瑟夫问题:设编号为1,2,3...n的n个人围成一圈,约定编号为 k (1 <= k <= n)的人从1开始报数,数到m的那个人出列,它的下一位又从1开始报数,数到m的那个人又出列,依此类推,直到所有人出列为止,由此产生一个出列编号的序列。
注意:约瑟夫问题也可以用其它数据结构解决,不一定要用循环链表,但是循环链表解决此问题很简单。

使用循环链表解决约瑟夫问题
为了发挥循环链表的最大威力,可对CircleLinkedList做如下改进:

5. 栈(stack)
5.1 理解
栈是一个先入后出
(FILO => First In Last Out)的有序列表。往栈中添加元素的操作,一般叫做入栈(push)
。从栈中移除元素的操作,一般叫做 出栈(pop)
,注意只能移除栈顶元素,也叫做弹出栈顶元素。
栈是限制线性表中元素的插入和删除 只能在线性表的同一端
进行的一种特殊线性表。允许插入和删除的一端为变化的一端,称为 栈顶(Top)
,另一端为固定的一端,称为 栈底(Bottom)
。
出栈(pop)和入栈(push)的概念如下:

注意:这里说的“栈”与内存中的“栈空间”是两个不同的概念。
5.2 栈的应用场景
-
子程序的调用
:在跳往子程序前,会先将下一个指令的地址存到堆栈中,直到子程序执行完后再将地址取出,以回到原来的程序中。 -
处理递归调用
:和子程序的调用类似,只是除了存储下一个指令的地址外,也将参数,区域变量等数据存入堆栈中。 -
表达式的转换与求值(实际解决)
:如中缀表达式转后缀表达式 -
二叉树的遍历
-
图形的深度优先(depth-first)搜索法
5.3 ArrayList模拟栈
5.4 LinkedList模拟栈
5.5 栈的应用-综合计算器(自定义优先级)
即使用栈计算一个中缀表达式的结果

5.6 栈的应用-逆波兰计算器
逆波兰表达式(前缀表达式)
前缀表达式的运算符位于操作数之前。
前缀表达式的计算机求值:从右至左扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈顶的两个数,用运算符对它们做相应的计算(栈顶元素 和 次顶元素),并将结果入栈;重复上述过程直到表达式最左端,最后运算得出的值即为表达式的结果
中缀表达式
中缀表达式就是常见的运算表达式,如(3+4)×5-6
中缀表达式的求值是我们人最熟悉的,但是对计算机来说却不好操作(上述案例就能看的这个问题),因此,在计算结果时,往往会将中缀表达式转成其它表达式来操作(一般转成后缀表达式)
后缀表达式
与前缀表达式相似,只是运算符位于操作数之后

后缀表达式的计算机求值
从左至右
扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈顶的两个数,用运算符对它们做相应的计算(次顶元素 和 栈顶元素),并将结果入栈;重复上述过程直到表达式最右端,最后运算得出的值即为表达式的结果
中缀表达式转换为后缀表达式
后缀表达式适合计算式进行运算,但是人却不太容易写出来,尤其是表达式很长的情况下,因此在开发中,需要将中缀表达式转成后缀表达式。
具体步骤:


使用栈实现逆波兰计算器(计算整数)
6.队列(queue)
6.1 理解
-
队列是一个有序列表,可以用
数组
或链表
来实现。 -
遵循
先入先出
的原则。即:先存入队列的数据,要先取出。后存入的要后取出 -
队尾(rear)
:只能从队尾添加元素,一般叫做入队(enQueue)
。 -
队头(front)
:只能从队头移除元素,一般叫做出队(deQueue)
。

注意:队列优先使用双向链表实现,因为队列主要是往头尾操作元素。
6.2 LinkedList(双向链表)模拟队列
Java官方使用LinkedList实现了Queue接口
6.3 LinkedList模拟双端队列
deque => double ended queue
双端队列是能在头尾两端添加、删除的队列。

6.4 ArrayList模拟循环队列
其实队列底层也可以使用动态数组(ArrayList)实现,并且采用循环队列的方式各项接口也可以优化到 O(1) 的时间复杂度,这个用数组实现并且优化之后的队列也叫做:循环队列。
6.5 ArrayList模拟循环双端队列
四. 递归
1. 理解
递归就是 方法自己调用自己,每次调用时传入不同的变量
。递归有助于编程者解决复杂的问题,同时可以让代码变得简洁。

2. 应用场景
- 各种数学问题如: 8皇后问题 , 汉诺塔, 阶乘问题, 迷宫问题, 球和篮子的问题(google编程大赛)
- 各种算法中也会使用到递归,比如快排,归并排序,二分查找,分治算法等
- 用栈解决的问题 => 使用递归可以让代码更简洁
3. 递归需要遵守的重要规则
- 执行一个方法时,就创建一个
新的受保护的独立空间
(栈空间)。 - 方法的
局部变量是独立
的,不会相互影响, 比如上述n变量。 - 如果方法中使用的是引用类型变量(比如数组),就会
共享该引用类型的数据
。 - 递归
必须向退出递归的条件逼近
,否则就是无限递归,出现StackOverflowError栈溢出了。 - 当一个方法执行完毕,或者遇到return,就会返回,遵守
谁调用,就将结果返回给谁
,同时当方法执行完毕或者返回时,该方法也就执行完毕。
4. 递归的应用-迷宫问题

5. 递归的应用-八皇后问题(回溯算法)

思路分析
-
第一个皇后先放第一行第一列。
-
第二个皇后放在第二行第一列、然后判断是否OK, 如果不OK,继续放在第二列、第三列、依次把所有列都放完,找到一个合适的。
-
继续第三个皇后,还是第一列、第二列……直到第8个皇后也能放在一个不冲突的位置,算是找到了一个正确解。
-
当得到一个正确解时,在栈回退到上一个栈时就会开始回溯(一层一层的回溯)。即将第一个皇后放到第一列的所有正确解,全部得到。
-
然后回头继续第一个皇后放第二列,后面继续循环执行上面的步骤。
说明:理论上应该创建一个二维数组来表示棋盘,但是实际上可以通过算法,用一个一维数组即可解决问题. arr[8] = {0 , 4, 7, 5, 2, 6, 1, 3} //对应arr 下标 表示第几行,即第几个皇后,arr[i] = val , val 表示第i+1个皇后,放在第i+1行的第val+1列。
实现
五. 树形结构
1. 概述
基本概念
节点
、根节点
、父节点
、子节点
、兄弟节点
(有相同的父节点)- 一棵树可以没有任何节点,称为
空树
- 一棵树可以只有 1 个节点,也就是
只有根节点
子树
、左子树
、右子树
节点的度(degree)
:子树的个数树的度
:所有节点度中的最大值叶子节点(leaf)
:度为 0 的节点非叶子节点
:度不为 0 的节点层数(level)
:根节点在第 1 层,根节点的子节点在第 2 层,以此类推(有些说法也从第 0 层开始计算)节点的深度(depth)
:从根节点到当前节点的唯一路径上的节点总数节点的高度(height)
:从当前节点到最远叶子节点的路径上的节点总数树的深度
:所有节点深度中的最大值树的高度
:所有节点高度中的最大值树的深度
等于树的高度
树支路总数
=树节点总数
- 1 (树中每个节点头上都有一个支路,但唯独根节点没有)
有序树,无序树,森林
有序树
:树中任意节点的子节点之间有顺序关系无序树
:树中任意节点的子节点之间没有顺序关系,也称为“自由树”森林
:由 m(m ≥ 0)棵互不相交的树组成的集合
2. 二叉树
2.1 特点
- 每个节点的
度最大为 2
(最多拥有 2 棵子树) - 左子树和右子树是
有顺序的
- 即使某节点
只有一棵子树,也要区分左右子树
二叉树是度不大于2的有序树
。但是度不大于2的有序树不是二叉树(因为有序树的节点次序是相对于另一节点而言的,当有序树的子树中只有一个孩子时,这个孩子节点无需区分左右次序,而二叉树无论孩子树是否为2,均需要确定左右次序)。
2.2 性质
- 非空二叉树的第 i 层,最多有
2^( i − 1)
个节点( i ≥ 1 ) - 在高度为 h 的二叉树上最多有
2^h − 1
个结点( h ≥ 1 )
- 对于任何一棵非空二叉树,如果叶子节点个数为 n0,度为 2 的节点个数为 n2,则有: n0 = n2 + 1
2.3 真二叉树
理解:所有节点的度都要么为 0,要么为 2。

2.4 满二叉树
理解:最后一层节点的度都为 0,其他节点的度都为 2。
注意:
① 在同样高度的二叉树中,满二叉树的叶子节点数量最多、总节点数量最多。
② 满二叉树一定是真二叉树,真二叉树不一定是满二叉树。

2.5 完全二叉树
理解:对节点从上至下、左至右开始编号,其所有编号都能与相同高度的满二叉树中的编号对应。
注意:
① 叶子节点只会出现最后 2 层,最后 1 层的叶子结点都靠左对齐。
② 完全二叉树从根结点至倒数第 2 层是一棵满二叉树。
③ 满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。

性质:
面试题:如果一棵完全二叉树有 768 个节点,求叶子节点的个数
由以上题总结公式:
-
当总节点为偶数,n0 = n / 2。
-
当总节点数为奇数,n0 = (n + 1) / 2
-
=>
n0 = floor( (n + 1) / 2 ) = ceiling( n / 2)
注意:java除法默认向下取整
判断一棵树是否为完全二叉树:
- 思路一:

- 思路二:

2.6 二叉树的遍历
遍历是数据结构中的常见操作:把所有元素都访问一遍
线性数据结构的遍历比较简单
- 正序遍历
- 逆序遍历
根据节点访问顺序的不同,二叉树的常见遍历方式有4种
- 前序遍历(Preorder Traversal)
- 中序遍历(Inorder Traversal)
- 后序遍历(Postorder Traversal)
- 层序遍历(Level Order Traversal)
前序遍历(递归/迭代)
访问顺序 :根节点
、前序遍历左子树、前序遍历右子树
应用:树状结构展示(注意左右子树的顺序)

中序遍历(递归/迭代)
访问顺序 :中序遍历左子树、根节点
、中序遍历右子树
应用:二叉搜索树的中序遍历按升序或降序处理节点

后序遍历(递归 / 迭代)
访问顺序 :后序遍历左子树、后序遍历右子树、根节点
应用:适用于一些先子后父的操作

层序遍历 (迭代实现:队列)
访问顺序 :从上到下、从左到右依次访问每一个节点
应用:① 计算二叉树的高度 ② 判断一棵树是否为完全二叉树

2.7 表达式树
四则运算的表达式可以分为3种
- 前缀表达式(prefix expression),又称为波兰表达式
- 中缀表达式(infix expression)
- 后缀表达式(postfix expression),又称为逆波兰表达式

如果将表达式的操作数作为叶子节点,运算符作为父节点(假设只是四则运算)
- 这些节点刚好可以组成一棵二叉树
- 比如表达式:A / B + C * D – E

如果对这棵二叉树进行遍历
前序遍历
- - + / A B * C D E
- 刚好就是前缀表达式(波兰表达式)
中序遍历
- A / B + C * D – E
- 刚好就是中缀表达式(波兰表达式)
后序遍历
- A B / C D * + E –
- 刚好就是后缀表达式(逆波兰表达式)
2.8 二叉树遍历的应用
- 前序遍历:树状结构展示(注意左右子树的顺序)
- 中序遍历:二叉搜索树的中序遍历按升序或者降序处理节点
- 后序遍历:适用于一些先子后父的操作
- 层序遍历:①计算二叉树的高度。②判断一棵树是否为完全二叉树
2.9 根据遍历结果重构二叉树
- 前序遍历 + 中序遍历 => 唯一的一颗二叉树
- 后序遍历 + 中序遍历 => 唯一的一颗二叉树
- 前序遍历 + 后序遍历 => 如果它是一棵真二叉树,结果是唯一的 。否则不然结果不唯一 。
2.10 前驱节点(predecessor)
理解:中序遍历时的前一个节点 “删除节点要使用该知识”
如果是二叉搜索树,前驱节点就是前一个比它小的节点

2.11 后继节点(successor)
理解:中序遍历时的后一个节点 “删除节点要使用该知识”
如果是二叉搜索树,后继节点就是后一个比它大的节点

2.12 打印二叉树的工具
https://github.com/CoderMJLee/BinaryTrees
使用步骤:
- 实现 BinaryTreeInfo 接口
- 调用打印API :BinaryTrees.println(bst);
2.13 二叉树遍历非递归实现思路
前序遍历-非递归
利用栈实现(法一)
- 设置 node = root
- 循环执行以下操作
- 如果 node != null
- 对 node 进行访问
- 将 node.right 入栈
- 设置 node = node.left
- 如果 node == null
- 如果栈为空,结束遍历
- 如果栈不为空,弹出栈顶元素并赋值给 node
- 如果 node != null
利用栈实现(法二)
-
将 root 入栈
-
循环执行以下操作,直到栈为空
-
弹出栈顶节点 top,进行访问
-
将 top.right 入栈
-
将 top.left 入栈
中序遍历-非递归
利用栈实现 :
-
设置 node = root
-
循环执行以下操作
- 如果 node != null
- 将 node 入栈
- 设置 node = node.left
- 如果 node == null
- 如果栈为空,结束遍历
- 如果栈不为空,弹出栈顶元素并赋值给 node
- 对 node 进行访问
- 设置 node = node.right
- 如果 node != null
后序遍历 – 非递归
利用栈实现
- 将 root 入栈
- 循环执行以下操作,直到栈为空
- 如果栈顶节点是叶子节点 或者 上一次访问的节点是栈顶节点的子节点 => 弹出栈顶节点,进行访问
- 否则 => 将栈顶节点的right、left按顺序入栈
2.14 二叉树代码实现
3. 二叉搜索树
3.1 引入
在 n 个动态的整数中搜索某个整数?(查看其是否存在)。
-
假设使用动态数组存放元素,从第 0 个位置开始遍历搜索,平均时间复杂度:O(n)。
-
- 如果维护一个有序的动态数组,使用二分搜索,最坏时间复杂度:O(logn)。但是添加、删除的平均时间复杂度是 O(n)。
针对这个需求,有没有更好的方案?=> 使用二叉搜索树,添加、删除、搜索的最坏时间复杂度均可优化至:O(logn)级别 <==> O(h) 复杂度只与h有关


3.2 理解
二叉搜索树是二叉树的一种,是应用非常广泛的一种二叉树,英文简称为 BST。
-
又被称为:二叉查找树、二叉排序树
-
任意一个节点的值都大于其左子树所有节点的值
-
任意一个节点的值都小于其右子树所有节点的值
-
它的左右子树也是一棵二叉搜索树
二叉搜索树可以大大提高搜索数据的效率
二叉搜索树存储的元素必须具备可比较性
- 比如 int、double 等
- 如果是自定义类型,需要指定比较方式
- 不允许为 null

3.3 接口设计
由于二叉搜索树继承于二叉树,只需要实现添加,删除,包含的接口

注意:对于当前使用的二叉树来说,它的元素没有索引的概念。
3.4 图解
添加节点

删除节点



3.5 代码实现
3.6 测试
4. AVL树
4.1 理解
平衡因子(Balance Factor)
:某结点的左右子树的高度差(左 - 右)
AVL树的特点:
-
每个节点的平衡因子只可能是 1、0、-1(绝对值 ≤ 1,如果超过 1,称之为“失衡”)
-
每个节点的左右子树高度差不超过 1
-
搜索、添加、删除的时间复杂度是
O(logn)
说明:红黑树的添加删除后的旋转恢复平衡都是O(1)级别的。AVL树添加后的旋转恢复平衡是O(1)级别的,而删除后的旋转恢复平衡操作的最坏情况达到了O(logn)级别

注意:
① AVL树是最早发明的自平衡二叉搜索树之一
② AVL 取名于两位发明者的名字 :G. M. Adelson-Velsky 和 E. M. Landis(来自苏联的科学家)
4.2 继承机构

4.3 添加导致的失衡
示例:往下面这棵子树中添加 13
-
最坏情况:可能会导致所有祖先节点都失衡
-
父节点、非祖先节点,都不可能失衡

四种添加失衡情况及其处理(有且仅有四种)
- LL-g右旋转(单旋)

- RR-g左旋转(单旋)

- LR-p左旋转,g右旋转(双旋)

- RL-p右旋转,g左旋转(双旋)

四种添加失衡情况的统一处理

4.4 删除导致的失衡
示例:删除下面这棵树的16

删除后需要使用 LL-右旋转 解决失衡的情况
- 如果绿色节点不存在,更高层的祖先节点可能也会失衡,需要再次恢复平衡,然后又可能导致更高层的祖先节点失衡...
- 极端情况下,所有祖先节点都需要进行恢复平衡的操作,共 O(logn) 次调整

删除后需要使用 RR-左旋转 解决失衡的情况

删除后需要使用 LR-p左旋转,g右旋转(双旋) 解决失衡的情况

删除后需要使用 RL-p右旋转,g左旋转(双旋) 解决失衡的情况

4.5 总结
添加
-
可能会导致
所有祖先节点
都失衡 -
只要让高度最低的失衡节点恢复平衡,整棵树就恢复平衡【
仅需 O(1) 次调整
】
删除
-
可能会导致父节点或祖先节点失衡(
只有1个节点会失衡
) -
恢复平衡后,可能会导致更高层的祖先节点失衡【
最多需要 O(logn) 次调整
】
平均时间复杂度
-
搜索:O(logn)
-
添加:O(logn),仅需 O(1) 次的旋转操作
-
删除:O(logn),最多需要 O(logn) 次的旋转操作
4.6 代码实现
平衡二叉搜索树
AVL树
4.7 测试
5. B树
5.1 理解
B树 是一种 平衡的多路搜索树
,多用于文件系统、数据库的实现

仔细观察B树,有什么眼前一亮的特点?
-
1 个节点可以存储超过 2 个元素、可以拥有超过 2 个子节点
-
拥有二叉搜索树的一些性质
-
平衡,每个节点的所有子树高度一致
-
比较矮
数据库中一般使用 200 ~ 300 阶B树
5.2 m阶B树的性质
规定的B树必须要遵守的一些性质
假设一个节点存储的元素个数为 x
-
根节点:1 ≤ x ≤ m − 1
-
非根节点:┌ m/2 ┐ − 1 ≤ x ≤ m − 1 (┌ ┐ => 向上取整)
-
如果有子节点,子节点个数 :y = x + 1
-
根节点:2 ≤ y ≤ m
-
非根节点:┌ m/2 ┐ ≤ y ≤ m
➢ 比如 m = 3,2 ≤ y ≤ 3,因此可以称为(2, 3)树、2-3树
➢ 比如 m = 4,2 ≤ y ≤ 4,因此可以称为(2, 4)树、2-3-4树
➢ 比如 m = 5,3 ≤ y ≤ 5,因此可以称为(3, 5)树
➢ 比如 m = 6,3 ≤ y ≤ 6,因此可以称为(3, 6)树
➢ 比如 m = 7,4 ≤ y ≤ 7,因此可以称为(4, 7)树
-
5.3 B树 与二叉搜索树 的关系
B树 和 二叉搜索树,在逻辑上是等价的
多代节点合并,可以获得一个 超级节点
-
2代合并的超级节点,最多拥有 4 个子节点(至少是 4阶B树)
-
3代合并的超级节点,最多拥有 8 个子节点(至少是 8阶B树)
-
n代合并的超级节点,最多拥有 2^n 个子节点( 至少是 2^n 阶B树)
m阶B树,最多需要 log2m 代合并

5.4 B树搜索
跟二叉搜索树的搜索类似
-
先在节点内部从小到大开始搜索元素
-
如果命中,搜索结束
-
如果未命中,再去对应的子节点中搜索元素,重复步骤 1

5.5 B树添加
新添加的元素必定是添加到 叶子节点 中 √ 红黑树会用到这个结论

-
再插入 98 呢?(假设这是一棵 4阶B树)
- 最右下角的叶子节点的元素个数将超过限制
- 这种现象可以称之为:上溢(overflow)
添加 – 上溢的解决(假设5阶)
-
上溢节点的元素个数必然等于 m
-
假设上溢节点最中间元素的位置为 k
-
将 k 位置的元素向上与父节点合并
-
将 [0, k-1] 和 [k + 1, m - 1] 位置的元素分裂成 2 个子节点
- 这 2 个子节点的元素个数,必然都不会低于最低限制(┌ m/2 ┐ − 1)
-
-
一次分裂完毕后,有可能导致父节点上溢,依然按照上述方法解决
- 最极端的情况,有可能一直分裂到根节点。如果一直传播到根节点就会导致B树变高(仅此一种情况导致B树变高)

插入98

插入52

插入54

5.6 B树删除
如果需要删除的元素在 叶子节点 中,那么直接删除即可

如果需要删除的元素在 非叶子节点 中
- 先找到前驱或后继元素,覆盖所需删除元素的值
- 再把前驱或后继元素删除

非叶子节点
的前驱或后继元素,必定在叶子节点
中- 所以这里的删除前驱或后继元素 ,就是最开始提到的情况:删除的元素在叶子节点中
真正的删除元素都是发生在叶子节点中
√红黑树会用到这个结论
删除-非叶子节点的 下溢 现象
-
删除 22 ?(假设这是一棵 5阶B树)
- 叶子节点被删掉一个元素后,元素个数可能会低于最低限制( 即
┌ m/2 ┐−1
) - 这种现象称为:
**下溢(underflow)**
- 叶子节点被删掉一个元素后,元素个数可能会低于最低限制( 即

删除-非叶子节点的 下溢 解决
-
下溢节点的元素数量必然等于
**┌ m/2 ┐ − 2**
-
如果下溢节点临近的兄弟节点,有至少
**┌ m/2 ┐**
个元素,可以向其借一个元素- 将父节点的元素
**b**
插入到下溢节点的**0**
位置(最小位置) - 用兄弟节点的元素
**a**
(最大的元素)替代父节点的元素 b - 这种操作其实就是:
**旋转**
- 将父节点的元素
注意:因为 b > a,所以不能破环二叉搜索树的性质直接将a放到下溢节点去。

- 如果下溢节点临近的兄弟节点,只有
**┌ m/2 ┐ − 1**
个元素 - 将父节点的元素 b 挪下来跟左右子节点进行合并
- 合并后的节点元素个数等于
**┌ m/2 ┐ + ┌ m/2 ┐ − 2**
,不会超过**m − 1**
上溢 - 这个操作可能会导致父节点下溢,依然按照上述方法解决,下溢现象可能会一直往上传播。
如果一直传播到根节点就会导致B树变矮(仅此一种情况导致B树变矮)

5.7 理解4阶b树
"理解了4阶b树,将能更好的学习理解 红黑树"
4阶B树的性质
- 所有节点能存储的元素个数 x :1 ≤ x ≤ 3
- 所有非叶子节点的子节点个数 y :2 ≤ y ≤ 4
添加
- 手绘 从 1 添加到 22
删除
- 手绘 从 1 删除到 22
6. 红黑树
6.1 理解
红黑树也是一种 自平衡的二叉搜索树
,以前也叫做平衡二叉B树(Symmetric Binary B-tree)。
**红黑树必须满足以下 5 条性质 **
-
节点是
RED
或者BLACK
-
根节点是
BLACK
-
叶子节点(外部节点,空节点)都是
BLACK
-
RED
节点的子节点都是 `BLACK-
RED
节点的 parent 都是BLACK
-
从根节点到叶子节点的所有路径上不能有 2 个连续的
RED
节点
-
-
从任一节点到叶子节点的所有路径都包含
相同数目
的BLACK
节点

注意:红黑树的
叶子节点
是让原来度为 0 的节点或度为 1 的节点都变成度为 2 的节点后的叶子节点。(增加空节点 null 实现此功能)此时红黑树就变成了真二叉树。

注意:之后展示的红黑树都会省略 null 节点 (空节点是假想出来的)
红黑树的平衡 (为什么满足以上5条性质,就能保证红黑树是平衡的?)
-
以上5条性质,可以保证 红黑树 等价于 4阶B树
-
相比AVL树,红黑树的平衡标准比较宽松:
没有一条路径会大于其他路径的2倍
-
可以理解为是一种弱平衡、黑高度平衡 (任意一条路的黑节点数量都是相等的)
-
红黑树的最大高度是 2 ∗ log(n + 1) ,依然是 O(logn) 级别
红黑树的平均时间复杂度
-
搜索:O(logn)
-
添加:O(logn),O(1) 次的旋转操作
-
删除:O(logn),O(1) 次的旋转操作
AVL树 对比 红黑树
-
AVL树
-
平衡标准比较严格:
每个左右子树的高度差不超过1
-
最大高度是 1.44 ∗ log(n + 2) − 1.328(100W个节点,AVL树最大树高28)
-
搜索、添加、删除都是 O(logn) 复杂度,其中添加仅需 O(1) 次旋转调整、删除最多需要 O(logn) 次旋转调整
-
-
红黑树
-
平衡标准比较宽松:
没有一条路径会大于其他路径的2倍
-
最大高度是 2 ∗ log(n + 1)( 100W个节点,红黑树最大树高40)
-
搜索、添加、删除都是 O(logn) 复杂度,其中添加、删除都仅需 O(1) 次旋转调整
-
-
搜索的次数远远大于插入和删除,选择AVL树;搜索、插入、删除次数几乎差不多,选择红黑树
-
相对于AVL树来说,红黑树牺牲了部分平衡性以换取插入/删除操作时少量的旋转操作,整体来说性能要优于AVL树
-
红黑树的平均统计性能优于AVL树,实际应用中更多选择使用红黑树
6.2 红黑树的等价变换
红黑树 和 四阶B树(2-3-4树)具有等价性
BLACK
节点与它的 RED
子节点融合在一起,形成1个B树节点
红黑树的 BLACK
节点个数 与 4阶B树的节点总个数 相等
注意:用 2-3树 与 红黑树 进行类比,这是极其不严谨的,2-3树 并不能完美匹配 红 黑树 的所有情况

6.3 红黑树 与 2-3-4树 的比较
如果下图最底层的 BLACK 节点是不存在的,在B树中是什么样的情形?=>整棵B树只有1个节点,而且是超级节点

6.4 添加节点
已知:
-
B树中,新元素必定是添加到叶子节点中
-
4阶B树所有节点的元素个数 x 都符合 1 ≤ x ≤ 3
注意:
① 建议新添加的节点默认为
RED
,这样能够让红黑树的性质尽快满足(性质1,2,3,5 都满足,性质 4 不一定)② 如果添加的是根节点,染成
BLACK
即可
添加的所有情况

有 4 种情况既满足红黑树的性质四:parent 为 BLACK
,同时也满足4阶B树的性质,因此不用做任何额外的处理。

有 8 种情况不满足红黑树的性质四:parent 为 RED
( Double Red ),其中前 4 种属于B树节点上溢的情况

添加 – 修复性质4 – LL\RR
判定条件:uncle 不是 RED
-
parent 染成
BLACK
,grand 染成RED
-
grand 进行单旋操作:
- LL:右旋转
- RR:左旋转

添加 – 修复性质4 – LR\RL
判定条件:uncle 不是 RED
-
自己染成
BLACK
,grand 染成RED
-
进行双旋操作:
- LR:parent 左旋转, grand 右旋转
- RL:parent 右旋转, grand 左旋转

添加 – 修复性质4 – 上溢 – LL
注意:之前修复的四种情况,添加节点的叔父节点都为null(null默认记为黑色)。
判定条件:uncle 是 RED
- parent、uncle 染成
BLACK
- grand 向上合并,且染成
RED
,当做是新添加的节点进行处理
grand 向上合并时,可能继续发生上溢
若上溢持续到根节点,只需将根节点染成 BLACK

添加 – 修复性质4 – 上溢 – RR
判定条件:uncle 是 RED
- parent、uncle 染成
BLACK
- grand 向上合并,且染成
RED
,当做是新添加的节点进行处理

添加 – 修复性质4 – 上溢 – LR
判定条件:uncle 是 RED
- parent、uncle 染成
BLACK
- grand 向上合并,且染成
RED
,当做是新添加的节点进行处理

添加 – 修复性质4 – 上溢 – RL
判定条件:uncle 是 RED
- parent、uncle 染成
BLACK
- grand 向上合并,且染成
RED
,当做是新添加的节点进行处理

6.4 删除节点
已知:B树中,最后真正被删除的元素都在叶子节点中
删除-RED节点
直接删除,不用做任何调整

删除 – BLACK 节点 (有 3 种情况)
删除拥有 2 个 RED
子节点的 BLACK
节点 (如 25)
- 不可能被直接删除,因为会找它的前驱节点或后继节点替代删除,在BSTree中已经实现了此功能因此也不用考虑这种情况
删除拥有 1 个 RED
子节点的 BLACK
节点 (如 46,76)
删除 BLACK
叶子节点 (如 88)
总结:删除后真正需要处理的只有两种情况:① 删除拥有 1 个
RED
子节点的BLACK
节点 ② 删除BLACK
叶子节点
删除 - 拥有 1 个 RED 子节点的 BLACK 节点
判定条件:用以替代的子节点是 RED
“注意:删除Black叶子节点,没有用于替代的就相当于用null(默认为Black)替代”
将替代的子节点染成 BLACK
即可保持红黑树性质

删除 - BLACK 叶子节点 - sibling为 BLACK
BLACK
叶子节点被删除后,会导致B树节点下溢(比如删除88)
判定条件:如果 sibling 至少有 1 个 RED
子节点
- 进行旋转操作
- 旋转之后的中心节点继承 parent 的颜色
- 旋转之后的左右节点染为
BLACK

判定条件:如果 sibling 没有 RED
子节点
- 将 sibling 染成
RED
、parent 染成BLACK
即可修复红黑树性质 (合并) - 如果 parent 是
BLACK
会导致 parent 也下溢,这时只需要把 parent 当做被删除的节点处理即可(递归)

删除 - BLACK 叶子节点 - sibling为 RED
如果 sibling 是 RED
- sibling 染成
BLACK
,parent 染成RED
,进行旋转 - 于是又回到 sibling 是
*BLACK
的情况

6.5 实现
6.6 测试
六. 集合(Set)实现
1. 集合的特点
不存放重复的元素
常用于去重
-
存放新增 IP,统计新增 IP 量
-
存放词汇,统计词汇量
集合的内部实现能直接使用 动态数组
,链表
,二叉搜索树(AVL树,红黑树)
实现
2. 接口设计
3. 链表实现集合(ListSet)
3.1 实现
3.2 测试
4. 红黑树实现集合(TreeSet)
4.1 ListSet 与 TreeSet效率对比
链表
-
查找:最坏情况为O(n)级别
-
添加:最坏情况为O(n)级别
-
删除:最坏情况为O(n)级别
红黑树
-
查找:最坏情况为O(logn)级别
-
添加:最坏情况为O(logn)级别
-
删除:最坏情况为O(logn)级别
4.2 TreeSet 的局限性
通过二叉搜索树实现的TreeSet,元素必须具备 可比较性 才能加进去
通过 哈希表
实现的 HashSet,可以解决这个局限性
4.3 实现
4.4 测试
七.映射(Map)实现
1. 理解
Map 在有些编程语言中也叫做字典(dictionary,比如 Python、Objective-C、Swift 等)

Map 的每一个 key 是唯一的
类似Set,Map可以直接利用链表
,二叉搜索树 (AVL树,红黑树)
等数据结构来实现
2. Map 与 Set 的关系
Map 的所有 key 组合在一起,其实就是一个 Set。因此,Set 可以间接利用 Map 来作内部实现
3. 接口设计
4. 红黑树实现TreeMap
4.1 TreeMap分析
时间复杂度(平均)
- 添加、删除、搜索:O(logn)
特点
-
Key 必须具备可比较性
-
元素的分布是有顺序的
在实际应用中,很多时候的需求
-
Map 中存储的元素不需要讲究顺序
-
Map 中的 Key 不需要具备可比较性
不考虑顺序、不考虑 Key 的可比较性,Map 有更好的实现方案,平均时间复杂度可以达到 O(1),那就是采取哈希表来实现 Map
4.2 实现
4.3 测试
八.哈希表
1. 理解
哈希表也叫做 散列表
( hash 有“剁碎”的意思)
它是如何实现高效处理数据的?
-
put("Jack", 666);
-
put("Rose", 777);
-
put("Kate", 888);
添加、搜索、删除的流程都是类似的
-
利用哈希函数生成 key 对应的 index【O(1)】
-
根据 index 操作定位数组元素【O(1)】
哈希表是【空间换时间】的典型应用
哈希函数,也叫做 散列函数
哈希表内部的数组元素,很多地方也叫 Bucket(桶),整个数组叫 Buckets 或者 Bucket Array

注意:在实际应用中很多时候的需求:Map 中存储的元素不需要讲究顺序,Map 中的 Key 不需要具备可比较性。其实不考虑顺序、不考虑 Key 的可比较性,Map 有更好的实现方案,平均时间复杂度可以达到 O(1) ,那就是采取
哈希表来实现 Map
2. 哈希冲突(Hash Collision)
哈希冲突也叫做 哈希碰撞
- 2 个不同的 key,经过哈希函数计算出相同的结果
- key1 ≠ key2 ,hash(key1) = hash(key2)

解决哈希冲突的常见方法
-
开放定址法(Open Addressing)
即按照一定规则向其他地址探测,直到遇到空桶 。 -
再哈希法(Re-Hashing)
即设计多个哈希函数 -
链地址法(Separate Chaining)
即比如通过链表将同一index的元素串起来
JDK1.8的哈希冲突解决方案
- 默认使用
单向链表
将元素串起来(链地址法
) - 在添加元素时,可能会由
单向链表
转为红黑树
来存储元素。比如当哈希表容量 ≥ 64 且 单向链表的节点数量大于 8 时 - 当
红黑树
节点数量少到一定程度时,又会转为单向链表
- JDK1.8中的哈希表是使用
链表+红黑树解决哈希冲突
- 思考一下这里为什么使用单链表?=> 每次都是从头节点开始遍历,单向链表比双向链表少一个指针,可以节省内存空间

3. 哈希函数
哈希表中哈希函数的实现步骤大概如下:
-
先生成
key 的哈希值
(必须是整数
) -
再让
key 的哈希值
跟数组的大小
进行相关运算,生成一个索引值
为了提高效率,可以使用 &
位运算取代 %
运算【前提:将数组的长度设计为 2 的幂(2^n)
】
良好的哈希函数 能让哈希值更加均匀分布 → 减少哈希冲突次数 → 提升哈希表的性能
此外,hashCode相等,生成的索引不一定相等。
4. 如何生成key的哈希值
key 的常见种类可能有
-
整数、浮点数、字符串、自定义对象
-
不同种类的 key,哈希值的生成方式不一样,但目标是一致的
-
尽量让每个 key 的哈希值是唯一的
-
尽量让 key 的所有信息参与运算
-
在Java中,HashMap 的 key 必须实现 hashCode
、equals
方法,也允许 key 为 null
整数
整数值当做哈希值
比如 10 的哈希值就是 10
浮点数
将存储的二进制格式转为整数值
long
注意:Java的哈希值必须是
int
类型(32位)

double
字符串
先看一个问题:整数 5489 是如何计算出来的?
字符串是由若干个字符组成的
- 比如字符串 jack,由 j、a、c、k 四个字符组成(字符的本质就是一个整数,ASCII码)
- 因此,jack 的哈希值可以表示为 j ∗ n^3 + a ∗ n^2 + c ∗ n^1 + k ∗ n^0,等价于 [ ( j ∗ n + a ) ∗ n + c ] ∗ n + k (等价后可以避免n的重复计算)
- 在JDK中,乘数 n 为 31,为什么使用 31? => 31 是一个奇素数,JVM会将 31 * i 自动优化转化为 (i << 5) – i
注意:
① 31 * i = (2^5 - 1) * i = i * 2^5 - i = (i << 5) - i
② 31不仅仅是符合2^n - 1,它也是一个奇素数(既是奇数,也是质数。即质数)
=>素数和其他数相乘的结果比其他方式更容易产生唯一性,减少哈希冲突。
总结
自定义对象的哈希值
自定义对象的hash值默认与该对象的内存地址有关。
注意:
① 哈希值太大,整型溢出怎么办? => 不用作任何处理,溢出了还是一个整 数。
② 不重写hashCode方法有什么后果? => 会以对象内存地址相关的值作为hash值。
重点:
① hashCode方法在在计算索引时调用
② equals方法在hash冲突时比较两个key是否相等时调用
④ 如果要求两个对象的哪些成员变量相等就代表这两个对象相等的话,hashCode方法和equals方法就只包含这些成员变量的计算就可以了。(hashCode方法必须要保证 equals 为 true 的 2 个key的哈希值一样,反过来hashCode相等的key,不一定equals为true)
5. HashMap实现
这里有如下设计
- 直接使用红黑树解决hash冲突
- 数组元素存储红黑树根节点,而不是存储红黑树对象。这样做的好处是就不用额外存储红黑树的size,comparator属性了(用不上)。
6. 哈希值的进一步处理:扰动计算
在上面的hashmap实现中,生成hash值时为什么还要再次高低16位做与运算?
==> 扰动计算,能使hash排布更加均匀!
7. 装填因子
在上面的hashmap实现中,在扩容时用到了 装填因子 !
装填因子(Load Factor):节点总数量 / 哈希表桶数组长度,也叫做负载因子
在JDK1.8的HashMap中,如果装填因子超过0.75,就扩容为原来的2倍
8. 关于使用%来计算索引
如果使用%来计算索引
- 建议把哈希表的长度设计为素数(质数),可以大大减小哈希冲突

下边表格列出了不同数据规模对应的最佳素数,特点如下
- 每个素数略小于前一个素数的2倍
- 每个素数尽可能接近2的幂(2^n)

9. TreeMap VS HashMap
9.1 性能对比

9.2 选择时机
何时选择 TreeMap? => 元素具备可比较性且要求升序遍历(按照元素从小到大)
何时选择 HashMap?=> 无序遍历
10.LinkedHashMap
10.1 理解
在HashMap的基础上维护元素的添加顺序,使得遍历的结果是遵从添加顺序的
假设添加顺序是:37、21、31、41、97、95、52、42、83

10.2 LinkedHashMap实现
注意:链表是跨红黑树的!
10.3 删除的注意点
删除度为2的节点node时(比如删除31),需要注意更换 node 与 前驱\后继节点 的连接位置

10.4 更换节点的连接位置

交换prev
交换next
11. HashSet
12. LinkedHashSet
九. 二叉堆
1. 引入
设计一种数据结构,用来存放整数,要求提供 3 个接口
- 添加元素
- 获取最大值
- 删除最大值

有没有更优的数据结构?=> 堆
- 获取最大值:O(1)
- 删除最大值:O(logn)
- 添加元素:O(logn)
解决 Top K 问题
什么是 Top K 问题? => 从海量数据中找出前 K 个数据,比如从 100 万个整数中找出最大的 100 个整数
Top K 问题的解法之一:可以用数据结构“堆”来解决
2. 堆理解
堆(Heap)也是一种树状的数据结构(不要跟内存模型中的“堆空间”混淆),常见的堆实现有
- 二叉堆(Binary Heap,完全二叉堆)
- 多叉堆(D-heap、D-ary Heap)
- 索引堆(Index Heap)
- 二项堆(Binomial Heap)
- 斐波那契堆(Fibonacci Heap)
- 左倾堆(Leftist Heap,左式堆)
- 斜堆(Skew Heap)
堆的一个重要性质:任意节点的值总是 ≥( ≤ )子节点的值
-
如果任意节点的值总是 ≥ 子节点的值,称为:最大堆、大根堆、大顶堆
-
如果任意节点的值总是 ≤ 子节点的值,称为:最小堆、小根堆、小顶堆
由此可见,堆中的元素必须具备可比较性(跟二叉搜索树一样)
3. 堆基本接口设计

4. 二叉堆理解
二叉堆 的逻辑结构就是一棵完全二叉树,所以也叫 完全二叉堆
鉴于完全二叉树的一些特性,二叉堆的底层(物理结构)一般用数组实现即可
索引 i 的规律( n 是元素数量)
- 如果 i = 0 ,它是 根 节点
- 如果 i > 0 ,它的 父 节点的索引为 floor( (i – 1) / 2 )
- 如果 2i + 1 ≤ n – 1,它的 左子节点的索引为 2i + 1
- 如果 2i + 1 > n – 1 ,它 无左子节点
- 如果 2i + 2 ≤ n – 1 ,它的 右子节点的索引为 2i + 2
- 如果 2i + 2 > n – 1 ,它 无右子节点

5. 最大堆-添加

循环执行以下操作(图中的 80 简称为 node)
- 如果 node > 父节点 ==> 与父节点交换位置
- 如果 node ≤ 父节点,或者 node 没有父节点 ==> 退出循环
这个过程,叫做上滤(Sift Up),时间复杂度为 O(logn)
交换位置的优化
一般交换位置需要3行代码,可以进一步优化 ==> 将新添加节点备份,确定最终位置才摆放上去
仅从交换位置的代码角度看,可以由大概的 3 * O(logn) 优化到 1 * O(logn) + 1

6. 最大堆-删除

-
用最后一个节点覆盖根节点
-
删除最后一个节点
-
循环执行以下操作(图中的 43 简称为 node)
- 如果 node < 最大的子节点 ==> 与最大的子节点交换位置
- 如果 node ≥ 最大的子节点, 或者 node 没有子节点 ==> 退出循环
这个过程,叫做下滤(Sift Down),时间复杂度:O(logn)
同样的,交换位置的操作可以像添加那样进行优化
7. 最大堆–批量建堆 (Heapify)
批量建堆,有 2 种做法
- 自上而下的上滤
- 自下而上的下滤
自上而下的上滤

自下而上的下滤

效率对比

所有节点的深度之和
- 仅仅是叶子节点,就有近 n/2 个,而且每一个叶子节点的深度都是 O(logn) 级别的
- 因此,在叶子节点这一块,就达到了 O(nlogn) 级别
- O(nlogn) 的时间复杂度足以利用排序算法对所有节点进行全排序
所有节点的高度之和
- 假设是满树,节点总个数为 n,树高为 h,那么 n = 2^h − 1
- 所有节点的树高之和
公式推导
疑惑
以下方法可以批量建堆么
- 自上而下的下滤
- 自下而上的上滤
上述方法不可行,为什么?
认真思考【自上而下的上滤】、【自下而上的下滤】的本质。自上而下的上滤的本质是添加,自下而上的下滤的本质是删除
8. 构建小顶堆
只需要改变一下比较策略即可,比如值比较小的节点更大
9. 大顶堆实现
抽象父类
具体类
10. Top K 问题
从 n 个整数中,找出最大的前 k 个数( k 远远小于 n )
如果使用排序算法进行全排序,需要 O(nlogn) 的时间复杂度
如果使用二叉堆来解决,可以使用 O(nlogk) 的时间复杂度来解决
- 新建一个小顶堆
- 扫描 n 个整数
- 先将遍历到的前 k 个数放入堆中
- 从第 k + 1 个数开始,如果大于堆顶元素,就使用 replace 操作(删除堆顶元素,将第 k + 1 个数添加到堆中)
- 扫描完毕后,堆中剩下的就是最大的前 k 个数
如果是找出最小的前 k 个数呢?
- 用大顶堆
- 如果小于堆顶元素,就使用 replace 操作
十. 优先级队列
1. 接口设计
优先级队列也是个队列,因此也是提供以下接口

普通的队列是 FIFO 原则,也就是先进先出
优先级队列则是按照优先级高低进行出队,比如将优先级最高的元素作为队头优先出队
2. 应用场景
医院的夜间门诊
- 队列元素是病人
- 优先级是病情的严重情况、挂号时间
操作系统的多任务调度
-
队列元素是任务
-
优先级是任务类型
3. 实现
根据优先队列的特点,很容易想到:可以直接利用二叉堆作为优先队列的底层实现
可以通过 Comparator 或 Comparable 去自定义优先级高低
十一. 哈夫曼树
1. 哈夫曼编码
哈夫曼编码,又称为霍夫曼编码(Huffman Coding),它是现代压缩算法的基础
假设要把字符串【ABBBCCCCCCCCDDDDDDEE】转成二进制编码进行传输
-
可以转成ASCII编码(6569,10000011000101),但是有点冗长,如果希望编码更短呢?
-
可以先约定5个字母对应的二进制。对应的二进制编码:000001001001010010010010010010010010011011011011011011100100 ,一共20个字母,转成了60个二进制位

- 如果使用哈夫曼编码,可以压缩至41个二进制位,约为原来长度的68.3%
2. 哈夫曼树
先计算出每个字母的出现频率(权值,这里直接用出现次数),【ABBBCCCCCCCCDDDDDDEE】

利用这些权值,构建一棵哈夫曼树(又称为霍夫曼树、最优二叉树)
如何构建一棵哈夫曼树?(假设有 n 个权值)
- 以权值作为根节点构建 n 棵二叉树,组成森林
- 在森林中选出 2 个根节点最小的树合并,作为一棵新树的左右子树,且新树的根节点为其左右子树根节点之和
- 从森林中删除刚才选取的 2 棵树,并将新树加入森
- 重复 2、3 步骤,直到森林只剩一棵树为止,该树即为哈夫曼树
3. 构建哈夫曼树

4. 构建哈夫曼编码

left为0,right为1,可以得出5个字母对应的哈夫曼编码

【ABBBCCCCCCCCDDDDDDEE】的哈夫曼编码是 1110110110110000000001010101010101111
总结
- n 个权值构建出来的哈夫曼树拥有 n 个叶子节点
- 每个哈夫曼编码都不是另一个哈夫曼编码的前缀
- 哈夫曼树是带权路径长度最短的树,权值较大的节点离根节点较近
- 带权路径长度:树中所有的叶子节点的权值乘上其到根节点的路径长度。与最终的哈夫曼编码总长度成正比关系。
十二 Trie 字典树
1. 引入
需求
如何判断一堆不重复的字符串是否以某个前缀开头?我们可以用Set或Map存储字符串,遍历所有字符串进行判断。时间复杂度为O(n)
有没有更优的数据结构实现前缀搜索?有!那就是Trie
Trie理解
Trie 也叫做字典树、前缀树(Prefix Tree)、单词查找树
Trie 搜索字符串的效率主要跟字符串的长度有关
假设使用 Trie 存储 cat、dog、doggy、does、cast、add 六个单词

2. 接口设计
有两种接口形式,可以分别用Set和Map实现。Map可以做到在存储字符串的同时储存其对应的value(如人的姓名和其对应的电话号码)

3. 实现
4. 总结
Trie 的优点:搜索前缀的效率主要跟前缀的长度有关
Trie 的缺点:需要耗费大量的内存,因此还有待改进
更多Trie 相关的数据结构和算法
- Double-array Trie
- Suffix Tree
- Patricia Tree
- Crit-bit Tree
- AC自动机
__EOF__

本文链接:https://www.cnblogs.com/mpolaris/p/15773493.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix