高阶数据结构(3)并查集:并查集原理、并查集实现、并查集应用;LRUCache:概念、LRU Cache的实现、JDK中类似LRUCahe的数据结构LinkedHashMap、LRU Cache的OJ

接上次博客:高阶数据结构(2)位图&布隆过滤器&海量数据面试题(位图:概念、实现、应用;布隆过滤器:提出、插入、查找、实现、删除、优点、缺陷、应用场景;海量数据面试题:哈希切割、位图应用\布隆过滤器​​​)-CSDN博客

目录

并查集

并查集原理

并查集实现 

并查集应用

 LRUCache

什么是LRU Cache

LRU Cache的实现 

JDK中类似 LRUCahe的数据结构LinkedHashMap 

LRU Cache的OJ


并查集

并查集原理

并查集(Union-Find Set)是一种用于解决集合合并与查找问题的数据结构。它主要支持两个操作:合并(Union)和查找(Find)。在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合,而并查集正是用来有效管理这些集合的。

初始时,每个元素被视为一个独立的单元素集合,每个集合有一个代表元素(或称为根节点)。然后,通过一定的规律不断地合并具有相同代表元素的集合。同时,通过查询可以快速得知某个元素属于哪个集合。

并查集的主要操作如下:

  1. 初始化(Initialization): 初始时,每个元素都是一个单独的集合,代表元素即为自身。

  2. 查找(Find): 查询某个元素所属的集合,通常通过查找代表元素来判断。

  3. 合并(Union): 将两个集合合并为一个集合,通常选择其中一个集合的代表元素作为新集合的代表元素。

并查集的典型应用场景包括连通性问题、图的最小生成树算法中的 Kruskal 算法等。

我们可以举个简单的例子来理解一下“并查集”的概念:

假设,你的社团出去团建,活动内容是在玄武湖捉迷藏。一共有10个同学一起玩,第一轮游戏指定了社长和两位副社长当“猫”,他们分头出发,去寻找藏起来的小社员们,游戏要求每次找到一个小社员,这个小社员都需要跟在找到他的那个社长身后,一同出发去寻找剩下的成员。

游戏还没开始时是这样的:

现在游戏开始,“猫”们开始行动了,很快他们找到了所有藏好的社员:

 

从游戏结果看,我们知道6、7、8小社员被0社长找到了,他们归属于同一个小分队;相应的,4和9归属于1号社长的小分队;3、5归属于2号社长的小分队。

仔细观察数组中内容,我们可以得出以下结论:

  1. 数组的下标对应集合中元素的编号: 在并查集的数组表示中,数组的索引通常对应集合中的元素编号。例如,数组的第 i 个元素表示元素 i 所在的集合。

  2. 数组中如果为负数,负号代表根,数字代表该集合中元素个数: 这是一种常见的表示方式。如果数组中的某个元素为负数,那么它表示该集合的根节点,而负数的绝对值表示该集合中元素的个数。这种方式有助于在查找时找到集合的根节点,并且可以通过负数的绝对值获取集合中元素的个数。

  3. 数组中如果为非负数,代表该元素双亲在数组中的下标: 如果数组中的某个元素为非负数,那么它表示该元素的双亲在数组中的下标。这是在进行合并操作时使用的信息,通过找到两个元素所在集合的根节点,然后将其中一个根节点的双亲指向另一个根节点,实现集合的合并。

当然,在游戏进行时,三个社长消息不能互通,所以他们都不知道对方已经找到了所有的社员,他们还会带着小分队继续寻找剩下的社员。

这个过程中,0号小分队和1号小分队巧遇,于是他们合并到了一起:

通过上面这个例子我们可以知到,并查集一般可以解决以下问题: 

  1. 查找元素属于哪个集合: 沿着数组表示的树形结构,一直找到根节点,即数组中元素为负数的位置。根节点的索引即为该元素所属的集合的代表元素。

  2. 查看两个元素是否属于同一个集合: 沿着数组表示的树形结构往上追溯,一直找到两个元素的根节点。如果两个元素的根节点相同,说明它们属于同一个集合;反之,如果根节点不同,说明它们分属不同的集合。

  3. 将两个集合归并成一个集合:

    • 合并两个集合中的元素,可以将其中一个集合的根节点的双亲指向另一个集合的根节点,实现集合的合并。
    • 可以选择将一个集合的名称改成另一个集合的名称,即将一个集合的根节点的索引设置为另一个集合的根节点的索引。
  4. 集合的个数: 遍历数组,统计数组中元素为负数的个数,即为集合的个数。每个负数表示一个集合的根节点。

