news 2026/9/16 11:30:51

同余转化与桶优化:高效解决整除子数组计数问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
同余转化与桶优化:高效解决整除子数组计数问题

先声明一下,今天聊的MOD是模运算的mod,不是游戏模组那种MOD。这篇文章要解决的是算法题里非常高频的一类问题:给定数组,统计满足某种整除或取模条件的子数组。这类题拿到手如果直接双重循环去枚举左右端点,数据量一到10万级别就必挂。我见过太多人在这个坑里栽跟头,所以把同余转化和桶优化这套组合套路彻底拆开讲一遍,从数学推导到代码实现,从经典题目到变种扩展,一次说透。

1. 同余转化:把“整除判断”翻译成“余数相等”

1.1 先从一个高频问题说起

力扣第974题“和可被 K 整除的子数组”,是我刷题这么多年遇到的最典型的同余应用。很多人第一次看到这个题目,第一反应都是:枚举左端点、枚举右端点、算区间和、判断能不能整除K。思路不能说错,但复杂度完全失控。n是10^5时,n(n+1)/2个区间,算一遍就是10^10级别操作,稳稳超时。

换个角度想,判断“某个整数能不能被K整除”,本质上就是判断“这个整数模K是不是余0”。那如果我们能把区间和这种动态变化的东西,转换成一个容易维护的静态属性,问题就好办了。前缀和就是干这个的。一旦引入前缀和,整个问题就从“算一堆区间和”变成了“比较两个前缀和的关系”,这一步是今天所有内容的起点。

1.2 前缀和公式推导:区间和怎么变成余数比较

先写下基础定义。pre[i]表示数组前i个元素的和,并且规定pre[0] = 0,表示一个元素都不取的“空前缀”。那么从下标l到下标r这个区间的和,就等于pre[r + 1] - pre[l]。这是前缀和的标准用法,背熟就行。

现在关键来了。我们要求的是区间和能被K整除,也就是:

(pre[r + 1] - pre[l]) % K == 0

这个式子等价于什么?等价于pre[r + 1]和pre[l]在模K意义下余数相同,写成:

pre[r + 1] % K == pre[l] % K

这步转化就是整个算法里最核心的“同余转化”。你可以用带余除法验证一下:两个数之差能被K整除,当且仅当这两个数除以K的余数相等。比如11和1,都除以5,余数分别是1和1,所以11 - 1 = 10能被5整除。再比如7和2,余数分别是2和2,所以7 - 2 = 5也能被5整除。

一旦抓住这个等价关系,“统计能被K整除的子数组数量”就变成了“统计有多少对前缀和的余数相等”。问题性质完全不同了。

1.3 为什么转化后复杂度能降一个量级

暴力做法要枚举所有l和r,组合数是n(n+1)/2,复杂度O(n^2)。同余转化之后,我们只需要从左到右扫一遍数组,每扫到一个位置,问一个问题:当前这个前缀和的余数,之前出现过几次?每出现一次,就说明能和之前某个前缀配成一对,产生一个合法子数组。

为什么配对数量可以直接累加?因为余数相同的关系是等价关系:如果pre[a]余数是r,pre[b]余数也是r,那么pre[a]和pre[b]的差一定被K整除,对应的区间一定合法。反过来,如果区间合法,两个端点的前缀和余数一定相同。所以一对一同余的前缀和,就是一个合法子数组,不多不少。

这个思路的本质是把问题从“笛卡尔积式的两两比较”压缩成了“线性扫描加查表”。我打个比方:班里统计生日相差能被7整除的同学对数,暴力做法是两两比较365天;聪明做法是按星期几分成7组,只需要统计组内组合数。这个“按星期几分组”的动作,就是桶优化的雏形,下一章细说。

2. 桶优化:用计数器把两层循环压成一层

2.1 桶里到底存什么

同余转化做完以后,我们需要一个数据结构,把“余数”映射到“出现次数”。这个数据结构就是桶。桶的下标是余数,桶里存的值是这个余数已经出现了几次。

