leetcode 总结

滑动窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;

int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...

/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/

// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
  1. 我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引左闭右开区间 [left, right) 称为一个「窗口」。
  2. 我们先不断地增加 right 指针扩大窗口 [left, right),直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right),直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
  4. 重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

动态规划

明确一点,dp 就是暴力,dp 多的仅仅是使用 dp table 或备忘录优化递归树,同时确定状态转移方程,计算机没有奇技淫巧,只有暴力穷举。

  • 备忘录、dp table 的作用是将冗余的递归树剪枝。
  • 『自顶向下』是指我们的问题是逐步拆分的,比如斐波那契数列的第 20 项,从 19、18 … 向下求解。递归解一般用的是自顶向下的方式解。
  • 『自底向上』是 dp 的说法,我们如果要求斐波那契额数列的第 20 项,应该从 1、 2 … 向上求解。dp 数组解一般用自底向上的方式解。
  • 状态转移方程是,前后两个『状态』,他们存在推导关系,同时『子问题』相互独立。
  • 最优子结构,可以从子问题的最优结果推出更大规模问题的最优结果。

比如给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。

状态转移方程:

自顶向下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def coinChange(coins: List[int], amount: int):
def dp(n):
# base case
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)
return res if res != float('INF') else -1
return dp(amount)

自底向上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def coinChange(coins: List[int], amount: int):
# 备忘录
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)

dp 遍历存在技巧:

  1. 遍历的过程,所需的状态必须是计算出来的。
  2. 遍历的终点必须是存储结果的位置。

背包问题

背包分多种,首先是01背包,给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?

1
2
3
4
5
6
7
Input:
N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]

Output:
6 // 选择前两件物品

动态规划问题要先明确两个问题:「状态」和「选择」。状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。套框架:

1
2
3
4
5
// 动态规划状态转移方程框架
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
  • dp[i][w] 的定义如下:对于前 i 个物品,当前背包的容量为 w,这种情况下可以装的最大价值是 dp[i][w]
  • 我们想求的最终答案就是 dp[N][W]。base case 就是 dp[0][..] = dp[..][0] = 0,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0
  • 01 背包的状态转移方程即:dp[i][w] = max(dp[i-1][w], dp[i-1][w - wt[i-1]] + val[i-1])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 01背包模板
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
// base case 已初始化
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= N; i++) {
for (int w = 1; w <= W; w++) {
if (w - wt[i-1] < 0) {
// 装不下了,这种情况下只能选择不装入背包
dp[i][w] = dp[i - 1][w];
} else {
// 装入或者不装入背包,择优
dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1],
dp[i - 1][w]);
}
}
}
return dp[N][W];
}

接着是子集背包,给一个正整数数组分割成两个子集,令两个子集和相等。用背包问题的口吻说,即给一个可装载重量为 sum / 2 的背包和 N 个物品,每个物品的重量为 nums[i]。现在让你装物品,是否存在一种装法,能够恰好将背包装满?

状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。

  • dp[i][j] = x 表示,对于前 i 个物品,当前背包的容量为 j 时,若 xtrue,则说明可以恰好将背包装满;若 xfalse,则说明不能恰好将背包装满。
  • 我们想求的最终答案就是 dp[N][sum/2],base case 就是 dp[..][0] = truedp[0][..] = false,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
  • 子集背包的状态转移方程:dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 子集背包模板
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums) sum += num;
// 和为奇数时,不可能划分成两个和相等的集合
if (sum % 2 != 0) return false;
int n = nums.size();
sum = sum / 2;
vector<vector<bool>>
dp(n + 1, vector<bool>(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 {
// 装入或不装入背包
dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
}
}
}
return dp[n][sum];
}
  • 01 背包,和现在的子集背包中的 dp[i][j] 都只和 dp[i-1][...] 相关,所以只要 i-1 保留,dp 数组可以压缩到一维。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 子集背包状态压缩模板
bool canPartition(vector<int>& nums) {
int sum = 0, n = nums.size();
for (int num : nums) sum += num;
if (sum % 2 != 0) return false;
sum = sum / 2;
vector<bool> 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];
}

然后完全背包问题,有一个背包,最大容量为 amount,有一系列物品 coins,每个物品的重量为 coins[i],每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?

状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」嘛,背包问题的套路都是这样。

  • dp[i][j] 表示若只使用前 i 个物品,当背包容量为 j 时,有 dp[i][j] 种方法可以装满背包。
  • base case 为 dp[0][..] = 0dp[..][0] = 1。因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为 0,那么什么都不用就是唯一的一种凑法。
  • 完全背包的状态转移方程:dp[i][j] = dp[i - 1][j] + dp[i][j-coins[i-1]];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 完全背包模板
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];
}
  • 同样,这里的 dp[i][j] 仅和上一行的 dp[i-1][...] 有关,这一行反正是要另算的,因此也可以状态压缩。
1
2
3
4
5
6
7
8
9
10
11
// 完全背包状态压缩模板
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];
}

子序列问题

子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,用最长公共子序列问题抛砖引玉一下。

1
2
3
输入: str1 = "abcde", str2 = "ace" 
输出: 3
解释: 最长公共子序列是 "ace",它的长度是 3
  • dp[i][j] 的含义是:对于 s1[1..i]s2[1..j],它们的 LCS 长度是 dp[i][j]
  • 我们专门让索引为 0 的行和列表示空串,dp[0][..]dp[..][0] 都应该初始化为 0,这就是 base case。因为有一个字符串是空串。
  • LCS 的状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) 或者 dp[i][j] = 1 + dp[i-1][j-1] ,前者是当前 str1[i - 1] != str2[j - 1]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# LCS 模板
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:
# 谁能让 lcs 最长,就听谁的
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[-1][-1]
  • dp[i-1][j-1] 对应的 lcs 长度不可能比前两种情况大,所以只需要 max(dp[i-1][j], dp[i][j-1]) ,没有必要参与比较。

