LRU和LFU算法

本文详细介绍了LRU(最近最少使用)和LFU(最不经常使用)缓存淘汰算法的原理及实现。LRU算法使用LinkedHashMap作为核心数据结构,通过双链表和哈希表保证高效操作。LFU算法则利用LinkedHashSet来快速访问和删除元素,根据访问频次和数据新鲜度进行淘汰。文章通过实例代码展示了如何手动实现这两个算法,加深了对它们工作方式的理解。
摘要由CSDN通过智能技术生成

LRU和LFU算法

LRU算法

LRU 的全称是 Least Recently Used,也就是说我们认为最近使用过的数据应该是「有用的」,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据。

LRU 缓存算法的核心数据结构就是哈希链表LinkedHashMap,双向链表和哈希表的结合体。使用该数据结构,就可以确保getput 方法都是 O(1) 的时间复杂度。

在这里插入图片描述

力扣第 146 题「LRU缓存机制」就是让你设计数据结构。

在使用LinkedHashMap实现LRU算法前,我们先手动使用双链表和HashMap实现以下LRU算法,加深对算法的理解。

使用双链表和HashMap实现

1)首先先把双链表的节点类写出来
public class Node {
    public int key, val;
    public Node next, prev;

    public Node(int key, int val) {
        this.key = key;
        this.val = val;
    }
}
2)构建双链表,实现几个 LRU 算法必须的 API
public class DoubleList {
    private Node head, tail;
    private int size;

    public DoubleList() {
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
        size = 0;
    }

    // 在链表尾部添加节点 x,时间 O(1)
    public void addLast(Node x) {
        x.prev = tail.prev;
        x.next = tail;
        tail.prev.next = x;
        tail.prev = x;
        size++;
    }

    // 删除链表中的 x 节点(x 一定存在)
    // 由于是双链表且给的是目标 Node 节点,时间 O(1)
    public void remove(Node x) {
        x.prev.next = x.next;
        x.next.prev = x.prev;
        size--;
    }

    // 删除链表中第一个节点,并返回该节点,时间 O(1)
    public Node removeFirst() {
        if (head.next == null) {
            return null;
        }
        Node first = head.next;
        remove(first);
        return first;
    }

    // 返回链表长度,时间 O(1)
    public int size() {
        return size;
    }

}

为什么必须使用双向链表?因为我们需要删除操作。删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)。

注意我们实现的双链表 API 只能从尾部插入,也就是说靠尾部的数据是最近使用的,靠头部的数据是最久为使用的

3)LRU算法框架
public class LRUCache {

    private HashMap<Integer, Node> map;
    private DoubleList cache;
    private int cap;

    public LRUCache(int cap) {
        this.cap = cap;
        map = new HashMap<>();
        cache = new DoubleList();
    }
}
4)提供API避免直接操作细节

由于我们要同时维护一个双链表 cache 和一个哈希表 map,很容易漏掉一些操作,比如说删除某个 key 时,在 cache 中删除了对应的 Node,但是却忘记在 map 中删除 key解决这种问题的有效方法是:在这两种数据结构之上提供一层抽象 API

/* 将某个 key 提升为最近使用的 */
private void makeRecently(int key) {
    Node x = map.get(key);
    // 先从链表中删除这个节点
    cache.remove(x);
    // 重新插到队尾
    cache.addLast(x);
}

/* 添加最近使用的元素 */
private void addRecently(int key, int val) {
    Node x = new Node(key, val);
    // 链表尾部就是最近使用的元素
    cache.addLast(x);
    // 别忘了在 map 中添加 key 的映射
    map.put(key, x);
}

/* 删除某一个 key */
private void deleteKey(int key) {
    Node x = map.get(key);
    // 从链表中删除
    cache.remove(x);
    // 从 map 中删除
    map.remove(key);
}

/* 删除最久未使用的元素 */
private void removeLeastRecently() {
    // 链表头部的第一个元素就是最久未使用的
    Node deleteNode = cache.removeFirst();
    // 同时别忘了从 map 中删除它的 key
    int deleteKey = deleteNode.key;
    map.remove(deleteKey);
}
5)实现get和put方法
public int get(int key) {
    if (!map.containsKey(key)) {
        return -1;
    }
    makeRecently(key);
    return map.get(key).val;
}

public void put(int key, int val) {
    if (map.containsKey(key)) {
        // 删除旧的数据
        deleteKey(key);
        // 新插入的数据为最近使用的数据
        addRecently(key, val);
        return;
    }

    if (cap == cache.size()) {
        // 删除最久未使用的元素
        removeLeastRecently();
    }
    // 添加为最近使用的元素
    addRecently(key, val);
}

至此,经过层层拆解,LRU 算法就完成了。

使用LinkedHashMap实现

public class LRUCache_146 {

    int cap;
    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();

    public LRUCache_146(int capacity) {
        this.cap = capacity;
    }

    public int get(int key) {
        if (!cache.containsKey(key)) {
            return -1;
        }
        // 将 key 变为最近使用
        makeRecently(key);
        return cache.get(key);

    }

    public void put(int key, int value) {
        if (cache.containsKey(key)) {
            // 修改 key 的值
            cache.put(key, value);
            // 将 key 变为最近使用
            makeRecently(key);
            return;
        }

        if (cache.size() >= this.cap) {
            // 链表头部就是最久未使用的 key
            int oldestKey = cache.keySet().iterator().next();
            cache.remove(oldestKey);
        }
        // 将新的 key 添加链表尾部
        cache.put(key, value);
    }

