1、前言
缓存,一个再常见的不过的名词了,就好比我们把一些随时需要用到的工具放在办公桌的杂物箱,当我们需要使用某个工具的时候,不用去专门的工具箱取,方便快捷。实际一点儿的例子,比如在我们Web服务端最常见的就是使用Redis做缓存,降低直接对数据库的读写次数,可以大大的提升网站在高峰时期的访问效率。但是缓存并不是越多越好,毕竟缓存还是需要占用空间的,太久没有用的缓存是没有什么意义的,反而占用了计算机的空间,那么,如何权衡缓存的量与时间呢?最近我在刷LeetCode的时候发现了一个比较好的解决方案。
2、LRU缓存
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:
LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;
如果不存在,则向缓存中插入该组 key-value 。
如果插入操作导致关键字数量超过 capacity ,则应该 逐出 >最久未使用的关键字。
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。
2.1、分析
这个算法有一个非常关键的点,get和put必须以O(1)的平均时间复杂度
运行。看到O(1)
的时间复杂度,我的第一反应是能否用哈希表
实现,但是有个问题,怎么去保证顺序呢?显然,哈希表不太适合这个场景。除了哈希表,还有一个数据结构在插入和删除的时候,也是O(1)
,当然就是链表
啦,哈哈哈。如果对于链表
的知识点不太明白的读者,可以参考笔者早期的博客:积跬步,以至千里——你应该掌握这些链表的基础知识
为了维持这个顺序,每次一旦我们更新了缓存,我们可以把缓存节点提到链表的最头部,这样的话,节点就变新了,如果我们要逐出最久没有使用的缓存,可以直接从链表的尾部删除一个节点即可。
但是还是存在一个问题,当我们更新链表节点,原来两个节点的相对顺序还是不能改变的;当我们删除最后一个节点,其前一个节点就应该变成最后一个节点,要保证O(1)
的时间复杂度,那么我们就只能用双向链表就可以方便的查找前置节点了。
好了,在确定好了使用何种数据结构之后,以下就是我们的编码实现。
首先定义一下双向链表的数据结构,这个结构里面多定义了一个字段key,主要是为了再删除节点的时候便于销毁缓存。
/**
* LRUCache节点
*/
interface DoubleLinkedListNode {
/**
* 前驱缓存节点
*/
prev: DoubleLinkedListNode | null;
/**
* 后继缓存节点
*/
next: DoubleLinkedListNode | null;
/**
* 缓存值
*/
val: any;
/**
* 缓存键
*/
key: string;
}
2.2、LRUCache的代码实现
LRUCache类的实现如下:
/**
* 最近最少使用缓存类
* @param {number} capacity
*/
var LRUCache = function (capacity) {
if (capacity <= 0) {
console.error("the LRUCache capacity must bigger than zero");
}
this.capacity = capacity;
this.size = 0;
/**
* @type { Map<any, DoubleLinkedListNode> }
*/
this.mapping = new Map();
/**
* @type { DoubleLinkedListNode | null }
*/
this.head = null;
/**
* @type { DoubleLinkedListNode | null }
*/
this.tail = null;
};
/**
* 刷新链表节点
* @param {DoubleLinkedListNode} node
* @returns
*/
LRUCache.prototype.refresh = function (node) {
if (!node) {
console.warn("failed to refresh cache node");
return;
}
let prevNode = node.prev;
let nextNode = node.next;
// 如果不存在前驱节点,说明当前节点就是最近使用过的节点,无需刷新
if (!prevNode) {
return;
}
// 如果不存在后继节点,说明当前节点就是最后一个节点,直接提到最前面去
if (!nextNode) {
// 备注4:更新尾节点
prevNode.next = null;
this.tail = prevNode;
node.next = this.head;
this.head.prev = node;
this.head = node;
}
// 如果同时存在前驱和后继节点
if (prevNode && nextNode) {
// 备注3:正常情况下的已有缓存节点的刷新
// 把原来的两个节点接到一起
prevNode.next = nextNode;
nextNode.prev = prevNode;
// 然后把当前这个节点提到最前面去
node.next = this.head;
this.head.prev = node;
node.prev = null;
this.head = node;
}
};
/**
* 获取缓存节点
* @param {any} key
* @return {number}
*/
LRUCache.prototype.get = function (key) {
if(this.capacity <=0) {
console.warn("can not get anything from an empty list");
return -1;
}
let node = this.mapping.get(key);
if (!node) {
return -1;
}
// 刷新节点
this.refresh(node);
return node.val;
};
/**
* 更新缓存节点
* @param {any} key
* @param {number} value
* @return {void}
*/
LRUCache.prototype.put = function (key, value) {
let oldNode = this.mapping.get(key);
// 旧节点不存在
if (!oldNode) {
const newNode = this.createNode(key, value);
// 设置新值
this.mapping.set(key, newNode);
if (this.size === 0) {
// 备注1:向空表中插入一个缓存节点
this.head = newNode;
this.tail = newNode;
} else {
// 备注2:向不为空的表中插入一个缓存节点
newNode.next = this.head;
this.head.prev = newNode;
this.head = newNode;
}
this.size++;
if (this.size > this.capacity) {
let oldKey = this.tail.key;
this.mapping.delete(oldKey);
// 备注5:解开最后一个节点,
let preTail = this.tail.prev;
preTail.next = null;
this.tail.prev = null;
this.tail = preTail;
this.size--;
}
} else {
oldNode.val = value;
this.refresh(oldNode);
}
};
/**
* 创建一个链表节点
* @param {number} val
* @returns {Node}
*/
LRUCache.prototype.createNode = function (key, val) {
return {
prev: null,
next: null,
val,
key,
};
};
2.3、LRUCache关键流程分析
接下来,我们画一些图对这个算法里面关键流程进行讲解。
2.3.1、插入缓存节点
事先没有节点,肯定是最新的,那么直接放在链表表头。
插入一个缓存节点,头指针和尾指针都指向这个新插入的节点
对应这部分操作的代码如备注1。
当LRUCache有一个以上的节点时,插入过程如下:
首先,让新加入的节点的后继节点指向当前头结点
然后,让当前的头结点的前驱指针指向新加入的节点
最后,因为我们已经成功加入了节点了,那么当前的头结点已经不再是真正的头结点了,我们需要修改头结点指针
对应上述操作的代码如备注2
2.3.2、更新缓存节点
更新缓存节点,我们需要把事先存在的节点提到最前面去嘛,因为它是最新的。但是这个过程,看起来比较简单,但是需要注意边界情况。
首先我们还是看一下理想情况(假设当前缓存有3个节点):
解除待更新节点的前驱与后继指向关系,为了避免节点丢失,我们得声明prevNode和nextNode记住待更新节点的前驱和后继节点。
重新建立prevNode和nextNode的指向关系:
将待更新节点插入到表头,完成更新。
对应上述系列操作代码如备注3。
说完了正常的情况,我们来看一下边界情况。假设当前待更新的节点没有前驱节点,这个可能是我们最开心的事儿了吧,哈哈哈,没有前驱节点?那不就是当前节点就是第一个节点吗?用咱们老李的口气来说就是:更新?更新个屁。
最后看一下另外一个边界情况,有前驱节点,没有后继节点。说明当前节点是最后一个节点。删除最后一个节点,需要注意tail指针的指向将会发生变化,即指向前面一个节点,如下图所示:
然后再完成插入即可。整个过程如备注4
2.3.3、删除缓存节点
在搞明白了插入和更新之后,删除其实我们已经在之前已经做了(更新尾节点),整个过程如备注5
整个过程可以看到我们没有使用任何循环操作,只是一直在不停的修改指针,代码看起来比较复杂,并且修改节点指针操作的代码顺序不要轻易调换,否则程序就不一定符合预期了。
而且可以看到,我们的节点中next
域和prev
域也将会占用一定的空间。虽然时间快了,但是空间消耗也多了,这是典型的空间换时间的一种场景,毕竟快速的读取缓存可提高应用程序的访问效率,提升用户的体验相当重要,牺牲一定的空间必然是值得的。
3、Vue内置组件之KeepAlive
去年我在面试滴滴一面的时候我清楚记得有这样一个问题:请问vue的KeepAlive的组件缓存策略是什么?
当时的我是一脸懵逼,显然我是没有回答上这个问题的,哈哈哈。
不过后来我搞明白了这个问题,当我刷到LeetCode的LRUCache这题的时候我脑子中一下便想到了Vue的KeepAlive组件。
本文KeepAlive组件的代码节选自vue@2.6.4
...已省略无关代码
function pruneCache (keepAliveInstance: any, filter: Function) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const cachedNode: ?VNode = cache[key]
if (cachedNode) {
const name: ?string = getComponentName(cachedNode.componentOptions)
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}
function pruneCacheEntry (
cache: VNodeCache,
key: string,
keys: Array<string>,
current?: VNode
) {
const cached = cache[key]
if (cached && (!current || cached.tag !== current.tag)) {
cached.componentInstance.$destroy()
}
cache[key] = null
remove(keys, key)
}
export default {
name: 'keep-alive',
abstract: true,
props: {
include: patternTypes,
exclude: patternTypes,
max: [String, Number]
},
created () {
this.cache = Object.create(null)
this.keys = []
},
destroyed () {
for (const key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys)
}
},
mounted () {
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},
render () {
const slot = this.$slots.default
const vnode: VNode = getFirstComponentChild(slot)
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// check pattern
const name: ?string = getComponentName(componentOptions)
const { include, exclude } = this
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
}
const { cache, keys } = this
const key: ?string = vnode.key == null
// same constructor may get registered as different local components
// so cid alone is not enough (#3269)
? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance
// make current key freshest
remove(keys, key)
keys.push(key)
} else {
cache[key] = vnode
keys.push(key)
// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
}
vnode.data.keepAlive = true
}
return vnode || (slot && slot[0])
}
}
代码中,对于缓存的更新,Vue并没有采用双向链表的这么复杂的操作,但是思路都是一样的,逐出最久没有使用的。更新缓存只改变key的位置,使得缓存刷新。
并且我们再看看上述代码,还能得到一些有用的信息,比如:在设置缓存的时候如果有key的话,会优先使用VNode的key,没有key的话再采用一套规则生成key,这个key的规则及上文提到的更新删除策略正好回答了我在滴滴面试中面试官问到的缓存策略;
并且上述代码还给VNode设置了一个标记vnode.data.keepAlive = true
,这个标记很重要,它会让我们的组件实例在某个时刻去触发activated
和deactivated
。至于这两个生命周期是怎么触发的呢,代码如下:(我在源文件里面并没有搜到跟activated
和deactivated
相关的代码,但是在打包好的dist/vue.js
里面搜到了这个逻辑,我猜测这个逻辑可能被抽到了别的包里面去了)
// 大致位置在4370行
// inline hooks to be invoked on component VNodes during patch
var componentVNodeHooks = {
init: function init (vnode, hydrating) {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
var mountedNode = vnode; // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode);
} else {
var child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
);
child.$mount(hydrating ? vnode.elm : undefined, hydrating);
}
},
prepatch: function prepatch (oldVnode, vnode) {
var options = vnode.componentOptions;
var child = vnode.componentInstance = oldVnode.componentInstance;
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
);
},
insert: function insert (vnode) {
var context = vnode.context;
var componentInstance = vnode.componentInstance;
if (!componentInstance._isMounted) {
componentInstance._isMounted = true;
callHook(componentInstance, 'mounted');
}
if (vnode.data.keepAlive) {
if (context._isMounted) {
// vue-router#1212
// During updates, a kept-alive component's child components may
// change, so directly walking the tree here may call activated hooks
// on incorrect children. Instead we push them into a queue which will
// be processed after the whole patch process ended.
queueActivatedComponent(componentInstance);
} else {
activateChildComponent(componentInstance, true /* direct */);
}
}
},
destroy: function destroy (vnode) {
var componentInstance = vnode.componentInstance;
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
componentInstance.$destroy();
} else {
deactivateChildComponent(componentInstance, true /* direct */);
}
}
}
};
如果组件VNode是有keepAlive
的话,则视为是更新,并且触发activated
生命周期,而不是执行新增逻辑。如果是销毁操作的话,则触发的是deactivated
生命周期,而不是直接销毁。
另外还能看出Vue是怎么将之前的渲染结果保存下来的;其次,就是如果我们KeepAlive组件的子组件如果不是一个Vue组件的话,是不能缓存的;最后一个细节是我们的include
、exclude
参数能支持正则表达式配置是否缓存组件实例的规则。
除开KeepAlive组件中大部分对max
、exclude
、include
参数的一些判断以外,我们来梳理一下KeepAlive组件的流程。
首先,KeepAlive组件是一个抽象组件,就是你必须要传递它的默认插槽(即children)才能正确渲染(通常我们传递的是一个组件)。在组件创建时,我们生产一个缓存池(销毁的时候销毁所有缓存)。同时设置对include
、和exclude
参数的监听(当它们发生变化时,要注意删除缓存,在删除缓存时,调用组件实例的destroy方法,通知用户组件销毁)。然后,获取传入的子组件的参数信息,如果命中不缓存规则,则直接返回VNode供框架渲染,否则根据key或者cid+tag生成缓存key,如果能够获取到缓存的内容,则使用缓存的组件实例替换当前VNode关联的组件实例,并且把当前key上的缓存内容更新,如果没有命中缓存,则缓存,若超出了最大的缓存数量(即已配置max
参数),则删除最久没有使用的缓存,调用组件的destroy方法,通知用户组件销毁。经过这一系列的处理之后,给VNode设置一个标记keepAlive
供后续的逻辑判断,返回VNode供框架渲染。
4、结语
本文主要结合缓存知识点阐述了双向链表
在实际开发中的应用。同时在LeetCode上还有另外一道题叫做LFUCache,有兴趣的朋友可以点击查阅,其依然可以使用双向链表
结合广义表
的知识点使得在多个同权重节点(我自己的叫法)的访问效率为O(1)
。
由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。