数组基础与常用操作

差分数组技巧

它是前缀和的镜像

前缀和解决「反复查询区间和」。 差分数组解决反过来的那件事:反复修改一整个区间,最后才看结果。

  • 前缀和:预处理一次 → 每次查询 O(1)
  • 差分数组:每次修改 O(1) → 最后还原一次

⭐ 两者是互逆运算:对差分数组求前缀和,就得回原数组。 这不是巧合,是定义决定的。

定义

// diff[i] = nums[i] - nums[i-1],diff[0] = nums[0]
function buildDiff(nums) {
  const diff = new Array(nums.length);
  diff[0] = nums[0];
  for (let i = 1; i < nums.length; i++) diff[i] = nums[i] - nums[i - 1];
  return diff;
}

// 还原:对 diff 求前缀和
function restore(diff) {
  const res = new Array(diff.length);
  res[0] = diff[0];
  for (let i = 1; i < diff.length; i++) res[i] = res[i - 1] + diff[i];
  return res;
}

nums = [8,5,9,6,1] → diff = [8,-3,4,-3,-5] → 还原回 [8,5,9,6,1]。

⭐ 区间加减为什么只动两个位置

要给闭区间 [i, j] 里每个元素都加 val:

function increment(diff, i, j, val) {
  diff[i] += val;
  if (j + 1 < diff.length) diff[j + 1] -= val;
}

就两行。 想清楚为什么,这个技巧就算掌握了:

diff[i] += val 意味着「从下标 i 开始,后面每一个还原值都多了 val」—— 因为还原是累加,i 处的增量会一路传到末尾。

但我们只想影响到 j 为止,所以在 j+1 处减回来,把这个增量掐断。

原数组      [8, 5, 9, 6, 1]
给 [1,3] 加 3
diff        [8, -3, 4, -3, -5]
diff[1]+=3  [8,  0, 4, -3, -5]
diff[4]-=3  [8,  0, 4, -3, -8]
还原        [8,  8, 12, 9, 1]
                ↑------↑ 这三个各加了 3,两头没动

🚨 if (j + 1 < diff.length) 不能省。区间一直延伸到数组末尾时, j+1 越界;JavaScript 里 diff[5] -= 3 不报错, 它会给数组挂一个下标为 5 的元素,length 悄悄变成 6。实测:

const diff = [8, -3, 4, -3, -5];   // length 5
diff[5] -= 3;
// → length 变成 6,且 diff[5] === NaN(undefined - 3)
还原结果   [8, 5, 9, 6, 1, NaN]
           └──── 前 5 个全对 ────┘  └ 多出来的这一位是 NaN

⚠️ 症状有两层,第一层比第二层更难查:

  1. 长度多了一位 —— 前面所有元素都是对的,很容易被当成别处的问题
  2. 多出来的那一位是 NaN,不是 -3 —— 因为 undefined - 3 = NaN

⭐ 「前面所有元素都是对的」不是随口一说。把 if 去掉,跑 20000 组随机数据 (长度 115、每组 16 次区间加减)与朴素做法对拍:

长度比正确答案长了一位     14603 组
【前 n 个元素】出错          0 组      ← 前面真的一个不差

⭐ 第 2 点其实是好事:NaN 比一个看着正常的数字显眼得多。 真正危险的是你只检查前几个元素,那样两层症状都看不见。 📌 判据:这类 bug 要拿「长度」当第一道检查,别只比内容。

复杂度对比

k 次区间修改、最后读一次结果:

朴素做法 差分数组
每次修改 O(区间长度) O(1)
最后还原 0 O(n)
总计 O(Σ区间长度) O(k + n)

🚨 朴素那一栏常被写成 O(k·n),那其实假设了「每次都改整个数组」。 区间是随机子区间时平均长度约 n/3,成本只有 k·n/3。 这个区别直接决定交叉点在哪,所以下面每张表都会写明区间口径。

⭐ 先看与机器、与实现都无关的操作数(n = 100000):

k       Σ区间长度(随机子区间)   Σ区间长度(整区间)   差分 2k+n
1              17,883               100,000          100,002
10            303,181             1,000,000          100,020
100         2,515,377            10,000,000          100,200
1000       25,294,919           100,000,000          102,000

⭐ k=1000 那行就是「数量级差距」的来源:整区间口径下朴素 1 亿次、差分 10.2 万次。

⚠️ 但操作数打平的地方,不是耗时打平的地方

按操作数算,交叉点该在 k·n = 2k+n → k ≈ 1(整区间)。实测差着五倍:

整区间 [0, n-1]                        随机子区间
k     朴素      差分     谁快          k     朴素      差分     谁快
1     0.05     0.30    朴素 5.80×     1     0.04     0.25    朴素 6.85×
4     0.20     0.29    朴素 1.44×    10     0.12     0.28    朴素 2.30×
5     0.27     0.27    持平          20     0.24     0.28    朴素 1.17×
6     0.30     0.28    差分 1.07×    25     0.28     0.28    持平
10    0.51     0.30    差分 1.68×    30     0.37     0.28    差分 1.31×
30    1.53     0.29    差分 5.29×    40     0.52     0.29    差分 1.79×

🚨 交叉点整区间是个位数,随机子区间是几十 —— 相差三到五倍,全看区间怎么取。

⚠️ 具体到哪个 k,这台机器上量不准。把整个扫描重复 5 轮(每轮换随机种子):

轮次   整区间   随机子区间
 1     6~7      15~20
 2     5~6      25~30
 3     5~6      20~25
 4     5~6      20~25
 5     5~6      15~20

