哈希和队列专题—队列和 Hash 的特征

通关进度

  1. Hash 存储原理
  2. Hash 处理冲突的方法
  3. 队列存储的基本特征
  4. 使用链表实现队列

参考资料

队列缓存、Hash 与缓存设计问题

Hash 基础

Hash 概念和基本特征

Hash(哈希)是一种将数据映射到固定长度的数字串(哈希值)的过程,通常用于数据的唯一标识、索引、加密等应用。哈希函数是执行这种映射的算法。

Hash 碰撞处理

链地址法(Separate Chaining): 该方法中,每个哈希桶都是一个链表。当多个键映射到同一个哈希码时,它们会被存储在同一个桶中的链表中。这样,相同哈希码的键值对可以共享同一个哈希桶,通过链表来存储。

开放寻址法(Open Addressing):该方法中,当发生冲突时,程序会尝试寻找另一个空的哈希桶,而不是使用链表。

  • 线性探测(Linear Probing): 当发生哈希冲突时,线性探测会依次检查下一个哈希桶,直到找到一个空的桶或者遍历整个哈希表。这样,冲突的元素会被放置在找到的第一个空桶中。

  • 二次探测(Quadratic Probing): 它使用二次方程来决定下一个探测的位置。这样可以更均匀地分布元素,避免线性探测的一些问题。

  • 双重散列(Double Hashing): 它使用第二个哈希函数来决定下一个探测的位置。这可以提供更好的均匀性,减少冲突的概率。

队列基础

队列概念和基本特征

队列是一种基本的数据结构,它遵循先进先出(FIFO,First In, First Out)的原则。在队列中,首先进入队列的元素将首先被移除,而最后进入队列的元素将最后被移除。

实现队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class LinkQueue {

private ListNode front;
private ListNode rear;
private int size;

public LinkQueue() {
this.front = new ListNode(0);
this.rear = new ListNode(0);
}

// 入队
public void push(int value) {
ListNode newNode = new ListNode(value);
ListNode temp = front;

while (temp.next != null) {
temp = temp.next;

}
temp.next = newNode;
size++;
}

// 出队
public int pull() {
if (front.next == null) {
throw new RuntimeException("队列为空");
}
ListNode firstNode = front.next;
front.next = firstNode.next;
size--;
return firstNode.val;
}

// 遍历
public void traverse() {
ListNode temp = front.next;
while (temp != null) {
System.out.print(temp.val + "\t");
temp = temp.next;
}
}
}

哈希和队列专题—队栈和 Hash 经典算法题

通关进度

题目 说明
使用栈实现队列 通关
使用队列实现栈 通关
N 数之和 通关

使用栈实现队列

232. 用栈实现队列

问题

【LeetCode 232】:请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push、pop、peek、empty):

实现 MyQueue 类:

  • void push(int x):将元素 x 推到队列的末尾
  • int pop():从队列的开头移除并返回元素
  • int peek():返回队列开头的元素
  • boolean empty():如果队列为空,返回 true ;否则,返回 false

双栈

  • 将一个栈作为输入栈,用于压入 push 传入的数据;另一个栈作为输出栈,用于 pop 和 peek 操作
  • 每次 pop 或 peek 时,若输出栈为空,则将输入栈的全部数据依次弹出并压入输出栈,这样输出栈从栈顶往栈底的顺序就是队列从队首往队尾的顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class MyQueue {

// 输出栈
private Stack<Integer> inStack;
// 输出栈
private Stack<Integer> outStack;

public MyQueue() {
inStack = new Stack<>();
outStack = new Stack<>();
}

// 入队
public void push(int x) {
inStack.push(x);
}

// 出队
public int pop() {
// 如果输出栈为空,则将输入栈元素出栈并压入输出栈,再进行出队操作
if (outStack.isEmpty()) {
inToOut();
}
return outStack.pop();
}

// 取队首
public int peek() {
if (outStack.isEmpty()) {
inToOut();
}
return outStack.peek();
}

// 判空
public boolean empty() {
return inStack.isEmpty() && outStack.isEmpty();
}

// 将输入栈元素弹出,并压入输出栈
public void inToOut() {
while (!inStack.isEmpty()) {
outStack.push(inStack.pop());
}
}
}

