设计一个点击计数器,计算过去 5 分钟内收到的点击次数。
资料来源:微软面试体验
“设计命中计数器”问题最近被包括 Dropbox 在内的许多公司提出,而且这个问题比看起来更难。它包括几个主题,如基本数据结构设计、各种优化、并发和分布式计数器。
它应该支持以下两种操作: hit和getHits 。
hit(timestamp) – 显示给定时间戳的命中。
getHits(timestamp) – 返回过去 5 分钟(300 秒)内收到的点击次数(来自 currentTimestamp)。
每个函数接受一个时间戳参数(以秒为单位),您可以假设正在按时间顺序对系统进行调用(即时间戳单调递增)。您可以假设最早的时间戳从 1 开始。
例子:
HitCounter counter = new HitCounter();
// hit at timestamp 1.
counter.hit(1);
// hit at timestamp 2.
counter.hit(2);
// hit at timestamp 3.
counter.hit(3);
// get hits at timestamp 4, should return 3.
counter.getHits(4);
// hit at timestamp 300.
counter.hit(300);
// get hits at timestamp 300, should return 4.
counter.getHits(300);
// get hits at timestamp 301, should return 3.
counter.getHits(301);
问:微软、亚马逊、Dropbox 和更多公司。
1.简单的解决方案(蛮力方法):
我们可以使用一个向量来存储所有的命中。这两个函数是不言自明的。
vector v;
/* Record a hit.
@param timestamp - The current timestamp (in
seconds granularity). */
void hit(int timestamp)
{
v.push_back(timestamp);
}
// Time Complexity : O(1)
/** Return the number of hits in the past 5 minutes.
@param timestamp - The current timestamp (in
seconds granularity). */
int getHits(int timestamp)
{
int i, j;
for (i = 0; i < v.size(); ++i) {
if (v[i] > timestamp - 300) {
break;
}
}
return v.size() - i;
}
// Time Complexity : O(n)
2.空间优化方案:
我们可以使用队列来存储命中并删除队列中无用的条目。它将节省我们的空间。
我们正在从队列中删除额外的元素。
queue q;
/** Record a hit.
@param timestamp - The current timestamp
(in seconds granularity). */
void hit(int timestamp)
{
q.push(timestamp);
}
// Time Complexity : O(1)
/** Return the number of hits in the past 5 minutes.
@param timestamp - The current timestamp (in seconds
granularity). */
int getHits(int timestamp)
{
while (!q.empty() && timestamp - q.front() >= 300) {
q.pop();
}
return q.size();
}
// Time Complexity : O(n)
3.最优化的解决方案:
如果数据是无序的,并且多次点击带有相同的时间戳怎么办。
由于队列方法在没有有序数据的情况下无法工作,因此这次使用数组来存储每个时间单位的命中计数。
如果我们以 300 秒的秒粒度跟踪过去 5 分钟内的命中,则创建 2 个大小为 300 的数组。
int[] hits = new int[300];
TimeStamp[] 次 = 新的 TimeStamp[300]; // 最后一次计数命中的时间戳
给定一个传入,将其时间戳修改 300 以查看它在 hits 数组中的位置。
int idx = 时间戳 % 300; => hits[idx] 保持命中计数发生在这一秒
但是在我们将 idx 处的命中计数增加 1 之前,时间戳确实属于 hits[idx] 正在跟踪的秒。
timestamp[i] 存储最后计数的命中的时间戳。
如果时间戳 [i] > 时间戳,则应丢弃此命中,因为它在过去 5 分钟内未发生。
如果 timestamp[i] == timestamp,则 hits[i] 增加 1。
如果时间戳 [i] currentTime – 300。
vector times, hits;
times.resize(300);
hits.resize(300);
/** Record a hit.
@param timestamp - The current timestamp
(in seconds granularity). */
void hit(int timestamp)
{
int idx = timestamp % 300;
if (times[idx] != timestamp) {
times[idx] = timestamp;
hits[idx] = 1;
}
else {
++hits[idx];
}
}
// Time Complexity : O(1)
/** Return the number of hits in the past 5 minutes.
@param timestamp - The current timestamp (in
seconds granularity). */
int getHits(int timestamp)
{
int res = 0;
for (int i = 0; i < 300; ++i) {
if (timestamp - times[i] < 300) {
res += hits[i];
}
}
return res;
}
// Time Complexity : O(300) == O(1)
如何处理并发请求?
当两个请求同时更新列表时,可能会出现竞争条件。首先更新列表的请求可能最终不会被包含在内。
最常见的解决方案是使用锁来保护列表。每当有人想要更新列表(通过添加新元素或删除尾部)时,都会在容器上放置一个锁。操作完成后,列表将被解锁。
当您没有大量请求或性能不是问题时,这非常有效。有时,放置锁的成本可能很高,并且当并发请求过多时,锁可能会阻塞系统并成为性能瓶颈。
分发计数器
当单台机器的流量过多并且性能成为问题时,是考虑分布式解决方案的最佳时机。分布式系统通过将系统扩展到多个节点,显着降低了单台机器的负担,但同时也增加了复杂性。
假设我们将访问请求平均分配给多台机器。我想首先强调平均分配的重要性。如果特定机器的流量比其他机器多得多,则系统无法充分利用,在设计系统时考虑到这一点非常重要。在我们的例子中,我们可以获取用户电子邮件的散列并通过散列进行分发(直接使用电子邮件不是一个好主意,因为某些信件可能比其他信件出现得更频繁)。
为了计算数量,每台机器独立工作,从过去一分钟开始计算自己的用户。当我们请求全局编号时,我们只需要将所有计数器加在一起。
参考资料:我最初在这里发表了它。
https://aonecode.com/getArticle/211
如果您希望与专家一起参加现场课程,请参阅DSA 现场工作专业课程和学生竞争性编程现场课程。