本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 BaguTree Pro 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭与你分享每场 LeetCode 周赛的解题报告,一起体会上分之旅。

本文是 LeetCode 上分之旅系列的第 48 篇文章,往期回顾请移步到文章末尾~

LeetCode 双周赛 114

T1. 收集元素的最少操作次数(Easy)

  • 标签:模拟、散列表

T2. 使数组为空的最少操作次数(Medium)

  • 标签:贪心、散列表

T3. 将数组分割成最多数目的子数组(Medium)

  • 标签:思维、位运算

T4. 可以被 K 整除连通块的最大数目(Hard)

  • 标签:树上 DP


T1. 收集元素的最少操作次数(Easy)

https://leetcode.cn/problems/minimum-operations-to-collect-elements/description/

题解(散列表)

简单模拟题。

预初始化包含 $1 - k$ 元素的集合,根据题意逆向遍历数组并从集合中移除元素,当集合为空时表示已经收集到所有元素,返回 $n - i$。

class Solution {
fun minOperations(nums: List<Int>, k: Int): Int {
val n = nums.size
val set = (1..k).toHashSet()
for (i in n - 1 downTo 0) {
set.remove(nums[i])
if (set.isEmpty()) return n - i
}
return -1
}
}
class Solution:
def minOperations(self, nums, k):
n, nums_set = len(nums), set(range(1, k+1))
for i in range(n-1, -1, -1):
nums_set.discard(nums[i])
if not nums_set:
return n - i
return -1
class Solution {
public:
int minOperations(std::vector<int>& nums, int k) {
int n = nums.size();
unordered_set<int> set;
for (int i = 1; i <= k; ++i) {
set.insert(i);
}
for (int i = n - 1; i >= 0; --i) {
set.erase(nums[i]);
if (set.empty()) {
return n - i;
}
}
return -1;
}
};
function minOperations(nums: number[], k: number): number {
var n = nums.length;
var set = new Set<number>();
for (let i = 1; i <= k; ++i) {
set.add(i);
}
for (let i = n - 1; i >= 0; --i) {
set.delete(nums[i]);
if (set.size === 0) {
return n - i;
}
}
return -1;
};
class Solution {
int minOperations(List<int> nums, int k) {
int n = nums.length;
Set<int> set = Set<int>();
for (int i = 1; i <= k; i++) {
set.add(i);
}
for (int i = n - 1; i >= 0; i--) {
set.remove(nums[i]);
if (set.isEmpty) return n - i;
}
return -1;
}
}

复杂度分析:

  • 时间复杂度:$O(n)$ 线性遍历;
  • 空间复杂度:$O(k)$ 散列表空间。

T2. 使数组为空的最少操作次数(Medium)

https://leetcode.cn/problems/minimum-number-of-operations-to-make-array-empty/description/

题解(贪心)

题目两种操作的前提是数字相等,因此我们先统计每个元素的出现次数。

从最少次数的目标出发,显然能移除 $3$ 个就尽量移除 $3$ 个,再分类讨论:

  • 如果出现次数为 $1$,那么一定无解,返回 $-1$;
  • 如果出现次数能够被 $3$ 整除,那么操作 $cnt / 3$ 次是最优的;
  • 如果出现次数除 $3$ 余 $1$,那么把 $1$ 个 $3$ 拆出来合并为 4,操作 $cnt / 3 + 1$ 次是最优的;
  • 如果出现次数除 $3$ 余 $2$,那么剩下的 $2$ 操作 $1$ 次,即操作 $cnt / 3 + 1$ 次是最优的。

组合以上讨论:

class Solution {
fun minOperations(nums: IntArray): Int {
val cnts = HashMap<Int, Int>()
for (e in nums) {
cnts[e] = cnts.getOrDefault(e, 0) + 1
}
var ret = 0
for ((_, cnt) in cnts) {
if (cnt == 1) return -1
when (cnt % 3) {
0 -> {
ret += cnt / 3
}
1, 2 -> {
ret += cnt / 3 + 1
}
}
}
return ret
}
}

