本文已收录到 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. 【python基础】类-继承

    编写类时,并非总是要从空白开始.如果要编写的类时另一个现成类的特殊版本,可使用继承.一个类继承另一个类时,它将自动获得另一个类的所有属性和方法 原有的类称为父类,而新类被称为子类.子类继承了其父类的所 ...

  2. Linux Nacos2.2.0版本集群搭建,常见报错问题解决

    准备: 服务器,nacos,mysql,nginx,java,maven Nacos 官网:https://nacos.io 下载地址github:https://github.com/alibaba ...

  3. Hugging News #0703: 在浏览器中运行 Whisper 模型、WAIC 分论坛活动邀请报名

    每一周,我们的同事都会向社区的成员们发布一些关于 Hugging Face 相关的更新,包括我们的产品和平台更新.社区活动.学习资源和内容更新.开源库和模型更新等,我们将其称之为「Hugging Ne ...

  4.  Python + unittest + ddt + HTMLTestRunner + log + excel + mysql + 企业微信通知, 接口自动化框架V2.0,支持多业务处理,仅需维护 excel 用例,无需要编写代码

    Python + unittest + ddt + HTMLTestRunner + log + excel + mysql + 企业微信通知 + Jenkins 实现的接口自动化框架. 项目介绍 接 ...

  5. 【Springboot】拦截器

    Springboot 拦截器 1.什么是拦截器? 拦截器可以根据 URL 对请求进行拦截,主要应用于登陆校验.权限验证.乱码解决.性能监控和异常处理等功能. 2.定义拦截器步骤 在 Spring Bo ...

  6. 踏入数字天地之中 | Metaworld SDK 2.0进化纵览

    ​ ZEGO从未停止对技术边界的探索,我们力图让用户能够更高效.便捷地使用技术去创造价值. 去年8月,ZEGO打造的元宇宙智能互动引擎首次与大家见面,Metaworld SDK作为其中的核心能力组件, ...

  7. Kerberos、黄金票据与白银票据

    kerberos Kerberos是一个网络认证协议,用于验证用户和服务之间的身份,解决分布式计算环境中的身份验证问题.它使用加密技术来提供安全的身份验证,并防止网络中的身份欺骗攻击.Kerberos ...

  8. 这可能是前端处理excel最好的工具了

    大家好,我是程序视点的小二哥! 今天小二哥要分享的是一个纯前端实现读取和导出excel文件的工具库:ExcelJS ExcelJs 简介 功能十分简单: 读取,操作并写入电子表格数据和样式到 XLSX ...

  9. Vue笔记(一)

    1. Vue.js是什么? 1). 一位华裔前Google工程师(尤雨溪)开发的前端js库 2). 作用: 动态构建用户界面 3). 特点: * 遵循MVVM模式 * 编码简洁, 体积小, 运行效率高 ...

  10. FlinkSQL类型系统

    类型有什么作用, 类型可以提供编译期检查, 避免到运行期才报错. 类型 首先Flink中自己定义了一套类型, 有LogicalType和DataType两个表示 LogicalType Logical ...