#LC523. 连续子段的整除判定
连续子段的整除判定
你好!我是你的OI金牌教练。今天我们来拆解一道非常经典的题目。这道题在LeetCode上是523题,但在OI竞赛中,它考察的是前缀和与同余性质结合的思维技巧。
一、 题目描述 (OI风格改写)
题目名称: 连续子段的整除判定 (Subsegment Divisibility)
【题目描述】 给定一个包含 个非负整数的序列 和一个整数 。 请编写一个程序,判断序列 中是否存在一个连续子段(Continuous Subarray),该子段的长度至少为 2,且该子段内所有元素的和是 的倍数。
如果存在这样的子段,输出 YES,否则输出 NO。
注: 也是 的倍数。
【输入格式】 第一行包含两个整数 和 ,分别表示序列长度和给定的除数。 第二行包含 个整数,表示序列 的元素。
【输出格式】
输出一行字符串 YES 或 NO。
【样例输入】
5 6
23 2 4 6 7
【样例输出】
YES
(解释:子段 [2, 4] 的和为 6,是 6 的倍数;或者 [23, 2, 4, 6, 7] 的和为 42,是 6 的倍数)
【数据范围】
- 保证所有前缀和在 64 位整数范围内。
二、 思路提示 (Hint)
不要急着看完全解,先思考以下几个方向:
- 区间和怎么求快? 当我们需要频繁获取一段连续区间的和 时,通常会用到什么预处理技巧?
- 整除的转化: 如果一个数 是 的倍数,那么 等于多少?
- 同余的性质: 如果两个数 和 满足 (即它们除以 的余数相同),那么它们的差 与 有什么关系?
- 数据结构选择: 我们需要快速查找某个“余数”以前是否出现过,以及出现的位置,应该用数组还是哈希表?
三、 预备知识点
解决这道题,你需要熟练掌握以下工具:
- 前缀和 (Prefix Sum): 将区间求和复杂度从 降为 。
- 同余定理 (Congruence modulo): 若 ,则 是 的倍数。
- 哈希表 (Hash Map): C++ STL 中的
std::unordered_map或手写哈希,用于 存取 {余数: 下标}。
四、 启发式教学与草稿纸推演
假设我们在教室里,面前是一块黑板(草稿纸)。来,跟着我的笔触一步步思考。
第一阶段:暴力法的瓶颈
学生: “教练,我可以枚举所有的子段,算出和,看是不是 的倍数。” 教练: “好,我们算一下复杂度。 枚举左端点 ,枚举右端点 ,这一步是 。如果是 ,运算量达到 ,肯定 TLE(超时)。我们要找 或 的做法。”
第二阶段:引入前缀和
教练: “我们定义前缀和数组 表示前 个数的和。那么子段 的和可以怎么表示?” 学生: “。” 教练: “题目要求 是 的倍数,翻译成数学公式就是:”
第三阶段:数学变形(关键一步!)
教练: “这个公式怎么变形?根据同余的性质,如果 能被 整除,说明什么?” 学生: “说明 和 除以 的余数相等!” 教练: “没错!所以在草稿纸上写下这个核心结论:”
教练: “这意味着,我们不需要枚举两个端点。我们只需要遍历一遍数组,计算当前的 。如果这个余数之前出现过,假设上次出现的下标是 (也就是 和当前一样),那么从 到 这一段的和就是 的倍数。”
第四阶段:细节处理(长度限制与初始化)
教练: “题目有个坑:子段长度至少为 2。这意味着什么?”
学生: “意味着 ?或者说 和 不能相邻?”
教练: “准确说是当前下标 与记录的那个下标 的距离要大于等于 2。所以我们在哈希表里存什么?存 {余数: 第一次出现的下标}。如果是第二次遇到这个余数,不要更新下标(保留最左边的,这样区间才最长,肯定满足条件),而是计算距离。”
教练: “还有一个特例。如果 怎么办?比如序列 [6, 6], 。。”
学生: “这直接就是倍数了呀。”
教练: “为了统一逻辑,我们假设有一个虚拟的前缀和 ,它的下标是 。
所以在程序一开始,我们就在哈希表里塞入:{0: -1}。
这样如果算出余数是 0,距离就是 ,逻辑就通了。”
五、 复杂度分析
-
时间复杂度:
- 我们只遍历数组一次。
- 在哈希表中查找和插入的操作平均是 的。
- 总时间复杂度:。
- 优化建议: 虽然
std::map是 ,在这题也能过,但用std::unordered_map或手写哈希表更优。
-
空间复杂度:
- 最坏情况下,每个前缀和的余数都不同。
- 哈希表最多存储 个键值对。
- 总空间复杂度:。
六、 读题理解关键词
做这类题时,要在题目中圈出以下关键词:
- "Continuous / 连续" 暗示可以使用前缀和、滑动窗口或单调队列。
- "Multiple of k / k的倍数" 暗示涉及模运算(Modulo)、同余性质。
- "Size at least 2 / 长度至少为2" 这是边界条件的坑,决定了判断逻辑()。
七、 参考代码 (OI风格 C++14)
/**
* 题目:连续子段的整除判定 (LeetCode 523 Modified)
* 知识点:前缀和、同余性质、哈希表
* 时间复杂度:O(n)
*/
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;
// 既然是竞赛风格,使用一个 solve 函数封装核心逻辑,或者直接在 main 中写
// 为了代码清晰,这里分离逻辑
bool checkSubarraySum(int n, int k, const vector<long long>& nums) {
// 只有少于2个数,无法构成长度>=2的子段
if (n < 2) return false;
// 哈希表用于存储 {余数 : 第一次出现的下标}
// key: remainder, value: index
unordered_map<int, int> remainderIndexMap;
// 初始化:余数为0的情况,虚拟下标为-1
// 这一步是为了处理类似 [6, 6], k=6 这种情况,
// 前缀和为6,余数为0,距离 = 0 - (-1) = 1 (不满足>=2) -> 继续
// 下一个前缀和12,余数为0,距离 = 1 - (-1) = 2 (满足) -> True
remainderIndexMap[0] = -1;
long long currentPrefixSum = 0;
for (int i = 0; i < n; ++i) {
currentPrefixSum += nums[i];
// 计算当前前缀和关于 k 的余数
// 注意:题目保证 nums 非负,所以 sum 也非负。
// 如果题目有负数,需要 (sum % k + k) % k 来保证余数为正
int remainder = currentPrefixSum % k;
// 查找该余数是否之前出现过
if (remainderIndexMap.count(remainder)) {
int prevIndex = remainderIndexMap[remainder];
// 检查子段长度是否 >= 2
if (i - prevIndex >= 2) {
return true;
}
// 注意:如果长度 < 2,我们不更新哈希表
// 因为我们要保留该余数出现的最左下标,以便后续获得更长的子段
} else {
// 如果没出现过,记录当前下标
remainderIndexMap[remainder] = i;
}
}
return false;
}
int main() {
// 1. 读入优化,OI竞赛标配
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long k; // k 有可能很大,虽然题目说 sum % k,int 也行,但保持习惯
if (!(cin >> n >> k)) return 0;
vector<long long> a(n);
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
// 2. 调用核心逻辑
if (checkSubarraySum(n, (int)k, a)) {
cout << "YES" << endl;
} else {
cout << "NO" << endl;
}
return 0;
}
希望这份讲义能帮你彻底掌握这道题的精髓。记住,看到“区间和倍数”,立刻想到“前缀和余数相同”! 加油!