白话 LRU 缓存及链表的数据结构讲解(二)

问题的讨论

前文讲述了数组、单链表来实现缓存的数据结构,一步一步地反映出 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) 时间复杂度(除了遍历)。下一节深入讨论。

文章贡献:https://www.cnblogs.com/dolphin0520/p/3741519.html

发布了293 篇原创文章 · 获赞 260 · 访问量 232万+

猜你喜欢

转载自blog.csdn.net/zhangxin09/article/details/90492307