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

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

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

T1. 统计对称整数的数目(Easy)

  • 标签:模拟

T2. 生成特殊数字的最少操作(Medium)

  • 标签:思维、回溯、双指针

T3. 统计趣味子数组的数目(Medium)

  • 标签:同余定理、前缀和、散列表

T4. 边权重均等查询(Hard)

  • 标签:图、倍增、LCA、树上差分


T1. 统计对称整数的数目(Easy)

https://leetcode.cn/problems/count-symmetric-integers/

题解(模拟)

根据题意模拟,亦可以使用前缀和预处理优化。

class Solution {
fun countSymmetricIntegers(low: Int, high: Int): Int {
var ret = 0
for (x in low..high) {
val s = "$x"
val n = s.length
if (n % 2 != 0) continue
var diff = 0
for (i in 0 until n / 2) {
diff += s[i] - '0'
diff -= s[n - 1 - i] - '0'
}
if (diff == 0) ret += 1
}
return ret
}
}

复杂度分析:

  • 时间复杂度:$O((high-low)lg^{high})$ 单次检查时间为 $O(lg^{high})$;
  • 空间复杂度:$O(1)$ 仅使用常量级别空间。

T2. 生成特殊数字的最少操作(Easy)

https://leetcode.cn/problems/minimum-operations-to-make-a-special-number/

题解一(回溯)

思维题,这道卡了多少人。

  • 阅读理解: 在一次操作中,您可以选择 $num$ 的任意一位数字并将其删除,求最少需要多少次操作可以使 $num$ 变成 $25$ 的倍数;
  • 规律: 对于 $25$ 的倍数,当且仅当结尾为「00、25、50、75」这 $4$ 种情况时成立,我们尝试构造出尾部符合两个数字能被 $25$ 整除的情况。

可以用回溯解决:

class Solution {
fun minimumOperations(num: String): Int {
val memo = HashMap<String, Int>() fun count(x: String): Int {
val n = x.length
if (n == 1) return if (x == "0") 0 else 1
if (((x[n - 2] - '0') * 10 + (x[n - 1]- '0')) % 25 == 0) return 0
if(memo.containsKey(x))return memo[x]!!
val builder1 = StringBuilder(x)
builder1.deleteCharAt(n - 1)
val builder2 = StringBuilder(x)
builder2.deleteCharAt(n - 2)
val ret = 1 + min(count(builder1.toString()), count(builder2.toString()))
memo[x]=ret
return ret
} return count(num)
}
}

复杂度分析:

  • 时间复杂度:$O(n^2·m)$ 最多有 $n^2$ 种子状态,其中 $m$ 是字符串的平均长度,$O(m)$ 是构造中间字符串的时间;
  • 空间复杂度:$O(n)$ 回溯递归栈空间。

题解二(双指针)

初步分析:

  • 模拟: 事实上,问题的方案最多只有 4 种,回溯的中间过程事实在尝试很多无意义的方案。我们直接枚举这 4 种方案,删除尾部不属于该方案的字符。以 25 为例,就是删除 5 后面的字符以及删除 2 与 5 中间的字符;
  • 抽象: 本质上是一个最短匹配子序列的问题,即 「找到 nums 中最靠后的匹配的最短子序列」问题,可以用双指针模拟。

具体实现:

  • 双指针: 我们找到满足条件的最靠左的下标 i,并删除末尾除了目标数字外的整段元素,即 $ret = n - i - 2$;
  • 特殊情况: 在 4 种构造合法的特殊数字外,还存在删除所有非 0 数字后构造出 0 的方案;
  • 是否要验证数据含有前导零: 对于构造「00」的情况,是否会存在删到最后剩下多个 0 的情况呢?其实是不存在的。因为题目说明输入数据 num 本身是不包含前导零的,如果最后剩下多个 0 ,那么在最左边的 0 左侧一定存在非 0 数字,否则与题目说明矛盾。
class Solution {
fun minimumOperations(num: String): Int {
val n = num.length
var ret = n
for (choice in arrayOf("00", "25", "50", "75")) {
// 双指针
var j = 1
for (i in n - 1 downTo 0) {
if (choice[j] != num[i]) continue
if (--j == -1) {
ret = min(ret, n - i - 2)
break
}
}
}
// 特殊情况
ret = min(ret, n - num.count { it == '0'})
return ret
}
}

