news 2026/10/24 2:53:31

模拟算法实战:提莫攻击的中毒状态刷新与区间合并

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
模拟算法实战:提莫攻击的中毒状态刷新与区间合并

第一次在题单里看到“提莫攻击”这四个字时,我愣了一下,以为点进了游戏资讯。看清题目以后才发现,名字再花哨,考的还是非常经典的模拟功底:某个英雄按固定攻击时刻发起攻击,每次命中会让目标陷入持续若干秒的中毒状态,如果中毒还没结束又被命中,就把中毒结束时刻往后刷新。最后要算的,是目标在整个过程中一共有多少秒处于中毒状态。

这道题很适合用来练“模拟”这一算法思想。你别看它描述像游戏技能,剥掉外壳以后就是一个时间轴上的区间合并问题,而且因为攻击时间默认有序,解法可以压到一次遍历完成,时间复杂度 O(n),空间复杂度 O(1)。这篇文章我会用 C++ 从题意建模、公式推导、代码实现、边界测试一直讲到如何把这类“状态重置”的模型扩展到其他题目和真实业务场景,希望看完全文的人能完全吃透它,而不是背个答案就完事。

1. 题目到底在描述什么

1.1 先明确输入和输出

题目会给两个信息,一个是攻击时刻数组timeSeries,一个是中毒持续时间duration。

  • 数组里的每个数字timeSeries[i]表示第 i 次攻击发生的时刻,题目默认从小到大排列。
  • 每次攻击都会让目标从timeSeries[i]这一时刻开始中毒,持续duration秒。
  • 如果目标在中毒期间又被打中,旧的剩余中毒时间直接作废,重新从新攻击时刻计算满额的duration秒。
  • 要求输出目标最终处于中毒状态的总秒数。

有人会问:如果两次攻击之间隔了很久,中毒状态已经消失,那新攻击是不是重新开始一个段?是的,这就是“重置”的核心语义。所以问题最后不是把所有攻击各自贡献的时间简单相加,而是要处理区间之间产生的重叠。

1.2 亲手推两个小例子

先看最简单的情况:timeSeries = [1, 2],duration = 2。

第一次攻击在时刻 1 发生,目标从第 1 秒开始中毒,持续 2 秒,也就是直到第 3 秒结束。第二次攻击发生在时刻 2,此时目标还处于第一次的中毒状态中,所以剩余时间作废,重新从第 2 秒开始中毒 2 秒,最终到第 4 秒结束。两次攻击带来的中毒区间分别是 [1, 3) 和 [2, 4),合并以后的覆盖范围是 [1, 4),一共 3 秒。

再看timeSeries = [1, 4],duration = 2。第一次攻击让目标在 [1, 3) 中毒,第二次攻击发生在时刻 4,这时上一次中毒已经结束,所以第二次重新开启一段 [4, 6)。两个区间完全不重叠,答案是 3 + 3 = 6 秒。注意这里的端点写法,我用的是左闭右开区间,含义是“开始时刻算中毒,结束时刻已经恢复”,后面解析边界时还会再讨论。

1.3 把问题抽象成区间求并集

做过区间类题目的同学应该已经察觉,这道题的底层模型和“合并区间”非常像。每一次攻击都可以看作一个区间:

[t_i, t_i + duration)

如果区间之间有重叠,会被后面的攻击“重置”并往后延伸,最终答案就是所有这些区间合并后的总长度。

不妨把“中毒状态”想象成一条时间线上的连续段。当新的攻击到来时,只有两种情况:

  • 新攻击时刻落在当前中毒段内,那么当前段的右边界向右扩展。
  • 新攻击时刻落在当前中毒段之外,那么产生一个全新中毒段。

这个认知是整个模拟策略的基础。接下来所有代码和公式,都是在处理这个“当前中毒段”如何被更新。

2. 解法核心:比较相邻攻击时间差

2.1 千万不要真的逐秒模拟

最直接的模拟思路是准备一个布尔数组,把中毒的每一秒都标记成 true,最后数 true 的个数。数组规模小的时候没问题,可一旦攻击时间跨度大,比如最后一秒是 10 的 9 次方级别,这种逐秒模拟就会立刻超时,空间也完全扛不住。

正确的思路是:不去枚举每一个时间点,而去模拟中毒区间的“变化过程”。对一个真正在时间轴上游走的连续区间来说,我们只需要关心区间什么时候开始、什么时候结束,不需要关心中间每一秒到底发生了什么。

