本文正在参加「金石计划」
哈希表
哈希表一直是面试数据结构中的重中之重,今天通过 道与哈希表相关的题目来进行学习。
380. O(1) 时间插入、删除和获取随机元素
实现 RandomizedSet
类:
RandomizedSet()
初始化RandomizedSet
对象bool insert(int val)
当元素val
不存在时,向集合中插入该项,并返回true
;否则,返回false
。bool remove(int val)
当元素val
存在时,从集合中移除该项,并返回true
;否则,返回false
。int getRandom()
随机返回现有集合中的一项(测试用例保证调用此方法时集合中至少存在一个元素)。每个元素应该有 相同的概率 被返回。
你必须实现类的所有函数,并满足每个函数的 平均 时间复杂度为 。
示例:
输入
["RandomizedSet", "insert", "remove", "insert", "getRandom", "remove", "insert", "getRandom"]
[[], [1], [2], [2], [], [1], [2], []]
输出
[null, true, false, true, 2, true, false, 2]
解释
RandomizedSet randomizedSet = new RandomizedSet();
randomizedSet.insert(1); // 向集合中插入 1 。返回 true 表示 1 被成功地插入。
randomizedSet.remove(2); // 返回 false ,表示集合中不存在 2 。
randomizedSet.insert(2); // 向集合中插入 2 。返回 true 。集合现在包含 [1,2] 。
randomizedSet.getRandom(); // getRandom 应随机返回 1 或 2 。
randomizedSet.remove(1); // 从集合中移除 1 ,返回 true 。集合现在包含 [2] 。
randomizedSet.insert(2); // 2 已在集合中,所以返回 false 。
randomizedSet.getRandom(); // 由于 2 是集合中唯一的数字,getRandom 总是返回 2 。
提示:
- 最多调用
insert
、remove
和getRandom
函数 次 - 在调用
getRandom
方法时,数据结构中 至少存在一个 元素。
哈希表 + 删除交换
对于 insert
和 remove
操作容易想到使用「哈希表」来实现 复杂度,但对于 getRandom
操作,比较理想的情况是能够在一个数组内随机下标进行返回。
将两者结合,我们可以将哈希表设计为:以入参 val
为键,数组下标 loc
为值。
为了确保严格 ,我们不能「使用拒绝采样」和「在数组非结尾位置添加/删除元素」。
因此我们需要申请一个足够大的数组 nums
(利用数据范围为 ),并使用变量 idx
记录当前使用到哪一位(即下标在 范围内均是存活值)。
对于几类操作逻辑:
insert
操作:使用哈希表判断val
是否存在,存在的话返回false
,否则将其添加到nums
,更新idx
,同时更新哈希表;remove
操作:使用哈希表判断val
是否存在,不存在的话返回false
,否则从哈希表中将val
删除,同时取出其所在nums
的下标loc
,然后将nums[idx]
赋值到loc
位置,并更新idx
(含义为将原本处于loc
位置的元素删除),同时更新原本位于idx
位置的数在哈希表中的值为loc
(若loc
与idx
相等,说明删除的是最后一个元素,这一步可跳过);getRandom
操作:由于我们人为确保了 均为存活值,因此直接在 范围内进行随机即可。
代码:
class RandomizedSet {
static int[] nums = new int[200010];
Random random = new Random();
Map<Integer, Integer> map = new HashMap<>();
int idx = -1;
public boolean insert(int val) {
if (map.containsKey(val)) return false;
nums[++idx] = val;
map.put(val, idx);
return true;
}
public boolean remove(int val) {
if (!map.containsKey(val)) return false;
int loc = map.remove(val);
if (loc != idx) map.put(nums[idx], loc);
nums[loc] = nums[idx--];
return true;
}
public int getRandom() {
return nums[random.nextInt(idx + 1)];
}
}
- 时间复杂度:所有操作均为
- 空间复杂度:
895. 最大频率栈
设计一个类似堆栈的数据结构,将元素推入堆栈,并从堆栈中弹出出现频率最高的元素。
实现 FreqStack
类:
FreqStack()
构造一个空的堆栈。void push(int val)
将一个整数val
压入栈顶。int pop()
删除并返回堆栈中出现频率最高的元素。
如果出现频率最高的元素不只一个,则移除并返回最接近栈顶的元素。
示例 1:
输入:
["FreqStack","push","push","push","push","push","push","pop","pop","pop","pop"],
[[],[5],[7],[5],[7],[4],[5],[],[],[],[]]
输出:[null,null,null,null,null,null,null,5,7,5,4]
解释:
FreqStack = new FreqStack();
freqStack.push (5);//堆栈为 [5]
freqStack.push (7);//堆栈是 [5,7]
freqStack.push (5);//堆栈是 [5,7,5]
freqStack.push (7);//堆栈是 [5,7,5,7]
freqStack.push (4);//堆栈是 [5,7,5,7,4]
freqStack.push (5);//堆栈是 [5,7,5,7,4,5]
freqStack.pop ();//返回 5 ,因为 5 出现频率最高。堆栈变成 [5,7,5,7,4]。
freqStack.pop ();//返回 7 ,因为 5 和 7 出现频率最高,但7最接近顶部。堆栈变成 [5,7,5,4]。
freqStack.pop ();//返回 5 ,因为 5 出现频率最高。堆栈变成 [5,7,4]。
freqStack.pop ();//返回 4 ,因为 4, 5 和 7 出现频率最高,但 4 是最接近顶部的。堆栈变成 [5,7]。
提示:
push
和pop
的操作数不大于- 输入保证在调用
pop
之前堆栈中至少有一个元素
哈希表
这是一道很纯的哈希表题儿。
首先,我们容易想到建立 第一个哈希表 cnts
用于记录某个数值的出现次数,cnts[val] = c
含义为数值 val
当前在栈中的出现次数为 c
。我们称该哈希表为「计数哈希表」。
再结合每次 pop
需要返回「频率最大的元素,若有多个则返回最考虑栈顶的一个」的要求,我们还可以 建立第二个哈希 map
,该哈希表以「出现次数 c
」为键,以「出现次数均为 c
的元素序列」为值,map[c] = A = [...]
含义为出现次数为 c
的序列为 A
,并且序列 A
中的结尾元素为出现次数为 c
的所有元素中最靠近栈顶的元素。我们称该哈希表为「分桶哈希表」。
最后再额外使用一个变量 max
记录当前最大出现频数,不难发现,max
必然是以步长 进行变化(当出现次数为 max
的元素被 pop
掉了一个后,必然剩下 max - 1
个),因此当我们在某次 pop
操作后发现出现次数为 max
的集合为空时,对 max
进行自减操作即可。
将题目给的样例作为 🌰 ,大家可以看看 cnts
、map
和 max
三者如何变化,以及 pop
的更新逻辑:
Python 代码:
class FreqStack:
def __init__(self):
self.cnts = defaultdict(int)
self.map = defaultdict(list)
self.mv = 0
def push(self, val: int) -> None:
self.cnts[val] += 1
c = self.cnts[val]
self.map[c].append(val)
self.mv = max(self.mv, c)
def pop(self) -> int:
ans = self.map[self.mv].pop()
self.cnts[ans] -= 1
self.mv -= 0 if self.map[self.mv] else 1
return ans
- 时间复杂度:所有操作均为
- 空间复杂度:所有入栈的节点最多会被存储两次,一次在计数哈希表中,一次在分桶哈希表中,复杂度为
388. 文件的最长绝对路径
假设有一个同时存储文件和目录的文件系统。下图展示了文件系统的一个示例:
这里将 dir
作为根目录中的唯一目录。dir
包含两个子目录 subdir1
和 subdir2
。
subdir1
包含文件 file1.ext
和子目录 subsubdir1
;subdir2
包含子目录 subsubdir2
,该子目录下包含文件 file2.ext
。
在文本格式中,如下所示(⟶表示制表符):
dir
⟶ subdir1
⟶ ⟶ file1.ext
⟶ ⟶ subsubdir1
⟶ subdir2
⟶ ⟶ subsubdir2
⟶ ⟶ ⟶ file2.ext
如果是代码表示,上面的文件系统可以写为 "dir\n\tsubdir1\n\t\tfile1.ext\n\t\tsubsubdir1\n\tsubdir2\n\t\tsubsubdir2\n\t\t\tfile2.ext"
。'\n'
和 '\t'
分别是换行符和制表符。
文件系统中的每个文件和文件夹都有一个唯一的 绝对路径 ,即必须打开才能到达文件/目录所在位置的目录顺序,所有路径用 '/' 连接。上面例子中,指向 file2.ext
的 绝对路径 是 "dir/subdir2/subsubdir2/file2.ext"
。每个目录名由字母、数字和/或空格组成,每个文件名遵循 name.extension
的格式,其中 name
和 extension
由字母、数字和/或空格组成。
给定一个以上述格式表示文件系统的字符串 input
,返回文件系统中 指向 文件 的 最长绝对路径 的长度 。 如果系统中没有文件,返回 。
示例 1:
输入:input = "dir\n\tsubdir1\n\tsubdir2\n\t\tfile.ext"
输出:20
解释:只有一个文件,绝对路径为 "dir/subdir2/file.ext" ,路径长度 20
提示:
input
可能包含小写或大写的英文字母,一个换行符'\n'
,一个制表符'\t'
,一个点'.'
,一个空格' '
,和数字。
模拟 + 哈希表
为了方便,我们将 input
替换为 s
。
对于每一个文件或文件夹而言,我们可以通过访问到结尾(\n
)的方式取得,记为 cur
,然后根据 cur
前面有多少个 \t
得知其所在的层级,假设当前其所在层级为 level
,那么它自然归属到最新一个层级为 level - 1
的文件夹中,因此我们可以使用哈希表记录每个层级最新的文件夹路径,通过字符串拼接的方式得到 cur
所在的完整路径 path
,并在处理整个 s
过程中,统计长度最大的文件路径。
代码:
class Solution {
public int lengthLongestPath(String s) {
Map<Integer, String> map = new HashMap<>();
int n = s.length();
String ans = null;
for (int i = 0; i < n; ) {
int level = 0;
while (i < n && s.charAt(i) == '\t' && ++level >= 0) i++;
int j = i;
boolean isDir = true;
while (j < n && s.charAt(j) != '\n') {
if (s.charAt(j++) == '.') isDir = false;
}
String cur = s.substring(i, j);
String prev = map.getOrDefault(level - 1, null);
String path = prev == null ? cur : prev + "/" + cur;
if (isDir) map.put(level, path);
else if (ans == null || path.length() > ans.length()) ans = path;
i = j + 1;
}
return ans == null ? 0 : ans.length();
}
}
- 时间复杂度:
- 空间复杂度:
优化
上述做法只是为了方便我们输出具体方案。
实际上,我们只关心最终的路径长度,而不关心具体路径,因此容易将解法一修改为只记录长度,而不记录路径的做法,从而避免掉字符串拼接带来的消耗,同时利用 s
的长度数据范围,使用数组来替代常数较大的哈希表。
代码:
class Solution {
static int[] hash = new int[10010];
public int lengthLongestPath(String s) {
Arrays.fill(hash, -1);
int n = s.length(), ans = 0;
for (int i = 0; i < n; ) {
int level = 0;
while (i < n && s.charAt(i) == '\t' && ++level >= 0) i++;
int j = i;
boolean isDir = true;
while (j < n && s.charAt(j) != '\n') {
if (s.charAt(j++) == '.') isDir = false;
}
Integer cur = j - i;
Integer prev = level - 1 >= 0 ? hash[level - 1] : -1;
Integer path = prev + 1 + cur;
if (isDir) hash[level] = path;
else if (path > ans) ans = path;
i = j + 1;
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
146. LRU 缓存机制
运用你所掌握的数据结构,设计和实现一个 LRU
(最近最少使用) 缓存机制 。
实现 LRUCache
类:
LRUCache(int capacity)
以正整数作为容量capacity
初始化LRU
缓存int get(int key)
如果关键字key
存在于缓存中,则返回关键字的值,否则返回void put(int key, int value)
如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在 时间复杂度内完成这两种操作?
示例:
输入
["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
提示:
- 最多调用 次
get
和put
基本分析
LRU 是一种十分常见的页面置换算法。
将 LRU 翻译成大白话就是:当不得不淘汰某些数据时(通常是容量已满),选择最久未被使用的数据进行淘汰。
题目让我们实现一个容量固定的 LRUCache
。如果插入数据时,发现容器已满时,则先按照 LRU 规则淘汰一个数据,再将新数据插入,其中「插入」和「查询」都算作一次“使用”。
可以通过 🌰 来理解,假设我们有容量为 的 LRUCache
和 测试键值对 [1-1,2-2,3-3]
,将其按照顺序进行插入 & 查询:
- 插入
1-1
,此时最新的使用数据为1-1
- 插入
2-2
,此时最新使用数据变为2-2
- 查询
1-1
,此时最新使用数据为1-1
- 插入
3-3
,由于容器已经达到容量,需要先淘汰已有数据才能插入,这时候会淘汰2-2
,3-3
成为最新使用数据
键值对存储方面,我们可以使用「哈希表」来确保插入和查询的复杂度为 。
另外我们还需要额外维护一个「使用顺序」序列。
我们期望当「新数据被插入」或「发生键值对查询」时,能够将当前键值对放到序列头部,这样当触发 LRU 淘汰时,只需要从序列尾部进行数据删除即可。
期望在 复杂度内调整某个节点在序列中的位置,很自然想到双向链表。
双向链表
具体的,我们使用哈希表来存储「键值对」,键值对的键作为哈希表的 Key,而哈希表的 Value 则使用我们自己封装的 Node
类,Node
同时作为双向链表的节点。
-
插入:检查当前键值对是否已经存在于哈希表:
-
如果存在,则更新键值对,并将当前键值对所对应的
Node
节点调整到链表头部(refresh
操作) -
如果不存在,则检查哈希表容量是否已经达到容量:
- 没达到容量:插入哈希表,并将当前键值对所对应的
Node
节点调整到链表头部(refresh
操作) - 已达到容量:先从链表尾部找到待删除元素进行删除(
delete
操作),然后再插入哈希表,并将当前键值对所对应的Node
节点调整到链表头部(refresh
操作)
- 没达到容量:插入哈希表,并将当前键值对所对应的
-
-
查询:如果没在哈希表中找到该 Key,直接返回 ;如果存在该 Key,则将对应的值返回,并将当前键值对所对应的
Node
节点调整到链表头部(refresh
操作)
一些细节: 为了减少双向链表左右节点的「判空」操作,我们预先建立两个「哨兵」节点 head
和 tail
。
代码:
class LRUCache {
class Node {
int k, v;
Node l, r;
Node(int _k, int _v) {
k = _k;
v = _v;
}
}
int n;
Node head, tail;
Map<Integer, Node> map;
public LRUCache(int capacity) {
n = capacity;
map = new HashMap<>();
head = new Node(-1, -1);
tail = new Node(-1, -1);
head.r = tail;
tail.l = head;
}
public int get(int key) {
if (map.containsKey(key)) {
Node node = map.get(key);
refresh(node);
return node.v;
}
return -1;
}
public void put(int key, int value) {
Node node = null;
if (map.containsKey(key)) {
node = map.get(key);
node.v = value;
} else {
if (map.size() == n) {
Node del = tail.l;
map.remove(del.k);
delete(del);
}
node = new Node(key, value);
map.put(key, node);
}
refresh(node);
}
// refresh 操作分两步:
// 1. 先将当前节点从双向链表中删除(如果该节点本身存在于双向链表中的话)
// 2. 将当前节点添加到双向链表头部
void refresh(Node node) {
delete(node);
node.r = head.r;
node.l = head;
head.r.l = node;
head.r = node;
}
// delete 操作:将当前节点从双向链表中移除
// 由于我们预先建立 head 和 tail 两位哨兵,因此如果 node.l 不为空,则代表了 node 本身存在于双向链表(不是新节点)
void delete(Node node) {
if (node.l != null) {
Node left = node.l;
left.r = node.r;
node.r.l = left;
}
}
}
- 时间复杂度:各操作均为
- 空间复杂度:
1218. 最长定差子序列
给你一个整数数组 arr
和一个整数 difference
,请你找出并返回 arr
中最长等差子序列的长度,该子序列中相邻元素之间的差等于 difference
。
子序列 是指在不改变其余元素顺序的情况下,通过删除一些元素或不删除任何元素而从 arr
派生出来的序列。
示例 1:
输入:arr = [1,2,3,4], difference = 1
输出:4
解释:最长的等差子序列是 [1,2,3,4]。
提示:
状态机序列 DP + 哈希表
定义 ( 非 即 ) 为代表考虑前 个数,且第 个数的选择情况为 时,得到的最长定差子序列长度。
最终答案为 ,同时我们有显然的初始化条件 和 。
不失一般性考虑 如何转移:
- :明确了第 个不选,那么此时最大长度为前一个位置的结果。即有:
-
:明确了第 个要选,此时进行分情况讨论:
-
独立成为一个子序列,此时有:;
-
接在某一个数的后面,由于给定了差值 ,可直接算得上一位的值为 ,此时应当找到值为 ,下标最大(下标小于 )的位置,然后从该位置转移过来,即有:;
容易证明:如果存在多个位置的值为 ,从中选择一个下标最大的位置(下标小于 )进行转移,结果相比于最优位置不会变差。因此我们「贪心」选择下标最大的位置(下标小于 )即可,这引导我们在转移过程中使用「哈希表」记录处理过的位置的值信息。
综上,我们有:
-
代码(使用数组充当哈希表的代码在 ):
class Solution {
public int longestSubsequence(int[] arr, int d) {
int n = arr.length;
Map<Integer, Integer> map = new HashMap<>();
int[][] f = new int[n][2];
f[0][1] = 1;
map.put(arr[0], 0);
for (int i = 1; i < n; i++) {
f[i][0] = Math.max(f[i - 1][0], f[i - 1][1]);
f[i][1] = 1;
int prev = arr[i] - d;
if (map.containsKey(prev)) f[i][1] = Math.max(f[i][1], f[map.get(prev)][1] + 1);
map.put(arr[i], i);
}
return Math.max(f[n - 1][0], f[n - 1][1]);
}
}
class Solution {
int N = 40009, M = N / 2;
public int longestSubsequence(int[] arr, int d) {
int n = arr.length;
int[] hash = new int[N];
Arrays.fill(hash, -1);
int[][] f = new int[n][2];
f[0][1] = 1;
hash[arr[0] + M] = 0;
for (int i = 1; i < n; i++) {
f[i][0] = Math.max(f[i - 1][0], f[i - 1][1]);
f[i][1] = 1;
int prev = arr[i] - d;
if (hash[prev + M] != -1) f[i][1] = Math.max(f[i][1], f[hash[prev + M]][1] + 1);
hash[arr[i] + M] = i;
}
return Math.max(f[n - 1][0], f[n - 1][1]);
}
}
- 时间复杂度:令 为数组长度,共有 个状态需要被计算,每个状态转移的复杂度为 。整体复杂度为
- 空间复杂度:
优化状态定义
不难发现,我们多定义一维状态来区分某个位置的值是否被选择,目的是为了正确转移出第 位被选择的情况。
事实上,利用哈希表本身我们就能轻松做到这一点。
我们调整状态定义为: 为考虑前 个数(第 个数必选)时,得到的最长定差子序列长度。
不失一般性考虑 该如何转移,分情况讨论:
- 独立成为一个子序列,此时有:;
- 接在某一个数的后面,由于给定了差值 ,可直接算得上一位的值为 ,此时应当找到 为 的最新位置(下标最大,同时满足 )当时的转移结果,在此基础上加一即可,即有:;
综上,我们有( 初始化为 ):
代码(使用数组充当哈希表的代码在 ):
class Solution {
public int longestSubsequence(int[] arr, int d) {
int ans = 1;
Map<Integer, Integer> map = new HashMap<>();
for (int i : arr) {
map.put(i, map.getOrDefault(i - d, 0) + 1);
ans = Math.max(ans, map.get(i));
}
return ans;
}
}
class Solution {
int N = 40009, M = N / 2;
public int longestSubsequence(int[] arr, int d) {
int ans = 1;
int[] hash = new int[N];
for (int i : arr) {
hash[i + M] = hash[i - d + M] + 1;
ans = Math.max(ans, hash[i + M]);
}
return ans;
}
}
- 时间复杂度:令 为数组长度,共有 个状态需要被计算,每个状态转移的复杂度为 。整体复杂度为
- 空间复杂度:
总结
相比于「数组」或是「链表」,哈希表能够有效假设我们的查询效率,在一些检索目标数据为瓶颈的算法题中,引入「哈希表」来降低复杂度往往是解题关键。