子序列问题考察的是动态规划技巧,时间复杂度一般都是 O(n^2)

第一种思路模板是一个一维的 dp 数组,比如 LIS 最长递增自序列就能用:

1
2
3
4
5
6
7
8
// 子序列问题一维数组模板
int n = array.length;
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}

第二种思路模板是一个二维的 dp 数组,比如 LCS 最长公共子序列问题就能用:

1
2
3
4
5
6
7
8
9
10
11
// 子序列问题二维数组模板
int n = arr.length;
int[][] dp = new dp[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
  • 涉及两个字符串/数组时,我们要求的子序列(最长公共子序列)长度为 dp[i][j]
  • 只涉及一个字符串/数组时,在子数组 array[i..j] 中,我们要求的子序列(最长回文子序列)的长度为 dp[i][j]

最长回文子序列比如 bbbab 中,最长的回文子序列是 bbbb ,很好理解。

  • dp 数组的定义是:在子串 s[i..j] 中,最长回文子序列的长度为 dp[i][j]
  • 如果 s[i]s[j] 相等,dp[i][j] = dp[i + 1][j - 1] + 2 ;否则 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
  • 如果只有一个字符,显然最长回文子序列长度是 1,也就是 dp[i][j] = 1 if (i == j)
  • 因为 i 肯定小于等于 j,所以对于那些 i > j 的位置,根本不存在什么子序列,应该初始化为 0。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 最长回文子序列模板
int longestPalindromeSubseq(string s) {
int n = s.size();
// dp 数组全部初始化为 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
dp[i][i] = 1;
// 反着遍历保证正确的状态转移
for (int i = n - 1; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 状态转移方程
if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
// 整个 s 的最长回文子串长度
return dp[0][n - 1];
}
  • 这种遍历较为特殊,因为结果在 dp[0][n - 1] ,所以遍历方向是从下到上,从左到右。

编辑距离

编辑距离问题就是给我们两个字符串 s1 和 s2,只能用增、删、改三种操作,让我们把 s1 变成 s2,求最少的操作数。需要明确的是,不管是把 s1 变成 s2 还是反过来,结果都是一样的,所以后文就以 s1 变成 s2 举例。

  • dp[i,j] 表示 s1[0..i] 转变成 s2[0..j] 的最小操作数。
  • dp[..][0]dp[0][..] 对应 base case 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 最短编辑距离模板
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;
// 自底向上求解
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));
}
  • 可以将这里的 int[] dp 实现一个类比如 Node[][] dp,其中保存 val 为具体操作,可以以此反推编辑细节。

扔鸡蛋问题

题目是这样:你面前有一栋从 1 到 N 共 N 层的楼,然后给你 K 个鸡蛋(K 至少为 1)。现在确定这栋楼存在楼层 0 <= F <= N,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于 F 的楼层都会碎,低于 F 的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层 F 呢?

  • m = dp[K, N] 表示当前状态为 k 个鸡蛋,面对 n 层楼,这个状态下最少的扔鸡蛋次数 m。
  • min(res, max(dp,dp) + 1) 的含义是最坏情况下的最少次数,外层 for 枚举的是 N ,选择出尝试次数最少的哪一层。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 扔鸡蛋问题枚举鸡蛋和楼层
def superEggDrop(self, K: int, 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)]
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)
  • 这样的时间复杂度是 O(N*K*logN)

我们上面的想法 dp[k][n] = m 当前状态为 k 个鸡蛋,面对 n 层楼,最少的扔鸡蛋次数为 m 。

可以反过来想,dp[k][m] = n ,当前有 k 个鸡蛋,可以尝试扔 m 次鸡蛋,这个状态下,最坏情况下最多能确切测试一栋 n 层的楼。

总的楼层数 = 楼上的楼层数 + 楼下的楼层数 + 1 当前这层楼),此时状态转移方程变了:dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 扔鸡蛋问题枚举鸡蛋和次数
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
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;
}
  • 时间复杂度是 O(KN)
  • 另外注意到 dp[m][k] 转移只和左边和左上的两个状态有关,所以很容易优化成一维 dp 数组。

博弈问题

有一些智力题是可以靠推算直接得到结果的,比如『石头游戏』等,你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。

如果理性分析,可以发现是可以将数组分成奇数和偶数两组,以保证先手者的胜利,但是更一般的做法则是博弈。

  • 状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。
  • 选择有两个:选择最左边的那堆石头,或者选择最右边的那堆石头。
  • dp[i][j] 被定义为一个类的对象,从 piles[i..j] 得到的前手和后手分。
  • 状态转移方程:dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec) 。我作为先手,面对 piles[i...j] 时,有两种选择,要么我选择最左边的那一堆石头,然后面对 piles[i+1...j],要么我选择最右边的那一堆石头,然后面对 piles[i...j-1] ,下一轮我一定是后手。

base case 有点意思:

1
2
3
4
5
6
dp[i][j].fir = piles[i]
dp[i][j].sec = 0
其中 0 <= i == j < n
# 解释:i 和 j 相等就是说面前只有一堆石头 piles[i]
# 那么显然先手的得分为 piles[i]
# 后手没有石头拿了,得分为 0

最终结果是 dp[0][n-1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 石头游戏博弈模板
class Pair {
int fir, sec;
Pair(int fir, int sec) {
this.fir = fir;
this.sec = sec;
}
}
/* 返回游戏最后先手和后手的得分之差 */
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;
}

股票买卖

