5 条题解

  • 0
    @ 2026-3-16 12:00:27

    测评测试数据

    太棒了!自己动手造数据是深入理解算法边界情况的最好方法。这也是出题人(Setter)和验题人(Tester)的必备技能。

    为了能精准区分不同的算法时间复杂度,并考察各种常见的“坑点”(如全负数、全部为0等),我为你设计了以下 10 个测试点的生成策略:

    • 测试点 1-4N2000N \le 2000。专门用来给 O(n2)O(n^2) 算法放行,送出 40% 的部分分。包含了单元素、纯随机、全负数等。
    • 测试点 5-10105N2×10510^5 \le N \le 2\times 10^5。用来卡掉 O(n2)O(n^2),只有 O(n)O(n) 能过。
      • 包含“全正数”、“全负数”(专门卡把 ans 初始化为 0 的萌新)、“大量正负交替”、“极大极小值突变”等边界情况。
      • 生成文件时每行数字用空格隔开,最大文件大约在 1.2MB 左右,完全符合你 < 2MB 的理想要求。

    以下是完整且可独立运行的数据生成器(C++14 标准):

    #include <iostream>
    #include <fstream>
    #include <vector>
    #include <random>
    #include <string>
    
    using namespace std;
    
    // 使用基于 Mersenne Twister 的高质量随机数生成器
    mt19937 rng(1337); // 固定种子保证每次生成的数据一致,方便复现调试
    
    // 获取指定范围内的随机整数 [L, R]
    int get_rand(int L, int R) {
        uniform_int_distribution<int> dist(L, R);
        return dist(rng);
    }
    
    // 标准的满分算法:用于计算输出文件的标准答案 O(n)
    long long solve_standard(const vector<int>& a) {
        long long ans = -1e18;
        long long current_sum = -1e18;
        for (int x : a) {
            if (current_sum > 0) {
                current_sum += x;
            } else {
                current_sum = x;
            }
            if (current_sum > ans) {
                ans = current_sum;
            }
        }
        return ans;
    }
    
    // 生成单个测试点的核心函数
    void generate_testcase(int id, int n, int type) {
        vector<int> a(n);
        int MAX_VAL = 10000;
    
        for (int i = 0; i < n; ++i) {
            if (type == 0) {
                // 类型0:完全随机[-10000, 10000]
                a[i] = get_rand(-MAX_VAL, MAX_VAL);
            } else if (type == 1) {
                // 类型1:全负数 [-10000, -1] (卡 ans = 0 的错解)
                a[i] = get_rand(-MAX_VAL, -1);
            } else if (type == 2) {
                // 类型2:全正数 [1, 10000]
                a[i] = get_rand(1, MAX_VAL);
            } else if (type == 3) {
                // 类型3:大量负数,极少量的正数穿插其中 (极易引诱错误贪心)
                int chance = get_rand(1, 100);
                if (chance <= 5) a[i] = get_rand(1, MAX_VAL);
                else a[i] = get_rand(-MAX_VAL, -1);
            } else if (type == 4) {
                // 类型4:正负交替波动
                if (i % 2 == 0) a[i] = get_rand(1, MAX_VAL);
                else a[i] = get_rand(-MAX_VAL, -1);
            } else if (type == 5) {
                // 类型5:含有大量的 0
                int chance = get_rand(1, 100);
                if (chance <= 70) a[i] = 0;
                else a[i] = get_rand(-MAX_VAL, MAX_VAL);
            }
        }
    
        // 写入输入文件 x.in
        string in_filename = to_string(id) + ".in";
        ofstream fin(in_filename);
        fin << n << "\n";
        for (int i = 0; i < n; ++i) {
            fin << a[i];
            if (i != n - 1) fin << " ";
        }
        fin << "\n";
        fin.close();
    
        // 计算标准答案并写入输出文件 x.out
        long long ans = solve_standard(a);
        string out_filename = to_string(id) + ".out";
        ofstream fout(out_filename);
        fout << ans << "\n";
        fout.close();
    
        cout << "Testcase " << id << " generated: " 
             << in_filename << " and " << out_filename 
             << " | N = " << n << " | Ans = " << ans << "\n";
    }
    
    int main() {
        // 优化终端输出速度
        ios::sync_with_stdio(false);
        cin.tie(nullptr);
    
        cout << "Start generating test data..." << "\n";
    
        // 测试点 1-4:满足 N <= 2000 (给 O(n^2) 放行 40分)
        generate_testcase(1, 1, 0);       // 边界测试:N=1
        generate_testcase(2, 50, 0);      // 小数据随机
        generate_testcase(3, 2000, 1);    // 2000规模全负数
        generate_testcase(4, 2000, 3);    // 2000规模少量正数
    
        // 测试点 5-10:满足 10^5 <= N <= 2*10^5 (必须 O(n) 满分)
        generate_testcase(5, 100000, 2);  // 全正数
        generate_testcase(6, 150000, 5);  // 大量0存在
        generate_testcase(7, 200000, 4);  // 正负极限交替
        generate_testcase(8, 200000, 1);  // 满规模全负数陷阱
        generate_testcase(9, 200000, 3);  // 满规模绝望沙海中找绿洲(大量负数带点正数)
        generate_testcase(10, 200000, 0); // 满规模纯随机数据
    
        cout << "All test data generated successfully!\n";
        return 0;
    }
    

    💡 教练的数据生成思路解析与提醒:

    1. 防止越界与 Exception 保护:在计算答案 ans 时,最大的可能结果是全正数情况,2×105×10000=2×1092 \times 10^5 \times 10000 = 2 \times 10^9。虽然这刚好还在 32 位有符号整数范围(大约 21.4 亿)内,但为了绝对的安全,生成器内置的 solve_standard 依然采用了 long long 承载计算过程,有效避免了越界造成的意外。
    2. 随机数生成的坑:老派的 rand() 在 Windows 下最大值通常只有 32767,且低位随机性极差。这里我采用了 NOIP 新时代常用的基于梅森旋转算法std::mt19937 引擎。此外,设定了固定种子 (1337),这样做的好处是无论你执行多少次代码,生成的 x.in 数据一定是相同的,非常方便后续如果发现数据出了问题,进行回溯调试。
    3. 性能极致释放
      • 此生成器生成一个 N=200000N=200000 的文件并且跑出答案,大约只需要 10ms 左右,极快!
      • 没有使用任何递归逻辑,天然避开了递归层数爆炸导致的 Stack Overflow 风险。
      • 输入输出使用了 ofstream 而非 freopen 来避免对全局流的影响,使我们在跑批量的 for 循环造数据时能一口气无缝完成。
    • 0
      @ 2026-3-16 11:55:24

      动态规划解法


      你好!我是你的OI教练。这道题是动态规划(DP)乃至贪心思想中最经典的NOI入门题之一,接下来我们用启发式的思维来一步步拆解它。

      🧠 启发式思考过程

      第一步:最直接的想法是什么? 如果我们要找一个最大子段和,最“暴力”的方法是枚举这段序列的“起点”和“终点”,然后求和。 教练提问: 这样做时间复杂度是多少呢? 思考: 枚举起点和终点需要 O(n2)O(n^2),再求和可能要 O(n3)O(n^3)(哪怕用前缀和优化也只能降到 O(n2)O(n^2))。 教练追问: 看看数据规模 n2×105n \le 2 \times 10^5,如果用 O(n2)O(n^2) 的算法,大概要执行 4×10104 \times 10^{10} 次操作,肯定会超时(TLE)。我们必须找一个 O(n)O(n) 的解法!

      第二步:换个角度看问题(定义状态) 与其同时盯着一段区间的“起点”和“终点”,不如我们只盯着“终点”看! 教练提问: 假设现在我们处理到了第 ii 个数字 aia_i,并且我们只关心以 aia_i 为结尾的连续子段。你能想到它和前一个状态的关系吗?

      第三步:分情况讨论(状态转移方程) 假设我们已经算出了“以第 i1i-1 个数字结尾的最大子段和”,我们叫它 dpi1dp_{i-1}。现在要把当前的数 aia_i 接上去。 教练引导: aia_i 面前只有两条路:

      1. 和前面的队伍接轨: 前提是前面的队伍是个“神队友”。也就是说,只要 dpi1dp_{i-1} 是个正数(大于 0),加上它肯定能让现在的和变得更大。
      2. 自己另起炉灶: 如果前面的队伍是个“猪队友”(dpi1dp_{i-1} 小于等于 0),带上他们只会拖累现在的和!那我们还不如丢下前面的,从 aia_i 自己重新开始算起。 核心结论: 以第 ii 个数结尾的最大和 dpi=max(dpi1+ai,ai)dp_i = \max(dp_{i-1} + a_i, a_i)

      第四步:全局最优解在哪里? 教练提问: 我们算出了所有“以各个位置结尾”的局部最大和,那整个序列的最终答案是谁? 思考: 最大子段可以在序列的任何位置结束,所以我们需要在整个遍历的过程中,用一个全局变量 ans 把历史上出现过的最大的 dpidp_i 给记录下来。

      第五步:空间能不能再省一点? 教练点拨: 我们看上面的转移方程,计算 dpidp_i 的时候,仅仅用到了 dpi1dp_{i-1},之前的 dp1dp_1dpi2dp_{i-2} 完全用不上了。 因此,在 C++ 编码时,我们连数组都不用开,只用一个变量 dp 记录前一步的值就行了,这样空间复杂度就从 O(n)O(n) 优化到了极致的 O(1)O(1)


      💻 算法伪代码流程图 (符合 C++14 NOI标准逻辑)

      我们使用流程图来表示上述动态规划的核心代码逻辑。 (注:为了避免 Mermaid 引擎解析冲突报错,图表节点中以中文描述替代了大于、小于、冒号等特殊编程符号)

      graph TD
          Start((开始程序)) --> InputN(读入序列长度 n)
          InputN --> Init(初始化 全局最大值 ans 为负无穷极小值, 局部和 dp 为 0, 计数器 i 为 1)
          
          Init --> LoopCondition{i 小于等于 n 吗}
          
          LoopCondition -- 是 --> ReadA(读入当前数字 a)
          ReadA --> CheckDP{前置局部和 dp 大于 0 吗}
          
          CheckDP -- 是 --> AddDP(将 a 加到 dp 上, 延续队伍)
          CheckDP -- 否 --> ResetDP(将 dp 赋值为 a, 另起炉灶)
          
          AddDP --> CheckAns{当前的 dp 大于 ans 吗}
          ResetDP --> CheckAns
          
          CheckAns -- 是 --> UpdateAns(更新 ans 为当前的 dp)
          CheckAns -- 否 --> NextI(计数器 i 加上 1)
          
          UpdateAns --> NextI
          NextI --> LoopCondition
          
          LoopCondition -- 否 --> OutputAns(输出最终的全局最大值 ans)
          OutputAns --> End((结束程序))
      

      🎯 教练总结

      在这道题中,我们通过固定子段终点将复杂的区间问题简化成了线性问题,然后用贪心/动态规划的思想果断决定是“延续前一段”还是“重新开始”。只需跑一遍 O(n)O(n) 的循环即可完美 AC(Accept)本题。根据这个流程图,你可以尝试动手用 C++ 把真正的代码实现出来了!


      你好!教练很高兴看到你有探究到底的精神。

      在OI(信息学奥赛)中,我们解决问题通常遵循**“先想暴力,再想优化”**的原则。下面我将带你从最简单粗暴的方法开始,一步步打怪升级,最终写出能拿到满分且时间和空间都达到最优的代码。

      所有的代码均严格遵循 NOIP C++14 竞赛标准,均可独立运行。


      版本一:最朴素的暴力枚举 (O(n3)O(n^3)) —— 新手村级别

      思考过程: 既然是求“连续一段”的和,最直白的想法就是:我把所有的“起点”和所有的“终点”都试一遍。确定了起点 ii 和终点 jj 后,我再用一个循环把 aia_iaja_j 的数字加起来,最后取一个最大值。

      #include <iostream>
      #include <vector>
      #include <algorithm>
      
      using namespace std;
      
      const long long INF = 1e18; // 使用极小值作为初始答案,防止序列全为负数时出错
      
      int main() {
          // 优化输入输出流,NOIP竞赛常规操作
          ios::sync_with_stdio(false);
          cin.tie(nullptr);
      
          int n;
          if (!(cin >> n)) return 0;
      
          vector<int> a(n + 1);
          for (int i = 1; i <= n; ++i) {
              cin >> a[i];
          }
      
          long long ans = -INF; // 关键点:一定要初始化为极小值!不能是0!
      
          // 枚举起点 i
          for (int i = 1; i <= n; ++i) {
              // 枚举终点 j
              for (int j = i; j <= n; ++j) {
                  long long current_sum = 0;
                  // 计算从 i 到 j 的和
                  for (int k = i; k <= j; ++k) {
                      current_sum += a[k];
                  }
                  // 更新全局最大值
                  ans = max(ans, current_sum);
              }
          }
      
          cout << ans << "\n";
          return 0;
      }
      
      • 时间复杂度O(n3)O(n^3)。三层嵌套循环。当 n=2000n=2000 时,计算量约 10910^9,必然超时(只能拿到 0-20 分)。
      • 空间复杂度O(n)O(n)。需要存储原序列。
      • 优化建议:最内层的循环(从 ii 加到 jj)做了很多重复工作。我们算 iijj 的和时,其实就是在 iij1j-1 的和的基础上加了一个 aja_j。为什么要重新跑一遍循环呢?这就引出了版本二。

      版本二:前缀和优化 (O(n2)O(n^2)) —— 进阶级别

      思考过程: 为了快速求出任意区间 [i,j][i, j] 的和,我们可以使用前缀和 (Prefix Sum) 技巧。预处理出前 ii 个数的和 sum[i],那么区间 [i,j][i, j] 的和就可以在 O(1)O(1) 的时间内通过 sum[j] - sum[i-1] 瞬间算出来。

      #include <iostream>
      #include <vector>
      #include <algorithm>
      
      using namespace std;
      
      const long long INF = 1e18;
      
      int main() {
          ios::sync_with_stdio(false);
          cin.tie(nullptr);
      
          int n;
          if (!(cin >> n)) return 0;
      
          vector<long long> sum(n + 1, 0); // 前缀和数组
          for (int i = 1; i <= n; ++i) {
              int x;
              cin >> x;
              sum[i] = sum[i - 1] + x; // 关键点:一边读入,一边计算前缀和
          }
      
          long long ans = -INF;
      
          // 枚举起点 i
          for (int i = 1; i <= n; ++i) {
              // 枚举终点 j
              for (int j = i; j <= n; ++j) {
                  // 利用前缀和 O(1) 求区间和
                  long long current_sum = sum[j] - sum[i - 1];
                  ans = max(ans, current_sum);
              }
          }
      
          cout << ans << "\n";
          return 0;
      }
      
      • 时间复杂度O(n2)O(n^2)。去掉了最内层的循环。当 n=2000n=2000 时(40%的数据),计算量约 2×1062 \times 10^6,可以在时限内通过,拿到 40 分。
      • 空间复杂度O(n)O(n)。存储前缀和数组。
      • 优化建议:对于 n=2×105n=2\times 10^5 的全量数据,n2n^2 的操作次数达到 4×10104 \times 10^{10},依然会 TLE(超时)。我们要思考:能不能不枚举起点?我们能不能在扫描一遍数组 O(n)O(n) 的过程中就直接得出答案?这就引出了版本三。

      版本三:一维动态规划 (O(n)O(n)) —— 满分级别

      思考过程: 正如我们在启发式推导中所讲,我们定义状态 dp[i] 为:以第 i 个数结尾的最大子段和。 转移方程:dp[i] = max(dp[i-1] + a[i], a[i])。 无论如何,最大子段一定是以序列中某一个数结尾的,所以我们在所有的 dp[i] 中挑一个最大值,就是我们要的最终答案。

      #include <iostream>
      #include <vector>
      #include <algorithm>
      
      using namespace std;
      
      const long long INF = 1e18;
      
      int main() {
          ios::sync_with_stdio(false);
          cin.tie(nullptr);
      
          int n;
          if (!(cin >> n)) return 0;
      
          vector<int> a(n + 1);
          for (int i = 1; i <= n; ++i) {
              cin >> a[i];
          }
      
          // dp[i] 表示以第 i 个数结尾的最大子段和
          vector<long long> dp(n + 1, 0); 
          long long ans = -INF;
      
          for (int i = 1; i <= n; ++i) {
              // 易错点提示:如果前一个子段和 dp[i-1] 是正数,加上它肯定更优;如果是负数,不如自己单飞。
              dp[i] = max((long long)a[i], dp[i - 1] + a[i]); 
              
              // 全局最大值可能在任意位置取得,每次都要更新
              ans = max(ans, dp[i]);
          }
      
          cout << ans << "\n";
          return 0;
      }
      
      • 时间复杂度O(n)O(n)。只遍历了数组一次,可以完美通过 n=2×105n=2\times 10^5 的数据,斩获 100 分。
      • 空间复杂度O(n)O(n)。开辟了原数组 a 和状态数组 dp
      • 优化建议:在真正的赛场上,能写出这个版本已经足够满分了。但作为一个追求极致的 Coder,我们可以发现 dp[i] 每次更新只依赖 dp[i-1],不需要保存前面所有的状态。同时,输入数据 a[i] 也可以边读边处理,不需要全部存下来。能否把空间压榨到极限?

      版本四:滚动变量优化动态规划 (O(n)O(n) 时间, O(1)O(1) 空间) —— 极致最优解

      思考过程: 这其实也是很多人所说的“贪心法”。我们只用一个变量 current_sum 来代替整个 dp 数组。读入一个数就处理一个数,处理完就丢弃。

      #include <iostream>
      #include <algorithm>
      
      using namespace std;
      
      const long long INF = 1e18;
      
      int main() {
          // 必加:关闭流同步,在处理大规模数据读入时防止超时
          ios::sync_with_stdio(false);
          cin.tie(nullptr);
      
          int n;
          if (!(cin >> n)) return 0;
      
          long long ans = -INF;         // 记录全局最大值
          long long current_sum = -INF; // 记录以“上一个数”结尾的最大子段和(等效于上一版的 dp[i-1])
      
          for (int i = 1; i <= n; ++i) {
              long long a;
              cin >> a; // 边读边处理,完全抛弃了数组
      
              // 核心转移逻辑:
              // 如果 current_sum < 0,说明前面的和是累赘,不如从现在的 a 开始重新累加
              // 此时 current_sum + a 必定小于 a,max 自动会选择 a
              if (current_sum > 0) {
                  current_sum += a;
              } else {
                  current_sum = a; // 前面的是负资产,抛弃!
              }
      
              // 更新历史上出现过的最大值
              if (current_sum > ans) {
                  ans = current_sum;
              }
          }
      
          cout << ans << "\n";
          return 0;
      }
      
      • 时间复杂度O(n)O(n)。最完美的线性扫描。
      • 空间复杂度O(1)O(1)。只开了常数级别(几个)变量,没有使用任何数组。这是本题理论上的极致最优解!

      教练的贴心叮嘱(避坑指南):

      1. 初始化陷阱:最大值 ans 千万千万不要初始化为 0!如果序列全是负数(比如 -4 -2 -9),最大子段和应该是 -2。如果初始为 0,答案就会输出 0 导致错误。一定要初始化为极小的负数!
      2. 数据类型:本题单个数字 104\le 10^4N2×105N \le 2\times 10^5,极限情况下的和是 2×1092\times 10^9,正好卡在 C++ int (最大 23112.14×1092^{31}-1 \approx 2.14 \times 10^9) 的上限边缘。养成好习惯,涉及到求和的时候,果断开 long long 防爆 int
      • 0
        @ 2025-12-5 0:07:26

        解释kadane算法

        当然,很乐意为你详细解释Kadane 算法。这是一种非常巧妙、高效的算法,专门用来解决最大子段和 (Maximum Subarray Sum) 问题。

        我们可以从三个层面来理解它:直观的比喻算法的步骤正确性的证明


        1. 直观的比喻:一场“接力赛”

        想象一下,数组中的每个数字代表赛道上一段路程的收益(正数)或成本(负数)。你要组织一支接力队,从起点跑到终点,目标是找到一段连续的路程,使得跑完这段路的总收益最大。

        • current_max (当前选手的成绩)

          • 一个选手从某个起点开始跑。他手里的“成绩”就是他跑过的路段的收益总和。
          • 每跑过一段 a[i],他的成绩就更新为 成绩 + a[i]
        • global_max (历史最佳成绩)

          • 这是记录板,记下所有选手跑出的历史最佳成绩。
          • 每当一个选手跑完一段路,我们都把他的当前成绩和记录板上的最佳成绩比较一下,更新记录板。
        • 核心决策:何时“交接棒”?

          • 一个选手跑着跑着,如果发现自己的累计成绩变成了负数,这意味着他跑的这段路是“亏本”的。
          • 关键点:如果下一个选手从他这里接棒,就相当于背负了他的负资产!这显然不划算。
          • 所以,明智的策略是:一旦某个选手的累计成绩变为负数,就让他立刻放弃,下一个选手从全新的起点(当前位置的下一段)开始跑,成绩从 0 重新计算。

        Kadane 算法的本质,就是模拟这场接力赛。 current_max 就是当前选手的成绩,global_max 就是记录板。当 current_max 变为负数时,就相当于交接棒,current_max 被重置。


        2. 算法的步骤

        Kadane 算法非常简洁,只需要两个变量和一次遍历。

        输入:一个整数数组 A,长度为 n输出A 的最大子段和。

        变量:

        1. global_max (全局最大和):用来存储最终结果。初始化为数组的第一个元素 A[0] 或一个极小的数(比如负无穷)。
        2. current_max (当前子段和):用来累加当前正在考虑的连续子段的和。初始化为 0A[0]

        流程:

        // 初始化
        global_max = A[0]
        current_max = A[0]
        
        // 从第二个元素开始遍历
        for i from 1 to n-1:
            // 决策:是继续累加,还是从当前元素 a[i] 重新开始?
            // 这等价于 DP 方程:dp[i] = max(a[i], dp[i-1] + a[i])
            current_max = max(A[i], current_max + A[i])
            
            // 更新全局最大值
            global_max = max(global_max, current_max)
        
        // 返回结果
        return global_max
        

        另一种等价的贪心写法(更符合“接力赛”比喻):

        // 初始化
        global_max = -infinity
        current_max = 0
        
        // 遍历所有元素
        for x in A:
            // 累加当前元素
            current_max = current_max + x
            
            // 更新全局最大值
            if current_max > global_max:
                global_max = current_max
                
            // 决策:如果当前累加和变成了负数,就放弃它
            if current_max < 0:
                current_max = 0
        
        // 特殊情况处理:如果数组全是负数
        // 上述循环会得到 0,但正确答案是最大的那个负数。
        // 所以需要在循环结束后或初始化时处理。
        // 一个简单的处理方法是 global_max 初始化为 A[0],然后从 A[1] 开始循环。
        
        // 最健壮的写法是上面第一种,因为它天然处理了全负数的情况。
        

        以数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例,走一遍流程 (第一种写法):

        i A[i] current_max + A[i] current_max = max(A[i], ...) global_max = max(global_max, ...)
        0 -2 (init) -2 (init)
        1 -2 + 1 = -1 1 (因为 1 > -1) max(-2, 1) = 1
        2 -3 1 + (-3) = -2 -2 (因为 -2 > -3) max(1, -2) = 1
        3 4 -2 + 4 = 2 4 (因为 4 > 2) max(1, 4) = 4
        4 -1 4 + (-1) = 3 3 (因为 3 > -1) max(4, 3) = 4
        5 2 3 + 2 = 5 5 (因为 5 > 2) max(4, 5) = 5
        6 1 5 + 1 = 6 6 (因为 6 > 1) max(5, 6) = 6
        7 -5 6 + (-5) = 1 1 (因为 1 > -5) max(6, 1) = 6
        8 4 1 + 4 = 5 5 (因为 5 > 4) max(6, 5) = 6

        最终 global_max6


        3. 正确性的证明(为什么这个贪心是对的?)

        我们可以用反证法来理解。

        假设 Kadane 算法找到的最大子段和是 S,它对应的子数组是 A[i...j]。但假设存在一个真正的最大子段和 S',它对应的子数组是 A[p...q],并且 S' > S

        1. qj 的关系

          • Kadane 算法在遍历到 q 时,会计算出以 q 结尾的最大子段和,记为 current_max_q
          • global_max 变量会记录下遍历到 q 时所见过的所有 current_max 的最大值。
          • 真正的以 q 结尾的最大子段和就是 S' 本身 ( A[p...q] )。
          • Kadane 算法的 current_max_q 一定等于 S'。为什么?因为 current_max_q 是通过 max(A[q], current_max_{q-1} + A[q]) 递推得到的。如果 A[p...q-1] 的和是正的,那么 current_max_{q-1} 就会包含这部分和,current_max_q 也就等于 A[p...q] 的和。如果 A[p...q-1] 的和是负的,那么真正的最大子段和就不可能是从 p 开始,而应该是从 q 之后的某个位置开始,与假设矛盾。
          • 既然 current_max_q = S',那么 global_maxq 这一步之后,至少会是 S'。这与我们假设的 S' > S ( S 是最终的 global_max ) 矛盾。
        2. 核心思想

          • 算法的关键在于维护了这样一个循环不变式 (Loop Invariant):在遍历到第 i 个元素时,current_max 变量中存储的是A[i] 结尾的最大子段和
          • 全局最大子段和,必然是所有“以 A[i] 结尾的最大子段和”中的那个最大值。
          • 因此,我们只需要在每次计算完 current_max 后,用它来更新 global_max,就能保证在遍历结束后,global_max 就是我们要求的最终答案。

        总结:Kadane 算法通过一次遍历,巧妙地利用了“如果一个子段的和为负,它对后面的任何子段都只会产生负贡献”这一贪心性质,从而实现了 O(N)O(N) 时间复杂度和 O(1)O(1) 空间复杂度的求解。

        • 0
          @ 2025-12-5 0:04:29

          你好!很高兴你对这个问题进行了深入的思考。用双指针的思想来理解【最大子段和】的 O(N)O(N) 解法,是一个非常深刻且能直击算法本质的视角。

          我们平常学习的最大子段和解法,通常是两种:

          1. DP 解法dp[i] = max(a[i], dp[i-1] + a[i])
          2. 贪心解法:维护一个 current_sum,如果 current_sum 变成负数,就把它重置为 0。

          这两种解法都非常简洁高效,但有时会让人觉得有点“神奇”,不够直观。现在,我们用“动态调整起点的双指针”这个模型来重新剖析它,你会发现,它完美地解释了上述两种解法的内在逻辑。


          1. 暴力双指针:O(N2)O(N^2) 的起点

          首先,我们来思考这个问题的最暴力、最直观的解法。 一个连续子段由起点 l终点 r 决定。我们可以枚举所有的起点 l 和终点 r

          // O(N^3) 暴力
          long long max_sum = -INF;
          for (int l = 0; l < n; l++) {
              for (int r = l; r < n; r++) {
                  long long current_sum = 0;
                  for (int k = l; k <= r; k++) {
                      current_sum += a[k];
                  }
                  max_sum = max(max_sum, current_sum);
              }
          }
          

          用前缀和可以优化到 O(N2)O(N^2),但这仍然不够快。问题出在:我们固定了 l,然后移动 r,这种方式无法利用之前计算的信息来优化 l 的选择。


          2. O(N)O(N) 解法的双指针视角

          现在,我们换一个思路。我们只用一个指针 r 从左到右扫描,作为子段的终点。对于每一个固定的终点 r,我们都希望能快速找到一个最佳的起点 l ( lrl \le r ),使得 sum(l, r) 最大。

          核心问题:当 r 向右移动到 r+1 时,最佳起点 l 会如何变化?

          让我们在草稿纸上推演。 数组 a: [-2, 1, -3, 4, -1, 2, 1, -5, 4]

          1. r = 0 (a = -2)

            • 终点是 0。最佳起点 l 只能是 0。
            • 最大和是 sum(0, 0) = -2
          2. r = 1 (a = 1)

            • 终点是 1。起点 l 可以是 0 或 1。
            • sum(0, 1) = -2 + 1 = -1
            • sum(1, 1) = 1
            • 最佳起点是 l=1,最大和是 1
          3. r = 2 (a = -3)

            • 终点是 2。起点 l 可以是 0, 1, 2。
            • sum(0, 2) = -2 + 1 - 3 = -4
            • sum(1, 2) = 1 - 3 = -2
            • sum(2, 2) = -3
            • 最佳起点是 l=1,最大和是 -2

          等等!我们好像发现了什么规律!

          当我们计算以 r 为终点的最大和时,sum(l, r) = sum(l, r-1) + a[r]。 为了让 sum(l, r) 最大,我们其实就是在找一个 l,让 sum(l, r-1) 最大。 这不就是上一步 r-1 的结果吗?

          并不完全是。因为 sum(l, r-1)r-1 结尾的最大和,而我们需要的 l 是一个全局最佳起点。

          真正的突破口在这里:

          让我们定义 prefix_sum[i]a[0...i] 的和。 那么 sum(l, r) = prefix_sum[r] - prefix_sum[l-1]

          对于固定的终点 r,要最大化 sum(l, r),就等价于最小化 prefix_sum[l-1],其中 l-1 < r

          所以,当指针 r 从左到右扫描时,我们只需要在 r 的左边,实时维护一个到目前为止的最小前缀和 min_prefix_sum

          算法流程:

          1. 初始化 max_so_far = -INF (全局最大和)。
          2. 初始化 min_prefix_sum = 0 (代表一个空前缀,即 l=0prefix_sum[-1] 的情况)。
          3. 初始化 current_prefix_sum = 0
          4. 用指针 r0 遍历到 n-1: a. current_prefix_sum += a[r]。 b. 以 r 为终点的最大子段和就是 current_prefix_sum - min_prefix_sum。 c. 用这个值更新 max_so_far。 d. 用 current_prefix_sum 更新 min_prefix_sum

          这个算法就是 Kadane 算法的“前缀和”版本!

          3. 连接到经典的贪心/DP解法

          现在,我们把上面的“前缀和”版本稍微变形一下。

          current_max_ending_at_r 表示以 r 结尾的最大子段和。 current_max_ending_at_r = current_prefix_sum - min_prefix_sum_before_r

          那么,current_max_ending_at_r+1 是什么呢? current_max_ending_at_r+1 = current_prefix_sum_at_r+1 - min_prefix_sum_before_r+1 = (current_prefix_sum_at_r + a[r+1]) - min(min_prefix_sum_before_r, current_prefix_sum_at_r)

          这个公式太复杂了。我们回到更直观的定义。

          current_sum 表示以当前 r 结尾的最大子段和。当 r 移动到 r+1 时:

          • 新的“以 r+1 结尾的最大子段和”有两种可能:
            1. 自立门户:就只包含 a[r+1] 自己。
            2. 加入组织:在“以 r 结尾的最大子段和”后面,再接上 a[r+1]
          • 我们取这两者中较大的那个:new_current_sum = max(a[r+1], current_sum + a[r+1])

          这,就是DP方程 dp[i] = max(a[i], dp[i-1] + a[i])完美解释

          那贪心解法呢? current_sum = max(a[i], current_sum + a[i]) 如果 current_sum + a[i]a[i] 自己还小,说明什么?说明 current_sum 一定是负数! 所以,这个 max 判断,等价于:

          • current_sum += a[i]
          • if (current_sum < a[i]) { current_sum = a[i] } 这又等价于我们贪心解法里的:
          • current_sum += a[i]
          • 如果之前的和是负的,current_sum 就被重置了。

          回到双指针的视角:

          • 右指针 r:就是我们循环的 i,它一直在前进,探索新的终点。
          • 左指针 l:它在哪里?它隐藏current_sum 这个变量里!
            • current_sum 保持为正并累加时,意味着最佳起点 l 保持不变
            • current_sum 因为累加了一个负数而即将变为负数时,我们发现 current_sum + a[i] < a[i],于是我们执行 current_sum = a[i]
            • 这个操作,在双指针的视角下,就相当于我们放弃了旧的起点 l,把新的最佳起点 l 移动到了 i

          总结

          • 右指针 r:固定,从左到右扫描,作为区间的终点。
          • 左指针 l动态调整。它指向当前最优子段的起点。
            • 只要以 r 结尾的累加和 current_sum 还是正的,就说明从 lr 这个子段是有“正贡献”的,值得保留。所以 l 不动。
            • 一旦 current_sum 变成了负数,就说明从 lr 这段已经成了“负资产”,对后面的累加只会起到拖累作用。我们必须果断地**“断臂求生”**,把这段负资产扔掉,将新的起点 l 移到 r+1 的位置,从一个新的、干净的状态开始。

          这个“当累加和为负时就重置起点”的策略,就是最大子段和 O(N)O(N) 解法的精髓,也是 Kadane 算法的核心思想。它完美地体现了双指针技巧中“根据单调性(这里是和的正负)来移动指针以维护最优解”的理念。

          • 0
            @ 2025-12-5 0:04:08

            P1115 最大子段和 难度:🟡 普及/提高-

            一句话题意:求一个序列中连续子段的最大和。

            教练点评:虽然这题的标准解法是 O(N)的简单 DP 或贪心,但它体现了双指针“维护最优性”的思想。你可以思考一下,能否用一个指针 r 扫描,另一个指针 l 动态地指向“最佳起点”?这能帮你理解Kadane 算法的本质。

            • 1

            信息

            ID
            4894
            时间
            1000ms
            内存
            128MiB
            难度
            3
            标签
            递交数
            4
            已通过
            1
            上传者