高频面试题:写一个LRU缓存

330 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

当我们在学习数据结构的时候同一时间一般只会关注一种数据结构,我们很难想到hashmap链表的组合能解决什么问题,所以今天我们来谈谈LRU缓存,它将这两个数据结构巧妙的结合起来。同时这也是一道高频面试题,它通过一道题考察了面试者对这两个数据结构的应用和对缓存的理解以及编码能力。

什么是LRU?

LRU算法是一种缓存淘汰策略,当缓存的可用内存不够的时候,它会将最近最少使用的缓存项移除,它同时考虑了缓存的最近使用时间使用频率,来决定缓存是否要被淘汰。

如何构建lru缓存?

使用hashmap保证我们getset调用的时间复杂度是O(1);如何确定哪些缓存项是最近操作过的呢?我们需要一个双向链表,当我们getset缓存项的时候我们需要把它移到队首,当我需要释放缓存空间的时候,我们从队尾依次移出缓存项,并把它从hashmap中移出。

代码实现是这样的:

type CacheAble interface {
   Key() string
   Size() int
}

type LRUCache struct {
   capacity int // 缓存可用容量
   items    map[string]*LRUCacheItem // hash表  key => Item
   list     *list.List //双向链表
}

type LRUCacheItem struct {
   CacheAble   CacheAble
   listElement *list.Element //双向链表指针
}

func NewLRUCache(capacity int) *LRUCache {
   return &LRUCache{
      capacity: capacity,
      items:    make(map[string]*LRUCacheItem, 9999),
      list:     list.New(),
   }
}

LRUCacheItem是内部实际的缓存项,包含了CacheAble接口和指向了双向链表的指针listElement。实现了CacheAble接口的任意类型都可以作为缓存项的一部分,当我们从双向链表中取出要淘汰的缓存项时,可以通过key()方法直接取到缓存项对应的key值,然后从map中删除这个缓存,通过len()方法在设置,更新和删除缓存项的时候可以很方便的维护缓存剩余容量。LRUCache包含了hashmap双向链表capacity记录了缓存的剩余可用大小,当剩余大小不足以支持写入新的缓存项时,就需要淘汰链表末尾的缓存了。

Get()从缓存中取值

从缓存中取值的时候我们需要把这个缓存项移到双向链表的头部:

func (c *LRUCache) Get(key string) CacheAble {
   item, ok := c.items[key]
   if !ok {
      return nil
   }
   c.list.MoveToFront(item.listElement)
   return item.CacheAble
}

Set()设置或更新缓存值

写缓存的时候我们首先要判断,缓存的剩余容量是否能够容纳新写入的缓存,不够就需要删除缓存直到能够写入,写入的时候先判断缓存key是否已经存在,不存在则创建缓存,并将缓存项放在链表的头部,同时减去相应的容量;当缓存key存在时,更新对应的缓存项,同时将缓存移动到链表头部同时更新缓存的容量,注意这时的容量应该减去的是更新前后缓存值大小的差值。

func (c *LRUCache) Set(CacheAble CacheAble) {
   // 清理空间
   for {
      if CacheAble.Size() < c.capacity {
         break
      }
      c.prune()
   }

   item, ok := c.items[CacheAble.Key()]
   if ok {
      item.CacheAble = CacheAble
      c.capacity -= CacheAble.Size() - item.CacheAble.Size()
      c.list.MoveToFront(item.listElement)
   } else {
      item := &LRUCacheItem{
         CacheAble: CacheAble,
      }
      item.listElement = c.list.PushFront(item)
      c.items[CacheAble.Key()] = item

      c.capacity -= CacheAble.Size()
   }
}

缓存淘汰

从链表尾部移除缓存并在map删除同时返还缓存容量。

// 淘汰末尾的10个缓存
func (c *LRUCache) prune() {
   for i := 0; i < 10; i++ {
      tail := c.list.Back()
      if tail == nil {
         continue
      }
      item := c.list.Remove(tail).(*LRUCacheItem)
      delete(c.items, item.CacheAble.Key())
      c.capacity += item.CacheAble.Size()
   }
}

单元测试

package main

import (
   "encoding/json"
   "testing"
)

type Product struct {
   Id       string
   Name     string
   Price    float64
   ImageURL string
   Describe string
}

func (p Product) Key() string {
   return p.Id
}

func (p Product) Size() int {
   bytes, _ := json.Marshal(p)
   return len(bytes)
}

func (p Product) GetName() string {
   return p.Name
}

func TestLRUCache(t *testing.T) {
   cache := NewLRUCache(100)
   p := Product{
      Id:    "689",
      Name:  "mysql从删库到跑路",
      Price: 54,
   }

   p1 := Product{
      Id:    "799",
      Name:  "go从入门到放弃",
      Price: 54,
   }

   cache.Set(p)
   t.Log("cacheItem1 大小: ", p.Size()) // 84
   cache.Set(p1)
   t.Log("cacheItem2 大小: ", p1.Size()) // 81 超过100 淘汰p

   cacheP := cache.Get("689")

   if cacheP != nil {
      t.Error("缓存淘汰失效")
   }

   cacheP1 := cache.Get("799")

   if cacheP1.Key() != "799" {
      t.Error("获取缓存失败")
   }

}

总结

  • LRU是一种常用的缓存淘汰策略,在实现上需要用到hashmap双向链表
  • 本文的代码实现并没有考虑并发控制,实际上需要引入锁,同时还要考虑锁的性能,可以考虑在LRUCache上层再包一层hashmap用来实现hash分片,降低锁粒度,详细可以参考这篇文章