news 2026/10/16 4:39:53

滑动窗口+哈希计数:日志统计热帖的O(N log N)解法与边界处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
滑动窗口+哈希计数:日志统计热帖的O(N log N)解法与边界处理

1. 从"日志统计"这道题看滑动窗口的实战价值

"日志统计"这个题目名字听起来平平无奇,但如果你真正动手做过历届算法竞赛的真题,就会发现它是一道非常典型的滑动窗口加哈希计数的综合应用题。我第一次看到这道题的时候,第一反应是"这不就是个排序加遍历吗",结果真正写起来才发现,朴素做法在数据量稍微大一点的时候就会超时,必须换一种思路才能把时间复杂度压下来。

这道题的核心场景是这样的:给定一批日志记录,每条记录包含两个信息——某个帖子的编号和这条日志被点赞的时间点(以秒为单位)。然后给定一个时间窗口长度和一个热度阈值,要求找出所有在任意一个长度为该窗口的时间段内,点赞数达到或超过阈值的帖子编号,最后按编号从小到大输出。

说白了,就是让你在一堆按时间散落的数据里,找出那些"在某段时间内突然火起来"的帖子。这个场景在现实中非常常见——比如统计某个商品在某个时间段内的销量是否突破了爆款线,或者某个话题在某个时间窗口内的讨论量是否达到了热搜标准。所以这道题虽然披着竞赛题的外衣,但它背后的思维模型是有真实应用价值的。

适合谁来读这篇内容?如果你正在准备算法竞赛,尤其是想搞明白滑动窗口这一类题目的通用套路,那这篇内容会非常对口。如果你已经工作了,但发现自己面对"时间窗口统计"这类需求时总是写得又慢又乱,那这篇也能帮你理清思路。我会从最朴素的暴力思路讲起,一步步推导到优化方案,把每一步"为什么要这样做"都说清楚,最后给出可以直接拿去用的完整代码和实测注意事项。

2. 朴素思路为什么容易翻车:从O(N²)到O(N log N)的思考过程

2.1 最直觉的做法:对每个帖子单独统计

拿到这道题,很多人第一反应是这样的:先把所有日志按帖子编号分组,然后对每个帖子,把它所有的点赞时间拿出来排序,接着用双重循环枚举每一个时间点作为窗口起点,统计窗口内有多少条记录,如果达到阈值就标记这个帖子为热帖。

这个思路逻辑上完全正确,但问题出在效率上。假设一共有N条日志,平均每个帖子有K条记录,那么对每个帖子做双重循环的时间复杂度是O(K²),所有帖子加起来就是O(N×K)。在最坏情况下,如果所有日志都属于同一个帖子,那就退化成O(N²)。当N达到十万级别的时候,这个复杂度就非常危险了,跑起来可能要好几秒甚至十几秒,在竞赛环境下基本就是超时的命。

我当初就是这么写的,本地测试小数据完全没问题,一提交就报超时。后来才意识到,问题不在于逻辑对不对,而在于我没有利用"时间窗口"这个约束本身的结构特性。

2.2 换个角度:把问题看成"时间轴上的滑动"

我们重新审视一下这个问题。题目的本质是:在时间轴上,是否存在一个长度为D的区间,使得某个帖子的点赞记录落在这个区间内的数量不少于K。

注意关键词——"长度为D的区间"。这意味着我们不需要对每个帖子单独去枚举所有可能的区间起点,而是可以让一个固定长度的窗口在时间轴上"滑动",在滑动的过程中动态维护窗口内各个帖子的计数。

这就是滑动窗口的核心思想:窗口的左右边界只往前走,不回头。每移动一次,只需要处理"新进入窗口的元素"和"离开窗口的元素",而不需要重新统计整个窗口。这样一来,每个元素最多被加入窗口一次、移出窗口一次,总的时间复杂度就降到了O(N)。

