去做题

第十六单元:状态数组 · 追踪与更新元素

Unit 16: State Arrays — Tracking and Updating Elements
计算机科学 5 · 安息云峰 Cloudrip Mountain · 驯鹿苏醒 → 驯鹿观察者 → 驯鹿招标

16.1 什么是状态数组

What Is a State Array

欢迎来到计算机科学 5 的第十六单元!之前你已经会创建数组、遍历数组、读取元素了。但数组不只是"只读的清单"——它还能被不断修改,用来记录一群对象随时间变化的状态。本单元你将学会一项在游戏开发、库存管理、任务调度里无处不在的核心技巧:状态数组(state array)。你要帮助盲人牧民 Merek,用一个数组记录哪些驯鹿醒着、哪些围栏空着,并把驯鹿一只只安排进圈里。

本单元主线:用一个数组"平行地"记录另一组对象的状态——第 i 只驯鹿的状态放在 deerStatus[i]。你将学会用 deerStatus[i] = "awake" 修改元素、用嵌套循环做位置匹配并更新状态、用 None 表示"空位/已删除"、以及查找数组里第一个空位来分配新对象。这套技巧让你能"追踪一群东西的当前状态并动态调整"。

16.1.1 用数组记录状态(Tracking Status with an Array)

16.1.1.1 一个平行数组,专门存"状态" A parallel array that holds status

状态数组的核心思想是"平行":你有一组对象(比如 5 只驯鹿),再单独建一个同样长度的数组,专门记录它们各自的状态。关卡"驯鹿苏醒"一开始就给了这样一个数组,5 只驯鹿全都初始化为 'asleep'(睡着)。随着程序运行,你把醒来的驯鹿在数组里对应位置改成 'awake'。

# 这个数组记录每只驯鹿的状态(初始全部睡着)
deerStatus = ['asleep', 'asleep', 'asleep', 'asleep', 'asleep']

# 这个数组是驯鹿本身
friends = hero.findFriends()
🦌 状态数组就像"点名表"
老师有一份学生名单(friends),又有一张同样顺序的"出勤表"(deerStatus)。名单第 2 位的同学,出勤表第 2 格就记录他"到"还是"缺席"。两张表顺序一一对应,靠同一个索引把"是谁"和"状态如何"连接起来。

16.1.2 索引即身份(The Index Is the Identity)

16.1.2.1 同一个索引,串起两个数组 One index links two parallel arrays

平行数组之所以能配合,是因为它们共用同一套索引。friends[2] 是第 3 只驯鹿这个对象,deerStatus[2] 就是它的状态。只要保证两个数组顺序不乱,i 就同时是"第几只驯鹿"和"它的状态在哪一格"。这就是为什么遍历时我们常用 range(len(...)) 拿到索引——索引本身携带了"身份"信息。

两个平行数组,靠同一个索引对齐 索引 0 1 2 3 4 friends 🦌 🦌 🦌 🦌 🦌 deerStatus asleep asleep awake asleep asleep

16.2 修改数组元素

Modifying Array Elements

16.2.1 按索引赋值(Assigning by Index)

16.2.1.1 array[i] = 新值 array[i] = newValue

数组不是刻在石头上的——你可以随时把某个位置的元素换成新值。语法很简单:把 array[i] 放在等号左边,右边写新值。关卡说明里给了最直观的演示:读取用 a[1],赋值也用 a[1],只是放在等号左边。

a = [None, 'no', 'maybe']
hero.say(a[1])   # 说 "no"

a[1] = 'yes'      # ← 把索引 1 的元素改成 'yes'
hero.say(a[1])   # 说 "yes"
按索引赋值的三个零件 deerStatus [ i ] = "awake" 哪个数组 哪个位置 存进什么新值

16.2.2 随机访问任意元素(Random Access)

16.2.2.1 任意顺序、任意位置,只要它存在 Any element, any order — if it exists

