封神冰川的农场里,牦牛并不是被随手扔进格子里的——农民用一套神奇的算法,把每头牦牛的名字直接翻译成一个格子编号。只要你知道名字,套用同样的算法一算,就能瞬间知道它在哪个格子,再也不用挨个翻找。这套"从名字算出位置"的魔法,正是计算机科学里大名鼎鼎的哈希函数(hash function)。这一单元由两个关卡组成,你将掌握如何设计哈希函数、如何用 ord() 取字符编码、如何用取模把结果"塞"进有限的格子里,以及哈希背后"存取一致"的核心思想。
ord() 获取字符的 ASCII 码;掌握两种设计法——取首字母编码与遍历字符串求和;用取模(%)把哈希值限制在数组范围内(归一化 normalize);懂得存(PUT)和取(GET)必须用同一个哈希函数;并初步认识碰撞(collision)的概念。
哈希函数是一种把输入(键,key)转换成固定范围整数(哈希值)的函数。在本单元里,键是牦牛或士兵的名字(字符串),哈希值则是一个可以当作数组下标用的数字。它的魔力在于:同一个名字,每次算出来的编号永远相同——于是"算一次就能定位",无需逐个查找。
在计算机中,哈希表(hash table / 哈希映射)是一种把"键"映射到"值"的数据结构。它用哈希函数计算出一个索引,指向数组中该记录应该存放的位置。这样一来,存数据和查数据都能在接近常数时间内完成——无论表里有多少元素,定位一个键的代价几乎不变。这正是哈希表相比链表、数组"顺序查找"的巨大优势。
计算机里没有"字母",只有数字。每个字符都对应一个整数编码,这套编码表就是 ASCII。Python 的内置函数 ord() 能把一个字符转换成它的编码:比如 ord("A") 是 65,ord("B") 是 66,ord("a") 是 97。哈希函数正是靠 ord() 把"字符串键"变成"可计算的数字"的第一步。
# ord() 把单个字符转为它的 ASCII 编码 ord("A") # → 65 ord("B") # → 66 ord("Z") # → 90 ord("a") # → 97
ord() 的参数必须是长度为 1 的字符串,比如 ord(name[0]) 取名字第一个字母的编码。若传整个字符串(如 ord("Yak"))会报错。要处理整串,需要遍历它、对每个字符分别调用 ord()。
要把整个名字变成一个数字,常见做法是遍历字符串的每个字符,用 ord() 取码后累加。可以用索引循环 for i in range(len(name)) 取出 name[i],也可以直接 for letter in name。这是"哈希魔术"关卡里求和法哈希的核心步骤。
# 遍历字符串,累加每个字符的 ASCII 码 total = 0 for i in range(len(name)): letter = name[i] total += ord(letter) # 把这个字符的码加进总和 # 更简洁的写法:直接遍历字符 for letter in name: total += ord(letter)
"散列的牦牛"关卡用了一个非常简单的哈希函数:取名字第一个字母的 ASCII 码,再减去 65(因为 "A" 的码是 65),这样 A 开头得 0、B 开头得 1……最后取模限制在格子数内。这个方案只在"每头牦牛首字母各不相同"时才可靠,但对当前关卡已经够用。
# "散列的牦牛":首字母哈希函数 def hashName(name): asciiCode = ord(name[0]) # 取首字母的 ASCII 码 return (asciiCode - 65) % 12 # 减 65 归零, 再对 12(格子数)取模
"哈希魔术"关卡用了更结实的方案:把名字里每个字符的 ASCII 码全部相加,得到一个大数,再取模归一化。相比只看首字母,求和法让名字的每一个字母都参与计算,不同名字更不容易撞到同一个编号,分布也更均匀。
# "哈希魔术":求和哈希函数(尚未取模) def hashName(name): hash = 0 for i in range(len(name)): letter = name[i] hash += ord(letter) # 累加每个字符的码 return hash # ← 下一步还要对 totalCells 取模
只取首字母编码,简单快速;但只要有两头牦牛首字母相同就会冲突。适合键很少、首字母互异的场景。
累加全部字符编码,更均匀、更抗冲突;但字母相同顺序不同的词(如 "abc"/"cba")仍会得到相同和。
字符编码相加常常得到很大的数(几百甚至上千),但格子只有有限个(如 12 个或 135 个)。取模运算 % 正是解决之道:hash % totalCells 会得到 0 到 totalCells - 1 之间的余数,恰好落在合法的索引范围内。这一步叫作归一化(normalize),是每个哈希函数不可或缺的收尾。
# "哈希魔术":用取模把哈希值压进 0~134 totalCells = 135 def hashName(name): hash = 0 for letter in name: hash += ord(letter) hash = hash % totalCells # 归一化到 0~134 return hash
任何整数对 N 取模,结果一定在 0 到 N-1 之间——这正好对应一个长度为 N 的数组的所有合法下标。所以 135 个格子就模 135(得 0~134),12 个格子就模 12(得 0~11)。取模的数必须等于格子总数,这样才能既不越界、又能用满每一个格子。
% 100,那么索引 100~134 永远用不到、还可能把本该分散的键挤到前 100 格。取模的除数必须等于可用格子的总数,归一化才正确。
要存或取某条信息,就用它的键(name)作为参数调用哈希函数,得到的数字就是它在内存(格子阵列)中的地址。存的时候(PUT)把数据放进那个格子;取的时候(GET)去那个格子拿数据。整个过程不需要遍历,一算即达。
# 用键调用哈希函数得到存储地址 key = "someName" hero.say("它在第 " + hashFunction(key) + " 号格子")
这是哈希表最关键的规则:存(PUT)和取(GET)必须使用完全相同的哈希函数。"哈希魔术"关卡里,你用士兵名字算出位置把他们放好(PUT),圣骑士再用同一个哈希函数算出位置去找他们(GET)。只要函数一致,同一个名字必然指向同一个格子——存进去的东西才找得回来。若两边算法不同,存取的地址就对不上,数据就"丢"了。
哈希函数把大量可能的键压进有限的格子里,难免出现两个不同的键算出相同哈希值的情况——这就叫碰撞(collision)。比如首字母法里,两头都以 "B" 开头的牦牛会撞到同一格;求和法里,"abc" 和 "cba" 字母和相同也会相撞。碰撞是哈希表无法完全避免的固有现象,真实系统会用"链地址法""开放寻址"等技术来化解。不过在本单元的两关里,数据经过精心设计,不会发生碰撞,你可以先专注于哈希函数本身的设计。
这一节聚焦每一关的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。
牦牛按名字的哈希值分格存放,你只能"按格子号"召唤牦牛,而且不能吵醒睡着的。农民会给你一份清醒牦牛的名字列表(yakList)。策略很清晰:遍历这份名字列表,对每个名字调用哈希函数算出格子号,再把号码说出来,即可精准召唤,不惊扰睡牦牛。
先拿到农民的 yakNameList;哈希函数 hashName 取首字母 ord(name[0])、减 65、对 12 取模。然后用 for yakName in yakNameList 遍历,对每个名字 hero.say(hashName(yakName)) 说出结果。核心就是把"遍历列表"和"调用哈希函数"组合起来。
# 关键片段:哈希函数 + 遍历名单 def hashName(name): asciiCode = ord(name[0]) return (asciiCode - 65) % 12 # 遍历清醒牦牛名单,逐个说出格子号 for yakName in yakNameList: hero.say(hashName(yakName)) # 说出号码 = 召唤该牦牛
只有 135 个格子,所以哈希值必须归一化到 0~134。策略分三步:① 遍历名字累加每个字符的 ASCII 码;② 用取模把和压进 0~134;③ 遍历所有士兵,说出"名字 + 空格 + 哈希值"。这样存(PUT)时定位、圣骑士取(GET)时用同一函数定位,才能对上号。
默认代码里 hash 从 0 开始,循环累加 ord(letter),但缺了归一化。关键是在 return 前补上 hash = hash % totalCells,把可能上千的和折进合法索引范围。这是本关最核心的一行。
# 关键片段:求和 + 取模归一化 totalCells = 135 def hashName(name): hash = 0 for i in range(len(name)): letter = name[i] hash += ord(letter) hash = hash % totalCells # ← 关键:归一化到 0~134 return hash
用 findByType("soldier") 拿到所有士兵,遍历时对每个士兵 hero.say(soldier.id + " " + hashName(soldier.id))。注意名字和数字之间要用空格分隔——这是关卡的格式要求。用 soldier.id 作为键传入哈希函数,存取都以它为准。
# 关键片段:遍历士兵,说出"名字 空格 哈希" soldiers = hero.findByType("soldier") for soldier in soldiers: hero.say(soldier.id + " " + hashName(soldier.id))
hash % totalCells。字符编码之和轻易超过 135,若不取模,得到的索引会远超格子范围,圣骑士就找不到魔法的位置。取模这一步是让哈希"落地"的关键。hashName 摆放士兵(PUT),圣骑士也用同一个 hashName 寻找(GET)。只要函数不变,同名必同位。这正是哈希表能可靠工作的根本保证。
% totalCells 会导致索引越界、定位失败。ord() 只接受单个字符;要处理整串需遍历后对 name[i] 逐个取码。- 65 会让索引从 65 起跳而非从 0 起,浪费前面的格子。hash = 0,否则无法累加或报错。恭喜你揭开了计算机科学中一门优雅高效的魔法——哈希函数!在封神冰川的这两关里,你理解了哈希函数把"键"映射为"数组索引"的核心思想;学会了用 ord() 获取字符的 ASCII 码、遍历字符串累加编码;掌握了两种设计法——首字母法(取 name[0] 编码)与求和法(累加全部字符码);懂得用取模 % 把哈希值归一化到 0~(N-1) 的合法范围;也牢记了存(PUT)与取(GET)必须用同一个哈希函数这一铁律,并初识了碰撞的概念。从"散列的牦牛"的首字母定位,到"哈希魔术"的求和归一化,你已经能亲手设计一个可用的哈希函数。来回顾一下核心概念:
| 核心概念 | 关键点 | 对应写法 |
|---|---|---|
| 哈希函数 | 键 → 索引 | hashName(name) → 数字 |
| ord() 取码 | 字符 → ASCII 整数 | ord(name[0]) |
| 遍历求和 | 累加每个字符码 | for letter: hash += ord(letter) |
| 首字母法 | 取首字母减基准 | (ord(name[0]) - 65) % N |
| 取模归一化 | 压进 0~N-1 | hash = hash % totalCells |
| PUT/GET 一致 | 存取同一函数 | 同一个 hashName |
| 碰撞 | 不同键同索引 | collision |
懂得用一次计算把"名字"变成"地址"、让数据存取都能一步直达——哈希这份化繁为简的智慧,会成为你构建高效查找系统的强大魔法。
封神冰川的哈希试炼圆满完成,向着更深的数据结构世界,继续前行!🧊❄️🔑
扣哒世界 编程知识 · 计算机科学 6(封神冰川):第十二单元
扣哒世界教研团队