但这里有一个前提:所有日志必须按照时间排序。如果日志本身不是按时间给出的,我们就需要先做一次排序,时间复杂度是O(N log N)。排序之后,再用滑动窗口扫一遍,总复杂度就是O(N log N),对于十万级别的数据来说完全够用。

2.3 为什么选择"按时间排序+滑动窗口"而不是"按帖子分组"

这里我想特别说一下选型逻辑。按帖子分组再各自统计的做法,虽然直观,但它有一个隐含的代价:你需要为每个帖子单独维护一个时间列表,而且不同帖子的时间列表之间没有共享信息。换句话说,你做了很多重复的"扫描"工作。

而按时间排序之后,所有帖子的记录混在一起,但滑动窗口在移动的过程中,天然地就把"同一个时间段内不同帖子的表现"同时统计了。你只需要一个计数数组来记录当前窗口内每个帖子的点赞数,窗口每滑动一次,就检查一下有没有帖子的计数达到了阈值。

这个思路的妙处在于:一次扫描,同时处理所有帖子。不需要为每个帖子单独开循环,也不需要反复排序。这就是算法优化中常说的"用空间换时间"和"用结构换效率"的典型体现。

3. 滑动窗口的具体实现:边界处理才是真正的坑

3.1 窗口的左右指针如何移动

滑动窗口的实现通常需要两个指针,我们叫它们left和right。right负责向右扩展窗口,把新的元素纳入进来;left负责在窗口超出规定长度时向右收缩,把旧的元素踢出去。

具体到这道题,流程是这样的:

  1. 先将所有日志按时间从小到大排序。
  2. 初始化left = 0,right = 0,以及一个计数数组cnt[],用来记录当前窗口内每个帖子的点赞数。
  3. 让right从0遍历到N-1,每次把第right条日志的帖子编号加入计数。
  4. 然后检查当前窗口的时间跨度:如果logs[right].time - logs[left].time >= D,说明窗口太大了,需要移动left,把logs[left]对应的帖子计数减一,然后left加一。重复这个过程直到窗口的时间跨度小于D。
  5. 在每次调整完窗口之后,检查当前窗口内是否有帖子的计数达到了K。如果有,就标记该帖子为热帖。

这里有一个非常容易出错的细节:窗口的长度到底是"D"还是"D-1"?题目说的是"长度为D的时间段",通常指的是时间差小于D,也就是说,如果两条日志的时间差恰好等于D,它们不属于同一个长度为D的窗口。这个边界条件如果搞错了,就会导致某些测试点过不了。

我当初就是在这里栽了跟头。我一开始写的是logs[right].time - logs[left].time > D才收缩窗口,结果发现有些本该被标记的帖子没有被标记。后来仔细读题才发现,题目要求的是时间差严格小于D,所以应该是>= D就收缩。

3.2 计数数组的大小怎么定

计数数组的大小取决于帖子编号的最大值。题目通常会给出编号的范围,比如1到100000。如果你不确定范围,可以先扫一遍所有日志,找到最大的帖子编号,然后开一个比它大一点的数组。

这里有一个小技巧:如果帖子编号的范围非常大但实际出现的编号很少,用数组可能会浪费很多空间。这时候可以考虑用哈希表来代替数组。不过在这道题里,编号范围通常是可控的,用数组就够了,而且数组的访问速度比哈希表快得多。

注意:如果使用数组作为计数工具,一定要确保数组大小足够,否则会出现越界访问,导致程序崩溃或者结果错误。建议在开数组之前先确认一下编号的最大值。

3.3 标记热帖的时机

标记热帖的时机也很关键。你可以在每次窗口调整完毕之后,检查当前窗口内所有帖子的计数。但这样做的效率不高,因为每次都要遍历整个计数数组。

更好的做法是:在每次把一条日志加入窗口之后,立即检查该日志对应的帖子计数是否达到了K。如果达到了,就标记它为热帖。这样只需要O(1)的时间就能完成检查,不需要遍历整个数组。

