labuladong的算法小抄官方完整版

  • 92 浏览

imikay

2020/10/28 发布于 技术 分类

算法 

文字内容
1. Table of Contents 开篇词 1.1 第零章、必读系列 1.2 学习算法和刷题的框架思维 1.2.1 动态规划解题套路框架 1.2.2 回溯算法解题套路框架 1.2.3 BFS 算法解题套路框架 1.2.4 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 1.2.5 我写了⾸诗,把滑动窗⼝算法算法变成了默写题 1.2.6 ⼀个⽅法团灭 LeetCode 股票买卖问题 1.2.7 ⼀个⽅法团灭 LeetCode 打家劫舍问题 1.2.8 ⼀个⽅法团灭 nSum 问题 1.2.9 经典动态规划:⾼楼扔鸡蛋 1.2.10 经典动态规划:⼦集背包问题 1.2.11 经典动态规划:完全背包问题 1.2.12 表达式求值算法:实现计算器 1.2.13 第⼀章、动态规划系列 1.3 动态规划解题套路框架 1.3.1 动态规划答疑篇 1.3.2 动态规划和回溯算法到底谁是谁爹? 1.3.3 动态规划设计:最⻓递增⼦序列 1.3.4 动态规划设计:最⼤⼦数组 1.3.5 经典动态规划:0-1 背包问题 1.3.6 经典动态规划:⼦集背包问题 1.3.7 经典动态规划:完全背包问题 1.3.8 1
2. 经典动态规划:编辑距离 经典动态规划:⾼楼扔鸡蛋 1.3.9 1.3.10 经典动态规划:⾼楼扔鸡蛋(进阶) 1.3.11 经典动态规划:戳⽓球 1.3.12 经典动态规划:最⻓公共⼦序列 1.3.13 动态规划之⼦序列问题解题模板 1.3.14 动态规划之博弈问题 1.3.15 动态规划之正则表达 1.3.16 动态规划之四键键盘 1.3.17 动态规划之KMP字符匹配算法 1.3.18 贪⼼算法之区间调度问题 1.3.19 团灭 LeetCode 股票买卖问题 1.3.20 团灭 LeetCode 打家劫舍问题 1.3.21 第⼆章、数据结构系列 2.1 学习数据结构和算法读什么书 2.1.1 算法学习之路 2.1.2 ⼆叉堆详解实现优先级队列 2.1.3 LRU算法详解 2.1.4 ⼆叉搜索树操作集锦 2.1.5 如何计算完全⼆叉树的节点数 2.1.6 特殊数据结构:单调栈 2.1.7 特殊数据结构:单调队列 2.1.8 设计Twitter 2.1.9 递归反转链表的⼀部分 2.1.10 队列实现栈 栈实现队列 2.1.11 第三章、算法思维系列 学习算法和刷题的思路指南 3.1 3.1.1 2
3. 回溯算法解题套路框架 回溯算法团灭⼦集、排列、组合问题 3.1.3 3.1.2 回溯算法最佳实践:解数独 3.1.4 回溯算法最佳实践:括号⽣成 3.1.5 ⼆分查找详解 3.1.6 双指针技巧总结 3.1.7 滑动窗⼝技巧 3.1.8 twoSum问题的核⼼思想 3.1.9 常⽤的位操作 3.1.10 拆解复杂问题:实现计算器 3.1.11 烧饼排序 3.1.12 前缀和技巧 3.1.13 字符串乘法 3.1.14 FloodFill算法详解及应⽤ 3.1.15 区间调度之区间合并问题 3.1.16 区间调度之区间交集问题 3.1.17 信封嵌套问题 3.1.18 ⼏个反直觉的概率问题 3.1.19 第四章、⾼频⾯试系列 4.1 如何实现LRU算法 4.1.1 如何⽤ BFS 算法秒杀各种智⼒题 4.1.2 如何⾼效寻找素数 4.1.3 如何⾼效进⾏模幂运算 4.1.4 如何计算编辑距离 4.1.5 如何运⽤⼆分查找算法 4.1.6 如何⾼效解决接⾬⽔问题 4.1.7 如何去除有序数组的重复元素 4.1.8 3
4. 如何寻找最⻓回⽂⼦串 如何运⽤贪⼼思想玩跳跃游戏 4.1.10 4.1.9 如何k个⼀组反转链表 4.1.11 如何判定括号合法性 4.1.12 如何寻找缺失的元素 4.1.13 如何同时寻找缺失和重复的元素 4.1.14 如何判断回⽂链表 4.1.15 如何在⽆限序列中随机抽取元素 4.1.16 如何调度考⽣的座位 4.1.17 Union-Find算法详解 4.1.18 Union-Find算法应⽤ 4.1.19 ⼀⾏代码就能解决的算法题 4.1.20 ⼆分查找⾼效判定⼦序列 4.1.21 第五章、技术⽂章系列 4.2 Linux的进程、线程、⽂件描述符是什么 4.2.1 关于 Linux shell 你必须知道的 4.2.2 Linux shell 的实⽤⼩技巧 4.2.3 ⼀⽂看懂 session 和 cookie 4.2.4 加密算法的前⾝今世 4.2.5 我⽤四个命令概括了 Git 的所有套路 4.2.6 Git/SQL/正则表达式的在线练习平台 4.2.7 4
5. 开篇词 labuladong 的算法⼩抄 5
6. 开篇词 本书⽬前可以⼿把⼿带你解决 110 道 LeetCode 算法问题,⽽且在不断更 新,全部基于 LeetCode 的题⽬,涵盖了所有题型和技巧。本书的 在线版本 在每篇⽂章的开头加上了该⽂章可以解决的 LeetCode 题⽬链接,可以看完 ⽂章⽴即去拿下对应题⽬。 本套教程即将出版,公众号后台回复关键词「电⼦书」,限时免费下载这份 算法⼩抄的 PDF 版本。 不多说了,刷算法,学套路,认准 labuladong 就够了,从现在开始,带你 ⼀周之内⽇穿 LeetCode。 ⽬前已本站包含的 114 道题⽬教程如下: 1.两数之和 10.正则表达式匹配 100.相同的树 1011.在D天内送达包裹的能⼒ 111.⼆叉树的最⼩深度 1118.⼀⽉有多少天 1143.最⻓公共⼦序列 130.被围绕的区域 141.环形链表II 141.环形链表 146.LRU缓存机制 167.两数之和 II - 输⼊有序数组 170.两数之和 III - 数据结构设计 198.打家劫舍 6
7. 开篇词 20.有效的括号 204.计数质数 213.打家劫舍II 22.括号⽣成 222.完全⼆叉树的节点个数 224.基本计算器 225.⽤队列实现栈 227.基本计算器II 232.⽤栈实现队列 234.回⽂链表 236.⼆叉树的最近公共祖先 239.滑动窗⼝最⼤值 25.K个⼀组翻转链表 26.删除排序数组中的重复项 28.实现 strStr() 292.Nim游戏 3.⽆重复字符的最⻓⼦串 300.最⻓上升⼦序列 312.戳⽓球 319.灯泡开关 322.零钱兑换 7
8. 开篇词 337.打家劫舍III 34.在排序数组中查找元素的第⼀个和最后⼀个位置 354.俄罗斯套娃信封问题 355.设计推特 37.解数独 372.超级次⽅ 382.链表随机节点 384.打乱数组 392.判断⼦序列 398.随机数索引 416.分割等和⼦集 42.接⾬⽔ 43.字符串相乘 435. ⽆重叠区间 438.找到字符串中所有字⺟异位词 448.找到所有数组中消失的数字 45.跳跃游戏 450.删除⼆叉搜索树中的节点 452.⽤最少数量的箭引爆⽓球 46.全排列 46.全排列 8
9. 开篇词 496.下⼀个更⼤元素I 5.最⻓回⽂⼦串 503.下⼀个更⼤元素II 509.斐波那契数 51.N皇后 516.最⻓回⽂⼦序列 518.零钱兑换II 53.最⼤⼦序和 55.跳跃游戏 56.合并区间 560.和为K的⼦数组 567.字符串的排列 645.错误的集合 651.四键键盘 700.⼆叉搜索树中的搜索 701.⼆叉搜索树中的插⼊操作 704.⼆分查找 72.编辑距离 733.图像渲染 752.打开转盘锁 76.最⼩覆盖⼦串 9
10. 开篇词 77.组合 772.基本计算器III 773.滑动谜题 78.⼦集 83.删除排序链表中的重复元素 855.考场就座 875.爱吃⾹蕉的珂珂 877.⽯⼦游戏 877.⽯⼦游戏 887.鸡蛋掉落 887.鸡蛋掉落 92.反转链表II 969.煎饼排序 98.验证⼆叉搜索树 986.区间列表的交集 990.等式⽅程的可满⾜性 买卖股票的最佳时机 III 买卖股票的最佳时机 II 买卖股票的最佳时机 IV 买卖股票的最佳时机 最佳买卖股票时机含冷冻期 10
11. 开篇词 买卖股票的最佳时机含⼿续费 11
12. 学习算法和刷题的框架思维 学习算法和刷题的思路指南 学算法,认准 labuladong 就够了! 这是好久之前的⼀篇⽂章「学习数据结构和算法的框架思维」的修订版。之 前那篇⽂章收到⼴泛好评,没看过也没关系,这篇⽂章会涵盖之前的所有内 容,并且会举很多代码的实例,教你如何使⽤框架思维。 ⾸先,这⾥讲的都是普通的数据结构,咱不是搞算法竞赛的,野路⼦出⽣, 我只会解决常规的问题。另外,以下是我个⼈的经验的总结,没有哪本算法 书会写这些东⻄,所以请读者试着理解我的⾓度,别纠结于细节问题,因为 这篇⽂章就是希望对数据结构和算法建⽴⼀个框架性的认识。 从整体到细节,⾃顶向下,从抽象到具体的框架思维是通⽤的,不只是学习 数据结构和算法,学习其他任何知识都是⾼效的。 ⼀、数据结构的存储⽅式 数据结构的存储⽅式只有两种:数组(顺序存储)和链表(链式存储)。 这句话怎么理解,不是还有散列表、栈、队列、堆、树、图等等各种数据结 构吗? 我们分析问题,⼀定要有递归的思想,⾃顶向下,从抽象到具体。你上来就 列出这么多,那些都属于「上层建筑」,⽽数组和链表才是「结构基础」。 因为那些多样化的数据结构,究其源头,都是在链表或者数组上的特殊操 作,API 不同⽽已。 ⽐如说「队列」、「栈」这两种数据结构既可以使⽤链表也可以使⽤数组实 现。⽤数组实现,就要处理扩容缩容的问题;⽤链表实现,没有这个问题, 但需要更多的内存空间存储节点指针。 12
13. 学习算法和刷题的框架思维 「图」的两种表⽰⽅法,邻接表就是链表,邻接矩阵就是⼆维数组。邻接矩 阵判断连通性迅速,并可以进⾏矩阵运算解决⼀些问题,但是如果图⽐较稀 疏的话很耗费空间。邻接表⽐较节省空间,但是很多操作的效率上肯定⽐不 过邻接矩阵。 「散列表」就是通过散列函数把键映射到⼀个⼤数组⾥。⽽且对于解决散列 冲突的⽅法,拉链法需要链表特性,操作简单,但需要额外的空间存储指 针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间, 但操作稍微复杂些。 「树」,⽤数组实现就是「堆」,因为「堆」是⼀个完全⼆叉树,⽤数组存 储不需要节点指针,操作也⽐较简单;⽤链表实现就是很常⻅的那种 「树」,因为不⼀定是完全⼆叉树,所以不适合⽤数组存储。为此,在这种 链表「树」结构之上,⼜衍⽣出各种巧妙的设计,⽐如⼆叉搜索树、AVL 树、红⿊树、区间树、B 树等等,以应对不同的问题。 了解 Redis 数据库的朋友可能也知道,Redis 提供列表、字符串、集合等等 ⼏种常⽤数据结构,但是对于每种数据结构,底层的存储⽅式都⾄少有两 种,以便于根据存储数据的实际情况使⽤合适的存储⽅式。 综上,数据结构种类很多,甚⾄你也可以发明⾃⼰的数据结构,但是底层存 储⽆⾮数组或者链表,⼆者的优缺点如下: 数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,⽽ 且相对节约存储空间。但正因为连续存储,内存空间必须⼀次性分配够,所 以说数组如果要扩容,需要重新分配⼀块更⼤的空间,再把数据全部复制过 去,时间复杂度 O(N);⽽且你如果想在数组中间进⾏插⼊和删除,每次必 须搬移后⾯的所有数据以保持连续,时间复杂度 O(N)。 链表因为元素不连续,⽽是靠指针指向下⼀个元素的位置,所以不存在数组 的扩容问题;如果知道某⼀元素的前驱和后驱,操作指针即可删除该元素或 者插⼊新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你⽆法根 据⼀个索引算出对应元素的地址,所以不能随机访问;⽽且由于每个元素必 须存储指向前后元素位置的指针,会消耗相对更多的储存空间。 13
14. 学习算法和刷题的框架思维 ⼆、数据结构的基本操作 对于任何数据结构,其基本操作⽆⾮遍历 + 访问,再具体⼀点就是:增删 查改。 数据结构种类很多,但它们存在的⽬的都是在不同的应⽤场景,尽可能⾼效 地增删查改。话说这不就是数据结构的使命么? 如何遍历 + 访问?我们仍然从最⾼层来看,各种数据结构的遍历 + 访问⽆ ⾮两种形式:线性的和⾮线性的。 线性就是 for/while 迭代为代表,⾮线性就是递归为代表。再具体⼀步,⽆ ⾮以下⼏种框架: 数组遍历框架,典型的线性迭代结构: void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代访问 arr[i] } } 链表遍历框架,兼具迭代和递归结构: /* 基本的单链表节点 */ class ListNode { int val; ListNode next; } void traverse(ListNode head) { for (ListNode p = head; p != null; p = p.next) { // 迭代访问 p.val } } void traverse(ListNode head) { // 递归访问 head.val traverse(head.next) 14
15. 学习算法和刷题的框架思维 } ⼆叉树遍历框架,典型的⾮线性递归遍历结构: /* 基本的⼆叉树节点 */ class TreeNode { int val; TreeNode left, right; } void traverse(TreeNode root) { traverse(root.left) traverse(root.right) } 你看⼆叉树的递归遍历⽅式和链表的递归遍历⽅式,相似不?再看看⼆叉树 结构和单链表结构,相似不?如果再多⼏条叉,N 叉树你会不会遍历? ⼆叉树框架可以扩展为 N 叉树的遍历框架: /* 基本的 N 叉树节点 */ class TreeNode { int val; TreeNode[] children; } void traverse(TreeNode root) { for (TreeNode child : root.children) traverse(child) } N 叉树的遍历⼜可以扩展为图的遍历,因为图就是好⼏ N 叉棵树的结合 体。你说图是可能出现环的?这个很好办,⽤个布尔数组 visited 做标记就 ⾏了,这⾥就不写代码了。 15
16. 学习算法和刷题的框架思维 所谓框架,就是套路。不管增删查改,这些代码都是永远⽆法脱离的结构, 你可以把这个结构作为⼤纲,根据具体问题在框架上添加代码就⾏了,下⾯ 会具体举例。 三、算法刷题指南 ⾸先要明确的是,数据结构是⼯具,算法是通过合适的⼯具解决特定问题的 ⽅法。也就是说,学习算法之前,最起码得了解那些常⽤的数据结构,了解 它们的特性和缺陷。 那么该如何在 LeetCode 刷题呢?之前的⽂章算法学习之路写过⼀些,什么 按标签刷,坚持下去云云。现在距那篇⽂章已经过去将近⼀年了,我不说那 些不痛不痒的话,直接说具体的建议: 先刷⼆叉树,先刷⼆叉树,先刷⼆叉树! 这是我这刷题⼀年的亲⾝体会,下图是去年⼗⽉份的提交截图: 公众号⽂章的阅读数据显⽰,⼤部分⼈对数据结构相关的算法⽂章不感兴 趣,⽽是更关⼼动规回溯分治等等技巧。为什么要先刷⼆叉树呢,因为⼆叉 树是最容易培养框架思维的,⽽且⼤部分算法技巧,本质上都是树的遍历问 16
17. 学习算法和刷题的框架思维 题。 刷⼆叉树看到题⽬没思路?根据很多读者的问题,其实⼤家不是没思路,只 是没有理解我们说的「框架」是什么。不要⼩看这⼏⾏破代码,⼏乎所有⼆ 叉树的题⽬都是⼀套这个框架就出来了。 void traverse(TreeNode root) { // 前序遍历 traverse(root.left) // 中序遍历 traverse(root.right) // 后序遍历 } ⽐如说我随便拿⼏道题的解法出来,不⽤管具体的代码逻辑,只要看看框架 在其中是如何发挥作⽤的就⾏。 LeetCode 124 题,难度 Hard,让你求⼆叉树中最⼤路径和,主要代码如 下: int ans = INT_MIN; int oneSideMax(TreeNode* root) { if (root == nullptr) return 0; int left = max(0, oneSideMax(root->left)); int right = max(0, oneSideMax(root->right)); ans = max(ans, left + right + root->val); return max(left, right) + root->val; } 你看,这就是个后序遍历嘛。 LeetCode 105 题,难度 Medium,让你根据前序遍历和中序遍历的结果还原 ⼀棵⼆叉树,很经典的问题吧,主要代码如下: TreeNode buildTree(int[] preorder, int preStart, int preEnd, int[] inorder, int inStart, int inEnd, Map inMa p) { 17
18. 学习算法和刷题的框架思维 if(preStart > preEnd inStart > inEnd) return null; TreeNode root = new TreeNode(preorder[preStart]); int inRoot = inMap.get(root.val); int numsLeft = inRoot - inStart; root.left = buildTree(preorder, preStart + 1, preStart + numsLeft , inorder, inStart, inRoot - 1, inMap); root.right = buildTree(preorder, preStart + numsLeft + 1, preEnd, inorder, inRoot + 1, inEnd, inMap); return root; } 不要看这个函数的参数很多,只是为了控制数组索引⽽已,本质上该算法也 就是⼀个前序遍历。 LeetCode 99 题,难度 Hard,恢复⼀棵 BST,主要代码如下: void traverse(TreeNode* node) { if (!node) return; traverse(node->left); if (node->val < prev->val) { s = (s == NULL) ? prev : s; t = node; } prev = node; traverse(node->right); } 这不就是个中序遍历嘛,对于⼀棵 BST 中序遍历意味着什么,应该不需要 解释了吧。 你看,Hard 难度的题⽬不过如此,⽽且还这么有规律可循,只要把框架写 出来,然后往相应的位置加东⻄就⾏了,这不就是思路吗。 18
19. 学习算法和刷题的框架思维 对于⼀个理解⼆叉树的⼈来说,刷⼀道⼆叉树的题⽬花不了多⻓时间。那么 如果你对刷题⽆从下⼿或者有畏惧⼼理,不妨从⼆叉树下⼿,前 10 道也许 有点难受;结合框架再做 20 道,也许你就有点⾃⼰的理解了;刷完整个专 题,再去做什么回溯动规分治专题,你就会发现只要涉及递归的问题,都是 树的问题。 再举例吧,说⼏道我们之前⽂章写过的问题。 动态规划详解说过凑零钱问题,暴⼒解法就是遍历⼀棵 N 叉树: def coinChange(coins:'>coins: List[int], amount: int): def dp(n): if n == 0: return 0 if n < 0: return -1 res = float('INF') for coin in coins:'>coins: subproblem = dp(n - coin) # ⼦问题⽆解,跳过 if subproblem == -1: continue res = min(res, 1 + subproblem) return res if res != float('INF') else -1 return dp(amount) 19
20. 学习算法和刷题的框架思维 这么多代码看不懂咋办?直接提取出框架,就能看出核⼼思路了: # 不过是⼀个 N 叉树的遍历问题⽽已 def dp(n): for coin in coins: dp(n - coin) 其实很多动态规划问题就是在遍历⼀棵树,你如果对树的遍历操作烂熟于 ⼼,起码知道怎么把思路转化成代码,也知道如何提取别⼈解法的核⼼思 路。 再看看回溯算法,前⽂回溯算法详解⼲脆直接说了,回溯算法就是个 N 叉 树的前后序遍历问题,没有例外。 ⽐如 N 皇后问题吧,主要代码如下: void backtrack(int[] nums, LinkedList track) { if (track.size() == nums.length) { res.add(new LinkedList(track)); return; } for (int i = 0; i < nums.length; i++) { if (track.contains(nums[i])) continue; track.add(nums[i]); // 进⼊下⼀层决策树 backtrack(nums, track); track.removeLast(); } /* 提取出 N 叉树遍历框架 */ void backtrack(int[] nums, LinkedList track) { for (int i = 0; i < nums.length; i++) { backtrack(nums, track); } 20
21. 学习算法和刷题的框架思维 N 叉树的遍历框架,找出来了把〜你说,树这种结构重不重要? 综上,对于畏惧算法的朋友来说,可以先刷树的相关题⽬,试着从框架上看 问题,⽽不要纠结于细节问题。 纠结细节问题,就⽐如纠结 i 到底应该加到 n 还是加到 n - 1,这个数组的⼤ ⼩到底应该开 n 还是 n + 1 ? 从框架上看问题,就是像我们这样基于框架进⾏抽取和扩展,既可以在看别 ⼈解法时快速理解核⼼逻辑,也有助于找到我们⾃⼰写解法时的思路⽅向。 当然,如果细节出错,你得不到正确的答案,但是只要有框架,你再错也错 不到哪去,因为你的⽅向是对的。 但是,你要是⼼中没有框架,那么你根本⽆法解题,给了你答案,你也不会 发现这就是个树的遍历问题。 这种思维是很重要的,动态规划详解中总结的找状态转移⽅程的⼏步流程, 有时候按照流程写出解法,说实话我⾃⼰都不知道为啥是对的,反正它就是 对了。。。 这就是框架的⼒量,能够保证你在快睡着的时候,依然能写出正确的程序; 就算你啥都不会,都能⽐别⼈⾼⼀个级别。 四、总结⼏句 数据结构的基本存储⽅式就是链式和顺序两种,基本操作就是增删查改,遍 历⽅式⽆⾮迭代和递归。 刷算法题建议从「树」分类开始刷,结合框架思维,把这⼏⼗道题刷完,对 于树结构的理解应该就到位了。这时候去看回溯、动规、分治等算法专题, 对思路的理解可能会更加深刻⼀些。 _____________ 刷算法,学套路,认准 labuladong。 21
22. 学习算法和刷题的框架思维 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 22
23. 动态规划解题套路框架 动态规划详解 学算法,认准 labuladong 就够了! 这篇⽂章是我们号半年前⼀篇 200 多赞赏的成名之作「动态规划详解」的进 阶版。由于账号迁移的原因,旧⽂⽆法被搜索到,所以我润⾊了本⽂,并添 加了更多⼲货内容,希望本⽂成为解决动态规划的⼀部「指导⽅针」。 再说句题外话,我们的公众号开号⾄今写了起码⼗⼏篇⽂章拆解动态规划问 题,我都整理到了公众号菜单的「⽂章⽬录」中,它们都提到了动态规划的 解题框架思维,本⽂就系统总结⼀下。这段时间本⼈也从⾮科班⼩⽩成⻓到 刷通半个 LeetCode,所以我总结的套路可能不适合各路⼤神,但是应该适 合⼤众,毕竟我⾃⼰也是⼀路摸爬滚打过来的。 算法技巧就那⼏个套路,如果你⼼⾥有数,就会轻松很多,本⽂就来扒⼀扒 动态规划的裤⼦,形成⼀套解决这类问题的思维框架。废话不多说了,上⼲ 货。 动态规划问题的⼀般形式就是求最值。动态规划其实是运筹学的⼀种最优化 ⽅法,只不过在计算机问题上应⽤⽐较多,⽐如说让你求最⻓递增⼦序列 呀,最⼩编辑距离呀等等。 既然是要求最值,核⼼问题是什么呢?求解动态规划的核⼼问题是穷举。因 为要求最值,肯定要把所有可⾏的答案穷举出来,然后在其中找最值呗。 动态规划就这么简单,就是穷举就完事了?我看到的动态规划问题都很难 啊! ⾸先,动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果 暴⼒穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优 化穷举过程,避免不必要的计算。 ⽽且,动态规划问题⼀定会具备「最优⼦结构」,才能通过⼦问题的最值得 到原问题的最值。 23
24. 动态规划解题套路框架 另外,虽然动态规划的核⼼思想就是穷举求最值,但是问题可以千变万化, 穷举所有可⾏解其实并不是⼀件容易的事,只有列出正确的「状态转移⽅ 程」才能正确地穷举。 以上提到的重叠⼦问题、最优⼦结构、状态转移⽅程就是动态规划三要素。 具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移⽅ 程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我 来提供我研究出来的⼀个思维框架,辅助你思考状态转移⽅程: 明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。 下⾯通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者 主要是让你明⽩什么是重叠⼦问题(斐波那契数列严格来说不是动态规划问 题),后者主要举集中于如何列出状态转移⽅程。 请读者不要嫌弃这个例⼦简单,只有简单的例⼦才能让你把精⼒充分集中在 算法背后的通⽤思想和技巧上,⽽不会被那些隐晦的细节问题搞的莫名其 妙。想要困难的例⼦,历史⽂章⾥有的是。 ⼀、斐波那契数列 1、暴⼒递归 斐波那契数列的数学形式就是递归的,写成代码就是这样: int fib(int N) { if (N == 1 N == 2) return 1; return fib(N - 1) + fib(N - 2); } 这个不⽤多说了,学校⽼师讲递归的时候似乎都是拿这个举例。我们也知道 这样写代码虽然简洁易懂,但是⼗分低效,低效在哪⾥?假设 n = 20,请画 出递归树。 24
25. 动态规划解题套路框架 PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复 杂度,寻找算法低效的原因都有巨⼤帮助。 这个递归树怎么理解?就是说想要计算原问题 问题 和 f(19) f(17) 和 f(18) ,然后要计算 ,以此类推。最后遇到 f(19) f(1) f(20) ,我就得先计算出⼦ ,我就要先算出⼦问题 或者 f(2) f(18) 的时候,结果已知,就 能直接返回结果,递归树不再向下⽣⻓了。 递归算法的时间复杂度怎么计算?⼦问题个数乘以解决⼀个⼦问题需要的时 间。 ⼦问题个数,即递归树中节点的总数。显然⼆叉树节点总数为指数级别,所 以⼦问题个数为 O(2^n)。 解决⼀个⼦问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) ⼀ 个加法操作,时间为 O(1)。 所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。 观察递归树,很明显发现了算法低效的原因:存在⼤量重复计算,⽐如 f(18) 被计算了两次,⽽且你可以看到,以 f(18) 为根的这个递归树体量 巨⼤,多算⼀遍,会耗费巨⼤的时间。更何况,还不⽌ f(18) 这⼀个节点 被重复计算,所以这个算法及其低效。 25
26. 动态规划解题套路框架 这就是动态规划问题的第⼀个性质:重叠⼦问题。下⾯,我们想办法解决这 个问题。 2、带备忘录的递归解法 明确了问题,其实就已经把问题解决了⼀半。即然耗时的原因是重复计算, 那么我们可以造⼀个「备忘录」,每次算出某个⼦问题的答案后别急着返 回,先记到「备忘录」⾥再返回;每次遇到⼀个⼦问题先去「备忘录」⾥查 ⼀查,如果发现之前已经解决过这个问题了,直接把答案拿出来⽤,不要再 耗时去计算了。 ⼀般使⽤⼀个数组充当这个「备忘录」,当然你也可以使⽤哈希表(字 典),思想都是⼀样的。 int fib(int N) { if (N < 1) return 0; // 备忘录全初始化为 0 vector memo(N + 1, 0); // 初始化最简情况 return helper(memo, N); } int helper(vector& memo, int n) { // base case if (n == 1 n == 2) return 1; // 已经计算过 if (memo[n] != 0) return memo[n]; memo[n] = helper(memo, n - 1) + helper(memo, n - 2); return memo[n]; } 现在,画出递归树,你就知道「备忘录」到底做了什么。 26
27. 动态规划解题套路框架 实际上,带「备忘录」的递归算法,把⼀棵存在巨量冗余的递归树通过「剪 枝」,改造成了⼀幅不存在冗余的递归图,极⼤减少了⼦问题(即递归图中 节点)的个数。 递归算法的时间复杂度怎么算?⼦问题个数乘以解决⼀个⼦问题需要的时 间。 27
28. 动态规划解题套路框架 ⼦问题个数,即图中节点的总数,由于本算法不存在冗余计算,⼦问题就是 f(1) , f(2) , f(3) ... f(20) ,数量和输⼊规模 n = 20 成正⽐,所以⼦问 题个数为 O(n)。 解决⼀个⼦问题的时间,同上,没有什么循环,时间为 O(1)。 所以,本算法的时间复杂度是 O(n)。⽐起暴⼒算法,是降维打击。 ⾄此,带备忘录的递归解法的效率已经和迭代的动态规划解法⼀样了。实际 上,这种解法和迭代的动态规划已经差不多了,只不过这种⽅法叫做「⾃顶 向下」,动态规划叫做「⾃底向上」。 啥叫「⾃顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延 伸,都是从⼀个规模较⼤的原问题⽐如说 到 f(1) 和 f(2) f(20) ,向下逐渐分解规模,直 触底,然后逐层返回答案,这就叫「⾃顶向下」。 啥叫「⾃底向上」?反过来,我们直接从最底下,最简单,问题规模最⼩的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20) ,这就是动 态规划的思路,这也是为什么动态规划⼀般都脱离了递归,⽽是由循环迭代 完成计算。 3、dp 数组的迭代解法 有了上⼀步「备忘录」的启发,我们可以把这个「备忘录」独⽴出来成为⼀ 张表,就叫做 DP table 吧,在这张表上完成「⾃底向上」的推算岂不美哉! int fib(int N) { vector dp(N + 1, 0); // base case dp[1] = dp[2] = 1; for (int i = 3; i <= N; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[N]; } 28
29. 动态规划解题套路框架 画个图就很好理解了,⽽且你发现这个 DP table 特别像之前那个「剪枝」后 的结果,只是反过来算⽽已。实际上,带备忘录的递归解法中的「备忘 录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的, ⼤部分情况下,效率也基本相同。 这⾥,引出「状态转移⽅程」这个名词,实际上就是描述问题结构的数学形 式: 为啥叫「状态转移⽅程」?为了听起来⾼端。你把 f(n) 想做⼀个状态 n,这 个状态 n 是由状态 n - 1 和状态 n - 2 相加转移⽽来,这就叫状态转移,仅此 ⽽已。 你会发现,上⾯的⼏种解法中的所有操作,例如 return f(n - 1) + f(n - 2), dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围 绕这个⽅程式的不同表现形式。可⻅列出「状态转移⽅程」的重要性,它是 解决问题的核⼼。很容易发现,其实状态转移⽅程直接代表着暴⼒解法。 29
30. 动态规划解题套路框架 千万不要看不起暴⼒解,动态规划问题最困难的就是写出状态转移⽅程,即 这个暴⼒解。优化⽅法⽆⾮是⽤备忘录或者 DP table,再⽆奥妙可⾔。 这个例⼦的最后,讲⼀个细节优化。细⼼的读者会发现,根据斐波那契数列 的状态转移⽅程,当前状态只和之前的两个状态有关,其实并不需要那么⻓ 的⼀个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就⾏ 了。所以,可以进⼀步优化,把空间复杂度降为 O(1): int fib(int n) { if (n == 2 n == 1) return 1; int prev = 1, curr = 1; for (int i = 3; i <= n; i++) { int sum = prev + curr; prev = curr; curr = sum; } return curr; } 有⼈会问,动态规划的另⼀个重要特性「最优⼦结构」,怎么没有涉及?下 ⾯会涉及。斐波那契数列的例⼦严格来说不算动态规划,因为没有涉及求最 值,以上旨在演⽰算法设计螺旋上升的过程。下⾯,看第⼆个例⼦,凑零钱 问题。 ⼆、凑零钱问题 先看下题⽬:给你 k 种⾯值的硬币,⾯值分别为 币的数量⽆限,再给⼀个总⾦额 amount c1, c2 ... ck ,每种硬 ,问你最少需要⼏枚硬币凑出这个 ⾦额,如果不可能凑出,算法返回 -1 。算法的函数签名如下: // coins 中是可选硬币⾯值,amount 是⽬标⾦额 int coinChange(int[] coins, int amount); 30
31. 动态规划解题套路框架 ⽐如说 k = 3 ,⾯值分别为 1,2,5,总⾦额 amount = 11 。那么最少需 要 3 枚硬币凑出,即 11 = 5 + 5 + 1。 你认为计算机应该如何解决这个问题?显然,就是把所有肯能的凑硬币⽅法 都穷举出来,然后找找看最少需要多少枚硬币。 1、暴⼒递归 ⾸先,这个问题是动态规划问题,因为它具有「最优⼦结构」的。要符合 「最优⼦结构」,⼦问题间必须互相独⽴。啥叫相互独⽴?你肯定不想看数 学证明,我⽤⼀个直观的例⼦来讲解。 ⽐如说,你的原问题是考出最⾼的总成绩,那么你的⼦问题就是要把语⽂考 到最⾼,数学考到最⾼…… 为了每门课考到最⾼,你要把每门课相应的选 择题分数拿到最⾼,填空题分数拿到最⾼…… 当然,最终就是你每门课都 是满分,这就是最⾼的总成绩。 得到了正确的结果:最⾼的总成绩就是总分。因为这个过程符合最优⼦结 构,“每门科⽬考到最⾼”这些⼦问题是互相独⽴,互不⼲扰的。 但是,如果加⼀个条件:你的语⽂成绩和数学成绩会互相制约,此消彼⻓。 这样的话,显然你能考到的最⾼总成绩就达不到总分了,按刚才那个思路就 会得到错误的结果。因为⼦问题并不独⽴,语⽂数学成绩⽆法同时最优,所 以最优⼦结构被破坏。 回到凑零钱问题,为什么说它符合最优⼦结构呢?⽐如你想求 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10 amount = 的最少硬币 数(⼦问题),你只需要把⼦问题的答案加⼀(再选⼀枚⾯值为 1 的硬币) 就是原问题的答案,因为硬币的数量是没有限制的,⼦问题之间没有相互 制,是互相独⽴的。 那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移 ⽅程? 先确定「状态」,也就是原问题和⼦问题中变化的变量。由于硬币数量⽆ 限,所以唯⼀的状态就是⽬标⾦额 amount 。 31
32. 动态规划解题套路框架 然后确定 dp 函数的定义:当前的⽬标⾦额是 n ,⾄少需要 dp(n) 个硬 币凑出该⾦额。 然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当 前状态。具体到这个问题,⽆论当的⽬标⾦额是多少,选择就是从⾯额列表 coins 中选择⼀个硬币,然后⽬标⾦额就会减少: # 伪码框架 def coinChange(coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins: List[int], amount:'>amount: int): # 定义:要凑出⾦额 n,⾄少要 dp(n) 个硬币 def dp(n): # 做选择,选择需要硬币最少的那个结果 for coin in coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins: res = min(res, 1 + dp(n - coin)) return res # 我们要求的问题是 dp(amount) return dp(amount) 最后明确 base case,显然⽬标⾦额为 0 时,所需硬币数量为 0;当⽬标⾦额 ⼩于 0 时,⽆解,返回 -1: def coinChange(coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins: List[int], amount:'>amount: int): def dp(n): # base case if n == 0: return 0 if n < 0: return -1 # 求最⼩值,所以初始化为正⽆穷 res = float('INF') for coin in coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins:'>coins: subproblem = dp(n - coin) # ⼦问题⽆解,跳过 if subproblem == -1: continue res = min(res, 1 + subproblem) return res if res != float('INF') else -1 return dp(amount) 32
33. 动态规划解题套路框架 ⾄此,状态转移⽅程其实已经完成了,以上算法已经是暴⼒解法了,以上代 码的数学形式就是状态转移⽅程: ⾄此,这个问题其实就解决了,只不过需要消除⼀下重叠⼦问题,⽐如 amount = 11, coins = {1,2,5} 时画出递归树看看: 时间复杂度分析:⼦问题总数 x 每个⼦问题的时间。 ⼦问题总数为递归树节点个数,这个⽐较难看出来,是 O(n^k),总之是指 数级别的。每个⼦问题中含有⼀个 for 循环,复杂度为 O(k)。所以总时间复 杂度为 O(k * n^k),指数级别。 2、带备忘录的递归 只需要稍加修改,就可以通过备忘录消除⼦问题: def coinChange(coins: List[int], amount: int): # 备忘录 33
34. 动态规划解题套路框架 memo = dict() def dp(n): # 查备忘录,避免重复计算 if n in memo: return memo[n] if n == 0: return 0 if n < 0: return -1 res = float('INF') for coin in coins: subproblem = dp(n - coin) if subproblem == -1: continue res = min(res, 1 + subproblem) # 记⼊备忘录 memo[n] = res if res != float('INF') else -1 return memo[n] return dp(amount) 不画图了,很显然「备忘录」⼤⼤减⼩了⼦问题数⽬,完全消除了⼦问题的 冗余,所以⼦问题总数不会超过⾦额数 n,即⼦问题数⽬为 O(n)。处理⼀个 ⼦问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)。 3、dp 数组的迭代解法 当然,我们也可以⾃底向上使⽤ dp table 来消除重叠⼦问题, 义和刚才 dp[i] = x dp dp 数组的定 函数类似,定义也是⼀样的: 表⽰,当⽬标⾦额为 i 时,⾄少需要 x 枚硬币。 int coinChange(vector& coins, int amount) { // 数组⼤⼩为 amount + 1,初始值也为 amount + 1 vector dp(amount + 1, amount + 1); // base case dp[0] = 0; for (int i = 0; i < dp.size(); i++) { // 内层 for 在求所有⼦问题 + 1 的最⼩值 for (int coin : coins) { // ⼦问题⽆解,跳过 if (i - coin < 0) continue; dp[i] = min(dp[i], 1 + dp[i - coin]); 34
35. 动态规划解题套路框架 } } return (dp[amount] == amount + 1) ? -1 : dp[amount]; } PS:为啥 dp 数组初始化为 币数最多只可能等于 amount + 1 amount amount + 1 呢,因为凑成 amount ⾦额的硬 (全⽤ 1 元⾯值的硬币),所以初始化为 就相当于初始化为正⽆穷,便于后续取最⼩值。 三、最后总结 第⼀个斐波那契数列的问题,解释了如何通过「备忘录」或者「dp table」 的⽅法来优化递归树,并且明确了这两种⽅法本质上是⼀样的,只是⾃顶向 下和⾃底向上的不同⽽已。 第⼆个凑零钱的问题,展⽰了如何流程化确定「状态转移⽅程」,只要通过 状态转移⽅程写出暴⼒递归解,剩下的也就是优化递归树,消除重叠⼦问题 ⽽已。 如果你不太了解动态规划,还能看到这⾥,真得给你⿎掌,相信你已经掌握 了这个算法的设计技巧。 35
36. 动态规划解题套路框架 计算机解决问题其实没有任何奇技淫巧,它唯⼀的解决办法就是穷举,穷举 所有可能性。算法设计⽆⾮就是先思考“如何穷举”,然后再追求“如何聪明 地穷举”。 列出动态转移⽅程,就是在解决“如何穷举”的问题。之所以说它难,⼀是因 为很多穷举需要递归实现,⼆是因为有的问题本⾝的解空间复杂,不那么容 易穷举完整。 备忘录、DP table 就是在追求“如何聪明地穷举”。⽤空间换时间的思路,是 降低时间复杂度的不⼆法门,除此之外,试问,还能玩出啥花活? _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 36
37. 回溯算法解题套路框架 回溯算法详解 学算法,认准 labuladong 就够了! 这篇⽂章是很久之前的⼀篇《回溯算法详解》的进阶版,之前那篇不够清 楚,就不必看了,看这篇就⾏。把框架给你讲清楚,你会发现回溯算法问题 都是⼀个套路。 废话不多说,直接上回溯算法框架。解决⼀个回溯问题,实际上就是⼀个决 策树的遍历过程。你只需要思考 3 个问题: 1、路径:也就是已经做出的选择。 2、选择列表:也就是你当前可以做的选择。 3、结束条件:也就是到达决策树底层,⽆法再做选择的条件。 如果你不理解这三个词语的解释,没关系,我们后⾯会⽤「全排列」和「N 皇后问题」这两个经典的回溯算法问题来帮你理解这些词语是什么意思,现 在你先留着印象。 代码⽅⾯,回溯算法的框架: result = [] def backtrack(路径, 选择列表): if 满⾜结束条件: result.add(路径) return for 选择 in 选择列表: 做选择 backtrack(路径, 选择列表) 撤销选择 其核⼼就是 for 循环⾥⾯的递归,在递归调⽤之前「做选择」,在递归调⽤ 之后「撤销选择」,特别简单。 37
38. 回溯算法解题套路框架 什么叫做选择和撤销选择呢,这个框架的底层原理是什么呢?下⾯我们就通 过「全排列」这个问题来解开之前的疑惑,详细探究⼀下其中的奥妙! ⼀、全排列问题 我们在⾼中的时候就做过排列组合的数学题,我们也知道 n 个不重复的 数,全排列共有 n! 个。 PS:为了简单清晰起⻅,我们这次讨论的全排列问题不包含重复的数字。 那么我们当时是怎么穷举全排列的呢?⽐⽅说给三个数 [1,2,3] ,你肯定 不会⽆规律地乱穷举,⼀般是这样: 先固定第⼀位为 1,然后第⼆位可以是 2,那么第三位只能是 3;然后可以 把第⼆位变成 3,第三位就只能是 2 了;然后就只能变化第⼀位,变成 2, 然后再穷举后两位…… 其实这就是回溯算法,我们⾼中⽆师⾃通就会⽤,或者有的同学直接画出如 下这棵回溯树: 只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。我们不 妨把这棵树称为回溯算法的「决策树」。 38
39. 回溯算法解题套路框架 为啥说这是决策树呢,因为你在每个节点上其实都在做决策。⽐如说你站在 下图的红⾊节点上: 你现在就在做决策,可以选择 1 那条树枝,也可以选择 3 那条树枝。为啥只 能在 1 和 3 之中选择呢?因为 2 这个树枝在你⾝后,这个选择你之前做过 了,⽽全排列是不允许重复使⽤数字的。 现在可以解答开头的⼏个名词: 择; [1,3] [2] 就是「路径」,记录你已经做过的选 就是「选择列表」,表⽰你当前可以做出的选择;「结束条 件」就是遍历到树的底层,在这⾥就是选择列表为空的时候。 如果明⽩了这⼏个名词,可以把「路径」和「选择」列表作为决策树上每个 节点的属性,⽐如下图列出了⼏个节点的属性: 39
40. 回溯算法解题套路框架 我们定义的 backtrack 函数其实就像⼀个指针,在这棵树上游⾛,同时要 正确维护每个节点的属性,每当⾛到树的底层,其「路径」就是⼀个全排 列。 再进⼀步,如何遍历⼀棵树?这个应该不难吧。回忆⼀下之前「学习数据结 构的框架思维」写过,各种搜索问题其实都是树的遍历问题,⽽多叉树的遍 历框架就是这样: void traverse(TreeNode root) { for (TreeNode child : root.childern) // 前序遍历需要的操作 traverse(child); // 后序遍历需要的操作 } ⽽所谓的前序遍历和后序遍历,他们只是两个很有⽤的时间点,我给你画张 图你就明⽩了: 40
41. 回溯算法解题套路框架 前序遍历的代码在进⼊某⼀个节点之前的那个时间点执⾏,后序遍历代码在 离开某个节点之后的那个时间点执⾏。 回想我们刚才说的,「路径」和「选择」是每个节点的属性,函数在树上游 ⾛要正确维护节点的属性,那么就要在这两个特殊时间点搞点动作: 现在,你是否理解了回溯算法的这段核⼼框架? for 选择 in 选择列表: 41
42. 回溯算法解题套路框架 # 做选择 将该选择从选择列表移除 路径.add(选择) backtrack(路径, 选择列表) # 撤销选择 路径.remove(选择) 将该选择再加⼊选择列表 我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到 每个节点的选择列表和路径。 下⾯,直接看全排列代码: List> res = new LinkedList<>(); /* 主函数,输⼊⼀组不重复的数字,返回它们的全排列 */ List> permute(int[] nums) { // 记录「路径」 LinkedList track = new LinkedList<>(); backtrack(nums, track); return res; } // 路径:记录在 track 中 // 选择列表:nums 中不存在于 track 的那些元素 // 结束条件:nums 中的元素全都在 track 中出现 void backtrack(int[] nums, LinkedList track) { // 触发结束条件 if (track.size() == nums.length) { res.add(new LinkedList(track)); return; } for (int i = 0; i < nums.length; i++) { // 排除不合法的选择 if (track.contains(nums[i])) continue; // 做选择 track.add(nums[i]); // 进⼊下⼀层决策树 backtrack(nums, track); // 取消选择 42
43. 回溯算法解题套路框架 track.removeLast(); } } 我们这⾥稍微做了些变通,没有显式记录「选择列表」,⽽是通过 和 track nums 推导出当前的选择列表: ⾄此,我们就通过全排列问题详解了回溯算法的底层原理。当然,这个算法 解决全排列不是很⾼效,应为对链表使⽤ contains ⽅法需要 O(N) 的时间 复杂度。有更好的⽅法通过交换元素达到⽬的,但是难理解⼀些,这⾥就不 写了,有兴趣可以⾃⾏搜索⼀下。 但是必须说明的是,不管怎么优化,都符合回溯框架,⽽且时间复杂度都不 可能低于 O(N!),因为穷举整棵决策树是⽆法避免的。这也是回溯算法的⼀ 个特点,不像动态规划存在重叠⼦问题可以优化,回溯算法就是纯暴⼒穷 举,复杂度⼀般都很⾼。 明⽩了全排列问题,就可以直接套回溯算法框架了,下⾯简单看看 N 皇后 问题。 ⼆、N 皇后问题 43
44. 回溯算法解题套路框架 这个问题很经典了,简单解释⼀下:给你⼀个 N×N 的棋盘,让你放置 N 个 皇后,使得它们不能互相攻击。 PS:皇后可以攻击同⼀⾏、同⼀列、左上左下右上右下四个⽅向的任意单 位。 这个问题本质上跟全排列问题差不多,决策树的每⼀层表⽰棋盘上的每⼀ ⾏;每个节点可以做出的选择是,在该⾏的任意⼀列放置⼀个皇后。 直接套⽤框架: vector> res; /* 输⼊棋盘边⻓ n,返回所有合法的放置 */ vector> solveNQueens(int n) { // '.' 表⽰空,'Q' 表⽰皇后,初始化空棋盘。 vector board(n, string(n, '.')); backtrack(board, 0); return res; } // 路径:board 中⼩于 row 的那些⾏都已经成功放置了皇后 // 选择列表:第 row ⾏的所有列都是放置皇后的选择 // 结束条件:row 超过 board 的最后⼀⾏ void backtrack(vector& board, int row) { // 触发结束条件 if (row == board.size()) { res.push_back(board); return; } int n = board[row].size(); for (int col = 0; col < n; col++) { // 排除不合法选择 if (!isValid(board, row, col)) continue; // 做选择 board[row][col] = 'Q'; // 进⼊下⼀⾏决策 backtrack(board, row + 1); // 撤销选择 board[row][col] = '.'; 44
45. 回溯算法解题套路框架 } } 这部分主要代码,其实跟全排列问题差不多, isValid 函数的实现也很简 单: /* 是否可以在 board[row][col] 放置皇后? */ bool isValid(vector& board, int row, int col) { int n = board.size(); // 检查列是否有皇后互相冲突 for (int i = 0; i < n; i++) { if (board[i][col] == 'Q') return false; } // 检查右上⽅是否有皇后互相冲突 for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) { if (board[i][j] == 'Q') return false; } // 检查左上⽅是否有皇后互相冲突 for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (board[i][j] == 'Q') return false; } return true; } 函数 backtrack 依然像个在决策树上游⾛的指针,通过 以表⽰函数遍历到的位置,通过 isValid row 和 col 就可 函数可以将不符合条件的情况剪 枝: 45
46. 回溯算法解题套路框架 如果直接给你这么⼀⼤段解法代码,可能是懵逼的。但是现在明⽩了回溯算 法的框架套路,还有啥难理解的呢?⽆⾮是改改做选择的⽅式,排除不合法 选择的⽅式⽽已,只要框架存于⼼,你⾯对的只剩下⼩问题了。 当 N = 8 时,就是⼋皇后问题,数学⼤佬⾼斯穷尽⼀⽣都没有数清楚⼋皇 后问题到底有⼏种可能的放置⽅法,但是我们的算法只需要⼀秒就可以算出 来所有可能的结果。 不过真的不怪⾼斯。这个问题的复杂度确实⾮常⾼,看看我们的决策树,虽 然有 isValid 法优化。如果 函数剪枝,但是最坏时间复杂度仍然是 O(N^(N+1)),⽽且⽆ N = 10 的时候,计算就已经很耗时了。 有的时候,我们并不想得到所有合法的答案,只想要⼀个答案,怎么办呢? ⽐如解数独的算法,找所有解法复杂度太⾼,只要找到⼀种解法就可以。 其实特别简单,只要稍微修改⼀下回溯算法的代码即可: // 函数找到⼀个答案后就返回 true bool backtrack(vector& board, int row) { // 触发结束条件 if (row == board.size()) { res.push_back(board); return true; 46
47. 回溯算法解题套路框架 } ... for (int col = 0; col < n; col++) { ... board[row][col] = 'Q'; if (backtrack(board, row + 1)) return true; board[row][col] = '.'; } return false; } 这样修改后,只要找到⼀个答案,for 循环的后续递归穷举都会被阻断。也 许你可以在 N 皇后问题的代码框架上,稍加修改,写⼀个解数独的算法? 三、最后总结 回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置 做⼀些操作,算法框架如下: def backtrack(...): for 选择 in 选择列表: 做选择 backtrack(...) 撤销选择 写 backtrack 函数时,需要维护⾛过的「路径」和当前可以做的「选择列 表」,当触发「结束条件」时,将「路径」记⼊结果集。 其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划系列⽂ 章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和 「base case」,是不是就对应着⾛过的「路径」,当前的「选择列表」和 「结束条件」? 47
48. 回溯算法解题套路框架 某种程度上说,动态规划的暴⼒求解阶段就是回溯算法。只是有的问题具有 重叠⼦问题性质,可以⽤ dp table 或者备忘录优化,将递归树⼤幅剪枝,这 就变成了动态规划。⽽今天的两个问题,都没有重叠⼦问题,也就是回溯算 法问题了,复杂度⾮常⾼是不可避免的。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 48
49. BFS 算法解题套路框架 BFS 算法框架套路详解 学算法,认准 labuladong 就够了! 后台有很多⼈问起 BFS 和 DFS 的框架,今天就来说说吧。 ⾸先,你要说 labuladong 没写过 BFS 框架,这话没错,今天写个框架你背 住就完事⼉了。但要是说没写过 DFS 框架,那你还真是说错了,其实 DFS 算法就是回溯算法,我们前⽂ 回溯算法框架套路详解 就写过了,⽽且写得 不是⼀般得好,建议好好复习。 BFS 的核⼼思想应该不难理解的,就是把⼀些问题抽象成图,从⼀个点开 始,向四周开始扩散。⼀般来说,我们写 BFS 算法都是⽤「队列」这种数 据结构,每次将⼀个节点周围的所有节点加⼊队列。 BFS 相对 DFS 的最主要的区别是:BFS 找到的路径⼀定是最短的,但代价 就是空间复杂度⽐ DFS ⼤很多,⾄于为什么,我们后⾯介绍了框架就很容 易看出来了。 本⽂就由浅⼊深写两道 BFS 的典型题⽬,分别是「⼆叉树的最⼩⾼度」和 「打开密码锁的最少步数」,⼿把⼿教你怎么写 BFS 算法。 ⼀、算法框架 要说框架的话,我们先举例⼀下 BFS 出现的常⻅场景好吧,问题的本质就 是让你在⼀幅「图」中找到从起点 start 到终点 target 的最近距离,这 个例⼦听起来很枯燥,但是 BFS 算法问题其实都是在⼲这个事⼉,把枯燥 的本质搞清楚了,再去欣赏各种问题的包装才能胸有成⽵嘛。 这个⼴义的描述可以有各种变体,⽐如⾛迷宫,有的格⼦是围墙不能⾛,从 起点到终点的最短距离是多少?如果这个迷宫带「传送门」可以瞬间传送 呢? 49
50. BFS 算法解题套路框架 再⽐如说两个单词,要求你通过某些替换,把其中⼀个变成另⼀个,每次只 能替换⼀个字符,最少要替换⼏次? 再⽐如说连连看游戏,两个⽅块消除的条件不仅仅是图案相同,还得保证两 个⽅块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游 戏是如何判断它俩的最短连线有⼏个拐点的? 再⽐如…… 净整些花⾥胡哨的,这些问题都没啥奇技淫巧,本质上就是⼀幅「图」,让 你从⼀个起点,⾛到终点,问最短路径。这就是 BFS 的本质,框架搞清楚 了直接默写就好。 记住下⾯这个框架就 OK 了: // 计算从起点 start 到终点 target 的最近距离 int BFS(Node start, Node target) { Queue q; // 核⼼数据结构 Set visited; // 避免⾛回头路 q.offer(start); // 将起点加⼊队列 visited.add(start); int step = 0; // 记录扩散的步数 while (q not empty) { int sz = q.size(); /* 将当前队列中的所有节点向四周扩散 */ for (int i = 0; i < sz; i++) { Node cur = q.poll(); /* 划重点:这⾥判断是否到达终点 */ 50
51. BFS 算法解题套路框架 if (cur is target) return step; /* 将 cur 的相邻节点加⼊队列 */ for (Node x : cur.adj()) if (x not in visited) { q.offer(x); visited.add(x); } } /* 划重点:更新步数在这⾥ */ step++; } } 队列 q 就不说了,BFS 的核⼼数据结构; 点,⽐如说⼆维数组中, 点; visited cur cur.adj() 泛指 cur 相邻的节 上下左右四⾯的位置就是相邻节 的主要作⽤是防⽌⾛回头路,⼤部分时候都是必须的,但是 像⼀般的⼆叉树结构,没有⼦节点到⽗节点的指针,不会⾛回头路就不需要 visited 。 ⼆、⼆叉树的最⼩⾼度 先来个简单的问题实践⼀下 BFS 框架吧,判断⼀棵⼆叉树的最⼩⾼度,这 也是 LeetCode 第 111 题,看⼀下题⽬: 51
52. BFS 算法解题套路框架 怎么套到 BFS 的框架⾥呢?⾸先明确⼀下起点 start 和终点 target 是什 么,怎么判断到达了终点? 显然起点就是 root 根节点,终点就是最靠近根节点的那个「叶⼦节点」 嘛,叶⼦节点就是两个⼦节点都是 null 的节点: if (cur.left == null && cur.right == null) // 到达叶⼦节点 那么,按照我们上述的框架稍加改造来写解法即可: int minDepth(TreeNode root) { 52
53. BFS 算法解题套路框架 if (root == null) return 0; Queue q = new LinkedList<>(); q.offer(root); // root 本⾝就是⼀层,depth 初始化为 1 int depth = 1; while (!q.isEmpty()) { int sz = q.size(); /* 将当前队列中的所有节点向四周扩散 */ for (int i = 0; i < sz; i++) { TreeNode cur = q.poll(); /* 判断是否到达终点 */ if (cur.left == null && cur.right == null) return depth; /* 将 cur 的相邻节点加⼊队列 */ if (cur.left != null) q.offer(cur.left); if (cur.right != null) q.offer(cur.right); } /* 这⾥增加步数 */ depth++; } return depth; } ⼆叉树是很简单的数据结构,我想上述代码你应该可以理解的吧,其实其他 复杂问题都是这个框架的变形,再探讨复杂问题之前,我们解答两个问题: 1、为什么 BFS 可以找到最短距离,DFS 不⾏吗? ⾸先,你看 BFS 的逻辑, depth 每增加⼀次,队列中的所有节点都向前迈 ⼀步,这保证了第⼀次到达终点的时候,⾛的步数是最少的。 DFS 不能找最短路径吗?其实也是可以的,但是时间复杂度相对⾼很多。 你想啊,DFS 实际上是靠递归的堆栈记录⾛过的路径,你要找到最短路 径,肯定得把⼆叉树中所有树杈都探索完才能对⽐出最短的路径有多⻓对不 对?⽽ BFS 借助队列做到⼀次⼀步「⻬头并进」,是可以在不遍历完整棵 树的条件下找到最短距离的。 53
54. BFS 算法解题套路框架 形象点说,DFS 是线,BFS 是⾯;DFS 是单打独⽃,BFS 是集体⾏动。这 个应该⽐较容易理解吧。 2、既然 BFS 那么好,为啥 DFS 还要存在? BFS 可以找到最短距离,但是空间复杂度⾼,⽽ DFS 的空间复杂度较低。 还是拿刚才我们处理⼆叉树问题的例⼦,假设给你的这个⼆叉树是满⼆叉 树,节点数为 N ,对于 DFS 算法来说,空间复杂度⽆⾮就是递归堆栈,最 坏情况下顶多就是树的⾼度,也就是 O(logN) 。 但是你想想 BFS 算法,队列中每次都会储存着⼆叉树⼀层的节点,这样的 话最坏情况下空间复杂度应该是树的最底层节点的数量,也就是 Big O 表⽰的话也就是 O(N) N/2 ,⽤ 。 由此观之,BFS 还是有代价的,⼀般来说在找最短路径的时候使⽤ BFS, 其他时候还是 DFS 使⽤得多⼀些(主要是递归代码好写)。 好了,现在你对 BFS 了解得⾜够多了,下⾯来⼀道难⼀点的题⽬,深化⼀ 下框架的理解吧。 三、解开密码锁的最少次数 这道 LeetCode 题⽬是第 752 题,⽐较有意思: 54
55. BFS 算法解题套路框架 题⽬中描述的就是我们⽣活中常⻅的那种密码锁,若果没有任何约束,最少 的拨动次数很好算,就像我们平时开密码锁那样直奔密码拨就⾏了。 但现在的难点就在于,不能出现 deadends ,应该如何计算出最少的转动次 数呢? 55
56. BFS 算法解题套路框架 第⼀步,我们不管所有的限制条件,不管 deadends 和 target 的限制,就 思考⼀个问题:如果让你设计⼀个算法,穷举所有可能的密码组合,你怎么 做? 穷举呗,再简单⼀点,如果你只转⼀下锁,有⼏种可能?总共有 4 个位置, 每个位置可以向上转,也可以向下转,也就是有 8 种可能对吧。 ⽐如说从 "0000" 开始,转⼀次,可以穷举出 "0900"... 共 8 种密码。然后,再以这 8 种密码作为基础,对每个密码再转 "1000", "9000", "0100", ⼀下,穷举出所有可能... 仔细想想,这就可以抽象成⼀幅图,每个节点有 8 个相邻的节点,⼜让你求 最短距离,这不就是典型的 BFS 嘛,框架就可以派上⽤场了,先写出⼀个 「简陋」的 BFS 框架代码再说别的: // 将 s[j] 向上拨动⼀次 String plusOne(String s, int j) { char[] ch = s.toCharArray(); if (ch[j] == '9') ch[j] = '0'; else ch[j] += 1; return new String(ch); } // 将 s[i] 向下拨动⼀次 String minusOne(String s, int j) { char[] ch = s.toCharArray(); if (ch[j] == '0') ch[j] = '9'; else ch[j] -= 1; return new String(ch); } // BFS 框架,打印出所有可能的密码 void BFS(String target) { Queue q = new LinkedList<>(); q.offer("0000"); while (!q.isEmpty()) { 56
57. BFS 算法解题套路框架 int sz = q.size(); /* 将当前队列中的所有节点向周围扩散 */ for (int i = 0; i < sz; i++) { String cur = q.poll(); /* 判断是否到达终点 */ System.out.println(cur); /* 将⼀个节点的相邻节点加⼊队列 */ for (int j = 0; j < 4; j++) { String up = plusOne(cur, j); String down = minusOne(cur, j); q.offer(up); q.offer(down); } } /* 在这⾥增加步数 */ } return; } PS:这段代码当然有很多问题,但是我们做算法题肯定不是⼀蹴⽽就的, ⽽是从简陋到完美的。不要完美主义,咱要慢慢来,好不。 这段 BFS 代码已经能够穷举所有可能的密码组合了,但是显然不能完成题 ⽬,有如下问题需要解决: 1、会⾛回头路。⽐如说我们从 "1000" 时,还会拨出⼀个 "0000" "0000" 拨到 "1000" ,但是等从队列拿出 ,这样的话会产⽣死循环。 2、没有终⽌条件,按照题⽬要求,我们找到 target 就应该结束并返回拨 动的次数。 3、没有对 deadends 的处理,按道理这些「死亡密码」是不能出现的,也 就是说你遇到这些密码的时候需要跳过。 如果你能够看懂上⾯那段代码,真得给你⿎掌,只要按照 BFS 框架在对应 的位置稍作修改即可修复这些问题: int openLock(String[] deadends, String target) { // 记录需要跳过的死亡密码 57
58. BFS 算法解题套路框架 Set deads = new HashSet<>(); for (String s : deadends) deads.add(s); // 记录已经穷举过的密码,防⽌⾛回头路 Set visited = new HashSet<>(); Queue q = new LinkedList<>(); // 从起点开始启动⼴度优先搜索 int step = 0; q.offer("0000"); visited.add("0000"); while (!q.isEmpty()) { int sz = q.size(); /* 将当前队列中的所有节点向周围扩散 */ for (int i = 0; i < sz; i++) { String cur = q.poll(); /* 判断是否到达终点 */ if (deads.contains(cur)) continue; if (cur.equals(target)) return step; /* 将⼀个节点的未遍历相邻节点加⼊队列 */ for (int j = 0; j < 4; j++) { String up = plusOne(cur, j); if (!visited.contains(up)) { q.offer(up); visited.add(up); } String down = minusOne(cur, j); if (!visited.contains(down)) { q.offer(down); visited.add(down); } } } /* 在这⾥增加步数 */ step++; } // 如果穷举完都没找到⽬标密码,那就是找不到了 return -1; } 58
59. BFS 算法解题套路框架 ⾄此,我们就解决这道题⽬了。有⼀个⽐较⼩的优化:可以不需要 这个哈希集合,可以直接将这些元素初始化到 visited dead 集合中,效果是⼀ 样的,可能更加优雅⼀些。 四、双向 BFS 优化 你以为到这⾥ BFS 算法就结束了?恰恰相反。BFS 算法还有⼀种稍微⾼级 ⼀点的优化思路:双向 BFS,可以进⼀步提⾼算法的效率。 篇幅所限,这⾥就提⼀下区别:传统的 BFS 框架就是从起点开始向四周扩 散,遇到终点时停⽌;⽽双向 BFS 则是从起点和终点同时开始扩散,当两 边有交集的时候停⽌。 为什么这样能够能够提升效率呢?其实从 Big O 表⽰法分析算法复杂度的 话,它俩的最坏复杂度都是 O(N) ,但是实际上双向 BFS 确实会快⼀些, 我给你画两张图看⼀眼就明⽩了: 59
60. BFS 算法解题套路框架 图⽰中的树形结构,如果终点在最底部,按照传统 BFS 算法的策略,会把 整棵树的节点都搜索⼀遍,最后找到 target ;⽽双向 BFS 其实只遍历了 半棵树就出现了交集,也就是找到了最短距离。从这个例⼦可以直观地感受 到,双向 BFS 是要⽐传统 BFS ⾼效的。 不过,双向 BFS 也有局限,因为你必须知道终点在哪⾥。⽐如我们刚才讨 论的⼆叉树最⼩⾼度的问题,你⼀开始根本就不知道终点在哪⾥,也就⽆法 使⽤双向 BFS;但是第⼆个密码锁的问题,是可以使⽤双向 BFS 算法来提 ⾼效率的,代码稍加修改即可: int openLock(String[] deadends, String target) { Set deads = new HashSet<>(); for (String s : deadends) deads.add(s); // ⽤集合不⽤队列,可以快速判断元素是否存在 Set q1 = new HashSet<>(); Set q2 = new HashSet<>(); Set visited = new HashSet<>(); int step = 0; q1.add("0000"); q2.add(target); while (!q1.isEmpty() && !q2.isEmpty()) { // 哈希集合在遍历的过程中不能修改,⽤ temp 存储扩散结果 60
61. BFS 算法解题套路框架 Set temp = new HashSet<>(); /* 将 q1 中的所有节点向周围扩散 */ for (String cur : q1) { /* 判断是否到达终点 */ if (deads.contains(cur)) continue; if (q2.contains(cur)) return step; visited.add(cur); /* 将⼀个节点的未遍历相邻节点加⼊集合 */ for (int j = 0; j < 4; j++) { String up = plusOne(cur, j); if (!visited.contains(up)) temp.add(up); String down = minusOne(cur, j); if (!visited.contains(down)) temp.add(down); } } /* 在这⾥增加步数 */ step++; // temp 相当于 q1 // 这⾥交换 q1 q2,下⼀轮 while 就是扩散 q2 q1 = q2; q2 = temp; } return -1; } 双向 BFS 还是遵循 BFS 算法框架的,只是不再使⽤队列,⽽是使⽤ HashSet ⽅便快速判断两个集合是否有交集。 另外的⼀个技巧点就是 while 循环的最后交换 q1 要默认扩散 。 q1 就相当于轮流扩散 q1 和 q2 和 q2 的内容,所以只 其实双向 BFS 还有⼀个优化,就是在 while 循环开始时做⼀个判断: // ... while (!q1.isEmpty() && !q2.isEmpty()) { 61
62. BFS 算法解题套路框架 if (q1.size() > q2.size()) { // 交换 q1 和 q2 temp = q1; q1 = q2; q2 = temp; } // ... 为什么这是⼀个优化呢? 因为按照 BFS 的逻辑,队列(集合)中的元素越多,扩散之后新的队列 (集合)中的元素就越多;在双向 BFS 算法中,如果我们每次都选择⼀个 较⼩的集合进⾏扩散,那么占⽤的空间增⻓速度就会慢⼀些,效率就会⾼⼀ 些。 不过话说回来,⽆论传统 BFS 还是双向 BFS,⽆论做不做优化,从 Big O 衡量标准来看,时间复杂度都是⼀样的,只能说双向 BFS 是⼀种 trick,算 法运⾏的速度会相对快⼀点,掌握不掌握其实都⽆所谓。最关键的是把 BFS 通⽤框架记下来,反正所有 BFS 算法都可以⽤它套出解法。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 62
63. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 ⼆分查找详解 学算法,认准 labuladong 就够了! 先给⼤家讲个笑话乐呵⼀下: 有⼀天阿东到图书馆借了 N 本书,出图书馆的时候,警报响了,于是保安 把阿东拦下,要检查⼀下哪本书没有登记出借。阿东正准备把每⼀本书在报 警器下过⼀下,以找出引发警报的书,但是保安露出不屑的眼神:你连⼆分 查找都不会吗?于是保安把书分成两堆,让第⼀堆过⼀下报警器,报警器 响;于是再把这堆书分成两堆…… 最终,检测了 logN 次之后,保安成功的 找到了那本引起警报的书,露出了得意和嘲讽的笑容。于是阿东背着剩下的 书⾛了。 从此,图书馆丢了 N - 1 本书。 ⼆分查找并不简单,Knuth ⼤佬(发明 KMP 算法的那位)都说⼆分查找: 思路很简单,细节是魔⿁。很多⼈喜欢拿整型溢出的 bug 说事⼉,但是⼆分 查找真正的坑根本就不是那个细节问题,⽽是在于到底要给 减⼀,while ⾥到底⽤ <= 还是 < mid 加⼀还是 。 你要是没有正确理解这些细节,写⼆分肯定就是⽞学编程,有没有 bug 只能 靠菩萨保佑。我特意写了⼀⾸诗来歌颂该算法,概括本⽂的主要内容,建议 保存: 63
64. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 本⽂就来探究⼏个最常⽤的⼆分查找场景:寻找⼀个数、寻找左侧边界、寻 找右侧边界。⽽且,我们就是要深⼊细节,⽐如不等号是否应该带等号, mid 是否应该加⼀等等。分析这些细节的差异以及出现这些差异的原因,保 证你能灵活准确地写出正确的⼆分查找算法。 64
65. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 零、⼆分查找框架 int binarySearch(int[] nums, int target) { int left = 0, right = ...; while(...) { int mid = left + (right - left) / 2; if (nums[mid] == target) { ... } else if (nums[mid] < target) { left = ... } else if (nums[mid] > target) { right = ... } } return ...; } 分析⼆分查找的⼀个技巧是:不要出现 else,⽽是把所有情况⽤ else if 写清 楚,这样可以清楚地展现所有细节。本⽂都会使⽤ else if,旨在讲清楚,读 者理解后可⾃⾏简化。 其中 ... 标记的部分,就是可能出现细节问题的地⽅,当你⻅到⼀个⼆分 查找的代码时,⾸先注意这⼏个地⽅。后⽂⽤实例分析这些地⽅能有什么样 的变化。 另外声明⼀下,计算 mid 时需要防⽌溢出,代码中 2 就和 right (left + right) / 2 left + (right - left) / 的结果相同,但是有效防⽌了 left 和 太⼤直接相加导致溢出。 ⼀、寻找⼀个数(基本的⼆分搜索) 这个场景是最简单的,肯能也是⼤家最熟悉的,即搜索⼀个数,如果存在, 返回其索引,否则返回 -1。 int binarySearch(int[] nums, int target) { int left = 0; 65
66. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 int right = nums.length - 1; // 注意 while(left <= right) { int mid = left + (right - left) / 2; if(nums[mid] == target) return mid; else if (nums[mid] < target) left = mid + 1; // 注意 else if (nums[mid] > target) right = mid - 1; // 注意 } return -1; } 1、为什么 while 循环的条件中是 <=,⽽不是 <? 答:因为初始化 引,⽽不是 的赋值是 right nums.length nums.length - 1 ,即最后⼀个元素的索 。 这⼆者可能出现在不同功能的⼆分查找中,区别是:前者相当于两端都闭区 间 [left, right] ⼩为 nums.length ,后者相当于左闭右开区间 [left, right) ,因为索引⼤ 是越界的。 我们这个算法中使⽤的是前者 [left, right] 两端都闭的区间。这个区间 其实就是每次进⾏搜索的区间。 什么时候应该停⽌搜索呢?当然,找到了⽬标值的时候可以终⽌: if(nums[mid] == target) return mid; 但如果没找到,就需要 while 循环终⽌,然后返回 -1。那 while 循环什么时 候应该终⽌?搜索区间为空的时候应该终⽌,意味着你没得找了,就等于没 找到嘛。 while(left <= right) 就是 的终⽌条件是 [right + 1, right] left == right + 1 ,或者带个具体的数字进去 ,写成区间的形式 [3, 2] ,可⻅这时候 区间为空,因为没有数字既⼤于等于 3 ⼜⼩于等于 2 的吧。所以这时候 66
67. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 while 循环终⽌是正确的,直接返回 -1 即可。 while(left < right) [left, right] 的终⽌条件是 left == right ,或者带个具体的数字进去 [2, 2] ,写成区间的形式就是 ,这时候区间⾮空,还 有⼀个数 2,但此时 while 循环终⽌了。也就是说这区间 [2, 2] 被漏掉 了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。 当然,如果你⾮要⽤ while(left < right) 也可以,我们已经知道了出错的 原因,就打个补丁好了: //... while(left < right) { // ... } return nums[left] == target ? left : -1; 2、为什么 mid 或者 left = mid + 1 left = mid , right = mid - 1 ?我看有的代码是 right = ,没有这些加加减减,到底怎么回事,怎么判断? 答:这也是⼆分查找的⼀个难点,不过只要你能理解前⾯的内容,就能够很 容易判断。 刚才明确了「搜索区间」这个概念,⽽且本算法的搜索区间是两端都闭的, 即 [left, right] 。那么当我们发现索引 mid 不是要找的 target 时,下 ⼀步应该去搜索哪⾥呢? 当然是去搜索 [left, mid-1] 或者 [mid+1, right] 对不对?因为 mid 已 经搜索过,应该从搜索区间中去除。 3、此算法有什么缺陷? 答:⾄此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但 是,这个算法存在局限性。 ⽐如说给你有序数组 nums = [1,2,2,2,3] 引是 2,没错。但是如果我想得到 想得到 target target , target 为 2,此算法返回的索 的左侧边界,即索引 1,或者我 的右侧边界,即索引 3,这样的话此算法是⽆法处理的。 67
68. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 这样的需求很常⻅,你也许会说,找到⼀个 target,然后向左或向右线性搜 索不⾏吗?可以,但是不好,因为这样难以保证⼆分查找对数级的复杂度 了。 我们后续的算法就来讨论这两种⼆分查找的算法。 ⼆、寻找左侧边界的⼆分搜索 以下是最常⻅的代码形式,其中的标记是需要注意的细节: int left_bound(int[] nums, int target) { if (nums.length == 0) return -1; int left = 0; int right = nums.length; // 注意 while (left < right) { // 注意 int mid = (left + right) / 2; if (nums[mid] == target) { right = mid; } else if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid; // 注意 } } return left; } 1、为什么 while 中是 < ⽽不是 答:⽤相同的⽅法分析,因为 1 <= right = nums.length 。因此每次循环的「搜索区间」是 while(left < right) left) ? 终⽌的条件是 [left, right) left == right ⽽不是 nums.length - 左闭右开。 ,此时搜索区间 [left, 为空,所以可以正确终⽌。 68
69. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 PS:这⾥先要说⼀个搜索左右边界和上⾯这个算法的⼀个区别,也是很多 读者问的:刚才的 nums.length right 不是 nums.length - 1 吗,为啥这⾥⾮要写成 使得「搜索区间」变成左闭右开呢? 因为对于搜索左右侧边界的⼆分查找,这种写法⽐较普遍,我就拿这种写法 举例了,保证你以后遇到这类代码可以理解。你⾮要⽤两端都闭的写法反⽽ 更简单,我会在后⾯写相关的代码,把三种⼆分搜索都⽤⼀种两端都闭的写 法统⼀起来,你耐⼼往后看就⾏了。 2、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎 么办? 答:因为要⼀步⼀步来,先理解⼀下这个「左侧边界」有什么特殊含义: 对于这个数组,算法会返回 1。这个 1 的含义可以这样解读: nums 中⼩于 2 的元素有 1 个。 ⽐如对于有序数组 是: nums 再⽐如说 nums = [2,3,5,7] , target = 1 ,算法会返回 0,含义 中⼩于 1 的元素有 0 个。 nums = [2,3,5,7], target = 8 ,算法会返回 4,含义是: nums 中⼩于 8 的元素有 4 个。 69
70. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 综上可以看出,函数的返回值(即 [0, nums.length] left 变量的值)取值区间是闭区间 ,所以我们简单添加两⾏代码就能在正确的时候 return -1: while (left < right) { //... } // target ⽐所有数都⼤ if (left == nums.length) return -1; // 类似之前算法的处理⽅式 return nums[left] == target ? left : -1; 3、为什么 left = mid + 1 , right = mid ?和之前的算法不⼀样? 答:这个很好解释,因为我们的「搜索区间」是 开,所以当 nums[mid] 割成两个区间,即 [left, right) 左闭右 被检测之后,下⼀步的搜索区间应该去掉 [left, mid) 或 [mid + 1, right) mid 分 。 4、为什么该算法能够搜索左侧边界? 答:关键在于对于 nums[mid] == target 这种情况的处理: if (nums[mid] == target) right = mid; 可⻅,找到 target 时不要⽴即返回,⽽是缩⼩「搜索区间」的上界 right ,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左 侧边界的⽬的。 5、为什么返回 left ⽽不是 right ? 答:都是⼀样的,因为 while 终⽌的条件是 6、能不能想办法把 right 变成 left == right nums.length - 1 。 ,也就是继续使⽤两边都 闭的「搜索区间」?这样就可以和第⼀种⼆分搜索在某种程度上统⼀起来 了。 70
71. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 答:当然可以,只要你明⽩了「搜索区间」这个概念,就能有效避免漏掉元 素,随便你怎么改都⾏。下⾯我们严格根据逻辑来修改: 因为你⾮要让搜索区间两端都闭,所以 - 1 <= ,while 的终⽌条件应该是 right 应该初始化为 left == right + 1 nums.length ,也就是其中应该⽤ : int left_bound(int[] nums, int target) { // 搜索区间为 [left, right] int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; // if else ... } 因为搜索区间是两端都闭的,且现在是搜索左侧边界,所以 right left 和 的更新逻辑如下: if (nums[mid] < target) { // 搜索区间变为 [mid+1, right] left = mid + 1; } else if (nums[mid] > target) { // 搜索区间变为 [left, mid-1] right = mid - 1; } else if (nums[mid] == target) { // 收缩右侧边界 right = mid - 1; } 由于 while 的退出条件是 left == right + 1 ,所以当 target ⽐ nums 中 所有元素都⼤时,会存在以下情况使得索引越界: 71
72. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 因此,最后返回结果的代码应该检查越界情况: if (left >= nums.length nums[left] != target) return -1; return left; ⾄此,整个算法就写完了,完整代码如下: int left_bound(int[] nums, int target) { int left = 0, right = nums.length - 1; // 搜索区间为 [left, right] while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { // 搜索区间变为 [mid+1, right] left = mid + 1; } else if (nums[mid] > target) { // 搜索区间变为 [left, mid-1] right = mid - 1; } else if (nums[mid] == target) { // 收缩右侧边界 right = mid - 1; } } // 检查出界情况 72
73. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 if (left >= nums.length nums[left] != target) return -1; return left; } 这样就和第⼀种⼆分搜索算法统⼀了,都是两端都闭的「搜索区间」,⽽且 最后返回的也是 left 变量的值。只要把住⼆分搜索的逻辑,两种形式⼤ 家看⾃⼰喜欢哪种记哪种吧。 三、寻找右侧边界的⼆分查找 类似寻找左侧边界的算法,这⾥也会提供两种写法,还是先写常⻅的左闭右 开的写法,只有两处和搜索左侧边界不同,已标注: int right_bound(int[] nums, int target) { if (nums.length == 0) return -1; int left = 0, right = nums.length; while (left < right) { int mid = (left + right) / 2; if (nums[mid] == target) { left = mid + 1; // 注意 } else if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid; } } return left - 1; // 注意 } 1、为什么这个算法能够找到右侧边界? 答:类似地,关键点还是这⾥: if (nums[mid] == target) { left = mid + 1; 73
74. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 当 nums[mid] == target left 时,不要⽴即返回,⽽是增⼤「搜索区间」的下界 ,使得区间不断向右收缩,达到锁定右侧边界的⽬的。 2、为什么最后返回 ⽽不像左侧边界的函数,返回 left - 1 我觉得这⾥既然是搜索右侧边界,应该返回 答:⾸先,while 循环的终⽌条件是 right left == right 是⼀样的,你⾮要体现右侧的特点,返回 left ?⽽且 才对。 ,所以 right - 1 left 和 right 好了。 ⾄于为什么要减⼀,这是搜索右侧边界的⼀个特殊点,关键在这个条件判 断: if (nums[mid] == target) { left = mid + 1; // 这样想: mid = left - 1 因为我们对 时, left nums[left] target 的更新必须是 ⼀定不等于 left = mid + 1 target 了,⽽ ,就是说 while 循环结束 nums[left-1] 可能是 。 ⾄于为什么 left 的更新必须是 left = mid + 1 ,同左侧边界搜索,就不 再赘述。 74
75. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 3、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎 么办? 答:类似之前的左侧边界搜索,因为 while 的终⽌条件是 就是说 left 的取值范围是 [0, nums.length] left == right , ,所以可以添加两⾏代码, 正确地返回 -1: while (left < right) { // ... } if (left == 0) return -1; return nums[left-1] == target ? (left-1) : -1; 4、是否也可以把这个算法的「搜索区间」也统⼀成两端都闭的形式呢?这 样这三个写法就完全统⼀了,以后就可以闭着眼睛写出来了。 答:当然可以,类似搜索左侧边界的统⼀写法,其实只要改两个地⽅就⾏ 了: int right_bound(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] == target) { // 这⾥改成收缩左侧边界即可 left = mid + 1; } } // 这⾥改为检查 right 越界的情况,⻅下图 if (right < 0 nums[right] != target) return -1; return right; } 75
76. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 当 target ⽐所有元素都⼩时, right 会被减到 -1,所以需要在最后防⽌ 越界: ⾄此,搜索右侧边界的⼆分查找的两种写法也完成了,其实将「搜索区间」 统⼀成两端都闭反⽽更容易记忆,你说是吧? 四、逻辑统⼀ 来梳理⼀下这些细节差异的因果逻辑: 第⼀个,最基本的⼆分查找算法: 因为我们初始化 right = nums.length - 1 所以决定了我们的「搜索区间」是 [left, right] 所以决定了 while (left <= right) 同时也决定了 left = mid+1 和 right = mid-1 因为我们只需找到⼀个 target 的索引即可 所以当 nums[mid] == target 时可以⽴即返回 第⼆个,寻找左侧边界的⼆分查找: 因为我们初始化 right = nums.length 76
77. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 所以决定了我们的「搜索区间」是 [left, right) 所以决定了 while (left < right) 同时也决定了 left = mid + 1 和 right = mid 因为我们需找到 target 的最左侧索引 所以当 nums[mid] == target 时不要⽴即返回 ⽽要收紧右侧边界以锁定左侧边界 第三个,寻找右侧边界的⼆分查找: 因为我们初始化 right = nums.length 所以决定了我们的「搜索区间」是 [left, right) 所以决定了 while (left < right) 同时也决定了 left = mid + 1 和 right = mid 因为我们需找到 target 的最右侧索引 所以当 nums[mid] == target 时不要⽴即返回 ⽽要收紧左侧边界以锁定右侧边界 ⼜因为收紧左侧边界时必须 left = mid + 1 所以最后⽆论返回 left 还是 right,必须减⼀ 对于寻找左右边界的⼆分搜索,常⻅的⼿法是使⽤左闭右开的「搜索区 间」,我们还根据逻辑将「搜索区间」全都统⼀成了两端都闭,便于记忆, 只要修改两处即可变化出三种写法: int binary_search(int[] nums, int target) { int left = 0, right = nums.length - 1; while(left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if(nums[mid] == target) { // 直接返回 return mid; } } // 直接返回 77
78. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 return -1; } int left_bound(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] == target) { // 别返回,锁定左侧边界 right = mid - 1; } } // 最后要检查 left 越界的情况 if (left >= nums.length nums[left] != target) return -1; return left; } int right_bound(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] == target) { // 别返回,锁定右侧边界 left = mid + 1; } } // 最后要检查 right 越界的情况 if (right < 0 nums[right] != target) return -1; return right; } 如果以上内容你都能理解,那么恭喜你,⼆分查找算法的细节不过如此。 78
79. 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 通过本⽂,你学会了: 1、分析⼆分查找代码时,不要出现 else,全部展开成 else if ⽅便理解。 2、注意「搜索区间」和 while 的终⽌条件,如果存在漏掉的元素,记得在 最后检查。 3、如需定义左闭右开的「搜索区间」搜索左右边界,只要在 target nums[mid] == 时做修改即可,搜索右侧时需要减⼀。 4、如果将「搜索区间」全都统⼀成两端都闭,好记,只要稍改 == target nums[mid] 条件处的代码和返回的逻辑即可,推荐拿⼩本本记下,作为⼆分 搜索模板。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 79
80. 我写了⾸诗,把滑动窗⼝算法算法变成了默写题 滑动窗⼝算法框架 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 76.最⼩覆盖⼦串 567.字符串的排列 438.找到字符串中所有字⺟异位词 3.⽆重复字符的最⻓⼦串 鉴于前⽂ ⼆分搜索框架详解 的那⾸《⼆分搜索升天词》很受好评,并在⺠ 间⼴为流传,成为安睡助眠的⼀剂良⽅,今天在滑动窗⼝算法框架中,我再 次编写⼀⾸⼩诗来歌颂滑动窗⼝算法的伟⼤: 80
81. 我写了⾸诗,把滑动窗⼝算法算法变成了默写题 关于双指针的快慢指针和左右指针的⽤法,可以参⻅前⽂ 双指针技巧汇 总,本⽂就解决⼀类最难掌握的双指针技巧:滑动窗⼝技巧。总结出⼀套框 架,可以保你闭着眼睛都能写出正确的解法。 说起滑动窗⼝算法,很多读者都会头疼。这个算法技巧的思路⾮常简单,就 是维护⼀个窗⼝,不断滑动,然后更新答案么。LeetCode 上有起码 10 道运 ⽤滑动窗⼝算法的题⽬,难度都是中等和困难。该算法的⼤致逻辑如下: int left = 0, right = 0; 81
82. 我写了⾸诗,把滑动窗⼝算法算法变成了默写题 while (right < s.size()) {` // 增⼤窗⼝ window.add(s[right]); right++; while (window needs shrink) { // 缩⼩窗⼝ window.remove(s[left]); left++; } } 这个算法技巧的时间复杂度是 O(N),⽐字符串暴⼒算法要⾼效得多。 其实困扰⼤家的,不是算法的思路,⽽是各种细节问题。⽐如说如何向窗⼝ 中添加新元素,如何缩⼩窗⼝,在窗⼝滑动的哪个阶段更新结果。即便你明 ⽩了这些细节,也容易出 bug,找 bug 还不知道怎么找,真的挺让⼈⼼烦 的。 所以今天我就写⼀套滑动窗⼝算法的代码框架,我连再哪⾥做输出 debug 都给你写好了,以后遇到相关的问题,你就默写出来如下框架然后改三个地 ⽅就⾏,还不会出 bug: _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 82
83. 我写了⾸诗,把滑动窗⼝算法算法变成了默写题 83
84. ⼀个⽅法团灭 LeetCode 股票买卖问题 团灭 LeetCode 股票买卖问题 学算法,认准 labuladong 就够了! 很多读者抱怨 LeetCode 的股票系列问题奇技淫巧太多,如果⾯试真的遇到 这类问题,基本不会想到那些巧妙的办法,怎么办?所以本⽂拒绝奇技淫 巧,⽽是稳扎稳打,只⽤⼀种通⽤⽅法解决所⽤问题,以不变应万变。 这篇⽂章⽤状态机的技巧来解决,可以全部提交通过。不要觉得这个名词⾼ ⼤上,⽂学词汇⽽已,实际上就是 DP table,看⼀眼就明⽩了。 先随便抽出⼀道题,看看别⼈的解法: int maxProfit(vector& prices) { if(prices.empty()) return 0; int s1=-prices[0],s2=INT_MIN,s3=INT_MIN,s4=INT_MIN; for(int i=1;i
85. ⼀个⽅法团灭 LeetCode 股票买卖问题 这 6 道题⽬是有共性的,我就抽出来第 4 道题⽬,因为这道题是⼀个最泛化 的形式,其他的问题都是这个形式的简化,看下题⽬: 第⼀题是只进⾏⼀次交易,相当于 k = 1;第⼆题是不限交易次数,相当于 k = +infinity(正⽆穷);第三题是只进⾏ 2 次交易,相当于 k = 2;剩下两 道也是不限次数,但是加了交易「冷冻期」和「⼿续费」的额外条件,其实 就是第⼆题的变种,都很容易处理。 如果你还不熟悉题⽬,可以去 LeetCode 查看这些题⽬的内容,本⽂为了节 省篇幅,就不列举这些题⽬的具体内容了。下⾯⾔归正传,开始解题。 ⼀、穷举框架 ⾸先,还是⼀样的思路:如何穷举?这⾥的穷举思路和上篇⽂章递归的思想 不太⼀样。 85
86. ⼀个⽅法团灭 LeetCode 股票买卖问题 递归其实是符合我们思考的逻辑的,⼀步步推进,遇到⽆法解决的就丢给递 归,⼀不⼩⼼就做出来了,可读性还很好。缺点就是⼀旦出错,你也不容易 找到错误出现的原因。⽐如上篇⽂章的递归解法,肯定还有计算冗余,但确 实不容易找到。 ⽽这⾥,我们不⽤递归思想进⾏穷举,⽽是利⽤「状态」进⾏穷举。我们具 体到每⼀天,看看总共有⼏种可能的「状态」,再找出每个「状态」对应的 「选择」。我们要穷举所有「状态」,穷举的⽬的是根据对应的「选择」更 新状态。听起来抽象,你只要记住「状态」和「选择」两个词就⾏,下⾯实 操⼀下就很容易明⽩了。 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 择优(选择1,选择2...) ⽐如说这个问题,每天都有三种「选择」:买⼊、卖出、⽆操作,我们⽤ buy, sell, rest 表⽰这三种选择。但问题是,并不是每天都可以任意选择这三 种选择的,因为 sell 必须在 buy 之后,buy 必须在 sell 之后。那么 rest 操作 还应该分两种状态,⼀种是 buy 之后的 rest(持有了股票),⼀种是 sell 之 后的 rest(没有持有股票)。⽽且别忘了,我们还有交易次数 k 的限制,就 是说你 buy 还只能在 k > 0 的前提下操作。 很复杂对吧,不要怕,我们现在的⽬的只是穷举,你有再多的状态,⽼夫要 做的就是⼀把梭全部列举出来。这个问题的「状态」有三个,第⼀个是天 数,第⼆个是允许交易的最⼤次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨⽤ 1 表⽰持有,0 表⽰没有持有)。然后我们⽤⼀个 三维数组就可以装下这⼏种状态的全部组合: dp[i][k][0 or 1] 0 <= i <= n-1, 1 <= k <= K n 为天数,⼤ K 为最多交易数 此问题共 n × K × 2 种状态,全部穷举就能搞定。 for 0 <= i < n: 86
87. ⼀个⽅法团灭 LeetCode 股票买卖问题 for 1 <= k <= K: for s in {0, 1}: dp[i][k][s] = max(buy, sell, rest) ⽽且我们可以⽤⾃然语⾔描述出每⼀个状态的含义,⽐如说 dp[3][2][1] 的含义就是:今天是第三天,我现在⼿上持有着股票,⾄今最多进⾏ 2 次交 易。再⽐如 dp[2][3][0] 的含义:今天是第⼆天,我现在⼿上没有持有股 票,⾄今最多进⾏ 3 次交易。很容易理解,对吧? 我们想求的最终答案是 dp[n - 1][K][0],即最后⼀天,最多允许 K 次交易, 最多获得多少利润。读者可能问为什么不是 dp[n - 1][K][1]?因为 [1] 代表⼿ 上还持有股票,[0] 表⽰⼿上的股票已经卖出去了,很显然后者得到的利润 ⼀定⼤于前者。 记住如何解释「状态」,⼀旦你觉得哪⾥不好理解,把它翻译成⾃然语⾔就 容易理解了。 ⼆、状态转移框架 现在,我们完成了「状态」的穷举,我们开始思考每种「状态」有哪些「选 择」,应该如何更新「状态」。只看「持有状态」,可以画个状态转移图。 87
88. ⼀个⽅法团灭 LeetCode 股票买卖问题 通过这个图可以很清楚地看到,每种状态(0 和 1)是如何转移⽽来的。根 据这个图,我们来写⼀下状态转移⽅程: dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) max( 选择 rest 选择 sell , ) 解释:今天我没有持有股票,有两种可能: 要么是我昨天就没有持有,然后今天选择 rest,所以我今天还是没有持有; 要么是我昨天持有股票,但是今天我 sell 了,所以我今天没有持有股票了。 dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) max( 选择 rest , 选择 buy ) 解释:今天我持有着股票,有两种可能: 要么我昨天就持有着股票,然后今天选择 rest,所以我今天还持有着股票; 要么我昨天本没有持有,但今天我选择 buy,所以今天我就持有股票了。 这个解释应该很清楚了,如果 buy,就要从利润中减去 prices[i],如果 sell, 就要给利润增加 prices[i]。今天的最⼤利润就是这两种可能选择中较⼤的那 个。⽽且注意 k 的限制,我们在选择 buy 的时候,把 k 减⼩了 1,很好理解 吧,当然你也可以在 sell 的时候减 1,⼀样的。 88
89. ⼀个⽅法团灭 LeetCode 股票买卖问题 现在,我们已经完成了动态规划中最困难的⼀步:状态转移⽅程。如果之前 的内容你都可以理解,那么你已经可以秒杀所有问题了,只要套这个框架就 ⾏了。不过还差最后⼀点点,就是定义 base case,即最简单的情况。 dp[-1][k][0] = 0 解释:因为 i 是从 0 开始的,所以 i = -1 意味着还没有开始,这时候的利润当然是 0 。 dp[-1][k][1] = -infinity 解释:还没开始的时候,是不可能持有股票的,⽤负⽆穷表⽰这种不可能。 dp[i][0][0] = 0 解释:因为 k 是从 1 开始的,所以 k = 0 意味着根本不允许交易,这时候利润当然是 0 。 dp[i][0][1] = -infinity 解释:不允许交易的情况下,是不可能持有股票的,⽤负⽆穷表⽰这种不可能。 把上⾯的状态转移⽅程总结⼀下: base case: dp[-1][k][0] = dp[i][0][0] = 0 dp[-1][k][1] = dp[i][0][1] = -infinity 状态转移⽅程: dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) 读者可能会问,这个数组索引是 -1 怎么编程表⽰出来呢,负⽆穷怎么表⽰ 呢?这都是细节问题,有很多⽅法实现。现在完整的框架已经完成,下⾯开 始具体化。 三、秒杀题⽬ 第⼀题,k = 1 直接套状态转移⽅程,根据 base case,可以做⼀些化简: dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i]) dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][0][0] - prices[i]) = max(dp[i-1][1][1], -prices[i]) 89
90. ⼀个⽅法团灭 LeetCode 股票买卖问题 解释:k = 0 的 base case,所以 dp[i-1][0][0] = 0。 现在发现 k 都是 1,不会改变,即 k 对状态转移已经没有影响了。 可以进⾏进⼀步化简去掉所有 k: dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]) dp[i][1] = max(dp[i-1][1], -prices[i]) 直接写出代码: int n = prices.length; int[][] dp = new int[n][2]; for (int i = 0; i < n; i++) { dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]); dp[i][1] = Math.max(dp[i-1][1], -prices[i]); } return dp[n - 1][0]; 显然 i = 0 时 dp[i-1] 是不合法的。这是因为我们没有对 i 的 base case 进⾏处 理。可以这样处理: for (int i = 0; i < n; i++) { if (i - 1 == -1) { dp[i][0] = 0; // 解释: // dp[i][0] // = max(dp[-1][0], dp[-1][1] + prices[i]) // = max(0, -infinity + prices[i]) = 0 dp[i][1] = -prices[i]; //解释: // dp[i][1] // = max(dp[-1][1], dp[-1][0] - prices[i]) // = max(-infinity, 0 - prices[i]) // = -prices[i] continue; } dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]); dp[i][1] = Math.max(dp[i-1][1], -prices[i]); } return dp[n - 1][0]; 90
91. ⼀个⽅法团灭 LeetCode 股票买卖问题 第⼀题就解决了,但是这样处理 base case 很⿇烦,⽽且注意⼀下状态转移 ⽅程,新状态只和相邻的⼀个状态有关,其实不⽤整个 dp 数组,只需要⼀ 个变量储存相邻的那个状态就⾜够了,这样可以把空间复杂度降到 O(1): // k == 1 int maxProfit_k_1(int[] prices) { int n = prices.length; // base case: dp[-1][0] = 0, dp[-1][1] = -infinity int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE; for (int i = 0; i < n; i++) { // dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]) dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]); // dp[i][1] = max(dp[i-1][1], -prices[i]) dp_i_1 = Math.max(dp_i_1, -prices[i]); } return dp_i_0; } 两种⽅式都是⼀样的,不过这种编程⽅法简洁很多。但是如果没有前⾯状态 转移⽅程的引导,是肯定看不懂的。后续的题⽬,我主要写这种空间复杂度 O(1) 的解法。 第⼆题,k = +infinity 如果 k 为正⽆穷,那么就可以认为 k 和 k - 1 是⼀样的。可以这样改写框 架: dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) = max(dp[i-1][k][1], dp[i-1][k][0] - prices[i]) 我们发现数组中的 k 已经不会改变了,也就是说不需要记录 k 这个状态了: dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]) dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]) 直接翻译成代码: int maxProfit_k_inf(int[] prices) { 91
92. ⼀个⽅法团灭 LeetCode 股票买卖问题 int n = prices.length; int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE; for (int i = 0; i < n; i++) { int temp = dp_i_0; dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]); dp_i_1 = Math.max(dp_i_1, temp - prices[i]); } return dp_i_0; } 第三题,k = +infinity with cooldown 每次 sell 之后要等⼀天才能继续交易。只要把这个特点融⼊上⼀题的状态转 移⽅程即可: dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]) dp[i][1] = max(dp[i-1][1], dp[i-2][0] - prices[i]) 解释:第 i 天选择 buy 的时候,要从 i-2 的状态转移,⽽不是 i-1 。 翻译成代码: int maxProfit_with_cool(int[] prices) { int n = prices.length; int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE; int dp_pre_0 = 0; // 代表 dp[i-2][0] for (int i = 0; i < n; i++) { int temp = dp_i_0; dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]); dp_i_1 = Math.max(dp_i_1, dp_pre_0 - prices[i]); dp_pre_0 = temp; } return dp_i_0; } 第四题,k = +infinity with fee 每次交易要⽀付⼿续费,只要把⼿续费从利润中减去即可。改写⽅程: 92
93. ⼀个⽅法团灭 LeetCode 股票买卖问题 dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]) dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i] - fee) 解释:相当于买⼊股票的价格升⾼了。 在第⼀个式⼦⾥减也是⼀样的,相当于卖出股票的价格减⼩了。 直接翻译成代码: int maxProfit_with_fee(int[] prices, int fee) { int n = prices.length; int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE; for (int i = 0; i < n; i++) { int temp = dp_i_0; dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]); dp_i_1 = Math.max(dp_i_1, temp - prices[i] - fee); } return dp_i_0; } 第五题,k = 2 k = 2 和前⾯题⽬的情况稍微不同,因为上⾯的情况都和 k 的关系不太⼤。 要么 k 是正⽆穷,状态转移和 k 没关系了;要么 k = 1,跟 k = 0 这个 base case 挨得近,最后也没有存在感。 这道题 k = 2 和后⾯要讲的 k 是任意正整数的情况中,对 k 的处理就凸显出 来了。我们直接写代码,边写边分析原因。 原始的动态转移⽅程,没有可化简的地⽅ dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) 按照之前的代码,我们可能想当然这样写代码(错误的): int k = 2; int[][][] dp = new int[n][k + 1][2]; for (int i = 0; i < n; i++) if (i - 1 == -1) { /* 处理⼀下 base case*/ } 93
94. ⼀个⽅法团灭 LeetCode 股票买卖问题 dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]); dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i] ); } return dp[n - 1][k][0]; 为什么错误?我这不是照着状态转移⽅程写的吗? 还记得前⾯总结的「穷举框架」吗?就是说我们必须穷举所有状态。其实我 们之前的解法,都在穷举所有状态,只是之前的题⽬中 k 都被化简掉了。⽐ 如说第⼀题,k = 1: 「代码截图」 这道题由于没有消掉 k 的影响,所以必须要对 k 进⾏穷举: int max_k = 2; int[][][] dp = new int[n][max_k + 1][2]; for (int i = 0; i < n; i++) { for (int k = max_k; k >= 1; k--) { if (i - 1 == -1) { /*处理 base case */ } dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]); dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) ; } } // 穷举了 n × max_k × 2 个状态,正确。 return dp[n - 1][max_k][0]; 如果你不理解,可以返回第⼀点「穷举框架」重新阅读体会⼀下。 这⾥ k 取值范围⽐较⼩,所以可以不⽤ for 循环,直接把 k = 1 和 2 的情况 全部列举出来也可以: dp[i][2][0] = max(dp[i-1][2][0], dp[i-1][2][1] + prices[i]) dp[i][2][1] = max(dp[i-1][2][1], dp[i-1][1][0] - prices[i]) dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i]) dp[i][1][1] = max(dp[i-1][1][1], -prices[i]) 94
95. ⼀个⽅法团灭 LeetCode 股票买卖问题 int maxProfit_k_2(int[] prices) { int dp_i10 = 0, dp_i11 = Integer.MIN_VALUE; int dp_i20 = 0, dp_i21 = Integer.MIN_VALUE; for (int price : prices) { dp_i20 = Math.max(dp_i20, dp_i21 + price); dp_i21 = Math.max(dp_i21, dp_i10 - price); dp_i10 = Math.max(dp_i10, dp_i11 + price); dp_i11 = Math.max(dp_i11, -price); } return dp_i20; } 有状态转移⽅程和含义明确的变量名指导,相信你很容易看懂。其实我们可 以故弄⽞虚,把上述四个变量换成 a, b, c, d。这样当别⼈看到你的代码时就 会⼤惊失⾊,对你肃然起敬。 第六题,k = any integer 有了上⼀题 k = 2 的铺垫,这题应该和上⼀题的第⼀个解法没啥区别。但是 出现了⼀个超内存的错误,原来是传⼊的 k 值会⾮常⼤,dp 数组太⼤了。 现在想想,交易次数 k 最多有多⼤呢? ⼀次交易由买⼊和卖出构成,⾄少需要两天。所以说有效的限制 k 应该不超 过 n/2,如果超过,就没有约束作⽤了,相当于 k = +infinity。这种情况是之 前解决过的。 直接把之前的代码重⽤: int maxProfit_k_any(int max_k, int[] prices) { int n = prices.length; if (max_k > n / 2) return maxProfit_k_inf(prices); int[][][] dp = new int[n][max_k + 1][2]; for (int i = 0; i < n; i++) for (int k = max_k; k >= 1; k--) { if (i - 1 == -1) { /* 处理 base case */ } dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i ]); dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices 95
96. ⼀个⽅法团灭 LeetCode 股票买卖问题 [i]); } return dp[n - 1][max_k][0]; } ⾄此,6 道题⽬通过⼀个状态转移⽅程全部解决。 四、最后总结 本⽂给⼤家讲了如何通过状态转移的⽅法解决复杂的问题,⽤⼀个状态转移 ⽅程秒杀了 6 道股票买卖问题,现在想想,其实也不算难对吧?这已经属于 动态规划问题中较困难的了。 关键就在于列举出所有可能的「状态」,然后想想怎么穷举更新这些「状 态」。⼀般⽤⼀个多维 dp 数组储存这些状态,从 base case 开始向后推进, 推进到最后的状态,就是我们想要的答案。想想这个过程,你是不是有点理 解「动态规划」这个名词的意义了呢? 具体到股票买卖问题,我们发现了三个状态,使⽤了⼀个三维数组,⽆⾮还 是穷举 + 更新,不过我们可以说的⾼⼤上⼀点,这叫「三维 DP」,怕不 怕?这个⼤实话⼀说,⽴刻显得你⾼⼈⼀等,名利双收有没有,所以给个在 看/分享吧,⿎励⼀下我。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 96
97. ⼀个⽅法团灭 LeetCode 打家劫舍问题 团灭 LeetCode 打家劫舍问题 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 198.打家劫舍 213.打家劫舍II 337.打家劫舍III 有读者私下问我 LeetCode 「打家劫舍」系列问题(英⽂版叫 House Robber)怎么做,我发现这⼀系列题⽬的点赞⾮常之⾼,是⽐较有代表性和 技巧性的动态规划题⽬,今天就来聊聊这道题⽬。 打家劫舍系列总共有三道,难度设计⾮常合理,层层递进。第⼀道是⽐较标 准的动态规划问题,⽽第⼆道融⼊了环形数组的条件,第三道更绝,把动态 规划的⾃底向上和⾃顶向下解法和⼆叉树结合起来,我认为很有启发性。如 果没做过的朋友,建议学习⼀下。 下⾯,我们从第⼀道开始分析。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 97
98. ⼀个⽅法团灭 LeetCode 打家劫舍问题 98
99. ⼀个⽅法团灭 nSum 问题 回溯算法和动态规划,谁是谁爹? 学好算法全靠套路,认准 labuladong 就够了。 相关推荐: Union-Find算法详解 贪⼼算法之区间调度问题 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 1.两数之和 15.三数之和 18.四数之和 经常刷 LeetCode 的读者肯定知道⿍⿍有名的 twoSum 问题的核⼼思想 就对 但是除了 后出个 twoSum 5Sum , twoSum 问题,我们旧⽂ 的⼏个变种做了解析。 问题,LeetCode 上⾯还有 6Sum twoSum 3Sum , 4Sum 问题,我估计以 也不是不可能。 那么,对于这种问题有没有什么好办法⽤套路解决呢? 今天 labuladong 就由浅⼊深,层层推进,⽤⼀个函数来解决所有 nSum 类 型的问题。 _____________ 99
100. ⼀个⽅法团灭 nSum 问题 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 100
101. 经典动态规划:⾼楼扔鸡蛋 经典动态规划问题:⾼楼扔鸡蛋 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 887.鸡蛋掉落 今天要聊⼀个很经典的算法问题,若⼲层楼,若⼲个鸡蛋,让你算出最少的 尝试次数,找到鸡蛋恰好摔不碎的那层楼。国内⼤⼚以及⾕歌脸书⾯试都经 常考察这道题,只不过他们觉得扔鸡蛋太浪费,改成扔杯⼦,扔破碗什么 的。 具体的问题等会再说,但是这道题的解法技巧很多,光动态规划就好⼏种效 率不同的思路,最后还有⼀种极其⾼效数学解法。秉承咱们号⼀贯的作⻛, 拒绝奇技淫巧,拒绝过于诡异的技巧,因为这些技巧⽆法举⼀反三,学了也 不划算。 下⾯就来⽤我们⼀直强调的动态规划通⽤思路来研究⼀下这道题。 ⼀、解析题⽬ 题⽬是这样:你⾯前有⼀栋从 1 到 ( K N 共 N ⾄少为 1)。现在确定这栋楼存在楼层 蛋扔下去,鸡蛋恰好没摔碎(⾼于 F 层的楼,然后给你 0 <= F <= N K 个鸡蛋 ,在这层楼将鸡 的楼层都会碎,低于 F 的楼层都不 会碎)。现在问你,最坏情况下,你⾄少要扔⼏次鸡蛋,才能确定这个楼层 F 呢? 也就是让你找摔不碎鸡蛋的最⾼楼层 F ,但什么叫「最坏情况」下「⾄ 少」要扔⼏次呢?我们分别举个例⼦就明⽩了。 101
102. 经典动态规划:⾼楼扔鸡蛋 ⽐⽅说现在先不管鸡蛋个数的限制,有 7 层楼,你怎么去找鸡蛋恰好摔碎的 那层楼? 最原始的⽅式就是线性扫描:我先在 1 楼扔⼀下,没碎,我再去 2 楼扔⼀ 下,没碎,我再去 3 楼…… 以这种策略,最坏情况应该就是我试到第 7 层鸡蛋也没碎( F = 7 ),也 就是我扔了 7 次鸡蛋。 先在你应该理解什么叫做「最坏情况」下了,鸡蛋破碎⼀定发⽣在搜索区间 穷尽时,不会说你在第 1 层摔⼀下鸡蛋就碎了,这是你运⽓好,不是最坏情 况。 现在再来理解⼀下什么叫「⾄少」要扔⼏次。依然不考虑鸡蛋个数限制,同 样是 7 层楼,我们可以优化策略。 最好的策略是使⽤⼆分查找思路,我先去第 如果碎了说明 F ⼩于 4,我就去第 如果没碎说明 F ⼤于等于 4,我就去第 (1 + 7) / 2 = 4 (1 + 3) / 2 = 2 层试…… (5 + 7) / 2 = 6 以这种策略,最坏情况应该是试到第 7 层鸡蛋还没碎( 蛋⼀直碎到第 1 层( F = 0 层扔⼀下: 层试…… F = 7 ),或者鸡 )。然⽽⽆论那种最坏情况,只需要试 log7 向上取整等于 3 次,⽐刚才尝试 7 次要少,这就是所谓的⾄少要扔⼏次。 PS:这有点像 Big O 表⽰法计算​算法的复杂度。 实际上,如果不限制鸡蛋个数的话,⼆分思路显然可以得到最少尝试的次 数,但问题是,现在给你了鸡蛋个数的限制 K ,直接使⽤⼆分思路就不⾏ 了。 ⽐如说只给你 1 个鸡蛋,7 层楼,你敢⽤⼆分吗?你直接去第 4 层扔⼀下, 如果鸡蛋没碎还好,但如果碎了你就没有鸡蛋继续测试了,⽆法确定鸡蛋恰 好摔不碎的楼层 F 了。这种情况下只能⽤线性扫描的⽅法,算法返回结果 应该是 7。 102
103. 经典动态规划:⾼楼扔鸡蛋 有的读者也许会有这种想法:⼆分查找排除楼层的速度⽆疑是最快的,那⼲ 脆先⽤⼆分查找,等到只剩 1 个鸡蛋的时候再执⾏线性扫描,这样得到的结 果是不是就是最少的扔鸡蛋次数呢? 很遗憾,并不是,⽐如说把楼层变⾼⼀些,100 层,给你 2 个鸡蛋,你在 50 层扔⼀下,碎了,那就只能线性扫描 1〜49 层了,最坏情况下要扔 50 次。 如果不要「⼆分」,变成「五分」「⼗分」都会⼤幅减少最坏情况下的尝试 次数。⽐⽅说第⼀个鸡蛋每隔⼗层楼扔,在哪⾥碎了第⼆个鸡蛋⼀个个线性 扫描,总共不会超过 20 次​。 最优解其实是 14 次。最优策略⾮常多,⽽且并没有什么规律可⾔。 说了这么多废话,就是确保⼤家理解了题⽬的意思,⽽且认识到这个题⽬确 实复杂,就连我们⼿算都不容易,如何⽤算法解决呢? ⼆、思路分析 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 103
104. 经典动态规划:⼦集背包问题 背包问题变体之⼦集分割 学算法,认准 labuladong 就够了! 上篇⽂章 经典动态规划:0-1 背包问题 详解了通⽤的 0-1 背包问题,今天来 看看背包问题的思想能够如何运⽤到其他算法题⽬。 ⽽且,不是经常有读者问,怎么将⼆维动态规划压缩成⼀维动态规划吗?这 就是状态压缩,很容易的,本⽂也会提及这种技巧。 ⼀、问题分析 先看⼀下题⽬: 104
105. 经典动态规划:⼦集背包问题 算法的函数签名如下: // 输⼊⼀个集合,返回是否能够分割成和相等的两个⼦集 bool canPartition(vector& nums); 对于这个问题,看起来和背包没有任何关系,为什么说它是背包问题呢? ⾸先回忆⼀下背包问题⼤致的描述是什么: 给你⼀个可装载重量为 个属性。其中第 i W 的背包和 个物品的重量为 N 个物品,每个物品有重量和价值两 wt[i] ,价值为 val[i] ,现在让你⽤ 这个背包装物品,最多能装的价值是多少? 那么对于这个问题,我们可以先对集合求和,得出 sum ,把问题转化为背 包问题: 105
106. 经典动态规划:⼦集背包问题 给⼀个可装载重量为 nums[i] 的背包和 sum / 2 个物品,每个物品的重量为 N 。现在让你装物品,是否存在⼀种装法,能够恰好将背包装满? 你看,这就是背包问题的模型,甚⾄⽐我们之前的经典背包问题还要简单⼀ 些,下⾯我们就直接转换成背包问题,开始套前⽂讲过的背包问题框架即 可。 ⼆、解法分析 第⼀步要明确两点,「状态」和「选择」。 这个前⽂ 经典动态规划:背包问题 已经详细解释过了,状态就是「背包的 容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。 第⼆步要明确 dp 数组的定义。 按照背包问题的套路,可以给出如下定义: dp[i][j] = x 为 true 表⽰,对于前 i 个物品,当前背包的容量为 ,则说明可以恰好将背包装满,若 x 为 false j 时,若 x ,则说明不能恰 好将背包装满。 ⽐如说,如果 dp[4][9] = true ,其含义为:对于容量为 9 的背包,若只是 ⽤钱 4 个物品,可以有⼀种⽅法把背包恰好装满。 或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进⾏选择, 存在⼀个⼦集的和可以恰好凑出 9。 根据这个定义,我们想求的最终答案就是 dp[..][0] = true 和 dp[0][..] = false dp[N][sum/2] ,base case 就是 ,因为背包没有空间的时候,就相 当于装满了,⽽当没有物品可选择的时候,肯定没办法装满背包。 第三步,根据「选择」,思考状态转移的逻辑。 回想刚才的 dp 数组含义,可以根据「选择」对 dp[i][j] 得到以下状态 转移: 106
107. 经典动态规划:⼦集背包问题 如果不把 算⼊⼦集,或者说你不把这第 nums[i] 么是否能够恰好装满背包,取决于上⼀个状态 i 个物品装⼊背包,那 dp[i-1][j] ,继承之前的结 果。 如果把 算⼊⼦集,或者说你把这第 nums[i] 是否能够恰好装满背包,取决于状态 ⾸先,由于 i 个物品装⼊了背包,那么 dp[i - 1][j-nums[i-1]] 。 是从 1 开始的,⽽数组索引是从 0 开始的,所以第 品的重量应该是 nums[i-1] dp[i - 1][j-nums[i-1]] 包的剩余重量 i 个物 ,这⼀点不要搞混。 也很好理解:你如果装了第 j - nums[i-1] 换句话说,如果 i 个物品,就要看背 限制下是否能够被恰好装满。 的重量可以被恰好装满,那么只要把第 j - nums[i-1] 个物品装进去,也可恰好装满 i j 的重量;否则的话,重量 j i 肯定是装不 满的。 最后⼀步,把伪码翻译成代码,处理⼀些边界情况。 以下是我的 C++ 代码,完全翻译了之前的思路,并处理了⼀些边界情况: bool canPartition(vector& nums) { int sum = 0; for (int num : nums) sum += num; // 和为奇数时,不可能划分成两个和相等的集合 if (sum % 2 != 0) return false; int n = nums.size(); sum = sum / 2; vector> dp(n + 1, vector(sum + 1, false)); // base case for (int i = 0; i <= n; i++) dp[i][0] = true; for (int i = 1; i <= n; i++) { for (int j = 1; j <= sum; j++) { if (j - nums[i - 1] < 0) { // 背包容量不⾜,不能装⼊第 i 个物品 dp[i][j] = dp[i - 1][j]; } else { 107
108. 经典动态规划:⼦集背包问题 // 装⼊或不装⼊背包 dp[i][j] = dp[i - 1][j] dp[i - 1][j-nums[i-1]]; } } } return dp[n][sum]; } 三、进⾏状态压缩 再进⼀步,是否可以优化这个代码呢?注意到 dp[i-1][..] dp[i][j] 都是通过上⼀⾏ 转移过来的,之前的数据都不会再使⽤了。 所以,我们可以进⾏状态压缩,将⼆维 dp 数组压缩为⼀维,节约空间复 杂度: bool canPartition(vector& nums) { int sum = 0, n = nums.size(); for (int num : nums) sum += num; if (sum % 2 != 0) return false; sum = sum / 2; vector dp(sum + 1, false); // base case dp[0] = true; for (int i = 0; i < n; i++) for (int j = sum; j >= 0; j--) if (j - nums[i] >= 0) dp[j] = dp[j] dp[j - nums[i]]; return dp[sum]; } 这就是状态压缩,其实这段代码和之前的解法思路完全相同,只在⼀⾏ dp [j] 数组上操作, i 每进⾏⼀轮迭代, dp[j] 其实就相当于 dp[i-1] ,所以只需要⼀维数组就够⽤了。 108
109. 经典动态规划:⼦集背包问题 唯⼀需要注意的是 j 应该从后往前反向遍历,因为每个物品(或者说数 字)只能⽤⼀次,以免之前的结果影响其他的结果。 ⾄此,⼦集切割的问题就完全解决了,时间复杂度 O(n*sum),空间复杂度 O(sum)。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 109
110. 经典动态规划:完全背包问题 背包问题之零钱兑换 学算法,认准 labuladong 就够了! 零钱兑换 2 是另⼀种典型背包问题的变体,我们前⽂已经讲了 经典动态规 划:0-1 背包问题 和 背包问题变体:相等⼦集分割。 希望你已经看过前两篇⽂章,看过了动态规划和背包问题的套路,这篇继续 按照背包问题的套路,列举⼀个背包问题的变形。 本⽂聊的是 LeetCode 第 518 题 Coin Change 2,题⽬如下: int change(int amount, int[] coins); PS:⾄于 Coin Change 1,在我们前⽂ 动态规划套路详解 写过。 我们可以把这个问题转化为背包问题的描述形式: 110
111. 经典动态规划:完全背包问题 有⼀个背包,最⼤容量为 量为 coins[i] amount ,有⼀系列物品 coins ,每个物品的重 ,每个物品的数量⽆限。请问有多少种⽅法,能够把背包恰 好装满? 这个问题和我们前⾯讲过的两个背包问题,有⼀个最⼤的区别就是,每个物 品的数量是⽆限的,这也就是传说中的「完全背包问题」,没啥⾼⼤上的, ⽆⾮就是状态转移⽅程有⼀点变化⽽已。 下⾯就以背包问题的描述形式,继续按照流程来分析。 解题思路 第⼀步要明确两点,「状态」和「选择」。 状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背 包」或者「不装进背包」嘛,背包问题的套路都是这样。 明⽩了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完 事⼉了: for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 计算(选择1,选择2...) 第⼆步要明确 dp 数组的定义。 ⾸先看看刚才找到的「状态」,有两个,也就是说我们需要⼀个⼆维 dp 数组。 dp[i][j] 的定义如下: 若只使⽤前 i 个物品,当背包容量为 j 时,有 dp[i][j] 种⽅法可以装 满背包。 换句话说,翻译回我们题⽬的意思就是: 111
112. 经典动态规划:完全背包问题 若只使⽤ [j] coins 中的前 i 个硬币的⾯值,若想凑出⾦额 j ,有 dp[i] 种凑法。 经过以上的定义,可以得到: base case 为 。因为如果不使⽤任何硬币⾯ dp[0][..] = 0, dp[..][0] = 1 值,就⽆法凑出任何⾦额;如果凑出的⽬标⾦额为 0,那么“⽆为⽽治”就是 唯⼀的⼀种凑法。 我们最终想得到的答案就是 dp[N][amount] ,其中 N 为 coins 数组的⼤ ⼩。 ⼤致的伪码思路如下: int dp[N+1][amount+1] dp[0][..] = 0 dp[..][0] = 1 for i in [1..N]: for j in [1..amount]: 把物品 i 装进背包, 不把物品 i 装进背包 return dp[N][amount] 第三步,根据「选择」,思考状态转移的逻辑。 注意,我们这个问题的特殊点在于物品的数量是⽆限的,所以这⾥和之前写 的背包问题⽂章有所不同。 如果你不把这第 i 个物品装⼊背包,也就是说你不使⽤ 值的硬币,那么凑出⾯额 j 的⽅法数 dp[i][j] coins[i] 应该等于 这个⾯ dp[i-1][j] , 继承之前的结果。 如果你把这第 i 的硬币,那么 dp[i][j] ⾸先由于 i 个物品装⼊了背包,也就是说你使⽤ 应该等于 是从 1 开始的,所以 dp[i][j-coins[i-1]] coins 的索引是 i-1 coins[i] 这个⾯值 。 时表⽰第 i 个硬 币的⾯值。 112
113. 经典动态规划:完全背包问题 dp[i][j-coins[i-1]] 也不难理解,如果你决定使⽤这个⾯值的硬币,那么 就应该关注如何凑出⾦额 j - coins[i-1] 。 ⽐如说,你想⽤⾯值为 2 的硬币凑出⾦额 5,那么如果你知道了凑出⾦额 3 的⽅法,再加上⼀枚⾯额为 2 的硬币,不就可以凑出 5 了嘛。 综上就是两种选择,⽽我们想求的 dp[i][j] dp[i][j] 是「共有多少种凑法」,所以 的值应该是以上两种选择的结果之和: for (int i = 1; i <= n; i++) { for (int j = 1; j <= amount; j++) { if (j - coins[i-1] >= 0) dp[i][j] = dp[i - 1][j] + dp[i][j-coins[i-1]]; return dp[N][W] 最后⼀步,把伪码翻译成代码,处理⼀些边界情况。 我⽤ Java 写的代码,把上⾯的思路完全翻译了⼀遍,并且处理了⼀些边界 问题: int change(int amount, int[] coins) { int n = coins.length; int[][] dp = amount int[n + 1][amount + 1]; // base case for (int i = 0; i <= n; i++) dp[i][0] = 1; for (int i = 1; i <= n; i++) { for (int j = 1; j <= amount; j++) if (j - coins[i-1] >= 0) dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i-1]]; else dp[i][j] = dp[i - 1][j]; } return dp[n][amount]; } 113
114. 经典动态规划:完全背包问题 ⽽且,我们通过观察可以发现, 1][..] dp 数组的转移只和 dp[i][..] 和 dp[i- 有关,所以可以压缩状态,进⼀步降低算法的空间复杂度: int change(int amount, int[] coins) { int n = coins.length; int[] dp = new int[amount + 1]; dp[0] = 1; // base case for (int i = 0; i < n; i++) for (int j = 1; j <= amount; j++) if (j - coins[i] >= 0) dp[j] = dp[j] + dp[j-coins[i]]; return dp[amount]; } 这个解法和之前的思路完全相同,将⼆维 dp 数组压缩为⼀维,时间复杂 度 O(N*amount),空间复杂度 O(amount)。 ⾄此,这道零钱兑换问题也通过背包问题的框架解决了。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 114
115. 表达式求值算法:实现计算器 拆解复杂问题:实现计算器 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 224.基本计算器 227.基本计算器II 772.基本计算器III 我们最终要实现的计算器功能如下: 1、输⼊⼀个字符串,可以包含 + - * / 、数字、括号以及空格,你的算法 返回运算结果。 2、要符合运算法则,括号的优先级最⾼,先乘除后加减。 3、除号是整数除法,⽆论正负都向 0 取整(5/2=2,-5/2=-2)。 4、可以假定输⼊的算式⼀定合法,且计算过程不会出现整型溢出,不会出 现除数为 0 的意外情况。 ⽐如输⼊如下字符串,算法会返回 9: 3 * (2-6 /(3 -7)) 可以看到,这就已经⾮常接近我们实际⽣活中使⽤的计算器了,虽然我们以 前肯定都⽤过计算器,但是如果简单思考⼀下其算法实现,就会⼤惊失⾊: 115
116. 表达式求值算法:实现计算器 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 116
117. 第⼀章、动态规划系列 动态规划系列 学算法,认准 labuladong 就够了! 我们公众号最⽕的就是动态规划系列的⽂章,也许是动态规划问题有难度⽽ 且有意思,也许因为它是⾯试常考题型。不管你之前是否害怕动态规划系列 的问题,相信这⼀章的内容⾜以帮助你消除对动态规划算法的恐惧。 具体来说,动态规划的⼀般流程就是三步:暴⼒的递归解法 -> 带备忘录的 递归解法 -> 迭代的动态规划解法。 就思考流程来说,就分为⼀下⼏步:找到状态和选择 -> 明确 dp 数组/函数 的定义 -> 寻找状态之间的关系。 这就是思维模式的框架,本章都会按照以上的模式来解决问题,辅助读者养 成这种模式思维,有了⽅向遇到问题就不会抓瞎,⾜以解决⼀般的动态规划 问题。 欢迎关注我的公众号 labuladong,⽅便获得最新的优质⽂章: 117
118. 动态规划答疑篇 动态规划答疑篇 学好算法全靠套路,认准 labuladong 就够了。 这篇⽂章就给你讲明⽩两个问题: 1、到底什么才叫「最优⼦结构」,和动态规划什么关系。 2、为什么动态规划遍历 dp 数组的⽅式五花⼋门,有的正着遍历,有的倒 着遍历,有的斜着遍历。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 118
119. 动态规划和回溯算法到底谁是谁爹? 回溯算法和动态规划,谁是谁爹? 学好算法全靠套路,认准 labuladong 就够了。 相关推荐: 我写了⾸诗,让你闭着眼睛也能写对⼆分搜索 动态规划之⼦序列问题解题模板 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 494.⽬标和 我们前⽂经常说回溯算法和递归算法有点类似,有的问题如果实在想不出状 态转移⽅程,尝试⽤回溯算法暴⼒解决也是⼀个聪明的策略,总⽐写不出来 解法强。 那么,回溯算法和动态规划到底是啥关系?它俩都涉及递归,算法模板看起 来还挺像的,都涉及做「选择」,真的酷似⽗与⼦。 119
120. 动态规划和回溯算法到底谁是谁爹? 那么,它俩具体有啥区别呢?回溯算法和动态规划之间,是否可能互相转化 呢? 今天就⽤⼒扣第 494 题「⽬标和」来详细对⽐⼀下回溯算法和动态规划,真 可谓群魔乱舞: 注意,给出的例⼦ nums 全是 1,但实际上可以是任意正整数哦。 120
121. 动态规划和回溯算法到底谁是谁爹? ⼀、回溯思路 其实我第⼀眼看到这个题⽬,花了两分钟就写出了⼀个回溯解法。 任何算法的核⼼都是穷举,回溯算法就是⼀个暴⼒穷举算法,前⽂ 回溯算 法解题框架 就写了回溯算法框架: def backtrack(路径, 选择列表): if 满⾜结束条件: result.add(路径) return for 选择 in 选择列表: 做选择 backtrack(路径, 选择列表) 撤销选择 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 121
122. 动态规划设计:最⻓递增⼦序列 动态规划设计:最⻓递增⼦序列 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 300.最⻓上升⼦序列 也许有读者看了前⽂ 动态规划详解,学会了动态规划的套路:找到了问题 的「状态」,明确了 dp 数组/函数的含义,定义了 base case;但是不知道 如何确定「选择」,也就是不到状态转移的关系,依然写不出动态规划解 法,怎么办? 不要担⼼,动态规划的难点本来就在于寻找正确的状态转移⽅程,本⽂就借 助经典的「最⻓递增⼦序列问题」来讲⼀讲设计动态规划的通⽤技巧:数学 归纳思想。 最⻓递增⼦序列(Longest Increasing Subsequence,简写 LIS)是⾮常经典的 ⼀个算法问题,⽐较容易想到的是动态规划解法,时间复杂度 O(N^2),我 们借这个问题来由浅⼊深讲解如何找状态转移⽅程,如何写出动态规划解 法。⽐较难想到的是利⽤⼆分查找,时间复杂度是 O(NlogN),我们通过⼀ 种简单的纸牌游戏来辅助理解这种巧妙的解法。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 122
123. 动态规划设计:最⻓递增⼦序列 123
124. 经典动态规划:0-1 背包问题 动态规划之背包问题 学好算法全靠套路,认准 labuladong 就够了。 后台天天有⼈问背包问题,这个问题其实不难啊,如果我们号动态规划系列 的⼗⼏篇⽂章你都看过,借助框架,遇到背包问题可以说是⼿到擒来好吧。 ⽆⾮就是状态 + 选择,也没啥特别之处嘛。 今天就来说⼀下背包问题吧,就讨论最常说的 0-1 背包问题。描述: 给你⼀个可装载重量为 个属性。其中第 i W 的背包和 个物品的重量为 N 个物品,每个物品有重量和价值两 wt[i] ,价值为 val[i] ,现在让你⽤ 这个背包装物品,最多能装的价值是多少? 124
125. 经典动态规划:0-1 背包问题 举个简单的例⼦,输⼊如下: N = 3, W = 4 wt = [2, 1, 3] val = [4, 2, 3] 算法返回 6,选择前两件物品装进背包,总重量 3 ⼩于 W ,可以获得最⼤ 价值 6。 题⽬就是这么简单,⼀个典型的动态规划问题。这个题⽬中的物品不可以分 割,要么装进包⾥,要么不装,不能说切成两块装⼀半。这就是 0-1 背包这 个名词的来历。 解决这个问题没有什么排序之类巧妙的⽅法,只能穷举所有可能,根据我们 「动态规划详解」中的套路,直接⾛流程就⾏了。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 125
126. 经典动态规划:0-1 背包问题 126
127. 经典动态规划:编辑距离 编辑距离 学算法,认准 labuladong 就够了! 前⼏天看了⼀份鹅场的⾯试题,算法部分⼤半是动态规划,最后⼀题就是写 ⼀个计算编辑距离的函数,今天就专门写⼀篇⽂章来探讨⼀下这个问题。 我个⼈很喜欢编辑距离这个问题,因为它看起来⼗分困难,解法却出奇得简 单漂亮,⽽且它是少有的⽐较实⽤的算法(是的,我承认很多算法问题都不 太实⽤)。下⾯先来看下题⽬: 127
128. 经典动态规划:编辑距离 为什么说这个问题难呢,因为显⽽易⻅,它就是难,让⼈⼿⾜⽆措,望⽽⽣ 畏。 为什么说它实⽤呢,因为前⼏天我就在⽇常⽣活中⽤到了这个算法。之前有 ⼀篇公众号⽂章由于疏忽,写错位了⼀段内容,我决定修改这部分内容让逻 辑通顺。但是公众号⽂章最多只能修改 20 个字,且只⽀持增、删、替换操 128
129. 经典动态规划:编辑距离 作(跟编辑距离问题⼀模⼀样),于是我就⽤算法求出了⼀个最优⽅案,只 ⽤了 16 步就完成了修改。 再⽐如⾼⼤上⼀点的应⽤,DNA 序列是由 A,G,C,T 组成的序列,可以类⽐ 成字符串。编辑距离可以衡量两个 DNA 序列的相似度,编辑距离越⼩,说 明这两段 DNA 越相似,说不定这俩 DNA 的主⼈是远古近亲啥的。 下⾯⾔归正传,详细讲解⼀下编辑距离该怎么算,相信本⽂会让你有收获。 ⼀、思路 编辑距离问题就是给我们两个字符串 们把 s2 s1 变成 s2 s1 和 s2 ,只能⽤三种操作,让我 ,求最少的操作数。需要明确的是,不管是把 还是反过来,结果都是⼀样的,所以后⽂就以 s1 变成 s2 s1 变成 举例。 前⽂「最⻓公共⼦序列」说过,解决两个字符串的动态规划问题,⼀般都是 ⽤两个指针 i,j 分别指向两个字符串的最后,然后⼀步步往前⾛,缩⼩问 题的规模。 设两个字符串分别为 "rad" 和 "apple",为了把 s1 变成 s2 ,算法会这样 进⾏: 【PDF格式⽆法显⽰GIF⽂件 editDistance/edit.gif,可移步公众号查看】 129
130. 经典动态规划:编辑距离 请记住这个 GIF 过程,这样就能算出编辑距离。关键在于如何做出正确的 操作,稍后会讲。 根据上⾯的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什 么都不要做(skip)。⽐如这个情况: 因为这两个字符本来就相同,为了使编辑距离最⼩,显然不应该对它们有任 何操作,直接往前移动 i,j 即可。 还有⼀个很容易处理的情况,就是 s1 ,那么只能⽤删除操作把 s1 j ⾛完 缩短为 s2 s2 时,如果 i 还没⾛完 。⽐如这个情况: 130
131. 经典动态规划:编辑距离 类似的,如果 s2 i ⾛完 时 s1 剩下的字符全部插⼊ s1 j 还没⾛完了 s2 ,那就只能⽤插⼊操作把 。等会会看到,这两种情况就是算法的 base case。 下⾯详解⼀下如何将思路转换成代码,坐稳,要发⻋了。 ⼆、代码详解 先梳理⼀下之前的思路: base case 是 i ⾛完 s1 或 j ⾛完 s2 ,可以直接返回另⼀个字符串剩下 的⻓度。 对于每对⼉字符 s1[i] 和 s2[j] ,可以有四种操作: if s1[i] == s2[j]: 啥都别做(skip) i, j 同时向前移动 else: 三选⼀: 插⼊(insert) 删除(delete) 替换(replace) 131
132. 经典动态规划:编辑距离 有这个框架,问题就已经解决了。读者也许会问,这个「三选⼀」到底该怎 么选择呢?很简单,全试⼀遍,哪个操作最后得到的编辑距离最⼩,就选 谁。这⾥需要递归技巧,理解需要点技巧,先看下代码: def minDistance(s1, s2) -> int: def dp(i, j): # base case if i == -1: return j + 1 if j == -1: return i + 1 if s1[i] == s2[j]: return dp(i - 1, j - 1) # 啥都不做 else: return min( dp(i, j - 1) + 1, # 插⼊ dp(i - 1, j) + 1, # 删除 dp(i - 1, j - 1) + 1 # 替换 ) # i,j 初始化指向最后⼀个索引 return dp(len(s1) - 1, len(s2) - 1) 下⾯来详细解释⼀下这段递归代码,base case 应该不⽤解释了,主要解释⼀ 下递归部分。 都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能 很清楚地理解算法的逻辑。我们这⾥ dp(i, j) 函数的定义是这样的: def dp(i, j) -> int # 返回 s1[0..i] 和 s2[0..j] 的最⼩编辑距离 记住这个定义之后,先来看这段代码: if s1[i] == s2[j]: return dp(i - 1, j - 1) # 啥都不做 # 解释: # 本来就相等,不需要任何操作 132
133. 经典动态规划:编辑距离 # s1[0..i] 和 s2[0..j] 的最⼩编辑距离等于 # s1[0..i-1] 和 s2[0..j-1] 的最⼩编辑距离 # 也就是说 dp(i, j) 等于 dp(i-1, j-1) 如果 s1[i]!=s2[j] ,就要对三个操作递归了,稍微需要点思考: dp(i, j - 1) + 1, # 插⼊ # 解释: # 我直接在 s1[i] 插⼊⼀个和 s2[j] ⼀样的字符 # 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对⽐ # 别忘了操作数加⼀ 【PDF格式⽆法显⽰GIF⽂件 editDistance/insert.gif,可移步公众号查看】 dp(i - 1, j) + 1, # 删除 # 解释: # 我直接把 s[i] 这个字符删掉 # 前移 i,继续跟 j 对⽐ # 操作数加⼀ 【PDF格式⽆法显⽰GIF⽂件 editDistance/delete.gif,可移步公众号查看】 dp(i - 1, j - 1) + 1 # 替换 # 解释: # 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了 # 同时前移 i,j 继续对⽐ # 操作数加⼀ 【PDF格式⽆法显⽰GIF⽂件 editDistance/replace.gif,可移步公众号查看】 现在,你应该完全理解这段短⼩精悍的代码了。还有点⼩问题就是,这个解 法是暴⼒解法,存在重叠⼦问题,需要⽤动态规划技巧来优化。 怎么能⼀眼看出存在重叠⼦问题呢?前⽂「动态规划之正则表达式」有提 过,这⾥再简单提⼀下,需要抽象出本⽂算法的递归框架: def dp(i, j): 133
134. 经典动态规划:编辑距离 dp(i - 1, j - 1) #1 dp(i, j - 1) #2 dp(i - 1, j) #3 对于⼦问题 dp(i-1, j-1) 条路径,⽐如 ,如何通过原问题 dp(i, j) -> #1 和 dp(i, j) dp(i, j) -> #2 -> #3 得到呢?有不⽌⼀ 。⼀旦发现⼀条重 复路径,就说明存在巨量重复路径,也就是重叠⼦问题。 三、动态规划优化 对于重叠⼦问题呢,前⽂「动态规划详解」详细介绍过,优化⽅法⽆⾮是备 忘录或者 DP table。 备忘录很好加,原来的代码稍加修改即可: def minDistance(s1, s2) -> int: memo = dict() # 备忘录 def dp(i, j): if (i, j) in memo: return memo[(i, j)] ... if s1[i] == s2[j]: memo[(i, j)] = ... else: memo[(i, j)] = ... return memo[(i, j)] return dp(len(s1) - 1, len(s2) - 1) 主要说下 DP table 的解法: ⾸先明确 dp 数组的含义,dp 数组是⼀个⼆维数组,⻓这样: 134
135. 经典动态规划:编辑距离 有了之前递归解法的铺垫,应该很容易理解。 应 base case, dp[..][0] 和 dp[0][..] 对 的含义和之前的 dp 函数类似: dp[i][j] def dp(i, j) -> int # 返回 s1[0..i] 和 s2[0..j] 的最⼩编辑距离 dp[i-1][j-1] # 存储 s1[0..i] 和 s2[0..j] 的最⼩编辑距离 dp 函数的 base case 是 i,j 等于 -1,⽽数组索引⾄少是 0,所以 dp 数组会 偏移⼀位。 既然 dp 数组和递归 dp 函数含义⼀样,也就可以直接套⽤之前的思路写代 码,唯⼀不同的是,DP table 是⾃底向上求解,递归解法是⾃顶向下求解: int minDistance(String s1, String s2) { int m = s1.length(), n = s2.length(); int[][] dp = new int[m + 1][n + 1]; // base case for (int i = 1; i <= m; i++) dp[i][0] = i; for (int j = 1; j <= n; j++) dp[0][j] = j; // ⾃底向上求解 135
136. 经典动态规划:编辑距离 for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++) if (s1.charAt(i-1) == s2.charAt(j-1)) dp[i][j] = dp[i - 1][j - 1]; else dp[i][j] = min( dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i-1][j-1] + 1 ); // 储存着整个 s1 和 s2 的最⼩编辑距离 return dp[m][n]; } int min(int a, int b, int c) { return Math.min(a, Math.min(b, c)); } 三、扩展延伸 ⼀般来说,处理两个字符串的动态规划问题,都是按本⽂的思路处理,建⽴ DP table。为什么呢,因为易于找出状态转移的关系,⽐如编辑距离的 DP table: 136
137. 经典动态规划:编辑距离 还有⼀个细节,既然每个 度是可以压缩成 dp[i][j] O(min(M, N)) 只和它附近的三个状态有关,空间复杂 的(M,N 是两个字符串的⻓度)。不难, 但是可解释性⼤⼤降低,读者可以⾃⼰尝试优化⼀下。 你可能还会问,这⾥只求出了最⼩的编辑距离,那具体的操作是什么?你之 前举的修改公众号⽂章的例⼦,只有⼀个最⼩编辑距离肯定不够,还得知道 具体怎么修改才⾏。 这个其实很简单,代码稍加修改,给 dp 数组增加额外的信息即可: // int[][] dp; Node[][] dp; class Node { int val; int choice; // 0 代表啥都不做 // 1 代表插⼊ // 2 代表删除 // 3 代表替换 } val 属性就是之前的 dp 数组的数值, choice 属性代表操作。在做最优选 择时,顺便把操作记录下来,然后就从结果反推具体操作。 我们的最终结果不是 离, choice dp[m][n] 吗,这⾥的 val 存着最⼩编辑距 存着最后⼀个操作,⽐如说是插⼊操作,那么就可以左移⼀ 格: 137
138. 经典动态规划:编辑距离 重复此过程,可以⼀步步回到起点 dp[0][0] ,形成⼀条路径,按这条路径 上的操作进⾏编辑,就是最佳⽅案。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 138
139. 经典动态规划:编辑距离 139
140. 经典动态规划:⾼楼扔鸡蛋(进阶) 经典动态规划问题:⾼楼扔鸡蛋(进 阶) 学算法,认准 labuladong 就够了! 上篇⽂章聊了⾼楼扔鸡蛋问题,讲了⼀种效率不是很⾼,但是较为容易理解 的动态规划解法。后台很多读者问如何更⾼效地解决这个问题,今天就谈两 种思路,来优化⼀下这个问题,分别是⼆分查找优化和重新定义状态转移。 如果还不知道⾼楼扔鸡蛋问题的读者可以看下「经典动态规划:⾼楼扔鸡 蛋」,那篇⽂章详解了题⽬的含义和基本的动态规划解题思路,请确保理解 前⽂,因为今天的优化都是基于这个基本解法的。 ⼆分搜索的优化思路也许是我们可以尽⼒尝试写出的,⽽修改状态转移的解 法可能是不容易想到的,可以借此⻅识⼀下动态规划算法设计的⽞妙,当做 思维拓展。 ⼆分搜索优化 之前提到过这个解法,核⼼是因为状态转移⽅程的单调性,这⾥可以具体展 开看看。 ⾸先简述⼀下原始动态规划的思路: 1、暴⼒穷举尝试在所有楼层 1 <= i <= N 扔鸡蛋,每次选择尝试次数最少 的那⼀层; 2、每次扔鸡蛋有两种可能,要么碎,要么没碎; 3、如果鸡蛋碎了, F 应该在第 i 层下⾯,否则, F 应该在第 i 层上 ⾯; 140
141. 经典动态规划:⾼楼扔鸡蛋(进阶) 4、鸡蛋是碎了还是没碎,取决于哪种情况下尝试次数更多,因为我们想求 的是最坏情况下的结果。 核⼼的状态转移代码是这段: # 当前状态为 K 个鸡蛋,⾯对 N 层楼 # 返回这个状态下的最优结果 def dp(K, N): for 1 <= i <= N: # 最坏情况下的最少扔鸡蛋次数 res = min(res, max( dp(K - 1, i - 1), # 碎 # 没碎 dp(K, N - i) ) + 1 # 在第 i 楼扔了⼀次 ) return res 这个 for 循环就是下⾯这个状态转移⽅程的具体代码实现: 如果能够理解这个状态转移⽅程,那么就很容易理解⼆分查找的优化思路。 ⾸先我们根据 dp(K, N) 数组的定义(有 要扔⼏次),很容易知道 K 个鸡蛋⾯对 固定时,这个函数随着 K N N 层楼,最少需 的增加⼀定是单调 递增的,⽆论你策略多聪明,楼层增加测试次数⼀定要增加。 那么注意 到 N dp(K - 1, i - 1) 和 单增的,如果我们固定 数,前者随着 i 这两个函数,其中 i 是从 1 ,把这两个函数看做关于 i 的函 dp(K, N - i) K 和 N 的增加应该也是单调递增的,⽽后者随着 i 的增加应该 是单调递减的: 141
142. 经典动态规划:⾼楼扔鸡蛋(进阶) 这时候求⼆者的较⼤值,再求这些最⼤值之中的最⼩值,其实就是求这两条 直线交点,也就是红⾊折线的最低点嘛。 我们前⽂「⼆分查找只能⽤来查找元素吗」讲过,⼆分查找的运⽤很⼴泛, 形如下⾯这种形式的 for 循环代码: for (int i = 0; i < n; i++) { if (isOK(i)) return i; } 都很有可能可以运⽤⼆分查找来优化线性搜索的复杂度,回顾这两个 dp 函数的曲线,我们要找的最低点其实就是这种情况: for (int i = 1; i <= N; i++) { if (dp(K - 1, i - 1) == dp(K, N - i)) return dp(K, N - i); } 熟悉⼆分搜索的同学肯定敏感地想到了,这不就是相当于求 Valley(⼭⾕) 值嘛,可以⽤⼆分查找来快速寻找这个点的,直接看代码吧,整体的思路还 是⼀样,只是加快了搜索速度: 142
143. 经典动态规划:⾼楼扔鸡蛋(进阶) def superEggDrop(self, K: int, N:'>N: int) -> int: memo = dict() def dp(K, N): if K == 1: return N if N == 0: return 0 if (K, N) in memo: return memo[(K, N)] # for 1 <= i <= N:'>N: # res = min(res, # max( # dp(K - 1, i - 1), # dp(K, N - i) # ) + 1 # ) res = float('INF') # ⽤⼆分搜索代替线性搜索 lo, hi = 1, N while lo <= hi: mid = (lo + hi) // 2 broken = dp(K - 1, mid - 1) # 碎 not_broken = dp(K, N - mid) # 没碎 # res = min(max(碎,没碎) + 1) if broken > not_broken: hi = mid - 1 res = min(res, broken + 1) else: lo = mid + 1 res = min(res, not_broken + 1) memo[(K, N)] = res return res return dp(K, N) 这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是⼦问题个 数 × 函数本⾝的复杂度。 143
144. 经典动态规划:⾼楼扔鸡蛋(进阶) 函数本⾝的复杂度就是忽略递归部分的复杂度,这⾥ dp 函数中⽤了⼀个 ⼆分搜索,所以函数本⾝的复杂度是 O(logN)。 ⼦问题个数也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。 所以算法的总时间复杂度是 O(K*N*logN), 空间复杂度 O(KN)。效率上⽐之 前的算法 O(KN^2) 要⾼效⼀些。 重新定义状态转移 前⽂「不同定义有不同解法」就提过,找动态规划的状态转移本就是⻅仁⻅ 智,⽐较⽞学的事情,不同的状态定义可以衍⽣出不同的解法,其解法和复 杂程度都可能有巨⼤差异。这⾥就是⼀个很好的例⼦。 再回顾⼀下我们之前定义的 数组含义: dp def dp(k, n) -> int # 当前状态为 k 个鸡蛋,⾯对 n 层楼 # 返回这个状态下最少的扔鸡蛋次数 ⽤ dp 数组表⽰的话也是⼀样的: dp[k][n] = m # 当前状态为 k 个鸡蛋,⾯对 n 层楼 # 这个状态下最少的扔鸡蛋次数为 m 按照这个定义,就是确定当前的鸡蛋个数和⾯对的楼层数,就知道最⼩扔鸡 蛋次数。最终我们想要的答案就是 dp(K, N) 的结果。 这种思路下,肯定要穷举所有可能的扔法的,⽤⼆分搜索优化也只是做了 「剪枝」,减⼩了搜索空间,但本质思路没有变,还是穷举。 现在,我们稍微修改 dp 数组的定义,确定当前的鸡蛋个数和最多允许的 扔鸡蛋次数,就知道能够确定 F 的最⾼楼层数。具体来说是这个意思: 144
145. 经典动态规划:⾼楼扔鸡蛋(进阶) dp[k][m] = n # 当前有 k 个鸡蛋,可以尝试扔 m 次鸡蛋 # 这个状态下,最坏情况下最多能确切测试⼀栋 n 层的楼 # ⽐如说 dp[1][7] = 7 表⽰: # 现在有 1 个鸡蛋,允许你扔 7 次; # 这个状态下最多给你 7 层楼, # 使得你可以确定楼层 F 使得鸡蛋恰好摔不碎 # (⼀层⼀层线性探查嘛) 这其实就是我们原始思路的⼀个「反向」版本,我们先不管这种思路的状态 转移怎么写,先来思考⼀下这种定义之下,最终想求的答案是什么? 我们最终要求的其实是扔鸡蛋次数 dp m ,但是这时候 m 在状态之中⽽不是 数组的结果,可以这样处理: int superEggDrop(int K, int N) { int m = 0; while (dp[K][m] < N) { m++; // 状态转移... } return m; } 题⽬不是给你 吗? 测试 while m K 鸡蛋, N 层楼,让你求最坏情况下最少的测试次数 循环结束的条件是 dp[K][m] == N 次,最坏情况下最多能测试 N ,也就是给你 K m 个鸡蛋, 层楼。 注意看这两段描述,是完全⼀样的!所以说这样组织代码是正确的,关键就 是状态转移⽅程怎么找呢?还得从我们原始的思路开始讲。之前的解法配了 这样图帮助⼤家理解状态转移思路: 145
146. 经典动态规划:⾼楼扔鸡蛋(进阶) 146
147. 经典动态规划:⾼楼扔鸡蛋(进阶) 这个图描述的仅仅是某⼀个楼层 i ,原始解法还得线性或者⼆分扫描所有 楼层,要求最⼤值、最⼩值。但是现在这种 dp 定义根本不需要这些了, 基于下⾯两个事实: 1、⽆论你在哪层楼扔鸡蛋,鸡蛋只可能摔碎或者没摔碎,碎了的话就测楼 下,没碎的话就测楼上。 2、⽆论你上楼还是下楼,总的楼层数 = 楼上的楼层数 + 楼下的楼层数 + 1(当前这层楼)。 根据这个特点,可以写出下⾯的状态转移⽅程: dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1 dp[k][m - 1] 就是楼上的楼层数,因为鸡蛋个数 碎,扔鸡蛋次数 dp[k - 1][m - 1] m m 不变,也就是鸡蛋没 减⼀; 就是楼下的楼层数,因为鸡蛋个数 蛋碎了,同时扔鸡蛋次数 PS:这个 k m k 减⼀,也就是鸡 减⼀。 为什么要减⼀⽽不是加⼀?之前定义得很清楚,这个 m 是⼀ 个允许的次数上界,⽽不是扔了⼏次。 147
148. 经典动态规划:⾼楼扔鸡蛋(进阶) 148
149. 经典动态规划:⾼楼扔鸡蛋(进阶) ⾄此,整个思路就完成了,只要把状态转移⽅程填进框架即可: int superEggDrop(int K, int N) { // m 最多不会超过 N 次(线性扫描) int[][] dp = new int[K + 1][N + 1]; // base case: // dp[0][..] = 0 // dp[..][0] = 0 // Java 默认初始化数组都为 0 int m = 0; while (dp[K][m] < N) { m++; for (int k = 1; k <= K; k++) dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1; } return m; } 如果你还觉得这段代码有点难以理解,其实它就等同于这样写: for (int m = 1; dp[K][m] < N; m++) for (int k = 1; k <= K; k++) dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1; 看到这种代码形式就熟悉多了吧,因为我们要求的不是 ⽽是某个符合条件的索引 m ,所以⽤ while dp 数组⾥的值, 循环来找到这个 m ⽽已。 这个算法的时间复杂度是多少?很明显就是两个嵌套循环的复杂度 O(KN)。 另外注意到 化成⼀维 dp dp[m][k] 转移只和左边和左上的两个状态有关,所以很容易优 数组,这⾥就不写了。 还可以再优化 再往下就要⽤⼀些数学⽅法了,不具体展开,就简单提⼀下思路吧。 149
150. 经典动态规划:⾼楼扔鸡蛋(进阶) 在刚才的思路之上,注意函数 dp(m, k) 是随着 m 单增的,因为鸡蛋个数 不变时,允许的测试次数越多,可测试的楼层就越⾼。 k 这⾥⼜可以借助⼆分搜索算法快速逼近 dp[K][m] == N 间复杂度进⼀步下降为 O(KlogN),我们可以设 这个终⽌条件,时 g(k, m) = …… 算了算了,打住吧。我觉得我们能够写出 O(K*N*logN) 的⼆分优化算法就 ⾏了,后⾯的这些解法呢,听个响⿎个掌就⾏了,把欲望限制在能⼒的范围 之内才能拥有快乐! 不过可以肯定的是,根据⼆分搜索代替线性扫描 架肯定是修改穷举 m m 的取值,代码的⼤致框 的 for 循环: // 把线性搜索改成⼆分搜索 // for (int m = 1; dp[K][m] < N; m++) int lo = 1, hi = N; while (lo < hi) { int mid = (lo + hi) / 2; if (... < N) { lo = ... } else { hi = ... } for (int k = 1; k <= K; k++) // 状态转移⽅程 } 简单总结⼀下吧,第⼀个⼆分优化是利⽤了 dp 函数的单调性,⽤⼆分查 找技巧快速搜索答案;第⼆种优化是巧妙地修改了状态转移⽅程,简化了求 解了流程,但相应的,解题逻辑⽐较难以想到;后续还可以⽤⼀些数学⽅法 和⼆分搜索进⼀步优化第⼆种解法,不过看了看镜⼦中的发量,算了。 本⽂终,希望对你有⼀点启发。 _____________ 刷算法,学套路,认准 labuladong。 150
151. 经典动态规划:⾼楼扔鸡蛋(进阶) 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 151
152. 经典动态规划:戳⽓球 经典动态规划:戳⽓球 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 312.戳⽓球 今天我们要聊的这道题「Burst Balloon」和之前我们写过的那篇 经典动态规 划:⾼楼扔鸡蛋问题 分析过的⾼楼扔鸡蛋问题类似,知名度很⾼,但难度 确实也很⼤。因此 labuladong 公众号就给这道题赐个座,来看⼀看这道题⽬ 到底有多难。 它是 LeetCode 第 312 题,题⽬如下: 152
153. 经典动态规划:戳⽓球 ⾸先必须要说明,这个题⽬的状态转移⽅程真的⽐较巧妙,所以说如果你看 了题⽬之后完全没有思路恰恰是正常的。虽然最优答案不容易想出来,但基 本的思路分析是我们应该⼒求做到的。所以本⽂会先分析⼀下常规思路,然 后再引⼊动态规划解法。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 153
154. 经典动态规划:戳⽓球 154
155. 经典动态规划:最⻓公共⼦序列 最⻓公共⼦序列 学算法,认准 labuladong 就够了! 最⻓公共⼦序列(Longest Common Subsequence,简称 LCS)是⼀道⾮常经 典的⾯试题⽬,因为它的解法是典型的⼆维动态规划,⼤部分⽐较困难的字 符串问题都和这个问题⼀个套路,⽐如说编辑距离。⽽且,这个算法稍加改 造就可以⽤于解决其他问题,所以说 LCS 算法是值得掌握的。 题⽬就是让我们求两个字符串的 LCS ⻓度: 输⼊: str1 = "abcde", str2 = "ace" 输出: 3 解释: 最⻓公共⼦序列是 "ace",它的⻓度是 3 肯定有读者会问,为啥这个问题就是动态规划来解决呢?因为⼦序列类型的 问题,穷举出所有可能的结果都不容易,⽽动态规划算法做的就是穷举 + 剪枝,它俩天⽣⼀对⼉。所以可以说只要涉及⼦序列问题,⼗有⼋九都需要 动态规划来解决,往这⽅⾯考虑就对了。 下⾯就来⼿把⼿分析⼀下,这道题⽬如何⽤动态规划技巧解决。 ⼀、动态规划思路 第⼀步,⼀定要明确 数组的含义。对于两个字符串的动态规划问题, dp 套路是通⽤的。 ⽐如说对于字符串 s1 和 s2 ,⼀般来说都要构造⼀个这样的 DP table: 155
156. 经典动态规划:最⻓公共⼦序列 为了⽅便理解此表,我们暂时认为索引是从 1 开始的,待会的代码中只要稍 作调整即可。其中, 它们的 LCS ⻓度是 dp[i][j] dp[i][j] 的含义是:对于 和 s2[1..j] "babc" ,它们的 s1[1..i] , 。 ⽐如上图的例⼦,d[2][4] 的含义就是:对于 "ac" LCS ⻓度是 2。我们最终想得到的答案应该是 和 dp[3][6] 。 第⼆步,定义 base case。 我们专门让索引为 0 的⾏和列表⽰空串, dp[0][..] 和 dp[..][0] 都应该 初始化为 0,这就是 base case。 ⽐如说,按照刚才 dp 数组的定义, "" 和 "bab" dp[0][3]=0 的含义是:对于字符串 ,其 LCS 的⻓度为 0。因为有⼀个字符串是空串,它们的最 ⻓公共⼦序列的⻓度显然应该是 0。 第三步,找状态转移⽅程。 这是动态规划最难的⼀步,不过好在这种字符串问题的套路都差不多,权且 借这道题来聊聊处理这类问题的思路。 156
157. 经典动态规划:最⻓公共⼦序列 状态转移说简单些就是做选择,⽐如说这个问题,是求 s1 和 s2 公共⼦序列,不妨称这个⼦序列为 和 s2 中的每个 lcs 。那么对于 字符,有什么选择?很简单,两种选择,要么在 s1 lcs 的最⻓ 中,要么不在。 这个「在」和「不在」就是选择,关键是,应该如何选择呢?这个需要动点 脑筋:如果某个字符应该在 s2 中,因为 ⽤两个指针 中,那么这个字符肯定同时存在于 lcs 是最⻓公共⼦序列嘛。所以本题的思路是这样: 和 j i 么这个字符⼀定在 有⼀个不在 lcs lcs 从后往前遍历 lcs s1 中;否则的话, 和 s2 s1[i] ,如果 和 s1[i]==s2[j] s2[j] s1 和 ,那 这两个字符⾄少 中,需要丢弃⼀个。先看⼀下递归解法,⽐较容易理解: def longestCommonSubsequence(str1, str2) -> int: def dp(i, j): # 空串的 base case if i == -1 or j == -1: return 0 if str1[i] == str2[j]: # 这边找到⼀个 lcs 的元素,继续往前找 return dp(i - 1, j - 1) + 1 else: # 谁能让 lcs 最⻓,就听谁的 return max(dp(i-1, j), dp(i, j-1)) 157
158. 经典动态规划:最⻓公共⼦序列 # i 和 j 初始化为最后⼀个索引 return dp(len(str1)-1, len(str2)-1) 对于第⼀种情况,找到⼀个 位,并给 lcs 中的字符,同时将 i j 向前移动⼀ 的⻓度加⼀;对于后者,则尝试两种情况,取更⼤的结果。 lcs 其实这段代码就是暴⼒解法,我们可以通过备忘录或者 DP table 来优化时间 复杂度,⽐如通过前⽂描述的 DP table 来解决: def longestCommonSubsequence(str1, str2) -> int: m, n = len(str1), len(str2) # 构建 DP table 和 base case dp = [[0] * (n + 1) for _ in range(m + 1)] # 进⾏状态转移 for i in range(1, m + 1): for j in range(1, n + 1): if str1[i - 1] == str2[j - 1]: # 找到⼀个 lcs 中的字符 dp[i][j] = 1 + dp[i-1][j-1] else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) return dp[-1][-1] ⼆、疑难解答 对于 s1[i] 和 s2[j] 不相等的情况,⾄少有⼀个字符不在 lcs 中,会不 会两个字符都不在呢?⽐如下⾯这种情况: 158
159. 经典动态规划:最⻓公共⼦序列 所以代码是不是应该考虑这种情况,改成这样: if str1[i - 1] == str2[j - 1]: # ... else: dp[i][j] = max(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) 我⼀开始也有这种怀疑,其实可以这样改,也能得到正确答案,但是多此⼀ 举,因为 dp[i-1][j-1] 永远是三者中最⼩的,max 根本不可能取到它。 原因在于我们对 dp 数组的定义:对于 LCS ⻓度是 dp[i][j] s1[1..i] 和 s2[1..j] ,它们的 。 159
160. 经典动态规划:最⻓公共⼦序列 这样⼀看,显然 dp[i-1][j-1] 对应的 lcs ⻓度不可能⽐前两种情况⼤, 所以没有必要参与⽐较。 三、总结 对于两个字符串的动态规划问题,⼀般来说都是像本⽂⼀样定义 DP table, 因为这样定义有⼀个好处,就是容易写出状态转移⽅程, dp[i][j] 的状态 可以通过之前的状态推导出来: 160
161. 经典动态规划:最⻓公共⼦序列 找状态转移⽅程的⽅法是,思考每个状态有哪些「选择」,只要我们能⽤正 确的逻辑做出正确的选择,算法就能够正确运⾏。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 161
162. 动态规划之⼦序列问题解题模板 动态规划之⼦序列问题解题模板 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 516.最⻓回⽂⼦序列 ⼦序列问题是常⻅的算法问题,⽽且并不好解决。 ⾸先,⼦序列问题本⾝就相对⼦串、⼦数组更困难⼀些,因为前者是不连续 的序列,⽽后两者是连续的,就算穷举你都不⼀定会,更别说求解相关的算 法问题了。 ⽽且,⼦序列问题很可能涉及到两个字符串,⽐如前⽂「最⻓公共⼦序 列」,如果没有⼀定的处理经验,真的不容易想出来。所以本⽂就来扒⼀扒 ⼦序列问题的套路,其实就有两种模板,相关问题只要往这两种思路上想, ⼗拿九稳。 ⼀般来说,这类问题都是让你求⼀个最⻓⼦序列,因为最短⼦序列就是⼀个 字符嘛,没啥可问的。⼀旦涉及到⼦序列和最值,那⼏乎可以肯定,考察的 是动态规划技巧,时间复杂度⼀般都是 O(n^2)。 原因很简单,你想想⼀个字符串,它的⼦序列有多少种可能?起码是指数级 的吧,这种情况下,不⽤动态规划技巧,还想怎么着? 既然要⽤动态规划,那就要定义 dp 数组,找状态转移关系。我们说的两种 思路模板,就是 dp 数组的定义思路。不同的问题可能需要不同的 dp 数组定 义来解决。 ⼀、两种思路 162
163. 动态规划之⼦序列问题解题模板 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 163
164. 动态规划之博弈问题 动态规划之博弈问题 学算法,认准 labuladong 就够了! 上⼀篇⽂章 ⼏道智⼒题 中讨论到⼀个有趣的「⽯头游戏」,通过题⽬的限 制条件,这个游戏是先⼿必胜的。但是智⼒题终究是智⼒题,真正的算法问 题肯定不会是投机取巧能搞定的。所以,本⽂就借⽯头游戏来讲讲「假设两 个⼈都⾜够聪明,最后谁会获胜」这⼀类问题该如何⽤动态规划算法解决。 博弈类问题的套路都差不多,下⽂举例讲解,其核⼼思路是在⼆维 dp 的基 础上使⽤元组分别存储两个⼈的博弈结果。掌握了这个技巧以后,别⼈再问 你什么俩海盗分宝⽯,俩⼈拿硬币的问题,你就告诉别⼈:我懒得想,直接 给你写个算法算⼀下得了。 我们「⽯头游戏」改的更具有⼀般性: 你和你的朋友⾯前有⼀排⽯头堆,⽤⼀个数组 piles 表⽰,piles[i] 表⽰第 i 堆⽯⼦有多少个。你们轮流拿⽯头,⼀次拿⼀堆,但是只能拿⾛最左边或者 最右边的⽯头堆。所有⽯头被拿完后,谁拥有的⽯头多,谁获胜。 ⽯头的堆数可以是任意正整数,⽯头的总数也可以是任意正整数,这样就能 打破先⼿必胜的局⾯了。⽐如有三堆⽯头 piles = [1, 100, 3] ,先⼿不管 拿 1 还是 3,能够决定胜负的 100 都会被后⼿拿⾛,后⼿会获胜。 假设两⼈都很聪明,请你设计⼀个算法,返回先⼿和后⼿的最后得分(⽯头 总数)之差。⽐如上⾯那个例⼦,先⼿能获得 4 分,后⼿会获得 100 分,你 的算法应该返回 -96。 这样推⼴之后,这个问题算是⼀道 Hard 的动态规划问题了。博弈问题的难 点在于,两个⼈要轮流进⾏选择,⽽且都贼精明,应该如何编程表⽰这个过 程呢? 还是强调多次的套路,⾸先明确 dp 数组的含义,然后和股票买卖系列问题 类似,只要找到「状态」和「选择」,⼀切就⽔到渠成了。 164
165. 动态规划之博弈问题 ⼀、定义 dp 数组的含义 定义 dp 数组的含义是很有技术含量的,同⼀问题可能有多种定义⽅法,不 同的定义会引出不同的状态转移⽅程,不过只要逻辑没有问题,最终都能得 到相同的答案。 我建议不要迷恋那些看起来很⽜逼,代码很短⼩的奇技淫巧,最好是稳⼀ 点,采取可解释性最好,最容易推⼴的设计思路。本⽂就给出⼀种博弈问题 的通⽤设计框架。 介绍 dp 数组的含义之前,我们先看⼀下 dp 数组最终的样⼦: 下⽂讲解时,认为元组是包含 first 和 second 属性的⼀个类,⽽且为了节省 篇幅,将这两个属性简写为 fir 和 sec。⽐如按上图的数据,我们说 [3].fir = 10 , dp[0][1].sec = 3 dp[1] 。 先回答⼏个读者可能提出的问题: 165
166. 动态规划之博弈问题 这个⼆维 dp table 中存储的是元组,怎么编程表⽰呢?这个 dp table 有⼀半 根本没⽤上,怎么优化?很简单,都不要管,先把解题的思路想明⽩了再谈 也不迟。 以下是对 dp 数组含义的解释: dp[i][j].fir 表⽰,对于 piles[i...j] 这部分⽯头堆,先⼿能获得的最⾼分数。 dp[i][j].sec 表⽰,对于 piles[i...j] 这部分⽯头堆,后⼿能获得的最⾼分数。 举例理解⼀下,假设 piles = [3, 9, 1, 2],索引从 0 开始 dp[0][1].fir = 9 意味着:⾯对⽯头堆 [3, 9],先⼿最终能够获得 9 分。 dp[1][3].sec = 2 意味着:⾯对⽯头堆 [9, 1, 2],后⼿最终能够获得 2 分。 我们想求的答案是先⼿和后⼿最终分数之差,按照这个定义也就是 [n-1].fir - dp[0][n-1].sec dp[0] ,即⾯对整个 piles,先⼿的最优得分和后⼿的 最优得分之差。 ⼆、状态转移⽅程 写状态转移⽅程很简单,⾸先要找到所有「状态」和每个状态可以做的「选 择」,然后择优。 根据前⾯对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的⼈。 dp[i][j][fir or sec] 其中: 0 <= i < piles.length i <= j < piles.length 对于这个问题的每个状态,可以做的选择有两个:选择最左边的那堆⽯头, 或者选择最右边的那堆⽯头。 我们可以这样穷举所有状态: n = piles.length for 0 <= i < n:'>n: for j <= i < n:'>n: 166
167. 动态规划之博弈问题 for who in {fir, sec}: dp[i][j][who] = max(left, right) 上⾯的伪码是动态规划的⼀个⼤致的框架,股票系列问题中也有类似的伪 码。这道题的难点在于,两⼈是交替进⾏选择的,也就是说先⼿的选择会对 后⼿有影响,这怎么表达出来呢? 根据我们对 dp 数组的定义,很容易解决这个难点,写出状态转移⽅程: dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].s ec) dp[i][j].fir = max( 选择最左边的⽯头堆 , 选择最右边的⽯头堆 ) # 解释:我作为先⼿,⾯对 piles[i...j] 时,有两种选择: # 要么我选择最左边的那⼀堆⽯头,然后⾯对 piles[i+1...j] # 但是此时轮到对⽅,相当于我变成了后⼿; # 要么我选择最右边的那⼀堆⽯头,然后⾯对 piles[i...j-1] # 但是此时轮到对⽅,相当于我变成了后⼿。 if 先⼿选择左边: dp[i][j].sec = dp[i+1][j].fir if 先⼿选择右边: dp[i][j].sec = dp[i][j-1].fir # 解释:我作为后⼿,要等先⼿先选择,有两种情况: # 如果先⼿选择了最左边那堆,给我剩下了 piles[i+1...j] # 此时轮到我,我变成了先⼿; # 如果先⼿选择了最右边那堆,给我剩下了 piles[i...j-1] # 此时轮到我,我变成了先⼿。 根据 dp 数组的定义,我们也可以找出 base case,也就是最简单的情况: dp[i][j].fir = piles[i] dp[i][j].sec = 0 其中 0 <= i == j < n # 解释:i 和 j 相等就是说⾯前只有⼀堆⽯头 piles[i] # 那么显然先⼿的得分为 piles[i] # 后⼿没有⽯头拿了,得分为 0 167
168. 动态规划之博弈问题 这⾥需要注意⼀点,我们发现 base case 是斜着的,⽽且我们推算 dp[i][j] 时 需要⽤到 dp[i+1][j] 和 dp[i][j-1]: 168
169. 动态规划之博弈问题 所以说算法不能简单的⼀⾏⼀⾏遍历 dp 数组,⽽要斜着遍历数组: 169
170. 动态规划之博弈问题 说实话,斜着遍历⼆维数组说起来容易,你还真不⼀定能想出来怎么实现, 不信你思考⼀下?这么巧妙的状态转移⽅程都列出来了,要是不会写代码实 现,那真的很尴尬了。 三、代码实现 如何实现这个 fir 和 sec 元组呢,你可以⽤ python,⾃带元组类型;或者使 ⽤ C++ 的 pair 容器;或者⽤⼀个三维数组 dp[n][n][2] ,最后⼀个维度就 相当于元组;或者我们⾃⼰写⼀个 Pair 类: class Pair { int fir, sec; Pair(int fir, int sec) { this.fir = fir; this.sec = sec; } } 170
171. 动态规划之博弈问题 然后直接把我们的状态转移⽅程翻译成代码即可,可以注意⼀下斜着遍历数 组的技巧: /* 返回游戏最后先⼿和后⼿的得分之差 */ int stoneGame(int[] piles) { int n = piles.length; // 初始化 dp 数组 Pair[][] dp = new Pair[n][n]; for (int i = 0; i < n; i++) for (int j = i; j < n; j++) dp[i][j] = new Pair(0, 0); // 填⼊ base case for (int i = 0; i < n; i++) { dp[i][i].fir = piles[i]; dp[i][i].sec = 0; } // 斜着遍历数组 for (int l = 2; l <= n; l++) { for (int i = 0; i <= n - l; i++) { int j = l + i - 1; // 先⼿选择最左边或最右边的分数 int left = piles[i] + dp[i+1][j].sec; int right = piles[j] + dp[i][j-1].sec; // 套⽤状态转移⽅程 if (left > right) { dp[i][j].fir = left; dp[i][j].sec = dp[i+1][j].fir; } else { dp[i][j].fir = right; dp[i][j].sec = dp[i][j-1].fir; } } } Pair res = dp[0][n-1]; return res.fir - res.sec; } 动态规划解法,如果没有状态转移⽅程指导,绝对是⼀头雾⽔,但是根据前 ⾯的详细解释,读者应该可以清晰理解这⼀⼤段代码的含义。 171
172. 动态规划之博弈问题 ⽽且,注意到计算 dp[i][j] 只依赖其左边和下边的元素,所以说肯定有优 化空间,转换成⼀维 dp,想象⼀下把⼆维平⾯压扁,也就是投影到⼀维。 但是,⼀维 dp ⽐较复杂,可解释性很差,⼤家就不必浪费这个时间去理解 了。 四、最后总结 本⽂给出了解决博弈问题的动态规划解法。博弈问题的前提⼀般都是在两个 聪明⼈之间进⾏,编程描述这种游戏的⼀般⽅法是⼆维 dp 数组,数组中通 过元组分别表⽰两⼈的最优决策。 之所以这样设计,是因为先⼿在做出选择之后,就成了后⼿,后⼿在对⽅做 完选择后,就变成了先⼿。这种⾓⾊转换使得我们可以重⽤之前的结果,典 型的动态规划标志。 读到这⾥的朋友应该能理解算法解决博弈问题的套路了。学习算法,⼀定要 注重算法的模板框架,⽽不是⼀些看起来⽜逼的思路,也不要奢求上来就写 ⼀个最优的解法。不要舍不得多⽤空间,不要过早尝试优化,不要惧怕多维 数组。dp 数组就是存储信息避免重复计算的,随便⽤,直到咱满意为⽌。 希望本⽂对你有帮助。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 172
173. 动态规划之正则表达 动态规划之正则表达 学算法,认准 labuladong 就够了! 之前的⽂章「动态规划详解」收到了普遍的好评,今天写⼀个动态规划的实 际应⽤:正则表达式。如果有读者对「动态规划」还不了解,建议先看⼀下 上⾯那篇⽂章。 正则表达式匹配是⼀个很精妙的算法,⽽且难度也不⼩。本⽂主要写两个正 则符号的算法实现:点号「.」和星号「*」,如果你⽤过正则表达式,应该 明⽩他们的⽤法,不明⽩也没关系,等会会介绍。⽂章的最后,介绍了⼀种 快速看出重叠⼦问题的技巧。 本⽂还有⼀个重要⽬的,就是教会读者如何设计算法。我们平时看别⼈的解 法,直接看到⼀个⾯⾯俱到的完整答案,总觉得⽆法理解,以⾄觉得问题太 难,⾃⼰太菜。我⼒求向读者展⽰,算法的设计是⼀个螺旋上升、逐步求精 的过程,绝不是⼀步到位就能写出正确算法。本⽂会带你解决这个较为复杂 的问题,让你明⽩如何化繁为简,逐个击破,从最简单的框架搭建出最终的 答案。 前⽂⽆数次强调的框架思维,就是在这种设计过程中逐步培养的。下⾯进⼊ 正题,⾸先看⼀下题⽬: 173
174. 动态规划之正则表达 ⼀、热⾝ 第⼀步,我们暂时不管正则符号,如果是两个普通的字符串进⾏⽐较,如何 进⾏匹配?我想这个算法应该谁都会写: bool isMatch(string text, string pattern) { if (text.size() != pattern.size()) return false; for (int j = 0; j < pattern.size(); j++) { if (pattern[j] != text[j]) return false; } return true; } 174
175. 动态规划之正则表达 然后,我稍微改造⼀下上⾯的代码,略微复杂了⼀点,但意思还是⼀样的, 很容易理解吧: bool isMatch(string text, string pattern) { int i = 0; // text 的索引位置 int j = 0; // pattern 的索引位置 while (j < pattern.size()) { if (i >= text.size()) return false; if (pattern[j++] != text[i++]) return false; } // 相等则说明完成匹配 return j == text.size(); } 如上改写,是为了将这个算法改造成递归算法(伪码): def isMatch(text, pattern) -> bool:'>bool: if pattern is empty: return (text is empty?) first_match = (text not empty) and pattern[0] == text[0] return first_match and isMatch(text[1:], pattern[1:]) 如果你能够理解这段代码,恭喜你,你的递归思想已经到位,正则表达式算 法虽然有点复杂,其实是基于这段递归代码逐步改造⽽成的。 ⼆、处理点号「.」通配符 点号可以匹配任意⼀个字符,万⾦油嘛,其实是最简单的,稍加改造即可: def isMatch(text, pattern) -> bool:'>bool: if not pattern: return not text first_match = bool(text) and pattern[0] in {text[0], '.'} return first_match and isMatch(text[1:], pattern[1:]) 三、处理「*」通配符 175
176. 动态规划之正则表达 星号通配符可以让前⼀个字符重复任意次数,包括零次。那到底是重复⼏次 呢?这似乎有点困难,不过不要着急,我们起码可以把框架的搭建再进⼀ 步: def isMatch(text, pattern) -> bool: if not pattern: return not text first_match = bool(text) and pattern[0] in {text[0], '.'} if len(pattern) >= 2 and pattern[1] == '*': # 发现 '*' 通配符 else: return first_match and isMatch(text[1:], pattern[1:]) 星号前⾯的那个字符到底要重复⼏次呢?这需要计算机暴⼒穷举来算,假设 重复 N 次吧。前⽂多次强调过,写递归的技巧是管好当下,之后的事抛给 递归。具体到这⾥,不管 N 是多少,当前的选择只有两个:匹配 0 次、匹 配 1 次。所以可以这样处理: if len(pattern) >= 2 and pattern[1] == '*': return isMatch(text, pattern[2:]) or \ first_match and isMatch(text[1:], pattern) # 解释:如果发现有字符和 '*' 结合, # 或者匹配该字符 0 次,然后跳过该字符和 '*' # 或者当 pattern[0] 和 text[0] 匹配后,移动 text 可以看到,我们是通过保留 pattern 中的「*」,同时向后推移 text,来实现 「」将字符重复匹配多次的功能。举个简单的例⼦就能理解这个逻辑了。假 设 `pattern = a , text = aaa`,画个图看看匹配过程: 176
177. 动态规划之正则表达 ⾄此,正则表达式算法就基本完成了, 四、动态规划 我选择使⽤「备忘录」递归的⽅法来降低复杂度。有了暴⼒解法,优化的过 程及其简单,就是使⽤两个变量 i, j 记录当前匹配到的位置,从⽽避免使⽤ ⼦字符串切⽚,并且将 i, j 存⼊备忘录,避免重复计算即可。 我将暴⼒解法和优化解法放在⼀起,⽅便你对⽐,你可以发现优化解法⽆⾮ 就是把暴⼒解法「翻译」了⼀遍,加了个 memo 作为备忘录,仅此⽽已。 # 带备忘录的递归 def isMatch(text, pattern) -> bool: memo = dict() # 备忘录 def dp(i, j): if (i, j) in memo: return memo[(i, j)] if j == len(pattern): return i == len(text) first = i < len(text) and pattern[j] in {text[i], '.'} if j <= len(pattern) - 2 and pattern[j + 1] == '*': ans = dp(i, j + 2) or \ first and dp(i + 1, j) else: 177
178. 动态规划之正则表达 ans = first and dp(i + 1, j + 1) memo[(i, j)] = ans return ans return dp(0, 0) # 暴⼒递归 def isMatch(text, pattern) -> bool: if not pattern: return not text first = bool(text) and pattern[0] in {text[0], '.'} if len(pattern) >= 2 and pattern[1] == '*': return isMatch(text, pattern[2:]) or \ first and isMatch(text[1:], pattern) else: return first and isMatch(text[1:], pattern[1:]) 有的读者也许会问,你怎么知道这个问题是个动态规划问题呢,你怎么知道 它就存在「重叠⼦问题」呢,这似乎不容易看出来呀? 解答这个问题,最直观的应该是随便假设⼀个输⼊,然后画递归树,肯定是 可以发现相同节点的。这属于定量分析,其实不⽤这么⿇烦,下⾯我来教你 定性分析,⼀眼就能看出「重叠⼦问题」性质。 先拿最简单的斐波那契数列举例,我们抽象出递归算法的框架: def fib(n): fib(n - 1) #1 fib(n - 2) #2 看着这个框架,请问原问题 f(n) 如何触达⼦问题 f(n - 2) ?有两种路径,⼀ 是 f(n) -> #1 -> #1, ⼆是 f(n) -> #2。前者经过两次递归,后者进过⼀次递归 ⽽已。两条不同的计算路径都到达了同⼀个问题,这就是「重叠⼦问题」, ⽽且可以肯定的是,只要你发现⼀条重复路径,这样的重复路径⼀定存在千 万条,意味着巨量⼦问题重叠。 178
179. 动态规划之正则表达 同理,对于本问题,我们依然先抽象出算法框架: def dp(i, j): dp(i, j + 2) #1 dp(i + 1, j) #2 dp(i + 1, j + 1) #3 提出类似的问题,请问如何从原问题 dp(i, j) 触达⼦问题 dp(i + 2, j + 2) ?⾄ 少有两种路径,⼀是 dp(i, j) -> #3 -> #3,⼆是 dp(i, j) -> #1 -> #2 -> #2。因 此,本问题⼀定存在重叠⼦问题,⼀定需要动态规划的优化技巧来处理。 五、最后总结 通过本⽂,你深⼊理解了正则表达式的两种常⽤通配符的算法实现。其实点 号「.」的实现及其简单,关键是星号「*」的实现需要⽤到动态规划技巧, 稍微复杂些,但是也架不住我们对问题的层层拆解,逐个击破。另外,你掌 握了⼀种快速分析「重叠⼦问题」性质的技巧,可以快速判断⼀个问题是否 可以使⽤动态规划套路解决。 回顾整个解题过程,你应该能够体会到算法设计的流程:从简单的类似问题 ⼊⼿,给基本的框架逐渐组装新的逻辑,最终成为⼀个⽐较复杂、精巧的算 法。所以说,读者不必畏惧⼀些⽐较复杂的算法问题,多思考多类⽐,再⾼ ⼤上的算法在你眼⾥也不过⼀个脆⽪。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 179
180. 动态规划之正则表达 180
181. 动态规划之四键键盘 动态规划之四键键盘 学算法,认准 labuladong 就够了! 四键键盘问题很有意思,⽽且可以明显感受到:对 dp 数组的不同定义需要 完全不同的逻辑,从⽽产⽣完全不同的解法。 ⾸先看⼀下题⽬: 181
182. 动态规划之四键键盘 如何在 N 次敲击按钮后得到最多的 A?我们穷举呗,每次有对于每次按 键,我们可以穷举四种可能,很明显就是⼀个动态规划问题。 第⼀种思路 这种思路会很容易理解,但是效率并不⾼,我们直接⾛流程:对于动态规划 问题,⾸先要明⽩有哪些「状态」,有哪些「选择」。 具体到这个问题,对于每次敲击按键,有哪些「选择」是很明显的:4 种, 就是题⽬中提到的四个按键,分别是 写为 C A 、 C-A 、 C-C 、 C-V ( Ctrl 简 )。 接下来,思考⼀下对于这个问题有哪些「状态」?或者换句话说,我们需要 知道什么信息,才能将原问题分解为规模更⼩的⼦问题? 你看我这样定义三个状态⾏不⾏:第⼀个状态是剩余的按键次数,⽤ ⽰;第⼆个状态是当前屏幕上字符 A 的数量,⽤ 是剪切板中字符 A 的数量,⽤ copy a_num n 表 表⽰;第三个状态 表⽰。 如此定义「状态」,就可以知道 base case:当剩余次数 n 为 0 时, a_num 就是我们想要的答案。 结合刚才说的 4 种「选择」,我们可以把这⼏种选择通过状态转移表⽰出 来: dp(n - 1, a_num + 1, copy), # A 解释:按下 A 键,屏幕上加⼀个字符 同时消耗 1 个操作数 dp(n - 1, a_num + copy, copy), # C-V 解释:按下 C-V 粘贴,剪切板中的字符加⼊屏幕 同时消耗 1 个操作数 dp(n - 2, a_num, a_num) # C-A C-C 解释:全选和复制必然是联合使⽤的, 剪切板中 A 的数量变为屏幕上 A 的数量 同时消耗 2 个操作数 182
183. 动态规划之四键键盘 这样可以看到问题的规模 n 在不断减⼩,肯定可以到达 n = 0 的 base case,所以这个思路是正确的: def maxA(N:'>N: int) -> int:'>int: # 对于 (n, a_num, copy) 这个状态, # 屏幕上能最终最多能有 dp(n, a_num, copy) 个 A def dp(n, a_num, copy): # base case if n <= 0: return a_num; # ⼏种选择全试⼀遍,选择最⼤的结果 return max( dp(n - 1, a_num + 1, copy), # A dp(n - 1, a_num + copy, copy), # C-V dp(n - 2, a_num, a_num) # C-A C-C ) # 可以按 N 次按键,屏幕和剪切板⾥都还没有 A return dp(N, 0, 0) 这个解法应该很好理解,因为语义明确。下⾯就继续⾛流程,⽤备忘录消除 ⼀下重叠⼦问题: def maxA(N:'>N: int) -> int:'>int: # 备忘录 memo = dict() def dp(n, a_num, copy): if n <= 0: return a_num; # 避免计算重叠⼦问题 if (n, a_num, copy) in memo: return memo[(n, a_num, copy)] memo[(n, a_num, copy)] = max( # ⼏种选择还是⼀样的 ) return memo[(n, a_num, copy)] return dp(N, 0, 0) 183
184. 动态规划之四键键盘 这样优化代码之后,⼦问题虽然没有重复了,但数⽬仍然很多,在 LeetCode 提交会超时的。 我们尝试分析⼀下这个算法的时间复杂度,就会发现不容易分析。我们可以 把这个 dp 函数写成 dp 数组: dp[n][a_num][copy] # 状态的总数(时空复杂度)就是这个三维数组的体积 我们知道变量 n 最多为 N ,但是 a_num 和 copy 最多为多少我们很难 计算,复杂度起码也有 O(N^3) 把。所以这个算法并不好,复杂度太⾼,且 已经⽆法优化了。 这也就说明,我们这样定义「状态」是不太优秀的,下⾯我们换⼀种定义 dp 的思路。 第⼆种思路 这种思路稍微有点复杂,但是效率⾼。继续⾛流程,「选择」还是那 4 个, 但是这次我们只定义⼀个「状态」,也就是剩余的敲击次数 n 。 这个算法基于这样⼀个事实,最优按键序列⼀定只有两种情况: 要么⼀直按 A :A,A,...A(当 N ⽐较⼩时)。 要么是这么⼀个形式:A,A,...C-A,C-C,C-V,C-V,...C-V(当 N ⽐较⼤时)。 因为字符数量少(N ⽐较⼩)时, ⾼,可能不如⼀个个按 A C-A C-C C-V 这⼀套操作的代价相对⽐较 ;⽽当 N ⽐较⼤时,后期 ⼤。这种情况下整个操作序列⼤致是:开头连按⼏个 组合再接若⼲ C-V ,然后再 C-A C-C 换句话说,最后⼀次按键要么是 A 接着若⼲ 要么是 C-V C-V C-V A 的收获肯定很 ,然后 C-A C-C ,循环下去。 。明确了这⼀点,可以通 过这两种情况来设计算法: int[] dp = new int[N + 1]; 184
185. 动态规划之四键键盘 // 定义:dp[i] 表⽰ i 次操作后最多能显⽰多少个 A for (int i = 0; i <= N; i++) dp[i] = max( 这次按 A 键, 这次按 C-V ) 对于「按 A 键」这种情况,就是状态 i - 1 的屏幕上新增了⼀个 A ⽽ 已,很容易得到结果: // 按 A 键,就⽐上次多⼀个 A ⽽已 dp[i] = dp[i - 1] + 1; 但是,如果要按 C-V ,还要考虑之前是在哪⾥ 刚才说了,最优的操作序列⼀定是 个变量 C-C j 作为若⼲ C-V C-A C-C 的起点。那么 j C-A C-C 接着若⼲ 的。 C-V ,所以我们⽤⼀ 之前的 2 个操作就应该是 C-A 了: public int maxA(int N) { int[] dp = new int[N + 1]; dp[0] = 0; for (int i = 1; i <= N; i++) { // 按 A 键 dp[i] = dp[i - 1] + 1; for (int j = 2; j < i; j++) { // 全选 & 复制 dp[j-2],连续粘贴 i - j 次 // 屏幕上共 dp[j - 2] * (i - j + 1) 个 A dp[i] = Math.max(dp[i], dp[j - 2] * (i - j + 1)); } } // N 次按键之后最多有⼏个 A? return dp[N]; } 其中 j 变量减 2 是给 C-A C-C 留下操作数,看个图就明⽩了: 185
186. 动态规划之四键键盘 这样,此算法就完成了,时间复杂度 O(N^2),空间复杂度 O(N),这种解法 应该是⽐较⾼效的了。 最后总结 动态规划难就难在寻找状态转移,不同的定义可以产⽣不同的状态转移逻 辑,虽然最后都能得到正确的结果,但是效率可能有巨⼤的差异。 回顾第⼀种解法,重叠⼦问题已经消除了,但是效率还是低,到底低在哪⾥ 呢?抽象出递归框架: def dp(n, a_num, copy): dp(n - 1, a_num + 1, copy), # A dp(n - 1, a_num + copy, copy), # C-V dp(n - 2, a_num, a_num) # C-A C-C 看这个穷举逻辑,是有可能出现这样的操作序列 C-V,C-V,... C-A C-C,C-A C-C... 或者 。然这种操作序列的结果不是最优的,但是我们并没有想办法 规避这些情况的发⽣,从⽽增加了很多没必要的⼦问题计算。 186
187. 动态规划之四键键盘 回顾第⼆种解法,我们稍加思考就能想到,最优的序列应该是这种形 式: A,A..C-A,C-C,C-V,C-V..C-A,C-C,C-V.. 。 根据这个事实,我们重新定义了状态,重新寻找了状态转移,从逻辑上减少 了⽆效的⼦问题个数,从⽽提⾼了算法的效率。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 187
188. 动态规划之KMP字符匹配算法 动态规划之KMP字符匹配算法 学算法,认准 labuladong 就够了! KMP 算法(Knuth-Morris-Pratt 算法)是⼀个著名的字符串匹配算法,效率 很⾼,但是确实有点复杂。 很多读者抱怨 KMP 算法⽆法理解,这很正常,想到⼤学教材上关于 KMP 算法的讲解,也不知道有多少未来的 Knuth、Morris、Pratt 被提前劝退了。 有⼀些优秀的同学通过⼿推 KMP 算法的过程来辅助理解该算法,这是⼀种 办法,不过本⽂要从逻辑层⾯帮助读者理解算法的原理。⼗⾏代码之间, KMP 灰⻜烟灭。 先在开头约定,本⽂⽤ ⻓度为 N pat 。KMP 算法是在 表⽰模式串,⻓度为 txt 中查找⼦串 pat M , 表⽰⽂本串, txt ,如果存在,返回这个 ⼦串的起始索引,否则返回 -1。 为什么我认为 KMP 算法就是个动态规划问题呢,等会再解释。对于动态规 划,之前多次强调了要明确 ⼀种定义 dp dp 数组的含义,⽽且同⼀个问题可能有不⽌ 数组含义的⽅法,不同的定义会有不同的解法。 读者⻅过的 KMP 算法应该是,⼀波诡异的操作处理 的数组 next pat 后形成⼀个⼀维 ,然后根据这个数组经过⼜⼀波复杂操作去匹配 复杂度 O(N),空间复杂度 O(M)。其实它这个 组,其中元素的含义跟 pat 理解。本⽂则⽤⼀个⼆维的 next txt 数组就相当于 。时间 dp 数 的前缀和后缀有关,判定规则⽐较复杂,不好 dp 数组(但空间复杂度还是 O(M)),重新定 义其中元素的含义,使得代码⻓度⼤⼤减少,可解释性⼤⼤提⾼。 PS:本⽂的代码参考《算法4》,原代码使⽤的数组名称是 dfa (确定有 限状态机),因为我们的公众号之前有⼀系列动态规划的⽂章,就不说这么 ⾼⼤上的名词了,我对书中代码进⾏了⼀点修改,并沿⽤ dp 数组的名 称。 188
189. 动态规划之KMP字符匹配算法 ⼀、KMP 算法概述 ⾸先还是简单介绍⼀下 KMP 算法和暴⼒匹配算法的不同在哪⾥,难点在哪 ⾥,和动态规划有啥关系。 暴⼒的字符串匹配算法很容易写,看⼀下它的运⾏逻辑: // 暴⼒匹配(伪码) int search(String pat, String txt) { int M = pat.length; int N = txt.length; for (int i = 0; i <= N - M; i++) { int j; for (j = 0; j < M; j++) { if (pat[j] != txt[i+j]) break; } // pat 全都匹配了 if (j == M) return i; } // txt 中不存在 pat ⼦串 return -1; } 对于暴⼒算法,如果出现不匹配字符,同时回退 套 for 循环,时间复杂度 O(MN) ,空间复杂度 txt O(1) 和 pat 的指针,嵌 。最主要的问题是,如 果字符串中重复的字符⽐较多,该算法就显得很蠢。 ⽐如 txt = "aaacaaab" pat = "aaab": 【PDF格式⽆法显⽰GIF⽂件 kmp/1.gif,可移步公众号查看】 很明显, pat 中根本没有字符 c,根本没必要回退指针 i ,暴⼒解法明显 多做了很多不必要的操作。 KMP 算法的不同之处在于,它会花费空间来记录⼀些信息,在上述情况中 就会显得很聪明: 【PDF格式⽆法显⽰GIF⽂件 kmp/2.gif,可移步公众号查看】 189
190. 动态规划之KMP字符匹配算法 再⽐如类似的 txt = "aaaaaaab" pat = "aaab",暴⼒解法还会和上⾯那个例⼦⼀ 样蠢蠢地回退指针 i ,⽽ KMP 算法⼜会耍聪明: 【PDF格式⽆法显⽰GIF⽂件 kmp/3.gif,可移步公众号查看】 因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要⽐较 字符 b 是否被匹配就⾏了。 KMP 算法永不回退 txt ),⽽是借助 txt dp 的指针 i ,不⾛回头路(不会重复扫描 数组中储存的信息把 pat 移到正确的位置继续匹 配,时间复杂度只需 O(N),⽤空间换时间,所以我认为它是⼀种动态规划 算法。 KMP 算法的难点在于,如何计算 确地移动 pat dp 数组中的信息?如何根据这些信息正 的指针?这个就需要确定有限状态⾃动机来辅助了,别怕这 种⾼⼤上的⽂学词汇,其实和动态规划的 dp 数组如出⼀辙,等你学会了 也可以拿这个词去吓唬别⼈。 还有⼀点需要明确的是:计算这个 说,只要给我个 以给我不同的 pat txt dp 数组,只和 pat ,我就能通过这个模式串计算出 ,我都不怕,利⽤这个 dp 串有关。意思是 dp 数组,然后你可 数组我都能在 O(N) 时间完 成字符串匹配。 具体来说,⽐如上⽂举的两个例⼦: txt1 = "aaacaaab" pat = "aaab" txt2 = "aaaaaaab" pat = "aaab" 我们的 txt 不同,但是 pat 是⼀样的,所以 KMP 算法使⽤的 dp 数组 是同⼀个。 只不过对于 txt1 的下⾯这个即将出现的未匹配情况: 190
191. 动态规划之KMP字符匹配算法 dp 数组指⽰ PS:这个 j pat 这样移动: 不要理解为索引,它的含义更准确地说应该是状态(state), 所以它会出现这个奇怪的位置,后⽂会详述。 ⽽对于 txt2 的下⾯这个即将出现的未匹配情况: 191
192. 动态规划之KMP字符匹配算法 dp 数组指⽰ 明⽩了 dp pat 这样移动: 数组只和 pat 有关,那么我们这样设计 KMP 算法就会⽐较漂 亮: public class KMP { private int[][] dp; private String pat; 192
193. 动态规划之KMP字符匹配算法 public KMP(String pat) { this.pat = pat; // 通过 pat 构建 dp 数组 // 需要 O(M) 时间 } public int search(String txt) { // 借助 dp 数组去匹配 txt // 需要 O(N) 时间 } } 这样,当我们需要⽤同⼀ 造 dp pat 去匹配不同 txt 时,就不需要浪费时间构 数组了: KMP kmp = new KMP("aaab"); int pos1 = kmp.search("aaacaaab"); //4 int pos2 = kmp.search("aaaaaaab"); //4 ⼆、状态机概述 为什么说 KMP 算法和状态机有关呢?是这样的,我们可以认为 pat 的匹 配就是状态的转移。⽐如当 pat = "ABABC": 193
194. 动态规划之KMP字符匹配算法 如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5( pat.length )是终⽌状态。开始匹配时 到终⽌状态,就说明在 txt 中找到了 pat pat 处于起始状态,⼀旦转移 。⽐如说当前处于状态 2,就说 明字符 "AB" 被匹配: 另外,处于不同状态时, pat 状态转移的⾏为也不同。⽐如说假设现在匹 配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转 移到状态 5,如果遇到字符 B 就应该转移到状态 0: 194
195. 动态规划之KMP字符匹配算法 具体什么意思呢,我们来⼀个个举例看看。⽤变量 指针,当前 pat j 表⽰指向当前状态的 匹配到了状态 4: 如果遇到了字符 "A",根据箭头指⽰,转移到状态 3 是最聪明的: 如果遇到了字符 "B",根据箭头指⽰,只能转移到状态 0(⼀夜回到解放 前): 195
196. 动态规划之KMP字符匹配算法 如果遇到了字符 "C",根据箭头指⽰,应该转移到终⽌状态 5,这也就意味 着匹配完成: 当然了,还可能遇到其他字符,⽐如 Z,但是显然应该转移到起始状态 0, 因为 pat 中根本都没有字符 Z: 196
197. 动态规划之KMP字符匹配算法 这⾥为了清晰起⻅,我们画状态图时就把其他字符转移到状态 0 的箭头省 略,只画 pat 中出现的字符的状态转移: KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的⾏ 为,得明确两个变量,⼀个是当前的匹配状态,另⼀个是遇到的字符;确定 了这两个变量后,就可以知道这个情况下应该转移到哪个状态。 下⾯看⼀下 KMP 算法根据这幅状态转移图匹配字符串 txt 的过程: 197
198. 动态规划之KMP字符匹配算法 【PDF格式⽆法显⽰GIF⽂件 kmp/kmp.gif,可移步公众号查看】 请记住这个 GIF 的匹配过程,这就是 KMP 算法的核⼼逻辑! 为了描述状态转移图,我们定义⼀个⼆维 dp 数组,它的含义如下: dp[j][c] = next 0 <= j < M,代表当前的状态 0 <= c < 256,代表遇到的字符(ASCII 码) 0 <= next <= M,代表下⼀个状态 dp[4]['A'] = 3 表⽰: 当前是状态 4,如果遇到字符 A, pat 应该转移到状态 3 dp[1]['B'] = 2 表⽰: 当前是状态 1,如果遇到字符 B, pat 应该转移到状态 2 根据我们这个 dp 数组的定义和刚才状态转移的过程,我们可以先写出 KMP 算法的 search 函数代码: public int search(String txt) { int M = pat.length(); int N = txt.length(); // pat 的初始态为 0 int j = 0; for (int i = 0; i < N; i++) { // 当前是状态 j,遇到字符 txt[i], // pat 应该转移到哪个状态? j = dp[j][txt.charAt(i)]; // 如果达到终⽌态,返回匹配开头的索引 if (j == M) return i - M + 1; } // 没到达终⽌态,匹配失败 return -1; } 198
199. 动态规划之KMP字符匹配算法 到这⾥,应该还是很好理解的吧, dp 数组就是我们刚才画的那幅状态转 移图,如果不清楚的话回去看下 GIF 的算法演进过程。下⾯讲解:如何通 过 pat 构建这个 dp 数组? 三、构建状态转移图 回想刚才说的:要确定状态转移的⾏为,必须明确两个变量,⼀个是当前的 匹配状态,另⼀个是遇到的字符,⽽且我们已经根据这个逻辑确定了 数组的含义,那么构造 dp dp 数组的框架就是这样: for 0 <= j < M: # 状态 for 0 <= c < 256: # 字符 dp[j][c] = next 这个 next 状态应该怎么求呢?显然,如果遇到的字符 的话,状态就应该向前推进⼀个,也就是说 c next = j + 1 和 pat[j] 匹配 ,我们不妨称这 种情况为状态推进: 如果字符 c 和 pat[j] 不匹配的话,状态就要回退(或者原地不动),我 们不妨称这种情况为状态重启: 199
200. 动态规划之KMP字符匹配算法 那么,如何得知在哪个状态重启呢?解答这个问题之前,我们再定义⼀个名 字:影⼦状态(我编的名字),⽤变量 X 表⽰。所谓影⼦状态,就是和当 前状态具有相同的前缀。⽐如下⾯这种情况: 当前状态 j = 4 为状态 和状态 X ,其影⼦状态为 j 的时候(遇到的字符 X = 2 ,它们都有相同的前缀 "AB"。因 存在相同的前缀,所以当状态 c 和 pat[j] j 准备进⾏状态重启 不匹配),可以通过 X 的状态转移图 来获得最近的重启位置。 200
201. 动态规划之KMP字符匹配算法 ⽐如说刚才的情况,如果状态 j 遇到⼀个字符 "A",应该转移到哪⾥呢? ⾸先只有遇到 "C" 才能推进状态,遇到 "A" 显然只能进⾏状态重启。状态 j 会把这个字符委托给状态 X 为什么这样可以呢?因为:既然 处理,也就是 j dp[j]['A'] = dp[X]['A'] : 这边已经确定字符 "A" ⽆法推进状态, 只能回退,⽽且 KMP 就是要尽可能少的回退,以免多余的计算。那么 就可以去问问和⾃⼰具有相同前缀的 X ,如果 X j 遇⻅ "A" 可以进⾏「状 态推进」,那就转移过去,因为这样回退最少。 【PDF格式⽆法显⽰GIF⽂件 kmp/A.gif,可移步公众号查看】 当然,如果遇到的字符是 "B",状态 退, j 只要跟着 X X 也不能进⾏「状态推进」,只能回 指引的⽅向回退就⾏了: 201
202. 动态规划之KMP字符匹配算法 你也许会问,这个 永远跟在 j X 怎么知道遇到字符 "B" 要回退到状态 0 呢?因为 的⾝后,状态 X X 如何转移,在之前就已经算出来了。动态规 划算法不就是利⽤过去的结果解决现在的问题吗? 这样,我们就细化⼀下刚才的框架代码: int X # 影⼦状态 for 0 <= j < M: for 0 <= c < 256: if c == pat[j]: # 状态推进 dp[j][c] = j + 1 else: # 状态重启 # 委托 X 计算重启位置 dp[j][c] = dp[X][c] 四、代码实现 如果之前的内容你都能理解,恭喜你,现在就剩下⼀个问题:影⼦状态 X 是如何得到的呢?下⾯先直接看完整代码吧。 public class KMP { 202
203. 动态规划之KMP字符匹配算法 private int[][] dp; private String pat; public KMP(String pat) { this.pat = pat; int M = pat.length(); // dp[状态][字符] = 下个状态 dp = new int[M][256]; // base case dp[0][pat.charAt(0)] = 1; // 影⼦状态 X 初始为 0 int X = 0; // 当前状态 j 从 1 开始 for (int j = 1; j < M; j++) { for (int c = 0; c < 256; c++) { if (pat.charAt(j) == c) dp[j][c] = j + 1; else dp[j][c] = dp[X][c]; } // 更新影⼦状态 X = dp[X][pat.charAt(j)]; } } public int search(String txt) {...} } 先解释⼀下这⼀⾏代码: // base case dp[0][pat.charAt(0)] = 1; 这⾏代码是 base case,只有遇到 pat[0] 这个字符才能使状态从 0 转移到 1, 遇到其它字符的话还是停留在状态 0(Java 默认初始化数组全为 0)。 影⼦状态 X 是先初始化为 0,然后随着 看到底应该如何更新影⼦状态 X j 的前进⽽不断更新的。下⾯看 : int X = 0; 203
204. 动态规划之KMP字符匹配算法 for (int j = 1; j < M; j++) { ... // 更新影⼦状态 // 当前是状态 X,遇到字符 pat[j], // pat 应该转移到哪个状态? X = dp[X][pat.charAt(j)]; } 更新 X 其实和 search 函数中更新状态 j 的过程是⾮常相似的: int j = 0; for (int i = 0; i < N; i++) { // 当前是状态 j,遇到字符 txt[i], // pat 应该转移到哪个状态? j = dp[j][txt.charAt(i)]; ... } 其中的原理⾮常微妙,注意代码中 for 循环的变量初始值,可以这样理解: 后者是在 X txt 中匹配 总是落后状态 j pat ,前者是在 ⼀个状态,与 j pat 中匹配 pat[1..end] ,状态 具有最⻓的相同前缀。所以我把 X ⽐喻为影⼦状态,似乎也有⼀点贴切。 另外,构建 dp 数组是根据 base case dp[0][..] 向后推演。这就是我认为 KMP 算法就是⼀种动态规划算法的原因。 下⾯来看⼀下状态转移图的完整构造过程,你就能理解状态 X 作⽤之精妙 了: 【PDF格式⽆法显⽰GIF⽂件 kmp/dfa.gif,可移步公众号查看】 ⾄此,KMP 算法的核⼼终于写完啦啦啦啦!看下 KMP 算法的完整代码 吧: public class KMP { private int[][] dp; private String pat; 204
205. 动态规划之KMP字符匹配算法 public KMP(String pat) { this.pat = pat; int M = pat.length(); // dp[状态][字符] = 下个状态 dp = new int[M][256]; // base case dp[0][pat.charAt(0)] = 1; // 影⼦状态 X 初始为 0 int X = 0; // 构建状态转移图(稍改的更紧凑了) for (int j = 1; j < M; j++) { for (int c = 0; c < 256; c++) dp[j][c] = dp[X][c]; dp[j][pat.charAt(j)] = j + 1; // 更新影⼦状态 X = dp[X][pat.charAt(j)]; } } public int search(String txt) { int M = pat.length(); int N = txt.length(); // pat 的初始态为 0 int j = 0; for (int i = 0; i < N; i++) { // 计算 pat 的下⼀个状态 j = dp[j][txt.charAt(i)]; // 到达终⽌态,返回结果 if (j == M) return i - M + 1; } // 没到达终⽌态,匹配失败 return -1; } } 经过之前的详细举例讲解,你应该可以理解这段代码的含义了,当然你也可 以把 KMP 算法写成⼀个函数。核⼼代码也就是两个函数中 for 循环的部 分,数⼀下有超过⼗⾏吗? 五、最后总结 205
206. 动态规划之KMP字符匹配算法 传统的 KMP 算法是使⽤⼀个⼀维数组 ⼀个⼆维数组 next 记录前缀信息,⽽本⽂是使⽤ 以状态转移的⾓度解决字符匹配问题,但是空间复杂度 dp 仍然是 O(256M) = O(M)。 在 匹配 pat 的过程中,只要明确了「当前处在哪个状态」和「遇到 txt 的字符是什么」这两个问题,就可以确定应该转移到哪个状态(推进或回 退)。 对于⼀个模式串 pat ,其总共就有 M 个状态,对于 ASCII 字符,总共不会 超过 256 种。所以我们就构造⼀个数组 明确 dp 来包含所有情况,并且 数组的含义: dp[j][c] = next next dp[M][256] 表⽰,当前是状态 j ,遇到了字符 c ,应该转移到状态 。 明确了其含义,就可以很容易写出 search 函数的代码。 对于如何构建这个 j dp 数组,需要⼀个辅助状态 落后⼀个状态,拥有和 j X ,它永远⽐当前状态 最⻓的相同前缀,我们给它起了个名字叫 「影⼦状态」。 在构建当前状态 ( j 的转移⽅向时,只有字符 dp[j][pat[j]] = j+1 教影⼦状态 other X 是除了 对于影⼦状态 );⽽对于其他字符只能进⾏状态回退,应该去请 应该回退到哪⾥( pat[j] X dp[j][other] = dp[X][other] ,其中 之外所有字符)。 ,我们把它初始化为 0,并且随着 更新的⽅式和 search 过程更新 [pat[j]] 才能使状态推进 pat[j] j 的过程⾮常相似( j 的前进进⾏更新, X = dp[X] )。 KMP 算法也就是动态规划那点事,我们的公众号⽂章⽬录有⼀系列专门讲 动态规划的,⽽且都是按照⼀套框架来的,⽆⾮就是描述问题逻辑,明确 dp 数组含义,定义 base case 这点破事。希望这篇⽂章能让⼤家对动态规 划有更深的理解。 _____________ 206
207. 动态规划之KMP字符匹配算法 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 207
208. 贪⼼算法之区间调度问题 贪⼼算法之区间调度问题 学算法,认准 labuladong 就够了! 什么是贪⼼算法呢?贪⼼算法可以认为是动态规划算法的⼀个特例,相⽐动 态规划,使⽤贪⼼算法需要满⾜更多的条件(贪⼼选择性质),但是效率⽐ 动态规划要⾼。 ⽐如说⼀个算法问题使⽤暴⼒解法需要指数级时间,如果能使⽤动态规划消 除重叠⼦问题,就可以降到多项式级别的时间,如果满⾜贪⼼选择性质,那 么可以进⼀步降低时间复杂度,达到线性级别的。 什么是贪⼼选择性质呢,简单说就是:每⼀步都做出⼀个局部最优的选择, 最终的结果就是全局最优。注意哦,这是⼀种特殊性质,其实只有⼀部分问 题拥有这个性质。 ⽐如你⾯前放着 100 张⼈⺠币,你只能拿⼗张,怎么才能拿最多的⾯额?显 然每次选择剩下钞票中⾯值最⼤的⼀张,最后你的选择⼀定是最优的。 然⽽,⼤部分问题明显不具有贪⼼选择性质。⽐如打⽃地主,对⼿出对⼉ 三,按照贪⼼策略,你应该出尽可能⼩的牌刚好压制住对⽅,但现实情况我 们甚⾄可能会出王炸。这种情况就不能⽤贪⼼算法,⽽得使⽤动态规划解 决,参⻅前⽂「动态规划解决博弈问题」。 ⼀、问题概述 ⾔归正传,本⽂解决⼀个很经典的贪⼼算法问题 Interval Scheduling(区间 调度问题)。给你很多形如 [start, end] 的闭区间,请你设计⼀个算法, 算出这些区间中最多有⼏个互不相交的区间。 int intervalSchedule(int[][] intvs) {} 208
209. 贪⼼算法之区间调度问题 举个例⼦, 相交,即 intvs = [[1,3], [2,4], [3,6]] [[1,3], [3,6]] ,这些区间最多有 2 个区间互不 ,你的算法应该返回 2。注意边界相同并不算相 交。 这个问题在⽣活中的应⽤⼴泛,⽐如你今天有好⼏个活动,每个活动都可以 ⽤区间 [start, end] 表⽰开始和结束的时间,请问你今天最多能参加⼏个 活动呢?显然你⼀个⼈不能同时参加两个活动,所以说这个问题就是求这些 时间区间的最⼤不相交⼦集。 ⼆、贪⼼解法 这个问题有许多看起来不错的贪⼼思路,却都不能得到正确答案。⽐如说: 也许我们可以每次选择可选区间中开始最早的那个?但是可能存在某些区间 开始很早,但是很⻓,使得我们错误地错过了⼀些短的区间。或者我们每次 选择可选区间中最短的那个?或者选择出现冲突最少的那个区间?这些⽅案 都能很容易举出反例,不是正确的⽅案。 正确的思路其实很简单,可以分为以下三步: 1. 从区间集合 intvs 中选择⼀个区间 x,这个 x 是在当前所有区间中结束 最早的(end 最⼩)。 2. 把所有与 x 区间相交的区间从区间集合 intvs 中删除。 3. 重复步骤 1 和 2,直到 intvs 为空为⽌。之前选出的那些 x 就是最⼤不 相交⼦集。 把这个思路实现成算法的话,可以按每个区间的 end 数值升序排序,因为 这样处理之后实现步骤 1 和步骤 2 都⽅便很多: 【PDF格式⽆法显⽰GIF⽂件 interval/1.gif,可移步公众号查看】 现在来实现算法,对于步骤 1,由于我们预先按照 end 排了序,所以选择 x 是很容易的。关键在于,如何去除与 x 相交的区间,选择下⼀轮循环的 x 呢? 209
210. 贪⼼算法之区间调度问题 由于我们事先排了序,不难发现所有与 x 相交的区间必然会与 x 的 交;如果⼀个区间不想与 x 的 于)x 的 end end 相交,它的 start end 相 必须要⼤于(或等 : 看下代码: public int intervalSchedule(int[][] intvs) { if (intvs.length == 0) return 0; // 按 end 升序排序 Arrays.sort(intvs, new Comparator() { public int compare(int[] a, int[] b) { return a[1] - b[1]; } }); // ⾄少有⼀个区间不相交 int count = 1; // 排序后,第⼀个区间就是 x int x_end = intvs[0][1]; for (int[] interval : intvs) { int start = interval[0]; if (start >= x_end) { // 找到下⼀个选择的区间了 count++; x_end = interval[1]; } 210
211. 贪⼼算法之区间调度问题 } return count; } 三、应⽤举例 下⾯举例⼏道 LeetCode 题⽬应⽤⼀下区间调度算法。 第 435 题,⽆重叠区间: 我们已经会求最多有⼏个区间不会重叠了,那么剩下的不就是⾄少需要去除 的区间吗? int eraseOverlapIntervals(int[][] intervals) { int n = intervals.length; return n - intervalSchedule(intervals); } 第 452 题,⽤最少的箭头射爆⽓球: 211
212. 贪⼼算法之区间调度问题 其实稍微思考⼀下,这个问题和区间调度算法⼀模⼀样!如果最多有 不重叠的区间,那么就⾄少需要 n n 个 个箭头穿透所有区间: 212
213. 贪⼼算法之区间调度问题 只是有⼀点不⼀样,在 intervalSchedule 算法中,如果两个区间的边界触 碰,不算重叠;⽽按照这道题⽬的描述,箭头如果碰到⽓球的边界⽓球也会 爆炸,所以说相当于区间的边界触碰也算重叠: 所以只要将之前的算法稍作修改,就是这道题⽬的答案: int findMinArrowShots(int[][] intvs) { // ... 213
214. 贪⼼算法之区间调度问题 for (int[] interval : intvs) { int start = interval[0]; // 把 >= 改成 > 就⾏了 if (start > x_end) { count++; x_end = interval[1]; } } return count; } _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 214
215. 第⼆章、数据结构系列 数据结构系列 学算法,认准 labuladong 就够了! 这⼀章主要是⼀些特殊的数据结构设计,⽐如单调栈解决 Next Greater Number,单调队列解决滑动窗⼝问题;还有常⽤数据结构的操作,⽐如链 表、树、⼆叉堆。 欢迎关注我的公众号 labuladong,⽅便获得最新的优质⽂章: 215
216. 学习数据结构和算法读什么书 为什么我推荐《算法4》 学算法,认准 labuladong 就够了! 咱们的公众号有很多硬核的算法⽂章,今天就聊点轻松的,就具体聊聊我⾮ 常“⿎吹”的《算法4》。这本书我在之前的⽂章多次推荐过,但是没有具体 的介绍,今天就来正式介绍⼀下。。 我的推荐不会直接甩⼀⼤堆书⽬,⽽是会联系实际⽣活,讲⼀些书中有趣有 ⽤的知识,⽆论你最后会不会去看这本书,本⽂都会给你带来⼀些收获。 ⾸先这本书是适合初学者的。总是有很多读者问,我只会 C 语⾔,能不能 看《算法4》?学算法最好⽤什么语⾔?诸如此类的问题。 经常看咱们公众号的读者应该体会到了,算法其实是⼀种思维模式,和你⽤ 什么语⾔没啥关系。我们的⽂章也不会固定⽤某⼀种语⾔,⽽是什么语⾔写 出来容易理解就⽤什么语⾔。再退⼀步说,到底适不适合你,⽹上找个 PDF 亲⾃看⼀下不就知道了? 《算法4》看起来挺厚的,但是前⾯⼏⼗⻚是教你 Java 的;每章后⾯还有习 题,占了不少⻚数;每章还有⼀些数学证明,这些都可以忽略。这样算下 来,剩下的就是基础知识和疑难解答之类的内容,含⾦量很⾼,把这些基础 知识动⼿实践⼀遍,真的就可以达到不错的⽔平了。 我觉得这本书之所以能有这么⾼的评分,⼀个是因为讲解详细,还有⼤量配 图,另⼀个原因就是书中把⼀些算法和现实⽣活中的使⽤场景联系起来,你 不仅知道某个算法怎么实现,也知道它⼤概能运⽤到什么场景,下⾯我就来 介绍两个图算法的简单应⽤。 ⼀、⼆分图的应⽤ 216
217. 学习数据结构和算法读什么书 我想举的第⼀个例⼦是⼆分图。简单来说,⼆分图就是⼀幅拥有特殊性质的 图:能够⽤两种颜⾊为所有顶点着⾊,使得任何⼀条边的两个顶点颜⾊不 同。 明⽩了⼆分图是什么,能解决什么实际问题呢?算法⽅⾯,常⻅的操作是如 何判定⼀幅图是不是⼆分图。⽐如说下⾯这道 LeetCode 题⽬: 217
218. 学习数据结构和算法读什么书 你想想,如果我们把每个⼈视为⼀个顶点,边代表讨厌;相互讨厌的两个⼈ 之间连接⼀条边,就可以形成⼀幅图。那么根据刚才⼆分图的定义,如果这 幅图是⼀幅⼆分图,就说明这些⼈可以被分为两组,否则的话就不⾏。 这是判定⼆分图算法的⼀个应⽤,其实⼆分图在数据结构⽅⾯也有⼀些不错 的特性。 ⽐如说我们需要⼀种数据结构来储存电影和演员之间的关系:某⼀部电影肯 定是由多位演员出演的,且某⼀位演员可能会出演多部电影。你使⽤什么数 据结构来存储这种关系呢? 既然是存储映射关系,最简单的不就是使⽤哈希表嘛,我们可以使⽤⼀个 HashMap> 来存储电影到演员列表的映射,如果给⼀部 电影的名字,就能快速得到出演该电影的演员。 但是如果给出⼀个演员的名字,我们想快速得到该演员演出的所有电影,怎 么办呢?这就需要「反向索引」,对之前的哈希表进⾏⼀些操作,新建另⼀ 个哈希表,把演员作为键,把电影列表作为值。 218
219. 学习数据结构和算法读什么书 对于上⾯这个例⼦,可以使⽤⼆分图来取代哈希表。电影和演员是具有⼆分 图性质的:如果把电影和演员视为图中的顶点,出演关系作为边,那么与电 影顶点相连的⼀定是演员,与演员相邻的⼀定是电影,不存在演员和演员相 连,电影和电影相连的情况。 回顾⼆分图的定义,如果对演员和电影顶点着⾊,肯定就是⼀幅⼆分图: 如果这幅图构建完成,就不需要反向索引,对于演员顶点,其直接连接的顶 点就是他出演的电影,对于电影顶点,其直接连接的顶点就是出演演员。 当然,对于这个问题,书中还提到了⼀些其他有趣的玩法,⽐如说社交⽹络 中「间隔度数」的计算(六度空间理论应该听说过)等等,其实就是⼀个 BFS ⼴度优先搜索寻找最短路径的问题,具体代码实现这⾥就不展开了。 ⼆、套汇的算法 如果我们说货币 A 到货币 B 的汇率是 10,意思就是 1 单位的货币 A 可以换 10 单位货币 B。如果我们把每种货币视为⼀幅图的顶点,货币之间的汇率 视为加权有向边,那么整个汇率市场就是⼀幅「完全加权有向图」。 ⼀旦把现实⽣活中的情景抽象成图,就有可能运⽤算法解决⼀些问题。⽐如 说图中可能存在下⾯的情况: 219
220. 学习数据结构和算法读什么书 图中的加权有向边代表汇率,我们可以发现如果把 100 单位的货币 A 换成 B,再换成 C,最后换回 A,就可以得到 100×0.9×0.8×1.4 = 100.8 单位的 A!如果交易的⾦额⼤⼀些的话,赚的钱是很可观的,这种空⼿套⽩狼的操 作就是套汇。 现实中交易会有种种限制,⽽且市场瞬息万变,但是套汇的利润还是很⾼ 的,关键就在于如何快速找到这种套汇机会呢? 借助图的抽象,我们发现套汇机会其实就是⼀个环,且这个环上的权重之积 ⼤于 1,只要在顺着这个环交易⼀圈就能空⼿套⽩狼。 图论中有⼀个经典算法叫做 Bellman-Ford 算法,可以⽤于寻找负权重环。 对于我们说的套汇问题,可以先把所有边的权重 w 替换成 -ln(w),这样「寻 找权重乘积⼤于 1 的环」就转化成了「寻找权重和⼩于 0 的环」,就可以使 ⽤ Bellman-Ford 算法在 O(EV) 的时间内寻找负权重环,也就是寻找套汇机 会。 《算法4》就介绍到这⾥,关于上⾯两个例⼦的具体内容,可以⾃⼰去看 书,公众号后台回复关键词「算法4」就有 PDF。 三、最后说⼏句 220
221. 学习数据结构和算法读什么书 ⾸先,前⽂说对于数学证明、章后习题可以忽略,可能有⼈要抬杠了:难道 习题和数学证明不重要吗? 那我想说,就是不重要,起码对⼤多数⼈来说不重要。我觉得吧,学习就要 带着⽬的性去学,⼤部分⼈学算法不就是巩固计算机知识,对付⾯试题⽬ 吗?如果是这个⽬的,那就学些基本的数据结构和经典算法,明⽩它们的时 间复杂度,然后去刷题就好了,何必和习题、证明过不去? 这也是我从来不推荐《算法导论》这本书的原因。如果有⼈给你推荐这本 书,只可能有两个原因,要么他是真⼤佬,要么他在装⼤佬。《算法导论》 中充斥⼤量数学证明,⽽且很多数据结构是很少⽤到的,顶多当个字典⽤。 你说你学了那些有啥⽤呢,饶过⾃⼰呗。 另外,读书在精不在多。你花时间《算法4》过个⼤半(最后⼩半部分有点 困难),同时刷点题,看看咱们的公众号⽂章,算法这块真就够了,别对细 节问题太较真。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 221
222. 算法学习之路 算法学习之路 学算法,认准 labuladong 就够了! 之前发的那篇关于框架性思维的⽂章,我也发到了不少其他圈⼦,受到了⼤ 家的普遍好评,这⼀点我真的没想到,⾸先感谢⼤家的认可,我会更加努 ⼒,写出通俗易懂的算法⽂章。 有很多朋友问我数据结构和算法到底该怎么学,尤其是很多朋友说⾃⼰是 「⼩⽩」,感觉这些东⻄好难啊,就算看了之前的「框架思维」,也感觉⾃ ⼰刷题乏⼒,希望我能聊聊我从⼀个⾮科班⼩⽩⼀路是怎么学过来的。 ⾸先要给怀有这样疑问的朋友⿎掌,因为你现在已经「知道⾃⼰不知道」, ⽽且开始尝试学习、刷题、寻求帮助,能做到这⼀点本⾝就是及其困难的。 关于「框架性思维」,对于⼀个⼩⽩来说,可能暂时⽆法完全理解(如果你 能理解,说明你⽔平已经不错啦,不是⼩⽩啦)。就像软件⼯程,对于我这 种没带过项⽬的⼈来说,感觉其内容枯燥乏味,全是废话,但是对于⼀个带 过团队的⼈,他就会觉得软件⼯程⾥的每⼀句话都是精华。暂时不太理解没 关系,留个印象,功夫到了很快就明⽩了。 下⾯写⼀写我⼀路过来的⼀些经验。如果你已经看过很多「如何⾼效刷题」 「如何学习算法」的⽂章,却还是没有开始⾏动并坚持下去,本⽂的第五点 就是写给你的。 我觉得之所以有时候认为⾃⼰是「⼩⽩」,是由于知识某些⽅⾯的空⽩造成 的。具体到数据结构的学习,⽆⾮就是两个问题搞得不太清楚:这是啥?有 啥⽤? 举个例⼦,⽐如说你看到了「栈」这个名词,⽼师可能会讲这些关键词:先 进后出、函数堆栈等等。但是,对于初学者,这些描述属于⽂学词汇,没有 实际价值,没有解决最基本的两个问题。如何回答这两个基本问题呢?回答 「这是啥」需要看教科书,回答「有啥⽤」需要刷算法题。 222
223. 算法学习之路 ⼀、这是啥? 这个问题最容易解决,就像⼀层窗户纸,你只要随便找本书看两天,⾃⼰动 ⼿实现⼀个「队列」「栈」之类的数据结构,就能捅破这层窗户纸。 这时候你就能理解「框架思维」⽂章中的前半部分了:数据结构⽆⾮就是数 组、链表为⾻架的⼀些特定操作⽽已;每个数据结构实现的功能⽆⾮增删查 改罢了。 ⽐如说「列队」这个数据结构,⽆⾮就是基于数组或者链表,实现 enqueue 和 dequeue 两个⽅法。这两个⽅法就是增和删呀,连查和改的⽅法都不需 要。 ⼆、有啥⽤? 解决这个问题,就涉及算法的设计了,是个持久战,需要经常进⾏抽象思 考,刷算法题,培养「计算机思维」。 之前的⽂章讲了,算法就是对数据结构准确⽽巧妙的运⽤。常⽤算法问题也 就那⼏⼤类,算法题⽆⾮就是不断变换场景,给那⼏个算法框架套上不同的 ⽪。刷题,就是在锻炼你的眼⼒,看你能不能看穿问题表象揪出相应的解法 框架。 ⽐如说,让你求解⼀个迷宫,你要把这个问题层层抽象:迷宫 -> 图的遍历 > N 叉树的遍历 -> ⼆叉树的遍历。然后让框架指导你写具体的解法。 抽象问题,直击本质,是刷题中你需要刻意培养的能⼒。 三、如何看书 直接推荐⼀本公认的好书,《算法第 4 版》,我⼀般简写成《算法4》。不 要蜻蜓点⽔,这本书你能选择性的看上 50%,基本上就达到平均⽔平了。 别怕这本书厚,因为起码有三分之⼀不⽤看,下⾯讲讲怎么看这本书。 看书仍然遵循递归的思想:⾃顶向下,逐步求精。 223
224. 算法学习之路 这本书知识结构合理,讲解也清楚,所以可以按顺序学习。书中正⽂的算法 代码⼀定要亲⾃敲⼀遍,因为这些真的是扎实的基础,要认真理解。不要以 为⾃⼰看⼀遍就看懂了,不动⼿的话理解不了的。但是,开头部分的基础可 以酌情跳过;书中的数学证明,如不影响对算法本⾝的理解,完全可以跳 过;章节最后的练习题,也可以全部跳过。这样⼀来,这本书就薄了很多。 相信读者现在已经认可了「框架性思维」的重要性,这种看书⽅式也是⼀种 框架性策略,抓⼤放⼩,着重理解整体的知识架构,⽽忽略证明、练习题这 种细节问题,即保持⾃⼰对新知识的好奇⼼,避免陷⼊⽆限的细节被劝退。 当然,《算法4》到后⾯的内容也⽐较难了,⽐如那⼏个著名的串算法,以 及正则表达式算法。这些属于「经典算法」,看个⼈接受能⼒吧,单说刷 LeetCode 的话,基本⽤不上,量⼒⽽⾏即可。 四、如何刷题 ⾸先声明⼀下,算法和数学⽔平没关系,和编程语⾔也没关系,你爱⽤什么 语⾔⽤什么。算法,主要是培养⼀种新的思维⽅式。所谓「计算机思维」, 就跟你考驾照⼀样,你以前骑⾃⾏⻋,有⼀套⾃⾏⻋的规则和技巧,现在你 开汽⻋,就需要适应并练习开汽⻋的规则和技巧。 LeetCode 上的算法题和前⾯说的「经典算法」不⼀样,我们权且称为「解 闷算法」吧,因为很多题⽬都⽐较有趣,有种在做奥数题或者脑筋急转弯的 感觉。⽐如说,让你⽤队列实现⼀个栈,或者⽤栈实现⼀个队列,以及不⽤ 加号做加法,开脑洞吧? 当然,这些问题虽然看起来⽆厘头,实际⽣活中也⽤不到,但是想解决这些 问题依然要靠数据结构以及对基础知识的理解,也许这就是很多公司⾯试都 喜欢出这种「智⼒题」的原因。下⾯说⼏点技巧吧。 尽量刷英⽂版的 LeetCode,中⽂版的“⼒扣”是阉割版,不仅很多题⽬没有 答案,⽽且连个讨论区都没有。英⽂版的是真的很良⼼了,很多问题都有官 ⽅解答,详细易懂。⽽且讨论区(Discuss)也沉淀了⼤量优质内容,甚⾄ 好过官⽅解答。真正能打开你思路的,很可能是讨论区各路⼤神的思路荟 萃。 224
225. 算法学习之路 PS:如果有的英⽂题⽬实在看不懂,有个⼩技巧,你在题⽬⻚⾯的 url ⾥加 ⼀个 -cn,即 https://leetcode.com/xxx 改成 https://leetcode-cn.com/xxx,这样 就能切换到相应的中⽂版⻚⾯查看。 对于初学者,强烈建议从 Explore 菜单⾥最下⾯的 Learn 开始刷,这个专题 就是专门教你学习数据结构和基本算法的,教学篇和相应的练习题结合,不 要太良⼼。 最近 Learn 专题⾥新增了⼀些内容,我们挑数据结构相关的内容刷就⾏了, 像 Ruby,Machine Learning 就没必要刷了。刷完 Learn 专题的基础内容,基 本就有能⼒去 Explore 菜单的 Interview 专题刷⾯试题,或者去 Problem 菜 单,在真正的题海⾥遨游了。 ⽆论刷 Explore 还是 Problems 菜单,最好⼀个分类⼀个分类的刷,不要蜻蜓 点⽔。⽐如说这⼏天就刷链表,刷完链表再去连刷⼏天⼆叉树。这样做是为 了帮助你提取「框架」。⼀旦总结出针对⼀类问题的框架,解决同类问题可 谓是⼿到擒来。 五、道理我都懂,还是不能坚持下去 这其实⽆关算法了,还是⽼⽣常谈的执⾏⼒的问题。不说什么破鸡汤了,我 觉得解决办法就是「激起欲望」,注意我说的是欲望,⽽不是常说的兴趣, 拿我⾃⼰说说吧。 半年前我开始刷题,⽬的和⼤部分⼈都⼀样的,就是为毕业找⼯作做准备。 只不过,⼤部分⼈是等到临近毕业了才开始刷,⽽我离毕业还有⼀阵⼦。这 不是炫耀我多有觉悟,⽽是我承认⾃⼰的极度平凡。 ⾸先,我真的想找到⼀份不错的⼯作(谁都想吧?),我想要⾼薪呀!否则 我在朋友⾯前,⼥神⾯前放下的骚话,最终都会反过来啪啪地打我的脸。我 也是要恰饭,要⾯⼦,要虚荣⼼的嘛。赚钱,虚荣⼼,⾜以激起我的欲望 了。 但是,我不擅⻓ deadline 突击,我理解东⻄真的慢,所以⼲脆笨⻦先⻜了。 智商不够,拿时间来补,我没能⼒两个⽉突击,⼲脆拉⻓战线,打他个两年 游击战,我还不信耗不死算法这个强敌。事实证明,你如果认真学习⼀个 225
226. 算法学习之路 ⽉,就能够取得⾁眼可⻅的进步了。 现在,我依然在坚持刷题,⽽且为了另外⼀个原因,这个公众号。我没想到 ⾃⼰的⽂字竟然能够帮助到他⼈,甚⾄能得到认可。这也是虚荣⼼啊,我不 能让读者失望啊,我想让更多的⼈认可(夸)我呀! 以上,不光是坚持刷算法题吧,很多场景都适⽤。执⾏⼒是要靠「欲望」⽀ 撑的,我也是⼀凡⼈,只有那些看得⻅摸得着的东⻄才能使我快乐呀。读者 不妨也尝试把刷题学习和⾃⼰的切⾝利益联系起来,这恐怕是坚持下去最简 单直⽩的理由了。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 226
227. ⼆叉堆详解实现优先级队列 ⼆叉堆详解实现优先级队列 学算法,认准 labuladong 就够了! ⼆叉堆(Binary Heap)没什么神秘,性质⽐⼆叉搜索树 BST 还简单。其主 要操作就两个, sink (下沉)和 swim (上浮),⽤以维护⼆叉堆的性 质。其主要应⽤有两个,⾸先是⼀种排序⽅法「堆排序」,第⼆是⼀种很有 ⽤的数据结构「优先级队列」。 本⽂就以实现优先级队列(Priority Queue)为例,通过图⽚和⼈类的语⾔来 描述⼀下⼆叉堆怎么运作的。 ⼀、⼆叉堆概览 ⾸先,⼆叉堆和⼆叉树有啥关系呢,为什么⼈们总数把⼆叉堆画成⼀棵⼆叉 树? 因为,⼆叉堆其实就是⼀种特殊的⼆叉树(完全⼆叉树),只不过存储在数 组⾥。⼀般的链表⼆叉树,我们操作节点的指针,⽽在数组⾥,我们把数组 索引作为指针: // ⽗节点的索引 int parent(int root) { return root / 2; } // 左孩⼦的索引 int left(int root) { return root * 2; } // 右孩⼦的索引 int right(int root) { return root * 2 + 1; } 227
228. ⼆叉堆详解实现优先级队列 画个图你⽴即就能理解了,注意数组的第⼀个索引 0 空着不⽤, PS:因为数组索引是数组,为了⽅便区分,将字符作为数组元素。 你看到了,把 arr[1] 作为整棵树的根的话,每个节点的⽗节点和左右孩⼦的 索引都可以通过简单的运算得到,这就是⼆叉堆设计的⼀个巧妙之处。为了 ⽅便讲解,下⾯都会画的图都是⼆叉树结构,相信你能把树和数组对应起 来。 ⼆叉堆还分为最⼤堆和最⼩堆。最⼤堆的性质是:每个节点都⼤于等于它的 两个⼦节点。类似的,最⼩堆的性质是:每个节点都⼩于等于它的⼦节点。 两种堆核⼼思路都是⼀样的,本⽂以最⼤堆为例讲解。 对于⼀个最⼤堆,根据其性质,显然堆顶,也就是 arr[1] ⼀定是所有元素中 最⼤的元素。 ⼆、优先级队列概览 228
229. ⼆叉堆详解实现优先级队列 优先级队列这种数据结构有⼀个很有⽤的功能,你插⼊或者删除元素的时 候,元素会⾃动排序,这底层的原理就是⼆叉堆的操作。 数据结构的功能⽆⾮增删查该,优先级队列有两个主要 API,分别是 insert 就是 插⼊⼀个元素和 delMin delMax 删除最⼤元素(如果底层⽤最⼩堆,那么 )。 下⾯我们实现⼀个简化的优先级队列,先看下代码框架: PS:为了清晰起⻅,这⾥⽤到 Java 的泛型, Key 可以是任何⼀种可⽐较⼤ ⼩的数据类型,你可以认为它是 int、char 等。 public class MaxPQ > { // 存储元素的数组 private Key[] pq; // 当前 Priority Queue 中的元素个数 private int N = 0; public MaxPQ(int cap) { // 索引 0 不⽤,所以多分配⼀个空间 pq = (Key[]) new Comparable[cap + 1]; } /* 返回当前队列中最⼤元素 */ public Key max() { return pq[1]; } /* 插⼊元素 e */ public void insert(Key e) {...} /* 删除并返回当前队列中最⼤元素 */ public Key delMax() {...} /* 上浮第 k 个元素,以维护最⼤堆性质 */ private void swim(int k) {...} /* 下沉第 k 个元素,以维护最⼤堆性质 */ private void sink(int k) {...} 229
230. ⼆叉堆详解实现优先级队列 /* 交换数组的两个元素 */ private void exch(int i, int j) { Key temp = pq[i]; pq[i] = pq[j]; pq[j] = temp; } /* pq[i] 是否⽐ pq[j] ⼩? */ private boolean less(int i, int j) { return pq[i].compareTo(pq[j]) < 0; } /* 还有 left, right, parent 三个⽅法 */ } 空出来的四个⽅法是⼆叉堆和优先级队列的奥妙所在,下⾯⽤图⽂来逐个理 解。 三、实现 swim 和 sink 为什么要有上浮 swim 和下沉 sink 的操作呢?为了维护堆结构。 我们要讲的是最⼤堆,每个节点都⽐它的两个⼦节点⼤,但是在插⼊元素和 删除元素时,难免破坏堆的性质,这就需要通过这两个操作来恢复堆的性质 了。 对于最⼤堆,会破坏堆性质的有有两种情况: 1. 如果某个节点 A ⽐它的⼦节点(中的⼀个)⼩,那么 A 就不配做⽗节 点,应该下去,下⾯那个更⼤的节点上来做⽗节点,这就是对 A 进⾏ 下沉。 2. 如果某个节点 A ⽐它的⽗节点⼤,那么 A 不应该做⼦节点,应该把⽗ 节点换下来,⾃⼰去做⽗节点,这就是对 A 的上浮。 当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位 置,恢复堆的性质。所以代码中肯定有⼀个 while 循环。 230
231. ⼆叉堆详解实现优先级队列 细⼼的读者也许会问,这两个操作不是互逆吗,所以上浮的操作⼀定能⽤下 沉来完成,为什么我还要费劲写两个⽅法? 是的,操作是互逆等价的,但是最终我们的操作只会在堆底和堆顶进⾏(等 会讲原因),显然堆底的「错位」元素需要上浮,堆顶的「错位」元素需要 下沉。 上浮的代码实现: private void swim(int k) { // 如果浮到堆顶,就不能再上浮了 while (k > 1 && less(parent(k), k)) { // 如果第 k 个元素⽐上层⼤ // 将 k 换上去 exch(parent(k), k); k = parent(k); } } 画个 GIF 看⼀眼就明⽩了: 【PDF格式⽆法显⽰GIF⽂件 heap/swim.gif,可移步公众号查看】 下沉的代码实现: 下沉⽐上浮略微复杂⼀点,因为上浮某个节点 A,只需要 A 和其⽗节点⽐ 较⼤⼩即可;但是下沉某个节点 A,需要 A 和其两个⼦节点⽐较⼤⼩,如 果 A 不是最⼤的就需要调整位置,要把较⼤的那个⼦节点和 A 交换。 private void sink(int k) { // 如果沉到堆底,就沉不下去了 while (left(k) <= N) { // 先假设左边节点较⼤ int older = left(k); // 如果右边节点存在,⽐⼀下⼤⼩ if (right(k) <= N && less(older, right(k))) older = right(k); // 结点 k ⽐俩孩⼦都⼤,就不必下沉了 if (less(older, k)) break; 231
232. ⼆叉堆详解实现优先级队列 // 否则,不符合最⼤堆的结构,下沉 k 结点 exch(k, older); k = older; } } 画个 GIF 看下就明⽩了: 【PDF格式⽆法显⽰GIF⽂件 heap/sink.gif,可移步公众号查看】 ⾄此,⼆叉堆的主要操作就讲完了,⼀点都不难吧,代码加起来也就⼗⾏。 明⽩了 sink 和 swim 的⾏为,下⾯就可以实现优先级队列了。 四、实现 delMax 和 insert 这两个⽅法就是建⽴在 insert swim 和 sink 上的。 ⽅法先把要插⼊的元素添加到堆底的最后,然后让其上浮到正确位 置。 【PDF格式⽆法显⽰GIF⽂件 heap/insert.gif,可移步公众号查看】 public void insert(Key e) { N++; // 先把新元素加到最后 pq[N] = e; // 然后让它上浮到正确的位置 swim(N); } delMax ⽅法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A,最 后让 B 下沉到正确位置。 public Key delMax() { // 最⼤堆的堆顶就是最⼤元素 Key max = pq[1]; // 把这个最⼤元素换到最后,删除之 exch(1, N); 232
233. ⼆叉堆详解实现优先级队列 pq[N] = null; N--; // 让 pq[1] 下沉到正确位置 sink(1); return max; } 【PDF格式⽆法显⽰GIF⽂件 heap/delete.gif,可移步公众号查看】 ⾄此,⼀个优先级队列就实现了,插⼊和删除元素的时间复杂度为 O(logK) , K 为当前⼆叉堆(优先级队列)中的元素总数。因为我们时间 复杂度主要花费在 sink 或者 swim 上,⽽不管上浮还是下沉,最多也就 树(堆)的⾼度,也就是 log 级别。 五、最后总结 ⼆叉堆就是⼀种完全⼆叉树,所以适合存储在数组中,⽽且⼆叉堆拥有⼀些 特殊性质。 ⼆叉堆的操作很简单,主要就是上浮和下沉,来维护堆的性质(堆有序), 核⼼代码也就⼗⾏。 优先级队列是基于⼆叉堆实现的,主要操作是插⼊和删除。插⼊是先插到最 后,然后上浮到正确位置;删除是调换位置后再删除,然后下沉到正确位 置。核⼼代码也就⼗⾏。 也许这就是数据结构的威⼒,简单的操作就能实现巧妙的功能,真⼼佩服发 明⼆叉堆算法的⼈! _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 233
234. ⼆叉堆详解实现优先级队列 234
235. LRU算法详解 LRU算法详解 学算法,认准 labuladong 就够了! ⼀、什么是 LRU 算法 就是⼀种缓存淘汰策略。 计算机的缓存容量有限,如果缓存满了就要删除⼀些内容,给新内容腾位 置。但问题是,删除哪些内容呢?我们肯定希望删掉哪些没什么⽤的缓存, ⽽把有⽤的数据继续留在缓存⾥,⽅便之后继续使⽤。那么,什么样的数 据,我们判定为「有⽤的」的数据呢? LRU 缓存淘汰算法就是⼀种常⽤策略。LRU 的全称是 Least Recently Used,也就是说我们认为最近使⽤过的数据应该是是「有⽤的」,很久都 没⽤过的数据应该是⽆⽤的,内存满了就优先删那些很久没⽤过的数据。 举个简单的例⼦,安卓⼿机都可以把软件放到后台运⾏,⽐如我先后打开了 「设置」「⼿机管家」「⽇历」,那么现在他们在后台排列的顺序是这样 的: 235
236. LRU算法详解 236
237. LRU算法详解 但是这时候如果我访问了⼀下「设置」界⾯,那么「设置」就会被提前到第 ⼀个,变成这样: 237
238. LRU算法详解 238
239. LRU算法详解 假设我的⼿机只允许我同时开 3 个应⽤程序,现在已经满了。那么如果我新 开了⼀个应⽤「时钟」,就必须关闭⼀个应⽤为「时钟」腾出⼀个位置,关 那个呢? 按照 LRU 的策略,就关最底下的「⼿机管家」,因为那是最久未使⽤的, 然后把新开的应⽤放到最上⾯: 239
240. LRU算法详解 240
241. LRU算法详解 现在你应该理解 LRU(Least Recently Used)策略了。当然还有其他缓存淘 汰策略,⽐如不要按访问的时序来淘汰,⽽是按访问频率(LFU 策略)来 淘汰等等,各有应⽤场景。本⽂讲解 LRU 算法策略。 ⼆、LRU 算法描述 LRU 算法实际上是让你设计数据结构:⾸先要接收⼀个 capacity 参数作为 缓存的最⼤容量,然后实现两个 API,⼀个是 put(key, val) ⽅法存⼊键值 对,另⼀个是 get(key) ⽅法获取 key 对应的 val,如果 key 不存在则返回 -1。 注意哦,get 和 put ⽅法必须都是 O(1) 的时间复杂度,我们举个具体例⼦ 来看看 LRU 算法怎么⼯作。 /* 缓存容量为 2 */ LRUCache cache = new LRUCache(2); // 你可以把 cache 理解成⼀个队列 // 假设左边是队头,右边是队尾 // 最近使⽤的排在队头,久未使⽤的排在队尾 // 圆括号表⽰键值对 (key, val) cache.put(1, 1); // cache = [(1, 1)] cache.put(2, 2); // cache = [(2, 2), (1, 1)] cache.get(1); // 返回 1 // cache = [(1, 1), (2, 2)] // 解释:因为最近访问了键 1,所以提前⾄队头 // 返回键 1 对应的值 1 cache.put(3, 3); // cache = [(3, 3), (1, 1)] // 解释:缓存容量已满,需要删除内容空出位置 // 优先删除久未使⽤的数据,也就是队尾的数据 // 然后把新的数据插⼊队头 cache.get(2); // 返回 -1 (未找到) // cache = [(3, 3), (1, 1)] // 解释:cache 中不存在键为 2 的数据 cache.put(1, 4); // cache = [(1, 4), (3, 3)] 241
242. LRU算法详解 // 解释:键 1 已存在,把原始值 1 覆盖为 4 // 不要忘了也要将键值对提前到队头 三、LRU 算法设计 分析上⾯的操作过程,要让 put 和 get ⽅法的时间复杂度为 O(1),我们可以 总结出 cache 这个数据结构必要的条件:查找快,插⼊快,删除快,有顺序 之分。 因为显然 cache 必须有顺序之分,以区分最近使⽤的和久未使⽤的数据;⽽ 且我们要在 cache 中查找键是否已存在;如果容量满了要删除最后⼀个数 据;每次访问还要把数据插⼊到队头。 那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据⽆固定 顺序;链表有顺序之分,插⼊删除快,但是查找慢。所以结合⼀下,形成⼀ 种新的数据结构:哈希链表。 LRU 缓存算法的核⼼数据结构就是哈希链表,双向链表和哈希表的结合 体。这个数据结构⻓这样: 242
243. LRU算法详解 思想很简单,就是借助哈希表赋予了链表快速查找的特性嘛:可以快速查找 某个 key 是否存在缓存(链表)中,同时可以快速删除、添加节点。回想刚 才的例⼦,这种数据结构是不是完美解决了 LRU 缓存的需求? 也许读者会问,为什么要是双向链表,单链表⾏不⾏?另外,既然哈希表中 已经存了 key,为什么链表中还要存键值对呢,只存值不就⾏了? 想的时候都是问题,只有做的时候才有答案。这样设计的原因,必须等我们 亲⾃实现 LRU 算法之后才能理解,所以我们开始看代码吧〜 四、代码实现 很多编程语⾔都有内置的哈希链表或者类似 LRU 功能的库函数,但是为了 帮⼤家理解算法的细节,我们⽤ Java ⾃⼰造轮⼦实现⼀遍 LRU 算法。 ⾸先,我们把双链表的节点类写出来,为了简化,key 和 val 都认为是 int 类 型: class Node { public int key, val; public Node next, prev; public Node(int k, int v) { this.key = k; this.val = v; } } 然后依靠我们的 Node 类型构建⼀个双链表,实现⼏个需要的 API(这些操 作的时间复杂度均为 O(1) ): class DoubleList { // 在链表头部添加节点 x,时间 O(1) public void addFirst(Node x); // 删除链表中的 x 节点(x ⼀定存在) // 由于是双链表且给的是⽬标 Node 节点,时间 O(1) public void remove(Node x); 243
244. LRU算法详解 // 删除链表中最后⼀个节点,并返回该节点,时间 O(1) public Node removeLast(); // 返回链表⻓度,时间 O(1) public int size(); } PS:这就是普通双向链表的实现,为了让读者集中精⼒理解 LRU 算法的逻 辑,就省略链表的具体代码。 到这⾥就能回答刚才“为什么必须要⽤双向链表”的问题了,因为我们需要删 除操作。删除⼀个节点不光要得到该节点本⾝的指针,也需要操作其前驱节 点的指针,⽽双向链表才能⽀持直接查找前驱,保证操作的时间复杂度 O(1) 。 有了双向链表的实现,我们只需要在 LRU 算法中把它和哈希表结合起来即 可。我们先把逻辑理清楚: // key 映射到 Node(key, val) HashMap map; // Node(k1, v1) <-> Node(k2, v2)... DoubleList cache; int get(int key) { if (key 不存在) { return -1; } else { 将数据 (key, val) 提到开头; return val; } } void put(int key, int val) { Node x = new Node(key, val); if (key 已存在) { 把旧的数据删除; 将新节点 x 插⼊到开头; } else { if (cache 已满) { 244
245. LRU算法详解 删除链表的最后⼀个数据腾位置; 删除 map 中映射到该数据的键; } 将新节点 x 插⼊到开头; map 中新建 key 对新节点 x 的映射; } } 如果能够看懂上述逻辑,翻译成代码就很容易理解了: class LRUCache { // key -> Node(key, val) private HashMap map; // Node(k1, v1) <-> Node(k2, v2)... private DoubleList cache; // 最⼤容量 private int cap; public LRUCache(int capacity) { this.cap = capacity; map = new HashMap<>(); cache = new DoubleList(); } public int get(int key) { if (!map.containsKey(key)) return -1; int val = map.get(key).val; // 利⽤ put ⽅法把该数据提前 put(key, val); return val; } public void put(int key, int val) { // 先把新节点 x 做出来 Node x = new Node(key, val); if (map.containsKey(key)) { // 删除旧的节点,新的插到头部 cache.remove(map.get(key)); cache.addFirst(x); // 更新 map 中对应的数据 245
246. LRU算法详解 map.put(key, x); } else { if (cap == cache.size()) { // 删除链表最后⼀个数据 Node last = cache.removeLast(); map.remove(last.key); } // 直接添加到头部 cache.addFirst(x); map.put(key, x); } } } 这⾥就能回答之前的问答题“为什么要在链表中同时存储 key 和 val,⽽不是 只存储 val”,注意这段代码: if (cap == cache.size()) { // 删除链表最后⼀个数据 Node last = cache.removeLast(); map.remove(last.key); } 当缓存容量已满,我们不仅仅要删除最后⼀个 Node 节点,还要把 map 中映 射到该节点的 key 同时删除,⽽这个 key 只能由 Node 得到。如果 Node 结 构中只存储 val,那么我们就⽆法得知 key 是什么,就⽆法删除 map 中的 键,造成错误。 ⾄此,你应该已经掌握 LRU 算法的思想和实现了,很容易犯错的⼀点是: 处理链表节点的同时不要忘了更新哈希表中对节点的映射。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 246
247. LRU算法详解 247
248. ⼆叉搜索树操作集锦 ⼆叉搜索树操作集锦 学算法,认准 labuladong 就够了! 通过之前的⽂章框架思维,⼆叉树的遍历框架应该已经印到你的脑⼦⾥了, 这篇⽂章就来实操⼀下,看看框架思维是怎么灵活运⽤,秒杀⼀切⼆叉树问 题的。 ⼆叉树算法的设计的总路线:明确⼀个节点要做的事情,然后剩下的事抛给 框架。 void traverse(TreeNode root) { // root 需要做什么?在这做。 // 其他的不⽤ root 操⼼,抛给框架 traverse(root.left); traverse(root.right); } 举两个简单的例⼦体会⼀下这个思路,热热⾝。 1. 如何把⼆叉树所有的节点中的值加⼀? void plusOne(TreeNode root) { if (root == null) return; root.val += 1; plusOne(root.left); plusOne(root.right); } 2. 如何判断两棵⼆叉树是否完全相同? boolean isSameTree(TreeNode root1, TreeNode root2) { // 都为空的话,显然相同 if (root1 == null && root2 == null) return true; 248
249. ⼆叉搜索树操作集锦 // ⼀个为空,⼀个⾮空,显然不同 if (root1 == null root2 == null) return false; // 两个都⾮空,但 val 不⼀样也不⾏ if (root1.val != root2.val) return false; // root1 和 root2 该⽐的都⽐完了 return isSameTree(root1.left, root2.left) && isSameTree(root1.right, root2.right); } 借助框架,上⾯这两个例⼦不难理解吧?如果可以理解,那么所有⼆叉树算 法你都能解决。 ⼆叉搜索树(Binary Search Tree,简称 BST)是⼀种很常⽤的的⼆叉树。它 的定义是:⼀个⼆叉树中,任意节点的值要⼤于等于左⼦树所有节点的值, 且要⼩于等于右边⼦树的所有节点的值。 如下就是⼀个符合定义的 BST: 249
250. ⼆叉搜索树操作集锦 下⾯实现 BST 的基础操作:判断 BST 的合法性、增、删、查。其 中“删”和“判断合法性”略微复杂。 零、判断 BST 的合法性 这⾥是有坑的哦,我们按照刚才的思路,每个节点⾃⼰要做的事不就是⽐较 ⾃⼰和左右孩⼦吗?看起来应该这样写代码: boolean isValidBST(TreeNode root) { if (root == null) return true; if (root.left != null && root.val <= root.left.val) return false; if (root.right != null && root.val >= root.right.val) return fals e; return isValidBST(root.left) && isValidBST(root.right); } 但是这个算法出现了错误,BST 的每个节点应该要⼩于右边⼦树的所有节 点,下⾯这个⼆叉树显然不是 BST,但是我们的算法会把它判定为 BST。 出现错误,不要慌张,框架没有错,⼀定是某个细节问题没注意到。我们重 新看⼀下 BST 的定义,root 需要做的不只是和左右⼦节点⽐较,⽽是要整 个左⼦树和右⼦树所有节点⽐较。怎么办,鞭⻓莫及啊! 250
251. ⼆叉搜索树操作集锦 这种情况,我们可以使⽤辅助函数,增加函数参数列表,在参数中携带额外 信息,请看正确的代码: boolean isValidBST(TreeNode root) { return isValidBST(root, null, null); } boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) { if (root == null) return true; if (min != null && root.val <= min.val) return false; if (max != null && root.val >= max.val) return false; return isValidBST(root.left, min, root) && isValidBST(root.right, root, max); } ⼀、在 BST 中查找⼀个数是否存在 根据我们的指导思想,可以这样写代码: boolean isInBST(TreeNode root, int target) { if (root == null) return false; if (root.val == target) return true; return isInBST(root.left, target) isInBST(root.right, target); } 这样写完全正确,充分证明了你的框架性思维已经养成。现在你可以考虑⼀ 点细节问题了:如何充分利⽤信息,把 BST 这个“左⼩右⼤”的特性⽤上? 很简单,其实不需要递归地搜索两边,类似⼆分查找思想,根据 target 和 root.val 的⼤⼩⽐较,就能排除⼀边。我们把上⾯的思路稍稍改动: boolean isInBST(TreeNode root, int target) { if (root == null) return false; if (root.val == target) return true; if (root.val < target) return isInBST(root.right, target); 251
252. ⼆叉搜索树操作集锦 if (root.val > target) return isInBST(root.left, target); // root 该做的事做完了,顺带把框架也完成了,妙 } 于是,我们对原始框架进⾏改造,抽象出⼀套针对 BST 的遍历框架: void BST(TreeNode root, int target) { if (root.val == target) // 找到⽬标,做点什么 if (root.val < target) BST(root.right, target); if (root.val > target) BST(root.left, target); } ⼆、在 BST 中插⼊⼀个数 对数据结构的操作⽆⾮遍历 + 访问,遍历就是“找”,访问就是“改”。具体到 这个问题,插⼊⼀个数,就是先找到插⼊位置,然后进⾏插⼊操作。 上⼀个问题,我们总结了 BST 中的遍历框架,就是“找”的问题。直接套框 架,加上“改”的操作即可。⼀旦涉及“改”,函数就要返回 TreeNode 类型, 并且对递归调⽤的返回值进⾏接收。 TreeNode insertIntoBST(TreeNode root, int val) { // 找到空位置插⼊新节点 if (root == null) return new TreeNode(val); // if (root.val == val) // BST 中⼀般不会插⼊已存在元素 if (root.val < val) root.right = insertIntoBST(root.right, val); if (root.val > val) root.left = insertIntoBST(root.left, val); return root; } 三、在 BST 中删除⼀个数 252
253. ⼆叉搜索树操作集锦 这个问题稍微复杂,不过你有框架指导,难不住你。跟插⼊操作类似, 先“找”再“改”,先把框架写出来再说: TreeNode deleteNode(TreeNode root, int key) { if (root.val == key) { // 找到啦,进⾏删除 } else if (root.val > key) { root.left = deleteNode(root.left, key); } else if (root.val < key) { root.right = deleteNode(root.right, key); } return root; } 找到⽬标节点了,⽐⽅说是节点 A,如何删除这个节点,这是难点。因为删 除节点的同时不能破坏 BST 的性质。有三种情况,⽤图⽚来说明。 情况 1:A 恰好是末端节点,两个⼦节点都为空,那么它可以当场去世了。 图⽚来⾃ LeetCode if (root.left == null && root.right == null) return null; 情况 2:A 只有⼀个⾮空⼦节点,那么它要让这个孩⼦接替⾃⼰的位置。 253
254. ⼆叉搜索树操作集锦 图⽚来⾃ LeetCode // 排除了情况 1 之后 if (root.left == null) return root.right; if (root.right == null) return root.left; 情况 3:A 有两个⼦节点,⿇烦了,为了不破坏 BST 的性质,A 必须找到 左⼦树中最⼤的那个节点,或者右⼦树中最⼩的那个节点来接替⾃⼰。我们 以第⼆种⽅式讲解。 图⽚来⾃ LeetCode if (root.left != null && root.right != null) { // 找到右⼦树的最⼩节点 TreeNode minNode = getMin(root.right); // 把 root 改成 minNode root.val = minNode.val; // 转⽽去删除 minNode root.right = deleteNode(root.right, minNode.val); } 三种情况分析完毕,填⼊框架,简化⼀下代码: 254
255. ⼆叉搜索树操作集锦 TreeNode deleteNode(TreeNode root, int key) { if (root == null) return null; if (root.val == key) { // 这两个 if 把情况 1 和 2 都正确处理了 if (root.left == null) return root.right; if (root.right == null) return root.left; // 处理情况 3 TreeNode minNode = getMin(root.right); root.val = minNode.val; root.right = deleteNode(root.right, minNode.val); } else if (root.val > key) { root.left = deleteNode(root.left, key); } else if (root.val < key) { root.right = deleteNode(root.right, key); } return root; } TreeNode getMin(TreeNode node) { // BST 最左边的就是最⼩的 while (node.left != null) node = node.left; return node; } 删除操作就完成了。注意⼀下,这个删除操作并不完美,因为我们⼀般不会 通过 root.val = minNode.val 修改节点内部的值来交换节点,⽽是通过⼀系列 略微复杂的链表操作交换 root 和 minNode 两个节点。因为具体应⽤中,val 域可能会很⼤,修改起来很耗时,⽽链表操作⽆⾮改⼀改指针,⽽不会去碰 内部数据。 但这⾥忽略这个细节,旨在突出 BST 基本操作的共性,以及借助框架逐层 细化问题的思维⽅式。 四、最后总结 通过这篇⽂章,你学会了如下⼏个技巧: 1. ⼆叉树算法设计的总路线:把当前节点要做的事做好,其他的交给递归 框架,不⽤当前节点操⼼。 255
256. ⼆叉搜索树操作集锦 2. 如果当前节点会对下⾯的⼦节点有整体影响,可以通过辅助函数增⻓参 数列表,借助参数传递信息。 3. 在⼆叉树框架之上,扩展出⼀套 BST 遍历框架: void BST(TreeNode root, int target) { if (root.val == target) // 找到⽬标,做点什么 if (root.val < target) BST(root.right, target); if (root.val > target) BST(root.left, target); } 4. 掌握了 BST 的基本操作。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 256
257. 如何计算完全⼆叉树的节点数 快速计算完全⼆叉树的节点 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 222.完全⼆叉树的节点个数 如果让你数⼀下⼀棵普通⼆叉树有多少个节点,这很简单,只要在⼆叉树的 遍历框架上加⼀点代码就⾏了。 但是,如果给你⼀棵完全⼆叉树,让你计算它的节点个数,你会不会?算法 的时间复杂度是多少?这个算法的时间复杂度应该是 O(logN*logN),如果 你⼼中的算法没有达到⾼效,那么本⽂就是给你写的。 ⾸先要明确⼀下两个关于⼆叉树的名词「完全⼆叉树」和「满⼆叉树」。 我们说的完全⼆叉树如下图,每⼀层都是紧凑靠左排列的: 257
258. 如何计算完全⼆叉树的节点数 我们说的满⼆叉树如下图,是⼀种特殊的完全⼆叉树,每层都是是满的,像 ⼀个稳定的三⾓形: 说句题外话,关于这两个定义,中⽂语境和英⽂语境似乎有点区别,我们说 的完全⼆叉树对应英⽂ Complete Binary Tree,没有问题。但是我们说的满 ⼆叉树对应英⽂ Perfect Binary Tree,⽽英⽂中的 Full Binary Tree 是指⼀棵 ⼆叉树的所有节点要么没有孩⼦节点,要么有两个孩⼦节点。如下: 以上定义出⾃ wikipedia,这⾥就是顺便⼀提,其实名词叫什么都⽆所谓, 重要的是算法操作。本⽂就按我们中⽂的语境,记住「满⼆叉树」和「完全 ⼆叉树」的区别,等会会⽤到。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 258
259. 如何计算完全⼆叉树的节点数 259
260. 特殊数据结构:单调栈 如何使⽤单调栈解题 学算法,认准 labuladong 就够了! 栈(stack)是很简单的⼀种数据结构,先进后出的逻辑顺序,符合某些问 题的特点,⽐如说函数调⽤栈。 单调栈实际上就是栈,只是利⽤了⼀些巧妙的逻辑,使得每次新元素⼊栈 后,栈内的元素都保持有序(单调递增或单调递减)。 听起来有点像堆(heap)?不是的,单调栈⽤途不太⼴泛,只处理⼀种典型 的问题,叫做 Next Greater Element。本⽂⽤讲解单调队列的算法模版解决 这类问题,并且探讨处理「循环数组」的策略。 ⾸先,讲解 Next Greater Number 的原始问题:给你⼀个数组,返回⼀个等 ⻓的数组,对应索引存储着下⼀个更⼤元素,如果没有更⼤的元素,就存 -1。不好⽤语⾔解释清楚,直接上⼀个例⼦: 给你⼀个数组 [2,1,2,4,3],你返回数组 [4,2,4,-1,-1]。 解释:第⼀个 2 后⾯⽐ 2 ⼤的数是 4; 1 后⾯⽐ 1 ⼤的数是 2;第⼆个 2 后⾯ ⽐ 2 ⼤的数是 4; 4 后⾯没有⽐ 4 ⼤的数,填 -1;3 后⾯没有⽐ 3 ⼤的数,填 -1。 这道题的暴⼒解法很好想到,就是对每个元素后⾯都进⾏扫描,找到第⼀个 更⼤的元素就⾏了。但是暴⼒解法的时间复杂度是 O(n^2)。 这个问题可以这样抽象思考:把数组的元素想象成并列站⽴的⼈,元素⼤⼩ 想象成⼈的⾝⾼。这些⼈⾯对你站成⼀列,如何求元素「2」的 Next Greater Number 呢?很简单,如果能够看到元素「2」,那么他后⾯可⻅的第⼀个 ⼈就是「2」的 Next Greater Number,因为⽐「2」⼩的元素⾝⾼不够,都被 「2」挡住了,第⼀个露出来的就是答案。 260
261. 特殊数据结构:单调栈 这个情景很好理解吧?带着这个抽象的情景,先来看下代码。 vector nextGreaterElement(vector& nums) { vector ans(nums.size()); // 存放答案的数组 stack s; for (int i = nums.size() - 1; i >= 0; i--) { // 倒着往栈⾥放 while (!s.empty() && s.top() <= nums[i]) { // 判定个⼦⾼矮 s.pop(); // 矮个起开,反正也被挡着了。。。 } ans[i] = s.empty() ? -1 : s.top(); // 这个元素⾝后的第⼀个⾼个 s.push(nums[i]); // 进队,接受之后的⾝⾼判定吧! } return ans; } 这就是单调队列解决问题的模板。for 循环要从后往前扫描元素,因为我们 借助的是栈的结构,倒着⼊栈,其实是正着出栈。while 循环是把两个“⾼ 个”元素之间的元素排除,因为他们的存在没有意义,前⾯挡着个“更⾼”的 元素,所以他们不可能被作为后续进来的元素的 Next Great Number 了。 这个算法的时间复杂度不是那么直观,如果你看到 for 循环嵌套 while 循 环,可能认为这个算法的复杂度也是 O(n^2),但是实际上这个算法的复杂 度只有 O(n)。 261
262. 特殊数据结构:单调栈 分析它的时间复杂度,要从整体来看:总共有 n 个元素,每个元素都被 push ⼊栈了⼀次,⽽最多会被 pop ⼀次,没有任何冗余操作。所以总的计 算规模是和元素规模 n 成正⽐的,也就是 O(n) 的复杂度。 现在,你已经掌握了单调栈的使⽤技巧,来⼀个简单的变形来加深⼀下理 解。 给你⼀个数组 T = [73, 74, 75, 71, 69, 72, 76, 73],这个数组存放的是近⼏天 的天⽓⽓温(这⽓温是铁板烧?不是的,这⾥⽤的华⽒度)。你返回⼀个数 组,计算:对于每⼀天,你还要⾄少等多少天才能等到⼀个更暖和的⽓温; 如果等不到那⼀天,填 0 。 举例:给你 T = [73, 74, 75, 71, 69, 72, 76, 73],你返回 [1, 1, 4, 2, 1, 1, 0, 0]。 解释:第⼀天 73 华⽒度,第⼆天 74 华⽒度,⽐ 73 ⼤,所以对于第⼀天, 只要等⼀天就能等到⼀个更暖和的⽓温。后⾯的同理。 你已经对 Next Greater Number 类型问题有些敏感了,这个问题本质上也是 找 Next Greater Number,只不过现在不是问你 Next Greater Number 是多 少,⽽是问你当前距离 Next Greater Number 的距离⽽已。 相同类型的问题,相同的思路,直接调⽤单调栈的算法模板,稍作改动就可 以啦,直接上代码把。 vector dailyTemperatures(vector& T) { vector ans(T.size()); stack s; // 这⾥放元素索引,⽽不是元素 for (int i = T.size() - 1; i >= 0; i--) { while (!s.empty() && T[s.top()] <= T[i]) { s.pop(); } ans[i] = s.empty() ? 0 : (s.top() - i); // 得到索引间距 s.push(i); // 加⼊索引,⽽不是元素 } return ans; } 单调栈讲解完毕。下⾯开始另⼀个重点:如何处理「循环数组」。 262
263. 特殊数据结构:单调栈 同样是 Next Greater Number,现在假设给你的数组是个环形的,如何处理? 给你⼀个数组 [2,1,2,4,3],你返回数组 [4,2,4,-1,4]。拥有了环形属性,最后 ⼀个元素 3 绕了⼀圈后找到了⽐⾃⼰⼤的元素 4 。 ⾸先,计算机的内存都是线性的,没有真正意义上的环形数组,但是我们可 以模拟出环形数组的效果,⼀般是通过 % 运算符求模(余数),获得环形 特效: int[] arr = {1,2,3,4,5}; int n = arr.length, index = 0; while (true) { print(arr[index % n]); index++; } 回到 Next Greater Number 的问题,增加了环形属性后,问题的难点在于: 这个 Next 的意义不仅仅是当前元素的右边了,有可能出现在当前元素的左 边(如上例)。 明确问题,问题就已经解决了⼀半了。我们可以考虑这样的思路:将原始数 组“翻倍”,就是在后⾯再接⼀个原始数组,这样的话,按照之前“⽐⾝⾼”的 流程,每个元素不仅可以⽐较⾃⼰右边的元素,⽽且也可以和左边的元素⽐ 较了。 263
264. 特殊数据结构:单调栈 怎么实现呢?你当然可以把这个双倍⻓度的数组构造出来,然后套⽤算法模 板。但是,我们可以不⽤构造新数组,⽽是利⽤循环数组的技巧来模拟。直 接看代码吧: vector nextGreaterElements(vector& nums) { int n = nums.size(); vector res(n); // 存放结果 stack s; // 假装这个数组⻓度翻倍了 for (int i = 2 * n - 1; i >= 0; i--) { while (!s.empty() && s.top() <= nums[i % n]) s.pop(); res[i % n] = s.empty() ? -1 : s.top(); s.push(nums[i % n]); } return res; } _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 264
265. 特殊数据结构:单调栈 265
266. 特殊数据结构:单调队列 特殊数据结构:单调队列 学算法,认准 labuladong 就够了! 前⽂讲了⼀种特殊的数据结构「单调栈」monotonic stack,解决了⼀类问题 「Next Greater Number」,本⽂写⼀个类似的数据结构「单调队列」。 也许这种数据结构的名字你没听过,其实没啥难的,就是⼀个「队列」,只 是使⽤了⼀点巧妙的⽅法,使得队列中的元素单调递增(或递减)。这个数 据结构有什么⽤?可以解决滑动窗⼝的⼀系列问题。 看⼀道 LeetCode 题⽬,难度 hard: ⼀、搭建解题框架 266
267. 特殊数据结构:单调队列 这道题不复杂,难点在于如何在 O(1) 时间算出每个「窗⼝」中的最⼤值, 使得整个算法在线性时间完成。在之前我们探讨过类似的场景,得到⼀个结 论: 在⼀堆数字中,已知最值,如果给这堆数添加⼀个数,那么⽐较⼀下就可以 很快算出最值;但如果减少⼀个数,就不⼀定能很快得到最值了,⽽要遍历 所有数重新找最值。 回到这道题的场景,每个窗⼝前进的时候,要添加⼀个数同时减少⼀个数, 所以想在 O(1) 的时间得出新的最值,就需要「单调队列」这种特殊的数据 结构来辅助了。 ⼀个普通的队列⼀定有这两个操作: class Queue { void push(int n); // 或 enqueue,在队尾加⼊元素 n void pop(); // 或 dequeue,删除队头元素 } ⼀个「单调队列」的操作也差不多: class MonotonicQueue { // 在队尾添加元素 n void push(int n); // 返回当前队列中的最⼤值 int max(); // 队头元素如果是 n,删除它 void pop(int n); } 当然,这⼏个 API 的实现⽅法肯定跟⼀般的 Queue 不⼀样,不过我们暂且 不管,⽽且认为这⼏个操作的时间复杂度都是 O(1),先把这道「滑动窗 ⼝」问题的解答框架搭出来: vector maxSlidingWindow(vector& nums, int k) { 267
268. 特殊数据结构:单调队列 MonotonicQueue window; vector res; for (int i = 0; i < nums.size(); i++) { if (i < k - 1) { //先把窗⼝的前 k - 1 填满 window.push(nums[i]); } else { // 窗⼝开始向前滑动 window.push(nums[i]); res.push_back(window.max()); window.pop(nums[i - k + 1]); // nums[i - k + 1] 就是窗⼝最后的元素 } } return res; } 这个思路很简单,能理解吧?下⾯我们开始重头戏,单调队列的实现。 ⼆、实现单调队列数据结构 ⾸先我们要认识另⼀种数据结构:deque,即双端队列。很简单: class deque { // 在队头插⼊元素 n void push_front(int n); // 在队尾插⼊元素 n 268
269. 特殊数据结构:单调队列 void push_back(int n); // 在队头删除元素 void pop_front(); // 在队尾删除元素 void pop_back(); // 返回队头元素 int front(); // 返回队尾元素 int back(); } ⽽且,这些操作的复杂度都是 O(1)。这其实不是啥稀奇的数据结构,⽤链 表作为底层结构的话,很容易实现这些功能。 「单调队列」的核⼼思路和「单调栈」类似。单调队列的 push ⽅法依然在 队尾添加元素,但是要把前⾯⽐新元素⼩的元素都删掉: class MonotonicQueue { private: deque data; public: void push(int n) { while (!data.empty() && data.back() < n) data.pop_back(); data.push_back(n); } }; 你可以想象,加⼊数字的⼤⼩代表⼈的体重,把前⾯体重不⾜的都压扁了, 直到遇到更⼤的量级才停住。 269
270. 特殊数据结构:单调队列 如果每个元素被加⼊时都这样操作,最终单调队列中的元素⼤⼩就会保持⼀ 个单调递减的顺序,因此我们的 max() API 可以可以这样写: int max() { return data.front(); } pop() API 在队头删除元素 n,也很好写: void pop(int n) { if (!data.empty() && data.front() == n) data.pop_front(); } 之所以要判断 data.front() == n ,是因为我们想删除的队头元素 n 可能已 经被「压扁」了,这时候就不⽤删除了: 270
271. 特殊数据结构:单调队列 ⾄此,单调队列设计完毕,看下完整的解题代码: class MonotonicQueue { private: deque data; public: void push(int n) { while (!data.empty() && data.back() < n) data.pop_back(); data.push_back(n); } int max() { return data.front(); } void pop(int n) { if (!data.empty() && data.front() == n) data.pop_front(); } }; vector maxSlidingWindow(vector& nums, int k) { MonotonicQueue window; vector res; for (int i = 0; i < nums.size(); i++) { 271
272. 特殊数据结构:单调队列 if (i < k - 1) { //先填满窗⼝的前 k - 1 window.push(nums[i]); } else { // 窗⼝向前滑动 window.push(nums[i]); res.push_back(window.max()); window.pop(nums[i - k + 1]); } } return res; } 三、算法复杂度分析 读者可能疑惑,push 操作中含有 while 循环,时间复杂度不是 O(1) 呀,那 么本算法的时间复杂度应该不是线性时间吧? 单独看 push 操作的复杂度确实不是 O(1),但是算法整体的复杂度依然是 O(N) 线性时间。要这样想,nums 中的每个元素最多被 push_back 和 pop_back ⼀次,没有任何多余操作,所以整体的复杂度还是 O(N)。 空间复杂度就很简单了,就是窗⼝的⼤⼩ O(k)。 四、最后总结 有的读者可能觉得「单调队列」和「优先级队列」⽐较像,实际上差别很⼤ 的。 单调队列在添加元素的时候靠删除元素保持队列的单调性,相当于抽取出某 个函数中单调递增(或递减)的部分;⽽优先级队列(⼆叉堆)相当于⾃动 排序,差别⼤了去了。 赶紧去拿下 LeetCode 第 239 道题吧〜 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 272
273. 特殊数据结构:单调队列 273
274. 设计Twitter 设计Twitter 学算法,认准 labuladong 就够了! 「design Twitter」是 LeetCode 上第 355 道题⽬,不仅题⽬本⾝很有意思, ⽽且把合并多个有序链表的算法和⾯向对象设计(OO design)结合起来 了,很有实际意义,本⽂就带⼤家来看看这道题。 ⾄于 Twitter 的什么功能跟算法有关系,等我们描述⼀下题⽬要求就知道 了。 ⼀、题⽬及应⽤场景简介 Twitter 和微博功能差不多,我们主要要实现这样⼏个 API: class Twitter { /** user 发表⼀条 tweet 动态 */ public void postTweet(int userId, int tweetId) {} /** 返回该 user 关注的⼈(包括他⾃⼰)最近的动态 id, 最多 10 条,⽽且这些动态必须按从新到旧的时间线顺序排列。*/ public List getNewsFeed(int userId) {} /** follower 关注 followee,如果 Id 不存在则新建 */ public void follow(int followerId, int followeeId) {} /** follower 取关 followee,如果 Id 不存在则什么都不做 */ public void unfollow(int followerId, int followeeId) {} } 举个具体的例⼦,⽅便⼤家理解 API 的具体⽤法: Twitter twitter = new Twitter(); 274
275. 设计Twitter twitter.postTweet(1, 5); // ⽤户 1 发送了⼀条新推⽂ 5 twitter.getNewsFeed(1); // return [5],因为⾃⼰是关注⾃⼰的 twitter.follow(1, 2); // ⽤户 1 关注了⽤户 2 twitter.postTweet(2, 6); // ⽤户2发送了⼀个新推⽂ (id = 6) twitter.getNewsFeed(1); // return [6, 5] // 解释:⽤户 1 关注了⾃⼰和⽤户 2,所以返回他们的最近推⽂ // ⽽且 6 必须在 5 之前,因为 6 是最近发送的 twitter.unfollow(1, 2); // ⽤户 1 取消关注了⽤户 2 twitter.getNewsFeed(1); // return [5] 这个场景在我们的现实⽣活中⾮常常⻅。拿朋友圈举例,⽐如我刚加到⼥神 的微信,然后我去刷新⼀下我的朋友圈动态,那么⼥神的动态就会出现在我 的动态列表,⽽且会和其他动态按时间排好序。只不过 Twitter 是单向关 注,微信好友相当于双向关注。除⾮,被屏蔽... 这⼏个 API 中⼤部分都很好实现,最核⼼的功能难点应该是 getNewsFeed ,因为返回的结果必须在时间上有序,但问题是⽤户的关注是 动态变化的,怎么办? 这⾥就涉及到算法了:如果我们把每个⽤户各⾃的推⽂存储在链表⾥,每个 链表节点存储⽂章 id 和⼀个时间戳 time(记录发帖时间以便⽐较),⽽且 这个链表是按 time 有序的,那么如果某个⽤户关注了 k 个⽤户,我们就可 以⽤合并 k 个有序链表的算法合并出有序的推⽂列表,正确地 getNewsFeed 了! 275
276. 设计Twitter 具体的算法等会讲解。不过,就算我们掌握了算法,应该如何编程表⽰⽤户 user 和推⽂动态 tweet 才能把算法流畅地⽤出来呢?这就涉及简单的⾯向对 象设计了,下⾯我们来由浅⼊深,⼀步⼀步进⾏设计。 ⼆、⾯向对象设计 根据刚才的分析,我们需要⼀个 User 类,储存 user 信息,还需要⼀个 Tweet 类,储存推⽂信息,并且要作为链表的节点。所以我们先搭建⼀下整 体的框架: class Twitter { private static int timestamp = 0; private static class Tweet {} private static class User {} /* 还有那⼏个 API ⽅法 */ public void postTweet(int userId, int tweetId) {} public List getNewsFeed(int userId) {} public void follow(int followerId, int followeeId) {} public void unfollow(int followerId, int followeeId) {} } 之所以要把 Tweet 和 User 类放到 Twitter 类⾥⾯,是因为 Tweet 类必须要⽤ 到⼀个全局时间戳 timestamp,⽽ User 类⼜需要⽤到 Tweet 类记录⽤户发送 的推⽂,所以它们都作为内部类。不过为了清晰和简洁,下⽂会把每个内部 类和 API ⽅法单独拿出来实现。 1、Tweet 类的实现 根据前⾯的分析,Tweet 类很容易实现:每个 Tweet 实例需要记录⾃⼰的 tweetId 和发表时间 time,⽽且作为链表节点,要有⼀个指向下⼀个节点的 next 指针。 class Tweet { private int id; private int time; private Tweet next; 276
277. 设计Twitter // 需要传⼊推⽂内容(id)和发⽂时间 public Tweet(int id, int time) { this.id = id; this.time = time; this.next = null; } } 2、User 类的实现 我们根据实际场景想⼀想,⼀个⽤户需要存储的信息有 userId,关注列表, 以及该⽤户发过的推⽂列表。其中关注列表应该⽤集合(Hash Set)这种数 据结构来存,因为不能重复,⽽且需要快速查找;推⽂列表应该由链表这种 数据结构储存,以便于进⾏有序合并的操作。画个图理解⼀下: 277
278. 设计Twitter 除此之外,根据⾯向对象的设计原则,「关注」「取关」和「发⽂」应该是 User 的⾏为,况且关注列表和推⽂列表也存储在 User 类中,所以我们也应 该给 User 添加 follow,unfollow 和 post 这⼏个⽅法: // static int timestamp = 0 class User { private int id; public Set followed; // ⽤户发表的推⽂链表头结点 public Tweet head; public User(int userId) { followed = new HashSet<>(); this.id = userId; this.head = null; // 关注⼀下⾃⼰ follow(id); } public void follow(int userId) { followed.add(userId); } 278
279. 设计Twitter public void unfollow(int userId) { // 不可以取关⾃⼰ if (userId != this.id) followed.remove(userId); } public void post(int tweetId) { Tweet twt = new Tweet(tweetId, timestamp); timestamp++; // 将新建的推⽂插⼊链表头 // 越靠前的推⽂ time 值越⼤ twt.next = head; head = twt; } } 3、⼏个 API ⽅法的实现 class Twitter { private static int timestamp = 0; private static class Tweet {...} private static class User {...} // 我们需要⼀个映射将 userId 和 User 对象对应起来 private HashMap userMap = new HashMap<>(); /** user 发表⼀条 tweet 动态 */ public void postTweet(int userId, int tweetId) { // 若 userId 不存在,则新建 if (!userMap.containsKey(userId)) userMap.put(userId, new User(userId)); User u = userMap.get(userId); u.post(tweetId); } /** follower 关注 followee */ public void follow(int followerId, int followeeId) { // 若 follower 不存在,则新建 if(!userMap.containsKey(followerId)){ User u = new User(followerId); userMap.put(followerId, u); 279
280. 设计Twitter } // 若 followee 不存在,则新建 if(!userMap.containsKey(followeeId)){ User u = new User(followeeId); userMap.put(followeeId, u); } userMap.get(followerId).follow(followeeId); } /** follower 取关 followee,如果 Id 不存在则什么都不做 */ public void unfollow(int followerId, int followeeId) { if (userMap.containsKey(followerId)) { User flwer = userMap.get(followerId); flwer.unfollow(followeeId); } } /** 返回该 user 关注的⼈(包括他⾃⼰)最近的动态 id, 最多 10 条,⽽且这些动态必须按从新到旧的时间线顺序排列。*/ public List getNewsFeed(int userId) { // 需要理解算法,⻅下⽂ } } 三、算法设计 实现合并 k 个有序链表的算法需要⽤到优先级队列(Priority Queue),这种 数据结构是「⼆叉堆」最重要的应⽤,你可以理解为它可以对插⼊的元素⾃ 动排序。乱序的元素插⼊其中就被放到了正确的位置,可以按照从⼩到⼤ (或从⼤到⼩)有序地取出元素。 PriorityQueue pq # 乱序插⼊ for i in {2,4,1,9,6}: pq.add(i) while pq not empty: # 每次取出第⼀个(最⼩)元素 print(pq.pop()) # 输出有序:1,2,4,6,9 280
281. 设计Twitter 借助这种⽜逼的数据结构⽀持,我们就很容易实现这个核⼼功能了。注意我 们把优先级队列设为按 time 属性从⼤到⼩降序排列,因为 time 越⼤意味着 时间越近,应该排在前⾯: public List getNewsFeed(int userId) { List res = new ArrayList<>(); if (!userMap.containsKey(userId)) return res; // 关注列表的⽤户 Id Set users = userMap.get(userId).followed; // ⾃动通过 time 属性从⼤到⼩排序,容量为 users 的⼤⼩ PriorityQueue pq = new PriorityQueue<>(users.size(), (a, b)->(b.time - a.time)); // 先将所有链表头节点插⼊优先级队列 for (int id : users) { Tweet twt = userMap.get(id).head; if (twt == null) continue; pq.add(twt); } while (!pq.isEmpty()) { // 最多返回 10 条就够了 if (res.size() == 10) break; // 弹出 time 值最⼤的(最近发表的) Tweet twt = pq.poll(); res.add(twt.id); // 将下⼀篇 Tweet 插⼊进⾏排序 if (twt.next != null) pq.add(twt.next); } return res; } 这个过程是这样的,下⾯是我制作的⼀个 GIF 图描述合并链表的过程。假 设有三个 Tweet 链表按 time 属性降序排列,我们把他们降序合并添加到 res 中。注意图中链表节点中的数字是 time 属性,不是 id 属性: 【PDF格式⽆法显⽰GIF⽂件 设计Twitter/merge.gif,可移步公众号查看】 281
282. 设计Twitter ⾄此,这道⼀个极其简化的 Twitter 时间线功能就设计完毕了。 四、最后总结 本⽂运⽤简单的⾯向对象技巧和合并 k 个有序链表的算法设计了⼀套简化的 时间线功能,这个功能其实⼴泛地运⽤在许多社交应⽤中。 我们先合理地设计出 User 和 Tweet 两个类,然后基于这个设计之上运⽤算 法解决了最重要的⼀个功能。可⻅实际应⽤中的算法并不是孤⽴存在的,需 要和其他知识混合运⽤,才能发挥实际价值。 当然,实际应⽤中的社交 App 数据量是巨⼤的,考虑到数据库的读写性 能,我们的设计可能承受不住流量压⼒,还是有些太简化了。⽽且实际的应 ⽤都是⼀个极其庞⼤的⼯程,⽐如下图,是 Twitter 这样的社交⽹站⼤致的 系统结构: 282
283. 设计Twitter 我们解决的问题应该只能算 Timeline Service 模块的⼀⼩部分,功能越多, 系统的复杂性可能是指数级增⻓的。所以说合理的顶层设计⼗分重要,其作 ⽤是远超某⼀个算法的。 最后,Github 上有⼀个优秀的开源项⽬,专门收集了很多⼤型系统设计的 案例和解析,⽽且有中⽂版本,上⾯这个图也出⾃该项⽬。对系统设计感兴 趣的读者可以点击 这⾥ 查看。 PS:本⽂前两张图⽚和 GIF 是我第⼀次尝试⽤平板的绘图软件制作的,花 了很多时间,尤其是 GIF 图,需要⼀帧⼀帧制作。如果本⽂内容对你有帮 助,点个赞分个享,⿎励⼀下我呗! 283
284. 设计Twitter _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 284
285. 递归反转链表的⼀部分 递归反转链表的⼀部分 学算法,认准 labuladong 就够了! 反转单链表的迭代实现不是⼀个困难的事情,但是递归实现就有点难度了, 如果再加⼀点难度,让你仅仅反转单链表中的⼀部分,你是否能够递归实现 呢? 本⽂就来由浅⼊深,step by step 地解决这个问题。如果你还不会递归地反转 单链表也没关系,本⽂会从递归反转整个单链表开始拓展,只要你明⽩单链 表的结构,相信你能够有所收获。 // 单链表节点的结构 public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } } 什么叫反转单链表的⼀部分呢,就是给你⼀个索引区间,让你把单链表中这 部分元素反转,其他部分不变: 285
286. 递归反转链表的⼀部分 注意这⾥的索引是从 1 开始的。迭代的思路⼤概是:先⽤⼀个 for 循环找到 第 m 个位置,然后再⽤⼀个 for 循环将 m 和 n 之间的元素反转。但是 我们的递归解法不⽤⼀个 for 循环,纯递归实现反转。 迭代实现思路看起来虽然简单,但是细节问题很多的,反⽽不容易写对。相 反,递归实现就很简洁优美,下⾯就由浅⼊深,先从反转整个单链表说起。 ⼀、递归反转整个链表 这个算法可能很多读者都听说过,这⾥详细介绍⼀下,先直接看实现代码: ListNode reverse(ListNode head) { if (head.next == null) return head; ListNode last = reverse(head.next); head.next.next = head; head.next = null; return last; } 看起来是不是感觉不知所云,完全不能理解这样为什么能够反转链表?这就 对了,这个算法常常拿来显⽰递归的巧妙和优美,我们下⾯来详细解释⼀下 这段代码。 对于递归算法,最重要的就是明确递归函数的定义。具体来说,我们的 reverse 函数定义是这样的: 输⼊⼀个节点 head ,将「以 head 为起点」的链表反转,并返回反转之 后的头结点。 明⽩了函数的定义,在来看这个问题。⽐如说我们想反转这个链表: 286
287. 递归反转链表的⼀部分 那么输⼊ reverse(head) 后,会在这⾥进⾏递归: ListNode last = reverse(head.next); 不要跳进递归(你的脑袋能压⼏个栈呀?),⽽是要根据刚才的函数定义, 来弄清楚这段代码会产⽣什么结果: 这个 reverse(head.next) 执⾏完成后,整个链表就成了这样: 287
288. 递归反转链表的⼀部分 并且根据函数定义, last reverse 函数会返回反转之后的头结点,我们⽤变量 接收了。 现在再来看下⾯的代码: head.next.next = head; 接下来: 288
289. 递归反转链表的⼀部分 head.next = null; return last; 神不神奇,这样整个链表就反转过来了!递归代码就是这么简洁优雅,不过 其中有两个地⽅需要注意: 1、递归函数要有 base case,也就是这句: if (head.next == null) return head; 意思是如果链表只有⼀个节点的时候反转也是它⾃⼰,直接返回即可。 2、当链表递归反转之后,新的头结点是 last ,⽽之前的 head 变成了最 后⼀个节点,别忘了链表的末尾要指向 null: head.next = null; 理解了这两点后,我们就可以进⼀步深⼊了,接下来的问题其实都是在这个 算法上的扩展。 289
290. 递归反转链表的⼀部分 ⼆、反转链表前 N 个节点 这次我们实现⼀个这样的函数: // 将链表的前 n 个节点反转(n <= 链表⻓度) ListNode reverseN(ListNode head, int n) ⽐如说对于下图链表,执⾏ reverseN(head, 3) : 解决思路和反转整个链表差不多,只要稍加修改即可: ListNode successor = null; // 后驱节点 // 反转以 head 为起点的 n 个节点,返回新的头结点 ListNode reverseN(ListNode head, int n) { if (n == 1) { // 记录第 n + 1 个节点 successor = head.next; return head; } // 以 head.next 为起点,需要反转前 n - 1 个节点 ListNode last = reverseN(head.next, n - 1); head.next.next = head; 290
291. 递归反转链表的⼀部分 // 让反转之后的 head 节点和后⾯的节点连起来 head.next = successor; return last; } 具体的区别: 1、base case 变为 n == 1 ,反转⼀个元素,就是它本⾝,同时要记录后驱 节点。 2、刚才我们直接把 head head.next 设置为 null,因为整个链表反转后原来的 变成了整个链表的最后⼀个节点。但现在 后不⼀定是最后⼀个节点了,所以要记录后驱 点),反转之后将 head head 节点在递归反转之 successor (第 n + 1 个节 连接上。 OK,如果这个函数你也能看懂,就离实现「反转⼀部分链表」不远了。 三、反转链表的⼀部分 现在解决我们最开始提出的问题,给⼀个索引区间 [m,n] (索引从 1 开 始),仅仅反转区间中的链表元素。 291
292. 递归反转链表的⼀部分 ListNode reverseBetween(ListNode head, int m, int n) ⾸先,如果 m == 1 ,就相当于反转链表开头的 n 个元素嘛,也就是我们 刚才实现的功能: ListNode reverseBetween(ListNode head, int m, int n) { // base case if (m == 1) { // 相当于反转前 n 个元素 return reverseN(head, n); } // ... } 如果 m != 1 怎么办?如果我们把 个元素开始反转对吧;如果把 m head.next head head.next ,反转的区间应该是从第 head.next.next 的索引视为 1,那么我们是想从第 m - 1 的索引视为 1 呢?那么相对于 个元素开始的;那么对于 呢…… 区别于迭代思想,这就是递归思想,所以我们可以完成代码: ListNode reverseBetween(ListNode head, int m, int n) { // base case if (m == 1) { return reverseN(head, n); } // 前进到反转的起点触发 base case head.next = reverseBetween(head.next, m - 1, n - 1); return head; } ⾄此,我们的最终⼤ BOSS 就被解决了。 四、最后总结 292
293. 递归反转链表的⼀部分 递归的思想相对迭代思想,稍微有点难以理解,处理的技巧是:不要跳进递 归,⽽是利⽤明确的定义来实现算法逻辑。 处理看起来⽐较困难的问题,可以尝试化整为零,把⼀些简单的解法进⾏修 改,解决困难的问题。 值得⼀提的是,递归操作链表并不⾼效。和迭代解法相⽐,虽然时间复杂度 都是 O(N),但是迭代解法的空间复杂度是 O(1),⽽递归解法需要堆栈,空 间复杂度是 O(N)。所以递归操作链表可以作为对递归算法的练习或者拿去 和⼩伙伴装逼,但是考虑效率的话还是使⽤迭代算法更好。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 293
294. 队列实现栈 栈实现队列 队列实现栈 栈实现队列 学算法,认准 labuladong 就够了! 队列是⼀种先进先出的数据结构,栈是⼀种先进后出的数据结构,形象⼀点 就是这样: 这两种数据结构底层其实都是数组或者链表实现的,只是 API 限定了它们 的特性,那么今天就来看看如何使⽤「栈」的特性来实现⼀个「队列」,如 何⽤「队列」实现⼀个「栈」。 ⼀、⽤栈实现队列 ⾸先,队列的 API 如下: class MyQueue { /** 添加元素到队尾 */ public void push(int x); 294
295. 队列实现栈 栈实现队列 /** 删除队头的元素并返回 */ public int pop(); /** 返回队头元素 */ public int peek(); /** 判断队列是否为空 */ public boolean empty(); } 我们使⽤两个栈 s1, s2 就能实现⼀个队列的功能(这样放置栈可能更容 易理解): class MyQueue { private Stack s1, s2; public MyQueue() { s1 = new Stack<>(); s2 = new Stack<>(); } // ... } 295
296. 队列实现栈 栈实现队列 当调⽤ push 让元素⼊队时,只要把元素压⼊ s1 即可,⽐如说 push 进 3 个元素分别是 1,2,3,那么底层结构就是这样: /** 添加元素到队尾 */ public void push(int x) { s1.push(x); } 那么如果这时候使⽤ peek 查看队头的元素怎么办呢?按道理队头元素应 该是 1,但是在 s1 作⽤了:当 为空时,可以把 候 s2 s2 中 1 被压在栈底,现在就要轮到 s1 s2 起到⼀个中转的 的所有元素取出再添加进 s2 ,这时 中元素就是先进先出顺序了。 296
297. 队列实现栈 栈实现队列 /** 返回队头元素 */ public int peek() { if (s2.isEmpty()) // 把 s1 元素压⼊ s2 while (!s1.isEmpty()) s2.push(s1.pop()); return s2.peek(); } 同理,对于 pop 操作,只要操作 s2 就可以了。 /** 删除队头的元素并返回 */ public int pop() { // 先调⽤ peek 保证 s2 ⾮空 peek(); return s2.pop(); } 最后,如何判断队列是否为空呢?如果两个栈都为空的话,就说明队列为 空: /** 判断队列是否为空 */ public boolean empty() { 297
298. 队列实现栈 栈实现队列 return s1.isEmpty() && s2.isEmpty(); } ⾄此,就⽤栈结构实现了⼀个队列,核⼼思想是利⽤两个栈互相配合。 值得⼀提的是,这⼏个操作的时间复杂度是多少呢?有点意思的是 操作,调⽤它时可能触发 while 循环,这样的话时间复杂度是 O(N),但是 ⼤部分情况下 while 作调⽤了 ,它的时间复杂度和 peek 循环不会被触发,时间复杂度是 O(1)。由于 peek s1 往 s2 pop 操 相同。 像这种情况,可以说它们的最坏时间复杂度是 O(N),因为包含 环,可能需要从 peek while 循 搬移元素。 但是它们的均摊时间复杂度是 O(1),这个要这么理解:对于⼀个元素,最 多只可能被搬运⼀次,也就是说 peek 操作平均到每个元素的时间复杂度 是 O(1)。 ⼆、⽤队列实现栈 如果说双栈实现队列⽐较巧妙,那么⽤队列实现栈就⽐较简单粗暴了,只需 要⼀个队列作为底层数据结构。⾸先看下栈的 API: class MyStack { /** 添加元素到栈顶 */ public void push(int x); /** 删除栈顶的元素并返回 */ public int pop(); /** 返回栈顶元素 */ public int top(); /** 判断栈是否为空 */ public boolean empty(); } 298
299. 队列实现栈 栈实现队列 先说 push API,直接将元素加⼊队列,同时记录队尾元素,因为队尾元素 相当于栈顶元素,如果要 top 查看栈顶元素的话可以直接返回: class MyStack { Queue q = new LinkedList<>(); int top_elem = 0; /** 添加元素到栈顶 */ public void push(int x) { // x 是队列的队尾,是栈的栈顶 q.offer(x); top_elem = x; } /** 返回栈顶元素 */ public int top() { return top_elem; } } 我们的底层数据结构是先进先出的队列,每次 是栈是后进先出,也就是说 pop pop 只能从队头取元素;但 API 要从队尾取元素。 299
300. 队列实现栈 栈实现队列 解决⽅法简单粗暴,把队列前⾯的都取出来再加⼊队尾,让之前的队尾元素 排到队头,这样就可以取出了: /** 删除栈顶的元素并返回 */ public int pop() { int size = q.size(); while (size > 1) { q.offer(q.poll()); size--; } // 之前的队尾元素已经到了队头 return q.poll(); } 这样实现还有⼀点⼩问题就是,原来的队尾元素被提到队头并删除了,但是 top_elem 变量没有更新,我们还需要⼀点⼩修改: /** 删除栈顶的元素并返回 */ public int pop() { int size = q.size(); // 留下队尾 2 个元素 while (size > 2) { q.offer(q.poll()); size--; 300
301. 队列实现栈 栈实现队列 } // 记录新的队尾元素 top_elem = q.peek(); q.offer(q.poll()); // 删除之前的队尾元素 return q.poll(); } 最后,API empty 就很容易实现了,只要看底层的队列是否为空即可: /** 判断栈是否为空 */ public boolean empty() { return q.isEmpty(); } 很明显,⽤队列实现栈的话, pop 操作时间复杂度是 O(N),其他操作都是 O(1)​。​ 个⼈认为,⽤队列实现栈是没啥亮点的问题,但是⽤双栈实现队列是值得学 习的。 从栈 s1 搬运元素到 s2 之后,元素在 s2 中就变成了队列的先进先出顺 序,这个特性有点类似「负负得正」,确实不太容易想到。 301
302. 队列实现栈 栈实现队列 希望本⽂对你有帮助。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 302
303. 第三章、算法思维系列 算法思维系列 学算法,认准 labuladong 就够了! 本章包含⼀些常⽤的算法技巧,⽐如前缀和、回溯思想、位操作、双指针、 如何正确书写⼆分查找等等。 欢迎关注我的公众号 labuladong,⽅便获得最新的优质⽂章: 303
304. 回溯算法团灭⼦集、排列、组合问题 回溯算法团灭⼦集、排列、组合问题 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 78.⼦集 46.全排列 77.组合 今天就来聊三道考察频率⾼,⽽且容易让⼈搞混的算法问题,分别是求⼦集 (subset),求排列(permutation),求组合(combination)。 这⼏个问题都可以⽤回溯算法模板解决,同时⼦集问题还可以⽤数学归纳思 想解决。读者可以记住这⼏个问题的回溯套路,就不怕搞不清了。 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 304
305. 回溯算法最佳实践:解数独 回溯算法秒杀数独问题 学算法,认准 labuladong 就够了! 经常拿回溯算法来说事⼉的,⽆⾮就是⼋皇后问题和数独问题了。那我们今 天就通过实际且有趣的例⼦来讲⼀下如何⽤回溯算法来解决数独问题。 ⼀、直观感受 说实话我⼩的时候也尝试过玩数独游戏,但从来都没有完成过⼀次。做数独 是有技巧的,我记得⼀些⽐较专业的数独游戏软件,他们会教你玩数独的技 巧,不过在我看来这些技巧都太复杂,我根本就没有兴趣看下去。 不过⾃从我学习了算法,多困难的数独问题都拦不住我了。下⾯是我⽤程序 完成数独的⼀个例⼦: 【PDF格式⽆法显⽰GIF⽂件 sudoku/sudoku_slove.gif,可移步公众号查看】 ​PS:GIF 可能出现 bug,若卡住​点开查看即可,​下同。​ 这是⼀个安卓⼿机中的数独游戏,我使⽤⼀个叫做 Auto.js 的脚本引擎,配 合回溯算法来实现⾃动完成填写,并且算法记录了执⾏次数。在后⽂,我会 给出该脚本的实现思路代码以及软件⼯具的下载,你也可以拿来装逼⽤。 可以观察到前两次都执⾏了 1 万多次,⽽最后⼀次只执⾏了 100 多次就算出 了答案,这说明对于不同的局⾯,回溯算法得到答案的时间是不相同的。 那么计算机如何解决数独问题呢?其实⾮常的简单,就是穷举嘛,下⾯我可 视化了求解过程: 【PDF格式⽆法显⽰GIF⽂件 sudoku/sudoku_process.gif,可移步公众号查 看】 305
306. 回溯算法最佳实践:解数独 算法的核⼼思路⾮常⾮常的简单,就是对每⼀个空着的格⼦穷举 1 到 9,如 果遇到不合法的数字(在同⼀⾏或同⼀列或同⼀个 3×3 的区域中存在相同 的数字)则跳过,如果找到⼀个合法的数字,则继续穷举下⼀个空格⼦。 对于数独游戏,也许我们还会有另⼀个误区:就是下意识地认为如果给定的 数字越少那么这个局⾯的难度就越⼤。 这个结论对⼈来说应该没⽑病,但对于计算机⽽⾔,给的数字越少,反⽽穷 举的步数就越少,得到答案的速度越快,⾄于为什么,我们后⾯探讨代码实 现的时候会讲。 上⼀个 GIF 是最后⼀关 70 关,下图是第 52 关,数字⽐较多,看起来似乎 不难,但是我们看⼀下算法执⾏的过程: 【PDF格式⽆法显⽰GIF⽂件 sudoku/sudoku3.gif,可移步公众号查看】 可以看到算法在前两⾏穷举了半天都没有⾛出去,由于时间原因我就没有继 续录制了,事实上,这个局⾯穷举的次数⼤概是上⼀个局⾯的 10 倍。 ⾔归正传,下⾯我们就来具体探讨⼀下如何⽤算法来求解数独问题,顺便说 说我是如何可视化这个求解过程的。 ⼆、代码实现 ⾸先,我们不⽤管游戏的 UI,先单纯地解决回溯算法,LeetCode 第 37 题就 是解数独的问题,算法函数签名如下: void solveSudoku(char[][] board); 输⼊是⼀个9x9的棋盘,空⽩格⼦⽤点号字符 . 表⽰,算法需要在原地修 改棋盘,将空⽩格⼦填上数字,得到⼀个可⾏解。 ⾄于数独的要求,⼤家想必都很熟悉了,每⾏,每列以及每⼀个 3×3 的⼩ ⽅格都不能有相同的数字出现。那么,现在我们直接套回溯框架即可求解。 306
307. 回溯算法最佳实践:解数独 前⽂回溯算法详解,已经写过了回溯算法的套路框架,如果还没看过那篇⽂ 章的,建议先看看。 回忆刚才的 GIF 图⽚,我们求解数独的思路很简单粗暴,就是对每⼀个格 ⼦所有可能的数字进⾏穷举。对于每个位置,应该如何穷举,有⼏个选择 呢?很简单啊,从 1 到 9 就是选择,全部试⼀遍不就⾏了: // 对 board[i][j] 进⾏穷举尝试 void backtrack(char[][] board, int i, int j) { int m = 9, n = 9; for (char ch = '1'; ch <= '9'; ch++) { // 做选择 board[i][j] = ch; // 继续穷举下⼀个 backtrack(board, i, j + 1); // 撤销选择 board[i][j] = '.'; } } emmm,再继续细化,并不是 1 到 9 都可以取到的,有的数字不是不满⾜数 独的合法条件吗?⽽且现在只是给 j 加⼀,那如果 j 加到最后⼀列了, 怎么办? 很简单,当 j 到达超过每⼀⾏的最后⼀个索引时,转为增加 i 开始穷举 下⼀⾏,并且在穷举之前添加⼀个判断,跳过不满⾜条件的数字: void backtrack(char[][] board, int i, int j) { int m = 9, n = 9; if (j == n) { // 穷举到最后⼀列的话就换到下⼀⾏重新开始。 backtrack(board, i + 1, 0); return; } // 如果该位置是预设的数字,不⽤我们操⼼ if (board[i][j] != '.') { backtrack(board, i, j + 1); return; 307
308. 回溯算法最佳实践:解数独 } for (char ch = '1'; ch <= '9'; ch++) { // 如果遇到不合法的数字,就跳过 if (!isValid(board, i, j, ch)) continue; board[i][j] = ch; backtrack(board, i, j + 1); board[i][j] = '.'; } } // 判断 board[i][j] 是否可以填⼊ n boolean isValid(char[][] board, int r, int c, char n) { for (int i = 0; i < 9; i++) { // 判断⾏是否存在重复 if (board[r][i] == n) return false; // 判断列是否存在重复 if (board[i][c] == n) return false; // 判断 3 x 3 ⽅框是否存在重复 if (board[(r/3)*3 + i/3][(c/3)*3 + i%3] == n) return false; } return true; } emmm,现在基本上差不多了,还剩最后⼀个问题:这个算法没有 base case,永远不会停⽌递归。这个好办,什么时候结束递归?显然 r == m 的 时候就说明穷举完了最后⼀⾏,完成了所有的穷举,就是 base case。 另外,前⽂也提到过,为了减少复杂度,我们可以让 值为 boolean backtrack 函数返回 ,如果找到⼀个可⾏解就返回 true,这样就可以阻⽌后续的 递归。只找⼀个可⾏解,也是题⽬的本意。 最终代码修改如下: boolean backtrack(char[][] board, int i, int j) { int m = 9, n = 9; if (j == n) { // 穷举到最后⼀列的话就换到下⼀⾏重新开始。 308
309. 回溯算法最佳实践:解数独 return backtrack(board, i + 1, 0); } if (i == m) { // 找到⼀个可⾏解,触发 base case return true; } if (board[i][j] != '.') { // 如果有预设数字,不⽤我们穷举 return backtrack(board, i, j + 1); } for (char ch = '1'; ch <= '9'; ch++) { // 如果遇到不合法的数字,就跳过 if (!isValid(board, i, j, ch)) continue; board[i][j] = ch; // 如果找到⼀个可⾏解,⽴即结束 if (backtrack(board, i, j + 1)) { return true; } board[i][j] = '.'; } // 穷举完 1~9,依然没有找到可⾏解,此路不通 return false; } boolean isValid(char[][] board, int r, int c, char n) { // ⻅上⽂ } 现在可以回答⼀下之前的问题,为什么有时候算法执⾏的次数多,有时候 少?为什么对于计算机⽽⾔,确定的数字越少,反⽽算出答案的速度越快? 我们已经实现了⼀遍算法,掌握了其原理,回溯就是从 1 开始对每个格⼦穷 举,最后只要试出⼀个可⾏解,就会⽴即停⽌后续的递归穷举。所以暴⼒试 出答案的次数和随机⽣成的棋盘关系很⼤,这个是说不准的。 那么你可能问,既然运⾏次数说不准,那么这个算法的时间复杂度是多少 呢? 309
310. 回溯算法最佳实践:解数独 对于这种时间复杂度的计算,我们只能给出⼀个最坏情况,也就是 O(9^M),其中 M 是棋盘中空着的格⼦数量。你想嘛,对每个空格⼦穷举 9 个数,结果就是指数级的。 这个复杂度⾮常⾼,但稍作思考就能发现,实际上我们并没有真的对每个空 格都穷举 9 次,有的数字会跳过,有的数字根本就没有穷举,因为当我们找 到⼀个可⾏解的时候就⽴即结束了,后续的递归都没有展开。 这个 O(9^M) 的复杂度实际上是完全穷举,或者说是找到所有可⾏解的时间 复杂度。 如果给定的数字越少,相当于给出的约束条件越少,对于计算机这种穷举策 略来说,是更容易进⾏下去,⽽不容易⾛回头路进⾏回溯的,所以说如果仅 仅找出⼀个可⾏解,这种情况下穷举的速度反⽽⽐较快。 ⾄此,回溯算法就完成了,你可以⽤以上代码通过 LeetCode 的判题系统, 下⾯我们来简单说下我是如何把这个回溯过程可视化出来的。 三、算法可视化 让算法帮我玩游戏的核⼼是算法,如果你理解了这个算法,剩下就是借助安 卓脚本引擎 Auto.js 调 API 操作⼿机了,⼯具我都放在后台了,你等会⼉就 可以下载。 ⽤伪码简单说下思路,我可以写两个函数: void setNum(Button b, char n) { // 输⼊⼀个⽅格,将该⽅格设置为数字 n } void cancelNum(Button b) { // 输⼊⼀个⽅格,将该⽅格上的数字撤销 } 310
311. 回溯算法最佳实践:解数独 回溯算法的核⼼框架如下,只要在框架对应的位置加上对应的操作,即可将 算法做选择、撤销选择的过程完全展⽰出来,也许这就是套路框架的魅⼒所 在: for (char ch = '1'; ch <= '9'; ch++) { Button b = new Button(r, c); // 做选择 setNum(b, ch); board[i][j] = ch; // 继续穷举下⼀个 backtrack(board, i, j + 1) // 撤销选择 cancelNum(b); board[i][j] = '.'; } 以上思路就可以模拟出算法穷举的过程: 【PDF格式⽆法显⽰GIF⽂件 sudoku/sudoku_process.gif,可移步公众号查 看】 公众号后台回复关键词「数独」即可下载相应脚本、⼯具和游戏,Auto.js 是⼀款优秀的开源脚本引擎,可以⽤ JavaScript 操作安卓⼿机。把脚本代码 copy 进去即可运⾏,注意只⽀持安卓⼿机哦。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 311
312. 回溯算法最佳实践:解数独 312
313. 回溯算法最佳实践:括号⽣成 合法括号⽣成算法 学算法,认准 labuladong 就够了! 括号问题可以简单分成两类,⼀类是前⽂写过的 括号的合法性判断 ,⼀类 是合法括号的⽣成。对于括号合法性的判断,主要是借助「栈」这种数据结 构,⽽对于括号的⽣成,⼀般都要利⽤回溯递归的思想。 关于回溯算法,我们前⽂写过⼀篇 回溯算法套路框架详解 反响⾮常好,读 本⽂前应该读过那篇⽂章,这样你就能够进⼀步了解回溯算法的框架使⽤⽅ 法了。 回到正题,括号⽣成算法是 LeetCode 第 22 题,要求如下: 请你写⼀个算法,输⼊是⼀个正整数 n ,输出是 n 对⼉括号的所有合法 组合,函数签名如下: vector generateParenthesis(int n); ⽐如说,输⼊ n=3 ,输出为如下 5 个字符串: "((()))", "(()())", "(())()", "()(())", "()()()" 有关括号问题,你只要记住⼀个性质,思路就很容易想出来: 1、⼀个「合法」括号组合的左括号数量⼀定等于右括号数量,这个很好理 解。 2、对于⼀个「合法」的括号字符串组合 len(p) 都有:⼦串 p[0..i] p ,必然对于任何 0 <= i < 中左括号的数量都⼤于或等于右括号的数量。 如果不跟你说这个性质,可能不太容易发现,但是稍微想⼀下,其实很容易 理解,因为从左往右算的话,肯定是左括号多嘛,到最后左右括号数量相 等,说明这个括号组合是合法的。 313
314. 回溯算法最佳实践:括号⽣成 反之,⽐如这个括号组合 ))(( ,前⼏个⼦串都是右括号多于左括号,显然 不是合法的括号组合。 下⾯就来⼿把⼿实践⼀下回溯算法框架。 回溯思路 明⽩了合法括号的性质,如何把这道题和回溯算法扯上关系呢? 算法输⼊⼀个整数 n ,让你计算 n 对⼉括号能组成⼏种合法的括号组 合,可以改写成如下问题: 现在有 2n 个位置,每个位置可以放置字符 ( 或者 ) ,组成的所有括号 组合中,有多少个是合法的? 这个命题和题⽬的意思完全是⼀样的对吧,那么我们先想想如何得到全部 2^(2n) 种组合,然后再根据我们刚才总结出的合法括号组合的性质筛选出 合法的组合,不就完事⼉了? 如何得到所有的组合呢?这就是标准的暴⼒穷举回溯框架啊,我们前⽂ 回 溯算法套路框架详解 都总结过了: result = [] def backtrack(路径, 选择列表): if 满⾜结束条件: result.add(路径) return for 选择 in 选择列表: 做选择 backtrack(路径, 选择列表) 撤销选择 那么对于我们的需求,如何打印所有括号组合呢?套⼀下框架就出来了,伪 码如下: void backtrack(int n, int i, string& track) { 314
315. 回溯算法最佳实践:括号⽣成 // i 代表当前的位置,共 2n 个位置 // 穷举到最后⼀个位置了,得到⼀个⻓度为 2n 组合 if (i == 2 * n) { print(track); return; } // 对于每个位置可以是左括号或者右括号两种选择 for choice in ['(', ')'] { track.push(choice); // 做选择 // 穷举下⼀个位置 backtrack(n, i + 1, track); track.pop(choice); // 撤销选择 } } 那么,现在能够打印所有括号组合了,如何从它们中筛选出合法的括号组合 呢?​很简单,加⼏个条件进⾏「剪枝」就⾏了。 对于 2n 个位置,必然有 的记录穷举位置 right i n ,⽽是⽤ 个左括号, left n 个右括号,所以我们不是简单 记录还可以使⽤多少个左括号,⽤ 记录还可以使⽤多少个右括号,这样就可以通过刚才总结的合法括 号规律进⾏筛选了: vector generateParenthesis(int n) { if (n == 0) return {}; // 记录所有合法的括号组合 vector res; // 回溯过程中的路径 string track; // 可⽤的左括号和右括号数量初始化为 n backtrack(n, n, track, res); return res; } // 可⽤的左括号数量为 left 个,可⽤的右括号数量为 rgiht 个 void backtrack(int left, int right, string& track, vector& res) { // 若左括号剩下的多,说明不合法 if (right < left) return; // 数量⼩于 0 肯定是不合法的 315
316. 回溯算法最佳实践:括号⽣成 if (left < 0 right < 0) return; // 当所有括号都恰好⽤完时,得到⼀个合法的括号组合 if (left == 0 && right == 0) { res.push_back(track); return; } // 尝试放⼀个左括号 track.push_back('('); // 选择 backtrack(left - 1, right, track, res); track.pop_back(); // 撤消选择 // 尝试放⼀个右括号 track.push_back(')'); // 选择 backtrack(left, right - 1, track, res); track.pop_back(); ;// 撤消选择 } 这样,我们的算法就完成了,算法的复杂度是多少呢?这个⽐较难分析,对 于递归相关的算法,时间复杂度这样计算(递归次数)*(递归函数本⾝的 时间复杂度)。 就是我们的递归函数,其中没有任何 for 循环代码,所以递归函 backtrack 数本⾝的时间复杂度是 O(1),但关键是这个函数的递归次数是多少?换句 话说,给定⼀个 n , backtrack 函数递归被调⽤了多少次? 我们前⾯怎么分析动态规划算法的递归次数的?主要是看「状态」的个数对 吧。其实回溯算法和动态规划的本质都是穷举,只不过动态规划存在「重叠 ⼦问题」可以优化,⽽回溯算法不存在⽽已。 所以说这⾥也可以⽤「状态」这个概念,对于 个,分别是 backtrack left 和 left, right, track backtrack 函数,状态有三 ,这三个变量的所有组合个数就是 函数的状态个数(调⽤次数)。 right 种⽽已;这个 的组合好办,他俩取值就是 0~n 嘛,组合起来也就 track n^2 的⻓度虽然取在 0~2n,但对于每⼀个⻓度,它还有指 数级的括号组合,这个是不好算的。 316
317. 回溯算法最佳实践:括号⽣成 说了这么多,就是想让⼤家知道这个算法的复杂度是指数级,⽽且不好算, 这⾥就不具体展开了,是 $\frac{4^{n}}{\sqrt{n}}$,有兴趣的读者可以搜索 ⼀下「卡特兰数」相关的知识了解⼀下这个复杂度是怎么算的。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 317
318. 双指针技巧总结 双指针技巧总结 学算法,认准 labuladong 就够了! 我把双指针技巧再分为两类,⼀类是「快慢指针」,⼀类是「左右指针」。 前者解决主要解决链表中的问题,⽐如典型的判定链表中是否包含环;后者 主要解决数组(或者字符串)中的问题,⽐如⼆分查找。 ⼀、快慢指针的常⻅算法 快慢指针⼀般都初始化指向链表的头结点 head,前进时快指针 fast 在前, 慢指针 slow 在后,巧妙解决⼀些链表中的问题。 1、判定链表中是否含有环 这应该属于链表最基本的操作了,如果读者已经知道这个技巧,可以跳过。 单链表的特点是每个节点只知道下⼀个节点,所以⼀个指针的话⽆法判断链 表中是否含有环的。 如果链表中不含环,那么这个指针最终会遇到空指针 null 表⽰链表到头 了,这还好说,可以判断该链表不含环。 boolean hasCycle(ListNode head) { while (head != null) head = head.next; return false; } 但是如果链表中含有环,那么这个指针就会陷⼊死循环,因为环形数组中没 有 null 指针作为尾部节点。 318
319. 双指针技巧总结 经典解法就是⽤两个指针,⼀个跑得快,⼀个跑得慢。如果不含有环,跑得 快的那个指针最终会遇到 null,说明链表不含环;如果含有环,快指针最终 会超慢指针⼀圈,和慢指针相遇,说明链表含有环。 boolean hasCycle(ListNode head) { ListNode fast, slow; fast = slow = head; while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; if (fast == slow) return true; } return false; } 2、已知链表中含有环,返回这个环的起始位置 这个问题⼀点都不困难,有点类似脑筋急转弯,先直接看代码: ListNode detectCycle(ListNode head) { ListNode fast, slow; fast = slow = head; while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; if (fast == slow) break; } // 上⾯的代码类似 hasCycle 函数 319
320. 双指针技巧总结 slow = head; while (slow != fast) { fast = fast.next; slow = slow.next; } return slow; } 可以看到,当快慢指针相遇时,让其中任⼀个指针指向头节点,然后让它俩 以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。这是为什 么呢? 第⼀次相遇时,假设慢指针 slow ⾛了 k 步,那么快指针 fast ⼀定⾛了 2k 步,也就是说⽐ slow 多⾛了 k 步(也就是环的⻓度)。 设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k m,也就是说如果从 head 前进 k - m 步就能到达环起点。 巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。 320
321. 双指针技巧总结 所以,只要我们把快慢指针中的任⼀个重新指向 head,然后两个指针同速 前进,k - m 步后就会相遇,相遇之处就是环的起点了。 3、寻找链表的中点 类似上⾯的思路,我们还可以让快指针⼀次前进两步,慢指针⼀次前进⼀ 步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。 while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; } // slow 就在中间位置 return slow; 当链表的⻓度是奇数时,slow 恰巧停在中点位置;如果⻓度是偶数,slow 最终的位置是中间偏右: 321
322. 双指针技巧总结 寻找链表中点的⼀个重要作⽤是对链表进⾏归并排序。 回想数组的归并排序:求中点索引递归地把数组⼆分,最后合并两个有序数 组。对于链表,合并两个有序链表是很简单的,难点就在于⼆分。 但是现在你学会了找到链表的中点,就能实现链表的⼆分了。关于归并排序 的具体内容本⽂就不具体展开了。 4、寻找链表的倒数第 k 个元素 我们的思路还是使⽤快慢指针,让快指针先⾛ k 步,然后快慢指针开始同速 前进。这样当快指针⾛到链表末尾 null 时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表⻓度): ListNode slow, fast; slow = fast = head; while (k-- > 0) fast = fast.next; while (fast != null) { slow = slow.next; fast = fast.next; } return slow; ⼆、左右指针的常⽤算法 左右指针在数组中实际是指两个索引值,⼀般初始化为 left = 0, right = nums.length - 1 。 1、⼆分查找 前⽂「⼆分查找」有详细讲解,这⾥只写最简单的⼆分算法,旨在突出它的 双指针特性: int binarySearch(int[] nums, int target) { int left = 0; int right = nums.length - 1; 322
323. 双指针技巧总结 while(left <= right) { int mid = (right + left) / 2; if(nums[mid] == target) return mid; else if (nums[mid] < target) left = mid + 1; else if (nums[mid] > target) right = mid - 1; } return -1; } 2、两数之和 直接看⼀道 LeetCode 题⽬吧: 只要数组有序,就应该想到双指针技巧。这道题的解法有点类似⼆分查找, 通过调节 left 和 right 可以调整 sum 的⼤⼩: int[] twoSum(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { // 题⽬要求的索引是从 1 开始的 323
324. 双指针技巧总结 return new int[]{left + 1, right + 1}; } else if (sum < target) { left++; // 让 sum ⼤⼀点 } else if (sum > target) { right--; // 让 sum ⼩⼀点 } } return new int[]{-1, -1}; } 3、反转数组 void reverse(int[] nums) { int left = 0; int right = nums.length - 1; while (left < right) { // swap(nums[left], nums[right]) int temp = nums[left]; nums[left] = nums[right]; nums[right] = temp; left++; right--; } } 4、滑动窗⼝算法 这也许是双指针技巧的最⾼境界了,如果掌握了此算法,可以解决⼀⼤类⼦ 字符串匹配的问题,不过「滑动窗⼝」稍微⽐上述的这些算法复杂些。 幸运的是,这类算法是有框架模板的,⽽且这篇⽂章就讲解了「滑动窗⼝」 算法模板,帮⼤家秒杀⼏道 LeetCode ⼦串匹配的问题。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 324
325. 双指针技巧总结 325
326. twoSum问题的核⼼思想 twoSum问题的核⼼思想 学算法,认准 labuladong 就够了! Two Sum 系列问题在 LeetCode 上有好⼏道,这篇⽂章就挑出有代表性的⼏ 道,介绍⼀下这种问题怎么解决。 TwoSum I 这个问题的最基本形式是这样:给你⼀个数组和⼀个整数 证数组中存在两个数的和为 ⽐如输⼊ target ,可以保 target ,请你返回这两个数的索引。 nums = [3,1,3,6], target = 6 ,算法应该返回数组 [0,2] ,因为 3 + 3 = 6。 这个问题如何解决呢?⾸先最简单粗暴的办法当然是穷举了: int[] twoSum(int[] nums, int target) { for (int i = 0; i < nums.length; i++) for (int j = i + 1; j < nums.length; j++) if (nums[j] == target - nums[i]) return new int[] { i, j }; // 不存在这么两个数 return new int[] {-1, -1}; } 这个解法⾮常直接,时间复杂度 O(N^2),空间复杂度 O(1)。 可以通过⼀个哈希表减少时间复杂度: int[] twoSum(int[] nums, int target) { int n = nums.length; index index = new HashMap<>(); 326
327. twoSum问题的核⼼思想 // 构造⼀个哈希表:元素映射到相应的索引 for (int i = 0; i < n; i++) index.put(nums[i], i); for (int i = 0; i < n; i++) { int other = target - nums[i]; // 如果 other 存在且不是 nums[i] 本⾝ if (index.containsKey(other) && index.get(other) != i) return new int[] {i, index.get(other)}; } return new int[] {-1, -1}; } 这样,由于哈希表的查询时间为 O(1),算法的时间复杂度降低到 O(N),但 是需要 O(N) 的空间复杂度来存储哈希表。不过综合来看,是要⽐暴⼒解法 ⾼效的。 我觉得 Two Sum 系列问题就是想教我们如何使⽤哈希表处理问题。我们接 着往后看。 TwoSum II 这⾥我们稍微修改⼀下上⾯的问题。我们设计⼀个类,拥有两个 API: class TwoSum { // 向数据结构中添加⼀个数 number public void add(int number); // 寻找当前数据结构中是否存在两个数的和为 value public boolean find(int value); } 如何实现这两个 API 呢,我们可以仿照上⼀道题⽬,使⽤⼀个哈希表辅助 find ⽅法: class TwoSum { Map freq = new HashMap<>(); 327
328. twoSum问题的核⼼思想 public void add(int number) { // 记录 number 出现的次数 freq.put(number, freq.getOrDefault(number, 0) + 1); } public boolean find(int value) { for (Integer key : freq.keySet()) { int other = value - key; // 情况⼀ if (other == key && freq.get(key) > 1) return true; // 情况⼆ if (other != key && freq.containsKey(other)) return true; } return false; } } 进⾏ find 情况⼀: 的时候有两种情况,举个例⼦: add 了 [3,3,2,5] 之后,执⾏ find(6) ,由于 3 出现了两次,3 之后,执⾏ find(7) ,那么 + 3 = 6,所以返回 true。 情况⼆: 2, other add 了 [3,3,2,5] key 为 为 5 时算法可以返回 true。 除了上述两种情况外, find 只能返回 false 了。 对于这个解法的时间复杂度呢, add ⽅法是 O(1), find ⽅法是 O(N),空 间复杂度为 O(N),和上⼀道题⽬⽐较类似。 但是对于 API 的设计,是需要考虑现实情况的。⽐如说,我们设计的这个 类,使⽤ find ⽅法⾮常频繁,那么每次都要 O(N) 的时间,岂不是很浪费 费时间吗?对于这种情况,我们是否可以做些优化呢? 是的,对于频繁使⽤ find ⽅法的场景,我们可以进⾏优化。我们可以参 考上⼀道题⽬的暴⼒解法,借助哈希集合来针对性优化 find ⽅法: class TwoSum { 328
329. twoSum问题的核⼼思想 Set sum = new HashSet<>(); List nums = new ArrayList<>(); public void add(int number) { // 记录所有可能组成的和 for (int n : nums) sum.add(n + number); nums.add(number); } public boolean find(int value) { return sum.contains(value); } } 这样 sum 中就储存了所有加⼊数字可能组成的和,每次 find 只要花费 O(1) 的时间在集合中判断⼀下是否存在就⾏了,显然⾮常适合频繁使⽤ find 的场景。 三、总结 对于 TwoSum 问题,⼀个难点就是给的数组⽆序。对于⼀个⽆序的数组, 我们似乎什么技巧也没有,只能暴⼒穷举所有可能。 ⼀般情况下,我们会⾸先把数组排序再考虑双指针技巧。TwoSum 启发我 们,HashMap 或者 HashSet 也可以帮助我们处理⽆序数组相关的简单问题。 另外,设计的核⼼在于权衡,利⽤不同的数据结构,可以得到⼀些针对性的 加强。 最后,如果 TwoSum I 中给的数组是有序的,应该如何编写算法呢?答案很 简单,前⽂「双指针技巧汇总」写过: int[] twoSum(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { 329
330. twoSum问题的核⼼思想 return new int[]{left, right}; } else if (sum < target) { left++; // 让 sum ⼤⼀点 } else if (sum > target) { right--; // 让 sum ⼩⼀点 } } // 不存在这样两个数 return new int[]{-1, -1}; } _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 330
331. 常⽤的位操作 常⽤的位操作 学算法,认准 labuladong 就够了! 本⽂分两部分,第⼀部分列举⼏个有趣的位操作,第⼆部分讲解算法中常⽤ 的 n & (n - 1) 操作,顺便把⽤到这个技巧的算法题列出来讲解⼀下。因为位 操作很简单,所以假设读者已经了解与、或、异或这三种基本操作。 位操作(Bit Manipulation)可以玩出很多奇技淫巧,但是这些技巧⼤部分都 过于晦涩,没必要深究,读者只要记住⼀些有⽤的操作即可。 ⼀、⼏个有趣的位操作 1. 利⽤或操作 和空格将英⽂字符转换为⼩写 ('a' ' ') = 'a' ('A' ' ') = 'a' 1. 利⽤与操作 & 和下划线将英⽂字符转换为⼤写 ('b' & '_') = 'B' ('B' & '_') = 'B' 1. 利⽤异或操作 ^ 和空格进⾏英⽂字符⼤⼩写互换 ('d' ^ ' ') = 'D' ('D' ^ ' ') = 'd' PS:以上操作能够产⽣奇特效果的原因在于 ASCII 编码。字符其实就是数 字,恰巧这些字符对应的数字通过位运算就能得到正确的结果,有兴趣的读 者可以查 ASCII 码表⾃⼰算算,本⽂就不展开讲了。 331
332. 常⽤的位操作 1. 判断两个数是否异号 int x = -1, y = 2; bool f = ((x ^ y) < 0); // true int x = 3, y = 2; bool f = ((x ^ y) < 0); // false PS:这个技巧还是很实⽤的,利⽤的是补码编码的符号位。如果不⽤位运 算来判断是否异号,需要使⽤ if else 分⽀,还挺⿇烦的。读者可能想利⽤乘 积或者商来判断两个数是否异号,但是这种处理⽅式可能造成溢出,从⽽出 现错误。(关于补码编码和溢出,参⻅前⽂) 1. 交换两个数 int a = 1, b = 2; a ^= b; b ^= a; a ^= b; // 现在 a = 2, b = 1 1. 加⼀ int n = 1; n = -~n; // 现在 n = 2 1. 减⼀ int n = 2; n = ~-n; // 现在 n = 1 PS:上⾯这三个操作就纯属装逼⽤的,没啥实际⽤处,⼤家了解了解乐呵 ⼀下就⾏。 332
333. 常⽤的位操作 ⼆、算法常⽤操作 n&(n-1) 这个操作是算法中常⻅的,作⽤是消除数字 n 的⼆进制表⽰中的最后⼀个 1。 看个图就很容易理解了: 1. 计算汉明权重(Hamming Weight) 333
334. 常⽤的位操作 就是让你返回 n 的⼆进制表⽰中有⼏个 1。因为 n & (n - 1) 可以消除最后⼀ 个 1,所以可以⽤⼀个循环不停地消除 1 同时计数,直到 n 变成 0 为⽌。 int hammingWeight(uint32_t n) { int res = 0; while (n != 0) { n = n & (n - 1); res++; } return res; } 1. 判断⼀个数是不是 2 的指数 334
335. 常⽤的位操作 ⼀个数如果是 2 的指数,那么它的⼆进制表⽰⼀定只含有⼀个 1: 2^0 = 1 = 0b0001 2^1 = 2 = 0b0010 2^2 = 4 = 0b0100 如果使⽤位运算技巧就很简单了(注意运算符优先级,括号不可以省略): bool isPowerOfTwo(int n) { if (n <= 0) return false; return (n & (n - 1)) == 0; } 以上便是⼀些有趣/常⽤的位操作。其实位操作的技巧很多,有⼀个叫做 Bit Twiddling Hacks 的外国⽹站收集了⼏乎所有位操作的⿊科技玩法,感兴趣 的读者可以点击「阅读原⽂」按钮查看。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 335
336. 烧饼排序 烧饼排序 学算法,认准 labuladong 就够了! 烧饼排序是个很有意思的实际问题:假设盘⼦上有 n 块⾯积⼤⼩不⼀的烧 饼,你如何⽤⼀把锅铲进⾏若⼲次翻转,让这些烧饼的⼤⼩有序(⼩的在 上,⼤的在下)? 设想⼀下⽤锅铲翻转⼀堆烧饼的情景,其实是有⼀点限制的,我们每次只能 将最上⾯的若⼲块饼⼦翻转: 336
337. 烧饼排序 我们的问题是,如何使⽤算法得到⼀个翻转序列,使得烧饼堆变得有序? ⾸先,需要把这个问题抽象,⽤数组来表⽰烧饼堆: 如何解决这个问题呢?其实类似上篇⽂章 递归反转链表的⼀部分,这也是 需要递归思想的。 ⼀、思路分析 为什么说这个问题有递归性质呢?⽐如说我们需要实现这样⼀个函数: // cakes 是⼀堆烧饼,函数会将前 n 个烧饼排序 void sort(int[] cakes, int n); 如果我们找到了前 n 个烧饼中最⼤的那个,然后设法将这个饼⼦翻转到最 底下: 337
338. 烧饼排序 那么,原问题的规模就可以减⼩,递归调⽤ 接下来,对于上⾯的这 n - 1 pancakeSort(A, n-1) 即可: 块饼,如何排序呢?还是先从中找到最⼤的 ⼀块饼,然后把这块饼放到底下,再递归调⽤ pancakeSort(A, n-1-1) …… 你看,这就是递归性质,总结⼀下思路就是: 1、找到 n 个饼中最⼤的那个。 338
339. 烧饼排序 2、把这个最⼤的饼移到最底下。 3、递归调⽤ base case: pancakeSort(A, n - 1) 。 时,排序 1 个饼时不需要翻转。 n == 1 那么,最后剩下个问题,如何设法将某块烧饼翻到最后呢? 其实很简单,⽐如第 3 块饼是最⼤的,我们想把它换到最后,也就是换到第 块。可以这样操作: n 1、⽤锅铲将前 3 块饼翻转⼀下,这样最⼤的饼就翻到了最上⾯。 2、⽤锅铲将前 n 块饼全部翻转,这样最⼤的饼就翻到了第 n 块,也就 是最后⼀块。 以上两个流程理解之后,基本就可以写出解法了,不过题⽬要求我们写出具 体的反转操作序列,这也很简单,只要在每次翻转烧饼时记录下来就⾏了。 ⼆、代码实现 只要把上述的思路⽤代码实现即可,唯⼀需要注意的是,数组索引从 0 开 始,⽽我们要返回的结果是从 1 开始算的。 // 记录反转操作序列 LinkedList res = new LinkedList<>(); List pancakeSort(int[] cakes) { sort(cakes, cakes.length); return res; } void sort(int[] cakes, int n) { // base case if (n == 1) return; // 寻找最⼤饼的索引 int maxCake = 0; int maxCakeIndex = 0; for (int i = 0; i < n; i++) 339
340. 烧饼排序 if (cakes[i] > maxCake) { maxCakeIndex = i; maxCake = cakes[i]; } // 第⼀次翻转,将最⼤饼翻到最上⾯ reverse(cakes, 0, maxCakeIndex); res.add(maxCakeIndex + 1); // 第⼆次翻转,将最⼤饼翻到最下⾯ reverse(cakes, 0, n - 1); res.add(n); // 递归调⽤ sort(cakes, n - 1); } void reverse(int[] arr, int i, int j) { while (i < j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } } 通过刚才的详细解释,这段代码应该是很清晰了。 算法的时间复杂度很容易计算,因为递归调⽤的次数是 n ,每次递归调⽤ 都需要⼀次 for 循环,时间复杂度是 O(n),所以总的复杂度是 O(n^2)。 最后,我们可以思考⼀个问题​:按照我们这个思路,得出的操作序列⻓度应 该为​ 2(n - 1) ,因为每次递归都要进⾏ 2 次翻转并记录操作,总共有 n 层递归,但由于 base case 直接返回结果,不进⾏翻转,所以最终的操作序 列⻓度应该是固定的 2(n - 1) 。 显然,这个结果不是最优的(最短的),⽐如说⼀堆煎饼 们的算法得到的翻转序列是 [2,3,4] [3,4,2,3,1,2] [3,2,4,1] ,我 ,但是最快捷的翻转⽅法应该是 : 340
341. 烧饼排序 初始状态 :[3,2,4,1] 翻前 2 个:[2,3,4,1] 翻前 3 个:[4,3,2,1] 翻前 4 个: [1,2,3,4] 如果要求你的算法计算排序烧饼的最短操作序列,你该如何计算呢?或者 说,解决这种求最优解法的问题,核⼼思路什么,⼀定需要使⽤什么算法技 巧呢? 不妨分享⼀下你的思考。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 341
342. 前缀和技巧 前缀和技巧 学算法,认准 labuladong 就够了! 今天来聊⼀道简单却⼗分巧妙的算法问题:算出⼀共有⼏个和为 k 的⼦数 组。 那我把所有⼦数组都穷举出来,算它们的和,看看谁的和等于 k 不就⾏了。 关键是,如何快速得到某个⼦数组的和呢,⽐如说给你⼀个数组 你实现⼀个接⼝ sum(i, j) ,这个接⼝要返回 nums[i..j] nums ,让 的和,⽽且会被 多次调⽤,你怎么实现这个接⼝呢? 因为接⼝要被多次调⽤,显然不能每次都去遍历 快速的⽅法在 O(1) 时间内算出 nums[i..j] nums[i..j] ,有没有⼀种 呢?这就需要前缀和技巧了。 ⼀、什么是前缀和 前缀和的思路是这样的,对于⼀个给定的数组 nums ,我们额外开辟⼀个前 缀和数组进⾏预处理: int n = nums.length; // 前缀和数组 342
343. 前缀和技巧 int[] preSum = new int[n + 1]; preSum[0] = 0; for (int i = 0; i < n; i++) preSum[i + 1] = preSum[i] + nums[i]; 这个前缀和数组 1] preSum 的含义也很好理解, 的和。那么如果我们想求 preSum[j+1]-preSum[i] nums[i..j] preSum[i] 就是 nums[0..i- 的和,只需要⼀步操作 即可,⽽不需要重新去遍历数组了。 回到这个⼦数组问题,我们想求有多少个⼦数组的和为 k,借助前缀和技巧 很容易写出⼀个解法: int subarraySum(int[] nums, int k) { int n = nums.length; // 构造前缀和 int[] sum = new int[n + 1]; sum[0] = 0; for (int i = 0; i < n; i++) sum[i + 1] = sum[i] + nums[i]; int ans = 0; // 穷举所有⼦数组 for (int i = 1; i <= n; i++) for (int j = 0; j < i; j++) 343
344. 前缀和技巧 // sum of nums[j..i-1] if (sum[i] - sum[j] == k) ans++; return ans; } 这个解法的时间复杂度 O(N^2) 空间复杂度 O(N) ,并不是最优的解法。不 过通过这个解法理解了前缀和数组的⼯作原理之后,可以使⽤⼀些巧妙的办 法把时间复杂度进⼀步降低。 ⼆、优化解法 前⾯的解法有嵌套的 for 循环: for (int i = 1; i <= n; i++) for (int j = 0; j < i; j++) if (sum[i] - sum[j] == k) ans++; 第⼆层 for 循环在⼲嘛呢?翻译⼀下就是,在计算,有⼏个 sum[i] 和 sum[j] 的差为 k。毎找到⼀个这样的 j j 能够使得 ,就把结果加⼀。 我们可以把 if 语句⾥的条件判断移项,这样写: if (sum[j] == sum[i] - k) ans++; 优化的思路是:我直接记录下有⼏个 sum[j] 和 sum[i] - k 相等,直接更 新结果,就避免了内层的 for 循环。我们可以⽤哈希表,在记录前缀和的同 时记录该前缀和出现的次数。 int subarraySum(int[] nums, int k) { int n = nums.length; // map:前缀和 -> 该前缀和出现的次数 HashMap 344
345. 前缀和技巧 preSum = new HashMap<>(); // base case preSum.put(0, 1); int ans = 0, sum0_i = 0; for (int i = 0; i < n; i++) { sum0_i += nums[i]; // 这是我们想找的前缀和 nums[0..j] int sum0_j = sum0_i - k; // 如果前⾯有这个前缀和,则直接更新答案 if (preSum.containsKey(sum0_j)) ans += preSum.get(sum0_j); // 把前缀和 nums[0..i] 加⼊并记录出现次数 preSum.put(sum0_i, preSum.getOrDefault(sum0_i, 0) + 1); } return ans; } ⽐如说下⾯这个情况,需要前缀和 8 就能找到和为 k 的⼦数组了,之前的暴 ⼒解法需要遍历数组去数有⼏个 8,⽽优化解法借助哈希表可以直接得知有 ⼏个前缀和为 8。 这样,就把时间复杂度降到了 O(N) ,是最优解法了。 345
346. 前缀和技巧 三、总结 前缀和不难,却很有⽤,主要⽤于处理数组区间的问题。 ⽐如说,让你统计班上同学考试成绩在不同分数段的百分⽐,也可以利⽤前 缀和技巧: int[] scores; // 存储着所有同学的分数 // 试卷满分 150 分 int[] count = new int[150 + 1] // 记录每个分数有⼏个同学 for (int score : scores) count[score]++ // 构造前缀和 for (int i = 1; i < count.length; i++) count[i] = count[i] + count[i-1]; 这样,给你任何⼀个分数段,你都能通过前缀和相减快速计算出这个分数段 的⼈数,百分⽐也就很容易计算了。 但是,稍微复杂⼀些的算法问题,不⽌考察简单的前缀和技巧。⽐如本⽂探 讨的这道题⽬,就需要借助前缀和的思路做进⼀步的优化,借助哈希表去除 不必要的嵌套循环。可⻅对题⽬的理解和细节的分析能⼒对于算法的优化是 ⾄关重要的。 希望本⽂对你有帮助。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 346
347. 前缀和技巧 347
348. 字符串乘法 字符串乘法 学算法,认准 labuladong 就够了! 对于⽐较⼩的数字,做运算可以直接使⽤编程语⾔提供的运算符,但是如果 相乘的两个因数⾮常⼤,语⾔提供的数据类型可能就会溢出。⼀种替代⽅案 就是,运算数以字符串的形式输⼊,然后模仿我们⼩学学习的乘法算术过程 计算出结果,并且也⽤字符串表⽰。 需要注意的是, num1 和 num2 可以⾮常⻓,所以不可以把他们直接转成 整型然后运算,唯⼀的思路就是模仿我们⼿算乘法。 ⽐如说我们⼿算 123 × 45 ,应该会这样计算: 348
349. 字符串乘法 349
350. 字符串乘法 计算 123 × 5 ,再计算 123 × 4 ,最后错⼀位相加。这个流程恐怕⼩学⽣ 都可以熟练完成,但是你是否能把这个运算过程进⼀步机械化,写成⼀套算 法指令让没有任何智商的计算机来执⾏呢? 你看这个简单过程,其中涉及乘法进位,涉及错位相加,还涉及加法进位; ⽽且还有⼀些不易察觉的问题,⽐如说两位数乘以两位数,结果可能是四位 数,也可能是三位数,你怎么想出⼀个标准化的处理⽅式?这就是算法的魅 ⼒,如果没有计算机思维,简单的问题可能都没办法⾃动化处理。 ⾸先,我们这种⼿算⽅式还是太「⾼级」了,我们要再「低级」⼀点, × 5 和 123 × 4 123 的过程还可以进⼀步分解,最后再相加: 350
351. 字符串乘法 351
352. 字符串乘法 现在 123 并不⼤,如果是个很⼤的数字的话,是⽆法直接计算乘积的。我 们可以⽤⼀个数组在底下接收相加结果: 352
353. 字符串乘法 353
354. 字符串乘法 整个计算过程⼤概是这样,有两个指针 计算乘积,同时将乘积叠加到 res i,j 在 num1 和 num2 上游⾛, 的正确位置: 【PDF格式⽆法显⽰GIF⽂件 %E5%AD%97%E7%AC%A6%E4%B8%B2%E4%B9%98%E6%B3%95/4.gif, 可移步公众号查看】 现在还有⼀个关键问题,如何将乘积叠加到 何通过 i,j 计算 res 和 的正确位置,或者说,如 的对应索引呢? 其实,细⼼观察之后就发现, res[i+j] res res[i+j+1] num1[i] 和 num2[j] 的乘积对应的就是 这两个位置。 354
355. 字符串乘法 355
356. 字符串乘法 明⽩了这⼀点,就可以⽤代码模仿出这个计算过程了: string multiply(string num1, string num2) { int m = num1.size(), n = num2.size(); // 结果最多为 m + n 位数 vector res(m + n, 0); // 从个位数开始逐位相乘 for (int i = m - 1; i >= 0; i--) for (int j = n - 1; j >= 0; j--) { int mul = (num1[i]-'0') * (num2[j]-'0'); // 乘积在 res 对应的索引位置 int p1 = i + j, p2 = i + j + 1; // 叠加到 res 上 int sum = mul + res[p2]; res[p2] = sum % 10; res[p1] += sum / 10; } // 结果前缀可能存的 0(未使⽤的位) int i = 0; while (i < res.size() && res[i] == 0) i++; // 将计算结果转化成字符串 string str; for (; i < res.size(); i++) str.push_back('0' + res[i]); return str.size() == 0 ? "0" : str; } ⾄此,字符串乘法算法就完成了。 总结⼀下,我们习以为常的⼀些思维⽅式,在计算机看来是⾮常难以做到 的。⽐如说我们习惯的算术流程并不复杂,但是如果让你再进⼀步,翻译成 代码逻辑,并不简单。算法需要将计算流程再简化,通过边算边叠加的⽅式 来得到结果。 俗话教育我们,不要陷⼊思维定式,不要程序化,要发散思维,要创新。但 我觉得程序化并不是坏事,可以⼤幅提⾼效率,减⼩失误率。算法不就是⼀ 套程序化的思维吗,只有程序化才能让计算机帮助我们解决复杂问题呀! 356
357. 字符串乘法 也许算法就是⼀种寻找思维定式的思维吧,希望本⽂对你有帮助。 _____________ 公众号:labuladong B站:labuladong 知乎:labuladong 作为⼀名饱受算法之苦的硬核朋克,我在 Github 上开了个名为 fuckingalgorithm 的仓库,⼿把⼿教你刷 LeetCode,两个⽉获得 40k star,多次登 上 Github Trending 榜⾸,⼀起来凑凑热闹? 本⼩抄即将出版,扫描⼆维码关注微信公众号,后台回复「pdf」限时免费 获取本站的 PDF,回复「进群」可进刷题群⼀起刷题,来快活啊〜 ![](../pictures/qrcode.jpg 357
358. FloodFill算法详解及应⽤ FloodFill算法详解及应⽤ 学算法,认准 labuladong 就够了! 啥是 FloodFill 算法呢,最直接的⼀个应⽤就是「颜⾊填充」,就是 Windows 绘画本中那个⼩油漆桶的标志,可以把⼀块被圈起来的区域全部 染⾊。 【PDF格式⽆法显⽰GIF⽂件 floodfill/floodfill.gif,可移步公众号查看】 这种算法思想还在许多其他地⽅有应⽤。⽐如说扫雷游戏,有时候你点⼀个 ⽅格,会⼀下⼦展开⼀⽚区域,这个展开过程,就是 FloodFill 算法实现 的。 类似的,像消消乐这类游戏,相同⽅块积累到⼀定数量,就全部消除,也是 FloodFill 算法的功劳。 358
359. FloodFill算法详解及应⽤ 通过以上的⼏个例⼦,你应该对 FloodFill 算法有个概念了,现在我们要抽 象问题,提取共同点。 ⼀、构建框架 以上⼏个例⼦,都可以抽象成⼀个⼆维矩阵(图⽚其实就是像素点矩阵), 然后从某个点开始向四周扩展,直到⽆法再扩展为⽌。 矩阵,可以抽象为⼀幅「图」,这就是⼀个图的遍历问题,也就类似⼀个 N 叉树遍历的问题。⼏⾏代码就能解决,直接上框架吧: // (x, y) 为坐标位置 void fill(int x, int y) { fill(x - 1, y); // 上 fill(x + 1, y); // 下 fill(x, y - 1); // 左 fill(x, y + 1); // 右 359
360. FloodFill算法详解及应⽤ } 这个框架可以解决所有在⼆维矩阵中遍历的问题,说得⾼端⼀点,这就叫深 度优先搜索(Depth First Search,简称 DFS),说得简单⼀点,这就叫四叉 树遍历框架。坐标 (x, y) 就是 root,四个⽅向就是 root 的四个⼦节点。 下⾯看⼀道 LeetCode 题⽬,其实就是让我们来实现⼀个「颜⾊填充」功 能。 根据上篇⽂章,我们讲了「树」算法设计的⼀个总路线,今天就可以⽤到: int[][] floodFill(int[][] image, int sr, int sc, int newColor) { int origColor = image[sr][sc]; fill(image, sr, sc, origColor, newColor); return image; } void fill(int[][] image, int x, int y, int origColor, int newColor) { // 出界:超出边界索引 if (!inArea(image, x, y)) return; // 碰壁:遇到其他颜⾊,超出 origColor 区域 if (image[x][y] != origColor) return; 360
361. FloodFill算法详解及应⽤ image[x][y] = newColor; fill(image, x, y + 1, origColor, newColor); fill(image, x, y - 1, origColor, newColor); fill(image, x - 1, y, origColor, newColor); fill(image, x + 1, y, origColor, newColor); } boolean inArea(int[][] image, int x, int y) { return x >= 0 && x < image.length && y >= 0 && y < image[0].length; } 只要你能够理解这段代码,⼀定要给你⿎掌,给你 99 分,因为你对「框架 思维」的掌控已经炉⽕纯⻘,此算法已经 cover 了 99% 的情况,仅有⼀个 细节问题没有解决,就是当 origColor 和 newColor 相同时,会陷⼊⽆限递 归。 ⼆、研究细节 为什么会陷⼊⽆限递归呢,很好理解,因为每个坐标都要搜索上下左右,那 么对于⼀个坐标,⼀定会被上下左右的坐标搜索。被重复搜索时,必须保证 递归函数能够能正确地退出,否则就会陷⼊死循环。 为什么 newColor 和 origColor 不同时可以正常退出呢?把算法流程画个图理 解⼀下: 361
362. FloodFill算法详解及应⽤ 可以看到,fill(1, 1) 被重复搜索了,我们⽤ fill(1, 1) 表⽰这次重复搜索。 fill(1, 1) 执⾏时,(1, 1) 已经被换成了 newColor,所以 fill(1, 1)* 会在这个 if 语句被怼回去,正确退出了。 // 碰壁:遇到其他颜⾊,超出 origColor 区域 if (image[x][y] != origColor) return; 但是,如果说 origColor 和 newColor ⼀样,这个 if 语句就⽆法让 fill(1, 1)* 正确退出,⽽是开启了下⾯的重复递归,形成了死循环。 362
363. FloodFill算法详解及应⽤ 三、处理细节 如何避免上述问题的发⽣,最容易想到的就是⽤⼀个和 image ⼀样⼤⼩的⼆ 维 bool 数组记录⾛过的地⽅,⼀旦发现重复⽴即 return。 // 出界:超出边界索引 if (!inArea(image, x, y)) return; // 碰壁:遇到其他颜⾊,超出 origColor 区域 if (image[x][y] != origColor) return; // 不⾛回头路 if (visited[x][y]) return; visited[x][y] = true; image[x][y] = newColor; 完全 OK,这也是处理「图」的⼀种常⽤⼿段。不过对于此题,不⽤开数 组,我们有⼀种更好的⽅法,那就是回溯算法。 前⽂「回溯算法详解」讲过,这⾥不再赘述,直接套回溯算法框架: void fill(int[][] image, int x, int y, int origColor, int newColor) { // 出界:超出数组边界 if (!inArea(image, x, y)) return; // 碰壁:遇到其他颜⾊,超出 origColor 区域 if (image[x][y] != origColor) return; 363
364. FloodFill算法详解及应⽤ // 已探索过的 origColor 区域 if (image[x][y] == -1) return; // choose:打标记,以免重复 image[x][y] = -1; fill(image, x, y + 1, origColor, newColor); fill(image, x, y - 1, origColor, newColor); fill(image, x - 1, y, origColor, newColor); fill(image, x + 1, y, origColor, newColor); // unchoose:将标记替换为 newColor image[x][y] = newColor; } 这种解决⽅法是最常⽤的,相当于使⽤⼀个特殊值 -1 代替 visited 数组的作 ⽤,达到不⾛回头路的效果。为什么是 -1,因为题⽬中说了颜⾊取值在 0 65535 之间,所以 -1 ⾜够特殊,能和颜⾊区分开。 四、拓展延伸:⾃动魔棒⼯具和扫雷 ⼤部分图⽚编辑软件⼀定有「⾃动魔棒⼯具」这个功能:点击⼀个地⽅,帮 你⾃动选中相近颜⾊的部分。如下图,我想选中⽼鹰,可以先⽤⾃动魔棒选 中蓝天背景,然后反向选择,就选中了⽼鹰。我们来分析⼀下⾃动魔棒⼯具 的原理。 显然,这个算法肯定是基于 FloodFill 算法的,但有两点不同:⾸先,背景 ⾊是蓝⾊,但不能保证都是相同的蓝⾊,毕竟是像素点,可能存在⾁眼⽆法 分辨的深浅差异,⽽我们希望能够忽略这种细微差异。第⼆,FloodFill 算法 是「区域填充」,这⾥更像「边界填充」。 364
365. FloodFill算法详解及应⽤ 对于第⼀个问题,很好解决,可以设置⼀个阈值 threshold,在阈值范围内波 动的颜⾊都视为 origColor: if (Math.abs(image[x][y] - origColor) > threshold) return; 对于第⼆个问题,我们⾸先明确问题:不要把区域内所有 origColor 的都染 ⾊,⽽是只给区域最外圈染⾊。然后,我们分析,如何才能仅给外围染⾊, 即如何才能找到最外围坐标,最外围坐标有什么特点? 可以发现,区域边界上的坐标,⾄少有⼀个⽅向不是 origColor,⽽区域内 部的坐标,四⾯都是 origColor,这就是解决问题的关键。保持框架不变, 使⽤ visited 数组记录已搜索坐标,主要代码如下: int fill(int[][] image, int x, int y, int origColor, int newColor) { // 出界:超出数组边界 if (!inArea(image, x, y)) return 0; // 已探索过的 origColor 区域 if (visited[x][y]) return 1; // 碰壁:遇到其他颜⾊,超出 origColor 区域 if (image[x][y] != origColor) return 0; visited[x][y] = true; int surround = 365
366. FloodFill算法详解及应⽤ fill(image, x - 1, y, origColor, newColor) + fill(image, x + 1, y, origColor, newColor) + fill(image, x, y - 1, origColor, newColor) + fill(image, x, y + 1, origColor, newColor); if (surround < 4) image[x][y] = newColor; return 1; } 这样,区域内部的坐标探索四周后得到的 surround 是 4,⽽边界的坐标会遇 到其他颜⾊,或超出边界索引,surround 会⼩于 4。如果你对这句话不理 解,我们把逻辑框架抽象出来看: int fill(int[][] image, int x, int y, int origColor, int newColor) { // 出界:超出数组边界 if (!inArea(image, x, y)) return 0; // 已探索过的 origColor 区域 if (visited[x][y]) return 1; // 碰壁:遇到其他颜⾊,超出 origColor 区域 if (image[x][y] != origColor) return 0; // 未探索且属于 origColor 区域 if (image[x][y] == origColor) { // ... return 1; } } 这 4 个 if 判断涵盖了 (x, y) 的所有可能情况,surround 的值由四个递归函数 相加得到,⽽每个递归函数的返回值就这四种情况的⼀种。借助这个逻辑框 架,你⼀定能理解上⾯那句话了。 这样就实现了仅对 origColor 区域边界坐标染⾊的⽬的,等同于完成了魔棒 ⼯具选定区域边界的功能。 366
367. FloodFill算法详解及应⽤ 这个算法有两个细节问题,⼀是必须借助 visited 来记录已探索的坐标,⽽ ⽆法使⽤回溯算法;⼆是开头⼏个 if 顺序不可打乱。读者可以思考⼀下原 因。 同理,思考扫雷游戏,应⽤ FloodFill 算法展开空⽩区域的同时,也需要计 算并显⽰边界上雷的个数,如何实现的?其实也是相同的思路,遇到雷就返 回 true,这样 surround 变量存储的就是雷的个数。当然,扫雷的 FloodFill 算法不能只检查上下左右,还得加上四个斜向。 以上详细讲解了 FloodFill 算法的框架设计,⼆维矩阵中的搜索问题,都逃 不出这个算法框架。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 367
368. FloodFill算法详解及应⽤ 368
369. 区间调度之区间合并问题 区间调度问题之区间合并 学算法,认准 labuladong 就够了! 上篇⽂章⽤贪⼼算法解决了区间调度问题:给你很多区间,让你求其中的最 ⼤不重叠⼦集。 其实对于区间相关的问题,还有很多其他类型,本⽂就来讲讲区间合并问题 (Merge Interval)。 LeetCode 第 56 题就是⼀道相关问题,题⽬很好理解: 我们解决区间问题的⼀般思路是先排序,然后观察规律。 ⼀、思路 369
370. 区间调度之区间合并问题 ⼀个区间可以表⽰为 [start, end] ,前⽂聊的区间调度问题,需要按 排序,以便满⾜贪⼼选择性质。⽽对于区间合并问题,其实按 start 排序都可以,不过为了清晰起⻅,我们选择按 显然,对于⼏个相交区间合并后的结果区间 区间中 start 最⼩的, x.end x , start x.start ⼀定是这些相交区间中 end end 和 排序。 ⼀定是这些相交 end 最⼤的。 370
371. 区间调度之区间合并问题 由于已经排了序, x.start 很好确定,求 x.end 也很容易,可以类⽐在数 组中找最⼤值的过程: int max_ele = arr[0]; for (int i = 1; i < arr.length; i++) max_ele = max(max_ele, arr[i]); return max_ele; ⼆、代码 # intervals 形如 [[1,3],[2,6]...] def merge(intervals): if not intervals: return [] # 按区间的 start 升序排列 intervals.sort(key=lambda intv: intv[0]) res = [] res.append(intervals[0]) for i in range(1, len(intervals)): curr = intervals[i] # res 中最后⼀个元素的引⽤ last = res[-1] if curr[0] <= last[1]: # 找到最⼤的 end last[1] = max(last[1], curr[1]) else: # 处理下⼀个待合并区间 res.append(curr) return res 看下动画就⼀⽬了然了: 【PDF格式⽆法显⽰GIF⽂件 mergeInterval/3.gif,可移步公众号查看】 ⾄此,区间合并问题就解决了。本⽂篇幅短⼩,因为区间合并只是区间问题 的⼀个类型,后续还有⼀些区间问题。本想把所有问题类型都总结在⼀篇⽂ 章,但有读者反应,⻓⽂只会收藏不会看... 所以还是分成⼩短⽂吧,读者有 什么看法可以在留⾔板留⾔交流。 371
372. 区间调度之区间合并问题 本⽂终,希望对你有帮助。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 372
373. 区间调度之区间交集问题 区间交集问题 学算法,认准 labuladong 就够了! 本⽂是区间系列问题的第三篇,前两篇分别讲了区间的最⼤不相交⼦集和重 叠区间的合并,今天再写⼀个算法,可以快速找出两组区间的交集。 先看下题⽬,LeetCode 第 986 题就是这个问题: 题⽬很好理解,就是让你找交集,注意区间都是闭区间。 373
374. 区间调度之区间交集问题 思路 解决区间问题的思路⼀般是先排序,以便操作,不过题⽬说已经排好序了, 那么可以⽤两个索引指针在 A 和 B 中游⾛,把交集找出来,代码⼤概是 这样的: # A, B 形如 [[0,2],[5,10]...] def intervalIntersection(A, B): i, j = 0, 0 res = [] while i < len(A) and j < len(B): # ... j += 1 i += 1 return res 不难,我们先⽼⽼实实分析⼀下各种情况。 ⾸先,对于两个区间,我们⽤ [a1,a2] 和 [b1,b2] 表⽰在 A 和 B 中的 两个区间,那么什么情况下这两个区间没有交集呢: 只有这两种情况,写成代码的条件判断就是这样: 374
375. 区间调度之区间交集问题 if b2 < a1 or a2 < b1:'>b1: [a1,a2] 和 [b1,b2] ⽆交集 那么,什么情况下,两个区间存在交集呢?根据命题的否定,上⾯逻辑的否 命题就是存在交集的条件: # 不等号取反,or 也要变成 and if b2 >= a1 and a2 >= b1:'>b1: [a1,a2] 和 [b1,b2] 存在交集 接下来,两个区间存在交集的情况有哪些呢?穷举出来: 这很简单吧,就这四种情况⽽已。那么接下来思考,这⼏种情况下,交集是 否有什么共同点呢? 375
376. 区间调度之区间交集问题 我们惊奇地发现,交集区间是有规律的!如果交集区间是 c1=max(a1,b1) , c2=min(a2,b2) [c1,c2] ,那么 !这⼀点就是寻找交集的核⼼,我们把代 码更进⼀步: while i < len(A) and j < len(B): a1, a2 = A[i][0], A[i][1] b1, b2 = B[j][0], B[j][1] if b2 >= a1 and a2 >= b1: res.append([max(a1, b1), min(a2, b2)]) # ... 最后⼀步,我们的指针 i 和 j 肯定要前进(递增)的,什么时候应该前 进呢? 【PDF格式⽆法显⽰GIF⽂件 intersection/4.gif,可移步公众号查看】 结合动画⽰例就很好理解了,是否前进,只取决于 a2 和 b2 的⼤⼩关 系: while i < len(A) and j < len(B): # ... if b2 < a2: j += 1 376
377. 区间调度之区间交集问题 else:'>else: i += 1 代码 # A, B 形如 [[0,2],[5,10]...] def intervalIntersection(A, B): i, j = 0, 0 # 双指针 res = [] while i < len(A) and j < len(B): a1, a2 = A[i][0], A[i][1] b1, b2 = B[j][0], B[j][1] # 两个区间存在交集 if b2 >= a1 and a2 >= b1: # 计算出交集,加⼊ res res.append([max(a1, b1), min(a2, b2)]) # 指针前进 if b2 < a2: j += 1 else:'>else: i += 1 return res 总结⼀下,区间类问题看起来都⽐较复杂,情况很多难以处理,但实际上通 过观察各种不同情况之间的共性可以发现规律,⽤简洁的代码就能处理。 另外,区间问题没啥特别厉害的奇技淫巧,其操作也朴实⽆华,但其应⽤却 ⼗分⼴泛。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 377
378. 区间调度之区间交集问题 378
379. 信封嵌套问题 信封嵌套问题 学算法,认准 labuladong 就够了! 很多算法问题都需要排序技巧,其难点不在于排序本⾝,⽽是需要巧妙地排 序进⾏预处理,将算法问题进⾏转换,为之后的操作打下基础。 信封嵌套问题就需要先按特定的规则排序,之后就转换为⼀个 最⻓递增⼦ 序列问题,可以⽤前⽂ ⼆分查找详解 的技巧来解决了。 ⼀、题⽬概述 信封嵌套问题是个很有意思且经常出现在⽣活中的问题,先看下题⽬: 这道题⽬其实是最⻓递增⼦序列(Longes Increasing Subsequence,简写为 LIS)的⼀个变种,因为很显然,每次合法的嵌套是⼤的套⼩的,相当于找 ⼀个最⻓递增的⼦序列,其⻓度就是最多能嵌套的信封个数。 379
380. 信封嵌套问题 但是难点在于,标准的 LIS 算法只能在数组中寻找最⻓⼦序列,⽽我们的信 封是由 (w, h) 这样的⼆维数对形式表⽰的,如何把 LIS 算法运⽤过来 呢? 读者也许会想,通过 w × h 计算⾯积,然后对⾯积进⾏标准的 LIS 算法。 但是稍加思考就会发现这样不⾏,⽐如 1 × 10 ⼤于 3 × 3 ,但是显然这 样的两个信封是⽆法互相嵌套的。 ⼆、解法 这道题的解法是⽐较巧妙的: 先对宽度 w 进⾏升序排序,如果遇到 序排序。之后把所有的 h w 相同的情况,则按照⾼度 h 降 作为⼀个数组,在这个数组上计算 LIS 的⻓度就 是答案。 画个图理解⼀下,先对这些数对进⾏排序: 380
381. 信封嵌套问题 然后在 h 上寻找最⻓递增⼦序列: 381
382. 信封嵌套问题 这个⼦序列就是最优的嵌套⽅案。 382
383. 信封嵌套问题 这个解法的关键在于,对于宽度 w 相同的数对,要对其⾼度 h 进⾏降序 排序。因为两个宽度相同的信封不能相互包含的,逆序排序保证在 w 相同 的数对中最多只选取⼀个。 下⾯看代码: // envelopes = [[w, h], [w, h]...] public int maxEnvelopes(int[][] envelopes) { int n = envelopes.length; // 按宽度升序排列,如果宽度⼀样,则按⾼度降序排列 Arrays.sort(envelopes, new Comparator() { public int compare(int[] a, int[] b) { return a[0] == b[0] ? b[1] - a[1] : a[0] - b[0]; } }); // 对⾼度数组寻找 LIS int[] height = new int[n]; for (int i = 0; i < n; i++) height[i] = envelopes[i][1]; return lengthOfLIS(height); } 关于最⻓递增⼦序列的寻找⽅法,在前⽂中详细介绍了动态规划解法,并⽤ 扑克牌游戏解释了⼆分查找解法,本⽂就不展开了,直接套⽤算法模板: /* 返回 nums 中 LIS 的⻓度 */ public int lengthOfLIS(int[] nums) { int piles = 0, n = nums.length; int[] top = new int[n]; for (int i = 0; i < n; i++) { // 要处理的扑克牌 int poker = nums[i]; int left = 0, right = piles; // ⼆分查找插⼊位置 while (left < right) { int mid = (left + right) / 2; if (top[mid] >= poker) 383
384. 信封嵌套问题 right = mid; else left = mid + 1; } if (left == piles) piles++; // 把这张牌放到牌堆顶 top[left] = poker; } // 牌堆数就是 LIS ⻓度 return piles; } 为了清晰,我将代码分为了两个函数, 你也可以合并,这样可以节省下 height 数组的空间。 此算法的时间复杂度为 O(NlogN) ,因为排序和计算 LIS 各需要 O(NlogN) 的时间。 空间复杂度为 O(N) ,因为计算 LIS 的函数中需要⼀个 top 数组。 三、总结 这个问题是个 Hard 级别的题⽬,难就难在排序,正确地排序后此问题就被 转化成了⼀个标准的 LIS 问题,容易解决⼀些。 其实这种问题还可以拓展到三维,⽐如说现在不是让你嵌套信封,⽽是嵌套 箱⼦,每个箱⼦有⻓宽⾼三个维度,请你算算最多能嵌套⼏个箱⼦? 我们可能会这样想,先把前两个维度(⻓和宽)按信封嵌套的思路求⼀个嵌 套序列,最后在这个序列的第三个维度(⾼度)找⼀下 LIS,应该能算出答 案。 实际上,这个思路是错误的。这类问题叫做「偏序问题」,上升到三维会使 难度巨幅提升,需要借助⼀种⾼级数据结构「树状数组」,有兴趣的读者可 以⾃⾏搜索。 有很多算法问题都需要排序后进⾏处理,阿东正在进⾏整理总结。希望本⽂ 对你有帮助。 384
385. 信封嵌套问题 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 385
386. ⼏个反直觉的概率问题 ⼏个反直觉的概率问题 学算法,认准 labuladong 就够了! 上篇⽂章 洗牌算法详解 讲到了验证概率算法的蒙特卡罗⽅法,今天聊点轻 松的内容:⼏个和概率相关的有趣问题。 计算概率有下⾯两个最简单的原则: 原则⼀、计算概率⼀定要有⼀个参照系,称作「样本空间」,即随机事件可 能出现的所有结果。事件 A 发⽣的概率 = A 包含的样本点 / 样本空间的样 本总数。 原则⼆、计算概率⼀定要明⽩,概率是⼀个连续的整体,不可以把连续的概 率分割开,也就是所谓的条件概率。 上述两个原则⾼中就学过,但是我们还是很容易犯错,⽽且犯错的流程也有 异曲同⼯之妙: 先是忽略了原则⼆,错误地计算了样本空间,然后通过原则⼀算出了错误的 答案。 下⾯介绍⼏个简单却具有迷惑性的问题,分别是男孩⼥孩问题、⽣⽇悖论、 三门问题。当然,三门问题可能是⼤家最⽿熟的,所以就多说⼀些有趣的思 考。 ⼀、男孩⼥孩问题 假设有⼀个家庭,有两个孩⼦,现在告诉你其中有⼀个男孩,请问另⼀个也 是男孩的概率是多少? 很多⼈,包括我在内,不假思索地回答:1/2 啊,因为另⼀个孩⼦要么是男 孩,要么是⼥孩,⽽且概率相等呀。但是实际上,答案是 1/3。 386
387. ⼏个反直觉的概率问题 上述思想为什么错误呢?因为没有正确计算样本空间,导致原则⼀计算错 误。有两个孩⼦,那么样本空间为 4,即哥哥妹妹,哥哥弟弟,姐姐妹妹, 姐姐弟弟这四种情况。已知有⼀个男孩,那么排除姐姐妹妹这种情况,所以 样本空间变成 3。另⼀个孩⼦也是男孩只有哥哥弟弟这 1 种情况,所以概率 为 1/3。 为什么计算样本空间会出错呢?因为我们忽略了条件概率,即混淆了下⾯两 个问题: 这个家庭只有⼀个孩⼦,这个孩⼦是男孩的概率是多少? 这个家庭有两个孩⼦,其中⼀个是男孩,另⼀个孩⼦是男孩的概率是多少? 根据原则⼆,概率问题是连续的,不可以把上述两个问题混淆。第⼆个问题 需要⽤条件概率,即求⼀个孩⼦是男孩的条件下,另⼀个也是男孩的概率。 运⽤条件概率的公式也很好算,就不多说了。 通过这个问题,读者应该理解两个概率计算原则的关系了,最具有迷惑性的 就是条件概率的忽视。为了不要被迷惑,最简单的办法就是把所有可能结果 穷举出来。 最后,对于此问题我⻅过⼀个很奇葩的质疑:如果这两个孩⼦是双胞胎,不 存在年龄上的差异怎么办? 我竟然觉得有那么⼀丝道理!但其实,我们只是通过年龄差异来表⽰两个孩 ⼦的独⽴性,也就是说即便两个孩⼦同性,也有两种可能。所以不要⽤双胞 胎抬杠了。 ⼆、⽣⽇悖论 ⽣⽇悖论是由这样⼀个问题引出的:⼀个屋⼦⾥需要有多少⼈,才能使得存 在⾄少两个⼈⽣⽇是同⼀天的概率达到 50%? 答案是 23 个⼈,也就是说房⼦⾥如果有 23 个⼈,那么就有 50% 的概率会 存在两个⼈⽣⽇相同。这个结论看起来不可思议,所以被称为悖论。按照直 觉,要得到 50% 的概率,起码得有 183 个⼈吧,因为⼀年有 365 天呀?其 387
388. ⼏个反直觉的概率问题 实不是的,觉得这个结论不可思议主要有两个思维误区: 第⼀个误区是误解「存在」这个词的含义。 读者可能认为,如果 23 个⼈中出现相同⽣⽇的概率就能达到 50%,是不是 意味着: 假设现在屋⼦⾥坐着 22 个⼈,然后我⾛进去,那么有 50% 的概率我可以找 到⼀个⼈和我⽣⽇相同。但这怎么可能呢? 并不是的,你这种想法是以⾃我为中⼼,⽽题⽬的概率是在描述整体。也就 是说「存在」的含义是指 23 ⼈中的任意两个⼈,涉及排列组合,⼤概率和 你没啥关系。 如果你⾮要计算存在和⾃⼰⽣⽇相同的⼈的概率是多少,可以这样计算: 1 - P(22 个⼈都和我的⽣⽇不同) = 1 -(364/365)^22 = 0.06 这样计算得到的结果是不是看起来合理多了?⽣⽇悖论计算对象的不是某⼀ 个⼈,⽽是⼀个整体,其中包含了所有⼈的排列组合,它们的概率之和当然 会⼤得多。 第⼆个误区是认为概率是线性变化的。 读者可能认为,如果 23 个⼈中出现相同⽣⽇的概率就能达到 50%,是不是 意味着 46 个⼈的概率就能达到 100%? 不是的,就像中奖率 50% 的游戏,你玩两次的中奖率就是 100% 吗?显然 不是,你玩两次的中奖率是 75%: P(两次能中奖) = P(第⼀次就中了) + P(第⼀次没中但第⼆次中了) = 1/2 + 1/2*1/2 = 75% 那么换到⽣⽇悖论也是⼀个道理,概率不是简单叠加,⽽要考虑⼀个连续的 过程,所以这个结论并没有什么不合常理之处。 那为什么只要 23 个⼈出现相同⽣⽇的概率就能⼤于 50% 了呢?我们先计算 23 个⼈⽣⽇都唯⼀(不重复)的概率。只有 1 个⼈的时候,⽣⽇唯⼀的概 率是 365/365 ,2 个⼈时,⽣⽇唯⼀的概率是 365/365 × 364/365 ,以此类 388
389. ⼏个反直觉的概率问题 推可知 23 ⼈的⽣⽇都唯⼀的概率: 算出来⼤约是 0.493,所以存在相同⽣⽇的概率就是 0.507,差不多就是 50% 了。实际上,按照这个算法,当⼈数达到 70 时,存在两个⼈⽣⽇相同 的概率就上升到了 99.9%,基本可以认为是 100% 了。所以从概率上说,⼀ 个⼏⼗⼈的⼩团体中存在⽣⽇相同的⼈真没啥稀奇的。 三、三门问题 这个游戏很经典了:游戏参与者⾯对三扇门,其中两扇门后⾯是⼭⽺,⼀扇 门后⾯是跑⻋。参与者只要随便选⼀扇门,门后⾯的东⻄就归他(跑⻋的价 值当然更⼤)。但是主持⼈决定帮⼀下参与者:在他选择之后,先不急着打 开这扇门,⽽是由主持⼈打开剩下两扇门中的⼀扇,展⽰其中的⼭⽺(主持 ⼈知道每扇门后⾯是什么),然后给参与者⼀次换门的机会,此时参与者应 该换门还是不换门呢? 为了防⽌第⼀次看到这个问题的读者迷惑,再具体描述⼀下这个问题: 你是游戏参与者,现在有门 1,2,3,假设你随机选择了门 1,然后主持⼈打开 了门 3 告诉你那后⾯是⼭⽺。现在,你是坚持你最初的选择门 1,还是选择 换成门 2 呢? 答案是应该换门,换门之后抽到跑⻋的概率是 2/3,不换的话是 1/3。⼜⼀ 次反直觉,感觉换不换的中奖概率应该都⼀样啊,因为最后肯定就剩两个 门,⼀个是⽺,⼀个是跑⻋,这是事实,所以不管选哪个的概率不都是 1/2 389
390. ⼏个反直觉的概率问题 吗? 类似前⾯说的男孩⼥孩问题,最简单稳妥的⽅法就是把所有可能结果穷举出 来: 很容易看到选择换门中奖的概率是 2/3,不换的话是 1/3。 关于这个问题还有更简单的⽅法:主持⼈开门实际上在「浓缩」概率。⼀开 始你选择到跑⻋的概率当然是 1/3,剩下两个门中包含跑⻋的概率当然是 2/3,这没啥可说的。但是主持⼈帮你排除了⼀个含有⼭⽺的门,相当于把 那 2/3 的概率浓缩到了剩下的这⼀扇门上。那么,你说你是抱着原来那扇 1/3 的门,还是换成那扇经过「浓缩」的 2/3 概率的门呢? 再直观⼀点,假设你三选⼀,剩下 2 扇门,再给你加⼊ 98 扇装⼭⽺的门, 把这 100 扇门随机打乱,问你换不换?肯定不换对吧,这明摆着把概率稀释 了,肯定抱着原来的那扇门是最可能中跑⻋的。再假设,初始有 100 扇门, 你选了⼀扇,然后主持⼈在剩下 99 扇门中帮你排除 98 个⼭⽺,问你换不换 390
391. ⼏个反直觉的概率问题 ⼀扇门?肯定换对吧,你⼿上那扇门是 1%,另⼀扇门是 99%,或者也可以 这样理解,不换只是选择了 1 扇门,换门相当于选择了 99 扇门,这样结果 很明显了吧? 以上思想,也许有的读者都思考过,下⾯我们思考这样⼀个问题:假设你在 决定是否换门的时候,⼩明破门⽽⼊,要求帮你做出选择。他完全不知道之 前发⽣的事,他只知道⾯前有两扇门,⼀扇是跑⻋⼀扇是⼭⽺,那么他抽中 跑⻋的概率是多⼤? 当然是 1/2,这也是很多⼈做错三门问题的根本原因。类似⽣⽇悖论,⼈们 总是容易以⾃我为中⼼,通过这个⼩明的视⾓来计算是否换门,这显然会进 ⼊误区。 就好⽐有两个箱⼦,⼀号箱⼦有 4 个⿊球 2 个红球,⼆号箱⼦有 2 个⿊球 4 个红球,随便选⼀个箱⼦,随便摸⼀个球,问你摸出红球的概率。 对于不知情的⼩明,他会随机选择⼀个箱⼦,随机摸球,摸到红球的概率 是:1/2 × 2/6 + 1/2 × 4/6 = 1/2 对于知情的你,你知道在⼆号箱⼦摸球概率⼤,所以只在⼆号箱摸,摸到红 球的概率是:0 × 2/6 + 1 × 4/6 = 2/3 三门问题是有指导意义的。⽐如你蒙选择题,先蒙了 A,后来灵机⼀动排除 了 B 和 C,请问你是否要把 A 换成 D?答案是,换! 也许读者会问,如果只排除了⼀个答案,⽐如说 B,那么我是否应该把 A 换成 C 或者 D 呢?答案是,换! 因为按照刚才「浓缩」概率这个思想,只要进⾏了排除,都是在进⾏「浓 缩」,均摊下来肯定⽐你⼀开始蒙的那个答案概率 1/4 ⾼。⽐如刚才的例 ⼦,C 和 D 的正确概率都是 3/8,⽽你开始蒙的 A 只有 1/4。 当然,运⽤此策略蒙题的前提是你真的抓瞎,真的随机乱选答案,这样概率 才能作为最后的杀⼿锏。 _____________ 391
392. ⼏个反直觉的概率问题 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 392
393. 第四章、⾼频⾯试系列 ⾼频⾯试系列 学算法,认准 labuladong 就够了! 8 说了,本章都是⾼频⾯试题,配合前⾯的动态规划系列,祝各位⻢到成 功! 欢迎关注我的公众号 labuladong,⽅便获得最新的优质⽂章: 393
394. 如何⽤ BFS 算法秒杀各种智⼒题 BFS 算法秒杀各种益智游戏 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 773.滑动谜题 滑动拼图游戏⼤家应该都玩过,下图是⼀个 4x4 的滑动拼图: 394
395. 如何⽤ BFS 算法秒杀各种智⼒题 拼图中有⼀个格⼦是空的,可以利⽤这个空着的格⼦移动其他数字。你需要 通过移动这些数字,得到某个特定排列顺序,这样就算赢了。 我⼩时候还玩过⼀款叫做「华容道」的益智游戏,也和滑动拼图⽐较类似: 395
396. 如何⽤ BFS 算法秒杀各种智⼒题 那么这种游戏怎么玩呢?我记得是有⼀些套路的,类似于魔⽅还原公式。但 是我们今天不来研究让⼈头秃的技巧,这些益智游戏通通可以⽤暴⼒搜索算 法解决,所以今天我们就学以致⽤,⽤ BFS 算法框架来秒杀这些游戏。 396
397. 如何⽤ BFS 算法秒杀各种智⼒题 _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 397
398. 如何⾼效寻找素数 如何⾼效寻找素数 学算法,认准 labuladong 就够了! 素数的定义看起来很简单,如果⼀个数如果只能被 1 和它本⾝整除,那么这 个数就是素数。 不要觉得素数的定义简单,恐怕没多少⼈真的能把素数相关的算法写得⾼ 效。⽐如让你写这样⼀个函数: // 返回区间 [2, n) 中有⼏个素数 int countPrimes(int n) // ⽐如 countPrimes(10) 返回 4 // 因为 2,3,5,7 是素数 你会如何写这个函数?我想⼤家应该会这样写: int countPrimes(int n) { int count = 0; for (int i = 2; i < n; i++) if (isPrim(i)) count++; return count; } // 判断整数 n 是否是素数 boolean isPrime(int n) { for (int i = 2; i < n; i++) if (n % i == 0) // 有其他整除因⼦ return false; return true; } 398
399. 如何⾼效寻找素数 这样写的话时间复杂度 O(n^2),问题很⼤。⾸先你⽤ isPrime 函数来辅助的 思路就不够⾼效;⽽且就算你要⽤ isPrime 函数,这样写算法也是存在计算 冗余的。 先来简单说下如果你要判断⼀个数是不是素数,应该如何写算法。只需稍微 修改⼀下上⾯的 isPrim 代码中的 for 循环条件: boolean isPrime(int n) { for (int i = 2; i * i <= n; i++) ... } 换句话说, 不需要遍历到 i 我们举个例⼦,假设 n = 12 n ,⽽只需要到 sqrt(n) 即可。为什么呢, 。 12 = 2 × 6 12 = 3 × 4 12 = sqrt(12) × sqrt(12) 12 = 4 × 3 12 = 6 × 2 可以看到,后两个乘积就是前⾯两个反过来,反转临界点就在 换句话说,如果在 以直接断定 n [2,sqrt(n)] sqrt(n) 。 这个区间之内没有发现可整除因⼦,就可 是素数了,因为在区间 [sqrt(n),n] 也⼀定不会发现可整 除因⼦。 现在, isPrime countPrimes sqrt(n) 函数的时间复杂度降为 O(sqrt(N)),但是我们实现 函数其实并不需要这个函数,以上只是希望读者明⽩ 的含义,因为等会还会⽤到。 ⾼效实现 countPrimes ⾼效解决这个问题的核⼼思路是和上⾯的常规思路反着来: 399
400. 如何⾼效寻找素数 ⾸先从 2 开始,我们知道 2 是⼀个素数,那么 2 × 2 = 4, 3 × 2 = 6, 4 × 2 = 8... 都不可能是素数了。 然后我们发现 3 也是素数,那么 3 × 2 = 6, 3 × 3 = 9, 3 × 4 = 12... 也都不可能 是素数了。 看到这⾥,你是否有点明⽩这个排除法的逻辑了呢?先看我们的第⼀版代 码: int countPrimes(int n) { boolean[] isPrim = new boolean[n]; // 将数组都初始化为 true Arrays.fill(isPrim, true); for (int i = 2; i < n; i++) if (isPrim[i]) // i 的倍数不可能是素数了 for (int j = 2 * i; j < n; j += i) isPrim[j] = false; int count = 0; for (int i = 2; i < n; i++) if (isPrim[i]) count++; return count; } 如果上⾯这段代码你能够理解,那么你已经掌握了整体思路,但是还有两个 细微的地⽅可以优化。 ⾸先,回想刚才判断⼀个数是否是素数的 性,其中的 for 循环只需要遍历 isPrime [2,sqrt(n)] 们外层的 for 循环也只需要遍历到 sqrt(n) 函数,由于因⼦的对称 就够了。这⾥也是类似的,我 : for (int i = 2; i * i < n; i++) if (isPrim[i]) ... 400
401. 如何⾼效寻找素数 除此之外,很难注意到内层的 for 循环也可以优化。我们之前的做法是: for (int j = 2 * i; j < n; j += i) isPrim[j] = false; 这样可以把 i ⽐如 , n = 25 的整数倍都标记为 i = 4 这两个数字已经被 false ,但是仍然存在计算冗余。 时算法会标记 4 × 2 = 8,4 × 3 = 12 等等数字,但是 i = 2 和 我们可以稍微优化⼀下,让 的 2 × 4 和 3 × 4 标记了。 i = 3 j 从 i 的平⽅开始遍历,⽽不是从 2 * i 开始: for (int j = i * i; j < n; j += i) isPrim[j] = false; 这样,素数计数的算法就⾼效实现了,其实这个算法有⼀个名字,叫做 Sieve of Eratosthenes。看下完整的最终代码: int countPrimes(int n) { boolean[] isPrim = new boolean[n]; Arrays.fill(isPrim, true); for (int i = 2; i * i < n; i++) if (isPrim[i]) for (int j = i * i; j < n; j += i) isPrim[j] = false; int count = 0; for (int i = 2; i < n; i++) if (isPrim[i]) count++; return count; } 该算法的时间复杂度⽐较难算,显然时间跟这两个嵌套的 for 循环有关,其 操作数应该是: 401
402. 如何⾼效寻找素数 n/2 + n/3 + n/5 + n/7 + ... = n × (1/2 + 1/3 + 1/5 + 1/7...) 括号中是素数的倒数。其最终结果是 O(N * loglogN),有兴趣的读者可以查 ⼀下该算法的时间复杂度证明。 以上就是素数算法相关的全部内容。怎么样,是不是看似简单的问题却有不 少细节可以打磨呀? _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 402
403. 如何⾼效进⾏模幂运算 快速模幂算法 学算法,认准 labuladong 就够了! 今天来聊⼀道与数学运算有关的题⽬,LeetCode 372 题 Super Pow,让你进 ⾏巨⼤的幂运算,然后求余数。 int superPow(int a, vector& b); 要求你的算法返回幂运算 a^b 的计算结果与 1337 取模(mod,也就是余 数)后的结果。就是你先得计算幂 a^b ,但是这个 b 会⾮常⼤,所以 b 是⽤数组的形式表⽰的。 这个算法其实就是⼴泛应⽤于离散数学的模幂算法,⾄于为什么要对 1337 求模我们不管,单就这道题可以有三个难点: ⼀是如何处理⽤数组表⽰的指数,现在 b 是⼀个数组,也就是说 b 可以 ⾮常⼤,没办法直接转成整型,否则可能溢出。你怎么把这个数组作为指 数,进⾏运算呢? ⼆是如何得到求模之后的结果?按道理,起码应该先把幂运算结果算出来, 然后做 % 1337 这个运算。但问题是,指数运算你懂得,真实结果肯定会 ⼤得吓⼈,也就是说,算出来真实结果也没办法表⽰,早都溢出报错了。 三是如何⾼效进⾏幂运算,进⾏幂运算也是有算法技巧的,如果你不了解这 个算法,后⽂会讲解。 那么对于这⼏个问题,我们分开思考,逐个击破。 如何处理数组指数 ⾸先明确问题:现在 b 是⼀个数组,不能表⽰成整型,⽽且数组的特点是 随机访问,删除最后⼀个元素⽐较⾼效。 403
404. 如何⾼效进⾏模幂运算 不考虑求模的要求,以 b = [1,5,6,4] 来举例,结合指数运算的法则,我 们可以发现这样的⼀个规律: 看到这,我们的⽼读者肯定已经敏感地意识到了,这就是递归的标志呀!因 为问题的规模缩⼩了: superPow(a, [1,5,6,4]) => superPow(a, [1,5,6]) 那么,发现了这个规律,我们可以先简单翻译出代码框架: // 计算 a 的 k 次⽅的结果 // 后⽂我们会⼿动实现 int mypow(int a, int k); int superPow(int a, vector& b) { // 递归的 base case if (b.empty()) return 1; // 取出最后⼀个数 int last = b.back(); b.pop_back(); // 将原问题化简,缩⼩规模递归求解 int part1 = mypow(a, last); int part2 = mypow(superPow(a, b), 10); // 合并出结果 return part1 * part2; } 404
405. 如何⾼效进⾏模幂运算 到这⾥,应该都不难理解吧!我们已经解决了 b 是⼀个数组的问题,现在 来看看如何处理 mod,避免结果太⼤⽽导致的整型溢出。 如何处理 mod 运算 ⾸先明确问题:由于计算机的编码⽅式,形如 (a * b) % base 这样的运 算,乘法的结果可能导致溢出,我们希望找到⼀种技巧,能够化简这种表达 式,避免溢出同时得到结果。 ⽐如在⼆分查找中,我们求中点索引时⽤ (l+r)/2 转化成 l+(r-l)/2 ,避 免溢出的同时得到正确的结果。 那么,说⼀个关于模运算的技巧吧,毕竟模运算在算法中⽐较常⻅: (a * b) % k = (a % k)(b % k) % k 证明很简单,假设: a = Ak +B;b = Ck + D 其中 A,B,C,D 是任意常数,那么: ab = ACk^2 + ADk + BCk +BD ab % k = BD % k ⼜因为: a % k = B;b % k = D 所以: (a % k)(b % k) % k = BD % k 综上,就可以得到我们化简求模的等式了。 换句话说,对乘法的结果求模,等价于先对每个因⼦都求模,然后对因⼦相 乘的结果再求模。 那么扩展到这道题,求⼀个数的幂不就是对这个数连乘么?所以说只要简单 扩展刚才的思路,即可给幂运算求模: 405
406. 如何⾼效进⾏模幂运算 int base = 1337; // 计算 a 的 k 次⽅然后与 base 求模的结果 int mypow(int a, int k) { // 对因⼦求模 a %= base; int res = 1; for (int _ = 0; _ < k; _++) { // 这⾥有乘法,是潜在的溢出点 res *= a; // 对乘法结果求模 res %= base; } return res; } int superPow(int a, vector& b) { if (b.empty()) return 1; int last = b.back(); b.pop_back(); int part1 = mypow(a, last); int part2 = mypow(superPow(a, b), 10); // 每次乘法都要求模 return (part1 * part2) % base; } 你看,先对因⼦ 证 res *= a a 求模,然后每次都对乘法结果 这句代码执⾏时两个因⼦都是⼩于 res base 求模,这样可以保 的,也就⼀定不会 造成溢出,同时结果也是正确的。 ⾄此,这个问题就已经完全解决了,已经可以通过 LeetCode 的判题系统 了。 但是有的读者可能会问,这个求幂的算法就这么简单吗,直接⼀个 for 循环 累乘就⾏了?复杂度会不会⽐较⾼,有没有更⾼效地算法呢? 有更⾼效地算法的,但是单就这道题来说,已经⾜够了。 406
407. 如何⾼效进⾏模幂运算 因为你想想,调⽤ mypow 函数传⼊的 k 最多有多⼤? k 不过是 中的⼀个数,也就是在 0 到 9 之间,所以可以说这⾥每次调⽤ 间复杂度就是 O(1)。整个算法的时间复杂度是 O(N),N 为 b b mypow 数组 的时 的⻓度。 但是既然说到幂运算了,不妨顺带说⼀下如何⾼效计算幂运算吧。 如何⾼效求幂 快速求幂的算法不⽌⼀个,就说⼀个我们应该掌握的基本思路吧。利⽤幂运 算的性质,我们可以写出这样⼀个递归式: 这个思想肯定⽐直接⽤ for 循环求幂要⾼效,因为有机会直接把问题规模 ( b 的⼤⼩)直接减⼩⼀半,该算法的复杂度肯定是 log 级了。 那么就可以修改之前的 mypow 函数,翻译这个递归公式,再加上求模的运 算: int base = 1337; int mypow(int a, int k) { if (k == 0) return 1; a %= base; if (k % 2 == 1) { // k 是奇数 return (a * mypow(a, k - 1)) % base; } else { // k 是偶数 int sub = mypow(a, k / 2); return (sub * sub) % base; } } 407
408. 如何⾼效进⾏模幂运算 虽然对于题⽬,这个优化没有啥特别明显的效率提升,但是这个求幂算法已 经升级了,以后如果别⼈让你写幂算法,起码要写出这个算法。 ⾄此,Super Pow 就算完全解决了,包括了递归思想以及处理模运算、幂运 算的技巧,可以说这个题⽬还是挺有意思的,你有什么有趣的题⽬,不妨留 ⾔分享⼀下。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 408
409. 如何运⽤⼆分查找算法 如何运⽤⼆分查找算法 学算法,认准 labuladong 就够了! ⼆分查找到底有能运⽤在哪⾥? 最常⻅的就是教科书上的例⼦,在有序数组中搜索给定的某个⽬标值的索 引。再推⼴⼀点,如果⽬标值存在重复,修改版的⼆分查找可以返回⽬标值 的左侧边界索引或者右侧边界索引。 PS:以上提到的三种⼆分查找算法形式在前⽂「⼆分查找详解」有代码详 解,如果没看过强烈建议看看。 抛开有序数组这个枯燥的数据结构,⼆分查找如何运⽤到实际的算法问题中 呢?当搜索空间有序的时候,就可以通过⼆分搜索「剪枝」,⼤幅提升效 率。 说起来⽞乎得很,本⽂先⽤⼀个具体的「Koko 吃⾹蕉」的问题来举个例 ⼦。 ⼀、问题分析 409
410. 如何运⽤⼆分查找算法 也就是说,Koko 每⼩时最多吃⼀堆⾹蕉,如果吃不下的话留到下⼀⼩时再 吃;如果吃完了这⼀堆还有胃⼝,也只会等到下⼀⼩时才会吃下⼀堆。在这 个条件下,让我们确定 Koko 吃⾹蕉的最⼩速度(根/⼩时)。 如果直接给你这个情景,你能想到哪⾥能⽤到⼆分查找算法吗?如果没有⻅ 过类似的问题,恐怕是很难把这个问题和⼆分查找联系起来的。 那么我们先抛开⼆分查找技巧,想想如何暴⼒解决这个问题呢? ⾸先,算法要求的是「 speed ,请问 speed H ⼩时内吃完⾹蕉的最⼩速度」,我们不妨称为 最⼤可能为多少,最少可能为多少呢? 显然最少为 1,最⼤为 max(piles) ,因为⼀⼩时最多只能吃⼀堆⾹蕉。那 么暴⼒解法就很简单了,只要从 1 开始穷举到 某个值可以在 H max(piles) ,⼀旦发现发现 ⼩时内吃完所有⾹蕉,这个值就是最⼩速度: 410
411. 如何运⽤⼆分查找算法 int minEatingSpeed(int[] piles, int H) { // piles 数组的最⼤值 int max = getMax(piles); for (int speed = 1; speed < max; speed++) { // 以 speed 是否能在 H ⼩时内吃完⾹蕉 if (canFinish(piles, speed, H)) return speed; } return max; } 注意这个 for 循环,就是在连续的空间线性搜索,这就是⼆分查找可以发挥 作⽤的标志。由于我们要求的是最⼩速度,所以可以⽤⼀个搜索左侧边界的 ⼆分查找来代替线性搜索,提升效率: int minEatingSpeed(int[] piles, int H) { // 套⽤搜索左侧边界的算法框架 int left = 1, right = getMax(piles) + 1; while (left < right) { // 防⽌溢出 int mid = left + (right - left) / 2; if (canFinish(piles, mid, H)) { right = mid; } else { left = mid + 1; } } return left; } PS:如果对于这个⼆分查找算法的细节问题有疑问,建议看下前⽂「⼆分 查找详解」搜索左侧边界的算法模板,这⾥不展开了。 剩下的辅助函数也很简单,可以⼀步步拆解实现: // 时间复杂度 O(N) boolean canFinish(int[] piles, int speed, int H) { int time = 0; for (int n : piles) { 411
412. 如何运⽤⼆分查找算法 time += timeOf(n, speed); } return time <= H; } int timeOf(int n, int speed) { return (n / speed) + ((n % speed > 0) ? 1 : 0); } int getMax(int[] piles) { int max = 0; for (int n : piles) max = Math.max(n, max); return max; } ⾄此,借助⼆分查找技巧,算法的时间复杂度为 O(NlogN)。 ⼆、扩展延伸 类似的,再看⼀道运输问题: 412
413. 如何运⽤⼆分查找算法 要在 D 天内运输完所有货物,货物不可分割,如何确定运输的最⼩载重呢 (下⽂称为 cap )? 其实本质上和 Koko 吃⾹蕉的问题⼀样的,⾸先确定 值分别为 max(weights) 和 sum(weights) cap 的最⼩值和最⼤ 。 我们要求最⼩载重,所以可以⽤搜索左侧边界的⼆分查找算法优化线性搜 索: // 寻找左侧边界的⼆分查找 int shipWithinDays(int[] weights, int D) { // 载重可能的最⼩值 int left = getMax(weights); // 载重可能的最⼤值 + 1 int right = getSum(weights) + 1; while (left < right) { 413
414. 如何运⽤⼆分查找算法 int mid = left + (right - left) / 2; if (canFinish(weights, D, mid)) { right = mid; } else { left = mid + 1; } } return left; } // 如果载重为 cap,是否能在 D 天内运完货物? boolean canFinish(int[] w, int D, int cap) { int i = 0; for (int day = 0; day < D; day++) { int maxCap = cap; while ((maxCap -= w[i]) >= 0) { i++; if (i == w.length) return true; } } return false; } 通过这两个例⼦,你是否明⽩了⼆分查找在实际问题中的应⽤? for (int i = 0; i < n; i++) if (isOK(i)) return ans; _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 414
415. 如何运⽤⼆分查找算法 415
416. 如何⾼效解决接⾬⽔问题 接⾬⽔问题详解 学算法,认准 labuladong 就够了! 接⾬⽔这道题⽬挺有意思,在⾯试题中出现频率还挺⾼的,本⽂就来步步优 化,讲解⼀下这道题。 先看⼀下题⽬: 就是⽤⼀个数组表⽰⼀个条形图,问你这个条形图最多能接多少⽔。 int trap(int[] height); 下⾯就来由浅⼊深介绍暴⼒解法 -> 备忘录解法 -> 双指针解法,在 O(N) 时 间 O(1) 空间内解决这个问题。 416
417. 如何⾼效解决接⾬⽔问题 ⼀、核⼼思路 我第⼀次看到这个问题,⽆计可施,完全没有思路,相信很多朋友跟我⼀ 样。所以对于这种问题,我们不要想整体,⽽应该去想局部;就像之前的⽂ 章处理字符串问题,不要考虑如何处理整个字符串,⽽是去思考应该如何处 理每⼀个字符。 这么⼀想,可以发现这道题的思路其实很简单。具体来说,仅仅对于位置 i,能装下多少⽔呢? 能装 2 格⽔。为什么恰好是两格⽔呢?因为 height[i] 的⾼度为 0,⽽这⾥最 多能盛 2 格⽔,2-0=2。 为什么位置 i 最多能盛 2 格⽔呢?因为,位置 i 能达到的⽔柱⾼度和其左边 的最⾼柱⼦、右边的最⾼柱⼦有关,我们分别称这两个柱⼦⾼度为 和 r_max ;位置 i 最⼤的⽔柱⾼度就是 min(l_max, r_max) l_max 。 更进⼀步,对于位置 i,能够装的⽔为: water[i] = min( # 左边最⾼的柱⼦ max(height[0..i]), # 右边最⾼的柱⼦ max(height[i..end]) ) - height[i] 417
418. 如何⾼效解决接⾬⽔问题 这就是本问题的核⼼思路,我们可以简单写⼀个暴⼒算法: int trap(vector& height) { int n = height.size(); int ans = 0; for (int i = 1; i < n - 1; i++) { int l_max = 0, r_max = 0; // 找右边最⾼的柱⼦ for (int j = i; j < n; j++) 418
419. 如何⾼效解决接⾬⽔问题 r_max = max(r_max, height[j]); // 找左边最⾼的柱⼦ for (int j = i; j >= 0; j--) l_max = max(l_max, height[j]); // 如果⾃⼰就是最⾼的话, // l_max == r_max == height[i] ans += min(l_max, r_max) - height[i]; } return ans; } 有之前的思路,这个解法应该是很直接粗暴的,时间复杂度 O(N^2),空间 复杂度 O(1)。但是很明显这种计算 和 l_max 的⽅式⾮常笨拙,⼀ 之前的暴⼒解法,不是在每个位置 i 都要计算 r_max 和 r_max 般的优化⽅法就是备忘录。 ⼆、备忘录优化 l_max 吗?我们直 接把结果都缓存下来,别傻不拉⼏的每次都遍历,这时间复杂度不就降下来 了嘛。 我们开两个数组 r_max 最⾼的柱⼦⾼度, 和 l_max r_max[i] 充当备忘录, l_max[i] 表⽰位置 i 左边 表⽰位置 i 右边最⾼的柱⼦⾼度。预先把这两 个数组计算好,避免重复计算: int trap(vector& height) { if (height.empty()) return 0; int n = height.size(); int ans = 0; // 数组充当备忘录 vector l_max(n), r_max(n); // 初始化 base case l_max[0] = height[0]; r_max[n - 1] = height[n - 1]; // 从左向右计算 l_max for (int i = 1; i < n; i++) l_max[i] = max(height[i], l_max[i - 1]); // 从右向左计算 r_max for (int i = n - 2; i >= 0; i--) 419
420. 如何⾼效解决接⾬⽔问题 r_max[i] = max(height[i], r_max[i + 1]); // 计算答案 for (int i = 1; i < n - 1; i++) ans += min(l_max[i], r_max[i]) - height[i]; return ans; } 这个优化其实和暴⼒解法差不多,就是避免了重复计算,把时间复杂度降低 为 O(N),已经是最优了,但是空间复杂度是 O(N)。下⾯来看⼀个精妙⼀些 的解法,能够把空间复杂度降低到 O(1)。 三、双指针解法 这种解法的思路是完全相同的,但在实现⼿法上⾮常巧妙,我们这次也不要 ⽤备忘录提前计算了,⽽是⽤双指针边⾛边算,节省下空间复杂度。 ⾸先,看⼀部分代码: int trap(vector& height) { int n = height.size(); int left = 0, right = n - 1; int l_max = height[0]; int r_max = height[n - 1]; while (left <= right) { l_max = max(l_max, height[left]); r_max = max(r_max, height[right]); left++; right--; } } 对于这部分代码,请问 很容易理解, l_max height[right..end] 是 l_max 和 r_max height[0..left] 分别表⽰什么意义呢? 中最⾼柱⼦的⾼度, r_max 是 的最⾼柱⼦的⾼度。 明⽩了这⼀点,直接看解法: 420
421. 如何⾼效解决接⾬⽔问题 int trap(vector& height) { if (height.empty()) return 0; int n = height.size(); int left = 0, right = n - 1; int ans = 0; int l_max = height[0]; int r_max = height[n - 1]; while (left <= right) { l_max = max(l_max, height[left]); r_max = max(r_max, height[right]); // ans += min(l_max, r_max) - height[i] if (l_max < r_max) { ans += l_max - height[left]; left++; } else { ans += r_max - height[right]; right--; } } return ans; } 你看,其中的核⼼思想和之前⼀模⼀样,换汤不换药。但是细⼼的读者可能 会发现次解法还是有点细节差异: 之前的备忘录解法, height[i..end] l_max[i] 和 r_max[i] 代表的是 height[0..i] 和 的最⾼柱⼦⾼度。 ans += min(l_max[i], r_max[i]) - height[i]; 421
422. 如何⾼效解决接⾬⽔问题 但是双指针解法中, height[right..end] l_max 和 r_max 代表的是 height[0..left] 和 的最⾼柱⼦⾼度。⽐如这段代码: if (l_max < r_max) { ans += l_max - height[left]; left++; } 422
423. 如何⾼效解决接⾬⽔问题 此时的 left l_max 是 left 指针左边的最⾼柱⼦,但是 并不⼀定是 r_max 指针右边最⾼的柱⼦,这真的可以得到正确答案吗? 其实这个问题要这么思考,我们只在乎 况,我们已经知道 l_max < r_max 的,不重要,重要的是 height[i] min(l_max, r_max) 了,⾄于这个 r_max 能够装的⽔只和 。对于上图的情 是不是右边最⼤ l_max 有关。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 423
424. 如何去除有序数组的重复元素 如何去除有序数组的重复元素 学算法,认准 labuladong 就够了! 我们知道对于数组来说,在尾部插⼊、删除元素是⽐较⾼效的,时间复杂度 是 O(1),但是如果在中间或者开头插⼊、删除元素,就会涉及数据的搬 移,时间复杂度为 O(N),效率较低。 所以对于⼀般处理数组的算法问题,我们要尽可能只对数组尾部的元素进⾏ 操作,以避免额外的时间复杂度。 这篇⽂章讲讲如何对⼀个有序数组去重,先看下题⽬: 显然,由于数组已经排序,所以重复的元素⼀定连在⼀起,找出它们并不 难,但如果毎找到⼀个重复元素就⽴即删除它,就是在数组中间进⾏删除操 作,整个时间复杂度是会达到 O(N^2)。⽽且题⽬要求我们原地修改,也就 是说不能⽤辅助数组,空间复杂度得是 O(1)。 424
425. 如何去除有序数组的重复元素 其实,对于数组相关的算法问题,有⼀个通⽤的技巧:要尽量避免在中间删 除元素,那我就想先办法把这个元素换到最后去。这样的话,最终待删除的 元素都拖在数组尾部,⼀个⼀个 pop 掉就⾏了,每次操作的时间复杂度也就 降低到 O(1) 了。 按照这个思路呢,⼜可以衍⽣出解决类似需求的通⽤⽅式:双指针技巧。具 体⼀点说,应该是快慢指针。 我们让慢指针 重复的元素就告诉 整个数组 ⾛左后⾯,快指针 slow nums slow 后, 并让 ⾛在前⾯探路,找到⼀个不 前进⼀步。这样当 slow nums[0..slow] fast fast 指针遍历完 就是不重复元素,之后的所有元素都 是重复元素。 int removeDuplicates(int[] nums) { int n = nums.length; if (n == 0) return 0; int slow = 0, fast = 1; while (fast < n) { if (nums[fast] != nums[slow]) { slow++; // 维护 nums[0..slow] ⽆重复 nums[slow] = nums[fast]; } fast++; } // ⻓度为索引 + 1 return slow + 1; } 看下算法执⾏的过程: 【PDF格式⽆法显⽰GIF⽂件 %E6%9C%89%E5%BA%8F%E6%95%B0%E7%BB%84%E5%8E%BB%E9%8 7%8D/1.gif,可移步公众号查看】 再简单扩展⼀下,如果给你⼀个有序链表,如何去重呢?其实和数组是⼀模 ⼀样的,唯⼀的区别是把数组赋值操作变成操作指针⽽已: 425
426. 如何去除有序数组的重复元素 ListNode deleteDuplicates(ListNode head) { if (head == null) return null; ListNode slow = head, fast = head.next; while (fast != null) { if (fast.val != slow.val) { // nums[slow] = nums[fast]; slow.next = fast; // slow++; slow = slow.next; } // fast++ fast = fast.next; } // 断开与后⾯重复元素的连接 slow.next = null; return head; } 【PDF格式⽆法显⽰GIF⽂件 %E6%9C%89%E5%BA%8F%E6%95%B0%E7%BB%84%E5%8E%BB%E9%8 7%8D/2.gif,可移步公众号查看】 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 426
427. 如何寻找最⻓回⽂⼦串 如何寻找最⻓回⽂⼦串 学好算法全靠套路,认准 labuladong 就够了。 读完本⽂,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题 ⽬: 5.最⻓回⽂⼦串 回⽂串是⾯试常常遇到的问题(虽然问题本⾝没啥意义),本⽂就告诉你回 ⽂串问题的核⼼思想是什么。 ⾸先,明确⼀下什:回⽂串就是正着读和反着读都⼀样的字符串。 ⽐如说字符串 aba 和 ⾝⼀样。反之,字符串 abba abac 都是回⽂串,因为它们对称,反过来还是和本 就不是回⽂串。 可以看到回⽂串的的⻓度可能是奇数,也可能是偶数,这就添加了回⽂串问 题的难度,解决该类问题的核⼼是双指针。下⾯就通过⼀道最⻓回⽂⼦串的 问题来具体理解⼀下回⽂串问题: 427
428. 如何寻找最⻓回⽂⼦串 string longestPalindrome(string s) {} _____________ 由于格式原因,本⽂只能在 labuladong 公众号查看,关注后可直接搜索本站 内容: 428
429. 如何运⽤贪⼼思想玩跳跃游戏 经典贪⼼算法:跳跃游戏 学算法,认准 labuladong 就够了! 经常有读者在后台问,动态规划和贪⼼算法到底有啥关系。我们之前的⽂章 贪⼼算法之区间调度问题 就说过⼀个常⻅的时间区间调度的贪⼼算法问 题。 说⽩了,贪⼼算法可以理解为⼀种特殊的动态规划问题,拥有⼀些更特殊的 性质,可以进⼀步降低动态规划算法的时间复杂度。那么这篇⽂章,就讲 LeetCode 上两道经典的贪⼼算法:跳跃游戏 I 和跳跃游戏 II。 我们可以对这两道题分别使⽤动态规划算法和贪⼼算法进⾏求解,通过实 践,你就能更深刻地理解贪⼼和动规的区别和联系了。 Jump Game I 跳跃游戏 I 是 LeetCode 第 55 题,难度是 Medium,但实际上是⽐较简单 的,看题⽬: 429
430. 如何运⽤贪⼼思想玩跳跃游戏 不知道读者有没有发现,有关动态规划的问题,⼤多是让你求最值的,⽐如 最⻓⼦序列,最⼩编辑距离,最⻓公共⼦串等等等。这就是规律,因为动态 规划本⾝就是运筹学⾥的⼀种求最值的算法。 那么贪⼼算法作为特殊的动态规划也是⼀样,也⼀定是让你求个最值。这道 题表⾯上不是求最值,但是可以改⼀改: 请问通过题⽬中的跳跃规则,最多能跳多远?如果能够越过最后⼀格,返回 true,否则返回 false。 所以说,这道题肯定可以⽤动态规划求解的。但是由于它⽐较简单,下⼀道 题再⽤动态规划和贪⼼思路进⾏对⽐,现在直接上贪⼼的思路: bool canJump(vector& nums) { int n = nums.size(); int farthest = 0; for (int i = 0; i < n - 1; i++) { // 不断计算能跳到的最远距离 farthest = max(farthest, i + nums[i]); // 可能碰到了 0,卡住跳不动了 430
431. 如何运⽤贪⼼思想玩跳跃游戏 if (farthest <= i) return false; } return farthest >= n - 1; } 你别说,如果之前没有做过类似的题⽬,还真不⼀定能够想出来这个解法。 每⼀步都计算⼀下从当前位置最远能够跳到哪⾥,然后和⼀个全局最优的最 远位置 farthest 做对⽐,通过每⼀步的最优解,更新全局最优解,这就是 贪⼼。 很简单是吧?记住这⼀题的思路,看第⼆题,你就发现事情没有这么简 单。。。 Jump Game II 这是 LeetCode 第 45 题,也是让你在数组上跳,不过难度是 Hard,解法可 没上⼀题那么简单直接: 现在的问题是,保证你⼀定可以跳到最后⼀格,请问你最少要跳多少次,才 能跳过去。 431
432. 如何运⽤贪⼼思想玩跳跃游戏 我们先来说说动态规划的思路,采⽤⾃顶向下的递归动态规划,可以这样定 义⼀个 dp 函数: // 定义:从索引 p 跳到最后⼀格,⾄少需要 dp(nums, p) 步 int dp(vector& nums, int p); 我们想求的结果就是 dp(nums, 0) ,base case 就是当 p 超过最后⼀格时, 不需要跳跃: if (p >= nums.size() - 1) { return 0; } 根据前⽂ 动态规划套路详解 的动规框架,就可以暴⼒穷举所有可能的跳 法,通过备忘录 memo 消除重叠⼦问题,取其中的最⼩值最为最终答案: vector memo; // 主函数 int jump(vector& nums) { int n = nums.size(); // 备忘录都初始化为 n,相当于 INT_MAX // 因为从 0 调到 n - 1 最多 n - 1 步 memo = vector(n, n); return dp(nums, 0); } int dp(vector& nums, int p) { int n = nums.size(); // base case if (p >= n - 1) { return 0; } // ⼦问题已经计算过 if (memo[p] != n) { return memo[p]; } int steps = nums[p]; // 你可以选择跳 1 步,2 步... for (int i = 1; i <= steps; i++) { 432
433. 如何运⽤贪⼼思想玩跳跃游戏 // 穷举每⼀个选择 // 计算每⼀个⼦问题的结果 int subProblem = dp(nums, p + i); // 取其中最⼩的作为最终结果 memo[p] = min(memo[p], subProblem + 1); } return memo[p]; } 这个动态规划应该很明显了,按照前⽂ 动态规划套路详解 所说的套路,状 态就是当前所站⽴的索引 p ,选择就是可以跳出的步数。 该算法的时间复杂度是 递归深度 × 每次递归需要的时间复杂度,即 O(N^2),在 LeetCode 上是⽆法通过所有⽤例的,会超时。 贪⼼算法⽐动态规划多了⼀个性质:贪⼼选择性质。我知道⼤家都不喜欢看 严谨但枯燥的数学形式定义,那么我们就来直观地看⼀看什么样的问题满⾜ 贪⼼选择性质。 刚才的动态规划思路,不是要穷举所有⼦问题,然后取其中最⼩的作为结果 吗?核⼼的代码框架是这样: int steps = nums[p]; // 你可以选择跳 1 步,2 步... for (int i = 1; i <= steps; i++) { // 计算每⼀个⼦问题的结果 int subProblem = dp(nums, p + i); res = min(subProblem + 1, res); } for 循环中会陷⼊递归计算⼦问题,这是动态规划时间复杂度⾼的根本原 因。 但是,真的需要【递归地】计算出每⼀个⼦问题的结果,然后求最值吗?直 观地想⼀想,似乎不需要递归,只需要判断哪⼀个选择最具有【潜⼒】即 可: 433
434. 如何运⽤贪⼼思想玩跳跃游戏 ⽐如上图这种情况,我们站在索引 0 的位置,可以向前跳 1,2 或 3 步,你 说应该选择跳多少呢? 显然应该跳 2 步调到索引 2,因为 [3..6] nums[2] 的可跳跃区域涵盖了索引区间 ,⽐其他的都⼤。如果想求最少的跳跃次数,那么往索引 2 跳必然 是最优的选择。 你看,这就是贪⼼选择性质,我们不需要【递归地】计算出所有选择的具体 结果然后⽐较求最值,⽽只需要做出那个最有【潜⼒】,看起来最优的选择 即可。 绕过这个弯⼉来,就可以写代码了: int jump(vector& nums) { int n = nums.size(); int end = 0, farthest = 0; int jumps = 0; for (int i = 0; i < n - 1; i++) { farthest = max(nums[i] + i, farthest); if (end == i) { jumps++; end = farthest; } } 434
435. 如何运⽤贪⼼思想玩跳跃游戏 return jumps; } 结合刚才那个图,就知道这段短⼩精悍的代码在⼲什么了: i 和 end [i..end] 标记了可以选择的跳跃步数, 中能够跳到的最远距离, jumps farthest 标记了所有选择 记录了跳跃次数。 本算法的时间复杂度 O(N),空间复杂度 O(1),可以说是⾮常⾼效,动态规 划都被吊起来打了。 ⾄此,两道跳跃问题都使⽤贪⼼算法解决了。 其实对于贪⼼选择性质,是可以有严格的数学证明的,有兴趣的读者可以参 看《算法导论》第⼗六章,专门有⼀个章节介绍贪⼼算法。这⾥限于篇幅和 通俗性,就不展开了。 使⽤贪⼼算法的实际应⽤还挺多,⽐如赫夫曼编码也是⼀个经典的贪⼼算法 应⽤。更多时候运⽤贪⼼算法可能不是求最优解,⽽是求次优解以节约时 间,⽐如经典的旅⾏商问题。 435
436. 如何运⽤贪⼼思想玩跳跃游戏 不过我们常⻅的贪⼼算法题⽬,就像本⽂的题⽬,⼤多⼀眼就能看出来,⼤ 不了就先⽤动态规划求解,如果动态规划都超时,说明该问题存在贪⼼选择 性质⽆疑了。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 436
437. 如何k个⼀组反转链表 如何k个⼀组反转链表 学算法,认准 labuladong 就够了! 之前的⽂章「递归反转链表的⼀部分」讲了如何递归地反转⼀部分链表,有 读者就问如何迭代地反转链表,这篇⽂章解决的问题也需要反转链表的函 数,我们不妨就⽤迭代⽅式来解决。 本⽂要解决「K 个⼀组反转链表」,不难理解: 这个问题经常在⾯经中看到,⽽且 LeetCode 上难度是 Hard,它真的有那么 难吗? 对于基本数据结构的算法问题其实都不难,只要结合特点⼀点点拆解分析, ⼀般都没啥难点。下⾯我们就来拆解⼀下这个问题。 ⼀、分析问题 ⾸先,前⽂学习数据结构的框架思维提到过,链表是⼀种兼具递归和迭代性 质的数据结构,认真思考⼀下可以发现这个问题具有递归性质。 437
438. 如何k个⼀组反转链表 什么叫递归性质?直接上图理解,⽐如说我们对这个链表调⽤ reverseKGroup(head, 2) ,即以 2 个节点为⼀组反转链表: 如果我设法把前 2 个节点反转,那么后⾯的那些节点怎么处理?后⾯的这些 节点也是⼀条链表,⽽且规模(⻓度)⽐原来这条链表⼩,这就叫⼦问题。 我们可以直接递归调⽤ reverseKGroup(cur, 2) ,因为⼦问题和原问题的结 构完全相同,这就是所谓的递归性质。 438
439. 如何k个⼀组反转链表 发现了递归性质,就可以得到⼤致的算法流程: 1、先反转以 2、将第 head k + 1 开头的 个元素作为 k 个元素。 head 递归调⽤ reverseKGroup 函数。 3、将上述两个过程的结果连接起来。 439
440. 如何k个⼀组反转链表 整体思路就是这样了,最后⼀点值得注意的是,递归函数都有个 base case, 对于这个问题是什么呢? 题⽬说了,如果最后的元素不⾜ k 个,就保持不变。这就是 base case,待 会会在代码⾥体现。 ⼆、代码实现 ⾸先,我们要实现⼀个 reverse 函数反转⼀个区间之内的元素。在此之前 我们再简化⼀下,给定链表头结点,如何反转整个链表? // 反转以 a 为头结点的链表 ListNode reverse(ListNode a) { ListNode pre, cur, nxt; pre = null; cur = a; nxt = a; while (cur != null) { nxt = cur.next; // 逐个结点反转 cur.next = pre; // 更新指针位置 pre = cur; cur = nxt; } // 返回反转后的头结点 440
441. 如何k个⼀组反转链表 return pre; } 【PDF格式⽆法显⽰GIF⽂件 kgroup/8.gif,可移步公众号查看】 这次使⽤迭代思路来实现的,借助动画理解应该很容易。 「反转以 a 为头结点的链表」其实就是「反转 那么如果让你「反转 a 到 b a 到 null 之间的结点」, 之间的结点」,你会不会? 只要更改函数签名,并把上⾯的代码中 null 改成 b 即可: /** 反转区间 [a, b) 的元素,注意是左闭右开 */ ListNode reverse(ListNode a, ListNode b) { ListNode pre, cur, nxt; pre = null; cur = a; nxt = a; // while 终⽌的条件改⼀下就⾏了 while (cur != b) { nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } // 返回反转后的头结点 return pre; } 现在我们迭代实现了反转部分链表的功能,接下来就按照之前的逻辑编写 reverseKGroup 函数即可: ListNode reverseKGroup(ListNode head, int k) { if (head == null) return null; // 区间 [a, b) 包含 k 个待反转元素 ListNode a, b; a = b = head; for (int i = 0; i < k; i++) { // 不⾜ k 个,不需要反转,base case if (b == null) return head; b = b.next; } 441
442. 如何k个⼀组反转链表 // 反转前 k 个元素 ListNode newHead = reverse(a, b); // 递归反转后续链表并连接起来 a.next = reverseKGroup(b, k); return newHead; } 解释⼀下 b) for 循环之后的⼏句代码,注意 reverse 函数是反转区间 [a, ,所以情形是这样的: 递归部分就不展开了,整个函数递归完成之后就是这个结果,完全符合题 意: 442
443. 如何k个⼀组反转链表 三、最后说两句 从阅读量上看,基本数据结构相关的算法⽂章看的⼈都不多,我想说这是要 吃亏的。 ⼤家喜欢看动态规划相关的问题,可能因为⾯试很常⻅,但就我个⼈理解, 很多算法思想都是源于数据结构的。我们公众号的成名之作之⼀,「学习数 据结构的框架思维」就提过,什么动规、回溯、分治算法,其实都是树的遍 历,树这种结构它不就是个多叉链表吗?你能处理基本数据结构的问题,解 决⼀般的算法问题应该也不会太费事。 那么如何分解问题、发现递归性质呢?这个只能多练习,也许后续可以专门 写⼀篇⽂章来探讨⼀下,本⽂就到此为⽌吧,希望对⼤家有帮助! _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 443
444. 如何k个⼀组反转链表 444
445. 如何判定括号合法性 如何判定括号合法性 学算法,认准 labuladong 就够了! 对括号的合法性判断是⼀个很常⻅且实⽤的问题,⽐如说我们写的代码,编 辑器和编译器都会检查括号是否正确闭合。⽽且我们的代码可能会包含三种 括号 [](){} ,判断起来有⼀点难度。 本⽂就来聊⼀道关于括号合法性判断的算法题,相信能加深你对栈这种数据 结构的理解。 题⽬很简单,输⼊⼀个字符串,其中包含 [](){} 六种括号,请你判断这 个字符串组成的括号是否合法。 Input:'>Input:'>Input:'>Input: "()[]{}" Output:'>Output:'>Output:'>Output: true Input:'>Input:'>Input:'>Input: "([)]" Output:'>Output:'>Output:'>Output: false Input:'>Input:'>Input:'>Input: "{[]}" Output:'>Output:'>Output:'>Output: true 解决这个问题之前,我们先降低难度,思考⼀下,如果只有⼀种括号 () ,应该如何判断字符串组成的括号是否合法呢? ⼀、处理⼀种括号 字符串中只有圆括号,如果想让括号字符串合法,那么必须做到: 每个右括号 ) ⽐如说字符串 的左边必须有⼀个左括号 ()))(( ( 和它匹配。 中,中间的两个右括号左边就没有左括号匹配,所 以这个括号组合是不合法的。 445
446. 如何判定括号合法性 那么根据这个思路,我们可以写出算法: bool isValid(string str) { // 待匹配的左括号数量 int left = 0; for (char c : str) { if (c == '(') left++; else // 遇到右括号 left--; if (left < 0) return false; } return left == 0; } 如果只有圆括号,这样就能正确判断合法性。对于三种括号的情况,我⼀开 始想模仿这个思路,定义三个变量 left1 , left2 , left3 分别处理每种 括号,虽然要多写不少 if else 分⽀,但是似乎可以解决问题。 但实际上直接照搬这种思路是不⾏的,⽐如说只有⼀个括号的情况下 是合法的,但是多种括号的情况下, [(]) (()) 显然是不合法的。 仅仅记录每种左括号出现的次数已经不能做出正确判断了,我们要加⼤存储 的信息量,可以利⽤栈来模仿类似的思路。 ⼆、处理多种括号 栈是⼀种先进后出的数据结构,处理括号问题的时候尤其有⽤。 我们这道题就⽤⼀个名为 left 的栈代替之前思路中的 left 变量,遇到 左括号就⼊栈,遇到右括号就去栈中寻找最近的左括号,看是否匹配。 bool isValid(string str) { stack left; for (char c : str) { if (c == '(' c == '{' c == '[') 446
447. 如何判定括号合法性 left.push(c); else // 字符 c 是右括号 if (!left.empty() && leftOf(c) == left.top()) left.pop(); else // 和最近的左括号不匹配 return false; } // 是否所有的左括号都被匹配了 return left.empty(); } char leftOf(char c) { if (c == '}') return '{'; if (c == ')') return '('; return '['; } _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,扫码关注 labuladong 公众号或 ongline book 查看更新⽂ 章,后台回复「pdf」限时免费获取,回复「进群」可进刷题群⼀起刷题, labuladong 带你⽇穿 LeetCode。 447
448. 如何寻找缺失的元素 如何寻找消失的元素 学算法,认准 labuladong 就够了! 之前也有⽂章写过⼏个有趣的智⼒题,今天再聊⼀道巧妙的题⽬。 题⽬⾮常简单: 给⼀个⻓度为 n 的数组,其索引应该在 个元素 [0,n] [0,n) ,但是现在你要装进去 n + 1 ,那么肯定有⼀个元素装不下嘛,请你找出这个缺失的元 素。 这道题不难的,我们应该很容易想到,把这个数组排个序,然后遍历⼀遍, 不就很容易找到缺失的那个元素了吗? 或者说,借助数据结构的特性,⽤⼀个 HashSet 把数组⾥出现的数字都储存 下来,再遍历 [0,n] 之间的数字,去 HashSet 中查询,也可以很容易查出 那个缺失的元素。 排序解法的时间复杂度是 O(NlogN),HashSet 的解法时间复杂度是 O(N), 但是还需要 O(N) 的空间复杂度存储 HashSet。 448
449. 如何寻找缺失的元素 第三种⽅法是位运算。 对于异或运算( ^ ),我们知道它有⼀个特殊性质:⼀个数和它本⾝做异 或运算结果为 0,⼀个数和 0 做异或运算还是它本⾝。 ⽽且异或运算满⾜交换律和结合律,也就是说: 2 ^ 3 ^ 2 = 3 ^ (2 ^ 2) = 3 ^ 0 = 3 ⽽这道题索就可以通过这些性质巧妙算出缺失的那个元素。⽐如说 [0,3,1,4] nums = : 为了容易理解,我们假设先把索引补⼀位,然后让每个元素和⾃⼰相等的索 引相对应: 449
450. 如何寻找缺失的元素 这样做了之后,就可以发现除了缺失元素之外,所有的索引和元素都组成⼀ 对⼉了,现在如果把这个落单的索引 2 找出来,也就找到了缺失的那个元 素。 如何找这个落单的数字呢,只要把所有的元素和索引做异或运算,成对⼉的 数字都会消为 0,只有这个落单的元素会剩下,也就达到了我们的⽬的。 int missingNumber(int[] nums) { int n = nums.length; int res = 0; // 先和新补的索引异或⼀下 res ^= n; // 和其他的元素、索引做异或 for (int i = 0; i < n; i++) res ^= i ^ nums[i]; return res; } 450
451. 如何寻找缺失的元素 由于异或运算满⾜交换律和结合律,所以总是能把成对⼉的数字消去,留下 缺失的那个元素的。 ⾄此,时间复杂度 O(N),空间复杂度 O(1),已经达到了最优,我们是否就 应该打道回府了呢? 如果这样想,说明我们受算法的毒害太深,随着我们学习的知识越来越多, 反⽽容易陷⼊思维定式,这个问题其实还有⼀个特别简单的解法:等差数列 求和公式。 题⽬的意思可以这样理解:现在有个等差数列 0, 1, 2,..., n,其中少了某⼀个 数字,请你把它找出来。那这个数字不就是 sum(0,1,..n) - sum(nums) 嘛? int missingNumber(int[] nums) { int n = nums.length; // 公式:(⾸项 + 末项) * 项数 / 2 int expect = (0 + n) * (n + 1) / 2; int sum = 0; for (int x : nums) sum += x; return expect - sum; 451
452. 如何寻找缺失的元素 你看,这种解法应该是最简单的,但说实话,我⾃⼰也没想到这个解法,⽽ 且我去问了⼏个⼤佬,他们也没想到这个最简单的思路。相反,如果去问⼀ 个初中⽣,他也许很快就能想到。 做到这⼀步了,我们是否就应该打道回府了呢? 如果这样想,说明我们对细节的把控还差点⽕候。在⽤求和公式计算 expect 时,你考虑过整型溢出吗?如果相乘的结果太⼤导致溢出,那么结 果肯定是错误的。 刚才我们的思路是把两个和都加出来然后相减,为了避免溢出,⼲脆⼀边求 和⼀边减算了。很类似刚才位运算解法的思路,仍然假设 [0,3,1,4] nums = ,先补⼀位索引再让元素跟索引配对: 我们让每个索引减去其对应的元素,再把相减的结果加起来,不就是那个缺 失的元素吗? public int missingNumber(int[] nums) { int n = nums.length; int res = 0; // 新补的索引 res += n - 0; // 剩下索引和元素的差加起来 for (int i = 0; i < n; i++) res += i - nums[i]; return res; } 452
453. 如何寻找缺失的元素 由于加减法满⾜交换律和结合律,所以总是能把成对⼉的数字消去,留下缺 失的那个元素的。 ⾄此这道算法题⽬经历九曲⼗⼋弯,终于再也没有什么坑了。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 453
454. 如何同时寻找缺失和重复的元素 如何寻找缺失和重复的元素 学算法,认准 labuladong 就够了! 今天就聊⼀道很看起来简单却⼗分巧妙的问题,寻找缺失和重复的元素。之 前的⼀篇⽂章「寻找缺失元素」也写过类似的问题,不过这次的和上次的问 题使⽤的技巧不同。 这是 LeetCode 645 题,我来描述⼀下这个题⽬: 给⼀个⻓度为 N 的数组 nums ,其中本来装着 序。但是现在出现了⼀些错误, nums [1..N] 这 N 个元素,⽆ 中的⼀个元素出现了重复,也就同时 导致了另⼀个元素的缺失。请你写⼀个算法,找到 nums 中的重复元素和 缺失元素的值。 // 返回两个数字,分别是 {dup, missing} vector findErrorNums(vector& nums); ⽐如说输⼊: nums = [1,2,2,4] ,算法返回 [2,3] 。 其实很容易解决这个问题,先遍历⼀次数组,⽤⼀个哈希表记录每个数字出 现的次数,然后遍历⼀次 [1..N] ,看看那个元素重复出现,那个元素没有 出现,就 OK 了。 但问题是,这个常规解法需要⼀个哈希表,也就是 O(N) 的空间复杂度。你 看题⽬给的条件那么巧,在 [1..N] 的⼏个数字中恰好有⼀个重复,⼀个 缺失,事出反常必有妖,对吧。 O(N) 的时间复杂度遍历数组是⽆法避免的,所以我们可以想想办法如何降 低空间复杂度,是否可以在 O(1) 的空间复杂度之下找到重复和确实的元素 呢? 思路分析 454
455. 如何同时寻找缺失和重复的元素 这个问题的特点是,每个元素和数组索引有⼀定的对应关系。 我们现在⾃⼰改造下问题,暂且将 nums 中的元素变为 [0..N-1] ,这样每 个元素就和⼀个数组索引完全对应了,这样⽅便理解⼀些。 如果说 nums 中不存在重复元素和缺失元素,那么每个元素就和唯⼀⼀个 索引值对应,对吧? 现在的问题是,有⼀个元素重复了,同时导致⼀个元素缺失了,这会产⽣什 么现象呢?会导致有两个元素对应到了同⼀个索引,⽽且会有⼀个索引没有 元素对应过去。 那么,如果我能够通过某些⽅法,找到这个重复对应的索引,不就是找到了 那个重复元素么?找到那个没有元素对应的索引,不就是找到了那个缺失的 元素了么? 那么,如何不使⽤额外空间判断某个索引有多少个元素对应呢?这就是这个 问题的精妙之处了: 通过将每个索引对应的元素变成负数,以表⽰这个索引被对应过⼀次了: 【PDF格式⽆法显⽰GIF⽂件 dupmissing/1.gif,可移步公众号查看】 如果出现重复元素 4 ,直观结果就是,索引 4 所对应的元素已经是负数 了: 455
456. 如何同时寻找缺失和重复的元素 对于缺失元素 3 ,直观结果就是,索引 3 所对应的元素是正数: 对于这个现象,我们就可以翻译成代码了: vector findErrorNums(vector& nums) { int n = nums.size(); int dup = -1; for (int i = 0; i < n; i++) { int index = abs(nums[i]); 456
457. 如何同时寻找缺失和重复的元素 // nums[index] ⼩于 0 则说明重复访问 if (nums[index] < 0) dup = abs(nums[i]); else nums[index] *= -1; } int missing = -1; for (int i = 0; i < n; i++) // nums[i] ⼤于 0 则说明没有访问 if (nums[i] > 0) missing = i; return {dup, missing}; } 这个问题就基本解决了,别忘了我们刚才为了⽅便分析,假设元素是 [0..N-1] ,但题⽬要求是 [1..N] ,所以只要简单修改两处地⽅即可得到 原题的答案: vector findErrorNums(vector& nums) { int n = nums.size(); int dup = -1; for (int i = 0; i < n; i++) { // 现在的元素是从 1 开始的 int index = abs(nums[i]) - 1; if (nums[index] < 0) dup = abs(nums[i]); else nums[index] *= -1; } int missing = -1; for (int i = 0; i < n; i++) if (nums[i] > 0) // 将索引转换成元素 missing = i + 1; return {dup, missing}; } 457
458. 如何同时寻找缺失和重复的元素 其实,元素从 1 开始是有道理的,也必须从⼀个⾮零数开始。因为如果元素 从 0 开始,那么 0 的相反数还是⾃⼰,所以如果数字 0 出现了重复或者缺 失,算法就⽆法判断 0 是否被访问过。我们之前的假设只是为了简化题⽬, 更通俗易懂。 最后总结 对于这种数组问题,关键点在于元素和索引是成对⼉出现的,常⽤的⽅法是 排序、异或、映射。 映射的思路就是我们刚才的分析,将每个索引和元素映射起来,通过正负号 记录某个元素是否被映射。 排序的⽅法也很好理解,对于这个问题,可以想象如果元素都被从⼩到⼤排 序,如果发现索引对应的元素如果不相符,就可以找到重复和缺失的元素。 异或运算也是常⽤的,因为异或性质 a ^ a = 0, a ^ 0 = a ,如果将索引 和元素同时异或,就可以消除成对⼉的索引和元素,留下的就是重复或者缺 失的元素。可以看看前⽂「寻找缺失元素」,介绍过这种⽅法。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 458
459. 如何判断回⽂链表 如何⾼效判断回⽂链表 学算法,认准 labuladong 就够了! 我们之前有两篇⽂章写了回⽂串和回⽂序列相关的问题。 寻找回⽂串的核⼼思想是从中⼼向两端扩展: string palindrome(string& s, int l, int r) { // 防⽌索引越界 while (l >= 0 && r < s.size() && s[l] == s[r]) { // 向两边展开 l--; r++; } // 返回以 s[l] 和 s[r] 为中⼼的最⻓回⽂串 return s.substr(l + 1, r - l - 1); } 因为回⽂串⻓度可能为奇数也可能是偶数,⻓度为奇数时只存在⼀个中⼼ 点,⽽⻓度为偶数时存在两个中⼼点,所以上⾯这个函数需要传 ⼊ l 和 r 。 ⽽判断⼀个字符串是不是回⽂串就简单很多,不需要考虑奇偶情况,只需要 「双指针技巧」,从两端向中间逼近即可: bool isPalindrome(string s) { int left = 0, right = s.length - 1; while (left < right) { if (s[left] != s[right]) return false; left++; right--; } return true; } 459
460. 如何判断回⽂链表 以上代码很好理解吧,因为回⽂串是对称的,所以正着读和倒着读应该是⼀ 样的,这⼀特点是解决回⽂串问题的关键。 下⾯扩展这⼀最简单的情况,来解决:如何判断⼀个「单链表」是不是回 ⽂。 ⼀、判断回⽂单链表 输⼊⼀个单链表的头结点,判断这个链表中的数字是不是回⽂: /** * 单链表节点的定义: * public class ListNode { * int val; * ListNode next; * } */ boolean isPalindrome(ListNode head); 输⼊: 1->2->null 输出: false 输⼊: 1->2->2->1->null 输出: true 这道题的关键在于,单链表⽆法倒着遍历,⽆法使⽤双指针技巧。那么最简 单的办法就是,把原始链表反转存⼊⼀条新的链表,然后⽐较这两条链表是 否相同。关于如何反转链表,可以参⻅前⽂「递归操作链表」。 其实,借助⼆叉树后序遍历的思路,不需要显式反转原始链表也可以倒序遍 历链表,下⾯来具体聊聊。 对于⼆叉树的⼏种遍历⽅式,我们再熟悉不过了: void traverse(TreeNode root) { // 前序遍历代码 traverse(root.left); 460
461. 如何判断回⽂链表 // 中序遍历代码 traverse(root.right); // 后序遍历代码 } 在「学习数据结构的框架思维」中说过,链表兼具递归结构,树结构不过是 链表的衍⽣。那么,链表其实也可以有前序遍历和后序遍历: void traverse(ListNode head) { // 前序遍历代码 traverse(head.next); // 后序遍历代码 } 这个框架有什么指导意义呢?如果我想正序打印链表中的 val 值,可以在 前序遍历位置写代码;反之,如果想倒序遍历链表,就可以在后序遍历位置 操作: /* 倒序打印单链表中的元素值 */ void traverse(ListNode head) { if (head == null) return; traverse(head.next); // 后序遍历代码 print(head.val); } 说到这了,其实可以稍作修改,模仿双指针实现回⽂判断的功能: // 左侧指针 ListNode left; boolean isPalindrome(ListNode head) { left = head; return traverse(head); } boolean traverse(ListNode right) { if (right == null) return true; 461
462. 如何判断回⽂链表 boolean res = traverse(right.next); // 后序遍历代码 res = res && (right.val == left.val); left = left.next; return res; } 这么做的核⼼逻辑是什么呢?实际上就是把链表节点放⼊⼀个栈,然后再拿 出来,这时候元素顺序就是反的,只不过我们利⽤的是递归函数的堆栈⽽ 已。 【PDF格式⽆法显⽰GIF⽂件 回⽂链表/1.gif,可移步公众号查看】 当然,⽆论造⼀条反转链表还是利⽤后续遍历,算法的时间和空间复杂度都 是 O(N)。下⾯我们想想,能不能不⽤额外的空间,解决这个问题呢? ⼆、优化空间复杂度 更好的思路是这样的: 1、先通过「双指针技巧」中的快慢指针来找到链表的中点: ListNode slow, fast; slow = fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; } // slow 指针现在指向链表中点 462
463. 如何判断回⽂链表 2、如果 fast 指针没有指向 null ,说明链表⻓度为奇数, slow 还要再前 进⼀步: if (fast != null) slow = slow.next; 3、从 slow 开始反转后⾯的链表,现在就可以开始⽐较回⽂串了: 463
464. 如何判断回⽂链表 ListNode left = head; ListNode right = reverse(slow); while (right != null) { if (left.val != right.val) return false; left = left.next; right = right.next; } return true; ⾄此,把上⾯ 3 段代码合在⼀起就⾼效地解决这个问题了,其中 reverse 函 数很容易实现: ListNode reverse(ListNode head) { ListNode pre = null, cur = head; while (cur != null) { ListNode next = cur.next; cur.next = pre; pre = cur; cur = next; } return pre; } 464
465. 如何判断回⽂链表 【PDF格式⽆法显⽰GIF⽂件 kgroup/8.gif,可移步公众号查看】 算法总体的时间复杂度 O(N),空间复杂度 O(1),已经是最优的了。 我知道肯定有读者会问:这种解法虽然⾼效,但破坏了输⼊链表的原始结 构,能不能避免这个瑕疵呢? 其实这个问题很好解决,关键在于得到 p, q 这两个指针位置: 这样,只要在函数 return 之前加⼀段代码即可恢复原先链表顺序: p.next = reverse(q); 篇幅所限,我就不写了,读者可以⾃⼰尝试⼀下。 三、最后总结 ⾸先,寻找回⽂串是从中间向两端扩展,判断回⽂串是从两端向中间收缩。 对于单链表,⽆法直接倒序遍历,可以造⼀条新的反转链表,可以利⽤链表 的后序遍历,也可以⽤栈结构倒序处理单链表。 465
466. 如何判断回⽂链表 具体到回⽂链表的判断问题,由于回⽂的特殊性,可以不完全反转链表,⽽ 是仅仅反转部分链表,将空间复杂度降到 O(1)。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 466
467. 如何在⽆限序列中随机抽取元素 随机算法之⽔塘抽样算法 学算法,认准 labuladong 就够了! 我最近在 LeetCode 上做到两道⾮常有意思的题⽬,382 和 398 题,关于⽔ 塘抽样算法(Reservoir Sampling),本质上是⼀种随机概率算法,解法应该 说会者不难,难者不会。 我第⼀次⻅到这个算法问题是⾕歌的⼀道算法题:给你⼀个未知⻓度的链 表,请你设计⼀个算法,只能遍历⼀次,随机地返回链表中的⼀个节点。 这⾥说的随机是均匀随机(uniform random),也就是说,如果有 素,每个元素被选中的概率都是 1/n 个元 ,不可以有统计意义上的偏差。 ⼀般的想法就是,我先遍历⼀遍链表,得到链表的总⻓度 [1,n] n n ,再⽣成⼀个 之间的随机数为索引,然后找到索引对应的节点,不就是⼀个随机 的节点了吗? 但题⽬说了,只能遍历⼀次,意味着这种思路不可⾏。题⽬还可以再泛化, 给⼀个未知⻓度的序列,如何在其中随机地选择 k 个元素?想要解决这个 问题,就需要著名的⽔塘抽样算法了。 算法实现 先解决只抽取⼀个元素的问题,这个问题的难点在于,随机选择是「动态」 的,⽐如说你现在你有 5 个元素,你已经随机选取了其中的某个元素 为结果,但是现在再给你⼀个新元素 结果呢,以什么逻辑选择 a 和 b b ,你应该留着 a 还是将 b 作 a 作为 呢,怎么证明你的选择⽅法在概率上是 公平的呢? 先说结论,当你遇到第 - 1/i i 个元素时,应该有 1/i 的概率选择该元素, 1 的概率保持原有的选择。看代码容易理解这个思路: 467
468. 如何在⽆限序列中随机抽取元素 /* 返回链表中⼀个随机节点的值 */ int getRandom(ListNode head) { Random r = new Random(); int i = 0, res = 0; ListNode p = head; // while 循环遍历链表 while (p != null) { // ⽣成⼀个 [0, i) 之间的整数 // 这个整数等于 0 的概率就是 1/i if (r.nextInt(++i) == 0) { res = p.val; } p = p.next; } return res; } 对于概率算法,代码往往都是很浅显的,但是这种问题的关键在于证明,你 的算法为什么是对的?为什么每次以 1/i 的概率更新结果就可以保证结果 是平均随机(uniform random)? 证明:假设总共有 概率都是 第 i 个元素,我们要的随机性⽆⾮就是每个元素被选择的 对吧,那么对于第 个元素被选择的概率是 1/(i+1) 1/n 1/n n 1/i ,以此类推,相乘就是第 i 个元素,它被选择的概率就是: ,第 i i+1 次不被替换的概率是 1 - 个元素最终被选中的概率,就是 。 因此,该算法的逻辑是正确的。 468
469. 如何在⽆限序列中随机抽取元素 同理,如果要随机选择 k 择该元素,以 的概率保持原有选择即可。代码如下: 1 - k/i 个数,只要在第 i 个元素处以 k/i 的概率选 /* 返回链表中 k 个随机节点的值 */ int[] getRandom(ListNode head, int k) { Random r = new Random(); int[] res = new int[k]; ListNode p = head; // 前 k 个元素先默认选上 for (int j = 0; j < k && p != null; j++) { res[j] = p.val; p = p.next; } int i = k; // while 循环遍历链表 while (p != null) { // ⽣成⼀个 [0, i) 之间的整数 int j = r.nextInt(++i); // 这个整数⼩于 k 的概率就是 k/i if (j < k) { res[j] = p.val; } p = p.next; } return res; } 对于数学证明,和上⾯区别不⼤: 469
470. 如何在⽆限序列中随机抽取元素 因为虽然每次更新选择的概率增⼤了 概率还是要乘 1/k k 倍,但是选到具体第 个元素的 i ,也就回到了上⼀个推导。 拓展延伸 以上的抽样算法时间复杂度是 O(n),但不是最优的⽅法,更优化的算法基 于⼏何分布(geometric distribution),时间复杂度为 O(k + klog(n/k))。由于 涉及的数学知识⽐较多,这⾥就不列出了,有兴趣的读者可以⾃⾏搜索⼀ 下。 还有⼀种思路是基于「Fisher–Yates 洗牌算法」的。随机抽取 等价于对所有元素洗牌,然后选取前 k k 个元素, 个。只不过,洗牌算法需要对元素 的随机访问,所以只能对数组这类⽀持随机存储的数据结构有效。 另外有⼀种思路也⽐较有启发意义:给每⼀个元素关联⼀个随机数,然后把 每个元素插⼊⼀个容量为 ⾏排序,最后剩下的 k k 的⼆叉堆(优先级队列)按照配对的随机数进 个元素也是随机的。 这个⽅案看起来似乎有点多此⼀举,因为插⼊⼆叉堆需要 O(logk) 的时间复 杂度,所以整个抽样算法就需要 O(nlogk) 的复杂度,还不如我们最开始的 算法。但是,这种思路可以指导我们解决加权随机抽样算法,权重越⾼,被 随机选中的概率相应增⼤,这种情况在现实⽣活中是很常⻅的,⽐如你不往 游戏⾥充钱,就永远抽不到⽪肤。 最后,我想说随机算法虽然不多,但其实很有技巧的,读者不妨思考两个常 ⻅且看起来很简单的问题: 1、如何对带有权重的样本进⾏加权随机抽取?⽐如给你⼀个数组 个元素 w[i] w = [1,99] w ,每 代表权重,请你写⼀个算法,按照权重随机抽取索引。⽐如 ,算法抽到索引 0 的概率是 1%,抽到索引 1 的概率是 99%。 2、实现⼀个⽣成器类,构造函数传⼊⼀个很⻓的数组,请你实现 randomGet ⽅法,每次调⽤随机返回数组中的⼀个元素,多次调⽤不能重 复返回相同索引的元素。要求不能对该数组进⾏任何形式的修改,且操作的 时间复杂度是 O(1)。 470
471. 如何在⽆限序列中随机抽取元素 这两个问题都是⽐较困难的,以后有时间我会写⼀写相关的⽂章。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 471
472. 如何调度考⽣的座位 如何调度考⽣的座位 学算法,认准 labuladong 就够了! 这是 LeetCode 第 885 题,有趣且具有⼀定技巧性。这种题⽬并不像动态规 划这类算法拼智商,⽽是看你对常⽤数据结构的理解和写代码的⽔平,个⼈ 认为值得重视和学习。 另外说句题外话,很多读者都问,算法框架是如何总结出来的,其实框架反 ⽽是慢慢从细节⾥抠出来的。希望⼤家看了我们的⽂章之后,最好能抽时间 把相关的问题亲⾃做⼀做,纸上得来终觉浅,绝知此事要躬⾏嘛。 先来描述⼀下题⽬:假设有⼀个考场,考场有⼀排共 是 [0..N-1] N 个座位,索引分别 ,考⽣会陆续进⼊考场考试,并且可能在任何时候离开考场。 你作为考官,要安排考⽣们的座位,满⾜:每当⼀个学⽣进⼊时,你需要最 ⼤化他和最近其他⼈的距离;如果有多个这样的座位,安排到他到索引最⼩ 的那个座位。这很符合实际情况对吧, 也就是请你实现下⾯这样⼀个类: class ExamRoom { // 构造函数,传⼊座位总数 N public ExamRoom(int N); // 来了⼀名考⽣,返回你给他分配的座位 public int seat(); // 坐在 p 位置的考⽣离开了 // 可以认为 p 位置⼀定坐有考⽣ public void leave(int p); } ⽐⽅说考场有 5 个座位,分别是 [0..4] 第⼀名考⽣进⼊时(调⽤ ),坐在任何位置都⾏,但是要给他安排 seat() : 索引最⼩的位置,也就是返回位置 0。 472
473. 如何调度考⽣的座位 第⼆名学⽣进⼊时(再调⽤ seat() ),要和旁边的⼈距离最远,也就是返 回位置 4。 第三名学⽣进⼊时,要和旁边的⼈距离最远,应该做到中间,也就是座位 2。 如果再进⼀名学⽣,他可以坐在座位 1 或者 3,取较⼩的索引 1。 以此类推。 刚才所说的情况,没有调⽤ leave 函数,不过读者肯定能够发现规律: 如果将每两个相邻的考⽣看做线段的两端点,新安排考⽣就是找最⻓的线 段,然后让该考⽣在中间把这个线段「⼆分」,中点就是给他分配的座 位。 leave(p) 其实就是去除端点 p ,使得相邻两个线段合并为⼀个。 核⼼思路很简单对吧,所以这个问题实际上实在考察你对数据结构的理解。 对于上述这个逻辑,你⽤什么数据结构来实现呢? ⼀、思路分析 根据上述思路,⾸先需要把坐在教室的学⽣抽象成线段,我们可以简单的⽤ ⼀个⼤⼩为 2 的数组表⽰。 另外,思路需要我们找到「最⻓」的线段,还需要去除线段,增加线段。 但凡遇到在动态过程中取最值的要求,肯定要使⽤有序数据结构,我们常⽤ 的数据结构就是⼆叉堆和平衡⼆叉搜索树了。⼆叉堆实现的优先级队列取最 值的时间复杂度是 O(logN),但是只能删除最⼤值。平衡⼆叉树也可以取最 值,也可以修改、删除任意⼀个值,⽽且时间复杂度都是 O(logN)。 综上,⼆叉堆不能满⾜ leave 会⽤到 Java 的⼀种数据结构 操作,应该使⽤平衡⼆叉树。所以这⾥我们 TreeSet ,这是⼀种有序数据结构,底层由红 ⿊树维护有序性。 473
474. 如何调度考⽣的座位 这⾥顺便提⼀下,⼀说到集合(Set)或者映射(Map),有的读者可能就 想当然的认为是哈希集合(HashSet)或者哈希表(HashMap),这样理解 是有点问题的。 因为哈希集合/映射底层是由哈希函数和数组实现的,特性是遍历⽆固定顺 序,但是操作效率⾼,时间复杂度为 O(1)。 ⽽集合/映射还可以依赖其他底层数据结构,常⻅的就是红⿊树(⼀种平衡 ⼆叉搜索树),特性是⾃动维护其中元素的顺序,操作效率是 O(logN)。这 种⼀般称为「有序集合/映射」。 我们使⽤的 TreeSet 就是⼀个有序集合,⽬的就是为了保持线段⻓度的有 序性,快速查找最⼤线段,快速删除和插⼊。 ⼆、简化问题 ⾸先,如果有多个可选座位,需要选择索引最⼩的座位对吧?我们先简化⼀ 下问题,暂时不管这个要求,实现上述思路。 这个问题还⽤到⼀个常⽤的编程技巧,就是使⽤⼀个「虚拟线段」让算法正 确启动,这就和链表相关的算法需要「虚拟头结点」⼀个道理。 // 将端点 p 映射到以 p 为左端点的线段 private Map startMap; // 将端点 p 映射到以 p 为右端点的线段 private Map endMap; // 根据线段⻓度从⼩到⼤存放所有线段 private TreeSet pq; private int N; public ExamRoom(int N) { this.N = N; startMap = new HashMap<>(); endMap = new HashMap<>(); pq = new TreeSet<>((a, b) -> { // 算出两个线段的⻓度 int distA = distance(a); int distB = distance(b); 474
475. 如何调度考⽣的座位 // ⻓度更⻓的更⼤,排后⾯ return distA - distB; }); // 在有序集合中先放⼀个虚拟线段 addInterval(new int[] {-1, N}); } /* 去除⼀个线段 */ private void removeInterval(int[] intv) { pq.remove(intv); startMap.remove(intv[0]); endMap.remove(intv[1]); } /* 增加⼀个线段 */ private void addInterval(int[] intv) { pq.add(intv); startMap.put(intv[0], intv); endMap.put(intv[1], intv); } /* 计算⼀个线段的⻓度 */ private int distance(int[] intv) { return intv[1] - intv[0] - 1; } 「虚拟线段」其实就是为了将所有座位表⽰为⼀个线段: 475
476. 如何调度考⽣的座位 有了上述铺垫,主要 API seat 和 leave 就可以写了: public int seat() { // 从有序集合拿出最⻓的线段 int[] longest = pq.last(); int x = longest[0]; int y = longest[1]; int seat; if (x == -1) { // 情况⼀ seat = 0; } else if (y == N) { // 情况⼆ seat = N - 1; } else { // 情况三 seat = (y - x) / 2 + x; } // 将最⻓的线段分成两段 int[] left = new int[] {x, seat}; int[] right = new int[] {seat, y}; removeInterval(longest); addInterval(left); addInterval(right); return seat; } public void leave(int p) { // 将 p 左右的线段找出来 476
477. 如何调度考⽣的座位 int[] right = startMap.get(p); int[] left = endMap.get(p); // 合并两个线段成为⼀个线段 int[] merged = new int[] {left[0], right[1]}; removeInterval(left); removeInterval(right); addInterval(merged); } ⾄此,算法就基本实现了,代码虽多,但思路很简单:找最⻓的线段,从中 间分隔成两段,中点就是 个线段,这就是 leave(p) seat() 的返回值;找 p 的左右线段,合并成⼀ 的逻辑。 三、进阶问题 但是,题⽬要求多个选择时选择索引最⼩的那个座位,我们刚才忽略了这个 问题。⽐如下⾯这种情况会出错: 477
478. 如何调度考⽣的座位 现在有序集合⾥有线段 者,按照 seat [0,4] 和 的逻辑,就会分割 [4,9] ,那么最⻓线段 [4,9] longest 就是后 ,也就是返回座位 6。但正确答 案应该是座位 2,因为 2 和 6 都满⾜最⼤化相邻考⽣距离的条件,⼆者应该 取较⼩的。 遇到题⽬的这种要求,解决⽅式就是修改有序数据结构的排序⽅式。具体到 这个问题,就是修改 TreeMap 的⽐较函数逻辑: 478
479. 如何调度考⽣的座位 pq = new TreeSet<>((a, b) -> { int distA = distance(a); int distB = distance(b); // 如果⻓度相同,就⽐较索引 if (distA == distB) return b[0] - a[0]; return distA - distB; }); 除此之外,还要改变 distance 函数,不能简单地让它计算⼀个线段两个端 点间的⻓度,⽽是让它计算该线段中点和端点之间的⻓度。 private int distance(int[] intv) { int x = intv[0]; int y = intv[1]; if (x == -1) return y; if (y == N) return N - 1 - x; // 中点和端点之间的⻓度 return (y - x) / 2; } 这样, [0,4] 和 [4,9] 的 distance 值就相等了,算法会⽐较⼆者的索 引,取较⼩的线段进⾏分割。到这⾥,这道算法题⽬算是完全解决了。 479
480. 如何调度考⽣的座位 四、最后总结 本⽂聊的这个问题其实并不算难,虽然看起来代码很多。核⼼问题就是考察 有序数据结构的理解和使⽤,来梳理⼀下。 处理动态问题⼀般都会⽤到有序数据结构,⽐如平衡⼆叉搜索树和⼆叉堆, ⼆者的时间复杂度差不多,但前者⽀持的操作更多。 既然平衡⼆叉搜索树这么好⽤,还⽤⼆叉堆⼲嘛呢?因为⼆叉堆底层就是数 组,实现简单啊,详⻅旧⽂「⼆叉堆详解」。你实现个红⿊树试试?操作复 杂,⽽且消耗的空间相对来说会多⼀些。具体问题,还是要选择恰当的数据 结构来解决。 希望本⽂对⼤家有帮助。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 480
481. Union-Find算法详解 Union-Find算法详解 学算法,认准 labuladong 就够了! 今天讲讲 Union-Find 算法,也就是常说的并查集算法,主要是解决图论中 「动态连通性」问题的。名词很⾼端,其实特别好理解,等会解释,另外这 个算法的应⽤都⾮常有趣。 说起这个 Union-Find,应该算是我的「启蒙算法」了,因为《算法4》的开 头就介绍了这款算法,可是把我秀翻了,感觉好精妙啊!后来刷了 LeetCode,并查集相关的算法题⽬都⾮常有意思,⽽且《算法4》给的解法 竟然还可以进⼀步优化,只要加⼀个微⼩的修改就可以把时间复杂度降到 O(1)。 废话不多说,直接上⼲货,先解释⼀下什么叫动态连通性吧。 ⼀、问题介绍 简单说,动态连通性其实可以抽象成给⼀幅图连线。⽐如下⾯这幅图,总共 有 10 个节点,他们互不相连,分别⽤ 0~9 标记: 481
482. Union-Find算法详解 现在我们的 Union-Find 算法主要需要实现这两个 API: class UF { /* 将 p 和 q 连接 */ public void union(int p, int q); /* 判断 p 和 q 是否连通 */ public boolean connected(int p, int q); /* 返回图中有多少个连通分量 */ public int count(); } 这⾥所说的「连通」是⼀种等价关系,也就是说具有如下三个性质: 1、⾃反性:节点 p 和 p 是连通的。 2、对称性:如果节点 p 和 q 连通,那么 3、传递性:如果节点 p 和 q 连通, q 和 q r 和 p 也连通。 连通,那么 p 和 ⽐如说之前那幅图,0〜9 任意两个不同的点都不连通,调⽤ r 也连通。 connected 都 会返回 false,连通分量为 10 个。 如果现在调⽤ union(0, 1) ,那么 0 和 1 被连通,连通分量降为 9 个。 482
483. Union-Find算法详解 再调⽤ union(1, 2) ,这时 0,1,2 都被连通,调⽤ connected(0, 2) 也会返回 true,连通分量变为 8 个。 判断这种「等价关系」⾮常实⽤,⽐如说编译器判断同⼀个变量的不同引 ⽤,⽐如社交⽹络中的朋友圈计算等等。 这样,你应该⼤概明⽩什么是动态连通性了,Union-Find 算法的关键就在 于 union 和 connected 函数的效率。那么⽤什么模型来表⽰这幅图的连通状 态呢?⽤什么数据结构来实现代码呢? ⼆、基本思路 注意我刚才把「模型」和具体的「数据结构」分开说,这么做是有原因的。 因为我们使⽤森林(若⼲棵树)来表⽰图的动态连通性,⽤数组来具体实现 这个森林。 怎么⽤森林来表⽰连通性呢?我们设定树的每个节点有⼀个指针指向其⽗节 点,如果是根节点的话,这个指针指向⾃⼰。⽐如说刚才那幅 10 个节点的 图,⼀开始的时候没有相互连通,就是这样: 483
484. Union-Find算法详解 class UF { // 记录连通分量 private int count; // 节点 x 的节点是 parent[x] private int[] parent; /* 构造函数,n 为图的节点总数 */ public UF(int n) { // ⼀开始互不连通 this.count = n; // ⽗节点指针初始指向⾃⼰ parent = new int[n]; for (int i = 0; i < n; i++) parent[i] = i; } /* 其他函数 */ } 如果某两个节点被连通,则让其中的(任意)⼀个节点的根节点接到另⼀个 节点的根节点上: 484
485. Union-Find算法详解 public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; // 将两棵树合并为⼀棵 parent[rootP] = rootQ; // parent[rootQ] = rootP 也⼀样 count--; // 两个分量合⼆为⼀ } /* 返回某个节点 x 的根节点 */ private int find(int x) { // 根节点的 parent[x] == x while (parent[x] != x) x = parent[x]; return x; } /* 返回当前的连通分量个数 */ public int count() { return count; } 这样,如果节点 p 和 q 连通的话,它们⼀定拥有相同的根节点: 485
486. Union-Find算法详解 public boolean connected(int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; } ⾄此,Union-Find 算法就基本完成了。是不是很神奇?竟然可以这样使⽤数 组来模拟出⼀个森林,如此巧妙的解决这个⽐较复杂的问题! 那么这个算法的复杂度是多少呢?我们发现,主要 API connected 复杂度和 find find 和 union 中的复杂度都是 find 函数造成的,所以说它们的 ⼀样。 主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的⾼ 度。我们可能习惯性地认为树的⾼度就是 logN ,但这并不⼀定。 logN 的 ⾼度只存在于平衡⼆叉树,对于⼀般的树可能出现极端不平衡的情况,使得 「树」⼏乎退化成「链表」,树的⾼度最坏情况下可能变成 N 。 486
487. Union-Find算法详解 所以说上⾯这种解法, find , union , connected 的时间复杂度都是 O(N)。 这个复杂度很不理想的,你想图论解决的都是诸如社交⽹络这样数据规模巨 ⼤的问题,对于 union 和 connected 的调⽤⾮常频繁,每次调⽤需要线性时 间完全不可忍受。 问题的关键在于,如何想办法避免树的不平衡呢?只需要略施⼩计即可。 三、平衡性优化 我们要知道哪种情况下可能出现不平衡现象,关键在于 union 过程: public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; // 将两棵树合并为⼀棵 parent[rootP] = rootQ; // parent[rootQ] = rootP 也可以 count--; 487
488. Union-Find算法详解 我们⼀开始就是简单粗暴的把 p 所在的树接到 q 所在的树的根节点下⾯, 那么这⾥就可能出现「头重脚轻」的不平衡状况,⽐如下⾯这种局⾯: ⻓此以往,树可能⽣⻓得很不平衡。我们其实是希望,⼩⼀些的树接到⼤⼀ 些的树下⾯,这样就能避免头重脚轻,更平衡⼀些。解决⽅法是额外使⽤⼀ 个 size 数组,记录每棵树包含的节点数,我们不妨称为「重量」: class UF { private int count; private int[] parent; // 新增⼀个数组记录树的“重量” private int[] size; public UF(int n) { this.count = n; parent = new int[n]; // 最初每棵树只有⼀个节点 // 重量应该初始化 1 size = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; } } /* 其他函数 */ 488
489. Union-Find算法详解 } ⽐如说 size[3] = 5 表⽰,以节点 样我们可以修改⼀下 union 3 为根的那棵树,总共有 5 个节点。这 ⽅法: public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; // ⼩树接到⼤树下⾯,较平衡 if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; } 这样,通过⽐较树的重量,就可以保证树的⽣⻓相对平衡,树的⾼度⼤致 在 logN 此时, 这个数量级,极⼤提升执⾏效率。 find , union , connected 的时间复杂度都下降为 O(logN),即便数据 规模上亿,所需时间也⾮常少。 四、路径压缩 这步优化特别简单,所以⾮常巧妙。我们能不能进⼀步压缩每棵树的⾼度, 使树⾼始终保持为常数? 489
490. Union-Find算法详解 这样 find 就能以 O(1) 的时间找到某⼀节点的根节点,相应 的, connected 和 union 复杂度都下降为 O(1)。 要做到这⼀点,⾮常简单,只需要在 find 中加⼀⾏代码: private int find(int x) { while (parent[x] != x) { // 进⾏路径压缩 parent[x] = parent[parent[x]]; x = parent[x]; } return x; } 这个操作有点匪夷所思,看个 GIF 就明⽩它的作⽤了(为清晰起⻅,这棵 树⽐较极端): 【PDF格式⽆法显⽰GIF⽂件 unionfind/9.gif,可移步公众号查看】 可⻅,调⽤ find 函数每次向树根遍历的同时,顺⼿将树⾼缩短了,最终所 有树⾼都不会超过 3( union 的时候树⾼可能达到 3)。 490
491. Union-Find算法详解 PS:读者可能会问,这个 GIF 图的find过程完成之后,树⾼恰好等于 3 了, 但是如果更⾼的树,压缩后⾼度依然会⼤于 3 呀?不能这么想。这个 GIF 的情景是我编出来⽅便⼤家理解路径压缩的,但是实际中,每次find都会进 ⾏路径压缩,所以树本来就不可能增⻓到这么⾼,你的这种担⼼应该是多余 的。 五、最后总结 我们先来看⼀下完整代码: class UF { // 连通分量个数 private int count; // 存储⼀棵树 private int[] parent; // 记录树的“重量” private int[] size; public UF(int n) { this.count = n; parent = new int[n]; size = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; } } public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; // ⼩树接到⼤树下⾯,较平衡 if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; 491
492. Union-Find算法详解 size[rootQ] += size[rootP]; } count--; } public boolean connected(int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; } private int find(int x) { while (parent[x] != x) { // 进⾏路径压缩 parent[x] = parent[parent[x]]; x = parent[x]; } return x; } public int count() { return count; } } Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点 性 connected 、计算连通分量 count union 、判断两个节点的连通 所需的时间复杂度均为 O(1)。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 492
493. Union-Find算法详解 493
494. Union-Find算法应⽤ Union-Find算法应⽤ 学算法,认准 labuladong 就够了! 上篇⽂章很多读者对于 Union-Find 算法的应⽤表⽰很感兴趣,这篇⽂章就 拿⼏道 LeetCode 题⽬来讲讲这个算法的巧妙⽤法。 ⾸先,复习⼀下,Union-Find 算法解决的是图的动态连通性问题,这个算法 本⾝不难,能不能应⽤出来主要是看你抽象问题的能⼒,是否能够把原始问 题抽象成⼀个有关图论的问题。 先复习⼀下上篇⽂章写的算法代码,回答读者提出的⼏个问题: class UF { // 记录连通分量个数 private int count; // 存储若⼲棵树 private int[] parent; // 记录树的“重量” private int[] size; public UF(int n) { this.count = n; parent = new int[n]; size = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; } } /* 将 p 和 q 连通 */ public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; 494
495. Union-Find算法应⽤ // ⼩树接到⼤树下⾯,较平衡 if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; } /* 判断 p 和 q 是否互相连通 */ public boolean connected(int p, int q) { int rootP = find(p); int rootQ = find(q); // 处于同⼀棵树上的节点,相互连通 return rootP == rootQ; } /* 返回节点 x 的根节点 */ private int find(int x) { while (parent[x] != x) { // 进⾏路径压缩 parent[x] = parent[parent[x]]; x = parent[x]; } return x; } public int count() { return count; } } 算法的关键点有 3 个: 1、⽤ 以 parent parent 2、⽤ size 数组记录每个节点的⽗节点,相当于指向⽗节点的指针,所 数组内实际存储着⼀个森林(若⼲棵多叉树)。 数组记录着每棵树的重量,⽬的是让 union 后树依然拥有平 衡性,⽽不会退化成链表,影响操作效率。 495
496. Union-Find算法应⽤ 3、在 find 和 union 函数中进⾏路径压缩,保证任意树的⾼度保持在常数,使得 connected API 时间复杂度为 O(1)。 有的读者问,既然有了路径压缩, size 数组的重量平衡还需要吗?这个问 题很有意思,因为路径压缩保证了树⾼为常数(不超过 3),那么树就算不 平衡,⾼度也是常数,基本没什么影响。 我认为,论时间复杂度的话,确实,不需要重量平衡也是 O(1)。但是如果 加上 size 数组辅助,效率还是略微⾼⼀些,⽐如下⾯这种情况: 如果带有重量平衡优化,⼀定会得到情况⼀,⽽不带重量优化,可能出现情 况⼆。⾼度为 3 时才会触发路径压缩那个 while 循环,所以情况⼀根本不 会触发路径压缩,⽽情况⼆会多执⾏很多次路径压缩,将第三层节点压缩到 第⼆层。 也就是说,去掉重量平衡,虽然对于单个的 find 函数调⽤,时间复杂度 依然是 O(1),但是对于 API 调⽤的整个过程,效率会有⼀定的下降。当 然,好处就是减少了⼀些空间,不过对于 Big O 表⽰法来说,时空复杂度都 没变。 下⾯⾔归正传,来看看这个算法有什么实际应⽤。 496
497. Union-Find算法应⽤ ⼀、DFS 的替代⽅案 很多使⽤ DFS 深度优先算法解决的问题,也可以⽤ Union-Find 算法解决。 ⽐如第 130 题,被围绕的区域:给你⼀个 M×N 的⼆维矩阵,其中包含字符 X X 和 O ,让你找到矩阵中四⾯被 X 围住的 O ,并且把它们替换成 。 void solve(char[][] board); 注意哦,必须是四⾯被围的 O 才能被换成 定不会被围,进⼀步,与边⾓上的 O X 相连的 ,也就是说边⾓上的 O 也不会被 X O ⼀ 围四⾯,也 不会被替换。 PS:这让我想起⼩时候玩的棋类游戏「⿊⽩棋」,只要你⽤两个棋⼦把对 ⽅的棋⼦夹在中间,对⽅的⼦就被替换成你的⼦。可⻅,占据四⾓的棋⼦是 ⽆敌的,与其相连的边棋⼦也是⽆敌的(⽆法被夹掉)。 解决这个问题的传统⽅法也不困难,先⽤ for 循环遍历棋盘的四边,⽤ DFS 算法把那些与边界相连的 个棋盘,把剩下的 O O 换成 换成⼀个特殊字符,⽐如 X ,把 # 恢复成 O # ;然后再遍历整 。这样就能完成题⽬的要 497
498. Union-Find算法应⽤ 求,时间复杂度 O(MN)。 这个问题也可以⽤ Union-Find 算法解决,虽然实现复杂⼀些,甚⾄效率也 略低,但这是使⽤ Union-Find 算法的通⽤思想,值得⼀学。 你可以把那些不需要被替换的 个共同祖师爷叫 的 O 与 dummy dummy ,这些 O O 看成⼀个拥有独门绝技的门派,它们有⼀ 和 dummy 互相连通,⽽那些需要被替换 不连通。 这就是 Union-Find 的核⼼思路,明⽩这个图,就很容易看懂代码了。 ⾸先要解决的是,根据我们的实现,Union-Find 底层⽤的是⼀维数组,构造 函数需要传⼊这个数组的⼤⼩,⽽题⽬给的是⼀个⼆维棋盘。 这个很简单,⼆维坐标 的⾏数, n (x,y) 可以转换成 x * n + y 这个数( m 是棋盘 是棋盘的列数)。敲⿊板,这是将⼆维坐标映射到⼀维的常⽤ 技巧。 其次,我们之前描述的「祖师爷」是虚构的,需要给他⽼⼈家留个位置。索 引 [0.. m*n-1] 点占据索引 都是棋盘内坐标的⼀维映射,那就让这个虚拟的 m * n dummy 节 好了。 void solve(char[][] board) { 498
499. Union-Find算法应⽤ if (board.length == 0) return; int m = board.length; int n = board[0].length; // 给 dummy 留⼀个额外位置 UF uf = new UF(m * n + 1); int dummy = m * n; // 将⾸列和末列的 O 与 dummy 连通 for (int i = 0; i < m; i++) { if (board[i][0] == 'O') uf.union(i * n, dummy); if (board[i][n - 1] == 'O') uf.union(i * n + n - 1, dummy); } // 将⾸⾏和末⾏的 O 与 dummy 连通 for (int j = 0; j < n; j++) { if (board[0][j] == 'O') uf.union(j, dummy); if (board[m - 1][j] == 'O') uf.union(n * (m - 1) + j, dummy); } // ⽅向数组 d 是上下左右搜索的常⽤⼿法 int[][] d = new int[][]{{1,0}, {0,1}, {0,-1}, {-1,0}}; for (int i = 1; i < m - 1; i++) for (int j = 1; j < n - 1; j++) if (board[i][j] == 'O') // 将此 O 与上下左右的 O 连通 for (int k = 0; k < 4; k++) { int x = i + d[k][0]; int y = j + d[k][1]; if (board[x][y] == 'O') uf.union(x * n + y, i * n + j); } // 所有不和 dummy 连通的 O,都要被替换 for (int i = 1; i < m - 1; i++) for (int j = 1; j < n - 1; j++) if (!uf.connected(dummy, i * n + j)) board[i][j] = 'X'; } 这段代码很⻓,其实就是刚才的思路实现,只有和边界 有和 dummy O 相连的 O 才具 的连通性,他们不会被替换。 499
500. Union-Find算法应⽤ 说实话,Union-Find 算法解决这个简单的问题有点杀鸡⽤⽜⼑,它可以解决 更复杂,更具有技巧性的问题,主要思路是适时增加虚拟节点,想办法让元 素「分门别类」,建⽴动态连通关系。 ⼆、判定合法等式 这个问题⽤ Union-Find 算法就显得⼗分优美了。题⽬是这样: 给你⼀个数组 equations[i] 中 a,b equations ,装着若⼲字符串表⽰的算式。每个算式 ⻓度都是 4,⽽且只有这两种情况: a==b 可以是任意⼩写字⺟。你写⼀个算法,如果 或者 equations a!=b ,其 中所有算 式都不会互相冲突,返回 true,否则返回 false。 ⽐如说,输⼊ ["a==b","b!=c","c==a"] ,算法返回 false,因为这三个算式 不可能同时正确。 再⽐如,输⼊ ["c==c","b==d","x!=z"] ,算法返回 true,因为这三个算式并 不会造成逻辑冲突。 我们前⽂说过,动态连通性其实就是⼀种等价关系,具有「⾃反性」「传递 性」和「对称性」,其实 == 关系也是⼀种等价关系,具有这些性质。所 以这个问题⽤ Union-Find 算法就很⾃然。 核⼼思想是,将 == equations 中的算式根据 == 和 != 分成两部分,先处理 算式,使得他们通过相等关系各⾃勾结成门派;然后处理 != 算式, 检查不等关系是否破坏了相等关系的连通性。 boolean equationsPossible(String[] equations) { // 26 个英⽂字⺟ UF uf = new UF(26); // 先让相等的字⺟形成连通分量 for (String eq : equations) { if (eq.charAt(1) == '=') { char x = eq.charAt(0); char y = eq.charAt(3); uf.union(x - 'a', y - 'a'); } 500
501. Union-Find算法应⽤ } // 检查不等关系是否打破相等关系的连通性 for (String eq : equations) { if (eq.charAt(1) == '!') { char x = eq.charAt(0); char y = eq.charAt(3); // 如果相等关系成⽴,就是逻辑冲突 if (uf.connected(x - 'a', y - 'a')) return false; } } return true; } ⾄此,这道判断算式合法性的问题就解决了,借助 Union-Find 算法,是不 是很简单呢? 三、简单总结 使⽤ Union-Find 算法,主要是如何把原问题转化成图的动态连通性问题。 对于算式合法性问题,可以直接利⽤等价关系,对于棋盘包围问题,则是利 ⽤⼀个虚拟节点,营造出动态连通特性。 另外,将⼆维数组映射到⼀维数组,利⽤⽅向数组 d 来简化代码量,都是 在写算法时常⽤的⼀些⼩技巧,如果没⻅过可以注意⼀下。 很多更复杂的 DFS 算法问题,都可以利⽤ Union-Find 算法更漂亮的解决。 LeetCode 上 Union-Find 相关的问题也就⼆⼗多道,有兴趣的读者可以去做 ⼀做。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 501
502. Union-Find算法应⽤ 502
503. ⼀⾏代码就能解决的算法题 ⼀⾏代码就能解决的算法题 学算法,认准 labuladong 就够了! 下⽂是我在 LeetCode 刷题过程中总结的三道有趣的「脑筋急转弯」题⽬, 可以使⽤算法编程解决,但只要稍加思考,就能找到规律,直接想出答案。 ⼀、Nim 游戏 游戏规则是这样的:你和你的朋友⾯前有⼀堆⽯⼦,你们轮流拿,⼀次⾄少 拿⼀颗,最多拿三颗,谁拿⾛最后⼀颗⽯⼦谁获胜。 假设你们都很聪明,由你第⼀个开始拿,请你写⼀个算法,输⼊⼀个正整数 n,返回你是否能赢(true 或 false)。 ⽐如现在有 4 颗⽯⼦,算法应该返回 false。因为⽆论你拿 1 颗 2 颗还是 3 颗,对⽅都能⼀次性拿完,拿⾛最后⼀颗⽯⼦,所以你⼀定会输。 ⾸先,这道题肯定可以使⽤动态规划,因为显然原问题存在⼦问题,且⼦问 题存在重复。但是因为你们都很聪明,涉及到你和对⼿的博弈,动态规划会 ⽐较复杂。 我们解决这种问题的思路⼀般都是反着思考: 如果我能赢,那么最后轮到我取⽯⼦的时候必须要剩下 1~3 颗⽯⼦,这样 我才能⼀把拿完。 如何营造这样的⼀个局⾯呢?显然,如果对⼿拿的时候只剩 4 颗⽯⼦,那么 ⽆论他怎么拿,总会剩下 1~3 颗⽯⼦,我就能赢。 如何逼迫对⼿⾯对 4 颗⽯⼦呢?要想办法,让我选择的时候还有 5~7 颗⽯ ⼦,这样的话我就有把握让对⽅不得不⾯对 4 颗⽯⼦。 503
504. ⼀⾏代码就能解决的算法题 如何营造 5~7 颗⽯⼦的局⾯呢?让对⼿⾯对 8 颗⽯⼦,⽆论他怎么拿,都 会给我剩下 5~7 颗,我就能赢。 这样⼀直循环下去,我们发现只要踩到 4 的倍数,就落⼊了圈套,永远逃不 出 4 的倍数,⽽且⼀定会输。所以这道题的解法⾮常简单: bool canWinNim(int n) { // 如果上来就踩到 4 的倍数,那就认输吧 // 否则,可以把对⽅控制在 4 的倍数,必胜 return n % 4 != 0; } ⼆、⽯头游戏 游戏规则是这样的:你和你的朋友⾯前有⼀排⽯头堆,⽤⼀个数组 piles 表 ⽰,piles[i] 表⽰第 i 堆⽯⼦有多少个。你们轮流拿⽯头,⼀次拿⼀堆,但是 只能拿⾛最左边或者最右边的⽯头堆。所有⽯头被拿完后,谁拥有的⽯头 多,谁获胜。 假设你们都很聪明,由你第⼀个开始拿,请你写⼀个算法,输⼊⼀个数组 piles,返回你是否能赢(true 或 false)。 注意,⽯头的堆的数量为偶数,所以你们两⼈拿⾛的堆数⼀定是相同的。⽯ 头的总数为奇数,也就是你们最后不可能拥有相同多的⽯头,⼀定有胜负之 分。 举个例⼦, piles=[2, 1, 9, 5] piles=[1, 9, 5] piles=[1, 9] ,你先拿,可以拿 2 或者 5,你选择 2。 ,轮到对⼿,可以拿 1 或 5,他选择 5。 轮到你拿,你拿 9。 最后,你的对⼿只能拿 1 了。 这样下来,你总共拥有 2 + 9 = 11 颗⽯头,对⼿有 5 + 1 = 6 颗⽯头, 你是可以赢的,所以算法应该返回 true。 504
505. ⼀⾏代码就能解决的算法题 你看到了,并不是简单的挑数字⼤的选,为什么第⼀次选择 2 ⽽不是 5 呢? 因为 5 后⾯是 9,你要是贪图⼀时的利益,就把 9 这堆⽯头暴露给对⼿了, 那你就要输了。 这也是强调双⽅都很聪明的原因,算法也是求最优决策过程下你是否能赢。 这道题⼜涉及到两⼈的博弈,也可以⽤动态规划算法暴⼒试,⽐较⿇烦。但 我们只要对规则深⼊思考,就会⼤惊失⾊:只要你⾜够聪明,你是必胜⽆疑 的,因为你是先⼿。 boolean stoneGame(int[] piles) { return true; } 这是为什么呢,因为题⽬有两个条件很重要:⼀是⽯头总共有偶数堆,⽯头 的总数是奇数。这两个看似增加游戏公平性的条件,反⽽使该游戏成为了⼀ 个割⾲菜游戏。我们以 piles=[2, 1, 9, 5] 讲解,假设这四堆⽯头从左到 右的索引分别是 1,2,3,4。 如果我们把这四堆⽯头按索引的奇偶分为两组,即第 1、3 堆和第 2、4 堆, 那么这两组⽯头的数量⼀定不同,也就是说⼀堆多⼀堆少。因为⽯头的总数 是奇数,不能被平分。 ⽽作为第⼀个拿⽯头的⼈,你可以控制⾃⼰拿到所有偶数堆,或者所有的奇 数堆。 你最开始可以选择第 1 堆或第 4 堆。如果你想要偶数堆,你就拿第 4 堆,这 样留给对⼿的选择只有第 1、3 堆,他不管怎么拿,第 2 堆⼜会暴露出来, 你就可以拿。同理,如果你想拿奇数堆,你就拿第 1 堆,留给对⼿的只有第 2、4 堆,他不管怎么拿,第 3 堆⼜给你暴露出来了。 也就是说,你可以在第⼀步就观察好,奇数堆的⽯头总数多,还是偶数堆的 ⽯头总数多,然后步步为营,就⼀切尽在掌控之中了。知道了这个漏洞,可 以整⼀整不知情的同学了。 505
506. ⼀⾏代码就能解决的算法题 三、电灯开关问题 这个问题是这样描述的:有 n 盏电灯,最开始时都是关着的。现在要进⾏ n 轮操作: 第 1 轮操作是把每⼀盏电灯的开关按⼀下(全部打开)。 第 2 轮操作是把每两盏灯的开关按⼀下(就是按第 2,4,6... 盏灯的开关, 它们被关闭)。 第 3 轮操作是把每三盏灯的开关按⼀下(就是按第 3,6,9... 盏灯的开关, 有的被关闭,⽐如 3,有的被打开,⽐如 6)... 如此往复,直到第 n 轮,即只按⼀下第 n 盏灯的开关。 现在给你输⼊⼀个正整数 n 代表电灯的个数,问你经过 n 轮操作后,这些电 灯有多少盏是亮的? 我们当然可以⽤⼀个布尔数组表⽰这些灯的开关情况,然后模拟这些操作过 程,最后去数⼀下就能出结果。但是这样显得没有灵性,最好的解法是这样 的: int bulbSwitch(int n) { return (int)Math.sqrt(n); } 什么?这个问题跟平⽅根有什么关系?其实这个解法挺精妙,如果没⼈告诉 你解法,还真不好想明⽩。 ⾸先,因为电灯⼀开始都是关闭的,所以某⼀盏灯最后如果是点亮的,必然 要被按奇数次开关。 我们假设只有 6 盏灯,⽽且我们只看第 6 盏灯。需要进⾏ 6 轮操作对吧,请 问对于第 6 盏灯,会被按下⼏次开关呢?这不难得出,第 1 轮会被按,第 2 轮,第 3 轮,第 6 轮都会被按。 506
507. ⼀⾏代码就能解决的算法题 为什么第 1、2、3、6 轮会被按呢?因为 6=1*6=2*3 。⼀般情况下,因⼦都 是成对出现的,也就是说开关被按的次数⼀般是偶数次。但是有特殊情况, ⽐如说总共有 16 盏灯,那么第 16 盏灯会被按⼏次? 16=1*16=2*8=4*4 其中因⼦ 4 重复出现,所以第 16 盏灯会被按 5 次,奇数次。现在你应该理 解这个问题为什么和平⽅根有关了吧? 不过,我们不是要算最后有⼏盏灯亮着吗,这样直接平⽅根⼀下是啥意思 呢?稍微思考⼀下就能理解了。 就假设现在总共有 16 盏灯,我们求 16 的平⽅根,等于 4,这就说明最后会 有 4 盏灯亮着,它们分别是第 4*4=16 1*1=1 盏、第 2*2=4 盏、第 3*3=9 盏和第 盏。 就算有的 n 平⽅根结果是⼩数,强转成 int 型,也相当于⼀个最⼤整数上 界,⽐这个上界⼩的所有整数,平⽅后的索引都是最后亮着的灯的索引。所 以说我们直接把平⽅根转成整数,就是这个问题的答案。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 507
508. ⼆分查找⾼效判定⼦序列 ⼆分查找⾼效判定⼦序列 学算法,认准 labuladong 就够了! ⼆分查找本⾝不难理解,难在巧妙地运⽤⼆分查找技巧。对于⼀个问题,你 可能都很难想到它跟⼆分查找有关,⽐如前⽂ 最⻓递增⼦序列 就借助⼀个 纸牌游戏衍⽣出⼆分查找解法。 今天再讲⼀道巧⽤⼆分查找的算法问题:如何判定字符串 的⼦序列(可以假定 t s ⻓度⽐较⼩,且 t s 是否是字符串 的⻓度⾮常⼤)。举两个 例⼦: s = "abc", t = "ahbgdc", return true. s = "axc", t = "ahbgdc", return false. 题⽬很容易理解,⽽且看起来很简单,但很难想到这个问题跟⼆分查找有关 吧? ⼀、问题分析 ⾸先,⼀个很简单的解法是这样的: bool isSubsequence(string s, string t) { int i = 0, j = 0; while (i < s.size() && j < t.size()) { if (s[i] == t[j]) i++; j++; } return i == s.size(); } 其思路也⾮常简单,利⽤双指针 i, j 分别指向 s, t ,⼀边前进⼀边匹 配⼦序列: 508
509. ⼆分查找⾼效判定⼦序列 【PDF格式⽆法显⽰GIF⽂件 %E5%AD%90%E5%BA%8F%E5%88%97/1.gif,可移步公众号查看】 读者也许会问,这不就是最优解法了吗,时间复杂度只需 O(N),N 为 t 的⻓度。 是的,如果仅仅是这个问题,这个解法就够好了,不过这个问题还有 follow up: 如果给你⼀系列字符串 否是 t s1,s2,... 的⼦序列(可以假定 s 和字符串 较短, t t ,你需要判定每个串 s 是 很⻓)。 boolean[] isSubsequence(String[] sn, String t); 你也许会问,这不是很简单吗,还是刚才的逻辑,加个 for 循环不就⾏了? 可以,但是此解法处理每个 s 时间复杂度仍然是 O(N),⽽如果巧妙运⽤ ⼆分查找,可以将时间复杂度降低,⼤约是 O(MlogN)。由于 N 相对 M ⼤ 很多,所以后者效率会更⾼。 ⼆、⼆分思路 ⼆分思路主要是对 t 进⾏预处理,⽤⼀个字典 index 将每个字符出现的 索引位置按顺序存储下来: int m = s.length(), n = t.length(); ArrayList[] index = new ArrayList[256]; // 先记下 t 中每个字符出现的位置 for (int i = 0; i < n; i++) { char c = t.charAt(i); if (index[c] == null) index[c] = new ArrayList<>(); index[c].add(i); } 509
510. ⼆分查找⾼效判定⼦序列 ⽐如对于这个情况,匹配了 "ab",应该匹配 "c" 了: 按照之前的解法,我们需要 录的信息,可以⼆分搜索 中,就是在 [0,2,6] j 线性前进扫描字符 "c",但借助 index[c] index 中记 中⽐ j ⼤的那个索引,在上图的例⼦ 中搜索⽐ 4 ⼤的那个索引: 510
511. ⼆分查找⾼效判定⼦序列 这样就可以直接得到下⼀个 "c" 的索引。现在的问题就是,如何⽤⼆分查找 计算那个恰好⽐ 4 ⼤的索引呢?答案是,寻找左侧边界的⼆分搜索就可以做 到。 三、再谈⼆分查找 在前⽂ ⼆分查找详解 中,详解了如何正确写出三种⼆分查找算法的细节。 ⼆分查找返回⽬标值 val 的索引,对于搜索左侧边界的⼆分查找,有⼀个 特殊性质: 当 val 不存在时,得到的索引恰好是⽐ 什么意思呢,就是说如果在数组 val [0,1,3,4] ⼤的最⼩元素索引。 中搜索元素 2,算法会返回索 引 2,也就是元素 3 的位置,元素 3 是数组中⼤于 2 的最⼩元素。所以我们 可以利⽤⼆分搜索避免线性扫描。 // 查找左侧边界的⼆分查找 int left_bound(ArrayList arr, int tar) { int lo = 0, hi = arr.size(); while (lo < hi) { int mid = lo + (hi - lo) / 2; if (tar > arr.get(mid)) { lo = mid + 1; 511
512. ⼆分查找⾼效判定⼦序列 } else { hi = mid; } } return lo; } 以上就是搜索左侧边界的⼆分查找,等会⼉会⽤到,其中的细节可以参⻅前 ⽂《⼆分查找详解》,这⾥不再赘述。 四、代码实现 这⾥以单个字符串 s 为例,对于多个字符串 s ,可以把预处理部分抽出 来。 boolean isSubsequence(String s, String t) { int m = s.length(), n = t.length(); // 对 t 进⾏预处理 ArrayList[] index = new ArrayList[256]; for (int i = 0; i < n; i++) { char c = t.charAt(i); if (index[c] == null) index[c] = new ArrayList<>(); index[c].add(i); } // 串 t 上的指针 int j = 0; // 借助 index 查找 s[i] for (int i = 0; i < m; i++) { char c = s.charAt(i); // 整个 t 压根⼉没有字符 c if (index[c] == null) return false; int pos = left_bound(index[c], j); // ⼆分搜索区间中没有找到字符 c if (pos == index[c].size()) return false; // 向前移动指针 j j = index[c].get(pos) + 1; } return true; 512
513. ⼆分查找⾼效判定⼦序列 } 算法执⾏的过程是这样的: 【PDF格式⽆法显⽰GIF⽂件 %E5%AD%90%E5%BA%8F%E5%88%97/2.gif,可移步公众号查看】 可⻅借助⼆分查找,算法的效率是可以⼤幅提升的。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 513
514. Linux的进程、线程、⽂件描述符是什么 Linux的进程、线程、⽂件描述符是 什么 学算法,认准 labuladong 就够了! 说到进程,恐怕⾯试中最常⻅的问题就是线程和进程的关系了,那么先说⼀ 下答案:在 Linux 系统中,进程和线程⼏乎没有区别。 Linux 中的进程就是⼀个数据结构,看明⽩就可以理解⽂件描述符、重定 向、管道命令的底层⼯作原理,最后我们从操作系统的⾓度看看为什么说线 程和进程基本没有区别。 ⼀、进程是什么 ⾸先,抽象地来说,我们的计算机就是这个东⻄: 这个⼤的矩形表⽰计算机的内存空间,其中的⼩矩形代表进程,左下⾓的圆 形表⽰磁盘,右下⾓的图形表⽰⼀些输⼊输出设备,⽐如⿏标键盘显⽰器等 等。另外,注意到内存空间被划分为了两块,上半部分表⽰⽤户空间,下半 514
515. Linux的进程、线程、⽂件描述符是什么 部分表⽰内核空间。 ⽤户空间装着⽤户进程需要使⽤的资源,⽐如你在程序代码⾥开⼀个数组, 这个数组肯定存在⽤户空间;内核空间存放内核进程需要加载的系统资源, 这⼀些资源⼀般是不允许⽤户访问的。但是注意有的⽤户进程会共享⼀些内 核空间的资源,⽐如⼀些动态链接库等等。 我们⽤ C 语⾔写⼀个 hello 程序,编译后得到⼀个可执⾏⽂件,在命令⾏运 ⾏就可以打印出⼀句 hello world,然后程序退出。在操作系统层⾯,就是新 建了⼀个进程,这个进程将我们编译出来的可执⾏⽂件读⼊内存空间,然后 执⾏,最后退出。 你编译好的那个可执⾏程序只是⼀个⽂件,不是进程,可执⾏⽂件必须要载 ⼊内存,包装成⼀个进程才能真正跑起来。进程是要依靠操作系统创建的, 每个进程都有它的固有属性,⽐如进程号(PID)、进程状态、打开的⽂件 等等,进程创建好之后,读⼊你的程序,你的程序才被系统执⾏。 那么,操作系统是如何创建进程的呢?对于操作系统,进程就是⼀个数据结 构,我们直接来看 Linux 的源码: struct task_struct { // 进程状态 long state; // 虚拟内存结构体 struct mm_struct *mm; // 进程号 pid_t pid; // 指向⽗进程的指针 struct task_struct __rcu *parent; // ⼦进程列表 struct list_head children; // 存放⽂件系统信息的指针 struct fs_struct *fs; // ⼀个数组,包含该进程打开的⽂件指针 struct files_struct *files; }; 515
516. Linux的进程、线程、⽂件描述符是什么 task_struct 就是 Linux 内核对于⼀个进程的描述,也可以称为「进程描述 符」。源码⽐较复杂,我这⾥就截取了⼀⼩部分⽐较常⻅的。 其中⽐较有意思的是 mm 指针和 files 指针。 存,也就是载⼊资源和可执⾏⽂件的地⽅; mm 指向的是进程的虚拟内 files 指针指向⼀个数组,这 个数组⾥装着所有该进程打开的⽂件的指针。 ⼆、⽂件描述符是什么 先说 files ,它是⼀个⽂件指针数组。⼀般来说,⼀个进程会 从 files[0] 读取输⼊,将输出写⼊ ⼊ files[2] 。 举个例⼦,以我们的⾓度 C 语⾔的 从进程的⾓度来看,就是向 程试图从 files[0] files[1] printf files[1] ,将错误信息写 函数是向命令⾏打印字符,但是 写⼊数据;同理, scanf 函数就是进 这个⽂件中读取数据。 每个进程被创建时, files 的前三位被填⼊默认值,分别指向标准输⼊ 流、标准输出流、标准错误流。我们常说的「⽂件描述符」就是指这个⽂件 指针数组的索引,所以程序的⽂件描述符默认情况下 0 是输⼊,1 是输出, 2 是错误。 我们可以重新画⼀幅图: 516
517. Linux的进程、线程、⽂件描述符是什么 对于⼀般的计算机,输⼊流是键盘,输出流是显⽰器,错误流也是显⽰器, 所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的 进程需要通过「系统调⽤」让内核进程访问硬件资源。 PS:不要忘了,Linux 中⼀切都被抽象成⽂件,设备也是⽂件,可以进⾏读 和写。 如果我们写的程序需要其他资源,⽐如打开⼀个⽂件进⾏读写,这也很简 单,进⾏系统调⽤,让内核把⽂件打开,这个⽂件就会被放到 files 的第 4 个位置: 517
518. Linux的进程、线程、⽂件描述符是什么 明⽩了这个原理,输⼊重定向就很好理解了,程序想读取数据的时候就会 去 files[0] 读取,所以我们只要把 files[0] 指向⼀个⽂件,那么程序就会 从这个⽂件中读取数据,⽽不是从键盘: $ command < file.txt 518
519. Linux的进程、线程、⽂件描述符是什么 同理,输出重定向就是把 files[1] 指向⼀个⽂件,那么程序的输出就不会 写⼊到显⽰器,⽽是写⼊到这个⽂件中: $ command > file.txt 错误重定向也是⼀样的,就不再赘述。 管道符其实也是异曲同⼯,把⼀个进程的输出流和另⼀个进程的输⼊流接起 ⼀条「管道」,数据就在其中传递,不得不说这种设计思想真的很优美: $ cmd1 cmd2 cmd3 519
520. Linux的进程、线程、⽂件描述符是什么 到这⾥,你可能也看出「Linux 中⼀切皆⽂件」设计思路的⾼明了,不管是 设备、另⼀个进程、socket 套接字还是真正的⽂件,全部都可以读写,统⼀ 装进⼀个简单的 files 数组,进程通过简单的⽂件描述符访问相应资源, 具体细节交于操作系统,有效解耦,优美⾼效。 三、线程是什么 ⾸先要明确的是,多进程和多线程都是并发,都可以提⾼处理器的利⽤效 率,所以现在的关键是,多线程和多进程有啥区别。 为什么说 Linux 中线程和进程基本没有区别呢,因为从 Linux 内核的⾓度来 看,并没有把线程和进程区别对待。 我们知道系统调⽤ fork() 可以新建⼀个⼦进程,函数 ⼀个线程。但⽆论线程还是进程,都是⽤ task_struct pthread() 可以新建 结构表⽰的,唯⼀的 区别就是共享的数据区域不同。 换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其⽗进 程是共享的,⽽⼦进程是拷⻉副本,⽽不是共享。就⽐如说, 和 files mm 结构 结构在线程中都是共享的,我画两张图你就明⽩了: 520
521. Linux的进程、线程、⽂件描述符是什么 所以说,我们的多线程程序要利⽤锁机制,避免多个线程同时往同⼀区域写 ⼊数据,否则可能造成数据错乱。 那么你可能问,既然进程和线程差不多,⽽且多进程数据不共享,即不存在 数据错乱的问题,为什么多线程的使⽤⽐多进程普遍得多呢? 521
522. Linux的进程、线程、⽂件描述符是什么 因为现实中数据共享的并发更普遍呀,⽐如⼗个⼈同时从⼀个账户取⼗元, 我们希望的是这个共享账户的余额正确减少⼀百元,⽽不是希望每⼈获得⼀ 个账户的拷⻉,每个拷⻉账户减少⼗元。 当然,必须要说明的是,只有 Linux 系统将线程看做共享数据的进程,不对 其做特殊看待,其他的很多操作系统是对线程和进程区别对待的,线程有其 特有的数据结构,我个⼈认为不如 Linux 的这种设计简洁,增加了系统的复 杂度。 在 Linux 中新建线程和进程的效率都是很⾼的,对于新建进程时内存区域拷 ⻉的问题,Linux 采⽤了 copy-on-write 的策略优化,也就是并不真正复制⽗ 进程的内存空间,⽽是等到需要写操作时才去复制。所以 Linux 中新建进 程和新建线程都是很迅速的。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 522
523. 关于 Linux shell 你必须知道的 关于 Linux shell 你必须知道的技巧 学算法,认准 labuladong 就够了! 我个⼈很喜欢使⽤ Linux 系统,虽然说 Windows 的图形化界⾯做的确实⽐ Linux 好,但是对脚本的⽀持太差了。⼀开始有点不习惯命令⾏操作,但是 熟悉了之后反⽽发现移动⿏标点点点才是浪费时间的罪魁祸⾸。。。 那么对于 Linux 命令⾏,本⽂不是介绍某些命令的⽤法,⽽是说明⼀些简 单却特别容易让⼈迷惑的细节问题。 1、标准输⼊和命令参数的区别。 2、在后台运⾏命令在退出终端后也全部退出了。 3、单引号和双引号表⽰字符串的区别。 4、有的命令和 ⼀起⽤就 command not found。 sudo ⼀、标准输⼊和参数的区别 这个问题⼀定是最容易让⼈迷惑的,具体来说,就是搞不清什么时候⽤管道 符 和⽂件重定向 > , < ,什么时候⽤变量 ⽐如说,我现在有个⾃动连接宽带的 shell 脚本 $ 。 connect.sh ,存在我的家⽬ 录: $ where connect.sh /home/fdl/bin/connect.sh 如果我想删除这个脚本,⽽且想少敲⼏次键盘,应该怎么操作呢?我曾经这 样尝试过: $ where connect.sh rm 523
524. 关于 Linux shell 你必须知道的 实际上,这样操作是错误的,正确的做法应该是这样的: $ rm $(where connect.sh) 前者试图将 where 的结果连接到 rm 的标准输⼊,后者试图将结果作为命令 ⾏参数传⼊。 标准输⼊就是编程语⾔中诸如 scanf 程序的 字符数组。 main 函数传⼊的 args 或者 readline 这种命令;⽽参数是指 前⽂「Linux⽂件描述符」说过,管道符和重定向符是将数据作为程序的标 准输⼊,⽽ $(cmd) ⽤刚才的例⼦说, 是读取 rm cmd 命令输出的数据作为参数。 命令源代码中肯定不接受标准输⼊,⽽是接收命令⾏ 参数,删除相应的⽂件。作为对⽐, cat 命令是既接受标准输⼊,⼜接受 命令⾏参数: $ cat filename ...file text... $ cat < filename ...file text... $ echo 'hello world' cat hello world 如果命令能够让终端阻塞,说明该命令接收标准输⼊,反之就是不接受,⽐ 如你只运⾏ cat 命令不加任何参数,终端就会阻塞,等待你输⼊字符串并 回显相同的字符串。 ⼆、后台运⾏程序 ⽐如说你远程登录到服务器上,运⾏⼀个 Django web 程序: 524
525. 关于 Linux shell 你必须知道的 $ python manager.py runserver 0.0.0.0 Listening on 0.0.0.0:8080... 现在你可以通过服务器的 IP 地址测试 Django 服务,但是终端此时就阻塞 了,你输⼊什么都不响应,除⾮输⼊ Ctrl-C 或者 Ctrl-/ 终⽌ python 进程。 可以在命令之后加⼀个 & 符号,这样命令⾏不会阻塞,可以响应你后续输 ⼊的命令,但是如果你退出服务器的登录,就不能访问该⽹⻚了。 如果你想在退出服务器之后仍然能够访问 web 服务,应该这样写命令 &) (cmd : $ (python manager.py runserver 0.0.0.0 &) Listening on 0.0.0.0:8080... $ logout 底层原理是这样的: 每⼀个命令⾏终端都是⼀个 shell 进程,你在这个终端⾥执⾏的程序实际上 都是这个 shell 进程分出来的⼦进程。正常情况下,shell 进程会阻塞,等待 ⼦进程退出才重新接收你输⼊的新的命令。加上 & 号,只是让 shell 进程不 再阻塞,可以继续响应你的新命令。但是⽆论如何,你如果关掉了这个 shell 命令⾏端⼝,依附于它的所有⼦进程都会退出。 ⽽ (cmd &) 这样运⾏命令,则是将 程名下,认 的 cmd systemd cmd 命令挂到⼀个 systemd 系统守护进 做爸爸,这样当你退出当前终端时,对于刚才 命令就完全没有影响了。 类似的,还有⼀种后台运⾏常⽤的做法是这样: $ nohub some_cmd & nohub 命令也是类似的原理,不过通过我的测试,还是 (cmd &) 这种形式 更加稳定。 525
526. 关于 Linux shell 你必须知道的 三、单引号和双引号的区别 不同的 shell ⾏为会有细微区别,但有⼀点是确定的,对于 $ , ( , ) 这 ⼏个符号,单引号包围的字符串不会做任何转义,双引号包围的字符串会转 义。 shell 的⾏为可以测试,使⽤ set -x 命令,会开启 shell 的命令回显,你可 以通过回显观察 shell 到底在执⾏什么命令: 可⻅ 和 echo $(cmd) echo "$(cmd)" ,结果差不多,但是仍然有区别。注 意观察,双引号转义完成的结果会⾃动增加单引号,⽽前者不会。 也就是说,如果 $ 读取出的参数字符串包含空格,应该⽤双引号括起来, 否则就会出错。 四、sudo 找不到命令 有时候我们普通⽤户可以⽤的命令,⽤ sudo 加权限之后却报错 command not found: 526
527. 关于 Linux shell 你必须知道的 $ connect.sh network-manager: Permission denied $ sudo connect.sh sudo: command not found 原因在于, connect.sh 这个脚本仅存在于该⽤户的环境变量中: $ where connect.sh /home/fdl/bin/connect.sh 当使⽤ 时,系统会使⽤ /etc/sudoers 这个⽂件中规定的该⽤户的权 限和环境变量,⽽这个脚本在 /etc/sudoers 环境变量⽬录中当然是找不到 sudo 的。 解决⽅法是使⽤脚本⽂件的路径,⽽不是仅仅通过脚本名称: $ sudo /home/fdl/bin/connect.sh _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 527
528. Linux shell 的实⽤⼩技巧 Linux shell 提⾼效率的技巧 学算法,认准 labuladong 就够了! 前⼏篇关于 Linux 的⽂章很受欢迎,很多读者都希望我多写写这⽅⾯的⽂ 章。我以后会定期分享⼀些 Linux 的实⽤⼩技巧,你⽤习惯之后可能就会和 我⼀样,使⽤ Windows 就头疼。。。 先说句题外话,⼤家总是问能不能装双系统,装什么 Linux 发⾏版⽐较好。 这⾥统⼀回答⼀下,装双系统很简单的,⽹上很多教程;⾄于发⾏版,推荐 Ubuntu,不要迷恋那些看起来⽜逼的⼩众发⾏版,我们的评判标准是是否 稳定,是否拥有完善的社区⽀持,这两点 Ubuntu 桌⾯版⽆疑是最好的。我 之前遇到蓝⽛键盘的适配问题,Ubuntu 社区上竟然有⼤佬直接写了个驱 动,完美解决,真是意料之外。 当然,你要是有时间爱折腾,可以随意。或者你有钱,你也不需要纠结 Linux 发⾏版,玩 MacBook 吧,它也是基于 Linux 的。 回归主题,我认为 Linux 的迷⼈之处在于完善的社区和许多⼩⽽美的⼯具, 加之管道符、重定向等等漂亮的设计理念,可以将很多复杂的⼯作⾃动化。 本⽂就介绍⼀些基本的 Linux shell 技巧,相信可以帮你提⾼⽣产⼒! 输⼊相似⽂件名太⿇烦 ⽤花括号括起来的字符串⽤逗号连接,可以⾃动扩展,⾮常有⽤,直接看例 ⼦: $ echo {one,two,three}file onefile twofile threefile $ echo {one,two,three}{1,2,3} one1 one2 one3 two1 two2 two3 three1 three2 three3 528
529. Linux shell 的实⽤⼩技巧 你看,花括号中的每个字符都可以和之后(或之前)的字符串进⾏组合拼 接,注意花括号和其中的逗号不可以⽤空格分隔,否则会被认为是普通的字 符串对待。 这个技巧有什么实际⽤处呢?最简单有⽤的就是给 cp , mv , rm 等命令扩 展参数: $ cp /very/long/path/file{,.bak} # 给 file 复制⼀个叫做 file.bak 的副本 $ rm file{1,3,5}.txt # 删除 file1.txt file3.txt file5.txt $ mv *.{c,cpp} src/ # 将所有 .c 和 .cpp 为后缀的⽂件移⼊ src ⽂件夹 输⼊路径名称太⿇烦 ⽤ cd - 返回刚才呆的⽬录,直接看例⼦吧: $ pwd /very/long/path $ cd # 回到家⽬录瞅瞅 $ pwd /home/labuladong $ cd - # 再返回刚才那个⽬录 $ pwd /very/long/path 特殊命令 !$ 会替换成上⼀次命令最后的路径,直接看例⼦: # 没有加可执⾏权限 $ /usr/bin/script.sh zsh: permission denied: /usr/bin/script.sh $ chmod +x !$ chmod +x /usr/bin/script.sh 529
530. Linux shell 的实⽤⼩技巧 特殊命令 !* 会替换成上⼀次命令输⼊的所有⽂件路径,直接看例⼦: # 创建了三个脚本⽂件 $ file script1.sh script2.sh script3.sh # 给它们全部加上可执⾏权限 $ chmod +x !* chmod +x script1.sh script2.sh script3.sh 可以在环境变量 CDPATH 中加⼊你常⽤的⼯作⽬录,当 录中找不到你指定的⽂件/⽬录时,会⾃动到 ⽐如说我常去 /var/log CDPATH cd 命令在当前⽬ 中的⽬录中寻找。 ⽬录找⽇志,可以执⾏如下命令: $ export CDPATH='~:/var/log' # cd 命令将会在 〜 ⽬录和 /var/log ⽬录扩展搜索 $ pwd /home/labuladong/musics $ cd mysql cd /var/log/mysql $ pwd /var/log/mysql $ cd my_pictures cd /home/labuladong/my_pictures 这个技巧是⼗分好⽤的,这样就免了经常写完整的路径名称,节约不少时 间。 需要注意的是,以上操作是 bash ⽀持的,其他主流 shell 解释器当然都⽀持 扩展 cd 命令的搜索⽬录,但可能不是修改 CDPATH 这个变量,具体的设 置⽅法可以⾃⾏搜索。 输⼊重复命令太⿇烦 使⽤特殊命令 !! ,可以⾃动替换成上⼀次使⽤的命令: 530
531. Linux shell 的实⽤⼩技巧 $ apt install net-tools E: Could not open lock file - open (13: Permission denied) $ sudo !! sudo apt install net-tools [sudo] password for fdl: 有的命令很⻓,⼀时间想不起来具体参数了怎么办? 对于 bash 终端,可以使⽤ Ctrl+R 快捷键反向搜索历史命令,之所以说是 反向搜索,就是搜索最近⼀次输⼊的命令。 ⽐如按下 Ctrl+R 之后,输⼊ sudo ,bash 就会搜索出最近⼀次包含 sudo 的命令,你回⻋之后就可以运⾏该命令了: (reverse-i-search)`sudo': sudo apt install git 但是这个⽅法有缺点:⾸先,该功能似乎只有 bash ⽀持,我⽤的 zsh 作为 shell 终端,就⽤不了;第⼆,只能查找出⼀个(最近的)命令,如果我想 找以前的某个命令,就没办法了。 对于这种情况,我们最常⽤的⽅法是使⽤ grep history 命令配合管道符和 命令来寻找某个历史命令: # 过滤出所有包含 config 字段的历史命令 $ history grep 'config' 7352 ./configure 7434 git config --global --unset https.proxy 9609 ifconfig 9985 clip -o sed -z 's/\n/,\n/g' clip 10433 cd ~/.config 你使⽤的所有 shell 命令都会被记录,前⾯的数字就表⽰这是第⼏个命令, 找到你想重复使⽤的命令后,也不需要复制粘贴该命令,只要使⽤ ! +你 想重⽤的命令编号即可运⾏该命令。 531
532. Linux shell 的实⽤⼩技巧 拿上⾯的例⼦,我想重新运⾏ git config 那条命令,就可以这样: $ !7434 git config --global --unset https.proxy # 运⾏完成 我觉得 history 配置⽂件中( 加管道加 .bashrc , grep .zshrc 这样打的字还是太多,可以在 你的 shell 等) 中写这样⼀个函数: his() { history grep "$@" } 这样就不需要写那么多,只需要 his 'some_keyword' 即可搜索历史命令。 我⼀般不使⽤ bash 作为终端,我给⼤家推荐⼀款很好⽤的 shell 终端叫做 zsh,这也是我⾃⼰使⽤的 shell。这款终端还可以扩展各种插件,⾮常好 ⽤,具体配置⽅法可⾃⾏搜索。 其他⼩技巧 1、 yes 命令⾃动输⼊字符 y 进⾏确认: 我们安装某些软件的时候,可能有交互式的提问: $ sudo apt install XXX ... XXX will use 996 MB disk space, continue? [y/n] ⼀般情况下我们都是⼀路 y 到底,但如果我们想⾃动化⼀些软件的安装就很 烦,遇到这种交互式提问就卡住了,还得⼿动处理。 yes 命令可以帮助我们: 532
533. Linux shell 的实⽤⼩技巧 $ yes your_cmd 这样就会⼀路⾃动 y 下去,不会停下让我们输⼊了。 如果你读过前⽂Linux ⽂件描述符,就知道其原理很简单: 你单独运⾏⼀下 输出和 your_cmd yes 命令,发现它就是打印出⼀⼤堆字符 y,通过管道把 的标准输⼊相连接,如果 your_cmd ⼜提出⽆聊的问题, 就会从标准输⼊读取数据,也就会读取到⼀个 y 和换⾏符,和你⼿动输⼊ y 确认是⼀个效果。 2、特殊变量 $? 记录上⼀次命令的返回值。 在 Linux shell 中,遵循 C 语⾔的习惯,返回值为 0 的话就是程序正常退 出,⾮ 0 值就是异常退出出。读取上⼀次命令的返回值在平时使⽤命令⾏时 感觉没什么⽤,但是如果你想编写⼀些 shell 脚本,知道返回值⾮常有⽤。 举个实际的例⼦,⽐如我的 Github 仓库 fucking-algorithm ,我需要给其中 所有 markdown ⽂件最下⽅添加上⼀篇、下⼀篇、⽬录三个⻚脚链接,有的 ⽂章已经有了⻚脚,⼤部分都没有。 为了防⽌重复添加,我必须知道⼀个 md ⽂件下⽅是否已添加,这时候就可 以使⽤ $? 变量配合 grep 命令做到: #!/bin/bash filename=$1 # 查看⽂件尾部是否包含关键词 tail grep '下⼀篇' $filename # grep 查找到匹配会返回 0,找不到则返回⾮ 0 值 [ $? -ne 0 ] && { 添加⻚脚; } 3、特殊变量 $$ 记录当前进程的 PID。 这个功能可能在平时使⽤时也不怎么⽤,但是在写 shell 脚本时也⾮常有 ⽤,⽐如说你要在 /tmp 创建临时⽂件,给⽂件起名字⼀直都是⾮常让⼈ 费脑⼦的,这时候可以使⽤ $$ 变量扩展出当前进程的 PID 作为临时⽂件 533
534. Linux shell 的实⽤⼩技巧 名,PID 在计算机中都是唯⼀的,所以绝不会重复,也不需要你记住临时⽂ 件的名字。 好了,今天就分享这些技巧吧,如果⼤家对 Linux 有兴趣,可以点在看分 享,数据不错的话下次再写点。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 534
535. ⼀⽂看懂 session 和 cookie ⼀⽂读懂 session 和 cookie 学算法,认准 labuladong 就够了! cookie ⼤家应该都熟悉,⽐如说登录某些⽹站⼀段时间后,就要求你重新登 录;再⽐如有的同学很喜欢玩爬⾍技术,有时候⽹站就是可以拦截住你的爬 ⾍,这些都和 cookie 有关。如果你明⽩了服务器后端对于 cookie 和 session 的处理逻辑,就可以解释这些现象,甚⾄钻⼀些空⼦⽆限⽩嫖,待我慢慢道 来。 ⼀、session 和 cookie 简介 cookie 的出现是因为 HTTP 是⽆状态的⼀种协议,换句话说,服务器记不住 你,可能你每刷新⼀次⽹⻚,就要重新输⼊⼀次账号密码进⾏登录。这显然 是让⼈⽆法接受的,cookie 的作⽤就好⽐服务器给你贴个标签,然后你每次 向服务器再发请求时,服务器就能够 cookie 认出你。 抽象地概括⼀下:⼀个 cookie 可以认为是⼀个「变量」,形如 name=value ,存储在浏览器;⼀个 session 可以理解为⼀种数据结构,多数 情况是「映射」(键值对),存储在服务器上。 注意,我说的是「⼀个」cookie 可以认为是⼀个变量,但是服务器可以⼀次 设置多个 cookie,所以有时候说 cookie 是「⼀组」键值对⼉,这也可以说 得通。 cookie 可以在服务器端通过 HTTP 的 SetCookie 字段设置 cookie,⽐如我⽤ Go 语⾔写的⼀个简单服务: func cookie(w http.ResponseWriter, r *http.Request) { // 设置了两个 cookie http.SetCookie(w, &http.Cookie{ Name: "name1", Value: "value1", }) 535
536. ⼀⽂看懂 session 和 cookie http.SetCookie(w, &http.Cookie{ Name: "name2", Value: "value2", }) // 将字符串写⼊⽹⻚ fmt.Fprintln(w, "⻚⾯内容") } 当浏览器访问对应⽹址时,通过浏览器的开发者⼯具查看此次 HTTP 通信的 细节,可以看⻅服务器的回应发出了两次 在这之后,浏览器的请求中的 Cookie SetCookie 命令: 字段就带上了这两个 cookie: cookie 的作⽤其实就是这么简单,⽆⾮就是服务器给每个客户端(浏览器) 打的标签,⽅便服务器辨认⽽已。当然,HTTP 还有很多参数可以设置 cookie,⽐如过期时间,或者让某个 cookie 只有某个特定路径才能使⽤等 等。 536
537. ⼀⽂看懂 session 和 cookie 但问题是,我们也知道现在的很多⽹站功能很复杂,⽽且涉及很多的数据交 互,⽐如说电商⽹站的购物⻋功能,信息量⼤,⽽且结构也⽐较复杂,⽆法 通过简单的 cookie 机制传递这么多信息,⽽且要知道 cookie 字段是存储在 HTTP header 中的,就算能够承载这些信息,也会消耗很多的带宽,⽐较消 耗⽹络资源。 session 就可以配合 cookie 解决这⼀问题,⽐如说⼀个 cookie 存储这样⼀个 变量 sessionID=xxxx ,仅仅把这⼀个 cookie 传给服务器,然后服务器通过 这个 ID 找到对应的 session,这个 session 是⼀个数据结构,⾥⾯存储着该 ⽤户的购物⻋等详细信息,服务器可以通过这些信息返回该⽤户的定制化⽹ ⻚,有效解决了追踪⽤户的问题。 session 是⼀个数据结构,由⽹站的开发者设计,所以可以承载各种数据, 只要客户端的 cookie 传来⼀个唯⼀的 session ID,服务器就可以找到对应的 session,认出这个客户。 当然,由于 session 存储在服务器中,肯定会消耗服务器的资源,所以 session ⼀般都会有⼀个过期时间,服务器⼀般会定期检查并删除过期的 session,如果后来该⽤户再次访问服务器,可能就会⾯临重新登录等等措 施,然后服务器新建⼀个 session,将 session ID 通过 cookie 的形式传送给客 户端。 那么,我们知道 cookie 和 session 的原理,有什么切实的好处呢?除了应对 ⾯试,我给你说⼀个鸡贼的⽤处,就是可以⽩嫖某些服务。 有些⽹站,你第⼀次使⽤它的服务,它直接免费让你试⽤,但是⽤⼀次之 后,就让你登录然后付费继续使⽤该服务。⽽且你发现⽹站似乎通过某些⼿ 段记住了你的电脑,除⾮你换个电脑或者换个浏览器才能再⽩嫖⼀次。 那么问题来了,你试⽤的时候没有登录,⽹站服务器是怎么记住你的呢?这 就很显然了,服务器⼀定是给你的浏览器打了 cookie,后台建⽴了对应的 session 记录你的状态。你的浏览器在每次访问该⽹站的时候都会听话地带 着 cookie,服务器⼀查 session 就知道这个浏览器已经免费使⽤过了,得让 它登录付费,不能让它继续⽩嫖了。 537
538. ⼀⽂看懂 session 和 cookie 那如果我不让浏览器发送 cookie,每次都伪装成⼀个第⼀次来试⽤的⼩萌 新,不就可以不断⽩嫖了么?浏览器会把⽹站的 cookie 以⽂件的形式存在 某些地⽅(不同的浏览器配置不同),你把他们找到然后删除就⾏了。但是 对于 Firefox 和 Chrome 浏览器,有很多插件可以直接编辑 cookie,⽐如我 的 Chrome 浏览器就⽤的⼀款叫做 EditThisCookie 的插件,这是他们官⽹: 这类插件可以读取浏览器在当前⽹⻚的 cookie,点开插件可以任意编辑和删 除 cookie。当然,偶尔⽩嫖⼀两次还⾏,不⿎励⾼频率⽩嫖,想常⽤还是掏 钱吧,否则⽹站赚不到钱,就只能取消免费试⽤这个机制了。 以上就是关于 cookie 和 session 的简单介绍,cookie 是 HTTP 协议的⼀部 分,不算复杂,⽽ session 是可以定制的,所以下⾯详细看⼀下实现 session 管理的代码架构吧。 ⼆、session 的实现 session 的原理不难,但是具体实现它可是很有技巧的,⼀般需要三个组件 配合完成,它们分别是 Manager 、 Provider 和 Session 三个类(接 ⼝)。 538
539. ⼀⽂看懂 session 和 cookie 1、浏览器通过 HTTP 协议向服务器请求路径 /content 的⽹⻚资源,对应 路径上有⼀个 Handler 函数接收请求,解析 HTTP header 中的 cookie,得到 其中存储的 sessionID,然后把这个 ID 发给 2、 Manager 。 充当⼀个 session 管理器的⾓⾊,主要存储⼀些配置信息,⽐ Manager 如 session 的存活时间,cookie 的名字等等。⽽所有的 session 存在 内部的⼀个 Provider 3、 中。所以 Provider Manager 4、 (sessionID)传递给 就是⼀个容器,最常⻅的应该就是⼀个散列表,将每个 Provider sid sid ,让它去找这个 ID 对应的具体是哪个 session。 和对应的 session ⼀⼀映射起来。收到 到 会把 Manager 对应的 session 结构,也就是 Session Manager Session 传递的 sid sid 之后,它就找 结构,然后返回它。 中存储着⽤户的具体信息,由 Handler 函数中的逻辑拿出这些 信息,⽣成该⽤户的 HTML ⽹⻚,返回给客户端。 那么你也许会问,为什么搞这么⿇烦,直接在 Handler 函数中搞⼀个哈希 表,然后存储 sid 和 Session 结构的映射不就完事⼉了? 这就是设计层⾯的技巧了,下⾯就来说说,为什么分成 Manager 、 Provider 和 Session 。 539
540. ⼀⽂看懂 session 和 cookie 先从最底层的 Session 说。既然 session 就是键值对,为啥不直接⽤哈希 表,⽽是要抽象出这么⼀个数据结构呢? 第⼀,因为 Session 助数据,⽐如 sid 结构可能不⽌存储了⼀个哈希表,还可以存储⼀些辅 ,访问次数,过期时间或者最后⼀次的访问时间,这样 便于实现想 LRU、LFU 这样的算法。 第⼆,因为 session 可以有不同的存储⽅式。如果⽤编程语⾔内置的哈希 表,那么 session 数据就是存储在内存中,如果数据量⼤,很容易造成程序 崩溃,⽽且⼀旦程序结束,所有 session 数据都会丢失。所以可以有很多种 session 的存储⽅式,⽐如存⼊缓存数据库 Redis,或者存⼊ MySQL 等等。 因此, 结构提供⼀层抽象,屏蔽不同存储⽅式的差异,只要提供 Session ⼀组通⽤接⼝操纵键值对: type Session interface { // 设置键值对 Set(key, val interface{}) // 获取 key 对应的值 Get(key interface{}) interface{} // 删除键 key Delete(key interface{}) } 再说 Provider 列表,保存 为啥要抽象出来。我们上⾯那个图的 sid 到 Session Provider 就是⼀个散 的映射,但是实际中肯定会更加复杂。我们 不是要时不时删除⼀些 session 吗,除了设置存活时间之外,还可以采⽤⼀ 些其他策略,⽐如 LRU 缓存淘汰算法,这样就需要 Provider 内部使⽤哈 希链表这种数据结构来存储 session。 PS:关于 LRU 算法的奥妙,参⻅前⽂「LRU 算法详解」。 因此, Provider 和算法组织 sid 作为⼀个容器,就是要屏蔽算法细节,以合理的数据结构 和 Session 的映射关系,只需要实现下⾯这⼏个⽅法实 现对 session 的增删查改: 540
541. ⼀⽂看懂 session 和 cookie type Provider interface { // 新增并返回⼀个 session SessionCreate(sid string) (Session, error) // 删除⼀个 session SessionDestroy(sid string) // 查找⼀个 session SessionRead(sid string) (Session, error) // 修改⼀个session SessionUpdate(sid string) // 通过类似 LRU 的算法回收过期的 session SessionGC(maxLifeTime int64) } 最后说 了, Manager Manager ,⼤部分具体⼯作都委托给 Session 和 Provider 承担 主要就是⼀个参数集合,⽐如 session 的存活时间,清理过期 session 的策略,以及 session 的可⽤存储⽅式。 细节,我们可以通过 Manager Manager 屏蔽了操作的具体 灵活地配置 session 机制。 综上,session 机制分成⼏部分的最主要原因就是解耦,实现定制化。我在 Github 上看过⼏个 Go 语⾔实现的 session 服务,源码都很简单,有兴趣的 朋友可以学习学习: https://github.com/alexedwards/scs https://github.com/astaxie/build-web-application-with-golang _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 541
542. ⼀⽂看懂 session 和 cookie 542
543. 加密算法的前⾝今世 密码算法的前世今⽣ 学算法,认准 labuladong 就够了! 说到密码,我们第⼀个想到的就是登陆账户的密码,但是从密码学的⾓度来 看,这种根本就不算合格的密码。 为什么呢,因为我们的账户密码,是依靠隐蔽性来达到加密作⽤:密码藏在 我⼼⾥,你不知道,所以你登不上我的账户。 然⽽密码技术认为,「保密」信息总有⼀天会被扒出来,所以加密算法不应 该依靠「保密」来保证机密性,⽽应该做到:即便知道了加密算法,依然⽆ 计可施。说的魔幻⼀点就是,告诉你我的密码,你依然不知道我的密码。 最⽞学的就是 Diffie-Hellman 密钥交换算法,我当初就觉得很惊奇,两个⼈ 当着你的⾯互相报⼏个数字,他们就可以拥有⼀个共同的秘密,⽽你却根本 不可能算出来这个秘密。下⽂会着重介绍⼀下这个算法。 本⽂讨论的密码技术要解决的主要是信息传输中的加密和解密问题。要假设 数据传输过程是不安全的,所有信息都在被窃听的,所以发送端要把信息加 密,接收⽅收到信息之后,肯定得知道如何解密。有意思的是,如果你能够 让接收者知道如何解密,那么窃听者不是也能够知道如何解密了吗? 下⾯,我们会介绍对称加密算法、密钥交换算法、⾮对称加密算法、数字签 名、公钥证书,看看解决安全传输问题的⼀路坎坷波折。 ⼀、对称性加密 对称性密码,也叫共享密钥密码,顾名思义,这种加密⽅式⽤相同的密钥进 ⾏加密和解密。 ⽐如我说⼀种最简单的对称加密的⽅法。⾸先我们知道信息都可以表⽰成 0/1 ⽐特序列,也知道相同的两个⽐特序列做异或运算的结果为 0。 543
544. 加密算法的前⾝今世 那么我们就可以⽣成⼀个⻓度和原始信息⼀样的随机⽐特序列作为密钥,然 后⽤它对原始信息做异或运算,就⽣成了密⽂。反之,再⽤该密钥对密⽂做 ⼀次异或运算,就可以恢复原始信息。 这是⼀个简单例⼦,不过有些过于简单,有很多问题。⽐如密钥的⻓度和原 始信息完全⼀致,如果原始信息很⼤,密钥也会⼀样⼤,⽽且⽣成⼤量真随 机⽐特序列的计算开销也⽐较⼤。 当然,有很多更复杂优秀的对称加密算法解决了这些问题,⽐如 Rijndael 算 法、三重 DES 算法等等。它们从算法上是⽆懈可击的,也就是拥有巨⼤的 密钥空间,基本⽆法暴⼒破解,⽽且加密过程相对快速。 但是,⼀切对称加密算法的软肋在于密钥的配送。加密和解密⽤同⼀个密 钥,发送⽅必须设法把密钥发送给接收⽅。如果窃听者有能⼒窃取密⽂,肯 定也可以窃取密钥,那么再⽆懈可击的算法依然不攻⾃破。 所以,下⾯介绍两种解决密钥配送问题最常⻅的算法,分别是 DiffieHellman 密钥交换算法和⾮对称加密算法。 ⼆、密钥交换算法 我们所说的密钥⼀般就是⼀个很⼤的数字,算法⽤这个数加密、解密。问题 在于,信道是不安全的,所有发出的数据都会被窃取。换句话说,有没有⼀ 种办法,能够让两个⼈在众⽬睽睽之下,光明正⼤地交换⼀个秘密,把对称 性密钥安全地送到接收⽅的⼿中? Diffie-Hellman 密钥交换算法可以做到。准确的说,该算法并不是把⼀个秘 密安全地「送给」对⽅,⽽是通过⼀些共享的数字,双⽅「⼼中」各⾃「⽣ 成」了⼀个相同的秘密,⽽且双⽅的这个秘密,是第三⽅窃听者⽆法⽣成 的。 也许这就是传说中的⼼有灵犀⼀点通吧。 544
545. 加密算法的前⾝今世 这个算法规则不算复杂,你甚⾄都可以找个朋友尝试⼀下共享秘密,等会我 会简单画出它的基本流程。在此之前,需要明确⼀个问题:并不是所有运算 都有逆运算。 最简单的例⼦就是我们熟知的单向散列函数,给⼀个数字 数 f ,你可以很快计算出 f(a) ,但是如果给你 f(a) 和 和⼀个散列函 a f ,推出 a 是⼀件基本做不到的事。密钥交换算法之所以看起来如此⽞幻,就是利⽤了 这种不可逆的性质。 下⾯,看下密钥交换算法的流程是什么,按照命名惯例,准备执⾏密钥交换 算法的双⽅称为 Alice 和 Bob,在⽹络中企图窃取他俩通信内容的坏⼈称为 Hack 吧。 ⾸先,Alice 和 Bob 协商出两个数字 N 和 G 作为⽣成元,当然协商过程 可以被窃听者 Hack 窃取,所以我把这两个数画到中间,代表三⽅都知道: 现在 Alice 和 Bob ⼼中各⾃想⼀个数字出来,分别称为 A 和 B 吧: 545
546. 加密算法的前⾝今世 现在 Alice 将⾃⼰⼼⾥的这个数字 AG A 和 G ,然后发给 Bob;Bob 将⾃⼰⼼⾥的数 ⼀个数 BG 通过某些运算得出⼀个数 B 和 G 通过相同的运算得出 ,然后发给 Alice: 现在的情况变成这样了: 546
547. 加密算法的前⾝今世 注意,类似刚才举的散列函数的例⼦,知道 是多少, BG AG 和 G ,并不能反推出 A 同理。 那么,Alice 可以通过 Bob 也可以通过 AG BG 和⾃⼰的 和⾃⼰的 B 通过某些运算得到⼀个数 A 通过某些运算得到 ABG ABG , ,这个数就是 Alice 和 Bob 共有的秘密。 ⽽对于 Hack,可以窃取传输过程中的 逆,怎么都⽆法结合出 ABG G , AG , BG ,但是由于计算不可 这个数字。 547
548. 加密算法的前⾝今世 以上就是基本流程,⾄于具体的数字取值是有讲究的,运算⽅法在百度上很 容易找到,限于篇幅我就不具体写了。 该算法可以在第三者窃听的前提下,算出⼀个别⼈⽆法算出的秘密作为对称 性加密算法的密钥,开始对称加密的通信。 对于该算法,Hack ⼜想到⼀种破解⽅法,不是窃听 Alice 和 Bob 的通信数 据,⽽是直接同时冒充 Alice 和 Bob 的⾝份,也就是我们说的「中间⼈攻 击」: 548
549. 加密算法的前⾝今世 这样,双⽅根本⽆法察觉在和 Hack 共享秘密,后果就是 Hack 可以解密甚 ⾄修改数据。 可⻅,密钥交换算法也不算完全解决了密钥配送问题,缺陷在于⽆法核实对 ⽅⾝份。所以密钥交换算法之前⼀般要核实对⽅⾝份,⽐如使⽤数字签名。 三、⾮对称加密 ⾮对称加密的思路就是,⼲脆别偷偷摸摸传输密钥了,我把加密密钥和解密 密钥分开,公钥⽤于加密,私钥⽤于解密。只把公钥传送给对⽅,然后对⽅ 开始给我发送加密的数据,我⽤私钥就可以解密。⾄于窃听者,拿到公钥和 加密数据也没⽤,因为只有我⼿上的私钥才能解密。 可以这样想,私钥是钥匙,⽽公钥是锁,可以把锁公开出去,让别⼈把数据 锁起来发给我;⽽钥匙⼀定要留在⾃⼰⼿⾥,⽤于解锁。我们常⻅的 RSA 算法就是典型的⾮对称加密算法,具体实现⽐较复杂,我就不写了,⽹上很 多资料。 在实际应⽤中,⾮对称性加密的运算速度要⽐对称性加密慢很多的,所以传 输⼤量数据时,⼀般不会⽤公钥直接加密数据,⽽是加密对称性加密的密 钥,传输给对⽅,然后双⽅使⽤对称性加密算法传输数据。 549
550. 加密算法的前⾝今世 需要注意的是,类似 Diffie-Hellman 算法,⾮对称加密算法也⽆法确定通信 双⽅的⾝份,依然会遭到中间⼈攻击。⽐如 Hack 拦截 Bob 发出的公钥,然 后冒充 Bob 的⾝份给 Alice 发送⾃⼰的公钥,那么不知情的 Alice 就会把私 密数据⽤ Hack 的公钥加密,Hack 可以通过私钥解密窃取。 那么,Diffie-Hellman 算法和 RSA ⾮对称加密算法都可以⼀定程度上解决密 钥配送的问题,也具有相同的缺陷,⼆者的应⽤场景有什么区别呢? 简单来说,根据两种算法的基本原理就可以看出来: 如果双⽅有⼀个对称加密⽅案,希望加密通信,⽽且不能让别⼈得到钥匙, 那么可以使⽤ Diffie-Hellman 算法交换密钥。 如果你希望任何⼈都可以对信息加密,⽽只有你能够解密,那么就使⽤ RSA ⾮对称加密算法,公布公钥。 下⾯,我们尝试着解决认证发送⽅⾝份的问题。 四、数字签名 刚才说⾮对称加密,把公钥公开⽤于他⼈对数据加密然后发给你,只有⽤你 ⼿上对应的私钥才能将密⽂解密。其实,私钥也可⽤⽤来加密数据的,对于 RSA 算法,私钥加密的数据只有公钥才能解开。 数字签名也是利⽤了⾮对称性密钥的特性,但是和公钥加密完全颠倒过来: 仍然公布公钥,但是⽤你的私钥加密数据,然后把加密的数据公布出去,这 就是数字签名。 你可能问,这有什么⽤,公钥可以解开私钥加密,我还加密发出去,不是多 此⼀举吗? 是的,但是数字签名的作⽤本来就不是保证数据的机密性,⽽是证明你的⾝ 份,证明这些数据确实是由你本⼈发出的。 550
551. 加密算法的前⾝今世 你想想,你的私钥加密的数据,只有你的公钥才能解开,那么如果⼀份加密 数据能够被你的公钥解开,不就说明这份数据是你(私钥持有者)本⼈发布 的吗? 当然,加密数据仅仅是⼀个签名,签名应该和数据⼀同发出,具体流程应该 是: 1、Bob ⽣成公钥和私钥,然后把公钥公布出去,私钥⾃⼰保留。 2、⽤私钥加密数据作为签名,然后将数据附带着签名⼀同发布出去。 3、Alice 收到数据和签名,需要检查此份数据是否是 Bob 所发出,于是⽤ Bob 之前发出的公钥尝试解密签名,将收到的数据和签名解密后的结果作对 ⽐,如果完全相同,说明数据没被篡改,且确实由 Bob 发出。 为什么 Alice 这么肯定呢,毕竟数据和签名是两部分,都可以被掉包呀?原 因如下: 1、如果有⼈修改了数据,那么 Alice 解密签名之后,对⽐发现⼆者不⼀ 致,察觉出异常。 2、如果有⼈替换了签名,那么 Alice ⽤ Bob 的公钥只能解出⼀串乱码,显 然和数据不⼀致。 3、也许有⼈企图修改数据,然后将修改之后的数据制成签名,使得 Alice 的对⽐⽆法发现不⼀致;但是⼀旦解开签名,就不可能再重新⽣成 Bob 的 签名了,因为没有 Bob 的私钥。 综上,数字签名可以⼀定程度上认证数据的来源。之所以说是⼀定程度上, 是因为这种⽅式依然可能受到中间⼈攻击。⼀旦涉及公钥的发布,接收⽅就 可能收到中间⼈的假公钥,进⾏错误的认证,这个问题始终避免不了。 说来可笑,数字签名就是验证对⽅⾝份的⼀种⽅式,但是前提是对⽅的⾝份 必须是真的... 这似乎陷⼊⼀个先有鸡还是先有蛋的死循环,要想确定对⽅的 ⾝份,必须有⼀个信任的源头,否则的话,再多的流程也只是在转移问题, ⽽不是真正解决问题。 551
552. 加密算法的前⾝今世 五、公钥证书 证书其实就是公钥 + 签名,由第三⽅认证机构颁发。引⼊可信任的第三 ⽅,是终结信任循环的⼀种可⾏⽅案。 证书认证的流程⼤致如下: 1、Bob 去可信任的认证机构证实本⼈真实⾝份,并提供⾃⼰的公钥。 2、Alice 想跟 Bob 通信,⾸先向认证机构请求 Bob 的公钥,认证机构会把 ⼀张证书(Bob 的公钥以及⾃⼰对其公钥的签名)发送给 Alice。 3、Alice 检查签名,确定该公钥确实由这家认证机构发送,中途未被篡改。 4、Alice 通过这个公钥加密数据,开始和 Bob 通信。 PS:以上只是为了说明,证书只需要安装⼀次,并不需要每次都向认证机 构请求;⼀般是服务器直接给客户端发送证书,⽽不是认证机构。 也许有⼈问,Alice 要想通过数字签名确定证书的有效性,前提是要有该机 构的(认证)公钥,这不是⼜回到刚才的死循环了吗? 552
553. 加密算法的前⾝今世 我们安装的正规浏览器中都预存了正规认证机构的证书(包含其公钥),⽤ 于确认机构⾝份,所以说证书的认证是可信的。 Bob 向机构提供公钥的过程中,需要提供很多个⼈信息进⾏⾝份验证,⽐较 严格,所以说也算是可靠的。 获得了 Bob 的可信公钥,Alice 和 Bob 之间的通信基于加密算法的保护,是 完全⽆懈可击的。 现在的正规⽹站,⼤都使⽤ HTTPS 协议,就是在 HTTP 协议和 TCP 协议之 间加了⼀个 SSL/TLS 安全层。在你的浏览器和⽹站服务器完成 TCP 握⼿ 后,SSL 协议层也会进⾏ SSL 握⼿交换安全参数,其中就包含该⽹站的证 书,以便浏览器验证站点⾝份。SSL 安全层验证完成之后,上层的 HTTP 协 议内容都会被加密,保证数据的安全传输。 这样⼀来,传统的中间⼈攻击就⼏乎没有了⽣存空间,攻击⼿段只能由技术 缺陷转变为坑蒙拐骗。事实上,这种⼿段的效果反⽽更⾼效,⽐如我就发现 ⽹上不少下载⽹站发布的浏览器,不仅包含乱七⼋糟的导航和收藏⽹址,还 包含⼀些不正规的认证机构证书。任何⼈都可以申请证书,这些不正规证书 很可能造成安全隐患。 六、最后总结 对称性加密算法使⽤同⼀个密钥加密和解密,难以破解,加密速度较快,但 是存在密钥配送问题。 Diffie-Hellman 密钥交换算法可以让双⽅「⼼有灵犀⼀点通」,⼀定程度解 决密钥配送问题,但是⽆法验证通信⽅的⾝份,所以可能受到中间⼈攻击。 ⾮对称性加密算法⽣成⼀对⼉密钥,把加密和解密的⼯作分开了。 RSA 算法作为经典的⾮对称加密算法,有两种⽤途:如果⽤于加密,可以 把公钥发布出去⽤于加密,只有⾃⼰的私钥可以解密,保证了数据的机密 性;如果⽤于数字签名,把公钥发布出去后,⽤私钥加密数据作为签名,以 553
554. 加密算法的前⾝今世 证明该数据由私钥持有者所发送。但是⽆论那种⽤法,涉及公钥的发布,都 ⽆法避免中间⼈攻击。 公钥证书就是公钥 + 签名,由可信任的第三⽅认证机构颁发。由于正规浏 览器都预装了可信的认证机构的公钥,所以可以有效防⽌中间⼈攻击。 HTTPS 协议中的 SSL/TLS 安全层会组合使⽤以上⼏种加密⽅式,所以说不 要安装⾮正规的浏览器,不要乱安装未知来源的证书。 密码技术只是安全的⼀⼩部分,即便是通过正规机构认证的 HTTPS 站点, 也不意味着可信任,只能说明其数据传输是安全的。技术永远不可能真正保 护你,最重要的还是得提⾼个⼈的安全防范意识,多留⼼眼⼉,谨慎处理敏 感数据。 _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 554
555. Git/SQL/正则表达式的在线练习平台 在线刷题学习平台 学算法,认准 labuladong 就够了! 虽说我没事就喜欢喷应试教育,但我也从应试教育中发现了⼀个窍门:如果 能够以刷题的形式学习某项技能,效率和效果是最佳的。对于技术的学习, 我经常⾯临的困境是,理论知识知道的不少,但是有的场景实在⽆法模拟, 缺少亲⾃动⼿实践的机会,如果能有⼀本带标准答案的习题册让我刷刷就好 了。 所以在学习新技术时,我⾸先会去搜索是否有在线刷题平台,你还别说,有 的⼤神真就做了很不错的在线练习平台,下⾯就介绍⼏个平台,分别是学习 Git、SQL、正则表达式的在线练习平台。 ⼀、练习 Git 这是个叫做 Learning Git Branching 的项⽬,是我⼀定要推荐的: 555
556. Git/SQL/正则表达式的在线练习平台 正如对话框中的⾃我介绍,这确实也是我⾄今发现的最好的 Git 动画教程, 没有之⼀。 想当年我⽤ Git 就会 add . , clone , push , pull , commit ⼏个命 令,其他的命令完全不会,Git 就是⼀个下载器,Github 就是个资源⽹站加 免费图床,命令能不能达成⽬的都是靠运⽓。什么版本控制,我根本搞不 懂,也懒得去看那⼀堆乱七⼋糟的⽂档。 这个⽹站的教程不是给你举那种修改⽂件的细节例⼦,⽽是将每次 commit 都抽象成树的节点,⽤动画闯关的形式,让你⾃由使⽤ Git 命令完成⽬标: 所有 Git 分⽀都被可视化了,你只要在左侧的命令⾏输⼊ Git 命令,分⽀会 进⾏相应的变化,只要达成任务⽬标,你就过关啦!⽹站还会记录你的命令 数,试试能不能以最少的命令数过关! 556
557. Git/SQL/正则表达式的在线练习平台 我⼀开始以为这个教程只包含本地 Git 仓库的版本管理,后来我惊奇地发现 它还有远程仓库的操作教程! 557
558. Git/SQL/正则表达式的在线练习平台 真的跟玩游戏⼀样,难度设计合理,流畅度很好,我⼀玩都停不下来了,⼏ ⼩时就打通了,哈哈哈! 总之,这个教程很适合初学和进阶,如果你觉得⾃⼰对 Git 的掌握还不太 好,⽤ Git 命令还是靠碰运⽓,就可以玩玩这个教程,相信能够让你更熟练 地使⽤ Git。 它是⼀个开源项⽬,Github 项⽬地址: 558
559. Git/SQL/正则表达式的在线练习平台 https://github.com/pcottle/learnGitBranching 教程⽹站地址: https://learngitbranching.js.org ⼆、练习正则表达式 正则表达式是个⾮常强有⼒的⼯具,可以说计算机中的⼀切数据都是字符, 借助正则表达式这种模式匹配⼯具,操作计算机可以说是如虎添翼。 我这⾥要推荐两个⽹站,⼀个是练习平台,⼀个是测试正则表达式的平台。 先说练习平台,叫做 RegexOne: 前⾯有基本教程,后⾯有⼀些常⻅的正则表达式题⽬,⽐如判断邮箱、 URL、电话号,或者抽取⽇志的关键信息等等。 只要写出符合要求的正则表达式,就可以进⼊下⼀个问题,关键是每道题还 有标准答案,可以点击下⾯的 solution 按钮查看: 559
560. Git/SQL/正则表达式的在线练习平台 RegexOne ⽹址: https://regexone.com/ 再说测试⼯具,是个叫做 RegExr 的 Github 项⽬,这是它的⽹站: 可以看⻅,输⼊⽂本和正则模式串后,⽹站会给正则表达式添加好看且容易 辨认的样式,⾃动在⽂本中搜索模式串,⾼亮显⽰匹配的字符串,并且还会 显⽰每个分组捕获的字符串。 560
561. Git/SQL/正则表达式的在线练习平台 这个⽹站可以配合前⾯的正则练习平台使⽤,在这⾥尝试各种表达式,成功 匹配之后粘贴过去。 RegExr ⽹址: https://regexr.com/ 三、练习 SQL 这是⼀个叫做 SQLZOO 的⽹站,左侧是所有的练习内容: SQLZOO 是⼀款很好⽤的 SQL 练习平台,英⽂不难理解,可以直接看英⽂ 版,但是也可以切换繁体中⽂,⽐较友好。 这⾥都是⽐较常⽤的 SQL 命令,给你⼀个需求,你写 SQL 语句实现正确的 查询结果。最重要的是,这⾥不仅对每个命令的⽤法有详细解释,每个专题 后⾯还有选择题(quiz),⽽且有判题系统,甚⾄有的⽐较难的题⽬还有视 频讲解: 561
562. Git/SQL/正则表达式的在线练习平台 ⾄于难度,循序渐进,即便对新⼿也很友好,靠后的问题确实⽐较有技巧 性,相信这是热爱思维挑战的⼈喜欢的!LeetCode 也有 SQL 相关的题⽬, 不过难度⼀般⽐较⼤,我觉得 SQLZOO 刷完基础 SQL 命令再去 LeetCode 刷⽐较合适。 ⽹站地址: https://sqlzoo.net/ _____________ 刷算法,学套路,认准 labuladong。 本⼩抄即将出版,关注 labuladong 公众号或 ongline book 查看更新⽂章, 后台回复「进群」可进刷题群,labuladong 带你⽇穿 LeetCode。 562