一个数组,比如 [2,4,1] 表示股票价格,同时定义参数 k 表示最多进行的操作,操作包括:买、卖、无操作,问最大利润。

  • dp 状态有三个:第一个是天数,第二个是允许交易的最大次数,第三个是当前的持有状态。
  • 比如说 dp[3][2][1] 的含义就是:今天是第三天,我现在手上持有着股票,至今最多进行 2 次交易。再比如 dp[2][3][0] 的含义:今天是第二天,我现在手上没有持有股票,至今最多进行 3 次交易。 [1] 代表手上还持有股票,[0] 表示手上的股票已经卖出去了.
  • 最终答案是 dp[n - 1][K][0]
1
2
3
4
5
6
7
8
9
// 股票买卖模板
dp[i][k][0 or 1]
0 <= i <= n-1, 1 <= k <= K
// n 为天数,大 K 为最多交易数
// 此问题共 n × K × 2 种状态,全部穷举就能搞定。
for 0 <= i < n:
for 1 <= k <= K:
for s in {0, 1}:
dp[i][k][s] = max(buy, sell, rest)
  • 状态转移方程:dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) 。表示要么是我昨天就没有持有,然后今天选择 rest,所以我今天还是没有持有;要么是我昨天持有股票,但是今天我 sell 了,所以我今天没有持有股票了。
  • 或者:dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) 。表示要么我昨天就持有着股票,然后今天选择 rest,所以我今天还持有着股票;要么我昨天本没有持有,但今天我选择 buy,所以今天我就持有股票了。
1
2
3
4
5
6
7
8
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
// 解释:不允许交易的情况下,是不可能持有股票的,用负无穷表示这种不可能。

对于 k 的不同要求,代码需要不同处理,但是状态转移方程可以共用。

打家劫舍

一个数组,你不能同时取两个相邻的数(但是可以跳着取),问取得的最大值。

1
2
3
4
5
6
7
8
9
10
11
12
// 打家劫舍-数组
int rob(int[] nums) {
int n = nums.length;
// dp[i] = x 表示:
// 从第 i 间房子开始抢劫,最多能抢到的钱为 x
// base case: dp[n] = 0
int[] dp = new int[n + 2];
for (int i = n - 1; i >= 0; i--) {
dp[i] = Math.max(dp[i + 1], nums[i] + dp[i + 2]);
}
return dp[0];
}
  • 状态转移方程非常简单,不用过多解释

如果是一个循环数组呢?即首位相邻。

1
2
3
4
5
6
7
// 打家劫舍-循环数组
public int rob(int[] nums) {
int n = nums.length;
if (n == 1) return nums[0];
return Math.max(robRange(nums, 0, n - 2),
robRange(nums, 1, n - 1));
}
  • 只有两个可能,取第一家或者取最后一家。

如果此强盗发现现在面对的房子不是一排,不是一圈,而是一棵二叉树!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 打家劫舍-二叉树
Map<TreeNode, Integer> memo = new HashMap<>();
public int rob(TreeNode root) {
if (root == null) return 0;
// 利用备忘录消除重叠子问题
if (memo.containsKey(root))
return memo.get(root);
// 抢,然后去下下家
int do_it = root.val
+ (root.left == null ?
0 : rob(root.left.left) + rob(root.left.right))
+ (root.right == null ?
0 : rob(root.right.left) + rob(root.right.right));
// 不抢,然后去下家
int not_do = rob(root.left) + rob(root.right);
int res = Math.max(do_it, not_do);
memo.put(root, res);
return res;
}
  • 完全按照之前的逻辑照抄可以得到这样的代码,事实上可以更加简单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 打家劫舍-二叉树 优化
int rob(TreeNode root) {
int[] res = dp(root);
return Math.max(res[0], res[1]);
}
/* 返回一个大小为 2 的数组 arr
arr[0] 表示不抢 root 的话,得到的最大钱数
arr[1] 表示抢 root 的话,得到的最大钱数 */
int[] dp(TreeNode root) {
if (root == null)
return new int[]{0, 0};
int[] left = dp(root.left);
int[] right = dp(root.right);
// 抢,下家就不能抢了
int rob = root.val + left[0] + right[0];
// 不抢,下家可抢可不抢,取决于收益大小
int not_rob = Math.max(left[0], left[1])
+ Math.max(right[0], right[1]);

return new int[]{not_rob, rob};
}
  • 时间复杂度 O(N),空间复杂度只有递归函数堆栈所需的空间,不需要备忘录的额外空间。

回溯问题

解决一个回溯问题,实际上就是一个决策树的遍历过程。只需要考虑三个问题:

  1. 路径:也就是已经做出的选择。
  2. 选择列表:也就是你当前可以做的选择。
  3. 结束条件:也就是到达决策树底层,无法再做选择的条件。
1
2
3
4
5
6
7
8
9
10
11
# 回溯模板
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return

for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。

比如用 [1 2 3] 进行全排列,第一位放 [2][2] 就是「路径」,记录你已经做过的选择;[1,3]就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层,在这里就是选择列表为空的时候。

全排列就是树的遍历,到叶子节点了加入数组或链表,而前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行

N 皇后问题,本质上也是一个全排列,需要行、列、斜边,都没有第二个皇后,套模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
vector<vector<string>> res;

/* 输入棋盘边长 n,返回所有合法的放置 */
vector<vector<string>> solveNQueens(int n) {
// '.' 表示空,'Q' 表示皇后,初始化空棋盘。
vector<string> board(n, string(n, '.'));
backtrack(board, 0);
return res;
}

// 路径:board 中小于 row 的那些行都已经成功放置了皇后
// 选择列表:第 row 行的所有列都是放置皇后的选择
// 结束条件:row 超过 board 的最后一行
void backtrack(vector<string>& 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] = '.';
}
}

/* 是否可以在 board[row][col] 放置皇后? */
bool isValid(vector<string>& 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;
}

