可以,这个题如果改成 面试一片空白模式,一般要补上:

  • import java.util.*;

  • 外部测试类

  • main 函数

  • LRUCache

  • 打印测试结果

我先给你整理成一个 标准可运行的面试版本,尽量保持你原来的写法和思路不变。


import java.util.*;

public class InterviewSolution {

    public static void main(String[] args) {
        LRUCache cache = new LRUCache(2);

        cache.put(1, 1);
        cache.put(2, 2);
        System.out.println(cache.get(1)); // 1

        cache.put(3, 3); // 淘汰 key=2
        System.out.println(cache.get(2)); // -1

        cache.put(4, 4); // 淘汰 key=1
        System.out.println(cache.get(1)); // -1
        System.out.println(cache.get(3)); // 3
        System.out.println(cache.get(4)); // 4
    }

    static class LRUCache {
        private final int capacity;
        private final Node dummy = new Node(0, 0); // 哨兵节点
        private final Map<Integer, Node> keyToNode = new HashMap<>();

        private static class Node {
            int key;
            int value;
            Node prev;
            Node next;

            public Node(int k, int v) {
                key = k;
                value = v;
            }
        }

        public LRUCache(int capacity) {
            this.capacity = capacity;
            dummy.prev = dummy;
            dummy.next = dummy;
        }

        public int get(int key) {
            Node node = getNode(key); // getNode 会把对应节点移到链表头部
            return node != null ? node.value : -1;
        }

        public void put(int key, int value) {
            Node node = getNode(key); // getNode 会把对应节点移到链表头部

            if (node != null) {
                node.value = value;
                return;
            }

            node = new Node(key, value);
            keyToNode.put(key, node);
            pushFront(node);

            if (keyToNode.size() > capacity) {
                Node backNode = dummy.prev;
                keyToNode.remove(backNode.key);
                remove(backNode);
            }
        }

        // 获取 key 对应的节点,同时把该节点移到链表头部
        private Node getNode(int key) {
            if (!keyToNode.containsKey(key)) {
                return null;
            }

            Node node = keyToNode.get(key);
            remove(node);
            pushFront(node);
            return node;
        }

        // 删除一个节点
        private void remove(Node x) {
            x.prev.next = x.next;
            x.next.prev = x.prev;
        }

        // 头插一个节点
        private void pushFront(Node x) {
            x.prev = dummy;
            x.next = dummy.next;
            x.prev.next = x;
            x.next.prev = x;
        }
    }
}

这种写法在面试里是很标准的

结构就是:

public class InterviewSolution {
    public static void main(String[] args) {
        // 写测试
    }

    static class LRUCache {
        ...
    }
}

这就是典型的 空白模式


面试时你最好能顺口讲出这几个点

1. 用了什么数据结构

HashMap + 双向链表
  • HashMap:O(1) 找到节点

  • 双向链表:O(1) 删除节点、移动节点到头部、删除尾部节点


2. 为什么有 dummy 哨兵节点

为了统一链表操作,避免判空。

这样删除和插入都更方便,不需要区分:

  • 空链表

  • 只有一个节点

  • 头节点

  • 尾节点


3. get(key) 做了什么

  • 先通过 map 找节点

  • 找到后把节点移到链表头部

  • 返回值

  • 找不到返回 -1


4. put(key, value) 做了什么

  • 如果 key 已存在,更新 value,并移到头部

  • 如果不存在,新建节点,插到头部

  • 如果超过容量,删除尾节点


这份代码有一个小优化点

你现在 getNode 里写的是:

if (!keyToNode.containsKey(key)) {
    return null;
}
Node node = keyToNode.get(key);

面试里可以稍微优化成:

Node node = keyToNode.get(key);
if (node == null) {
    return null;
}

这样少查一次 map,更简洁。

优化后:

private Node getNode(int key) {
    Node node = keyToNode.get(key);
    if (node == null) {
        return null;
    }
    remove(node);
    pushFront(node);
    return node;
}

不过你原来的写法也没问题。


面试里更推荐的版本

我给你一个 稍微更像面试高分答案 的版本,只做一点点微调:

import java.util.*;

public class InterviewSolution {