继续挖掘题目特性,对于余数大于 $0$ 的情况总是 向上取整 ,那么可以简化为:

class Solution {
fun minOperations(nums: IntArray): Int {
val cnts = HashMap<Int, Int>()
for (e in nums) {
cnts[e] = cnts.getOrDefault(e, 0) + 1
}
var ret = 0
for ((_, cnt) in cnts) {
if (cnt == 1) return -1
ret += (cnt + 2) / 3 // 向上取整
}
return ret
}
}
class Solution:
def minOperations(self, nums: List[int]) -> int:
cnts = Counter(nums)
ret = 0
for cnt in cnts.values():
if cnt == 1: return -1
ret += (cnt + 2) // 3
return ret
class Solution {
public:
int minOperations(std::vector<int>& nums) {
unordered_map<int, int> cnts;
for (auto &e : nums) {
cnts[e] += 1;
}
int ret = 0;
for (auto &p: cnts) {
if (p.second == 1) return -1;
ret += (p.second + 2) / 3;
}
return ret;
}
};
function minOperations(nums: number[]): number {
let cnts: Map<number, number> = new Map<number, number>();
for (let e of nums) {
cnts.set(e, (cnts.get(e) ?? 0) + 1);
}
let ret = 0;
for (let [_, cnt] of cnts) {
if (cnt == 1) return -1;
ret += Math.ceil(cnt / 3);
}
return ret;
};
class Solution {
int minOperations(List<int> nums) {
Map<int, int> cnts = {};
for (int e in nums) {
cnts[e] = (cnts[e] ?? 0) + 1;
}
int ret = 0;
for (int cnt in cnts.values) {
if (cnt == 1) return -1;
ret += (cnt + 2) ~/ 3; // 向上取整
}
return ret;
}
}

复杂度分析:

  • 时间复杂度:$O(n)$ 线性遍历
  • 空间复杂度:$O(n)$ 计数空间。

T3. 将数组分割成最多数目的子数组(Medium)

https://leetcode.cn/problems/split-array-into-maximum-number-of-subarrays/description/

题解(思维题)

一个重要的结论是:当按位与的数量增加时,按位与的结果是非递增的。

题目要求在子数组的按位与的和最小的前提下,让子数组的个数最大。根据上面的结论,显然将数组全部按位与是最小的。

分类讨论:

  • 如果整体按位于的结果不为 $0$,那么就不可能存在分割数组的方法使得按位与的和更小,直接返回 $1$;
  • 否则,问题就变成分割数组的最大个数,使得每个子数组按位与为 $0$,直接贪心分割就好了。
class Solution {
fun maxSubarrays(nums: IntArray): Int {
val mn = nums.reduce { acc, it -> acc and it }
if (mn > 0) return 1 // 特判
var ret = 0
var cur = Integer.MAX_VALUE
for (i in nums.indices) {
cur = cur and nums[i]
if (cur == 0) {
cur = Integer.MAX_VALUE
ret++
}
}
return ret
}
}
class Solution:
def maxSubarrays(self, nums: List[int]) -> int:
if reduce(iand, nums): return 1
ret, mask = 0, (1 << 20) - 1
cur = mask
for num in nums:
cur &= num
if cur == 0: ret += 1; cur = mask
return ret
class Solution {
public:
int maxSubarrays(vector<int>& nums) {
int mn = nums[0];
for (auto num : nums) mn &= num;
if (mn != 0) return 1;
int ret = 0;
int cur = INT_MAX;
for (int i = 0; i < nums.size(); i++) {
cur &= nums[i];
if (cur == 0) {
cur = INT_MAX;
ret++;
}
}
return ret;
}
};
function maxSubarrays(nums: number[]): number {
const n = nums.length;
let mn = nums.reduce((acc, it) => acc & it);
if (mn > 0) return 1; // 特判
let mask = (1 << 20) - 1
let ret = 0;
let cur = mask;
for (let i = 0; i < n; i++) {
cur = cur & nums[i];
if (cur === 0) {
cur = mask;
ret++;
}
}
return ret;
};
class Solution {
int maxSubarrays(List<int> nums) {
var mn = nums.reduce((acc, it) => acc & it);
if (mn > 0) return 1; // 特判
var mask = (1 << 20) - 1;
var ret = 0;
var cur = mask;
for (var i = 0; i < nums.length; i++) {
cur = cur & nums[i];
if (cur == 0) {
cur = mask;
ret++;
}
}
return ret;
}
}