这个区别值得强调一下。很多新手以为模拟题就必须循环到每个时刻,其实“模拟”这个词的重点是“用程序忠实还原状态变化”,不是“无脑完整遍历底层细节”。一个策略如果能在状态发生跳变的关键节点上准确还原变化,那它就是合格的模拟,而且会高效得多。

2.2 任意相邻两次攻击的贡献公式

设当前处理的攻击时刻为timeSeries[i],前一次攻击时刻为timeSeries[i-1]。

因为整个数组按时间递增,所以两次攻击之间的时间差是:

diff = timeSeries[i] - timeSeries[i-1]

这个diff决定了两件事:上一次中毒是否已经结束,以及这一次攻击能给总中毒时间新增多少秒。

如果diff >= duration,说明上一次攻击造成的中毒在本次攻击发生之前已经完全结束。那么本次攻击会开启一段全新的中毒区间,给答案总额增加满额的duration秒。

如果diff < duration,说明本次攻击发生时目标还在上一次攻击造成的中毒状态中。上一次攻击已经把区间开到了timeSeries[i-1] + duration,本次攻击从更晚的时刻timeSeries[i]开始重新计时,只是把中毒结束时刻从timeSeries[i-1] + duration推到了timeSeries[i] + duration。对于总答案来说,新增的部分并不是duration,而是两个攻击时刻之间的间隔diff。

两种情况下,累计新增都可以统一写成:

新增量 = min(duration, timeSeries[i] - timeSeries[i-1])

第一次攻击没有前一攻击,它一定会开一个满额的中毒区间,所以单独加上duration即可。

2.3 用时间线验证一下公式

拿一个稍微复杂点的例子验证:timeSeries = [1, 2, 6],duration = 4。

  • 攻击时刻 1:前面没有数据,新增 4,累计 4。区间是 [1, 5)。
  • 攻击时刻 2:与上次差 1,小于 4,新增 1,累计 5。区间 [1, 5) 被刷新成 [2, 6),但整体覆盖仍是从 1 到 6,所以只多了中间这 1 秒。
  • 攻击时刻 6:与上次差 4,等于 duration,上一次中毒刚好结束,新增 4,累计 9。区间 [2, 6) 结束,新开 [6, 10)。

最终答案是 9。手动画一下时间线也知道,[1, 6) 有 5 秒,[6, 10) 有 4 秒,合计正好 9 秒。

这个例子非常有代表性,因为它出现了“相邻差正好等于 duration”的情况。此时两次中毒之间没有任何重叠,新攻击会作为新段的起点,所以贡献满额 duration。

3. C++ 的两种主流写法与取舍

3.1 写法一:直接用相邻攻击差叠加

先处理空数组,再把第一段 duration 加进去,之后从下标 1 开始遍历:

class Solution { public: int findPoisonedDuration(vector<int>& timeSeries, int duration) { int n = timeSeries.size(); if (n == 0) return 0; int total = duration; for (int i = 1; i < n; ++i) { total += min(duration, timeSeries[i] - timeSeries[i - 1]); } return total; } };

这段代码很短,但信息量很足。

  • total = duration是在处理第一次攻击。
  • min(duration, timeSeries[i] - timeSeries[i - 1])是核心公式。
  • timeSeries[i] - timeSeries[i - 1]不需要担心负数,因为题目保证了数组升序。

这种写法的好处是简单直观,面试时容易讲清楚“我维护的是每个新攻击带来的增量”,坏处是代码里看不到“当前中毒段”的实体,理解不透彻的话,容易在讨论为什么是diff而不是duration时卡壳。

3.2 写法二:显式维护中毒窗口边界

如果你更喜欢“模拟状态本身”的感觉,可以维护一个当前中毒窗口的起点start和终点end。

class Solution { public: int findPoisonedDuration(vector<int>& timeSeries, int duration) { int n = timeSeries.size(); if (n == 0) return 0; int total = 0; int start = timeSeries[0]; int end = timeSeries[0] + duration; for (int i = 1; i < n; ++i) { if (timeSeries[i] >= end) { // 上一次中毒已结束,先结算上一个窗口 total += end - start; // 开启新窗口 start = timeSeries[i]; end = timeSeries[i] + duration; } else { // 上一次中毒还在,把窗口右边界刷新 end = timeSeries[i] + duration; } } total += end - start; return total; } };

这段代码里的start和end就是“当前正在延续的中毒窗口”。每次攻击到来时:

  • 如果攻击时刻已经大于等于窗口结束时刻end,说明上一个窗口彻底结束,先把它的长度end - start结算进答案,然后开启新窗口。
  • 如果攻击时刻还在窗口内,说明需要重置剩余时间,直接把end改成timeSeries[i] + duration。

最后循环结束后还要再结算一次,把最后一个窗口的长度加上去。

这种写法更像在“模拟”一个状态变量。代码比写一多几行,但可读性更强,尤其适合给面试官讲“中毒过程中发生攻击时,我做了哪些状态变更”。

3.3 两种写法怎么互相验证

我建议把两种写法都掌握,因为它们是同一套逻辑的两种观察角度。

  • 写法一看的是“每个新攻击给答案新增多少”。
  • 写法二看的是“每个连续中毒窗口整体长度是多少”。

对应关系是:

  • 当相邻攻击差小于 duration 时,窗口方法会进入 else 分支,把end往后推;写法一的min函数会累加 diff。
  • 当相邻攻击差大于等于 duration 时,窗口方法会进入 if 分支,结算旧窗口并开启新窗口;写法一的min函数会累加满额 duration。

两种写法在复杂度和最终结果上完全一致。如果你在写完代码以后担心边界有问题,可以用写法二去验证写法一,或者反过来。刷题时多准备一条互相印证的思路,比单独记代码要可靠得多。

3.4 复杂度与前提条件

两种写法的复杂度都一样:

  • 时间复杂度 O(n),因为只需要扫一遍攻击时刻数组。
  • 空间复杂度 O(1),只用了几个整数变量。
  • 如果数组没有排序,需要先排序,复杂度变成 O(n log n)。这道题通常会明确说明数组按时间递增,面试时如果没说,最好和面试官确认一下,或者自己主动加一次排序保证不出错。

另外,如果题目给出的时间戳范围特别大,或者累计时长可能超过 32 位整数,可以在代码里把int total换成long long total。大部分情况下题目默认答案在 int 范围内,但养成用更宽类型接收累计结果的习惯不是坏事。

4. 写代码时最容易翻车的几个位置

4.1 空数组和单元素数组

空数组是第一个必须处理的边界。很多人在写法一中直接从timeSeries[1]开始遍历,没先检查timeSeries.empty(),程序直接访问非法下标崩溃。

单元素数组相对温和,total = duration之后直接返回答案即可,写法二也能因循环不执行而正确输出 duration。

我把边界处理放在代码靠前位置,是因为这类防御性操作越早判断越不容易漏。面试时写if (n == 0) return 0;属于基础素养,不会加分,但漏掉一定会减分。

4.2 duration 比整个时间跨度还大

举个极端例子:timeSeries = [0, 1, 2],duration = 100。很多人下意识会觉得每次攻击都能贡献 100 秒,于是写出 300。但实际答案不是 300,因为第二次攻击和第三次攻击发生时,中毒状态都没有结束,只是把结束时间不断往后推进。

第一次攻击:区间 [0, 100],贡献 100。第二次攻击在时刻 1,把区间刷新成 [1, 101],整体覆盖 [0, 101],相比之前只多了第 100 秒这 1 秒。第三次攻击在时刻 2,把区间刷新成 [2, 102],整体覆盖 [0, 102],又只多了 1 秒。最终答案是 102。

用公式写出来是:

total = 100 + min(100, 1) + min(100, 1) = 102

这个例子很好地说明了一个原则:杀掉一次攻击,重置的是“剩余时长”和“结束时刻”,但不代表每一次攻击都能让答案增加满额 duration。真正能把贡献拉满的前提,是两次攻击之间至少隔了完整 duration 的时间。

4.3 “秒”到到底怎么算

这是初学者最容易打架的地方。题目说中毒持续 2 秒,那攻击发生在第 1 秒,中毒范围到底是第 1、2 秒,还是第 1 秒结束就算毒完?

我建议统一用左闭右开区间来理解:

攻击发生在 t,中毒持续时间 duration 中毒区间就是 [t, t + duration)

在这个区间中,时刻 t 是中毒状态,时刻 t + duration 已经不再中毒。持续 2 秒的意思是:时刻 1 和时刻 2 算中毒,时刻 3 开始恢复。如果下次攻击刚好发生在时刻 3,那么两次中毒不重叠,各自贡献满额 duration。

回到代码,timeSeries[i] >= end判断为真时结算旧窗口并开新窗口,正是采用了这个语义。哪怕攻击时刻等于旧的结束时刻,也认为上一次中毒已经结束,因为右端点是开区间。

4.4 数组没有排序怎么办

虽然原题通常保证升序,但本着严谨的思路,还是提一下:如果你拿到的数据没有排序,必须先排序再使用相邻差公式。因为公式里默认timeSeries[i]是当前最新攻击,只有按时间顺序处理,窗口边界才是连续的。

排序只需要一行:

sort(timeSeries.begin(), timeSeries.end());

排序以后再去跑上面的代码,整体复杂度变成 O(n log n)。面试中如果面试官故意换了一个乱序数组,你不仅能发现,还能顺手解决,这会成为明显的加分项。

4.5 如果攻击时刻相同呢

有些扩展版本可能存在同一时刻连续攻击的情况。此时两次攻击时间差为 0,公式会得到新贡献 0,因为第二次攻击发生时还处于第一次中毒状态,结束时间也没变化。若重叠区域处理不当,代码很可能会重复计算一次 duration,所以遇到重复时间戳时要保持警惕。幸运的是原题的递增序列一般不会出现重复,但你自己扩展练习时可以考虑这个变体。

5. 把“模拟中毒窗口”扩展到更多场景

5.1 思路一:整道题退化为区间合并

如果不要求一次遍历,其实这道题还有更通用的做法:把每次攻击都构造成区间[timeSeries[i], timeSeries[i] + duration),然后执行一次区间合并,最后把合并后的区间长度加起来。

区间合并模板是算法面试常客,基本逻辑是:

  1. 把所有区间按起点排序。
  2. 用一个当前区间[curStart, curEnd)依次扫描。
  3. 如果新区间起点在当前区间终点之前,说明有重叠,更新当前区间终点为两者较大值。
  4. 否则结算当前区间,再开启新区间。

这样做的复杂度和题目给出数组的有序性有关,但思想更通用,适合对“区间求并集”这种抽象模型不熟的同学作为切入点。如果已经把这道题的题意理解成了合并区间,后面再遇到一堆类似题目都会容易很多。

5.2 思路二:改成通用的事件窗口模板

把“提莫攻击”这个外壳拿掉,核心就是一个很通用的事件处理模型:

每个事件发生在一个时刻,会启动一个持续 window 的状态。 如果事件发生时,上一个状态窗口没过期,就把窗口结束时刻刷新到当前事件时刻 + window。 如果事件发生时,上一个状态窗口已经过期,那就先结算旧窗口,再开启新窗口。

这套模板可以套到很多地方。比如:

  • 服务器收到一次请求,给某个资源设置一个 window 秒的缓存;缓存没过期又来了新请求,就用新请求刷新缓存过期时间。
  • 网络重试场景中,某个错误状态进入后持续若干秒,期间又出现新错误,则需要把恢复时间往后推。
  • 游戏里的各种“持续伤害刷新”机制,本质都是这个模型。

所以代码里那几行 if、else 看起来很朴素,背后代表的是一种“基于状态截止时刻”的建模方式。你掌握了以后,遇到任何“过期时间刷新”类问题都可以直接迁移。

5.3 思路三:何时升级到差分数组或扫描线

如果题目只是要求总中毒秒数,窗口模拟是最优解。但地图大了以后,问题可能会变成:

  • 每个区间长度不同,还要求某个时间段内同时处于几个状态。
  • 攻击次数不多,但时间轴非常稀疏,且要多次查询。
  • 需要知道目标有多少秒处于“至少两层中毒”的状态。

这时候就要考虑差分数组或者扫描线事件:

  1. 在每个攻击开始时刻记录一个加一事件。
  2. 在每个中毒结束时刻记录一个减一事件。
  3. 扫描时间轴,维护当前重叠区间数量,再按需求统计。

这种做法的好处是能统计覆盖次数,不只是覆盖长度,但代价是需要额外排序事件,空间也可能更大。所以选型时要先想清楚题目问的是“总长度”还是“覆盖次数”,问总长度优先用窗口模拟,问覆盖次数再升级到扫描线。

我给个简单的伪代码作为扩展记录:

vector<pair<int, int>> events; for (int t : timeSeries) { events.push_back({t, 1}); events.push_back({t + duration, -1}); } sort(events.begin(), events.end()); int current = 0; long long total = 0; for (int i = 0; i < events.size(); ++i) { int now = events[i].first; int delta = events[i].second; if (current > 0 && i > 0) { total += now - events[i - 1].first; } current += delta; }

这段伪代码能算出所有中毒覆盖的总长度,而且它能告诉你什么时候中毒层数从 1 变成 2、从 2 变成 1。如果面试官打算把难度往上调,这类扩展是常见升级路径。不过你要是第一时间能给出那道题的 O(n) 窗口模拟,通常已经达到了目标。


这道题看起来短,其实把“区间重置”“相邻贡献”“边界语义”几个点都揉在了一起。我自己每次写它都会刻意在脑子里画一遍时间轴,先确认两次攻击是否重叠,再决定贡献是 duration 还是 diff。写完之后再用窗口写法交叉验证一遍,基本不会出错。

最后分享一个小习惯:刷模拟题不要把代码写得过于“灵性”,哪怕是一行min(duration, timeSeries[i] - timeSeries[i-1]),也要能边说边解释为什么是它。真正让模拟题拉开差距的,往往不是你能不能写出代码,而是你能不能把那个“状态刷新”的过程讲得让听的人也瞬间明白。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:52:09

Python文件读写与异常处理实战指南:从编码到容错的完整学习记录

第26天&#xff0c;Python学习进度条终于走到文件读写和异常处理这个坎儿上。这俩东西在教程里往往被放在不起眼的角落&#xff0c;但实际写起脚本、处理数据、做小工具&#xff0c;几乎每一个真实程序都逃不开它们。我自己的感受是&#xff0c;前面函数、列表、字典如果说是搭…

作者头像 李华
网站建设 2026/10/24 2:52:07

接口测试用例设计实战:从入参边界到业务场景全覆盖

做过一段时间接口测试的人基本都会遇到同一个问题&#xff1a;功能用例写得再熟练&#xff0c;一转到接口层面就不知道怎么下手。对着接口文档看了半天&#xff0c;最后只能把每个字段的“正常值、异常值”机械罗列一遍&#xff0c;用例看着不少&#xff0c;上线后还是漏测&…

作者头像 李华
网站建设 2026/10/24 2:51:38

C++移动语义实战指南:从右值引用到std::move的完整笔记

我用这门课的PPT内容为主线&#xff0c;结合自己课后的复现代码和实际项目里的使用经验&#xff0c;整理成一份带实践视角的完整笔记。如果你正处在"会写拷贝构造、但看不懂别人代码里为什么到处是std::move"的阶段&#xff0c;这份笔记能帮你把这些概念全部串起来。…

作者头像 李华
网站建设 2026/10/24 2:50:27

港股18A生物医药IPO:13.8亿营收背后的盈利质量与估值拆解

港股生物医药板块最近又热闹了。一家做抗体类生物药的企业传出冲刺港交所的消息&#xff0c;公开报道里的数据很直接&#xff1a;9个月营收13.8亿元&#xff0c;净利润1.2亿元&#xff0c;背后站着的股东既有国际知名私募机构&#xff0c;也有A股老牌上市药企。普通读者看到“净…

作者头像 李华
网站建设 2026/10/24 2:49:04

CLion嵌入式开发跳转失效修复:从编译数据库到索引重建

搞嵌入式开发的老铁们应该都有过这种经历&#xff1a;工程代码写了一堆&#xff0c;突然想顺手点一下某个函数的定义&#xff0c;Ctrl鼠标左键按下去&#xff0c;结果不是纹丝不动&#xff0c;就是给你跳到个“No definition found”的空空页面&#xff0c;瞬间血压就上来了。我…

作者头像 李华
网站建设 2026/10/24 2:49:01

钙钛矿组件产线检测三大挑战与激光测试方案解析

钙钛矿组件的产业化这几年从实验室走向GW级产线&#xff0c;速度比晶硅当年快得多&#xff0c;但产线上的检测环节却一直处于“有苦说不出”的状态。我在现场跟线调试过多次钙钛矿检测设备&#xff0c;最深的一个感受是&#xff1a;钙钛矿组件的检测难点&#xff0c;从来不只是…

作者头像 李华