对于求子集问题,也可以套用回溯,因为如果根据数学归纳的方式,那么时间复杂度太高了。比如对于在一个数组 nums 中找到全部的子集,对于数学归纳需要 O(n*2^n) ,但是用回溯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vector<vector<int>> res;

vector<vector<int>> subsets(vector<int>& nums) {
// 记录走过的路径
vector<int> track;
backtrack(nums, 0, track);
return res;
}

void backtrack(vector<int>& nums, int start, vector<int>& track) {
res.push_back(track);
for (int i = start; i < nums.size(); i++) {
// 做选择
track.push_back(nums[i]);
// 回溯
backtrack(nums, i + 1, track);
// 撤销选择
track.pop_back();
}
}
  • 可以看见,对 res 更新的位置处在前序遍历,也就是说,res 就是树上的所有节点。像这种子集问题都是前序问题。
  • 对于组合(输入两个数字 n, k,算法输出 [1..n] 中 k 个数字的所有组合)、排列问题,其实也是一种子集的理解,大同小异,代码不写了。
  • 子集、组合、排列,如果理解成树形,可以发现他们都是在先序上进行的操作,同时子集和组合是不完整的多叉树,而排列则是较为完整的多叉树(越到底层分叉约少)。

二分查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 二分模板
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 写清楚,这样可以清楚地展现所有细节。
  • 计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 leftright 太大直接相加导致溢出。这样算的是左中位数。
  • Java 中可以用 (left + right) >>> 1 来防止溢出,这样算的是右中位数。
  • whilie 中的内容与 right 的初始值关系到区间的开闭,如果用 left < right 同时 rightnums.length 则区间是 (left, right] ;如果使用 left <= right 同时 rightnums.length - 1 则区间是 [left, right] 。 推荐用后一种。
  • 如果当前 mid 不对,那么就要根据区间来改 rightleft ,如果区间是 [] (推荐),那么自然下一个就是 left = mid + 1right = mid - 1 ;如果区间是 [) ,那么下一个就是 left = mid + 1right = mid

知道了这些,来看左边界二分查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 左边界二分,左闭右开
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;
}
  • 这样搜索的含义是数组 nums 中比 target 小的元素有 left

  • 之所以这样可以找到边界,是因为当找到目标时 nums[mid] == targetright = mid ,即不断向左收缩,达到锁定左侧边界的目的。

左边界二分查找改成闭区间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 左边界二分,闭区间
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;
}
}
// 检查出界情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
  • 由于 while 的退出条件是 left == right + 1,所以当 targetnums 中所有元素都大时,会存在索引越界。

同理,有右边边界二分查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 右边界二分,左闭右开
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; // 注意
}
  • nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的下界 left,使得区间不断向右收缩,达到锁定右侧边界的目的。
  • 最后的 left - 1 是因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target

右边界二分查找改成闭区间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 右边界二分,闭区间
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;
}

N 堆香蕉,每堆 p[i] 个香蕉,需要在 H 时间内吃完,求每小时吃香蕉速度 speed ,同时一小时只能吃一堆。这是非常有趣的左边界二分查找问题。这个题可以用二分是因为它属于连续的空间线性搜索,这就是二分查找可以发挥作用的标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 求吃香蕉速度,左边界二分解
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;
}
// 时间复杂度 O(N)
boolean canFinish(int[] piles, int speed, int H) {
int time = 0;
for (int n : piles) {
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;
}
  • 先确定最小 speed 与最大 speed ,然后进行搜索。

二分可以完成判定子序列的任务,比如判断子串 s 中是否包含 t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 二分判断子串模板
boolean isSubsequence(String s, String t) {
int m = s.length(), n = t.length();
// 对 t 进行预处理
ArrayList<Integer>[] 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;
}
// 查找左侧边界的二分查找
int left_bound(ArrayList<Integer> 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;
} else {
hi = mid;
}
}
return lo;
}
  • 重点是用一个字典 index 将每个字符出现的索引位置按顺序存储下来,然后二分搜索 index[c] 中比 j 大的那个索引,即左边界二分。

双指针技巧

快慢指针都比较简单,比较难理解的是有环链表判断环起点。直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 函数
slow = head;
while (slow != fast) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
  • 先找到快满指针相交点,然后任一一个从 head 开始,另一个继续向前,相遇点即环起点。

此外左右指针在各种场景都要用:二分、反转数组、滑动窗口等。


接雨水是双指针的一种应用,有一个数组表示柱子高度,求出这片柱子可以接住的雨水。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int trap(vector<int>& 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;
}
  • 对于每一块柱子间,盛水应该是 min(max(height[0..i]), max(height[i..end])) - height[i]
  • 现在的 l_maxr_max 不一定是当前 i 左右的最值,但是可以得到正确结果,因为我们已经知道 l_max < r_max 了,至于这个 r_max 是不是右边最大的,不重要,重要的是 height[i] 能够装的水只和 l_max 有关。

快慢指针有一种应用是对有序数组、链表去重,慢指针从 0 开始,快指针从 1 开始,快指针向前如果与满指针对象不同,即满指针赋值并前进,当快指针到尾,慢指针前的全是不重复的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 快慢指针去重模板
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;
}

BFS / DFS

  • DFS 本质上就是回溯算法。
  • BFS 找到的路径一定是最短的,但代价就是空间复杂度比 DFS 大很多。
  • BFS 要解决的本质问题是在一幅「图」中找到从起点 start 到终点 target 的最近距离。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// BFS 模板
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
Queue<Node> q; // 核心数据结构
Set<Node> 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();
/* 划重点:这里判断是否到达终点 */
if (cur is target)
return step;
/* 将 cur 的相邻节点加入队列 */
for (Node x : cur.adj())
if (x not in visited) {
q.offer(x);
visited.add(x);
}
}
/* 划重点:更新步数在这里 */
step++;
}
}
  • cur.adj() 泛指 cur 相邻的节点,比如说二维数组中,cur 上下左右四面的位置就是相邻节点;
  • visited 的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要 visited
  • DFS 是线,BFS 是面;DFS 是单打独斗,BFS 是集体行动。在找最 xxx 的点时,BFS 要优于 DFS 。相对的,DFS 空间复杂度低一些。
  • BFS 还可以双向,当然需要先知道 target ,但是时间复杂度还是 O(n) ,理论上是有优化的,写法类似,这里不写模板了。

