【算法】力扣
数据结构
数组;字符串;链表;哈希;栈、辅助栈、单调栈;队列、单调队列、优先队列;树、二叉树、二叉搜索树、字典树、线段树;图;集合框架
链表
链表
链表定义:
public class ListNode {
int val;
ListNode next;
ListNode() { }
}
做题步骤:
- 按需定义
dummy节点 - 按需定义
prev、now、slow、fast指针 - 确定指针的含义
- 确定指针指向的变化
- 更新指针
栈
栈、辅助栈、单调栈
单调栈:
-
对于每个元素,找出第一个满足某种条件的元素的位置:寻找「左边 / 右边」第一个比我「大 / 小」的
-
解题形式
// 寻找右边第一个比我大的 int [] ans = new int[n]; Deque<Integer> stack = new LinkedList<>(); for (int i = 0; i < n; i++) { // 单调栈, 递减, 只有新元素大于栈顶时, 才出栈, 同时更新答案 while (!stack.isEmpty() && temperatures[stack.peek()] < temperatures[i]) { int idx = stack.pop(); ans[idx] = i - idx; } stack.push(i); // 不管是否出栈, 都要将新元素入栈 }- 寻找「右边」第一个比我「大」的:从左到右遍历,单调栈递减,新元素大于栈顶时出栈
- 寻找「右边」第一个比我「小」的:从左到右遍历,单调栈递增,新元素小于栈顶时出栈
- 寻找「左边」第一个比我「大」的:从右到左遍历,单调栈递减,新元素大于栈顶时出栈
- 寻找「左边」第一个比我「小」的:从右到左遍历,单调栈递增,新元素小于栈顶时出栈
队列
队列、单调队列、优先队列
- 队列:一头进,一头出
- 双端队列:两头进,两头出
- 单调队列:队头是最大 / 最小值,从队头到队尾,递减 / 递增;队头出队,队尾入队,不满足单调性时,队尾出队;可用双端队列实现
树
树、二叉树、二叉搜索树、字典树、线段树
二叉树定义:
public class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode() { }
}
二叉树遍历:
-
DFS:前序、中序、后序,递归或使用栈迭代实现
- 无论前序、中序、后序,左节点都在右节点之前遍历
- 前序:中间节点在左右节点之前遍历
- 中序:中间节点在左右节点之间遍历
- 后序:中间节点在左右节点之后遍历
// 递归写法 public void dfs(TreeNode node) { if (node == null) { return; } dfs(node.left); ans.add(node.val); // 前序、中序、后序, 取决于本行代码的位置 dfs(node.right); }// 迭代写法 (与递归时访问顺序严格相同) Deque<TreeNode> stack = new LinkedList<>(); TreeNode tmp = root; TreeNode prev = null; // 上一次添加到 ans 的节点 while (!(stack.isEmpty() && tmp == null)) { // 栈不为空, 或 tmp 不为空 while (tmp != null) { // ans.add(tmp.val); // 前序遍历 stack.push(tmp); tmp = tmp.left; } tmp = stack.pop(); // ans.add(tmp.val); // 中序遍历 // tmp = tmp.right; // 前序遍历, 中序遍历 // 后序遍历 if (tmp.right == null || tmp.right == prev) { // 如果右节点为空, 或右节点为上一次添加到 ans 的节点, 说明当前节点可以被添加到 ans ans.add(tmp.val); prev = tmp; tmp = null; } else { // 否则继续向右遍历 stack.push(tmp); tmp = tmp.right; } }// 迭代写法 (与递归时访问顺序不同, 仅前序和后序) Deque<TreeNode> stack = new LinkedList<>(); if (root != null) { stack.push(root); } while (!stack.isEmpty()) { TreeNode node = stack.pop(); ans.add(node.val); if (node.right != null) { // 前序先将右节点入栈, 后序先将左节点入栈 stack.push(node.right); } if (node.left != null) { stack.push(node.left); } } // Collections.reverse(ans); // 后序需要反转结果数组 -
BFS:层次,使用队列迭代实现
Queue<TreeNode> q = new LinkedList<>(); if (root != null) { q.add(root); } while (!q.isEmpty()) { int cnt = q.size(); List<Integer> one = new ArrayList<>(); for (int i = 0; i < cnt; i++) { TreeNode node = q.poll(); if (node.left != null) { q.offer(node.left); } if (node.right != null) { q.offer(node.right); } one.add(node.val); } ans.add(one); }
二叉搜索树:
- 左子树只包含小于当前节点的数,右子树只包含大于当前节点的数,左子树和右子树自身也是二叉搜索树
- 中序遍历得到的结果是有序的
字典树:
public class TreeNode {
TreeNode [] child = new TreeNode[26];
boolean end = false; // 需要判断当前节点是否是某个单词的结尾
TreeNode() {}
}
线段树:
-
维护区间信息:在 \(O(\log N)\) 的时间复杂度内实现单点修改、区间修改、区间查询 (求和 / 求最大值 / 求最小值)
-
原始数组是 \(a\),线段树是 \(d\)
- \(d_1\) 中包含 \(a\) 中 \([1, n]\) 的信息,\(d_2\) 中包含 \(a\) 中 \([1, \frac{n}{2}]\) 的信息,\(d_3\) 中包含 \(a\) 中 \([\frac{n}{2}+1, n]\) 的信息
- 如果 \(d_i\) 表示的是 \([s, t]\) 的信息,则左儿子节点 \(d_{2\times i}\) 表示的是 \([s, \frac{s+t}{2}]\) 的信息,右儿子节点 \(d_{2\times i+1}\) 表示的是 \([\frac{s+t}{2}+1, t]\) 的信息
- 堆式存储,空间复杂度为 \(O(4n)\)
-
原始数组是 \(a\),线段树是 \(d\),懒惰标记是 \(b\)
- 每次修改时,使用懒惰标记表明该节点对应的区间在某一次操作中被更改,但不更新该节点的子节点的信息,实际的修改在下一次访问带有标记的节点时才进行
- 懒惰标记的长度与线段树的长度相同
-
动态开点线段树:结点只有在有需要的时候才被创建
-
递归建树
// 对 [s, t] 区间建立线段树, p 为当前节点的编号 void build(int s, int t, int p) { if (s == t) { d[p] = a[s]; return; } int m = s + ((t - s) >> 1); build(s, m, p * 2); build(m + 1, t, p * 2 + 1); d[p] = d[p * 2] + d[(p * 2) + 1]; } -
区间查询 (以求和为例)
// [l, r] 为查询区间, [s, t] 为当前节点包含的区间, p 为当前节点的编号 int getsum(int l, int r, int s, int t, int p) { if (l <= s && t <= r) { return d[p]; } int m = s + ((t - s) >> 1); if (b[p] && s != t) { // 若当前节点懒标记非空, 则更新当前节点的子节点和懒标记 d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m); b[p * 2] += b[p], b[p * 2 + 1] += b[p]; b[p] = 0; } int sum = 0; if (l <= m) sum = getsum(l, r, s, m, p * 2); if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1); return sum; } -
区间修改 (以求和为例)
// [l, r] 为修改区间, c 为被修改的元素的变化量, [s, t] 为当前节点包含的区间, p 为当前节点的编号 void update(int l, int r, int c, int s, int t, int p) { if (l <= s && t <= r) { d[p] += (t - s + 1) * c; b[p] += c; return; } int m = s + ((t - s) >> 1); if (b[p] && s != t) { // 若当前节点懒标记非空, 则更新当前节点的子节点和懒标记 d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m); b[p * 2] += b[p], b[p * 2 + 1] += b[p]; b[p] = 0; } if (l <= m) update(l, r, c, s, m, p * 2); if (r > m) update(l, r, c, m + 1, t, p * 2 + 1); d[p] = d[p * 2] + d[p * 2 + 1]; }
基础算法
排序、二分、回溯、深搜、广搜
排序:
-
冒泡排序:比较相邻元素,按需交换
int n = nums.length; for (int i = 0; i < n; i++) { int ys = 0; for (int j = 0; j < n - i - 1; j++) { // 每轮迭代会把最大的元素交换到数组末尾 if (nums[j] > nums[j + 1]) { int tmp = nums[j]; nums[j] = nums[j + 1]; nums[j + 1] = tmp; ys = 1; } } if (ys == 0) { break; } } -
插入排序
-
选择排序
-
归并排序:把输入序列分成两个子序列,先递归使两个子序列有序,再合并两个有序的子序列
// 调用方法 mergeSort(nums, 0, n - 1); public void mergeSort(int[] nums, int l, int r) { if (l >= r) { return; } // 终止条件, 区间内只有一个点或没有点 int m = (l + r) / 2; // 递归, 左闭右闭 mergeSort(nums, l, m); mergeSort(nums, m + 1, r); int [] tmp = new int[r - l + 1]; // 合并 int tmpi = 0, li = l, ri = m + 1; for (; li <= m && ri <= r; tmpi++) { if (nums[li] <= nums[ri]) { tmp[tmpi] = nums[li++]; } else { tmp[tmpi] = nums[ri++]; } } for (; li <= m; li++, tmpi++) { tmp[tmpi] = nums[li]; } for (; ri <= r; ri++, tmpi++) { tmp[tmpi] = nums[ri]; } for (int i = 0; i < tmpi; i++) { nums[l + i] = tmp[i]; } } -
快速排序
二分:
-
解题形式 (左闭右闭)
// 寻找 >= target 的最小下标 int l = 0, r = n - 1; // 初始化 while (l <= r) { // 终止条件, 区间内没有点 int m = l + ((r - l) >> 1); if (nums[m] < target) { // 左端点以左的部分满足 nums[l - 1] < target l = m + 1; } else { // 右端点以右的部分满足 nums[r + 1] >= target r = m - 1; } } return l; // 答案 -
三种区间划分方式
初始化 终止条件 更新 答案 左闭右闭 l = 0, r = n - 1l <= rl = m + 1, r = m - 1l或r + 1左闭右开 l = 0, r = nl < rl = m + 1, r = ml或r左开右开 l = -1, r = nl + 1 < rl = m, r = ml + 1或r -
四种不等式关系互换方式
- 寻找
>= target的最小下标 - 寻找
> target的最小下标,相当于,寻找>= target + 1的最小下标 - 寻找
< target的最大下标,相当于,寻找>= target的最小下标,然后-1 - 寻找
<= target的最大下标,相当于,寻找>= target + 1的最小下标,然后-1
- 寻找
回溯:
-
子集、排列、组合、切割、棋盘
-
解题形式
List<List<Integer>> ans = new ArrayList<>(); // 用来存储答案的全局变量 List<Integer> path = new ArrayList<>(); public void dfs(int now, int n, int k) { // 回溯函数的返回值及参数 if (path.size() == k) { // 回溯终止条件 ans.add(new ArrayList(path)); return; } for (int i = now; i <= n; i++) { // 回溯搜索的遍历过程 path.add(i); dfs(i + 1, n, k); path.remove(path.size() - 1); } }- 回溯过程可抽象为树形结构,从根节点到叶节点是一组答案,因此遍历过程相当于 DFS
for循环在树的同一层上横向遍历,可以使用int now或int [] used等方式确定某个节点是否需要访问- 递归在树的同一支上纵向遍历,递归前后要修改现场,如果除
path外用到其他变量,也需相应修改 - 回溯终止条件可以没有
- 如果需要去重,可以对初始
nums排序,并在for循环中跳过某些节点
深搜与广搜:
- 见
二叉树遍历部分
动态规划
动态规划、0-1 背包、完全背包、多重背包;贪心
做题步骤:
- 确定 dp 数组以及下标的含义
- 确定状态转移方程
- dp 数组如何初始化
- 确定遍历顺序
- 举例推导 dp 数组
其他说明:
- 动态规划原理:最优子结构、后无效性、子问题重叠
- 记忆化搜索约等于动态规划
- 步骤:确定状态转移方程、写出 DFS 函数、添加记忆化数组
- 记忆化搜索难以使用滚动数组等优化,且由于存在递归,运行效率会低于动态规划
0-1 背包:
-
物品只能放或不放
// 无空间优化版 // 当遍历到物品 i 时,背包容量不超过 j 的最大背包价值是 dp[i][j] int [][] dp = new int[weight.size()+1][bagWeight+1]; for (int j = weight[0]; j <= bagweight; j++) { dp[0][j] = value[0]; } // 可以先遍历物品, 也可以先遍历背包容量, 物品和背包容量可以正序遍历也可以倒序遍历 for(int i = 1; i < weight.size(); i++) { // 遍历物品 for(int j = 0; j <= bagweight; j++) { // 遍历背包容量 if (j < weight[i]) dp[i][j] = dp[i-1][j]; else dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]); } }// 滚动数组版 // 当遍历到物品 i 时,背包容量不超过 j 的最大背包价值是 dp[j] int [] dp = new int[bagWeight+1]; // 只能先遍历物品, 再遍历背包容量, 背包容量必须倒序遍历 for(int i = 0; i < weight.size(); i++) { // 遍历物品 for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量, 倒序遍历 dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i]); } }- 计算当前物品的 dp 数组时,需要使用上一个物品的 dp 数组中背包容量小于当前的 \(j\) 的部分,由于采用滚动数组,为避免上一个物品的 dp 数组中需要使用的部分被覆盖,需要倒序遍历
完全背包:
-
物品可以不放或放多个
// 滚动数组版 // 当遍历到物品 i 时,背包容量不超过 j 的最大背包价值是 dp[j] int [] dp = new int[bagWeight+1]; // 可以先遍历物品, 也可以先遍历背包容量, 背包容量必须正序遍历 for(int i = 0; i < weight.size(); i++) { // 遍历物品 for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量, 正序遍历 dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i]); } }-
状态转移方程:
\[dp[i][j] = \max_{k=0}^{\infin}(dp[i-1][j-k \times w_i] + k \times v_i) \] -
状态转移方程优化:
\[dp[i][j] = \max(dp[i-1][j], dp[i][j-w_i] + v_i) \] -
由于 $$dp[i][j-w_i]$$ 是由 \(dp[i][j-2 \times w_i]\) 得出的,因此在计算 \(dp[i][j]\) 时,已考虑过物品 \(i\) 被放入的情况下背包容量不超过 \(j-w_i\) 的最优值,此时只需要根据 $$dp[i][j-w_i]$$ 进行计算,从而消去参数 \(k\)
-
计算当前物品的 dp 数组时,需要使用上一个物品的 dp 数组中背包容量等于当前的 \(j\) 的值,以及当前物品的 dp 数组中背包容量小于当前的 \(j\) 的部分,因此需要正序遍历,因此可以先遍历物品,也可以先遍历背包容量,这两种方式均可保证计算 \(dp[j]\) 所需的部分已在之前计算过并且没有被覆盖
-
在实际问题中,遍历顺序可能会发生变化
-
多重背包:
-
物品可以放 \(0\) 到 \(k_i\) 个,等价于有 \(k_i\) 个相同的物品,每个物品选一次,可以转化为 0-1 背包
// 无时间优化版 for(int i = 0; i < weight.size(); i++) { for(int j = bagWeight; j >= weight[i]; j--) { for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++) { dp[j] = Math.max(dp[j], dp[j-k*weight[i]] + k * value[i]); } } }
其他算法
双指针、滑动窗口、前缀和与差分
滑动窗口:
-
找出满足「条件」的最大 / 最小子数组的长度
-
解题形式
int n = nums.length(); int start = 0, end = 0; int sum = 0, ans = 0; for (; end < n; end++) { // 窗口右端每次移动一个 sum += (nums.charAt(end) == x) ? 1 : 0; // 根据条件判断窗口左端是否需要移动 while (sum > k) { sum -= (nums.charAt(start) == x) ? 1 : 0; start++; } if (end - start + 1 > ans) { // 判断是否需要更新答案 ans = end - start + 1; } }
前缀和与差分:
-
维护区间信息:多次对序列的一个区间加上同一个数,并在最后多次查询序列某一位的数
-
前缀和:前 \(n\) 项的和;差分:求和的逆运算
\[b_i = \begin{cases} a_i - a_{i-1} & \text{ if } i \in [2,n] \\ a_1 & \text{ if } i = 1 \end{cases} \]- \(a\) 是 \(b\) 的前缀和,\(b\) 是 \(a\) 的差分
- 求 \(b\) 的前 \(n\) 项的和:\(\sum_{j=1}^nb_j\)
- 求 \(a\) 的前 \(n\) 项的和:\(a_i = \sum_{j=1}^ib_j\),\(\sum_{i=1}^n a_i = \sum_{i=1}^n \sum_{j=1}^i b_j = \sum_{i=1}^n (n - i + 1)b_i\)
-
原数列是
nums,差分是b,对原数列的区间[l, r]中的每个数加kb[l] = b[l] + k; b[r + 1] = b[r + 1] - k;查询原数列中位置为
q的值nums[q]int ans = 0; for (int i = 0; i <= q; i++) { ans += b[i]; }
基础板子
最大公因数和最小公倍数、最近公共祖先、拓补排序
最大公因数和最小公倍数:
-
最大公因数
public int gcd(int a, int b) { while (b != 0) { // 辗转相除法, 结束条件 b = r = 0, a 是最大公因数 int r = a % b; a = b; b = r; } return a; } -
最小公倍数
lcm(a, b) = a * b / gcd(a, b)
最近公共祖先:
-
最近公共祖先
public TreeNode lca(TreeNode root, TreeNode p, TreeNode q) { if (root == null || root == p || root == q) { return root; } TreeNode l = lca(root.left, p, q); TreeNode r = lca(root.right, p, q); if ((l == p && r == q) || (l == q && r == p)) { // 第一次遇到左右两个子树中有 p 和 q 时, 该节点是最近公共祖先, 之后的递归直接返回这个最近公共祖先 return root; } return l != null ? l : r; }
拓补排序:
-
拓补排序 (BFS 实现)
// 图的存储方式 int [] indegrees = new int[numNodes]; // 存储每个节点的入度 List<List<Integer>> edges = new ArrayList<>(); // 存储有向边, 节点 i 指向 edges[i] int finish = 0; // 存储已完成访问的节点数量 // BFS Deque<Integer> q = new LinkedList<>(); // 使用队列迭代实现 for (int i = 0; i < numCourses; i++) { // 初始化时, 入度为 0 的节点可以入队 if (indegrees[i] == 0) { q.offer(i); finish++; } } while (!q.isEmpty()) { int cnt = q.size(); for (int i = 0; i < cnt; i++) { int now = q.poll(); // 出队, 访问节点 for (int course: edges.get(now)) { // 每个被访问的节点, 将它指向的节点入度减少 indegrees[course]--; if (indegrees[course] == 0) { // 如果被指向的节点入度为 0, 则可加入队列 q.offer(course); finish++; } } } } // 访问顺序即为拓补排序, 如果结束时访问的节点数量小于图的节点数量, 则无法拓补排序
解题经验
-
在链表中,可使用虚拟头节点指向真正头节点简化代码
-
在一个栈 / 队列无法解题时,可考虑辅助栈 / 辅助队列
-
对于每个元素,找出第一个满足某种条件的元素的位置,可考虑单调栈
-
子数组通常考虑滑动窗口,子序列通常考虑动态规划
-
当一维动态规划无法解决问题时,可考虑增加维度,再使用滚动数组压缩空间,例如
-
树上动态规划通常考虑递归,状态为「在已遍历子节点的情况下,当前节点的最优解」,状态转移为「先 DFS 遍历子树的所有最优解,然后向上传递给子树的父节点」,最终解为根节点的值,例如
-
当问题对称且数据有序时,可考虑将同一个函数作用于颠倒顺序后的数据,例如
-
涉及到区间时,可考虑前缀和与差分、线段树,例如
-
位运算可考虑哈希,例如
未整理
位运算、数学、模拟、设计、离线
枚举

浙公网安备 33010602011771号