哈希和队列专题—队列和 Hash 的特征
通关进度
Hash 存储原理
Hash 处理冲突的方法
队列存储的基本特征
使用链表实现队列
参考资料
队列缓存、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
的前端的元素即为新入栈的元素,再将 queue1
和 queue2
互换,则 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.offer(x); while (!queue1.isEmpty()) { queue2.offer(queue1.poll()); } 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; public MyStack () { queue = new LinkedList <Integer>(); } public void push (int x) { int n = queue.size(); queue.offer(x); for (int i = 0 ; i < n; i++) { queue.offer(queue.poll()); } } public int pop () { return queue.poll(); } public int top () { return queue.peek(); } public boolean empty () { return queue.isEmpty(); } }
N 数之和
类似问题
两数之和
1. 两数之和
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
推荐回答
三数之和
LCR 007. 三数之和
给你一个整数数组 nums
,判断是否存在三元组 [nums[i], nums[j], nums[k]]
满足 i != j
、i != k
且 j != 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
,则应该 逐出 最久未使用的关键字。 函数 get
和 put
必须以 $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
不存在,使用 key
和 value
创建一个新的节点,在双向链表的头部添加该节点,并将 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 ; } moveToDLHead(node); return node.value; } public void put (int key, int value) { DLinkedNode node = cache.get(key); if (node == null ) { 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 { 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
操作)。对缓存中的键执行 get
或 put
操作,使用计数器的值将会递增。
函数 get
和 put
必须以 $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
表示缓存的使用时间,key
和 value
表示缓存的键值。
对于 get(key)
操作,我们只要查看一下哈希表 key_table
是否有 key
这个键即可,有的话需要同时更新哈希表和集合中该缓存的使用频率以及使用时间,否则返回 -1
。
对于 put(key, value)
操作,首先需要查看 key_table
中是否已有对应的键值。如果有的话操作基本等同于 get(key)
,不同的是需要更新缓存的 value
值。如果没有的话相当于是新插入一个缓存,这时候需要先查看是否达到缓存容量 capacity
,如果达到了的话,需要删除最近最少使用 的缓存,即平衡二叉树中最左边的结点,同时删除 key_table
中对应的索引,最后向 key_table
和 S
插入新的缓存信息即可。
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 ; } 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 { 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> { 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; } }