4.1 Word和判断单词拼写错误
在工作中,可能会遇到 Word 单词拼写错误的提醒,如下图:
在 Office Word 中书写英文单词时,如果有单词拼写错误,word
工具便会如上图一样自动给错误单词加上下划线
这个逻辑是如何实现的呢?
这时可能会想到,可以利用数组存储所有的单词,然后每次遍历所有的单词,查看单词是否准确。这种线性查找的时间复杂度为
O(N)
,接着可以利用二分查找或其他优化算法,将时间复杂度降低到O(logN)
但是这些都不是最有方案。所有的英文单词数量在 20w
左右,并且单词的判断是在键盘输入的同时进行处理的,所以用户都会希望单词拼写提醒的性能达到极致
那么还有什么办法呢?这就要引用到本文的主题:散列表,也可以称为哈希表
Java中,我们正常用到的 Map
,HashMap
,都是散列表。散列表是由一对对的数据组成,一对数据里,有两部分组成,一个叫作键(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 计算出来的值的唯一性
举个例子:bad
和 dab
的计算结果肯定都是一样的
如果之前已经存储了 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);
在这里 userInfos
的 Key
为用户ID(唯一识别号),value
为用户信息的对象。那么之后,我们就可以很方便的通过 用户ID 从 userInfos
中快速获取用户信息,并不需要查询数据库
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"));
}
}