最近刷题群里有人贴了一道排序数组相关的题,题目编号 3729,题面是说:给定一个非降序排列的整数数组 nums 和一个正整数 k,统计这段数组中所有连续非空子数组里,元素和能被 k 整除的数量。我第一反应是“数组有序,是不是该用双指针?”结果推了一阵子发现,这道题真正核心的数学模型是“前缀和同余”,跟数组有序没有必然关系。今天把完整的推导、Go 代码实现、以及我在负数取模和返回类型上踩过的坑都梳理一遍。
1. 题面第一印象:非降序、子数组和、整除,三个关键字凑在一起想表达什么
1.1 先把“连续非空子数组”这个概念钉死
“子数组”在算法题里有严格定义:必须是原数组中一段连续的元素切片,不能打乱顺序,也不能跳着选。比如 nums = [1,2,3,4,5],[1,2,3] 是子数组,[1,3,5] 不是;同时题目要求非空,所以长度为 0 的空区间不计入答案。
一个长度为 n 的数组,连续非空子数组的总数是 n*(n+1)/2。这个数在 n = 100000 时会到 5000050000 左右,已经超过 32 位整数的上限。这就是我后面坚持用 int64 返回答案的原因,看起来是个小细节,运行到大数组时直接决定你 WA 还是 AC。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.2 “非降序”三个字带来的第一误导
看到“非降序”,很多人的第一反应是:这题要用双指针或者滑动窗口。这个直觉在“子数组和等于某个固定值 target”的题目里是对的,因为数组有序且元素非负时,窗口扩张和收缩的方向是确定的。可这道题要的不是和等于某个定值,而是“和能被 k 整除”,也就是和值可能是 k、2k、3k……这是一个不确定的目标集合。
如果数组里还允许负数,“非降序”连“前缀和单调递增”都不保证。举个简单例子:[-5, 1, 1, 1],前缀和是 -5, -4, -3, -2,确实递增;但 [-1, -1, 3] 呢?前缀和是 -1, -2, 1,先降到 -2 再跳到 1,毫无单调性。所以不能上来就做滑窗,得先把数学关系理清楚。
1.3 这道题真正在考什么
如果只给我一句话概括,我会说这道题在考“前缀和取模的等价关系”。连续子数组的和等于两个前缀和的差,而“差能被 k 整除”等价于“两个前缀和对 k 取模的余数相同”。想通了这一点,剩下的就是计数。
这也是 LeetCode 上“和可被 K 整除的子数组”那一类题的通用套路,排序数组这个外表只是干扰项,或者说是给暴力枚举法留了一点优化余地,但最优解并不依赖数组有序。
2. 从暴力验收到同余计数:最优解是怎么一步步推出来的
2.1 暴力枚举:先把题目的朴素解法写出来
如果没有任何思路,最直接的方法是枚举所有左端点 i 和右端点 j,计算 sum(nums[i:j+1]),判断它模 k 是否为 0。伪代码如下:
go复制func bruteForce(nums []int, k int) int64 {
var ans int64
for i := 0; i < len(nums); i++ {
sum := 0
for j := i; j < len(nums); j++ {
sum += nums[j]
if sum%k == 0 {
ans++
}
}
}
return ans
}
这个解法的时间复杂度是 O(n^2),空间复杂度 O(1)。如果 n 到 100000,1e10 次操作在普通机器上轻松跑出几十秒,肯定没法用。但暴力法有个好处:它帮我验证后面所有优化解法的正确性。每次写完高效解法,我都会拿一个手算小样例和暴力结果对拍,避免聪明反被聪明误。
2.2 前缀和:把区间和变成两个前缀和的差
“子数组和”最常用的预处理手段就是前缀和。定义 prefix[0] = 0,prefix[i] = nums[0] + nums[1] + ... + nums[i-1],也就是前 i 个元素的和。
于是任意子数组 nums[l] 到 nums[r] 的和可以写成:
code复制sum(l, r) = prefix[r+1] - prefix[l]
这个式子本身不复杂,但它把“连续区间”的求和问题,变成了“两个独立前缀值相减”的判定问题。我们可以不用再动态维护窗口内的和,而是把所有前缀值记录下来,统一做差。
2.3 整除条件转换成同余条件
题目要求:
code复制(prefix[r+1] - prefix[l]) % k == 0
等价于:
code复制prefix[r+1] % k == prefix[l] % k
这是整个算法最关键的一步。用大白话说:只要两个前缀和除以 k 的余数相同,它们之间的那一整段子数组和就一定能被 k 整除。
举一个生活化的例子:假设你在数营业额,每笔收入都先记一个“对 7 取余”的余数。如果第 2 天的余数是 3,第 6 天的余数也是 3,那么第 3 天到第 6 天这四天的营业额加起来,必然能被 7 整除。因为两次记录余数相同,说明中间增长的营业额余数为 0。
2.4 用余数计数器累加答案
现在我们只需要从左到右扫一遍数组,维护一个哈希表,记录“每种余数已经出现过多少次”。每次遇到一个新的前缀余数 mod,它前面所有余数也是 mod 的位置,都能和当前位置构成一个合法子数组,所以答案直接加上 count[mod],然后再把 count[mod] 加一。
这里必须提前把 prefix[0] = 0 的余数 0 存进哈希表,且初始计为 1。否则会漏掉从数组开头开始的合法子数组。比如 nums = [1,2],k = 3,子数组 [1,2] 的和是 3,它是从下标 0 开始的,但它的左边界对应的是 prefix[0],也就是“一个元素都没选”的前缀。如果哈希表里没有这个 0 的存在,这个答案就会丢。
2.5 手动推演一个完整样例
我用 nums = [1,2,3,4,5]、k = 3 手动走一遍:
- 初始化
count = {0: 1},ans = 0,prefix = 0 - 读
1,prefix = 1,mod = 1,ans += count[1] = 0,count[1] = 1 - 读
2,prefix = 3,mod = 0,ans += count[0] = 1,count[0] = 2 - 读
3,prefix = 6,mod = 0,ans += count[0] = 2,count[0] = 3 - 读
4,prefix = 10,mod = 1,ans += count[1] = 1,count[1] = 2 - 读
5,prefix = 15,mod = 0,ans += count[0] = 3,count[0] = 4
最终 ans = 0 + 1 + 2 + 1 + 3 = 7。
这 7 个子数组分别是:[1,2]、[3]、[1,2,3]、[2,3,4]、[4,5]、[3,4,5]、[1,2,3,4,5]。可以自己验证一下,每一个的和都是 3 的倍数。整个过程只用了一次遍历,时间复杂度降到了 O(n)。
3. 用 Go 实现时的工程细节与性能取舍
3.1 最直接的 map 版本
Go 里没有内置 set,但 map 拿来计数非常顺手。实现如下:
go复制func countDivisibleSubarrays(nums []int, k int) int64 {
if k <= 0 {
return 0
}
prefix := int64(0)
cnt := map[int]int64{
0: 1,
}
var ans int64
for _, v := range nums {
prefix += int64(v)
mod := int((prefix%int64(k) + int64(k)) % int64(k))
ans += cnt[mod]
cnt[mod]++
}
return ans
}
这里我把 prefix 声明成 int64,是因为 Go 的 int 在 32 位平台上只有 32 位。题目如果给出比较大的整数范围,32 位前缀和很容易溢出,一旦溢出,后面的取模结果全错。直接用 int64 是最稳妥的选择。
3.2 Go 里负数取模和数学定义不一样
Java、Go 这类语言里的 % 运算符,结果符号跟随被除数。比如 -1 % 3 的结果是 -1,不是数学意义上的余数 2。
如果直接拿 prefix % k 当哈希表的 key:
prefix = 2,2 % 3 = 2prefix = -1,-1 % 3 = -1- 这两个前缀的余数一个 2、一个 -1,在哈希表里是两个 key,但数学上它们的余数分别是 2 和 2,应该算同一个余数。
所以必须做一次归一化处理:
go复制mod := (prefix%int64(k) + int64(k)) % int64(k)
这个式子对正数余数不影响,对负数余数会先把它抬到正数区间,再取一次模。这也是我写算法题时反复踩过的坑,一旦数组里有负数,忘记归一化就会漏答案。
3.3 用切片代替 map:性能和内存的平衡
如果 k 比较小,比如 k <= 1000000,我倾向于直接用长度为 k 的 []int64 当计数器。因为余数的取值范围是 [0, k-1],天然适合数组下标:
go复制func countDivisibleSubarraysWithSlice(nums []int, k int) int64 {
if k <= 0 {
return 0
}
cnt := make([]int64, k)
cnt[0] = 1
prefix := int64(0)
var ans int64
for _, v := range nums {
prefix += int64(v)
mod := int((prefix%int64(k) + int64(k)) % int64(k))
ans += cnt[mod]
cnt[mod]++
}
return ans
}
这个版本比 map 版本快不少,因为哈希表的计算、扩容、内存寻址都需要额外开销,数组下标访问是 O(1) 且常数极小。
但问题也很明显:如果 k = 1000000000,直接 make([]int64, k) 会申请 8GB 内存,还没开始计算程序就崩了。所以实际工程里我会加一个阈值判断:
go复制const maxKForSlice = 1 << 20 // 1,048,576
func countDivisibleSubarraysSmart(nums []int, k int) int64 {
prefix := int64(0)
var ans int64
if k <= maxKForSlice {
cnt := make([]int64, k)
cnt[0] = 1
for _, v := range nums {
prefix += int64(v)
mod := int((prefix%int64(k) + int64(k)) % int64(k))
ans += cnt[mod]
cnt[mod]++
}
return ans
}
cnt := map[int]int64{0: 1}
for _, v := range nums {
prefix += int64(v)
mod := int((prefix%int64(k) + int64(k)) % int64(k))
ans += cnt[mod]
cnt[mod]++
}
return ans
}
这种“小 k 用数组,大 k 用 map”的策略看起来不起眼,实际在比赛环境里能差出两倍以上的常数。如果你提交的题解被卡时间,可以优先检查这一点。
3.4 返回类型:为什么必须用 int64
子数组上限是 n*(n+1)/2。当 n = 200000 时,这个值是 20000100000,已经超过 int32 最大值 2147483647。LeetCode 这类平台经常把 n 放到 10^5 到 10^6 的范围,所以返回值用 int64 是保守且正确的选择。
注意:即使 Go 在 64 位平台下 int 是 64 位,普通函数返回值写 int 也不会出问题。但我更习惯显式 int64,因为这能提醒自己“这个数量级可能会溢出”,也方便和其他语言沟通。
4. “非降序”到底算不算一个可利用的强条件
4.1 有序对暴力的影响
现在回到标题里的“非降序数组”。如果数组所有元素都不小于 0,那么数组有序确实能让暴力枚举提前终止:固定左端点时,右端点越往右,子数组和越大,一旦和超过某个上限并且余数不满足条件,可以跳出内层循环。但这只对“判断和等于某个 target”的情形最有用,对“能被 k 整除”这个条件帮助有限,因为你想跳出的边界并不是一个固定值。
如果数组里存在负数,那有序性彻底不能用来提前剪枝。比如 [-3, 1, 2],虽然有序,但子数组和会随着右端点扩展出现先降后升的情况,剪枝无从谈起。
4.2 有序数组碰上非负元素,前缀和单调递增
当 nums 中所有元素都非负时,前缀和数组 prefix 是单调递增的。这个性质可以让我们对“满足同余条件的前缀下标”做二分搜索。
思路大概是:遍历到某个位置 i 时,要找到所有 j < i 且 prefix[j] % k == prefix[i] % k 的 j。因为排序数组的 prefix 单调,我们可以为每个余数维护一个有序的下标列表,然后用二分查找快速定位这个余数在上一次出现的位置。实现上其实等同于维护一个 map[mod][]int,记录每个余数出现过的所有前缀下标。
另一种更“充分利用有序性”的查法,是直接在 prefix 数组上二分搜索“和落在某个范围”的前缀位置。比如我们要找 8 - 3 <= prefix[j] <= 8 且余数与当前位置相同的前缀下标数量,可以用两次二分确定下标区间。但这个做法复杂得多,而且最终时间复杂度仍然是 O(n log n),比普通哈希计数法的 O(n) 还差一截。
4.3 我的结论:有序性不是这道题的核心
刷题时要学会区分“题面给的背景条件”和“解题真正依赖的条件”。这道题给“非降序”可能只是为了降低暴力的最坏情况,或者在原版题目里引导人们先想到双指针,但最优解完全不依赖它。
我实际测试下来,用通用哈希计数法处理任意无序数组,和把数组排序后再用同样的方法处理,结果没有任何区别。这不是说有序条件没用,而是说它没用“到位”。真正用到有序性的题型,通常是:“子数组和大于等于某个数”“最短子数组和等于目标值”“统计差值不超过 K 的数对数量”这一类,和前缀和取模的问题不太一样。
4.4 什么时候可以利用排序数组
如果题目强行要求你利用非降序,或者说数据范围大到必须省哈希表,那可以这样处理:
- 对每个余数维护一个“有序前缀位置列表”。
- 因为数组本身有序,前缀和若满足单调,可以用二分找到当前位置之前,余数相同的最后一个位置,然后线性累加。
- 或者使用基于前缀和值的分段计数,配合二分查找快速跳过。
但这套做法的代码量远大于 map 版本,常数也更大。除非题目明确禁止使用哈希表,或者是在面试中被追问“能不能不用额外哈希表”,否则我不建议优先写二分版本。工程上,简单、正确、可读性高的代码,通常比花哨的“利用排序”版本更受欢迎。
5. 测试用例与边界条件复盘:负数、零、大 K、溢出
5.1 一组值得重点验证的用例
我在本地跑了下面这些用例,覆盖了最容易出错的几个角落:
| 输入 | k | 期望输出 | 说明 |
|---|---|---|---|
[1,2,3,4,5] |
3 | 7 | 常规正数数组 |
[-1,-1,2,2] |
3 | 2 | 负数参与,检查取模归一化 |
[1,1,1] |
1 | 6 | k=1,所有子数组都满足 |
[] |
5 | 0 | 空数组,返回 0 |
[0] |
5 | 1 | 单个 0 也能被任何 k 整除 |
[-5,0,5] |
5 | 6 | 负数和 0 混合 |
[1,1,1], k=1 这个用例尤其能检验算法是否漏掉了“从头部开始的子数组”。因为所有子数组都满足条件,结果必须是 3*4/2 = 6,如果初始化 cnt[0] 步骤写错,答案会变成 3。
5.2 表驱动测试代码
Go 里我习惯写表驱动测试,这样以后回归也方便:
go复制package main
import "testing"
func TestCountDivisibleSubarrays(t *testing.T) {
cases := []struct {
name string
nums []int
k int
want int64
}{
{"regular", []int{1, 2, 3, 4, 5}, 3, 7},
{"negative", []int{-1, -1, 2, 2}, 3, 2},
{"all ones", []int{1, 1, 1}, 1, 6},
{"empty", []int{}, 5, 0},
{"single zero", []int{0}, 5, 1},
{"mixed negative", []int{-5, 0, 5}, 5, 6},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := countDivisibleSubarrays(c.nums, c.k); got != c.want {
t.Fatalf("countDivisibleSubarrays(%v, %d) = %d, want %d",
c.nums, c.k, got, c.want)
}
})
}
}
5.3 易错点复盘:每个坑都是血泪经验
第一,cnt[0] 必须初始化为 1。这是为了把 prefix[0] 这个“空前缀”记进去,否则从下标 0 开始的合法子数组全会被漏掉。第二,取模必须归一化。Go 的负数取模结果可能为负数,不归一化就会把同一个余数拆成两个 key,答案偏小。第三,返回值类型用 int64,n 稍微大一点,答案就容易超过 int32。第四,检查 k <= 0 的边界,题目说 k 是正整数,但我习惯在函数入口做防御性判断,万一测试数据抽风也不会 panic。
我还遇到过一个很隐蔽的问题:当 k 特别大,比如接近 int64 最大值时,prefix%int64(k) 这行代码仍然安全,因为 prefix 和有界,不会出现除零。但不能把 k 转成 int32 或 uint32 之后再用,否则一旦原始 k 超过 32 位上限,转完就变成负数,GC 直接崩。这种边界问题,日常练习很难碰到,但在极端数据下能一击致命。
6. 从 3729 延伸出去:余数计数的通用解题套路
6.1 一个套路管一串题
这道题的核心是“前缀和取模 + 哈希表计数”,它几乎是好多 LeetCode 题目的母体。
- 和为 K 的子数组数量:把“能被 k 整除”换成“等于 target”,维护
count[prefix - target]即可。 - 和可被 K 整除的最短/最长子数组:记录每个余数第一次出现的位置,用当前位置减最早位置得到长度,再维护最值。
- 使整个数组和能被 K 整除的最少删除次数:把问题转化为“找一段连续子数组,其和与整体和同余”,本质上还是前缀余数匹配。
- 奇数/偶数次出现次数的子数组计数:类似思路,用位运算前缀状态当作 key,也能套上哈希表计数。
这套方法之所以通用,是因为它把“连续区间”的任意属性判断,转化为“两个前缀状态”的比较。你可以只看状态是否相等、差值是否为某个值、或者差值是否满足某个条件,而不用再去逐个枚举区间。
6.2 面试现场怎么表达思路
如果面试官让我现场做这道题,我会先说暴力解,再引导到前缀和,最后给出同余关系。不要一上来就甩出“用哈希表统计余数”这个结论,否则对方很难相信你真懂了推导过程。
可以这样讲:“我先定义前缀和 prefix[i],那么任意子数组 nums[l..r] 的和是 prefix[r+1] - prefix[l]。要求这个差能被 k 整除,就是两个前缀和取模后相等。所以我从左往右扫,拿一个哈希表统计当前前缀余数出现的次数,每遇到一个余数 mod,就把之前 mod 出现的次数累加到答案里,因为那每一次出现都代表一个合法子数组的左边界。最后再把这个 mod 的次数加 1。”
这套表达顺序跟代码的执行顺序一致,面试官听起来会非常顺畅。
6.3 最后再分享一个小技巧
如果你在笔试平台提交,代码只要求通过核心函数,我会沿用函数签名 func countDivisibleSubarrays(nums []int, k int) int64,并且加上 if k <= 0 的防御分支。在本地验证时,我用一个随机数组生成器,配合暴力解法 bruteForce 跑对拍,跑 1000 组随机数据,两组答案完全一致才放心提交。
这种“先暴力验证再优化”的习惯,帮我省下了无数次因为手误导致的重写时间。题是死的,思路是活的,先保证逻辑正确,再考虑常数优化,才是刷这类计数题最稳的节奏。
