前缀和与同余定理:K倍区间问题的O(n)解法详解

📅 2026/8/21 4:36:04
前缀和与同余定理:K倍区间问题的O(n)解法详解
1. 从一道经典真题说起K倍区间与它的“老朋友”如果你正在备战蓝桥杯国赛或者对算法竞赛中的经典题型感兴趣那么“K倍区间”这道题绝对是你绕不开的一座大山。我第一次在国赛模拟题里遇到它时感觉思路很清晰不就是找数组里有多少个子数组的和是K的倍数吗暴力枚举所有区间计算和然后判断取模时间复杂度O(n²)。信心满满地提交结果当然是——超时。这几乎是所有初学者都会踩的第一个坑。这道题之所以经典是因为它完美地将“前缀和”这一基础但强大的工具与“同余定理”这一数论思想结合了起来。它考察的不仅仅是你会不会写代码更是考察你是否能将一个看似需要O(n²)的暴力问题通过巧妙的数学转化优化到O(n)的极致效率。网络上相关的题解很多但大多只给出了“前缀和哈希表计数”的最终公式对于为什么可以这样转化、哈希表里到底存的是什么、边界条件如余数为0如何处理这些核心细节往往一笔带过。今天我们就抛开那些笼统的概述像解一道数学证明题一样从头到尾把“K倍区间”的每一个技术细节、每一种可能的变形以及我在实战中总结的避坑经验彻底讲透。2. 问题重述与暴力解法理解问题的本质首先我们明确一下“K倍区间”问题的标准描述给定一个长度为 N 的整数序列 A₁, A₂, ..., Aₙ 和一个整数 K。 请问有多少个连续子数组区间的和是 K 的倍数 即有多少对 (i, j) 满足 1 ≤ i ≤ j ≤ N且 (Aᵢ Aᵢ₊₁ ... Aⱼ) % K 0。为了更直观我们举个例子。假设序列 A [1, 2, 3, 4, 5] K 3。 我们需要找出所有和是3的倍数的连续子数组[3]和为3[1, 2]和为3[4, 5]和为9[1, 2, 3]和为6[2, 3, 4]和为9[1, 2, 3, 4, 5]和为15 所以总共有6个这样的区间。最直接的暴力解法是使用双重循环枚举所有可能的区间 [i, j]然后计算区间和并判断是否为K的倍数。def brute_force(arr, K): n len(arr) count 0 for i in range(n): current_sum 0 for j in range(i, n): current_sum arr[j] if current_sum % K 0: count 1 return count # 测试 arr [1, 2, 3, 4, 5] K 3 print(brute_force(arr, K)) # 输出6这个解法的时间复杂度是 O(n²)在 n 很大比如 10⁵时完全不可行。蓝桥杯的测试数据往往就是设计来卡掉这种暴力解法的。所以我们必须寻找更优的解法。优化的核心在于我们能否在遍历数组的过程中“记住”一些信息避免重复计算和大量的取模操作这就引出了我们今天的主角——前缀和。3. 前缀和化区间查询为单点查询的利器前缀和Prefix Sum是处理静态数组区间和问题的标准武器。它的思想非常简单我们预处理一个数组prefix其中prefix[i]表示原数组arr从第一个元素到第 i 个元素索引通常从1开始编程中常从0调整的总和。形式化定义假设数组索引从1开始prefix[i] arr[1] arr[2] ... arr[i]特别地我们可以定义prefix[0] 0这会让后续的公式更统一。有了前缀和数组任意区间 [i, j] 的和就可以通过一次减法得到sum(i, j) prefix[j] - prefix[i-1]这直接将一个 O(n) 的区间求和操作如果每次都重新加优化成了 O(1) 的常数时间查询。预处理前缀和数组本身需要 O(n) 的时间。在我们的“K倍区间”问题中我们关心的是sum(i, j) % K 0。代入前缀和公式(prefix[j] - prefix[i-1]) % K 0根据模运算的性质这等价于prefix[j] % K prefix[i-1] % K关键洞察这个转化是整道题的灵魂。它告诉我们寻找一个和为K倍数的区间等价于寻找两个前缀和它们的模K余数相等。注意这里的i-1可以是0对应prefix[0]0。这意味着区间 [1, j] 的和即prefix[j]本身是K的倍数的情况也被包含在这个条件里了因为prefix[0] % K 0。于是问题发生了根本性的转变我们从需要枚举 O(n²) 对区间端点 (i, j)变成了只需要关注前缀和数组的余数。我们只需要遍历一次数组计算每个位置的前缀和余数然后统计相同的余数出现了多少次。因为任意两个具有相同余数的前缀和位置都可以构成一个K倍区间。假设我们在遍历过程中计算到位置j时前缀和余数为r。此时之前所有前缀和余数也为r的位置假设有cnt个每一个都可以作为区间的左端点i-1与当前的j构成一个合法的K倍区间。因此当遇到余数r时它对答案的贡献就是当前cnt的值然后我们将cnt加1以供后续位置使用。4. 同余定理与哈希映射实现O(n)优化的核心基于上一节的推导我们的算法流程就非常清晰了初始化一个计数器通常用哈希表/字典实现用于记录各个余数出现的次数。初始时余数0已经出现了1次对应prefix[0]0。这是最容易忽略的边界条件必须在一开始就处理好。遍历原数组维护一个变量prefix_mod表示当前前缀和模K的余数。对于每个位置 a. 更新prefix_mod (prefix_mod arr[i]) % K。注意处理负数取模在某些语言如C、Java中负数取模可能得负余数需要调整到[0, K-1]范围。 b. 查询计数器cnt_map中键为prefix_mod的当前值cnt。这个cnt就代表了在当前位置之前有多少个前缀和的余数也是prefix_mod即可以构成多少个以当前位置为右端点的K倍区间。将cnt累加到答案中。 c. 将计数器cnt_map[prefix_mod]的值加1。这个算法的核心数据结构是哈希表它让我们可以在O(1)时间内查询和更新某个余数出现的次数。总时间复杂度为O(n)空间复杂度为O(K)因为余数最多只有K种可能。让我们用之前的例子 A[1,2,3,4,5], K3 来手动模拟这个过程索引 i元素 arr[i]前缀和 prefix_sumprefix_mod (prefix_sum % 3)计数器 cnt_map (更新前)新增区间数 (cnt)累计答案更新后 cnt_map初始-00{0: 1}-0-1111{0:1}cnt_map[1]00{0:1,1:1}2230{0:1, 1:1}cnt_map[0]11{0:2, 1:1}3360{0:2, 1:1}cnt_map[0]2123{0:3, 1:1}44101{0:3, 1:1}cnt_map[1]1314{0:3,1:2}55150{0:3, 1:2}cnt_map[0]3437{0:4, 1:2}最终累计答案是7等等我们之前暴力枚举的结果是6。这里出现了偏差。问题出在哪里仔细看我们的模拟当i5时prefix_mod0cnt_map[0]更新前是3我们加了3导致答案变成了7。让我们检查一下这多出来的一个区间是什么。根据算法当i5时prefix_mod0之前余数为0的位置有初始状态索引0、索引2prefix_sum3、索引3prefix_sum6。它们与索引5构成的区间分别是(0,5]: 对应区间[1,5]和为15是3的倍数。✅(2,5]: 对应区间[3,5]和为12是3的倍数。✅(3,5]: 对应区间[4,5]和为9是3的倍数。✅这只有3个区间我们的累计答案在i4时是4加上这3个应该是7。但暴力枚举只有6个。我们发现暴力枚举的结果里包含了区间[3]即元素3本身。在我们的前缀和模拟中区间[3]对应的是左端点i-12右端点j3。当i3时prefix_mod0此时cnt_map[0]是2来自初始0和索引2我们加了2到答案中。这2个区间是(0,3]: 区间[1,3]和为6。✅(2,3]: 区间[3]和为3。✅所以我们的模拟算法得出的7个区间是 [1,3], [3], [1,2], [4,5], [2,3,4], [1,2,3,4,5], [3,4,5]? 等等我好像漏数了。让我们重新用程序严格计算一下。实际上我的手动模拟在i2时就出错了。当i2prefix_mod变为0时cnt_map[0]初始是1只有prefix[0]所以新增1个区间即(0,2] - [1,2]和为3。答案变为1。此时cnt_map[0]变为2。 当i3prefix_mod变为0时cnt_map[0]是2新增2个区间(0,3] - [1,3] 和 (2,3] - [3]。答案变为3。 当i5prefix_mod变为0时cnt_map[0]是3来自0,2,3新增3个区间(0,5], (2,5], (3,5]。答案变为437。列出所有区间[1,2] (i2时产生)[1,3] (i3时产生)[3] (i3时产生)[4,5] (i4时prefix_mod1cnt_map[1]1产生区间(1,4]? 不对i4时prefix_mod1之前只有i1时余数为1所以产生区间(1,4] - [2,4]和为9。我之前的表格这里写错了。)[1,5] (i5时产生)[3,5] (i5时产生)[4,5] (i5时产生)等等[4,5] 出现了两次显然不对。[4,5] 的和是9它应该在i5时由余数0构成的区间(3,5]得到而不是在i4时。我意识到我在i4的模拟错了。当i4prefix_sum10,prefix_mod1。之前余数为1的位置只有i1。所以新增区间是 (1,4] - [2,4]和为2349。这才是正确的。 所以正确的区间列表是[1,2][1,3][3][2,4][1,5][3,5][4,5]这正好是7个而我们最初暴力枚举时漏掉了[2,4]和为9。让我们用暴力程序再验证一次arr [1,2,3,4,5] K3 nlen(arr) count0 for i in range(n): s0 for j in range(i, n): sarr[j] if s%K0: print(f区间[{i1}, {j1}]和{s}) count1 print(f总数{count})输出区间[1, 2]和3 区间[1, 3]和6 区间[1, 5]和15 区间[2, 4]和9 区间[3, 3]和3 区间[3, 5]和12 区间[4, 5]和9 总数7果然暴力解法也找到了7个区间。我最开始心算时漏掉了[2,4]这个区间。所以O(n)的算法是正确的它找到了所有7个K倍区间。这个模拟过程虽然曲折但极具价值。它暴露了几个关键点手工模拟必须非常仔细尤其是更新计数器和计算贡献的顺序。初始状态cnt_map[0]1至关重要。它代表了空前缀和为0使得那些从第一个元素开始的和为K倍数的区间能被正确计数。算法的正确性可以通过暴力枚举来验证对于不确定的情况不要依赖心算写个简单的验证程序更可靠。5. 代码实现与细节处理不同语言下的坑理解了原理代码实现就相对简单了。但不同编程语言在负数取模和整数溢出问题上处理方式不同这里分别给出Python和C的示例并说明关键细节。5.1 Python实现Python的取模运算%始终返回非负余数这让我们省心不少。def k_times_interval(nums, K): 计算数组中K倍区间的数量。 :param nums: 整数列表 :param K: 正整数 :return: K倍区间的个数 from collections import defaultdict # 哈希表记录各个余数出现的次数 mod_count defaultdict(int) # 初始化空前缀和的余数为0出现1次 mod_count[0] 1 prefix_mod 0 # 当前前缀和模K的余数 ans 0 for num in nums: # 更新当前前缀和余数。Python的 % 保证结果非负。 prefix_mod (prefix_mod num) % K # 当前余数之前出现的次数就是能与当前位置构成K倍区间的左端点数量 ans mod_count[prefix_mod] # 将当前余数出现的次数加1供后续位置使用 mod_count[prefix_mod] 1 return ans # 测试 arr [1, 2, 3, 4, 5] K 3 print(k_times_interval(arr, K)) # 输出7Python实现的注意事项defaultdict(int)比普通字典更方便访问不存在的键时返回0。循环中的三行代码顺序不能乱先累加答案再更新计数器。如果先更新计数器就会把自己和自己左端点i-1等于右端点j也算进去导致错误。对于非常大的数组例如n10⁶和结果答案可能超过32位整数范围Python的int可以自动处理大整数无需担心溢出。5.2 C实现C的实现需要特别注意两点1负数取模2答案可能超出int范围。#include iostream #include unordered_map #include vector using namespace std; long long kTimesInterval(vectorint nums, int K) { // 使用 long long 防止答案溢出 long long ans 0; // 哈希表键是余数值是该余数出现的次数 unordered_mapint, int modCount; // 初始化余数0出现1次对应空前缀和 modCount[0] 1; int prefixMod 0; // 当前前缀和模K的余数 for (int num : nums) { // C中负数取模可能得到负数需要调整 prefixMod ((prefixMod num) % K K) % K; // 累加当前余数已出现的次数到答案 ans modCount[prefixMod]; // 当前余数出现次数1 modCount[prefixMod]; } return ans; } int main() { vectorint arr {1, 2, 3, 4, 5}; int K 3; cout kTimesInterval(arr, K) endl; // 输出7 return 0; }C实现的坑点详解负数取模处理(a % b)在C中当a为负数时结果是一个负余数满足a b * q r且|r| |b|。例如-1 % 3得到-1。而我们的算法要求余数在[0, K-1]范围内。因此需要用(x % K K) % K这个技巧将余数调整到非负。在更新prefixMod时必须确保每一步加法后的结果都经过这个调整或者保证num是非负的。如果题目保证输入非负则可以简化。但为保险起见加上调整总是好的。整数溢出答案的数量级在最坏情况下可以是 O(n²)当所有数都是K的倍数时任意区间都合法对于n10⁵答案可能高达约 5e9这超出了32位int的范围约21亿。因此ans变量必须使用long long64位整数。哈希表的选择使用unordered_map而不是map因为前者基于哈希表平均O(1)的查询/插入时间而map基于红黑树是O(log n)。在算法竞赛中这点性能差异有时很关键。注意当K不大时比如K≤10⁵甚至可以用一个大小为K的数组来代替哈希表速度更快。5.3 使用数组替代哈希表进行优化当题目给定的K不是特别大比如K ≤ 10⁶且内存允许时使用定长数组来记录余数出现次数是更高效的选择。数组的访问是O(1)且常数开销远小于哈希表。long long kTimesIntervalOptimized(vectorint nums, int K) { long long ans 0; // 数组下标表示余数值表示出现次数。初始化为0。 vectorint modCount(K, 0); // 余数0出现1次 modCount[0] 1; int prefixMod 0; for (int num : nums) { prefixMod ((prefixMod num) % K K) % K; ans modCount[prefixMod]; modCount[prefixMod]; } return ans; }使用数组的注意事项必须确保K是正数且数组大小K在内存可接受范围内。如果K很大比如10⁹则无法使用数组。数组初始化需要O(K)时间如果K很大但实际出现的余数很少用哈希表更省内存。这是典型的“空间换时间”的优化在竞赛中非常实用。6. 算法正确性证明与思维延伸为什么“前缀和模K同余”就能推出“区间和是K的倍数”我们来做一个严谨的、但易于理解的证明。已知定义前缀和 S[i] A[1] A[2] ... A[i]并规定 S[0] 0。区间 [i, j] 的和 S[j] - S[i-1]。条件区间 [i, j] 的和是K的倍数即 (S[j] - S[i-1]) % K 0。根据模运算的减法规则(a - b) % K 0等价于a % K b % K。 因此(S[j] - S[i-1]) % K 0等价于S[j] % K S[i-1] % K。结论对于任意一个右端点j我们只需要统计在它之前包括位置0有多少个位置xx 0, 1, ..., j-1满足 S[x] % K S[j] % K。每一个这样的x都对应一个左端点 i x1使得区间 [i, j] 的和是K的倍数。这个证明过程简洁有力是算法竞赛中“数学思维”的完美体现。它把一个问题从“枚举区间”的几何视角转化为了“统计同余前缀和”的代数视角。思维延伸如果问题变一下呢求区间和是K的倍数的最大区间长度思路不变哈希表里不存储出现次数而是存储每个余数第一次出现的位置索引。当再次遇到相同余数r时当前索引减去第一次出现r的索引就得到了一个以当前索引为右端点的、和为K倍数的区间长度。记录所有这样的长度的最大值即可。求区间和模K等于一个特定值M的区间个数条件变为 (S[j] - S[i-1]) % K M。根据模运算这等价于 (S[j] - M) % K S[i-1] % K。我们可以将(S[j] - M) % K视为一个“目标余数”然后去哈希表里查找当前S[i-1] % K等于这个“目标余数”的次数。算法框架类似但查询的键值变了。数组中有负数怎么办我们的算法和代码特别是经过取模调整的C代码已经处理了负数的情况。前缀和可能为负但取模后我们将其调整到[0, K-1]范围同余关系依然成立。这些变形题在竞赛中也很常见其核心思想都是利用前缀和与哈希表将区间问题转化为对前缀和或其模余数的统计问题。7. 实战中的避坑指南与性能分析在真正的比赛或面试中仅仅写出代码是不够的还需要考虑各种边界情况和性能极限。以下是我在多次实践中总结的要点7.1 必须警惕的边界条件初始化cnt_map[0] 1这是最高频的错误来源。忘记初始化意味着忽略了所有从数组开头开始的、自身和就是K倍数的区间。可以这样理解当我们要计算区间[1, j]的和即S[j]是否为K倍数时我们需要比较 S[j] % K 和 S[0] % K即0是否相等。如果哈希表里没有0这个键这个区间就不会被计数。K0的情况题目通常保证K是正整数K0。但如果万一出现K0我们的算法就失效了因为不能对0取模。在解题时如果题目没明确说明可以默认K0。如果需要考虑那么K0时问题就变成了“有多少个区间和为0”这可以用类似的思想用哈希表记录前缀和本身出现的次数。整数溢出C/Java前缀和本身可能非常大。如果数组元素和K都很大前缀和可能会超出32位甚至64位整型的范围。好在我们的算法只关心前缀和模K的余数我们可以在计算过程中随时取模让prefix_mod始终保持在[0, K-1]范围内从而彻底避免前缀和溢出的问题。这也是这个算法除了快之外的另一个巨大优势。负数取模如前所述在C/Java中必须手动调整到非负余数。7.2 性能分析与优化选择时间复杂度O(n)遍历数组一次。哈希表每次操作平均O(1)。空间复杂度O(min(n, K))。哈希表最多存储min(n, K)个键值对因为最多有n个前缀和但余数最多只有K种。优化选择首选哈希表通用性强适用于任何K。K较小时用数组如果K的数量级在10⁶以内使用vectorint(K, 0)会快很多访问是真正的O(1)没有哈希冲突的开销。输入规模极大时即使n10⁷O(n)的算法也完全可行。主要压力在于I/O读取数据。在C中可以使用scanf/printf或关闭流同步的cin/cout来加速输入输出。7.3 一个综合性的测试案例让我们设计一个稍微复杂的案例来检验算法的健壮性nums [4, 5, 0, -2, -3, 1], K 5计算过程初始: modCount{0:1}, prefixMod0, ans0num4: prefixMod(04)%54。ansmodCount[4]0。modCount[4]1。num5: prefixMod(45)%54。ansmodCount[4]1。modCount[4]2。num0: prefixMod(40)%54。ansmodCount[4]2。modCount[4]3。num-2: 需调整。C: prefixMod((4-2)%55)%5(2%55)%52。ansmodCount[2]0。modCount[2]1。num-3: prefixMod((2-3)%55)%5(-1%55)%5(-15)%54。ansmodCount[4]3。modCount[4]4。num1: prefixMod(41)%50。ansmodCount[0]1。modCount[0]2。最终ans 012031 7。暴力验证可以写程序验证确实有7个区间和是5的倍数如[4,5], [0], [5,0], [4,5,0,-2,-3,1]等。这个案例包含了正数、负数、零以及重复的余数能很好地测试代码的鲁棒性。8. 从K倍区间到前缀和问题的通用解题框架“K倍区间”的解法揭示了一类问题的通用解题模式当问题涉及到“子数组和”满足某种条件时前缀和往往是第一个需要考虑的优化工具。通用解题框架如下定义前缀和设S[i]为数组前i个元素的和S[0]0。转化条件将原问题中关于区间[i, j]和的条件用前缀和表示为关于S[j]和S[i-1]的条件。例如求和等于T的区间数S[j] - S[i-1] T-S[j] S[i-1] T。求和大于等于T的区间数S[j] - S[i-1] T-S[j] S[i-1] T。这可能需要用有序数据结构如平衡树来维护前缀和求和模K为M的区间数(S[j] - S[i-1]) % K M-(S[j] - M) % K S[i-1] % K。选择数据结构根据转化后的条件选择合适的数据结构来高效地查询“历史信息”。哈希表字典适用于查询“等于”某个值的情况。O(1)时间。有序集合/树状数组/线段树适用于查询“小于”、“大于”、“区间和”等需要顺序或区间信息的情况。O(log n)时间。遍历与统计从左到右遍历数组计算当前前缀和S[j]根据条件在数据结构中查询符合条件的S[i-1]i≤j的数量或其它信息更新答案然后将当前S[j]或其某种形式如余数加入数据结构。掌握这个框架你就能解决一大票类似的题目比如“和等于K的子数组个数”、“和不超过K的最长子数组”、“区间和与位运算结合”等问题。前缀和就像一把钥匙能打开许多关于子数组求和问题的大门。而“K倍区间”无疑是其中最具代表性、也最考验你是否真正理解前缀和本质的一道题。把它吃透相关的题目思路都会清晰起来。