复杂度分析:

  • 时间复杂度:$O(n)$ 线性遍历;
  • 空间复杂度:$O(1)$ 仅使用常量级别空间。

T4. 可以被 K 整除连通块的最大数目(Hard)

https://leetcode.cn/problems/maximum-number-of-k-divisible-components/

问题分析

初步分析:

  • 问题目标: 求解分割后满足条件的最大连通块数量;
  • 问题条件: 连通块的和能够被 K 整除;
  • 关键信息: 题目保证数据是可以分割的,这是重要的前提。

思考实现:

在保证问题有解的情况下,树上的每个节点要么是单独的连通分量,要么与邻居组成连通分量。那么,这就是典型的「连或不连」和「连哪个」动态规划思维。

  • 思考「连或不连」:

如果节点 $A$ 的价值能够被 $K$ 整除,那么节点 $A$ 能作为单独的连通分量吗?

不一定,例如 $K = 3$ 且树为 $1 - 3 - 5$ 的情况,连通分量只能为 $1$,因为 $3$ 左右子树都不能构造合法的连通块,因此需要与 $3$ 连接才行。

  • 继续思考「连哪个」:

那么,节点 $A$ 应该与谁相连呢?对于节点 $A$ 的某个子树 $Tree_i$ 来说,存在 $2$ 种情况:

  • 能整除:那么子树 $Tree_i$ 不需要和节点 $A$ 相连;
  • 不能整除:那么子树 $Tree_i$ 的剩余值就必须与节点 $A$ 相连,有可能凑出 $K$ 的整除。

当节点 $A$ 与所有子树的剩余值组合后,再加上当前节点的价值,如果能够构造出 $K$ 的整数倍时,说明找到一个新的连通块,并且不需要和上一级节点组合。否则,则进入不能整除的条件,继续和上一级节点组合。

题解(DFS)

  • 定义 DFS 函数并返回两个数值:<子树构造的连通分量, 剩余值>;
  • 任意选择一个节点为根节点走一遍 DFS,最终返回 $dfs(0,-1)[0]$。
