哈希算法题目:全 O(1) 的数据结构
题目描述
请你设计一个用于存储字符串计数的数据结构,并能够以常数时间复杂度执行以下操作:
inc(String key):将key的计数增加 1。如果数据结构中不存在这个key,那么插入它并将计数设置为 1。dec(String key):将key的计数减少 1。如果key的计数减少后为 0,那么将其从数据结构中移除。题目保证在减少之前,key存在于数据结构中。getMaxKey():返回任意一个计数最大的key。如果数据结构为空,返回空字符串""。getMinKey():返回任意一个计数最小的key。如果数据结构为空,返回空字符串""。
所有操作都必须以 O(1) 的平均时间复杂度完成。
解题思路详解
这个问题的难点在于如何在修改计数(inc/dec)时,依然能在 O(1) 时间内知道当前最大和最小的计数是哪个。如果只用一个普通的哈希表(键 -> 计数),那么每次获取最大/最小值都需要 O(N) 的时间来遍历。为了实现 O(1) 的操作,我们需要一个能快速定位到“计数节点”的结构,并且这个结构能让我们在调整计数时快速找到相邻的计数节点。
核心思想:使用 “双向链表 + 哈希表” 的组合。这里的双向链表不是按插入顺序排列,而是按计数值排序的链表,每个链表节点代表一个“计数桶”,桶内存有所有具有相同计数的键。同时,我们还需要哈希表来建立“键”到其所在“计数桶”的快速映射。
步骤拆解
步骤 1:数据结构设计
我们需要定义两个核心结构:
-
节点(Node):代表双向链表中的一个计数桶。
count:这个桶对应的计数值。keys:一个集合(如HashSet或Python的set),存放所有计数值等于count的键。使用集合是为了能在 O(1) 时间内添加或删除一个键。prev和next:指向前后节点的指针。
-
主数据结构(AllOne):
key_to_node:一个哈希表,映射从“键”(key)到其当前所在的“计数桶节点”(Node)。这让我们在inc或dec时能直接找到这个键对应的节点。head和tail:指向我们维护的有序双向链表的哑元头节点和尾节点。这个链表从左(head之后)到右(tail之前)的节点,其count值是严格递增的。初始时,head和tail相互连接,链表为空。
步骤 2:链表操作辅助函数
为了方便,我们先定义两个链表操作:
_insert_node_after(Node prev_node, Node new_node):在prev_node之后插入new_node。_remove_node(Node node):从链表中移除node节点。
步骤 3:inc(String key) 操作详解
inc 的目的是将 key 的计数加 1。这意味着它需要从一个计数桶移动到下一个计数更高的桶。
-
检查键是否存在:
- 通过
key_to_node查找key对应的节点cur_node。 - 如果
key不存在(即cur_node为None),那么它当前的计数视为 0。我们可以认为它在计数为 0 的“虚拟桶”里。在实际操作中,我们可以将“新插入的键”看作是从“计数 0”移动到“计数 1”。
- 通过
-
计算新的计数值:
- 新的计数值
new_count = 1(如果键不存在)或cur_node.count + 1(如果键存在)。
- 新的计数值
-
寻找或创建目标桶:
- 目标桶是存放计数值为
new_count的节点。 - 关键检查:
new_count对应的桶是否存在?即链表中cur_node的下一个节点(cur_node.next)的计数值是否为new_count?- 如果是,那么这个节点就是目标节点
target_node。 - 如果不是,我们需要在
cur_node之后创建一个新的节点,其count = new_count,并将它插入链表。
- 如果是,那么这个节点就是目标节点
- 目标桶是存放计数值为
-
移动键到目标桶:
- 将
key添加到target_node.keys集合中。 - 更新
key_to_node[key] = target_node。
- 将
-
清理旧桶:
- 如果
key是原来存在的(即cur_node不是“虚拟桶”),那么我们需要将它从cur_node.keys集合中移除。 - 移除之后,检查
cur_node.keys是否为空。如果为空,说明这个计数值已经没有任何键了,我们需要将这个节点(cur_node)从链表中完全删除,以防止链表中有空的计数桶。
- 如果
步骤 4:dec(String key) 操作详解
dec 与 inc 逻辑对称,是减 1 并可能向左移动。
- 查找当前节点:从
key_to_node中获取key当前所在的节点cur_node。题目保证它一定存在。 - 计算新的计数值:
new_count = cur_node.count - 1。 - 处理 new_count 为 0 的情况:
- 如果
new_count == 0,说明这个键的计数要归零,需要从数据结构中完全删除。 - 操作:从
cur_node.keys中移除key,并从key_to_node中删除这个键的映射。
- 如果
- 处理 new_count 大于 0 的情况:
- 寻找或创建目标桶。这次是向左找,即检查
cur_node的前一个节点(cur_node.prev)的计数值是否为new_count。 - 同理,如果存在则用,不存在则新建节点插入在
cur_node.prev之后。 - 将
key加入目标桶的keys集合并更新key_to_node映射。
- 寻找或创建目标桶。这次是向左找,即检查
- 清理旧桶:
- 从
cur_node.keys中移除key。 - 如果移除后
cur_node.keys为空,则将该节点从链表中删除。
- 从
步骤 5:getMaxKey() 和 getMinKey() 操作
由于我们维护了一个有序链表(count 递增),且头尾是哑元节点:
getMaxKey():最大计数的桶就是tail的前一个节点(tail.prev)。只要这个节点不是head,我们就从这个节点的keys集合中任意返回一个元素(比如用 Python 的next(iter(node.keys)))。getMinKey():最小计数的桶就是head的后一个节点(head.next)。同理,只要这个节点不是tail,就返回其keys集合中的任意一个元素。
这两个操作都是 O(1) 的。
一个简单的例子
假设依次执行:inc("a"), inc("b"), inc("b"), inc("c"), inc("c"), inc("c"),之后调用 getMaxKey() 和 getMinKey()。
操作过程:
inc("a"): 链表:head <-> (count=1, keys={“a”}) <-> tailinc("b"): 因为count=1的桶已存在,b加入其中。链表:head <-> (count=1, keys={“a”, “b”}) <-> tailinc("b"):b要从count=1移到count=2。因为count=2的桶不存在,所以在count=1桶后创建它。链表:head <-> (count=1, keys={“a”}) <-> (count=2, keys={“b”}) <-> tailinc("c"):c加入count=1桶。链表:head <-> (count=1, keys={“a”, “c”}) <-> (count=2, keys={“b”}) <-> tailinc("c"):c从count=1移到count=2。因为c离开,count=1桶只剩下{“a”}。count=2桶已存在,c加入其中。链表:head <-> (count=1, keys={“a”}) <-> (count=2, keys={“b”, “c”}) <-> tailinc("c"):c要从count=2移到count=3。创建count=3桶。c离开后,count=2桶剩下{“b”}。链表:head <-> (count=1, keys={“a”}) <-> (count=2, keys={“b”}) <-> (count=3, keys={“c”}) <-> tailgetMaxKey(): 最大桶是tail.prev,即count=3桶,返回"c"。getMinKey(): 最小桶是head.next,即count=1桶,返回"a"。
此时如果调用 dec("a"),a 的计数变为 0,会从数据结构中删除。count=1 桶变空,被移除。链表变为:head <-> (count=2, keys={“b”}) <-> (count=3, keys={“c”}) <-> tail。此时 getMinKey() 会返回 "b"。
总结
这个 “全 O(1) 数据结构” 的核心创新点在于将相同计数的键分组到“桶”中,并用一个按计数值排序的双向链表来串联这些桶。哈希表负责从键快速定位到桶。当某个键的计数变化时,我们只需将其从一个桶的集合移动到相邻的另一个桶(或创建新桶),并维护链表的顺序。这样,获取最大/最小值就变成了访问链表两端,所有操作都实现了 O(1) 的时间复杂度。