子数组和整除问题——哈希表与前缀和

一、问题理解:什么是子数组和整除?

让我们想象有一个装满数字的盒子,里面有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++中的使用

  1. 包含头文件#include <unordered_map>
  2. 定义哈希表unordered_map<键类型, 值类型> 表名;
  3. 添加或修改表名[键] = 值;
  4. 查找表名.count(键)返回0或1,表示是否存在
  5. 访问表名[键]如果键不存在,会自动创建(值为0)

为什么用unordered_map而不是map?

  • unordered_map基于哈希表,平均查找时间是O(1)
  • map基于红黑树,查找时间是O(log n)
  • 这里我们只需要快速查找,不需要排序,所以用unordered_map更快

六、详细例子解析

让我们用例子[1, 2, 3, 4, 5],k=3来一步步看:

  1. 初始化:remainder_count = {0:1}prefix_sum = 0answer = 0
  2. 第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}
  3. 第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}
  4. 第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}
  5. 第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}
  6. 第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秒

可以看到,优化的效果是惊人的!

十、总结

通过这个问题,我们学到了:

  1. 前缀和:用空间换时间,把O(n)的区间求和变成O(1)
  2. 同余定理:把整除问题转化为余数相等问题
  3. 哈希表:快速记录和查找余数出现次数
  4. 优化思路:从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
  • 等等

评论

  1. 老电工 Chen
    3 天前
    2026-1-05 22:53:35

    哇,好棒的算法٩(ˊᗜˋ*)و😅

    • 91岁老太太
      老电工 Chen
      3 天前
      2026-1-06 0:04:39

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

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