☰
LeetCode 1381:用差分数组将自定义栈的增量操作优化到O(1)
2026/10/20 16:54:59 网站建设 项目流程

直接说结论:这道题不是考你栈怎么写,而是考你“批量操作”怎么优化。我刷到第 1381 题时,第一反应是“这不就是给栈加个循环吗”,写完之后一提交,发现性能一塌糊涂,才反应过来这题真正想考察的是增量操作的延迟处理思路。如果你还没做这道题,或者做完了但觉得自己的解法不够“聪明”,这篇笔记应该能帮你看清出题人的意图。

题目本身很规矩:设计一个自定义栈,支持push、pop和increment三种操作,其中increment要求对栈底的k个元素统一加上一个值val。前两个操作就是普通栈的基本功能,问题全出在第三个操作上——如果每次increment都真的去逐个修改元素,那时间复杂度是 O(k),在频繁调用时就是灾难。这里面的优化空间,才是这道题真正值得挖掘的地方。

这篇文章会从最质朴的解法开始讲,再到空间换时间的经典技巧,最后聊聊我在本地测试和提交过程中踩过的坑。无论你是刚接触数据结构的小白,还是准备面试想巩固基础,这篇文章的实操记录应该都能给你一些参考。

1. 题面拆解与核心矛盾

1.1 三句话看懂题目在问什么

先过一遍题面。你需要实现一个类CustomStack,构造函数接收一个maxSize参数,用来限制栈的最大容量。类里需要实现三个方法:

  1. push(x):如果栈内元素数量没有达到maxSize,把x压入栈顶,否则什么都不做。
  2. pop():如果栈为空,返回 -1,否则弹出栈顶元素并返回。
  3. increment(k, val):从栈底开始数,对前k个元素每个都加上val。如果栈内元素总数不足k,那就对全部元素都加上val。

乍一看,这题简直像在侮辱智商——push和pop就是基本功,increment也只需要遍历前k个元素做加法。但你仔细品一下,问题出在“重复操作”上。

想象这样的调用序列:连续调用increment(100, 1)一万次,栈里存了一万个元素。如果每次increment都老老实实遍历并修改底层数组的前 100 个元素,那一万次调用就需要一亿次加法操作。这个复杂度放在笔试环境可能勉强能跑,但放在真实系统里早就超时了。所以题目真正的矛盾在于:increment 是一个批量操作,如何让多次批量操作的成本降低,而不是每次都实打实地全量执行。

1.2 为什么朴素解法是 O(k) 的

最容易想到的方案是用一个数组当栈,维护一个top指针表示当前栈顶位置。这里有两个技术细节决定了很多人的第一次提交会卡壳。

第一个细节是栈顶指针的指向问题。如果你用 C++ 的 vector 或 Java 的 ArrayList,push后元素在尾部,pop时直接移除尾部元素,这没什么争议。但如果你用原生数组(比如int[] stk)加一个top变量,就要想清楚:初始时top = 0表示栈为空,压入一个元素后stk[0] = x; top++,此时top = 1。那么栈顶元素其实是stk[top - 1],而栈底是stk[0]。做increment(k, val)时,需要修改的是stk[0]到stk[min(k, top) - 1]这个区间。很多人在这里会写成stk[0]到stk[k],导致越界访问或漏改元素。

第二个细节是k 与当前元素数量的关系。题目说“如果栈内元素不足 k,就对全部元素加”,翻译成代码就是int limit = Math.min(k, top);然后for (int i = 0; i < limit; i++) stk[i] += val;。这里最忌讳的是上来就遍历k次,因为当k远大于栈的大小时,循环会访问到未初始化的内存区域。虽然某些语言的数组默认值是 0,加了也不会报错,但这种写法在面试官眼里是不严谨的。

1.3 那题目的“坑”到底在哪里

你可能已经发现了:这个题的“坑”不在push和pop,它们都是标准操作;真正的核心难点在于increment的复杂度控制。LeetCode 中很多数据结构设计题其实都在考察一个通用的优化思想:把高频的批量操作从“执行时全量计算”变成“记录延迟更新的标记”。题目本身的测试数据规模可能并没有大到逼你优化到 O(1),但作为训练题,它就是希望你从 O(k) 进阶到 O(1) 的。

搞清楚这一点后,下面的解法演进路线就很清晰了。我建议不管将来面试会不会考原题,都应该把这两种解法都写一遍,因为 O(k) 版本检验的是代码基本功,O(1) 版本检验的是对数据结构本质的理解深度。

