3 条题解

  • 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
        内存
        125MiB
        难度
        3
        标签
        递交数
        0
        已通过
        0
        上传者