10.面试算法-Hash和缓存设计

1. Hash的概念和基本特征

哈希(Hash)也称为散列,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。

1.1 Hash是什么

很多人可能想不明白,这里的映射到底是啥意思,为啥访问的时间复杂度为O(1)?我们只要看存的时候和读的时候分别怎么映射的就知道了。

我们现在假设数组array存放的是1到15这些数,现在要存在一个大小是7的Hash表中,该如何存呢? 我们存储的位置计算公式是 :
在这里插入图片描述

这时候我们将1到6存入的时候,图示如下:
在这里插入图片描述
这个没有疑问吧,就是简单的取模。

然后继续存7到13,结果是下面这样子:
在这里插入图片描述
最后再存14和15:
在这里插入图片描述
这时候我们会发现有些数据被存到同一个位置了,我们后面再讨论,我们先理解这个结构。 接下来,我们看看如何取。

假如我要测试13在不在这里结构里,则同样使用上面的公式来进行,很明显13 模7=6,我们直接访问array[6]这个位置,很明显是在的,所以返回true。

假如我要测试20在不在这里结构里,则同样使用上面的公式来进行,很明显20模7=6,我们直接访问array[6]这个位置,但是只有6和13,所以返回false。

理解这个例子我们就理解了Hash是如何进行最基本的映射的,还有就是为什么访问的时间复杂度为O(1)。

1.2 碰撞处理方法

在上面的例子中,我们发现有些在Hash中很多位置可能要存两个甚至多个元素,很明显单纯的数组是不行的,这种两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。

那该怎么解决呢?常见的方法有:开放定址法、链地址法、再哈希法、建立公共溢出区。后两种用的比较少,我们重点看前两个。

1.2.1 开放定址法

开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
在这里插入图片描述
例如上面要继续存7,8,9的时候,7没问题,可以直接存到索引为0位置。

8本来应该存到索引为1的位置,但是已经满了,所以继续向后找,索引3的位置是空的,所以8存到3位置。同理9存到索引6位置。

这里你是否有一个疑惑:这样鸠占鹊巢的方法会不会引起混乱?比如再存3和6的话,本来自己的位置好好的,但是被外来户占领了,该如何处理呢?这个在我的ThreadLocal讲解中解开。我们这里只说一下基本思想。 ThreadLocal有一个专门存储元素的TheadLocalMap,每次在get和set元素的时候,会先将目标位置前后的元素搜索一下,将标记为null的位置回收掉,这样就可以将大部分不用的位置回收回来。这就像假期之后你到公司,每个人都将自己的位子打扫干净,结果整个工作区就很干净了。当然Hash处理该问题的整个过程非常复杂,涉及弱引用等等。

1.2.2 链地址法

将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。例如:
在这里插入图片描述
这种处理方法的问题是处理起来代价还是比较高的,具体使用的时候还要进行优化。例如在jdk的 ConcurrentHashMap中就使用了这种方式。

具体在设计的时候要考虑的因素非常多,例如元素尽量均匀、访问和操作速度要快、线程安全等等。我们来看一下下面这个Hash结构,下面的图有两处非常明显的错误,请你先想想是啥。
在这里插入图片描述
如果对HashMap源码比较熟悉的估计一眼就能看的出来。首先是数组的长度必须是2的n次幂,这里长度是9,明显有错,然后是entry的个数不能大于数组长度的75%,如果大于就会触发扩容机制进行扩容,这里明显是大于 75% 。 这里放一个不严谨的图,希望大家能够看清楚,正确的图应该是这样的:

在这里插入图片描述
数组的长度即是2的n次幂,而他的size又不大于数组长度的75% 。 HashMap的实现原理是先要找到要存放数组的下标,如果是空的就存进去,如果不是空的就判断key值是否一样,如果一样就替换,如果不一样就以链表的形式存在。 在java中1.7及以前的版本如果以链表的形式存在,在插入的时候采用的是头插法。

在1.8是尾插法,并且在java1.8中如果链表的长度大于8的时候会转为红黑树。

上面几个Hash的内容在手写算法时基本不涉及,但是在面试时是考察java基本功的重点中的重点。上面至少涉及的考察点:

  • 1.Hash的原理, HashMap的原理,特别是如何解决碰撞和线程安全问题。
  • 2.ThreadLocal的原理
  • 3.ConcurrentHashMap的原理
  • 4.红黑树

