【Java】轻松掌握散列表操作

4.1 Word和判断单词拼写错误

在工作中,可能会遇到 Word 单词拼写错误的提醒,如下图:

在这里插入图片描述

在 Office Word 中书写英文单词时,如果有单词拼写错误,word 工具便会如上图一样自动给错误单词加上下划线

这个逻辑是如何实现的呢?

图片选自优课达官网

这时可能会想到,可以利用数组存储所有的单词,然后每次遍历所有的单词,查看单词是否准确。这种线性查找的时间复杂度为 O(N),接着可以利用二分查找或其他优化算法,将时间复杂度降低到 O(logN)

但是这些都不是最有方案。所有的英文单词数量在 20w 左右,并且单词的判断是在键盘输入的同时进行处理的,所以用户都会希望单词拼写提醒的性能达到极致

那么还有什么办法呢?这就要引用到本文的主题:散列表,也可以称为哈希表

Java中,我们正常用到的 MapHashMap,都是散列表。散列表是由一对对的数据组成,一对数据里,有两部分组成,一个叫作键(Key),一个叫作值(Value)

如果用散列表来存储20w个单词,那么形式如下:

图片选自优课达

将单词当作 Key,将值设置为 1。在查找单词的时候,如果根据单词 key 获取到的值不是 1,则表示单词拼写错误

散列表查询的时间复杂度是 O(1),可以极快的查询数据。这就是散列表的优势,理解此数据结构的原理以及适用的场景,就可以依靠其快速查找的能力应对各种状况


4.2 同义词字典

在高中每次写英语作文的时候,老师都会教我们使用高级词汇!!!高级词汇!!!高级词汇!!!(重要的事情说三遍)

比如这句话:新闻在互联网中传播的很快,应该怎么用英文翻译呢?

图片选自优课达官网

一定会有会人对 goes viral 一脸懵逼,居然还能这么翻译!!!这个时候,多么希望能够有一个英文同义词典来存储同义的高级词汇,如下:

贫穷的:poor = needy
富裕的:rich = wealthy
优秀的:excellent = eminent
积极的,好的:good = conducive
消极的,不良的:bad = evil
明显的:obvious = apparent

在这里暂时只考虑两个单词的同一问题。如果用散列表来存储同义词该怎么编码呢?假设创建的散列表为 synonyms,那么代码如下:

Map<String, String> synonyms = new HashMap<>();
synonyms.put("bad", "evil");
synonyms.put("goog", "conducive");
...

散列表可以看作是一个超级数组

如果知道数组的索引,那么数组的查询效率是非常高的

那么现在的核心问题是怎么将散列表的 Key 转换为数组的索引,这样的转换函数被称为散列函数(哈希函数)

图片选自优课达官网

此处用乘法函数来当作散列函数,也就是将 Key 字符串的每个字符换成一个数字进行乘法

'a' -> 1
'b' -> 2
'c' -> 3
...
'z' -> 26

利用这个乘法函数,可以计算出 bad 的结果为:

bad = 2 * 1 * 4 = 8

所以在数组的索引 7 位置写入 evil,如图所示:

图片选自优课达官网

另一个 Key:good 用同样的散列函数计算的结果为:

good = 7 * 15 * 15 * 4 = 6300

因为 6300 超过数组的长度,可以通过取模的方式获取位置

6300 % 16 = 12

所以在数组的索引 11 位置写入 conducive,如图所示:

图片选自优课达官网

散列表查询

我们已经将散列表的存储,映射到了一个数组。之后如果想获取 xxx 单词的同义词,首先利用散列函数将 xxx 转换成一个索引值,再通过数组索引查询,找到同义词即可


4.3 hash冲突

大家一定会想到这样一个问题:

上面的介绍,这样的散列函数,并不能保证 Key 计算出来的值的唯一性

举个例子:baddab 的计算结果肯定都是一样的

如果之前已经存储了 bad,那么继续往这个格子里面放东西就会引起冲突。这该如何解决呢?接下来介绍两个常用方法:

开发寻址法

所谓开放寻址法,用大白话描述就是如果当前位置冲突,则依次往后面寻找,直到找 到了一个可用的空间位置

图片选自优课达官网
如上图,利用哈希函数,计算出 dab = 8,继而发现索引 7 处已经有元素了,因此往后面推进,写入索引 8