    public static void main(String[] args) {
        LRUCache cache = new LRUCache(2);

        cache.put(1, 1);
        cache.put(2, 2);
        System.out.println(cache.get(1)); // 1

        cache.put(3, 3);
        System.out.println(cache.get(2)); // -1

        cache.put(4, 4);
        System.out.println(cache.get(1)); // -1
        System.out.println(cache.get(3)); // 3
        System.out.println(cache.get(4)); // 4
    }

    static class LRUCache {
        private final int capacity;
        private final Node dummy;
        private final Map<Integer, Node> keyToNode;

        static class Node {
            int key;
            int value;
            Node prev;
            Node next;

            Node(int key, int value) {
                this.key = key;
                this.value = value;
            }
        }

        public LRUCache(int capacity) {
            this.capacity = capacity;
            this.keyToNode = new HashMap<>();
            this.dummy = new Node(0, 0);
            dummy.prev = dummy;
            dummy.next = dummy;
        }

        public int get(int key) {
            Node node = getNode(key);
            return node == null ? -1 : node.value;
        }

        public void put(int key, int value) {
            Node node = getNode(key);

            if (node != null) {
                node.value = value;
                return;
            }

            node = new Node(key, value);
            keyToNode.put(key, node);
            pushFront(node);

            if (keyToNode.size() > capacity) {
                Node lru = dummy.prev;
                remove(lru);
                keyToNode.remove(lru.key);
            }
        }

        private Node getNode(int key) {
            Node node = keyToNode.get(key);
            if (node == null) {
                return null;
            }
            remove(node);
            pushFront(node);
            return node;
        }

        private void remove(Node node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }

        private void pushFront(Node node) {
            node.prev = dummy;
            node.next = dummy.next;
            dummy.next.prev = node;
            dummy.next = node;
        }
    }
}

你这题面试时最容易被追问的点

面试官很可能会问:

“为什么一定要双向链表?”

因为单链表删除一个节点时,需要先找到前驱节点,不是 O(1)。

双向链表可以直接:

node.prev.next = node.next;
node.next.prev = node.prev;

所以删除是 O(1)。


“为什么 put 里先调用 getNode?”

因为如果 key 已存在,除了更新 value,还要把它变成最近使用,也就是移到头部。


“复杂度是多少?”

  • get:O(1)

  • put:O(1)


下一步你可以继续让我帮你把它改成 面试里带 TTL 的版本,我会在这个结构上直接加,不会一下子跳太复杂。

可以。下面我直接在你这个 面试空白模式 的基础上,改成 带 TTL 的 LRUCache

我先给你一个 适合面试讲清楚、实现也不复杂 的版本:

  • put(key, value, ttlMillis):每个 key 都可以单独设置过期时间

  • get(key):如果不存在或已过期,返回 -1

  • 仍然保留 LRU 语义

  • 过期删除采用 惰性删除

    • 访问到过期 key 时删掉

    • put 时顺手清理一下尾部过期节点

这版很适合面试第一步。


面试空白模式:LRU + TTL

import java.util.*;

public class InterviewSolution {

    public static void main(String[] args) throws InterruptedException {
        LRUCache cache = new LRUCache(2);

        cache.put(1, 100, 2000); // 2秒过期
        cache.put(2, 200, 5000); // 5秒过期

        System.out.println(cache.get(1)); // 100

        Thread.sleep(2500);

        System.out.println(cache.get(1)); // -1,已过期
        System.out.println(cache.get(2)); // 200

        cache.put(3, 300, 5000);
        System.out.println(cache.get(3)); // 300

        cache.put(4, 400, 5000); // 可能触发淘汰
        System.out.println(cache.get(2)); // 看最近使用情况
        System.out.println(cache.get(3));
        System.out.println(cache.get(4));
    }

    static class LRUCache {
        private final int capacity;
        private final Node dummy;
        private final Map<Integer, Node> keyToNode;

        static class Node {
            int key;
            int value;
            long expireAt; // 过期时间戳,毫秒
            Node prev;
            Node next;

            Node(int key, int value, long expireAt) {
                this.key = key;
                this.value = value;
                this.expireAt = expireAt;
            }
        }

        public LRUCache(int capacity) {
            this.capacity = capacity;
            this.keyToNode = new HashMap<>();
            this.dummy = new Node(0, 0, 0);
            dummy.prev = dummy;
            dummy.next = dummy;
        }

