最近刷题群里有人贴了一道排序数组相关的题,题目编号 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]
这个式子本身不复杂,但它把“连续区间”的求和问题,变成了“两个独立前缀值相减”的判定问题。我们可以不用再动态维护窗口内的和,而是把所有前缀值记录下来,统一做差。