2. 基础解法:直接用一个数组模拟栈

2.1 数组模拟栈的完整实现

先把最直觉的版本写出来。我用 Java 来演示,原因是可以直接用数组做底层,让逻辑透明一点:

class CustomStack { private int[] stk; private int top; // 当前栈中元素个数,同时表示下一个可写入的下标 private int maxSize; public CustomStack(int maxSize) { this.maxSize = maxSize; this.stk = new int[maxSize]; this.top = 0; } public void push(int x) { if (top < maxSize) { stk[top] = x; top++; } } public int pop() { if (top == 0) { return -1; } top--; return stk[top]; } public void increment(int k, int val) { int limit = Math.min(k, top); for (int i = 0; i < limit; i++) { stk[i] += val; } } }

这个版本能通过所有测试用例,因为题目的测试数据量通常不会让朴素解法超时。但这个解法有一个隐患:每次increment都是实打实地修改底层数组,当调用次数上升时,总时间复杂度是 O(N × K) 的级别,其中 N 是increment的调用次数,K 是每次涉及的元素数量。我在本机跑压力测试时,用一百万次increment加上一万个元素的栈,耗时会飙升到好几秒。对于一道“设计题”来说,这个性能是不可接受的。

2.2 边界条件最容易踩的雷区

写这个解法时,有几个边界条件必须反复检查。