二叉堆

即一般意义上的堆,可以用数组表示一个堆,下面是大顶堆模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 大顶堆模板
public class MaxPQ<Key extends Comparable<Key>> {
// 存储元素的数组
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) {
N++;
// 先把新元素加到最后
pq[N] = e;
// 然后让它上浮到正确的位置
swim(N);
}

/* 删除并返回当前队列中最大元素 */
public Key delMax() {
// 最大堆的堆顶就是最大元素
Key max = pq[1];
// 把这个最大元素换到最后,删除之
exch(1, N);
pq[N] = null;
N--;
// 让 pq[1] 下沉到正确位置
sink(1);
return max;
}

/* 上浮第 k 个元素,以维护最大堆性质 */
private void swim(int k) {
// 如果浮到堆顶,就不能再上浮了
while (k > 1 && less(parent(k), k)) {
// 如果第 k 个元素比上层大
// 将 k 换上去
exch(parent(k), k);
k = parent(k);
}
}

/* 下沉第 k 个元素,以维护最大堆性质 */
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;
// 否则,不符合最大堆的结构,下沉 k 结点
exch(k, older);
k = older;
}
}

/* 交换数组的两个元素 */
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;
}

// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}
}
  • 这里用到 Java 的泛型,Key 可以是任何一种可比较大小的数据类型,可以认为它是 int、char 等。
  • 先要完成 swimsinkinsertdelMax 是在此基础上的。
  • 一个优先级队列就实现了,插入和删除元素的时间复杂度为 O(logK)K 为当前二叉堆(优先级队列)中的元素总数。因为我们时间复杂度主要花费在 sink 或者 swim 上,而不管上浮还是下沉,最多也就树(堆)的高度,也就是 log 级别。

LRU 设计

算是必考题,也要记录一下,最近访问过就移动到队伍头部,实际上就是设计 getput 两个方法,限制是这两个方法的时间复杂度需要在 O(1)

哈希表查找快但是无序,链表插入快但是查找慢,因此结合一下,就是哈希链表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
public class LRUCache {
// 节点
class Node {
int key, val;
Node next, prev;

Node(int k, int v) {
this.key = k;
this.val = v;
}
}
// 双向链表
class DoubleList {
private Node head, tail; // 头尾虚节点
private int size; // 链表元素数

DoubleList() {
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
size = 0;
}
// 在链表头部添加节点 x
void addFirst(Node x) {
x.next = head.next;
x.prev = head;
head.next.prev = x;
head.next = x;
size++;
}
// 删除链表中的 x 节点(x 一定存在)
void remove(Node x) {
x.prev.next = x.next;
x.next.prev = x.prev;
size--;
}
// 删除链表中最后一个节点,并返回该节点
Node removeLast() {
if (tail.prev == head)
return null;
Node last = tail.prev;
remove(last);
return last;
}
// 返回链表长度
int size() {
return size;
}
}
// key -> Node(key, val)
private HashMap<Integer, Node> 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 中对应的数据
map.put(key, x);
} else {
if (cap == cache.size()) {
// LRU 的核心部分,删除链表最后一个数据
Node last = cache.removeLast();
map.remove(last.key);
}
// 直接添加到头部
cache.addFirst(x);
map.put(key, x);
}
}
}
  • 本质上来说 LRU 就是一个双向链表和一个 map 来记录 key 与 val 的对应。

二叉搜索树

二叉树算法的设计的总路线:明确一个节点要做的事情,然后剩下的事抛给框架。

1
2
3
4
5
6
7
// 二叉树模板
void traverse(TreeNode root) {
// root 需要做什么?在这做。
// 其他的不用 root 操心,抛给框架
traverse(root.left);
traverse(root.right);
}

二叉搜索树(Binary Search Tree,简称 BST)是一种很常用的的二叉树。它的定义是:一个二叉树中,任意节点的值要大于等于左子树所有节点的值,且要小于等于右边子树的所有节点的值。

单纯上面的模板,写出的 BST 可能有坑,因为 root 需要做的不只是和左右子节点比较,而是要整个左子树和右子树所有节点比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 判断 BST
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 操作模板
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 删除需要分三种情况:
// 无子节点、仅一个子节点、有两个子节点
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;
}

完全二叉树

  • 完全二叉树,Complete Binary Tree,每一层都是紧凑靠左排列的。
  • 满二叉树,Perfect Binary Tree,是一种特殊的完全二叉树,每层都是是满的。
  • Full Binary Tree,一棵二叉树的所有节点要么没有孩子节点,要么有两个孩子节点。

计算完全二叉树的节点数,有一定的技巧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 完全二叉树节点数
public int countNodes(TreeNode root) {
TreeNode l = root, r = root;
// 记录左、右子树的高度
int hl = 0, hr = 0;
while (l != null) {
l = l.left;
hl++;
}
while (r != null) {
r = r.right;
hr++;
}
// 如果左右子树的高度相同,则是一棵满二叉树
if (hl == hr) {
return (int)Math.pow(2, hl) - 1;
}
// 如果左右高度不同,则按照普通二叉树的逻辑计算
return 1 + countNodes(root.left) + countNodes(root.right);
}
  • 这个算法的时间复杂度是 O(logN*logN) 。
  • 乍一看复杂度应该是 O(N*logN) ,关键点在于 return 1 + countNodes(root.left) + countNodes(root.right); 这两个递归只有一个会真的递归下去,另一个一定会触发 hl == hr 而立即返回,不会递归下去。

