导航
前言
上次我们介绍了LRU的思想,并借助Java集合框架中的LinkedList和HashMap非常简单的实现了一个LRU:手撕面试题算法(1)——LRU算法
但是,在面试中要是借助集合框架写LRU,想必面试官不是很满意吧,想到这里,我放弃了午休时间,撸了个不使用Java集合框架的LRU
源码
一、如何设计?
没有集合框架,怎么办呀?如果平时只会使用集合框架的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方法,但是为了支撑这两个方法,我们需要实现以下方法:
- 头插
- 尾删
- 通过key寻找节点
- 将节点移动到链头
我们先逐个实现吧
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);
}
其实很简单,就是
- 删除原有的
- 将被删除的头插
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
}
}
测试无误