⭐ 整区间那一列还算稳(5 轮里 4 轮都是 56); 随机子区间那一列自己就在 1530 之间摆 —— 因为 Σ区间长度本身随种子变。

🚨 更糟的是机器负载会把整张表一起抬起来。同一段代码、预热之后、 连测 5 批各 21 次取中位数,在负载 8.7 的机器上:

5 批的中位数   0.648 / 0.764 / 0.714 / 0.580 / 0.586 ms
批间极差       0.184 ms(相对 32%)

⚠️ 连「中位数」本身都在批与批之间漂 32% —— 而交叉点附近两边的差异只有 0.02~0.05 ms。在这种负载下,交叉点具体落在 5 还是 8,量出来的是负载不是算法。

🚨 而这个漂移量自己也不稳定:换个时间再测,同样的 5 批只漂 5%。 所以连「噪声有多大」都不能只量一次。相比之下单次测量的抖动一直稳定在 3.5~5 倍 (p10~p90 0.491.27,极值 0.481.82)—— 那才是可以写下来的量。

👉 所以这一节要记的不是数字,是三条关系:

  1. k 小的时候朴素更快(差分白付两趟 O(n))
  2. k 大的时候差分快出数量级(k=1000 时 30 倍以上)
  3. 交叉点随「区间平均有多长」右移 —— 区间越短,朴素越便宜,差分越晚才划算

📌 判据:报交叉点之前先量噪声下限。 噪声比要测的差异还大时, 就只报关系、不报数字。

📌 为什么操作数算不准:预热后单独量两边的单位成本 ——

朴素扫一遍全数组(含一次 slice)      0.114 ms
差分的固定开销(build + restore)     0.307 ms   ← 两趟 O(n) 外加两次数组分配
                                                  ≈ 朴素的 2.7 倍

⭐ 差分那两趟的单位成本比朴素那一趟贵近三倍,所以真实交叉点比「操作数打平」 预测的位置往右挪。操作数只能给量级,交叉点必须实测。

⭐ 差分那一列确实几乎是平的(中位数全在 0.25~0.30 ms), 因为它的成本主要是 build + restore 那两趟 O(n),与修改次数几乎无关。 这和前缀和那张表是镜像的。

🚨 这组数据里噪声比信号大,两个坑都踩过:

不预热的首次测量    朴素单遍 1.311 ms / 差分固定开销 1.729 ms
预热 50 次之后      朴素单遍 0.114 ms / 差分固定开销 0.307 ms   ← 差 11 倍
同一组参数连测 61 次(差分)  中位数 0.28   p10~p90 0.26~0.51   最小~最大 0.25~1.21

⚠️ 单次测量的抖动能到 4.8 倍,比这里要测的差异还大。 👉 所以:先预热,再取中位数,最后把区间口径写出来 —— 三样缺一样,量到的就是噪声。

典型题:航班预订

题意复述:有 n 个航班(编号 1~n),给一批预订记录 [first, last, seats] 表示从 first 到 last 每个航班都预订了 seats 个座位。 求每个航班总共被预订了多少座位。

一眼就是差分:区间整体加,最后统一读。

function corpFlightBookings(bookings, n) {
  const diff = new Array(n).fill(0);

  for (const [first, last, seats] of bookings) {
    diff[first - 1] += seats;                  // 🚨 题目是 1-indexed
    if (last < n) diff[last] -= seats;         // last 已经是 0-indexed 的 last+1
  }

  const res = new Array(n);
  res[0] = diff[0];
  for (let i = 1; i < n; i++) res[i] = res[i - 1] + diff[i];
  return res;
}

⚠️ 这题最容易错的不是差分本身,是下标基准。题目给的是 1-indexed, 数组是 0-indexed,于是 first-1 是起点、而 last(不减 1)恰好就是「终点后一位」。 两个减 1 只减了一个 —— 看起来不对称,但确实是对的。

「看起来对称」地把两个都减 1,实测 2000 组随机数据全错,100% (并穷举了 n=14、单条预订、权 13 的全部 60 种情形,没有一种能让错版侥幸对上):

n=6,预订 [[6,6,5],[3,6,9]]
正确        [0, 0, 9, 9, 9, 14]
两个都减 1  [0, 0, 9, 9, 9,  0]
                            ↑ 最后一个航班的座位全丢了

⭐ 症状很干净:每个区间的最后一个位置被漏掉(增量提前一位被掐断)。 单区间的 3000 个用例里,3000 个的差异都恰好是「下标 last-1 那一位少了 seats」 —— 一个别的位置都没动。看到「结果的尾巴不对」就该查这里。 📌 100% 出错也意味着随便造一个用例就能发现它 —— 和上面那些 「触发条件很窄」的 bug 正好相反,这类反而是最不危险的。 ⚠️ 对照一下这一篇里的两个 bug:漏掉 if (j+1 < len) 那个答案全对、只是长度多一位, corpFlightBookings 里减错下标那个每次都错、错得很显眼。 越安静的越危险,和出错率高低无关。

📌 遇到 1-indexed 的题,先把「起点」和「终点后一位」这两个量单独写出来再动手, 比在脑子里同时转换两个基准可靠得多。

判据

看到这三个特征同时出现,就是差分:

  1. 对连续区间整体加减
  2. 修改很多次
  3. 中途不需要读,最后统一出结果

📌 少任何一条都不划算。中途要读的话,每次读之前都得还原一遍,优势立刻没了 —— 那种情况要上线段树。

练习

勾选记录做过哪些,0 / 4 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。