LRU的几种实现

145 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 15 天,点击查看活动详情

LRU

LRU(Least Recently Used)是一种缓存淘汰算法,通常用于在缓存容量不足时淘汰最近最少使用的缓存数据,以保证缓存中的数据都是热点数据,提高缓存命中率。

缓存的作用就是存储最近使用的数据,最近用过了下一次也更可能再用到,但是容量有限。缓存的操作有:插入数据、查找数据

  • 插入数据:把数据加入缓存,如果满了就删除一个,再插进去
  • 查找数据:
    • 命中:要查找的数据在缓存里,就命中了,可以直接返回数据
    • Miss:没有命中,需要去磁盘读取数据,再把这个数据插入到缓存

LRU就是一种缓存淘汰策略,LRU就是当缓存满了,淘汰最近最久未使用的,就相当于对缓存的每个数据,记一下他们最后一次查询、插入的时间,最早来的就可以滚了,这里不欢迎没人要的人,新人和红人最受追捧。 因此要实现LRU就要记录使用的先后顺序,每次淘汰时,淘汰最久没人要的就好了

LRU接口

先定义一下接口的实现:

type LruCache interface {
   New(cap int) *LruCache
   Search(k int) (int, bool)
}

缓存其实存储的是键值对,用k来代表你需要的数据v,下面k和v都为int为例。

数组 + 时间戳

数组+时间戳的实现方式可以用一个固定大小的数组,每个元素保存一个键值对以及对应的时间戳。对于每个键值对,都记录一个时间戳,表示最近一次被访问的时间。当数组满时,如果需要添加新的键值对,则需要找到最旧的键值对,也就是访问时间最早的键值对,将其删除,再添加新的键值对。

type kv struct {
   key   string
   value string
   ts    time.Time
}

type LRU struct {
   size    int
   data    []kv
   current int
}

func NewLRU(size int) *LRU {
   return &LRU{size: size, data: make([]kv, size)}
}

func (l *LRU) Get(key string) (string, bool) {
   for i := range l.data {
      if l.data[i].key == key {
         l.data[i].ts = time.Now()
         return l.data[i].value, true
      }
   }
   return "", false
}

func (l *LRU) Set(key, value string) {
   for i := range l.data {
      if l.data[i].key == key {
         l.data[i].value = value
         l.data[i].ts = time.Now()
         return
      }
   }
   if l.current == l.size {
      oldest := l.data[0].ts
      oldestIdx := 0
      for i := 1; i < l.size; i++ {
         if l.data[i].ts.Before(oldest) {
            oldest = l.data[i].ts
            oldestIdx = i
         }
      }
      l.data[oldestIdx].key = key
      l.data[oldestIdx].value = value
      l.data[oldestIdx].ts = time.Now()
   } else {
      l.data[l.current].key = key
      l.data[l.current].value = value
      l.data[l.current].ts = time.Now()
      l.current++
   }
}

这里空间复杂度为O(n)

get时间复杂度O(n),查询要枚举

set时间复杂度O(n),修改时也要枚举先找到

双向链表

数组一个带麻烦就是,删除一个节点,要把后面的依次向前挪动,这个太慢了,利用链表可以避免后面的挪动。 DoublyLinkedListAdd 方法用于向链表尾部添加一个节点。如果链表为空,就将头节点和尾节点都指向这个新节点。否则,将新节点的前向指针指向当前的尾节点,将当前尾节点的后向指针指向新节点,然后将尾节点指向新节点。

DoublyLinkedListRemove 方法用于删除一个节点。删除一个节点时需要考虑两种情况:删除的是头节点和删除的是尾节点。如果删除的是头节点,就将头节点指向下一个节点;否则将要删除节点的前向指针指向要删除节点的后一个节点。

DoublyLinkedListMoveToBack 方法用于将一个节点移动到链表的尾

type Node struct {
   key   int
   value int
   prev  *Node
   next  *Node
}

type DoublyLinkedList struct {
   head *Node
   tail *Node
}

func NewDoublyLinkedList() *DoublyLinkedList {
   return &DoublyLinkedList{nil, nil}
}

func (l *DoublyLinkedList) Add(node *Node) {
   if l.head == nil {
      l.head = node
      l.tail = node
   } else {
      node.prev = l.tail
      l.tail.next = node
      l.tail = node
   }
}

func (l *DoublyLinkedList) Remove(node *Node) {
   if node.prev == nil {
      l.head = node.next
   } else {
      node.prev.next = node.next
   }

   if node.next == nil {
      l.tail = node.prev
   } else {
      node.next.prev = node.prev
   }
}

func (l *DoublyLinkedList) MoveToBack(node *Node) {
   l.Remove(node)
   l.Add(node)
}

空间复杂度O(n) 插入时间复杂度O(1) 查询时间复杂度O(n),耗时在枚举链表的遍历

哈希+双向链表

那么对于双向链表的改进就是,加一个map记录k到链表节点的映射,这样子O(1)可以实现查询

import "container/list"

type LRUCache struct {
   capacity int
   size     int
   cache    map[int]*list.Element
   list     *list.List
}

type cacheNode struct {
   key   int
   value int
}

func Constructor(capacity int) LRUCache {
   return LRUCache{
      capacity: capacity,
      cache:    make(map[int]*list.Element),
      list:     list.New(),
   }
}

func (this *LRUCache) Get(key int) int {
   if elem, ok := this.cache[key]; ok {
      this.list.MoveToFront(elem)
      return elem.Value.(*cacheNode).value
   }
   return -1
}

func (this *LRUCache) Put(key int, value int) {
   if elem, ok := this.cache[key]; ok {
      elem.Value.(*cacheNode).value = value
      this.list.MoveToFront(elem)
      return
   }
   if this.size == this.capacity {
      tail := this.list.Back()
      delete(this.cache, tail.Value.(*cacheNode).key)
      this.list.Remove(tail)
      this.size--
   }
   elem := this.list.PushFront(&cacheNode{key, value})
   this.cache[key] = elem
   this.size++
}

空间复杂度O(n) GetO(1) PutO(1)

总的来说,哈希表 + 双向链表是最为常见和推荐的一种LRU实现方法,具有时间复杂度低、空间复杂度合理的优点。