    private void makeRecently(int key) {
        int val = cache.get(key);
        // 删除 key,重新插入到队尾
        cache.remove(key);
        cache.put(key, val);
    }
}

LFU算法

LFU 算法相当于是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。把数据按照访问频次进行排序,而且频次还会不断变化,这可不容易实现。

在动手写代码之前,我们必须对LFU算法的一些特性了解清楚,不然无法写出正确的代码:

  1. 调用get(key)方法时,要返回该key对应的val
  2. 只要用get或者put方法访问一次某个key,该keyfreq就要加一。
  3. 如果在容量满了的时候进行插入,则需要将freq最小的key删除,如果最小的freq对应多个key,则删除其中最旧的那一个。(隐含条件:希望能够快速删除key列表中的任何一个key,因为频次为freq的某个key被访问,那么它的频次就会变成freq+1,就应该从freq对应的key列表中删除,加到freq+1对应的key的列表中。)

了解了这些特性之后,我们就发现光是普通的链表LinkedListHashMap满足不了我们快速删减key的需求,因此这里我们选择使用LinkedHashSetLinkedHashSet顾名思义,是链表和哈希集合的结合体。链表不能快速访问链表节点,但是插入元素具有时序;哈希集合中的元素无序,但是可以对元素进行快速的访问和删除,它俩结合起来就兼具了哈希集合和链表的特性。

综上,我们可以写出 LFU 算法的基本数据结构:

//使用LinkedHashSet
public class LFUCache {
    // key 到 val 的映射
    HashMap<Integer, Integer> keyToVal;
    // key 到 freq 的映射
    HashMap<Integer, Integer> keyToFreq;
    // freq 到 key 列表的映射
    HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;

    // 记录最小的频次
    int minFreq;
    // 记录 LFU 缓存的最大容量
    int cap;

    public LFUCache(int capacity) {
        keyToVal = new HashMap<>();
        keyToFreq = new HashMap<>();
        freqToKeys = new HashMap<>();
        this.cap = capacity;// 记录 LFU 缓存的最大容量
        this.minFreq = 0;// 记录最小的频次
    }
    
    public int get(int key) {}
    
    public void put(int key, int val) {}
}

LinkedHashSet实现

1)实现get和put方法框架

下面我们先来实现get(key)方法,逻辑很简单,返回key对应的val,然后增加key对应的freq

public int get(int key) {
    if (!keyToVal.containsKey(key)) {
        return -1;
    }
    // 增加 key 对应的 freq
    increaseFreq(key);
    return keyToVal.get(key);
}

这里需要注意的是,increaseFreq()方法的作用是增加 key 对应的 freq,具体实现后面来做。

写出put方法的逻辑:

public void put(int key, int value) {
    // 如果没有执行初始化直接执行 put ,就会出错
    if (this.cap == 0) return;

    /* 若 key 已存在,修改对应的 val 即可 */
    if (keyToVal.containsKey(key)) {
        keyToVal.put(key, value);
        increaseFreq(key);
        return;
    }

    /* key 不存在,需要插入 */
    /* 容量已满的话需要淘汰一个 freq 最小的 key */
    if (this.cap <= keyToVal.size()) {
        removeMinFreqKey();
    }

    /* 插入 key 和 val,对应的 freq 为 1 */
    keyToVal.put(key, value);
    keyToFreq.put(key, 1);
    freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
    freqToKeys.get(1).add(key);
    this.minFreq = 1;

}

同理,removeMinFreqKey()方法交由后面来实现。

2)实现两个方法

首先来实现removeMinFreqKey函数:

/* 移除频率最少的key */
private void removeMinFreqKey() {
    // freq 最小的 key 列表
    LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);
    // 其中最先被插入的那个 key 就是该被淘汰的 key
    int deleteKey = keyList.iterator().next();
    keyList.remove(deleteKey);
    // 删除后如果该频率 key 列表为空,则删除该频率
    // 无须更新minFreq,因为调用该方法的时候只有 put 方法, 新增元素会将 freq 置为 1
    if (keyList.isEmpty()) {
        freqToKeys.remove(this.minFreq);
    }
    keyToVal.remove(deleteKey);
    keyToFreq.remove(deleteKey);
}

下面来实现increaseFreq函数:

private void increaseFreq(int key) {
    int freq = keyToFreq.get(key);
    //更新两表
    keyToFreq.put(key, freq + 1);
    freqToKeys.get(freq).remove(key);

    // putIfAbsent: 如果传入key对应的value已经存在,就返回存在的value,不进行替换。
    // 如果不存在,就添加key和value,返回null
    // 将 key 加入 freq + 1 对应的列表中
    freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
    freqToKeys.get(freq + 1).add(key);

    // 如果 freq 对应的列表空了,移除这个 freq
    if (freqToKeys.get(freq).isEmpty()) {
        freqToKeys.remove(freq);
        // 如果这个 freq 恰好是 minFreq,更新 minFreq
        if (freq == this.minFreq) {
            this.minFreq++;
        }
    }
}

至此,经过层层拆解,LFU 算法就完成了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值