力扣 369 周赛总结

概要

力扣第 369 场周赛

本文只是周赛的总结,我不准备详细地讨论每一道题,只会给出大致思路。重点还是在于自己的总结、反思和提高。

赛后来看,这场比赛难度不大。做题情况简要描述:

  • 第一题:简单模拟,送分题;
  • 第二题:难度不大,但要考虑多种情况,我没有静下心来细想,造成了 3 个 WA 😒;
  • 第三题:难度中等的 DP,一开始没有想出来,转而去做最终没做出来的第四题,浪费了时间。好在后来很快做出来了😋;
  • 第四题:虽然是困难题,并且没有做出来,但比赛的代码最终也只是超时,赛后我也自己想出来了优化的方法——不难。值得注意的是,写代码时的逻辑混乱还是造成了 2 个 WA 😒;

一、找出数组中的 K-or 值

image-20231029195438014

image-20231029195448237

image-20231029195455078

题目描述有点难懂,比赛时需要结合示例才能快速读懂

逐比特模拟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public int findKOr(int[] nums, int k) {
int ans = 0;
for (int i = 0; i < 32; i++) {
int count = 0;
int val = 1 << i;
for (int num : nums) {
if ((num & val) > 0) count++;
}
if (count >= k) {
ans |= val;
}
}
return ans;
}
}

时间复杂度:$O(n)$

二、数组的最小相等和

image-20231029195518874

image-20231029195534194

  • 如果 nums1 中有 k0,那么它的和在替换后必须要增加至少 k
  • 如果 nums1 中至少有一个 0,那么它在替换后的和可以等于任意大于 sum(nums1) 的值
  • nums2 同理

函数的返回类型为 long;数组长度最大为 $10^5$,元素最大为 $10^6$,数组和最大为 $10^{11}$,超过 32-bit int 的上限。这提醒我们中间步骤的计算结果需要用 long 存储。事实上,算法题应该都用 long 代替 int,避免整数溢出,并且开销也不大。

重点在于判断无法相等的条件,在这上面的失误造成了 3 个 WA:

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
import java.util.Arrays;

class Solution {
public long minSum(int[] nums1, int[] nums2) {
long sum1 = Arrays.stream(nums1).mapToLong(i -> i).sum();
long sum2 = Arrays.stream(nums2).mapToLong(i -> i).sum();
long c1 = Arrays.stream(nums1).filter(i -> i == 0).count();
long c2 = Arrays.stream(nums2).filter(i -> i == 0).count();

// 判断无法相等的情况
if (sum1 > sum2) {
// sum2 无法增加
if (c2 == 0) return -1;
// sum1 无法增加,sum2 有 c2 个 0,至少会增加 c2
if (c1 == 0 && c2 + sum2 > sum1) return -1;
} else if (sum1 == sum2) {
// sum1 或 sum2 一方必须增加,但另一方不会变
if (c1 == 0 && c2 != 0) return -1;
if (c1 != 0 && c2 == 0) return -1;
} else {
// 和 sum1 > sum2 的代码逻辑一致
if (c1 == 0) return -1;
if (c2 == 0 && c1 + sum1 > sum2) return -1;
}
return Math.max(sum1 + c1, sum2 + c2);
}
}

三、使数组变美的最小增量运算数

image-20231029195556639

image-20231029195611424

image-20231029195617587

如果数组中任何长度 大于或等于 3 的子数组,其 最大 元素都大于或等于 k ,则认为数组是一个 美丽数组

上述条件等价于 每个长度为 3 的子数组 中的最大元素都 $\ge k$

很明显,我们应该尽量地使每 3 个连续元素中,有一个元素 $\ge k$。但是应该使哪些元素增加到 $k$ 呢?不清楚,那就用 DP —— 局部暴力解

1️⃣DP定义:dp[i]:使得 nums[0:i] 为美丽数组,并且 nums[i] $\ge k$ 的最小递增运算数。—— 这里有 3 个加粗的重点

nums[i] $\ge k$,那么 nums[i - 2 : i] 这个长度为 3 的子数组肯定能被覆盖,满足美丽数组的条件。这一覆盖同时表明,我们至少需要保证nums[0:(i-3)] 是美丽数组,才能使得 nums[0:i] 成为美丽数组。dp[i - 3]dp[i - 2]dp[i - 1] 的结果都可以满足这一点,取最小值值即可:

2️⃣递归式:$dp[i] = \max\set{0,k-nums[i]} + \min\set{dp[i - 1], dp[i - 2], dp[i - 3]}$

3️⃣答案:dp[n - 3]dp[n - 2]dp[n - 1] 按照定义都可以使 nums 成为美丽数组,取三者最小值即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.Arrays;

class Solution {
public long minIncrementOperations(int[] nums, int k) {
int n = nums.length;
long[] dp = new long[n];
// base case
Arrays.fill(dp, Long.MAX_VALUE);
// n >= 3 保证了初始条件不需要根据 n 判断元素是否存在
dp[0] = Math.max(0, k - nums[0]);
dp[1] = Math.max(0, k - nums[1]);
dp[2] = Math.max(0, k - nums[2]);
// recursively
for (int i = 3; i < n; i++) {
long v = Math.max(0, k - nums[i]);
dp[i] = Math.min(dp[i], dp[i - 1] + v);
dp[i] = Math.min(dp[i], dp[i - 2] + v);
dp[i] = Math.min(dp[i], dp[i - 3] + v);
}
// answer
return Math.min(dp[n - 1], Math.min(dp[n - 2], dp[n - 3]));
}
}