class Solution {
fun maxKDivisibleComponents(n: Int, edges: Array<IntArray>, values: IntArray, k: Int): Int {
// 建图
val graph = Array(n) { LinkedList<Int>() }
for ((u, v) in edges) {
graph[u].add(v)
graph[v].add(u)
}
// DFS <cnt, left>
fun dfs(i: Int, pre: Int): IntArray {
var ret = intArrayOf(0, values[i])
for (to in graph[i]) {
if (to == pre) continue
val (childCnt, childLeft) = dfs(to, i)
ret[0] += childCnt
ret[1] += childLeft
}
if (ret[1] % k == 0) {
ret[0] += 1
ret[1] = 0
}
return ret
}
return dfs(0, -1)[0]
}
}
class Solution:
def maxKDivisibleComponents(self, n, edges, values, k):
# 建图
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# DFS <cnt, left>
def dfs(i, pre):
ret = [0, values[i]]
for to in graph[i]:
if to == pre: continue
childCnt, childLeft = dfs(to, i)
ret[0] += childCnt
ret[1] += childLeft
if ret[1] % k == 0:
ret[0] += 1
ret[1] = 0
return ret
return dfs(0, -1)[0]
class Solution {
public:
int maxKDivisibleComponents(int n, vector<vector<int>>& edges, vector<int>& values, int k) {
// 建图
vector<list<int>> graph(n);
for (auto& edge : edges) {
int u = edge[0];
int v = edge[1];
graph[u].push_back(v);
graph[v].push_back(u);
}
// DFS <cnt, left>
function<vector<int>(int, int)> dfs = [&](int i, int pre) -> vector<int> {
vector<int> ret(2, 0);
ret[1] = values[i];
for (int to : graph[i]) {
if (to == pre) continue;
vector<int> child = dfs(to, i);
ret[0] += child[0];
ret[1] += child[1];
}
if (ret[1] % k == 0) {
ret[0] += 1;
ret[1] = 0;
}
return ret;
};
return dfs(0, -1)[0];
}
};
function maxKDivisibleComponents(n: number, edges: number[][], values: number[], k: number): number {
// 建图
let graph = Array(n).fill(0).map(() => []);
for (const [u, v] of edges) {
graph[u].push(v);
graph[v].push(u);
}
// DFS <cnt, left>
let dfs = (i: number, pre: number): number[] => {
let ret = [0, values[i]];
for (let to of graph[i]) {
if (to === pre) continue;
let [childCnt, childLeft] = dfs(to, i);
ret[0] += childCnt;
ret[1] += childLeft;
}
if (ret[1] % k === 0) {
ret[0] += 1;
ret[1] = 0;
}
return ret;
};
return dfs(0, -1)[0];
};
class Solution {
int maxKDivisibleComponents(int n, List<List<int>> edges, List<int> values, int k) {
// 建图
List<List<int>> graph = List.generate(n, (_) => []);
for (final edge in edges) {
int u = edge[0];
int v = edge[1];
graph[u].add(v);
graph[v].add(u);
}
// DFS <cnt, left>
List<int> dfs(int i, int pre) {
List<int> ret = [0, values[i]];
for (int to in graph[i]) {
if (to == pre) continue;
List<int> child = dfs(to, i);
ret[0] += child[0];
ret[1] += child[1];
}
if (ret[1] % k == 0) {
ret[0] += 1;
ret[1] = 0;
}
return ret;
}
return dfs(0, -1)[0];
}
}

复杂度分析:

  • 时间复杂度:$O(n)$ 每个节点访问 $1$ 次;
  • 空间复杂度:$O(n)$ 图空间。

推荐阅读

LeetCode 上分之旅系列往期回顾:

️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