复杂度分析:

  • 时间复杂度:$O(n)$ 4 种方案和特殊方案均是线性遍历;
  • 空间复杂度:$O(1)$ 仅使用常量级别空间。

T3. 统计趣味子数组的数目(Medium)

https://leetcode.cn/problems/count-of-interesting-subarrays/

题解(同余 + 前缀和 + 散列表)

初步分析:

  • 问题目标: 统计数组中满足目标条件的子数组;
  • 目标条件: 在子数组范围 $[l, r]$ 内,设 $cnt$ 为满足 $nums[i] % m == k$ 的索引 $i$ 的数量,并且 $cnt % m == k$。大白话就是算一下有多少数的模是 $k$,再判断个数的模是不是也是 $k$;
  • 权重: 对于满足 $nums[i] % m == k$ 的元素,它对结果的贡献是 $1$,否则是 $0$;

分析到这里,容易想到用前缀和实现:

  • 前缀和: 记录从起点到 $[i]$ 位置的 $[0, i]$ 区间范围内满足目标的权重数;
  • 两数之和: 从左到右枚举 $[i]$,并寻找已经遍历的位置中满足 $(preSum[i] - preSum[j]) % m == k$ 的方案数记入结果;
  • 公式转换: 上式带有取模运算,我们需要转换一下:
    • 原式 $(preSum[i] - preSum[j]) % m == k$
    • 考虑 $preSum[i] % m - preSum[j] % m$ 是正数数的的情况,原式等价于:$preSum[i] % m - preSum[j] % m == k$
    • 考虑 $preSum[i] % m - preSum[j] % m$ 是负数的的情况,我们在等式左边增加补数:$(preSum[i] % m - preSum[j] % m + m) %m == k$
    • 联合正数和负数两种情况,即我们需要找到前缀和为 $(preSum[i] % m - k + m) % m$ 的元素;
  • 修正前缀和定义: 最后,我们修改前缀和的定义为权重 $% m$。

组合以上技巧:

class Solution {
fun countInterestingSubarrays(nums: List<Int>, m: Int, k: Int): Long {
val n = nums.size
var ret = 0L
val preSum = HashMap<Int, Int>()
preSum[0] = 1 // 注意空数组的状态
var cur = 0
for (i in 0 until n) {
if (nums[i] % m == k) cur ++ // 更新前缀和
val key = cur % m
val target = (key - k + m) % m
ret += preSum.getOrDefault(target, 0) // 记录方案
preSum[key] = preSum.getOrDefault(key, 0) + 1 // 记录前缀和
}
return ret
}
}

复杂度分析:

  • 时间复杂度:$O(n)$ 线性遍历,单次查询时间为 $O(1)$;
  • 空间复杂度:$O(m)$ 散列表空间。

相似题目:


T4. 边权重均等查询(Hard)

https://leetcode.cn/problems/minimum-edge-weight-equilibrium-queries-in-a-tree/

题解(倍增求 LCA、树上差分)

初步分析:

  • 问题目标: 给定若干个查询 $[x, y]$,要求计算将 $<x, y>$ 的路径上的每条边修改为相同权重的最少操作次数;
  • 问题要件: 对于每个查询 $[x, y]$,我们需要计算 $<x, y>$ 的路径长度 $l$,以及边权重的众数的出现次数 $c$,而要修改的操作次数就是 $l - c$;
  • 技巧: 对于 “树上路径” 问题有一种经典技巧,我们可以把 $<x, y>$ 的路径转换为从 $<x, lca>$ 的路径与 $<lca, y>$ 的两条路径;

思考实现:

  • 长度: 将问题转换为经过 $lca$ 中转的路径后,路径长度 $l$ 可以用深度来计算:$l = depth[x] + depth[y] - 2 * depth[lca]$;
  • 权重: 同理,权重 $w[x,y]$ 可以通过 $w[x, lca]$ 与 $w[lca, y]$ 累加计算;

现在的关键问题是,如何快速地找到 $<x, y>$ 的最近公共祖先 LCA?

对于单次 LCA 操作来说,我们可以走 DFS 实现 $O(n)$ 时间复杂度的算法,而对于多次 LCA 操作可以使用 倍增算法 预处理以空间换时间,单次 LCA 操作的时间复杂度进位 $O(lgn)$。