并查集通过这些操作,可以高效地维护元素之间的集合关系,广泛应用于解决网络连通性问题、图的最小生成树算法、区域合并等场景。其简洁而高效的设计使得它成为处理一些集合问题的有力工具。

并查集实现 

package unionfindset;

import java.util.Arrays;
import java.util.Scanner;

public class UnionFindSet {
    private int[] elem; // 底层是一个数组

    public UnionFindSet(int n) {
        this.elem = new int[n];
        Arrays.fill(elem, -1); // 整体初始化为-1:代表根
    }

    /**
     * 查找x的根
     *
     * @param x
     * @return
     */
    public int findRoot(int x) {
        if (x < 0 || x >= elem.length) {
            throw new IndexOutOfBoundsException("数据不合法");
        }
        while (elem[x] >= 0) {
            x = elem[x];
        }
        return x;
    }

    /**
     * 合并x1和x2,但是有个问题,x1和x2必须都是根
     * 所以,先得查找x1和x2的根
     *
     * @param x1
     * @param x2
     */
    public void union(int x1, int x2) {
        int index1 = findRoot(x1);
        int index2 = findRoot(x2);
        if (index1 == index2) return; // 说明x1和x2的根是相同的,不进行合并
        elem[index1] = elem[index1] + elem[index2];
        elem[index2] = index1;
    }

    /**
     * 判断两个数字是不是在同一个集合当中
     *
     * @param x1
     * @param x2
     * @return
     */
    public boolean isSameSet(int x1, int x2) {
        int index1 = findRoot(x1);
        int index2 = findRoot(x2);
        return index1 == index2; // 说明在同一个集合
    }

    /**
     * 求数组当中集合的个数
     *
     * @return
     */
    public int getCount() {
        int count = 0;
        for (int x : elem) {
            if (x < 0) {
                count++;
            }
        }
        return count;
    }

    public void printArr() {
        for (int i = 0; i < elem.length; i++) {
            System.out.print(elem[i] + " ");
        }
        System.out.println();
    }
}

构造函数 public UnionFindSet(int n):

初始化底层数组 elem,将所有元素的父节点初始化为 -1,表示每个元素都是自己的根。

查找根节点 public int findRoot(int x):

从当前元素 x 开始,不断向上查找,直到找到根节点,根节点的特征是其父节点为负数,返回根节点的索引。

合并两个集合 public void union(int x1, int x2):

  • 先找到两个元素的根节点 index1 和 index2。
  • 将 index2 的父节点指向 index1,表示将两个集合合并。
  • 更新 index1 的父节点为合并后的集合大小。

判断是否在同一个集合 public boolean isSameSet(int x1, int x2):

  • 找到元素 x1 和 x2 的根节点。
  • 如果它们的根节点相同,说明在同一个集合中,返回 true;否则返回 false。

计算集合个数 public int getCount():

遍历数组,统计数组中元素为负数的个数,每个负数表示一个集合的根节点,返回集合的个数。

打印数组状态 public void printArr():

打印当前数组的状态,用于调试和观察。

 

并查集应用

力扣链接:547. 省份数量 - 力扣(LeetCode)

有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

省份是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

返回矩阵中省份的数量。

 

import java.util.Arrays;

public class UnionFindSet {
    private int[] elem;  // 底层是一个数组

    // 构造函数,初始化并查集,每个元素自成一个单元素集合,根节点初始化为 -1
    public UnionFindSet(int n) {
        this.elem = new int[n];
        Arrays.fill(elem, -1);
    }

    // 查找x的根节点
    public int findRoot(int x) {
        if (x < 0) {
            throw new IndexOutOfBoundsException("数据不合法");
        }
        while (elem[x] >= 0) {
            x = elem[x];
        }
        return x;
    }

    // 合并x1和x2,x1和x2必须都是根节点
    public void union(int x1, int x2) {
        int index1 = findRoot(x1);
        int index2 = findRoot(x2);
        if (index1 == index2) return;  // 说明x1和x2的根是相同的,不进行合并
        elem[index1] = elem[index1] + elem[index2];
        elem[index2] = index1;
    }

    // 判断两个数字是不是在同一个集合中
    public boolean isSameSet(int x1, int x2) {
        int index1 = findRoot(x1);
        int index2 = findRoot(x2);
        return index1 == index2;  // 如果根节点相同,说明在同一个集合中
    }