LeetCode 周赛上分之旅 #48 一道简单的树上动态规划问题的更多相关文章

  1. 刷爆 LeetCode 周赛 337,位掩码/回溯/同余/分桶/动态规划·打家劫舍/贪心

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 上周末是 LeetCode 第 337 场周赛,你参加了吗?这场周赛第三题有点放水,如果 ...

  2. LeetCode 周赛 342(2023/04/23)容斥原理、计数排序、滑动窗口、子数组 GCB

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 前天刚举办 2023 年力扣杯个人 SOLO 赛,昨天周赛就出了一场 Easy - Ea ...

  3. 又一道简单题&&Ladygod(两道思维水题)

    Ladygod Time Limit: 3000/1000MS (Java/Others)     Memory Limit: 65535/65535KB (Java/Others) Submit S ...

  4. 一道简单的面试题,难倒各大 Java 高手!

    Java技术栈 www.javastack.cn 优秀的Java技术公众号 最近栈长在我们的<Java技术栈知识星球>上分享的一道 Java 实战面试题,很有意思,现在拿出来和大家分享下, ...

  5. 通过一道简单的例题了解Linux内核PWN

    写在前面 这篇文章目的在于简单介绍内核PWN题,揭开内核的神秘面纱.背后的知识点包含Linux驱动和内核源码,学习路线非常陡峭.也就是说,会一道Linux内核PWN需要非常多的铺垫知识,如果要学习可以 ...

  6. Kindle:自动追更之云上之旅

    2017年5月27: 原来的程序是批处理+Python脚本+Calibre2的方式,通过设定定时任务的方式,每天自动发动到自己的邮箱中.缺点是要一直开着电脑,又不敢放到服务器上~~ 鉴于最近公司查不关 ...

  7. CSU 1785: 又一道简单题

    1785: 又一道简单题 Submit Page   Summary   Time Limit: 5 Sec     Memory Limit: 128 Mb     Submitted: 602   ...

  8. QDUOJ 一道简单的数据结构题 栈的使用(括号配对)

    一道简单的数据结构题 发布时间: 2017年6月3日 18:46   最后更新: 2017年6月3日 18:51   时间限制: 1000ms   内存限制: 128M 描述 如果插入“+”和“1”到 ...

  9. POJ 3710 无向图简单环树上删边

    结论题,这题关键在于如何转换环,可以用tarjan求出连通分量后再进行标记,也可以DFS直接找到环后把点的SG值变掉就行了 /** @Date : 2017-10-23 19:47:47 * @Fil ...

  10. Leetcode 931. Minimum falling path sum 最小下降路径和(动态规划)

    Leetcode 931. Minimum falling path sum 最小下降路径和(动态规划) 题目描述 已知一个正方形二维数组A,我们想找到一条最小下降路径的和 所谓下降路径是指,从一行到 ...

随机推荐

  1. Framework 中使用 Toolkit.Mvvm 的生成器功能

    .NET Standard是.NET APIs的正式规范,可在多个.NET实现中使用..NET Standard的动机是为了在.NET生态系统中建立更大的统一性..NET 5及更高版本采用了不同的方法 ...

  2. 使用Docker将Vite Vue项目部署到Nginx二级目录

    Vue项目配置 使用Vite创建一个Vue项目,点我查看如何创建 配置打包路径 在Nginx中如果是二级目录,例如/web时,需要设置线上的打包路径 在项目跟路径下创建两个文件:.env.produc ...

  3. 【Linux内核】内核源码编译

    Linux内核源码编译过程 总体流程: 下载Linux内核源码文件 安装所需工具 解压源码文件并配置 make编译源码 下载busybox 配置busybox并编译 1. Linux源码编译 http ...

  4. 4. Mybatis的增删改查(CRUD)

    1.新增 ‍ <!--int insertUser();--> <insert id="insertUser"> insert into t_user va ...

  5. 鸿蒙星空的太白星 | WebView给元服务调用JS API指明方向

    ​漆黑深夜夜凉如水,繁星盛开于无垠苍穹.清风徐来,一片薄云,夜空顿然失色,有些阴霾.天空中最亮的星,太白星,在薄云中依然闪耀,如同海上迷雾中的灯塔,为迷失方向的船只指明方向. 元服务是华为提供的一种面 ...

  6. SpringBoot项目从0到1配置logback日志打印

    大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教. 以下是正文! 一.写文背景 我们在写后端项目的时候 ...

  7. 论文日记一:AlexNet

    1.导读 ALexNet在2012图像识别竞赛中ILSVRC大放异彩,直接将错误了降低了近10个百分点. 论文<ImageNet Classification with Deep Convolu ...

  8. (占坑编辑中)hexo个人博客主页添加百度搜索资源平台

    hexo个人博客主页添加百度搜索资源平台 目的是在百度搜你的网站,可以搜到 配置过程 添加效果: 我的个人博客主页,欢迎访问 我的CSDN主页,欢迎访问 我的简书主页,欢迎访问 我的GitHub主页, ...

  9. Redis理论

    什么是Redis Redis(Remote Dictionary Server)是使用C语言编写的,开源的(BSD许可)高性能非关系型(NoSQL)的键值对数据库. Redis可以存储键和五种不同类型 ...

  10. React函数式组件渲染、useEffect顺序总结

    参考资料: 深入React的生命周期(上):出生阶段(Mount) 深入React的生命周期(下):更新(Update) 精读<useEffect 完全指南> React组件重新渲染理解 ...