2. LRU缓存设计

缓存是应用软件的必备功能之一,在Spring 、mybatis 、redis 、mysql等软件中都有自己的内部缓存模块,而缓存是如何实现的呢?

在操作系统教科书里我们知道常用的有FIFO、LRU和LFU三种基本的方式。 FIFO也就是队列方式,不能很好利用程序局部性特征,缓存效果比较差,一般使用LRU(最近最少使用)和LFU(最不经常使用淘汰算法)⽐较多一些。 LRU是淘汰最长时间没有被使用的页面,而LFU是淘汰一段时间内,使用次数最少的页面。

从实现上LRU是相对容易的,而LFU比较复杂,我们重点研究一下LFU的问题,这也是一道高频题目。 LeetCode146:设计一个LRU缓存,这个题也经常见到,在牛客网也是长期排名前三:

先看题意:

请你设计并实现一个满足 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) 的平均时间复杂度运行。
示例:
输入
[“LRUCache”, “put”, “put”, “get”, “put”, “get”, “put”, “get”, “get”, “get”]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]
解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1); // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2); // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1); // 返回 -1 (未找到)
lRUCache.get(3); // 返回 3
lRUCache.get(4); // 返回 4
提示:
1 <= capacity <= 3000
0 <= key <= 10000
0 <= value <= 105
最多调用 2 * 105 次 get 和 put

关于什么是LRU,简单来说就是当内存空间满了,不得不淘汰某些数据时(通常是容量已满),选择最久未被使用的数据进行淘汰。

这里做了简化,题目让我们实现一个容量固定的LRUCache 。如果插入数据时,发现容器已满时,则先按照 LRU 规则淘汰一个数据,再将新数据插入,其中 “插入” 和 “查询” 都算作一次“使用”。

看一个百度百科的例子: https://baike.baidu.com/item/LRU/1269842?fr=aladdin
最近最少使用算法(LRU)是大部分操作系统为最大化页面命中率而广泛采用的一种页面置换算法。

该算法的思路是,发生缺页中断时,选择未使用时间最长的页面置换出去。假设内存只能容纳3个页大小,按照 7 0 1 2 0 3 0 4 的次序访问页。假设内存按照栈的方式来描述访问时间,在上面的,是最近访问的,在下面的是,最远时间访问的, LRU就是这样工作的:
在这里插入图片描述

如果再有其他元素就依次类推。

如果告诉你上述原理,该怎么实现呢?定义一个数组,然后根据上面的规则写吗?估计一小时也写不出来,即使写出来了,也非常容易超时,那该怎么做呢?直接说结论,目前公认最好的方式是使用Hash+双链表。

2.1 hash+双向链表实现LRU

目前公认最合理的方式是使用hash+双向链表。想不到吧,接下来我们就看看该怎么做。

  • Hash的作用是 用来做到O(1)访问元素,哈希表就是普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。 Hash里的数据是key-value结构。value就是我们自己封装的node,key则是键值,也就是在Hash的地址。
  • 双向链表用来实现根据访问情况对元素进行排序。双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。

这样一来,我们要确认元素的位置直接访问哈希表就行了,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O(1)的时间内完成 get 或者 put 操作。具体的方法如下:

  • 对于 get 操作,首先判断 key 是否存在:
    • 如果 key 不存在,则返回 -1;
    • 如果 key 存在,则key 对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。
  • 对于 put 操作,首先判断 key 是否存在:
    • 如果 key 不存在,使用 key 和 value 创建一个新的节点,在双向链表的头部添加该节点,并将 key 和该节点添加进哈希表中。然后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
    • 如果 key 存在,则与 get 操作类似,先通过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。

上述各项操作中,访问哈希表的时间复杂度为 O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O(1) 时间内完成。

同时为了方便操作,在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

看个图示:

在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

