LRU 缓存,能够快速返回缓存中元素的位置

Posted

技术标签:

【中文标题】LRU 缓存,能够快速返回缓存中元素的位置【英文标题】:LRU cache that is capable of quickly returning the position of an element in the cache 【发布时间】:2021-05-02 13:24:38 【问题描述】:

对于数据压缩,我想用关于该值最后出现时间的索引替换(长)列表中的值。所以列表:

18499   10123   5678   10123   10123   3344   10123   5678   4912   18499

将被替换如下:

N18449  N10123  N5678  K1      K0      N3344  K1      K2     N4912  K4

以前未见过的新值以 N 为前缀。已知的旧值以 K 为前缀。例如,第二次出现的 10123 被替换为 K1,因为还有另一个值 ( 5678)之间。但是,为了使索引尽可能低,我希望 K-indexes 不是测量列表中的距离,而是在最后一个值和当前出现值之间看到的唯一其他值的实际数量。因此,例如,将第二次出现的 5678 替换为 K2,因为它们之间还有两个其他值(10123 和 3344),即使 10123 重复了几次。类似地,最后一个值 18499 被 K4 替换,因为在它和列表的开头(也是 18499)之间还有四个其他值。如果只测量距离,则最后一个元素是 K9。

首先,看起来压缩/索引替换可以使用 LRU 缓存来完成,*** 拥有一些非常好的参考(即在 LRU cache design 上)。不幸的是,经典的 LRU 缓存对于这个目的不是很好:当查找时,如果一个项目在最后 N 个(LRU 缓存大小)项目中,那么 O(1) 的查找速度很快,实际 的查找LRU 缓存中某项的位置是 O(n)(其中 n 是找到的元素之前的元素数)。

解压步骤同样缓慢,当 Kn 需要再次替换为相应的值时:遍历经典 LRU 缓存的链表以查找要替换的项目,需要 n 步。

我很清楚,对于我的问题不存在 O(1) 解决方案,因为每次将新元素添加到缓存或一个现有的被移到前面。但是有 O(log(n)) 解决方案吗?如果可能的话,我不想使用 O(N)。

我想在 C++ 中实现它,但在任何其他编程语言中指向这种数据结构的指针也将不胜感激。我在这里询问的更多是算法而不是具体的实现。

【问题讨论】:

【参考方案1】:

到目前为止,我能想到的最好的解决方案是将已知值存储在双链表中(请记住,经典的 LRU 缓存由一个双链表组成,用于维护元素排序和一个哈希表快速 O(1) 元素访问),但在树结构中。一个合适的树似乎是在 github 上找到的 Countertree 2.0 库:https://github.com/fjtapia/countertree_2.0

Countertree 2.0 库提供了经典的 mapmultimapsetmultiset 变体树数据结构。但由于该库还在每个树节点处维护计数器,因此它允许快速 O(log(n)) 索引访问和两个位置/迭代器之间的距离计算。

因此,我想用countertree::multiset 替换标准LRU 缓存的双链接listmultiset 实际上会退化,因为所有元素比较相等。新元素总是插入到multiset 的前面。由于树中元素的相对顺序永远不会改变,因此所有元素都保持其相对于插入时间的相对位置。如果在树的构建过程中重新访问了值,它们将与当前树位置断开链接并重新插入到前面。所以每个值只会在多重集中出现一次。

为了快速找到已使用的值,multiset 中的位置(= 迭代器)由它们在单独的 hashmap 中的值索引(很像经典的 LRU 缓存由链表和 hashmap 组成)。如果稍后通过该哈希图再次找到一个值,则通过所述 O(log(n)) 距离计算测量其到multiset 前面的距离,然后将其从multiset 的当前位置删除,并且在前面重新插入(这是另一个 O(log(n)) 操作,它基本上是访问与计算距离相同的树节点,因此它不应该需要更多的内存访问)。

在解压过程中,通过索引在多重集中查找值,这再次发生在 O(log(n)) 时间内。

所以一切都很完美。还是我忽略了什么?还是有更好的解决方案?

【讨论】:

【参考方案2】:

另一种方法是保留一个位图,其中列表位置是缓存中某个元素最近出现的位置,加上一个段树,用于在一个范围内进行有效的弹出计数。 (Fenwick 树也是一种选择,但是您总是为查询支付 Ω(height),并且需要一种奇怪的非均匀存储方案来最小化空间。)

生成的结构有

unordered_map 从元素到(最近出现的索引,LRU 列表迭代器) list 最近使用次数最多到最少 如上所述的分段树。

要处理一个元素,请在unordered_map 中查找它。如果存在,则清除段树中上一次出现的位,创建一个列表条目,如果需要,则弹出列表的后面(清除段树中的索引)。将当前元素移动到 LRU 列表的前面,将其索引复制到 unordered_map 并在段树中设置该索引。

unordered_maplist 操作预计摊销 O(1),因此段树驱动渐近成本。这个成本理论上是 Θ(log n),但在实践中,局部性比树好得多。我们可以用 popcount 替换段树的底层,并使用 k-ary 段树而不是 2-ary 来减少随机内存访问。

缺点是管理段树的空间使用。也许您可以负担得起整个输入的位图,在这种情况下,太好了!否则,您可能需要对 LRU 列表和相关数据结构进行线性传递,以在保持顺序的同时连续重新编号索引。这不会影响摊销成本,因为它只需要在您处理了足够多的元素来证明它的合理性时才会发生。

【讨论】:

非常感谢您的评论!我对关于段树的 Wikipedia 文章有点担心,该文章将它们的一般存储要求列为 O(n log(n))。可能是,在此处的应用程序中,我们没有重叠段,内存使用量可能会更低。我什至有一种感觉,最后,对于线性情况,段树分解为计数器树中的计数。所以也许,我们的两个解决方案甚至实际上是相同的。但我必须考虑更多。 @KaiPetzke 是的,这是存储 n 个任意段的成本。这棵树不适用于点定位;我们只是存储每个 O(n) 基段的总和,这将是 O(n) bits

以上是关于LRU 缓存,能够快速返回缓存中元素的位置的主要内容,如果未能解决你的问题,请参考以下文章

Leetcode——LRU 缓存机制

LRU缓存策略

LinkedHashMap实现LRU缓存算法

146. LRU 缓存机制

LRU缓存(LRUCache)

算法:LRU缓存机制