单调栈

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) ,可以把问题抽象成排队,找到每一个元素后第一个比他高的数字。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 单调栈模板
vector<int> nextGreaterElement(vector<int>& nums) {
vector<int> ans(nums.size()); // 存放答案的数组
stack<int> 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 循环,可能认为这个算法的复杂度也是 O(n^2),但是实际上这个算法的复杂度只有 O(n)。
  • 总共有 n 个元素,每个元素都被 push 入栈了一次,而最多会被 pop 一次,没有任何冗余操作。所以总的计算规模是和元素规模 n 成正比的,也就是 O(n) 的复杂度。
  • 如果单调栈不只看右侧,是循环,则可以想象有两个数组组合,不用真把数组翻倍,循环体变化即可:
1
2
3
4
5
6
7
8
n = nums.size();
for (int i = n * 2 - 1; i >= 0; i--) { // 倒着往栈里放
while (!s.empty() && s.top() <= nums[i % n]) { // 判定个子高矮
s.pop(); // 矮个起开,反正也被挡着了。。。
}
ans[i % n] = s.empty() ? -1 : s.top(); // 这个元素身后的第一个高个
s.push(nums[i % n]); // 进队,接受之后的身高判定吧!
}

单调队列

有些特殊的滑窗问题需要在 O(1) 的时间得到滑窗内的最大值,这个时候需要用『单调队列』来辅助了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 单调队列模板
class MonotonicQueue {
private:
deque<int> data; // deque 是双端队列
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();
}
};
// 数组 nums 中每 k 个元素的最大值
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
// k 是窗口大小
MonotonicQueue window; // 滑窗
vector<int> 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;
}
  • 核心点在于 push 的时候,把比当前小的元素都删除了,因此 queue 中的元素是从大到小排列的。
  • pop 的时候需要进行比较,是因为可能当前想要移除的元素已经被删除了,此时不用操作。
  • 和单调栈同理,每个元素最多被 push_back 和 pop_back 一次,没有任何多余操作,所以整体的复杂度还是 O(N)。

反转链表

1
2
3
4
5
6
7
8
// 链表反转模板 递归
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 的作用是返回原本链表尾部的节点,因此最后返回的就是 reverse 的节点。

如果仅仅反转前 n 个节点呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 链表反转前 n 个节点模板 递归
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;
// 让反转之后的 head 节点和后面的节点连起来
head.next = successor;
return last;
}
  • 和完整的反转基本相同,这里不同的地方在于 successor 节点,它需要被 head.next 引用,后续的节点顺序不变。

如果是反转一部分节点呢?

1
2
3
4
5
6
7
8
9
10
11
// 链表反转部分节点模板 递归
ListNode reverseBetween(ListNode head, int m, int n) {
// base case
if (m == 1) {
// 相当于反转前 n 个元素
return reverseN(head, n);
}
// 前进到反转的起点触发 base case
head.next = reverseBetween(head.next, m - 1, n - 1);
return head;
}
  • 对于 m = 1 相当于反转前 n 个节点;
  • 而当 m != 1 时,如果我们把 head 的索引视为 1,那么我们是想从第 m 个元素开始反转对吧;
  • 如果把 head.next 的索引视为 1 呢?那么相对于 head.next 反转的区间应该是从第 m - 1 个元素开始的;
  • 区别于迭代思想,这就是递归思想。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 链表反转模板 迭代
