面试官:不用集合框架给我写一个LRU

前言

上次我们介绍了LRU的思想,并借助Java集合框架中的LinkedList和HashMap非常简单的实现了一个LRU:手撕面试题算法(1)——LRU算法
但是,在面试中要是借助集合框架写LRU,想必面试官不是很满意吧,想到这里,我放弃了午休时间,撸了个不使用Java集合框架的LRU

源码

点击这里获取源码,别忘了点个Star哦~

一、如何设计?

没有集合框架,怎么办呀?如果平时只会使用集合框架的api而不去研究其原理,就很难去想这样的问题。
LinkedList的底层是双向链表,我之前也有文章写到用双向链表实现LinkedList:从零开始手撕一个数据结构(1)——双向链表,里面也对LRU提了一嘴

LRU存放的是键值对,在使用集合框架的实现上我使用了HashMap,手写HashMap似乎不太现实,毕竟hash函数和扩容之类的操作也挺复杂的,这里我们就以双向链表存放键值对的形式来设计LRU

二、核心定义

1. 类设计

/**
 * 不借助集合框架手撕LRU
 * @param <K>
 * @param <V>
 */
public class PureLRU<K,V> {

    /**
     * 缓存容量
     */
    private int capacity;

    /**
     * 缓存元素个数
     */
    private int size;

    /**
     * 链表的头、尾指针
     */
    private Entry head, tail;

    public PureLRU(int capacity){
        this.capacity=capacity;
        this.size=0;
    }
}

因为不借助集合框架了,所以给它命名为PureLRU ^-^
头尾指针是双向链表的必备之物了,缓存的容量和缓存元素个数则是我们判断容器是否满载的依据

2. 内部类

    /**
     * 键值对节点
     * @param <K>
     * @param <V>
     */
    private class Entry<K,V>{
        
        /**
         * 存放节点的键
         */
        public K key;
        
        /**
         * 存放节点的值
         */
        public V val;

        /**
         * 节点的前后指针
         */
        public Entry next,pre;

        public Entry(K key,V val){
            this.key = key;
            this.val = val;
        }
    }

也就比双向链表多加了一个字段:key
这样就可以通过链表存放键值对啦~

三、方法实现

Leetcode 146 中我们只需要实现put方法和get方法,但是为了支撑这两个方法,我们需要实现以下方法:

  1. 头插
  2. 尾删
  3. 通过key寻找节点
  4. 将节点移动到链头

我们先逐个实现吧

1. 头插方法addToHead

    /**
     * 头插方法
     * @param entry
     */
    private void addToHead(Entry entry){
        // 将新节点的下一节点定义为当前链表的头节点
        entry.next = head;
        // 将当前链表头节点的上一个节点定义为新节点
        head.pre = entry;
        // 以上两步将新节点与链头连接完成
        
        // 将头节点定义为新节点,实现头插
        head = entry;
        // 链表添置新元素,size自增
        ++size;
    }

2. 尾删方法removeLast

    /**
     * 尾删方法
     */
    private void removeLast(){
        // 将尾节点定义为当前尾节点的前一个节点
        tail = tail.pre;
        // 将尾节点的下一个节点(即原本的尾节点)设置为null
        tail.next = null;
        //以上操作将原本的尾节点与链表脱离,size自减
        --size;
    }

3. 通过key寻找节点

由于要在链表中寻找一个节点,只能遍历整条链表了,在这一点上,java.util.LinkedList的indexOf也是这样的,时间复杂度为O(n)

    /**
     * 根据key寻找节点,O(n)
     * @param key
     * @return
     */
    private Entry findEntryByKey(K key){
        /**
         * 进行判空操作,有两个好处
         * 1. 使缓存能够正确存储以null为key的数据
         * 2. 避免key.equals调用引发空指针异常
         */
        // 当key为空时,寻找链表中key为空的节点
        if (key == null){
            for(Entry cur = head; cur != null; cur = cur.next) {
                if(cur.key==null){
                    return cur;
                }
            }
        }else {
            // key不为空时,寻找key和传入值相等的节点
            for(Entry cur = head; cur != null; cur = cur.next){
                if(key.equals(cur.key)){
                    return cur;
                }
            }
        }
        return null;
    }