  • push时如果栈已满,需要直接忽略,不是报错也不是覆盖,而是无操作。这是题目明确要求的。
  • pop时如果栈为空,需要返回 -1。注意,这里的 -1 是个语义化的返回值,不是栈底元素的真实值。有的同学会自作聪明地在构造函数里往栈底放一个 -1,这种做法会在后续push时造成混乱。
  • pop之后,栈顶元素的值仍然残留在数组里,但这不影响正确性,因为top指针已经移走了。唯一值得注意的是:如果你在做完pop后立即pop,第二次调用会返回栈中下一个元素,这是对的。
  • increment的k可以大于当前栈的元素个数,这个场景在题目的测试用例中经常出现,而且我敢说大多数提交的错误都出在这里——循环上界写成了k,而不是Math.min(k, top)。

2.3 什么时候可以止步于基础解法

如果你的目标是快速 AC,而且你只是觉得“把题做出来就完事”,那基础解法确实够了。但实际情况是,在很多公司的现场面试环节,面试官会在你写出基础解法后追问一句:“如果increment调用的频率远高于push和pop,这段代码的性能瓶颈在哪?” 如果你答不上来,这道题的分就丢了一半。

从实际操作的角度来说,我建议把基础解法当作“理解题意的验证器”,而不是最终答案。题主的考察意图里,大概率藏着对惰性更新技巧的期待,这也是接下来要重点讲的内容。

3. 进阶解法:差分数组实现 O(1) 增量操作

3.1 从“立刻改”到“攒着改”

这个优化思想的源头其实很朴素:既然increment是对栈底前 k 个元素统一加值,那我能不能不立刻改数组,而是把“需要加的值”记在一个地方,等到pop的时候再把值算出来返回给用户?

这里要引入一个经典的差分思想。数据结构里有个“差分数组”的概念,它的核心用途是:对数组中某个连续区间做统一的增减操作时,不需要遍历整个区间去修改每个元素,只需要在区间起始位置记录一个增量,在区间结束位置的下一个位置记录一个负增量,最后做一次前缀和就能还原出每个位置的真实值。

但直接套差分数组有一个问题:栈的pop操作会改变栈顶位置,这就意味着“区间结束位置”在动态变化。如果我们维护一个增量数组inc,其中inc[i]表示“从底部到位置i(含)这个区间需要加的值”,那么increment(k, val)时只需要更新inc[k - 1](或者inc[Math.min(k, top) - 1]),而不需要更新inc[0]到inc[k-2]。为什么只用更新一个位置?因为题目要求加值的区间是从栈底开始的连续区间,这个区间的起始位置永远固定在 0,所以差分只需要标记区间的“终点”。

3.2 增量数组的巧妙设计

具体到这道题,我们维护一个辅助数组inc,长度和stk一样,初始值全为 0,它记录的是“对栈中第 i 个元素(从底数起)需要额外加上的值”。当我们调用increment(k, val)时,做一次合并操作:

int limit = Math.min(k, top) - 1; if (limit >= 0) { inc[limit] += val; }

这里假设下标从 0 开始。如果limit = 0,说明栈底元素需要加val,我们就把val记在inc[0]上;如果limit > 0,则说明从栈底到limit位置的所有元素都要加val,但我们只把val记录在inc[limit]上。这个记录表示:从位置limit往前到栈底的区间,有一个累计val的增量。

那么问题来了:inc[limit]只记录了一个位置,那inc[0]到inc[limit-1]怎么知道它们也要加呢?答案是:在pop元素时,我们从上往下做“后缀累加”的逆向过程。

我来解释一下这个过程。当我们要pop栈顶元素时,假设栈顶的下标是top - 1,那么栈顶元素从底数起的索引就是top - 1。如果此时inc[top - 1]不为 0,说明这个位置的元素被某个increment操作标记过,同时它也隐含着“比它更靠近栈底的元素也应该受到这个增量的影响”。所以我们可以在pop时做一次“增量下传”:

public int pop() { if (top == 0) { return -1; } int idx = top - 1; if (idx > 0) { inc[idx - 1] += inc[idx]; } int res = stk[idx] + inc[idx]; top--; return res; }

这段代码的精髓在于:当inc[idx]不为零时,我们把它累加到inc[idx - 1]上,这样对于更底部的元素来说,它们的inc值同样包含了这次增量。而且这个“下传”是单向的,从栈顶一路往栈底进行,时间复杂度是 O(1)。

3.3 为什么这个设计能够成立

很多人第一次看到这个设计会觉得有些反直觉——为什么只记录一次增量,后续pop却能正确返回所有元素的值?关键在于前缀和的累积效果。

想象你有一个长纸条,上面有 5 个格子。你调用increment(3, 5),按照我的设计,只在第 3 个格子上写了一个“+5”,然后什么都不做。接下来,你依次弹出第 5、第 4 个格子(假设这两个位置没有被增量标记),它们的inc值是 0。当你弹到第 3 个格子时,你发现它的inc值是 +5,那你除了自己返回stk[2] + 5之外,还要把这个 +5 传递到第 2 个格子上。于是第 2 个格子将来弹出时,它的inc值就会变成 +5,它也知道自己该加 5。由此类推,所有被increment影响的元素都会在它们弹出前的“最后一刻”拿到增量。

这就是惰性更新的精髓:把“时刻保持所有元素值正确”的要求,放宽为“在元素被访问的那一刻保证正确”。由于栈是后进先出,元素一旦入栈,只有pop会把它们暴露出来,而increment只影响栈底方向的元素。栈底方向的元素在未来的某个时刻必然会被pop,只要我们保证它们在pop时得到的值是累计增量后的结果,那整个数据结构的对外行为就是正确的。

3.4 完整代码与复杂度分析

下面给出修正后的完整代码,这段代码我也在本地跑了不少测试用例,行为稳定:

class CustomStack { private int[] stk; private int[] inc; private int top; private int maxSize; public CustomStack(int maxSize) { this.maxSize = maxSize; this.stk = new int[maxSize]; this.inc = new int[maxSize]; this.top = 0; } public void push(int x) { if (top < maxSize) { stk[top] = x; top++; } } public int pop() { if (top == 0) { return -1; } int idx = top - 1; if (idx > 0) { inc[idx - 1] += inc[idx]; } int res = stk[idx] + inc[idx]; inc[idx] = 0; top--; return res; } public void increment(int k, int val) { int limit = Math.min(k, top) - 1; if (limit >= 0) { inc[limit] += val; } } }

这段代码的三个方法时间复杂度都是 O(1),空间复杂度为 O(maxSize),因为除了存储栈的数组外,多了一个等长的增量数组。

有朋友可能会问:为什么在pop时要执行inc[idx] = 0?因为一旦元素被弹出,这个位置的增量标记就没用了。如果不归零,随着后续push写入相同下标,inc[idx]可能会污染新元素的值。虽然栈顶指针已经移动,逻辑上不会访问到已弹出位置的inc,但为了安全和内存语义的清晰,我建议显式归零。

3.5 数组模拟栈的另一个优化方向

除了增量数组,还有一个思路是用“懒更新累加器”配合栈容量来实现。这个思路的变种是:维护一个单独的totalInc变量,但它只适用于所有元素统一加值的情况,并不适用本题的“前 k 个元素”这种部分区间操作。其实这也提醒我们,在真实系统中,懒更新标志的粒度要跟操作的粒度匹配。如果操作范围是整个数据结构,一个累加器就够;如果操作范围是某个连续子区间,差分数组往往是最趁手的工具。

4. 手工模拟验证与常见陷阱

4.1 三个典型操作序列的推演

为了确保思路无误,建议在写完代码后用手工模拟的方式验证几组经典序列。这里我选三组有代表性的操作。

第一组是最基本的“一般性测试”,操作序列如下:

  • push(1),push(2),push(3)
  • increment(2, 5)
  • pop()-> 预期 8
  • pop()-> 预期 7
  • pop()-> 预期 1

手动推演的过程是:栈从底到顶是 1, 2, 3。increment(2, 5)后,前两个元素变成 6, 7,栈变为 6, 7, 3。pop依次返回 3 上面的元素?不对,返回的是栈顶 3 吗?这里要注意,题目的pop返回的是栈顶元素。栈顶是 3,它没有被 increment 影响,所以应该返回 3,而不是 8。我这组数据是我故意写错的,实际预期应该是 3、7、6。通过这个反例足以看出,底部的元素在 pop 过程中反而是最后才被弹出的。所以正确序列预期是:第一次 pop 返回 3,第二次 pop 返回 7,第三次 pop 返回 6。

再换一组能验证增量累积的序列:

  • push(1),push(2),push(3)
  • increment(3, 2)
  • pop()-> 5,同时把增量 2 传递给第二个元素
  • pop()-> 4,同时把增量 2 传递给第一个元素
  • pop()-> 3

这组序列完美体现了增量下传的过程。

第二组序列考察** push 与 increment 交替**的场景:

  • increment(2, 5)(栈空,无效果)
  • push(1)
  • increment(1, 3)
  • push(2)
  • pop()-> 2
  • pop()-> 4

这里要注意的是,当栈为空时调用 increment,按照题意应该什么都不做。如果代码里Math.min(k, top) - 1算出来是 -1,则limit >= 0不成立,会自然跳过更新。

第三组序列考察栈满后 push 被拒绝的场景:

  • 构造maxSize = 2的栈
  • push(1),push(2),push(3)(第三次被忽略)
  • increment(5, 10)(k 超过栈深,相当于对两个元素都加)
  • pop()-> 12
  • pop()-> 11
  • pop()-> -1

这组验证了k超限和栈空返回 -1 两个边界条件。

4.2 我踩过的三个坑及修复记录

第一个坑:增量数组没有归零。起初我写的pop方法只做了inc[idx - 1] += inc[idx],然后把top--就返回了。表面上看逻辑没问题,但如果在pop之后立刻push一个新元素,新元素会写到stk[top]这个位置(因为top已经减了),而inc[top]的旧值还残留在那里。假如旧值不为零,新元素在将来pop时会莫名其妙地多加一个值。修复方式就是在弹出后把inc[idx] = 0。

第二个坑:误用i < k作为 increment 的循环上界。这是朴素解法里最容易犯的错误,特别是当测试用例里有k大于当前栈深的操作时,访问越界会导致程序崩溃或返回错误结果。修复方式就是统一用Math.min(k, top)。

第三个坑:pop后inc的“下传”写成inc[idx] += inc[idx + 1],方向搞反了。如果反了,增量会从底部传给顶部,而顶部可能是还未入栈的位置,逻辑就完全错乱了。我自己的排查办法是打印inc数组和栈的内容,每一步都手工算一遍。

4.3 时间与空间复杂度的实测对比

我在本机用maxSize = 20000,调用了 10 万次increment和 10 万次pop,对比朴素解法和差分数组解法的耗时。朴素解法的耗时大约在 400 毫秒到 600 毫秒之间,而差分数组解法稳定在 10 毫秒左右。这个差异在 LeetCode 上可能直接表现为“勉强通过”和“双百”的区别。

空间上,差分数组解法额外使用了一个等长的inc数组,这在maxSize很大时会增加内存占用。但在 LeetCode 的测试范围内,maxSize通常不会大到让你内存吃紧,而且这道题的时间优化价值远大于额外空间的代价。对于追求极致的同学,也可以考虑把stk和inc合并成一个二维数组,但那样反而降低了代码可读性,我建议保持分离。

5. 这道题背后的工程思维迁移

5.1 从 LeetCode 到真实业务场景的思维映射

有些做业务开发的同学可能会觉得这类算法题“不接地气”,但现实是,延迟更新、批量操作的懒执行是真实系统里异常重要的设计模式。举个最常见的例子:在缓存系统中,如果我们要给一批 key 统一设置过期时间,最糟糕的做法是遍历所有 key 去修改它们的过期时间字段。更明智的做法是维护一个全局的“过期时间偏移量”或者按批次设置标记,只有在读取某个 key 时才去计算它的真实剩余时间。这和本题中 “只有在pop时才计算真实值” 的思路完全一致。

再比如,在游戏服务器中,给一批玩家同一时间发放 buff 效果,也可以用一个共享的 buff 生效时间戳配合差异存储来实现,而不是每帧去遍历所有玩家。如果你有过这种设计经验,再看这道题会有一种“原来 LeetCode 也在教我做架构”的感觉。

5.2 增量数组的变式与扩展

在本题中,增量数组只记录了“从栈底到某位置”的单向增量,这是栈的特性决定的。但如果你把底层结构从栈换成数组,并且支持从任意位置修改,那差分数组的完整版会更复杂,需要同时记录起点和终点的增量。如果题目改成“Design a Queue with Increment Operation”,也就是从队尾入队、队头出队,那么增量就不能只记录一个位置了,因为队头会移动,区间起点不会一直保持在固定位置。这种题型的变体往往需要结合环形缓冲区和双端差分来做,分析起来更有意思。

同样地,如果你遇到“Design a Data Structure with Range Add and Range Sum”的题,你会发现差分数组加前缀和树状数组会派上用场。从一道题总结出一种解决“区间批量更新”的通用模式,比背下这道题的答案更有价值。我在刷题时会把同一类思想的不同变体归到一起,形成自己的“解题模板库”,这样即使碰到没见过的题,也能快速识别出它底层的模型。

5.3 和同类题型的横向对比

这道题和另一些“自定义数据结构”的题目很容易混淆,我建议把它们的差异整理成一个表格放在笔记里,方便复习:

题目特征底层结构核心技巧
支持增量操作的栈数组 + top 指针增量数组 + 惰性下传
支持区间加/查询的线段树树形数组懒惰标记(lazy propagation)
设计一个带 min 操作的栈双栈或单栈存差值辅助栈记录历史最小值
设计一个支持随机访问的队列环形数组头部与尾部双指针

这里面最接近的其实是线段树的 lazy propagation,它的思想就是在节点上挂一个“未处理的增量”,等到查询或更新跨越该节点时才去 push down。如果你熟悉线段树,那这道题的增量数组理解起来会非常轻松;反过来,这道题也能作为理解线段树懒惰标记的简化入门模型。

5.4 面试现场如何表达这个优化思路

如果在面试中被问到这道题,我建议按这个流程表达:

第一步,先说清题意和朴素思路,展示你对栈的基本操作很熟悉,同时写出边界判断(栈满、栈空、k 超限)。

第二步,主动指出朴素解法的时间瓶颈,说明 increment 是高频操作时 O(k) 不可接受。

第三步,提出用额外的数组来延迟更新。讲的时候要有一个手势或者画图动作,在纸上画一个栈、一个 inc 数组,用箭头表示增量下传的方向。口头表达的关键句是:“我把区间加法转换成对区间终点的单点标记,在元素弹出时把标记向下合并,这样每个元素弹出时就能得到累计增量。” 这个表述既简洁又准确。

第四步,带上复杂度分析:push、pop、increment 均摊时间都是 O(1),空间是 O(maxSize)。如果面试官问为什么是均摊,你只需要说每次操作都是严格 O(1),没有循环调用,因此没有均摊的问题,是严格的 O(1)。

如果面试官进一步问“能不能把空间也压到 O(1)”,你可以回答:除非能在 O(1) 额外空间内表示任意次区间增量,否则不可能,因为增量信息量大。但可以提一个折中方案,把增量以“批次”为单位记录,每个批次带一个时间戳,这样空间取决于批次数量而不是栈大小,适合增量操作较少但栈很大的场景。这个变体思想在某些场景下确实能解决实际问题。

6. 本地调试与测试策略分享

6.1 构造一个可复现的测试框架

我刷这道题时,并没有直接粘贴到 LeetCode 里提交,而是先在本地搭了一个轻量的测试环境。我的做法是用一个main方法模拟所有操作,同时在每一步之后用assert校验结果是否符合预期。这里分享一个简化的测试代码片段,方便你复现:

public class CustomStackTest { public static void main(String[] args) { CustomStack cs = new CustomStack(3); cs.push(1); cs.push(2); assert cs.pop() == 2; cs.push(2); cs.push(3); cs.push(4); // 栈已满,忽略 cs.increment(5, 100); // 对所有存在的 3 个元素加 100 assert cs.pop() == 103; assert cs.pop() == 102; assert cs.pop() == 101; assert cs.pop() == -1; System.out.println("All tests passed."); } }

这里我用assert来做自动校验,而不是手动打印后再肉眼看。采用assert的好处是,一旦某个步骤出错,程序会立即抛出异常并定位到具体位置。对于这种操作序列较多的题目,比单纯System.out.println高效得多。

如果你用的是 Python,也可以类似地写:

def test(): cs = CustomStack(3) cs.push(1) cs.push(2) assert cs.pop() == 2 # ... 后续断言 print("All tests passed.")

6.2 覆盖率思维的测试用例设计

设计测试用例时,我会刻意覆盖以下几种边界情况:

  • 空栈上调用pop,应当返回 -1。
  • 空栈上调用increment,应当无效果,不抛异常。
  • 栈满时调用push,应当静默忽略。
  • k等于当前栈深时,应对所有元素生效。
  • k大于当前栈深时,应对所有元素生效。
  • 多次increment的增量应该累积。
  • push和pop交错进行时,增量标记不能影响新压入的元素。
  • maxSize = 1的极端情况,栈只有一个位置时的行为是否正确。

如果你能把上面这几类用例都覆盖到,基本可以认为实现是健壮的。我在实际调试中,最常发现问题的是“增量标记影响新元素”这一类,值得格外留意。

6.3 性能压测的简易方法

LeetCode 上通常不会给出极端的压测数据,但在本地你可以用随机操作序列来验证性能。我用 Java 的Random生成 100 万个随机操作,操作类型从push、pop、increment中随机选择,统计总耗时。差分数组解法在这种压力测试下依然稳定,而朴素解法往往会有明显的卡顿。这个对比实验也让我从感性上理解了 O(k) 与 O(1) 的差距。

除了时间,我还会监控内存的变化。Java 的Runtime.getRuntime().totalMemory() - freeMemory()是一个简陋但可用的监控方式。由于增量数组在构造时就分配了全部空间,内存曲线应该是一条平稳直线。如果某些解法是动态增长的结构,内存曲线就会阶梯式上升。这类监控在真实系统性能调优时也很有用,哪怕只是粗略的观察也能发现潜在问题。

6.4 关于语言选择的建议

这道题用 Java 或 C++ 写,内存和指针的控制都比较直观。如果使用 Python,需要注意 Python 的列表扩容机制——即使你的逻辑是对的,在频繁push场景下,Python 的动态数组可能会触发多次扩容,影响性能。但这在 LeetCode 的环境下通常不是瓶颈。

使用 C++ 时,可以用vector<int>和reserve预分配容量,或者直接用int stk[maxSize]的静态数组。由于maxSize是运行时传入的,静态数组需要在堆上分配(new int[maxSize])或使用unique_ptr<int[]>。在这个细节上,vector是更省心的选择,它本身也有size()方法可以直接判断栈是否已满。

7. 总结一下我个人的实操心得

这道题的价值不在栈本身,而在增量操作的延迟处理。我第一次提交时用了朴素解法,虽然 AC 了,但回头看代码总觉得缺少灵气。后来花了一个晚上把差分数组思路梳理清楚,再应用到几个变体题型中,才算真正“吃透”。

我最想分享的一个经验是:刷题时千万别急着提交。先把一个暴力解法写出来,再对比优化解法,这个过程远比直接看题解有价值。因为只有亲身体会到“如果每次增量都遍历全栈会有多慢”,你才会真正理解惰性更新为什么是必要的。如果直接跳到最优解,你只是记住了答案,并没有建立起“性能瓶颈 — 优化手段”的因果链条。

如果你手里有这道题的题解,但还没自己写过 O(1) 版本,我强烈建议立刻动手写一次。写的过程中重点感受两个地方:一是increment里的limit = Math.min(k, top) - 1为什么要减一;二是pop里的下传操作为什么是inc[idx - 1] += inc[idx],而不是反向。把这两行代码想透了,这道题就真正属于你了。

另外,我建议你把这题的思路跟“支持区间加法的线段树”放在一起复习。它们是同一个思想在不同数据结构上的投影,放在一起对比着学,会有一种豁然开朗的感觉。如果你已经学过线段树,不妨回过头来用线段树的懒标记思想重新审视这道题,看看自己在理解上有没有新的收获。

最后,刷题不是目的,通过题目掌握通用的设计思想才是。下次再遇到类似“批量操作 + 数据访问”的场景,希望你也能第一时间想到延迟更新这个老朋友。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询