文章标签 :LeetCode、前缀和、哈希表、取模运算、Go 语言、算法优化
难度 :中等
适合人群:算法刷题、Go 开发者、面试准备
📌 前言
本文详细讲解 LeetCode 1590 题「使数组和能被 p 整除」的解题思路与实现。该题综合考察前缀和 、取模运算 和哈希表的应用,是一道经典的中等难度算法题。
核心思路:通过前缀和余数 + 哈希表,将 O(n²) 暴力枚举优化为 O(n) 时间复杂度。
一、问题描述
1.1 题目要求
给你一个正整数数组 nums,请你移除最短 子数组(可以为空,但不允许移除全部元素 ),使得剩余元素的和能被 p 整除。
返回需要移除的最短子数组的长度,如果无法满足要求,返回 -1。
子数组定义:原数组中连续的一组元素。
1.2 示例
示例 1:
text
输入:nums = [3,1,4,2], p = 6
输出:1
解释:数组和为 10,10 % 6 = 4。移除 [4] 后,剩余 [3,1,2],和为 6 能被 6 整除。
示例 2:
text
输入:nums = [6,3,5,2], p = 9
输出:2
解释:移除 [5,2],剩余 [6,3],和为 9。
示例 3:
text
输入:nums = [1,2,3], p = 3
输出:0
解释:和为 6,已经能被 3 整除,无需移除。
1.3 数据范围
1 <= nums.length <= 10^51 <= nums[i] <= 10^91 <= p <= 10^9
二、解题思路
2.1 问题分析
看到"子数组的和"这种关键词,应该立即联想到前缀和 技术。但如果暴力枚举所有子数组 [i, j],时间复杂度为 O(n²) ,在 n = 10^5 的数据范围下会超时。
2.2 核心观察
设数组总和为 total,令 q = total % p:
- 如果
q == 0,说明总和已经能被 p 整除,直接返回 0 - 如果
q != 0,我们需要找到一个最短子数组,其和模 p 的余数恰好等于 q
数学证明 :假设移除子数组的和为 subSum,剩余部分和为 total - subSum。要使剩余部分能被 p 整除:
text
(total - subSum) % p == 0
=> total % p == subSum % p
=> q == subSum % p
2.3 优化算法:前缀和 + 哈希表
关键思路:
- 使用前缀和的余数
cur = prefix % p来避免大数运算 - 用哈希表记录每个余数最近一次出现的位置
- 对于当前位置 i,计算目标余数
need = (cur - q + p) % p - 如果之前出现过余数
need(位置为 j),则子数组[j+1, i]的和模 p 等于 q
算法要点:
- 使用 map/哈希表 而非数组(p 可能高达 10^9,无法开数组)
- 初始化
map[0] = -1,支持从数组开头移除 - 先查询后更新:避免用当前位置覆盖自己导致长度为 0 的错误结果
- 若最短长度等于 n,返回 -1(题目不允许移除全部元素)
三、算法实现
3.1 算法流程(伪代码)
text
sum = sum(nums)
q = sum % p
if q == 0: return 0
map = {0: -1} // 初始化:余数0对应索引-1
cur = 0
ans = INF
for i in 0..n-1:
cur = (cur + nums[i]) % p
need = (cur - q + p) % p // 目标余数
if need in map:
ans = min(ans, i - map[need]) // 更新最短长度
map[cur] = i // 记录当前余数的位置
if ans == INF or ans == n: return -1
return ans
3.2 Go 语言实现
go
func minSubarray(nums []int, p int) int {
n := len(nums)
// 1. 计算总和的余数
total := 0
for _, v := range nums {
total = (total + v) % p
}
q := total % p
if q == 0 {
return 0 // 已满足条件
}
// 2. 哈希表记录余数 -> 最近索引
last := make(map[int]int)
last[0] = -1 // 初始化
// 3. 遍历数组,维护前缀和余数
cur := 0
ans := n + 1
for i, v := range nums {
cur = (cur + v) % p
need := (cur - q + p) % p // 防止负数
// 先查询是否存在目标余数
if j, ok := last[need]; ok {
if i-j < ans {
ans = i - j
}
}
// 再更新当前余数的位置
last[cur] = i
}
// 4. 边界处理
if ans == n+1 || ans == n {
return -1 // 无法满足或需要移除全部
}
return ans
}
代码说明:
- 第 1 步:计算总和余数 q,提前判断 q == 0 的情况
- 第 2 步 :初始化哈希表,
last[0] = -1允许从数组开头移除 - 第 3 步:核心循环,先查询后更新,确保不会错误匹配自己
- 第 4 步:若答案为 n 或未更新(INF),返回 -1
四、复杂度分析
时间复杂度
O(n) --- 只需遍历一次数组,每次哈希表操作均为 O(1)。
空间复杂度
O(min(n, p)) --- 哈希表最多存储 n 个不同的余数,且余数范围为 [0, p-1]。
五、常见陷阱与注意事项
⚠️ 易错点
-
取模负数问题
- 某些语言中
(a - b) % p可能为负数 - 解决方法:
(a - b + p) % p保证结果非负
- 某些语言中
-
空间陷阱
- ❌ 不要用大小为 p 的数组(p 可达 10^9)
- ✅ 使用哈希表,只存储出现过的余数
-
更新顺序
- ❌ 先更新
map[cur] = i再查询会导致长度为 0 的错误 - ✅ 先查询
map[need],再更新map[cur]
- ❌ 先更新
-
边界条件
- 当最短长度等于 n 时,必须返回 -1(不能移除全部)
- 初始化
map[0] = -1是为了支持从头移除
🎯 测试用例建议
go
// 特殊边界
minSubarray([]int{1}, 2) // -1,单元素无法满足
minSubarray([]int{1,2}, 4) // -1,必须移除全部
minSubarray([]int{1,1,1}, 3) // 0,已满足条件
minSubarray([]int{3,1,4,2}, 6) // 1,示例 1
minSubarray([]int{6,3,5,2}, 9) // 2,示例 2
六、单元测试
项目中包含完整的单元测试文件 main_test.go,覆盖了示例用例和边界情况。
运行测试
bash
go test -v
测试输出示例
text
=== RUN TestMinSubarray
=== RUN TestMinSubarray/example1
=== RUN TestMinSubarray/example2
=== RUN TestMinSubarray/example3
=== RUN TestMinSubarray/single_impossible
=== RUN TestMinSubarray/entire_array_candidate
=== RUN TestMinSubarray/all_same
--- PASS: TestMinSubarray (0.00s)
PASS
ok <module-path> 0.00s
七、总结
核心要点
- 算法本质:将"移除子数组"问题转化为"寻找前缀余数差"问题
- 关键技巧:前缀和 + 取模 + 哈希表,实现 O(n) 时间复杂度
- 实现细节 :
- 用哈希表而非数组(避免空间溢出)
- 初始化
map[0] = -1(支持从头移除) - 先查询后更新(避免自我匹配)
- 处理边界条件(q == 0、ans == n)
适用场景
这道题的解法可以推广到类似问题:
- 子数组和模运算相关问题
- 需要找最短/最长满足某种条件的子数组
- 前缀和 + 哈希表优化的典型应用
扩展练习
- LeetCode 560:和为 K 的子数组
- LeetCode 974:和可被 K 整除的子数组
- LeetCode 523:连续的子数组和
📚 参考资源
- LeetCode 1590 题目链接
- 完整代码仓库:
algorithm-go/lettcode-cn/make-sum-divisible-by-p
如果本文对你有帮助,欢迎点赞收藏!有问题欢迎在评论区讨论交流 👇