问题的讨论
前文讲述了数组、单链表来实现缓存的数据结构,一步一步地反映出 LRU 是如何被改进的。至今我们没有放下改进的脚步。在上一例单链表中遇到的一个问题是,提供泛型的 T 为单一记录值,无法处理 Key/Value 结构。通过百度,我们找到一仁兄的资源,比较不错,解决了该问题,并且在单链表的思路与上例更不一样,却更为简洁。
public class LRUCache_2<K, V> {
int cap;
int size = 0;
private Node<K, V> head;
private Node<K, V> tail;
/**
* 设定容量
* @param cap 设定容量
* @param db
*/
public LRUCache_2(int cap) {
this.cap = cap;
}
public V get(K key) {
Node<K, V> previous = null; // ahead 是之前的那个节点
Node<K, V> temp = head;
while (temp != null) {
if (temp.key.equals(key)) {
if (temp.next != null) // 找到了匹配的缓存
moveToLast(previous, temp);
return temp.value;
}
previous = temp;
temp = temp.next;
}
return null;
}
private void moveToLast(Node<K, V> previous, Node<K, V> target) {
if (target == tail)
return;
if (head == target) {
head = target.next;// 头部不当头了,后面的上
} else {
previous.next = target.next; // target 被抽出来了,后面的接上
}
tail.next = target;// 续上新的, 放在最后插入
tail = target;
target.next = null;
}
public V put(K key, V value) {
if (head == null) { // 空,直接插入
tail = head = new Node<>(key, value);
size++;
return value;
}
Node<K, V> temp = head;
while (temp != null) {
if (temp.key.equals(key)) { // 已经存在
if (temp.value.equals(value)) { // 没有不同
return value;
} else {
V oldValue = temp.value; // 进行了值的替换
temp.value = value;
return oldValue;
}
}
temp = temp.next;
}
// 找不到,是新成员
temp = new Node<>(key, value);
tail.next = temp;// 加入到尾部
tail = temp;
size++;
checkCap();
return value;
}
private void checkCap() {
if (size > cap) {
// 进行淘汰,摘除开头
head = head.next;
size--;
}
}
public Node<K, V> peek() {
return head;
}
public static class Node<K, V> {
K key;
V value;
Node<K, V> next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
while (head != null) {
sb.append(head.key + " " + head.value);
head = head.next;
}
return sb.toString();
}
}
笔者修改了几处地方,不影响主干意思。通过代码可见,该例并没有分离单链表类和 LRU 类,而是合二为一。另外类声明中除了 head 头部元素外,还给出了 tail 尾部元素,使得在执行 moveToLast()
时候无须遍历链表,改进了性能。
值得一提的是,该链表都是尾部加入新元素,在头部删除老元素——这一点与前面的例子刚好相反。删除的方法也直观简单:
if (size > cap) {
head = head.next;// 进行淘汰,摘除开头
size--;
}
问题的讨论(二)
1、既然谈到优化问题,我们不妨再继续讨论。我们考察 moveToLast()
,原本需要前驱节点 previous 参数,现在可否只需要一个 target 参数无须 previous?
private void moveToLast(Node<K, V> previous, Node<K, V> target) {
if (target == tail)
return;
if (head == target) {
head = target.next;// 头部不当头了,后面的上
} else {
previous.next = target.next; // target 被抽出来了,后面的接上
}
tail.next = target;// 续上新的, 放在最后插入
tail = target;
target.next = null;
}
传统思维,如上例,是要获取到被删除结点前面的那一个结点的指针 next
, 修过它为 target.next
才可以的,比如一个链表 A->B->C->D->E->F->G。如果我们要删除结点 E,那么我们只需要让结点 D 的指针指向结点 F 即可。但是因为限于单链表,是没有前驱节点的,所以要靠遍历链表获取前一个节点,显然这种方法是不能在 O(1)
时间内调整节点的。
我们能否换个思路?这里有个巧妙的方法,被删除结点E的后一个结点指针是很容易得到的,也就是 target.next
,下一级也容易得到,即 target.next.next
。于是当前节点对象不变,但 data 数据拷贝自下一个节点,并且 next 指向 target.next.next
,那样等于“架空”了 target.next
,却又保留 target.next
的数据,完成删除动作。moveToLast()
也是同理,如法炮制。
2、考察 get
方法,要获取匹配的 value 仍需要经过 while
循环,时间复杂度为 O(n)
。
public V get(K key) {
Node<K, V> previous = null; // ahead 是之前的那个节点
Node<K, V> temp = head;
while (temp != null) {
if (temp.key.equals(key)) {
if (temp.next != null) // 找到了匹配的缓存
moveToLast(previous, temp);
return temp.value;
}
previous = temp;
temp = temp.next;
}
return null;
}
有没有办法可以让复杂度也在 O(1)
? 实际上 moveToLast()
的操作是 O(1)
时间复杂度,因为提供了 tail 尾部指针,这已经有点 双向链表的意味了。——没错,使用双向链表,所有操作都是 O(1)
时间复杂度(除了遍历)。下一节将深入讨论。