但这里有一个问题:一个帖子可能在多个不同的窗口中都达到阈值,我们只需要标记一次就够了。所以可以用一个布尔数组来记录每个帖子是否已经被标记为热帖,避免重复标记。

4. 完整代码实现与逐行注释

4.1 数据结构定义与输入处理

#include <iostream> #include <algorithm> #include <cstring> using namespace std; const int MAXN = 100005; // 日志最大数量 const int MAXID = 100005; // 帖子编号最大值 struct Log { int time; // 点赞时间 int id; // 帖子编号 } logs[MAXN]; bool cmp(const Log &a, const Log &b) { return a.time < b.time; // 按时间升序排列 } int cnt[MAXID]; // 当前窗口内每个帖子的点赞计数 bool isHot[MAXID]; // 标记帖子是否为热帖 int main() { int N, D, K; cin >> N >> D >> K; for (int i = 0; i < N; i++) { cin >> logs[i].time >> logs[i].id; } // 按时间排序 sort(logs, logs + N, cmp); memset(cnt, 0, sizeof(cnt)); memset(isHot, false, sizeof(isHot)); int left = 0; for (int right = 0; right < N; right++) { // 将当前日志加入窗口 cnt[logs[right].id]++; // 收缩窗口,直到时间跨度小于D while (logs[right].time - logs[left].time >= D) { cnt[logs[left].id]--; left++; } // 检查当前帖子是否达到热度阈值 if (cnt[logs[right].id] >= K) { isHot[logs[right].id] = true; } } // 按编号从小到大输出所有热帖 for (int i = 0; i < MAXID; i++) { if (isHot[i]) { cout << i << endl; } } return 0; }

这段代码的逻辑非常清晰:先排序,然后用滑动窗口扫描,最后按顺序输出。但有几个地方需要特别注意。

4.2 排序的比较函数为什么不能写成a.time <= b.time

在C++的sort函数中,比较函数必须满足严格弱序的要求。如果你写成a.time <= b.time,当两个元素的时间相等时,比较函数会返回true,这违反了严格弱序的规则,可能导致排序结果不确定,甚至在某些编译器下引发运行时错误。

正确的写法是a.time < b.time,这样当时间相等时返回false,符合严格弱序的要求。这个问题在竞赛中非常常见,很多人因为写错了比较函数而导致程序崩溃或者结果错误。

4.3 窗口收缩的循环为什么用while而不是if

窗口收缩必须用while循环,而不能用if。原因很简单:当right向右移动时,新加入的日志的时间可能比left指向的日志的时间大很多,导致窗口的时间跨度一下子超出了D很多。这时候需要连续移动left多次,才能把窗口的时间跨度降回D以内。

如果只用if,那么每次right移动后最多只能收缩一次窗口,可能导致窗口的时间跨度仍然大于D,从而影响后续的统计结果。这是一个非常隐蔽的bug,在小数据下可能看不出来,但在大数据下会导致结果错误。

4.4 输出时为什么从0遍历到MAXID

题目要求按帖子编号从小到大输出所有热帖。由于我们使用布尔数组isHot来标记热帖,直接按编号顺序遍历这个数组,就能保证输出是有序的。不需要额外做排序。

但这里有一个细节:如果帖子编号的范围很大,而实际出现的编号很少,那么遍历整个数组可能会浪费一些时间。不过在这道题里,编号范围通常是可控的,遍历一遍的开销可以忽略不计。

5. 实测中容易踩的五个坑与排查方法

5.1 时间差等于D时的边界判断

这是最容易出错的地方。题目说的是"长度为D的时间段",通常理解为时间差小于D。也就是说,如果两条日志的时间差恰好等于D,它们不属于同一个窗口。

我在第一次提交的时候,写的是logs[right].time - logs[left].time > D才收缩窗口,结果发现有些测试点过不了。后来改成>= D就通过了。这个细节在题目描述中可能不会特别强调,但它是决定程序正确性的关键。

