题目描述
在 RecentCounter 类中有一个方法:ping(int t),t 代表某个时间(毫秒),返回从 3000 毫秒前(时间范围:[t - 3000, t] )到现在的 ping 数
- 保证每次对 ping 的调用都使用比之前更大的 t 值,1 <= t <= 10^9
- 每个测试用例会使用严格递增的 t 值来调用 ping,最多调用 10000 次 ping
数据结构
- 数组、链表、队列
算法思维
- 遍历、双指针、队列 FIFO 特性
解题要点
- 尽量减少遍历操作
- 使用双指针 或 队列 实时维护“目标数据”
- 充分利用 队列的 FIFO 特性
队列(Queue)
➢ 始终在一端插入数据,另一端删除数据
➢ 先进先出(FIFO,First Input First Output)
➢ 插入和删除时间复杂度:O(1)
基本操作
➢ 入队:向队列尾部添加节点,size++
➢ 出队:从队列头部删除节点,size--
队列的应用
➢ 生产者消费者模式
➢ CPU调度多线程
➢ 消息队列
➢ 作为缓冲区提高效率
解题步骤
一. Comprehend 理解题意
题目主要含义
- 在 [1-10^9] 时间范围内的任意一个(整数)时间点都有可能发生请求
- 下一次请求时间一定大于上一次请求时间,可以看作一个递增数列,求指定元素到此前某个元素之间的总个数
- 每次请求都会执行 ping(int t)方法,该方法返回 t 时间点及此前3000毫秒内的请求总次数(包含 t 时间点和 t-3000 时间点)
附加信息
- 请求时间取值范围在 int 类型内
- 测试用例最多调用 10000 次,即最多产生 10000 个时间点
二. Choose 选择数据结构与算法
解题思路
- 解法一:遍历数组
- 解法二:数组 + 双指针
解法一:遍历数组
- 把请求时间点看成是递增数组
- 每次请求统计 t 时间点到此前 3000 毫秒之间的请求次数
数据结构:数组
算法思维:遍历
优化思路
- 最多有多少次符合要求的请求?
t 时刻一次请求 + 前 3000 秒(每秒最多 1次)= 3001次 - 统计请求次数,是否有必要遍历已发生的所有请求?
记录当前请求时间点对应的符合要求的起止索引,下次缩小检查范围
解法二:数组 + 双指针
数据结构:数组 + 指针
算法思维:双指针
三. Code 编码实现基本解法
解法一:分解、删除再合并
- 创建数组,存放所有的请求(整型数组,存放10000个元素)
- 把当前请求存入数组,记录最后一次存入的索引,从0开始
- 从最后一次存放位置倒序遍历,统计距离此次请求前 3000 毫秒之间的请求次数
边界问题
- 数组越界:题目给定最多存放 10000 个元素,因此不会越界
细节问题
- 存入数组的元素都 > 0(整型默认值)
- 记录最后一次存入的索引,倒序遍历每个元素,直到元素小于 t-3000
class RecentCounter {
// 1. 创建数组,存放所有的请求
int[] array = new int[10000];
public int ping(int t) {
int end = 0; // 最近一次请求存放的索引,从0开始
// 2.把当前请求存入数组
for(int i = 0; i < 10000; i++) {
if(array[i] == 0) { // 细节:数组元素为0,则该位置没有存过请求
array[i] = t;
end = i; // 记录最近一次请求存放的索引
break; // 存放操作完成
}
}
// 3.统计前3000毫秒之间的请求次数
int count = 0; // 计数器
while(array[end] >= t - 3000) { // 数组元素在符合要求的范围内
count++;
if(--end < 0) { // 倒序遍历,防止越界
break;
}
}
return count;
}
}
时间复杂度:O(n2) -- 遍历数组 O(n),ping 方法被调用 n(n-1+n) 次 O(n2)
空间复杂度:O(1) -- 固定长度 10000 的数组 O(1)
执行耗时:400 ms,击败了 5.71% 的Java用户
内存消耗:47.2 MB,击败了 64.72% 的Java用户
解法二:数组 + 双指针
- 创建数组存放请求:int[3002]
- 额外定义开始指针:start=0,end=0,记录起止索引
- 存放请求后,更新起止索引:end++
从上次的开始索引(start)向后查找,直到新的合法的起始位置 - 通过 end 与 start 差值计算请求次数
边界问题
- 计算控制 start 和 end,请求次数超过数组容量则越界
细节问题
- end 指针溢出重新指向 0,start 同理
- 计算请求次数时,包含 start 和 end
class RecentCounter {
// 1. 创建数组存放请求,最大合法请求次数为3001次(双闭区间)
final int length = 3002; // 增加一个额外空间
// 2. 记录起止索引,从0开始
int start = 0, end = 0;
int[] array = new int[length];
public int ping(int t) {
// 3. 存放请求后,更新起止索引
array[end++] = t; // 存放最近一次请求,结束索引加1
end = end == length ? 0 : end; // 越界后,从头开始
// 从start位置正向查找符合要求的请求次数
while(array[start] < t - 3000) { // 过滤所有不符合要求的数据
start++; // 开始指针后移一位,继续循环检测下一个位置的元素
start = start == length ? 0 : start; // 越界后,从头开始
}
// 4. 通过end与start差值计算请求次数
if(start > end) { // 请求次数超过数组容量,发生了溢出
return length - (start - end);
}
// 此时,end为最新一次请求+1的索引,start是3000毫秒前的第一次合法请求的索引
return end - start;
}
}
时间复杂度:O(n) -- 存入数组 O(1),过滤不符合要求的元素 1~3002 O(1),ping方法调用n次 O(n)
空间复杂度:O(1) -- 固定长度 3002 的数组 O(1) ,两个指针 O(1)
执行耗时:24 ms,击败了 99.91% 的Java用户
内存消耗:46.7 MB,击败了 97.76% 的Java用户
四. Consider 思考更优解
剔除无效代码,优化空间消耗
- 创建成千上万个容量的数组比较浪费空间,能否动态扩展容器的容量?
- 是否有其它更方便的数据结构?
寻找更好的算法思维
- 精准计算首尾指针的索引容易出错,能否做到不计算就获取首尾?
- 借鉴其它算法
最优解:队列解法
数据结构:队列
算法思维:队列操作(入队、出队)
五. Code 编码实现最优解
最优解:队列解法
- 使用链表实现一个队列
定义属性:队头 -- head、队尾 -- tail、长度 -- size
定义方法:添加节点 -- add(int)、移除节点 -- poll() 、队列长度 -- size()
定义内部类:Node,封装每次入队的请求数据和指向下一个节点的指针 - 每次请求向队列尾部追加节点
- 循环检查队头数据是否合法,不合法则移除该节点
- 返回队列长度
边界问题
- 入队和出队始终关注首尾节点指针
细节问题
- 第一次添加节点,首尾指针都是 null
- 每次追加尾节点,size++,重置 tail
- 每次移除头结点,size--,重置 head
class RecentCounter {
Queue q = new Queue();
public int ping(int t) {
q.add(t);
while(q.head.getVal() < t - 3000) q.poll();
return q.size();
}
class Queue {
Node head;
Node tail;
int size = 0;
public void add(int x) { // 向尾部添加一个节点
Node last = tail; // 获取原来的尾节点
Node newNode = new Node(x); // 创建新节点,封装数据
tail = newNode; // 尾指针指向新节点
if(last == null) { // 第一次添加数据
head = newNode; // 头节点为新节点
tail = newNode;
}
else {
last.next = newNode; // 前一个节点指向新节点
}
size++; // 每添加一个节点,队列长度+1
}
public int poll() { // 从头部移除一个节点
int headVal = head.val; // 获取头节点的数据
Node next = head.next; // 获取头节点的下一个节点
head.next = null; // 断开队列链接,help GC
head = next; // 头指针指向下一个节点
if(next == null) { // 队列中的最后一个元素
tail = null; // 处理尾指针
}
size--; // 每移除一个节点,队列长度-1
return headVal;
}
public int size() {
return size;
}
class Node {
int val;
Node next;
Node(int x) {
val = x;
}
int getVal() {
return val;
}
}
}
}
时间复杂度:O(1) -- 添加或删除元素 O(1)
空间复杂度:O(1) -- 最多保留 3001 个元素(1~3001)O(1)
执行耗时:31 ms,击败了 43.43% 的Java用户
内存消耗:46.7 MB,击败了 97.76% 的Java用户
六. Change 变形与延伸
题目变形
- (练习)数组 + 双指针模式也是队列的另一种存在形式
延伸扩展
- 实际编码中,常常在初始化队列时创建一个空的 Node 对象作为 head 节点,同时 tail 也指向这个 Node 对象,即:
head = tail = new Node();
从而减少对头指针的非空判断