// 反转以 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;
}
// 返回反转后的头结点
return pre;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 链表反转区间模板 迭代
/** 反转区间 [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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 链表反转 k 个一组模板 迭代
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;
}
// 反转前 k 个元素
ListNode newHead = reverse(a, b);
// 递归反转后续链表并连接起来
a.next = reverseKGroup(b, k);
return newHead;
}

如何判断回文链表?一种思路是递归入栈,用后序遍历进行对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 回文链表递归模板
// 左侧指针
ListNode left;

boolean isPalindrome(ListNode head) {
left = head;
return traverse(head);
}

boolean traverse(ListNode right) {
if (right == null) return true;
boolean res = traverse(right.next);
// 后序遍历代码
res = res && (right.val == left.val);
left = left.next;
return res;
}
  • 递归入栈判断回文链表的时间复杂度 O(n) 空间复杂度 O(n)

如果用双指针可以有效降低空间复杂度,首先找到中点,然后将后半段链表进行反转,比较前半段和后半段链表即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 回文链表双指针模板
boolean isPalindrome(ListNode head) {
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 如果fast指针没有指向null,说明链表长度为奇数,slow还要再前进一步
if (fast != null) slow = slow.next;

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;
}
// 链表反转
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;
}

常用位操作

  1. 利用或操作 | 和空格将英文字符转换为小写(碰巧)
1
2
('a' | ' ') = 'a'
('A' | ' ') = 'a'
  1. 利用与操作 & 和下划线将英文字符转换为大写(碰巧)
1
2
('b' & '_') = 'B'
('B' & '_') = 'B'
  1. 利用异或操作 ^ 和空格进行英文字符大小写互换(碰巧)
1
2
('d' ^ ' ') = 'D'
('D' ^ ' ') = 'd'
  1. 判断两个数是否异号(有用)
1
2
3
4
5
int x = -1, y = 2;
bool f = ((x ^ y) < 0); // true

int x = 3, y = 2;
bool f = ((x ^ y) < 0); // false
  1. 交换两个数(没啥用)
1
2
3
4
5
int a = 1, b = 2;
a ^= b;
b ^= a;
a ^= b;
// 现在 a = 2, b = 1
  1. 加一(没啥用)
1
2
3
int n = 1;
n = -~n;
// 现在 n = 2
  1. 减一(没啥用)
1
2
3
int n = 2;
n = ~-n;
// 现在 n = 1
  1. 消除数字 n 的二进制表示中的最后一个 1(实用)
1
n&(n-1)
  • 判断一个数二进制中有多少 1
1
2
3
4
5
6
7
8
int hammingWeight(uint32_t n) {
int res = 0;
while (n != 0) {
n = n & (n - 1);
res++;
}
return res;
}
  • 判断一个数是不是 2 的指数
1
2
3
4
bool isPowerOfTwo(int n) {
if (n <= 0) return false;
return (n & (n - 1)) == 0; // 如果是 2 进制,则最多只有 1 个 1
}

字符串乘法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 字符串乘法模板
string multiply(string num1, string num2) {
int m = num1.size(), n = num2.size();
// 结果最多为 m + n 位数
vector<int> 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;
}

区间调度

区间有并集、交集、最多不相交等问题,这些都是找规律的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 区间并集模板
# 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
  • 区间合并对于几个相交区间合并后的结果区间 x,x.start 一定是这些相交区间中 start 最小的,x.end 一定是这些相交区间中 end 最大的。
  • 先定下 start ,如果有相交更新 end 位,如果没有相交如数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 区间交集模板
# 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: i += 1
return res
  • 区间交集同样是规律问题,不存在交集是 b2 < a1 or a2 < b1 因此取反即 b2 >= a1 and a2 >= b1
  • 如果交集区间是 [c1,c2],那么 c1=max(a1,b1)c2=min(a2,b2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 区间最多不相交模板
public int intervalSchedule(int[][] intvs) {
if (intvs.length == 0) return 0;
// 按 end 升序排序
Arrays.sort(intvs, new Comparator<int[]>() {
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];
}
}
return count;
}
  • 从区间集合 intvs 中选择一个区间 x,这个 x 是在当前所有区间中结束最早的(end 最小)。
  • 把所有与 x 区间相交的区间从区间集合 intvs 中删除。
  • 重复步骤 1 和 2,直到 intvs 为空为止。之前选出的那些 x 就是最大不相交子集。

信封嵌套

有一个数组都是二元组,二元组中每一位都比另一个二元组对应位大,才能完成嵌套。因此这不是单纯的最长递增子序列 LIS 问题,需要一定的变形。

先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序。之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 信封嵌套模板
// envelopes = [[w, h], [w, h]...]
public int maxEnvelopes(int[][] envelopes) {
int n = envelopes.length;
// 按宽度升序排列,如果宽度一样,则按高度降序排列
Arrays.sort(envelopes, new Comparator<int[]>()
{
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)
right = mid;
else
left = mid + 1;
}
if (left == piles) piles++;
// 把这张牌放到牌堆顶
top[left] = poker;
}
// 牌堆数就是 LIS 长度
return piles;
}

洗牌算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 四排算法四种写法
// 得到一个在闭区间 [min, max] 内的随机整数
int randInt(int min, int max);

// 第一种写法
void shuffle(int[] arr) {
int n = arr.length();
/******** 区别只有这两行 ********/
for (int i = 0 ; i < n; i++) {
// 从 i 到最后随机选一个元素
int rand = randInt(i, n - 1);
/*************************/
swap(arr[i], arr[rand]);
}
}

// 第二种写法
for (int i = 0 ; i < n - 1; i++)
int rand = randInt(i, n - 1);

// 第三种写法
for (int i = n - 1 ; i >= 0; i--)
int rand = randInt(0, i);

// 第四种写法
for (int i = n - 1 ; i > 0; i--)
int rand = randInt(0, i);

分治算法

归并是分治算法的一种应用。分治算法的套路是分解 -> 解决(触底)-> 合并(回溯)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 归并模板
public class Merge {
// 不要在 merge 函数里构造新数组了,因为 merge 函数会被多次调用,影响性能
// 直接一次性构造一个足够大的数组,简洁,高效
private static Comparable[] aux;

public static void sort(Comparable[] a) {
aux = new Comparable[a.length];
sort(a, 0, a.length - 1);
}

private static void sort(Comparable[] a, int lo, int hi) {
if (lo >= hi) return;
int mid = lo + (hi - lo) / 2;
sort(a, lo, mid);
sort(a, mid + 1, hi);
merge(a, lo, mid, hi);
}

private static void merge(Comparable[] a, int lo, int mid, int hi) {
int i = lo, j = mid + 1; // 对两个归并对象的起点指针
for (int k = lo; k <= hi; k++)
aux[k] = a[k]; // 归并前的原始顺序
for (int k = lo; k <= hi; k++) {
if (i > mid) { a[k] = aux[j++]; }
else if (j > hi) { a[k] = aux[i++]; }
else if (less(aux[j], aux[i])) { a[k] = aux[j++]; }
else { a[k] = aux[i++]; }
}
}

private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
}

素数判断

返回区间 [2, n) 中有几个素数,一个高效的筛法主要需要关注循环体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 素数判断模板
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;
}
  • 因子是对称的,4x2 和 2x4 是一回事,因此外层循环用 int i = 2; i * i < n; i++
  • 4×2=8,4×3=12 等等数字已经被 i=2 和 i=3 的 2×4 和 3×4 标记了,因此内层循环应该从 ixi 开始遍历,即 int j = i * i; j < n; j += i 。这里 j += i 是在找 i 的倍数。

高效模幂

1
int superPow(int a, vector<int>& b);

要求你的算法返回幂运算 a^b 的计算结果与 1337 取模(mod,也就是余数)后的结果。

这个问题分成三个小问题:数组转数字,如何进行模运算,如何高效模运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
}
}