排查方法:自己构造一组边界数据,比如D=10,有两条日志的时间分别是0和10,看看你的程序会不会把它们算在同一个窗口里。如果算进去了,那就说明你的边界判断有问题。

5.2 计数数组越界

如果帖子编号的最大值超过了数组的大小,就会发生越界访问。这种错误在本地测试时可能不会暴露,因为越界访问不一定立即导致崩溃,但它会悄悄地破坏其他变量的值,导致结果错误。

排查方法:在开数组之前,先扫一遍所有日志,找到最大的帖子编号,然后开一个比它大一点的数组。或者直接开一个足够大的数组,比如100005,通常题目给出的编号范围不会超过这个值。

5.3 多组测试数据的初始化问题

有些题目可能包含多组测试数据,每组数据之间需要清空计数数组和标记数组。如果你忘记清空,上一组数据的结果会影响到下一组,导致错误。

排查方法:在每组数据开始处理之前,用memset或者fill把计数数组和标记数组清零。不要依赖全局变量的默认初始化,因为全局变量只在程序开始时初始化一次。

5.4 排序后原始顺序丢失的问题

这道题不需要保留日志的原始顺序,所以排序不会带来问题。但如果你在别的题目中需要保留原始顺序,就要注意排序会打乱顺序。这时候可以考虑用一个额外的数组来记录原始下标,或者在结构体中加一个字段来记录原始位置。

排查方法:仔细读题,确认题目是否对输出顺序有要求。如果要求按原始顺序输出,那就需要在排序之前做好记录。

5.5 大量数据下的输入输出效率

当N达到十万级别的时候,用cin和cout进行输入输出可能会比较慢。虽然C++的cin和cout在默认情况下已经做了同步优化,但在某些环境下仍然可能成为瓶颈。

排查方法:可以在main函数开头加上ios::sync_with_stdio(false); cin.tie(0);来加速输入输出。如果还是不够快,可以考虑改用scanf和printf。

6. 从这道题延伸出的通用解题框架

6.1 滑动窗口类题目的识别特征

滑动窗口并不是万能的,它只适用于特定类型的问题。一般来说,如果题目满足以下特征,就可以考虑用滑动窗口:

  • 问题涉及一个连续的区间或窗口。
  • 窗口的左右边界都只能单向移动,不能回头。
  • 窗口内的状态可以通过增量更新的方式维护,不需要每次重新计算。

这道题完全符合这三个特征:窗口是时间轴上的一个区间,左右边界都只向右移动,窗口内的计数可以通过加减操作来维护。

6.2 滑动窗口的通用模板

