在前几个单元里,你学会了封装函数、用分支做决策。这一单元你要认识一个看起来不起眼、却能优雅解决"轮流"和"循环"问题的运算——模运算(modulo),也就是求余数。它能让你用极短的一行代码,实现"士兵、弓箭手轮着召唤""把部队均匀分配到几个防御点""索引无论多大都不会越界"这些实用效果。
%(求两数相除的余数)、用 len() 获取数组长度,并把二者结合成一个万能公式:索引 % 数组长度。这个公式能让索引在数组范围内"周而复始地折返",从而实现兵种轮换(len(hero.built) % len(summonTypes))、单位均分到多个防御点(i % len(defendPoints)),并从根本上杜绝数组越界。
余数其实你早就会了。把 12 颗糖平均分给 5 个小朋友,每人 2 颗,还剩 2 颗——这个"剩下的 2"就是余数,写作 12 % 5 == 2。再想想钟表:现在 10 点,过 5 小时是几点?(10 + 5) % 12 == 3,是 3 点——时针走到 12 就自动"折返"回头。模运算的精髓就是这种转圈、折返、周而复始的感觉。
% 长度,就能让索引像时针一样,永远落在有效范围内。
游戏里你会不停召唤部队,第 1 个、第 2 个、第 100 个……这个"已造数量"只增不减、越来越大。但你想召唤的兵种只有那么几种(比如士兵、弓箭手两种)。怎么让一个不断变大的数字,反复地、循环地对应到这少数几个选项上?答案就是模运算。它把"无限增长的计数"折叠回"有限的选项范围",让轮换自然发生。
关卡把它说得很清楚:"a % b 返回 a 除以 b 的余数(作为一个整数)。所以 12 % 5 == 2。"符号 % 在这里不是百分号,而是求余运算符。它的运算方法是:a 里能塞进多少个完整的 b,剩下不够再凑一个 b 的部分,就是余数。
这是模运算最重要的性质:无论被除数多大,a % b 的结果永远落在 0 到 b-1 之间,而且随着 a 递增,余数会 0, 1, 2, …, b-1, 0, 1, 2, … 这样周而复始地循环。关卡用一个数组的例子把这个规律列得明明白白:
# 数组有 4 个元素,用 i % 4 环绕它 summonTypes = ["soldier", "archer", "peasant", "paladin"] 0 % 4 == 0 # → "soldier" 1 % 4 == 1 # → "archer" 2 % 4 == 2 # → "peasant" 3 % 4 == 3 # → "paladin" 4 % 4 == 0 # → "soldier" 绕回开头! 5 % 4 == 1 # → "archer" ……如此循环
len(数组) 返回数组里元素的个数。比如 summonTypes = ["soldier", "archer"],那么 len(summonTypes) 就是 2。len(hero.built) 则是你到目前为止造过的部队总数。把 len() 用作 % 的除数,是本单元最核心的组合——因为数组长度正是"选项的总数",用它取余,索引就永远不会跑到数组外面去。
% len(summonTypes) 比写 % 2 更好。将来你往 summonTypes 里多加一个兵种,len() 会自动变成 3,代码无需任何改动就能正确轮换三种兵。让代码自己去数长度,别把长度写死。
数组的索引从 0 开始数,一个有 n 个元素的数组,合法索引是 0 到 n-1。一旦你用一个 ≥ n 的索引去访问,就会越界(out of range),程序报错。而不断增长的计数器(如已造部队数)迟早会超过数组长度——这正是模运算大显身手的地方:索引 % 长度 保证结果永远在合法范围内。
# summonTypes 只有 2 个元素 i = 5 type = summonTypes[i] # ✗ 索引 5 越界,报错!
i = 5 idx = i % len(summonTypes) # 5 % 2 == 1 type = summonTypes[idx] # ✓ 安全
把前面所有零件拼起来,就得到本单元反复出现的黄金公式:
它的读法是:"用已经造过的部队数量对兵种数组的长度取余,得到一个永远合法的索引,再拿它去数组里取出这一次该召唤的兵种"。每召唤一个,len(hero.built) 就加一,取余结果就往后挪一格,从而实现自动轮换。
"联合作战"关卡就是练这个。你先在 summonTypes 里编排好想要的兵种和顺序,比如 ["soldier", "archer"],然后用公式挑出本次该召唤的类型。已造 0 个时造士兵,造完变 1 个时造弓箭手,变 2 个时 2 % 2 == 0 又回到士兵……士兵、弓箭手就这样 1:1 地交替建造。
# 编排兵种与顺序 summonTypes = ["soldier", "archer"] def summonTroops(): # 用取余在数组里不断折返,挑出本次兵种 type = summonTypes[len(hero.built) % len(summonTypes)] if hero.costOf(type) <= hero.gold: hero.summon(type)
["soldier", "soldier", "archer"],轮换就变成士兵、士兵、弓箭手,即 2:1 的配比。数组的内容和重复次数直接决定了兵种比例,非常灵活——这正是"数据驱动"的思想:改数据,不改逻辑。
关卡默认代码的注释一语道破:"这可以用在索引可能大于长度时,能够绕回数组的开头。"len(hero.built) 会一直增长到几十、上百,但只要套上 % len(summonTypes),结果永远被"关"在 0 ~ 长度-1 之内,绝不会越界。你不必写任何 if 去检查索引超没超——取余本身就是护栏。
模运算的另一个妙用是均分。"钢爪间隙"要把 8 个单位平均派到 4 个防御点,每点两个。诀窍是:遍历友军时,用每个友军的下标 i 对防御点数量取余,defendPoints[i % len(defendPoints)],就能算出它该去哪个点。下标 0、1、2、3 分到 4 个不同的点,下标 4、5、6、7 又依次绕回这 4 个点——每点正好两个,自动均分。
索引 % 长度 让一个递增的数在有限选项里循环。看懂一个,另一个自然就通了。
这一节聚焦每一关的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。
这是一个开放式防御关,目标是生存 30 秒并收集 300 金钱(进阶挑战是撑到 60 秒)。关卡不限定你造什么兵,而是让你通过编排 summonTypes 数组来决定兵种的种类、顺序和配比。策略核心就是找到一套攻守兼备的组合——比如士兵扛线、弓箭手输出,让 ["soldier", "archer"] 交替成军。
整体沿用前几单元"一个函数做一件事"的结构,主循环每帧调用三件事:summonTroops 用取余公式轮换召唤、gatherCoins 移动去捡最近的金币维持经济、commandTroops 遍历友军各打最近的敌人。其中最关键的是 summonTroops 里的取余选型,以及召唤前用 if hero.costOf(type) <= hero.gold 判断"买得起才召唤"。
# 关键片段:编排数组 + 取余轮换 summonTypes = ["soldier", "archer"] def summonTroops(): type = summonTypes[len(hero.built) % len(summonTypes)] if hero.costOf(type) <= hero.gold: hero.summon(type)
commandTroops 里遍历友军时,要跳过任何 type == "palisade" 的盟友。栅栏是不能移动、不能攻击的防御建筑,对它下达"攻击"命令毫无意义甚至报错。用一句 if friend.type == "palisade": continue(或反过来只对能战斗的单位下令)即可优雅避开。
本关英雄被敌人四面包围,要靠部队守住一道临时栅栏。关卡设定得很直白:召唤 8 个单位、布防 4 个点,每点两个。这就要求把兵力均匀撒开,而不是全挤在一处——四个方向都得有人守。实现均分的钥匙,正是模运算 unitIndex % 4。
本关有两个预置数组:defendPoints 存 4 个防御点坐标,summonTypes 存 8 个单位的兵种顺序(4 士兵 + 4 弓箭手)。取余在两处发力。其一,召唤:summonTroops 仍用 len(hero.built) % len(summonTypes) 按顺序把 8 种配置造出来。其二,布防:commandTroops 遍历友军时,用友军下标 i % len(defendPoints) 算出它该去的点,再 hero.command(friend, "defend", point)。
# 关键片段:用下标取余把每个友军派到一个防御点 def commandTroops(): friends = hero.findFriends() for i in range(len(friends)): friend = friends[i] # 下标 % 防御点数量 → 绕回 defendPoints point = defendPoints[i % len(defendPoints)] hero.command(friend, "defend", point)
i % 4 就能均分?因为下标是连续的 0、1、2、3、4、5、6、7,对 4 取余后依次是 0、1、2、3、0、1、2、3——前四个各占一个点,后四个又各绕回同一个点。只要单位总数是防御点数的整数倍,每个点分到的数量就完全相等。8 个单位、4 个点,每点正好 2 个。
command(friend, "defend", point)——让单位驻守某个坐标点,敌人靠近才应战。这和前面关卡的 command(friend, "attack", enemy)(主动扑向某个敌人)不同。四面受敌时,"守住阵位"比"追着敌人跑"更稳,能防止阵型被敌人各个击破。
summonTypes[len(hero.built)] 迟早越界报错,必须 % len(summonTypes)。数组[i % len(数组)],别写成 数组[i] % len(数组)(那是先取元素再取余,逻辑全错)。% 2 而非 % len(summonTypes),一旦数组增减元素就轮换错乱。% 当成百分号理解——它是求余运算符,12 % 5 是 2,不是"百分之十二"。len(hero.built),均分防御点用友军下标 i,两者别混用。if hero.costOf(type) <= hero.gold,钱不够硬召唤无效。type == "palisade" 的盟友下攻击/移动命令没意义,要跳过。defend 守点,别错用成 attack 追敌。恭喜你掌握了模运算这件"以小博大"的利器!这一单元你理解了求余运算符 % 的含义(a % b 取相除的余数)与它周而复始、绝不越界的循环特性,学会了用 len() 取得数组长度,并把二者拼成本单元的黄金公式 索引 % 数组长度。你用它在"联合作战"里让士兵、弓箭手轮换成军,又在"钢爪间隙"里把 8 个单位均分到 4 个防御点,还顺带彻底解决了数组越界的隐患。来回顾一下核心概念:
| 核心概念 | 关键点 | 对应关卡 |
|---|---|---|
| 模运算符 % | a % b 取余数,如 12%5==2 | 两关 |
| 余数循环性 | 结果永远在 0 ~ b-1 之间转圈 | 两关 |
| len 取长度 | 数组元素个数,作 % 的除数 | 两关 |
| 索引 % 长度 | 让不断变大的索引绕回数组开头 | 两关 |
| 兵种轮换 | len(hero.built) % len(summonTypes) | 联合作战 |
| 单位均分 | i % len(defendPoints) 平均布防 | 钢爪间隙 |
| 防止越界 | 取余是天然的安全护栏 | 两关 |
| 数据驱动配比 | 改数组内容即改兵种比例 | 联合作战 |
懂得让一个小小的余数,指挥千军万马轮番上阵、四面布防的英雄,才是云峰之上真正举重若轻的程序员。
继续向雪山之巅挺进吧,勇敢的程序员!🏔️🤝🛡️
扣哒世界 编程知识 · 计算机科学 5(安息云峰):第四单元
扣哒世界教研团队