在查询的时候,应该做哪些改变呢?

需要优先计算哈希值,如果对应的位置找不到希望的元素,则依次右移直到找到为止

这个方案的时间复杂度只需要了解一下最坏的情况,假设所有元素计算出来的哈希表都在同一个位置,存储的时候需要依次后移寻找空闲位置。也就是每次查询几乎都要遍历整个数组,时间复杂度为 O(N)

链地址法

可以用一个单向链表存储所有哈希值相同的元素,如下图所示:

图片选自优课达官网

同样,需要思考一下链表的时间复杂度,假设有 N 个元素,数组的长度为 M。每个链表的长度平均为 N/M,所以时间复杂度为 O(N/M)

总结

对比一下上面两种方法的优缺点:

优点缺点
开放寻址法不需要额外的空间,是用于数据量小的场景处理冲突复杂,初始需要确定数组长度无法动态扩充,元素的删除需要判断后面的元素是否需要前移(因为冲突后移的元素,需要前移替换删除元素)
链地址法可以动态申请空间,链表删除元素非常方便链表指针本身需要额外的存储空间

4.4 LRU缓存算法实现

我们每天都在使用各种各样的 APP,例如:微信、QQ、支付宝等等。但是这些互联网应用(网站或者APP)的整体流程到底是怎么样的呢?

图片选自优课达官网

如上图所示,一般分为三步执行:

用户端发起网络请求,通过服务器处理,再查询对应的数据库获取到需要的数据

随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也会越来越多。但是往往我们的应用服务器资源是有限的,数据库每秒能接受的请求次数(或者文件的读写)也是有限的,数据库的读取已经成为整个应用的瓶颈

在这种情况下,该怎么对其进行优化呢?

答案就是:引入缓存。如下图所示:

图片选自优课达官网

缓存的特点就是快速读取,一般缓存存储方案都是采用散列表

比如获取用户信息服务,当我们第一次通过数据库查询用户A信息之后,可以将A用户信息存储在内存缓存中,那么下次再次请求用户A信息,可以直接从缓存中获取,代码如下:

Map<String, UserInfo> userInfos = new HashMap<>();
synonyms.put("1", UserInfo1);
synonyms.put("2", UserInfo2);

在这里 userInfosKey用户ID(唯一识别号),value 为用户信息的对象。那么之后,我们就可以很方便的通过 用户IDuserInfos 中快速获取用户信息,并不需要查询数据库

LRU 算法

随着用户数越来越多,缓存需要的空间也越来越大,但是缓存本身的存储空间是有限的,用完了就不能继续使用了,应该怎么办呢?

需要制定缓存的淘汰策略

LRU 是最常用的缓存淘汰策略,全称为least recently used,中文名为:最近最少使用

什么意思呢?

当我们缓存存储满了后,我们每次淘汰最近最少使用的缓存

即每个缓存都有优先级,每次使用一次缓存就将优先级提高,很久没有使用的缓存优先级就最低,最应该被淘汰

具体案例:假设 userInfos 缓存只能存储 3 个用户的数据

// userInfos 只能存储3个用户的数据
Map<String, UserInfo> userInfos = new HashMap<>();
synonyms.put("1", UserInfo1);
synonyms.put("2", UserInfo2);
synonyms.put("3", UserInfo3);

// 存储已经满了

// 获取ID为3的用户信息
synonyms.get("3");
// 获取ID为1的用户信息
synonyms.get("1");

// 即将添加用户6,但是缓存已经满了,应该淘汰谁呢?
synonyms.put("6", UserInfo6);

特别注意下代码中的最后一步,即将添加用户6的数据,但是缓存已经撑满,那么应该淘汰哪个用户信息呢?

利用 LRU 策略,我们很容易发现用户2是最近最少使用的,所以淘汰用户2

如何用代码实现一个 LRUCache

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

public class LRUCache {

  	// 存储元素的个数
  	private int size = 0;

  	// 容器大小
  	private int capacity = 0;
  	// 缓存存储内容
  	private Map<String, Node<String, String>> cache = new HashMap<>();

  	// 队列需要两个指针指向开头和结尾
  	private Node<String, String> first;
  	private Node<String, String> last;