使用队列实现栈

225. 用队列实现栈

问题

【LeetCode 225】:请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push、top、pop 和 empty)。

实现 MyStack 类:

  • void push(int x):将元素 x 压入栈顶。
  • int pop():移除并返回栈顶元素。
  • int top():返回栈顶元素。
  • boolean empty():如果栈是空的,返回 true ;否则,返回 false 。

双队列

为满足栈的特性,即最后入栈的元素最先出栈,在使用队列实现栈时,应满足队列前端的元素是最后入栈的元素。可以使用两个队列实现栈的操作,其中 queue1 用于存储栈内的元素,queue2 作为入栈操作的辅助队列。

入栈操作时,首先将元素入队到 queue2,然后将 queue1 的全部元素依次出队并入队到 queue2,此时 queue2 的前端的元素即为新入栈的元素,再将 queue1queue2 互换,则 queue1 的元素即为栈内的元素,queue1 的前端和后端分别对应栈顶和栈底。

由于每次入栈操作都确保 queue1 的前端元素为栈顶元素,因此出栈操作和获得栈顶元素操作都可以简单实现。出栈操作只需要移除 queue1 的前端元素并返回即可,获得栈顶元素操作只需要获得 queue1 的前端元素并返回即可(不移除元素)。

由于 queue1 用于存储栈内的元素,判断栈是否为空时,只需要判断 queue1 是否为空即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class MyStack {

// 用于存储栈内的元素
private Queue<Integer> queue1;
// 入栈操作的辅助队列
private Queue<Integer> queue2;

public MyStack() {
queue1 = new LinkedList<>();
queue2 = new LinkedList<>();
}

// 入栈
public void push(int x) {
// 首先将元素入队到 queue2
queue2.offer(x);
// 然后将 `queue1` 的全部元素依次出队并入队到 `queue2`
// 此时 `queue2` 的前端的元素即为新入栈的元素
while (!queue1.isEmpty()) {
queue2.offer(queue1.poll());
}
// 再将 `queue1` 和 `queue2` 互换,则 `queue1` 的元素即为栈内的元素
// `queue1` 的前端和后端分别对应栈顶和栈底
Queue<Integer> temp = queue1;
queue1 = queue2;
queue2 = temp;
}

// 出栈
public int pop() {
return queue1.poll();
}

// 取栈顶
public int top() {
return queue1.peek();
}

// 判空
public boolean empty() {
return queue1.isEmpty();
}
}

单队列

使用一个队列时,为了满足栈的特性,即最后入栈的元素最先出栈,同样需要满足队列前端的元素是最后入栈的元素。

入栈操作时,首先获得入栈前的元素个数 nnn,然后将元素入队到队列,再将队列中的前 nnn 个元素(即除了新入栈的元素之外的全部元素)依次出队并入队到队列,此时队列的前端的元素即为新入栈的元素,且队列的前端和后端分别对应栈顶和栈底。

由于每次入栈操作都确保队列的前端元素为栈顶元素,因此出栈操作和获得栈顶元素操作都可以简单实现。出栈操作只需要移除队列的前端元素并返回即可,获得栈顶元素操作只需要获得队列的前端元素并返回即可(不移除元素)。

由于队列用于存储栈内的元素,判断栈是否为空时,只需要判断队列是否为空即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class MyStack {
Queue<Integer> queue;

/** Initialize your data structure here. */
public MyStack() {
queue = new LinkedList<Integer>();
}

/** Push element x onto stack. */
public void push(int x) {
int n = queue.size();
queue.offer(x);
for (int i = 0; i < n; i++) {
queue.offer(queue.poll());
}
}

/** Removes the element on top of the stack and returns that element. */
public int pop() {
return queue.poll();
}

/** Get the top element. */
public int top() {
return queue.peek();
}

/** Returns whether the stack is empty. */
public boolean empty() {
return queue.isEmpty();
}
}

