哈希算法题目:全 O(1) 的数据结构
题目描述
请你设计一个数据结构,用于存储字符串及其计数的键值对。这个数据结构需要支持以下四种操作,并且每种操作的平均时间复杂度都需要是 O(1):
-
inc(String key):- 如果
key在数据结构中不存在,则插入这个key,并将它的计数设置为 1。 - 如果
key已经存在,则将其对应的计数增加 1。
- 如果
-
dec(String key):- 如果
key的计数为 1,则在数据结构中移除这个key。 - 如果
key的计数大于 1,则将其对应的计数减少 1。 - 如果
key不存在,该函数不做任何操作。
- 如果
-
getMaxKey():返回任意一个计数最大的key。如果数据结构为空,返回空字符串""。 -
getMinKey():返回任意一个计数最小的key。如果数据结构为空,返回空字符串""。
解题过程
这个题目的难点在于,我们不仅要能快速(O(1))地通过 key 找到其对应的计数(这可以用哈希表轻松实现),还要能快速(O(1))地找到全局的最大计数和最小计数,并从中返回一个 key。
如果我们只用一个哈希表(key -> count),那么 inc 和 dec 操作可以是 O(1),但 getMaxKey 和 getMinKey 就需要遍历整个哈希表,时间复杂度是 O(n),不符合要求。
因此,我们需要一个更精巧的设计。核心思路是使用 哈希表 + 双向链表。
-
设计节点(Node):表示一个计数桶
我们把具有相同计数的所有key放在一个“桶”里。这个桶就是一个节点。每个节点需要存储:count:这个桶代表的计数值。keys:一个集合(例如HashSet),存储所有计数为count的key。使用HashSet可以让我们在 O(1) 时间内添加或删除一个key。prev:指向前一个节点的指针。next:指向后一个节点的指针。
为什么用双向链表?因为当某个
key的计数增加或减少时,它需要从一个桶(节点)移动到相邻的另一个桶(计数+1或-1的节点)。双向链表可以让我们在 O(1) 时间内完成节点的插入和删除。 -
设计整体数据结构
keyMap:一个哈希表,映射是key -> Node。通过它,我们可以根据一个key快速找到它目前所在的节点(桶)。countList:一个双向链表,链表中的每个节点都是一个我们上面描述的“计数桶”。为了便于操作,我们设置两个哨兵节点:head(虚拟头节点)和tail(虚拟尾节点)。链表从head到tail,节点的count值依次递增。这样,head.next就是计数最小的节点,tail.prev就是计数最大的节点。
-
操作步骤详解
-
初始化
创建keyMap和一个空的双向链表,链表只有head和tail两个节点,它们相互连接。 -
inc(String key)操作- 检查
key是否在keyMap中。- 如果不存在:
- 检查
head的下一个节点(即最小计数节点)的计数值是否为 1。 - 如果是,说明已经有计数为 1 的桶,直接将这个
key加入该节点的keys集合中。 - 如果不是,说明还没有计数为 1 的桶。我们需要在
head后面创建一个新的节点(count=1),并将key加入这个新节点的keys集合中。 - 最后,在
keyMap中建立key到这个(计数为1的)节点的映射。
- 检查
- 如果存在:
- 通过
keyMap找到key当前所在的节点oldNode,其计数为c。 - 从
oldNode.keys集合中移除这个key。 - 检查
oldNode的下一个节点nextNode的计数值是否为c+1。- 如果是,说明已经有计数为
c+1的桶,直接将这个key加入nextNode.keys集合中。并更新keyMap的映射为key -> nextNode。 - 如果不是,说明还没有计数为
c+1的桶。我们需要在oldNode和nextNode之间创建一个新的节点(count = c+1),并将key加入这个新节点的keys集合中。并更新keyMap。
- 如果是,说明已经有计数为
- 关键步骤:如果
oldNode.keys在移除key后变成了空集合,这意味着这个计数桶已经没有任何key了。我们需要将这个空的oldNode从双向链表中删除,以保持链表的整洁。
- 通过
- 如果不存在:
- 检查
-
dec(String key)操作(与inc对称)- 检查
key是否在keyMap中。如果不存在,直接返回。 - 通过
keyMap找到key当前所在的节点oldNode,其计数为c。 - 从
oldNode.keys集合中移除这个key。 - 如果
c == 1:- 直接从
keyMap中移除这个key的映射。
- 直接从
- 如果
c > 1:- 检查
oldNode的前一个节点prevNode的计数值是否为c-1。- 如果是,将
key加入prevNode.keys集合,并更新keyMap。 - 如果不是,在
prevNode和oldNode之间创建一个新的节点(count = c-1),加入key,并更新keyMap。
- 如果是,将
- 检查
- 关键步骤:同样,如果
oldNode.keys在移除key后变成了空集合,我们将这个空的oldNode从链表中删除。
- 检查
-
getMaxKey()操作- 我们的链表是有序的,最大计数的节点是
tail的前一个节点tail.prev。 - 如果
tail.prev是head,说明链表是空的(只有哨兵节点),返回空字符串""。 - 否则,从
tail.prev.keys这个集合中任意返回一个key(例如,用迭代器返回第一个)。
- 我们的链表是有序的,最大计数的节点是
-
getMinKey()操作- 最小计数的节点是
head的后一个节点head.next。 - 如果
head.next是tail,说明链表是空的,返回空字符串""。 - 否则,从
head.next.keys这个集合中任意返回一个key。
- 最小计数的节点是
-
总结
通过结合哈希表(keyMap)和双向链表(countList),我们为每个出现的计数值维护了一个“桶”。哈希表保证了我们能用 O(1) 的时间访问到任何一个 key 所在的桶。双向链表保证了所有桶按计数值有序排列,并且当计数变化时,key 可以在相邻的桶之间快速移动。哨兵节点的使用简化了边界条件(如链表为空)的处理。这样,我们就成功地实现了所有四种操作都是 O(1) 时间复杂度的数据结构。