前言
尝试面试微软的暑期实习,结果在终面的时候被一道设计题给干趴了。本来以为会和之前几面一样上来丢个算法题做就是了,没想到突然来情境设计,弄得我当时的节奏有点乱了😑。面试后,我也尝试着去重新思考该题,但多次尝试都没有什么比较好的思路。最近,或许是因为空气被绵绵春雨洗刷得更加清新,使得我最近头脑比较清醒,致使我在图书馆窗边发呆的时候,突然又思考起这道题,然后灵光乍现。
题目分析设计
现在开始进入正题:
设计一个缓存,用于存储事件。每一个事件有两个主要的属性:事件类型、发生时间。接着,这个缓存需要提供一个方法 getTopN(st, en, n),该方法获取时间窗口 [st,en] 中出现频率最高的 n 个事件类型。时间复杂度和空间复杂度越优越好。
从题目看,事件的结构已经非常明显了,就是包含 type、time 两个字段。主要难点在于,如何组织这些事件来方便之后快速地定位一个时间窗口,更直白一点就是要方便根据 time 这个字段进行范围查询。一说到范围查询我就想到了数据库的范围查询,然后想到了 B+ 树,嘿,思维就是这么跳跃。但是面试当场写 B+ 树的话,对于我而言就是作死……接着,联想同样与 B+ 树一样能够范围查询的数据结构:
平衡二叉搜索树。树搜索比较高效,能够支持范围查询,单查询的过程是树搜索,涉及到深搜回溯之类的,总感觉范围查询得不够优雅纯粹,而且实现起来也是比较麻烦,暂时不作为选择。
跳表。跳表实际上就是在链表的基础上建立了索引,使之能够支持类似二分查找的过程,所以搜索效率也高。更好的一点是,跳表对范围查询的支持很好!要得到一个 [st, en] 的时间窗口的话,就在跳表中查找到时间 st,接着顺序向后遍历即可。所以,使用跳表作为这个事件缓存的数据结构。

这样,定位一个时间窗口的问题就解决了,查询窗口下界的时间基本和二分查找一样是 logN。
接着,要获取时间窗口内的 topN 个事件类型。这个看似简单,但似乎也没有想到什么比较好的方法,目前的思路就是将这个区间的所有事件按照类型关键字排序,然后再顺序遍历一遍。这样的时间复杂度为 O(NlgN),由于不能破坏原本的存储,所以需要对这个区间进行一次拷贝,故空间复杂度需要为 O(N)。另一种方案是,采用哈希表来进行统计,最后取出哈希表的元素进行排序,最终的复杂度也是一致的。
所以,总体的设计就是:
- 事件以 time 作为关键字,用跳表组织。
- getTopN 时查询跳表,定位到时间窗口的下界。
- 使用哈希表统计各个类型的出现次数。
- 维护一个大小为 N 的堆,将哈希表的所有元素放入。
实现代码
代码还是比较多的,感觉面试时要写出来还是比较困难……之后会展示更容易实现的一种方式。
public class EventCache {class Event implements {int time; // 事件发生时间int type; // 事件发生类型}// 跳表进行范围查询,解决时间窗口的问题class SkipList {private int MAX_LVL = 32;private Node head = new Node(null, 32);private int lvlCnt = 1; // 跳表的总层数class Node {Event e;Node[] nexts; // 向后指针int maxLvl; // 节点的最高层级Node(Event e, int maxLvl) {this.nexts = new Node[maxLvl];this.maxLvl = maxLvl;this.e = e;}}// 每高一层都要少 50% 的概率,即// 50% 的概率 1 层,25% 的概率 2 层,// 12.5% 的概率 3 层private int randomLvl() {int lvl = 1;while (Math.random() < 0.5f && lvl < MAX_LVL) {lvl += 1;}return lvl;}public void insert(Event e) {int lvl = randomLvl();this.lvlCnt = Math.max(lvlCnt, lvl);Node n = new Node(e, lvl);Node[] needUpdates = new Node[lvl];Node p = head, next;// 从最高层开始 自顶向下插入新的节点for (int i = lvlCnt - 1; i >= 0; i--) {while (p.maxLvl > i &&(next = p.nexts[i]) != null && next.e.time < e.time) {p = next;}if (i < lvl) {needUpdates[i] = p;}}for (int i = 0; i < lvl; i++) {n.nexts[i] = needUpdates[i].nexts[i];needUpdates[i].nexts[i] = n;}}// 查找时间 >= time 的第一个事件public Node findGtEq(int time) {Node p = head, next;for (int i = lvlCnt - 1; i >= 0; i--) {while (p.maxLvl > i &&(next = p.nexts[i]) != null && next.e.time < time) {p = next;}}return p.nexts[0];}}private SkipList cache = new SkipList();public List<Integer> getTopN(int st, int en, int n) {// 跳表进行范围查询SkipList.Node node = cache.findGtEq(st);// K:事件类型 V:出现次数Map<Integer, Integer> map = new HashMap<>();while (node != null && node.e.time <= en) {map.put(node.type, map.getOrDefault(node.type, 0) + 1);node = node.nexts[0];}// 最小堆PriorityQueue<Entry<Integer, Integer>> pq = new PriorityQueue<>((e1, e2) -> {return e1.getValue() - e2.getValue();});for (Entry<Integer, Integer> e : map.entrySet()) {pq.offer(e.getValue());if (pq.size() > n) {pq.poll();}}return pq.stream().map(e -> e.getValue()).collect(Collectors.toList());}}
简单版本
跳表的确是一个比较好的平衡查找与插入的数据结构,但是如果在面试的时候实现确实比较困难。由于时间总是递增的,所以以时间为关键字插入的时候,可以基本假设大部分情况有序。
这种情况可以使用插入排序的思想,每来一个新元素,就从后往前插入到其应在的位置,大部分情况就是直接末尾添加。由于还要支持区间的快速定位,所以可以考虑使用动态数组来实现,因为如果是末尾添加的话,除开拓容复制,动态数组添加元素也是比较高效的。
// Use ArrayList + binary search instead of SkipList,// which is less tricky to code.public static class Event {int time;int type;Event(int time, int type) {this.time = time;this.type = type;}}private List<Event> events = new ArrayList<>(); // dynamic array// top k in [a, b]public List<Integer> topK(int a, int b, int k) {int l = binSearch(a);int r = binSearch(b);List<Integer> res = new ArrayList<>();Map<Integer, Integer> map = new HashMap<>();for (int i = l; i <= r; i++) {Event e = events.get(i);if (e.time > b) break;map.put(e.type, map.getOrDefault(e.type, 0) + 1);}PriorityQueue<Entry<Integer, Integer>> pq =new PriorityQueue<>((e1, e2) -> e2.getValue() - e1.getValue()); // great heapfor (Entry<Integer, Integer> e: map.entrySet()) {pq.offer(e);}for (int i = 0; i < k; i++) {if (pq.isEmpty()) return res;res.add(pq.poll().getKey());}return res;}// insert sortpublic void happen(int time, int type) {Event e = new Event(time, type);for (int i = events.size() - 1; i >= 0; i--) {if (e.time >= events.get(i).time) {events.add(i + 1, e);return;}}events.add(0, e);}// return index, where event's time >= tprivate int binSearch(int t) {int l = 0, r = events.size() - 1;int index = 0;while (l <= r) {int mid = l + (r-l)/2;if (events.get(mid).time >= t) {index = mid;r = mid - 1;continue;}l = mid + 1;}return index;}
