├── .gitignore ├── .DS_Store ├── 18-素数筛小记.md ├── 27-状态机初探.md ├── 17.2-红黑树之插入调整(手撕算法篇).md ├── readme.md ├── 17.1-红黑树之插入调整(数据结构基础篇).md ├── 20.1-动态规划(算法基础篇).md ├── 28.1-金融系统中的RSA算法.md ├── 14.1-数据结构中的“渣男”——单调栈(算法基础篇).md ├── 12.1-深搜(DFS)与广搜(BFS):初识问题状态空间(算法基础篇).md ├── 08.1-归并排序Merge-Sort(从二路到多路).md ├── 19.1-递推算法与递推套路(算法基础篇).md ├── 09.1-排序思想.md ├── 17.3-红黑树之删除调整(数据结构基础篇).md ├── 07.2-排序算法之快速排序思想相关算法.md ├── 20.2-动态规划(手撕算法篇).md ├── 25.1-欧几里得算法.md ├── 06.1-并查集(数据结构基础篇).md ├── 20.3-动态规划(算法优化篇).md ├── 21.2-字符串的经典匹配算法(手撕算法篇).md ├── 13.1-单调队列(数据结构基础篇).md ├── 23.1-哈夫曼编码与二叉字典树(数据结构基础篇).md ├── 24.1-前缀和与树状数组(数据结构基础篇).md ├── 19.2-递推算法与递推套路(手撕算法篇).md ├── 13.2-单调队列(手撕算法篇).md ├── 10.1-二分查找.md ├── 04.1-你真的了解二叉树吗(树形结构基础篇).md ├── 03-递归与栈(解决表达式求值问题).md ├── 22.2-字典树与双数组字典树(手撕算法篇).md ├── 02-线程池与任务队列.md ├── 22.1-字典树与双数组字典树(数据结构基础篇).md ├── 21.1-字符串的经典匹配算法(算法基础篇).md └── 16.1-AVL树(数据结构基础篇).md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /26.1 -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiner-tang/coder/HEAD/.DS_Store -------------------------------------------------------------------------------- /18-素数筛小记.md: -------------------------------------------------------------------------------- 1 | ## 概念 2 | 3 | 素数筛就是使用素数筛选调合数的算法。 4 | 5 | 最小的`素数`为2,我们首先将2的倍数都标记为`合数`,如:4,6,8,10,12,14,16,18.... 6 | 7 | 然后找到下一个没有没标记的数,即:`3`,并将3的倍数标记为合数:6,9,12,15,18.... 8 | 9 | 然后在找到下一个没有被标记的数,即`5`,并将5的倍数标记为合数:5,10,15,20,25... 10 | 11 | 以此类推 12 | 13 | -------------------------------------------------------------------------------- /27-状态机初探.md: -------------------------------------------------------------------------------- 1 | # 状态机初探 2 | 3 | ## 何为状态机 4 | 5 | 状态机(State Machine),顾名思义,是一种带有状态的机器。可以用于抽象程序世界乃至真实世界中带有状态的现象。 6 | 7 | ## 哪些是状态机 8 | 9 | ### 真实世界 10 | 11 | - 门 12 | - 状态:已开启(opened)、已关闭(closed) 13 | - 状态转义函数:开门(openDoor)、关门(closeDoor) 14 | - 通常状态转义函数的参数是状态转移的条件,如已开启时我们才能关门,因此参数是开启状态 15 | - 通常函数的返回值就是新的状态,如原本已开启的门,通过关门的动作最终使得门变成已关闭 16 | - 计算机 17 | - 空调 18 | - 咖啡机 19 | - ...所有有状态的机器 20 | 21 | ### 程序世界 22 | 23 | - 正则表达式的底层实现就是一个状态机 24 | 25 | 正则表达式`/^a+$/`如何匹配字符串`aaaaaaa`呢?实际上,我们正则表达式的底层实现中,是将字符串的每一个字符都`喂`给一个状态机,当状态机发现传入的字符是`a`则继续等待下一个字符进入,直到所有字符串都匹配完成则匹配成功或者遇到一个非`a`字符则匹配失败。这一个状态机接受字符,返回值是匹配成功与匹配失败的新状态。 26 | 27 | - Vuex通过action将一个状态改变成另一个状态 28 | 29 | - 经典的字符串匹配算法`Shift-And`和`KMP`算法本质上也是一个状态机 30 | 31 | - ... -------------------------------------------------------------------------------- /17.2-红黑树之插入调整(手撕算法篇).md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 我们已经学习了红黑树的概念、基本性质、平衡条件、与AVL树的关系,也学习了红黑树的集中失衡情况以及这些失衡情况都要如何进行调整,还通过`Typescript`手写了一个支持插入操作的红黑树结构。那么接下来,我们就通过一些相关的算法题来巩固一下这些知识点吧 4 | 5 | ## [LeetCode 1339. 分裂二叉树的最大乘积](https://leetcode-cn.com/problems/maximum-product-of-splitted-binary-tree/) 6 | 7 | ### 解题思路 8 | 9 | 这道题的意思就是让我们在一颗完整的二叉树上砍一刀,使得新生成的两个二叉树的和值的乘积最大。要如何保证乘积最大呢?是不是两个乘数越接近,乘积就越大。那么,我们要先求出整颗二叉树的和,然后取他的中位数。然后拆出来的两颗子树的,他们的各自的和值越接近中位数,最终得出的乘积就会越大 10 | 11 | ### 代码演示 12 | 13 | ![1336](https://ydschool-video.nosdn.127.net/16307381440911336.png) 14 | 15 | ## [LeetCode 971. 翻转二叉树以匹配先序遍历](https://leetcode-cn.com/problems/flip-binary-tree-to-match-preorder-traversal/) 16 | 17 | ### 解题思路 18 | 19 | 我们都知道,二叉树的前序遍历结果是`根左右`,那么,如果一个序列要与二叉树的前序遍历结果能够匹配的上的话,除了根节点要能匹配的上之外,根节点的左子树也要能跟序列的下一个节点匹配上,如果不匹配,我们就尝试反转一下左右子树,看看能不能匹配,如果发生了翻转,则将翻转的节点记录下来即可 20 | 21 | ### 代码演示 22 | 23 | ![971](https://ydschool-video.nosdn.127.net/1630745274924971.png) 24 | 25 | ## [LeetCode 117. 填充每个节点的下一个右侧节点指针 II](https://leetcode-cn.com/problems/populating-next-right-pointers-in-each-node-ii/) 26 | 27 | ### 解题思路 28 | 29 | 如果这道题没有「你只能使用常量级额外空间 」这个限制的话,那么,我们或许可以用我们之前学过的广度优先搜索`BFS`,但是,有了 额外空间的限制,由于广搜一般要借助队列进行辅助,而队列的大小跟节点正相关,明显占用了非常量级的额外空间。那么,我们要如何通过常量级的额外空间求解呢? 30 | 31 | 我们可以定义一个额外的方法`_connect`,用来进行每一层遍历串联,并返回下一层串联后的第一个节点。然后我们再不断的循环调用这个方法,直到这个方法返回的节点为空时说明已经到了最后一层了。那么到此,我们也就串联完成了整颗二叉树了。 32 | 33 | ### 代码演示 34 | 35 | ![117](https://ydschool-video.nosdn.127.net/1630749166729117.png) 36 | 37 | ## [449. 序列化和反序列化二叉搜索树](https://leetcode-cn.com/problems/serialize-and-deserialize-bst/) 38 | 39 | ### 解题思路 40 | 41 | 这道题对二叉树的考察点不多,但却考察了包括广义表、自动机的一些实现思路。 42 | 43 | 首先我们可以使用[广义表](https://baike.baidu.com/item/%E5%B9%BF%E4%B9%89%E8%A1%A8/3685109?fr=aladdin)的规则对二叉树进行序列化。 44 | 45 | 反序列化时,我们采用类似[有限状态自动机](https://baike.baidu.com/item/%E8%87%AA%E5%8A%A8%E6%9C%BA)的设计思路,设置一个通用的状态变更的“路由”,所有其他操作完成后,最终都会回到这个路由进入下一个状态的演变。 46 | 47 | ### 代码演示 48 | 49 | ![449](https://ydschool-video.nosdn.127.net/1630756173145449.png) -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Kiner算法算题记 2 | 3 | ## 前言 4 | 5 | 积沙成塔,滴水穿石,谁说一个前端程序员就不能有自己的自我修养了?`算法与数据结构`作为计算机领域通用技能,贯穿开发生涯始终,如果没有一个夯实的数据结构与算法基础,就犹如空中楼阁,无法长久而立。然而,作为一个前端开发工程师,经常会被人戴上不需要掌握数据结构与算法或者是粗浅掌握就够了的帽子。但是,在自己的开发与职业生涯中,却又屡屡因为这个短板遭受迎头痛击。终于,再某一个时刻,我终于下定决心,要系统的学习算法与数据结构,为以后的开发与职业生涯铺平道路,夯实地基。这也就是本项目创建的初衷,一来见证自己的成长历程,二来给后来与我有相似经历者一个引路明灯,不至于走弯路。 6 | 7 | ### 文章导引 8 | 9 | + [01-Kiner算法刷题记:链表和链表思想](./01-Kiner算法刷题记:链表和链表思想.md) 10 | + [02-线程池与任务队列](./02-线程池与任务队列.md) 11 | + [03-递归与栈(解决表达式求值问题)](./03-递归与栈(解决表达式求值问题).md) 12 | + [04.1-你真的了解二叉树吗(树形结构基础篇)](./04.1-你真的了解二叉树吗(树形结构基础篇).md) 13 | + [04.2-你真的了解二叉树吗(手撕算法篇)](./04.2-你真的了解二叉树吗(手撕算法篇).md) 14 | + [05.1-堆(Heap)与优先队列(堆的数据结构基础篇)](./05.1-堆(Heap)与优先队列(堆的数据结构基础篇).md) 15 | + [05.2-堆(Heap)与优先队列(手撕算法篇)](./05.2-堆(Heap)与优先队列(手撕算法篇).md) 16 | + [06.1-并查集(数据结构基础篇).md](./06.1-并查集(数据结构基础篇).md) 17 | + [06.2-并查集(手撕算法篇).md](./06.2-并查集(手撕算法篇).md) 18 | + [07.1-排序算法之快速排序思想与基础(Quick-Sort)](./07.1-排序算法之快速排序思想与基础(Quick-Sort).md) 19 | + [07.2-排序算法之快速排序思想相关算法](./07.2-排序算法之快速排序思想相关算法.md) 20 | + [08.1-归并排序Merge-Sort(从二路到多路)](./08.1-归并排序Merge-Sort(从二路到多路).md) 21 | + [08.2-归并排序Merge-Sort(手撕算法题)](./08.2-归并排序Merge-Sort(手撕算法题).md) 22 | + [09.1-排序思想](./09.1-排序思想.md) 23 | + [09.2-排序思想(手撕算法篇)](./09.2-排序思想(手撕算法篇).md) 24 | + [10.1-二分查找](./10.1-二分查找.md) 25 | + [10.2-二分查找(手撕算法篇)](./10.2-二分查找(手撕算法篇).md) 26 | + [11.1-哈希表与布隆过滤器(数据结构基础篇)](./11.1-哈希表与布隆过滤器(数据结构基础篇).md) 27 | + [11.2-哈希表与布隆过滤器(手撕算法篇)](./11.2-哈希表与布隆过滤器(手撕算法篇).md) 28 | + [12.1-深搜(DFS)与广搜(BFS):初识问题状态空间(算法基础篇)](./12.1-深搜(DFS)与广搜(BFS):初识问题状态空间(算法基础篇).md) 29 | + [12.2-深搜(DFS)与广搜(BFS):初识问题状态空间(算法基础篇)](./12.2-深搜(DFS)与广搜(BFS):初识问题状态空间(手撕算法).md) 30 | + [13.1-单调队列(数据结构基础篇)](./13.1-单调队列(数据结构基础篇).md) 31 | + [13.2-单调队列(手撕算法篇)](./13.2-单调队列(手撕算法篇).md) 32 | + [14.1-数据结构中的“渣男”——单调栈(算法基础篇)](./14.1-数据结构中的“渣男”——单调栈(算法基础篇).md) 33 | + [14.2-数据结构中的“渣男”——单调栈(手撕算法篇)](./14.2-数据结构中的“渣男”——单调栈(手撕算法篇).md) 34 | + [15-中段综合训练刷题](./15-中段综合训练刷题.md) 35 | + [16.1-AVL树(数据结构基础篇)](./16.1-AVL树(数据结构基础篇).md) 36 | + [16.2-AVL树(手撕算法篇)](./16.2-AVL树(手撕算法篇).md) 37 | + [17.1-红黑树之插入调整(数据结构基础篇)](./17.1-红黑树之插入调整(数据结构基础篇).md) 38 | + [17.2-红黑树之插入调整(手撕算法篇)](./17.2-红黑树之插入调整(手撕算法篇).md) 39 | + [17.3-红黑树之删除调整(数据结构基础篇)](./17.3-红黑树之删除调整(数据结构基础篇).md) 40 | + [18-素数筛小记](./18-素数筛小记.md) 41 | + [19.1-递推算法与递推套路(算法基础篇)](./19.1-递推算法与递推套路(算法基础篇).md) 42 | + [19.2-递推算法与递推套路(手撕算法篇)](./19.2-递推算法与递推套路(手撕算法篇).md) 43 | + [20.1-动态规划(算法基础篇)](./20.1-动态规划(算法基础篇).md) 44 | + [20.1-动态规划(手撕算法篇)](./20.2-动态规划(手撕算法篇).md) 45 | + [21.1-字符串的经典匹配算法(算法基础篇)](./21.1-字符串的经典匹配算法(算法基础篇).md) 46 | + [21.2-字符串的经典匹配算法(手撕算法篇)](./21.2-字符串的经典匹配算法(手撕算法篇).md) 47 | + [22.1-字典树与双数组字典树(数据结构基础篇)](./22.1-字典树与双数组字典树(数据结构基础篇).md) 48 | + [22.2-字典树与双数组字典树(手撕算法篇)](./22.2-字典树与双数组字典树(手撕算法篇).md) 49 | + [23.1-哈夫曼编码与二叉字典树](./23.1-哈夫曼编码与二叉字典树(数据结构基础篇).md) 50 | + [24.1-前缀和与树状数组(数据结构基础篇)](./24.1-前缀和与树状数组(数据结构基础篇).md) 51 | + [24.2-前缀和与树状数组(手撕算法篇)](./24.2-前缀和与树状数组(手撕算法篇).md) 52 | + [25.1-欧几里得算法](./25.1-欧几里得算法.md) 53 | + [27.1-状态机初探](./27.1-状态机初探.md) 54 | + [28.1-金融系统中的RSA算法](./28.1-金融系统中的RSA算法.md) 55 | 56 | -------------------------------------------------------------------------------- /17.1-红黑树之插入调整(数据结构基础篇).md: -------------------------------------------------------------------------------- 1 | ## 红黑树的基本概念 2 | 3 | > 红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在[计算机](https://baike.baidu.com/item/计算机)科学中用到的一种[数据结构](https://baike.baidu.com/item/数据结构/1450),典型的用途是实现[关联数组](https://baike.baidu.com/item/关联数组/3317025)。 4 | > 5 | > 红黑树是一种特化的AVL树([平衡二叉树](https://baike.baidu.com/item/平衡二叉树/10421057)),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。 [2] 6 | > 7 | > 它虽然是复杂的,但它的最坏情况运行时间也是非常良好的,并且在实践中是高效的: 它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。 8 | 9 | ![image-20210815210053687](https://ydschool-video.nosdn.127.net/1629032462242image-20210815210053687.png) 10 | 11 | ## 红黑树的平衡条件 12 | 13 | 1. **每个节点非黑即红** 14 | 2. **根节点是黑色** 15 | 3. **叶子节点(NIL)是黑色(注意,这里说的叶子节点实际上是指虚拟空节点NIL节点,所以上面给出的红黑树的图中,其实每个节点下面都还挂着两个NIL节点,这些NIL节点都是黑色)** 16 | 4. **如果一个节点是红色,那么他的两个子节点都是黑色** 17 | 5. **从根节点出发,所有的节点路径上,所有的黑节点数量相同** 18 | 19 | ## 对于平衡条件的理解 20 | 21 | ### 红黑树中最长路径与最短路径的关系 22 | 23 | 根绝红黑树的平衡条件4和5两点,我们知道,最短路径应该都是由黑色节点组成,而最长路径,则应该是红黑相间的,因此,**最长路径应该是最短路径的长度的2倍**。 24 | 25 | ### 如何理解平衡条件3中的NIL节点 26 | 27 | NIL节点就像是文章中的标点符号,虽然不属于文章内容的部分,平时也很容易被人忽略,但,如果一篇文章没有标点符号,会让文章开起来“狗屁不通”,非常费劲。这里的NIL也是一样。 28 | 29 | ### 新插入的节点是红色还是黑色 30 | 31 | 根据上面第5点平衡条件,所有路径上的黑色节点数量相同,如果我们新插入的节点颜色时黑色的话,那么一定会导致我们的红黑树的某一条路径上的黑色节点数量比其他路径多,红黑树一定会失衡。因此,我们新插入的节点应该是**红色**,因为插入红色节点,只是有一定的概率会失衡(当新节点插入到原本是红色的节点下面的时候会失衡,而插入到黑色节点下面则不会),但是,插入黑色节点,则必定会失衡。 32 | 33 | ## 红黑树平衡调整的法门 34 | 35 | > **PS: AVL树是站在父节点向下看是否失衡** 36 | 37 | ### 插入调整 38 | 39 | **插入调整站在祖父节点向下看,即站在当前节点,要解决下两层的平衡冲突问题(如果在插入调整时当前节点和子节点发生平衡冲突,不需要解决,因为当前节点是父节点,而插入调整是要在祖父节点的视角去调整的)** 40 | 41 | 举个例子:**如果你和你爸打架了,两个人都是杠精,此时就需要你爷爷出来调停** 42 | 43 | ### 删除调整 44 | 45 | **删除站在父节点向下看,即站在当前节点,要解决下一层的平衡冲突问题** 46 | 47 | ## 插入调整 48 | 49 | 插入调整就是为了干掉“双红节点”,即当前节点是红色,父节点也是红色。 50 | 51 | > 插入调整的原则:调整之前路径上黑色节点数量等于调整之后黑色节点数量 52 | > 53 | > 原因:由于我们插入调整时是在一个大的红黑树的某一小段的子树上操作的,为了确保整颗二叉树上的黑色节点不受影响,因此要确保该子树调整前和调整后黑色节点数量要想同 54 | 55 | ### 插入调整情况一 56 | 57 | ![case1](https://ydschool-video.nosdn.127.net/1630729879397case1.png) 58 | 59 | ### 插入调整情况二 60 | 61 | #### LL型失衡 62 | 63 | 从当前节点看,左子树时红色,左子树的左子树时红色,右子树是黑色,那么这种情况就是“LL型”失衡 64 | 65 | ![LL](https://ydschool-video.nosdn.127.net/1630729952772LL.png) 66 | 67 | #### LR类型失衡 68 | 69 | 从当前节点看,左子树时红色,左子树的右子树时红色,右子树是黑色,那么这种情况就是“LR型”失衡 70 | 71 | ![LR](https://ydschool-video.nosdn.127.net/1630729989485LR.png) 72 | 73 | #### RR类型失衡 74 | 75 | 与LL类型失衡相对称,进行一个大左旋之后,在按照`红色上浮`或`红色下沉`操作即可重归平衡 76 | 77 | #### RL型失衡 78 | 79 | 与LR型失衡相对称,先进行一个右旋,然后再按照RR类型的失衡调整方式处理即可 80 | 81 | ## 红黑树插入调整代码演示 82 | 83 | > 处于渐进式学习的原因,此处只给出了红黑树插入调整相关的代码,完整代码请移步[红黑树之删除调整](https://kms.netease.com/article/41029) 84 | 85 | ![rbTree](https://ydschool-video.nosdn.127.net/1630721492769rbTree.png) 86 | 87 | ### 尝试还原红黑树 88 | 89 | ![restore](https://ydschool-video.nosdn.127.net/1630722593577restore.png) 90 | 91 | ## 总结 92 | 93 | 通过上面的对照,我们发现,我们生成的红黑树是满足所有平衡条件的,从上面的红黑树中,我们也不难看出,红黑树是一个特殊的AVL树(平衡条件特殊),而AVL树又是特殊的二叉搜索树(多了高度差的限制),而二叉搜索树又是特殊的二叉树(中序遍历结果是有序序列)。 94 | 95 | 综上,我们可以知道,**一个高级数据结构基本上都是由一些相对低级的数据结构层层递进演化而来的,高级的数据结构也有可能是某些数据结构在某些情况的特殊化,高级数据结构继承了低级数据结构的特性,并具备了一些自己的新特性,这很像我们程序里面的继承**。 96 | 97 | -------------------------------------------------------------------------------- /20.1-动态规划(算法基础篇).md: -------------------------------------------------------------------------------- 1 | ### 前言 2 | 3 | 我们之前学习了递推算法和地推套路,也说过了递推算法与动态规划暧昧不清的关系,那么,今天就来初步的学习一下动态规划。 4 | 5 | ### 状态转移方程 6 | 7 | 我们之前学习递推算法时所使用的递推公式,在动态规划中叫做:**状态转移方程** 8 | 9 | #### 重点 10 | 11 | 1. **状态:**一个数学符号加上语义描述 12 | 2. **决策:**从所有可能产生最优解的答案当中找出一个最大值或最小值。(动态规划的重点就在于决策,如果一个算法,不需要决策就能得到最优解,这类算法称为`贪心算法`,至于什么是`贪心算法`不在我们今天的讨论范畴,我们暂且略过,之后再一起学习。) 13 | 3. **阶段:**当前阶段仅仅依赖上一个阶段 14 | 15 | ### 递推问题的求解方向 16 | 17 | - **我(目标值)从哪里来:**求当前状态时查看前置依赖项(pull,拉取别人的更新,就像`React`和`Angular`数据变化侦测的方式) 18 | 19 | 如之前我们学过的数字三角形的递推公式:`dp[i][j] = max(dp[i-1][j], dp[i-1][j-1]) + val[i][j]`中,我们要求得`dp[i][j]`的话,必须先得到`dp[i-1][j]`和`dp[i-1][j-1]`的值。这就是从哪里推导出目标值的求解方向。 20 | 21 | - **我到哪里去:**求得当前状态时主动更新下一个状态(push,主动推送自己的更新,就像`Vue`侦测数据变化的方式) 22 | 23 | 还是数字三角形的题,当我们计算出来了`dp[i-1][j]`的值时,我就去更新一下`dp[i][j]`的值,当我计算出`dp[i-1][j-1]`的结果时,我们再更新一下`dp[i][j]`的值,通过两次更新,我们也可以保证`dp[i][j]`的正确性。这种实现方式与上一种在思维层面和实现层面都有所不同,但也是一种可行的求解方向。 24 | 25 | 那么,什么情况下应该选择**我从哪里来**的求解方向求解,什么情况下选择**我到哪里去**的求解方向求解呢? 26 | 27 | **当我们的状态和阶段很明确,并且状态不多时,我们可以使用我从哪里来这种求解方向,这也是绝大部分动态规划问题的求解方向。而当某些情况下,我们很难说清楚我从哪里来,也就是状态和阶段太过于抽象或隐晦,或者依赖的状态实在是太多了,此时,我们可以采用我到哪里去这个求解方向。通过当前状态值主动更新下一阶段状态的值。** 28 | 29 | **任何一个动态规划的程序,都可以用上面的两种方式去实现** 30 | 31 | #### 示例 32 | 33 | 之前我们已经使用过“**我从哪里来**”的求解方向解决过数字三角形的问题,那么,我们现在来使用“**我到哪里去**”这个求解方向来求解试试 34 | 35 | ##### [120. 三角形最小路径和](https://leetcode-cn.com/problems/triangle/) 36 | 37 | ###### 解题思路 38 | 39 | 与“**我从哪里来**”通过上一行数据计算下一行的数据不同,“**我到哪里去**”是假设当前你已经求出来`dp[i][j]`了,那么,此时你可以利用这个已经求出来的结果去主动更新下一行的数据。 40 | 41 | ###### 代码演示 42 | 43 | ```typescript 44 | function minimumTotal(triangle: number[][]): number { 45 | const n = triangle.length; 46 | let dp: number[][] = []; 47 | // 由于后续每次都要比较求解最小值,因此,我们初始时将dp数组的每一位都初始化为最大整型,方便后续进行大小比较 48 | for(let i=0;i idx).join('\t')}`); 93 | console.log(`curr:\t ${arr.join('\t')}`); 94 | console.log(`prev:\t ${res.prev.join('\t')}`); 95 | console.log(`next:\t ${res.next.join('\t')}`); 96 | 97 | // indx: 0 1 2 3 4 5 6 7 8 9 98 | // curr: 6 7 9 0 8 3 4 5 1 2 99 | // prev: -1 0 1 -1 3 3 5 6 3 8 100 | // next: 3 3 3 10 5 8 8 8 10 10 101 | ``` 102 | 103 | -------------------------------------------------------------------------------- /12.1-深搜(DFS)与广搜(BFS):初识问题状态空间(算法基础篇).md: -------------------------------------------------------------------------------- 1 | ## 搜索的核心概念 2 | 3 | ### 问题求解树 4 | 5 | 问题求解树又叫问题状态树或状态求解树。这不是一个真实的计算机结构,而是我们思维逻辑层面的一种结构。如下图,我们可以将状态1展开得状态2、状态3、状态4,而状态3又展开得状态5和状态6. 6 | 7 | ```bash 8 | 状态1 9 | / | \ 10 | 状态2 状态3 状态4 11 | / \ 12 | 状态5 状态6 13 | ``` 14 | 15 | 光看上面的状态树很难理解问题求解树的实际使用场景,接下来,咱们来举一个例子: 16 | 17 | ```javascript 18 | // 走迷宫,如下的二位数组代表一个迷宫,小狗🐶要怎样才能找到自己心爱的肉骨头🍖呢,其中🏖代表是海滩,小狗可以走这里,💣代表是有炸弹,不能走,不能走 19 | const arr = [ 20 | [🐶,💣,🏖,🏖,🏖,🏖], 21 | [🏖,🏖,🏖,💣,🏖,🏖], 22 | [🏖,💣,🏖,💣,🏖,🏖], 23 | [🏖,🏖,🏖,🏖,🏖,🍖], 24 | ]; 25 | // 我们以每个节点的坐标作为我们问题求解树的状态,初始坐标为[0,0],即小狗所在的位置,那么,小狗每走一步我们就能知道他下一步能走的状态(坐标)是什么,于是就生成了这样的一棵问题求解树。 26 | 🐶 27 | [0,0] 28 | | 29 | [1,0] 30 | / \ 31 | [2,0] [1,1] 32 | | | 33 | [3,0] [1,2] 34 | | | 35 | [3,1] [2,2] 36 | | | 37 | [3,2] [3,2] 38 | / \ / \ 39 | [2,2] [3,3][3,1] [3,3] 40 | | | | | 41 | ... [3,4] ... [3,4] 42 | / \ / \ 43 | [2,4] [3,5] [2,4] [3,5] 44 | 🍖 🍖 45 | // 最终小狗终于找到了他想要的肉骨头了。上面的问题求解树是精简版的,没有吧所有情况都画出来,但已经能够说明问题了 46 | ``` 47 | 48 | 从上面的示例中我们可以发现:**一些与我们求解的问题息息相关的量(示例中就是坐标)构成了问题求解树的节点**,分析问题的时候,最重要的一步是**定义什么是这个问题的状态**。需要特别注意的是,上面所说的问题求解树都是我们大脑的思维结构,而不是在程序里面的真实数据结构,因为这个这棵树理论上来说是有无限中可能的,比如小狗在[0,0]和[1,0]这两个位置来回走,那么它就陷入了“死循环”,需要走无限步才能找到肉骨头。如果上述结构是我们程序中的结构的话,大家应该很清楚,出现死循环意味着什么,那就是程序崩溃。 49 | 50 | ### 搜索的核心概念 51 | 52 | #### 1. 什么是深搜和广搜 53 | 54 | **深搜和广搜**就是针对于问题求解树的不同的遍历方式,由于我们问题求解树是存在于思维逻辑层面的结构,因此这里所说的遍历也是在思维逻辑层面的遍历,不是具体的程序实现的遍历方式。 55 | 56 | ##### 深搜(DFS:深度优先搜索) 57 | 58 | 搜索时尽量往深处搜索,比如我们经常说的知识深度,就是对某些类型的知识点达到专家的地步,不断往深层次探究,最终到达计算机底层、硬件编程、材料与物理等领域。总结一下,就是**专家路线** 59 | 60 | **深搜**的过程其实类似于我们树形结构的递归遍历,先一条路走到黑,碰壁了,再往回走一步(**回溯**),如果有其他出路,就往另一条路一条路走到黑,直到碰壁(继续**回溯**)或到达终点(返回结果)。 61 | 62 | 总结一下:**深度优先遍历通常情况下是以递归的程序形态来实现的**。 63 | 64 | ##### 广搜(BFS:广度优先搜索) 65 | 66 | **适合最优化问题的求解** 67 | 68 | 搜索时尽量往远处搜索,比如我们经常说的知识广度,就是不一定对某类型的知识点达到很深的理解,但掌握的知识点所横跨的领域很广,什么都会一点,知识面广博。总结一下,就是**全栈路线**。 69 | 70 | **广搜**通常需要使用**层序遍历**的方式进行搜索,搜索过程需要额外借助**队列**这样的数据结构。也就是说,当我们遍历到第一层时,先将第二次层的所有节点加入队列,然后依次遍历队列中的每一个节点,再进入下一层继续上述操作。 71 | 72 | 看到这个描述,有没有一些二次元的小伙伴想到了什么?哈哈哈,就是影分身。假如鸣人执行某些任务时,发现前面有很多分叉路,他会怎么办,有多少条岔路,就分出多少个影分身,然后同时探索每一条岔路,如果之后再出现岔路,继续影分身,直到到达正确的目标点。假如说在某一条分叉路,同时有两个影分身到达入口,此时就没必要两个影分身一起行动了,可以让其中一个融入另一个影分身,节省查克拉,这种操作就是剪枝。 73 | 74 | ![影分身](https://ydschool-video.nosdn.127.net/1624067730828yingfenshen.jpeg) 75 | 76 | ![图示](https://ydschool-video.nosdn.127.net/1624186582283Xnip2021-06-20_18-53-54.jpg) 77 | 78 | 由于我们**广搜**始终是处理完上一层的所有节点之后,再处理下一层的所有节点,因此,**广搜更适合处理最优化问题,即求最优解**。就像上面说的,鸣人先让影分身去探路,找到最优路线。 79 | 80 | #### 2. 什么是搜索的剪枝和优化 81 | 82 | 通过排除某些明确不可能达到目的子树,在搜索时不搜索这些子树,以达到提升搜索效率的目的,这就是我们经常说的**搜索剪枝**。 83 | 84 | 拿上面的问题求解树来说,假如说我们通过某些判断手段,明确最左侧的子树不可能达到目标(可能出现死循环),不搜索最左边的子树,也就是在问题求解树中将最左边的“树枝”减掉,这样就不会分摊给其他树枝提供的“营养”(搜索资源)了。 85 | 86 | #### 3. 设计搜索算法的核心关键点 87 | 88 | 设计问题求解树中的**状态**。 89 | 90 | -------------------------------------------------------------------------------- /08.1-归并排序Merge-Sort(从二路到多路).md: -------------------------------------------------------------------------------- 1 | ## 定义 2 | 3 | > 所谓归并排序,就是在递归过程中将数组进行拆分,然后再回溯的过程中将递归的结果合并。因此,归并排序大体上分为两个步骤: 4 | > 5 | > 1. 递归分解 6 | > 2. 回溯合并 7 | 8 | ## 算法优势 9 | 10 | 大数据排序的`霸主级算法` 11 | 12 | ## 归并排序 13 | 14 | ### 二路归并排序 15 | 16 | 将两个有序数组合并为一个有序数组 17 | 18 | #### 原理 19 | 20 | ```bash 21 | # 例如将下面的数组进行排序 22 | [1,4,2,3,5,8,6,7,9,0] 23 | # 进行归并排序的时候,会将对整个数组排序的任务拆分成两个较小的子任务进行排序 24 | [1,4,2,3,5] [8,6,7,9,0] 25 | # 同理,我们再将上面的两个子任务各自拆分成更小的两个子任务 26 | [1,4] [2,3,5] [8,6] [7,9,0] 27 | # .... 28 | # 当我们子任务的数组元素小于或等于3(当然,这个子任务最小元素数量其实我们可以根据实际情况调整,只要能够让我们能够轻易的排序就可以了)个时,就可以停止继续递归拆分了,可以开始进行排序,排序结果如下 29 | [1,4] [2,3,5] [6,8] [0,7,9] 30 | # 然后对左右的子任务分别进行合并,注意,此处借用了额外的数组空间用来临时存储,当合并完成之后,再将这个临时数组的值复制到原本左/右任务的数组中去。这一个步骤是归并排序的重点:将两个有序数组合并成一个有序数组的过程。 31 | # 那么,我们要如何将两个有序数组合并成一个有序数组呢?我们可以让两个指针p1和p2分别指向两个有序数组的第一个元素,然后将这两个元素中较小的一个元素放入到临时数组的末尾,然后让这个较小元素所在数组的指针向后移动一位,继续上述过程进行比较插入,最终就可以将两个有序数组合并成一个有序数组了。 32 | # [【1】,2,3,4,5] [【0】,6,7,8,9] 33 | # 上面两个指针所指向的元素,0比较小,所以先将0放入临时数组末尾,此时临时数组为:[0] 34 | # 然后右边数组的指针向右移动一位 35 | # [【1】,2,3,4,5] [0,【6】,7,8,9] 36 | # 继续上述的比较,直至两个指针都到达了各自数组的末尾时,我们就已经将两个有序数组合并到了一个临时数组中了 37 | [1,2,3,4,5] [0,6,7,8,9] 38 | # 再将左右子任务进行合并 39 | [0,1,2,3,4,5,6,7,8,9] 40 | 41 | ``` 42 | 43 | #### 简单代码实现 44 | 45 | ```javascript 46 | function mergeSort(arr, l = 0, r = arr.length - 1) { 47 | // 当数组元素大于等于1,即左指针和右指针相遇或错过时退出递归过程 48 | if (l >= r) return; 49 | // 计算中间值,方便之后将原数组拆分成两个子任务 50 | const mid = (l + r) >> 1; 51 | // 进行左半边数组排序 52 | mergeSort(arr, l, mid); 53 | // 进行右半边数组排序 54 | mergeSort(arr, mid + 1, r); 55 | // 到这里,我们左右两边数组已经排序完成了,我们准备将左右两边的数组进行合并 56 | // p1是左数组的指针,初始指向左数组第一个元素,p2是有数组的指针,初始指向右数组第一个元素 57 | let p1 = l, 58 | p2 = mid + 1; 59 | 60 | let tmp = []; 61 | // 左右指针没有到达各自数组的右边界则继续循环,左指针的右边界是mid,右指针的右边界是r 62 | while (p1 <= mid || p2 <= r) { 63 | // 如果右数组为空或者左数组的指针指向的值比较小,就将所指元素放入临时数组tmp 64 | if ( 65 | p2 > r || // 右数组为空 66 | (p1 <= mid && arr[p1] <= arr[p2]) // 判断左指针指向的值比较小 67 | ) { 68 | tmp.push(arr[p1++]); 69 | } else { 70 | // 否则将右数组元素加入到临时数组 71 | tmp.push(arr[p2++]); 72 | } 73 | 74 | } 75 | 76 | // 将临时数组中的元素复制到原数组中 77 | arr.splice(l, tmp.length, ...tmp); 78 | // 释放临时数组空间 79 | tmp = null; 80 | } 81 | 82 | const arr = [8, 2, 1, 3, 4, 0, 8, 7, 5, 4, 6, 9]; 83 | console.time('mergeSort'); 84 | mergeSort(arr) 85 | console.timeEnd('mergeSort');// mergeSort: 0.0947265625 ms 86 | console.log(arr);// [0, 1, 2, 3, 4, 4, 5, 6, 7, 8, 8, 9] 87 | ``` 88 | 89 | ### 多路归并排序 90 | 91 | 多个有序数组合并成一个有序数组 92 | 93 | ### 变路归并排序 94 | 95 | 在排序的过程中计算当前的数组区间需要拆分为几个子问题进行归并排序,排序过程中拆分的子问题数量是不确定的 96 | 97 | ## 归并排序在大数据场景下的应用 98 | 99 | ### 在小内存设备上进行大数据排序 100 | 101 | > 在一个内存仅有2GB的电脑上,对40GB大小的文件进行排序 102 | 103 | 这种场景下,大家来思考一下,能够使用之前学习过的`快速排序`进行操作吗?显然是不行的。我们快速排序的基础是`Partition`操作,也就是分区操作,是对数组整体进行操作的,我们现在的数据量有40GB,明显是没有办法一次性将所有数据读取到内存中进行排序的,那么,这个时候,我们的归并排序就登场了。 104 | 105 | 我们来思考一下,我们刚刚在实现`二路归并排序`的时候使用的`tmp`这个临时数组,是不是只要支持在尾部添加元素就可以了,那么,大家再来想想,我们操作系统中的文件,是不是也支持在文件的尾部添加内容,我们是不是可以借助外部的存储空间`硬盘`来存储我们这些临时数据呢?我们的硬盘大小一般是远大于内存大小的。 106 | 107 | 我们可以将我们`40GB`的文件,拆分成20份`2GB`的文件进行排序,排好序之后,我们再使用`20路归并排序`将这20份有序的文件合并成一个`40GB`的有序文件(合并的过程中,如果是二路归并的话,每个子任务的最小值是显而易见的,但因为我们要在20份数据中获取最小值,所以我们要借助之前我们学习过的`小顶堆`,这样每次从20份数据中获取最小值也就轻而易举了),这样就解决了我们因为内存不足而导致无法排序超过内存大小数据的问题了。 108 | 109 | 这也是为什么归并排序特别适合处理大数据问题的原因,因为**归并排序最重要的过程就是合并,而合并的时候的临时存储区是可以借助外部存储器存储的**,所以我们才把归并排序称为**外部排序**,而`快速排序`称为**内部排序** 110 | 111 | ## 归并排序与快速排序 112 | 113 | ### 快速排序 114 | 115 | **在内部排序表现优秀**,因为必须将所有的数据一次性读入内存中进行整体排序,是一个只针对内存级别的排序 116 | 117 | ### 归并排序 118 | 119 | **在外部排序表现优秀**,可以借助额外的外部存储空间,如硬盘等进行排序,在内存不足或大数据的场景下是非常有用的 120 | 121 | ## 归并排序的思想 122 | 123 | > 先处理左边,得到左边的信息 124 | > 125 | > 再处理右边,得到右边的信息 126 | > 127 | > 最后处理横跨左右两边的信息 128 | 129 | **思想:要解决一个大问题,先将这个大问题差分成若干个子问题分别求解,然后将所有子问题的解进行合并得出大问题的解,即分治思想** 130 | 131 | ​ -------------------------------------------------------------------------------- /19.1-递推算法与递推套路(算法基础篇).md: -------------------------------------------------------------------------------- 1 | ## 递推公式 2 | 3 | 每一个递推算法,都有一个递推公式,通过递推公式我们可以更加明确的了解递推算法。 4 | 5 | ### 斐波那契数列的递推公式 6 | 7 | `f(n) = f(n-1) + f(n-2)`,这是我们斐波那契数列的递推公式,有很多同学可能会问,这个公式实际有什么用呢?接下来,我们来直接看一个算法题:**爬楼梯** 8 | 9 | #### [LeetCode 70. 爬楼梯](https://leetcode-cn.com/problems/climbing-stairs/) 10 | 11 | 这道题我们要怎么理解呢?我们如果想要爬到第`n`阶楼梯,那么上一步有可能是在`n-1`,也有可能是在`n-2` 12 | 13 | ![fib](https://ydschool-video.nosdn.127.net/1632540303647fib.png) 14 | 15 | 因此,这道题的解法就一目了然了: 16 | 17 | ```typescript 18 | function climbStairs(n: number): number { 19 | const res: number[] = []; 20 | // 爬到第0层的方法就是一动不动,我们可以认为他只有一种 21 | res[0] = 1; 22 | // 爬上第一层阶梯的可能性只有1个,就是走一步 23 | res[1] = 1; 24 | // 因此,后面的爬楼方式,我们就可以通过地推方式计算出来 25 | for(let i=2;i<=n;i++) { 26 | res[i] = res[i-1] + res[i-2]; 27 | } 28 | return res[n]; 29 | }; 30 | ``` 31 | 32 | ## 数学归纳法 33 | 34 | 上面带着大家一起学习了一下斐波那契数列递推公式的实际应用。那么,为什么上面这个公式就能够描述这一类问题的特性呢?这就要再聊一下**数学归纳法**了。 35 | 36 | **数学归纳法**在整个计算机科学当中是非常重要的,主要分为以下几步: 37 | 38 | 1. **验证k0成立(边界条件)** 39 | 2. **证明如果k(i)成立,那么k(i+1)也成立** 40 | 3. **联合步骤1和步骤2,证明由k0~k(n)成立** 41 | 42 | 不知道大家是否还记得,之前我们学习`二叉树`时,有扩展学习过**递归程序的设计**,而**递归程序的设计**要点就是**数学归纳法**在广义方面的应用,又称为**结构归纳法**。 43 | 44 | 那么,我们再来看一下上面的爬楼梯问题,怎么使用**数学归纳法**分析。 45 | 46 | 1. **验证k0成立**:在爬楼梯问题中,我们的边界条件就是当n为0和n为1。 47 | 2. **证明如果k(i)成立,那么k(i+1)也成立**:我们假设`res[i-1]`和`res[i-2]`是正确的,那么,res[i]也是成立的。 48 | 3. **联合步骤1和步骤2,证明由k0~k(n)成立**:由于步骤1和步骤2联立,必然能够的出res[n]是成立的。 49 | 50 | ## 如何求解递推问题(递推问题的求解套路) 51 | 52 | > 论求解套路的重要性:求解套路就是递推算法的学习方式,如果学习方式错了,很可能南辕北辙,花了比别人更多的时间,反而掌握的更少。就像健身的时候,如果你掌握了一些动作要领,可能1~2个月肌肉就出来了,但是你要是没有掌握动作要领,练错了,不仅长得肌肉变成肥膘,还可能伤到自己。 53 | 54 | 1. **确定递推状态(重点)** 55 | 56 | - 一种函数符号`f(x)`以及赋予函数符号一个含义 57 | 58 | - 例如上面的斐波那契数的求解问题,我们要赋予`f(x)`一个含义:`爬上第x阶楼梯的方法总数` 59 | 60 | - 函数所对应的值就是我们要求解的答案 61 | 62 | - 如:`f(x) = y`中,`x`是自变量,`y`是因变量。而在上面爬楼梯的问题当中,自变量`x`就是要爬的楼梯数,而因变量`y`就是爬到`x`阶楼梯的方法总数。因此,我们再求解问题的时候,最终要的是确定哪个是自变量,哪个是因变量。通常,因变量的值就是我们要求解的值。 63 | - 那么,我们要如何分析题目中的自变量是什么呢?我们要确定,会影响因变量的因素有哪些。如爬楼梯问题中,影响方法总数的就只有我们当前要爬的楼梯数,因此,自变量就是楼梯数`x`。 64 | 65 | - **思维练习** 66 | 67 | ![question](https://ydschool-video.nosdn.127.net/1632541670305question.png) 68 | 69 | - 首先来分析一下递推状态是什么。 70 | 71 | 首先第一个会影响我们方法总数的自变量就是`n`,即房间被划分成了几个区块,其次,由于房间是环形的,为了不让首尾颜色相同,我们还需要将首尾颜色也记录到我们的递推状态当中,那么,我们就得到了如下的公式: 72 | 73 | `f(n, i, j) = y`,其中,n代表一个房间被分成几个区块,`i` 和`j`分别代表首尾颜色,`y`代表方法总数。这个公式的意思是:**总共有n个区块的房间,第一个区块涂第i种颜色,最后一个区块涂第j种颜色并且相邻颜色不同的方法总数为y** 74 | 75 | - 递推公式 76 | 77 | 上面分析得出了`f(n, i, j) = y`这样一个简易版的公式,现在,我们就需要确定,通过怎样的运算能够算出`f(n, i, j)` 78 | 79 | ![gongshi](https://ydschool-video.nosdn.127.net/1632540983969gongshi.png) 80 | 81 | 注意,上面三个递推公式都是正确的,只是在不断的优化我们的递推公式,提升程序效率,但三种方式都可以求解出正确答案 82 | 83 | 2. **确定递推公式** 84 | 85 | - 确定`f(n)`依赖于哪些`f(i)`的值 86 | 87 | 3. **分析边界条件(k0)** 88 | 89 | 4. **程序实现** 90 | 91 | - **递归** 92 | - **循环** 93 | 94 | ## 递推与动态规划 95 | 96 | **动态规划问题其实就是求解最优化的递推问题,动态规划问题相较于普通的递推问题,多出了一个决策的过程** 97 | 98 | 空讲概念有点抽象,我们来结合具体问题来分析。依旧还是爬楼梯问题,不过比之前的爬楼梯多了一个体力花费。 99 | 100 | ### [LeetCode 746. 使用最小花费爬楼梯](https://leetcode-cn.com/problems/min-cost-climbing-stairs/) 101 | 102 | 这道题与上面简单的爬楼梯问题类似,差别就在于每上一层楼梯,我们需要花费一定的体力,要求我们求出花费最小的体力。 103 | 104 | ![palouti](https://ydschool-video.nosdn.127.net/1632541072647palouti.png) 105 | 106 | 通过上面的分析,我们可以得出以下公式:`dp[n] = min(dp[n-2] + cost[n-2], dp[n-1] + cost[n-1]) ` 107 | 108 | 翻译一下上面的公式:**爬上第n层楼梯的总体力花费应该等于最后一步从第n-2层爬上来的体力花费与最后一步是从n-1层爬上来的体力花费的最小值** 109 | 110 | ```typescript 111 | function minCostClimbingStairs(cost: number[]): number { 112 | const n = cost.length; 113 | const dp: number[] = []; 114 | // 由于题目给定可以从第0层或第1层开始爬,因此,我们初始化第0层和第1层的体力花费为0 115 | dp[0] = 0; 116 | dp[1] = 0; 117 | // 我们从第二层的体力花费开始递推 118 | for(let i=2;i<=n;i++) { 119 | // 第i层的体力花费是我最后一步从i-1层爬上来的体力花费与从i-2层趴上来的体力花费的最小值 120 | dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]); 121 | } 122 | return dp[n]; 123 | }; 124 | ``` 125 | 126 | ## 结语 127 | 128 | 大家都觉的动态规划学起来很难,主要是因为我们要真正学好动态规划,需要从:**递推状态定义**、**状态转移方程推导**、**程序实现**等三个大方向上入手并学习,并且这三个方向都是不好学的。今天通过递推算法与地推公式的相关学习,以及初步的了解了递推算法与动态规划的关系。这些都是我们后续学习动态规划的基础,其中尤为重要的是数学归纳法的理解与应用。“光说不练假把式”,今天说的大部分都是理论,下一篇文章《递推算法与递推套路(手撕算法篇)》将会直接从一些地推或动态规划的题目入手,学习递推程序或动态规划程序的求解套路,让你看到递推和动归不在茫然。敬请期待! 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /09.1-排序思想.md: -------------------------------------------------------------------------------- 1 | ## 计数排序基础知识与原理 2 | 3 | 当我们待排序的序列中只有有限种类的数,如只有1,2,3三个数字时,适合使用计数排序,因为只有这么几种情况,我们只需要把每个数字出现的次数统计出来,然后按照顺序依次输出每个数字统计个数的数即可。如: 4 | 5 | 原数组:[3,2,2,3,1,1,2] 6 | 7 | 计数:1出现了2次,2出现了3次,3出现了两次 8 | 9 | 排序: 先输出两个1,在输出3个2,最后输出2个三 10 | 11 | 结果: [1,1,2,2,2,3,3] 12 | 13 | ### 应用场景 14 | 15 | 综上所述:**计数排序适用于简单的单值排序问题,排序问题中,数据的值域很有限** 16 | 17 | 例如:我现在要统计全国所有人的年龄,并且按照年龄进行排序,这种情况下,因为我们的值域即年龄是很有限的,最多也就是0~150左右,这时,我们就可以使用这种计数排序的方式进行排序。 18 | 19 | **PS:所谓的单值排序,是指我们用于排序的值只有一个,相较于我们经常用的多值排序,是根据我们某个对象下的某个字段进行排序,如根据对象的id进行排序** 20 | 21 | ## 基数排序基础知识与原理 22 | 23 | 基数排序的场景比较特殊,要进行基数排序要满足以下两个条件: 24 | 25 | 1. 每个数字都是2/3/4...位数(也就是说所有数字的位数是一样的,当数字位数不同时,我们就要按照数组中位数最大的那个数把其他数补位,如:12和345,要对12进行补位得:012和345,在进行操作) 26 | 27 | **特性:基数排序可以保证数据的稳定性,其时间复杂度可以达到O(N)。(所谓数据稳定性就是保证原始数据的相对位置不变,即使两个数字都是1,但之前第一个1排在前面,第二个1排在后面,那么,排完序之后,依然能保证这样的相对顺序)** 28 | 29 | 例如:[21,31,23,22,33,13,12,11] 30 | 31 | 如上面的数组,进行基数排序的话,我们首先要对`个位`上的数进行处理,然后再对`十位`上的数进行处理 32 | 33 | - 对个位进行处理 34 | 35 | - 统计个位中每种数字出现的次数 36 | - `1`出现了`3`次 37 | - `2`出现了`2`次 38 | - `3`出现了`3`次 39 | - 然后根据统计的次数求出每个次数的前缀和(前缀和数组中的每一个元素,代表每个数字存放区域的尾索引+1) 40 | - `1`的前缀和:`3`,`1`的存放区域尾部索引为`2`,即索引为0~2的地方都放`1` 41 | - `2`的前缀和:`5`,`2`的存放区域尾部索引为`4`,即索引为3~4的地方都放`2` 42 | - `3`的前缀和:`8`,`3`的存放区域尾部索引为`7`,即索引为5~7的地方都放`3` 43 | - 求出前缀和之后,我们就能够确定个位树的排序 44 | - 前三个数字(索引:0,1,2)放的应该是`1` 45 | - 索引3,4放的数字应该是`2` 46 | - 索引5,6,7应该放`3` 47 | - 然后按照每次数字所在的区间,将原数组从右向左扫描一遍,将个位数为指定值的树从后往前放入到他对应的区间中。得出根据个位数的排序:[21,31,11,22,12,23,33,13] 48 | 49 | - 对十位进行处理 50 | 51 | - 统计十位数中每个数字出现的次数 52 | - `1`出现了`3`次 53 | - `2`出现了`3`次 54 | - `3`出现了`2`次 55 | - 同上求出前缀和 56 | - `1`的前缀和:`3`,`1`的存放区域尾部索引为`2`,即索引为0~2的地方都放`1` 57 | - `2`的前缀和:`6`,`2`的存放区域尾部索引为`5`,即索引为3~5的地方都放`2` 58 | - `3`的前缀和:`8`,`3`的存放区域尾部索引为`7`,即索引为6~7的地方都放`3` 59 | - 然后按照每次数字所在的区间,将上一轮经过个位数排序的数组从右向左扫描一遍,将十位数为指定值的树从后往前放入到他对应的区间中。得出根据十位数排序后的结果:[11,12,13,21,22,23,31,33] 60 | 61 | 经过上面的操作,我们就已经将这个数组排序了。 62 | 63 | ### 代码演示 64 | 65 | ```javascript 66 | function radixSort(arr, len = arr.length) { 67 | // 整形数据总共有32个数据位,我们将其拆分成低16位和高16位分别用来模拟个位和十位的运算 68 | const halfBit32 = Math.pow(2, 16); 69 | const partBit32 = halfBit32 / 2; 70 | // 获取低16位数字的工具函数,只要将目标数字与16进制数0xffff进行按位与操作就可以得到低16位数了 71 | const getLow16BitNum = num => (num & 0xffff); 72 | // 同理,定义获取高16位数字的方法 73 | const __getHeigh16BitNum = num => ((num & 0xffff0000) >> 16); 74 | // 兼容负数的情况 75 | const getHeigh16BitNum = num => __getHeigh16BitNum(num) > (partBit32 - 1) ? (__getHeigh16BitNum(num) - partBit32) : (__getHeigh16BitNum(num) + partBit32); 76 | 77 | let count = new Array(halfBit32); 78 | let temp = []; 79 | count.fill(0); 80 | 81 | // 循环数组,计算每个数出现的数量暂存在count数组中 82 | for (let i = 0; i < len; i++) count[getLow16BitNum(arr[i])] += 1; 83 | // 求前缀和 84 | for (let i = 1; i < halfBit32; i++) count[i] += count[i - 1]; 85 | // 前缀和中的每一项就是我们存放每个数字索引加1,因此,我们借助前缀和数组将原数组按照第16位的顺序放入到临时数组中 86 | for (let i=len-1;i>=0;--i) temp[--count[getLow16BitNum(arr[i])]] = arr[i]; 87 | 88 | 89 | // 低16位处理完后,将count重新初始化 90 | count.fill(0); 91 | // 上面已经将低16位的数排好序放入temp数组了,接下来就要对这个temp数进行高16位数排序 92 | // 首先,依然是计算每个数的数量 93 | for(let i=0;i=0;--i) arr[--count[getHeigh16BitNum(temp[i])]] = temp[i]; 98 | 99 | } 100 | 101 | // 生成指定长度的随机数数组 102 | function getRandomNum(len) { 103 | let res = []; 104 | while(--len) { 105 | res.push((Math.random()>0.5?-1:1) * Math.floor(Math.random()*999999)); 106 | } 107 | return res; 108 | } 109 | 110 | let arr = getRandomNum(100); 111 | console.log('原数组:\n', arr.join('\t')); 112 | radixSort(arr); 113 | console.log('新数组:\n', arr.join('\t')); 114 | ``` 115 | 116 | 117 | 118 | ## 拓扑排序基础知识与原理 119 | 120 | 是一个`图算法`,由于图的含义极其广泛,因此应用非常广泛。`拓扑排序`就是求出一张图的其中一种`拓扑序`。对于一张`图`而言,`拓扑序`是不唯一的,可能有很多种,一般我们只要求出其中一种即可。拓扑排序我们一般会借助`队列`辅助完成排序。当某一个节点没有任何前置节点,即入度为0时,就可以把这个节点放入队列中,然后依次弹出队列就是我们要求的其中一组`拓扑序`了。 121 | 122 | 我们工作和生活中很多场景都可以转换成图,然后通过拓扑排序的方式简化处理流程。例如:谣言流传网络、朋友圈分享网络、上下级关系、课程表等等。这些问题咋一看上去及其复杂,但如果将其转换成图,问题就简单很多了。 123 | 124 | ```bash 125 | # 线面是一个传谣者网络的示意图,右下图可以看出,谣言先由传谣者1和传谣者2传递给了传谣者3,而传谣者3听到谣言后有把谣言传递给了传谣者4和传谣者6,传谣者4听到谣言之后把谣言传给了传谣者5。我们对这个图进行拓扑排序的其中一个结果是:传谣者1->传谣者2->传谣者3->传谣者6->传谣者4->传谣者5。其中,传谣者1是一级传播者,而3是二级传播者,4是三级传播者,而5和6分别是听信谣言但未传播者。这样,我们就把显示生活中的复杂问题在计算机中表现了出来,有了这个传播网络,网警就可以更加有效地预防和控制谣言的传播了。 126 | 传谣者1 传谣者5 127 | \ / 128 | \ / 129 | \ / 130 | 传谣者3----------传谣者4 131 | / \ 132 | / \ 133 | / \ 134 | 传谣者2 传谣者6 135 | ``` 136 | 137 | -------------------------------------------------------------------------------- /17.3-红黑树之删除调整(数据结构基础篇).md: -------------------------------------------------------------------------------- 1 | ## 红黑树的删除调整 2 | 3 | 插入调整是站在**祖父节点**向下看,而删除调整则是站在**父节点向下看**。 4 | 5 | ### 删除调整发生的场景 6 | 7 | > 删除怎样的节点会引起红黑树的失衡 8 | 9 | #### 删除度为0的红色节点 10 | 11 | 平衡条件中并没有对度为0的红色节点有什么限制,因此,度为0的红色节点可以直接删除,不影响红黑树的平衡 12 | 13 | #### 删除度为1的红色节点 14 | 15 | 根据之前我们学习的`AVL树`删除度为1的节点的调整策略,我们要将度为1的红色节点的唯一子节点挂到他爸爸上面去(托孤),这样,即使他的子节点是黑色节点,也不会影响红黑树的平衡,因为每条路径上的黑色节点依然相同。 16 | 17 | 但是,我们还需要思考一个问题,在红黑树中,有可能存在度为1的红色节点吗? 18 | 19 | 答案是不可能存在的。因为,一个红色节点,子节点必定是黑色,那么,但凡红色节点有一个黑色的子节点,为了保证红黑树的平衡,另一棵子树肯定不可能为空,必然至少会包含一个黑色节点,那么,这样就不可能存在度为1的红色节点了。因此,我们在进行红黑树的删除调整时,可以不考虑这种情况。 20 | 21 | #### 删除度为1的黑色节点 22 | 23 | 经过上面度为1的红色节点的思考,那么我们再来想一下,是否可能存在度为1的黑色节点呢?如果一个黑色节点度为1,那么,他的子节点要么是红色,要么是黑色。假如说他的子节点是黑色,那这个节点的另一边的子树必然不可能为空,不然每条路径上黑色节点数量就不相同了。因此,如果当前节点是度为1的黑色节点,那么他的子节点只可能是红色,而且这个红色节点不可能存在子节点,即这个红色节点必定度为0(因为如果不是度为0,那么这个红色节点下面一定是黑色节点,必然会导致当前节点发生失衡)。 24 | 25 | 到此,我们明确了,一个度为1的黑色节点下面只能是度为0的红色节点,那么,我们要怎么删除这个度为1的黑色节点呢? 26 | 27 | 首先,将黑色节点删掉,然后将黑色节点的子节点挂到他的父节点上,由于黑色节点的子节点必然是度为0的红色节点,因此,我们只需要将这个节点的颜色由红色变成黑色即可。 28 | 29 | ### 删除度为0的黑色节点 30 | 31 | 度为0的黑色节点的删除是红黑树删除调整最麻烦的一步,由于删除了一个度为0的黑色节点,那么必然会导致红黑树原节点所在的那条路径上少了一个黑色节点。那我们该怎么办呢?大家别忘了我们之前定义过所有实际叶子节点上,都还挂着`NIL`节点,我们首先将所有节点以往隐藏不去考虑的`NIL`节点显示出来,那么,当把一个度为0的黑色节点删除之后,原黑色节点的父级下面就挂了`NIL`节点,但是此时,由于所有路径都挂了`NIL`节点,并且按照约定,`NIL`节点算是黑色节点,我们原先黑色节点所在的路径依然是少了一个黑色节点。此时,我们可以把顶替这个黑色节点的`NIL`节点标记成`双重黑`,这个节点算是两个黑色节点,这样,我们的红黑树又重归平衡了。 32 | 33 | 上面说的几种情况,我们几乎都不需要做太多的额外调整,而我们红黑树的删除调整,就是为了干掉这个`双重黑`节点。 34 | 35 | ### 删除调整情况 36 | 37 | 我们上面说了,删除调整主要是为了干掉**双重黑**节点,在下面的示例中,标记了🌚的便是双重黑节点。 38 | 39 | #### 删除调整情况一 40 | 41 | 这种情况主要值针对双重黑节点的兄弟节点是黑色节点,兄弟节点的子节点也是黑色节点的情况,具体如下图: 42 | 43 | ![rbTreeDeleteMatainCase1](https://ydschool-video.nosdn.127.net/1631022275530rbTreeDeleteMatainCase1.png) 44 | 45 | 这种情况处理起来比较简单,既然`黑70`是双重黑,那么,我们先把`黑70`减去一重黑,减去之后,发现右子树比原先少了一重黑,我们就在其父节点`黑50`上加一重黑,然而,此时左子树又多了一重黑,我们让`黑50`的左子树减去一重黑变成红色,此时左右子树黑色又平衡了。那么大家可能会疑问了,此时`黑50`变成**双重黑**了,没有干掉呀!大家不要忘了,我们的调整操作是一个递归回溯的过程,我们上面给出的树只是红黑树中的一段子树,我们只需要再递归回溯时继续向上调整,直到最终**双重黑**到了根节点处,此时,我们直接将双重黑减去一重黑变成普通的黑色,由于是根节点,减去一重黑色后,左右子树都同时少了一重黑色,因此不会导致左右子树每个路径下面的黑色节点数量失衡。到此,我们第一种情况的调整就算完成了。 46 | 47 | #### 删除调整情况二 48 | 49 | 双重黑节点的兄弟节点是黑色节点,兄弟节点的右子树是红色节点(RR型失衡) 50 | 51 | ![rbTreeDeleteMatainCase2-2 (1)](https://ydschool-video.nosdn.127.net/1631191500291rbTreeDeleteMatainCase2-2+%281%29.png) 52 | 53 | 那么,首先,我们来确定一下,上面哪些节点的颜色是确定的。 54 | 55 | 首先,双重黑节点黑5一定是确定的,因为当前就是在讨论这个问题。 56 | 57 | 由于情况二明确了双重黑的兄弟节点是黑色,那么黑25号节点的颜色也是确定的 58 | 59 | 由于情况二明确了双重黑的兄弟节点的右子树根节点是红色,所以红28节点也是确定的 60 | 61 | 用于红28确定了是红色,因此黑26和黑32肯定确定为黑色。 62 | 63 | 由于节点红22是左子树根节点,他既可能是黑色也可能是红色,因此,红22节点的颜色时不确定的 64 | 65 | 由于红22号节点没办法确定,因此黑23号节点也是不确定的。 66 | 67 | 因此,我们可以知道,确定性的节点有:黑5、黑25、红28、黑26、黑32。 68 | 69 | 这种类型的失衡,我们又称为RR失衡,那么,大家是否还记得,在AVL树中,RR失衡要怎么调整呢?是不是直接拽着红22节点进行一个x左旋即可,以下为左旋之后的情况 70 | 71 | ![rbTreeDeleteMatainCase2-2 (2)](https://ydschool-video.nosdn.127.net/1631191946459rbTreeDeleteMatainCase2-2+%282%29.png) 72 | 73 | 那么接下来就来分析一下删除调整怎么调。 74 | 75 | ##### 当22号节点是红色时 76 | 77 | 1. 由于黑23号节点的颜色不确定,如果红22号节点真的是红色的话,那是不是意味着黑23号节点的颜色时确定性黑色了?这就与我们上面分析的结果相悖了。因此,我们不管红22节点真实的颜色时红色还是黑色,此时我们都需要将红22节点调整成黑色。即,变为黑22。 78 | 79 | ![rbTreeDeleteMatainCase2-2 (3)](https://ydschool-video.nosdn.127.net/1631193732366rbTreeDeleteMatainCase2-2+%283%29.png) 80 | 81 | 2. 对于以黑25作为根节点的子树来说,左子树有3个确定性的黑色节点,而其他路径上只有2个确定性的黑色节点(注:由于黑23不是确定性的黑色,因此不算),因此,我们将根节点黑25先变成红色 82 | 83 | ![rbTreeDeleteMatainCase2-2 (4)](https://ydschool-video.nosdn.127.net/1631196188398rbTreeDeleteMatainCase2-2+%284%29.png) 84 | 85 | 3. 但是,此时,以红25为根节点的右子树的黑色节点比之前少了一个,又因为红28号节点是确定性的红色,因此,我们可以把红28号节点改成黑色 86 | 87 | ![rbTreeDeleteMatainCase2-2 (5)](https://ydschool-video.nosdn.127.net/1631196413626rbTreeDeleteMatainCase2-2+%285%29.png) 88 | 89 | 4. 到此,以黑25为根节点的左子树又重归平衡了。 90 | 91 | 总结一下,删除调整情况二且22号节点是红色时的调整方法:**RR型失衡,先将失衡子树进行一次左旋,然后将左子树根节点和右子树根节点变成黑色,根节点变成红色,最后将双重黑节点去掉一重黑即可** 92 | 93 | ##### 当22号节点是黑色时 94 | 95 | 如果原22号节点的颜色时黑色的话,那么原先以22号节点的子树就应该有3个黑色节点,为了保证调整之后黑色节点数量不变,我们需要将25号节点改成黑色。 96 | 97 | 总结一下,删除调整情况二且22号节点是黑色时的调整方法:**RR型失衡,先将失衡子树进行一次左旋,然后将左子树根节点和右子树根节点变成黑色,根节点变成黑色,最后将双重黑节点去掉一重黑即可** 98 | 99 | ##### 总结 100 | 101 | 综上,调整情况二的调整方法为:**RR型失衡,先将失衡子树进行一次左旋,然后将左子树根节点和右子树根节点变成黑色,根节点变成调整前原根节点(22号节点)的颜色,最后将双重黑节点去掉一重黑即可** 102 | 103 | #### 删除调整三 104 | 105 | 双重黑节点的兄弟节点是黑色,兄弟节点的左子树根节点是红色(RL型失衡) 106 | 107 | 调整方法与AVL树RL类型失衡调整类似,先抓着失衡子树根节点进行小右旋,然后将新根节点与原根节点的颜色互换,调整成RR类型的失衡,然后再根据RR类型的调整方式进行调整即可。 108 | 109 | ##### 总结 110 | 111 | **先将失衡子树右旋,然后将新根节点与原根节点的颜色互换转换为RR类型失衡。然后再根据RR类型失衡进行调整即可** 112 | 113 | #### 删除调整四 114 | 115 | 双重黑节点的兄弟节点是红色节点的情况。 116 | 117 | 先将失衡子树左旋,然后将根节点(即原双重黑节点的兄弟节点)改成黑色,然后将原先的根节点改成红色,这样,我们就得到了一个双重黑色节点兄弟节点是黑色的情况,继续删除调整三的步骤调整即可。 118 | 119 | ### 完整红黑树代码演示 120 | 121 | ![红黑树完整代码实现](https://ydschool-video.nosdn.127.net/1631443750766rbTreeDeleteMatainCase2-2+%286%29.png) 122 | 123 | ### 前端项目中使用红黑树 124 | 125 | 我们经常使用的**ESLint**,用于对我们项目的语法、代码格式等进行合法性或优化检查,其中针对代码格式化检查时,需要知道当前代码的token与上一个token的偏移量,在**ESLint**当中便使用了红黑树的方式辅助,快速高效的进行插入与查找等操作。此处使用的是[functional-red-black-tree](https://www.npmjs.com/package/functional-red-black-tree),有兴趣的同学,可以去了解一下细节。[ESLint](https://github.com/eslint/eslint/blob/660f075386d0b700faf1a1a94cde9d51899738a3/lib/rules/indent.js#L15) -------------------------------------------------------------------------------- /07.2-排序算法之快速排序思想相关算法.md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 我们熟悉了快速排序的思想与其实现原理之后,可以在我们实际开发中带来很多的启发与灵感,借助快速排序双指针的游走的思想,我们也可以解决很多类快排问题。现在就来借助一些算法题巩固一下对于讨论的快排思想。 4 | 5 | ## 刷题 6 | 7 | ### [剑指 Offer 21. 调整数组顺序使奇数位于偶数前面](https://leetcode-cn.com/problems/diao-zheng-shu-zu-shun-xu-shi-qi-shu-wei-yu-ou-shu-qian-mian-lcof/) 8 | 9 | #### 解题思路 10 | 11 | 这道题的要点就是我们要找到数组中的奇偶数,并将他们按照规则放到他们改在的地方。我们可以借助快排中的双指针思想,让左右游标不断在数组中游走,左游标遇到偶数和右游标遇到奇数时,我们就可以让左右游标对应的值交换,这样就可以实现左边奇数,右边偶数的排序了。 12 | 13 | #### 代码实现 14 | 15 | ```typescript 16 | function exchange(nums: number[]): number[] { 17 | // 判断边界 18 | if(nums.length===0) return nums; 19 | 20 | // 定义双指针 21 | let x = 0, y = nums.length-1; 22 | // 由于我们进行了边界判断,因此,初始时x一定是大于y的,因此此处使用do...while循环,可以减少一些条件的判断 23 | do { 24 | // 左游标没有到达有边界并且左游标所指向的元素是奇数的话,就让左游标一直向右移,直到遇到偶数位置 25 | // num[x] & 1 利用二进制的与运算判断奇偶数,效率会高一些,与num[x]%2等效 26 | while(x < nums.length && (nums[x] & 1)) x++; 27 | // 同理,当右游标没有到达左边界并且右游标所指向的值为偶数时,让右游标一直向左移,直到遇到奇数 28 | while(y >= 0 && (nums[y] & 1) === 0) y--; 29 | // 经过上面两次移动过后,我们的左右游标已经分别停留在了偶数的位置和奇数的位置,如果这两个游标没有错开,即 30 | // 左游标依然没有超过右游标的话,我们就交换左右游标所对应数组值,这样就可以让奇数放到左边,偶数放到右边 31 | // 交换完之后,只需要让左游标向右走一步,右游标想左走一步,然后进入下一次循环继续处理即可 32 | if(x <= y) { 33 | [nums[x], nums[y]] = [nums[y], nums[x]]; 34 | x++; 35 | y--; 36 | } 37 | } while (x <= y); 38 | 39 | return nums; 40 | }; 41 | ``` 42 | 43 | ### [75. 颜色分类](https://leetcode-cn.com/problems/sort-colors/) 44 | 45 | #### 解题思路 46 | 47 | 这道题还是可以利用我们的快排思想,找到中间值作为基准值,然后设立左右指针作为哨兵静待守候,当我们遍历到的元素值是小于基准值时,让左哨兵与其交换,当遍历到的值大于基准值时,让右哨兵与其交换,交换完后别忘了让左右哨兵向中间靠拢。如果遇到等于基准值时,什么都不干,继续遍历下一个元素 48 | 49 | #### 代码演示 50 | 51 | ```typescript 52 | /* 53 | * @lc app=leetcode.cn id=75 lang=typescript 54 | * 55 | * [75] 颜色分类 56 | */ 57 | 58 | // @lc code=start 59 | /** 60 | Do not return anything, modify nums in-place instead. 61 | */ 62 | 63 | function sort(arr, l = 0, r = arr.length-1, mid = 1) { 64 | // 如果左右指针重合或越界则直接退出 65 | if(l >= r) return; 66 | // 定义两个哨兵游标,x在最左侧元素的前一位,y在最右侧元素的后一位 67 | // 这样我们就可以少很多条件判断,不管是不是第一个元素,直接x++, 68 | // 不管是不是最后一个元素,直接y--,有点类似我们处理链表问题的哨兵节点 69 | let x = -1, y = r + 1, i = l; 70 | while(i < y) { 71 | if(arr[i] < mid) { // 0 72 | // 如果当前元素是0则让左侧哨兵节点右移一位 73 | x++; 74 | // 然后交换当前节点与哨兵节点所指向的值 75 | [arr[i], arr[x]] = [arr[x], arr[i]]; 76 | // 处理完之后,当前节点向右移 77 | i++; 78 | } else if(arr[i] === mid) { 79 | // 如果当前节点是1,那我们就不管他,因为我们只需要把0放在1的左边 80 | // 把2放在1的右边就可以了,1就原地不动,直接右移到下一个节点 81 | i++; 82 | } else if(arr[i] > mid) { 83 | // 如果当前元素是2,则让右边的哨兵向左走一步 84 | y--; 85 | // 然后交换当前节点与哨兵节点的位置 86 | [arr[i], arr[y]] = [arr[y], arr[i]]; 87 | } 88 | } 89 | } 90 | 91 | function sortColors(nums: number[]): void { 92 | sort(nums); 93 | }; 94 | // @lc code=end 95 | 96 | 97 | ``` 98 | 99 | ### [面试题 17.14. 最小K个数](https://leetcode-cn.com/problems/smallest-k-lcci/) 100 | 101 | #### 解题思路 102 | 103 | 这题之前我们使用堆排序实现过,现在使用快排思想来解决这个问题,我们只需要对数组进行k轮的快排(注意:无需像数组完整快排,只需要按题意快排k轮,那么数组头k个数就是我们要的答案) 104 | 105 | #### 代码演示 106 | 107 | ```typescript 108 | // 三点取中间 109 | function getMid(a: number, b: number, c: number): number { 110 | if(a > b) [a, b] = [b, a]; 111 | if(a > c) [a, c] = [c, a]; 112 | if(b > c) [b, c] = [c, b]; 113 | return b; 114 | } 115 | // 使用快排思想实现的快速选择方法 116 | function quickSelect(arr, k, left = 0, right = arr.length-1) { 117 | // 左游标与右游标相遇或错过时退出 118 | if(left >= right) return; 119 | // 获取中间值 120 | let x = left, y = right, mid = getMid(arr[left], arr[Math.floor((left+right)/2)], arr[right]); 121 | do { 122 | // 左边的值小于基准值则继续右移 123 | while (arr[x] < mid) x++; 124 | // 右边的值大于基准值则继续左翼 125 | while (arr[y] > mid) y--; 126 | // 左右指针相遇时交换左右指针对应的值 127 | if(x <= y) { 128 | [arr[x], arr[y]] = [arr[y], arr[x]]; 129 | x++,y--; 130 | } 131 | } while (x <= y); 132 | // 我们只需要排序k位就行了,剩下的无需排序 133 | if(y - left === k - 1) return; 134 | if(y - left >= k) { // 做区间数量大于k,扩大范围 135 | quickSelect(arr, k, left, y); 136 | } else {// 做区间数量大于k,缩小范围 137 | quickSelect(arr, k - x + left, x, right); 138 | } 139 | } 140 | 141 | function smallestK(arr: number[], k: number): number[] { 142 | // 进行k轮快排 143 | quickSelect(arr, k); 144 | // 取前k位作为答案输出 145 | return arr.slice(0, k); 146 | }; 147 | ``` 148 | 149 | ### [11. 盛最多水的容器](https://leetcode-cn.com/problems/container-with-most-water/) 150 | 151 | #### 解题思路 152 | 153 | 这道题也是利用双指针思想来做。我们先来捋一下思路,首先,我们想让接水的面积最大,就要保证宽和高都尽可能大,我们利用双指针,通过左右游标的移动来确定宽的大小(宽就是左右游标中间的长度,也就是右游标的索引减去左游标的索引),由于左右游标都是一直向中间靠拢的,所以宽度肯定是一直在变小的,因此我们的面积大小就取决于高的大小了,我们的高就是左右游标对应的值,又因为实际高取决于比较短的哪一条,因此需要取左右两个游标的最小值与宽计算出面积,在与上一次计算的对比取最大值,这样,循环一遍后,就得到了最大面积了。 154 | 155 | #### 代码实现 156 | 157 | ```typescript 158 | function maxArea(height: number[]): number { 159 | // res为最大面积,初始为0,l为左游标,r为右游标 160 | let res = 0, l = 0, r = height.length - 1; 161 | // 左右游标没有相遇 162 | while(l < r) { 163 | // 计算面积并与上一次的面积比较取最大值 164 | res = Math.max(res, (r - l) * Math.min(height[l], height[r])); 165 | // 为了确保高尽可能的大,如果左边的高比较小,则让左游标右移,换一个左高 166 | if(height[l] < height[r]) l++; 167 | // 否则右游标左移 168 | else r--; 169 | } 170 | return res; 171 | }; 172 | ``` 173 | 174 | -------------------------------------------------------------------------------- /20.2-动态规划(手撕算法篇).md: -------------------------------------------------------------------------------- 1 | ### [1143. 最长公共子序列](https://leetcode-cn.com/problems/longest-common-subsequence/) 2 | 3 | #### 解题思路 4 | 5 | 1. **递推状态:**我们最长公共子序列的长度取决于以第`i-1`个字符作为结尾的`A`字符串与以第`j-1`个字符作为结尾的`B`字符串公共子串长度。因此,我们的递推状态应为:`dp[i,j]`,代表:**A串长度为i位,B串长度为j位的最长公共子序列的长度**。 6 | 2. **递推公式(状态转移方程):**当我们`A`串第`i-1`个字符与`B`串第`j-1`个字符相等时,即两个字符串分别以`i-1`位和`j-1`位对齐时,最长公共字串的长度应该为:`dp[i-1][j-1] + 1`,`dp[i-1][j-1]`代表我们`A`串第`i-1`个字符与`B`串第`j-1`个字符作为结尾的最长公共字串的长度,再加上当前这个相同的字符串的长度1。而当我们`A`串第`i-1`个字符与`B`串第`j-1`个字符不相等时,我们公共字串的长度取决于分别以`i-1`位与`j`位作为结尾的`A`、`B`串最长公共字串的长度和分别以`i`位与`j-1`位作为结尾的`A`、`B`串最长公共字串的长度的最大值。综合上述两个条件,我们得到递推公式为:`最后一位对齐时:dp[i][j] = dp[i-1][j-1] + 1;最后一位不对齐时:dp[i][j] = max(dp[i-1][j],dp[i][j-1])` 7 | 3. **边界条件:**当整个字符串都不匹配时,最长公共子序列的长度为0,我们初始时可以将dp数组中每个位置都初始化为0,后续操作也更加方便。 8 | 9 | #### 代码演示 10 | 11 | ```typescript 12 | function longestCommonSubsequence(text1: string, text2: string): number { 13 | let n = text1.length; 14 | let m = text2.length; 15 | // 初始化dp数组为(n+1)*(m+1)的二维数组,并在每一位初始填充为0 16 | const dp = new Array(n + 1).fill(0).map(() => new Array(m + 1).fill(0)); 17 | for(let i = 1; i <= n; i++) { 18 | for(let j = 1; j <= m; j++) { 19 | // 如果a串的最后一位与b串的最后一位相等 20 | if(text1[i-1] === text2[j-1]) { 21 | // 在最后一位对齐的情况下,我们A串第i-1个字符与B串第j-1个字符作为结尾的最长公共字串的长度,再加上当前这个相同的字符串的长度1 22 | dp[i][j] = dp[i-1][j-1] + 1; 23 | } else { 24 | // 如果不相等,那么我们尝试让a串倒数第二位与b串最后一位对齐或a串最后一位与b串倒数第二位对齐,并在此基础上取公共字串最长的作为当前的最大公共字串长度 25 | dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]); 26 | } 27 | } 28 | } 29 | return dp[n][m]; 30 | }; 31 | ``` 32 | 33 | ### [剑指 Offer II 094. 最少回文分割](https://leetcode-cn.com/problems/omKAoA/) 34 | 35 | #### 解题思路 36 | 37 | 1. **递推状态:**题目让我们求解最好回文切割的数量,这个看起来好像无从下手。我们可以换个角度来想,如果我们求得了原字符串最少能够分隔成几个回文串,那么切割数量就等于回文串数量减一(**毕竟一刀两断的道理大家还是明白的吧**)。所以,我们的递推状态就定义为`dp[n]`,代表以原字符串长度为n的字符串最少可以切割出多少个回文字符串。 38 | 2. **递推公式(状态转义方程):**那么,我们一个长度为n的字符串,最少可以切割出几个回文子串呢?我们以`n-1`位的字符作为结束字符,在前面找到一个位置`j`,使得`j`到`n-1`位的字符形成回文字符串,如果可以形成回文字符串,那么长度为`n`的字符串的最少回文子串的个数应为:`dp[n] = min(dp[j] + 1, dp[i])`,如果不能找到,那么至少第`i`位的字符自己可以独立成为一个回文子串:`dp[i] = i` 39 | 3. **边界条件:**当原字符串长度为0时,我们能够切割出来的回文子串自然也是0 40 | 4. **程序实现:**我们需要掌握如何判断一个字符串是否是回文串的技巧,我们直接使用双指针的方式从两端像中间扫描,一旦两端的值对不上,就说明不是回文字符串,如果直到前后指针相遇都相等,那么这个字符串就是回文字符串了。 41 | 42 | #### 代码演示 43 | 44 | ```typescript 45 | // 判断字符串从第i位到第j位是否是一个回文字串 46 | function isAoA(s: string, i: number, j: number) { 47 | // 使用双指针方法,如果两端字符不相等,那么肯定不是回文串 48 | while(i <= j) if(s[i++] !== s[j--]) return false; 49 | // 如果循环结束还没终止,说明是一个回文串 50 | return true; 51 | } 52 | function minCut(s: string): number { 53 | const n: number = s.length; 54 | const dp: number[] = []; 55 | // 当字符串长度为0时,回文串的个数也为0 56 | dp[0] = 0; 57 | for(let i=1;i<=n;i++) { 58 | // 我们最坏的情况就是每个字符单独成为一个回文串,那么就总共能够分成i个回文串 59 | dp[i] = i; 60 | for(let j=0;j 0/1背包是非常经典的动态规划问题,在很多场景上都可能会使用到0/1背包的思想。一般涉及在有限的资源内,如何合理的分配资源才能达到价值最大化的问题,都属于0/1背包问题。那么,为什么我们管这类问题叫做0/1背包呢?其实0就代表最后一个物品没选,而1代表选择了最后的物品。所以0/1其实就是代表决策的两种方向。 75 | 76 | #### 题目描述 77 | 78 | 给一个能承重𝑉V的背包,和𝑛n件物品,我们用重量和价值的二元组来表示一个物品,第𝑖i件物品表示为(𝑉𝑖,𝑊𝑖)(Vi,Wi),问:在背包不超重的情况下,得到物品的最大价值是多少? 79 | 80 | ![0-1bag](https://ydschool-video.nosdn.127.net/16329194798780-1bag.jpg) 81 | 82 | ------ 83 | 84 | #### 输入 85 | 86 | 第一行输入两个数 𝑉,𝑛V,n,分别代表背包的最大承重和物品数。 87 | 88 | 接下来𝑛n行,每行两个数𝑉𝑖,𝑊𝑖Vi,Wi,分别代表第i件物品的重量和价值。 89 | 90 | (𝑉𝑖≤𝑉≤10000,𝑛≤100,𝑊𝑖≤1000000)(Vi≤V≤10000,n≤100,Wi≤1000000) 91 | 92 | #### 输出 93 | 94 | 输出一个整数,代表在背包不超重情况下所装物品的最大价值。 95 | 96 | #### 解题思路 97 | 98 | 我们可以把题目中的`V`即背包能装下的容积看成是**资源**,而`W`当做是**收益**,我们如何在资源有限的情况下获得最大收益,这就是我们今天这道题要研究的问题。 99 | 100 | 1. **递推状态:**由于我们获得的最大收益跟物品数量和背包的最大承重有直接的关系,因此,我们定义递推状态为:`dp[i][j]`,代表我们前`i`件物品,背包的承重是`j`的情况下,我们能获得的最大收益。 101 | 2. **递推公式:**我们的递推状态有两种情况: 102 | 1. **没有选择第`i`件物品:**由于没有选择第`i`件物品,那么我们的总价值就取决于`i-1`件物品了。`dp[i][j] = dp[i-1][j]` 103 | 2. **选择了第`i`件物品:**如果选择了第`i`件物品,那么我们的背包就必须给第`i`件物品留下空间,因此,递推公式为:`dp[i][j] = dp[i-1][j - vi] + wi`,代表背包中除了最后一件物品的总价值再加上最后一件物品的总价值。 104 | 3. **边界条件:**没有塞进去一个东西时总价值为0 105 | 4. **程序实现:**可以使用滚动数组技巧节省空间复杂度 106 | 107 | #### 代码演示 108 | 109 | ```typescript 110 | function bag(v: number[], w: number[], V: number) { 111 | const n = v.length; 112 | const dp: number[][] = []; 113 | // 初始换一个初始填充0的滚动数组 114 | for(let i=0;i<2;i++) dp.push(new Array(V + 5).fill(0)); 115 | // 遍历每一个物品 116 | for (let i = 0; i <= n; i++) { 117 | // 计算滚动数组的索引 118 | const idx = i % 2; 119 | const preIdx = +!idx; 120 | // 遍历可能塞入背包的物品重量 121 | for (let j = 0; j <= V; j++) { 122 | // 如果当前物品不能塞进去,那么总价值取决于塞入上一个物品后的总价值 123 | dp[idx][j] = dp[preIdx][j]; 124 | // 如果当前背包的容量能够塞入第i件物品,那么我们需要更新总价值 125 | if (j >= v[i]){ 126 | // 由于能够塞进去第i件物品,那么总价值取决于塞入上一个物品的价值加上当前物品的价值,由于还可能存在其他的存放方案,因此,我们每次求得本次总价值还需与上一次的总价值做对比取最大值 127 | dp[idx][j] = Math.max(dp[idx][j], dp[preIdx][j - v[i]] + w[i]); 128 | } 129 | } 130 | } 131 | return dp[n % 2][V]; 132 | } 133 | 134 | // 物品重量数组 135 | const v: number[] = [4, 3, 12, 9]; 136 | // 物品价值数组 137 | const w: number[] = [10, 7, 12, 8]; 138 | // 背包总承重 139 | const V = 15; 140 | console.log(bag(v, w, V)); 141 | 142 | ``` 143 | 144 | -------------------------------------------------------------------------------- /25.1-欧几里得算法.md: -------------------------------------------------------------------------------- 1 | # 25_1-欧几里得算法及相关扩展算法 2 | 3 | ## 前言 4 | 5 | 看过《算法第4版》的同学应该都对第一章《基础》中轻鸿一瞥出现的**欧几里得算法**记忆犹新吧,算法看似以一种极其简单的方式解决了一个简单的问题,但是,你真的了解为啥这么做求出的答案就是正确的么?为什么么要这么实现?今天我们就从**欧几里得算法**开始聊起。(PS: 本节所讲的内容,或许在大部分场景上不知道如何使用,但却是当代密码学,如`rsa`算法和傅里叶变换的基础和前置知识) 6 | 7 | ## 欧几里得算法 8 | 9 | ### 概念 10 | 11 | 整数`a`和`b`的最大公约数为`gcd(a, b) = gcd(b, a % b)`。 12 | 13 | 上面的公式通过**辗转相除法**,当`a > b`时,使得每一轮计算,我们的范围都会缩小一点,当最终`a%b = 0`时,`a`就是我们要求的两数的最大公约数了。而当`a 查找快,合并慢。当两个集合合并时,将合并后的集合全部“染上同一种颜色”,也就是打上相同的标记(Quick Find算法) 14 | > 15 | > 查找时间复杂度:O(1) 16 | > 17 | > 合并时间复杂度:O(n) 18 | 19 | 代码实现 20 | 21 | ```typescript 22 | class UnionSet { 23 | // 用于存储集合中每一个点的“颜色” 24 | private colors: number[]; 25 | constructor(private n:number){ 26 | // 当编号从1...n时,数组长度为n+1,如果编号为0...n-1时,数组长度为n即可 27 | this.colors = new Array(n+1); 28 | // 初始时,将每一个点染上个字不同的“颜色” 29 | for(let i=0;i<=n;i++) { 30 | this.colors[i] = i; 31 | } 32 | } 33 | // 要查找某个元素,只需要从集合中直接查询即可,查找速度极快,因此叫做Quick-Find 34 | find(x: number): number { 35 | return this.colors[x]; 36 | } 37 | // 合并时,需要让b点所在集合的所有点都“染上a点的颜色”,因此需要遍历所有点并找出与b点颜色相同的点,即b集合的点。 38 | merge(a: number, b: number): void { 39 | // 如果a、b两点本来就颜色一样,也就是在同一个集合之内,那么无需合并 40 | if(this.colors[a] === this.colors[b]) return; 41 | const bColor = this.colors[b]; 42 | for(let i=0;i<=this.n;i++) { 43 | if(this.colors[i] === bColor) this.colors[i] = this.colors[a]; 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ##### Quick-Union算法 50 | 51 | > 查找慢,合并快。使用树形结构维护集合,合并集合时,只需将a集合挂在b集合根节点下面作为b集合的子节点即可 52 | > 53 | > 由于采用了树形结构,那么无论是查找还是合并操作,其时间复杂度都跟树的树高密切相关,在极端的情况下,我们的集合上的点会拼接成一个链表,即节点数量就等于树高,因此,使用Quick-Union算法时,有效的减小树高能够提升算法的效率。 54 | 55 | 代码实现 56 | 57 | 未经优化的`Quick-Union`算法实现 58 | 59 | ```typescript 60 | class UnionSet { 61 | // 用于存储每一个节点父级节点的索引 62 | private boss: numbers[]; 63 | constructor(private n: number) { 64 | this.boss = new Array(n+1); 65 | // 初始时所有节点的父级都是他自己,自己当自己的老板 66 | for(let i =0;i<=n;i++) { 67 | this.boss[i] = i; 68 | } 69 | } 70 | // 如果父节点的索引是他本身,那么无需寻找,直接返回x,否则递归寻找期父级节点 71 | find(x: number): number { 72 | if(x === this.boss[x]) return x; 73 | return this.find(this.boss[x]) 74 | } 75 | // 如果a、b的父级是统一个父级,那无需合并,因为他们本来就在同一个集合里,否则,把a挂在b下面,即让a树作为b的子树 76 | merge(a: number, b: number): void { 77 | const fa = this.boss[a]; 78 | const fb = this.boas[b]; 79 | if(fa === fb) return; 80 | this.boss[fa] = fb; 81 | } 82 | } 83 | ``` 84 | 85 | > 为了防止从树形结构退化成链表结构,我们在合并集合时,应该遵循谁的节点少谁作为子树,即“节点数量少就做儿子”。证明过程如下: 86 | > 87 | > 假设现在要合并a,b两个集合,a的节点总数为Sa,树高为Ha,b的节点总数为Sb,树高为Hb,那我们我们可以分别求出两个集合的平均查找次数。 88 | > 89 | > 当把a集合的根节点作为b集合的父节点时,a集合的平均查找次数(求平均查找次数我们可以用两个结合对应的树高Ha与Hb相加,由于把b挂在了a下面,所以b的所有节点的树高都增加了1,总共增加了Sb,因此,需要再加上Sb之和,除于两棵树节点数量之和): 90 | > 91 | > (Ha+Hb+Sb)/(Sa+Sb) 92 | > 93 | > 当把b集合的根节点作为a集合的父节点时,a集合的平均查找次数(求平均查找次数我们可以用两个结合对应的树高Ha与Hb相加,由于把a挂在了b下面,所以a的所有节点的树高都增加了1,总共增加了Sa,因此,需要再加上Sa之和,除于两棵树节点数量之和): 94 | > 95 | > (Ha+Hb+Sa)/(Sa+Sb) 96 | > 97 | > 将a和b两个集合的平均查找次数的公式化简可见,我们平均查找次数根本上是跟我们的节点数量直接相关,节点数量越少,作为合并时的父节点的话,我们的平均查找平局查找次数就越高,因此,我们只需要遵循节点数量少的集合作为子树即可有效的提升算法效率 98 | 99 | 经过优化后的`Quick-Union`算法实现,我们也叫这个是`权重并查集`即`Weight-Quick-Union` 100 | 101 | ```typescript 102 | // weightedUnionSet 103 | class UnionSet { 104 | private boss: number[]; 105 | private size: number[]; 106 | constructor(private n: number) { 107 | this.boss = new Array(n+1); 108 | this.size = new Array(n+1); 109 | for(let i=0;i<=n;i++) { 110 | this.boss[i] = i; 111 | this.size[i] = 1; 112 | } 113 | } 114 | 115 | find(x: number): number { 116 | if(this.boss[x] === x) return x; 117 | return this.find(this.boss[x]); 118 | } 119 | 120 | merge(a: number, b: number): number { 121 | const ra = this.find(a); 122 | const rb = this.find(b); 123 | if(ra === rb) return; 124 | // 谁小谁当儿子 125 | if(this.size[ra] < this.size[rb]) { 126 | this.boss[ra] = rb; 127 | // 因为将a挂在了b下面,因此,b树的大小增加了ra个节点 128 | this.size[rb] += ra; 129 | } else { 130 | this.boss[rb] = ra; 131 | this.size[ra] = rb; 132 | } 133 | } 134 | } 135 | ``` 136 | 137 | ##### 路径压缩 138 | 139 | > 我们知道,当我们查找并查集时,层级越少,我们查找的效率就越高,那么,我们就可以在昨晚一次查找操作之后,直接把查找的那个节点直接挂在根节点上面,这样,下一次再查找这个节点的时候,效率就能明显提升了 140 | 141 | 代码实现 142 | 143 | ```typescript 144 | // weightedPathCompressUnionSet 145 | class UnionSet { 146 | private boss: number[]; 147 | private size: number[]; 148 | constructor(private n: number) { 149 | this.boss = new Array(n+1); 150 | this.size = new Array(n+1); 151 | for(let i=0;i<=n;i++) { 152 | this.boss[i] = i; 153 | this.size[i] = 1; 154 | } 155 | } 156 | 157 | find(x: number): number { 158 | if(this.boss[x] === x) return x; 159 | const rootIdx = this.find(this.boss[x]); 160 | // 将要查找的节点挂在根节点下面,下次查找时效率便可显著提升,不再需要一层一层往上查找 161 | this.boss[x] = rootIdx; 162 | return rootIdx; 163 | } 164 | 165 | merge(a: number, b: number): number { 166 | const ra = this.find(a); 167 | const rb = this.find(b); 168 | if(ra === rb) return; 169 | // 谁小谁当儿子 170 | if(this.size[ra] < this.size[rb]) { 171 | this.boss[ra] = rb; 172 | // 因为将a挂在了b下面,因此,b树的大小增加了ra个节点 173 | this.size[rb] += ra; 174 | } else { 175 | this.boss[rb] = ra; 176 | this.size[ra] = rb; 177 | } 178 | } 179 | } 180 | ``` 181 | 182 | ##### 几种并查集算法的对比 183 | 184 | ![几种算法的对比](https://ydschool-video.nosdn.127.net/1619863991767%E5%B9%B6%E6%9F%A5%E9%9B%86.jpg) 185 | 186 | ### 并查集的模版代码 187 | 188 | > 上面说了很多种并查集的实现方式与优化算法,其实我们再实际使用时,最常用的优化就是路径压缩的方式来优化并查集。下面给出一个比较通用的并查集实现代码。这个代码将会在后续刷题巩固中反复使用。 189 | 190 | ```typescript 191 | // UnionSet 192 | class UnionSet { 193 | // 用于存储当前节点的父节点序号 194 | // 虽然并查集逻辑上是树形结构,但我们实际实现时无需定义一个树形结构,因为我们想要知道的仅仅是当前节点的父级到底是谁就足够了,因此,我们使用数组来进行存储,数组的索引为当前节点的索引,值为当前节点父节点的索引 195 | private boss: number[]; 196 | constructor(private n: number) { 197 | // 为了防止部分场景序号是从1开始的,因此初始化数组长度为n+1 198 | this.boss = new Array(n+1); 199 | // 初始设置所有节点的父节点都为它本身 200 | for(let i=0;i<=n;i++) { 201 | this.boss[i] = i; 202 | } 203 | } 204 | // 使用路径压缩算法实现并查集的查找操作 205 | // 其实原理就是每查找到一个节点,我们就把节点的父级改成根节点,这样我们下次查找相同节点时,效率便可显著提升 206 | // 判断是否为根节点的依据:如果当前节点就等于他的根节点,即:this.boss[x]===x,就说明这个节点就是根节点 207 | get(x: number): number { 208 | return (this.boss[x] = (this.boss[x] === x ? x : this.get(this.boss[x]))); 209 | } 210 | // 由于权重并查集的算法相较于路径压缩的算法带来的提升实际并不大,因为我们进行路径压缩时实际就已经极大的减少了树的层级了。因此,此处没有使用权重并查集的算法实现,直接把a树挂在b树上 211 | merge(a: number, b: number): void { 212 | this.boss[this.get(a)] = this.get(b); 213 | } 214 | // [debug] 用于调试 215 | output(): void { 216 | console.log(this.boss); 217 | } 218 | } 219 | ``` 220 | 221 | ### 并查集解决的经典问题 222 | 223 | 上面说了并查集是用来解决连通性问题的神兵利器,不过这说得有点抽象了,那么,我们在实际的开发过程中,有哪些情况可能会使用并查集来解决呢? 224 | 225 | #### 朋友圈 226 | 227 | > 所谓的一个朋友圈子,不一定其中的人都互相认识。例如: 228 | > 229 | > 小王的朋友是小李,小李的朋友是小王那么他们三个人其实就是一个朋友圈 230 | 231 | -------------------------------------------------------------------------------- /20.3-动态规划(算法优化篇).md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 之前整整花了4个文章篇幅讨论了从递推到到动态规划,从递推套路到递推问题的求解方向,从递推公式到动态转移方程。我们也已经初步了解了一个动态规划程序要从何下手了。但是,我们之前实现的动态规划还不是最优的程序,很多程序为了能够更简单直白的阐述所讲知识点,因此没有对这些题目进行优化。今天咱们就通过一些实实在在的算法题来看看我们要如何优化一个动态规划程序。 4 | 5 | ### [714. 买卖股票的最佳时机含手续费](https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-with-transaction-fee/) 6 | 7 | #### 解题思路 8 | 9 | 1. **状态定义:**我们获得最大收益无非取决于第`i`不持有股票的最大收益和第`i`天持有股票的最大收益。因此,我们有两个递推状态: 10 | 1. `dp[i][0]:`第`i`天不持有股票的最大收益 11 | 2. `dp[i][1]:`第`i`天持有股票的最大收益 12 | 2. **动态转移方程:**既然状态定义分成两种情况讨论,那么,我们的状态转义方程也应该分成两种情况来讨论。 13 | 1. **第i天不持有股票的收益取决于以下两种情况的最大值:**,即:`dp[i][0] = max(dp[i-1][0],dp[i-1][1] + price[i] - free)` 14 | 1. **第i-1天没有持有股票,第i天也没有持有股票:**这种情况的收益最大值应该取决于第i-1天不持有股票的最大值,即:`dp[i][0] = dp[i-1][0]` 15 | 2. **第i-1天有股票,但是我在第i天把股票买了:**这种情况应该取决于第i-1天持有股票的最大值再加上卖掉股票之后挣的收益,再减去手续费,即:`dp[i][0] = dp[i-1][1] + price[i] - fee` 16 | 2. **第i天持有股票的收益:**,即:`dp[i][1] = max(dp[i-1][1], dp[i-1][0] - price[i])` 17 | 1. **第i-1天持有股票,第i天继续持有:**`dp[i][1] = dp[i-1][1]` 18 | 2. **第i-1天没有股票,第i天新买入股票:**`dp[i][1] = dp[i-1][0] - price[i]` 19 | 3. **边界条件:**第1天如果不持有股票,那么收益为0,如果第1天持有股票,由于是第一天,肯定是新买入的,那么收益就应该是`-prize[i]`。 20 | 4. **程序实现:**直接使用循环方式解决。 21 | 22 | #### 优化思路 23 | 24 | 由于我们每一天要么就是持有股票,要么就不持有股票,并且,当前的最大收益仅跟上一天的最大收益有关,因此,我们无需额外开辟存储空间用来出处状态,可以直接定义`buy`和`sell`两个变量用来记录最后一天买入和卖出的最大收益,最后在这两者中取最大值即可完成推算任务。 25 | 26 | ##### 代码实现 27 | 28 | ##### 未优化版本 29 | 30 | ```typescript 31 | function maxProfit(prices: number[], fee: number): number { 32 | const n = prices.length; 33 | const dp: number[][] = new Array(n); 34 | dp.fill(new Array(2)); 35 | // 第一天没有持有股票,那么最大收益为0 36 | dp[0][0] = 0; 37 | // 第一天持有股票,那么最大收益就是-prices[0] 38 | dp[0][1] = -prices[0]; 39 | for(let i=1;i { 139 | const zerosOnes = new Array(2).fill(0); 140 | const length = str.length; 141 | for (let i = 0; i < length; i++) { 142 | zerosOnes[str[i].charCodeAt(0) - '0'.charCodeAt(0)]++; 143 | } 144 | return zerosOnes; 145 | } 146 | function findMaxForm(strs: string[], m: number, n: number): number { 147 | let dp: number[][] = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0)) 148 | for(let s of strs) { 149 | const [count0, count1] = getZerosOnes(s); 150 | // 使用逆向刷表法倒着扫描生成dp 151 | for(let i=m;i>=count0;--i) { 152 | for(let j=n;j>=count1;--j) { 153 | dp[i][j] = Math.max(dp[i - count0][j - count1] + 1, dp[i][j]); 154 | } 155 | } 156 | } 157 | return dp[m][n]; 158 | }; 159 | 160 | ``` 161 | 162 | 163 | 164 | ### [518. 零钱兑换 II](https://leetcode-cn.com/problems/coin-change-2/) 165 | 166 | #### 解题思路 167 | 168 | 这道题因为只是让我们求方法总数,没有决策过程,因此,算是递推问题。那么,我们就按照递推问题的思路: 169 | 170 | 1. **定义递推状态:** `dp[i][j]` 代表使用第i中硬币拼凑j元钱的方法总数 171 | 2. **递推公式:**拼凑的方法总数需要分为两种情况来讨论: 172 | 1. **没有使用第i种硬币:**`dp[i][j] = dp[i-1][j]` 173 | 2. **使用了第i种硬币:**`dp[i][j] = dp[i][j-x]`,其中,x代表第i种硬币的面额 174 | 3. **边界条件:**要拼凑0元的方法总数有1中 175 | 4. **程序实现:**使用正向刷表法 176 | 177 | #### 代码演示 178 | 179 | ```typescript 180 | function change(amount: number, coins: number[]): number { 181 | // 定义递推状态:dp[i][j]代表使用第i中硬币拼凑j元钱的方法总数 182 | // 递归公式:dp[i][j] = dp[i-1][j] + dp[i][j-x],代表如果没使用第i中硬币的方法总数为dp[i-1][j],使用了第i种硬币的方法总数为dp[i][j-x], 183 | // 两者相加就是总的方法总数 184 | // 由于dp[i][]只跟dp[i-1]有关,因此,我们可以将二位数组压缩成一维,即: 185 | // dp[j] = dp[j] + dp[j-x] 186 | // 或 dp[j]+=dp[j-x] 187 | let dp: number[] = new Array(amount+1); 188 | dp.fill(0); 189 | dp[0] = 1; 190 | for(let x of coins) { 191 | for(let i=x;i<=amount;i++) dp[i] += dp[i - x]; 192 | } 193 | return dp[amount]; 194 | }; 195 | ``` 196 | 197 | -------------------------------------------------------------------------------- /21.2-字符串的经典匹配算法(手撕算法篇).md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 我们已经了解了字符串单模匹配问题具体是怎样的一个问题,并且学习了暴力匹配、KMP算法、Sunday算法、Shift-And算法等经典的字符串匹配算法及每种算法的优缺点和使用场景。今天就来通过几道算法题来巩固提升一下这些算法的特性与基础吧。 4 | 5 | ## 前置知识 6 | 7 | ### 马拉车算法 8 | 9 | - **作用:**找出一个母串的回文子串 10 | 11 | 如果没有马拉车算法,我们要求一个母串的最长回文子串要怎么求呢?一般比较简单暴力的算法就是遍历字符串的每一位,然后以遍历到的这位作为中心点向前后扩展,只要前后字符串相同,则说明是回文串,继续往前后扩展,直到前后字符串不相等为止,不相等的两位之间的回文串就是以当前字母作为中心点的回文子串。我们只需要将整个字符串都遍历一遍,取最长的那个回文串即可。 12 | 13 | #### 马拉车算法要点 14 | 15 | ##### 预处理 16 | 17 | 为了程序实现与判断时更加方便快捷,马拉车算法会将原字符串每一个字符的前后都插入一个`#`字符,这样,无论我们原始字符串的长度是奇数还是偶数,都会变成奇数,我们再对每一位前后扩展时,就可以更加方便,不会出现在两个字符中间前后扩展的情况 18 | 19 | ```bash 20 | # 长度为偶数的字符 21 | a b b a 22 | △ 23 | # 正常情况下,如果一个偶数长度的字符串,我们要看他是不是一个回文串,查找他的中心点比较困难,如上面的示例,中心点在两个b的中间 24 | # 而马拉车算法的预处理就是为了干掉这种情况 25 | # a # b # b # a # 26 | # 这样,无论原始字符串的长度是多少,假设为n,那么预处理过后字符串的长度都是2n+1,恒定为奇数 27 | ``` 28 | 29 | ##### 确定范围 30 | 31 | 预处理之后,我们就可以无需考虑字符串的奇偶性了,接下来,我们需要判断一下当前遍历的节点`i`是不是在已知的回文串范围之内。由于回文串的特性是中心对称,我们要找到第`i`个点为中心的回文串,就相当于是找与其对称的点`j`的回文串 32 | 33 | ![image-20211107103420725](https://ydschool-video.nosdn.127.net/1636252464085image-20211107103420725.png) 34 | 35 | 如果我们上述的`i`点在上述的回文串内,那么我们就可以使用上述方法加速,如果以`j`点为中心点的回文串长度为没有超过`l`,即回文串的长度在已知的大回文串内部,则以`i`点为中心点的回文串的长度就是`j`点的回文串长度,如果存在超过的部分,由于我们没办法确保超过部分是否是一个回文串,因此最多只能取到`r-i`,即以`i`为中心点的回文串长度为`r-i` 36 | 37 | 如果不在的话,我们就只能老老实实使用暴力法,依次向前后扩展查找了。 38 | 39 | #### 代码实现 40 | 41 | ```typescript 42 | // LeetCode 5. 最长回文子串 (https://leetcode-cn.com/problems/longest-palindromic-substring/) 43 | function predo(s: string) { 44 | let res = '#'; 45 | for(let char of s) { 46 | res+=`${char}#`; 47 | } 48 | return res; 49 | } 50 | function longestPalindrome(s: string): string { 51 | // 先对原字符串进行预处理,在每一个字符的前后都插入#,可以避免奇偶性的特殊判断 52 | const newS = predo(s); 53 | // 用于存储以每一个字符作为中心点的最长回文子串的半径(注意,是半径,要求长度时需要乘以2) 54 | const d: number[] = []; 55 | // l代表回文串的左边界,r代表回文串的右边界 56 | let l = 0; 57 | let r = -1; 58 | // 遍历预处理后字符串的每一个字符 59 | for(let i=0;newS[i];i++) { 60 | // 如果i超过了右边界,由于我们没办法确定超出边界的字符是否能够与边界内的字符形成回文串,因此暂且认为超出边界的字符自身形成独立回文串,长度为1 61 | if(i > r) d[i] = 1; 62 | // 如果i没有超过右边界,那么我们就要再d[j]与当前位置到右边界的距离r-i之前找一个最小值 63 | // 对称点求解技巧 j = l + r - i,其中r-i是以i为中心点最长回文串的长度,l + r - i 便是i的对称点j的位置 64 | else d[i] = Math.min(r - i, d[l + r - i]); 65 | // 上面的步骤就是我们马拉车算法中的加速步骤,接下来,还需要用暴力匹配法对无法加速的部分进行匹配 66 | // 如果当前位置的索引i减去当前位置为中心点的回文半径d[i]是一个合法的索引,并且以i为中心,以d[i]为半径的两个端点的字符相等,说明目前位置还是回文串,我们需要让当前回文串的半径加1,让回文串继续向前后扩展 67 | while(i - d[i] >= 0 && newS[i - d[i]] === newS[i + d[i]]) d[i]++; 68 | // 更新回文串的边界 69 | // 当以i为中心点的回文长度已经超过了右边界,并且前半部分依然留在左右边界之间,我们需要更新我们的左右边界 70 | if(i + d[i] > r && i - d[i] > 0) { 71 | l = i - d[i]; 72 | r = i + d[i]; 73 | } 74 | } 75 | // 至此,我们就已经将整个字符串以每一位为中心点的最长回文半径存储到了d数组中,接下来,我们只需要根据这个最长回文半径,拼接出最长回文即可 76 | let res = ''; 77 | // 用于记录上一次的最长半径 78 | let tmp = -1; 79 | for(let i=0;newS[i];i++) { 80 | // 如果上一次的回文半径比这一次长,那我们这次循环毫无意义,直接跳过 81 | if(tmp >= d[i]) continue; 82 | // 更新回文半径 83 | tmp = d[i]; 84 | // 重置结果 85 | res = ''; 86 | // 以i为中心点,以d[i]为半径的左边开始截取,直到右端点 87 | for(let j = i - d[i] + 1; j < i + d[i]; j++) { 88 | // 过滤掉预处理时添加的# 89 | if(newS[j] === "#") continue; 90 | res+=newS[j]; 91 | } 92 | } 93 | return res; 94 | }; 95 | ``` 96 | 97 | 98 | 99 | 100 | 101 | ## 刷题正餐 102 | 103 | ### [459. 重复的子字符串](https://leetcode-cn.com/problems/repeated-substring-pattern/) 104 | 105 | #### 解题思路 106 | 107 | 首先,我们可以观察一下,一个由重复的子字符串组成的字符串会有什么特点呢?我们拿“abab”举例,假如说我们把“abab”复制一份,变成“abababab”,如果让再在新的字符串中第二位开始,查找“abab”出现的位置,我们会发现,此时从第二位开始,首次出现“abab”子串的位置的索引是`2`。我们再来看一下,一个不是由重复子串组成的字符串,如:“abac”,同样复制一份得:“abacabac”,此时,如果从第二位开始查找“abac”,首次出现“abac”的子串位置的索引是4。从上面两个例子,我们可以发现一个规律:**如果一个字符串是由重复子串组成的,那么复制一份后,从第二位开始查找原字符串,第一次出现原字符串的索引肯定小于原字符串长度。而如果这个字符串不是由重复子串组成,那么复制一份后,从第二位开始查找原字符串,第一次出现原字符串的索引肯定会等于原字符串长度。** 108 | 109 | 自此,我们就通过观察规律找到了简单解决这个问题的方法,这也是为什么我们说字符串匹配算法是非常考验观察力的算法了。 110 | 111 | 除此之外,我们是否还能通过之前学习的字符串匹配的几种算法来解决呢? 112 | 113 | 首先,我们再来观察一下重复子串组成字符串的规律,如:“abcabcabc”,如果这个字符串是由重复子串组成的,他会有怎样的特点呢?是不是这个字符串的最长公共前缀应该等于最长公共后缀(前缀与后缀可相交),即:`前缀:abcabc = 后缀abcabc`,如果前缀与后缀都不相等,那么这个字符串肯定不可能是由重复子串组成的了。看到了这里的小伙伴是否觉得异常熟悉,这个最长公共前缀与后缀,不就和我们的`KMP`算法中的概念一样吗?那我们是否可以通过先求取`next`数组的方式,先找出每一个字母匹配不上时下一个指向的索引加一求得公共后缀的长度,然后与总字符串的长度相减就是重复子串的长度了,只要这个字符串总长度与重复子串长度能够整除,那是不是就说明这个字符串就是由重复子串构成的呢? 114 | 115 | #### 代码实现 116 | 117 | ##### 观察+暴力匹配 118 | 119 | ```typescript 120 | function repeatedSubstringPattern(s: string): boolean { 121 | // 将原字符串重复叠加一次,如果这个字符串是由重复的子字符串组成的,那么从第二位开始查找第一个原字符串的位置就肯定比原字符串的长度小,因为在此之前已经有匹配上的字符串了,如: 122 | // 原字符串:abab 123 | // 重复叠加:abababab 124 | // 从第二位开始查找abab,查找到的索引是2,小于原字符串的索引4 125 | return (s+s).indexOf(s, 1) !== s.length; 126 | }; 127 | ``` 128 | 129 | 130 | 131 | ##### 观察+KMP-Next 132 | 133 | ```typescript 134 | function repeatedSubstringPattern(s: string): boolean { 135 | // 利用KMP算法中求解next数组的方法,先找到最长公共前缀和后缀的长度,只要存在这个最长公共前缀并且n与这个长度取余等于0就说明妈祖条件 136 | const n = s.length; 137 | const next: number[] = []; 138 | next[0] = -1; 139 | let j = -1; 140 | for(let i=1;i abcd#dcba 184 | // 如果不加 #,'aaa'+'aaa'得到'aaaaaa',求出的最长公共前后缀是 6,但其实想要的是 3。 185 | let tmp = s +'#'+ s.split('').reverse().join(''); 186 | const n = tmp.length; 187 | // 使用KMP算法技巧求取next数组 188 | let j = -1; 189 | const next: number[] = [-1]; 190 | for(let i=1;i RMQ问题(Range Minimum/Maximum Query),即区间最大/最小值求解问题:对于长度为n的数列A,回答若干询问RMQ(A,i,j)(i,j<=n),返回数列A中下标在i,j里的最小(大)值,也就是说,RMQ问题是指求区间最值的问题。 4 | > 5 | > RMQ(x,y),就是求解一个数组在(x,y)区间内的最小值 6 | > 7 | > 如:arr = [2,3,1,4,9,0,5,6,8,7]; 8 | > 9 | > `RMQ(2,5) = 1` 10 | > 11 | > 因为(2,5)的区间值有:`1,4,9,0`,因此,此区间内的最小值是`0` 12 | 13 | **思考:** 14 | 15 | RMQ(x,9)=? 16 | 17 | 即:询问区间的尾部是固定在索引为9的位置,而起始位置不确定,请问我们至少要记录上述数组中的多少个元素才能满足`RMQ(x,9)`的任意需求,即x可以是相对于该数组来说合法的任意索引。 18 | 19 | **解析:** 20 | 21 | 我们先来暴力分析一下: 22 | 23 | `x=0|1|2|3|4|5`: 最小值为`0` 24 | 25 | `x=6`: 最小值为`5` 26 | 27 | `x=7`: 最小值为`6` 28 | 29 | `x=8|9`: 最小值为`7` 30 | 31 | 从上面的暴力分析我们呢可以看出,我们至少需要记录`0,5,6,7`这四个值,我们就可以满足`RMQ(x,9)`的任何需求。 32 | 33 | 那么,我们可以将上面的4个特殊值放到一个额外的数据结构中存储,存储时需保证这些数据的相对位置不变,即: 34 | 35 | arr2 = [0,5,6,7] 36 | 37 | 从arr2中可以看出,我们这个序列是单调递增并且相对位置与原序列保持一致的。由于需要保持相对位置,所以元素进入的顺序肯定是:`0 -> 5 -> 6 -> 7`,大家有没有发现,我们元素进入的顺序跟之前学过的`队列`是一样的,从尾部进入,并且,这个队列还有一个特性,就是他里面的元素是单调的(单调递增或者单调递减),这就是我们今天要了解的进阶数据结构`单调队列`。 38 | 39 | ## 单调队列解决的问题 40 | 41 | 从上面的`RMQ`问题我们其实应该大概能猜到了,`单调队列经常用于维护区间的最值`。不知道大家还记不记得之前还学过一个**用于维护集合最值的神兵利器:堆(大顶堆、小顶堆)**。这两个数据结构都用于维护最值,但是使用的场景稍有不同,我们今天说的`单调队列`是用于维护一个大集合中某一段区间内的最值,而`堆`通常适用于维护整个集合的最值。 42 | 43 | - **维护区间最小值的队列一定是单调递增队列** 44 | - **维护区间最大值的队列一定是单调递减队列** 45 | 46 | ## 单调队列维护区间最值模拟 47 | 48 | ```bash 49 | # 还是以上面的数组为例:arr = [2,3,1,4,9,0,5,6,8,7] 50 | # 假设我们要维护的区间长度为3(区间长度具体多少,取决于RMQ问题区间的起始点和终点,当起始点和终点重合时,我们的窗口长度为1,即每次窗口中只有一个元素,那么这个元素肯定就是当前窗口的最小值),我们可以使用之前说过的滑动窗口概念进行模拟 51 | 52 | ┏━━━━━━━┓ 53 | 元素: 2 3 1 4 9 0 5 6 8 7 54 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 55 | 索引: 0 1 2 3 4 5 6 7 8 9 56 | ┗━━━━━━━┛ 57 | # 初始时,我们的滑动窗口处于索引0~2,首先,我们先将第一个元素放入我们的单调队列中 58 | queue = [2] 59 | # 再放入第二个元素时,我们需要确保我们这个队列的单调递增的性质,由于第二个元素是3,满足单调递增性质,因此继续加入队列 60 | queue = [2,3] 61 | # 在加入第三个元素时,因为元素是1,明显已经违反了队列单调递增的性质,此时,我们需要将队列前面所有违反单调递增性质的元素删掉,然后再将第三个元素放入1中,明显,2和3都是比1大,都违反了单调递增性质,全部删掉,此时队列中只剩下新加入的1 62 | queue = [1] 63 | # 当我们把滑动窗口内部的元素都处理完后,然规滑动窗口向后移动一位 64 | 65 | ┏━━━━━━━┓ 66 | 元素: 2 3 1 4 9 0 5 6 8 7 67 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 68 | 索引: 0 1 2 3 4 5 6 7 8 9 69 | ┗━━━━━━━┛ 70 | # 此时,新增了一个元素4,因为4与1满足单调递增的性质,因此,将4加入队列,并继续让滑动窗口后移 71 | queue = [1,4] 72 | 73 | ┏━━━━━━━┓ 74 | 元素: 2 3 1 4 9 0 5 6 8 7 75 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 76 | 索引: 0 1 2 3 4 5 6 7 8 9 77 | ┗━━━━━━━┛ 78 | # 新增元素9不违反队列单调性,加入队列,窗口后移 79 | queue = [1,4,9] 80 | 81 | 82 | ┏━━━━━━━┓ 83 | 元素: 2 3 1 4 9 0 5 6 8 7 84 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 85 | 索引: 0 1 2 3 4 5 6 7 8 9 86 | ┗━━━━━━━┛ 87 | # 注意,此时由于窗口的滑动,我们之前的最小值已经出了滑动窗口了,此时我们应该让1出队列,我们可以先用一个临时数据结构保存着这个值,毕竟这也是我们曾今的王(最小值)嘛,总要有点特殊待遇的,即使退位让贤了,也要拥有崇高的地位 88 | # tmp用于保存因移出了滑动窗口而出队的最小值,注意,tmp也是一个单调递增队列,也需要维护单调递增的特性 89 | tmp = [1] 90 | # 此时,因为新进来的元素是0,由于4,9和0违反了单调性,将4、9删除,加入0,窗口后移 91 | queue = [0] 92 | 93 | ┏━━━━━━━┓ 94 | 元素: 2 3 1 4 9 0 5 6 8 7 95 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 96 | 索引: 0 1 2 3 4 5 6 7 8 9 97 | ┗━━━━━━━┛ 98 | # 新增元素5不违反队列单调性,加入队列,窗口后移 99 | tmp = [1] 100 | queue = [0,5] 101 | 102 | ┏━━━━━━━┓ 103 | 元素: 2 3 1 4 9 0 5 6 8 7 104 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 105 | 索引: 0 1 2 3 4 5 6 7 8 9 106 | ┗━━━━━━━┛ 107 | # 新增元素6不违反队列单调性,加入队列,窗口后移 108 | tmp = [1] 109 | queue = [0,5,6] 110 | 111 | ┏━━━━━━━┓ 112 | 元素: 2 3 1 4 9 0 5 6 8 7 113 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 114 | 索引: 0 1 2 3 4 5 6 7 8 9 115 | ┗━━━━━━━┛ 116 | # 新增元素6不为饭队列单调性,加入队列,窗口后移 117 | tmp = [1] 118 | queue = [0,5,6] 119 | # 此时我们队列的最小值0已经移动到滑动窗口之外了,出队列加入临时数组,新加入元素为8,不违反单调性,加入队列,窗口后移 120 | # 由于tmp中0和1违反了单调递增的特性,因此,删除1,仅保留新增加的0 121 | tmp = [0] 122 | queue = [5,6,8] 123 | 124 | 125 | ┏━━━━━━━┓ 126 | 元素: 2 3 1 4 9 0 5 6 8 7 127 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 128 | 索引: 0 1 2 3 4 5 6 7 8 9 129 | ┗━━━━━━━┛ 130 | # 新增元素7与8违反队列单调性,删除8,7加入队列,此时窗口已经到了最后,结束 131 | tmp = [0] 132 | queue = [5,6,7] 133 | 134 | # 最终我们可以发现我们把tmp和queue中的左右元素放在一起,结果就是我们上面暴力解析出来的答案:0,5,6,7 135 | ``` 136 | 137 | 从上面的模拟中,我们也可以看出,其实单调队列维护最值问题的时候,其实就是在计算`RMQ(x,9)`即固定末尾的问题的必要元素。 138 | 139 | ```bash 140 | # 验证一下,现在要求RMQ(x,6),根据上面模拟的分析 141 | ┏━━━━━━━┓ 142 | 元素: 2 3 1 4 9 0 5 6 8 7 143 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 144 | 索引: 0 1 2 3 4 5 6 7 8 9 145 | ┗━━━━━━━┛ 146 | # 新增元素5不违反队列单调性,加入队列,窗口后移 147 | tmp = [1] 148 | queue = [0,5] 149 | 150 | # 结果是1,0,5,但是1和0明显违反了单调递增的特性,按照约定,删除1,最终结果应该为0,5 151 | 152 | 153 | # 验证一下,现在要求RMQ(x,3),根据上面模拟的分析,根据上面的模拟分析 154 | 155 | ┏━━━━━━━┓ 156 | 元素: 2 3 1 4 9 0 5 6 8 7 157 | ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛ 158 | 索引: 0 1 2 3 4 5 6 7 8 9 159 | ┗━━━━━━━┛ 160 | # 此时,新增了一个元素4,因为4与1满足单调递增的性质,因此,将4加入队列,并继续让滑动窗口后移 161 | queue = [1,4] 162 | # 最终结果很明显是1,4 163 | 164 | # 至此,我们可以验证我们的分析过程是没有问题的。 165 | ``` 166 | 167 | ## 单调队列的基本操作与性质 168 | 169 | ### 入队操作(维护元素单调性) 170 | 171 | 从队尾入队,入队的同时,会把队列中破坏队列单调性的元素删掉(从队尾移除),以此来维护队列的单调性 172 | 173 | ### 出队操作(维护元素生命周期) 174 | 175 | 如果元素超出区间范围,就将元素从队首出队 176 | 177 | ### 性质 178 | 179 | **队首元素永远是当前维护区间的最值(最大或最小),维护区间最小值用单调递增序列,维护区间最大值用单调递减队列** 180 | 181 | ## 使用单调队列实现滑动窗口求最小值问题 182 | 183 | ```javascript 184 | /** 185 | * 获取一个大小为k滑动窗口内部元素最小值 186 | * @param {Array} arr 待选数组 187 | * @param {number} k 滑动窗口大小 188 | * @returns {Array} 189 | */ 190 | function moveWindow(arr, k) { 191 | const minQueue = []; 192 | let res = []; 193 | for(let i=0;i arr[i]) minQueue.pop(); 196 | // 搅屎棍踢出去了,就有位置让新人进来了,为了方便知道你个元素的位置,队列中存储的是索引与值的对象 197 | minQueue.push({idx: i, data: arr[i]}); 198 | // 当元素已经移出了滑动窗口时,需要将元素从队首弹出 199 | if(i-minQueue[0].idx === k) minQueue.shift(); 200 | // 如果滑动窗口无法再向后滑动,则继续 201 | if(i+1 arr1[p]) q1.pop(); 236 | while(q2.length && q2[q2.length-1] > arr2[p]) q2.pop(); 237 | q1.push(arr1[p]); 238 | q2.push(arr2[p]); 239 | // 分别将两个数组的元素加入到单调队列,直到两个单调队列的长度不相等时截止 240 | if(q1.length!==q2.length) break; 241 | } 242 | 243 | return p; 244 | } 245 | console.log(twinSequence([3,1,5,2,4], [5,2,4,3,1]));// 4 246 | console.log(twinSequence([3,1,5,2,4], [5,2,4,3,6]));// 5 247 | // 从上面可以看出,如果子序列长度与原序列长度相等,则说明两个原序列的趋势完全相同 248 | ``` 249 | 250 | -------------------------------------------------------------------------------- /23.1-哈夫曼编码与二叉字典树(数据结构基础篇).md: -------------------------------------------------------------------------------- 1 | # 23_1-哈夫曼编码与二叉字典树(数据结构基础篇) 2 | 3 | ## 哈夫曼编码 4 | 5 | ### 什么是编码 6 | 7 | #### 思考:在计算机当中是如何表示一个字符'a'的 8 | 9 | 有相关计算机基础知识的同学肯定都知道,我们的计算机其实是非常“笨”的,它只认识`0`和`1`,非黑即白,因此,无论我们要往计算机中存储什么信息,最终都是以二进制的形式存储,即存储了一段`0`和`1`的信息。 10 | 11 | #### 计算机中的编码 12 | 13 | 如果想要人眼去一一识别计算机中存储的`0`和`1`的信息,基本是不可能的,在我们人类的认知中,更容易让我们接受的是诸如:“a、A、0、1、2、3”等信息,因此,就需要将我们人类能够读懂的信息转换为计算机能够读懂的`0`和`1`的信息,这个过程,就是计算机中的**编码**。 14 | 15 | #### 概念 16 | 17 | **所谓编码,就是一种信息到另一种信息的映射规则。** 18 | 19 | ### 定长编码与变长编码 20 | 21 | #### 定长编码 22 | 23 | 如:**ASCII码**就是一个定长编码。在我们的ASCII码表中,每一个字符占一个字节,而8个二进制位就表示一个字节,也就是说,我们的ASCII码是固定长度为8的二进制表示的定长编码,如字符'a'的ASCII码表示为:`97`,其8位二进制表示为:`01100001`。 24 | 25 | 定长编码的好处在于:**由于定长编码代表一个字符的长度是确定的,因此,我们在编码和解码时都比较方便,直接按照这个长度截取并编码/解码即可**。 26 | 27 | ```bash 28 | # 以下二进制位代表的是一个ASCII码字符的二进制串,请转换成实际的字符 29 | 01100001010000010011001000110000 30 | # 由于ASCII码是定长编码,每8个二进制位代表一个字符,因此,我们先将上面的二进制以8个为一个单位拆开 31 | 01100001 01000001 00110010 00110000 32 | # 然后针对每一段8位2进制找到期对应的ASCII代表的字符 33 | a A 2 0 34 | # 因此,上述二进制信息代表的是字符“aA20” 35 | ``` 36 | 37 | #### 变长编码 38 | 39 | **有效的变长编码:字符编码不能是其他字符编码的前缀(非绝对,只是比较常用)。如果把编码看成是二叉树上的路径,那么所有字符都落在叶子节点上。** 40 | 41 | ```bash 42 | # 假设我们将a、A和0三个字符分别编码成以下信息: 43 | # a 44 | 01 45 | # A 46 | 10 47 | # 0 48 | 11 49 | 50 | # 我们用一个二叉树来表示0和1的编码 51 | root 52 | / \ 53 | 0 1 54 | / \ / \ 55 | 0 1 0 1 56 | a A 0 57 | # 可以看出我们的字符都是落在了二叉树的叶子节点上 58 | ``` 59 | 60 | 变长编码的应用场景:**在网络传输的过程中,通过变长编码提升网络传输的效率(需要传输的数据大小变小了,传输效率自然就高了)** 61 | 62 | ### 哈夫曼编码 63 | 64 | > **哈夫曼编码是最优的变长编码** 65 | 66 | #### 统计字符出现频率 67 | 68 | 哈夫曼编码首先会对我们待编码的字符串进行统计,将每一个字符出现的频率统计出来 69 | 70 | #### 建树 71 | 72 | 统计出每一个字符的出现频率之后,会每次将所有频率中最低的两个字符合并创建成一颗二叉子树,这颗二叉子树的总频率就是两个字符出现频率之和。合并之后再从所有字符频率中(包括新合并子树的频率)再挑出两个最小的创建子树,依次类推,知道将所有字符都放到了一颗二叉树中。 73 | 74 | #### 编码 75 | 76 | 我们将每个路径按照左0右1的原则进行编号,那么,我们每个字符所代表的编码就已经呼之欲出了。 77 | 78 | ```bash 79 | # 假设一篇文章中出现了以下字符 80 | hello 81 | 82 | # 首先统计每个字符出现的频率 83 | h: 0.2 84 | e: 0.2 85 | l: 0.4 86 | o: 0.2 87 | 88 | # 递归地将每个字母按照出现频率最低的两两建树 89 | # 首先将h和e放到一颗二叉子树中 90 | 0.4 91 | / \ 92 | h e 93 | # 然后再将新建的子树放回概率池,剩余的字符概率最低的进行建树 94 | # 发现新建子树和字符o概率最低 95 | 0.6 96 | / \ 97 | o 0.4 98 | / \ 99 | h e 100 | # 最后将l于新建子树合并 101 | 1 102 | / \ 103 | l 0.6 104 | / \ 105 | o 0.4 106 | / \ 107 | h e 108 | # 接下来我们将每条边都按照左0右1的规则进行编号 109 | 110 | 1 111 | 0 / \ 1 112 | l 0.6 113 | 0 / \ 1 114 | o 0.4 115 | 0 / \ 1 116 | h e 117 | # 那么,我们每个字符的编码就出来了 118 | l: 0 119 | o: 10 120 | h: 110 121 | e: 111 122 | 123 | # 现在我们来对以下编码进行解码试试 124 | 1101110010 125 | 126 | # 变长编码的解码规则是尽可能匹配更长的编码,匹配不上再缩小范围,按照这个规则,我们把编码划分为已下几块 127 | 110 111 0 0 10 128 | # 依次编码得 129 | h e l l o 130 | ``` 131 | 132 | #### 公式 133 | 134 | ![image-20211119075906185](https://ydschool-video.nosdn.127.net/1637279949517image-20211119075906185.png) 135 | 136 | 哈夫曼编码实际上是在求解上述公式,即根据出现概率来求解平均长度最短的码字。这也是为什么说哈夫曼编码是`最优变长编码`的原因。 137 | 138 | #### 代码实现 139 | 140 | ```typescript 141 | // 利用小顶堆维护频率池最小的节点,npm i heap 142 | const Heap = require("heap"); 143 | 144 | class HaNode { 145 | freq: number = 0; 146 | ch: string = ""; 147 | left: HaNode = null; 148 | right: HaNode = null; 149 | constructor( 150 | freq: number, 151 | ch: string, 152 | left: HaNode = null, 153 | right: HaNode = null 154 | ) { 155 | this.freq = freq; 156 | this.ch = ch; 157 | this.left = left; 158 | this.right = right; 159 | } 160 | } 161 | 162 | export type HaffmanInfo = { 163 | root: HaNode; 164 | codes: Record; 165 | codeKey: Record; 166 | freqInfo: Record; 167 | }; 168 | 169 | export type HaffmanEncodeResult = { 170 | detail: Record; 171 | code: string; 172 | freqInfo: Record; 173 | haffmanInfo: HaffmanInfo 174 | }; 175 | 176 | class HaffmanCode { 177 | // 由于建树时需要每次获取两个出现频次最低的节点,因此利用小顶堆实现 178 | heap: Heap; 179 | constructor() { 180 | this.heap = new Heap((a: HaNode, b: HaNode) => a.freq - b.freq); 181 | } 182 | /** 183 | * 统计每个字符出现的次数 184 | * @param str 185 | * @returns 186 | */ 187 | statistics(str: string): Record { 188 | const map: Record = {}; 189 | for (let c of str) { 190 | if (map[c] === undefined) map[c] = 0; 191 | map[c]++; 192 | } 193 | return map; 194 | } 195 | /** 196 | * 根据构建的二叉字典树构建成哈夫曼编码 197 | * @param root 二叉树根节点 198 | * @param prefix 当前节点的前缀字符串 199 | * @param codes 储存字符和哈夫曼编码的映射关系,用于编码 200 | * @param codeKey 储存哈夫曼编码与字符的映射关系,用于解码 201 | * @returns 202 | */ 203 | __extra_code( 204 | root: HaNode, 205 | prefix: string = "", 206 | codes: Record, 207 | codeKey: Record 208 | ): void { 209 | if (root.ch !== "") { 210 | codes[root.ch] = prefix; 211 | codeKey[prefix] = root.ch; 212 | return; 213 | } 214 | this.__extra_code(root.left, prefix + "0", codes, codeKey); 215 | this.__extra_code(root.right, prefix + "1", codes, codeKey); 216 | } 217 | /** 218 | * 预处理哈夫曼编码的一些信息,如手机字符出现频次,创建小顶堆,建树,字符编码银蛇 219 | * @param str 220 | * @returns 221 | */ 222 | __predo(str: string): HaffmanInfo { 223 | // 统计每个字符出现的频次 224 | const map = this.statistics(str); 225 | // 按照频次将节点插入到小顶堆中 226 | Object.keys(map).forEach((key) => { 227 | this.heap.insert(new HaNode(map[key], key)); 228 | }); 229 | // 每次从小顶堆弹出两个元素(频次最少)合并成一个节点 230 | while (this.heap.size() > 1) { 231 | const a: HaNode = this.heap.pop(); 232 | const b: HaNode = this.heap.pop(); 233 | this.heap.insert(new HaNode(a.freq + b.freq, "", a, b)); 234 | } 235 | const root: HaNode = this.heap.top(); 236 | const codes: Record = {}; 237 | const codeKey: Record = {}; 238 | this.__extra_code(root, "", codes, codeKey); 239 | return { 240 | root, 241 | codes, 242 | freqInfo: map, 243 | codeKey 244 | }; 245 | } 246 | /** 247 | * 编码 248 | * @param str 249 | * @returns 250 | */ 251 | encode(str: string): HaffmanEncodeResult { 252 | const info = this.__predo(str); 253 | const { codes, freqInfo } = info; 254 | return { 255 | detail: codes, 256 | code: str 257 | .split("") 258 | .map((char) => codes[char]) 259 | .join(""), 260 | freqInfo, 261 | haffmanInfo: info 262 | }; 263 | } 264 | /** 265 | * 解码 266 | * @param str 267 | * @param info 包含编码信息与字符映射信息等编码相关信息,用于解码 268 | * @returns 269 | */ 270 | decode(str: string, info: HaffmanInfo): string { 271 | let pos = 0; 272 | let res = ''; 273 | let buff = ''; 274 | const {codeKey} = info; 275 | while(str[pos]) { 276 | buff += str[pos++] 277 | if(codeKey[buff]) { 278 | res+=codeKey[buff]; 279 | buff = ''; 280 | } 281 | } 282 | return res; 283 | } 284 | } 285 | 286 | const haffmanCode = new HaffmanCode(); 287 | 288 | const { detail, code, freqInfo, haffmanInfo } = haffmanCode.encode( 289 | "hello, my name is kiner tang! would you like some milk?" 290 | ); 291 | 292 | Object.keys(detail).forEach((key) => { 293 | console.log( 294 | `字符【${key}(出现频次:${freqInfo[key]})】的哈夫曼编码为:${detail[key]}` 295 | ); 296 | }); 297 | 298 | console.log("完整哈夫曼编码:", code); 299 | 300 | console.log(`解码:${haffmanCode.decode(code, haffmanInfo)}`); 301 | 302 | // 字符【 (出现频次:10)】的哈夫曼编码为:00 303 | // 字符【l(出现频次:5)】的哈夫曼编码为:010 304 | // 字符【!(出现频次:1)】的哈夫曼编码为:01100 305 | // 字符【s(出现频次:2)】的哈夫曼编码为:01101 306 | // 字符【n(出现频次:3)】的哈夫曼编码为:0111 307 | // 字符【o(出现频次:4)】的哈夫曼编码为:1000 308 | // 字符【t(出现频次:1)】的哈夫曼编码为:100100 309 | // 字符【,(出现频次:1)】的哈夫曼编码为:100101 310 | // 字符【d(出现频次:1)】的哈夫曼编码为:100110 311 | // 字符【?(出现频次:1)】的哈夫曼编码为:100111 312 | // 字符【i(出现频次:4)】的哈夫曼编码为:1010 313 | // 字符【m(出现频次:4)】的哈夫曼编码为:1011 314 | // 字符【a(出现频次:2)】的哈夫曼编码为:11000 315 | // 字符【g(出现频次:1)】的哈夫曼编码为:110010 316 | // 字符【w(出现频次:1)】的哈夫曼编码为:110011 317 | // 字符【h(出现频次:1)】的哈夫曼编码为:110100 318 | // 字符【r(出现频次:1)】的哈夫曼编码为:110101 319 | // 字符【u(出现频次:2)】的哈夫曼编码为:11011 320 | // 字符【y(出现频次:2)】的哈夫曼编码为:11100 321 | // 字符【k(出现频次:3)】的哈夫曼编码为:11101 322 | // 字符【e(出现频次:5)】的哈夫曼编码为:1111 323 | // 完整哈夫曼编码: 1101001111010010100010010100101111100000111110001011111100101001101001110110100111111111010100100100110000111110010011000011001110001101101010011000111001000110110001010101110111110001101100010111111001011101001011101100111 324 | // 解码:hello, my name is kiner tang! would you like some milk? 325 | 326 | ``` 327 | 328 | -------------------------------------------------------------------------------- /24.1-前缀和与树状数组(数据结构基础篇).md: -------------------------------------------------------------------------------- 1 | # 24_1-前缀和与树状数组(数据结构基础篇) 2 | 3 | ## 前言 4 | 5 | 在我们以往的文章中,经常会出现使用`前缀和`求取区间和的场景。但是,很多同学对于`前缀和`的概念和一些优缺点并不清楚,因此,这边发个单章单独聊一下前缀和与树状数组。 6 | 7 | ## 前缀和数组 8 | 9 | ### 初始化 10 | 11 | 时间复杂度为:`O(n)`,直接顺序遍历整个原数组累加即可 12 | 13 | ### 查询区间和 14 | 15 | 时间复杂度:`O(1)`,由于初始化时已经将前`n`个元素的总和存储到了数组的第`n`位中,因此,我们想要求取第`i`位到第`j`位的区间和:`s[j] - s[i]`即可。 16 | 17 | ### 单点修改 18 | 19 | 时间复杂度:`O(n)`,由于我们前缀和数组中的每一个元素都与之前的元素强相关,因此,只要任意元素改动都会影响到后面的所有元素的结果。由此可见,前缀和数组在处理元素组可能经常发生变化的场景时,效率是比较低的。 20 | 21 | ![image-20211123212110293](https://ydschool-video.nosdn.127.net/1637673675065image-20211123212110293.png) 22 | 23 | 上图代表的是,如果我们修改了数组的第一项,那么我们数组之后的每一项都要跟着改 24 | 25 | ### 优化 26 | 27 | 弱化每个元素与前面元素强相关的关系,虽然会牺牲掉一定的查询区间和的速度,但同时能够提升单点修改的速度。 28 | 29 | 那么,我们具体要如何优化呢?首先,我们来接触一个概念: 30 | 31 | #### lowbit 32 | 33 | 这是一个函数,这个函数的作用就是传入一个数字,返回这个数字二进制表示中最后一个1的位权。如: 34 | 35 | ```typescript 36 | lowbit(1) = 1;// 1的四位二进制表示为:0001,最后一个1代表的数字就是1 37 | lowbit(2) = 2;// 2的四位二进制表示为:0010,最后一个1代表的数字就是2 38 | lowbit(3) = 1;// 3的四位二进制表示为:0011,最后一个1代表的数字就是1 39 | lowbit(4) = 4;// 4的四位二进制表示为:0100,最后一个1代表的数字就是4 40 | lowbit(5) = 1;// 5的四位二进制表示为:0101,最后一个1代表的数字就是1 41 | lowbit(6) = 2;// 6的四位二进制表示为:0110,最后一个1代表的数字就是2 42 | lowbit(7) = 1;// 7的四位二进制表示为:0111,最后一个1代表的数字就是1 43 | lowbit(8) = 8;// 8的四位二进制表示为:1000,最后一个1代表的数字就是8 44 | lowbit(9) = 1;// 9的四位二进制表示为:1001,最后一个1代表的数字就是1 45 | lowbit(10) = 2;// 10的四位二进制表示为:1010,最后一个1代表的数字就是2 46 | lowbit(11) = 1;// 11的四位二进制表示为:1011,最后一个1代表的数字就是1 47 | lowbit(12) = 4;// 12的四位二进制表示为:1100,最后一个1代表的数字就是4 48 | lowbit(13) = 1;// 13的四位二进制表示为:1101,最后一个1代表的数字就是1 49 | lowbit(14) = 2;// 14的四位二进制表示为:1110,最后一个1代表的数字就是2 50 | lowbit(15) = 1;// 15的四位二进制表示为:1111,最后一个1代表的数字就是1 51 | ``` 52 | 53 | 从上面的示例中,大家应该都明白`lowbit`的作用了,在js当中,我们可以用以下方式实现`lowbit`函数: 54 | 55 | ```typescript 56 | function lowbit(num: number): number { 57 | return num & (-num); 58 | } 59 | ``` 60 | 61 | 假设`C(i)`代表前`lowbit(i)`项元素之和,如: 62 | 63 | ```typescript 64 | C[10] = a[10] + a[9]; // 由于lowbit(10) = 2,因此C[10]代表a[10]与a[9]的和 65 | C[12] = a[12] + a[11] + a[10] + a[9];// 由于lowbit(12) = 4,因此C[12]代表a[12]、a[11]、a[10]与a[9]的和 66 | ``` 67 | 68 | ![image-20211123212959244](https://ydschool-video.nosdn.127.net/1637674204924image-20211123212959244.png) 69 | 70 | 相同的情况,如果原数组的第一位发生改变,我们需要改变的只有`C[1]`、`C[2]`、`C[4]`、`C[8]`,相比起传统前缀和数组,无疑需要修改的元素数量减少了。 71 | 72 | ## 树状数组 73 | 74 | 上述使用`lowbit`优化后的数组,就是树状数组,那么,我们的树状数组要如何查询前缀和呢? 75 | 76 | ### 查询前缀和 77 | 78 | `S[i] = S[i - lowbit(i)] + C[i]` 79 | 80 | ![image-20211124202357546](https://ydschool-video.nosdn.127.net/1637756641072image-20211124202357546.png) 81 | 82 | 例如,上图所示,如果我们想要求前7项的前缀和`S[7] = S[7 - lowbit(7)] + C[7] = S[6] + C[7] = S[6 - lowbit(6)] + C[6] + C[7] = S[4] + C[6] + C[7] = C[4] + C[6] + C[7]` 83 | 84 | 使用这个公式统计前缀和,时间复杂度是:`log(n)`。 85 | 86 | ### 单点修改操作 87 | 88 | 假如说我们要将原数组第5个元素在原来的基础上加上10,那么,都有哪些元素会相应发生变化呢?`C[5]`肯定会发生改变的,`C[6]`和`C[8]`也会因为`C[5]`改变而改变,因此,改变的元素有:`C[5]、C[6]、C[8]`。 89 | 90 | ![image-20211124210506463](https://ydschool-video.nosdn.127.net/1637759110381image-20211124210506463.png) 91 | 92 | 那么,我们要如何确定`C[6]`和`C[8]`的值呢?大家先来思考一个问题:“之所以C[5]改变之后,C[6]和C[8]也会发生改变,是不是因为C[6]和C[8]包含的范围都包括了C[5],并且最少都比C[5]大1倍”。思考清楚这个事情之后,我们再来看一个公式:`A[i] = C[i + lowbit(i)]`。这个公式中的`A[i]`就是代表`C[i]头顶上的元素`,由于`i + lowbit(i)`的结果确定的范围至少是`i`确定范围的两倍,因此,刚好符合我们上面的推论。举个例子: 93 | 94 | ```bash 95 | # C[i] 96 | 10000100 97 | # C[i + lowbit(i)] 98 | i: 10000100 99 | lowbit(i): 00000100 100 | => 10001000 101 | # 由于i+lowbit(i)肯定会导致原来的i进位,一旦进位,那么表示的范围至少都是原先的1倍 102 | # 因此,我们可以通过下面公式求取头顶上的那个元素 103 | A[i] = C[i + lowbit(i)] 104 | 105 | # 如上图如果修改原数组的第五个元素 A[5],我们如何通过公式确定需要修改树状数组中的那些元素呢 106 | A[5] = C[5] + A[5 + lowbit(5)] = C[5] + A[6] = C[5] + C[6] + A[6 + lowbit(6)] = C[5] + C[6] + A[8] = C[5] + C[6] + C[8] 107 | ``` 108 | 109 | ### 树状数组求解前缀和及单点修改代码演示 110 | 111 | ```typescript 112 | class FenwickTree { 113 | // 用于计算lowbit值 114 | static lowbit(x) { 115 | return x & -x; 116 | } 117 | // 树状数组 118 | private c: number[]; 119 | // 数组的下标上线,一般是原数组长度加1 120 | private size: number; 121 | constructor(size: number) { 122 | this.size = size; 123 | this.c = new Array(size); 124 | this.c.fill(0); 125 | } 126 | /** 127 | * 往原始数组的第i位增加元素x 128 | * @param i 129 | * @param x 130 | */ 131 | public add(i: number, x: number): void { 132 | while(i <= this.size) { 133 | this.c[i] += x; 134 | i += FenwickTree.lowbit(i); 135 | } 136 | } 137 | /** 138 | * 查询原数组前i项和 139 | * @param i 140 | * @returns 141 | */ 142 | public query(i: number): number { 143 | let sum = 0; 144 | // S[i] = S[i - lowbit(i)] + C[i] 145 | while(i) { 146 | sum += this.c[i]; 147 | i -= FenwickTree.lowbit(i); 148 | } 149 | return sum; 150 | } 151 | /** 152 | * 根据索引查找原始数组每一项的值 153 | * @param idx 154 | * @returns 155 | */ 156 | public at(idx: number): number { 157 | return this.query(idx) - this.query(idx - 1); 158 | } 159 | /** 160 | * 将元素组第 idx 位的值改成 val 161 | * @param idx 162 | * @param val 163 | */ 164 | public update(idx: number, val: number): void { 165 | console.log(`将第${idx}位的值改成: ${val}`) 166 | this.add(idx, val - this.at(idx)); 167 | } 168 | public output(): void { 169 | let line1 = ''; 170 | let line2 = ''; 171 | let line3 = ''; 172 | let line4 = ''; 173 | let line5 = ''; 174 | this.c.forEach((item, idx) => { 175 | if(idx === 0) return; 176 | line1+=String(idx).padStart(6, ' '); 177 | line2+=String("=").padStart(6, '='); 178 | line3+=String(item).padStart(6, ' '); 179 | line4+=String("=").padStart(6, '='); 180 | line5+=String(this.at(idx)).padStart(6, ' '); 181 | }); 182 | // 编号 183 | console.log(line1); 184 | console.log(line2); 185 | // 树状数组值,当前所有元素都为1时,树状数组中第i位的值实际上就是lowbit(i) 186 | console.log(line3); 187 | console.log(line4); 188 | // 原数组的值 189 | console.log(line5+"\n\n"); 190 | } 191 | } 192 | 193 | const source = [1,1,1,1,1,1,1,1,1,1]; 194 | const fenwickTree = new FenwickTree(source.length+1); 195 | source.forEach((item, idx) => fenwickTree.add(idx+1, item)); 196 | fenwickTree.output(); 197 | 198 | // 1 2 3 4 5 6 7 8 9 10 199 | // ============================================================ 200 | // 1 2 1 4 1 2 1 8 1 2 201 | // ============================================================ 202 | // 1 1 1 1 1 1 1 1 1 1 203 | 204 | 205 | fenwickTree.update(5, 10); 206 | fenwickTree.output(); 207 | fenwickTree.update(3, 6); 208 | fenwickTree.output(); 209 | fenwickTree.update(4, 7); 210 | fenwickTree.output(); 211 | fenwickTree.update(2, 9); 212 | fenwickTree.output(); 213 | 214 | // 将第5位的值改成: 10 215 | // 1 2 3 4 5 6 7 8 9 10 216 | // ============================================================ 217 | // 1 2 1 4 10 11 1 17 1 2 218 | // ============================================================ 219 | // 1 1 1 1 10 1 1 1 1 1 220 | 221 | 222 | // 将第3位的值改成: 6 223 | // 1 2 3 4 5 6 7 8 9 10 224 | // ============================================================ 225 | // 1 2 6 9 10 11 1 22 1 2 226 | // ============================================================ 227 | // 1 1 6 1 10 1 1 1 1 1 228 | 229 | 230 | // 将第4位的值改成: 7 231 | // 1 2 3 4 5 6 7 8 9 10 232 | // ============================================================ 233 | // 1 2 6 15 10 11 1 28 1 2 234 | // ============================================================ 235 | // 1 1 6 7 10 1 1 1 1 1 236 | 237 | 238 | // 将第2位的值改成: 9 239 | // 1 2 3 4 5 6 7 8 9 10 240 | // ============================================================ 241 | // 1 10 6 23 10 11 1 36 1 2 242 | // ============================================================ 243 | // 1 9 6 7 10 1 1 1 1 1 244 | ``` 245 | 246 | 247 | 248 | ## 扩展知识 249 | 250 | ### 差分数组 251 | 252 | 顾名思义,就是原数组相邻两位相减的差作为数组的每一个元素,举个例子: 253 | 254 | ```bash 255 | # 原数组 256 | 1 2 3 4 5 257 | 258 | # 前缀和数组(前缀和数组的第0位为特殊位,固定为0,实际使用时不会使用到,因此不做考虑) 259 | S = 1 3 6 10 15 260 | # 原数组 261 | A = 1 2 3 4 5 262 | # 差分数组,将数组的每一位为当前位与前一位相减的差值 263 | X = 1 1 1 1 1 264 | ``` 265 | 266 | 从上面的前缀和数组、原数组、差分数组的对比,我们可以发现前缀和数组是的每一位是当前位与上一位相加之和,而差分数组则是当前位与上一位相减之差,而且我们可以发现,如果只看S和A,那么S是A的前缀和数组、A是S的差分数组。如果只看A和X也是一样,A是X的前缀和数组,X是A的差分数组,因此,我们可以得出,实际上,**前缀和数组和差分数组互为逆运算**的结论。 267 | 268 | 接下来我们再来看一下,如果要在元素组的第`i`位到第`j`位都加上`m`,那么,在差分数组上有什么特性呢? 269 | 270 | ```bash 271 | # 假如我们现在要在原数组A的第1位~第3位都加上2,即上述的i=1,j=3,m=2 272 | # 原数组 273 | A = 1 2+2 3+2 4+2 5 274 | # 差分数组 275 | X = 1 1+2 1 1 1+2 276 | 277 | ``` 278 | 279 | 由上面的规律,我们不难看出,如果在原数组的第`i`位到第`j`位都加上`m`,那么在差分数组中的表现为在第`i`位和第`j+1`位加上`m`(`i`和`j+1`均小于数组长度),即我们可以得出以下结论: 280 | 281 | `A[i, j, m] = X[i, j+1, m]`,其中`A[i, j, m]`代表原数组的第`i`位到第`j`位的数字均加上`m`的结果,而`X[i, j+1, m]`则代表在差分数组中,只有第`i`位和第`j+1`位需要加上`m`其他位不变。 282 | 283 | -------------------------------------------------------------------------------- /19.2-递推算法与递推套路(手撕算法篇).md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 之前学习基础知识的时候也说了,递推和动态规划有这暧昧不清的关系,可以说,动态规划就是多了一个决策过程的递推,因此,我们今天的刷题也会涉及到一些比较简单的动态规划的题目,同样能够对我们深刻的理解递推算法起到帮助,也为我们之后深入学习动态规划算法和动态规划的优化打下基础。 4 | 5 | ## 前置知识 6 | 7 | ### 滚动数组 8 | 9 | **使用场景**:当我们下(上)一行的状态可以仅仅通过上(下)一行的信息推导出来,并要求我们求解最后(第)一行时,我们无需将每一行的数据都存储在数组当中,可以通过滚动数组的技巧,节省空间复杂度。 10 | 11 | **具体实现**:假设我们已经知道第一行的数据,并且通过第一行数据经过一定的运算可以得到第二行的数据,那么,我们只需要一个数组临时存储两行数据,就可以将之后的每一行数据的结果计算出来,不断的滚动替换这两行数据,最终求解出最后一行数据的方式。 12 | 13 | **关键点**:推导计算当前行和下(上)一行的索引。由于数组时滚动的,因此,我们目标行的索引也是随时变化的,以下为求取当前行和上(下)一行的通用公式: 14 | 15 | - **当前行:** `const curIdx = i % 2` 16 | - 由于我们的滚动数组只有2行,因此,当前索引只要与2求模即可 17 | - **上(下)一行:**`const preIdx = +!curIdx` 18 | - 由于滚动数组只有两行,索引要么是`0`,要么是`1`,我们对当前行取反便可得到上(下)一行的索引(注意,在js中,对1取反是`false`,对0取反是`true`,因此我们通过一个隐式类型转换将布尔类型转换为数字类型)。 19 | 20 | **实际使用:**后面刷题时会经常用到,详见下文。 21 | 22 | ## 刷题正餐 23 | 24 | ### [LeetCode 120. 三角形最小路径和](https://leetcode-cn.com/problems/triangle/) 25 | 26 | #### 解题思路 27 | 28 | 按照我们之前将的递推套路: 29 | 30 | 1. **定义递推状态:**在这道题中,我们每走一步的路径和主要取决于当前所处的行数`i`和当前的列数`j`,因此,我们这道题的递推状态应该是:**dp[i, j]** 31 | 32 | 2. **递推公式:**确定了递推状态之后,我们就要确定递推公式了。那么,第`i`行第`j`列的数据要如何才能推导出来呢?首先,依据题意,我们要求最小路径和,如果我们从最底下往上走,那么我们可以知道,下一行`i`的数据应该是上一行`i+1`合法路径的最小值加上当前走到节点的值。 33 | 34 | 因此,我们得到了如下公式: 35 | 36 | ```javascript 37 | // 第i行第j列数据的推导公式 38 | dp[i, j] = min(dp[i+1, j], dp[i+1, j+1]) + val[i,j] 39 | ``` 40 | 41 | 42 | 43 | 3. **分析边界条件:**我们需要将我们题目已知的条件初始化到我们的递推数组当中作为边界条件。这道题中,边界条件就是最后一行的数据,我们将最后一行的数据先加入到滚动数组当中,这样之后就可以根据最后一行数据不断的往上推导总路径和,从而找到最小路径。 44 | 45 | 4. **程序实现:**我们直接使用循环加滚动数组技巧实现。 46 | 47 | #### 代码演示 48 | 49 | ```typescript 50 | function minimumTotal(triangle: number[][]): number { 51 | const n = triangle.length; 52 | // 递推公式(状态转义方程)以下为自底向上走的公式 53 | // dp[i, j] = min(dp[i+1, j], dp[i+1, j+1]) + val[i,j] 54 | // 由于i只跟i+1有关,因此,我们可以用滚动数组的技巧定义dp数组 55 | let dp: number[][] = []; 56 | for(let i=0;i<2;i++){ 57 | dp.push([]); 58 | } 59 | // 首先初始化最后一行的数值,由于使用了滚动数组的技巧,因此,我们最后一行的索引应该是(n-1)%2 60 | for(let i=0;i=0;--i) { 65 | // 由于使用了滚动数组,因此,当前行的下标为i%2,而下一行的下标则是当前行下标取反 66 | let idx = i % 2; 67 | let nextIdx = +!idx; 68 | // 根据上面的公式计算出每一个位置上的值 69 | for (let j=0; j <= i; j++) { 70 | dp[idx][j] = Math.min(dp[nextIdx][j], dp[nextIdx][j + 1]) + triangle[i][j]; 71 | } 72 | } 73 | // 最终,三角形顶点的那个值就是我们要求的值 74 | return dp[0][0]; 75 | }; 76 | ``` 77 | 78 | ### [LeetCode 119. 杨辉三角 II](https://leetcode-cn.com/problems/pascals-triangle-ii/) 79 | 80 | #### 解题思路 81 | 82 | 这道题与上一道题类似,依然可以根据上一行推导出下一行的值,因此还是要祭出滚动数组的技巧,递推状态与递推公式的分析也比较类似,大家可以自己尝试推导。而这一道题的边界条件,其实就是每一行的第一位都应该是1。 83 | 84 | #### 代码演示 85 | 86 | ```typescript 87 | function getRow(rowIndex: number): number[] { 88 | const res: number[][] = []; 89 | // 初始化两行全部初始填充0的滚动数组 90 | for(let i=0;i<2;i++) res.push(new Array(rowIndex+1).fill(0)); 91 | for(let i=0;i<=rowIndex;i++) { 92 | // 计算滚动数组当前索引和上一行索引 93 | let idx = i % 2; 94 | let preIdx = +!idx; 95 | res[idx][0] = 1; 96 | // 计算每一行出第一位外其他位置的值 97 | for(let j=1;j<=i;j++) { 98 | res[idx][j] = res[preIdx][j-1] + res[preIdx][j]; 99 | } 100 | } 101 | // 滚动数组最后一行 102 | return res[(rowIndex % 2)] 103 | }; 104 | ``` 105 | 106 | ### [LeetCode 198. 打家劫舍](https://leetcode-cn.com/problems/house-robber/) 107 | 108 | #### 解题思路 109 | 110 | 1. **递推状态分析:**既然要求能够偷到的最多的金额,那么,假设最后一家是`n`,那么,最大金额与我们是否偷最后一家有直接的关系,我们需要分类讨论: 111 | 112 | 1. **不偷最后一家:**`dp[n][0]`其中,0代表不偷 113 | 2. **偷最后一家:**`dp[n][1]`其中,1代表偷 114 | 115 | 2. **确定递推公式:**由于递推状态分为两种情况讨论,因此,我们的递推公式,也应该分成两个部分: 116 | 117 | 1. **不偷最后一家:**由于不能连续偷相邻的两家,如果最后一家不偷,那么,我们倒数第二家就可以投,因此,此时我们的最大收益就取决于偷倒数第二家的金额与不偷倒数第二家金额的最大值。即:` dp[n][0] = max(dp[n-1][0], dp[n-1][1]) ` 118 | 2. **偷最后一家:**由于要偷最后一家,那么就不能偷倒数第二家,因此,这种情况的最大收益是不偷倒数第二家获得的收益加上偷最后一家带来的收益,即`dp[n][1] = dp[n-1][0] + nums[n]`。 119 | 120 | 3. **确定边界条件:** 121 | 122 | 依据题意,我们如果不偷第一家的时候,因为一家都没偷,此时收益应该为`0`。如果偷第一家,那么收益就是第一家的钱。至此,我们就确立了最开始的边界条件。 123 | 124 | 4. **程序实现:**这道题由于当前收益只取决于上一家的收益,因此依然使用滚动数组技巧加循环实现。 125 | 126 | #### 代码演示 127 | 128 | ```typescript 129 | function rob(nums: number[]): number { 130 | const n = nums.length; 131 | // 由于不能连续偷两家,因此,最大收益应该分为两种情况讨论: 132 | // 1. dp[n][0] = max(dp[n-1][0], dp[n-1][1]) 即:不偷最后一家的最后收益,取决于我偷倒数第二家的收益与不偷倒数第二家的收益的最大值 133 | // 2. dp[n][1] = dp[n-1][0] + nums[n] 即:如果投了最后一家,那么倒数第二家就不能偷,所以最大收益就等于不偷倒数第二家的收益加上偷最后一家获得的收益 134 | const dp: number[][] = []; 135 | for(let i=0;i<2;i++) dp.push([]); 136 | 137 | // 初始化第0家的值 138 | dp[0][0] = 0;// 不偷第一家时收益为0 139 | dp[0][1] = nums[0];// 偷第一家时收益为第一家的钱 140 | for(let i=1;i dp[i - coin] + 1) dp[i] = dp[i - coin]+1; 217 | } 218 | } 219 | return dp[amount]; 220 | }; 221 | ``` 222 | 223 | ### [LeetCode 300. 最长递增子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/) 224 | 225 | #### 解题思路 226 | 227 | ##### 概念扫盲 228 | 229 | ###### 递增子序列 230 | 231 | 你可以在一个完整的序列中,“跳着”选择元素,并且下一个元素必须不能小于上一个元素。所谓“跳着”,就是指元素索引不需要连续,如下面示例: 232 | 233 | ```bash 234 | # 原始序列 235 | 1, 4, 2, 2, 3, 5, 0, 6 236 | # 递增子序列 237 | 1, 2, 2, 3, 5, 6 238 | ``` 239 | 240 | ###### 严格最长递增子序列 241 | 242 | 严格递增子序列是在递增子序列的基础上多了一个限定条件,就是下一个元素不能等于上一个元素,只能大于,如下示例: 243 | 244 | ```bash 245 | # 原始序列 246 | 1, 4, 2, 2, 3, 5, 0, 6 247 | # 严格递增子序列 248 | 1, 2, 3, 5, 6 249 | ``` 250 | 251 | ##### 思路详情 252 | 253 | 1. **递推状态:**由于我们最长递增子序列的长度与我当前以哪个元素作为最后一个元素有关,因此,我们的递推状态为:`dp[n]`,代表以n位置作为结尾的最长递增子序列的长度 254 | 2. **递推公式:**我们要算出以第n个元素作为结尾的最长递增子序列的长度,就要找出他上一个合法的最长递增子序列的最后一个元素j,而我们第n个元素作为结尾的最长递增子序列长度就是上一个最长递增子序列长度加一,我们只需要将所有满足这个条件的最长递增子序列长度的最大值求出来就是最终的结果,即:`dp[n] = max(dp[j] + 1) | j= k) { 182 | pos = q.shift(); 183 | } 184 | // 如果找到了满足条件的数,并且结果比上一次计算的小或上一次为-1,则更新结果 185 | if(pos!==-1&&(res===-1||i-pos sum[i]) q.pop(); 188 | q.push(i); 189 | } 190 | return res; 191 | }; 192 | // @lc code=end 193 | 194 | 195 | ``` 196 | 197 | ## [1438. 绝对差不超过限制的最长连续子数组](https://leetcode-cn.com/problems/longest-continuous-subarray-with-absolute-diff-less-than-or-equal-to-limit/) 198 | 199 | ### 解题思路 200 | 201 | ```bash 202 | # 首先,我们来假设我们要找的符合条件的最长连续子数组就在下图的╳的阴影部分 203 | 204 | ┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┓ 205 | ┃ ╳╳╳╳╳╳╳╳╳╳╳╳╳╳ ┃ 206 | ┗━━━━━━━━┻━━━━━━━━━━━━┻━━━━━━━━┛ 207 | # 假设上述的阴影部分也就是我们符合条件最长连续子数组的长度为L 208 | # 那么,大家来思考一个问题: 209 | # 在阴影部分,L-1长度的数组是否也符合条件呢?同理L-2、L-3呢? 210 | # 这个问题其实很简单,既然阴影部分长度为L的子数组满足条件,那么L-1、L-2、L-3自然也都满足绝对差不超过限制的条件,因为我们要求解一段区间的绝对差,首先是找到这个区间的最大值和最小值,然后相减。那么我们已经把整个阴影部分的最大值和最小值的绝对差求出来了,那么L-1、L-2、L-3的绝对差是不可能超过L长度子数组的绝对差的,因此,只要L长度的子数组满足条件,L-1、L-2、L-3长度的子数组也肯定满足条件。 211 | # 接下来,我们再来思考一个问题: 212 | # 在阴影部分,L+1长度的数组是否也符合条件呢?同理L+2、L+3呢? 213 | # 显然,因为我们刚开始假设L就是满足条件的最长连续子数组的长度,那么L+1、L+2、L+3肯定就是不满足条件的了。 214 | 215 | # 综上,我们可以总结出这样的规律 216 | 数组长度:L-1、L-2、L-3 L L+1、L+2、L+3 217 | 满足条件: √ √ √ √ × × × 218 | 零壹模型: 1 1 1 1 0 0 0 219 | # 从上面可以看出,我们要求的结果,其实可以看成是是之前学习二分查找算法时,经常会遇到的1-0模型求解最后一个1的问题 220 | # 因此,我们这道题应该使用1-0模型的二分查找最后一个1的方式解决 221 | # 那么,二分查找有一个关键的判定条件要如何确定呢? 222 | # 我们来思考一下,我们原本的判定条件应该是当前长度L的子数组,应该要满足绝对差不超过limit。那么,要求的L是数组的长度,那么,我们是否可以把这个判定条件看作是在一个长度为L的滑动窗口中查询最大值和最小值,然后将最大值和最小值求解绝对差的问题呢?这样,我们的判定条件就可以使用两个单调队列,一个单调队列维护最大值(单调递减),一个单调队列用于维护最小值(单调递增),然后求解绝对差是否小于或等于limit即可。 223 | 224 | # 综上所述,我们已经捋清楚了求解本题的思路了,总结一下: 225 | # 本题需要使用二分+判定的方式求解,至于为什么要用二分+判定,详见上文。 226 | # 而最终要的判定条件,我们可以转化为在一个长度为L的固定窗口中,求解最大值和最小值,然后求取他们的绝对差看是否小于等于limit来作为判定条件。 227 | ``` 228 | 229 | 230 | 231 | ### 代码演示 232 | 233 | ```typescript 234 | 235 | /* 236 | * @lc app=leetcode.cn id=1438 lang=typescript 237 | * 238 | * [1438] 绝对差不超过限制的最长连续子数组 239 | * 240 | * https://leetcode-cn.com/problems/longest-continuous-subarray-with-absolute-diff-less-than-or-equal-to-limit/description/ 241 | * 242 | * algorithms 243 | * Medium (48.12%) 244 | * Likes: 199 245 | * Dislikes: 0 246 | * Total Accepted: 31K 247 | * Total Submissions: 64.4K 248 | * Testcase Example: '[8,2,4,7]\n4' 249 | * 250 | * 给你一个整数数组 nums ,和一个表示限制的整数 limit,请你返回最长连续子数组的长度,该子数组中的任意两个元素之间的绝对差必须小于或者等于 251 | * limit 。 252 | * 253 | * 如果不存在满足条件的子数组,则返回 0 。 254 | * 255 | * 256 | * 257 | * 示例 1: 258 | * 259 | * 输入:nums = [8,2,4,7], limit = 4 260 | * 输出:2 261 | * 解释:所有子数组如下: 262 | * [8] 最大绝对差 |8-8| = 0 <= 4. 263 | * [8,2] 最大绝对差 |8-2| = 6 > 4. 264 | * [8,2,4] 最大绝对差 |8-2| = 6 > 4. 265 | * [8,2,4,7] 最大绝对差 |8-2| = 6 > 4. 266 | * [2] 最大绝对差 |2-2| = 0 <= 4. 267 | * [2,4] 最大绝对差 |2-4| = 2 <= 4. 268 | * [2,4,7] 最大绝对差 |2-7| = 5 > 4. 269 | * [4] 最大绝对差 |4-4| = 0 <= 4. 270 | * [4,7] 最大绝对差 |4-7| = 3 <= 4. 271 | * [7] 最大绝对差 |7-7| = 0 <= 4. 272 | * 因此,满足题意的最长子数组的长度为 2 。 273 | * 274 | * 275 | * 示例 2: 276 | * 277 | * 输入:nums = [10,1,2,4,7,2], limit = 5 278 | * 输出:4 279 | * 解释:满足题意的最长子数组是 [2,4,7,2],其最大绝对差 |2-7| = 5 <= 5 。 280 | * 281 | * 282 | * 示例 3: 283 | * 284 | * 输入:nums = [4,2,2,2,4,4,2,2], limit = 0 285 | * 输出:3 286 | * 287 | * 288 | * 289 | * 290 | * 提示: 291 | * 292 | * 293 | * 1 <= nums.length <= 10^5 294 | * 1 <= nums[i] <= 10^9 295 | * 0 <= limit <= 10^9 296 | * 297 | * 298 | */ 299 | 300 | // @lc code=start 301 | 302 | function check(nums: number[], k:number, limit: number): boolean { 303 | let qMin: number[] = [], qMax:number[] = []; 304 | for(let i=0;i nums[i]) qMin.pop(); 306 | while(qMax.length && nums[qMax[qMax.length-1]] < nums[i]) qMax.pop(); 307 | qMin.push(i); 308 | qMax.push(i); 309 | if(i + 1 < k) continue; 310 | if(i - qMin[0] === k) qMin.shift(); 311 | if(i - qMax[0] === k) qMax.shift(); 312 | if(nums[qMax[0]] - nums[qMin[0]] <= limit) return true; 313 | } 314 | return false; 315 | 316 | } 317 | 318 | function bs(nums: number[], l: number, r: number, limit: number): number { 319 | // 如果左右指针相遇,则说明找到了满足条件的最长数组的长度了 320 | if(l === r) return l; 321 | // 求取中间值,那么,为啥这里还要再+1呢? 322 | // 如果仍然使用mid = (l+r)>>1,当r-l=1时,mid = (l+r)>>1 = l,如果此时check判定通过的话 323 | // 就会进入l=mid的分支,因为mid=l此时,二分区间并没有缩小,会导致程序进入死循环,因此,去中间值的时候加1 324 | // 避免这种情况。详情可看一下《算法竞赛进阶指南》书中的 0x04 二分 章节 325 | let mid = (l + r + 1) >> 1; 326 | // 利用单调队列获取区间最大值和最小值计算绝对差是否小于或等于limit判定应该如何缩小二分区间 327 | if(check(nums, mid, limit)) l = mid; 328 | else r = mid - 1; 329 | return bs(nums, l, r, limit); 330 | } 331 | 332 | 333 | function longestSubarray(nums: number[], limit: number): number { 334 | return bs(nums, 0, nums.length, limit); 335 | }; 336 | // @lc code=end 337 | 338 | 339 | ``` 340 | 341 | -------------------------------------------------------------------------------- /10.1-二分查找.md: -------------------------------------------------------------------------------- 1 | ## 二分查找算法 2 | 3 | 通过头尾指针确定查找区间,然后通过不断地缩小查找区间来缩小我们的查找范围,最终找到目标值。所以`二分查找其实二分的是查找区间`。二分查找在每一次调整查找区间时,一定会保证如果存在目标值,那么这个目标值一定存在与我们调整后的查找区间当中。 4 | 5 | **PS:二分查找的条件是在有序数组中查找,无序数组需要从小到大排序后再进行二分查找** 6 | 7 | ```bash 8 | # 使用二分法查找x在arr数组中的位置,不存在则返回-1 9 | x = 8 10 | arr = [1,2,3,4,5,6,7,8,8,9,9] 11 | 12 | # 首先,我们先定义头尾两个指针分别指向数组第一位和最后一位元素,头尾指针之间的数组就是我们本次的查找区间。而通过头尾指针,我们就能够确定这个查找区间中间值mid的位置,通过将中间值mid与目标值x进行对比,我们就可以确定下一个查找区间的范围。 13 | # 当arr[mid]===x时,说明刚已经找到了目标值,直接返回mid作为目标值索引 14 | # 当arr[mid]x时,原理同上,便可以确定我们下一次查找的区间为头指针 到 mid - 1的位置 16 | # 就这样,我们每次查找区间缩小一半,相比起我们顺序查找来说,效率提高还是相当可观的 17 | [ 1 , 2, 3 , 4 , 5 , 6 , 7 , 8 , 8 , 9 , 9 ] 18 | ^ ^ ^ 19 | | | | 20 | 头指针 mid 尾指针 21 | 22 | # 如上,我们可以让x与mid所指向的数6对比,发现8比6大,我们就可以把查找区间缩小为: 23 | 24 | [ 1 , 2, 3 , 4 , 5 , 6 , 7 , 8 , 8 , 9 , 9 ] 25 | ^ ^ ^ 26 | | | | 27 | 头指针 mid 尾指针 28 | # 通过这一轮查找,发现mid所指向的值刚好与x相等,因此返回mid作为目标值的索引即可 29 | ``` 30 | 31 | 接下来使用js实现一个最简单的二分查找算法 32 | 33 | ```javascript 34 | // 为了方便查看二分查找的过程,打印每一次二分的情况,可以观察一下控制台的输出结果便可以知道每一次二分查找的过程了 35 | function outputBinarySearchProcess(arr, target, min, max, mid){ 36 | 37 | let idxLog = ''; 38 | let lineLog = ''; 39 | let valLog = ''; 40 | let arrowLog1 = ''; 41 | let arrowLog2 = ''; 42 | for(let i=0;i> 1; 72 | // 方便查看二分的过程输出一些辅助信息 73 | outputBinarySearchProcess(arr, target, min, max, mid); 74 | if(arr[mid] === target) return mid; 75 | else if(arr[mid] < target) min = mid + 1; 76 | else max = mid - 1; 77 | } 78 | return -1; 79 | } 80 | 81 | const arr = [0,1,2,3,4,5,6,7,8,9]; 82 | 83 | console.log("====================[查找8]==========================="); 84 | console.log(`查找8索引:`+binarySearch(arr, 8));// 8 85 | console.log("====================[查找3]==========================="); 86 | console.log(`查找3索引:`+binarySearch(arr, 3));// 3 87 | console.log("====================[查找7]==========================="); 88 | console.log(`查找7索引:`+binarySearch(arr, 7));// 7 89 | console.log("====================[查找6]==========================="); 90 | console.log(`查找6索引:`+binarySearch(arr, 6));// 6 91 | ``` 92 | 93 | 以上为标准的二分查找算法,之前还看到过一些其他的二分查找的骚操作,在大范围内使用二分查找,小范围内使用顺序查找。使用这种方式,可以巧妙的避免掉我们对于二分查找边界条件的处理,降低代码出错的几率。 94 | 95 | ```javascript 96 | // 为了方便查看二分查找的过程,打印每一次二分的情况,可以观察一下控制台的输出结果便可以知道每一次二分查找的过程了 97 | // 其中︽代表头/尾指针与mid重合,︿代表头指针或尾指针或mid 98 | function outputBinarySearchProcess(arr, target, min, max, mid){ 99 | 100 | let idxLog = ''; 101 | let lineLog = ''; 102 | let valLog = ''; 103 | let arrowLog1 = ''; 104 | let arrowLog2 = ''; 105 | for(let i=0;i 3) { 138 | // 计算中间值索引 139 | let mid = (min + max) >> 1; 140 | // 如果待查找数值的数字都极大的话,有可能相加之后除以2就不是我们要的目标值了,可以按照下面方式优化 141 | // 中间值 = 最小值 + 区间大小的一半 142 | // let mid = min + (max-min) >> 1; 143 | // 方便查看二分的过程输出一些辅助信息 144 | outputBinarySearchProcess(arr, target, min, max, mid); 145 | if(arr[mid] === target) return mid; 146 | else if(arr[mid] < target) min = mid + 1; 147 | else max = mid - 1; 148 | } 149 | outputBinarySearchProcess(arr, target, min, max, (min+max)>>1); 150 | // 区间大小小于或等于3时,直接顺序查找 151 | for(let i=min;i<=max;i++) { 152 | if(arr[i] === target) return i; 153 | } 154 | 155 | return -1; 156 | } 157 | 158 | const arr = [0,1,2,3,4,5,6,7,8,9]; 159 | 160 | console.log("====================[查找8]==========================="); 161 | console.log(`查找8索引:`+binarySearchV1(arr, 8));// 8 162 | console.log("====================[查找3]==========================="); 163 | console.log(`查找3索引:`+binarySearchV1(arr, 3));// 3 164 | console.log("====================[查找7]==========================="); 165 | console.log(`查找7索引:`+binarySearchV1(arr, 7));// 7 166 | console.log("====================[查找6]==========================="); 167 | console.log(`查找6索引:`+binarySearchV1(arr, 6));// 6 168 | ``` 169 | 170 | 171 | 172 | ## 二分查找的泛型情况 173 | 174 | > 二分查找的`0-1模型`(读:零一模型): 175 | > 176 | > 情况一: 177 | > 178 | > [0,0,0,0,0,0,1,1,1,1,1] 179 | > 180 | > 在上述数组中找到第一个1 181 | > 182 | > **解析:这种情况我们也是用双指针的方式来不断缩小查找区间。首先通过头尾指针计算出中间指针的索引,如果中间指针索引所指向的数字为0,那么说明中间指针mid和之前的数都为0,我们就把头指针移动到mid+1的位置。如果中间指针mid指向的数字是1,那么说明中间指针指向的值有可能就是我们要找的目标值,根据二分查找每次调整区间都必须把结果包含区间中(如果存在结果的话)的原则,此时因为mid有可能是我们的结果,所以需要将尾指针移动到mid所在的位置。循环上述操作,直到头尾指针中的一个与mid相遇就找到了我们要找的值的索引** 183 | > 184 | > 情况二: 185 | > 186 | > [1,1,1,1,0,0,0,0,0,0] 187 | > 188 | > 在上述数组中找到最后一个1 189 | > 190 | > **解析:这种情况我们可以把数组每一位数字“取反”,即将0变成1,1变成0,然后得出:[0,0,0,0,1,1,1,1,1,1],我们要找原数组最后一个1,就相当于找新数组第一个1的前一位,至于找第一个1的过程就跟情况一一样了。** 191 | 192 | 上面简单的介绍了一下二分查找的`0-1模型`,那么,既然说是模型,应该能够适合很多种情况的处理,那这个`0-1模型`到底适合处理怎样的实际问题呢? 193 | 194 | 如:在数组[4,7,9,12,15,15,16,21,33,44,54]中查找第一个大于15的数的索引,看似跟我们的`0-1模型`没啥关系,实际上,我们可以转换一下思维方式:**将数组里面满足条件即大于等于15的数标记为1,不满足条件的数字标记为0**,这样,我们就得到一个新的数组:[0,0,0,0,1,1,1,1,1,1,1,1],现在就变成了求取`0-1模型`中第一个1的问题了。 195 | 196 | 从上面的例子中我们可以看出,`0-1模型`确实是一个泛型情况,通过一定的思维转换能将很多问题转换成`0-1模型`的问题来解决。思维转换的要点是:**将满足条件的数据看成1,不满足条件的数据看成0,然后根据0-1模型的查找方式找到最终问题的解**。 197 | 198 | 光说不练假把式,下面来用js实现一下`0-1模型` 199 | 200 | ```javascript 201 | // 为了方便查看二分查找的过程,打印每一次二分的情况,可以观察一下控制台的输出结果便可以知道每一次二分查找的过程了 202 | // 其中︽代表头/尾指针与mid重合,︿代表头指针或尾指针或mid 203 | function outputBinarySearchProcess(arr, target, min, max, mid){ 204 | let idxLog = ''; 205 | let lineLog = ''; 206 | let valLog = ''; 207 | let arrowLog1 = ''; 208 | let arrowLog2 = ''; 209 | for(let i=0;i 3) { 243 | // 计算中间值索引 244 | let mid = (min + max) >> 1; 245 | // 如果待查找数值的数字都极大的话,有可能相加之后除以2就不是我们要的目标值了,可以按照下面方式优化 246 | // 中间值 = 最小值 + 区间大小的一半 247 | // let mid = min + (max-min) >> 1; 248 | // 方便查看二分的过程输出一些辅助信息 249 | outputBinarySearchProcess(arr, target, min, max, mid); 250 | if(arr[mid] < target) min = mid + 1; 251 | else max = mid; 252 | } 253 | outputBinarySearchProcess(arr, target, min, max, (min+max)>>1); 254 | 255 | // 区间大小小于或等于3时,直接顺序查找 256 | for(let i=min;i<=max;i++) { 257 | if(arr[i] >= target) return i; 258 | } 259 | return -1; 260 | } 261 | 262 | const arr = [0,1,2,3,4,5,6,7,8,9]; 263 | 264 | console.log("====================[第一个大于等于5的数]==========================="); 265 | console.log(`第一个大于等于5的数的索引:`+binarySearchV2(arr, 5));// 5 266 | console.log("====================[第一个大于等于2的数]==========================="); 267 | console.log(`第一个大于等于2的数的索引:`+binarySearchV2(arr, 2));// 2 268 | console.log("====================[第一个大于等于8的数]==========================="); 269 | console.log(`第一个大于等于8的数的索引:`+binarySearchV2(arr, 8));// 8 270 | console.log("====================[第一个大于等于22的数]==========================="); 271 | console.log(`第一个大于等于22的数的索引:`+binarySearchV2(arr, 22));// -1 272 | 273 | ``` 274 | 275 | ## 二分中的数组与函数的关系 276 | 277 | 有一定编程基础的同学应该都是知道,数组获取某个值:`F[x]=y`与函数获取某个值`F(x)=y`都是为了通过`x`获取目标映射值`y`,其中,数组是从下标到值的映射,而函数式从入参到函数值(返回值)的映射。 278 | 279 | 我们刚刚讲了使用二分查找,通过数组的值求取对应的下标,那么,同理,我们是否能够通过函数值利用二分查找求解函数的入参呢? 280 | 281 | 答案是可以的,不过有一个先决条件。我们再使用二分查找查找数组中某个值的索引时,必须保证数组是有序的(即数组是单调的),因此,使用二分查找根据函数值逆推函数入参时,也要保证这个函数是[单调函数](https://baike.baidu.com/item/%E5%8D%95%E8%B0%83%E6%80%A7/6194133?fr=aladdin) 282 | 283 | 例如:`F(x) = 2x`这个单调函数与单调数组[0,2,4,6,8,10],我们可以发现,这个单调函数的参数x如果与数组的索引对应,那么函数的值,也会跟数组索引对应的值相对应。如x传入2,函数值为4,而在数组中,索引2对应的值也是4。 284 | 285 | 这样,大家是否觉得单调数组和单调函数之间的界限没有那么明确了呢?感觉这两货就是一个东西呀。因此,在**思维逻辑层面中**,我们把**单调函数**当做是**压缩的单调数组**,而**单调数组**当做是**展开的单调函数**。函数使用的是计算资源,而数组使用的是存储资源,而计算资源和存储资源本质上没有什么差别,因此,在我们计算机中有这样一种说法叫做:**用时间(计算资源)换空间(存储资源)**或**用空间(存储资源)换时间(计算资源)**。**任何可以应用于数组的算法,都可以应用于某种性质的函数上**。 286 | 287 | 288 | 289 | 290 | 291 | -------------------------------------------------------------------------------- /04.1-你真的了解二叉树吗(树形结构基础篇).md: -------------------------------------------------------------------------------- 1 | # 你真的了解二叉树吗(树形结构基础篇) 2 | 3 | ## 树形结构基础 4 | 5 | 相较于`链表`每个节点只能唯一指向下一个节点(此处说的链表是单向链表),`树`则是每个节点可以有若干个子节点,因此,我们一个树形结构可以如下表示: 6 | 7 | ```typescript 8 | interface TreeNode { 9 | data: any; 10 | nodes: TreeNode[] 11 | } 12 | ``` 13 | 14 | ### 树的度 15 | 16 | > PS: 在图结构中,也有`度`的概念,分为`出度`和`入度`,如果把树看作是图的一部分的话,那么严格来说,树的度其实是`出度`。不过,在树形结构中,我们通常把`度`这个概念作为描述当前树节点有几个子节点。 17 | > 18 | > 即每个节点拥有几个孩子,因此,二叉树的度最大是2,链表(可以看成只有一个孩子的树)的度最大是1。 19 | 20 | > 定理:在一个二叉树中,度为0的节点比度为2的节点多1 21 | > 22 | > 证明: 23 | > 24 | > 假如一个树有`n`个节点,那么,这棵树肯定有`n-1`条边,也就是说,`点的数量=边的数量+1`(这个结论针对所有树形结构都适用,不仅仅是二叉树)如下图: 25 | > 26 | > ![示意图](https://ydschool-video.nosdn.127.net/1617420090869WechatIMG964.jpeg) 27 | > 28 | > 这个棵树有7个节点,节点与节点之间的连线,也就是边只有6条。 29 | > 30 | > 那么,我们假设度为`0`的节点数量为`n0`,度为`1`的节点为数量`n1`,度为`2`的节点数量为`n2`,又因为是度为0的节点,说明他的边的数量`N0=0`,度为1的节点边的数量为`N1=n1*1`,度为2的节点边的数量为`N2=n2*2`,的那么总共的节点数量为: 31 | > 32 | > ```bash 33 | > # 由上可知,边数量的表达式如下 34 | > N0=0; 35 | > N1=1*n1; 36 | > N2=2*n2; 37 | > # 由上可知,节点的数量=边的数量+1 38 | > n0 + n1 + n2 = N0 + N1 + N2 + 1; 39 | > # 代入N0,N1,N2得: 40 | > n0 + n1 + n2 = 0 + n1 + 2*n2 + 1; 41 | > # 化简得: 42 | > n0 = n2 + 1; 43 | > # 即度为0的节点数量永远比度为2的节点多一个 44 | > ``` 45 | > 46 | > 由此,我们就证明了上面的定理,我们把这个定理换个描述或许更容易理解: 47 | > 48 | > **在二叉树中,只要你知道了有多少个叶子节点,那么度为2的节点数量就是叶子节点的数量减1,反之,知道度为2的节点数量,那么叶子节点的数量就是度为2的节点数量加1** 49 | 50 | ### 树的遍历 51 | 52 | ``` 53 | 5 54 | / \ 55 | 1 4 56 | / \ 57 | 3 6 58 | ``` 59 | 60 | | 名称 | 遍历顺序 | 61 | | -------- | ------------------------------------------------------------ | 62 | | 前序遍历 | 根节点 左子树 右子树 5->1->4->3->6 | 63 | | 中序遍历 | 左子树 根节点 右子树 1->5->3->4->6 | 64 | | 后序遍历 | 左子树 右子树 根节点 1->3->6->4->5 | 65 | | 层序遍历 | 即从上往下一层层遍历,根节点 根节点上所有子节点 下一层的子节点 5->1->4->3->6 | 66 | 67 | ### 树的遍历思想 68 | 69 | 树天生就是一个适合`递归遍历`的数据结构,因为每一次处理`左子树`和`右子树`的时候,其实就是递归遍历的过程。 70 | 71 | `前序遍历`:「根节点」「递归遍历左子树的输出结果」「递归遍历右子树的输出结果」 72 | 73 | `中序遍历`:「递归遍历左子树的输出结果」「根节点」「递归遍历右子树的输出结果」 74 | 75 | `后序遍历`:「递归遍历左子树的输出结果」「递归遍历右子树的输出结果」 「根节点」 76 | 77 | ### 思维发散 78 | 79 | 看到这里,有一些小伙伴可能会感觉似曾相识,是不是在哪里看过树相关的一些知识呢。其实在之前我们学习`栈`这个数据结构的时候,就有讨论过这个话题。我们知道,`栈`天生适合用于表达式求值,那么,它在处理表达式求值的过程中,是怎样的一个逻辑结构呢?如:`3*(4+5)`这个表达式。其实,虽然我们在解答的时候,使用的是栈的思想,但实际上在逻辑层面,我们是在模拟一棵树的操作过程。不相信?那我们来看看: 80 | 81 | ```bash 82 | [ * ] 83 | [ 3 ] [ + ] 84 | 85 | [ 4 ] [ 5 ] 86 | 87 | 88 | ``` 89 | 90 | 上面,我们将这个表达式拆借成了一个树形结构,当我们表达式中遇到`()`时,说明里面的子表达式需要优先处理,那么,我们就把他看作是我们二叉树的一个子树。我们都知道,树的遍历思想是`递归遍历`,是有下往上逐层解决问题,这样,在递归调用的过程中,他就会先解决右子树的子问题,得到结果之后,再与左子树计算出来的结果进行最终运算得出最终结果。如果对栈数据结果感兴趣的同学,可以移步另一篇文章:[0003-递归与栈(解决表达式求值问题)](./03-递归与栈(解决表达式求值问题).md) 91 | 92 | ### 还原二叉树 93 | 94 | 如果我们已知`前序遍历结果`、`中序遍历结果`、`后续遍历结果`三者中的任意两个,我们就能够完整的还原一颗二叉树。例如: 95 | 96 | ```bash 97 | # 前序遍历输出结果 98 | 1 5 2 3 4 99 | # 中序遍历输出结果 100 | 5 1 3 2 4 101 | ``` 102 | 103 | 上面是两种遍历方式的输出结果,我们知道,前序遍历的第一个节点一定是根节点,所以,此时,我们就已经知道,原二叉树的根节点为1,接下来,我们拿这个1的节点到中序遍历的输出结果中,找到1的位置。又因为中序遍历的输出结果是`左根右`,那么,我们不难知道,在1左边的就是原二叉树的左子树的中序遍历输出,在1右边的就是原二叉树的右子树的中序遍历输出。这样,我们就可以把中序遍历输出分成以下几块: 104 | 105 | ```bash 106 | # 切割中序遍历结果 107 | 5 1 3 2 4 108 | # 左子树 根 右子树 109 | # 由上可知,我们左子树就已经出来了,就只有一个节点,就是5,但是右子树还是一个序列,那么我们继续往下走。 110 | 111 | # 由上,我们已经知道了原二叉树的左子树序列、和右子树序列,那么,我们也来切割以下前序遍历结果 112 | 1 5 2 3 4 113 | # 根 左子树 右子树 114 | 115 | #切割了前序遍历结果之后,我们找到右子树的序列,他的序列的第一位就是右子树的根节点,也就是2,找到根节点后,就很简单了,重复上面的步骤,在二叉树的中序遍历结果的右子树中就能找到右子树的左子树和右子树分别为3和4,到此,我么就已经还原了这颗二叉树了 116 | 1 117 | / \ 118 | 5 2 119 | / \ 120 | 3 4 121 | ``` 122 | 123 | 上面只有5个节点的树,是不是很简单呢?接下来,我们再来一个稍微难一点的的思维题。 124 | 125 | > 已知10个节点的二叉树的前序遍历结果和中序遍历结果,还原这个二叉树 126 | > 127 | > 前序遍历输出序列:1 2 4 9 5 6 10 3 7 8 128 | > 129 | > 中序遍历输出序列:4 9 2 10 6 5 1 3 8 7 130 | 131 | ```bash 132 | 133 | # 由2.可知,1是根节点,所以左子树序列:4 9 2 10 6 5 ;右子树序列:3 8 7 134 | 1.中序: 4 9 2 10 6 5 1 3 8 7 135 | # 断言:1是根节点 136 | 2.前序: 1 2 4 9 5 6 10 3 7 8 137 | # 由1.2可知,2是根节点,所以左子树序列:4 9 ;右子树序列:10 6 5 138 | 1.1中序: 4 9 2 10 6 5 139 | # 断言:2是根节点 140 | 1.2前序: 2 4 9 5 6 10 141 | # 由1.2.1可知,4是根节点,所以9是右子树 142 | 1.1.1中序: 4 9 143 | # 断言:4是根节点 144 | 1.2.1前序: 4 9 145 | # 由1.2.2可知,5是根节点,所以左子树序列为:10 6 146 | 1.1.2中序: 10 6 5 147 | # 断言:5是根节点 148 | 1.2.2前序: 5 6 10 149 | # 由1.2.2.2可知,6为根节点,所以10位左子树 150 | 1.1.2.1中序: 10 6 151 | # 断言:6为根节点 152 | 1.2.2.2前序: 6 10 153 | # 由2.2可知,3位根节点,所以右子树序列为:8 7 154 | 2.1中序: 3 8 7 155 | # 断言:3为根节点 156 | 2.2前序: 3 7 8 157 | # 由2.2.2可知,7为根节点,所以8为左子树 158 | 2.1.1中序: 8 7 159 | # 断言:7为根节点 160 | 2.2.2前序: 7 8 161 | 162 | # 最终二叉树长成这样 163 | 164 | 1 165 | / \ 166 | 2 3 167 | / \ \ 168 | 4 5 7 169 | \ / / 170 | 9 6 8 171 | / 172 | 10 173 | ``` 174 | 175 | ### 二叉树的常见分类 176 | 177 | #### 完全二叉树(Complete Binary Tree) 178 | 179 | > 只有在最后一层的右侧缺少节点的二叉树叫做完全二叉树,也就是说,完全二叉树的左侧是满的,只有右侧才允许有空节点 180 | 181 | ```bash 182 | 1 183 | / \ 184 | 2 3 185 | / \ / \ 186 | 4 5 6 187 | ``` 188 | 189 | 完全二叉树是一个非常优秀的一个数据结构,它有以下两个主要的特点,能够让我们在在性能和程序实现上有更好的体验。 190 | 191 | ##### 节点编号可计算 192 | 193 | 从上面的完全二叉树中,我们可以看出一个规律: 194 | 195 | > 编号为`n`的节点,他的左子树根节点的编号必定为`2n`,他的右孩子的根节点的编号必定为`2n+1`,如上图2的左子树根节点的编号为4,就是`2*2=4`。右子树根节点的编号为5,也就是`2*2+1=5`。 196 | > 197 | > 那么利用这个规律,我们可以干什么呢? 198 | > 199 | > 我们知道,普通的二叉树,除了存储数据用的数据域之外,还需要额外的存储空间用来存储左子树和右子树的指针,也就是指针域。如果我们能通过上面的规律直接计算出当前节点左子树和右子树根节点的编号,那是不是就不需要额外的存储空间去存储左右子树的存储地址了,当一个树足够大的时候,这可以给我们节省相当大的一个存储空间。 200 | 201 | 上面通过计算来替代记录存储地址的方法,引申出一个我们在日常工作中经常会使用到的一个算法思想: 202 | 203 | **记录式与计算式思想的转换** 204 | 205 | - 记录式(节省时间,耗费空间,无需计算,直接取值,即:`空间换时间`) 206 | 207 | 把信息存起来,用到的时候取出来用 208 | 209 | - 计算式(节省空间,耗费时间,无需存储,计算取值,即:`时间换空间`) 210 | 211 | 通过计算得到的,如`1+1=2`中的2就是我们通过计算`1+1`这个表达式得到的结果 212 | 213 | 这两种方式各有各的优缺点,脱离问题本身比较这两种方式的优劣是没有意义的,我们应该结合具体问题,看使用哪种方式能给你带来更大的收益。 214 | 215 | 场景一:当内存空间有限,对计算时间要求不强时,如在一个内存较小的机器中运行一段程序,我们会选择`计算式`,用`时间换空间` 216 | 217 | 场景二:当我们内存空间足够大,并且对计算速度有要求时,如企业级应用服务器上运行实时计算数据时,我们会选择`记录式`,用`空间换时间`,因为一个企业级的应用,一般内存是足够大的,还可以动态扩容,这时候,时间所带来的效益就远大于空间所带来的的效益了。 218 | 219 | ##### 可使用连续的存储空间存储 220 | 221 | 除了节点编号(即节点地址)可计算这个特性外,`完全二叉树`由于他的编号是连续的,从上到下升序且连续的序列,因此,我们可以把`完全二叉树`存储在一个连续的存储区,如:`数组`中,数组下标为0的元素存放1号节点,为1的元素存放2号节点... 222 | 223 | 利用这个特性,我们在实现一个`完全二叉树`时,可以无需像实现普通二叉树一样单独定义一个结构,并分别定义数据域和指针域来分别存储数据和指针,我们完全可以使用一个数组直接存储数据,这也是我们`完全二叉树`最常见的表现形式。 224 | 225 | 我们来想象一下:你在程序中实现时用的是一维的线性结构,即数组来表示的,但在你的脑海里,应该要把它转化为二维的树形结构来思考问题,这也是一个相对高级的编程逻辑思维能力,让我们能够在脑海中将看到的数据结构“编译”成它真正运行时的模样。当然,要有这样的能力,可不是一朝一夕的事情,需要经过大量的锻炼才能具备这种能力,至少,笔者写下此行的这一刻,是没办法达到这个境界的。 226 | 227 | 利用这两个特性,我们的完全二叉树衍生出来一些非常有用的特例变种:**大顶堆**和**小顶堆**等。各位对对结构感兴趣的同学,可以看一下[05.1-堆(Heap)与优先队列(堆的数据结构基础篇)](./05.1-堆(Heap)与优先队列(堆的数据结构基础篇).md)。这里对堆进行了非常详细的讲解,相信会对你有所帮助。 228 | 229 | #### 满二叉树(Full Binary Tree) 230 | 231 | > 没有度为1的节点的二叉树叫做满二叉树,即所有节点要么没有子节点,要么有两个子节点 232 | > 233 | > PS: 我们经常在网上看到很多文章博客上会把`完美二叉树`的定义放在`满二叉树`上,其实是错误的,`完美二叉树`的具体定义见下文。 234 | 235 | ```bash 236 | 1 237 | / \ 238 | 2 3 239 | / \ / \ 240 | 4 5 6 7 241 | / \ 242 | 8 9 243 | ``` 244 | 245 | #### 完美二叉树(Perfect Binary Tree) 246 | 247 | > 所有节点的度都为2。由此可以看出`完美二叉树`的定义还是与`满二叉树`有区别的。我们可以说`完美二叉树`是特殊的`满二叉树` 248 | 249 | ```bash 250 | 1 251 | / \ 252 | 2 3 253 | / \ / \ 254 | 4 5 6 7 255 | ``` 256 | 257 | ## 树结构深入理解 258 | 259 | ### 节点 260 | 261 | 树的节点代表一个`集合`,子节点就代表在`父集合`下互不相交的`子集`,这样说可能难以理解,那么,咱们来看下面的一个图: 262 | 263 | ```bash 264 | 5 265 | / \ 266 | 2 3 267 | # 上面的二叉树,5节点,我们可以把它当做是一个全集,而下面的两个子节点2和3则是这个全集下的两个互不相交的子集,两个子集相加应该等于全集 268 | ``` 269 | 270 | 由上图我们可以得出一个结论: 271 | 272 | > 树的一个节点代表一个集合,而子节点代表全集下面互不相交的子集,所有的子集相加能够得到全集 273 | 274 | ### 边 275 | 276 | 树的每一条边代表`关系` 277 | 278 | ## 学习二叉树的作用 279 | 280 | ### 应用于各种场景下的查找操作 281 | 282 | 由于二叉树结构包括天然递归结构、与二分思想完美契合的特性,使得二叉树及其各种变种结构极其适合在各种场景下进行高效的查找操作,我们计算机底层也有诸多设计时基于二叉树与二叉树变种结构的,便是由于其优秀的性能能够提供高效而稳定的查找效率。 283 | 284 | ### 有助于理解高级数据结构的基础 285 | 286 | - 完全二叉树(维护集合最值的神兵利器) 287 | - 堆 288 | - 优先队列 289 | - 多叉树/森林 290 | - 解决字符串及相关转换问题的神兵利器 291 | - 字典树 292 | - AC自动机 293 | - 解决连通性问题的神兵利器 294 | - 并查集 295 | - 二叉排序树 296 | - 语言标准库中重要的数据检索容器的底层实现 297 | - AVL树(二叉平衡树) 298 | - 2-3树(二叉平衡树) 299 | - 红黑树(二叉平衡树) 300 | - 文件系统、数据库底层的重要数据结构 301 | - B树/B+树(多叉平衡树) 302 | 303 | ### 练习递归技巧的最佳选择 304 | 305 | > **学习递归的顶层思维方式** 306 | > 307 | > 设计/理解一个递归程序: 308 | > 309 | > 1. 数学归纳法 => 结构归纳法 310 | > 311 | > 若k0是正确的,假设ki是正确的,那么k(i+1)也是正确的。如求解斐波那契数列: 312 | > 313 | > ```javascript 314 | > function fib(n) { 315 | > // 首先要确定k0是正确的,也就是前提条件(边界条件)是正确的,在这题中,k0就是n=1时,结果为1,n=2时,结果为2 316 | > if(n <= 2) return n; 317 | > return fib(n - 1) + fib(n - 2); 318 | > } 319 | > ``` 320 | > 321 | > 2. 赋予递归函数一个明确的意义 322 | > 323 | > 上面代码中,fib(n)代表第n项斐波那契数列的值 324 | > 325 | > 3. 思考边界条件 326 | > 327 | > 在上面的代码中,我们的边界就是已知条件,n=1时为1,n=2时为2,需要对这个边界进行特殊处理 328 | > 329 | > 4. 实现递归过程 330 | > 331 | > 处理完边界问题后,就可以递归继续往下走了 332 | 333 | 如果让你设计一个二叉树的前序遍历的程序,你会怎么设计呢? 334 | 335 | 1. 函数意义:前序遍历以root为根节点的二叉树 336 | 2. 边界条件:root为空时无需遍历,直接返回root 337 | 3. 递归过程:分别前序遍历左子树和前序遍历右子树 338 | 339 | ```javascript 340 | // 函数意义:前序遍历以root为根节点的二叉树 341 | function pre_order(root) { 342 | // 边界条件:root为空时无需遍历,直接返回root 343 | if(!root) return root; 344 | console.log(root.val); 345 | // 递归过程:分别前序遍历左子树和前序遍历右子树 346 | pre_order(root.left); 347 | pre_order(root.right); 348 | } 349 | ``` 350 | 351 | ### 使用左孩子右兄弟法节省空间 352 | 353 | 将任意的非二叉树转换成二叉树,如将一个三叉树转换成二叉树: 354 | 355 | ```bash 356 | # 注意,要始终保证二叉树的左边是子节点,右边是兄弟节点 357 | 358 | # 原三叉树 359 | 360 | 1 361 | / | \ 362 | 2 3 4 363 | / \ 364 | 5 6 365 | 366 | # 按照左孩子右兄弟的方式转换成二叉树 367 | 368 | 1 369 | / 370 | 2 371 | \ 372 | 3 373 | / \ 374 | 5 4 375 | \ 376 | 6 377 | # 因为2是1的孩子,所以放在左子树,因为3是2的兄弟,所以放在2的右子树,4是3的兄弟,放在3的右子树,5是3的孩子,放在3的左子树,6是5的兄弟,所以放在5的右子树 378 | ``` 379 | 380 | 大家可以发现,当只是将一棵树通过`左孩子右兄弟法`转换成二叉树时,根节点的右子树始终为空,那么,我们是不是可以有效地利用这个右子树,把多棵树合并到一棵二叉树中呢?例如下面的示例,就是将两颗二叉树合并到了一起,形成了森林。 381 | 382 | ```bash 383 | # 如果要把下面的两棵树合并到一个二叉树中呢 384 | 1 7 385 | / | \ / \ 386 | 2 3 4 8 9 387 | / \ 388 | 5 6 389 | 390 | 391 | 1 392 | / \ 393 | 2 7 394 | \ / 395 | 3 8 396 | / \ \ 397 | 5 4 9 398 | \ 399 | 6 400 | # 这样,我们就将两棵树合并成一颗树了,也就是森林了。这棵树看似一颗二叉树,但其实表示的是两棵树组成的森林 401 | ``` 402 | 403 | 众所周知的`Alpha Go`的算法源码中实现的[蒙特卡罗树搜索](https://baike.baidu.com/item/%E8%92%99%E7%89%B9%E5%8D%A1%E6%B4%9B%E6%A0%91%E6%90%9C%E7%B4%A2/22668758?fr=aladdin)算法框架的具体实现算法,称之为信心上限树算法([UCT](https://baike.baidu.com/item/UCT%E7%AE%97%E6%B3%95/19451060?fr=aladdin))就是采用了`左孩子右兄弟法`实现的一颗搜索树,用来表示整个棋盘的局面,正常来说,如果要存储一个棋盘的局面的话,会存储一个树形的结构中,但因为棋盘局面情况太多了,有可能形成一个100多叉以上的树,在`Alpha Go`中为了避免这种情况,就把这个100多叉树通过`左孩子有兄弟`的表示法转换成了二叉树。有兴趣的同学可以去看一下[pachi](https://github.com/pasky/pachi/blob/master/uct/tree.h)。 404 | 405 | 那么,为什么说这种方式能够节省空间呢?大家想想,一个三叉树,他的每个节点都会有三个指针域用于存储他的子树,不管是否有子树,都要预留这些空间,如上面的三叉树,有6个节点,总共有18个指针域,其中有效的指针域只有5个(所谓有效指针域就是指针域不是指向空的,即边的数量=节点数量-1),那么就还有18-5=13个指针是空着的。如果采用`左孩子右兄弟`的方式转换成二叉树,我们来看看总共有12个指针域,而有效指针域有5个,那么就只有12-5=7个指针域空着,明显比之前的13个节省了大量空间。 406 | 407 | 一个拥有n个节点的k叉树,他最多会有k*n条边,他的边实际上只有有n-1条,那么他浪费了:`k*n - (n-1)=(k-1)*n+1`条边,这就意味着,当我们分叉越多,我们浪费的空间就会越多,所以,我们要把k叉树转换成二叉树,因为二叉树浪费的边为:`n+1`,只跟我们实际存储数据的节点有关。 408 | 409 | ## 结语 410 | 411 | 到了这里,我们关于二叉树的一些基础知识就聊的差不多了,为了控制篇幅以及不同基础的小伙伴的接受程度,就不再展开更深的讨论了。本来还要跟大家一起刷一刷关于二叉树的算法题巩固一下二叉树的一些相关知识的,不过这样就会导致这篇文章又臭又长,所以,还是把它拆分成两篇文章吧。下一篇文章将会直接跳过树的基础,直接开撸二叉树算法题,附上传送门:[手撕二叉树算法题](./04.2-你真的了解二叉树吗(手撕算法篇).md) 412 | 413 | -------------------------------------------------------------------------------- /03-递归与栈(解决表达式求值问题).md: -------------------------------------------------------------------------------- 1 | ## 03-递归与栈(解决表达式求值问题) 2 | 3 | ### 思考 4 | 5 | - 要做一件事情,我们可以`+1`,也可以`入栈`,这只是一种思维方式的问题,本质上原理都是一样的 6 | - 同理,要完成一件事情,我们可以`-1`,也可以`出栈` 7 | - `(()())`这样我们其实可以理解为外层的一对括号代表一个`大任务`,但我们要完成这个大任务是有前置条件的,那就是完成里面的两个小任务,也就是中间的两对括号。因此,类似括号配对的这种思维方式,其实不仅仅用于对于一些字符串或html之类代码的解析上,还可以泛化为以下几种场景: 8 | - 一对`()`可以看成一个完整的事件,而`(()())`则可以看成具有`完全包含`关系的任务执行细节问题的处理。 9 | - 函数的执行,如:`(()())`我们可以把最外层的括号看成一个大函数`Fn0`,而在这个大函数`Fn0`中有调用了`Fn1`和`Fn2`,当我两个子函数执行完后,我们的`Fn0`也就执行完了。 10 | - `二叉树`的`深度优先遍历`结果,即`DFS`。`(()())`可看成是一个根节点下有两个子节点 11 | 12 | > 由上可知,我们遍得出了一个适合处理这些`完全包含关系`问题的数据结构,也就是我们今天的主角`栈` 13 | 14 | ### 栈的典型应用场景 15 | 16 | - 操作系统的线程栈(线程空间) 17 | 18 | - 当我们新申请一个线程空间时,`Mac`中线程栈的大小默认是`8192kb`,即`8M`左右。 19 | 20 | ```bash 21 | # mac中查看线程栈默认大小的命令 22 | ulimit -a 23 | ``` 24 | 25 | 26 | 27 | - 当我们的函数调用层数过深时,我们再线程栈中压入的变量过多,超出了`8192kb`时,就会出现爆栈的情况 28 | 29 | - 表达式求值`3 + 5`(如:逆波兰表达式求值) 30 | 31 | - 思考:使用`递归`方式解决表达式求值和使用`栈`解决表达式求值有没有什么区别 32 | 33 | - 答案:没有,其实递归本质上使用了`系统`给我们提供的变量栈。 34 | 35 | - 解决思路:将`3 + 5`在思维逻辑层面把它看做是以`+`为根节点,左子树为`3`,右子树为`5`的一个二叉树,即我们常说的:`表达式树`。 36 | 37 | - `表达式树`的概念:通常以`运算符`作为根节点,`计算因子`做为其子树的属性结构 38 | 39 | - 例如:`3 * (4 + 5)`,这个本质上是一个`乘法`表达式,后面的加法本质上是我们要计算这个乘法表达式之前要解决的一个`子问题,因此,我们可以在逻辑层面把上面的公式转换成以下二叉树:` 40 | 41 | ```bash 42 | [ * ] 43 | [ 3 ] [ + ] 44 | 45 | [ 4 ] [ 5 ] 46 | 47 | ``` 48 | 49 | ### 刷题 50 | 51 | #### LeetCode 682:棒球比赛 52 | 53 | ##### 解题思路 54 | 55 | 这道题是栈中比较基础题,主要利用了`分治思想`和`栈`的特性。大概的解题思路是这样的,循环我们的计分列表: 56 | 57 | - 在遇到数字时将数字压入`栈`中。 58 | 59 | - 当遇到`+`时,说明需要把最近两次得分相加,那我们就可以把`栈顶元素`和`栈顶下一位元素`拿出来相加(要注意,由于栈的特性,我们只能从栈顶获取元素,所以,要获取栈顶的下一位元素,我们需要先将栈顶弹出来,再获取,最后把栈顶元素重新压入栈中,并压入两个元素相加之和)。 60 | 61 | - 如果遇到`D`时,说明我们需要把最近的一个积分乘以2,然后再将结果压入栈中。 62 | - 如果遇到`C`时,说明上一次的计分无效,我们需要把栈顶元素弹出 63 | - 当整个循环结束后,我们的栈中就存储了我们的整个计分结果,我们只需要将栈中元素依次弹出,并累加就是我们想要的答案了。 64 | 65 | ##### 代码演示 66 | 67 | ```javascript 68 | /* 69 | * @lc app=leetcode.cn id=682 lang=typescript 70 | * 71 | * [682] 棒球比赛 72 | */ 73 | 74 | // @lc code=start 75 | function calPoints(ops: string[]): number { 76 | // 用一个数组模拟栈,用来存储得分数据 77 | const stack: number[] = []; 78 | // 使用分治思想,对不同的情况采用不同的方式处理 79 | for(const op of ops) { 80 | switch(op) { 81 | case "+": 82 | // 遇到加号时,代表要将栈最上面的两个结果相加作为结果再压入栈中 83 | // 先将栈顶元素弹出,不然无法获取栈顶元素下一个元素 84 | const a = stack.pop(); 85 | // 获取当前栈顶元素 86 | const b = stack[stack.length-1]; 87 | // 再将原先的栈顶元素重新压入栈中,并将a+b的结果也压入栈中 88 | stack.push(a, a+b); 89 | break; 90 | case "D": 91 | // 遇到D时,说明需要将栈顶元素乘以二再压入栈中 92 | const top = stack[stack.length-1]; 93 | stack.push(top*2); 94 | break; 95 | case "C": 96 | // 遇到C时,说明最后一次计分无效,将栈顶元素弹出 97 | stack.pop(); 98 | break; 99 | default: 100 | // 不是上述任意一个情况,说明是分数,则把分数压入栈中 101 | stack.push(parseInt(op)); 102 | } 103 | } 104 | // 循环结束后,只需要将栈中的元素依次弹出并累加就是总分数了 105 | let num = 0; 106 | while(stack.length!==0) { 107 | num += stack.pop(); 108 | } 109 | return num; 110 | }; 111 | // @lc code=end 112 | 113 | 114 | ``` 115 | 116 | #### LeetCode 844: 比较含退格的字符串 117 | 118 | ##### 解题思路 119 | 120 | 这道题也是典型的栈逻辑题,我们只需要将除了空字符串和#之外的其他字符串压入栈中,遇到#号时,我们就把栈顶元素弹出,这样就可以获得一串最终的字符串,最后,我们只需要比较两个原始字符串通过上述操作后的最终字符串是否相等即可 121 | 122 | ##### 代码演示 123 | 124 | ```javascript 125 | /* 126 | * @lc app=leetcode.cn id=844 lang=typescript 127 | * 128 | * [844] 比较含退格的字符串 129 | */ 130 | 131 | // @lc code=start 132 | function toRealString(s: string): string { 133 | // 用于存储结果字符的栈 134 | const stack = []; 135 | for(const char of s) { 136 | // 遇到空字符串就跳过 137 | if(char === " ") continue; 138 | // 遇到遇到了#,说明需要退格,就把栈顶元素弹出 139 | else if(char === "#") stack.pop(); 140 | // 否则就是我们真实的字符,直接压入栈中 141 | else stack.push(char); 142 | } 143 | // 直接将栈转换为字符串,方便后续比较 144 | return stack.toString(); 145 | } 146 | 147 | function backspaceCompare(S: string, T: string): boolean { 148 | return toRealString(S) === toRealString(T); 149 | }; 150 | // @lc code=end 151 | ``` 152 | 153 | #### LeetCode 1021: 删除最外层的括号 154 | 155 | ##### 解题思路 156 | 157 | 这道题是一道相当不错的栈思维题。为什么说是栈思维题呢?因为我们实际实现的时候,并没有使用到栈这样的数据结构,但却使用了栈的思维,即`先进后出、后进先出`。大家可以回去本文头部看一下我们思考当中的内容,应该就有一些思路了,我们可以使用`+1`来代表入栈,使用`-1`来代表出栈,而当我们的计数器结果为0时,说明已经栈空。那么,有了这一个思路打底,我们再来看看详细的解题思路吧。这道题就直接把解题思路写入代码注释当中,这样比较容易理解。 158 | 159 | ##### 代码演示 160 | 161 | ```javascript 162 | /* 163 | * @lc app=leetcode.cn id=1021 lang=typescript 164 | * 165 | * [1021] 删除最外层的括号 166 | */ 167 | 168 | // @lc code=start 169 | function removeOuterParentheses(S: string): string { 170 | // 虽然没有使用到栈的数据结构,但是使用到了栈的思想 171 | 172 | let res = ""; 173 | // 其中j代表的是第一个最外层括号的起始位置,count是左括号和右括号的差值 174 | // 当count为0的时候,就说明我们左括号和右括号配对成功了 175 | for(let i=0,j=0,count=0;i(n); 249 | res.fill(0); 250 | // 维护一个用于存储胡函数id的栈 251 | const stack: number[] = []; 252 | 253 | // pre用于记录上一次操作的时间 254 | for(let i=0,pre=0;i=0){ 329 | stack.push(char); 330 | }else if(right.indexOf(char)>=0&&left.indexOf(stack.head())===right.indexOf(char)){ 331 | stack.pop(); 332 | }else{ 333 | return false; 334 | } 335 | } 336 | 337 | return stack.empty(); 338 | }; 339 | function Stack(){ 340 | let arr = []; 341 | this.push = function(val){ 342 | arr.push(val); 343 | } 344 | this.pop = function(){ 345 | return arr.pop(); 346 | } 347 | this.tail = function(){ 348 | return arr[0]; 349 | } 350 | this.head = function(){ 351 | return arr[arr.length-1] 352 | } 353 | this.size = function(){ 354 | return arr.length; 355 | } 356 | this.empty = function(){ 357 | return this.size()===0; 358 | } 359 | } 360 | ``` 361 | 362 | 363 | 364 | - 方案二: 365 | 366 | ```javascript 367 | /* 368 | * @lc app=leetcode.cn id=20 lang=typescript 369 | * 370 | * [20] 有效的括号 371 | */ 372 | 373 | // @lc code=start 374 | function isValid(s: string): boolean { 375 | // 有多少种括号,就定义多少个计数器 376 | let count1 = 0, count2 = 0, count3 = 0; 377 | // 分别定义左括号集合和右括号集合,方便之后出现括号数量合法,但括号嵌套层级不合法的情况 378 | let subStrLeft = '({['; 379 | let subStrRight = ')}]'; 380 | // 用于存储存储括号类型对应的索引,如是{的话,那么他在左括号集合中的索引应该是1,当我们找到下一个右括号时, 381 | // 只需要看一下下一个右括号在右括号集合中的索引是否也为1即可 382 | let idxStack = []; 383 | for(let i=0;i a.localeCompare(b)); 278 | // 由于题目要求最大前三个,因此集合中的元素超过三个时,将集合中按照字典序排序的最后一个单词 279 | // 从集合中删除 280 | if(p.s.length>3) { 281 | const lastIdx = p.s.length-1; 282 | p.s.splice(lastIdx, 1); 283 | } 284 | } 285 | // console.log(p.s); 286 | // 循环结束后,我们的p指针指向插入单词的最后一个字母的位置 287 | // 如果flag为true,说明当前单词不是第一次插入,返回false 288 | if(p.flag) return false; 289 | // 如果flag为false,说明归档前单词是第一次插入,更新当前节点的flag,返回true 290 | return (p.flag = true); 291 | } 292 | /** 293 | * 查找目标单词在字典树中是否存在 294 | * @param word 295 | * @returns 296 | */ 297 | public search(word: string): string[][] { 298 | const res: string[][] = []; 299 | let p = this.root; 300 | for(let x of word) { 301 | // 如果当前节点不存在,则无法找到匹配的单词,往结果集合中压入一个空集合 302 | if(!p) { 303 | res.push([]); 304 | continue; 305 | } 306 | const idx = x.charCodeAt(0) - charCodeA; 307 | p = p.nexts[idx]; 308 | // 如果p指向的节点不为空,那么,将该节点下集合中压入到结果数组中 309 | if(p) { 310 | res.push([...p.s]); 311 | } else { 312 | res.push([]); 313 | }; 314 | } 315 | return res; 316 | } 317 | } 318 | 319 | function suggestedProducts(products: string[], searchWord: string): string[][] { 320 | // 构建一颗字典树 321 | const tree: Trie = new Trie(); 322 | // 将产品列表中的每一个产品加入到字典书中 323 | products.forEach(item => tree.insert(item)); 324 | // 在字典树的查找过程中找目标产品集合 325 | return tree.search(searchWord); 326 | }; 327 | // @lc code=end 328 | 329 | 330 | ``` 331 | 332 | ### [剑指 Offer II 067. 最大的异或](https://leetcode-cn.com/problems/ms70jA/) 333 | 334 | #### 解题思路 335 | 336 | 首先,我们需要搞清楚`异或运算`的本质:`异或运算`是一个位运算,两个二进制数字相同位上的数字相同则为0,不同则为1,如: 337 | 338 | ```bash 339 | 0b010101 340 | 0b101101 341 | # 异或得 342 | 0b111000 343 | ``` 344 | 345 | 实际上,异或运算就是在统计二进制数字每一位1的奇偶性,如果相同位上1的数量是偶数则为0,如果1的数量为奇数则为1。 346 | 347 | 了解了异或运算之后,我们再来看看,什么情况下,才能够确保异或运算的结果尽可能大,是不是从二进制的高位(左边)开始尽可能相同位上的数字不同,就能够尽可能保证异或的结果尽可能大呢?如: 348 | 349 | ```bash 350 | # 以下二进制数与那个数进行异或运算的结果是最大的 351 | 0b0100110 352 | 0b??????? 353 | # 异或 354 | 355 | # 按照我们上面说的,要让两个二进制数异或结果尽可能大,则需要让相同位上的数字尽可能不一样,因此, 356 | 0b0100110 357 | 0b1011001 # 正解 358 | # 异或 359 | 0b1111111 360 | ``` 361 | 362 | 我们现在已经知道了如何才能让两个二进制数的异或结果尽可能大了。那么我们要通过什么方式实现上面的推到过程呢?题目会输入一个数字数组,那么,我们是否可以将数字的二进制表示看成是我们要查找的一个个单词,然后插入到一个二叉字典树当中去,然后用每一个数字在二叉字典树中按照一定的规则查找并计算出异或值。此处涉及到较多的二进制位操作的技巧,详细实现请看代码注释。 363 | 364 | #### 代码演示 365 | 366 | ```typescript 367 | /** 368 | * 创建一个二叉字典树,将数组中的每个数字的二进制表示插入进去,为了确保异或的结构尽可能大 369 | * 我们需要保证当前位于目标数字的对应位的数字不同,因此在搜索时,尽可能往相反的位置走,如果 370 | * 当前位是0,那么就应该尽可能去1的位置查找,反之亦然 371 | */ 372 | class TrieNode { 373 | public next: TrieNode[]; 374 | constructor() { 375 | this.next = new Array(2); 376 | this.next.fill(null); 377 | } 378 | } 379 | class Trie { 380 | private root: TrieNode; 381 | constructor() { 382 | this.root = new TrieNode(); 383 | } 384 | 385 | insert(x: number): void { 386 | let p: TrieNode = this.root; 387 | // 从最高的二进制表示位开始 388 | for(let i=30;i>=0;--i) { 389 | // 先获取当前位是0还是1 390 | // 1 << i 代表将1按位左移i位,即将二进制表示中的第i位置为1,其他为0 391 | // (x & (1 << i) 则是判断插入数字x的第i位是0还是1,如果是0,则结果为0,如果是1,结果就是第i位为1,其他位为0 392 | // !!(x & (1 << i)),进行归一化,由于上一步非0的情况有可能不是1,而我们二叉字典树中只需要0和1,因此通过!!将所有非0的数字都变成1,而原来是0的话,依然是0 393 | // 在js中!!的结果是一个布尔值,因此,我们还需要进行一次隐士类型转换,即+ 394 | let idx = +(!!(x & (1 << i))); 395 | // 如果目标位置没有节点,则新建节点 396 | if(!p.next[idx]) p.next[idx] = new TrieNode(); 397 | // 指针后移 398 | p = p.next[idx]; 399 | } 400 | } 401 | 402 | search(x: number): number { 403 | let p = this.root; 404 | // 用于累计异或结果 405 | let res = 0; 406 | for(let i=30;i>=0;--i) { 407 | // 同样获取当前位的0、1信息 408 | let idx = +(!!(x & (1 << i))); 409 | // 为了确保异或运算尽可能大,因此,我们需要尽可能往相反的方向查找,即当前节点 410 | // 如果是0,则往1的方向走,如果是1,则尽可能往0的方向走 411 | if(p.next[+(!idx)]) { 412 | // 存在相反方向的节点,将当前位的结果加入到异或结果当中 413 | res |= (1 << i); 414 | // 指针往反方向走 415 | p = p.next[+(!idx)]; 416 | } else { 417 | // 相反方向上没有节点,迫不得已只能往相同的方向走 418 | p = p.next[idx]; 419 | } 420 | } 421 | return res; 422 | } 423 | } 424 | 425 | function findMaximumXOR(nums: number[]): number { 426 | const trie = new Trie; 427 | // 将所有的数字插入到二叉字典树中 428 | nums.forEach(item => trie.insert(item)); 429 | // 在二叉字典树中查找异或结果并取最大值 430 | let res = 0; 431 | nums.forEach(item => res = Math.max(res, trie.search(item))); 432 | return res; 433 | }; 434 | ``` 435 | 436 | -------------------------------------------------------------------------------- /02-线程池与任务队列.md: -------------------------------------------------------------------------------- 1 | ## 线程池与任务队列 2 | 3 | ### 概念 4 | 5 | > 有一个连续的存储区存储任意结构,有头指针和尾指针,尾指针一般指向最后一个元素的下一位 6 | > 7 | > 先入先出(FIFO) 8 | 9 | ### 基本操作 10 | 11 | 一个最简单的队列结构至少要支持以下两种操作: 12 | 13 | #### 入队(`push`) 14 | 15 | 尾指针向后移动一步,并插入元素 16 | 17 | #### 出队(`pop`) 18 | 19 | - 逻辑出队:头指针向后移动一步 20 | - 真实出队:如果使用数组模拟队列的话,就是调用数组的`shift`方法,将数组第一个元素弹出 21 | 22 | ### 队列的常见变种 23 | 24 | #### 循环队列 25 | 26 | 由于在大部分的语言中,都是采用头尾指针的方式对队列进行操作的,那么这样就可能导致一个问题: 27 | 28 | ```bash 29 | # 假如有一个长度为10的空队列,下面队列中的*代表该为为空 30 | [*,*,*,*,*,*,*,*,*,*] 31 | # 先后执行多次入队和出队操作 32 | push 1 33 | push 2 34 | pop 35 | push 3 36 | # 经过上述的操作后,我们的队列长成这样,其中头指针指向的位置是2所在的位置,尾指针指向的位置是3的下一位,也就是3后面的* 37 | # 由于队列是先进先出的结构,上述执行了一次pop操作,因此1被弹出了队列,用<1>标识1倍逻辑删除了。 38 | [<1>,2,3,*,*,*,*,*,*,*] 39 | # 从上面的操作我们不难联想到,我们这个队列的大小是有限的,仅有10位,而每当我们执行pop操作时,就会出现很多个被逻辑删除的元素, 40 | # 虽然我们已经用不上这个元素了,但是因为是逻辑删除,并没有真正的删除这个元素,所以还是会占了一个坑,就像上面的<1>,这样,就有可能会出现一个“假溢出”的情况,如: 41 | [<1>,<2>,<3>,4,5,6,7,8,9,10] 42 | # 如上面的这个队列,看起来这个队列好像是满了,没办法在放置下一个元素了,但是,我们可以发现,前面的<1>,<2>,<3>都是已经被逻辑删除的,对我们来说是没有用的元素,其实我们这个队列并没有真正溢出,仅仅是因为这几个家伙占着茅坑不拉屎导致的假溢出“假溢出”。 43 | 44 | ``` 45 | 46 | 那么,如果要解决`队列假溢出`的问题,我们就引申出来一个队列的变种,叫做`循环队列`。`循环队列`就是为了有效的利用队列的空间,当队列的尾指针到了最后的时候,如果还要插入元素,那么尾指针会回到队列的第一位,也就是上面<1>所在的位置,只要头尾指针不相遇,我们就可以继续往队列里面插入元素,如上述队列,如果使用`循环队列`实现,最终可能是这样: 47 | 48 | ```bash 49 | # 以下队列便是真正的队满队列,其中队首是4,队尾是13 50 | [11,12,13,4,5,6,7,8,9,10] 51 | ``` 52 | 53 | ##### 循环队列的`Typescript`版本实现 54 | 55 | ```javascript 56 | // leetcode [622] 设计循环队列 57 | // 这里为了模拟大部分语言中的实现,因此除了使用数组用来存储数据外,没有使用数户组的一些方法,如pop何push等,直接使用头尾指针实现 58 | class MyCircularQueue { 59 | // 用于记录队列中实际存储了多少个元素 60 | private count: number = 0; 61 | // js中使用数组模拟队列 62 | private queue: number[]; 63 | // 头指针 64 | private head: number = 0; 65 | // 尾指针 66 | private tail: number = 0; 67 | // 初始化时初始化一个长度为k的数组控件 68 | constructor(private k: number) { 69 | this.queue = new Array(k); 70 | } 71 | 72 | // 入队 73 | enQueue(value: number): boolean { 74 | // 若队列满了则直接返回false 75 | if(this.isFull()) return false; 76 | // console.log(this.isFull(), this.count, this.k); 77 | // 将元素赋值给尾指针指向的位置 78 | this.queue[this.tail] = value; 79 | // 入队操作,需要将元素数量加一 80 | this.count++; 81 | // 尾指针向后移动一位,由于当前队列是循环队列,如果刚好向后移动一位超出了数组的长度,就会出现异常 82 | // 这里可以使用一个技巧,尾指针向后移动一位之后,再跟初始化时的数组长度取余就可以获得真实的尾指针位置了 83 | // 如:k=10,当前的tail指针指向9,那么,尾指针向后移动一位就是:9+1%10=0,尾指针应该指向我们数组的第一位元素了 84 | this.tail = (this.tail + 1) % this.k; 85 | 86 | return true; 87 | } 88 | 89 | // 出队操作 90 | deQueue(): boolean { 91 | // 当队列为空时,返回false 92 | if(this.isEmpty()) return false; 93 | // 出队操作,元素数量减一 94 | this.count--; 95 | // 头指针向后移动一位,为了防止超过数组长度,因此进行取余操作,具体想看入队操作的解释 96 | this.head = (this.head + 1) % this.k; 97 | return true; 98 | } 99 | 100 | // 返回队首袁术 101 | Front(): number { 102 | if(this.isEmpty()) return -1; 103 | return this.queue[this.head]; 104 | } 105 | 106 | // 返回队尾元素 107 | Rear(): number { 108 | if(this.isEmpty()) return -1; 109 | // 因为尾指针始终指向的是队列最后一个元素的下一位,如果tail刚好为0时,tail-1就会出现负数的情况 110 | // 为了解决这种情况,我们可以尾指针减一后,先加上一个数组的长度,然后再对接过与数组长度取余, 111 | // 如:k=10, (0 - 1 + 10) % 10 = 9,因此,最后一个元素在数组里面的索引就是9 112 | const idx = (this.tail - 1 + this.k) % this.k; 113 | return this.queue[idx]; 114 | } 115 | 116 | // 判断队列是否为空 117 | isEmpty(): boolean { 118 | return this.count === 0; 119 | } 120 | 121 | // 判断队列是否已满 122 | isFull(): boolean { 123 | return this.count === this.k; 124 | } 125 | } 126 | 127 | ``` 128 | 129 | 130 | 131 | #### 双向循环队列 132 | 133 | `双向循环队列`就是在`循环队列`的基础上支持既可以在头部或尾部`入队`,也可以在头部或尾部`出队`的特殊队列 134 | 135 | ##### 双向循环队列的`Typescript`版本 136 | 137 | ```javascript 138 | // leetcode [641] 设计循环双端队列 139 | class MyCircularDeque { 140 | private head: number = 0; 141 | private tail: number = 0; 142 | private count: number = 0; 143 | private queue: number[]; 144 | constructor(private k: number) { 145 | this.queue = new Array(k); 146 | } 147 | 148 | // 在队首插入元素 149 | insertFront(value: number): boolean { 150 | if(this.isFull()) return false; 151 | 152 | // 由于队首可能是有元素的,而队尾是没有元素的,所以 153 | // 如果要在队首插入元素的话,需要让head左移一位(注意head为0的情况) 154 | this.head = (this.head - 1 + this.k) % this.k; 155 | this.queue[this.head] = value; 156 | this.count++; 157 | 158 | return true; 159 | } 160 | 161 | // 在队尾插入元素 162 | insertLast(value: number): boolean { 163 | if(this.isFull()) return false; 164 | 165 | this.queue[this.tail] = value; 166 | this.tail = (this.tail + 1) % this.k; 167 | this.count++; 168 | 169 | return true; 170 | } 171 | 172 | // 在队首删除元素 173 | deleteFront(): boolean { 174 | if(this.isEmpty()) return false; 175 | this.head = (this.head + 1) % this.k; 176 | this.count--; 177 | return true; 178 | } 179 | 180 | // 在队尾删除元素 181 | deleteLast(): boolean { 182 | if(this.isEmpty()) return false; 183 | this.tail = (this.tail - 1 + this.k) % this.k; 184 | this.count--; 185 | return true; 186 | } 187 | 188 | // 获取队首元素 189 | getFront(): number { 190 | if(this.isEmpty()) return -1; 191 | return this.queue[this.head]; 192 | } 193 | 194 | // 获取队尾与安娜苏 195 | getRear(): number { 196 | if(this.isEmpty()) return -1; 197 | const idx = (this.tail - 1 + this.k) % this.k; 198 | return this.queue[idx]; 199 | } 200 | 201 | // 判断队列是否为空 202 | isEmpty(): boolean { 203 | return this.count === 0; 204 | } 205 | 206 | // 判断队列是否已满 207 | isFull(): boolean { 208 | return this.count === this.k; 209 | } 210 | } 211 | ``` 212 | 213 | 214 | 215 | #### 前中后队列 216 | 217 | 前中后循环队列`双向循环队列的基础上,再加了一个,可以从队列中间入队和出队` 218 | 219 | ##### 前中后队列的`Typescript`版本 220 | 221 | ```javascript 222 | // leetcode: [1670] 设计前中后队列 223 | // 使用双向链表的形式实现前中后队列 224 | // 如:1->2->3->4 225 | // 可以看成是:1->2 ---> 3->4 226 | // 这两个链表串在一起,这样,我们想要往中间插入时,就只需要考虑到底是在链表1后面插入还是在链表2前面插入即可 227 | 228 | // 双向链表链表节点对象 229 | class Node { 230 | constructor(public val=0,public prev: Node=null, public next: Node=null){} 231 | // 在当前节点之前插入一个节点 232 | insertPrev(node: Node) { 233 | node.prev = this.prev; 234 | node.next = this; 235 | this.prev && (this.prev.next = node); 236 | this.prev = node; 237 | } 238 | 239 | // 在当前节点后插入一个节点 240 | insertNext(node: Node) { 241 | node.next = this.next; 242 | this.next && (this.next.prev = node) 243 | this.next = node; 244 | node.prev = this; 245 | } 246 | 247 | // 弹出当前节点的上一个节点 248 | popPrev(): void { 249 | if(!this.prev) return; 250 | let p = this.prev; 251 | this.prev = p.prev; 252 | this.prev && (this.prev.next = this); 253 | } 254 | 255 | // 弹出当前节点的下一个节点 256 | popNext(): void { 257 | if(!this.next) return; 258 | let p = this.next; 259 | this.next = p.next; 260 | this.next && (this.next.prev = this); 261 | } 262 | } 263 | 264 | // 使用双向链表实现一个循环双端队列 265 | class MyQueue { 266 | // 因为是双端队列,可以从头部添加和删除元素,也可以从尾部添加或删除元素 267 | // 因此需要定义头尾两个虚拟节点辅助我们操作这个链表 268 | private head: Node = new Node(); 269 | private tail: Node = new Node(); 270 | private count: number = 0;// 用于记录队列中实际的元素数量,循环队列的关键 271 | constructor(){ 272 | // 初始时,我们让头尾虚拟头相连即可 273 | // head -> tail 274 | // 我们需要从前面插入元素是,只需要在head节点后面插入元素 275 | // 我们需要从后面插入元素时,只需要从tail节点前面插入元素 276 | this.head.next = this.tail; 277 | this.tail.prev = this.head; 278 | } 279 | // 在队列尾部插入元素 280 | public pushBack(val: number) { 281 | this.tail.insertPrev(new Node(val)); 282 | this.count++; 283 | } 284 | // 在队列头部插入元素 285 | public pushFront(val: number) { 286 | this.head.insertNext(new Node(val)); 287 | this.count++; 288 | } 289 | // 在队列的尾部删除元素 290 | public popBack(): number { 291 | if(this.isEmpty()) return -1; 292 | let res = this.tail.prev.val; 293 | this.tail.popPrev(); 294 | this.count--; 295 | return res; 296 | } 297 | // 在队列的首部删除元素 298 | public popFront(): number { 299 | if(this.isEmpty()) return -1; 300 | let res = this.head.next.val; 301 | this.head.popNext(); 302 | this.count--; 303 | return res; 304 | } 305 | // 获取队首元素 306 | public front(): number{ 307 | return this.head.next.val; 308 | } 309 | // 获取对队尾元素 310 | public back(): number{ 311 | return this.tail.prev.val; 312 | } 313 | // 队列元素数量 314 | public size(): number{ 315 | return this.count; 316 | } 317 | // 队列是否为空 318 | public isEmpty(): boolean{ 319 | return this.head.next === this.tail; 320 | } 321 | } 322 | 323 | class FrontMiddleBackQueue { 324 | private q1: MyQueue; 325 | private q2: MyQueue; 326 | constructor() { 327 | this.q1 = new MyQueue(); 328 | this.q2 = new MyQueue(); 329 | } 330 | // 每一次添加或删除元素操作后,为了始终保持q1的元素数量始终大于或等于q2的元素数量,调用此方法进行修正 331 | update(): void { 332 | // 1 -> 2 -> 3 -> 4 333 | // 始终确保q1的长度大于或等于q2,并且两者节点数量的差值最大为1 334 | // 当q1数量小于q2时,从q2头部取出一个节点放在q1尾部 335 | if(this.q1.size() < this.q2.size()) { 336 | this.q1.pushBack(this.q2.popFront()); 337 | } 338 | // 如果q2的数量比q1数量少两个时,从q1尾部拿一个出来放在q2头部 339 | if(this.q2.size() === this.q1.size() - 2) { 340 | this.q2.pushFront(this.q1.popBack()); 341 | } 342 | } 343 | 344 | // 在队首添加元素,就直接在q1上添加元素,然后修正两个队列即可 345 | pushFront(val: number): void { 346 | this.q1.pushFront(val); 347 | this.update(); 348 | } 349 | 350 | // 在队列中间添加元素,首先判断如果q1内的元素数量大于q2的话,就把元素放在q2头部,否则放在q1尾部 351 | pushMiddle(val: number): void { 352 | if(this.q1.size() > this.q2.size()) { 353 | this.q2.pushFront(this.q1.popBack()); 354 | } 355 | this.q1.pushBack(val); 356 | this.update(); 357 | } 358 | 359 | // 在队尾添加元素,直接在q2尾部添加元素即可,然后修正两个队列 360 | pushBack(val: number): void { 361 | this.q2.pushBack(val); 362 | this.update(); 363 | } 364 | 365 | // 从队首删除元素,在q1头部删除并修正两个队列接口 366 | popFront(): number { 367 | if(this.isEmpty()) return -1; 368 | let res = this.q1.popFront(); 369 | this.update(); 370 | return res; 371 | } 372 | // 从队列中间删除元素,由于q1数量永远大于或等于q2数量,因此,我们只需要把q1的队尾元素删除即可 373 | popMiddle(): number { 374 | if(this.isEmpty()) return -1; 375 | let res = this.q1.popBack(); 376 | this.update(); 377 | return res; 378 | } 379 | 380 | // 从队尾删除元素,由于有可能长出现q2为空的情况,因此,如果q2为空,则从q1的队首删除元素,否则从q2的队首删除 381 | popBack(): number { 382 | if(this.isEmpty()) return -1; 383 | let res; 384 | if(this.q2.isEmpty()){ 385 | res = this.q1.popBack(); 386 | } else{ 387 | res = this.q2.popBack(); 388 | } 389 | this.update(); 390 | return res; 391 | } 392 | 393 | // 判断队列是否为空 394 | isEmpty(): boolean { 395 | return this.q1.size() === 0; 396 | } 397 | } 398 | ``` 399 | 400 | 401 | 402 | #### 优先队列 403 | 404 | 我们都知道,普通`队列`是一个严格遵循先进先出(FIFO)原则的数据结构,但是,在某些特殊场景,比如说我们的任务队列中,有一个优先级相当高的任务需要被优先执行,那么,这个时候就要`插队`了,而支持这种插队操作的队列,我们把它叫做`优先队列`,即:支持优先级的队列。我们的优先队列其实通常是使用**堆**实现的,分为**大顶堆**和**小顶堆**,分别维护最大值和最小值。关于堆的知识,后续会单独有文章详细介绍,这里就不再赘述。 405 | 406 | ### 队列的典型应用场景 407 | 408 | #### CPU的超线程技术 409 | 410 | > CPU通过指令队列不断的处理输入的指令 411 | > 412 | > 虚拟四核本质上只有两个核心,只是增加了两个指令队列 413 | > 414 | > 1个CPU包含多个计算核心 415 | 416 | #### 线程池的任务队列 417 | 418 | > 相当于任务的缓冲区,一般当前没空处理的时候,先放在队列里面等一会,有空了再从队列里面取 419 | 420 | 进程可以理解为是一个人 421 | 422 | 线程则是这个人要做的一些事 423 | 424 | 一个人可以同时做多件事,所以一个进程可以包含若干个线程 425 | 426 | ### LeetCode刷题 427 | 428 | #### LeetCode 933 最近的请求次数 429 | 430 | ##### 解题思路 431 | 432 | 这题就是利用了队列的先进先出的原理实现的,这题比较简单,我们直接来看具体实现吧。 433 | 434 | ##### 代码实现 435 | 436 | ```javascript 437 | /* 438 | * @lc app=leetcode.cn id=933 lang=typescript 439 | * 440 | * [933] 最近的请求次数 441 | */ 442 | 443 | // @lc code=start 444 | class RecentCounter { 445 | // 使用一个数组模拟栈 446 | private queue: number[]; 447 | constructor() { 448 | // 初始化数组 449 | this.queue = new Array(); 450 | } 451 | 452 | ping(t: number): number { 453 | // 每次请求时将t加入队列 454 | this.queue.push(t); 455 | // 将所有时间大于3000的元素弹出队列 456 | while(t - this.queue[0] > 3000) this.queue.shift(); 457 | // 最后剩下的数组的长度就是我们最近的请求次数 458 | return this.queue.length; 459 | } 460 | } 461 | 462 | /** 463 | * Your RecentCounter object will be instantiated and called as such: 464 | * var obj = new RecentCounter() 465 | * var param_1 = obj.ping(t) 466 | */ 467 | // @lc code=end 468 | 469 | 470 | ``` 471 | 472 | #### leetcode 622 设计循环队列 473 | 474 | 代码解析请参考上面队列常见变种中的代码详解 475 | 476 | #### leetcode 641 设计循环双端队列 477 | 478 | 代码解析请参考上面队列常见变种中的代码详解 479 | 480 | #### leetcode: 1670 设计前中后队列 481 | 482 | 代码解析请参考上面队列常见变种中的代码详解 -------------------------------------------------------------------------------- /22.1-字典树与双数组字典树(数据结构基础篇).md: -------------------------------------------------------------------------------- 1 | # 22_1-字典树与双数组字典树(数据结构基础篇) 2 | 3 | ## 字典树 4 | 5 | ### 概念 6 | 7 | 就像我们平时使用字典查找单词,我们首先按照首字母在字典中找到对应的位置,然后,再依次按照第二、第三...个字母依次缩小范围,最后找到这个单词的准确位置。我们可以发现,如果按照这样的方式查找,画成一张图的话是这样的: 8 | 9 | ![image-20211107111555661](https://ydschool-video.nosdn.127.net/1636254960059image-20211107111555661.png) 10 | 11 | **字典树**又叫(**单词查找树**或**Trie**),作用是:**单词查找,字符串排序** 12 | 13 | #### 单词查找 14 | 15 | 通常,我们将字典树的每一个边代表一个字母。而每个节点有两种颜色,一种颜色是白色,代表当前查找的单词在我们的字典中不存在,另一种颜色是红色,代表当前要查找的单词在我们的词典中是存在的。 16 | 17 | ![image-20211107111555661](https://ydschool-video.nosdn.127.net/1636254960059image-20211107111555661.png) 18 | 19 | #### 字符串排序 20 | 21 | > 使用字典树进行字典树排序,时间复杂度是`O(n)` 22 | 23 | 我们只需要对我们的字典树进行深度优先遍历(DFS),在遍历到每一个红色节点时输出单词,最终所得到的字符串数组就是按照字典序排序好的字符串数组。 24 | 25 | ```bash 26 | # 对上面的字典树进行深度优先遍历,遇到红色节点时输出单词,结果如下 27 | a 28 | aae 29 | af 30 | c 31 | fz 32 | fzc 33 | fzd 34 | # 我们可以看到,输出的结果正式按照字典序输出的,而且我们只需要进行一次深度优先遍历即可,因此,时间复杂度是O(n) 35 | ``` 36 | 37 | ### 字典树的升华 38 | 39 | 我们之前学习二叉树时,曾经学习过这样的一个概念: 40 | 41 | > 树的节点代表集合 42 | > 43 | > 树的边代表关系 44 | 45 | 那么,在字典树当中,我们怎么理解这个概念呢? 46 | 47 | ![image-20211107124932871](https://ydschool-video.nosdn.127.net/1636260581983image-20211107124932871.png) 48 | 49 | 例如上图的红色箭头所指向的节点代表:“**所有以f作为第一个字母的单词的集合(fz, fzc, fzd)**”,而`f`这条边则代表所有前缀是`f`的单词。我们可以理解为:“**字典树的根节点就代表全集,即整本字典**” 50 | 51 | ### 字典树的常规代码实现 52 | 53 | ```typescript 54 | const BASE = 26; 55 | const charCodeA = 'a'.charCodeAt(0); 56 | // 字典树的节点结构 57 | class TrieNode { 58 | public flag: boolean;// 代表当前节点是否能够独立成词,即true为红色节点,false为白色节点 59 | public nexts: TrieNode[] = new Array(BASE);// 每一条边代表26个字母中的一个,因此这个节点数组最大长度为26(假设我们的字典树只存储小写字母) 60 | constructor() { 61 | this.flag = false; 62 | this.nexts.fill(null); 63 | } 64 | } 65 | 66 | class Trie { 67 | private root: TrieNode; 68 | constructor() { 69 | this.root = new TrieNode(); 70 | } 71 | static clearTrieNode(root: TrieNode): void { 72 | if(!root) return; 73 | for(let i=0;i trie.insert(word)); 142 | 143 | console.log(`查找单词【name】结果:${trie.search('name')}`); 144 | console.log(`查找单词【kiner】结果:${trie.search('kiner')}`); 145 | console.log(`查找单词【hello】结果:${trie.search('hello')}`); 146 | console.log(`查找单词【hell】结果:${trie.search('hell')}`); 147 | console.log(`查找单词【000】结果:${trie.search('000')}`); 148 | console.log(`按照字典序排序:${trie.sort()}`) 149 | ``` 150 | 151 | 152 | 153 | ### 字典树的竞赛代码实现 154 | 155 | 相较于常规字典树而言,竞赛代码实现字典树的`nexts`中不在存储每个节点的信息,而是存储节点的索引,能够节省一定的存储空间,之后学习的双数组字典树,也是以此为基础的。 156 | 157 | ```typescript 158 | // 与上面的常规字典树类不同,这里存储的nexts是节点索引的数组,而非具体的某个节点 159 | const BASE = 26; 160 | const charCodeA = "a".charCodeAt(0); 161 | class TrieNode { 162 | flag: boolean; 163 | nexts: number[]; 164 | constructor() { 165 | this.nexts = new Array(BASE); 166 | this.clear(); 167 | } 168 | clear() { 169 | this.flag = false; 170 | this.nexts.fill(0); 171 | } 172 | } 173 | const tries: TrieNode[] = new Array(100); 174 | for (let i = 0; i < 100; i++) { 175 | tries[i] = new TrieNode(); 176 | } 177 | let count, root; 178 | 179 | function clearTrie() { 180 | // 根节点索引 181 | root = 1; 182 | // 下一个可以操作的索引 183 | count = 2; 184 | tries.forEach((t) => t.clear()); 185 | } 186 | /** 187 | * 创建一个新节点 188 | * @returns 189 | */ 190 | function getNewNode() { 191 | // 重置最后一个节点状态 192 | tries[count].clear(); 193 | // 返回新的可操作性节点的索引 194 | return count++; 195 | } 196 | 197 | function insert(s: string): void { 198 | let p = root; 199 | for (let x of s) { 200 | const idx = x.charCodeAt(0) - charCodeA; 201 | // console.log(tries, p); 202 | if (tries[p].nexts[idx] === 0) tries[p].nexts[idx] = getNewNode(); 203 | p = tries[p].nexts[idx]; 204 | } 205 | tries[p].flag = true; 206 | } 207 | 208 | function search(s: string): boolean { 209 | let p = root; 210 | for (let x of s) { 211 | const idx = x.charCodeAt(0) - charCodeA; 212 | p = tries[p].nexts[idx]; 213 | if (!p) return false; 214 | } 215 | return tries[p].flag; 216 | } 217 | 218 | function _sort(root: number, s: string = "", res: string[] = []): void { 219 | if(root === 0) return; 220 | if(tries[root].flag) res.push(s); 221 | for(let i=0;i insert(s)); 234 | 235 | console.log(`查找单词【name】结果:${search("name")}`); 236 | console.log(`查找单词【kiner】结果:${search("kiner")}`); 237 | console.log(`查找单词【hello】结果:${search("hello")}`); 238 | console.log(`查找单词【hell】结果:${search("hell")}`); 239 | console.log(`查找单词【000】结果:${search("000")}`); 240 | console.log(`字典序输出结果:${sort()}`); 241 | 242 | ``` 243 | 244 | ## 双数组字典树 245 | 246 | ### 概念 247 | 248 | **本质上的逻辑结构还是跟字典树是一样的,只是换了一种信息的表示方法**,就像罗马数字与阿拉伯数字的关系,本质上,他们表示的都是相同的东西,都是数字,但是他们的表示形式不一样。 249 | 250 | ### 作用 251 | 252 | 双数组字典树相较于普通字典树而言,占用的空间大小少很多,大概是普通字典树的十几分之一左右,因此,适合在大数据检索时使用。 253 | 254 | 除此之外,由于我们双数组字典树的关键信息都存储在两个数组中,而数组中的信息又是极易序列化输出到文件中的,虽然我们创建双数组字典树是比较耗时的操作,但一旦双数组字典树创建好之后,我们的检索操作的效率是相当高的。利用这样一个特点,我们可以在我们的集群服务器上去执行建立双数组字典树的操作,创建好后,将两个数组的信息序列化输出到文件并传输到用户终端,如手机上,这样,用户的手机就可以非常高效的进行检索了。 255 | 256 | ### 双数组 257 | 258 | #### base数组 259 | 260 | 是特殊确定的一组值,我们可以通过父节点在`base`数组的值和当前边的编号`i`确定子节点的值 261 | 262 | #### check数组 263 | 264 | 用于检测子节点的实际父节点的编号,解决同一个子节点同时存在两个父节点的问题 265 | 266 | ### 是否独立成词 267 | 268 | 因为`check`数组中存储的是节点编号,那么里面的合法值一定是自然数,那么我们就可以用一个负数代表当前节点独立成词,即红色节点,用正数代表不独立成词,即白色节点。如某个节点的父节点编号为`3`,而这个节点能够独立成词,则在`check`数组中表示为`-3`,若不能独立成词,则表示为`3`。 269 | 270 | ### 代码演示 271 | 272 | 我们基于上面的竞赛版本代码为基础实现一个双数组字典树 273 | 274 | ```typescript 275 | const BASE = 26; 276 | const charCodeA = "a".charCodeAt(0); 277 | class TrieNode { 278 | flag: boolean; 279 | nexts: number[]; 280 | constructor() { 281 | this.nexts = new Array(BASE); 282 | this.clear(); 283 | } 284 | clear() { 285 | this.flag = false; 286 | this.nexts.fill(0); 287 | } 288 | } 289 | const maxCount = 100; 290 | const tries: TrieNode[] = new Array(maxCount); 291 | for (let i = 0; i < maxCount; i++) { 292 | tries[i] = new TrieNode(); 293 | } 294 | let count, root; 295 | 296 | function clearTrie() { 297 | // 根节点索引 298 | root = 1; 299 | // 下一个可以操作的索引 300 | count = 2; 301 | tries.forEach((t) => t.clear()); 302 | } 303 | /** 304 | * 创建一个新节点 305 | * @returns 306 | */ 307 | function getNewNode() { 308 | // 重置最后一个节点状态 309 | tries[count].clear(); 310 | // 返回新的可操作性节点的索引 311 | return count++; 312 | } 313 | 314 | function insert(s: string): void { 315 | let p = root; 316 | for (let x of s) { 317 | const idx = x.charCodeAt(0) - charCodeA; 318 | // console.log(tries, p); 319 | if (tries[p].nexts[idx] === 0) tries[p].nexts[idx] = getNewNode(); 320 | p = tries[p].nexts[idx]; 321 | } 322 | tries[p].flag = true; 323 | } 324 | 325 | function search(s: string): boolean { 326 | let p = root; 327 | for (let x of s) { 328 | const idx = x.charCodeAt(0) - charCodeA; 329 | p = tries[p].nexts[idx]; 330 | if (!p) return false; 331 | } 332 | return tries[p].flag; 333 | } 334 | 335 | function _sort(root: number, s: string = "", res: string[] = []): void { 336 | if (root === 0) return; 337 | if (tries[root].flag) res.push(s); 338 | for (let i = 0; i < BASE; i++) { 339 | _sort(tries[root].nexts[i], s + String.fromCharCode(i + charCodeA), res); 340 | } 341 | } 342 | function sort(): string[] { 343 | const res: string[] = []; 344 | _sort(root, "", res); 345 | return res; 346 | } 347 | 348 | const base: number[] = new Array(maxCount); 349 | const check: number[] = new Array(maxCount); 350 | base.fill(0); 351 | check.fill(0); 352 | let daRoot = 1; 353 | 354 | /** 355 | * 根据常规字典树根节点计算base值 356 | * @description base值满足:base + i 在check中对应的值为0,即判断同一个子节点不能同时存在两个父节点的情况 357 | * @param root 常规字典树根节点 358 | * @param check 当前 check 数组 359 | */ 360 | function getBaseValue(root: number, check: number[]): number { 361 | // b本次尝试的base值,初始为1,flag用于判断当前是否需要继续下一轮循环查找下一个合法base值,默认为需要, 362 | // 进入循环后,我们将flag先设置为false,假设本轮能够找到我们想要的合法base值,当遍历完每个字母后发现无法找到想要的base 363 | // 再将flag设为true,让程序再次进入循环匹配下一个base 364 | let b = 1, 365 | flag = true; 366 | while (flag) { 367 | flag = false; 368 | // 每次累加base值 369 | b++; 370 | // 遍历每一个字符 371 | for (let i = 0; i < BASE; i++) { 372 | // 如果当前节点的第i条边是空的,就继续循环 373 | if (tries[root].nexts[i] === 0) continue; 374 | // 如果当前节点存在第i条边,我们还需要确定第i边对应的位置是空的,如果是空的,则当前边的b值是合法的,继续下一轮循环 375 | if (!check[b + i]) continue; 376 | // 如果当前节点的第i条边在check中对应的位置不是空的,说明出现了一个节点同时有两个父节点的情况,我们需要将flag置为true,重新尝试新的b值 377 | flag = true; 378 | break; 379 | } 380 | } 381 | // 至此,我们就找到了一个合法的base值了 382 | return b; 383 | } 384 | 385 | function convert2DoubleArrayTrie( 386 | root: number, 387 | daRoot: number, 388 | base: number[], 389 | check: number[] 390 | ): void { 391 | // 如果原字典树的根节点是0,则直接退出 392 | if (root === 0) return; 393 | 394 | // 确定根节点的base值 395 | base[daRoot] = getBaseValue(root, check); 396 | // 让当前节点的所有子节点认祖归宗,在check中将这些子节点的父节点标记为当前的daRoot 397 | for (let i = 0; i < BASE; i++) { 398 | // 当前节点不存在第i条边,继续循环 399 | if (tries[root].nexts[i] === 0) continue; 400 | // base + i => 父节点编号,即daRoot 401 | check[base[daRoot] + i] = daRoot; 402 | // 标记当前节点是否独立成词,如果独立成词,就将父节点编号标记为负数 403 | if (tries[tries[root].nexts[i]].flag) check[base[daRoot] + i] = -daRoot; 404 | } 405 | // 递归确定每一颗子树的base值 406 | for(let i=0;i insert(s)); 432 | convert2DoubleArrayTrie(root, daRoot, base, check); 433 | 434 | console.log(`常规字典树查找单词【name】结果:${search("name")},双数组字典树查找单词【name】结果:${searchDaTrie("name")}`); 435 | console.log(`常规字典树查找单词【kiner】结果:${search("kiner")},双数组字典树查找单词【kiner】结果:${searchDaTrie("kiner")}`); 436 | console.log(`常规字典树查找单词【hello】结果:${search("hello")},双数组字典树查找单词【hello】结果:${searchDaTrie("hello")}`); 437 | console.log(`常规字典树查找单词【hell】结果:${search("hell")},双数组字典树查找单词【hell】结果:${searchDaTrie("hell")}`); 438 | console.log(`常规字典树查找单词【dsa】结果:${search("dsa")},双数组字典树查找单词【dsa】结果:${searchDaTrie("dsa")}`); 439 | console.log(`字典序输出结果:${sort()}`); 440 | 441 | ``` 442 | 443 | -------------------------------------------------------------------------------- /21.1-字符串的经典匹配算法(算法基础篇).md: -------------------------------------------------------------------------------- 1 | ## 前言 2 | 3 | 字符串匹配算法相较于我们之前学习过的其他算法而言,需要极强的观察能力,不然,看似简单的问题,可能会被复杂化。今天我们就来一起了解一些经典的字符串匹配算法以及常用的辅助字符串匹配的数据结构,以及学习一下这些匹配算法的特性,在什么场景下应该使用哪个算法匹配会更高效。 4 | 5 | ## 基础概念扫盲 6 | 7 | ### 单模匹配问题 8 | 9 | 翻译成人话就是:“在一长串的字符串中,是否出现过某个子串,如果出现过某个子串,则匹配成功”。其中,长串的字符串,我们叫做**母串**(或叫**文本串**),而子串就被成为**模式串**。类似于在一个`word`文档中的“单词查找”功能,我们整个word文档的字符串就是**母串**,而我们要查找的那个单词就是**模式串**。这一类问题就是“**单模匹配问题**”。 10 | 11 | ### 母串 12 | 13 | 如:`aecaeaecaed` 14 | 15 | ### 模式串 16 | 17 | 如:`aecaed` 18 | 19 | ## 经典字符串匹配算法 20 | 21 | ### 暴力匹配算法 22 | 23 | #### 概念 24 | 25 | **暴力匹配算法**会用**模式串**和**母串**中的每个位置对齐,对齐以后,向后匹配,判断一下从对齐位开始,向后是否能够完整匹配**模式串**。 26 | 27 | #### 要点 28 | 29 | 用**模式串**对齐**母串**的每一位。即先用模式串的第一位与母串的第一位对齐,然后匹配一下能否匹配成功,如果不行,则让模式串的第一位与母串的第二位对齐,再匹配一下看看能否匹配成功。。。依次类推,直至匹配成功或母串长度已经小于模式串长度时停止。 30 | 31 | ```bash 32 | # 母串 33 | a e c a e a e c a e d 34 | # 模式串 35 | a e c a e d 36 | 37 | # 首先让模式串的第一位与母串第一位对齐,尝试匹配 38 | a e c a e a e c a e d 39 | △ 40 | ▽ 41 | a e c a e d 42 | # 发现最后一位a与d不匹配,接着让模式串的第一位与母串的第二位对齐 43 | a e c a e a e c a e d 44 | △ 45 | ▽ 46 | a e c a e d 47 | # 第一位都不对齐,继续对齐母串的下一位 48 | # ... 49 | a e c a e a e c a e d 50 | △ 51 | ▽ 52 | a e c a e d 53 | # 直到匹配到上面的情况,发现从a处对齐,直到最后,跟模式串完美匹配,说明能在母串中找到模式串 54 | ``` 55 | 56 | #### 特点 57 | 58 | 暴力匹配算法能够**不重不漏**得处理每一次的匹配操作。其中: 59 | 60 | **不重**指的是如果我的模式串已经跟我们母串的某一位对齐过了,那么,在匹配的过程中,不会再次与这一位重复对齐(因为在我们算法运行的过程中,重复的操作会降低算法的效率)。 61 | 62 | **不漏**指的是在我们的暴力匹配算法中,不会漏掉任何一次有可能匹配成功的操作。 63 | 64 | 无论我们其他算法如何优化,至少都要能够跟暴力匹配算法一样,达到**不重不漏**的匹配。 65 | 66 | #### 代码演示 67 | 68 | ```typescript 69 | // 暴力匹配算法 70 | /** 71 | * 在母串中使用暴力匹配方式查找模式串,如存在模式串,则返回期起始位置索引,否则返回-1 72 | * @param text 母串 73 | * @param chars 模式串 74 | * @returns 75 | */ 76 | function bruteForce(text: string, chars: string): number { 77 | // i用于使文本串与模式串对齐,j用于匹配对齐后的每一个字符是否与模式串匹配 78 | let i=0,j=0; 79 | // 只要文本串还存在未匹配的字符就继续 80 | while(text[i]) { 81 | // 设置标志位默认为true 82 | let flag = true; 83 | j = 0; 84 | // 从对齐位开始匹配母串中的每一个字符串,看是否与模式串的每一位字符串相同 85 | while(chars[j]) { 86 | // 如果相同则继续匹配下一个字符,注意,母串的字符是i+j位,因为母串是从第位开始对齐的 87 | if(text[i+j] === chars[j]) { 88 | j++; 89 | continue; 90 | } 91 | // 如果没有进入上面的逻辑,那么说明找到了一个不相同的字符串,将标志位设置为false, 92 | // 并退出本次对齐的匹配,因为已经有不匹配的字符了,再匹配下去也没意义了 93 | flag = false; 94 | break; 95 | } 96 | // 如果匹配结束后,标志位依然还是true,那就说明从对齐位开始的每一位斗鱼模式串匹配,直接返回true 97 | if(flag) return i; 98 | i++; 99 | 100 | } 101 | // 整个母串都匹配完了,还没匹配上,则返回false 102 | return -1; 103 | } 104 | console.time("耗时:"); 105 | console.log(bruteForce('aecaeaecaed', 'aecae'));// 0 106 | console.log(bruteForce('aecaeaecaed', 'aecaed'));// 5 107 | console.log(bruteForce('aecaeaecaed', 'aecaef'));// -1 108 | console.log(bruteForce('aecaeaecaed', 'aeaaed'));// -1 109 | console.timeEnd("耗时:"); 110 | 111 | // 0 112 | // 5 113 | // -1 114 | // -1 115 | // 耗时:: 8.566ms 116 | ``` 117 | 118 | 119 | 120 | ### KMP算法 121 | 122 | `KMP`算法就是基于上述`暴力匹配算法`进行一定优化的一种字符串匹配算法。 123 | 124 | #### 重点 125 | 126 | `KMP`算法的关键在于,将原本的`母串`与`模式串`的问题,转换成了`模式串`与`模式串`的问题。具体的转换逻辑如下: 127 | 128 | ![image-20211024124257045](https://ydschool-video.nosdn.127.net/1635050582865image-20211024124257045.png) 129 | 130 | 那么,将`母串`与`模式串`的问题转换成`模式串`和`模式串`的问题,到底有什么意义呢?通常情况下,我们的`母串`都是比较长的,我们要对`母串`做一些处理比较难,而`模式串`都是比较短的,我们要对`模式串`做一些处理就比较简单了。将原本`母串`与`模式串`的问题转换成为`模式串`与`模式串`的问题之后,我们就可以根据`模式串`做一些预处理。 131 | 132 | ```bash 133 | # 根据上面的推导过程,我们知道: 134 | # 第二位对齐的前提条件是:模式串的前4位等于模式串的后4位,即: 135 | pre4 = last4 136 | # 第三位对齐的前提条件是:模式串的前3位等于模式串的后3位,即: 137 | pre3 = last3 138 | # 第四位对齐的前提条件是:模式串的前2位等于模式串的后2位,即: 139 | pre2 = last2 140 | 141 | # 只有当上面的条件满足,我们对齐对应位才有意义,如果不满足,注定不能匹配成功,就不需要浪费资源去匹配了,这也是相较于暴力匹配算法做的一个优化。 142 | # 在匹配的过程中,我们已经知道了pre4不等于last4,pre3不等于last3了,那么当我们第一次匹配失败后,我们可以不用再跟第二位和第三位对齐,直接跟第一个满足条件的第四位进行对齐即可。 143 | ``` 144 | 145 | 使用上述方法优化了算法实现后,我们依然可以保证“**不重不漏**”的匹配到答案,之所以我们跳过了第二位和第三位对齐依然能够保证不重不漏,是因为我们使用了高效的方式已经提前判断出来了第二位和第三位对齐是不可能匹配成功的。 146 | 147 | 这就是暴力匹配算法的其中一个优化方向,就是跳过一些明确不可能匹配成功的环节,直接进入有可能匹配成功的匹配流程中。有点类似于之前动态规划优化中的**剪枝**操作,剪去一些无用的分支,提升算法效率。 148 | 149 | #### KMP的加速 150 | 151 | **KMP算法**就是基于上述的优化进行加速的一种算法,假设模式串的前缀为:**Ta**,模式串的后缀为:**Tb**,我们需要找到截止到目前匹配成功位置之前(即上面示例中带有`┈`的部分)模式串中最长的**Ta**与**Tb**相等的部分。之所以要找到截止到目前匹配成功位置之前(即上面示例中带有`┈`的部分)模式串中最长的部分,是为了保证匹配过程**不漏**。 152 | 153 | ![image-20211024113247733](https://ydschool-video.nosdn.127.net/1635046373920image-20211024113247733.png) 154 | 155 | **KMP算法**预处理的信息其实就是上述的模式串不断往前移动时,需要往前移动到哪一位的信息,即找到上一个可能跟`i+j`位匹配的字符在模式串中的位置。即我们在上述的`j`位如果匹配失败,我们就直接跳到预处理出来的`j’`位置,程序实现时,我们一般将这些信息存到数组当中,在数组的第`j`位存储的是`j’`的位置。 156 | 157 | 那么,假如说模式串中不存在“Ta = Tb”的结构呢?那就意味着我们的模式串跟母串标记了“┈”位置上的任何一位都不可能匹配成功,那么我们就可以让模式串直接跳过这些标注了"┈"的部分,从第一个没有标注"┈"的部分开始对齐。 158 | 159 | #### 代码演示 160 | 161 | ```typescript 162 | function initNext(char: string, next: number[]): void{ 163 | // 第一位固定是-1,我们假设-1是万能匹配位,无论跟谁都能匹配上,假如说我们模式串的第一位都 164 | // 没办法匹配,就会指向这个-1 165 | next[0] = -1; 166 | for(let i=1,j=-1;char[i];i++) { 167 | // 如果j+1位字符与i位字符不匹配并且j不是-1时,我们需要让j跳到下一位 168 | while(j!==-1 && char[j+1] !== char[i]) j = next[j]; 169 | // 如果j+1位于i位匹配则j向后移动一位 170 | if(char[j+1] === char[i]) j++; 171 | // 将当前的关键信息存入到next数组中 172 | next[i] = j; 173 | } 174 | } 175 | 176 | function kmp(text: string, char: string): number { 177 | // 模式串的长度 178 | const n = char.length; 179 | // 用于存储预处理出来的信息,当某一位匹配不上时,我们应该跳到哪一位 180 | const next: number[] = []; 181 | // 初始化关键信息 182 | initNext(char, next); 183 | // i指向的是当前匹配的位置,而j则是当前位置的前一位,因此,我们要匹配的是i与j+1位是否匹配 184 | for(let i=0,j=-1;text[i];i++) { 185 | // 如果j不是-1并且母串的第i位与模式串的j+1位不能匹配成功,则让j向前跳 186 | while(j!==-1 && text[i] !== char[j+1]) j = next[j]; 187 | // 如果文本串的第i位于模式串的j+1位匹配成功,那么模式串长度加1 188 | if(text[i] === char[j+1]) j++; 189 | // 判断是否匹配成功 190 | // 如果模式串的j+1位不存在,说明我们已经匹配完了整个模式串了,说明匹配成功 191 | if(!char[j+1]) return i - j; 192 | } 193 | // 整个匹配下来还没有匹配成功,则无法匹配,返回-1 194 | return -1; 195 | } 196 | 197 | console.time("耗时:"); 198 | console.log(kmp('aecaeaecaed', 'aecae'));// 0 199 | console.log(kmp('aecaeaecaed', 'aecaed'));// 5 200 | console.log(kmp('aecaeaecaed', 'aecaef'));// -1 201 | console.log(kmp('aecaeaecaed', 'aeaaed'));// -1 202 | console.timeEnd("耗时:"); 203 | 204 | // 0 205 | // 5 206 | // -1 207 | // -1 208 | // 耗时:: 7.771ms 209 | ``` 210 | 211 | #### 思维发散 212 | 213 | 从上面的代码程序实现中,我们不难看出,`i`只会不断往后,把一个个字符“喂”给下面的模式串匹配逻辑,真正一直在变化的是`j`,我们把`j`看成一个状态的话,那么,这个过程可以理解为:我们每改变一个字符(i变化),我们的状态(j)就会随之改变,这其实就是我们计算机中很常见的**状态机**。也就是说,`KMP算法`,本质上就是一种**状态机**。 214 | 215 | #### KMP算法的应用场景 216 | 217 | `KMP`算法可以处理基于**流**数据的单模匹配问题。因为我们的**KMP**算法每次根据所给的一个字符改变状态,并不需要一次性将全量的数据全部提供给`KMP算法`,即:“来一个字符,状态变化一下,再来一个字符,状态在变化一下”。举个例子: 218 | 219 | ![image-20211024113327016](https://ydschool-video.nosdn.127.net/1635046411264image-20211024113327016.png) 220 | 221 | ### Sunday算法 222 | 223 | 即**星期天算法**。通过对齐**黄金对齐点位**达到不重不漏的匹配字符串的目的。下面我们来看看星期天算法是怎么工作的。 224 | 225 | ```bash 226 | # 母串 227 | a e c a e a e c a e d 228 | # 模式串 229 | a e c a e d 230 | 231 | # 首先,第一次匹配失败后,我们将模式串向右移动一位 232 | # 母串 233 | a e c a e a e c a e d 234 | # 模式串 235 | a e c a e d 236 | # 此时,我们母串中与模式串对应的最后一位是e,那么,如果想要匹配成功,我们的模式串中也必须要出现e,此时我们从后往前查找模式串,找到第一个e出现的位置,然后让着两个e的位置对齐,而这两个e的位置就是黄金对齐点位 237 | # 母串 238 | a e c a e a e c a e d 239 | # 模式串 240 | a e c a e d 241 | # 黄金对齐点对齐后,然后从第一位开始尝试与母串匹配,仍然不匹配,模式串后移一位 242 | # 母串 243 | a e c a e a e c a e d 244 | # 模式串 245 | a e c a e d 246 | # 此时与模式串最后一位相对应的母串的字符是a,那么,相同的道理,我们需要在模式串中,从后向前找到第一个a出现的位置,然后再让这个a与母串的a对齐,这两个a的位置也是黄金对齐点位 247 | # 母串 248 | a e c a e a e c a e d 249 | # 模式串 250 | a e c a e d 251 | # 对齐后从新从模式串开头与母串进行匹配,发现能够完全匹配,说明已经找到了。 252 | ``` 253 | 254 | 总结一下,星期天算法,需要预处理收集每个字符在模式串中最后一个出现的位置,即从后往前第一个位置,当我们匹配失败之后,需要看一下母串当前位的下一位字符在模式串中排在倒数第几位,排在倒数第二位,我们的模式串就向右推2位,排在倒数第n位,模式串就向右推n位。假如说我们母串当前位的下一位字符在模式串中不存在,那么,我们可以假设模式串最前面有一个万能匹配位`-1`,我们直接让`-1`位跟这个字符对齐,就相当与我们让这个模式串调到这个字符之后的一位再开始尝试对齐,即模式串向后推整个模式串的长度。 255 | 256 | #### 应用场景 257 | 258 | 星期天算法最适合处理在一篇文章中查找一个单词是否出现过。假设我们在一个有10000个字母的查找一个由100个字母的段落,在最优的情况下,能够达到`10000/100` = `100`,也就是说,我们只需要循环100次就能找到。这种效率算是非常高的了。在通常情况下,实际应用场景中,星期天算法的时间复杂度远优于**暴力匹配算法**和**KMP算法**。当然,由于**星期天算法**要求知道整个文本串的内容才能进行匹配,因此,无法像**KMP算法**一样处理流数据。因此,脱离实际问题场景讨论算法的优劣就是在耍流氓,我们应该结合实际问题场景选择最适合的算法。 259 | 260 | #### 代码实现 261 | 262 | ```typescript 263 | function sunday(text: string, char: string): number { 264 | // 用于记录某个字符出现的最后一个的位置 265 | const lastPosition: Record = {}; 266 | // n是文本串长度,m时模式串的长度 267 | let n = text.length,m; 268 | // 预处理模式串中每个字母出现的最后一个位置 269 | for(let i=0;text[i];i++) lastPosition[text[i]] = -1; 270 | for(m=0;char[m];m++) lastPosition[char[m]] = m; 271 | // 遍历文本串的字符,由于当模式串中的字符在正在匹配的文本串中没有出现时会往后推一整个模式串的长度 272 | // 因此遍历的边界条件应该是当前匹配的位置加上模式串的长度要不大于文本串的长度。至于我们每次应该让 273 | // i往后走几位则取决于i+m位的字符出现在模式串的倒数第几位,lastPosition[text[i+m]]代表的是i+m 274 | // 位字符出现在模式串的倒数第几位,但我们要算的的往后推几位,因此,还要用总长度m减去它 275 | for(let i=0;i+m<=n;i+=(m - lastPosition[text[i+m]])) { 276 | // 跟暴力匹配算法一样,依次匹配模式串的每个字符 277 | let flag = true; 278 | for(let j=0;char[j];j++) { 279 | if(text[i+j] === char[j]) continue; 280 | flag = false; 281 | break; 282 | } 283 | if(flag) return i; 284 | } 285 | return -1; 286 | } 287 | 288 | console.time("耗时:"); 289 | console.log(sunday('aecaeaecaed', 'aecae'));// 0 290 | console.log(sunday('aecaeaecaed', 'aecaed'));// 5 291 | console.log(sunday('aecaeaecaed', 'aecaef'));// -1 292 | console.log(sunday('aecaeaecaed', 'aeaaed'));// -1 293 | console.timeEnd("耗时:"); 294 | 295 | // 0 296 | // 5 297 | // -1 298 | // -1 299 | // 耗时:: 7.48ms 300 | ``` 301 | 302 | ### Shift-And算法 303 | 304 | #### 重点 305 | 306 | **Shift-And**算法会先拿着模式串预处理出一种特定的数据信息,即**编码信息**,然后根据这个数据信息和文本串进行匹配 307 | 308 | ```bash 309 | # 模式串 310 | a e c a e d 311 | 312 | 预处理 313 | ▽ 314 | dict['a'] = [ 1, 0, 0, 1, 0, 0 ] # 二进制表示为:001001 315 | dict['c'] = [ 0, 0, 1, 0, 0, 0 ] # 二进制表示为:000100 316 | dict['d'] = [ 0, 0, 0, 0, 0, 1 ] # 二进制表示为:100000 317 | dict['e'] = [ 0, 1, 0, 0, 1, 0 ] # 二进制表示为:010010 318 | 319 | # 从上面我们可以看出,shift-and算法将模式串处理成将每个字符按照二进制的反向表示形式记录每个位上是否出现了该字符,由于字符串是从左到右的,因此,二进制表示也用从低位到高位排列。 320 | 321 | # 预处理出来上面的编码信息之后,shift-and算法还会设置一个额外的标记P 322 | P = [ 0, 0, 0, 0, 0, 0 ] 323 | # 如果P的相关位置为0,说明没有匹配上,如果为1,说明以文本串当前位置作为结尾,能够匹配成功模式串的前几位,如: 324 | P = [ 0, 1, 0, 0, 1, 0 ] 325 | # 上面P第2位和第4位分别代表的是以文本串的当前位置作为结尾,能够匹配模式串的前2位和前4位。 326 | 327 | # 举个例子: 328 | # 母串 329 | a e c a e a e c a e d 330 | # 模式串 331 | a e c a e d 332 | # 假如我当前以第4位的a作为结尾,那么此时由于最后一个a与第一位的a匹配,因此P的第一位为1,又因为以a作为结尾,前四位都能匹配上,因此,第四位也为1,因此,P为: 333 | P = [ 1, 0, 0, 1, 0, 0 ] 334 | ``` 335 | 336 | 上面已经说明了`P`的具体的含义以及`0`和`1`代表的意思,那么,接下来,我们再来看看`P`值在我们的文本串字符发生变化时是如何转移的呢? 337 | 338 | ```bash 339 | # 假设我们现在匹配的文本串新进来第i位的字符text[i],那么,此时,我们首先先将P的反向二进制表示统一向前移动(二进制表示为按位左移<<)一位,然后再跟1进行按位或(|)运算,最后在与我们新进来的字符编码的反向二进制表示进行按位与运算(&)来最终确定是否真的能够匹配上 340 | P = (P << 1 | 1) & d[text[i]] 341 | # 那么,我们要如何理解上面的这个公式呢?还是拿上面举的例子来说,由于我们新进来一个字符,那么尾门的二进制位自然也要多给一个,就像原本我们酒店预定了2间房,但现在来了三个人,如果不想两人挤一间房,是不是得再开一间房?这就是上面公式中:P<<1的含义,上面的P按位左移1位之后结果如下: 342 | P = [ 1, 0, 0, 1, 0, 0 ] 343 | P = P << 1 = [ 0, 1, 0, 0, 1, 0, 0 ] 344 | # 因为原本P能匹配上前1位和前4位,那么我们新进来一个字符,就有可能(注意,这里说的是有可能,而不是一定)匹配上前2位和前5位,又因为新进来的字符能够匹配上前1位,因此对按位左移之后的P进行按位或1处理匹配上新的前一位的问题 345 | P = 0b0100100 | 0b0000001 = 0b0100101 # 二进制按位或运算 346 | # 反向二进制表示 347 | P = (P << 1 | 1) = [ 0, 1, 0, 0, 1, 0, 1] 348 | 349 | # 假设新进来的数字是第五位e 350 | P = 0b0001001 & 0b010010 351 | P = P & d[text[i]] = [ 0, 1, 0, 0, 1, 0, 0 ] 352 | # 即新加入一个字符e后,前二位和前5位能够匹配上 353 | ``` 354 | 355 | 现在,我们已经借助`P`完成了匹配的过程,那么我们要如何判断模式串已经被找到了呢?这也很简单,只要判断`P`的最后一位是否为1就可以了,公式为:`P & (1 << (n-1)) !== 0`,也就是让`P`跟`1`按位左移`n-1`位后的结构进行按位与运算进行判断,如果结不为`0`,则说明匹配成功。其中,`n`为模式串的长度。 356 | 357 | #### 代码实现 358 | 359 | ```typescript 360 | // 算法时间复杂度:O(n) 361 | function shiftAnd(text: string, char: string): number { 362 | // 首先预处理字符编码信息 363 | const code: Record = {}; 364 | let m; 365 | for(m=0;char[m];m++) { 366 | if(code[char[m]] === undefined) { 367 | code[char[m]] = 0; 368 | } 369 | // 如果第m位存在该字符,就将该字符的对应位置为1 370 | code[char[m]] |= (1 << m); 371 | } 372 | let p = 0; 373 | for(let i=0;text[i];i++) { 374 | p = (p << 1 | 1) & code[text[i]]; 375 | // 由于i是代表以i下标字符作为结尾,因此,我们匹配模式串的开始位置的下标 应该是:i - m + 1 376 | if(p & (1 << m - 1)) return i - m + 1; 377 | } 378 | return -1; 379 | } 380 | 381 | 382 | 383 | console.time("耗时:"); 384 | console.log(shiftAnd('aecaeaecaed', 'aecae'));// 0 385 | console.log(shiftAnd('aecaeaecaed', 'aecaed'));// 5 386 | console.log(shiftAnd('aecaeaecaed', 'aecaef'));// -1 387 | console.log(shiftAnd('aecaeaecaed', 'aeaaed'));// -1 388 | console.timeEnd("耗时:"); 389 | 390 | // 0 391 | // 5 392 | // -1 393 | // -1 394 | // 耗时:: 8.003ms 395 | ``` 396 | 397 | #### 思维发撒 398 | 399 | 与`KMP算法`类似,我们这个`Shift-And`算法也是一个状态机,我们给一个字符,状态就改变一下,因此,这个算法也是适合流数据单模匹配问题的。并且其时间复杂度是`O(n)`,因此,相较而言,`Shift-And`算法会更高效。 400 | 401 | #### 应用场景 402 | 403 | 出了上面说的,`Shift-And`算法适合流数据处理,并且相较于`KMP`算法更加高效之外,在一个文章中,匹配一段**正则表达式**的场景也适合使用`Shift-And`算法。 404 | 405 | ```bash 406 | # 如:在一篇文章中按照如下正则表达式匹配字符串 407 | [a|c][d|e][f][g|g|i] 408 | # 那么,为什么shift-and算法擅长处理这种匹配场景呢? 409 | # 这就跟我们预处理出来的字符编码有关了,我们还是拿下面这个例子看一下: 410 | # 下面的例子与上面我们的例子唯一的不同就是,第0位既可以是字符a,也可以是字符c,这是不是就符合正则表达式中,第一位既可以是a,也可以是c的情况了呢? 411 | 412 | # 模式串 413 | a e c a e d 414 | 415 | 预处理 416 | ▽ 417 | dict['a'] = [ 1, 0, 0, 1, 0, 0 ] # 二进制表示为:001001 418 | dict['c'] = [ 1, 0, 1, 0, 0, 0 ] # 二进制表示为:100100 419 | dict['d'] = [ 0, 0, 0, 0, 0, 1 ] # 二进制表示为:100000 420 | dict['e'] = [ 0, 1, 0, 0, 1, 0 ] # 二进制表示为:010010 421 | 422 | ``` 423 | 424 | 总结一下:**Shift-And算法天生适合处理每个位置上允许出现不同字符的匹配问题,如正则匹配问题**。 425 | 426 | ## 结语 427 | 428 | 我们今天一起讨论了四种经典的字符串匹配算法,每种算法各有其奇妙之处,即使是最笨的暴力匹配算法,也让我们学习到了字符串匹配最关键的一个要求:**不重不漏**。我们再实际的应用场景中,根据不同的具体情况,选用不同的算法,能够极大得提升字符串的匹配效率。如在处理流数据时选用**KMP算法**或**Shift-And算法**,遇到已知全文的单个文章中查找单个单词的算法时,可以选用**sunday**算法,遇到每个位置上的字符允许出现不同字符时选用**Shift-And算法**等等。 -------------------------------------------------------------------------------- /16.1-AVL树(数据结构基础篇).md: -------------------------------------------------------------------------------- 1 | ## 二叉排序树(二叉搜索树、二叉查找树)基础知识 2 | 3 | `二叉排序树`也叫`二叉搜索树、二叉查找树`,他相较于普通二叉树的性质如下: 4 | 5 | ### 二叉排序树的性质 6 | 7 | 二叉排序树的任意左子树的值都小于他的根节点的值,任意右子树的值都大于他的根节点的值,二叉排序树的中序遍历结果是一个单调递增的有序序列。 8 | 9 | ### 二叉排序树性质的维护 10 | 11 | #### 插入新节点 12 | 13 | ![00001](https://ydschool-video.nosdn.127.net/162902470920300001.jpg) 14 | 15 | 从上面的操作过程我们可以看出`二叉排序树`的插入具体过程,总结一下: 16 | 17 | > 将待插入节点值与根节点比较,如果比根节点小,则递归在左子树中查找,否则递归在右子树中查找,直到找到一个节点比当前值小且不存在右子树时插入到其右子树当中,或者找到一个节点必当前值大且不存在左子树时插入到其左子树当中。 18 | 19 | #### 删除节点 20 | 21 | ##### 删除叶子节点(出度为0的节点) 22 | 23 | 删除叶子节点比较简单,就像一个无儿无女,无牵无挂的孤寡老人,即使家产再丰厚,如果哪天撒手人寰了,也就一了百了了,根本不用担心自己的子女是否会因争夺家产而兄弟相残,这里也一样,直接删除叶子节点即可。 24 | 25 | ##### 删除出度为1的节点 26 | 27 | 度为1的节点,就相当于有一个独生子女的富翁,假如哪天要不行了,只要自己爸爸还在,还可以把自己的子女交给爸爸照顾。此时,只需要将他唯一的子节点挂在他自己的父节点上,然后自己就可以安心驾鹤西去了。由于二叉排序树的性质,左子树的节点都会小于根节点,如果删除的节点在左子树上,那我们直接把它的子节点重新放到他的父节点的左子树即可。如果删除的节点再右子树上,则直接放到他的父节点的右子树即可。 28 | 29 | ##### 删除出度为2的节点 30 | 31 | 要删除度为2的节点,我们首先要搞清楚以下几个概念。 32 | 33 | ###### 前驱节点 34 | 35 | 在一个二叉排序树中,根节点的前驱节点就是他左子树的最大值 36 | 37 | ###### 后继节点 38 | 39 | 在一个二叉排序树中,根节点的后继节点就是他右子树的最小值 40 | 41 | 那么,我们要如何找到一个二叉排序树根节点的前驱节点和后继节点呢?我们来思考一个问题:既然前驱节点是左子树中的最大值,而因为二叉排序树的性质,所有的右子树都会大于根节点,那么,我们要找到左子树的最大值,是不是就可以在左子树中一直查找右子树,直到某个节点不存在右子树为止,那么这个节点就是二叉排序树根节点的前驱节点了。后继节点也是相同道理,在右子树中,一直往左子树查找,直到找到一个节点没有左子树为止,这个节点就是二叉排序树的后继节点了。 42 | 43 | 找到了前驱节点和后继节点之后,我们要删除度为2的节点就简单了,用前驱节点或后继节点替换根节点,然后删除原先的前驱或者后继节点就变成了删除度为1或度为0的节点的问题了。 44 | 45 | 总结一下: 46 | 47 | > 删除度为2的节点首先要找到一个节点的前驱节点或者后继节点,然后用前驱节点或后继节点替换这个节点,接下来问题就转变成了删除原先前驱或后继节点位置上的节点(这个接待的度只能为0或1,不可能为2,因为如果度为2是不可能是前驱节点和后继节点的),即转变成删除一个度为1或度为0的节点的问题。 48 | 49 | ###### 关于复杂问题简单化的思考 50 | 51 | 在树形结构中,类似这种将复杂度极高的度为2的删除问题转换成复杂度较低的度为1或0的问题极为常见。我们需要有一种将复杂问题简单化的能力,尽可能的将一个复杂问题想办法拆借或转换成若干简单问题来求解。 52 | 53 | ### 二叉搜索树的代码实现 54 | 55 | ```typescript 56 | 57 | type TreeNode = { 58 | key: number, 59 | data?: T, 60 | left: TreeNode, 61 | right: TreeNode 62 | }; 63 | 64 | const nil: TreeNode = { 65 | key: -1, 66 | data: null, 67 | left: null, 68 | right: null 69 | }; 70 | 71 | class Tree { 72 | public root: TreeNode = nil; 73 | constructor(key?: number, data?: T){ 74 | if(key!==undefined) { 75 | this.root = this.insert(this.root, key, data); 76 | } 77 | } 78 | // 根据数据创建二叉树节点 79 | public createNewNode(key: number, data?: T): TreeNode { 80 | return { 81 | key, 82 | data, 83 | left: nil, 84 | right: nil 85 | }; 86 | } 87 | // 删除以root为根节点的二叉树的所有节点 88 | public clear(root: TreeNode): void { 89 | if(root===nil) return; 90 | this.clear(root.left); 91 | this.clear(root.right) 92 | console.log(`删除节点:${root.key}`); 93 | root = nil; 94 | } 95 | // 中序遍历打印输出 96 | output(root: TreeNode): void { 97 | if(root === nil) return; 98 | this.output(root.left); 99 | console.log(`${root.key}: left<${root.left.key}>; right<${root.right.key}>;`); 100 | this.output(root.right); 101 | } 102 | // 查找并返回一颗以root为根节点的二叉搜索树的根节点root的前驱节点 103 | getPreeccessor(root: TreeNode): TreeNode { 104 | let tmp = root.left; 105 | while(tmp.right !== nil) tmp = tmp.right; 106 | return tmp; 107 | } 108 | // 插入节点 109 | public insert(root: TreeNode, key: number, data?: T): TreeNode { 110 | // 如果root节点不存在,则创建一个新的树节点,有了这个逻辑,我们后面可以无需关系左右子树是否存在就可以 111 | // 肆无忌惮的直接递归插入,因为如果不存在会创建新的节点并插入 112 | if(root === nil) return (this.root = this.createNewNode(key, data)); 113 | // 如果当前节点的值等于key,则直接返回当前节点 114 | if(root.key === key) return root; 115 | // 如果root不存在右子树时,依然进入此分支,是因为上面我们判断了,如果root不存在则创建一个新的节点,因此,我们使用root.right去接收这个节点 116 | // 假如root.right存在,按照我们的逻辑,最终会返回一个插入了目标节点的根节点,也就是之前的right节点,并没有变化 117 | if(root.key < key) root.right = this.insert(root.right, key, data); 118 | else root.left = this.insert(root.left, key, data); 119 | // 更新当前二叉树的根节点 120 | this.root = root; 121 | return root; 122 | } 123 | // 从二叉搜索树中删除某个节点 124 | public erase(root: TreeNode, key: number): TreeNode { 125 | // 如果节点不存在则直接返回nil 126 | if(root === nil) return (this.root = root); 127 | // 如果根节点的key比待删除的key小,说明待删除的节点在右子树,因此去右子树中删除,同上面一样,我们用root.right去接收删除key节点后的右子树 128 | if(root.key < key) root.right = this.erase(root.right, key); 129 | else if(root.key > key) root.left = this.erase(root.left, key); 130 | else { 131 | // 处理度为0或度为1的节点删除逻辑,当度为0时,因为没有左右子树,所以tmp为nil,并返回,上面会接收返回的节点挂在相应子树上 132 | // 如果度为1时,则会返回不为nil的子树 133 | if(root.left === nil || root.right === nil) { 134 | const tmp = root.left === nil ? root.right : root.left; 135 | root = nil; 136 | return tmp; 137 | } else { 138 | // 删除度为2的节点 139 | // 先找到当前节点的前驱节点 140 | const tmp = this.getPreeccessor(root); 141 | // 用前驱节点覆盖当前节点 142 | root.key = tmp.key; 143 | root.data = tmp.data; 144 | // 然后再去左子树中删除前驱节点即可,这样就转换了了删除度为0或1的节点的问题了 145 | root.left = this.erase(root.left, tmp.key); 146 | } 147 | } 148 | // 更新当前二叉搜索树的根节点 149 | this.root = root; 150 | return root; 151 | } 152 | } 153 | 154 | const arr = [3,2,1,5,4,7,9,8,6]; 155 | const avlTree = new Tree(); 156 | 157 | arr.forEach(item => { 158 | console.log(`\n============ 插入元素[${item}]开始 ==============\n`); 159 | avlTree.insert(avlTree.root, item); 160 | avlTree.output(avlTree.root); 161 | console.log(`\n============ 插入元素[${item}]结束 ==============\n`); 162 | }); 163 | 164 | const delArr = [5,3,2]; 165 | delArr.forEach(item => { 166 | console.log(`\n============ 删除元素[${item}]开始 ==============\n`); 167 | avlTree.erase(avlTree.root, item); 168 | avlTree.output(avlTree.root); 169 | console.log(`\n============ 删除元素[${item}]结束 ==============\n`); 170 | }); 171 | ``` 172 | 173 | ## AVL树基础知识 174 | 175 | ### 为什么要有AVL树 176 | 177 | 之前学习过普通二叉树、完全二叉树等树形结构,大家应该都很清楚,一颗二叉树最怕的是什么,最害怕的就是“退化”。本来霸气凌然的“裂空座”竟然退化成了“绿毛虫”,这谁能忍?例如按照以下顺序将每个节点插入到二叉排序树中: 178 | 179 | ![00002](https://ydschool-video.nosdn.127.net/162902473518900002.jpg) 180 | 181 | 如果按照上面的顺序插入的话,我们的二叉排序树就变成了一个链表了。我们都知道,一个二叉树,我们可以使用二分思想,使其搜索复杂度达到`logn`,但一旦二叉树退化成了链表,他的搜索复杂度直接就变成`O(n)`了。 182 | 183 | 而我们今天要学习的平衡二叉排序树,即`AVL树`就是为了防止二叉树退化而诞生的。 184 | 185 | ### 概念 186 | 187 | AVL树(平衡二叉排序树),在二叉排序树的基础上,对左右子树的高度做了一定的限制,设左子树的树高为`HL`,右子树的树高为`HR`,那么,一颗平衡二叉排序树必定满足:`|HL - HR|<=1`。即左右子树的高度差不超过1。 188 | 189 | 这样,由于对每一个节点的左右子树都做了限制,所以整棵树不会退化成链表。 190 | 191 | ### 平衡化旋转 192 | 193 | AVL树为了确保他的平衡性,即左右子树高度差不超过1,采用了`左旋`和`右旋`的方式进行调整,`左旋`和`右旋`是一个对称的操作,类似于`加法`和`减法`,一颗二叉排序树A通过左旋n次得到二叉排序树B,那么二叉排序树B必然可以通过右旋n次重新变回二叉排序树A。 194 | 195 | #### AVL树的左旋 196 | 197 | ![图片来自于网络](https://ydschool-video.nosdn.127.net/162855203331420180722220546910.gif) 198 | 199 | 如上示意图,假如我们捏住E点,将整颗二叉树往左边甩,那么,此时,原本在E点右子树的S点就变成了E的父级,而E则变成了S的左子树根节点,因为我们这棵树要保持二叉树的性质,只能有两个出度,原先S就有两个子节点,此时,我们可以将S原先的左子树当做E的右子树,这样就不再违反二叉树出度最大为2的性质了,并且左旋之后,我们依然维护了二叉排序树的性质。 200 | 201 | #### AVL树的右旋 202 | 203 | ![图片来源于网络](https://ydschool-video.nosdn.127.net/1628552213744%E5%8F%B3%E6%97%8B.gif) 204 | 205 | 右旋是左旋的对称操作,假如我们捏住S点,将整颗二叉树往右边甩,此时,E成了S的父节点,而S成了E的右子树根节点,因为我们这棵树要保持二叉树的性质,只能有两个出度,原先E就有两个子节点,此时,我们可以将E原先的右子树当做S的左子树,这样就不再违反二叉树出度最大为2的性质了,并且右旋之后,我们依然维护了二叉排序树的性质。 206 | 207 | #### AVL树的几种失衡类型 208 | 209 | 在我们对AVL树进行插入或删除操作时,就有可能使得AVL树失衡,即插入新节点或删除节点后,导致左右子树的高度差超过1,主要包括以下几种失衡类型: 210 | 211 | ##### LL型 212 | 213 | ![00003](https://ydschool-video.nosdn.127.net/162902475199700003.jpg) 214 | 215 | **失衡调整方法:由于左子树更高,所以,我们抓着0节点进行一次右旋,这样就可以让当前失衡的树重归平衡** 216 | 217 | ![Xnip2021-08-14_14-21-50](https://ydschool-video.nosdn.127.net/1628922350176Xnip2021-08-14_14-21-50.jpg) 218 | 219 | **证明推导上述结论:** 220 | 221 | ![00004](https://ydschool-video.nosdn.127.net/162902477123500004.jpg) 222 | 223 | 224 | 225 | ##### RR型 226 | 227 | 与LL型相对的,RR型代表站在0点上失衡了,他的右子树更高,且他的右子树的右子树要比右子树的左子树更高。 228 | 229 | **失衡调整方法:由于右子树更高,所以,我们抓着0节点进行一次左旋,这样就可以让当前失衡的树重归平衡** 230 | 231 | ##### LR型 232 | 233 | ![00005](https://ydschool-video.nosdn.127.net/162902477898000005.jpg) 234 | 235 | 236 | 237 | 左子树的右子树更高 238 | 239 | **失衡调整方法:由于是左子树的右子树更高,那么我们可以先抓着左子树根节点1进行一个左旋,将LR类型的失衡先转换成LL类型的失衡,然后再抓第一个开始失衡的节点0,进行一次右旋即可** 240 | 241 | **证明推导上述结论:** 242 | 243 | ![00006](https://ydschool-video.nosdn.127.net/162902480540200006.jpg) 244 | 245 | 246 | 247 | ##### RL型 248 | 249 | 右子树的左子树更高 250 | 251 | **失衡调整方法:由于是右子树的左子树更高,那么我们可以先抓着右子树子树根节点2进行一个右旋,将RL类型的失衡先转换成RR类型的失衡,然后再抓第一个开始失衡的节点0,进行一次左旋即可** 252 | 253 | #### AVL树的平衡调整发生在什么时候 254 | 255 | 由于我们AVL树的插入操作时一个递归的过程,因此,我们需要在递归的回溯过程中,检测AVL树是否失衡,一旦失衡则立即通过左旋或右旋(具体使用左旋还是右旋需要根据具体情况而定)进行平衡调整操作。因此,AVL树的平衡调整发生在递归回溯阶段。 256 | 257 | #### 插入调整思维训练 258 | 259 | ![00007](https://ydschool-video.nosdn.127.net/162902483440900007.jpg) 260 | 261 | ![00008](https://ydschool-video.nosdn.127.net/162902483481600008.jpg) 262 | 263 | ![00009](https://ydschool-video.nosdn.127.net/162902483371700009.jpg) 264 | 265 | ![00010](https://ydschool-video.nosdn.127.net/162902483158000010.jpg) 266 | 267 | ![00011](/Users/tangwenhui/Downloads/AVL树/00011.jpg) 268 | 269 | ### AVL树代码实现 270 | 271 | ```typescript 272 | 273 | type AVLTreeNode = { 274 | key: number, 275 | data?: T, 276 | h: number, 277 | left: AVLTreeNode, 278 | right: AVLTreeNode 279 | }; 280 | 281 | /** 282 | * 由于AVL树的平衡调整是一个动态的过程,涉及到大量的空节点判断,为了使逻辑更加清晰精简,音符虚拟空节点概念 283 | * 用nil替代所有的null 284 | */ 285 | const nil: AVLTreeNode = { 286 | key: -1, 287 | data: null, 288 | h: 0, 289 | left: null, 290 | right: null 291 | }; 292 | 293 | class AVLTree { 294 | public root: AVLTreeNode; 295 | constructor(key: number, data?: T){ 296 | this.root = this.createNewNode(key, data); 297 | } 298 | public createNewNode(key: number, data?: T): AVLTreeNode { 299 | return { 300 | key, 301 | data, 302 | h: 1, 303 | left: nil, 304 | right: nil 305 | }; 306 | } 307 | public clear(root: AVLTreeNode): void { 308 | if(root===nil) return; 309 | this.clear(root.left); 310 | this.clear(root.right) 311 | console.log(`删除节点:${root.key}`); 312 | root = nil; 313 | } 314 | public reCalcHeight(root: AVLTreeNode): void { 315 | root.h = Math.max(root.left.h, root.right.h) + 1; 316 | } 317 | 318 | output(root: AVLTreeNode): void { 319 | if(root === nil) return; 320 | console.log(`${root.key}[${root.h}]: left<${root.left.key}>; right<${root.right.key}>;`); 321 | this.output(root.left); 322 | this.output(root.right); 323 | } 324 | // 查找并返回一颗以root为根节点的二叉搜索树的根节点root的前驱节点 325 | getPreeccessor(root: AVLTreeNode): AVLTreeNode { 326 | let tmp = root.left; 327 | while(tmp.right !== nil) tmp = tmp.right; 328 | return tmp; 329 | } 330 | public insert(root: AVLTreeNode, key: number, data?: T): AVLTreeNode { 331 | if(root === nil) return this.createNewNode(key, data); 332 | if(root.key === key) return root; 333 | if(root.key < key) root.right = this.insert(root.right, key, data); 334 | else root.left = this.insert(root.left, key, data); 335 | // 重新计算当前节点的高度 336 | this.reCalcHeight(root); 337 | return this.maintain(root); 338 | } 339 | public erase(root: AVLTreeNode, key: number): AVLTreeNode { 340 | if(root === nil) return root; 341 | if(root.key < key) root.right = this.erase(root.right, key); 342 | else if(root.key > key) root.left = this.erase(root.left, key); 343 | else { 344 | if(root.left === nil || root.right === nil) { 345 | const tmp = root.left === nil ? root.right : root.left; 346 | root = nil; 347 | return tmp; 348 | } else { 349 | const tmp = this.getPreeccessor(root); 350 | root.key = tmp.key; 351 | root.left = this.erase(root.left, tmp.key); 352 | } 353 | } 354 | this.reCalcHeight(root); 355 | return this.maintain(root); 356 | } 357 | // 左旋操作 358 | public leftRotate(root: AVLTreeNode): AVLTreeNode { 359 | const newRoot = root.right; 360 | root.right = newRoot.left; 361 | newRoot.left = root; 362 | this.reCalcHeight(root); 363 | this.reCalcHeight(newRoot); 364 | return newRoot; 365 | } 366 | // 右旋操作 367 | public rightRotate(root: AVLTreeNode): AVLTreeNode { 368 | const newRoot = root.left; 369 | root.left = newRoot.right; 370 | newRoot.right = root; 371 | this.reCalcHeight(root); 372 | this.reCalcHeight(newRoot); 373 | return newRoot; 374 | } 375 | // 平衡调整 376 | public maintain(root: AVLTreeNode): AVLTreeNode { 377 | // 如果左右子树高度差小于2则无需调整 378 | if(Math.abs(root.left.h - root.right.h) < 2) return (this.root = root); 379 | const tmp = root.key; 380 | // 判断失衡类型,首先判断是LX型还是RX型 381 | if(root.left.h > root.right.h) { // 说明是LX型 382 | let flag = false; 383 | // 判断是LL型还是LR型 384 | if(root.left.right.h > root.left.left.h) {// LR型 385 | console.log(`LR型失衡, 拽着${root.left.key}节点左旋`); 386 | root.left = this.leftRotate(root.left); 387 | flag = true; 388 | } 389 | !flag && console.log(`LL型失衡,拽着${root.key}节点右旋`); 390 | root = this.rightRotate(root); 391 | } else {// RX型 392 | let flag = false; 393 | if(root.right.left.h > root.right.right.h) {// RL型 394 | console.log(`RL型失衡,拽着${root.right.key}节点右旋`); 395 | root.right = this.rightRotate(root.right); 396 | flag = true 397 | } 398 | !flag && console.log(`RR型失衡,拽着${root.key}节点左旋`); 399 | root = this.leftRotate(root); 400 | } 401 | // 旋转有可能会导致根节点改变,因此需要更新根节点 402 | return (this.root = root); 403 | } 404 | } 405 | 406 | const arr = [2,1,5,4,7,9,8,6]; 407 | const avlTree = new AVLTree(3); 408 | 409 | arr.forEach(item => { 410 | console.log("\n============ 开始 ==============\n"); 411 | avlTree.insert(avlTree.root, item); 412 | avlTree.output(avlTree.root); 413 | console.log("\n============ 结束 ==============\n"); 414 | }); 415 | 416 | avlTree.erase(avlTree.root, 5); 417 | avlTree.erase(avlTree.root, 3); 418 | avlTree.erase(avlTree.root, 2); 419 | avlTree.output(avlTree.root); 420 | ``` 421 | 422 | --------------------------------------------------------------------------------