一、问题理解:什么是子数组和整除?
让我们想象有一个装满数字的盒子,里面有5个数字:[1, 2, 3, 4, 5]。我们要从盒子里连续取出一些数字,看看取出的数字加起来能不能被3整除。
连续取出是什么意思?就是不能跳着拿。比如你可以拿[2, 3, 4],但不能拿[1, 3, 5]。我们要数一数,有多少种不同的连续取法,使得取出的数字和能被3整除。
这个问题在编程中很常见,特别是当数字很多的时候(比如10万个),怎么快速算出答案就变得很重要了。
二、基础解法:三层循环的暴力枚举
我们先看最直接的方法,用三层循环来解决这个问题:
#include <iostream>
using namespace std;
int main() {
int n, k; // n是数字个数,k是要整除的数
cin >> n >> k;
int arr[100005]; // 存储数字
for(int i = 0; i < n; i++) {
cin >> arr[i];
}
int count = 0; // 用来数有多少段符合条件
// 第一层循环:从哪个位置开始取
for(int start = 0; start < n; start++) {
// 第二层循环:取到哪个位置结束
for(int end = start; end < n; end++) {
int sum = 0; // 记录当前段的和
// 第三层循环:把这一段的所有数字加起来
for(int i = start; i <= end; i++) {
sum += arr[i];
}
// 检查是否能被k整除
if(sum % k == 0) {
count++;
}
}
}
cout << count << endl;
return 0;
}
这个解法有什么问题?
假设有1000个数字:
- 第一层循环要执行1000次
- 第二层循环平均要执行500次
- 第三层循环平均要执行250次
- 总共大约要执行1亿2500万次计算
这太慢了!我们需要更聪明的方法。
三、第一次优化:前缀和
什么是前缀和?
前缀和是一个很有用的工具。简单说,前缀和就是”到某个位置为止,前面所有数字的和”。
比如数字[1, 2, 3, 4, 5]:
- 到第1个位置为止的和是1
- 到第2个位置为止的和是1+2=3
- 到第3个位置为止的和是1+2+3=6
- 到第4个位置为止的和是1+2+3+4=10
- 到第5个位置为止的和是1+2+3+4+5=15
用代码计算前缀和:
int arr[] = {1, 2, 3, 4, 5};
int n = 5;
int prefix[6]; // 前缀和数组,比原数组多一个位置
prefix[0] = 0; // 没有数字时的和是0
for(int i = 1; i <= n; i++) {
prefix[i] = prefix[i-1] + arr[i-1];
}
计算后,prefix数组是[0, 1, 3, 6, 10, 15]。
前缀和有什么用?
有了前缀和,计算任意一段数字的和就变得非常简单了!
比如要计算[3, 4, 5]的和(也就是第3到第5个数字):
- 原来要:3+4+5=12
- 用前缀和:prefix[5] – prefix[2] = 15 – 3 = 12
为什么这样算?因为:
- prefix[5]是前5个数的和:1+2+3+4+5=15
- prefix[2]是前2个数的和:1+2=3
- 用前5个数的和减去前2个数的和,剩下的就是第3到第5个数
用公式表示就是:sum(arr[从a到b]) = prefix[b+1] - prefix[a]
用前缀和优化代码
#include <iostream>
using namespace std;
int main() {
int n, k;
cin >> n >> k;
int arr[100005];
int prefix[100005] = {0}; // 前缀和数组,初始化为0
for(int i = 0; i < n; i++) {
cin >> arr[i];
prefix[i+1] = prefix[i] + arr[i]; // 计算前缀和
}
int count = 0;
// 现在只需要两层循环
for(int l = 0; l < n; l++) { // 左边界
for(int r = l; r < n; r++) { // 右边界
// 用前缀和快速计算区间和
int sum = prefix[r+1] - prefix[l];
if(sum % k == 0) {
count++;
}
}
}
cout << count << endl;
return 0;
}
改进效果
原来计算一段数字的和需要循环累加,现在只需要一次减法。但是,我们还是要尝试所有可能的段。1000个数字有50万种可能的段,还是太慢。
四、数学技巧:同余定理
这里要用到一点数学知识,但别担心,很简单。
余数相同的神奇性质
假设我们有两个前缀和:prefix[r+1]和prefix[l]。它们相减的结果是某段数字的和。
我们关心的是:(prefix[r+1] - prefix[l]) % k == 0
数学上有一个定理:如果两个数相减能被k整除,那么这两个数除以k的余数一定相同。
举个例子,k=3:
- 10除以3余1
- 7除以3余1
- 10-7=3,3能被3整除
因为10=3×3+1,7=3×2+1,两者相减时,余数1-1=0,剩下的就是3的倍数。
问题的转化
所以我们的问题变成了:找有多少对位置,它们对应的前缀和除以k的余数相同。
因为只要两个前缀和的余数相同,它们相减的结果就一定能被k整除。
五、第二次优化:哈希表的妙用
什么是哈希表?
哈希表就像一个智能抽屉柜:
- 每个抽屉有一个标签(键)
- 你可以根据标签快速找到抽屉
- 可以快速放入、取出、查看物品
在C++中,我们使用unordered_map来实现哈希表功能。
用哈希表优化思路
我们遍历数组,计算每个位置的前缀和除以k的余数。我们用一个哈希表来记录:每个余数出现过多少次。
关键思路:如果当前的余数之前出现过x次,那么当前这个位置可以和之前的x个位置组成x段符合条件的数字段。
为什么加1:想象一下,当前余数出现过x次,表示之前有x个位置的前缀和余数与当前相同。当前这个前缀和与之前那x个前缀和相减,就能得到x段符合条件的数字段。
为什么初始化时要记录{0:1}:因为当某个位置的前缀和本身就能被k整除时,这段(从开始到这个位置)也应该被计数。这相当于当前前缀和与”什么都没有”(前缀和为0)配对。
代码实现
#include <iostream>
#include <unordered_map> // 哈希表需要的头文件
using namespace std;
int main() {
int n, k;
cin >> n >> k;
unordered_map<int, int> remainder_count; // 哈希表,记录余数出现次数
remainder_count[0] = 1; // 初始化,余数0出现过1次
long long prefix_sum = 0; // 当前前缀和
long long answer = 0; // 答案,用long long防止溢出
for(int i = 0; i < n; i++) {
int num;
cin >> num;
prefix_sum += num; // 更新前缀和
// 计算余数,确保是非负的
int remainder = prefix_sum % k;
if(remainder < 0) {
remainder += k; // 如果余数是负数,加k变成正数
}
// 如果这个余数之前出现过x次,就能找到x段
answer += remainder_count[remainder];
// 更新这个余数的出现次数
remainder_count[remainder]++;
}
cout << answer << endl;
return 0;
}
哈希表在C++中的使用
- 包含头文件:
#include <unordered_map> - 定义哈希表:
unordered_map<键类型, 值类型> 表名; - 添加或修改:
表名[键] = 值; - 查找:
表名.count(键)返回0或1,表示是否存在 - 访问:
表名[键]如果键不存在,会自动创建(值为0)
为什么用unordered_map而不是map?
unordered_map基于哈希表,平均查找时间是O(1)map基于红黑树,查找时间是O(log n)- 这里我们只需要快速查找,不需要排序,所以用
unordered_map更快
六、详细例子解析
让我们用例子[1, 2, 3, 4, 5],k=3来一步步看:
- 初始化:
remainder_count = {0:1},prefix_sum = 0,answer = 0 - 第1个数字1:
prefix_sum = 0+1 = 1- 余数 = 1%3 = 1
- 余数1出现过0次,
answer += 0(answer还是0) - 更新:
remainder_count[1] = 1,现在remainder_count = {0:1, 1:1}
- 第2个数字2:
prefix_sum = 1+2 = 3- 余数 = 3%3 = 0
- 余数0出现过1次,
answer += 1(answer=1,找到了[1,2]) - 更新:
remainder_count[0] = 2,现在remainder_count = {0:2, 1:1}
- 第3个数字3:
prefix_sum = 3+3 = 6- 余数 = 6%3 = 0
- 余数0出现过2次,
answer += 2(answer=3,找到了[3]和[1,2,3]) - 更新:
remainder_count[0] = 3,现在remainder_count = {0:3, 1:1}
- 第4个数字4:
prefix_sum = 6+4 = 10- 余数 = 10%3 = 1
- 余数1出现过1次,
answer += 1(answer=4,找到了[2,3,4]) - 更新:
remainder_count[1] = 2,现在remainder_count = {0:3, 1:2}
- 第5个数字5:
prefix_sum = 10+5 = 15- 余数 = 15%3 = 0
- 余数0出现过3次,
answer += 3(answer=7,找到了[4,5]、[3,4,5]、[1,2,3,4,5]) - 更新:
remainder_count[0] = 4
最终answer=7,正确。
七、常见错误与修正
错误1:没有初始化remainder_count[0] = 1
错误代码:
unordered_map<int, int> remainder_count;
// 没有初始化
问题:会漏掉前缀和本身就能被k整除的情况。
修正:
unordered_map<int, int> remainder_count;
remainder_count[0] = 1; // 一定要初始化
错误2:没有处理负数余数
错误代码:
int remainder = prefix_sum % k;
问题:C++中,负数%正数得到负数,比如-1%3=-1,但我们需要的是2。
修正:
int remainder = (prefix_sum % k + k) % k;
// 或者
int remainder = prefix_sum % k;
if(remainder < 0) remainder += k;
错误3:count用int可能溢出
错误代码:
int count = 0;
// 当n很大时,count可能超过20亿
修正:
long long count = 0; // 用long long
错误4:前缀和用int可能溢出
错误代码:
int prefix_sum = 0;
// 当数字很大或很多时,prefix_sum可能溢出
修正:
long long prefix_sum = 0; // 用long long
八、完整代码
综合所有修正的完整代码:
#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
// 这两行是C++的输入输出优化,可以让程序更快
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
cin >> n >> k;
unordered_map<int, int> remainder_count;
remainder_count[0] = 1; // 关键初始化
long long prefix_sum = 0; // 前缀和
long long answer = 0; // 答案
for(int i = 0; i < n; i++) {
int num;
cin >> num;
prefix_sum += num; // 更新前缀和
// 计算非负余数
int remainder = prefix_sum % k;
if(remainder < 0) {
remainder += k;
}
// 累加答案
answer += remainder_count[remainder];
// 更新哈希表
remainder_count[remainder]++;
}
cout << answer << endl;
return 0;
}
九、性能对比
假设有n=100,000个数字:
| 方法 | 时间复杂度 | 大概需要计算次数 | 运行时间估计 |
|---|---|---|---|
| 三层循环 | O(n³) | 约1000亿亿次 | 几个月 |
| 前缀和优化 | O(n²) | 约50亿次 | 几秒到几分钟 |
| 哈希表优化 | O(n) | 约10万次 | 不到0.01秒 |
可以看到,优化的效果是惊人的!
十、总结
通过这个问题,我们学到了:
- 前缀和:用空间换时间,把O(n)的区间求和变成O(1)
- 同余定理:把整除问题转化为余数相等问题
- 哈希表:快速记录和查找余数出现次数
- 优化思路:从O(n³)到O(n)的三次飞跃
关键点:
- 前缀和公式:
sum(l到r) = prefix[r+1] - prefix[l] - 同余定理:
(a-b)能被k整除⇔a%k == b%k - 哈希表初始化:
remainder_count[0] = 1 - 负数余数处理:
(x%k+k)%k
这个思路不仅适用于整除问题,还可以解决:
- 子数组和为固定值
- 子数组和为k的倍数
- 子数组异或值为0
- 等等
哇,好棒的算法٩(ˊᗜˋ*)و😅

确实是这样,文章写的也很详细