1. 题目解读与背景分析
1.1 题目到底是什么
第一次看到“日志统计”这四个字,可能不少朋友会觉得这题很简单——无非就是给一堆日志,统计统计热度呗。但真正上手之后才发现,这道2018年蓝桥杯第九届的真题,实际上是考察滑动窗口、双指针、排序哈希综合运用的经典题目,也是当年区分度比较高的一道题。
先看原题大致描述:小明维护着一个程序员论坛,收集了一份“点赞”日志,日志共有N行,每行包含两个整数,分别表示ts(时间戳)和id(帖子编号)。现在小明想知道,在哪些帖子中,任意长度为D的时间段内(注意是长度为D,不是差值为D),收到的点赞数不少于K个。如果满足这个条件,则称该帖为“热帖”。要求输出所有热帖的id,并按从小到大排序。
这个题目在蓝桥杯历年真题里地位很特殊。它不像纯模拟题那样无脑,也不像图论、DP那样需要高深算法,恰恰卡在一个“需要一点优化思维但又不至于太难”的位置。对于备赛蓝桥杯的同学来说,这道题是练习滑动窗口思想的绝佳素材,特别是准备蓝桥杯python组、Java组、C++组的同学,几乎都绕不开它。
1.2 题目考察的核心能力
这道题表面上考察的是模拟+统计,实际上暗含三个核心能力的考察:
第一个是数据组织能力。输入的日志是无序的,时间戳和帖子id交错在一起,怎么把数据组织成方便处理的结构?这决定了后续算法的复杂度。
第二个是时间复杂度的敏感度。如果直接对每个帖子、每个时间区间暴力枚举,数据量一大必然超时。很多同学第一次写出来的暴力版本,在小数据上没问题,一到蓝桥杯的评测数据就直接时间超限。今年不少同学在蓝桥杯赛场上栽跟头,往往不是不会做,而是没意识到需要优化。
第三个是边界条件的处理能力。时间区间是左闭右开还是左闭右闭?点赞数“不少于K”还是“大于K”?排序去重的细节?这些看似小的点,却直接决定代码能否AC。
这道题放在第九届是有一定“劝退”作用的,但现在回头看,它其实就是滑动窗口的入门经典。掌握了这道题,后面遇到同类区间统计问题,就会有很清晰的思路。
2. 核心思路拆解与方案选型
2.1 为什么不能暴力解
很多人的第一反应是:直接用一个二维数组,cnt[postId][time],然后对于每个帖子枚举所有长度为D的时间窗口统计点赞数。理论上看起来没啥问题,但算一下复杂度就明白了。
假设N条日志,帖子编号最大值是M,时间戳最大值是T,暴力做法的时间复杂度是O(N * T)或者O(M * T),在数据范围较大的情况下(比如N达到10^5,时间戳达到10^5甚至更大),这个复杂度是无法承受的。蓝桥杯的评测虽然不像ACM那么变态,但对超时同样是零容忍。
还有一个隐藏问题:日志数据是稀疏的。并不是每个时间点都有点赞记录,用一个稠密的二维数组去存储稀疏的数据,本身就是极大浪费。这也是为什么需要哈希表来组织数据。
2.2 滑动窗口:这道题最优雅的解法
既然暴力不行,那就要换个思路。这里我直接给出核心观察:对于一个固定的帖子id,它的点赞时间戳排好序之后,我们只需要判断是否存在一个区间[i, j],使得times[j] - times[i] < D且区间内点赞数(即j-i+1)不小于K。
这里有个关键细节:题目说的是“长度为D的时间段”,在代码实现时通常判断times[j] - times[i] < D,这样可以保证窗口长度严格小于D。不过要注意,不同版本的题目描述可能有差异,有的版本是<= D,这个细节决定了边界条件怎么写。
具体步骤是这样的:
- 用哈希表(字典)把每个帖子id对应的所有点赞时间戳存起来,每个id的时间戳是一个列表。
- 对每个id的时间戳列表从小到大排序。
- 在每个排序后的列表上用双指针维护一个窗口:右指针不断向右扩展,把新的时间戳纳入窗口;当
times[right] - times[left] >= D时,左指针向右收缩,直到窗口合法。 - 如果窗口内的元素个数(点赞数)达到K,则该id就是热帖,记录答案。
这个思路的时间复杂度是O(N log N),主要开销在排序上,双指针部分每个元素最多进出窗口一次,是线性的。排序加双指针的组合,直接就把原来的超高复杂度降下来了。
2.3 哈希表选择:C++ map还是unordered_map
如果使用C++实现,这里有个很实际的选择题:用map<int, vector<int>>还是unordered_map<int, vector<int>>?
map底层是红黑树,key有序,但插入和查询是O(log N)。unordered_map底层是哈希表,插入和查询均摊O(1),但key无序。
因为最后要按照id从小到大输出热帖,用map的话,遍历时天然有序,省去最后排序的一步;用unordered_map则需要在结尾单独排序。从代码简洁角度看,map更省事,而且N在10^5量级时,两者的性能差距并不明显。不过如果追求极致性能且不介意多写一行排序,unordered_map在数据量大的时候会略快一些。
我个人在实际写题时通常用map,因为蓝桥杯的评测环境对代码长度和出错概率更敏感,map少一个排序步骤,更稳。用Python的话就不存在这个问题了,直接字典搞定。
3. 完整实现与代码深度解析
3.1 C++完整实现
先上我用C++写的完整AC代码,代码里加了详细注释,方便对照分析:
#include <bits/stdc++.h> using namespace std; int main() { int n, d, k; scanf("%d %d %d", &n, &d, &k); map<int, vector<int>> mp; // id -> 时间戳列表 for (int i = 0; i < n; i++) { int ts, id; scanf("%d %d", &ts, &id); mp[id].push_back(ts); } vector<int> ans; for (auto &it : mp) { int id = it.first; vector<int> × = it.second; sort(times.begin(), times.end()); int left = 0, right = 0; int cnt = 0; // 滑动窗口:窗口内是 [left, right) while (right < (int)times.size()) { // 窗口右边界扩展,纳入一个新时间戳 while (right < (int)times.size() && times[right] - times[left] < d) { cnt++; right++; } // 如果当前窗口内点赞数满足条件,记录答案 if (cnt >= k) { ans.push_back(id); break; // 找到一个即可,不需要继续找 } // 左边界收缩,移出一个时间戳 cnt--; left++; // 注意:这里如果right == left,需要重置窗口 if (left == right) { cnt = 0; if (right < (int)times.size()) { cnt++; right++; } } } } for (int id : ans) { printf("%d\n", id); } return 0; }这段代码用的是map<int, vector<int>>,天然按id排序,最后直接遍历输出即可。但这段代码有个细节要仔细想:当left移动之后,cnt维护的是当前窗口[left, right)内的元素个数,而不是简单的right - left,因为right可能已经移动到了末尾,而cnt的变化需要手动维护。
其实这里有一个更简洁的写法,伪代码如下:
for (auto &it : mp) { sort(it.second.begin(), it.second.end()); int l = 0, r = 0; while (r < it.second.size()) { if (it.second[r] - it.second[l] < d) { r++; } else { l++; } if (r - l >= k) { ans.push_back(it.first); break; } } }这个写法更直观:先用l和r两个指针维护窗口,当窗口不满足时间差小于d时左指针右移;当窗口满足时间差小于d时右指针右移;每次移动后检查窗口长度(r - l)是否达到k。窗口长度就是点赞数,因为窗口内每个时间戳代表一次点赞。
注意这个写法里,r - l恰好等于窗口内元素数量,不需要额外维护cnt,逻辑更清晰。这也是我推荐大家掌握的版本。
3.2 Python完整实现
Python版本对准备蓝桥杯python组的同学更重要,因为近年来Python参赛人数暴涨,这道题也频繁出现在各路真题解析里。Python实现如下:
n, d, k = map(int, input().split()) from collections import defaultdict mp = defaultdict(list) for _ in range(n): ts, id_ = map(int, input().split()) mp[id_].append(ts) ans = [] for id_ in sorted(mp.keys()): times = sorted(mp[id_]) l = 0 for r in range(len(times)): # 保证窗口内时间差小于 d while times[r] - times[l] >= d: l += 1 # 如果窗口长度达到 k,说明是热帖 if r - l + 1 >= k: ans.append(id_) break for id_ in ans: print(id_)Python版本的逻辑更紧凑:外层遍历mp的key(即帖子id),按id排序后依次处理。内层用for r in range(len(times))作为右指针,每次循环中当窗口不满足时间差小于d时,左指针l右移,直到窗口重新合法。然后判断r-l+1是否达到k。
这里有一个关键点:为什么只判断一次就break?因为只要存在一个满足条件的窗口,该帖子就是热帖,不需要继续找。这个优化可以节省大量时间。
3.3 参数计算与核心细节说明
这道题的“参数”主要体现在时间差判断上。
题目描述说“任意长度为D的时间段”,但代码里是times[r] - times[l] < d。为什么是小于而不是小于等于?这里涉及到时间段的定义:如果时间段是从t到t+D,那么长度是D;但点赞时刻如果在t+D这一瞬间,算不算在这个时间段内?不同题目描述有细微差别。蓝桥杯2018年第九届这题的官方说法是“长度为D的时间段”,通常理解为开区间,即times[r] - times[l] < d。
但有一版这道题的描述写的是“在任意长度为D的闭区间内”,那判断条件就应该是times[r] - times[l] <= d。这就要求我们读题时格外注意,比赛时碰到这种边界描述一定要仔细。我在备考时喜欢把两种判断都写一遍,本地验证边界数据后再提交,这样最稳。
另外,窗口内的时间戳数量就是点赞次数,因为同一个时间戳可能出现多次?这里要特别说明:在真实的日志统计数据中,一个时间戳不可能对应同一条帖子两次点赞,但蓝桥杯的测试数据里,同一时间戳同一条帖子可能出现多次(理论上不合理,但数据就是这样给的)。vector里每个元素代表一次点赞,两个相同的时间戳也代表两次点赞,所以r-l+1就是点赞数,不做去重。这一点很多新手会踩坑,把时间戳去重后再统计,反而错了。
3.4 两种思路对比总结
| 方案 | 时间复杂度 | 空间复杂度 | 实现难度 | 推荐指数 |
|---|---|---|---|---|
| 暴力枚举(帖子id × 时间窗口) | O(N * T) | O(N) | 低 | 不推荐 |
| 排序 + 双指针滑动窗口 | O(N log N) | O(N) | 中 | 强烈推荐 |
| 排序 + 前缀和 | O(N log N + M * D) | O(N) | 中 | 看情况 |
前缀和方案也是一种可行思路:对每个帖子id的时间戳做前缀和,然后枚举每个长度为D的窗口,用前缀和O(1)查询窗口内点赞数。但这种方式需要把时间戳离散化或者映射到连续数组,代码复杂度比双指针高,而且枚举所有窗口的效率反而不如双指针一步到位。所以主流解法还是滑动窗口。
4. 常见问题与排查技巧实录
4.1 超时问题:十有八九是暴力了
这是最常见的错误。你在本地测试小数据时一切正常,交到OJ上就TLE。原因就是复杂度太高。解决办法只有一个:换滑动窗口。如果已经用了滑动窗口还超时,可以检查以下几点:
- 是不是用了
vector的push_back频繁扩容?可以预先reserve或换成deque。 - 是不是在循环里反复调用
size()函数?在C++里times.size()返回的是size_t,循环里反复调用效率略低,可以提前存起来。 - 是不是用了
endl进行输出?大数据的输出应该用\n而不是endl。
4.2 边界条件错误
最常见的边界错误是:
- 用
times[r] - times[l] <= d但题目要求开区间,导致边界情况多算了一个。 - 窗口内点赞数判断是
>= k还是> k?题面要求“不少于K个”,必然是>= k。 - 热帖id要从大到小还是从小到大?题目要求从小到大。
判断边界最有效的方法是构造一组极端小数据手算一遍。比如:
输入: 5 2 2 1 1 2 1 3 1 4 2 5 2手动模拟:帖子1的时间戳是1、2、3,d=2,区间[1,3)包含1和2,点赞数2,达到k=2,所以1是热帖。帖子2的时间戳是4、5,区间[4,6)包含4和5,点赞数2,也是热帖。输出应该是1和2。用这组数据验证代码逻辑,边界基本能暴露出来。
4.3 数据去重的误区
前面提到,一定不要对同一帖子的时间戳去重。逻辑上听起来好像同一条帖子同一秒只能被点赞一次?但题意没有做这个限制,而且蓝桥杯的评测数据并不会遵守这种“物理直觉”。你一旦去重,那些“同一秒被点赞两次”的数据点就会漏判。
用一个极端例子说明:假设d=1,k=2,某帖子的时间戳列表是[1, 1],这代表在时间1有两次点赞。按照题意,在长度为1的时间段[1,2)内点赞数为2,是热帖。如果去重后只剩[1],点赞数为1,就错误地判定为非热帖。这种隐蔽的坑,只有亲手踩过才会记住。
4.4 输出格式与排序
蓝桥杯的判题对输出格式要求严格,每个id占一行,末尾不能有多余空格,最后一行也无所谓换不换行。如果用map遍历,天然有序;用unordered_map则要最后sort一下。有些同学直接在遍历unordered_map时输出,结果顺序错误,白丢分。
还有一个细节:空输出。如果没有任何热帖,是输出空还是输出什么?题目没说有特殊输出,那就什么都不输出。有些同学会在最后加一个换行,虽然一般不会判错,但稳妥起见,没有答案就直接结束,不要画蛇添足。
4.5 读入速度优化
对于C++选手,如果担心cin太慢,最直接的办法是用scanf。但如果你想用cin,可以加上这两行:
ios::sync_with_stdio(false); cin.tie(0);这两行能显著提升cin的读取速度。注意加了ios::sync_with_stdio(false)之后,就不能再混用scanf和cin了,否则容易出错。Python选手则用sys.stdin.buffer.read()做快速读入,可以大幅提高大数据下的读入效率:
import sys data = sys.stdin.buffer.read().split() n, d, k = map(int, data[:3]) idx = 3 for _ in range(n): ts = int(data[idx]); id_ = int(data[idx+1]); idx += 2 mp[id_].append(ts)4.6 常见问题速查表
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 本地运行正常,OJ上超时 | 暴力枚举或低效输出 | 改用滑动窗口,用\n输出 |
| 答案错误,差1个结果 | 边界条件判断出错 | 检查< d还是<= d,>= k还是> k |
| 输出id顺序不对 | 用unordered_map未排序 | 改用map或最后sort |
| 漏掉一些热帖 | 把同一帖子的时间戳去重 | 保留重复时间戳,不去重 |
| 数组越界 | 双指针移动时左指针超过右指针 | 在循环内加l <= r保护判断 |
| 代码复杂难调 | 窗口维护逻辑太啰嗦 | 用简洁写法:右指针for循环+左指针while收缩 |
5. 同类题目扩展与进阶思路
5.1 滑动窗口应用的场景延伸
“日志统计”本质上是一个固定长度区间内的计数问题。这类问题在算法竞赛里出现频率极高,常见变体包括:
- 长度为D的窗口内最大点赞数是多少(不要求到K,而是求最大值)
- 多个帖子竞争热度,求最热帖子id(就是边维护边更新最大值)
- 时间区间是环形的(时间戳是一个环,需要考虑首尾相接的情况)
- 点赞数带上权重(每个人可以点赞多次,权重可能是非线性的)
这些变体在蓝桥杯后续的年份里都有影子。比如第十四届的某些模拟题、计数题,底层思路都和这道题一脉相承。掌握了滑动窗口,相当于拿到了区间统计问题的通用钥匙。
5.2 从滑动窗口到双指针的其他考法
双指针不只是滑动窗口的别名,它还衍生出很多变种:对撞指针(有序数组两数之和)、快慢指针(链表判环)、同向双指针(区间最值、去重)。在蓝桥杯中,双指针的结合场景很常见,比如“日志统计”这种“先排序再双指针”的模式,在很多真题里反复出现。
我建议备赛的同学把这道题刷透之后,主动做几道类似的题加深印象,比如POJ上的某些区间统计题、LeetCode的“无重复字符的最长子串”“长度最小的子数组”等。这些题的底层框架高度相似,都是“右指针扩展 + 左指针收缩”,区别只在于收缩条件和答案更新时机。
5.3 如果数据规模再扩大怎么办
如果这道题的N扩大到10^6甚至更大,O(N log N)的排序可能成为瓶颈。这时候可以考虑把时间戳的统计换成“桶排序”思想:由于时间戳范围有限(比如最大是10^5),可以用计数数组直接记录每个时间点是否有点赞,再做前缀和。但这样做的前提是提前知道时间戳的最大范围,否则空间可能不够。
还有一种思路是用“对偶尺取”或者其他更高级的算法,但蓝桥杯一般不会考到这么深。目前这道题的数据范围,排序+双指针是绝对够用的。
5.4 考场实战建议
在2026年蓝桥杯备考中,这道题很适合作为“模拟+数据结构”模块的重点练习。我给大家几个实战层面的建议:
- 拿到题先看数据范围,不要急着写代码。如果N在10^5量级,任何O(N^2)的做法都不可取,直接往滑动窗口上思考。
- 代码写完先构造边界测试,比如K=1、D=1、所有点赞集中在同一秒这类极端情况,确认无误再提交。
- 蓝桥杯的评测是赛后统一判分,没有实时反馈,所以必须靠平时的扎实积累来保证一遍AC。
6. 实操总结与个人经验记录
这道“日志统计”我刷了不止一遍,不同时期的收获完全不同。第一次做的时候也是一通暴力,结果在时间测试点上翻车。第二次静下来分析数据特征,才想到排序+双指针。到第三遍做的时候,已经能几分钟内写出完全正确的代码,连注释都不用加。
其中有一个细节印象特别深刻:我当时用了unordered_map但忘了最后排序,结果输出的热帖顺序是乱的。界面上显示答案错误,我看了很久都没发现问题,最后对比官方输出才恍然大悟。从那时起,我养成了一个习惯——凡是最后要求按顺序输出的题目,优先用map,如果用了unordered_map,一定在末尾补上sort。
还有一个体会是,这种区间计数问题最怕的不是算法不会,而是边界条件不清。建议大家在做这类题的时候,把“区间开闭”“不少于”这些描述专门圈出来,转化为代码中的具体符号。我的习惯是先在草稿纸上写“开区间:< d;点赞数:>= k”,再动键盘,这样能大幅减少低级错误。
最后再分享一个小技巧:如果题目数据很弱(N很小),暴力写起来快,但千万不要养成依赖暴力的习惯。蓝桥杯近几年的题目数据范围逐年增大,以前暴力能过的题,现在可能就过不了。把滑动窗口这种基础算法练成肌肉记忆,才是考场上的真正保障。
这道题虽然只是众多蓝桥杯真题中的一道,但它的价值在于:用最朴素的方式展示了“如何从暴力思维过渡到优化思维”。这也是我在备赛过程中最看重的收获。