四、收集所有金币可获得的最大积分

image-20231029195640792

image-20231029195650693

image-20231029195700814

1️⃣建树。其实是建图,只是我们要从节点 0 开始 dfs,模拟树遍历的过程

2️⃣收集金币的两个方案(题目故意写得复杂,需要化简):

  • 方案 1:当前收集到的金币加上 coins[i] - k (无论正负)
  • 方案 2:floor(coins[i] / 2) 就是整数除法,需要用位运算优化,即 coins[i] >> 1。位运算还有一个好处,假设当前节点有 $k$ 个祖先节点选择了方案 2,那么该节点的金币数就是 coins[i] >> k,免去了计算和除以 $2^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
/**
* 前序遍历,收集金币
* @param root 子树的根节点
* @param half 子树的祖先节点选择过多少次方案 2
* @return 该子树能够收集的最大金币数
*/
private long recur(Node root, long half) {
if (root == null) return 0;
// node 是图中的节点,遍历需要 visited 数组辅助
// 前序遍历本质上是 dfs
visited[root.index] = true;
// 方案一
long res1 = (root.val >> half) - k;
for (Node child : root.children) {
if (visited[child.index]) continue;
res1 += recur(child, half);
}

// 方案二
long res2 = (root.val >> (1 + half));
for (Node child : root.children) {
if (visited[child.index]) continue;
res2 += recur(child, half + 1);
}

visited[root.index] = false;
return Math.max(res1, res2);
}

到这一步为止,我在比赛中都写了出来,但还是超时了。之后,我又想到了如下的优化思路:

3️⃣ $\forall i, coins[i] \le 10^4$ ,并且 $\log_2(10^4) \approx 13.8$ ,因此,只要当前子树的祖先节点选择了至少 14 次方案 2,该子树中的所有节点的金币数都会不断减半至 0

这相当于一个剪枝,在 recur 函数开始处判断:

1
if (half >= 14) return 0;

4️⃣说到递归,就想到分治算法范式,又会联想到 DP 算法范式。两者的区别是什么?—— DP 会复用子问题的结果,因此效率更高。

尽管遍历树的过程没法写成迭代,但我们可以用 map 来缓存子问题的结果:key 是 (nodes[i], half),value 是收集到的金币数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private final HashMap<Node, HashMap<Long, Long>> dpMap = new HashMap<>();

private long recur(Node root, long half) {
if (root == null) return 0;
// 查询缓存,查到就直接返回
HashMap<Long, Long> mp = dpMap.get(root);
if (mp != null) {
Long ans = mp.get(half);
if (ans != null) return ans;
}

// 方案 1 和方案 2 的代码......

// 插入缓存
long ans = Math.max(res1, res2);
dpMap.putIfAbsent(root, new HashMap<>());
dpMap.get(root).put(half, ans);

return ans;
}

加上了这两步优化,成功 AC 了:

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
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.stream.IntStream;

class Solution {
private static class Node {
List<Node> children = new ArrayList<>();
int val;
int index;

public Node(int val, int index) {
this.val = val;
this.index = index;
}
}

private int k;
private boolean[] visited;

public int maximumPoints(int[][] edges, int[] coins, int k) {
this.k = k;
int n = coins.length;
visited = new boolean[n];
Node[] nodes = IntStream.range(0, n).mapToObj(i -> new Node(coins[i], i)).toArray(Node[]::new);
for (int[] e : edges) {
nodes[e[0]].children.add(nodes[e[1]]);
nodes[e[1]].children.add(nodes[e[0]]);
}
return (int) recur(nodes[0], 0);
}


/**
* 递归结果缓存,通过缓存将分治算法晋升为DP算法,提升效率
* <node, <half, result>>
*/
private final HashMap<Node, HashMap<Long, Long>> dpMap = new HashMap<>();

/**
* 前序遍历,收集金币
*
* @param root 子树的根节点
* @param half 子树的祖先节点选择过多少次方案 2
* @return 该子树能够收集的最大金币数
*/
private long recur(Node root, long half) {
// coins[i] <= 10^4 -> log_2 (10^4) < 14
if (half >= 14) return 0;
if (root == null) return 0;
// 查询缓存,查到就直接返回
HashMap<Long, Long> mp = dpMap.get(root);
if (mp != null) {
Long ans = mp.get(half);
if (ans != null) return ans;
}
// node 是图中的节点,遍历需要 visited 数组辅助
// 前序遍历本质上是 dfs
visited[root.index] = true;
// 方案一
long res1 = (root.val >> half) - k;
for (Node child : root.children) {
if (visited[child.index]) continue;
res1 += recur(child, half);
}

// 方案二
long res2 = (root.val >> (1 + half));
for (Node child : root.children) {
if (visited[child.index]) continue;
res2 += recur(child, half + 1);
}

visited[root.index] = false;
// 插入缓存
long ans = Math.max(res1, res2);
dpMap.putIfAbsent(root, new HashMap<>());
dpMap.get(root).put(half, ans);

return ans;
}
}