int superPow(int a, vector<int>& 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;
}
  • 对于数组转数字, a^1564 = a^4 * (a^156)^10 ,这就可以写成递归了。
  • 对于模运算, (a * b) % k = (a % k)(b % k) % k ,将每一步都进行取模,就不会数值越界了。
  • 对于快速幂, a^b = a x a^(b-1) (b 为奇数) || (a^(b/2))^2 (b 为偶数) ,也可以进行递归了。

贪心算法

一种跳跃游戏是判断是否可以达到数组尾部,另一种跳跃游戏是得出到达数组尾部的最小步数。

1
2
3
4
5
6
7
8
9
10
11
12
// 是否达到尾部贪心
bool canJump(vector<int>& nums) {
int n = nums.size();
int farthest = 0;
for (int i = 0; i < n - 1; i++) {
// 不断计算能跳到的最远距离
farthest = max(farthest, i + nums[i]);
// 可能碰到了 0,卡住跳不动了
if (farthest <= i) return false;
}
return farthest >= n - 1;
}
  • 这种非常简单,用一个 farthest 变量就能说明问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 达到尾部最小步数贪心
int jump(vector<int>& 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;
}
}
return jumps;
}
  • 这个需要一定的思考,时间复杂度 O(N) ,空间复杂度 O(1) ,很强。
  • i 和 end 标记了可以选择的跳跃步数,farthest 标记了所有选择 [i..end] 中能够跳到的最远距离,jumps 记录了跳跃次数。

随机抽数

洗牌是随机一个数组的顺序,随机抽数是遍历一遍链表,随机抽出一个数字,要求符合随机的要求,即抽出概率是 1/i ,1-1/i 的概率还是原来的选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 随机抽出 1 个数模板
/* 返回链表中一个随机节点的值 */
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 随机抽出 k 个数模板
/* 返回链表中 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;
}
  • 上面的两种做法是链表遍历一次,随机抽取数字,时间复杂度为 O(n) 。
  • 如果不是链表场景,也可以对数组洗牌,取出前 k 个数。

并查集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 并查集模板
class UF {
// 记录连通分量
private int count;
// 节点 x 的父节点是 parent[x] ,存储若干棵树
private int[] parent;
// 新增一个数组记录树的“重量”
private int[] size;
/* 构造函数,n 为图的节点总数 */
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;
}
}
/* 将 p 和 q 连通 */
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--; // 两个分量合二为一
}
/* 判断 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) {
// 根节点的 parent[x] == x
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
/* 返回当前的连通分量个数 */
public int count() {
return count;
}
}
  • 为了合并 union() 时,树不出现头重脚轻,需要一个数组表示树的重量 size ,目的是让 union 后树依然拥有平衡性,而不会退化成链表,影响操作效率。
  • find() 可能是一个线性的操作,因此需要进行路径压缩, parent[x] = parent[parent[x]]; 很简单也很强大。可以这样理解,将 parent[x] 降级,降到和 x 一个水平,他们都指向 parent[parent[x]] ,路径压缩保证任意树的高度保持在常数,使得 union()connected() API 时间复杂度为 O(1)。
  • parent 数组记录每个节点的父节点,相当于指向父节点的指针,所以 parent 数组内实际存储着一个森林(若干棵多叉树)。

UF 的应用有很多,比如判定合法等式,给你一个数组 equations,装着若干字符串表示的算式。每个算式 equations[i] 长度都是 4,而且只有这两种情况:a==b 或者 a!=b,其中 a,b 可以是任意小写字母。你写一个算法,如果 equations 中所有算式都不会互相冲突,返回 true,否则返回 false。

  • 比如说,输入 ["a==b","b!=c","c==a"],算法返回 false,因为这三个算式不可能同时正确。
  • 再比如,输入 ["c==c","b==d","x!=z"],算法返回 true,因为这三个算式并不会造成逻辑冲突。

用 UF 的思想就是:将 equations 中的算式根据 == 和 != 分成两部分,先处理 == 算式,使得他们通过相等关系各自勾结成门派;然后处理 != 算式,检查不等关系是否破坏了相等关系的连通性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// UF 应用模板,判断合法等式
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');
}
}
// 检查不等关系是否打破相等关系的连通性
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;
}

KMP 字符串匹配

KMP 算法是在 txt 中查找子串 pat,如果存在,返回这个子串的起始索引,否则返回 -1。pat 表示模式串,长度为 Mtxt 表示文本串,长度为 N

KMP 算法永不回退 txt 的指针 i,不走回头路(不会重复扫描 txt),而是借助 dp 数组中储存的信息把 pat 移到正确的位置继续匹配。

KMP 算法的难点在于,如何计算 dp 数组中的信息?计算这个 dp 数组,只和 pat 串有关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// KMP 模板
public class KMP {
private int[][] dp;
private String pat;
public KMP(String pat) {
this.pat = pat;
// 通过 pat 构建 dp 数组
// 需要 O(M) 时间
}
public int search(String txt) {
// 借助 dp 数组去匹配 txt
// 需要 O(N) 时间
}
}

上面这样做的目的,是为了做到这样的效果:

1
2
3
KMP kmp = new KMP("aaab");
int pos1 = kmp.search("aaacaaab"); //4
int pos2 = kmp.search("aaaaaaab"); //4

这里的 dp 是状态的变化:

1
2
3
4
5
6
7
8
9
10
11
12
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

由此 search() 方法出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

dp 初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public KMP(String pat) {
this.pat = pat;
int M = pat.length();
// dp[状态][字符] = 下个状态
dp = new int[M][256];
// base case 只有遇到 pat[0] 这个字符才能使状态从 0 转移到 1
// 遇到其它字符的话还是停留在状态 0
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)];
}
}
  • 更新 X 其实和 search 函数中更新状态 j 的过程是非常相似。
  • j 是在 txt 中匹配 patX 是在 pat 中匹配 pat[1..end],状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前缀。