int left = 0; for (int right = 0; right < N; right++) { // 将第right个元素加入窗口 add(logs[right]); // 收缩窗口,直到满足条件 while (needShrink()) { remove(logs[left]); left++; } // 检查当前窗口是否满足题目要求 check(); }

这个模板适用于大多数滑动窗口类题目。你只需要根据具体题目实现add、remove、needShrink和check这四个部分就可以了。

6.3 什么时候滑动窗口会失效

滑动窗口并不是万能的。如果窗口的左右边界需要回头移动,或者窗口内的状态无法通过增量更新的方式维护,那么滑动窗口就不适用了。这时候可能需要考虑其他算法,比如二分查找、动态规划或者线段树。

另外,如果题目要求的是"恰好等于某个值"而不是"大于等于某个值",滑动窗口可能也需要做一些调整。因为滑动窗口通常维护的是一个单调的性质,如果性质不单调,窗口的收缩条件就不容易确定。

6.4 类似题目的练习建议

如果你想巩固滑动窗口这个知识点,可以找一些类似的题目来练习。比如"最长无重复子串"、"最小覆盖子串"、"长度最小的子数组"等等。这些题目都是滑动窗口的经典应用,做完之后你会对滑动窗口的套路有更深刻的理解。

练习的时候,建议先自己想思路,不要急着看题解。想不出来再看题解,然后自己动手写一遍。写完之后,再造几组边界数据测试一下,确保程序在各种情况下都能正确运行。

7. 我在实际编码中的几点体会

这道题我前前后后做了三遍,每一遍都有新的收获。第一遍的时候,我只想着把逻辑写对,结果超时了。第二遍的时候,我学会了滑动窗口,但因为边界条件写错,还是没过。第三遍的时候,我终于把所有的细节都处理好了,提交一次就通过了。

我的体会是:算法题的核心不在于你知不知道某个算法,而在于你能不能把算法的细节处理到位。滑动窗口的思路本身并不复杂,但窗口的收缩条件、边界判断、计数更新这些细节,才是真正决定程序对错的地方。

另外,我建议大家在写代码之前,先在纸上把思路理清楚,特别是窗口的左右指针如何移动、什么时候收缩、什么时候检查,这些都想明白了再动手写。这样比边写边改效率高得多。

最后分享一个小技巧:如果你不确定自己的边界条件写得对不对,可以构造一组极端数据来测试。比如所有日志的时间都相同,或者所有日志的时间都相隔很远,或者只有一条日志。这些极端情况往往能暴露出边界条件的错误。

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

技术博客创作前,项目标题与关键词该如何填?

请提供需要拆解撰写的项目标题&#xff0c;以及可选的补充字段&#xff08;项目正文、关键词、摘要描述&#xff09;。你上一轮发送的内容中&#xff0c;核心的角色设定与创作规范我已经完整接收&#xff0c;但缺少最关键的一个输入参数——“项目标题”本身。没有标题&#xf…

作者头像 李华
网站建设 2026/10/16 4:39:37

Spring Boot + Vue 3 全栈项目实战:从接口设计到前后端联调

在后台管理系统、小程序管理端、企业官网这类项目里&#xff0c;Spring Boot Vue 的组合几乎成了事实标准。后端 Spring Boot 提供稳健的 API 服务&#xff0c;前端 Vue 负责交互体验&#xff0c;两者配合能快速完成从数据库到页面的全链路打通。这篇文章不是我第一次写全栈项…

作者头像 李华
网站建设 2026/10/16 4:39:16

JavaWeb学习路线:从Servlet到Spring Boot的全链路解析

1. 为什么我在2024年才开始写JavaWeb学习日记打开博客后台准备敲第一篇文章的时候&#xff0c;其实挺感慨的。做开发这几年&#xff0c;断断续续用过不少框架&#xff0c;也写过一些接口&#xff0c;但一直没系统梳理过JavaWeb这条线。直到最近重新翻自己早年记的零散笔记&…

作者头像 李华
网站建设 2026/10/16 4:38:46

端侧大模型推理性能调优实战:从0.4到12.8 token/s的排查指南

最近被一个“80 倍”的性能对比搞得相当狼狈。前提是端侧推理——模型不联网&#xff0c;完全在手机本地跑 LLM 的那种部署方式。别人演示视频里每秒稳定输出二十多个 token&#xff0c;我手里这台设备一个字一个字往外蹦&#xff0c;体感差距大到让人怀疑是不是拿错了机器。以…

作者头像 李华
网站建设 2026/10/16 4:38:11

C++高精度计时与延时:从steady_clock到低漂移定时器实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 4:38:01

OpenClaw AI代理爆火背后:权限失控与隐私危机深度解析

1. 从"排队装机"到"全网裸奔"&#xff1a;OpenClaw到底火成什么样了1.1 一周之内&#xff0c;它怎么把整个技术圈的时间线刷屏了这几天我被问得最多的一句话几乎一模一样&#xff1a;OpenClaw你装了吗&#xff1f;打开朋友圈&#xff0c;有人晒自己抢到了安…

作者头像 李华