        public int get(int key) {
            Node node = getNode(key);
            return node == null ? -1 : node.value;
        }

        public void put(int key, int value, long ttlMillis) {
            long now = System.currentTimeMillis();
            long expireAt = now + ttlMillis;

            Node node = keyToNode.get(key);

            // key 已存在
            if (node != null) {
                // 先判断是否过期
                if (isExpired(node, now)) {
                    remove(node);
                    keyToNode.remove(key);
                } else {
                    node.value = value;
                    node.expireAt = expireAt;
                    remove(node);
                    pushFront(node);
                    return;
                }
            }

            // 新建节点
            Node newNode = new Node(key, value, expireAt);
            keyToNode.put(key, newNode);
            pushFront(newNode);

            // 顺手清理尾部连续过期节点
            cleanExpiredFromBack();

            // 超容量则淘汰最久未使用
            if (keyToNode.size() > capacity) {
                Node lru = dummy.prev;
                remove(lru);
                keyToNode.remove(lru.key);
            }
        }

        // 获取节点:如果过期就删掉;没过期就移动到头部
        private Node getNode(int key) {
            Node node = keyToNode.get(key);
            if (node == null) {
                return null;
            }

            long now = System.currentTimeMillis();

            if (isExpired(node, now)) {
                remove(node);
                keyToNode.remove(key);
                return null;
            }

            remove(node);
            pushFront(node);
            return node;
        }

        private boolean isExpired(Node node, long now) {
            return now >= node.expireAt;
        }

        private void remove(Node node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
        }

        private void pushFront(Node node) {
            node.prev = dummy;
            node.next = dummy.next;
            dummy.next.prev = node;
            dummy.next = node;
        }

        // 从尾部开始清理连续过期节点
        private void cleanExpiredFromBack() {
            long now = System.currentTimeMillis();
            Node cur = dummy.prev;

            while (cur != dummy && isExpired(cur, now)) {
                Node prev = cur.prev;
                remove(cur);
                keyToNode.remove(cur.key);
                cur = prev;
            }
        }
    }
}

面试时你要怎么讲

这题如果面试官问,你可以这样说:

核心结构

还是:

HashMap + 双向链表
  • HashMap:O(1) 定位 key

  • 双向链表:O(1) 删除、移动、淘汰尾节点


TTL 怎么做

每个节点多加一个字段:

long expireAt;

表示这个 key 的过期时间。


get 的逻辑

get(key) 时:

  1. 先从 map 取节点

  2. 判断是否过期

  3. 过期就删除并返回 -1

  4. 没过期就移动到头部并返回 value


put 的逻辑

put(key, value, ttlMillis) 时:

  1. 如果 key 已存在

    • 先判断是否过期

    • 没过期就更新 value 和 expireAt,并移到头部

    • 已过期就先删,再按新节点插入

  2. 如果 key 不存在

    • 直接新建节点插入头部

  3. 如果超容量

    • 删除尾节点


这个版本的特点

这是一个 惰性过期 的版本。

意思是:

不是所有过期数据一到时间就立刻删掉,而是:

  • 被访问到时删除

  • put 时顺便从尾部清一点

这在面试里是完全可以接受的,而且很好解释。


面试官可能会追问的点

1. 为什么不是实时删除所有过期节点?

因为如果每次都全链表扫描,复杂度会变差,最坏会到 O(n)。


2. 这个版本有没有局限?

有。

cleanExpiredFromBack() 只能清理 尾部连续过期节点
如果中间有过期节点但没访问到,它会暂时留着。

这就是惰性删除的特点。


这题面试时更稳的说法

你可以说:

这是一个 LRU + TTL 的基础实现。
TTL 用惰性删除处理,保证核心逻辑简单,get/put 大部分情况下仍然接近 O(1)。
如果要更强的过期管理,可以再引入最小堆,按过期时间清理。


一个小提醒

这里我写的是:

long expireAt = now + ttlMillis;

所以要求:

ttlMillis > 0

如果你想兼容:

  • ttlMillis <= 0 立刻过期

也可以改成:

long expireAt = ttlMillis <= 0 ? now : now + ttlMillis;

下一步最自然的就是,我帮你把这版再升级成 面试更强的“LRU + TTL + 最小堆”版,这样就能更像真实缓存系统。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