【问题标题】:Why Use A Doubly Linked List and HashMap for a LRU Cache Instead of a Deque?为什么对 LRU 缓存使用双向链表和 HashMap 而不是 Deque?
【发布时间】:2021-11-01 11:42:36
【问题描述】:

我在 LeetCode 上使用常规方法(双链表+哈希图)实现了 LRU 缓存问题的设计。对于那些不熟悉这个问题的人,这个实现看起来像这样:

我明白为什么要使用这种方法(两端快速移除/插入,中间快速访问)。我不明白的是,当人们可以简单地使用基于数组的双端队列(在 Java ArrayDeque 中,C++ 只是双端队列)时,为什么有人会同时使用 HashMap 和 LinkedList。这个双端队列允许在两端轻松插入/删除,并在中间快速访问,这正是 LRU 缓存所需要的。您还可以使用更少的空间,因为您不需要存储指向每个节点的指针。

为什么 LRU 缓存几乎被普遍设计(至少在大多数教程中)使用后一种方法而不是 Deque/ArrayDeque 方法? HashMap/LinkedList 方法有什么好处吗?

【问题讨论】:

  • 双向链表除了linked listdoubly linked list之外,还可以同时是stackqueuedeque,非常方便。例如Java的LinkedList就是这样一个实现。实际上,当您尝试实现stackqueue 时,您会发现大多数操作都可以通过简单地包装来自一个良好实现的双向链表的操作来完成。
  • [续] 如果大小经常变化,那么基于数组的结构可能需要经常调整大小,这很昂贵,因此对性能不利。
  • 不使用基于数组的结构的另一个主要原因是从非端添加/删除成本高昂,需要移动以下元素,并且可能导致调整大小。虽然链表没有这个问题,但链表的问题是它不能快速访问中间的元素,但如果你只访问2端,那么双向链表也是O(1)。
  • @EricWang 您只能从 LRU 缓存的开头和结尾删除。
  • 如果您已经编写了 LRU 缓存并且不需要 move_to_end 操作,那么您可能已经编写了 FIFO 缓存并且不是 LRU 缓存。

标签: algorithm data-structures


【解决方案1】:

当 LRU 缓存已满时,我们会丢弃 最近最少使用项。

如果我们要丢弃队列前面的项目,那么,我们必须确保最前面的项目是最长时间未使用的项目。

我们通过确保在每次使用时 将项目排到队列的后面来确保这一点。最前面的项目就是最长时间没有移到后面的项目。

为此,我们需要在每个put OR get 操作上维护队列:

  • 当我们put缓存中的一个新项目时,它成为最近最近使用的项目,所以我们把它放在队列的后面。

    李>
  • 当我们get一个已经在缓存中的项目时,它成为最近最近使用的项目,所以我们将它从当前位置移动到队列的最后。

将项目从中间移动到末尾不是双端队列操作,ArrayDeque 接口不支持。 ArrayDeque 使用的底层数据结构也没有有效地支持它。使用双向链表是因为它们确实有效地支持此操作。

【讨论】:

  • 不知道我是否应该在一个单独的问题中问这个问题,但为什么它是 put(key, value) 而不是 put(value)? Leetcode 和许多其他教程网站上的示例具有相同的键和值。
  • @user3586940 因为get(value) 通常没有意义。通常的情况是,当值不在缓存中时,它是根据键计算的——如果您已经知道该值,那么您根本不需要在缓存中查找它。如果您使用缓存只是为了避免值的重复副本,它确实 有意义。那么keyvalue是一样的。
  • 恕我直言,有两种方法可以解释 maxsize - 缓存最后一次 size 调用或缓存总共 size 元素。如果您缓存最后一个大小调用,这可以使用 ArrayDeque - 您只需要常量 poplefts 和 appends(当将元素添加到队列时使用值和计数更新哈希映射;当元素弹出时减少计数和如果计数为零,则从 hashmap 中删除)。我根据 OP 的问题将其解释为未提及从中间删除元素请不要称我的回答完全不正确。
  • @Primusa 术语“LRU 缓存”很常见并且具有公认的定义。 OP 引用的实现显然是指普遍接受的定义。您的答案是指其他某种不会使“最近最少使用”键过期的缓存。可能您正在考虑 FIFO 缓存,它也有一个普遍接受的定义。您的回答暗示“每个人都在做”的事情是浪费和不必要的,但实际上并非如此。我称你的答案“完全不正确”,因为任何相信它的人在有人纠正它之前不会知道 LRU 缓存是什么
  • 轻度更正:它是一个不会使 most 最近使用的密钥过期的缓存。这与丢弃最近最少使用的元素不一样。至于答案,它直接指出“带有首先丢弃最近最少使用的键的附加约束”。这就是 LRU 缓存,不是吗?
【解决方案2】:

LRU 缓存的目的是在O(1) 时间支持两个操作:get(key)put(key, value),另外还有一个约束,即首先丢弃最近最少使用的键。通常,键是函数调用的参数,值是该调用的缓存输出。

无论您如何解决此问题,我们都同意您必须使用哈希图。您需要一个 hashmap 来将缓存中已经存在的键映射到 O(1) 中的值。

