ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

连续子数组和能被M整除:前缀和取模算法详解与实现

连续子数组和能被M整除:前缀和取模算法详解与实现 1. 第一眼看到“能被M整除的连续元素和”时的思路转变1.1 题目到底在要求什么这道题的核心可以概括成一句话给定一个整数数组和一个正整数 M统计有多少个连续子数组的元素和能被 M 整除。可能有人第一反应是“这不就是穷举所有子数组吗”但真正写代码时会发现如果题目给出的是 10^5 级别的数组长度再叠加上“样例便多”这个条件——也就是评测数据里有成百上千组测试用例——暴力做法的执行时间会直接爆炸。我在接收到这个标题信息时第一个想到的就是“前缀和取模”这个经典套路。它不光是这一道题的解法凡是遇到“连续子数组和 整除/余数”这种组合基本都能套用同一套数学逻辑。很多人觉得这类题难不是因为代码难写而是因为没绕明白“为什么两个前缀和余数相同就一定能配出一段合法子数组”这件事。这篇文章我会把这个链条完整拆开从暴力证明到最终代码再穿插几个我自己实测时踩过的坑。1.2 暴力枚举慢在哪里先算一下暴力方法的开销。假设数组长度为 n枚举所有起点 l 和终点 r 需要 O(n²) 对组合每一对组合再去累加元素和又是 O(n)整体复杂度 O(n³)。即便优化成动态维护区间和也只能到 O(n²)。当 n 10^5 时n² 10^10这个量级的操作在现代计算机上也是几十秒甚至几分钟的耗时。如果评测系统里有几十组这样的样例总耗时是不可接受的。所以线性复杂度 O(n) 几乎是这类题的唯一出路。而要把 O(n²) 降成 O(n)靠的不是更快的循环而是数学上的等价变换——这正是前缀和取模这个技巧的核心价值。2. 前缀和取模把配对问题变成统计问题2.1 前缀和的定义与子数组和的换算公式先快速复习一下前缀和。对于数组 a定义前缀和数组 SS[0] 0 S[i] a[0] a[1] ... a[i-1]注意这里我把 S[0] 定义为 0这是关键的一步后面解释为什么。这样一来任意连续子数组 a[l] 到 a[r] 的和可以表示为sum(l, r) S[r1] - S[l]举个例子。数组 a [4, 5, 0, -2, -3, 1]它的前缀和 S 是S[0] 0 S[1] 4 S[2] 4 5 9 S[3] 9 0 9 S[4] 9 (-2) 7 S[5] 7 (-3) 4 S[6] 4 1 5那么子数组 a[1] 到 a[3] 的和也就是 [5, 0, -2]按公式算就是 S[4] - S[1] 7 - 4 3实际相加 5 0 (-2) 3完全一致。这个换算本身不复杂它真正的价值在于把“子数组的和”这个问题成功转化为“两个前缀和相减”的问题。2.2 连续子数组和能被 M 整除的等价条件有了 sum(l, r) S[r1] - S[l]问题就变成了什么时候 S[r1] - S[l] 能被 M 整除用取模的语言说就是(S[r1] - S[l]) mod M ≡ 0这在数论上等价于S[r1] mod M ≡ S[l] mod M也就是说两个前缀和对 M 取模的余数相等那么它们之间的这段子数组和就能被 M 整除。这一步是整个解法的灵魂。它的意义在于我们不再需要枚举子数组只需要关心前缀和的余数。2.3 为什么相同的余数会凑出组合数假设我们遍历完整数组后发现余数为 t 的前缀和一共出现了 k 次。那么从这 k 个位置中任选两个位置都能构成一个“起点前缀”和一个“终点前缀”从而确定一个合法的连续子数组。这里要特别注意一个问题任选两个位置时必须是“后面的位置作为终点、前面的位置作为起点”才能保证子数组方向正确。但由于我们统计的是整套前缀和所有位置任意两个不同位置天然有先后顺序所以任选两个都能形成唯一一个合法子数组。所以余数 t 能贡献的合法子数组数量就是组合数C(k, 2) k × (k - 1) / 2把所有余数对应的组合数加起来就是最终答案。还有一个细节S[0] 0 必须参与统计因为它代表子数组从数组开头开始时需要用到的那个“0号前缀”。它的余数是 0 mod M所以统计时要把余数 0 的基数初始化为 1。2.4 一个能对得上答案的手工推演光讲理论不够直观我拿一个具体数组完整走一遍。用 2.1 节那个例子a [4, 5, 0, -2, -3, 1]M 5前缀和 SS[0] 0余数 0 S[1] 4余数 4 S[2] 9余数 4 S[3] 9余数 4 S[4] 7余数 2 S[5] 4余数 4 S[6] 5余数 0统计各余数出现次数余数 02 次位置 0 和 6 余数 44 次位置 1、2、3、5 余数 21 次位置 4答案C(2, 2) C(4, 2) C(1, 2) 1 6 0 7也就是说这个数组里一共有 7 个连续子数组之和能被 5 整除。为了确认不是算错我手动枚举一遍。所有子数组的和分别是长度为 14、5、0、-2、-3、1能被 5 整除的只有 5 和 0共 2 个。长度为 2459、505、0(-2)-2、(-2)(-3)-5、(-3)1-2能被 5 整除的是 5 和 -5共 2 个。长度为 34509、50(-2)3、0(-2)(-3)-5、(-2)(-3)1-4能被 5 整除的是 -5共 1 个。长度为 4450(-2)7、50(-2)(-3)0、0(-2)(-3)1-4能被 5 整除的是 0共 1 个。长度为 5450(-2)(-3)4、50(-2)(-3)11没有共 0 个。长度为 6450(-2)(-3)15共 1 个。总计2 2 1 1 0 1 7。和组合数公式算出来的结果完全一致。这个手工推演其实非常重要它能帮你确认不是“感觉懂了”而是真的理解了这个计数过程。3. 完整实现与“样例便多”背后的性能要求3.1 两种计数实现方式熟悉这个套路的人可能知道有两种落地写法。第一种是先完整统计每个余数出现的次数最后统一相加vectorint cnt(m, 0); cnt[0] 1; int prefix 0; for (int x : a) { prefix (prefix x) % m; cnt[prefix]; } long long ans 0; for (int c : cnt) { ans 1LL * c * (c - 1) / 2; }第二种是边遍历边累加利用“每遇到一个相同余数它和之前所有相同余数的位置都能组成新合法子数组”这个增量关系vectorint cnt(m, 0); cnt[0] 1; int prefix 0; long long ans 0; for (int x : a) { prefix (prefix x) % m; ans cnt[prefix]; cnt[prefix]; }两种写法本质上是同一个数学事实的两种叠加顺序第二种写起来更短也省掉了最后的汇总循环。3.2 C 与 Python 实现对照我用 C17 写一份完整可运行的版本#include bits/stdc.h using namespace std; long long countSubarraysDivisible(const vectorint a, int m) { vectorint cnt(m, 0); cnt[0] 1; long long ans 0; int prefix 0; for (int x : a) { prefix (prefix x) % m; if (prefix 0) prefix m; ans cnt[prefix]; cnt[prefix]; } return ans; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin n m; vectorint a(n); for (int i 0; i n; i) cin a[i]; cout countSubarraysDivisible(a, m) endl; return 0; }Python 里要简洁一些因为 Python 的取模运算符 % 对负数天然返回非负余数def count_subarrays_divisible(a, m): cnt [0] * m cnt[0] 1 ans 0 prefix 0 for x in a: prefix (prefix x) % m ans cnt[prefix] cnt[prefix] 1 return ans这两种语言实现各有特点。C 需要手动处理负数取模Python 不需要但 Python 在 n 达到 10^6 而且样例数很多时常数开销偏大所以比赛里如果数据特别猛还是优先用 C。3.3 算法复杂度与多样例场景的优化点这个算法的时间和空间复杂度都是 O(n M)。注意这里的 M 也要算进空间复杂度里因为需要长度 M 的计数数组。当“样例便多”时一个容易被忽略的问题是每个样例都开一个新的长度 M 的 cnt 数组会带来不小的初始化开销。如果总共有 T 个样例每个样例的 M 又各不相同那么单纯初始化空间就是 O(T × M) 的耗时。有几个实际优化思路如果 M 的总和不是特别大可以用局部 vector 每次重置简单清晰优先。如果 M 固定不变只开一次 cnt 数组每个样例跑完后只把用过的余数位置清零而不是整个数组 fill 一遍。如果 M 很大比如 10^9就不能开长度 M 的数组了这时要改用 unordered_map 存储出现过的余数复杂度变 O(n) 期望但常数略高。我在实际做题时发现很多人挂就挂在“以为 M 一定是小整数”上。当 M 超过数组长度甚至接近 10^9 时开定长数组的做法就失效了需要切换哈希表思路。4. 编码中的关键陷阱与我的实测踩坑记录4.1 负数取模是头号大坑C 里-2 % 5的结果是 -2不是 3。如果不处理负余数会直接导致计数错乱。比如前缀和是负数时(prefix x) % m 得到负值此时访问 cnt 数组的负数索引行为未定义轻则答案错误重则直接崩溃。我常用的修正方法是prefix (prefix x) % m; if (prefix 0) prefix m;或者一步到位prefix ((prefix x) % m m) % m;第二种写法多了一次模运算但语义上更保险而且在 m 不大时性能损耗可以忽略。Python 不存在这个问题Python 的取模运算符%对负数返回的是与除数同号的非负余数比如-2 % 5的结果是 3这正好是我们需要的。4.2 计数结果要用 64 位整数当 n 10^5 时合法子数组个数的极限数量级接近 n×(n-1)/2也就是约 5×10^9已经超出 32 位 int 的范围。当 n 10^6 时答案是 5×10^11 级别必须用 long long。我记得有一次做类似的题答案本身不大但中间某一组余数出现了特别多次组合数算出来超了 int结果我用 int 存答案导致错误提交了一次。后来养成习惯凡涉及到子数组计数答案一律用 long long保存余数数量的数组如果 M 很大也可以用 long long。还有一个细节是乘法的类型提升。C 里1LL * c * (c - 1) / 2这种写法就是防止c * (c - 1)先按 int 运算导致溢出哪怕 cnt 本身是用 int 存的也要先强转再乘。4.3 初始位置的容错设计cnt[0] 1 这个初始化特别容易漏。它表示的是前缀和数组里位置 0 的余数 0。如果没有它那些从数组开头开始、且前缀和正好能被 M 整除的子数组就会全部漏算。举个例子。数组 [5, 1]M 5正确结果应该是有 1 个子数组单个 [5]。但如果不初始化 cnt[0] 1遍历 5 prefix 0ans cnt[0] 0cnt[0] 变 1 遍历 1 prefix 1ans cnt[1] 0cnt[1] 变 1最终 ans 0完全错误。而初始化 cnt[0] 1 后遍历 5 prefix 0ans cnt[0] 1cnt[0] 变 2 遍历 1 prefix 1ans cnt[1] 0cnt[1] 变 1最终 ans 1就对了。这类边界问题在调试时特别隐蔽因为当你把数组改成 [1, 5] 时M 5 的答案又是 1两者差异会让人摸不着头脑。核心就是“位置 0 的前缀和必须作为合法起点参与配对”。4.4 样例多的另一个隐藏陷阱输入输出开销“样例便多”通常意味着输入文件很大。如果输入输出不优化可能会在 IO 上白白损失大量时间。C 里建议加这两行ios::sync_with_stdio(false); cin.tie(nullptr);否则 cin 的同步开销在百万级输入下是很可观的。Python 里如果 n 很大可以用 sys.stdin.buffer.read() 一次性读入再分割比循环用 input() 快得多。import sys def main(): data list(map(int, sys.stdin.buffer.read().split())) idx 0 n, m data[idx], data[idx 1] idx 2 a data[idx:idx n] # 后续处理这个优化在样例多且每个样例都是 10^5 级别时收益非常明显。5. 这道题的变体与思维延展以及我的总结体会5.1 从“统计个数”变成“求最长长度”同一套前缀和取模思路换一个提问方式就是另一种常见题求“最长的、和能被 M 整除的连续子数组”的长度。这时候统计计数就不够用了需要记录每个余数第一次出现的位置。遍历过程中如果当前余数之前出现过就用当前位置减去最早出现位置更新最大长度。vectorint first(m, -1); first[0] 0; int prefix 0; int maxLen 0; for (int i 0; i n; i) { prefix (prefix a[i]) % m; if (prefix 0) prefix m; if (first[prefix] -1) { first[prefix] i 1; } else { maxLen max(maxLen, i 1 - first[prefix]); } }注意这里仍要处理负数取模和初始位置。思路完全是同一个相同余数出现的位置跨度就是合法子数组的长度。5.2 一模一样的技巧还能用在哪些地方前缀和取模的用途远不止这一道题。统计“和为 K 的子数组个数”把取模换成直接比较前缀和的值配合哈希表计数值出现次数。“乘积可被 M 整除的子数组”通常需要质因数分解后对质因子次数做前缀和再判断差值是否满足条件。“异或和为 0 的子数组”本质也是前缀异或值相等与整除问题同构。“差为 M 的倍数的子数组”也是同一套同余类统计逻辑。这些问题的共性都是我们需要在 O(n) 或者 O(n log n) 内把原本需要 O(n²) 枚举的问题转化为“前缀状态是否匹配”的统计问题。所有这类题的内核就是找到合适的“状态”——前缀和的值、取模后的余数、异或值、质因子的奇偶性——然后去统计相同状态之间的配对关系。5.3 最后说点我的个人经验我最初学这个套路时最大的障碍不是不会写前缀和而是想不通“为什么两个相同余数就能必然对应一个合法子数组”。后来我发现画图比看公式管用把所有前缀和余数按位置排成一排相同余数的点连线每一条连线就是一段合法子数组。这个图一旦在脑子里立起来整个解法就忘不掉了。另外我强烈建议你准备一个小的随机数对拍脚本专门用来验证这类计数题的正确性。生成小数组暴力枚举子数组算答案再和前缀和取模法跑出的结果比对。花十分钟写对拍胜过调试两小时。还有一个小技巧是当 M 特别大、数组元素特别多时不要写递归也不要用动态扩容次数频繁的容器。C 优先用 vector 预分配空间Python 优先用 list 而不是 dict除非 M 太大超出数组范围才考虑哈希表。性能上的差异在“样例便多”的场景里会体现得非常明显。这道题的实现代码总共不超过二十行但背后涉及的数学推导、边界处理、性能优化足够写一篇文章。真正理解这套前缀和取模的方法之后你在遇到各种“连续 整除 计数/长度”组合的题目时都可以直接套用而且大概率不需要修改太多逻辑。这就是典型的“一次吃透长期复用”的算法点心。
返回列表