  	// 初始化容器大小
  	public LRUCache(int capacity) {
    	this.capacity = capacity;
  	}

  	// 获取缓存元素
  	public String get(String key) {
    	// 不包含则直接返回null
    	if (!cache.containsKey(key)) {
      		return null;
    	}
    	// 获取缓存节点
    	Node<String, String> node = cache.get(key);
    	// 将该节点置顶,优先级提高
    	this.moveToHead(node);
    	return node.getContent();
  	}

  	// 添加新元素
  	public void put(String key, String value) {
    	Node<String, String> node = this.cache.get(key);
    	// 如果缓存本身存在,则更新value
    	if (node != null) {
      		node.setContent(value);
    	} else {
      		// 如果缓存不存在,则添加到链表中
      		node = new Node<>(key, value);
      		this.addToLinked(node);
      		this.size++;
    	}
    	this.cache.put(key, node);
    	// 提高优先级
    	this.moveToHead(node);
    	// 如果达到存储上限,准备LRU策略删除尾部节点
    	if (this.size > this.capacity) {
      		Node<String, String> oldLast = this.removeLast();
      		// 删除完节点并且需要删除缓存
      		this.cache.remove(oldLast.getKey());
      		this.size--;
    	}
	}

  	// 删除最后一个节点
  	// 1. 修改last指针
  	// 2. 原始最后一个的prev置空
  	// 3. 新last节点的next置空
  	private Node<String, String> removeLast() {
    	Node<String, String> node = this.last;
    	this.last = node.getPrev();
    	node.setPrev(null);
    	this.last.setNext(null);
    	return node;
  	}

  	// 将节点添加到链表中
  	private void addToLinked(Node<String, String> node) {
    	// 如果链表为空,则初始化开头和结尾两个节点
    	if (this.size == 0) {
      		this.first = node;
      		this.last = node;
    	} else {
      		// 否则将元素添加到链表结尾处
      		this.last.setNext(node);
      		node.setPrev(this.last);
      		this.last = node;
    	}
  	}

  	// 提高节点优先级
  	private void moveToHead(Node<String, String> node) {
    	// 判断节点是否在头部,如果本身就在头部,则不用处理
    	if (node.getPrev() == null) {
      		return;
    	}

    	// 如果最后一个元素是node,提高优先级后需要修改last元素
    	if (this.last == node) {
      		this.last = node.getPrev();
    	}
    	// 删除节点
    	node.getPrev().setNext(node.getNext());
    	// 移动到队列头部
    	node.setPrev(null);
    	node.setNext(this.first);
    	this.first.setPrev(node);
    	this.first = node;
  	}


  	public String toString() {
    	StringBuilder sb = new StringBuilder();
    	Node<String, String> node = this.first;
    	while (node != null) {
      		sb.append(node.getKey() + ":" + node.getContent() + " , ");
      		node = node.getNext();
    	}
    	return sb.toString();
  	}

  	public static void main(String[] args) {
    	LRUCache cache = new LRUCache(3);
    	cache.put("1", "you");
    	System.out.println("cache.put(\"1\", \"you\") 此时链表为:" + cache.toString());
    	cache.put("2", "ke");
    	System.out.println("cache.put(\"2\", \"ke\") 此时链表为:" + cache.toString());
    	cache.put("3", "da");
    	System.out.println("cache.put(\"3\", \"da\") 此时链表为:" + cache.toString());
    	cache.put("4", "hello");
    	System.out.println("cache.put(\"4\", \"hello\") 此时链表为:" + cache.toString());

    	// 此处应该为null
    	System.out.println(cache.get("1"));
    	System.out.println("cache.get(\"1\") 此时链表为:" + cache.toString());

    	// 此处应该为hello
    	System.out.println(cache.get("4"));
    	System.out.println("cache.get(\"4\") 此时链表为:" + cache.toString());

    	cache.get("2");
    	System.out.println("cache.get(\"2\") 此时链表为:" + cache.toString());

    	cache.get("3");
    	System.out.println("cache.get(\"3\") 此时链表为:" + cache.toString());

    	cache.put("5", "word");
    	System.out.println("cache.put(\"5\", \"world\") 此时链表为:" + cache.toString());

    	// 此处应该为null, 4被淘汰了
    	System.out.println(cache.get("4"));
  	}
}
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值