N 数之和

类似问题

两数之和

1. 两数之和

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

你可以按任意顺序返回答案。

推荐回答

三数之和

LCR 007. 三数之和

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != ji != kj != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请

你返回所有和为 0 且不重复的三元组。

注意:答案中不可以包含重复的三元组。

推荐回答

哈希和队列专题—缓存设计与实现

通关进度

题目 说明
LRU 缓存 通关
LFU 缓存 通关

LRU 缓存(重点)

146. LRU 缓存

问题

【LeetCode 146】:请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity):以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key):如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1
  • void put(int key, int value):如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。
    函数 getput 必须以 $O(1)$ 的平均时间复杂度运行。

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
输入
["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

哈希表 + 双向链表

LRU 缓存机制可以通过哈希表辅以双向链表实现,我们用一个哈希表和一个双向链表维护所有在缓存中的键值对。

  • 双向链表按照被使用的顺序存储这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用
  • 哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置

首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 $O(1)$ 的时间内完成 get 或者 put 操作。具体的方法如下:

对于 get 操作,首先判断 key 是否存在:

  • 如果 key 不存在,则返回 −1
  • 如果 key 存在,则 key 对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值

对于 put 操作,首先判断 key 是否存在:

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

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

Tips

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
public class LRUCache {

// 双链表结构
class DLinkedNode {
private int key;
private int value;
private DLinkedNode prev;
private DLinkedNode next;

public DLinkedNode() {
}

public DLinkedNode(int _key, int _value) {
this.key = _key;
this.value = _value;
}
}

// 哈希结构
private Map<Integer, DLinkedNode> cache = new HashMap<>();
// 缓存实际占用元素数量
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 存在,先通过哈希表定位,再移动到头部
moveToDLHead(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);
// 添加至双链表头部
addToDLHead(newNode);
++size;
if (size > capacity) {
// 如果超出容量,删除双向链表的尾部节点
DLinkedNode tail = removeDLTail();
// 删除哈希表中对于的项
cache.remove(tail.key);
--size;
}
} else {
// 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
node.value = value;
moveToDLHead(node);
}
}

// 在双链表头部添加节点
private void addToDLHead(DLinkedNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;

}

// 移除双链表节点
private void removeDLNode(DLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}

// 移除双链表尾节点
private DLinkedNode removeDLTail() {
DLinkedNode res = tail.prev;
removeDLNode(res);
return res;
}

// 将节点移至双链表头部
public void moveToDLHead(DLinkedNode node) {
removeDLNode(node);
addToDLHead(node);
}
}

LFU 缓存(可选)

460. LFU 缓存

问题

【LeetCode 460】:请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。

实现 LFUCache 类:

  • LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象
  • int get(int key) - 如果键 key 存在于缓存中,则获取键的值,否则返回 -1
  • void put(int key, int value) - 如果键 key 已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量 capacity 时,则应该在插入新项之前,移除最不经常使用的项。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除 最久未使用 的键。

为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。

当一个键首次插入到缓存中时,它的使用计数器被设置为 1 (由于 put 操作)。对缓存中的键执行 getput 操作,使用计数器的值将会递增。

函数 getput 必须以 $O(1)$ 的平均时间复杂度运行。

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
输入:
["LFUCache", "put", "put", "get", "put", "get", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [3], [4, 4], [1], [3], [4]]
输出:
[null, null, null, 1, null, -1, 3, null, -1, 3, 4]