在 LeetCode 有倍增的模板题 1483. 树节点的第 K 个祖先

在求 LCA 时,我们先把 $<x, y>$ 跳到相同高度,再利用倍增算法向上跳 $2^j$ 个父节点,直到到达相同节点即为最近公共祖先。

class Solution {
fun minOperationsQueries(n: Int, edges: Array<IntArray>, queries: Array<IntArray>): IntArray {
val U = 26
// 建图
val graph = Array(n) { LinkedList<IntArray>() }
for (edge in edges) {
graph[edge[0]].add(intArrayOf(edge[1], edge[2] - 1))
graph[edge[1]].add(intArrayOf(edge[0], edge[2] - 1))
} // 预处理深度、倍增祖先节点、倍增路径信息
val m = 32 - Integer.numberOfLeadingZeros(n - 1)
val depth = IntArray(n)
val parent = Array(n) { IntArray(m) { -1 }} // parent[i][j] 表示 i 的第 2^j 个父节点
val cnt = Array(n) { Array(m) { IntArray(U) }} // cnt[i][j] 表示 <i - 2^j> 个父节点的路径信息 fun dfs(i: Int, par: Int) {
for ((to, w) in graph[i]) {
if (to == par) continue // 避免回环
depth[to] = depth[i] + 1
parent[to][0] = i
cnt[to][0][w] = 1
dfs(to, i)
}
} dfs(0, -1) // 选择 0 作为根节点 // 预处理倍增
for (j in 1 until m) {
for (i in 0 until n) {
val from = parent[i][j - 1]
if (-1 != from) {
parent[i][j] = parent[from][j - 1]
cnt[i][j] = cnt[i][j - 1].zip(cnt[from][j - 1]) { e1, e2 -> e1 + e2 }.toIntArray()
}
}
} // 查询
val q = queries.size
val ret = IntArray(q)
for ((i, query) in queries.withIndex()) {
var (x, y) = query
// 特判
if (x == y || parent[x][0] == y || parent[y][0] == x) {
ret[i] = 0
}
val w = IntArray(U) // 记录路径信息
var path = depth[x] + depth[y] // 记录路径长度
// 先跳到相同高度
if (depth[y] > depth[x]) {
val temp = x
x = y
y = temp
}
var k = depth[x] - depth[y]
while (k > 0) {
val j = Integer.numberOfTrailingZeros(k) // 二进制分解
w.indices.forEach { w[it] += cnt[x][j][it] } // 记录路径信息
x = parent[x][j] // 向上跳 2^j 个父节点
k = k and (k - 1)
} // 再使用倍增找 LCA
if (x != y) {
for (j in m - 1 downTo 0) { // 最多跳 m - 1 次
if (parent[x][j] == parent[y][j]) continue // 跳上去相同就不跳
w.indices.forEach { w[it] += cnt[x][j][it] } // 记录路径信息
w.indices.forEach { w[it] += cnt[y][j][it] } // 记录路径信息
x = parent[x][j]
y = parent[y][j] // 向上跳 2^j 个父节点
}
// 最后再跳一次就是 lca
w.indices.forEach { w[it] += cnt[x][0][it] } // 记录路径信息
w.indices.forEach { w[it] += cnt[y][0][it] } // 记录路径信息
x = parent[x][0]
}
// 减去重链长度
ret[i] = path - 2 * depth[x] - w.max()
}
return ret
}
}

复杂度分析:

  • 时间复杂度:$O(nlgn·U)$ 预处理的时间复杂度是 $O(nlgn·U)$,单次查询的时间是 $O(lgn·U)$;
  • 空间复杂度:$O(nlgn·U)$ 预处理倍增信息空间。

推荐阅读

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

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

