#LC523. 连续子段的整除判定

连续子段的整除判定

你好!我是你的OI金牌教练。今天我们来拆解一道非常经典的题目。这道题在LeetCode上是523题,但在OI竞赛中,它考察的是前缀和同余性质结合的思维技巧。


一、 题目描述 (OI风格改写)

题目名称: 连续子段的整除判定 (Subsegment Divisibility)

【题目描述】 给定一个包含 nn 个非负整数的序列 A=[a1,a2,,an]A = [a_1, a_2, \dots, a_n] 和一个整数 kk。 请编写一个程序,判断序列 AA 中是否存在一个连续子段(Continuous Subarray),该子段的长度至少为 2,且该子段内所有元素的和是 kk 的倍数。

如果存在这样的子段,输出 YES,否则输出 NO

注:00 也是 kk 的倍数。

【输入格式】 第一行包含两个整数 nnkk,分别表示序列长度和给定的除数。 第二行包含 nn 个整数,表示序列 AA 的元素。

【输出格式】 输出一行字符串 YESNO

【样例输入】

5 6
23 2 4 6 7

【样例输出】

YES

(解释:子段 [2, 4] 的和为 6,是 6 的倍数;或者 [23, 2, 4, 6, 7] 的和为 42,是 6 的倍数)

【数据范围】

  • 1n1051 \le n \le 10^5
  • 0ai1090 \le a_i \le 10^9
  • 1k23111 \le k \le 2^{31} - 1
  • 保证所有前缀和在 64 位整数范围内。

二、 思路提示 (Hint)

不要急着看完全解,先思考以下几个方向:

  1. 区间和怎么求快? 当我们需要频繁获取一段连续区间的和 i=lrai\sum_{i=l}^r a_i 时,通常会用到什么预处理技巧?
  2. 整除的转化: 如果一个数 XXkk 的倍数,那么 X%kX \% k 等于多少?
  3. 同余的性质: 如果两个数 SxS_xSyS_y 满足 SxSy(modk)S_x \equiv S_y \pmod k(即它们除以 kk 的余数相同),那么它们的差 SxSyS_x - S_ykk 有什么关系?
  4. 数据结构选择: 我们需要快速查找某个“余数”以前是否出现过,以及出现的位置,应该用数组还是哈希表?

三、 预备知识点

解决这道题,你需要熟练掌握以下工具:

  1. 前缀和 (Prefix Sum): 将区间求和复杂度从 O(n)O(n) 降为 O(1)O(1)
  2. 同余定理 (Congruence modulo):a%k==b%ka \% k == b \% k,则 (ab)(a - b)kk 的倍数。
  3. 哈希表 (Hash Map): C++ STL 中的 std::unordered_map 或手写哈希,用于 O(1)O(1) 存取 {余数: 下标}。

四、 启发式教学与草稿纸推演

假设我们在教室里,面前是一块黑板(草稿纸)。来,跟着我的笔触一步步思考。

第一阶段:暴力法的瓶颈

学生: “教练,我可以枚举所有的子段,算出和,看是不是 kk 的倍数。” 教练: “好,我们算一下复杂度。 枚举左端点 ii,枚举右端点 jj,这一步是 O(n2)O(n^2)。如果是 n=105n=10^5,运算量达到 101010^{10},肯定 TLE(超时)。我们要找 O(n)O(n)O(nlogn)O(n \log n) 的做法。”

第二阶段:引入前缀和

教练: “我们定义前缀和数组 S[i]S[i] 表示前 ii 个数的和。那么子段 a[lr]a[l \dots r] 的和可以怎么表示?” 学生:Sum(l,r)=S[r]S[l1]Sum(l, r) = S[r] - S[l-1]。” 教练: “题目要求 Sum(l,r)Sum(l, r)kk 的倍数,翻译成数学公式就是:”

(S[r]S[l1])%k==0(S[r] - S[l-1]) \% k == 0

第三阶段:数学变形(关键一步!)

教练: “这个公式怎么变形?根据同余的性质,如果 ABA - B 能被 kk 整除,说明什么?” 学生: “说明 AABB 除以 kk 的余数相等!” 教练: “没错!所以在草稿纸上写下这个核心结论:”

S[r]%k==S[l1]%kS[r] \% k == S[l-1] \% k

教练: “这意味着,我们不需要枚举两个端点。我们只需要遍历一遍数组,计算当前的 S[i]%kS[i] \% k。如果这个余数之前出现过,假设上次出现的下标是 jj(也就是 S[j]%kS[j] \% k 和当前一样),那么从 j+1j+1ii 这一段的和就是 kk 的倍数。”

第四阶段:细节处理(长度限制与初始化)

教练: “题目有个坑:子段长度至少为 2。这意味着什么?” 学生: “意味着 ij2i - j \ge 2?或者说 iijj 不能相邻?” 教练: “准确说是当前下标 ii 与记录的那个下标 last_indexlast\_index 的距离要大于等于 2。所以我们在哈希表里存什么?存 {余数: 第一次出现的下标}。如果是第二次遇到这个余数,不要更新下标(保留最左边的,这样区间才最长,肯定满足条件),而是计算距离。”

教练: “还有一个特例。如果 S[i]%k==0S[i] \% k == 0 怎么办?比如序列 [6, 6], k=6k=6S[1]=6,6%6=0S[1]=6, 6\%6=0。” 学生: “这直接就是倍数了呀。” 教练: “为了统一逻辑,我们假设有一个虚拟的前缀和 S[1]=0S[-1] = 0,它的下标是 1-1。 所以在程序一开始,我们就在哈希表里塞入:{0: -1}。 这样如果算出余数是 0,距离就是 i(1)=i+1i - (-1) = i + 1,逻辑就通了。”


五、 复杂度分析

  1. 时间复杂度:

    • 我们只遍历数组一次。
    • 在哈希表中查找和插入的操作平均是 O(1)O(1) 的。
    • 总时间复杂度:O(n)O(n)
    • 优化建议: 虽然 std::mapO(nlogn)O(n \log n),在这题也能过,但用 std::unordered_map 或手写哈希表更优。
  2. 空间复杂度:

    • 最坏情况下,每个前缀和的余数都不同。
    • 哈希表最多存储 min(n,k)\min(n, k) 个键值对。
    • 总空间复杂度:O(min(n,k))O(\min(n, k))

六、 读题理解关键词

做这类题时,要在题目中圈出以下关键词:

  1. "Continuous / 连续" \rightarrow 暗示可以使用前缀和、滑动窗口或单调队列。
  2. "Multiple of k / k的倍数" \rightarrow 暗示涉及模运算(Modulo)、同余性质。
  3. "Size at least 2 / 长度至少为2" \rightarrow 这是边界条件的坑,决定了判断逻辑(ipos2i - pos \ge 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;
}

希望这份讲义能帮你彻底掌握这道题的精髓。记住,看到“区间和倍数”,立刻想到“前缀和余数相同”! 加油!