你不一定非得从头到尾遍历。只要元素存在,就能以任意顺序读取或修改任意位置——这叫"随机访问"。但关卡也反复提醒:索引必须在有效范围内。一个长度为 3 的数组,合法索引是 0、1、2;访问 a[3] 就会越界报错(这正好呼应上一单元的边界防护)。

a = [None, 'one', 'two']
hero.say(a[1])   # 说 "one"
hero.say(a[2])   # 说 "two"
hero.say(a[0])   # 什么都不说(元素是 None)
hero.say(a[3])   # 错误!数组从 0 开始,到 2 结束
None 是"什么都没有":上面 a[0] 的值是 None——一个专门表示"空、无、还没有值"的特殊对象。让英雄去说 None,他什么都不会说。这个特性在本单元后面表示"空位"时会大显身手。

16.3 嵌套循环匹配更新

Matching and Updating with Nested Loops

16.3.1 位置匹配(Matching by Position)

16.3.1.1 外层对象、内层位置,两两比对 Outer objects, inner positions

很多时候,你需要把两组东西"配对"——比如判断每只驯鹿站在哪个围栏上。做法是嵌套循环:外层遍历每只驯鹿,内层遍历每个围栏位置,逐一比较坐标。当某只驯鹿的 x 和 y 都和某个围栏相等时,就"匹配成功",把该围栏对应位置的状态更新为这只驯鹿。这正是"驯鹿观察者"关卡的核心算法。

# 外层:遍历每只驯鹿
for deerIndex in range(len(friends)):
    reindeer = friends[deerIndex]
    # 内层:遍历每个围栏位置
    for penIndex in range(len(penPositions)):
        penPos = penPositions[penIndex]
        if penPos.x == reindeer.pos.x and penPos.y == reindeer.pos.y:
            penOccupants[penIndex] = reindeer.id  # 匹配成功,记录
            break                              # 找到就停

16.3.2 break 提前退出(Breaking Early)

16.3.2.1 匹配到就停,别做无用功 Once matched, stop searching

一只驯鹿只可能站在一个围栏上。所以一旦内层循环找到匹配的围栏、更新完状态,就该用 break 立刻跳出内层循环——继续比对剩下的围栏毫无意义,还浪费时间。break 只跳出它所在的那一层循环(这里是内层围栏循环),外层驯鹿循环会继续处理下一只驯鹿。

找到匹配 → 更新 → break 跳出内层 取一只驯鹿 外层循环 逐个比对围栏 内层循环 坐标相等 更新状态 + break 跳出内层,回外层 外层继续处理下一只驯鹿
break 只跳一层:在双层循环里,break 只结束最内层循环,不会连外层一起结束。所以驯鹿匹配到围栏后 break,程序会自然回到外层去看下一只驯鹿,非常适合"每个对象找到归宿就收手"的场景。

16.4 用 None 表示删除

Using None to Mark Deletion

16.4.1 None 与空位(None as an Empty Slot)

16.4.1.1 把元素设为 None,就像"清空这一格" Setting an element to None empties the slot

在"驯鹿招标"关卡里,你需要把已经归位的驯鹿从待分配名单中"移除"。但直接删掉数组元素会让后面所有元素的索引都往前挪、打乱平行关系。更安全的做法是:把那个位置设为 None——数组长度不变、索引不乱,只是这一格被标记为"空、已处理、无需再管"。关卡默认代码里 penOccupants 一开始就全是 None,表示所有围栏都空着。

# 围栏占用情况,初始全空(用 None 表示空位)
penOccupants = [None, None, None, None, None]

# 驯鹿归位后,把它从待分配名单里"抹掉"
friends[deerIndex] = None   # ← 长度不变,只是这格变空
设 None ≠ 真正删除:把 friends[i] = None 只是把该位置的内容清空,数组仍然有 5 个格子。后面遍历时你会遇到这些 None,必须主动跳过它们(比如用 if not reindeer: continue),否则会把"空位"当成驯鹿去处理而出错。