解释:
// cnt(x) = 键 x 的使用计数
// cache=[] 将显示最后一次使用的顺序(最左边的元素是最近的)
LFUCache lfu = new LFUCache(2);
lfu.put(1, 1); // cache=[1,_], cnt(1)=1
lfu.put(2, 2); // cache=[2,1], cnt(2)=1, cnt(1)=1
lfu.get(1); // 返回 1
// cache=[1,2], cnt(2)=1, cnt(1)=2
lfu.put(3, 3); // 去除键 2 ,因为 cnt(2)=1 ,使用计数最小
// cache=[3,1], cnt(3)=1, cnt(1)=2
lfu.get(2); // 返回 -1(未找到)
lfu.get(3); // 返回 3
// cache=[3,1], cnt(3)=2, cnt(1)=2
lfu.put(4, 4); // 去除键 1 ,1 和 3 的 cnt 相同,但 1 最久未使用
// cache=[4,3], cnt(4)=1, cnt(3)=2
lfu.get(1); // 返回 -1(未找到)
lfu.get(3); // 返回 3
// cache=[3,4], cnt(4)=1, cnt(3)=3
lfu.get(4); // 返回 4
// cache=[3,4], cnt(4)=2, cnt(3)=3

哈希表 + 平衡二叉树

用哈希表 key_table 以键 key 为索引存储缓存,建立一个平衡二叉树 S 来保持缓存根据 (cnt,time) 双关键字,其中 cnt 表示缓存使用的频率,time 表示缓存的使用时间,keyvalue 表示缓存的键值。

  • 对于 get(key) 操作,我们只要查看一下哈希表 key_table 是否有 key 这个键即可,有的话需要同时更新哈希表和集合中该缓存的使用频率以及使用时间,否则返回 -1

对于 put(key, value) 操作,首先需要查看 key_table 中是否已有对应的键值。如果有的话操作基本等同于 get(key),不同的是需要更新缓存的 value 值。如果没有的话相当于是新插入一个缓存,这时候需要先查看是否达到缓存容量 capacity,如果达到了的话,需要删除最近最少使用的缓存,即平衡二叉树中最左边的结点,同时删除 key_table 中对应的索引,最后向 key_tableS 插入新的缓存信息即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
public class LFUCache {

// 缓存容量,时间戳
private int capacity, time;
// 哈希表
private Map<Integer, Node> key_table;
// 平衡二叉树
private TreeSet<Node> S;

public LFUCache(int capacity) {
this.capacity = capacity;
this.time = 0;
key_table = new HashMap<Integer, Node>();
S = new TreeSet<Node>();
}

public int get(int key) {
if (capacity == 0) {
return -1;
}
// 如果哈希表中没有键 key,返回 -1
if (!key_table.containsKey(key)) {
return -1;
}
// 从哈希表中得到旧的缓存
Node cache = key_table.get(key);
// 从平衡二叉树中删除旧的缓存
S.remove(cache);
// 将旧缓存更新
cache.cnt += 1;
cache.time = ++time;
// 将新缓存重新放入哈希表和平衡二叉树中
S.add(cache);
key_table.put(key, cache);
return cache.value;
}

public void put(int key, int value) {
if (capacity == 0) {
return;
}
if (!key_table.containsKey(key)) {
// 如果到达缓存容量上限
if (key_table.size() == capacity) {
// 从哈希表和平衡二叉树中删除最近最少使用的缓存
key_table.remove(S.first().key);
S.remove(S.first());
}
// 创建新的缓存
Node cache = new Node(1, ++time, key, value);
// 将新缓存放入哈希表和平衡二叉树中
key_table.put(key, cache);
S.add(cache);
} else {
// 这里和 get() 函数类似
Node cache = key_table.get(key);
S.remove(cache);
cache.cnt += 1;
cache.time = ++time;
cache.value = value;
S.add(cache);
key_table.put(key, cache);
}
}
}

// 缓存结构
class Node implements Comparable<Node> {
// key - value 缓存的键值
// cnt 缓存使用的频率
// time 缓存的使用时间
int cnt, time, key, value;

Node(int cnt, int time, int key, int value) {
this.cnt = cnt;
this.time = time;
this.key = key;
this.value = value;
}

public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof Node) {
Node rhs = (Node) anObject;
return this.cnt == rhs.cnt && this.time == rhs.time;
}
return false;
}

public int compareTo(Node rhs) {
return cnt == rhs.cnt ? time - rhs.time : cnt - rhs.cnt;
}

public int hashCode() {
return cnt * 1000000007 + time;
}
}