HOT100题型归类汇总
1 介绍
- 先做 LeetCode 热题 HOT 100 中的 $\color{green}{easy}$ 和 $\color{orange}{middle}$ 的题,$\color{red}{hard}$ 的题难度较大放在每类的最后。
- 题目后带⭐标识的标识不是很理解或易错,所以作为重点以后还需要多加练习。
- 每一种类型的题目,并不绝对是按照原 HOT 100 的顺序来排列的。因为有些题目其实很相似,放在一起更好,便单独对他们做了调整,比如 [647. 回文子串] 和 [5. 最长回文子串]
- 这里只是有一个大概的思路,让自己复习的时候能够快速想起方法,并没有完整的题解。
1.1 链表(11 题)
2. 两数相加 ⭐
148. 排序链表 ⭐⭐
146. LRU 缓存 ⭐⭐⭐
1.2 二叉树(16 题)
98. 验证二叉搜索树 ⭐⭐⭐
236. 二叉树的最近公共祖先 ⭐⭐⭐
437. 路径总和 III ⭐⭐⭐
1.3 回溯(8 题)
46. 全排列 ⭐
22. 括号生成 ⭐
79. 单词搜索 ⭐
131. 分割回文串 ⭐⭐
51. N 皇后⭐⭐
1.4 贪心(4 题)
1.5 子串(3 题)
1.6 双指针(4 题)
15. 三数之和⭐⭐⭐
42. 接雨水 ⭐⭐
1.7 动态规划(10 题)
152. 乘积最大子数组 ⭐⭐
416. 分割等和子集 ⭐⭐
| 题 | 分类 | 思路 |
|---|---|---|
| 打家劫舍 | 线性 DP | dp [i] = max(dp [i-1], dp [i-2] + nums [i]) |
| 完全平方数 | 完全背包 | 平方数可重复使用 |
| 零钱兑换 | 完全背包 | 硬币可无限使用 |
| 单词拆分 | 完全背包变种 | 字典单词可重复使用 |
| 最长递增子序列 | 线性 DP | dp [i] = max(dp [j] + 1) where nums [j] < nums [i] |
| 乘积最大子数组 | 线性 DP | 双状态 maxDp[i], minDp[i] |
| 分割等和子集 | 0-1 背包 | 每个元素只能用一次 |
| 最长有效括号 | 栈/线性 DP | 栈匹配或 dp[i] 表示以 i 结尾的最长有效括号 |
1.8 多维动态规划(5 题)
1.9 滑动窗口(2 题)
1.10 普通数组(5 题)
1.11 矩阵(4 题)
54. 螺旋矩阵⭐⭐
48. 旋转图像⭐⭐
1.12 图论(4 题)
207. 课程表 ⭐
1.13 二分查找(6 题)
1.14 堆(3 题)
1.15 哈希(3 题)
1.16 栈(5 题)
394. 字符串解码 ⭐⭐
1.17 技巧(5 题)
1.18 Tips
创建各种数据结构简表
| 数据结构 | 新建方式 | 核心方法 |
|---|---|---|
| 哈希表 | Map<>() map = new HashMap<>() / Set<>() set = new HashSet<>() |
put/get, add/contains, getOrDefault |
| 栈 | Deque<Integer> stack = new ArrayDeque<>(); |
push/pop/peek (入栈/弹栈/获取栈顶元素) |
| 队列 | Queue<Integer> queue = new ArrayDeque<>(); |
offer/poll/peek(入队/出队/获取队头元素) |
| 双端队列 | Deque<Integer> deque = new ArrayDeque<>(); |
(栈+队列通用) |
| 堆 | PriorityQueue<Integer> minHeap = new PriorityQueue<>(); |
offer/poll/peek 堆使用的头部操作 |
| 双向链表 | LinkedList<Integer> list = new LinkedList<>(); |
2 链表
2. 两数相加 ⭐
用两个指针分别指向两个节点,对应处理每个节点的 val 相加;使用一个当前指针 cur 指向结果节点;使用一个进位值记录每轮的进位值 int sum = x + y + carry; carry = sum / 10;;当遍历完记得额外处理最后的一次进位。例如:99+9 = 108,这里需要单独处理百位最后的 1。
要得到倒数第 N 个节点,必须知道链表长度;所以使用快指针先遍历一遍整个链表,再用慢指针走到倒数第 N 个节点,最后删除即可。
迭代写法:使用一个指针指向哑节点,比较两个链表大小,指向较小节点。时间复杂度较高,不推荐。
递归写法:直接比较两个节点大小,如果一个链表空了,直接返回另一个(因为它本身有序,可以直接接上)。
在合并两个有序链表的基础上分治合并多个链表,关键在于分治上,要根据左右两个下标分成不同组的两两链表,流程类似于二分查找。
public ListNode merge(ListNode[] lists, int l, int r) {
if (l == r) {
// 只有一个链表,直接返回
return lists[l];
}
if (l > r) {
// 空区间,返回 null(比如输入是 [])
return null;
}
// 找到中点
int mid = (l + r) >> 1;
return mergeTwoLists(
merge(lists, l, mid), // 递归合并左半部分
merge(lists, mid + 1, r)); // 递归合并右半部分
}
创建哨兵节点 dummy,表示节点 0。用到四个指针(循环外两个循环内两个),用 nodeo 表示 0,node1 表示 1,依此类推。
- 把 node0 指向 node2。把 node2 指向 node1。把 node1 指向 node3。
- 更新 node0 为 node1,更新 node1 为 node3。
- 如果 node1 和 node1.next 都不为空,就回到第一步,执行下一轮交换
- 最后返回 dummy.next,作为新链表的头节点。
判断快慢指针是否相遇(都从 头节点出发,快指针两步慢指针一步),如果有环快指针必定追上慢指针。
使用 HashSet 记录每个遍历过的节点,当遇到第一次相同节点时说明为环的起点。
快慢指针:二者都同时从头节点出发,相遇时头节点再出发,头结点与慢指针相遇的节点即为环入口(Floyd 判圈法)。
148. 排序链表 ⭐⭐
- 利用快慢指针将原链表从中间断开分解为两个链表;
- 分治:不断递归上述过程,直至最终将链表切割为多个长度为 1 的链表
- 最后不断合并这多个长度为 1 的链表(此时比较大小并合并的过程,与 21. 合并两个有序链表 一样)
先遍历一遍其中一个链表将所有节点存到集合,如果遍历第二个链表在集合中存在时即可返回该节点。
TODO:用双指针 pA 、pB 分别遍历两个链表,pA 对链表 A 遍历结束后就去遍历链表 B,pB 对链表 B 遍历结束后就遍历链表 A。当 pA == pB 时,相遇节点即为交点,因为两个指针分别移动的步数是一样的,如下图所示,(x+z)+y = (y+z)+x,即指针 A 和指针 B 走过的长度相等,即使两者没有相交,也可视为在空节点相交:
三个指针依次前进即可,需要用 next 指针记录下一个节点。不需要创建 dummy 节点。
先 p0 指向 left 前一个节点,再使用三个指针翻转区域内节点,最后重新连接整个链表。注意需要创建一个 dummy 节点方便操作。
与翻转链表 2 类似,要注意更新 p0 时:开启下一轮循环时 p0 实际就是当前循环 p0.next,所以用一个值存储。必须要先确定节点总数才能确定当前节点是否需要翻转。
先数总数定组数,dummy 护头 p0 连,每组反转 k 次,不足 k 个保持原样。
反转链表的升级,快指针走两步,慢指针走一步,快指针走到末尾时慢指针走到中间;慢指针移动时需要将前半部分节点反转。注意奇偶数节点。
146. LRU 缓存 ⭐⭐⭐
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行,所以要结合双向链表(维护访问顺序实现 LRU)和哈希表(实现 O(1)get 和 put);哈希表中 key->node 的映射以便于 O(1)查找到节点,双向链表维护访问顺序时把访问过的节点放到链表开头以及 O(1)删除任意节点;每个节点都有两个指针 prev 和 next 分别指向前一个和下一个节点;此外,链表中还包含一个哨兵节点 dummy,方便在最开始插入节点。题解
怎么背这道题。
- 需要运用双向链表和哈希表,这是一种复合数据结构
哈希表只是根据 key, 能找到对应的 node, 即 <key,node*>
双向链表是双值双指针的形式, key, value, prev, next - 哈希表的操作,API 已经提供了,现在就是看自己创建双向链表,并看其需要哪些操作。
双向链表的基本操作需要 删掉节点 和 插入节点到最前 这两个方法。 - 到目前位置双向链表和哈希表的基本操作都够了,现在就是 LRUCache 这个复合类需要哪些操作。
- 到目前位置 put, 和 get 怎么写其实就很简单了。
get 操作,只会更新双向链表类的元素
put 操作,会更新双向链表的操作,也会更新哈希表类的元素,当超过容量时删除链表末尾元素。
附:常考继续扩展,带 TTL 的 LRU。
3 二叉树
递归 or 迭代(利用栈的先进后出特性模拟递归),必会。
迭代:入栈阶段一直向左走,出栈访问根,再转向右子树。
类似题目:
-
144. 二叉树的前序遍历 利用栈,类似于中序遍历,只是在入栈前先访问当前节点,出栈时就不访问了。
while (!stack.isEmpty() || node != null) { while (node != null) { res.add(node.val); // 先访问根 stack.push(node); node = node.left; } node = stack.pop(); node = node.right; } -
145. 二叉树的后序遍历 利用栈,有一个标记根节点的 flagMap,用来标记第几次经过某个根节点 root,只有第二次经过某个根节点时,才存储其结果,并将其出栈置空。
98. 验证二叉搜索树 ⭐⭐⭐
利用 中序遍历 二叉搜索树有序的特点,使用双指针比较法(pre 和 node),参考 B 站视频题解,不需额外引入常量,而只需通过一个 pre 指针,在向上回溯的过程中,不断保存之前的节点用于比较。
首先【不断向左子树递归】直至最后空节点:boolean left = isValidBST(root.left); 然后再自底向上【回溯】的过程中,pre 每次保存的都是之前 上一层栈空间中的根节点,并不断将当前 node 节点和 pre 节点的值做比较:(pre != null && pre.val >= root.val),条件成立时返回 false,保存当前节点 root 到 pre 中,用于下层递归中做比较,然后不断向右子树递归:boolean right = isValidBST(root.right);。整个的逻辑相当于是把二叉树中序遍历后前一个值 pre 与后一个值 root 比较。最后返回:return left && right,判断当前节点的左右子树是否分别是二叉搜索树。
递归:两个子节点都为 null 返回 true,两个结点中有一个为空和节点值不相等返回 false;最后剩下两个节点值相等的情况直接递归下一层子树:
return dfs(left.left, right.right) && dfs(left.right, right.left);
使用 后序遍历,每一层都是一个新的子链表,只要递归时记录层级数按顺序将每层的节点放入对应层级的子链表即可。
高度:目标节点到叶子节点的距离(从下往上计数,左右中的后序遍历)
深度:目标节点到根节点的距离(从上往下计数,中左右的前序遍历)
最大深度也就等于根节点的高度,题解采用 后序遍历 得到高度。实际代码非常简单,当 root == null 时直接返回 0 ,或者 return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; 即可。
取所有节点中“左高度” + “右高度”之和的最大值,先计算左右子树再处理根,所以是 后序遍历。
题目示例中“1”的左高度 2 + 右高度 1 = 3,“2” 的左高度 1+ 右高度 1 = 2,选择一个最大值。类似最大深度。
注意不是 根节点的左右深度之和,直径可能出现在 任意子树 中,所以不是简单的根节点左右最大深度之和,需要有一个全局的最大直径比较所有节点左右最大深度之和。
236. 二叉树的最近公共祖先 ⭐⭐⭐
类似于最大深度,也是自底而上的 后序遍历,即处理“中”节点在“左右”节点之后。
题解:从前序遍历中获取根,再从中序遍历中由根节点两边截断为两个子树,可以分别用两个指针指向两个遍历的边界。
可以适当优化,因为每次递归都要从 inorder 中根据根节点的值找到对应索引,所以可以用一个 HashMap 预处理中序遍历的值 → 索引映射,将查找优化到 O(1):
Map<Integer, Integer> inOrderMap = new HashMap<>();
for (int i = 0; i < inorder.length; i++) {
inOrderMap.put(inorder[i], i);
}
// 递归时直接用:int i_root_index = inOrderMap.get(root_val);
思路与 105 类似,但是边界不同。
因为数组是有序的,所以只要从上到下的前序遍历,类似于二分查找,每次把中间中间的数作为根节点是满足条件的。注意边界。
利用验证二叉树的思想,首先 前序遍历 把所有节点存到一个链表中,将链表理解为压为一条直线的二叉树,随后只要按照题意将其前一个节点置为 null 即可。例如:
存储到链表:[1,2,5,3,4,6] -> 结果链表:[1,null,2,null,3,null,4,null,5,null,6]
不额外使用链表的方法:用“右-左-根”的遍历方式,相当于是前序遍历的逆序,此时用一个 pre 节点记录当前节点的前一个节点,那么只要把前一个节点放到当前节点的 right 并把当前节点的 left 置为 null ,最后再记录当前节点为下一轮的前一个节点即可。
这里和 114 类似,二叉搜索树中序遍历有序,先中序遍历把所有节点存到一个链表中,这样就是一个有序的链表,直接返回第 k 小的元素即可。
可以 不使用链表,把 k 记录为全局变量,在递归遍历时第 k 小说明是前序遍历的第 k 个(1 开始),注意赋值时 this.k = k;。
private void dfs(TreeNode root) {
if (root == null || k <= 0) {
return;
}
dfs(root.left);
if (--k == 0) {
// 第 k 小说明是前序遍历的第 k 个(1 开始)
ans = root.val;
}
dfs(root.right);
}
可以观察到,前序遍历(根左右)的结果前 depth 个节点是其左视图,同理可得,“根右左” 遍历的结果前 depth 个节点是其右视图。也可以理解为按照 「根结点 -> 右子树 -> 左子树」 的顺序访问,就可以保证每层都是最先访问最右边的节点的。因为每一层只有一个最右节点,所以当前深度 == 答案节点数时则将其加入右视图。
先翻转左右子节点,再递归翻转左右子树(或者先递归再翻转左右子节点也是一样此时 后序遍历)。
437. 路径总和 III ⭐⭐⭐
如果二叉树是一条链,本题就和 560. 和为 K 的子数组 完全一样,需要先实现 560 题再来看此题,但还是挺难理解。
private int ans;
public int pathSum(TreeNode root, int targetSum) {
// key:从根到 node 的节点值之和
// value:节点值之和的出现次数
// 在递归过程中,哈希表只保存根到 node 的路径的前缀的节点值之和
Map<Long, Integer> cnt = new HashMap<>();
cnt.put(0L, 1);
dfs(root, 0, targetSum, cnt);
return ans;
}
// s 表示从根到 node 的父节点的节点值之和(node 的节点值尚未计入)
private void dfs(TreeNode node, long s, int targetSum, Map<Long, Integer> cnt) {
if (node == null) {
return;
}
s += node.val;
ans += cnt.getOrDefault(s - targetSum, 0);
cnt.merge(s, 1, Integer::sum);
dfs(node.left, s, targetSum, cnt);
dfs(node.right, s, targetSum, cnt);
cnt.merge(s, -1, Integer::sum); // cnt[s]-- 恢复现场(撤销 cnt[s]++)
}
虽然是 hard 题,但是代码量很少。每个节点尝试作为路径最高点更新答案,但递归返回值只向父节点提供最优单边路径,负贡献路径果断舍弃。
class Solution {
private int ans = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
dfs(root);
return ans;
}
private int dfs(TreeNode root) {
if (root == null) {
return 0; // 没有节点和为0
}
int lVal = dfs(root.left); // 左子树路径和
int rVal = dfs(root.right); // 右子树路径和
ans = Math.max(ans, lVal + rVal + root.val);
// 当前子树最大路径和,和0比防止负数
return Math.max(Math.max(lVal, rVal) + root.val, 0);
}
}
4 回溯
一般回溯模板:
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> backtrack(int[] nums) {
backtracking(nums, 0);
return result;
}
private void backtracking(int[] nums, int startIndex) {
// 1. 收集结果(子集问题在这里收集,组合/排列在终止条件收集)
result.add(new ArrayList<>(path));
// 2. 终止条件(可选,视具体问题而定)
if (startIndex >= nums.length) {
return;
}
// 3. 遍历选择列表
for (int i = startIndex; i < nums.length; i++) {
// 3.1 做选择
path.add(nums[i]);
// 3.2 递归进入下一层(注意:子集/组合用 i+1,排列用 used 数组)
backtracking(nums, i + 1);
// 3.3 撤销选择(回溯)
path.removeLast();
}
}
}
经典的回溯问题,可以使用一个 flag[] 记录数组中的哪些值已经添加。
注意:回溯时要使用临时变量的数值 ans.add(new ArrayList<>(tmp));,而不是使用引用 ans.add(tmp);,否则回溯结束后,tmp 被清空 → ans 中所有列表都变成空列表 []。
恢复现场是为了让 同一层的不同分支 能基于 相同的初始状态 独立探索,互不干扰。这是回溯算法 "试错" 思想的精髓!
回溯的模板题,每次递归使用一个计数器记录到第几个字母,不过需要用到字符串。
也是一道回溯模板题,但是分为选中和不选中当前整数两种情况,每次递归使用一个下标计数 idx 记录当前到整数数组的第几个数,当 idx == candidates.length 时说明没找到直接返回,当前回溯状态的 target == 0 添加后返回。
将当前元素添加到之前的子集即可,例如当前元素 2,之前 子集包括 [], [1],添加后得到 [], [1], [2], [1,2],注意获取子集时要 new ArrayList<>(ans.get(j));,否则传递的是 ans 的引用,会影响到 ans 。
22. 括号生成 ⭐
回溯模板题,如果左括号数量不大于 n,可以放一个左括号;如果右括号数量小于左括号的数量,可以放一个右括号。添加左/右括号后恢复现场。
79. 单词搜索 ⭐
使用一个 visited 数组记录访问过的位置,深度优先搜索时边界如下,cnt 表示当前需要匹配 word 的第 cnt 个字符(索引从 0 开始)。
if (board[i][j] != word.charAt(cnt)) {
return false;
} else if (cnt == word.length() - 1) {
return true;
}
此题和之前大一写过的象棋中“马”可以到达棋盘的位置问题和八皇后问题类似。
131. 分割回文串 ⭐⭐
枚举每个位置是否插入 "分割点",例如 “aab” 有四个分割点,通过 DFS 遍历所有可能的分割方案。下面是决策树 “aab”:

模板套用:
void dfs(状态参数) {
if (满足终止条件) {
记录答案(注意复制);
return;
}
// 分支1:不选当前分割点
if (可继续扩展) {
dfs(扩展状态);
}
// 分支2:选当前分割点(需满足约束)
if (满足约束条件) {
做选择;
dfs(新状态);
撤销选择; // 回溯
}
}
51. N 皇后⭐⭐
逐行放置皇后,每行选一个合法列,若冲突则回溯尝试其他位置。
int[] queens = new int[n]; // queens[r] = c 表示第r行皇后在第c列
boolean[] col = new boolean[n]; // 列占用状态
boolean[] diag1 = new boolean[n * 2 - 1]; // 主对角线 (\) r + c
boolean[] diag2 = new boolean[n * 2 - 1]; // 副对角线 (/) r - c + n - 1
主对角线 \ 副对角线 /
r + c = 常数 r - c = 常数
索引: r+c 索引: r-c+n-1
放置: 三个标记变 true
回溯: 三个标记变 false
queens: 直接覆盖,不管恢复
5 贪心
股价最低的那天买入最好,如果最低的那天是最后一天则返回 0。
逆向贪心:从终点往前推,维护「当前能够到达目标位置的位置」,能跳到就更新目标,最后看起点能否到达。
「贪心」地进行正向查找,每次找到可到达的最远位置,就可以在线性时间内得到最少的跳跃次数。
同一字母最多出现在一个片段中,所以只需要该字母的第一次出现的下标位置和最后一次出现的下标位置必须出现在同一个片段,先遍历字符串,记录字符串中字母最后一次出现的位置。例如 "ababcc",last [0] = 2(0 表示‘a’)。再遍历一次字符串,用 start 和 end 指针标记子串的起始位置,子片段的结束的位置一定大于等于当前字母最后一次出现的位置所以 end = Math.max(end, last[s.charAt(i) - 'a']);,当遍历到 i == end 时说明当前段的所有字符已经遍历完,可以加入到 ans ,所以 ans.add(end - start + 1); start = end + 1;。
6 子串
计算一个 前缀和,可以使用 s[0] = 0,s[1] = nums[0] 方便计算,再二重循环暴力枚举有多少个 s[j] − s[i] = k,但这样太慢了,$O(n^2)$。
for (int i = 0; i < s.length - 1; i++) {
for (int j = i + 1; j < s.length; j++) {
if (s[j] - s[i] == k) {
ans++;
}
}
}
使用一个哈希表解决二重循环的问题:每次内层循环需要找到是否有 s[j] - s[i] == k,所以说把对应的数存起来就行了。
Map<Integer, Integer> cnt = new HashMap<>(n + 1, 1);
for (int sj : s) {
// sj 左边有多少个 sj-k
// 因为 sj-si=k -> si=sj-k(j>i),所以这里的sj-k就等于si
ans += cnt.getOrDefault(sj - k, 0);
cnt.merge(sj, 1, Integer::sum); // 对应数的个数加 1
}
还可以继续优化:一边计算前缀和,一边遍历前缀和。在遍历 nums 之前,需要先统计 s [0] = 0,即空前缀的元素和等于 0。往 cnt 中添加 cnt [0] = 1。
单调队列(存储下标):
- 右边入(元素进入 队尾,同时维护队列 单调性,即移除比当前数小的队列元素);(能力不足的不要)
- 左边出(不在当前窗口范围的最左元素淘汰 队首);(年龄大的也不要)
- 记录/维护答案(记录 队首 元素为答案,因为队首元素一直是最大值,记得防止队首越界)。
- 初始化 ansLeft = −1, ansRight = m,用来记录最短子串的左右端点,其中 m 是 s 的长度。
- 用一个哈希表(或者数组)cntT 统计 t 中每个字母的出现次数。
- 初始化 left = 0,以及一个空哈希表(或者数组)cntS,用来统计 s 子串中每个字母的出现次数。
- 遍历 s,设当前枚举的子串右端点为 right,把 s [right] 的出现次数加一。
- 遍历 cntS 中的每个字母及其出现次数,如果出现次数都大于等于 cntT 中的字母出现次数:
- 如果 right−left < ansRight−ansLeft,说明我们 找到了更短的子串,更新 ansLeft = left, ansRight = right。
- 把 s [left] 的出现次数减一。
- 左端点右移,即 left 加一。
- 重复上述三步,直到 cntS 有字母的出现次数小于 cntT 中该字母的出现次数为止。
- 最后,如果 ansLeft < 0,说明没有找到符合要求的子串,返回空字符串,否则返回下标 ansLeft 到下标 ansRight 之间的子串。
7 双指针
i0 记录第一个 0 的下标,即最左边的 0 ,碰到非 0 时与第一个 0 交换数值。
两端开局,短板先走(移动短板可能面积变大,移动长板面积一定变小),边缩边记最大值。
15. 三数之和 ⭐⭐⭐
先排序,最外层循环固定一个,双指针找两数,三处去重不能忘(外层重复数、nums [i] > 0 时、内层双指针重复数)。两层循环,外层循环固定一个,内层循环找两数。
注意添加数组元素的方式:ans.add(Arrays.asList(nums[i], nums[l], nums[r]));。
42. 接雨水 ⭐⭐
左右双指针解法,"谁小谁动,谁小决定":因为对于当前 位置,能接的雨水 = min(左侧最大高度, 右侧最大高度) - 当前高度。
- 哪边的最大值小,哪边指针移动
- 哪边的最大值小,就用它来计算接水量
8 动态规划
第 x 级台阶的方案数是爬到第 x −1 级台阶的方案数和爬到第 x −2 级台阶的方案数的和
注意边界,按照公式递推即可。
假设一共有 $n$ 个房子,每个房子的金额分别是 $H_0,H_1,…,H_{n−1}$,子问题 $f(k)$ 表示从前 $k$ 个房子(即 $H_0,H_1,…,H_{k−1}$)中能偷到的最大金额。那么,偷 $k$ 个房子有两种偷法:
所以递推公式如下:
$$
f(k)= max{f(k−1), H_{k−1}+f(k−2)}
$$
279. 完全平方数
这些数必然落在区间 $[1, \sqrt{i}]$。我们可以枚举这些数,假设当前枚举到 $j$,那么我们还需要取若干数的平方,构成 $i−j^2$。此时我们发现该子问题和原问题类似,只是规模变小了。这符合了动态规划的要求,于是我们可以写出状态转移方程。
$$
f [i] = 1+\min_{j = 1}^{\left\lfloor\sqrt{i}\right\rfloor}f [i-j^{2}]
$$
同时因为计算 $f[i]$ 时所需要用到的状态仅有 $f[i−j2]$,必然小于 $i$,因此我们只需要从小到大地枚举 $i$ 来计算 $f[i]$ 即可。此题属于完全背包问题。
"dp 记最少,初始无穷大,0 元是基准,枚举硬币做转移,超 amount 返回-1"。
- 状态定义:dp [i] = 凑成金额 i 的最少硬币个数
- 初始化:dp [0] = 0,其余为 amount+1(无穷大)
- 状态转移:dp [i] = min(dp [i], dp [i-coin] + 1)
- 返回判断:dp [amount] > amount 说明无解,返回 -1
核心思想:把字符串 s 的前 i 个字符能否拆分分解为更小的子问题
状态定义:dp [i] = 字符串 s 的前 i 个字符(s [0: i])能否被成功拆分
转移方程:枚举分割点 j,若前 j 个可拆分且 s [j: i] 在字典中,则前 i 个可拆分
核心公式:dp[i] = dp[j] && wordDict.contains(s.substring(j, i))
核心思想:以每个位置结尾的 LIS 长度依赖于前面所有更小元素的最优解
状态定义:dp [i] = 以 nums [i] 结尾的最长递增子序列长度
转移方程:枚举所有 j < i,若 nums [j] < nums [i],则 dp [i] = max(dp [j] + 1)
152. 乘积最大子数组 ⭐⭐
这里需要 分正负讨论,并不是前一个位置结尾的某个段的最大值。例如 $a={5,6,−3,4,−3}$,那么此时 $f_{max}(i)$ 对应的序列是 ${5,30,−3,4,−3}$,按照前面的算法我们可以得到答案为 30,即前两个数的乘积,而实际上答案应该是全体数字的乘积。所以:考虑当前位置如果是一个负数的话,那么希望以它前一个位置结尾的某个段的积也是个负数,这样就可以负负得正,并且希望这个积尽可能「负得更多」,即尽可能小。如果当前位置是一个正数的话,更希望以它前一个位置结尾的某个段的积也是个正数,并且希望它尽可能地大。可以再维护一个 $f_{min}(i)$,它表示以第 i 个元素结尾的乘积最小子数组的乘积,那么可以得到这样的动态规划转移方程:
$$
\begin{aligned}&f_{\max}(i)=\max_{i = 1}^{n}{f_{\max}(i-1)\times a_{i}, f_{\min}(i-1)\times a_{i}, a_{i}}\&f_{\min}(i)=\min_{i = 1}^{n}{f_{\max}(i-1)\times a_{i}, f_{\min}(i-1)\times a_{i}, a_{i}}\ \end{aligned}
$$
双 DP 记最大最小,正数不变号,负数要翻转,全局更新取最大。
416. 分割等和子集 ⭐⭐
"0-1 背包" 的可行性变种:每个数字只能用一次
核心思想:问题转化为 "能否从数组中选出若干元素,使其和等于 sum/2"
状态定义:dp [j] = 能否凑出和为 j 的子集
转移方程:对于每个数字,可以选或不选,取逻辑或
不用动态规划的方法。
"栈底-1 做基准,左括号入栈,右括号匹配就弹,不匹配更新基准"
初始化:栈底放 -1 作为长度计算基准
左括号:直接入栈(存下标)
右括号 + 有匹配:弹出栈顶,i - 新栈顶 = 长度(先弹栈再计算长度,否则丢失之前连续的有效括号长度)
右括号 + 无匹配:更新栈底为当前位置(新分割点)
9 多维动态规划
62. 不同路径
$$
f(i, j)= f(i-1, j)+f(i, j-1)
$$
当 $i > 0$ 且 $j = 0$ 时, $ dp[i][0] = dp[i - 1][0] + grid[i][0] $ 。
当 $i=0$ 且 $j>0$ 时, $ dp[0][j]=dp[0][j-1]+grid[0][j] $ 。
当 $i > 0$ 且 $j > 0$ 时, $ dp[i][j] = \min(dp[i-1][j], dp[i][j-1]) + grid[i][j] $ 。
不使用动态规划使用 中心枚举技巧,回文串中心有两种类型:字符中心(奇数长度)+ 间隙中心(偶数长度), 总中心数 = n 个字符 + (n-1) 个间隙 = $2 \times n - 1$。2n-1 个中心,l = i/2, r =(i+1)/2,扩展后长度 r-l-1,答案区间[l+1, r)。
多出来的第 0 行和第 0 列表示 "空字符串" 的情况,作为 DP 的基准状态,避免复杂的边界判断
核心公式:
- 字符相等:
dp[i][j] = dp[i-1][j-1] + 1 - 字符不等:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
二维动态规划思路:相同时直接 dp[i][j] = dp[i - 1][j - 1];,不同时按照下面的表格操作。
| 操作 | 含义 | 对应状态 |
|---|---|---|
| 删除 | 删掉 word1[i-1] |
dp[i-1][j] + 1 |
| 插入 | 在 word1 末尾插入 word2[j-1] |
dp[i][j-1] + 1 |
| 替换 | 把 word1[i-1] 换成 word2[j-1] |
dp[i-1][j-1] + 1 |
一维优化:二维 DP 中,计算第 i 行只需要第 i-1 行的数据。因此可以用 一维数组滚动更新,复用空间;但是更新 f[j+1] 时,需要 dp[i-1][j+1](上方),但它会被覆盖,所以 pre 变量保存左上角的值,tmp 临时存储。
| 变量 | 含义 |
|---|---|
f[j] |
当前数组第 j 位(更新前 = 上一行,更新后 = 当前行) |
pre |
左上角的值 dp[i-1][j-1] |
tmp |
临时保存 f[j+1] 的旧值(即 dp[i-1][j+1],下一轮的 pre) |
10 滑动窗口
模板
// 外层循环扩展右边界,内层循环扩展左边界
for (int l = 0, r = 0 ; r < n ; r++) {
// 当前考虑的元素
while (l <= r && check()) { // 区间[left,right]不符合题意
// 扩展左边界
}
// 区间[left,right]符合题意,统计相关信息
}
左指针:外层循环遍历每个可能的窗口起点;
右指针:内层循环尽可能向右扩展(不重复前提下),注意初始化为 -1;
收缩窗口:左指针右移时移除 上一次循环 对应字符,boolean[] has = new boolean[128]; 记录窗口内是否重复;
更新答案:每次扩展后(内层循环结束)更新最大长度。
初始化:统计第一个窗口和 p 的字符频率;
首检查:立即比较第一个窗口;
滑动:每次移除左边字符,添加右边字符;
比较:数组相等则记录起始索引,Arrays.equals() 判断是否相等。
附:为什么要先初始化再滑动?
避免重复代码:如果从 i = 0 开始滑动,需要特殊处理第一个窗口;先初始化后,滑动逻辑统一。
11 普通数组
动态规划的思路,遍历数组时,在每个位置做出一个贪心/动态规划的决策:是“把当前数字接在之前的子数组后面”,还是“丢弃之前的部分,以当前数字作为新子数组的起点”?,不过这里不需要创建一个一维数组,只需要前一个和 $f(i-1)$ 维护即可。
$$
f(i)=\max{f(i-1)+nums [i], nums [i]}
$$
56. 合并区间 ⭐
排:按区间起点升序排序:Arrays.sort(intervals, (p, q) -> p[0] - q[0]);;
比:当前区间起点 ≤ 结果集最后一个区间的终点?
合:是 → 合并(更新终点为 max);否 → 直接添加
注意返回数据:return ans.toArray(new int [0][]); 因为初始化时:List <int[]> ans = new ArrayList <>();
三次反转:整体翻 → 前 k 翻 → 后 k 翻 = 向右旋转 k 位
利用 索引左侧 所有数字的乘积和 右侧 所有数字的乘积(即前缀与后缀)相乘得到答案。如果使用 $O(1)$ 的空间复杂度,可以使用结果数组存储左侧乘积,再使用一个变量记录右侧乘积。
在一个长度为 n 的数组中,缺失的第一个正数 一定在 [1, n+1] 这个范围内。
- 最好的情况:数组里正好包含了 1,2,3,..., n 这 n 个数。那么缺失的第一个正数就是 n+1 。
- 其他情况:只要数组里缺了 1 到 n 之间的任何一个数,那么缺失的第一个正数就一定 ≤n 。
想象有一间教室,座位从左到右编号为 1 到 n。
-
有 n 个学生坐在教室的座位上,把 nums [i] 当作坐在第 i 个座位上的学生的学号。我们要做的事情,就是让学号在 1 到 n 中的学生,都坐到编号与自己学号相同的座位上(学号与座位编号匹配)。学号不在 [1, n] 中的学生可以忽略,要考虑重复的情况(在第三个例子中,虽然 $nums[2]=1\neq2$,但由于 nums [nums[2]]= nums [1] = 1,所以 nums [2] 是个影分身,并且其真身坐在了正确的座位上,所以可以忽略 nums [2],向后遍历。)。
-
学生们交换座位后,从左往右看,第一个学号与座位编号不匹配的学生,其座位编号就是答案。
-
特别地,如果所有学生都坐在正确的座位上,那么答案是 n+1。
12 矩阵
用两个标记数组分别记录每一行和每一列是否有零出现,空间复杂度为 $O(m+n)$。如果空间复杂度必须为 $O(1)$,可以考虑用原矩阵的第一行、第一列记录,但这样会导致原数组的第一行和第一列被修改,无法记录它们是否 原本包含 0。因此需要额外使用两个标记变量分别记录第一行和第一列是否原本包含 0。注意随后遍历整个矩阵时从 1 开始。
54. 螺旋矩阵 ⭐⭐
沿着 右 → 下 → 左 → 上 的顺序遍历,每走完一边就 "收缩" 对应的边界,并检查是否遍历结束,直到遍历完所有元素。
- 边界收缩时机:每遍历完一条边,立即收缩 对应的边界
- 向右遍历完 →
upLimit++(上边界下移) - 向下遍历完 →
rightLimit--(右边界左移) - 向左遍历完 →
downLimit--(下边界上移) - 向上遍历完 →
leftLimit++(左边界右移)
- 向右遍历完 →
- 终止检查:每次收缩边界后都检查
res.size() == row * col,防止越界(比如单行或单列的矩阵) - 循环变量方向:
- 向右/向下:
i++(递增) - 向左/向上:
i--(递减)
- 向右/向下:
48. 旋转图像⭐⭐
因为是正方形,以左上,右上,左下,右下为四个点,每次将四个点按顺时针旋转,下一次就 +1 偏移量取四个点旋转一遍,外层旋转完,然后往里进一层,就是上下左右往里面缩一层循环即可。
起点:右上角 (0, n-1) 开局
比较:当前值与 target 比较
移动:小则向下(排除行),大则向左(排除列)
终止:找到返回 true,出界返回 false
13 图论
深度优先搜索(更简单容易理解):遍历所有格子,如果当前格子为 ‘1’,则深度优先搜索并将遍历过的格子置‘0’防止反复遍历。
广度优先搜索:到达一个岛屿后标记为“海”防止重复遍历。
"多源 BFS,fresh 计数,每轮扩散时间+1,最后检查有无剩余"
初始化:统计新鲜橘子,收集初始腐烂位置
BFS 扩散:每轮代表一分钟,所有腐烂橘子同时扩散,新鲜橘子才能腐烂
更新状态:感染后 fresh--,grid 标记为 2
返回判断:fresh > 0 返回-1,否则返回 ans
207. 课程表 ⭐
用有向图描述依赖关系:
让入度为 0 的课入列,它们是能直接选的课。然后逐个出列,出列代表着课被选,需要减小相关课的入度。如果相关课的入度新变为 0,安排它入列、再出列……直到没有入度为 0 的课可入列。可以用一个数组存储课程的入度数量,一个二维链表存储邻接表。
- 入度数组:课号 0 到 n - 1 作为索引,通过遍历先决条件表求出对应的初始入度。
- 邻接表:用二维矩阵,但有点大
key:课号
value:依赖这门课的后续课(数组)
初始化:创建一棵 26 叉树,一开始只有一个根节点 root。26 叉树的每个节点包含一个长为 26 的儿子节点列表 son,以及一个布尔值 end,表示是否为终止节点。
private static class Node {
Node[] son = new Node[26];
boolean end = false; // 标记当前节点是否是一个单词的结尾
}
private final Node root =new Node();
- insert:
- 遍历字符串 word,同时用一个变量 cur 表示当前在 26 叉树的哪个节点,初始值为 root。
- 如果 word [i] 不是 cur 的儿子,那么创建一个新的节点 node 作为 cur 的儿子。如果 word [i] = a,那么把 node 记录到 cur 的 son [0] 中。如果 word [i] = b,那么把 node 记录到 cur 的 son [1] 中。依此类推。
- 更新 cur 为儿子列表中的相应节点。
- 遍历结束,把 cur 的 end 标记为 true。
- search 和 startsWith 可以复用同一个函数 find:
- 遍历字符串 word,同时用一个变量 cur 表示当前在 26 叉树的哪个节点,初始值为 root。
- 如果 word [i] 不是 cur 的儿子,返回 0。search 和 startsWith 收到 0 之后返回 false。
- 更新 cur 为儿子列表中的相应节点。
- 遍历结束,如果 cur 的 end 是 false,返回 1,否则返回 2。search 如果收到的是 2,返回 true,否则返回 false。startsWith 如果收到的是非 0 数字,返回 true,否则返回 false。
构造函数为空。
private static class Node {
Node[] son = new Node[26];
boolean end = false;
}
private final Node root = new Node();
14 二分查找
可以采用矩阵中 240. 搜索二维矩阵 II 一样的非二分方法。
基础的二分查找问题,使用左闭右开:Arrays.binarySearch() 源码就是这种思想,right = nums.length 天然防止 right = nums.length - 1 时的 -1 越界问题。二分查找核心思想是 “排除不可能的一半”,所以一定按照先写 l = mid + 1; 的写法。
public int search(int[] nums, int target) {
// 使用左闭右开的写法更方便
int l = 0, r = nums.length;
while (l < r) { // 这种写法当 l==r 时为空
int mid = l + (r - l) / 2; // (l+r) 的防溢出写法
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
// 确定排除 nums[mid],一定按照模版写,不要写颠倒两种情况
l = mid + 1; // [mid+1, r)
} else {
r = mid; // [l,mid)
}
}
return -1;
}
在标准二分查找的基础上删除 nums[mid] == target 部分并最后返回 l 是查找左边界的代码(第一个 >= target 的元素下标),随后再 int end = lowerBound(nums, target + 1) - 1;,最后 return new int[] { start, end } 即可。
旋转过后把数组分为了分别递增的两段。设 x = nums [mid] 是现在二分取到的数。把 x 与最后一个数 nums [n−1] 比大小:
-
如果 x > nums [n−1],那么可以推出以下结论:
- nums 一定被分成左右两个递增段;第一段的所有元素均大于第二段的所有元素;x 在第一段。最小值在第二段。所以 x 一定在最小值的左边。
-
如果 x ≤ nums [n−1],那么 x 一定在第二段。(或者 nums 就是递增数组,此时只有一段。)x 要么是最小值,要么在最小值右边。
所以依旧是二分查找的思路,最后返回 nums[left] 即可。
两次遍历:可以利用 153. 寻找旋转排序数组中的最小值 的思路,先找到最小值,随后最小值左侧是第一段而右侧是第二段,目标值大于最后一个数说明在第一段。小于等于最后一个数说明在第二段。
一次遍历,分四种情况:(设 x = nums [mid])
-
如果 x 和 target 在不同的递增段:如果 target 在第一段,x 在第二段,说明 target 在 x 的左边(target > last && x < last)。
如果 x 在第一段,target 在第二段,说明 target 在 x 的右边(target <last && x> last)。
-
如果 x 和 target 在相同的递增段(同时大于或同时小于 last):正常二分查找即可。
下面是时间复杂度 $O(m+n)$ 的算法:
- 双指针 a/b 分别指向两数组头部
- 每次取较小值,指针后移,用 x 记录当前、y 记录上一个
- 遍历 (m+n)/2+1 次后,根据奇偶返回 x 或 (x+y)/2
15 堆
第 k 大元素在升序数组中的下标是 n − k。按照快速排序的思路:在数组中随机选一个基准元素 pivot;将 <=pivot 的元素放左边, > pivot 的元素放右边,此时 pivot 的位置等于排好序后的位置;设 pivot 下标为 i , i = n−k,答案就是 pivot。 i > n−k,答案在 pivot 左侧,在其中寻找,回到第一步。i < n−k,说明在 pivot 右侧,在其中寻找,回到第一步;类似于二分查找的思想。
如果是 < pivot 和 > pivot ,当数组包含大量重复元素时,划分后的 i 往往是子数组第一个元素的下标,算法会退化至 $O(n^2)$。
先使用一个 HashMap 遍历数组简历数字与个数的关系,再使用库函数按频率降序排列构建大顶堆,然后把 map 全部放入大顶堆,最后取出后前 k 个数即可。
// 构建大顶堆,按频率降序排列
PriorityQueue<Map.Entry<Integer, Integer>> pq = new PriorityQueue<>((e1, e2) -> e2.getValue() - e1.getValue());
pq.addAll(map.entrySet()); // 放入大顶堆
ans[i] = pq.poll().getKey(); // 取出前 k 个
不常见的:优先队列的创建、map 存入优先队列、取出队列的值。
中位数把这 6 个数均分成了左右两部分,一边是 left = [1,2,3],另一边是 right = [4,5,6]。我们要计算的中位数,就来自 left 中的最大值,以及 right 中的最小值。随着 addNum 不断地添加数字,我们需要:
- 保证 left 的大小和 right 的大小尽量相等。规定:在有奇数个数时,left 比 right 多 1 个数。
- 保证 left 的所有元素都小于等于 right 的所有元素。
要时刻满足以上两个要求,添加元素时分类讨论:
- 如果当前 left 的大小和 right 的大小相等:
- 如果添加的数字 num 比较大,比如添加 7,那么把 7 加到 right 中。现在 left 比 right 少 1 个数,不符合前文的规定,所以必须把 right 的最小值从 right 中去掉,添加到 left 中。如此操作后,可以保证 left 的所有元素都小于等于 right 的所有元素。
- 如果添加的数字 num 比较小,比如添加 0,那么把 0 加到 left 中。
- 这两种情况可以合并:无论 num 是大是小,都可以 先把 num 加到 right 中,然后把 right 的最小值从 right 中去掉,并添加到 left 中。
- 如果当前 left 比 right 多 1 个数:
- 如果添加的数字 num 比较大,比如添加 7,那么把 7 加到 right 中。
- 如果添加的数字 num 比较小,比如添加 0,那么把 0 加到 left 中。现在 left 比 right 多 2 个数,不符合前文的规定,所以必须把 left 的最大值
- 从 left 中去掉,添加到 right 中。如此操作后,可以保证 left 的所有元素都小于等于 right 的所有元素。
这两种情况可以合并:无论 num 是大是小,都可以 先把 num 加到 left 中,然后把 left 的最大值从 left 中去掉,并添加到 right 中。
所以堆可以完成这样的操作:left 是最大堆,right 是最小堆。
- 如果当前有奇数个元素,中位数是 left 的堆顶。
- 如果当前有偶数个元素,中位数是 left 的堆顶和 right 的堆顶的平均值。
private PriorityQueue<Integer> left = new PriorityQueue<>((a, b) -> b - a); // 最大堆,最大值在堆顶
private PriorityQueue<Integer> right = new PriorityQueue<>(); // 最小堆,最小值在堆顶
16 哈希
用哈希表存{key = 遍历过的数组元素, value = 对应下标},遍历到一个新元素时检查是否满足 map.containsKey(target - nums[i]),满足直接 return,否则继续记录 map.put(nums[i], i);。要记住哈希表常用的 API。
遍历:遍历每个字符串
排序:char[] 排序后 转 String 做 key,注意不能计算字母值作为 key,因为不同字母的串值也可能相同,一定要 new String(char [])。
分组:getOrDefault 获取或创建列表,add 原字符串
返回:new ArrayList<>(map.values())
熟练掌握 API map.getOrDefault() 、 map 转换为 List: new ArrayList<List<String>>(map.values()) 以及存储为 key 时要 String key = new String(a)。
去重:所有数字存入 HashSet
判断起点:num-1 不存在才是起点
扩展:从起点向右连续扩展
更新:记录最大长度
17 栈
- 由于括号两两一对,所以 s 的长度必须是偶数。如果 s 的长度是奇数,可以直接返回 false。
- 创建一个哈希表(或者数组)
new HashMap<>() { {put('}', '{'); },保存每个右括号对应的左括号,可以直接判断栈顶的左括号是否与右括号为同一类型。 - 左括号直接入栈,右括号判断栈是否为空或者栈顶没有与之对应的左括号。
- 最后返回 栈是否为空。
- 一般的可以使用一个 辅助栈 保存最小值;初始化时辅助栈初始化 push 一个最大值,入栈时辅助栈压入 min(辅助栈栈顶元素,当前值),弹栈时弹出栈和辅助栈的栈顶元素,求最小值时返回辅助栈栈顶元素。
- 不使用辅助栈:栈内 元素为数组(数组大小为 2),相当于栈中除了保存添加的元素,还保存 前缀最小值;初始化时入栈
{0, Integer.MAX_VALUE},入栈要比较最小值,弹栈时直接弹栈,求最小值时返回前缀最小值。
需要用到两个栈、两个临时变量,分别是 int、StringBuilder 类型。
数字:multi = multi * 10 + (c - '0')
左括号:倍数和结果入栈,重置 multi 和 res
右括号:出栈获取倍数,重复当前 res,拼接上层结果
字母:直接 append 到当前 res
单调栈 题解
遍历:从右向左递减单调栈 (i = n-1 → 0)
维护:弹出所有 ≤ 当前温度的栈顶
计算:栈不空则 ans [i] = 栈顶 - i
入栈:当前索引入栈
易错:栈中存储温度值而非索引 → 无法计算天数差
对于每个柱子 i,向左找第一个比它矮的即 left,向右找第一个比它矮的即 right,所以 L = left+1 和 R = right-1 都比当前柱子高都可以包含在当前柱子的矩形内,所以宽就是 R-L+1 = right-left-1,当前柱子的最大矩形面积就是 heights[i]*(right-left-1)。如果从左到右存递增的单调栈,那么向左找第一个比它矮的就是 left,向右找第一个比它矮的就是 right。
单调栈
遍历:从左向右递增单调栈,实际上循环遍历的是 right,弹栈的是当前柱子 i ,弹栈后的栈顶是
tip:需要遍历到 heights [n],使用一个哨兵 st.push(-1);。
18 技巧
数组中的全部元素的异或运算结果即为数组中只出现一次的数字。
核心思想:不同元素相互抵消,最后剩下的必然是多数元素
关键技巧:用 "血量" 概念,同阵营加血,异阵营扣血
前提条件:多数元素存在且出现次数 > n/2
使用两个指针记录最后 0 和 1 的位置,先存值,默认填 2,小于等于 1 就放 1,等于 0 再覆盖为 0。可以这样理解:22222222222 先全变成 2,11111122222 再变成 1,00011122222 再变成 0。
解释:下一个排列指的是将数组中所有数字重排列后大于原数据中最小的数,例如 [1,2,3] 重排列得到 [1,2,3]、[1,3,2]、[3,1,2]、[2,3,1],其中大于 123 的最小的数是 132,所以答案为 132。
核心思想:找到尽可能靠右的 "较小值",与右侧 "略大的值" 交换,然后反转右侧
关键技巧:利用右侧已知的降序特性,避免排序
原地修改:空间 O(1),不能分配额外空间
类似于环形链表,因为数字都在 [1, n] 范围内(包括 1 和 n),所以 nums [nums[i]] 可以在数组中找到对应的数,142 题中慢指针走一步 slow = slow.next ==> 本题 slow = nums [slow],142 题中快指针走两步 fast = fast.next.next == > 本题 fast = nums [nums[fast]];快慢指针进入环之后,再使用一个头指针从头开始遍历,由于慢指针一直在两个数的环内遍历,所以当头指针和慢指针相同时返回慢指针即可。举例:
数组映射为链表:
索引: 0 → 1 → 2 → 3 → 4
值: 1 → 3 → 4 → 2 → 2
路径:0 → 1 → 3 → 2 → 4 → 2 → 4 → ...
↑ ↑
└────┘ 环:2→4→2

浙公网安备 33010602011771号