目录
JDK中类似 LRUCahe的数据结构LinkedHashMap
并查集
并查集原理
并查集(Union-Find Set)是一种用于解决集合合并与查找问题的数据结构。它主要支持两个操作:合并(Union)和查找(Find)。在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合,而并查集正是用来有效管理这些集合的。
初始时,每个元素被视为一个独立的单元素集合,每个集合有一个代表元素(或称为根节点)。然后,通过一定的规律不断地合并具有相同代表元素的集合。同时,通过查询可以快速得知某个元素属于哪个集合。
并查集的主要操作如下:
-
初始化(Initialization): 初始时,每个元素都是一个单独的集合,代表元素即为自身。
-
查找(Find): 查询某个元素所属的集合,通常通过查找代表元素来判断。
-
合并(Union): 将两个集合合并为一个集合,通常选择其中一个集合的代表元素作为新集合的代表元素。
并查集的典型应用场景包括连通性问题、图的最小生成树算法中的 Kruskal 算法等。
我们可以举个简单的例子来理解一下“并查集”的概念:
假设,你的社团出去团建,活动内容是在玄武湖捉迷藏。一共有10个同学一起玩,第一轮游戏指定了社长和两位副社长当“猫”,他们分头出发,去寻找藏起来的小社员们,游戏要求每次找到一个小社员,这个小社员都需要跟在找到他的那个社长身后,一同出发去寻找剩下的成员。
游戏还没开始时是这样的:
现在游戏开始,“猫”们开始行动了,很快他们找到了所有藏好的社员:
从游戏结果看,我们知道6、7、8小社员被0社长找到了,他们归属于同一个小分队;相应的,4和9归属于1号社长的小分队;3、5归属于2号社长的小分队。
仔细观察数组中内容,我们可以得出以下结论:
-
数组的下标对应集合中元素的编号: 在并查集的数组表示中,数组的索引通常对应集合中的元素编号。例如,数组的第 i 个元素表示元素 i 所在的集合。
-
数组中如果为负数,负号代表根,数字代表该集合中元素个数: 这是一种常见的表示方式。如果数组中的某个元素为负数,那么它表示该集合的根节点,而负数的绝对值表示该集合中元素的个数。这种方式有助于在查找时找到集合的根节点,并且可以通过负数的绝对值获取集合中元素的个数。
-
数组中如果为非负数,代表该元素双亲在数组中的下标: 如果数组中的某个元素为非负数,那么它表示该元素的双亲在数组中的下标。这是在进行合并操作时使用的信息,通过找到两个元素所在集合的根节点,然后将其中一个根节点的双亲指向另一个根节点,实现集合的合并。
当然,在游戏进行时,三个社长消息不能互通,所以他们都不知道对方已经找到了所有的社员,他们还会带着小分队继续寻找剩下的社员。
这个过程中,0号小分队和1号小分队巧遇,于是他们合并到了一起:
通过上面这个例子我们可以知到,并查集一般可以解决以下问题:
-
查找元素属于哪个集合: 沿着数组表示的树形结构,一直找到根节点,即数组中元素为负数的位置。根节点的索引即为该元素所属的集合的代表元素。
-
查看两个元素是否属于同一个集合: 沿着数组表示的树形结构往上追溯,一直找到两个元素的根节点。如果两个元素的根节点相同,说明它们属于同一个集合;反之,如果根节点不同,说明它们分属不同的集合。
-
将两个集合归并成一个集合:
- 合并两个集合中的元素,可以将其中一个集合的根节点的双亲指向另一个集合的根节点,实现集合的合并。
- 可以选择将一个集合的名称改成另一个集合的名称,即将一个集合的根节点的索引设置为另一个集合的根节点的索引。
-
集合的个数: 遍历数组,统计数组中元素为负数的个数,即为集合的个数。每个负数表示一个集合的根节点。
并查集通过这些操作,可以高效地维护元素之间的集合关系,广泛应用于解决网络连通性问题、图的最小生成树算法、区域合并等场景。其简洁而高效的设计使得它成为处理一些集合问题的有力工具。
并查集实现
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():
打印当前数组的状态,用于调试和观察。
并查集应用
有 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; 这两行代码,确保了头部和尾部的连接,避免了这个潜在的问题。