16.4.2 查找第一个空位(Finding the First Empty Slot)

16.4.2.1 从头扫描,遇到空位就停 Scan from the start, stop at the first empty

"分配"类问题有一个经典算法:找第一个空位。做法是从索引 0 开始遍历目标数组,用 if not penOccupants[occIndex]: 判断这一格是不是空的(None 在布尔判断里是"假",所以 not None 为真)。找到第一个空位后,就把对象放进去,并立刻 break 退出——这样才能保证每个对象只占一个位置、不会重复分配。

# 寻找第一个没有驯鹿的圈
for occIndex in range(len(penOccupants)):
    if not penOccupants[occIndex]:   # 这一格是空的(None / 假)
        penOccupants[occIndex] = reindeer  # 放进去
        hero.command(reindeer, 'move', penPositions[occIndex])
        break   # 找到就停,避免重复分配
从左往右扫描,第一个 None 就是目标 Dasher Vixen None ★ None None 停!第一个空位,放入并 break

16.5 实战关卡精讲

Level Strategy & Analysis

这一节聚焦每一关的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。

关卡 · 驯鹿苏醒

🌅 驯鹿苏醒 —— 修改状态数组标记"醒来"

扣哒世界 驯鹿苏醒关卡 - koudashijie.com
图2:驯鹿苏醒——根据 y 坐标判断驯鹿是否离圈,把状态数组对应位置改成 awake,再报告给盲人牧民 Merek

策略:按位置判断醒睡,再逐一报告

本关是状态数组的入门关。盲人牧民 Merek 看不见,需要你告诉他每只驯鹿是醒是睡。判断依据很简单:不在圈里(y 坐标 > 30)的驯鹿一定是醒着的。策略分两步——先遍历所有驯鹿,把醒着的在 deerStatus 里标记为 'awake';再遍历 deerStatus,把每只驯鹿的索引和状态"说"给 Merek。他听到"asleep"就会命令那只驯鹿醒来。

思路拆解

算法有两个循环。第一个循环更新状态:用 range(len(friends)) 遍历拿到 deerIndex,取出 reindeer = friends[deerIndex],若 reindeer.pos.y > 30,就执行核心一步 deerStatus[deerIndex] = 'awake'——注意这里用同一个索引 deerIndex 同时定位驯鹿和它的状态格,这就是平行数组的精髓。第二个循环负责报告:遍历 deerStatus,用字符串拼接说出"Reindeer + 索引 + is + 状态"。

# 关键片段一:按位置更新状态数组
for deerIndex in range(len(friends)):
    reindeer = friends[deerIndex]
    if reindeer.pos.y > 30:            # 不在圈里 = 醒着
        deerStatus[deerIndex] = 'awake'  # 同索引定位状态

# 关键片段二:遍历状态数组,报告给 Merek
for statusIndex in range(len(deerStatus)):
    hero.say('Reindeer ' + statusIndex + ' is ' + deerStatus[statusIndex])
为什么用索引而不用值遍历:因为你要同时操作两个平行数组(friends 和 deerStatus),必须靠共享的索引 deerIndex 把二者对齐。如果只用 for reindeer in friends,就拿不到能定位状态格的索引了。
初始值的意义:deerStatus 一开始全是 'asleep',所以你只需把"醒着的"改成 'awake',没被改动的自然保持"睡着"。这种"先假设一个默认状态,再修正例外"的写法非常常见。
关卡 · 驯鹿观察者

👁️ 驯鹿观察者 —— 嵌套循环做位置匹配

扣哒世界 驯鹿观察者关卡 - koudashijie.com
图3:驯鹿观察者——逐只驯鹿与每个围栏坐标比对,把占用围栏的驯鹿 id 记入 penOccupants,空围栏保持 empty

策略:找出已占围栏,报告空位

