1.设计LRU缓存结构
LRU算法是指最近最久未使用,初次接触是在操作系统中,在虚拟内存,当内存空间不足时需要将一些段或者页淘汰出内存中,从而加载新的段页进来
设计和构建一个“最近最少使用”缓存,该缓存会删除最近最少使用的项目。缓存应该从键映射到值(允许你插入和检索特定键对应的值),并在初始化时指定最大容量。当缓存被填满时,它应该删除最近最少使用的项目。
它应该支持以下操作: 获取数据 get 和 写入数据 put 。
获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。
LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得密钥 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得密钥 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
思路:对于LRU缓存算法,Java原生的集合LinkedHashMap就已经具备了这种能力,LinkedHashMap是HashMap的子类,与HashMap不同的是,LinkedHashMap可以通过插入和访问来确定顺序,它的底层维护了一个双向链表
具体通过下面代码即可很好理解
Map<Integer,Integer> map=new LinkedHashMap<>(10,0.75f,true);
map.put(9,3);
map.put(7,4);
map.put(5,9);
map.put(3,4);
//现在遍历的话顺序肯定是9,7,5,3
//下面访问了一下9,3这个键值对,输出顺序就变喽~
map.get(9);
//现在的顺序为:7 5 3 9
所以LinkedHashMap(true)中如果调用一次get方法,会将该元素放置在总体顺序的最后,当长度达到最大时,可以将LinkedHashMap首部的元素淘汰,这样就完成了最近最久未使用,也就是LinkedHashMap是天生支持LRU缓存结构的
代码如下
class LRUCache {
HashMap<Integer, Integer> map = null;
int capacity;
public LRUCache(int capacity) {
map = new LinkedHashMap<Integer, Integer>((int)((float)capacity / 0.75f + 1f), 0.75f, true);
this.capacity = capacity;
}
public int get(int key) {
return map.get(key) == null ? -1 : map.get(key);
}
public void put(int key, int value) {
map.put(key, value);
if(map.size() > capacity){
//达到最大值 那么移除首部元素
map.remove(map.keySet().iterator().next());
}
}
}
但是,如果在面试中遇到一般不让使用原生的,我们完全可以手写一个LRU缓存,首先考虑LinkedHashMap的底层是使用了双向链表,而且该LRU缓存结构需要支持快速访问,所以必须要有一个map,综上,LRU应该具有以下结构:
- 一个节点LinkedNode,里面包含属性key、value、prev、next
- 一个map,存储(key, LinkedNode)
接着思考get方法,首先肯定是通过map.get(key)获取到该LinkedNode,接着需要将该节点放置在双向链表的尾部,作为最近使用的,所以可以同时维护一个head和tai的虚拟节点,这样移动到尾部的工作就比较简单
接着考虑put方法,这里有可能的情况是进行value的覆盖,所以首先要做的是使用map.get(key)判断是否存在该节点,如果存在该节点,应该做两件事情:①改变该节点value的值 ②将该节点移动到双向链表的尾部,完成最近使用的工作;如果不存在该节点,那么新创建该节点,同时将该节点add至双向链表的尾部。同时还需要判断当前加入节点之后整个链表的值是否到达最大值,如果是那么需要将链表的第一个值(head.next)进行移除,因为这就是最近最久未使用的
代码如下
class LRUCache {
//LinkedNode节点定义
class LinkedNode{
int key;
int value;
LinkedNode next;
LinkedNode prev;
public LinkedNode(int key, int value){
this.key = key;
this.value = value;
}
public LinkedNode(){}
}
//虚拟的头和尾节点,便于添加和删除操作
LinkedNode head;
LinkedNode tail;
//维护整个双向链表的size
int size;
int capacity;
Map<Integer, LinkedNode> cache = new HashMap<>();
//初始化
public LRUCache(int capacity) {
head = new LinkedNode();
tail = new LinkedNode();
head.next = tail;
tail.prev = head;
this.capacity = capacity;
}
public int get(int key) {
//从map中获取LinkedNode
LinkedNode node = cache.get(key);
if(node != null){
//将该节点移动到链尾部
moveToTail(node);
return node.value;
}else{
return -1;
}
}
public void put(int key, int value) {
//判断是否需要覆盖
LinkedNode node = cache.get(key);
if(node != null){
node.value = value;
//将该节点移动到链尾部
moveToTail(node);
return;
}
size++;
//创建新节点
LinkedNode temp = new LinkedNode(key, value);
addNode(temp);
cache.put(key, temp);
if(size > capacity){
//注意要同时更新map结构和双向链表的值
cache.remove(head.next.key);
remodNode(head.next);
}
}
public void moveToTail(LinkedNode node){
remodNode(node);
addNode(node);
}
public void remodNode(LinkedNode node){
node.prev.next = node.next;
node.next.prev = node.prev;
}
//每次添加都是添加到链表的尾部
public void addNode(LinkedNode node){
LinkedNode tailPrev = tail.prev;
tailPrev.next = node;
node.next = tail;
node.prev = tailPrev;
tail.prev = node;
}
}
2. 并查集Union-Find算法
题目描述
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。
第一行:三个整数n,m,p,(n<=5000,m<=5000,p<=5000),分别表示有n个人,m个亲戚关系,询问p对亲戚关系。
以下m行:每行两个数Mi,Mj,1<=Mi,Mj<=N,表示Mi和Mj具有亲戚关系。
接下来p行:每行两个数Pi,Pj,询问Pi和Pj是否具有亲戚关系。
P行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。
输入输出样例:
6 5 3
1 2
1 5
3 4
5 2
1 3
1 4
2 3
5 6
Yes
Yes
No
并查集的入门题,并查集通常用于判断两点之间是否连通的问题。可以采取如下的思想,对于具有连通性的元素具有相同的标识,例如上面题目有6个元素
元素 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
组别 | 1 | 2 | 3 | 4 | 5 | 6 |
初始化状态,任何元素之间都没有连通性,那么6个元素自然对于6个组别,当输入(1,2)说明元素1和元素2是同一个组的,那么可以做如下变化,当然这个组标识同时为2也是一样的
元素 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
组别 | 1 | 1 | 3 | 4 | 5 | 6 |
当输入(1,5)
元素 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
组别 | 1 | 1 | 3 | 4 | 1 | 6 |
当输入(3,4)
元素 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
组别 | 1 | 1 | 3 | 3 | 1 | 6 |
当输入(1,3)这个时候麻烦来了,我们需要将所有组别为3的组合并到组别为1的组上,这还是一个麻烦事,时间复杂度是O(n),因为需要遍历寻找所有的组别为3的组
上面是典型的牵一发而动全身,主要是各个节点组别之间无关系,可以采用如下方法解决:使用数组模拟树,对于每一个组都是一棵树,所以在修改组别的时候值需要将数的根节点进行修改就可以,对于数组arr[i] = i表示i节点的父节点是arr[i],例如arr[2] = 1表示2的父节点是1,那么在修改整个组别的时候只需要找到root根节点即可,找根节点可以使用while循环
while(arr[i] != i){
i = arr[i]
}
这样找下去可以找到根节点,上面采用的是一种树的思想,再次考虑极端情况如果对于该数组组成的树退化成为了链表,那么寻找的效率是非常低的,所以要采取路径压缩,因为上述没有具体指定谁是谁的上级只规定了总root节点就是所有人的最终上级,可以让每一个元素的直接父节点都是root,那么无论数据怎么变化,树的层级都只有两级,这就是咋很大程度上压缩了路径,提高了算法的效率
//递归的方法
int find(int x) //查找结点 x的根结点
{
if(pre[x] == x) return x; //递归出口:x的上级为 x本身,即 x为根结点
return pre[x] = find(pre[x]); //此代码相当于先找到根结点 rootx,pre[x]=rootx
}
//迭代的方法
public static int find(int i){
int fi = i;
while(arr[fi] != fi) fi = arr[fi];
//依次修改为fi
while(fi != i){
int temp = arr[i];
arr[i] = fi;
i = temp;
}
return fi;
}
上面例题代码为
import java.util.*;
public class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] arr = new int[n + 1];
for(int i = 1;i < arr.length;i++){
arr[i] = i;
}
int m = sc.nextInt();
int p = sc.nextInt();
while(m-- > 0){
int x = sc.nextInt();
int y = sc.nextInt();
unio(arr, x, y);
}
while(p-- > 0){
int x = sc.nextInt();
int y = sc.nextInt();
if(find(arr, x) == find(arr, y)){
System.out.println("Yes");
}else{
System.out.println("No");
}
}
}
public static int find(int[] arr, int i){
int fi = i;
while(arr[fi] != fi) fi = arr[fi];
while(fi != i){
int temp = arr[i];
arr[i] = fi;
i = temp;
}
return fi;
}
public static void unio(int[] arr, int x, int y){
int fx = find(arr, x);
int fy = find(arr, y);
if(fx != fy){
arr[fx] = fy;
}
}
}