程序员老鬼

领导,请停止对我的侵犯!

我在网上看到一个帖子,标题就很有味儿:“领导,请停止对我的侵犯。”先别紧张,这里说的不是啥悬疑剧情,说白了,就是晚上10点突然被拉去开复盘会。白天开不够,非得挑人脑子快关机的时候狠狠干一票,属实有点离谱。

Image

发帖那位网友讲得很清楚,他不是不想复盘,也不是摆烂,更不是对工作没责任心。他只是想留一点活人该有的时间。白天忙成陀螺,晚上刚准备洗澡、吃口热饭,结果手机一震:来,开会。那一刻,别说复盘了,魂都快盘没了。

说实话,我特别能共情。工作当然要认真,但认真不等于24小时待命。成年人上班是为了生活,不是把生活整个打包献祭给工位。真想把事做好,白天把节奏理顺,比半夜抓人开会强太多了。

面试题:敲击计数器

面试里碰到“敲击计数器”这题,我一般不会一上来就讲队列、环形数组这些名词,先把题意掰正:系统不断收到一次次敲击事件,给你一个时间戳 timestamp,表示这一秒发生了一次 hit;然后随时会问一句,最近 300 秒一共敲了多少次。

这题看着像计数,实际考的是“窗口”。

最容易想到的写法,就是来一次敲击就记一次时间。查询的时候,把 300 秒之外的数据都清掉,剩下多少条就是多少次。

先看一个直观版本,代码不复杂,但很好讲清楚思路:

import java.util.ArrayDeque;
import java.util.Deque;

classHitCounter{
privatefinal Deque<Integer> queue = new ArrayDeque<>();

publicvoidhit(int timestamp){
        queue.addLast(timestamp);
        clearExpired(timestamp);
    }

publicintgetHits(int timestamp){
        clearExpired(timestamp);
return queue.size();
    }

privatevoidclearExpired(int timestamp){
while (!queue.isEmpty() && timestamp - queue.peekFirst() >= 300) {
            queue.pollFirst();
        }
    }
}

这个版本很适合先把题做出来。queue 里存的是每一次敲击的时间,窗口往前滑的时候,把过期数据从队头弹掉。 如果面试官没继续追问,到这里其实已经够了。

但这题通常还会补一刀:如果某一秒内有很多次敲击怎么办?比如同一秒打进来几万次,你还一条条往队列里塞,空间就不太好看了。

这时候就该把“按事件存”换成“按秒聚合存”。

classHitCounter{
privatefinalint[] times = newint[300];
privatefinalint[] hits = newint[300];

publicvoidhit(int timestamp){
int idx = timestamp % 300;
if (times[idx] != timestamp) {
            times[idx] = timestamp;
            hits[idx] = 1;
        } else {
            hits[idx]++;
        }
    }

publicintgetHits(int timestamp){
int sum = 0;
for (int i = 0; i < 300; i++) {
if (timestamp - times[i] < 300) {
                sum += hits[i];
            }
        }
return sum;
    }
}

这里真正关键的是这两行:

int idx = timestamp % 300;
if (times[idx] != timestamp)

意思很直接:数组只保留最近 300 秒的位置,同一个位置会被 300 秒之后的新时间覆盖。 但覆盖之前要先确认,这个槽位里是不是当前这一秒的数据,不是的话就重置,是的话直接累加。

这个写法有点像线上做限流、滑动窗口统计时常见的桶计数。 优点也很明显:

  • hit() 是 O(1)
  • getHits() 固定扫 300 个桶,也是 O(1)
  • 空间稳定,不会因为敲击次数暴涨而膨胀

这题不难,难点主要在于你是不是能从“记录每次点击”自然过渡到“记录每秒汇总”。前者是把题做对,后者是把题做得像线上可用。