本关引入嵌套循环 + 位置匹配。Merek 想把驯鹿放进围栏过夜,但不知道哪些围栏已经有驯鹿了。你有围栏位置数组 penPositions、占用状态数组 penOccupants(初始全是 'empty')和驯鹿数组 friends。策略是:通过比对坐标,找出每只已站在围栏上的驯鹿,把它的 id 记入对应围栏的 penOccupants;最后报告每个围栏的占用情况,Merek 听到 'empty' 就会派驯鹿过去。

思路拆解

核心是双层循环的位置匹配:外层遍历每只驯鹿,内层遍历每个围栏位置。当 penPos.x == reindeer.pos.x and penPos.y == reindeer.pos.y 时,说明这只驯鹿正站在这个围栏上,于是执行 penOccupants[penIndex] = reindeer.id 记录,并 break 跳出内层——一只驯鹿只占一个围栏,找到即停。注意这里存的是 reindeer.id(驯鹿的标识),而不是整只驯鹿对象,因为 Merek 只需要知道"是哪一只"。

# 关键片段:嵌套循环匹配坐标,记录占用
for deerIndex in range(len(friends)):
    reindeer = friends[deerIndex]
    for penIndex in range(len(penPositions)):
        penPos = penPositions[penIndex]
        if penPos.x == reindeer.pos.x and penPos.y == reindeer.pos.y:
            penOccupants[penIndex] = reindeer.id  # 记录 id
            break                              # 跳出内层
x 和 y 都要相等:判断位置匹配必须同时比较 x 和 y(用 and 连接)。只比 x 相等是不够的——不同围栏可能横坐标相同、纵坐标不同。只有两个坐标都吻合,才能确定驯鹿真的在这个围栏上。
与"驯鹿苏醒"的进阶关系:上一关只用单层循环更新状态;本关多了一层内循环,用来"在另一个数组里找匹配项"。这种"外层拿对象、内层找归属"的双层结构,是数据配对、查表、去重等许多问题的通用骨架。
关卡 · 驯鹿招标

🏕️ 驯鹿招标 —— 移除元素并重新分配空位

扣哒世界 驯鹿招标关卡 - koudashijie.com
图4:驯鹿招标——先把已归位的驯鹿从 friends 中设为 None,再把剩下的驯鹿一只只分配到第一个空围栏

策略:两轮处理,先登记后分配

本关是本单元的压轴关,综合运用了修改元素、None 删除、查找空位三大技巧。任务是让每只驯鹿都睡进一个圈里。策略分两轮:第一轮找出已经站在圈里的驯鹿,把它们记入 penOccupants,同时把它们从 friends 里设为 None(表示"已处理,无需再分配");第二轮遍历剩下的驯鹿,为每只找一个第一个空圈,命令它移动过去。

思路拆解:第一轮 —— 登记并"移除"

和"驯鹿观察者"类似,用嵌套循环做位置匹配。区别在于匹配成功后做两件事:penOccupants[penIndex] = reindeer 把驯鹿存进占用数组,并且 friends[deerIndex] = None 把它从待分配名单里抹掉。这样第二轮就不会重复处理已归位的驯鹿了。

# 第一轮关键片段:匹配成功后登记 + 移除
if penPos.x == reindeer.pos.x and penPos.y == reindeer.pos.y:
    penOccupants[penIndex] = reindeer   # 登记进占用数组
    friends[deerIndex] = None          # 从名单里"删除"
    break

思路拆解:第二轮 —— 跳过空位、找空圈分配

再次遍历 friends。由于已归位的驯鹿被设成了 None,遇到它们要用 if not reindeer: continue 跳过。对于真正剩下的驯鹿,用"查找第一个空位"算法遍历 penOccupants,if not penOccupants[occIndex]: 找到第一个空圈,就把驯鹿放进去、命令它 move 过去,然后 break 提前退出,防止一只驯鹿被分到多个圈。

