1. 这个组合套路到底解决什么问题
上周在处理一批线上日志数据时,碰到一个很典型的问题:在一组时间戳里,需要统计“相隔固定周期余数”的事件对数量。数据规模到了百万级,用最朴素的两层循环算,跑完得等一首歌的时间。后来我把问题做了MOD同余转化,再用桶优化把计数过程从O(n²)降到了O(n),整个过程不到三秒就出结果。
说句实在话,这个套路在算法竞赛、日常业务代码、数据分析里出现频率高得吓人。凡是看到条件里出现“某个数除以k的余数”“和能被k整除”“差值是k的倍数”这类描述,基本都能往这个方向想。核心就两句话:先把条件写成同余方程,再用桶统计余数的出现次数。
这篇文章就把这套思路掰开揉碎讲清楚。适合什么人看?如果你刷题时看到“mod”“整除”就头疼,或者写业务代码时遇到大量余数判断导致性能瓶颈,又或者只是对“哈希表还能怎么优化”感兴趣的读者,都可以接着往下看。我会从数学原理、桶的设计、完整代码、踩坑经验四个方面展开,保证你读完能直接用。
先说个直觉层面的理解。假设你面前有一堆牌,每张牌上有一个整数。你想找有多少对牌,它们的点数之和除以7的余数等于3。如果一张一张匹配,那复杂度是O(n²)。但如果你把牌按“点数和模7后的结果”分成7堆,那每次来一张新牌,只需要去指定的某一堆里数数就行。这个“按余数分堆”的动作,就是同余转化;“一堆一计数”的数据结构,就是桶。
为什么两者要绑在一起用?因为同余转化负责把复杂的条件变成简单的等式,桶负责让“统计某个余数出现了多少次”这件事达到O(1)。一个管数学,一个管结构,缺一不可。
2. 同余转化的三条铁律,想明白就能少写一堆代码
很多人一看到字母mod就本能抗拒,其实它背后的道理非常朴素。取模的本质是把“无限延伸的整数轴”卷成一个“有限长度的环”,就像把一条长纸带首尾相接。在这个环上,加法和乘法依然是自洽的,这就是同余转化能成立的根源。
2.1 分配律,几乎所有变形的出发点
处理取模计数问题时,我几乎每天都会用到这三个恒等式:
- (a + b) % m == ((a % m) + (b % m)) % m
- (a * b) % m == ((a % m) * (b % m)) % m
- (a - b) % m == ((a % m) - (b % m) + m) % m
第一条用于加法条件的转化,第二条用于乘法条件的转化,第三条用于处理差值和负数。别小看这三条,我见过很多人在代码里硬算超大数的取模,其实早就可以拆开算了。
举个例子。要求统计满足 (nums[i] + nums[j]) % k == target 的数对,如果不做转化,你必须在两层循环里计算 nums[i] + nums[j] 再取模,每算一次都有加法溢出和类型转换的风险。但如果先对每个数单独取一次模,问题就变成:两个小于k的数相加,再取模等于target。原来的数值具体多大已经不重要了,重要的是它的余数。
这里有一个很好用的思考方式:先把所有数字丢进“余数加工厂”,得到一批 0 到 k-1 之间的结果,接下来所有运算都在这个有限集合内进行。这样既避免了中间结果溢出,又把问题的“数值空间”从无限压缩到了k个可能值。
2.2 负数取模,语言差异逼出来的统一写法
负数取模是一个看起来很小、却特别容易翻车的点。这里必须明确一个概念:不同语言对“取模”运算的结果符号定义不一样。
C++和Java里,(-7) % 3 的结果是 -1,因为它们的实现会保留被除数的符号。Python里,(-7) % 3 的结果是 2,因为它会保证结果和除数同号。同一个数学表达式,在不同语言里结果不同,这很容易导致代码迁移时出现诡异bug。更麻烦的是,在统计同一余数的场景里,-1和2虽然数值上相差3,但都属于一个同余类,数学上写成 2 ≡ -1 (mod 3)。
所以我在写计数类代码时,统一使用一个修正表达式:((x % k) + k) % k。
这个表达式先取一次模,加上k把负数抬到正数区间,再取一次模确保结果严格落在 0 到 k-1 之间。为什么要取两次模?因为如果只写 (x % k + k),当x原本就是正数余数时,比如7%3=1,加完k变成4,越界了。所以必须再模一下。这个写法在所有主流语言里结果都一致,我建议直接背下来,别嫌它啰嗦。
在推导同余方程时也要注意,如果从算式里解出“某个差值为负数”的情况,一定先做负数修正,再进桶查询。否则你拿着一个负余数去数组里做下标,轻则查错位置,重则直接越界崩溃。
2.3 同余类划分,把无限问题变成有限问题
同余转化带来的最大收益,是“状态空间”的压缩。任意整数n,对k取模后,结果只可能是0, 1, 2, ..., k-1这k种。换句话说,所有整数可以被划分成k个同余类,每个桶代表一个同余类。
这个思想在我们设计算法时极其有用。比如你要统计“有多少个数的余数是3”,你完全不需要把每个数都存下来再逐个比较,只需要维护一个长度等于k的计数器数组,每遇到一个数就把它对应的余数位置加1。整个过程连排序都不用做。
这里想强调一点:同余类划分之所以能降低复杂度,是因为它把“数值的精确大小”这个信息舍弃了,只保留了“在除法意义下属于哪一类”的信息。很多问题恰恰只需要分类信息就够了。这就像人可以根据出生月份分成12组,要统计每月出生人数时,没必要记录每个人的具体日期,只要看月份就行。
3. 桶优化为什么这么快,以及它和哈希表的真正区别
“桶”这个概念很多人一听就懂,但真正能把它用明白的人不多。我理解的桶,本质上是一个“下标即键”的数组。它和哈希表干的事非常像——都是把一个键映射到一个值,但实现路径完全不同。
3.1 数组的物理特性比哈希表更简单粗暴
哈希表(比如Python里的dict,Java里的HashMap)在插入和查询时,需要先计算哈希值,再处理哈希冲突,冲突严重时还会退化成链式查询。这些都是额外开销。虽然均摊复杂度还是O(1),但常数项明显比直接访问数组大得多。
桶则完全不走这套流程。如果键恰好是连续的非负整数,比如0到k-1,那么键可以直接当数组下标用,步数只有一步:内存地址 = 起始地址 + 下标 × 单个元素大小。没有哈希计算,没有冲突处理,没有链表节点。这不仅仅是理论上的优势,实际测试中,在数据量百万级的情况下,数组桶通常比哈希表快2到5倍,原因就是缓存局部性更好——数组元素在内存里紧挨着,CPU预取效率高。
所以判断要不要用桶优化,第一条标准就是:键的范围是否连续且有界。比如余数的范围天然就是0到k-1,这种场景不优化成桶简直浪费。
3.2 桶的构造方式,两种常见形态
桶不是只有一种写法。根据需求,我常用两种形态。
第一种是计数桶。开一个长度k的数组,初始全0,每次遍历到一个元素,就把对应计数加1。这适合统计余数出现次数、频率分布这类问题。
第二种是前缀余数桶。在遍历数组的同时,维护所有已读元素余数的计数,这样在某次查询时,可以立刻得到“之前出现过多少次满足某个余数条件的元素”。它和计数桶唯一的区别是查询时机:计数桶往往是全部统计完再查,前缀余数桶则边查边更新。后面给的代码示例里,两种都会展示。
如果你要维护的桶跨度很大,比如k达到10^9,这时候开数组就不可行了,内存直接爆掉。这种极端情况下,老老实实退回哈希表,或者用离散化压缩余数值。桶优化不是银弹,它的适用边界就在“k可控”这个范围内。
3.3 什么时候用桶,什么时候该用哈希表
我给自己定了一条简单的判断规则:k不超过10^6,优先用桶;k超过10^6,开始考虑哈希表;k超过10^8,大概率要用离散化或数学方法绕开存储。
还要看键的分布。如果余数值分布极其稀疏,比如10个数字落在一百万个可能的余数里,用桶虽然可行,但空间浪费很严重。这种情况下哈希表反而更划算,因为你只用存实际出现过的余数。而如果数据本身铺得很均匀,桶的空间利用率和时间效率都会拉满。
另外,桶还有一个隐藏优势:天然支持“反向更新”。比如滑动窗口场景里,窗口移动时只需要对旧余数减1、对新余数加1,O(1)就能维护整个窗口的余数分布。哈希表虽然也能这么做,但每次增减都要处理哈希计算的额外开销,窗口很大时差距很明显。
4. 实操示例:统计满足同余条件的数据对
理论讲再多,不如跑一段完整代码来得直接。下面我用一个最经典的场景演示整套流程:给定整数数组nums和正整数k、target,统计所有满足 (nums[i] + nums[j]) % k == target 的数对数量,要求 i < j。
4.1 暴力做法为什么慢
最直接的实现就是两重循环:
def count_pairs_bruteforce(nums, k, target): n = len(nums) ans = 0 for i in range(n): for j in range(i + 1, n): if (nums[i] + nums[j]) % k == target: ans += 1 return ans这段代码逻辑完全正确,但时间复杂度O(n²)。当n是10^5时,需要循环约50亿次,跑完基本可以下班了。问题出在每一对组合都要计算一次加法与取模,完全没有利用历史信息。
4.2 同余转化,把条件改写
根据分配律,条件 (nums[i] + nums[j]) % k == target 等价于 ((nums[i] % k) + (nums[j] % k)) % k == target。
设 x = nums[i] % k,y = nums[j] % k。那么在已知x的情况下,要找到满足条件的y,就要求:
(x + y) % k == target
两边同时整理,可得:
y ≡ (target - x) (mod k)
注意这里得到的是同余关系,y本身必须在0到k-1之间。所以实际需要的余数值是 ((target - x) % k + k) % k,也就是之前提到的负数修正表达式。
这样一来,我只需要记录所有已经出现过的余数的数量,每次遇到一个新数,去查询“之前有多少个余数等于 need”即可。
4.3 桶优化的完整代码
先给Python版本,逻辑最清晰:
def count_pairs_by_bucket(nums, k, target): bucket = [0] * k ans = 0 for num in nums: x = num % k need = (target - x) % k ans += bucket[need] bucket[x] += 1 return ans核心操作就三行:算当前余数x,算需要的余数need,累加bucket[need]再到bucket[x]自增。再给一个C++版本,方便刷题的同学直接抄:
long long countPairs(vector<int>& nums, int k, int target) { vector<int> bucket(k, 0); long long ans = 0; for (int num : nums) { int x = ((num % k) + k) % k; int need = ((target - x) % k + k) % k; ans += bucket[need]; bucket[x]++; } return ans; }有一个细节必须强调:顺序是先查 bucket[need],再更新 bucket[x]。如果反过来,先更新再查询,当前元素自己会被算进去,导致多统计“自己和自己配对”的结果,也可能破坏i<j的约束。凡是用“前缀信息”优化的题,都要养成“先查后更”的习惯。
如果用unordered_map代替数组桶,这段代码也能跑,但把键换成数组下标后,性能提升非常明显。在我本地测试中,n=10^6、k=100时,桶版本的耗时大约是哈希表版本的1/3。
4.4 复杂度分析和内存对比
桶优化版本的时间复杂度是O(n),只需要遍历一次数组;空间复杂度O(k),用于存储余数计数。相比暴力法的O(n²),这是质的飞跃。
同一道题,如果k比较小,比如k=7,桶数组甚至可以被CPU放进L1缓存,整段循环几乎不受内存访问限制,跑起来速度快得离谱。这也解释了为什么很多数学题看似需要复杂推导,最后却只是“一个固定大小的数组来回加”。
5. 进阶变体:子数组和可被k整除的计数
上面那个例子是对“数对”做操作,接下来这个变体在算法题里出场率更高:统计连续子数组中,和能被k整除的个数。最典型的就是LeetCode第974题。
5.1 前缀和同余,核心等式
设前缀和数组pre,pre[i]表示数组前i个元素的和(pre[0]=0)。那么子数组nums[l..r]的和等于 pre[r] - pre[l-1]。
子数组和能被k整除,等价于:
(pre[r] - pre[l-1]) % k == 0
由同余性质,等价于:
pre[r] % k == pre[l-1] % k
也就是说,只要两个前缀和除以k的余数相同,它们中间夹着的子数组和一定能被k整除。所以问题又一次变成了“统计相同余数出现了多少次”。这个转化极其优美,把原本需要枚举所有子数组的O(n²)问题,一下变成了线性问题。
5.2 代码实现和桶初始化的坑
def subarraysDivByK(nums, k): bucket = [0] * k bucket[0] = 1 pre = 0 ans = 0 for num in nums: pre = ((pre + num) % k + k) % k ans += bucket[pre] bucket[pre] += 1 return ans这里有两个常见的坑。
第一个坑是bucket[0]初始化为1。这表示“空前缀”的余数为0,已经出现过一次。为什么必须这样?因为如果某个前缀和本身就能被k整除,那么它对应的子数组是从下标0开始的。如果没有这个初始值,所有从0开始且和能被k整除的子数组都会被漏掉。这个细节我见过太多人踩过。
第二个坑是pre的更新必须用修正后的负数取模。前缀和可能因为负数元素变成负值,如果直接用pre % k,遇到负数结果就是负的,桶下标直接出错。用 ((pre + num) % k + k) % k 最稳妥。
整个代码的时间复杂度O(n),空间复杂度O(k)。对比暴力枚举所有子数组再逐个求和判断,效率提升非常可观。这个代码在LeetCode上能跑到击败90%以上的提交,很大程度上就依赖桶的下标O(1)访问。
6. 踩坑实录与排查速查表
这部分内容是我自己反复踩过、也帮别人debug过无数次的经验总结。代码不长,坑却不少,而且每个坑都可能导致结果完全错误或者运行时崩溃。
6.1 我踩过的几个坑
第一个坑是余数修正写成了 (x % k + k)。刚才说过,这只是把负数转正,但正数情况下会越界。必须再模一次。我见过同事写的代码里,遇到负余数时数据看起来对了,遇到正余数时桶下标偶尔越界,非常隐蔽。
第二个坑是桶的尺寸写成k+1或者k-1。理论上余数范围是0到k-1,所以数组长度必须是k。写k+1不会崩但是浪费空间,写k-1则必然下标越界,尤其在余数刚好是k-1的时候,程序会在运行时直接报错。如果你处理的是1-indexed的题目或者自定义的取模逻辑,这个细节尤其容易出错。
第三个坑是统计逻辑里的重复计数。有些题要求统计i<j的数对,有些题说i和j可以相同,还有些题要求有序数对。如果题目没看清,套用模板就会要么多算,要么少算。我自己的建议是,拿到题第一件事先确定“这对元素是否有顺序要求”,再决定是否需要在查询前排除自身。
第四个坑是大数溢出。虽然同余转化之后,中间变量都被限制在k以内,但在求 nums[i] + nums[j] 或者计算前缀和时,原始累加值可能非常大。在C++里,int类型很容易在累加过程中溢出,导致取模结果也错误。这种情况要用long long承接原始累加值,再做取模。
6.2 问题排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 计算结果比预期大很多 | 先更新桶再查询,把当前元素算进去了 | 改成先查询再更新 |
| 计算结果比预期小很多 | 漏掉了空前缀初始桶 | bucket[0]初始化为1 |
| 程序运行时报下标越界 | 取模结果出现了负数 | 使用 ((x % k) + k) % k 修正 |
| 大数组测试时结果错误 | 中间累加溢出了int范围 | 用long long存储累加值 |
| 换语言后结果不一致 | 负数取模规则不同 | 统一用修正表达式,不依赖语言默认行为 |
| 内存占用爆炸 | k极大导致桶数组过大 | 改用哈希表或离散化处理 |
| 窗口滑动时统计结果异常 | 桶删除和新增顺序混乱 | 先减旧余数计数,再加新余数计数 |
这张表我贴在工位旁边很久了,每次遇到类似问题直接对照排查,效率很高。尤其是在面试或者比赛现场,时间紧张时,先看表的“现象”列能快速定位到80%的问题。
7. 适用范围与扩展思路
说句心里话,MOD同余转化加桶优化这个组合,并不是所有取模问题都适用,但它覆盖的场景足够广,值得当成一种固定解题范式来练。
适合它的场景有这几类:统计满足取模条件的数据对数量,统计子数组和能被k整除的个数,求两个数组之间同余集合的交集,周期性事件的特征匹配,分桶后的频率分布,以及一切需要在滑动窗口内维护余数分布的问题。判断标准很简单:问题关心的核心是“除以某个数的余数”,而不是原始数值本身。
不太适合的场景是:k值极大且数据分布极度稀疏,比如k=10^18但数据只有几百个;这种情况桶数组开不出来,哈希表又显得大材小用,更合适的做法是直接对每个数的余数排序后只处理有数据的同余类。另外,如果题目要求的是余数最大的组合、余数绝对值最小的差值这类最优化问题,桶只能帮到一部分,还需要配合排序或数学推导。
这个思路还能继续往外扩。比如把一维桶扩成二维桶,同时按 (a % m, a % n) 做双维度统计,可以处理一些更复杂的周期性匹配问题。也可以把同余转化用在数据分片上,按余数把大任务拆成若干独立小任务,再并行处理,每个节点只负责一个桶的数据,这样能显著降低全局通信开销。再比如和前缀和、差分数组配合,可以在一维数组上快速做“模k区间加”的模拟,这在游戏开发里处理按回合刷新的buff状态时也经常用。
说一个我自己的习惯:写代码之前,先在一张草稿纸上手算三组小数据,模拟一遍“先查后插”的过程,确认余数方程没有推歪,再上编辑器。步骤很短,但能省下大量debug时间。这套方法熟练之后,再看到“mod”相关的问题,基本能一眼判定能不能用桶优化,然后直接套模板。
说到底,MOD同余转化解决的是“怎么把条件变简单”的问题,桶优化解决的是“怎么让计数变快”的问题。两个工具一组合,很多看似棘手的计数题就会变成一趟简单的线性遍历。平时刷题或者写代码时,多积累几个这样的固定组合,遇到新问题心里就有底了。