    // 求数组中集合的个数
    public int getCount() {
        int count = 0;
        for (int x : elem) {
            if (x < 0) {
                count++;
            }
        }
        return count;
    }

    // 打印数组状态
    public void printArr() {
        for (int i = 0; i < elem.length; i++) {
            System.out.print(elem[i] + " ");
        }
        System.out.println();
    }
}

class Solution {
    public int findCircleNum(int[][] isConnected) {
        int n = isConnected.length;
        UnionFindSet ufs = new UnionFindSet(n);
        for (int i = 0; i < isConnected.length; i++) {
            for (int j = 0; j < isConnected[i].length; j++) {
                // 表示第 i 个城市和第 j 个城市相邻,那么就合并
                if (isConnected[i][j] == 1) {
                    ufs.union(i, j);
                }
            }
        }
        return ufs.getCount();
    }
}

力扣链接:990. 等式方程的可满足性 - 力扣(LeetCode)

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b" 或 "a!=b"。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。

只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false。 

class UnionFindSet {
    private int[] parent;

    // 初始化并查集,每个节点独立成为一个集合,根节点的parent指向自己
    public UnionFindSet(int size) {
        parent = new int[size];
        for (int i = 0; i < size; ++i) {
            parent[i] = i;
        }
    }

    // 查找节点的根节点
    public int findRoot(int x) {
        if (x != parent[x]) {
            // 路径压缩,将当前节点直接指向根节点,降低树的高度
            parent[x] = findRoot(parent[x]);
        }
        return parent[x];
    }

    // 合并两个集合
    public void union(int x, int y) {
        int rootX = findRoot(x);
        int rootY = findRoot(y);
        if (rootX != rootY) {
            // 将一个集合的根节点连接到另一个集合的根节点
            parent[rootX] = rootY;
        }
    }
}

class Solution {
    public boolean equationsPossible(String[] equations) {
        // 初始化并查集,26个字母对应26个节点
        UnionFindSet ufs = new UnionFindSet(26);

        // 处理所有相等的方程,将相等的字母放入同一个集合
        for (String eq : equations) {
            char op = eq.charAt(1);
            int x = eq.charAt(0) - 'a';
            int y = eq.charAt(3) - 'a';
            if (op == '=') {
                ufs.union(x, y);
            }
        }

        // 处理所有不等的方程,检查它们是否在同一个集合中,如果是,则返回false
        for (String eq : equations) {
            char op = eq.charAt(1);
            int x = eq.charAt(0) - 'a';
            int y = eq.charAt(3) - 'a';
            if (op == '!' && ufs.findRoot(x) == ufs.findRoot(y)) {
                return false;
            }
        }

        // 所有方程都被处理,且没有出现矛盾,返回true
        return true;
    }
}

 LRUCache

什么是LRU Cache

LRU(Least Recently Used)是Cache替换算法中的一种,全名为“最近最少使用”,它的核心原则是在Cache空间不足时淘汰最近最少被使用的数据。这种算法的设计理念是基于程序的局部性原理,即在一段时间内,程序很可能对某些数据进行频繁的访问,而其他数据可能长时间不被使用。

那什么是Cache?

狭义上的Cache是指位于CPU和主存之间的快速RAM(Random Access Memory),通常采用较快速但昂贵的SRAM(Static Random Access Memory)技术。这种Cache被用于存储CPU频繁访问的指令和数据,以提高数据的读取速度,减少CPU等待主存数据的时间。

Cache的存在可以有效地缓解CPU与主存之间的速度差异,因为SRAM相比于主存中的DRAM(Dynamic Random Access Memory)更为快速。Cache的容量通常较小,但由于其高速访问特性,它可以加速CPU对于频繁使用的数据的读取操作。

广义上的Cache是指位于速度相差较大的两种硬件之间的结构,用于协调两者数据传输速度的差异。除了CPU与主存之间的Cache,还存在其他类型的Cache,比如内存与硬盘之间的Cache,以及硬盘与网络之间的Cache。这些Cache用于缓存数据,以减少不同硬件之间的数据传输时的性能瓶颈,提高整体系统的响应速度。在网络中,Internet临时文件夹或网络内容缓存等也被认为是某种形式上的Cache,用于存储已访问的网络数据,以减少重复下载,提高浏览器的加载速度。

Cache的容量有限,意味着它只能存储有限数量的数据。当Cache达到容量上限而又有新的内容需要添加时,就需要进行替换,腾出空间来存放新的数据。这个替换的策略对于Cache的性能至关重要。

