前几天我在群里看到一个问题:限制一个函数在 $m$ 毫秒内只能执行 $n$ 次。
我看到这个题目的第一反应,其实是它和节流很像。
因为节流做的事情,本质上也是在一段时间内限制函数的触发频率。最常见的节流写法,是让函数在固定时间间隔内最多执行一次。这个题目只是把“最多执行一次”放宽成了“最多执行 $n$ 次”,所以我一开始会自然地把它往节流的方向去理解。
第一反应:这是节流的变种
我最开始的判断是,这题应该可以参考节流的思路来做。
普通节流通常会维护一个上次执行时间:
function throttle(fn, delay) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= delay) {
lastTime = now;
fn.apply(this, args);
}
};
}
如果把这个思路继续往下推,就会发现这里和普通节流还是有一个明显差别:
- 节流限制的是“一段时间内最多执行 1 次”
- 这个题限制的是“一段时间内最多执行 $n$ 次”
也就是说,这道题确实和节流很像,但不能直接照搬普通节流的实现。
我一开始踩到的坑
最开始我尝试过用 setTimeout 去做,思路大概是:
- 调用时判断当前次数有没有超过上限。
- 如果没超过,就注册一个 $m$ 毫秒后的定时器。
- 定时器执行时再去真正调用函数,并修改计数。
这种写法一开始看起来很顺,但很快就会出现问题。
比如下面这种思路:
function limitFunction(fn, m, n) {
let callCount = 0;
return function (...args) {
if (callCount >= n) return;
setTimeout(() => {
fn.apply(this, args);
callCount++;
}, m);
};
}
问题在于,callCount++ 发生得太晚了。
如果我在很短时间内连续调用很多次,前面的调用都还没等到定时器执行,这时候 callCount 还是旧值,后面的调用就还是会继续放行。结果就是:本来想限制调用次数,实际上却放进去了很多个延迟任务。
换句话说,这个版本限制的不是“调用时刻的次数”,而更像是在错误的时机做统计,所以它并不能真正表达“$m$ 毫秒内最多执行 $n$ 次”这件事。
第二步理解:计数必须在调用当下生效
后来我意识到,关键不是“什么时候执行函数”,而是“什么时候占掉一个名额”。
如果这次调用已经被允许,那它就应该在调用当下立刻占用一个名额,并且函数也应该立即执行。真正需要延后的是“名额释放”这件事,而不是函数执行本身。
于是我把思路改成了这样:
function limitFunction(fn, m, n) {
let callCount = 0;
return function (...args) {
if (callCount >= n) return;
callCount++;
fn.apply(this, args);
setTimeout(() => {
callCount--;
}, m);
};
}
这一版比前一版好很多,因为名额是在调用发生的那一刻就被占用了,而且函数也在被允许的那个瞬间立刻执行。
但我后来又发现,这里还有一个更细的区别:
这段代码其实已经不是我后来理解的“固定窗口”了,它实现出来的更像是“滑动窗口”。
关键分歧:题目里的“$m$ 毫秒内”到底是什么意思
这道题真正不严谨的地方,其实就在这里。
“$m$ 毫秒内最多执行 $n$ 次”这句话,至少可以有两种理解:
- 固定窗口:从某个窗口起点开始,接下来的 $m$ 毫秒里最多执行 $n$ 次;窗口结束后,整体重新开始计数。
- 滑动窗口:对任意一个当前时刻,都去看它往前数 $m$ 毫秒这一段时间里已经执行了多少次,只要达到 $n$ 次,就不能再放行。
这两种理解看起来很像,但实现方式和结果都不一样。
为什么 setTimeout 这版其实是滑动窗口
先看这段代码:
function limitFunction(fn, m, n) {
let callCount = 0;
return function (...args) {
if (callCount >= n) return;
callCount++;
fn.apply(this, args);
setTimeout(() => {
callCount--;
}, m);
};
}
它的本质不是“整段窗口一起重置”,而是“每一次成功调用,都单独占用一个名额,并在各自的 $m$ 毫秒之后释放”。
也就是说:
- 第 1 次调用,有自己的释放时间
- 第 2 次调用,也有自己的释放时间
- 第 3 次调用,还是有自己的释放时间
每个名额都是独立过期的,所以窗口不是整块往前跳,而是随着时间连续滑动。这就是滑动窗口的味道。
比如这组调用:
const limitedFn = limitFunction(() => console.count("调用次数"), 3000, 3);
limitedFn(); // 输出:调用次数: 1
setTimeout(() => {
limitedFn(); // 输出:调用次数: 2
}, 1000);
setTimeout(() => {
limitedFn(); // 输出:调用次数: 3
}, 1000);
setTimeout(() => {
limitedFn(); // 输出:调用次数: 4
}, 3100);
setTimeout(() => {
limitedFn(); // 不输出,因为前面 2 个名额还没释放
}, 3200);
如果我按“固定窗口”的直觉去想,我会期待输出是 1 2 3 4 5。
因为我会把前 3 秒看成一个整体窗口:
- 第 1、2、3 次落在第一个窗口里
- 到了 3100ms,窗口已经结束
- 所以后面的第 4、5 次应该都落进第二个窗口里
但这段 setTimeout 版本实际不是这样工作的。
它的真实过程更像是:
t = 0时第 1 次调用,占掉 1 个名额,t = 3000释放t = 1000时第 2 次调用,占掉 1 个名额,t = 4000释放t = 1000时第 3 次调用,占掉 1 个名额,t = 4000释放t = 3100时第 1 个名额已经释放,所以第 4 次可以执行t = 3200时第 2、3 个名额还没释放,所以第 5 次会被拦住
所以输出会是 1 2 3 4,而不是 1 2 3 4 5。
这也正是我后来才想清楚的点:我以为自己写的是“固定窗口限次”,其实写出来的是“滑动窗口限次”。
方案一:滑动窗口实现
如果你对题目的理解是“任意连续 $m$ 毫秒内最多执行 $n$ 次”,那上面的 setTimeout 版本其实已经可以成立。
它可以理解成一个很直观的滑动窗口写法:成功调用一次,就占用一个名额;$m$ 毫秒后,这个名额自动归还。
function limitFunctionSliding(fn, m, n) {
let callCount = 0;
return function (...args) {
if (callCount >= n) return;
callCount++;
fn.apply(this, args);
setTimeout(() => {
callCount--;
}, m);
};
}
如果想把滑动窗口表达得更明确一点,也可以直接记录时间戳:
function limitFunctionSliding(fn, m, n) {
const timestamps = [];
return function (...args) {
const now = Date.now();
while (timestamps.length && now - timestamps[0] >= m) {
timestamps.shift();
}
if (timestamps.length >= n) return;
timestamps.push(now);
fn.apply(this, args);
};
}
这两个版本表达的是同一种语义,只是一个是“延迟释放名额”,另一个是“显式保存窗口内的调用时间”。
方案二:固定窗口实现
如果我理解的题意是“从窗口起点开始,接下来 $m$ 毫秒里最多执行 $n$ 次;窗口结束后统一重置”,那就应该用固定窗口的写法。
这种情况下,窗口里的调用不会各自单独过期,而是整段窗口一起结束。
function limitFunctionFixed(fn, m, n) {
let windowStart = 0;
let callCount = 0;
return function (...args) {
const now = Date.now();
if (now - windowStart >= m) {
windowStart = now;
callCount = 0;
}
if (callCount >= n) return;
callCount++;
fn.apply(this, args);
};
}
这版的逻辑是:
- 第一次调用时会自然开启一个窗口,之后只有超过窗口时长才会整体切到下一个窗口。
- 在这个窗口的 $m$ 毫秒内,只允许最多执行 $n$ 次。
- 一旦超过窗口时长,就整体开启下一个窗口,并把计数归零。
按这个语义,前面那组测试就会得到你直觉里的结果:前 3 次在第一个窗口内执行,3100ms 和 3200ms 的两次则落到新的窗口里,所以会输出 1 2 3 4 5。
到这里我才真正想清楚:不是代码先错,而是语义先分叉了
我后面再回头看,会发现我最开始并不是单纯“代码写错了”,而是我在脑子里默认了“固定窗口”的题意,但写出来的实现却更符合“滑动窗口”。
所以真正需要先回答的问题不是“这题像不像节流”,而是:
这个题里的“$m$ 毫秒内”到底指的是哪一种窗口?
一旦这个前提没说清楚,讨论“对不对”其实很容易鸡同鸭讲。
这次思考里我最重要的收获
这道题对我来说最有价值的地方,不是最后写出了哪一版代码,而是把几个容易混在一起的概念区分开了:
- 节流是频率控制
- 时间窗口限次是额度控制
- 固定窗口和滑动窗口,虽然都叫“$m$ 毫秒内最多 $n$ 次”,但含义并不一样
setTimeout只能帮我延后某段逻辑,不会自动帮我得到正确的统计时机和窗口语义
我最开始看到题目时,第一反应是“这和节流很像”。现在回头看,这个直觉本身没有问题,它确实是一个很自然的切入点。
但继续往下想,真正重要的是把“像不像节流”再拆开一层,去问自己:
这个题到底要限制的是什么?是执行间隔,还是窗口内的总次数?是固定窗口,还是滑动窗口?
当我把这个问题想清楚以后,后面的实现方向就顺了很多。
小结
如果只用一句话总结我对这个题的理解,那就是:
我一开始把它当成节流的变种来理解,这个方向没错;但真正把题拆开之后,我发现它其实至少有两种常见语义:固定窗口限次和滑动窗口限次。
所以后来我更愿意先问清楚题意,再决定写哪一种实现。很多时候,不是代码先出了问题,而是题目本身没有把窗口语义说完整。