为了处理最近最少使用的键首先被丢弃的附加约束,您可以使用 LinkedList 或 ArrayDeque。但是,由于我们实际上不需要访问中间,所以 LinkedList 更好,因为您不需要调整大小。

编辑:

先生。 Timmermans 在他的回答中讨论了为什么 ArrayDeques 不能在 LRU cache 中使用,因为必须将元素从中间移动到末尾。话虽如此,这里是一个 LRU cache 的实现,它仅使用 deque 中的 appends 和 poplefts 成功提交到 leetcode。请注意,python 的 collections.deque 是作为双向链表实现的,但是我们只使用 collections.deque 中的操作,这些操作也是循环数组中的 O(1),因此无论如何算法都保持不变。

from collections import deque

class LRUCache:

    def __init__(self, capacity: 'int'):
        self.capacity = capacity
        self.hashmap = {}
        self.deque = deque()

    def get(self, key: 'int') -> 'int':
        res = self.hashmap.get(key, [-1, 0])[0]
        if res != -1:
            self.put(key, res)
        return res

    def put(self, key: 'int', value: 'int') -> 'None':

        self.add(key, value)
        while len(self.hashmap) > self.capacity:
            self.remove()

    def add(self, key, value):
        if key in self.hashmap:
            self.hashmap[key][1] += 1
            self.hashmap[key][0] = value
        else:
            self.hashmap[key] = [value, 1]
        self.deque.append(key)

    def remove(self):
        k = self.deque.popleft()
        self.hashmap[k][1] -=1
        if self.hashmap[k][1] == 0:
            del self.hashmap[k]

我同意 Timmermans 先生的观点,即使用 LinkedList 方法更可取 - 但我想强调的是,使用 ArrayDeque 构建 LRU 缓存是可能的。

我和 Timmermans 先生之间的主要混淆是我们如何解释容量。我认为容量是指缓存最后一个 N 获取/放置请求,而 Timmermans 先生认为它是指缓存最后一个 N 唯一项。

上面的代码在put 中确实有一个循环,这会减慢代码的速度——但这只是为了让代码符合缓存最后一个N 唯一项。如果我们让代码缓存最后一个 N 请求,我们可以将循环替换为:

if len(self.deque) > self.capacity: self.remove()

这将使它与链表变体一样快。

无论maxsize 被解释为什么,上述方法仍可用作LRU 缓存——最近最少使用的元素首先被丢弃。

我只想强调以这种方式设计LRU 缓存是可能的。源就在那儿 - 尝试在 Leetcode 上提交!

【讨论】:

  • 为什么需要调整大小? LRU 缓存大小是固定的,不是吗?
  • 有时 lru_cache 没有限制,即 python 的 from functools import lru_cache 可以选择没有容量。但你是对的——在某些语言中,循环列表比双向链表快得多。无论哪种方式,您都需要一个哈希图和某种队列。
  • 谢谢,您的评论消除了我的困惑。我正在解决的问题版本具有 LRU 缓存的固定容量,在这种情况下,双端队列就可以了。
  • [to keep track of order of use,] you can use a LinkedList or ArrayDeque 一个选项是在可迭代的东西上使用 move to front - 我没有看到 java.util.ArrayDeque 支持这一点并承诺任何性能。虽然MTF 的从中间删除-部分由(双)链表直接支持,但这个集合是LRU 缓存私有的,也可以使用从下一个节点并取消链接,但对于是众所周知的JRE的一部分
  • 您不会从中间删除任何操作。您所需要的只是一个常规队列,该队列以恒定的时间附加到一端,并以恒定的时间从另一端弹出。只要这些操作是不变的,从时间复杂度的角度来看,如何实现队列并不重要。至于 java 的东西,我真的不知道它是什么意思 - 就我而言,OP 使用术语 ArrayDeque 来指代用循环数组实现的队列,它确实作为缓存中的队列工作。
【解决方案3】:

双向链表是队列的实现。因为双向链表可以立即访问列表的开头和结尾,所以它们可以在 O(1) 的任一侧插入数据,也可以在 O(1) 的任一侧删除数据。因为双向链表可以在 O(1) 时间内在末尾插入数据并在 O(1) 时间内从前面删除数据,它们为队列提供了完美的底层数据结构。 Queeus 是项目列表,其中数据只能在末尾插入并从开头删除。

队列是抽象数据类型的一个例子,我们可以在底层使用数组来实现它们。现在,由于队列在末尾插入并从开头删除,因此数组仅与底层数据结构一样好。虽然数组在末尾插入是 O(1),但从头开始删除是 O(N)。另一方面,双向链表在末尾插入和从开头删除都是 O(1)。这就是它非常适合用作队列的底层数据结构的原因。

Pyhon deque 使用链表作为其数据结构的一部分。这是它使用的那种链表。使用双向链表,deque 能够以恒定的 O(1) 性能从队列的两端插入或删除元素。 pyhton-deque

【讨论】:

  • 嗨@Yilmaz,为什么不保留对简单链表尾部和头部的引用?而不是使用在我看来浪费内存的双链表
猜你喜欢
  • 2020-06-01
  • 1970-01-01
  • 2017-12-21
  • 1970-01-01
  • 1970-01-01
  • 2015-12-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多