4. 将节点移动到链头

    /**
     * 将节点移动到链头
     * @param entry
     */
    private void moveToHead(Entry entry){
        // 当指定节点为头节点,无需移动
        if(entry == head){
            return;
        }
        // 指定节点为尾节点时,尾删头插即可
        if(entry == tail){
            removeLast();
            addToHead(entry);
            return;
        }
        // 指定节点为其他节点时
        Entry pre = entry.pre;
        Entry next = entry.next;
        pre.next = next;
        next.pre = pre;
        // 以上操作相当于从链表中删去该节点,需要将size自减
        --size;
        // 从链表中删去该节点后,将该节点头插
        addToHead(entry);
    }

其实很简单,就是

  1. 删除原有的
  2. 将被删除的头插

5. 添加键值对的put方法

    /**
     * 置入新数据
     * @param key
     * @param val
     */
    public void put(K key,V val){
        // 当缓存中存在当前要置入的数据的键时
        Entry entry = findEntryByKey(key);
        if(entry != null){
            moveToHead(entry);
            // 更新数据
            head.val=val;
            return;
        }

        // 缓存中不存在要存入的键时
        // 用Entry存储新键值对
        Entry<K,V> newEntry = new Entry<>(key,val);
        // 当容器为空时,头节点=尾节点,
        if (this.isEmpty()){
            head = newEntry;
            tail = head;
            //不要忽略size的自增
            ++size;
        }else {
            // 当容器不为空时
            // 当容器满时要尾删
            if (size == capacity){
                removeLast();
            }
            // 头插新节点
            addToHead(newEntry);
        }
    }

分情况讨论:

  • 链表中存在与传入键相等的键时——移动其节点到链头
  • 链表中不存在与传入键相等的键:
    • 检查链表长度:
      • 为0(isEmpty)?——处理头/尾节点
      • 大于0小于capacity?——进行头插
      • 缓存满(size==capacity)?——进行尾删+头插

6. 查询数据方法get

    /**
     * 查询数据
     * @param key
     * @return
     */
    public V get(K key){
        Entry target = findEntryByKey(key);
        // 不存在该键时,返回空即可
        if(target == null){
            return null;
        }
        // 将被访问数据节点移动到头节点
        moveToHead(target);
        return (V)target.val;
    }

将被访问到的数据移动到链表头即可

7. 其它

为了方便查看数据情况/增加可读性,我另外写了contains,isEmpty这些容器常用方法,也重写了toString

contains

    /**
     * 查询缓存中是否包含指定key
     * @param key
     * @return
     */
    public boolean contains(K key){
        return findEntryByKey(key) != null;
    }

isEmpty

    public boolean isEmpty(){
        return size==0;
    }

toString

    @Override
    public String toString(){
        StringBuilder sb = new StringBuilder();
        for(Entry cur = head; cur != null; cur = cur.next) {
            sb.append(cur.key).append(",");
        }
        return sb.deleteCharAt(sb.length()-1).toString();
    }

四、总结

自此,一个不用集合框架的LRU就完成了,跟双向链表基本是一模一样的
测试:

public class Test  {
    public static void main(String[] args) {
        PureLRU<Integer,String> pureLRU = new PureLRU<>(3);
        System.out.println(pureLRU.isEmpty()); // true
        pureLRU.put(1,"A");
        pureLRU.put(2,"B");
        pureLRU.put(3,"C");
        System.out.println(pureLRU.toString()); // 3,2,1
        System.out.println(pureLRU.get(1)); // A
        System.out.println(pureLRU.toString()); // 1,3,2
        pureLRU.get(2);
        System.out.println(pureLRU.toString()); // 2,1,3

        pureLRU.put(4,"D");
        System.out.println(pureLRU.toString()); // 4,2,1

        pureLRU.put(1,"Z");
        System.out.println(pureLRU.toString()); // 1,4,2
        System.out.println(pureLRU.get(1)); // Z

        System.out.println(pureLRU.contains(4)); // true
        System.out.println(pureLRU.isEmpty()); // false

        pureLRU.put(null,"nullVal");
        System.out.println(pureLRU.toString()); // null,1,4
        System.out.println(pureLRU.get(null)); // nullVal
    }
}

测试无误

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值