LeetCode 周赛上分之旅 #44 同余前缀和问题与经典倍增 LCA 算法的更多相关文章

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

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

  2. LeetCode 周赛 333,你管这叫 Medium 难度?

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 上周是 LeetCode 第 333 场周赛,你参加了吗?这场周赛质量很高,但难度标得不 ...

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

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

  4. LeetCode 周赛 340,质数 / 前缀和 / 极大化最小值 / 最短路 / 平衡二叉树

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 上周跟大家讲到小彭文章风格的问题,和一些朋友聊过以后,至少在算法题解方面确定了小彭的风格 ...

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

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

  6. 【Leetcode周赛】从contest-41开始。(一般是10个contest写一篇文章)

    Contest 41 ()(题号) Contest 42 ()(题号) Contest 43 ()(题号) Contest 44 (2018年12月6日,周四上午)(题号653—656) 链接:htt ...

  7. 【Leetcode周赛】从contest-111开始。(一般是10个contest写一篇文章)

    Contest 111 (题号941-944)(2019年1月19日,补充题解,主要是943题) 链接:https://leetcode.com/contest/weekly-contest-111 ...

  8. LeetCode 周赛 332,在套路里摸爬滚打~

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,今天是 3T 选手小彭. 上周是 LeetCode 第 332 场周赛,你参加了吗?算法解题思维需要 ...

  9. LeetCode 周赛 334,在算法的世界里反复横跳

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 今天是 LeetCode 第 334 场周赛,你参加了吗?这场周赛考察范围比较基础,整体 ...

  10. LeetCode 周赛 336,多少人直接 CV?

    本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问. 大家好,我是小彭. 今天早上是 LeetCode 第 336 场周赛,你参加了吗?这场周赛整体质量比较高,但 ...

随机推荐

  1. AcWing 423. 采药

    辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师. 为此,他想拜附近最有威望的医师为师. 医师为了判断他的资质,给他出了一个难题. 医师把他带到一个到处都是草药的山洞里对他说:"孩子 ...

  2. postman接口关联-token值

    背景: 在测试工作中,测试鉴权的接口需要用到登录接口的token,需要我们先调用登录接口,获得token,然后把即时获得的token填入请求中发送请求,我们可以用设置全局变量的办法解决这个问题   实 ...

  3. 如何使用Go中的Weighted实现资源管理

    1. 简介 本文将介绍 Go 语言中的 Weighted 并发原语,包括 Weighted 的基本使用方法.实现原理.使用注意事项等内容.能够更好地理解和应用 Weighted 来实现资源的管理,从而 ...

  4. Java(instanceof和类型转换)

    1.instanceof和类型转换 instanceof 引用类型比较,判断一个对象是什么类型 public static void main(String[] args) { // Object & ...

  5. 记一次618军演压测TPS上不去排查及优化

    本文内容主要介绍,618医药供应链质量组一次军演压测发现的问题及排查优化过程.旨在给大家借鉴参考. 背景 本次军演压测背景是,2B业务线及多个业务侧共同和B中台联合军演. 现象 当压测商品卡片接口的时 ...

  6. 解码器 | 基于 Transformers 的编码器-解码器模型

    基于 transformer 的编码器-解码器模型是 表征学习 和 模型架构 这两个领域多年研究成果的结晶.本文简要介绍了神经编码器-解码器模型的历史,更多背景知识,建议读者阅读由 Sebastion ...

  7. 解决适用EntityFramework生成时报错“无法解析依赖项。"EntityFramework 6.4.4" 与 ' EntityFramework.zh-Hans 6.2.0 约束:EntityFramework(=6.2.0)'不兼容。"

    起因:通过vs2022创建mvc项目时, 执行添加"包含视图的MVC5控制器(使用Entity Framework)时 点击添加,出现错误提示  解决方法: 在您的解决方案资源管理器中,右键 ...

  8. C++面试八股文:了解auto关键字吗?

    某日二师兄参加XXX科技公司的C++工程师开发岗位第15面: 面试官:了解auto关键字吗? 二师兄:嗯,了解一些(我很熟悉). 面试官:说一说auto的用法吧? 二师兄:auto主要是为了编译器进行 ...

  9. 5. Mybatis获取参数值的两种方式

    ‍ MyBatis 获取参数值的两种方式:​${} 和 #{}​ ${}的本质就是字符串拼接,#{}的本质就是占位符赋值 ${}使用字符串拼接的方式拼接 sql,若为字符串类型或日期类型的字段进行赋值 ...

  10. oracle 19c rpm 个性化配置安装

    简单来说就是: 1.安装preinstall   :    oracle-database-preinstall-19c-1.0-1.el7.x86_64.rpm 2.安装 ee    : oracl ...