LRU Cache(Least Recently Used Cache)的替换原则是基于最近的访问历史。当需要替换数据时,LRU会选择淘汰最近最少被使用的数据,即在一段时间内未被访问的数据。这个原则也可以理解为“最久未使用”。

举例来说,假设Cache中有多个数据块,每个数据块有一个时间戳表示最近一次访问的时间。当需要替换时,LRU会选择时间戳最早的数据块进行淘汰,以便为新的数据腾出空间。这就确保了Cache中保留的数据是最近使用过的,有助于提高Cache命中率。

LRU算法的实现可以通过多种方式,例如使用双向链表和哈希表的结合。这样,每次对数据的访问都可以在常数时间内更新链表中的位置,使得LRU Cache能够高效地维护最近的访问历史。

LRU Cache的实现 

实现LRU Cache的方法和思路很多,但为了保持高效实现O(1)的put和get操作,通常使用双向链表和哈希表的组合是最高效和经典的选择。

  • 双向链表: 双向链表的每个节点都有两个指针,一个指向前一个节点,一个指向后一个节点。这使得在任意位置进行O(1)的插入和删除操作成为可能。在LRU Cache中,双向链表用于维护数据的访问顺序,确保最近使用的数据始终位于链表头部,而最久未使用的数据位于链表尾部

  • 哈希表: 通过哈希函数,可以将键映射到索引,实现快速的查找、插入和删除操作。在LRU Cache中,哈希表用于存储数据的键值对,其中键是数据的唯一标识,值是指向双向链表中对应节点的指针。这样,通过哈希表可以在O(1)时间内找到对应节点。

通过结合双向链表和哈希表,实现了高效的LRU Cache。当需要插入、删除或访问数据时,可以在O(1)时间内完成操作。这种设计确保了Cache在有限容量内存储最相关的数据,同时维护了访问顺序,使得最近使用的数据更容易被访问到。这种经典的设计模式在实际应用中被广泛采用,以提高缓存系统的性能。

JDK中类似 LRUCahe的数据结构LinkedHashMap 

LinkedHashMap 是 Java 中提供的一种有序的哈希表,它继承自 HashMap,但相比于 HashMap 具有按照插入顺序或访问顺序进行迭代的特性。在其内部实现中,使用了双向链表维护了插入或访问的顺序。

LinkedHashMap 构造方法中参数的解释:

  • initialCapacity(初始容量大小): 指定 LinkedHashMap 的初始容量大小,即哈希表的大小。使用无参构造方法时,此值默认为 16。
  • loadFactor(加载因子): 指定哈希表在达到多少比例的满时进行扩容。加载因子越小,哈希表的负载越小,但会导致空间浪费;加载因子越大,哈希表的负载越大,但可能会增加碰撞。使用无参构造方法时,此值默认为 0.75f
  • accessOrder(访问顺序): 如果设置为 true,则表示基于访问顺序;如果设置为 false(默认值),则表示基于插入顺序。基于访问顺序意味着每次调用 get 方法后,会将对应的元素移到双向链表的末尾,以保持最近访问的元素在链表末尾。这样,在迭代时,元素的顺序就是它们被访问的顺序。

 我们可以来看看当accessOrder为true的时候:

import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapExample {
    public static void main(String[] args) {
        // 创建一个基于访问顺序的LinkedHashMap
        Map<String, Integer> linkedHashMap = new LinkedHashMap<>(16, 0.75f, true);

        // 添加数据
        linkedHashMap.put("One", 1);
        linkedHashMap.put("Two", 2);
        linkedHashMap.put("Three", 3);

        System.out.println(linkedHashMap);
        // 访问"Two"
        linkedHashMap.get("Two");

        System.out.println(linkedHashMap);
    }
}

再来看看当accessOrder为true的时候:

import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapExample {
    public static void main(String[] args) {
        // 创建一个基于访问顺序的LinkedHashMap
        Map<String, Integer> linkedHashMap = new LinkedHashMap<>(16, 0.75f, false);

        // 添加数据
        linkedHashMap.put("One", 1);
        linkedHashMap.put("Two", 2);
        linkedHashMap.put("Three", 3);

        System.out.println(linkedHashMap);
        // 访问"Two"
        linkedHashMap.get("Two");

        System.out.println(linkedHashMap);
    }
}

我们也可以点进源码看看它的具体实现:

它的构造方法有很多种:

 

 

我们看看部分比较重要的方法:

构造方法:

public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
    // 调用父类 HashMap 的构造方法,初始化哈希表
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

 这个构造方法用于创建一个 LinkedHashMap 实例。其中,initialCapacity 是初始容量大小,loadFactor 是加载因子,accessOrder 用于指定是基于插入顺序还是访问顺序。

 双向链表的 Entry 类:

// Entry 继承自 HashMap.Node
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after; // 用于维护双向链表的前后关系
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

 这个 Entry 类继承自 HashMap.Node,并新增了 before 和 after 两个属性,用于维护双向链表的前后关系。

get 方法:

public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    if (accessOrder)
        afterNodeAccess((Entry<K,V>)e);
    return e.value;
}

get 方法首先调用 getNode 方法获取节点,然后如果启用了访问顺序,就调用 afterNodeAccess 方法,将访问的节点移动到链表末尾。

afterNodeAccess 方法:

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    // 判断是否启用了访问顺序,并且当前节点不是链表的最后一个节点
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.after = null; // 将当前节点的 after 引用置为 null,表示它将移到链表末尾
        if (b == null)
            head = a; // 如果当前节点的前一个节点为 null,说明当前节点是链表头部节点,将头部指针指向当前节点的 after 节点
        else
            b.after = a; // 如果当前节点不是头部节点,将当前节点的前一个节点的 after 指向当前节点的 after 节点
        if (a != null)
            a.before = b; // 如果当前节点的 after 不为 null,将当前节点的 after 的 before 指向当前节点的 before 节点
        else
            last = b; // 如果当前节点的 after 为 null,说明当前节点是链表尾部节点,将尾部指针指向当前节点的 before 节点
        if (last == null)
            head = p; // 如果链表为空,将头部指针指向当前节点
        else {
            p.before = last; // 如果链表不为空,将当前节点的 before 指向链表的最后一个节点
            last.after = p; // 将链表的最后一个节点的 after 指向当前节点
        }
        tail = p; // 更新尾部指针为当前节点
        ++modCount; // 修改计数器
    }
}

afterNodeAccess 方法被 get 方法调用,用于在访问节点后调整链表,将访问的节点移到链表末尾,以保持访问顺序。 

put 方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

put 方法调用 putVal 方法进行实际的插入操作,其中的参数 false 表示不仅要创建新节点,还要检查是否需要扩容,true 表示需要记录访问顺序。

LRU Cache的OJ

力扣链接:146. LRU 缓存 - 力扣(LeetCode) 

请你设计并实现一个满足  LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRUCache 类:
LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。 

 解法一:

import java.util.LinkedHashMap;
import java.util.Map;

class LRUCache extends LinkedHashMap<Integer, Integer> {
    private int capacity;

    // LRUCache构造方法,调用父类LinkedHashMap的构造方法
    public LRUCache(int capacity) {
        /**
         * 第3个参数的意思:
         * 当accessOrder设置为false时,会按照插入顺序进行排序,
         * 当accessOrder为true是,会按照访问顺序(也就是插入和访问都会将当前节点放置到尾部,
         * 尾部代表的是最近访问的数据,这和JDK1.6是反过来的,jdk1.6头部是最近访问的)。
         */
        super(capacity, 0.75F, true);
        this.capacity = capacity;
    }

    // 重写get方法,获取指定key的值


    public int get(int key) {
        // 使用LinkedHashMap的getOrDefault方法,如果key不存在返回-1
        return super.getOrDefault(key, -1);
    }

    // 重写put方法,直接调用父类LinkedHashMap的put方法


    public void put(int key, int value) {
        super.put(key, value);
    }

    // 重写removeEldestEntry方法,用于移除最老的元素
    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        // 判断当前缓存大小是否超过容量,如果超过则返回true,触发删除最老元素
        return size() > capacity;
    }

    public static class MyLinkedHashMap {
        public static void main(String[] args) {
            // 创建LRUCache对象,指定容量为3
            LRUCache lruCache = new LRUCache(3);

            // 尾插法插入
            lruCache.put(2, 1);
            lruCache.put(3, 1);
            lruCache.put(4, 1);

            // 打印LRUCache的状态
            System.out.println(lruCache);  // {2=1, 3=1, 4=1}

            // 查询键为2的元素,会将2移到链表尾部
            System.out.println(lruCache.get(2));  // 1
            System.out.println(lruCache);  // {3=1, 4=1, 2=1}
        }
    }
}

在LRUCache中,通过定义容量(capacity)来限制缓存的大小。在插入新的元素时,会检查缓存的大小是否超过了容量。如果超过了容量,就会触发删除最老的元素的逻辑,以确保缓存的大小不会无限增长。

