十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

字典(哈希表)在算法竞赛中的实战应用:从查重到归类

字典(哈希表)在算法竞赛中的实战应用:从查重到归类 1. 从“弗里的语言”到“快递分拣”字典在算法竞赛中的实战价值在算法竞赛的征途上尤其是像蓝桥杯这样注重基础与思维结合的比赛中数据结构的选择往往决定了代码的简洁度与运行效率。今天我们不谈那些高深莫测的线段树或平衡二叉树就聚焦于一个看似简单却威力无穷的基础数据结构——字典在很多语言中也叫映射或哈希表。很多新手朋友可能会觉得字典不就是个“键值对”容器吗能有多复杂但恰恰是这种“简单”让它成为了解决特定类型问题的“银弹”。我遇到过不少同学在面对“弗里的语言”这类字符串统计或是“快递分拣”这种归类问题时第一反应是写一堆复杂的数组和循环代码冗长且容易出错。其实一个设计得当的字典往往能让代码逻辑瞬间清晰效率倍增。这篇文章我就结合这两个经典的竞赛题目带你彻底吃透字典的实战应用让你下次遇到类似问题能条件反射般地想到它并写出优雅高效的解。2. 题目一“弗里的语言”——字典的查重与计数核心“弗里的语言”是一个典型的字符串处理与查重问题。题目大意通常是给定一系列单词或字符串判断是否有某个单词出现了两次或以上。这听起来非常简单但如何高效地实现却体现了不同的编程思维层级。2.1 暴力法的局限与字典法的降维打击最直观的想法可能是暴力双循环遍历每个单词再遍历它之后的所有单词看是否有相同的。这种方法的时间复杂度是 O(n²)当数据量稍大比如 n10⁵时必然超时。这是竞赛中必须避免的“思维陷阱”。此时字典的优势就凸显出来了。它的核心能力是提供近似 O(1) 时间复杂度的查找、插入和访问。对于查重问题我们可以将每个单词作为“键”key而“值”value可以用来存储该单词出现的次数。解题思路立刻变得清晰初始化一个空的字典例如Python 中的dictC中的std::unordered_mapJava中的HashMap。遍历每一个输入的单词。对于每个单词查询它是否已经存在于字典的键中。如果不存在则将其加入字典并设置其值为 1表示第一次出现。如果已经存在那么问题已经解决——我们找到了重复的单词。根据题目要求可以直接输出并结束程序。如果遍历完所有单词都没有触发“已存在”的条件则说明没有重复。这个过程就像我们现实中登记名单一样。来一个人我们先看花名册上有没有他的名字。没有就登记上有就说明他之前来过了。字典就是这个“花名册”它的查找速度极快。注意在 Python 中直接使用in运算符判断键是否存在于字典中其平均时间复杂度是 O(1)。但如果你先尝试用dict.get(key)获取值再根据返回值是否为None来判断也是同样高效的常见写法。选择你习惯且清晰的即可。2.2 代码实现与细节打磨以 Python 为例一个健壮且高效的实现如下def check_duplicate_words(): n int(input().strip()) # 假设第一行输入单词数量 word_dict {} # 初始化空字典 found_duplicate False for _ in range(n): word input().strip() # 读取一个单词去除首尾空格 if word in word_dict: # 核心查重逻辑 print(word) # 或者根据题目要求输出“YES”等 found_duplicate True break # 找到第一个重复即可退出 else: word_dict[word] 1 # 记录这个单词已出现 if not found_duplicate: print(NO) # 遍历完未发现重复 # 调用函数 check_duplicate_words()这段代码简洁有力。但这里有一个极易忽略的细节输入处理。竞赛题目的输入可能包含多余的空格、空行或者单词大小写敏感与否的问题。input().strip()是处理这类问题的第一道防线。如果题目明确说明大小写不敏感如“Hello”和“hello”算重复那么我们就需要在将单词存入字典前进行统一转换例如word input().strip().lower()。这个细节往往决定了一个测试用例的通过与否。2.3 从查重到计数字典值的灵活运用“弗里的语言”的变种可能要求输出所有重复的单词或者每个单词出现的次数。这时字典“值”的作用就从简单的“是否存在标记1”变成了“计数器”。我们稍微修改一下逻辑def count_word_occurrences(): n int(input().strip()) word_count {} # 字典键是单词值是出现次数 duplicates [] for _ in range(n): word input().strip() # 使用 get 方法如果键不存在则返回默认值 0然后加 1 word_count[word] word_count.get(word, 0) 1 # 遍历字典找出出现次数大于1的单词 for word, count in word_count.items(): if count 1: duplicates.append(word) if duplicates: print(重复单词有, sorted(duplicates)) # 排序后输出更规范 else: print(没有重复单词) # 也可以打印完整的词频统计 # for word, count in word_count.items(): # print(f{word}: {count})这里引入了dict.get(key, default)方法它是处理“键可能不存在”情况的优雅方式避免了先判断if key in dict再赋值的繁琐。这个技巧在计数场景下非常常用。3. 题目二“快递分拣”——字典的归类与映射艺术如果说“弗里的语言”展示了字典在“一对一”查找上的威力那么“快递分拣”则完美体现了字典在“一对多”归类问题上的核心价值。题目通常描述为有一批快递每个快递上有目的地城市名和快递单号。需要将所有快递按城市进行分类输出。3.1 思维转换从多重数组到嵌套字典新手可能会想到为每个城市维护一个列表然后根据城市名找到对应的列表再添加。但如何根据城市名快速找到对应的列表呢又回到了查找问题。最笨的办法是维护一个城市名列表再维护一个与之平行的列表的列表每次都要线性查找城市名在第一个列表中的位置效率低下。字典的键值对思想在这里可以升维。我们可以把城市名作为键而值则是一个列表List这个列表用来存储该城市的所有快递单号。这样数据结构就变成了dict_city {“北京”: [“SF1001”, “SF1002”], “上海”: [“YT2001”], ...}。查找、归类一步到位。3.2 完整实现与输入输出处理这类问题的输入格式通常是多行每行包含城市和单号用空格隔开。我们需要持续读取直到文件结束EOF。在蓝桥杯的在线评测系统中这通常意味着循环读取input()直到捕获到EOFError。def sort_express(): city_dict {} # 外层字典城市 - 快递单号列表 # 方法一使用 try-except 处理 EOF import sys for line in sys.stdin: # 逐行读取标准输入直到EOF line line.strip() if not line: # 跳过空行 continue try: city, number line.split() # 默认按空格分割 except ValueError: # 处理可能的格式错误某些题目输入可能不规整 continue # 核心归类逻辑 if city not in city_dict: city_dict[city] [] # 首次遇到该城市为其创建一个空列表 city_dict[city].append(number) # 将单号添加到该城市的列表中 # 方法二明确知道行数n时如果题目第一行给出了 # n int(input()) # for _ in range(n): # city, number input().split() # ... 同上 ... # 输出结果 for city in sorted(city_dict.keys()): # 按城市名字典序输出 print(city) for number in city_dict[city]: # 输出该城市下的所有单号 print(f {number}) # 通常需要缩进以示层级关系关键点剖析if city not in city_dict: city_dict[city] []这一行是灵魂。它确保了每个城市键在第一次出现时其对应的值被初始化为一个空列表。这是一个非常经典的“惰性初始化”模式。输出时sorted(city_dict.keys())保证了输出是按城市名称排序的这通常是题目要求或良好输出的习惯。如果题目要求按输入顺序或其他顺序则需调整。内层循环打印单号时的缩进如两个空格是格式化输出的常见要求务必注意否则可能因“格式错误”而丢分。3.3 进阶思考如果还要统计每个城市的快递数量呢这太简单了字典的值已经是一个列表了列表的长度len(city_dict[city])就是数量。你可以在输出城市名时一并输出for city in sorted(city_dict.keys()): count len(city_dict[city]) print(f{city} {count}) # 先输出城市和总数 for number in city_dict[city]: print(f {number})这个需求的变化丝毫没有改变我们核心的数据结构设计只是对已有的数据做了进一步计算充分体现了良好数据结构设计带来的扩展性。4. 字典的底层、性能与竞赛中的避坑指南理解了应用我们还需要知道其所以然并避开实战中的坑。4.1 哈希表字典高效背后的引擎字典之所以能实现 O(1) 级别的平均查找时间核心在于其底层实现——哈希表。简单来说当你插入一个键值对(key, value)时系统会对key调用一个哈希函数计算出一个整数哈希值。将这个哈希值映射到一个固定大小的数组哈希桶的某个索引位置。将值存储在该索引对应的位置或链表中。查找时对要查找的key执行同样的哈希计算直接定位到数组索引从而快速取得值。理想情况下这个过程是常数时间的。但这带来了两个关键约束键必须是可哈希的在 Python 中不可变类型如整数、浮点数、字符串、元组是可哈希的可以作为字典的键。而列表、字典、集合这些可变类型是不可哈希的不能作为键。如果你尝试my_dict[[1,2]] 3会得到TypeError。哈希冲突不同的键可能计算出相同的哈希值这就是冲突。好的哈希表实现如 Python 的 dict会通过“开放寻址”等高级方法优雅地处理冲突但这意味着在最坏情况下大量冲突性能会退化到 O(n)。不过在竞赛的数据规模和常规使用下几乎不用担心这个问题。4.2 Pythoncollections.defaultdict让代码更简洁在“快递分拣”的例子中我们需要先判断城市是否存在不存在则初始化列表。Python 的collections模块提供了一个叫defaultdict的工具可以简化这个操作。from collections import defaultdict def sort_express_with_defaultdict(): city_dict defaultdict(list) # 指定默认值为 list 类型 import sys for line in sys.stdin: line line.strip() if not line: continue try: city, number line.split() except ValueError: continue # 看这里不需要 if 判断了 city_dict[city].append(number) for city in sorted(city_dict): print(city) for number in city_dict[city]: print(f {number})defaultdict(list)创建了一个字典当你访问一个不存在的键时它会自动调用list()函数生成一个空列表作为该键的值然后返回。这使代码更加简洁意图更清晰。对于计数场景可以使用defaultdict(int)。4.3 内存与性能的权衡字典虽然快但它比数组列表消耗更多的内存因为它需要存储哈希表结构、键和值。在极端内存限制的题目中虽然蓝桥杯较少见如果键的范围很小且连续例如键是 1 到 1000 的整数那么使用列表数组通过索引直接访问可能更节省内存count_list [0] * 1001然后count_list[key] 1。一个重要的性能陷阱在循环中频繁检查一个键是否在字典中并伴随插入操作这种模式本身是高效的。但要避免在循环内部进行不必要的字典拷贝如new_dict old_dict.copy()或遍历除非必要这会导致时间复杂度骤增。4.4 输入输出加速应对大数据量蓝桥杯有些题目数据量很大。当输入行数达到 10⁵ 甚至更多时Python 标准的input()可能会成为性能瓶颈。此时可以使用sys.stdin.readline()进行加速。import sys def fast_input_example(): data sys.stdin.read().strip().split() # 一次性读取所有输入按空白字符分割 # 假设数据格式是n, 然后n对 city number n int(data[0]) idx 1 city_dict defaultdict(list) for _ in range(n): city data[idx] number data[idx 1] idx 2 city_dict[city].append(number) # ... 后续处理 ...sys.stdin.read()一次性读入所有内容速度远快于循环调用input()。但要注意这种方法需要你非常清楚输入数据的格式并能正确地进行解析。5. 举一反三字典在竞赛中的其他高频场景掌握了这两个经典问题字典的用法已经入门。但它的应用远不止于此。下面这些场景本质上都是“弗里的语言”或“快递分拣”的变体两数之和/三数之和给定一个数组和一个目标值找出数组中两个数之和等于目标值的索引。核心思路是遍历数组将“目标值 - 当前数”作为键当前索引作为值存入字典。当后续遍历到的数存在于字典中时即找到一对解。这完全是将查找问题从 O(n²) 降为 O(n) 的字典经典应用。前缀和与子数组问题例如寻找和为 k 的连续子数组。计算前缀和数组prefix_sum问题转化为寻找prefix_sum[j] - prefix_sum[i] k的 (i, j) 对。我们可以遍历前缀和用字典记录每个前缀和值第一次出现的索引。对于当前的prefix_sum[j]检查prefix_sum[j] - k是否在字典中如果在就找到了一个子数组。这比暴力枚举所有子数组高效得多。模拟与状态记录在一些复杂模拟题中需要记录某个状态如棋盘局面、字符串模式是否出现过以避免重复搜索或进入循环。可以将状态序列化为一个字符串或元组作为字典的键。值可以是最小步数、是否访问过等。缓存记忆化搜索在递归求解问题如斐波那契数列、爬楼梯问题时会有大量重复子问题。用一个字典作为缓存键是函数参数如 n值是计算结果。在递归函数开头先查缓存如果存在直接返回能极大提升效率这是动态规划思想的雏形。字典这个看似简单的键值对容器实则是算法竞赛中提升代码效率、简化逻辑思维的利器。从“弗里的语言”的快速查重到“快递分拣”的优雅归类其核心思想都是利用哈希表实现快速映射将原本可能需要复杂逻辑或多重循环的问题转化为直观的查找与更新操作。我个人的经验是在比赛或练习中一旦遇到需要根据某个“标识”如字符串、数字ID去关联、统计、查找其他数据的场景第一时间就应该想到字典。多尝试用defaultdict来简化初始化逻辑注意输入输出的格式和性能你就能稳稳地拿下这一类基础但至关重要的分数。
返回列表