遍历到每个元素时,把当前前缀和pre取模得到r,然后做两件事:第一,ans加上cnt[r];第二,把cnt[r]自增1。顺序绝对不能反。如果先自增再累加,就会把当前前缀和自己跟自己配对的情况也算进去,形成一个长度为零的“空子数组”,导致答案多算。

为了加深理解,看一个最简单的例子。nums = [5], K = 5。初始化桶cnt = {0: 1}。扫到5,pre = 5,r = 0,ans += cnt[0]也就是1,然后cnt[0]变成2。最终答案是1。这里ans加上的1,对应的就是子数组[5]本身。如果没有提前把余数0放进桶里,答案就会算成0,全错。这个初始化细节我在第5章还要重点强调,它是新手最容易漏的地方。

2.2 负余数陷阱:统一模系

取模运算有个让人头疼的细节:负数怎么取余。C++和Java里,-2 % 5的结果是-2,而Python里-2 % 5的结果是3。如果拿到一个负余数直接去访问数组下标或者作为哈希键,程序会直接出问题,要么越界,要么统计出错。

统一处理方式是用一个防御性公式:

r = (pre % K + K) % K

这个式子的原理很简单:pre % K先把结果限制在(-K, K)区间内,加上K以后变成(0, 2K)区间内的正数,再取一次模,结果必然落在[0, K-1]。无论pre是正是负,最终得到的r都是合法的非负余数。

我用一个具体例子演示为什么要这样处理。假设K = 5,某个前缀和pre = -2。在C++里直接-2 % 5得到-2,把这个-2作为桶下标,访问cnt[-2]显然不对。用公式算一下:(-2 % 5 + 5) % 5 = (-2 + 5) % 5 = 3 % 5 = 3,正确余数是3。因为-2和3在模5意义下确实同余,-2 = 5 * (-1) + 3。

2.3 数组桶 vs 哈希桶,怎么选

实现桶有两种常见方式,选择标准主要看K的大小。

第一种,K在10^6甚至10^5以内时,直接开一个长度为K的整型数组。初始化全0。数组下标访问是O(1),常数极小,而且是连续内存,缓存命中率高,实测性能远好于一切哈希结构。

第二种,K非常大,比如1e9,根本开不了那么大的数组,就只能用哈希表,只存储出现过的余数。时间上仍然是O(n),但因为哈希碰撞、扩容等原因,常数比数组大不少。

我个人的选型原则是:竞赛或者面试环境里能开数组就开数组,空间换时间非常划算。只有K确实大到没法开数组时才用哈希。原因很简单,数组桶最坏情况就是k个位置的随机访问,性能可预期;哈希桶在最坏情况下可能因为碰撞严重而退化成O(n^2),虽然实际很少出现,但没必要给自己留隐患。

方案时间复杂度空间复杂度适用场景
暴力双重循环O(n^2)O(1)n ≤ 1000
前缀和 + 数组桶O(n)O(K)K ≤ 10^6,性能最优
前缀和 + 哈希桶O(n)O(n),只存出现过的余数K任意,K很大时使用

3. 从零实操:三道经典题的思路、代码与手推

3.1 题一:和可被K整除的子数组(力扣974)

题目描述很长,核心就一句:给一个整数数组nums和一个整数K,返回数组中能被子数组和整除K的连续非空子数组的数目。

标准解法就是前缀和加同余桶。Python代码非常干净:

def subarraysDivByK(nums, k): cnt = {0: 1} pre = 0 ans = 0 for x in nums: pre += x r = pre % k # Python的%保证结果为非负 ans += cnt.get(r, 0) cnt[r] = cnt.get(r, 0) + 1 return ans

C++版本需要注意负数取模的问题:

int subarraysDivByK(vector<int>& nums, int k) { unordered_map<int, int> cnt; cnt[0] = 1; int pre = 0, ans = 0; for (int x : nums) { pre += x; int r = (pre % k + k) % k; // 统一非负余数 ans += cnt[r]; cnt[r]++; } return ans; }

我用题目自带的示例nums = [4,5,0,-2,-3,1], K = 5,一步一步手推一遍。这个过程非常重要,建议你自己也在纸上跟着画一遍。

定义桶cnt,初始状态是cnt[0] = 1,表示空前缀pre[0] = 0的余数0已经出现一次。然后逐个扫描:

扫描元素当前pre余数r累加前cnt[r]ans更新更新后cnt
初始---0{0:1}
44400{0:1, 4:1}
59411{0:1, 4:2}
09423{0:1, 4:3}
-27203{0:1, 4:3, 2:1}
-34436{0:1, 4:4, 2:1}
15017{0:2, 4:4, 2:1}

最终答案是7,和官方示例输出一致。这张表建议多看两遍,你就能直观理解“每遇到一个已经出现过的余数,就多了一批新的合法配对”这个逻辑。比如扫到第三个元素0时,余数4已经出现过两次,所以ans从1变成3,说明新增了两个合法子数组。

3.2 题二:和为K的子数组(力扣560)——同余桶的“近亲”

力扣560题是“和为K的子数组”,虽然名字里没有“整除”,但它和974题共用同一个前缀和加桶的框架。唯一区别是,974题比的是“余数相等”,560题比的是“差值等于K”。

推导过程是这样的:要sum(l..r) == K,等价于pre[r + 1] - pre[l] == K,也就是pre[l] == pre[r + 1] - K。所以每扫到一个位置,答案要加上“之前有多少个前缀和恰好等于当前pre - K”,然后把当前pre放入桶中。

def subarraySum(nums, k): cnt = {0: 1} pre = 0 ans = 0 for x in nums: pre += x ans += cnt.get(pre - k, 0) cnt[pre] = cnt.get(pre, 0) + 1 return ans

注意这里桶的键不是余数,而是真实的前缀和值。你能清楚区分974题和560题的差别,说明你真正理解了前缀和加桶的通用框架。两题的代码结构几乎一模一样,唯一的灵魂差异就是“桶里存什么、查什么”。

3.3 题三:最长的和能被K整除的子数组

再升级一下:不统计数量,要长度。思路同样用同余桶,但桶里不再存“出现次数”,而是存“某个余数第一次出现的位置”。

维护一个字典first,key是前缀和的余数,value是这个余数第一次出现的下标位置。注意这里的下标我用“已扫描元素个数”表示。扫描过程中,如果当前余数已经出现过,就用当前位置减去first[r],得到一个候选长度,更新最大值;如果没出现过,就记录first[r]为当前位置。

def max_len_divisible(nums, k): first = {0: 0} pre = 0 ans = 0 for i, x in enumerate(nums, 1): pre += x r = pre % k if r in first: ans = max(ans, i - first[r]) else: first[r] = i return ans

这段代码里有几个点需要解释。第一,初始化first[0] = 0,对应空前缀的位置0。第二,只有当余数第一次出现时才记录位置,这样才能保证“当前位置减去首次出现位置”得到的差是“该余数类里最长的子数组”。第三,题目如果要求子数组必须非空,还要额外加个判断,确保长度大于0。

这个变种题完美诠释了桶优化的灵活性:桶的结构不变,桶里的“值”从计数变成了下标,解决的问题就完全不同了。这也提示我们,遇到新题不要僵化套模板,先想清楚题目到底要什么,再去决定桶里存什么。

4. 同余桶思想的更多战场:校验码、循环节与状态压缩

4.1 校验码里的同余:ISBN-10与mod 11/10

很多人以为同余和桶只存在于算法竞赛中,其实它在现实工程里应用特别广。最典型的就是ISBN-10书号的校验算法。

ISBN-10的规则是:前9位数字依次乘以10、9、8、…、2,得到一个加权和sum,然后计算校验位check = (11 - sum % 11) % 11。如果结果等于10,就用X表示。举例来说,假设前9位是0-306-40615,那么加权和是010 + 39 + 08 + 67 + 46 + 05 + 64 + 13 + 5*2 = 142,142 % 11 = 10,check = (11 - 10) % 11 = 1,所以校验位是1,完整书号是0-306-40615-1。

这个校验过程本质上就是“把一串数字压缩成一个模11的余数,再检查余数是否符合特定值”。你看,它和974题里“把前缀和压缩成模K的余数,再检查余数是否相同”是同一个思维模型。理解了这个,刷题时就多了一层亲切感:原来我们天天用的书籍编号,背后就是同余理论在支撑。

4.2 循环节、环形数组与同余分组

再往深走一层。只要数据存在“周期为K”的性质,同余分组就是天然优化方向。我做模拟题时遇到过很多“状态每走一步对M取模”的题,处理经验是:余数一旦出现重复,就意味着进入循环,后面一大段状态序列会周而复始地重复,可以直接跳过,不用一步步傻算。

环形数组问题也是如此。有人喜欢把数组复制一份来模拟环,其实可以直接用下标对数组长度取模来完成环形定位。本质上还是在用模运算处理循环结构。

同余分组的通用价值在于:它把“连续数值域”(比如前缀和的范围可能非常大)压缩成“离散的K个类”。数据一旦被分进K个同余桶里,后续的查重、计数、求最长距离、判断循环,都只发生在桶内部,和全局无关。这就是降维。

4.3 状态压缩里的同余判断

动态规划里同样大量出现同余。比如集合划分问题,要求判断能否把一些数分成若干组,每组和相等。如果总数对K有整除约束,余数往往直接可以作为DP状态的一维。再比如某些背包优化,枚举数量维度时可以按模K分成几个类,每个类内部单独做单调队列优化,从而把一维循环的复杂度降下来。

这类题型的共同点都是:表面上看不出“同余”两个字,但一旦你把数据按模K分类,规律就出来了。这就是为什么我一直强调,同余桶不只是“一道题的解法”,而是一种通用的算法直觉。当你看到整除、取模、循环、周期这几个关键词时,大脑就应该自动弹出“前缀和 + 同余 + 桶”的候选方案。

5. 常见问题与排查技巧实录

5.1 负数取模的跨语言差异

这是所有坑里出场率最高的一个。C++和Java对负数取模会保留负号,Python则保证结果和除数同号(所以除数为正时结果非负)。直接用语言默认的取模结果作为桶下标,是必错写法。

错误写法示范:

int r = pre % k; // 如果pre是负数,r可能是负数,访问数组越界

正确写法:

int r = (pre % k + k) % k;

再提醒一个点:如果K本身可能为负数,这个防御公式也救不了你。虽然题目一般给正整数K,但万一遇到,先把K取绝对值,再统一处理。

5.2 桶初始化漏掉pre[0]

第二个高频bug是忘了把pre[0] = 0对应的余数0在桶里初始化为1。这个初始化代表“空前缀”的存在。没有它,所有从数组开头开始且满足条件的子数组都会被漏统计。

我再用一个极小例子验证。nums = [5], K = 5,结果应该是1。如果不初始化cnt[0],桶为空,扫到5时r = 0,ans += 0,答案是0,直接错误。初始化cnt[0] = 1后,ans += 1,答案正确。

工程上怎么避免这种错?建议把“前缀和桶”的初始化写成固定两步:先建桶,然后显式cnt[0] = 1。不管题目里数组是什么,这个步骤永远不省。

5.3 前缀和溢出与取模时机

数组元素很大、n也很大时,pre的累加值很容易超出int范围。两种处理方式:一是把pre声明为long long;二是只保留模K后的余数前缀。第二种有个前提,题目只关心余数之间的比较关系,比如974题。这里给出余数前缀的写法:

int r = 0; for (int x : nums) { r = ((r + x) % k + k) % k; // 用r做桶查询和更新 }

因为(a + b) % k == (a % k + b % k) % k,所以这样维护出来的r和“先维护完整pre再取模”结果完全一致,还省掉了long long。但560题这类需要比较pre - K精确值的题目不能这么省,必须维护真实前缀和,否则信息丢失,答案必然错。到底用哪种,就一条判断标准:题目要的是余数关系,还是数值关系。

5.4 哈希桶的退化问题

最后聊下哈希桶的性能隐患。unordered_map在平均情况下是O(1),但遇到数据构造不好的场景可能退化,尤其是K在2的幂附近,很多数值的低位高度重合时,桶内元素容易扎堆。竞赛中如果时限卡得很紧,这就是致命的。

我的应对策略是三级方案:第一优先数组桶,K允许就开数组;第二,如果K很大,但n不大,可以把所有余数记录下来,排序后统一统计,避开哈希的动态扩容和碰撞问题;第三,实在要用哈希表,就自己写一个简单可靠的哈希函数,或者直接换map,把时间稳定在O(n log n)。

前面提到的mod 11/10校验也是一个思路:校验算法往往用固定模数,不依赖动态哈希,所以性能和时间复杂度都可精确预估。做工程或者竞赛,稳定性永远排在第一位。

最后再分享一点个人体会。同余转化和桶优化这套组合,最值钱的地方是一套思维习惯:看到整除、取模、重复周期,立刻想到前缀和、余数分类、桶内聚合。我当年第一次接触974题时也觉得巧妙,但真正搞懂是在纸上把前缀和的每个余数列出来之后。那一刻我才发现,所有合法子数组其实就是那些“相同余数”之间的连线。希望你也能亲手做一遍这个推演,那个豁然开朗的瞬间,比背任何模板都值钱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 11:30:27

硬件电路分析实战:从公式到系统直觉的三级跃迁

1. 这不是题库&#xff0c;是电路分析能力的实战切片“硬件笔试面试2026年通关秘籍&#xff1a;电路分析核心问题深度剖析”——看到这个标题&#xff0c;别急着去翻《模拟电子技术基础》前五章&#xff0c;也别一上来就背戴维南定理公式。我带过三年校招面试&#xff0c;筛过两…

作者头像 李华
网站建设 2026/9/16 11:30:10

MOSFET小信号三结构实战解析:CS/CG/SF物理本质与工程避坑指南

1. 这不是教科书里的公式搬运&#xff0c;而是我在模拟电路实验室熬了72小时后画出的三张“小信号地图”你打开任何一本《模拟电子技术基础》&#xff0c;翻到MOSFET放大器章节&#xff0c;大概率会看到三张并排的电路图&#xff1a;共源极&#xff08;CS&#xff09;、共栅极&…

作者头像 李华
网站建设 2026/9/16 11:29:07

ILI9341驱动芯片深度解析:从接口时序到树莓派移植与性能优化

简介&#xff1a;这是一份面向嵌入式开发者的ILI9341 TFT液晶屏驱动源码&#xff0c;配套芯片详解与应用说明&#xff0c;适合使用Arduino、Raspberry Pi等平台需要点亮屏幕、快速上手的开发者。资源包为单个C语言源文件&#xff08;共1个文件&#xff09;&#xff0c;压缩体积…

作者头像 李华
网站建设 2026/9/16 11:27:53

LunaTV 保姆级部署教程:用 Docker 从零搭建影视聚合播放器

LunaTV 保姆级部署教程&#xff1a;用 Docker 从零搭建影视聚合播放器 【免费下载链接】LunaTV 本项目采用 CC BY-NC-SA 协议&#xff0c;禁止任何商业化行为&#xff0c;任何衍生项目必须保留本项目地址并以相同协议开源 项目地址: https://gitcode.com/GitHub_Trending/lu/…

作者头像 李华
网站建设 2026/9/16 11:27:41

AgentScope分布式框架架构设计与性能优化解析

1. AgentScope核心架构解析AgentScope是一个新兴的分布式系统框架&#xff0c;最近在开发者社区引起了广泛讨论。作为一名长期关注分布式系统架构的从业者&#xff0c;我花了三周时间深入研究了它的设计理念和实现细节。下面我将从架构师的角度&#xff0c;带大家拆解这个框架的…

作者头像 李华
网站建设 2026/9/16 11:26:49

Python实现可调试SFM三维重建:从特征匹配到BA优化全流程

简介&#xff1a;本资源是一份基于Python实现的结构光三维重建&#xff08;SFM&#xff0c;Structure from Motion&#xff09;算法源码包&#xff0c;面向计算机视觉初学者、三维重建方向研究生及算法工程师&#xff0c;用于理解SFM核心流程——包括特征匹配、相机位姿估计、稀…

作者头像 李华