算法+ 数据结构 = 程序
导读
1.1 为什么要学习数据结构与算法
很多人认为学习数据结构与算法只是为了应付面试,在日常的开发中基本上没有使用的机会,也有一些同学去刷题,但发现有时候这些题目一看就会一问就费的局面。甚至有一些的同学看到数据结构和算法这一词时内心直接就是抵触的。
这里很大的一部分原因是因为你没有真正的去了解数据结构,你有想过为什么大厂都要求数据结构与算法吗?为什么技术过关了但是总会挂在算法这一关?
学习数据结构与算法不仅仅是学习其中的数组,链表,队列,堆栈,树,图等经典的结构,也不仅仅是学习应付面试的算法。
其实数据结构是考验你的基本功是否扎实的重要环节之一,最重要的是你要学习一种思想:如何把现实问题转化为计算机语言的表示。
算法+ 数据结构 = 程序
本篇文章将介绍数据结构中的基础概念,了解基础概念之后会配套的做一些相关的练习题加深对其的理解。希望能对你得到一些帮助。
这个专题也会不断的向内添加内容,如有图片或者描述错误的地方或者讲解不清楚的地方还请各位同学及时指出,我加以改正。
目前已完成数据结构的基本概念 相关算法题会在编辑算法章节时陆续补充进来。感谢关注。
1.2 什么是数据结构与算法
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
数据结构包括:
- 线性结构:线性表(数组、链表、队列、栈、哈希表)
- 树型结构:二叉树、AVL树、红黑树、B树、堆、Trie、哈夫曼树、并查集
- 图形结构:邻接矩阵、邻接表
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制
算法包括:递推法、递归法、穷举法、贪心算法、分治法、动态规划法、迭代法、分支界限法、回溯法
一. 算法基本概念
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制
一个算法的优劣可以用空间复杂度与时间复杂度来衡量。
特征:
- 有穷性(Finiteness):算法的有穷性是指算法必须能在执行有限个步骤之后终止。
- 确切性(Definiteness): 算法的每一步骤必须有确切的定义。
- 输入项(Input)::一个算法有0个或多个输入,以刻画运算对象的初始情况,所谓0个输入是指算法本身定出了初始条件。
- 输出项(Output):一个算法有一个或多个输出,以反映对输入数据加工后的结果。没有输出的算法是毫无意义的。
- 可行性(Effectiveness):算法中执行的任何计算步骤都是可以被分解为基本的可执行的操作步骤,即每个计算步骤都可以在有限时间内完成(也称之为有效性)。
1.1 时间复杂度
算法的时间复杂度是指执行算法所需要的计算工作量。也就是说程序需要执行的次数
大O表示法
一般用大O表示法来描述负责度,它表示的是数据规模为n对应的复杂度
-
忽略常数、系数、低阶
执行次数 复杂度 非正式术语 12 O(1) 常数阶 2n+3 O(n) 线性阶 4n^2+2n+6 O(n^2) 平方阶 4log2n+25 O(logn) 对数阶 3n+2nlog3n+15 O(nlogn) nlogn阶 4n^3+3n^2+n+12 O(n^3) 立方阶 2^n O(2^n) 指数阶
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)
1.2 空间复杂度
空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度,记做S(n)=O(f(n))。比如直接插入排序的时间复杂度是O(n^2),空间复杂度是O(1) 。而一般的递归算法就要有O(n)的空间复杂度了,因为每次递归都要存储返回信息。一个算法的优劣主要从算法的执行时间和所需要占用的存储空间两个方面衡量)。
二. 数据结构 - 线性结构()
线性表是具有n个相同类型元素的有限序列 (n>=0)
索引 0 1 2 3 n
a1 >> a2 >> a3 >> a4 >> ... >> an
a1 是首节点/ 首元素 , an是尾节点/ 尾元素
a1是a2的前驱,a2是a1的后继
常见线性表线性表(数组、链表、队列、哈希表)
2.1 数组(Array)
数组(Array)是一种顺序存储的线性表,所有元素的内存地址都是连续的。
2.2 链表(linked list)
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的
2.2.1 单向链表
2.2.1 环形链表
2.2.1 双向链表
2.3 栈(stack)
栈是一种特殊的线性表,只能在一端进行操作
- 向栈中添加元素的操作,叫做 push,入栈
- 从栈中移除元素的才做,叫做 pop, 出栈 (只能移除栈顶的元素)
- 栈遵循 后进先出的原则 Last In First Out LIFO
2.4 队列(Queue)
队列是一种特殊的线性表,只能在头尾两端进行操作 特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。
- 队尾(rear): 只能从队尾添加元素,一般叫做 enQueue,入队
- 队头(front):只能从队头移除元素,一般叫做 deQueue,出队
2.4.1 双端队列
双端队列是能在队头队尾两端 进行 添加或删除的操作
2.4.2 循环队列
将向量空间想象为一个首尾相接的圆环,并称这种向量为循环向量。存储在其中的队列称为循环队列(Circular Queue)。循环队列是把顺序队列首尾相连,把存储队列元素的表从逻辑上看成一个环,成为循环队列。
2.5 哈希表/散列表(Hash table)
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
哈希表本质是一个数组,数组中的每一个元素成为一个箱子,箱子中存放的是键值对。根据下标index从数组中取value。获取index是通过固定的哈希函数获取的,将key转换成index。不论哈希函数设计的如何完美,都可能出现不同的key经过hash处理后得到相同的hash值,这时候就需要处理哈希冲突。
哈希函数:
哈希函数指将哈希表中元素的关键键值映射为元素存储位置的函数, 通俗一点说就是通过一种函数直接算出key所在表中的位置。
哈希函数主要实现的步骤:
- 先成Key的哈希值(必须是整数)
- 再让key的哈希值跟数组大小进行相关运算,生成一个索引值
负载因子: 节点总数量(总键值对数量)/ 哈希表桶数组长度
负载因子,它用来衡量哈希表的 空/满 程度,一定程度上也可以体现查询的效率,负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。因此,一般来说,当负载因子大于某个常数(一般是0.75)时,哈希表将自动扩容。哈希表扩容时,一般会创建两倍于原来的数组长度。因此即使key的哈希值没有变化,对数组个数取余的结果会随着数组个数的扩容发生变化,因此键值对的位置都有可能发生变化,这个过程也成为重哈希
2.5.1 哈希函数的构造方法
1.直接定制法:
取关键字或关键字的某个线性函数值为散列地址。即H(key)=key或H(key) = a·key + b,其中a和b为常数(这种散列函数)叫做自身函数)。若其中H(key)中已经有值了,就往下一个找,直到H(key)中没有值了,就放进去。
示例:H(key) = a·key + b a = 1/10 b = 5
| Key | Hash(Key) |
|---|---|
| 1630 | 168 |
| 80 | 13 |
| 4780 | 483 |
| 1820 | 187 |
2.数字分析法:
分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址
3.平方去中法:
当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。
示例:我们把英文字母在字母表中的位置序号作为该英文字母的内部编码。例如K的内部编码为11,E的内部编码为05,Y的内部编码为25,A的内部编码为01, B的内部编码为02。由此组成关键字“KEYA”的内部代码为11052501,同理我们可以得到关键字“KYAB”、“AKEY”、“BKEY”的内部编码。之后对关键字进行平方运算后,取出第7到第9位作为该关键字哈希地址,如下表所示:
| 关键字 | 内部编码 | 内部编码的平方值 | H(k)关键字的哈希地址 |
|---|---|---|---|
| KEYA | 11052501 | 122157778355001 | 778 |
| KYAB | 11250102 | 126564795010404 | 795 |
| AKEY | 01110525 | 001233265775625 | 265 |
| BKEY | 02110525 | 004454315775625 | 315 |
5.折叠法:
将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。
5.随机数法:
选择一个随机数,取关键字的随机值作为散列地址,即H(key)=random(key)其中random为随机函数,通常用于关键字长度不等的场合。
6.除留余数法:
取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p,p<=m。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对p的选择很重要,一般取素数或m,若p选的不好,容易产生同义词。
示例:
H(key) = key % p (p<=m)
但是一般都不会是这么简单的计算,key可能会经过其他计算 然后取余。
不论哈希函数设计的如何完美,都可能出现不同的key经过hash处理后得到相同的hash值,这时候就需要处理哈希冲突。
2.5.2 哈希冲突解决方案
1.拉链发:
链表+数组的结合使用
在哈希表中每一个桶的位置存储的链表,如果出现了hash值重复的就让 链表的next指向当前元素。
JDK1.8中默认使用的是单向链表将元素穿起来,在添加元素的时候会由单项链表转为红黑树来存储元素,如哈希表容量>64 && 单项链表节点数量大于8时。
当红黑树节点数量少到一定程度又会转为单链表。
2.再哈希法(多哈希法):
设计二种甚至多种哈希函数,可以避免冲突,但是冲突几率还是有的,函数设计的越好或越多都可以将几率降到最低(除非人品太差,否则几乎不可能冲突)。
3. 开放地址法:
开放地址法有一个公式:Hi=(H(key)+di) MOD m i=1,2,...,k(k<=m-1)
其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。
-
线性探测(Linear Probing)
如果di值可能为1,2,3,...m-1 常数
逐个探测直到找到空置的然后将数据存入桶中
-
平方探测(Quadratic Probing)
d i = a * i 2 (i <= m/2) m是Key集合的总数。a是常数。
探测间隔 i2 个单元的位置是否为空,如果为空,将地址存放进去。
-
伪随机探测
d i = random(Key);
探测间隔为一个伪随机数。
3.5.3 相关算法题
三. 数据结构 - 树型结构(Tree Structure)
树形结构是一层次的嵌套结构。 一个树形结构的外层和内层有相似的结构, 所以这种结构多可以递归的表示。经典数据结构中的各种树状图是一种典型的树形结构:一颗树可以简单的表示为根, 左子树, 右子树。 左子树和右子树又有自己的子树。 --- 百科
基本概念
-
树种类:
-
节点:树中每个元素都称为节点
-
空树:没有任何节点的树 称之为空树
-
根节点:一种没有父节点的节点 (只有一个根节点也可以称之为树)
-
父节点:一种节点,至少有一个直接下属于它的子节点。
-
子节点:父节点的下一层节点
-
兄弟节点:同一父结点的子结点之间互为兄弟结点
-
子树:子节点与子节点所有的后代所组成的树
-
左子树: 左侧的子节点与子节点所有的后代所组成的树
-
右子树: 右侧的子节点与子节点所有的后代所组成的树
-
节点的度:子树的个数
-
树的度:所有节点的度 中的 最大值 为树的度
-
叶子节点:节点的度为0的节点
-
非叶子节点:节点的度不为0的节点
-
树的层数:根节点第一层(或第0层) 子节点节点第二层 依次类推
-
节点的深度(depth):从根节点到当前节点 所经过路径上的节点总数
-
节点的高度(height):从当前节点到最远的叶子节点 所经过路径上的节点总数
-
树的深度:所有节点深度中的最大值
-
树的高度:从跟节点到最远的叶子节点 所经过路径上的节点总数
-
森林:由m(m>=0)棵互不相交的树的集合称为森林。
3.1 二叉树(Binary Tree)
二叉树(binary tree) 是指树中节点的度不大于2的有序树,它是一种最简单且最重要的树。二叉树的递归定义为:二叉树是一棵空树,或者是一棵由一个根节点和两棵互不相交的,分别称作根的左子树和右子树组成的非空树;左子树和右子树又同样都是二叉树 [2] 。
二叉树的特点:
- 每个节点最多有2颗子树(度最大为2 )
- 所有的子树都是有序的
- 即使只有一个子树也要分左右子树
二叉树的性质:
-
二叉树的第i层上至多有2^i-1(i≥1)个节点
-
深度为h的二叉树中至多含有2^h-1个节点
-
若在任意一个二叉树中,有n0个叶子节点,有n2个度为2的节点,则必有n0 = n2+1
-
具有n个节点的完全二叉树深为log2x+1(其中x表示不大于n的最大整数)
-
若对一棵有n个节点的完全二叉树进行顺序编号(1 ≤ i ≤ n),那么,对于编号为i(i ≥ 1)的节点:
当i = 1时,该节点为根,它无双亲节点 当i > 1时,该节点的双亲节点的编号为i/2 若2i ≤ n,则有编号为2i的左节点,否则没有左节点 若2i+1 ≤ n,则有编号为2i+1的右节点,否则没有右节点
真二叉树: 二叉树中只有度为0的结点和度为2的结点 (如果有度为1的节点就不是真二叉树)
满二叉树 : 二叉树中只有度为0的结点和度为2的结点,并且度为0的结点都在最后一层,满二叉树也是也是真二叉树
完全二叉树: 深度为k,有n个结点的二叉树当且仅当其每一个结点都与深度为k的满二叉树中编号从1到n的结点一一对应时,称为完全二叉树,叶子节点只会出现在最后两层,且最后1层的叶子节点都是靠左对齐,从根节点到倒数第二层一定是一颗满二叉树
完全二叉树的性质:
-
度为1的节点只有左子树
-
度为1 的节点要么只有1个 要么是0个
-
同样节点数量的二叉树,完全二叉树的高度最小 (15个节点普通二叉树可为5层,但满二叉树一定只有4层)
-
假设完全二叉树的高度为 h(h >= 1)
-
至少有2^(h-1)个节点 (2^0 + 2^1 + 2^2 + ...+2^(h-2) + 1 ) = 2^(h-1)
-
最多有(2^h) - 1个节点 (2^0 + 2^1 + 2^2 + ...+2^(h-1) ) = (2^h)-1,满二叉树
-
若 完全二叉树的总 结点数量为n。
-
叶子节点的数量
若 叶子节点(度为0)为n0, 度为1的节点为n1, 度为2的节点为n2 则 n = n0+n1+n2 二叉树的性质 :若在任意一个二叉树中,有n0个叶子节点,有n2个度为2的节点,则必有n0 = n2+1 则 n0 = n2+1 , n2 = n0-1 n = n0+n1+n0-1 = 2n0+n1-1 完全二叉树性质:度为1 的节点要么只有1个 要么是0个 所以 若n1 = 1 则n为偶数, n = 2n0+1-1 = 2n0, n0 = n/2 , 所以 若n1 = 0 则n为奇数, n = 2n0+0-1 = 2n0-1, n0 = (n+1)/2 所以 奇数偶数结合公式: n0 = floor(n+1) // 向下取整 代码: 叶子节点 = (n+1) >> 1 -
非叶子节点的数量
非叶子节点数量为 n1+n2: 叶子节点:若n1=1,n0 = n/2 , 叶子节点:若n1=0,n0 = (n+1)/2 则: 若n1 = 1 则n为偶数, n1+n2 = n-(n/2) = (2n-n)/2 = n/2, 若n1 = 0 则n为奇数, n1+n2 = n-(n+1)/2 = (2n-n-1)/2 = (n-1)/2 所以: n1+n2 = floor(n-1)/2 // 向下取整 -
完全二叉树的高度
根据 :高度为h的完全二叉树 至少有2^(h-1)个节点,至多有(2^h)-1 所以: 2^(h-1) <= n < 2^h h-1 <= log2n < h // 对数 eg: log2n = 5.5 , h-1 <= 5.5 < h , h = 6 h = floor(log2n)+1 // 向下取整 总结公式
-
-
一棵有n个节点的完全二叉树(n>0),从上到下 从左到右对节点从0/1开始编号,对任意i个节点
从 x 开始编号 (x=0或1) 0开始编号 x = 0 1开始编号 x = 1 如果 i = x 它就是根节点 它就是根节点 如果 i > x 父节点的编号 为 floor(i/2) 父节点的编号 为 floor((i-1)/2) 如果 2i+x <= n-x 左子节点编号为 2i 左子节点编号为 2i+1 如果 2i+x > n-x 它没有左子节点 它没有左子节点 如果 2i+1+x <= n-x 右子节点编号为 2i+1 右子节点编号为 2i+2 如果 2i+1+x > n-x 它没有右子节点 它没有右子节点 注意若从0编号的话 则公式会有响应变化
-
3.2 二叉搜索树(Binary Search Tree)
二叉搜索树(又:二叉查找树,二叉排序树)它或者是一棵空树,或者是具有下列性质的二叉树:
- 若它的左子树不空,则左子树上任意结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上任意结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉搜索树。
二叉搜索树作为一种经典的数据结构,它既有链表的快速插入与删除操作的特点,又有数组快速查找的优势;
应用十分广泛,例如在文件系统和数据库系统一般会采用这种数据结构进行高效率的排序与检索操作。
二叉搜索树的元素必须具备可比较性 eg: int double 等类型 如果自定一类型需要制定比较方式
例如:一个有序的动态数组中搜索数据 [3,4,6,8,9,7,10,11,14.16.18]
- 二分查找法 搜索一个数的最坏时间复杂度为O(logn) 但添加和删除的 时间复杂度 最坏为 O(n)
- 二叉搜索树 搜索一个数、添加、删除的 的时间复杂度 最坏均为为 O(logn)
3.2.1 前序遍历
根节点 - 左子树 - 右子树
3.2.2 中序遍历
左子树 - 根节点 - 右子树
3.2.3 后序遍历
左子树 - 右子树 - 根节点
3.2.4 层序遍历
从上到下 从左到右 一次访问每一个节点
3.2.5 二叉搜索树相关算法题
3.3 AVL树(AVL tree)
在计算机科学中,AVL树是最先发明的自平衡二叉查找树。在AVL树中任何节点的两个子树的高度最大差别为1,所以它也被称为高度平衡树。增加和删除可能需要通过一次或多次树旋转来重新平衡这个树。
AVL树得名于它的发明者G. M. Adelson-Velsky和E. M. Landis,他们在1962年的论文《An algorithm for the organization of information》中发表了它。
3.3.1 基本概念及特点
基本概念
- 平衡因子: 某个节点和左右子树的高度
AVL树的特点
-
本身首先是一颗二叉搜索树。
-
带有平衡条件:每个结点的左右子树的高度之差的平衡因子只可能是 -1,0,1 。绝对值是1
-
每个节点节点的高度差的绝对值 > 1 则这棵树 失衡。
-
搜索、添加、删除的时间复杂度为 O(logn)
失衡的例子:
左边的树为 平衡二叉搜索树 在 节点13处添加一个14
会导致 节点 15 以及父节点18 都处在了失衡的状态 最坏的情况下可能导致所有的祖先节点全部失衡
3.3.2 恢复平衡:LL-右旋转
3.3.3 恢复平衡:RR-左旋转
3.3.4 恢复平衡:LR-先左再右
3.3.5 恢复平衡:RL-先右再左
3.3.6 AVL总结
添加
- 可能会导致所有的祖先节点都失衡
- 只要让高度最低的失衡点回复平衡 ,整棵树就恢复平衡了 仅需要O(1)次的调整
删除
- 可能会导致父节点或祖先节点失衡(只有一个节点会失衡)
- 恢复平衡后,可能会导致更高层的祖先节点失去平衡, 所以要循环到根节点进行检测 最多需要O(logn)次调整
平均复杂度
- 搜索 O(logn)
- 添加:O(logn) 仅需要O(1)次调整操作
- 删除:O(logn) 最多需要O(logn)次调整操作
3.3.6 AVL相关算法题
3.4 B树(B tree)
3.4.1 基本概念
在B-树中查找给定关键字的方法是,首先把根结点取来,在根结点所包含的关键字K1,…,Kn查找给定的关键字(可用顺序查找或二分查找法),若找到等于给定值的关键字,则查找成功;否则,一定可以确定要查找的关键字在Ki与Ki+1之间,Pi为指向子树根节点的指针,此时取指针Pi所指的结点继续查找,直至找到,或指针Pi为空时查找失败。
B树是一种平衡的多路搜索树,多用于文件系统、数据库的实现
- 一个节点可以存储超过两个元素、可以拥有超过两个子节点
- 拥有二叉搜索树的一些性质
- 平衡,每个节点的多有子树高度一致
- 比较挨
B树的性质
- 假设一个节点存储的元素个数为x个 m为阶数(m>=2)
- 元素个数: 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树
- ...
- 元素个数: x
B树和二叉搜索树 在逻辑上是等价的
- 多代节点合并,可以获得一个超级节点
- 2代合并的超级节点 最多拥有4个子节点 至少是4阶B树
- 3代合并的超级节点 最多拥有8个子节点 至少是8阶B树
- N代合并的超级节点 最多拥有2^n个子节点 至少是2^n阶B树
m阶B树,最多需要log2m代合并
3.4.2 添加 - 上溢
性质:
- 新添加的元素必定添加到叶子节点
- 产生上溢情况 节点元素必定等于m(阶数)
- 假设上溢的节点最中间的位置的元素是K, 并分裂子节点
- 将K位置的元素向上与父节点合并
- 将[0,k-1] 和 [k+1,m-1] 位置的元素分裂成2个子节点。
- 一次分裂之后,可能会导致父节点出现上溢的情况 循环执行上诉方法 最坏结果是执行到根节点
3.4.3 删除 - 下溢
-
如果需要删除叶子节点 就直接删除
-
如需删除的元素在非叶子节点
- 先把前驱或后继节点找出,覆盖所需删除的节点
- 再把前驱或后继节点删除
-
删除下溢 - 兄弟节点借用元素
- 元素的节点必然等于
⌈m/2⌉ -2 - 如果下溢节点临近的兄弟节点,至少有
⌈m/2⌉个元素,可以借一个元素 - 将父节点元素插入到下溢节点的 最小位置
- 用兄弟节点的元素 替代父节点的元素
- 也就是之前所说的旋转
- 元素的节点必然等于
-
删除下溢 - 合并左右子节点
- 如果下溢的节点临近的兄弟节点的元素个数之后
⌈m/2⌉-1个 - 将父节点的元素 挪下来 和齐左右子节点合并
- 这个操作可能导致下溢一直向上传播
- 如果下溢的节点临近的兄弟节点的元素个数之后
3.4.4 动画演示推荐
为了更好的理解 B树 给大家推荐一个网站 :www.cs.usfca.edu/~galles/vis…
3.4.5 B树相关算法题
3.5 红黑树(RED-BLACK-TREE)
红黑树(英语:Red–black tree)是一种自平衡二叉查找,是在计算机科学中用到的一种数据结构,典型用途是实现关联数组。它在1972年由鲁道夫·贝尔发明,被称为"对称二叉B树",它现代的名字源于Leo J. Guibas和罗伯特·塞奇威克于1978年写的一篇论文。红黑树的结构复杂,但它的操作有着良好的最坏情况运行时间,并且在实践中高效:它可以在O(logn)时间内完成查找、插入和删除,这里的n是树中元素的数目。
红黑树是2-3-4树的一种等同。换句话说,对于每个2-3-4树,都存在至少一个数据元素是同样次序的红黑树。
下面讲的红黑树也会涉及到B树的一些概念,如果没看过B树的建议先向上滑动浏览一遍B树的基本添加删除等操作。
3.5.1 红黑树的性质
红黑树是每个结点都带有颜色属性的二叉查找树,颜色或红色或黑色。在二叉查找树强制一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:
-
节点是红色或者黑色
-
根节点一定是黑色
-
所有的叶子节点都是黑色。(叶子是null节点)
-
每个红色节点的两个子节点都是黑色。
- 红色节点的父节点一定是黑色
- 每个叶子节点到跟的所有路径上不能有两个连续的红色节点
-
从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
我们在维护一个红黑树的时候 只要满足以上5种性质 就可以了。
在真正开始红黑树之前 需要先了解一下B树 来为我们进行辅助的了解作用,其实红黑树也可以转为2-3-4树 也就是4阶B树 ,接下来的 添加删除等操作 如果我们结合2-3-4树来理解 会更清晰一些
3.5.2 添加 - 性质维护
由于B树的性质 新添加的元素一定是添加到叶子节点中 并且 4阶B树 的元素个数 1~3个 (非叶子节点会使用节点的前驱或后继节点替换后删除)
我们首先以二叉查找树的方法增加节点并标记它为红色。(如果设为黑色,就会导致根到叶子的路径上有一条路上,多一个额外的黑节点,这个是很难调整的。但是设为红色节点后,可能会导致出现两个连续红色节点的冲突,那么可以通过颜色调换(color flips)和树旋转来调整。)下面要进行什么操作取决于其他临近节点的颜色。同人类的家族树中一样,我们将使用术语叔父节点来指一个节点的父节点的兄弟节点。注意:
- 性质1和性质3总是保持着。
- 性质4只在增加红色节点、重绘黑色节点为红色,或做旋转时受到威胁。
- 性质5只在增加黑色节点、重绘红色节点为黑色,或做旋转时受到威胁。
- 如果添加的是根节点 染成黑色即可
红黑树的添加 一共有以下13个位置 我会根据颜色顺序进行讲解
在次之前我们需要了解一个二叉树各个节点之间的关系网,如下图所示:
注意 所有的新节点都默认为红色
func afterAdd(_ node: Any){
guard let n = node as? TreeNode<Any> else { return } // 如果node是空的
insert_case1(n)
}
情况1 :
假设,新节点node位于树的跟上,没有父节点。
违背的性质:
- 性质2:根节点一定是黑色
解决方案:
func insert_case1(_ node:TreeNode<Any>) {
if node.parent == nil {
dyed_black(node)
}else{
insert_case2(node)
}
}
情况2:
假设,新节点的父节点parent黑色(新节点node是红色) 所以满足所有RBTree的性质 无需处理
违背的性质:
- 无
解决方案:
func insert_case2(_ node:TreeNode<Any>) {
if isBlack(node.parent) {
return
}else{
insert_case3(node)
}
}
情况3 :
假设,新节点node的父节点和叔父节点都是红色 ( 新插入的节点是红色)
下面的例子是 添加的新节点 10
违背的性质:
- 性质4:每个红色节点的两个子节点都是黑色。
- 红色节点的父节点一定是黑色
- 每个叶子节点到跟的所有路径上不能有两个连续的红色节点
解决方案:
-
将parent节点和uncle节点染成黑色
此情形就是B树中添加节点不符合B树的特性 需要将中间的节点进行向上合并并分裂
-
guard向上合并 并分列
-
这时候发现可能会造成父节点也变成了 不符合红黑树的特性 只需要将grand当成一个新的添加的节点进行处理在进行处理一遍就好了
此案例中 再次上溢 导致根节点是红色 insert_case1(dyed_red(node.grand)) 后将根节点染为红色
func insert_case3(_ node:TreeNode<Any>) {
// 到这里 parent 一定是红色
if isRed(node.uncle){ // parent 和 uncle 都是红色
dyed_black(node.parent)
dyed_black(node.uncle)
afterAdd(dyed_red(node.grand))
}else{ // 叔父不是红色或者缺少
insert_case4(node)
}
}
情况4 :
假设,父节点是红色而叔父节点是黑的或者缺少,
node是parent节点的左子节点 并且 parent节点是grand节点的右子节点
或者
node是parent节点的右子节点 并且 parent节点是grand节点的左子节点
违背的性质:
- 性质4:每个红色节点的两个子节点都是黑色。
- 红色节点的父节点一定是黑色
- 每个叶子节点到跟的所有路径上不能有两个连续的红色节点
解决方案:
如果: node是parent节点的左子节点 并且 parent节点是grand节点的右子节点
将node的parent向右旋转
如果: node是parent节点的右子节点 并且 parent节点是grand节点的左子节点
将node的parent向左旋转
之后就变成了情况5的情形 直接按照情况5 进行处理就可以了
注意 此时的 node已经变了 需要处理node节点为旋转后的节点
func insert_case4(_ node:TreeNode<Any>) {
// 叔父不是红色或者缺少
var tempNode = node
if isLeftChild(node) && isRightChild(node.parent){ // LR
rotate_left(node.parent)
tempNode = node.left!
}else if isRightChild(node) && isLeftChild(node.parent){ // RL
rotate_right(node.parent)
tempNode = node.right!
}
// 注意这时候如果进行情况5的话 node的值已经变化了
insert_case5(tempNode)
}
情况5 :
假设,父节点是红色而叔父节点是黑的或者缺少,
node是parent节点的左子节点 并且 parent节点是grand节点的左子节点
或者
node是parent节点的右子节点 并且 parent节点是grand节点的右子节点
这个和 情况4 旋转之后是一样的
违背的性质:
- 性质4:每个红色节点的两个子节点都是黑色。
- 红色节点的父节点一定是黑色
- 每个叶子节点到跟的所有路径上不能有两个连续的红色节点
解决方案:
将此时的node的parent染成黑色 node的grand染成红色
如果:node是parent节点的右子节点 并且 parent节点是grand节点的右子节点
node的grand向左旋转
如果:node是parent节点的左子节点 并且 parent节点是grand节点的左子节点
node的grand向右旋转
func insert_case5(_ node:TreeNode<Any>) {
// 叔父不是红色或者缺少
dyed_black(node.parent)
dyed_red(node.grand)
if isLeftChild(node) && isLeftChild(node.parent){ // LL
rotate_right(node.grand)
}else if isRightChild(node) && isRightChild(node.parent){ // RR
rotate_left(node.grand)
}
}
3.5.3 删除 - 性质维护
删除节点同样的也是要维护红黑树的5条性质
在2-3-4树(4阶B树)中我们的删除操作 总是会在叶子节点(删除非叶子节点时,会找到前驱或后继节点替换后删除)。可以根据这一点去想想一下红黑树的删除的情况,情况是一样的 删除节点都是在叶子节点上进行操作的。
重点: 再删除之前需要声明 一个事情 在二叉树的删除中对 node是度为1的节点 的afterRemove做了调整
如果删除的是非叶子节点传递的 afterRemove(node); node的值是当前节点的前驱或者后继节点
func remove(_ n:BSNode<Any>?) {
guard var node = n else { return } // 如果node是空的
size -= 1
if node.hasTwoChild() {
let succeeding = successor(node) // 后继节点
node.value = succeeding.value
node = succeeding
}
let replacemrnt = node.left != nil ? node.left : node.right
if replacemrnt != nil { // node是度为1的节点 并且只会出现在第二层
replacemrnt?.parent = node.parent
// ...code
afterRemove(replacemrnt!)
}else if node.parent == nil { // node是叶子节点并且是根节点
// ...code
afterRemove(node) // 其它树中处理的方法
}else{ // node是叶子节点 但不是根节点
// ...code
afterRemove(node)
}
}
/// RBTree 红黑树中处理 删除之后的各种情况
func afterRemove(_ node: Any){
guard let n = node as? TreeNode<Any> else { return } // 如果node是空的
delete_case1(n) // 红黑树中的 情况开始
}
情况1 :
假设,传递过来用于替代的子节点是红色(删除的节点是红色的 或者 删除的是黑色的 但是度为2的节点 )
违背的性质:
- 性质2:无
解决方案:
如果删除的是 25, 传递过来的一定是他的前驱节点或者后继节点17或33,两个节点都是红色的 所以只做染黑处理就可以了。
func delete_case1(_ node:TreeNode<Any>) {
if isRed(node){
dyed_black(node)
return
}else{
delete_case2(node)
}
}
情况2 :
假设, 删除的是根节点
违背的性质:
- 性质2:无
解决方案:
如果传过来的node是根节点 红黑树中一定只有根节点。
func delete_case2(_ node:TreeNode<Any>) {
if node.parent == nil {
return
}else{
delete_case3(node)
}
}
情况3 :
假设:
- 删除黑色的子节点
- 删除的是右边的子节点
- 删除的节点的兄弟节点是红色
违背的性质:
- 性质5:从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
解决方案:
先将sibling染成黑色、parent染成红色
然后对parent进行右旋转 这时 55的子节点就是88、88的左子节点是 76
之后就变成了情况4或情况5的情形 直接按照情况4或情况5 进行处理就可以了
// 删除黑色的子节点 && 删除的是右边的子节点 && 删除的节点的兄弟节点是红色
func delete_case3(_ node:TreeNode<Any>) {
// 走到这里时 这个节点已经被删除了。 所以 直接调用sibling是拿不到的
// 被删除的node是左还是右
// 如果parent.right == nil 的话 说明这个就是被删除的节点 || 被删除的是否是右子节点
let isRight = node.parent?.right == nil || isRightChild(node)
// 兄弟节点
var sibling = isRight ? node.parent?.left: node.parent?.right
if isRight { // 如果删除的是右边的节点
// 兄弟节点是红色
if isRed(sibling) {
dyed_black(sibling)
dyed_red(node.parent)
rotate_right(node.parent)
sibling = node.parent?.left
}
delete_case4(node,sibling!)
}else{
delete_case5(node, sibling!)
}
}
情况4 :
假设:
- 删除黑色的子节点
- 删除的是右边的子节点
- 删除的节点的兄弟节点是黑色
- 兄弟节点的子节点均是黑色
违背的性质:
违背的性质:
- 性质5:从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
解决方案:
将parent染成黑色、sibling染成红色
如果原始parent是黑色 会导致parent下溢的情况 ,这时只需要把parent当做新删除的节点处理即可。
func delete_case4(_ node:TreeNode<Any>, _ s:TreeNode<Any>) {
// 兄弟节点的子节点都是黑色
var sibling = s
// 兄弟节点左右都是黑色 或 兄弟节点是子节点
if isBlack(sibling.left) && isBlack(sibling.right) {
let parentBlack = isBlack(node.parent)
dyed_black(node.parent)
dyed_red(sibling)
if parentBlack {
afterRemove(node.parent!)
}
}else{
// 兄弟节点左右存在红色的子节点
delete_case5(node, sibling)
}
}
情况5 :
假设:
- 删除黑色的子节点
- 删除的是右边的子节点
- 删除的节点的兄弟节点是黑色
- 兄弟节点的存在红色子节点
违背的性质:
- 性质5:从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
解决方案:
这里我们可以结合 2-3-4阶B树思考 在B树中遇到这种情况系的处理方式就是下溢的处理方式也是一样的
案例一: 如图 下溢后我们进行一次右旋转 并将旋转后的中金节点的颜色继承parent节点的颜色
案例二: 如图 下溢后我们先进行一次左旋转在进行一次右旋转 并将旋转后的中金节点的颜色继承parent节点的颜色
案例三: 如图 这里的情况可以选择与案例一相似或者案例二相似 因为案例一之旋转一次 所以这我们选择处理的方式与案例一相同。
func delete_case5(_ node:TreeNode<Any>, _ s:TreeNode<Any>) {
var sibling = s
if isBlack(sibling.left) {
rotate_left(sibling)
sibling = (node.parent?.left)!
}
if node.parent?.color == RED {
dyed_red(sibling)
}else{
dyed_black(sibling)
}
dyed_black(sibling.left)
dyed_black(node.parent)
rotate_right(node.parent)
}
情况6 :
假设,
- 删除黑色的子节点
- 删除的是左边的子节点
- 删除的节点的兄弟节点是黑色
- 兄弟节点的子节点均是黑色
这个与情况3 差别只是 删除的的左子节点还是右子节点。
与情况四的旋转和 sibling的获取是相反的 其他一致 这里就直接附上代码了。
违背的性质:
- 性质5:从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
解决方案:
// 删除的是左边
func delete_case6(_ node:TreeNode<Any>, _ s:TreeNode<Any>) {
var sibling = s
// 兄弟节点是红色
if isRed(sibling) {
dyed_black(sibling)
dyed_red(node.parent)
rotate_left(node.parent)
sibling = (node.parent?.right)!
}
delete_case6(node, sibling)
}
情况7 :
假设:
- 删除黑色的子节点
- 删除的是左边的子节点
- 删除的节点的兄弟节点是黑色
- 兄弟节点的子节点均是黑色
这个与情况4 差别只是 删除的的左子节点还是右子节点。
与情况四的旋转和 sibling的获取是相反的 其他一致 这里就直接附上代码了。
违背的性质:
- 性质5:从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
解决方案:
func delete_case7(_ node:TreeNode<Any>, _ s:TreeNode<Any>) {
let sibling = s
// 兄弟节点是黑色
if isBlack(sibling.left) && isBlack(sibling.right) {
let parentBlack = isBlack(node.parent)
dyed_black(node.parent)
dyed_red(sibling)
if parentBlack {
afterRemove(node.parent!)
}
}else{
delete_case8(node, sibling)
}
}
情况8 :
假设:
- 删除黑色的子节点
- 删除的是右边的子节点
- 删除的节点的兄弟节点是黑色
- 兄弟节点的存在红色子节点
这个与情况5 差别只是 删除的的左子节点还是右子节点。
与情况四的旋转和 sibling的获取是相反的 其他一致 这里就直接附上代码了。
违背的性质:
- 性质5:从任一节点其到每个叶子节点的所有路径 都有相同数目的黑色节点。
解决方案:
func delete_case8(_ node:TreeNode<Any>, _ s:TreeNode<Any>) {
var sibling = s
if isBlack(sibling.right) {
rotate_left(sibling)
sibling = (node.parent?.right)!
}
if node.parent?.color == RED {
dyed_red(sibling)
}else{
dyed_black(sibling)
}
dyed_black(sibling.right)
dyed_black(node.parent)
rotate_left(node.parent)
}
3.5.4 红黑树的复杂度分析
AVL树:
- 平衡标准比较严格: 每个左右子树的高度差不能超过1 (平衡因子)
- 最大高度: 1.44 * log2(n+2) - 1.328 (100W个节点,AVL树的最大高度为28)
- 搜索、添加、删除都是 O(logn)复杂度,其中添加仅需要O(1)次旋转调整,但是删除最多需要O(logn)次旋转调整
红黑树:
- 平衡标准比较宽松: 没有一条路径 会大于 其他路径的2倍
- 最大高度 2 * log2(n+1) (100W个节点 ,红黑树的最大高度为40)
- 搜索、添加、删除都是 O(logn)复杂的,其中添加、删除都仅需要 O(1)次旋转调整。
如何选择:
- 搜索的次数 远远 大于插入和删除的的次数 选择AVL树
- 搜索、插入、删除的次数几乎差不多,选择红黑树
- 相对于AVL树来说,红黑树牺牲了 平衡性的标准来换区 回复平衡时旋转的次数 整体来说要优于AVL树
- 红黑树的平均统计性能优于AVL树,实际应用中绝大多数都是选择的红黑树。
3.5.5 红黑树相关算法题
3.6 二叉堆(Heap)
二叉堆是一种特殊的堆,二叉堆是完全二元树(二叉树)或者是近似完全二元树(二叉树)。
二叉堆有两种:最大堆和最小堆。
最大堆:父结点的键值总是大于或等于任何一个子节点的键值;
最小堆:父结点的键值总是小于或等于任何一个子节点的键值。
应用场景 : 获取数据中的最大值(TopK问题)
| 方式 | 获取最大值 | 删除最大值 | 添加元素 | |
|---|---|---|---|---|
| 动态数组/双向链表 | O(n) | O(n) | O(1) | |
| 有序动态数组/ 双线关联表 | O(1) | O(1) | O(n) | |
| 平衡二叉搜索树 | O(logn) | O(logn) | O(logn) | |
| 堆 | O(1) | O(logn) | O(logn) |
堆的一个重要性质:
- 任意节点的值总是大于等于子节点的值 称之为 最大堆 大根堆 大顶堆
- 任意节点的值总是小于等于子节点的值 称之为 最小堆 小根堆 小顶堆
根据 完全二叉树的性质,二叉堆的底层实现一般使用数组就可以了
- 索引
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, 它没有右子节点
推荐阅读:数据结构 | 微博 Top 10 热搜是怎么计算出来的?(二叉堆)
3.6.1 堆相关算法题
3.7 查找树\字典树(trie tree)
又称单词查找树,Trie树,是一种、树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
搜索字典项目的方法为:
(1) 从根结点开始一次搜索;
(2) 取得要查找关键词的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;
(3) 在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索。
(4) 迭代过程……
(5) 在某个结点处,关键词的所有字母已被取出,则读取附在该结点上的信息,即完成查找。
其他操作类似处理
Trie的优点:搜索前缀的效率主要跟前缀的长度有关
Trie的缺点:需要消耗大量的内存
3.8 哈夫曼树(Huffman Tree)
给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
例子:
- 哈弗曼树是现在的压缩算法的基础
- 假设要把字符串【ABBBCCCCCCCCDDDDDDEE】
- 可以转化成ASCII码(65
69, 10000011000101) ,但是会很长 怎么变短嗯? - 约定字母对应的二进制 A: 000 , B:001, C:010, D:011, E:100
- 对应二进制码:000 001001001 010010010010010010010010 011011011011011011 100100
- 20个字母转化成了 60个二进制位
- 可以转化成ASCII码(65
- 如果使用哈夫曼树 可以压缩至41个二进制位
- 先计算处每个字母出现的 频率 A:1, B:3, C:8, D:6, E:2
- 利用这些权重构建一个哈弗曼树
-
如何构建一颗哈弗曼树?
- 以权重值作为根节点构建n棵二叉树,组成森林
- 在森林中选出2个根节点最小的树合并,作为一颗新树的左右子树,且新树的根节点为其左右子树根节点之和
- 从森林中删除刚才选取的2棵树,并将新树加入森林
- 重复2、3 直达森林变为一棵树为止。
-
left为0 right为1, 可以得出5个字母
A:1110, B:110 , C:0 , D:10 , E:1111,
最终哈夫曼编码为: 11110110110110000000001010101010101111
-
总结:
-
n个权值构建出来的哈夫曼树 拥有那个叶子节点
-
每个哈夫曼编码都不是另一个哈夫曼编码的前缀
-
哈夫曼树是带权路径长度最短的树,权值较大的节点离根节点较近
带权路径长度:树中所有的叶子节点的权值 乘上 其到根节点的路径的长度,最终的哈夫曼编码的总长度成正比。
-
算法
...
...
参考: