LRUCache 是一种经典的缓存机制,它的基本思路是按照最近使用的时间对元素排序,在清理时优先把搁置最久的删除掉。

如果不想给每个缓存元素都记录一个时间戳的话,可以应用哈希链表来简单地实现 LRU 算法。也就是对一个哈希表中的所有元素增加指针,从而串起一个双链表,这样既可以快速 get value,又可以通过把最近使用过的元素放到头部来维护顺序,删除的时候从末尾开始就好了。

手写双链表并不困难,但是借助 OrderedDict 的话,可以写出非常简短的代码:

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.hashtable = OrderedDict()

    def get(self, key: int) -> int:
        if key in self.hashtable:
            self.hashtable.move_to_end(key, last=False)
            return self.hashtable[key]

        return -1

    def put(self, key: int, value: int) -> None:
        self.hashtable[key] = value
        self.hashtable.move_to_end(key, last=False)
        if len(self.hashtable) > self.capacity:
            self.hashtable.popitem()

其中最神奇的就是 move_to_endpopitem 方法(后者默认是弹出最后面的元素)的使用,这也得益于 OD 可以保证 key-value pair 的顺序。那么

OD 是如何做到的呢?其实还是双链表,下面是它的 Python 实现:

class _Link(object):
    __slots__ = 'prev', 'next', 'key', '__weakref__'

class OrderedDict(dict):
    def __init__(self, other=(), /, **kwds):
        '''Initialize an ordered dictionary.  The signature is the same as
        regular dictionaries.  Keyword argument order is preserved.
        '''
        try:
            self.__root
        except AttributeError:
            self.__hardroot = _Link()
            self.__root = root = _proxy(self.__hardroot)
            root.prev = root.next = root
            self.__map = {}
        self.__update(other, **kwds)

    def __setitem__(self, key, value,
                    dict_setitem=dict.__setitem__, proxy=_proxy, Link=_Link):
        'od.__setitem__(i, y) <==> od[i]=y'
        # Setting a new item creates a new link at the end of the linked list,
        # and the inherited dictionary is updated with the new key/value pair.
        if key not in self:
            self.__map[key] = link = Link()
            root = self.__root
            last = root.prev
            link.prev, link.next, link.key = last, root, key
            last.next = link
            root.prev = proxy(link)  # 为了保证不出现循环引用
        dict_setitem(self, key, value)

    def __delitem__(self, key, dict_delitem=dict.__delitem__):
        'od.__delitem__(y) <==> del od[y]'
        # Deleting an existing item uses self.__map to find the link which gets
        # removed by updating the links in the predecessor and successor nodes.
        dict_delitem(self, key)
        link = self.__map.pop(key)
        link_prev = link.prev
        link_next = link.next
        link_prev.next = link_next
        link_next.prev = link_prev
        link.prev = None
        link.next = None

    def popitem(self, last=True):
        '''Remove and return a (key, value) pair from the dictionary.

        Pairs are returned in LIFO order if last is true or FIFO order if false.
        '''
        if not self:
            raise KeyError('dictionary is empty')
        root = self.__root
        if last:
            link = root.prev
            link_prev = link.prev
            link_prev.next = root
            root.prev = link_prev
        else:
            link = root.next
            link_next = link.next
            root.next = link_next
            link_next.prev = root
        key = link.key
        del self.__map[key]  # 这里并没有手动解除指针,所以有可能造成循环引用(prev 不使用 weakref 的话)
        value = dict.pop(self, key)
        return key, value

    def move_to_end(self, key, last=True):
        '''Move an existing element to the end (or beginning if last is false).

        Raise KeyError if the element does not exist.
        '''
        link = self.__map[key]
        link_prev = link.prev
        link_next = link.next
        soft_link = link_next.prev
        link_prev.next = link_next
        link_next.prev = link_prev
        root = self.__root
        if last:
            last = root.prev
            link.prev = last
            link.next = root
            root.prev = soft_link
            last.next = link
        else:
            first = root.next
            link.prev = root
            link.next = first
            first.prev = soft_link
            root.next = link

源码还有很多其他方法,这里只展示了关键的几个。可以看到,重要的数据结构在于 self.__root 为首的双链表以及 self.__map 来保存 key-link 的映射关系。这其实和哈希链表实现 LRUCache 的思路如出一辙:双链表维护顺序,字典(哈希表)快速定位元素。

Link 的定义很精简,利用了 __slots__ 来节省空间。

self.__root 作为伪头部节点,这样整个双链表的操作会很统一(不过感觉 root 节点不用 weakref 也没有什么问题)。

值得一提的是 weakref 的部分,注意在 __setitem__ 方法中,root 的前置节点使用了 weakref.proxy 来定义,这是为了避免在 popitem 的时候出现虽然删除了 self.__map 中的 link 但仍然释放不了内存的情况。

另外在 __delitem__ 最后两行,代码显式地把 link 的指针置为 None,其实不这么写的话当方法结束时 link 被回收之后也是一样的,不过这样更:

Explicit is better than implicit.

OD 和普通的 Python dict 在比较上也不一样:

    def __eq__(self, other):
        '''od.__eq__(y) <==> od==y.  Comparison to another OD is order-sensitive
        while comparison to a regular mapping is order-insensitive.

        '''
        if isinstance(other, OrderedDict):
            return dict.__eq__(self, other) and all(map(_eq, self, other))
        return dict.__eq__(self, other)

两个 OD 对象比较时会严格按照元素的顺序,而 OD 和 dict 比较则又会忽略顺序了。

OD 拥有 __dict__ (dict 没有),所以可以使用 d.foo = bar 这种属性赋值操作。

自 Python3.6 开始,普通 dict 也默认会按照插入的顺序保存元素,不过如果想显式地表达 order matters,还是 OD 更合适,而且它提供的 move_to_endpopitem 方法也更方便。相比之下,由于 dict 高效的实现,OD 的操作性能和内存占用都要略逊一筹,所以要依据具体的场景选择使用。

因为 LRUCache 才想一览 OD 的代码,有意思的是,在 OD 最初实现的 Issue 讨论中,就已经有人提出了支持 LRUCache 的想法,不过最终被否决了。


References