我们先看容量为3的例子,首先缓存了1,此时结构如图a所示。之后再缓存2和3,结构如b所示。
在这里插入图片描述
之后 4再进入,此时容量已经不够了,只能将最远未使用的元素1删掉,然后将4插入到链表头部。此时就变成了
上图c的样子。
接下来假如又访问了一次2,会怎么样呢?此时会将2移动到链表的首部,也就是下图d的样子。
在这里插入图片描述
之后假如又要缓存5呢?此时就将tail指向的3删除,然后将5插入到链表头部。也就是上图e的样子。上面的方案要实现是非常容易的。我们注意到链表主要执行几个操作:

  • 1.假如容量没满,则将新元素直接插入到链表头就行了。
  • 2.如果容量够了,新的元素到来,则将tail指向的表尾元素删除就行了。
  • 3.假如要访问已经存在的元素,则此时将该先从链表中删除,再插入到表头就行了。

再看Hash的操作:

  • 1.Hash没有容量的限制,凡是被访问的元素都会在Hash中有个标记,key就是我们的查询条件,而value就是链表的结点的引用,可以不用访问链表直接定位到某个结点,然后就可以执行我们在之前提到的方法来删除对应的结点。
  • 2.这里双向链表的删除好理解,那HashMap中是如何删除的呢?其实就是将node变成为null。这样get(key)的时候返回的是null,就实现了删除的功能。

上述各项操作中,访问哈希表的时间复杂度为 O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O(1)时间内完成。

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;
		}
	}

	private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>();
	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;
	}

	public int get(int key) {
		DLinkedNode node = cache.get(key);
		if (node == null) {
			return -1; 
		}
		// 如果key存在,先通过哈希表定位,再移到头部 
		moveToHead(node);
		return node.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);
			// 添加至双向链表的头部 
			addToHead(newNode); 
			++size;
			if (size > capacity) {
				// 如果超出容量,删除双向链表的尾部节点 
				DLinkedNode tail = removeTail();  
				// 删除哈希表中对应的项
				cache.remove(tail.key);
				--size;
			}
		} else {
			// 如果key =存在,先通过哈希表定位,再修改value,并移到头部 
			node.value = value;
			moveToHead(node);
		}
	}

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

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

	private void moveToHead(DLinkedNode node) {
		removeNode(node);
		addToHead(node);
	}

	private DLinkedNode removeTail() {
		DLinkedNode res = tail.prev;
		removeNode(res);
		return res;
	}
}

再来个测试类:

public static void main(String[] args) {
	LRUCache lRUCache = new LRUCache(2);
	lRUCache.put(1, 10); // 缓存是   {1=1}
	lRUCache.put(2, 20); // 缓存是   {1=1, 2=2}
	lRUCache.get(1);    // 返回  1
	lRUCache.put(3, 30); // 该操作会使得关键字  2 作废,缓存是   {1=1, 3=3}
	lRUCache.get(2);    // 返回  -1 (未找到)
	lRUCache.put(4, 40); // 该操作会使得关键字  1 作废,缓存是   {4=4, 3=3}
	lRUCache.get(1);    // 返回  -1 (未找到)
	lRUCache.get(3);    // 返回  3
	lRUCache.get(4);    // 返回  4
}

很多高级语言都提供了封装好的数据结构,例如java中的 LinkedHashMap,只需要短短的几行代码就可以完成本题,平时开发中我们可以直接用,但是面试的时候不能直接用,需要自己实现。

2.2 基于LinkedHashMap实现

上面的方法虽然直接,但是代码太多,即使写过,也很难保证面试时20min内写完,那有没有简洁一点的方法呢?如果面试官要求不是特别严的话,我们可以基于LinkedHashMap来做,而LinkedHashMap本身就是我们上面说的 Hash+双链表,我们只要简单封装一下就行,只需要短短的几行代码就可以完成本题。但是能不能这么写,最好先咨询一下面试官,不用上来就写。

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

	public LRUCache(int capacity) {
		super(capacity, 0.75F, true);
		this.capacity = capacity;
	}

	public int get(int key) {
		return super.getOrDefault(key, -1);
	}

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

	@Override
	protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
		return size() > capacity;
	}
}

2.3 反击面试官: mysql是如何使用的缓存的

这个算法明显比一般的复杂,如果面试要求了,你是否可以给自己加点分呢? 这个涉及到mysql里缓存部分如何基于LRU实现冷热数据分离。

具体内容在后续的mysql专题详细讲解。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值