# 第二轮关键片段:跳过 None,为剩下的驯鹿找空圈
for deerIndex in range(len(friends)):
    reindeer = friends[deerIndex]
    if not reindeer:          # 是 None(已归位),跳过
        continue
    for occIndex in range(len(penOccupants)):
        if not penOccupants[occIndex]:   # 第一个空圈
            penOccupants[occIndex] = reindeer
            hero.command(reindeer, 'move', penPositions[occIndex])
            break                       # 分配一个就停
continue 和 break 别混淆:continue 是"跳过本次循环的剩余部分,直接进入下一轮"——用来略过 None 空位;break 是"彻底结束当前循环"——用来在找到空圈分配后停手。用错会导致驯鹿分配混乱:该跳过的没跳过、该停的没停。
为什么第二轮存整只驯鹿而非 id:因为这一轮不仅要"记录",还要 hero.command(reindeer, 'move', ...) 亲自指挥驯鹿移动到空圈——命令需要驯鹿对象本身,所以 penOccupants 里存的是 reindeer 而不是它的 id。

16.6 常见错误

Common Mistakes

16.6.1 新手最容易踩的坑(Pitfalls for Beginners)

16.6.1.1 状态数组的典型问题 Typical issues with state arrays

扣哒世界 状态数组常见错误速查 - koudashijie.com
图5:索引对不齐、忘跳 None、缺 break、混淆 continue 与 break,是本单元最常见的失误
易错点清单:
  • 两个平行数组索引对不齐——更新状态时必须用同一个索引,写成 deerStatus[deerIndex] 而不是别的变量,否则改错了驯鹿的状态。
  • 忘记跳过 None——把元素设为 None 后再遍历,一定要用 if not x: continue 跳过空位,否则会把空位当对象处理而报错。
  • 匹配后忘写 break——找到归属却不 break,内层循环会继续跑,可能重复分配或做无用功。
  • continue 与 break 混用——跳过空位该用 continue,分配完成该用 break,用反了逻辑就乱。
  • 位置匹配只比 x 或只比 y——必须 x 和 y 都用 and 判断相等,才能确认真的在同一位置。
  • 把"值"当"索引"——deerStatus[i] 里的 i 是位置,元素的值是状态,别把状态字符串拿去当索引用。
  • 误以为设 None 会缩短数组——friends[i] = None 不改变长度,只是清空该格,len(friends) 依然不变。
  • 存错了内容——只需报告身份就存 id;还要指挥移动就存整只驯鹿对象,按用途选择存什么。

本单元小结 ✨

恭喜你掌握了"状态数组"这项强大的追踪技能!这一单元你理解了平行数组的核心思想——用同一个索引把"对象"和"它的状态"连接起来。你学会了用 array[i] = 新值 修改任意位置的元素、用嵌套循环做位置匹配并更新状态、用 None 表示"空位或已删除"、以及"查找第一个空位"这一经典分配算法;还弄清了 break(结束循环)和 continue(跳过本轮)的区别。在"驯鹿苏醒、驯鹿观察者、驯鹿招标"三关里,你从单层更新状态,一步步走到"移除元素 + 重新分配"的综合应用。来回顾一下核心概念:

核心概念关键点对应关卡
平行状态数组同索引连接对象与状态三关
按索引赋值array[i] = 新值驯鹿苏醒
随机访问任意顺序读写存在的元素三关
嵌套循环匹配外层对象、内层位置比对观察者/招标
break 提前退出匹配到就跳出内层观察者/招标
None 表示删除清空该格,长度不变驯鹿招标
跳过空位 continueif not x: continue驯鹿招标
查找第一个空位从头扫描,遇空即分配驯鹿招标

懂得用一个数组追踪万物状态、把对象井井有条地登记与分配的英雄,才是云峰之上真正周全的程序员。
继续向雪山之巅挺进吧,勇敢的程序员!🏔️🦌📋

扣哒世界 编程知识 · 计算机科学 5(安息云峰):第十六单元

扣哒世界教研团队