算法 1.4 队列:最近的请求次数【leetcode 933】

题目描述

在 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 选择数据结构与算法
解题思路
  • 解法一:遍历数组
  • 解法二:数组 + 双指针
解法一:遍历数组
  1. 把请求时间点看成是递增数组
  2. 每次请求统计 t 时间点到此前 3000 毫秒之间的请求次数

数据结构:数组
算法思维:遍历

优化思路
  • 最多有多少次符合要求的请求?
    t 时刻一次请求 + 前 3000 秒(每秒最多 1次)= 3001次
  • 统计请求次数,是否有必要遍历已发生的所有请求?
    记录当前请求时间点对应的符合要求的起止索引,下次缩小检查范围
解法二:数组 + 双指针

数据结构:数组 + 指针
算法思维:双指针

三. Code 编码实现基本解法
解法一:分解、删除再合并
  1. 创建数组,存放所有的请求(整型数组,存放10000个元素)
  2. 把当前请求存入数组,记录最后一次存入的索引,从0开始
  3. 从最后一次存放位置倒序遍历,统计距离此次请求前 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用户

解法二:数组 + 双指针
  1. 创建数组存放请求:int[3002]
  2. 额外定义开始指针:start=0,end=0,记录起止索引
  3. 存放请求后,更新起止索引:end++
    从上次的开始索引(start)向后查找,直到新的合法的起始位置
  4. 通过 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 编码实现最优解
最优解:队列解法
  1. 使用链表实现一个队列
    定义属性:队头 -- head、队尾 -- tail、长度 -- size
    定义方法:添加节点 -- add(int)、移除节点 -- poll() 、队列长度 -- size()
    定义内部类:Node,封装每次入队的请求数据和指向下一个节点的指针
  2. 每次请求向队列尾部追加节点
  3. 循环检查队头数据是否合法,不合法则移除该节点
  4. 返回队列长度
边界问题
  • 入队和出队始终关注首尾节点指针
细节问题
  • 第一次添加节点,首尾指针都是 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(); 从而减少对头指针的非空判断

你可能感兴趣的:(算法 1.4 队列:最近的请求次数【leetcode 933】)