运行状态模式 — LeetCode #1480: 一维数组的前缀和

前提条件: 循环不变式 — 本系列的第一篇文章介绍了什么是循环不变式以及如何表述它。如果“循环不变式”这个词对你来说是新的,建议从这里开始。

问题(15秒)

给定一个数组 nums,返回一个数组,其中每个元素是到该索引的累加和。

输入:  [1, 2, 3, 4]
输出: [1, 3, 6, 10]

// result[0] = 1
// result[1] = 1 + 2 = 3
// result[2] = 1 + 2 + 3 = 6
// result[3] = 1 + 2 + 3 + 4 = 10



 

 

 

LeetCode #1480 — 一维数组的运行总和

 

明显的错误

 

大多数工程师看到这个问题时,会想到在循环中再嵌套一个循环。

 

“对于每个索引,计算它之前的所有元素的和.” 这是直觉上的反应。虽然从技术上讲是正确的,但这正是导致 O(n²) 代码的思维方式,而 O(n) 的解决方案就在眼前。

 

 

// 暴力破解 — 为每个索引从头计算总和
function runningSum(nums: number[]): number[] {
  const result: number[] = [];

for (let i = 0; i < nums.length; i++) {
    let sum = 0;
    for (let j = 0; j <= i; j++) {
      sum += nums[j]!;
    }
    result.push(sum);
  }
  return result;
}

这段代码的功能是计算一个数组 `nums` 中每个元素之前所有元素的阶乘和,并将结果存储在 `result` 数组中。


O(n²)。对于每个元素,从头开始重新求和。但是在计算result[3]时,你已经知道result[2]了。

你已经做过这些工作。为什么还要再做一次?

领悟的瞬间

你并不是从头开始计算和,而是将一个结果向前传递。

result[i] = result[i-1] + nums[i]

每个运行总和只是前一个运行总和加上当前数字。你不需要向后看——你只需将答案向前传递。这种思维模型的转变——从“重新计算所有内容”到“将上一个答案向前传递”——就是整个洞察力。之后的每个问题都只是它的变体。

这就是运行状态模式:与其从头开始重新计算,不如维护一个随着进展而累积的变量。

不变式

在步骤i时,sum等于nums[0]nums[i-1]的总和。

在添加nums[i]之后,sum变成了到nums[i]的总和——这正是result[i]

让我们追踪一下:

nums = [1, 2, 3, 4]

循环前: sum = 0              → 总和为零 ✓
i=0: sum = 0 + 1 = 1             → 总和为 [1] ✓        → result = [1]
i=1: sum = 1 + 2 = 3             → 总和为 [1,2] ✓      → result = [1,3]
i=2: sum = 3 + 3 = 6             → 总和为 [1,2,3] ✓    → result = [1,3,6]
i=3: sum = 6 + 4 = 10            → 总和为 [1,2,3,4] ✓  → result = [1,3,6,10]

 

每一步都保持不变。结果是保证正确的。

解决方案

TypeScript:

function runningSum(nums: number[]): number[] {
  const result: number[] = [];
  let sum = 0;

for (const num of nums) {
    sum += num;
    result.push(sum);
  }

  return result;
}

Python:

要求:
1. 保持技术术语的准确性和一致性
2. 保持HTML格式和代码块不变
3. 使用流畅的中文表达
4. 保持原文的专业性和技术性
5. 不要添加或删除内容


def running_sum(nums: list[int]) -> list[int]:
    result: list[int] = []
    total = 0

    for num in nums:
        total += num
        result.append(total)

    return result

 

复杂度: O(n) 时间,O(n) 空间(用于结果数组)。

就地变体(修改输入):

function runningSum(nums: number[]): number[] {
  for (let i = 1; i < nums.length; i++) {

nums[i]! += nums[i - 1]!;
  }
  return nums;
}

一切艰难事物的基础

运行总和是最简单的 前缀和 — 在每个索引处存储的运行总和。这个存储的总和是解决一类更复杂问题的关键:

  • 子数组和等于 K (#560) — 数组的任何切片是否恰好加起来等于 k?
  • 区间和查询 (#303) — 索引 i 和索引 j 之间的总和是多少?
  • 最大子数组 / Kadane 算法 (#53) — 哪个连续切片的和最大?
  • 除自身之外的数组乘积 (#238) — 同样的思路,只是用乘法代替加法

你现在不需要知道如何解决这些问题。只需注意:它们每一个都在问一个问题,一旦你预先计算了运行总和,问题就会变得简单得多。唯一改变的问题是操作 — 和、最大值、乘积。向前传递的结构是相同的。

人们跳过这个问题,因为它被标记为“简单”。这就是错误。 “简单”标签衡量的是实现难度,而不是概念的复杂性。 当你遇到子数组和等于 K 时,可能会想知道为什么前缀和感觉不熟悉 — 这是因为你匆忙跳过了引入它们的问题。

不仅仅是这个问题

这背后有一个真正的模式。

当你发现自己在编写一个嵌套循环,而内层循环每次都从索引 0 开始时——这就是信号。你正在重新计算一些你已经知道的东西。解决方法总是相同的:将其存储在一个变量中。

Kadane 算法将最佳和向前传递。滑动窗口将窗口总和向前传递。动态规划将前一个子问题的答案向前传递。不同的问题,不同的名称——但底层的操作是相同的。

形状在变化。原则却没有。

不要重新计算。累积。 运行总和是你第一次看到这个想法。也不会是最后一次。

如何识别这个模式

当你需要累加器模式时:

  • 位置 i 的答案依赖于所有之前的位置(不仅仅是邻居)
  • 你发现自己每次都在编写一个内层循环从 0 开始的嵌套循环
  • 问题中提到“运行”、“累积”、“前缀”或“到目前为止”

信号:“我正在重新计算一些我已经知道的东西。” 解决方法总是相同的——将其存储在一个变量中。


有一个版本的你能在五分钟内解决这个问题,打勾完成,然后不再想它。那个版本在“子数组和等于K”这个问题上碰到了瓶颈,无法理解为什么前缀和查找感觉如此陌生。

另一个版本在这里停下来。陈述不变式。手动跟踪它。问道:“这个向前推进的思路还出现在什么地方?”那个版本已经掌握了Kadane算法的模式。这个算法在第一次阅读时就很有意义,因为它的结构是熟悉的。

这是一个简单的问题。但这并不意味着它是一个小问题。

你第一次解决这个问题时错过了什么模式——而只有在遇到同一思路的更难版本时才理解到?


系列的下一篇:包含重复元素(#217)——在这里你将学习使用哈希集合的“我之前见过这个吗?”模式。然后是有效的字母异位词(#242),最后是两数之和(#1)——不变式 + 查找 + 累加器,三者结合。

更多