这个逻辑是通过重写removeEldestEntry方法实现的。这样,LRUCache类就能够根据我们自己的策略来管理缓存中的元素,保持缓存的大小在设定的容量范围内。这种机制对于实现LRU缓存非常有用,可以确保缓存不会无限制地增长,而是按照最近访问的顺序进行管理。

在LinkedHashMap中,removeEldestEntry方法的默认实现总是返回false,这意味着不会自动移除最老的元素。当我们继承LinkedHashMap并重写removeEldestEntry方法时,我们可以根据自己的逻辑来决定是否移除最老的元素。

在上面的代码中,removeEldestEntry方法判断当前缓存大小是否超过容量,如果超过则返回true,触发删除最老元素。这样,当插入新元素时,如果缓存大小超过容量,就会自动触发删除最老的元素。

如果没有重写removeEldestEntry方法,LinkedHashMap默认的行为是不会自动删除最老的元素。通过重写这个方法,我们可以定义自己的策略来控制缓存的行为。

解法二:自己实现链表

在LRU缓存的实现中,我们使用伪头部(pseudo head)和伪尾部(pseudo tail)节点的目的是为了简化边界条件的处理,并且使得在头部和尾部插入、删除节点时更加方便。

import java.util.HashMap;
import java.util.Map;

public class LRUCache {
    // 双向链表节点定义
    class DLinkedNode {
        int key;
        int value;
        DLinkedNode prev;
        DLinkedNode next;

        public DLinkedNode() {}

        public DLinkedNode(int _key, int _value) {
            key = _key;
            value = _value;
        }
    }

    //定义一个map
    private Map<Integer, DLinkedNode> cache = new HashMap<>();
    //代表当前双向列表中有效的数据个数
    private int size;
    //容量
    private int capacity;
    //双向列表的头节点和尾节点
    private DLinkedNode head, tail;

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 使用伪头部和伪尾部节点
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }

    /**
     * 获取当前key对应的节点
     * 把访问的节点放到尾巴
     * 当前节点就是最近最多用到的,先删除,后添加到链表末尾
     *
     * @param key
     * @return
     */
    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 如果 key 存在,先通过哈希表定位,再移到头部
        moveTail(node);
        return node.value;
    }

    /**
     * 添加元素:
     * 查看当前是否存在该key对应的节点
     * 如果有,更新key对应的value,然后向该节点移动至尾巴处【已经是新插入的数据了】
     * 如果没有,需要实例化一个节点,然后把当前节点存入map,并且存储双向链表的尾部
     * 检查,如果超过了指定容量,删除最近最少用到的数据(移除头部节点并且清除cache当中元素),即头部节点
     *
     * @param key
     * @param value
     */
    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            // 如果 key不存在,创建一个新的节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            // 添加进哈希表
            cache.put(key, newNode);
            // 添加至双向链表的尾部
            addToTail(newNode);
            ++size;
            if (size > capacity) {
                // 如果超出容量,移除双向链表的头部节点
                DLinkedNode removedNode = removeHead();
                // 删除哈希表中对应的项
                cache.remove(removedNode.key);
                --size;
            }
        } else {
            // 如果 key 存在,先通过哈希表定位,再修改 value,并移到尾部
            node.value = value;
            moveTail(node);
        }
    }

    // 删除第一个节点数据
    private DLinkedNode removeHead() {
        DLinkedNode removedNode = head.next;
        head.next = removedNode.next;
        removedNode.next.prev = head;
        return removedNode;
    }
    
    private void addToTail(DLinkedNode node) {
        tail.prev.next = node;
        node.next = tail;
        node.prev = tail.prev;
        tail.prev = node;
    }

    /**
     * 删除该节点
     * 添加节点到尾部
     *
     * @param node
     */
    private void moveTail(DLinkedNode node) {
        removeNode(node);
        addToTail(node);
    }

    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

}

如果不加上 head.next = tail; 和 tail.prev = head; 这两行代码,会导致头节点和尾节点不相连,从而在 addToTail 等方法中可能出现空指针异常。

在我们的 addToTail 方法中,tail.prev.next = node; 这行代码尝试访问 tail.prev 节点的 next 属性。如果没有正确连接头部和尾部,tail.prev 可能为 null,从而导致空指针异常。通过在构造函数中加上 head.next = tail; 和 tail.prev = head; 这两行代码,确保了头部和尾部的连接,避免了这个潜在的问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值