家庭作业 3:可变性、树、迭代器与生成器

截止时间:7 月 15 日(星期三)晚上 11:59

操作指南

下载 hw03.zip。压缩包中包含文件 hw03.py以及 ok 自动评分器。

提交: 完成后,请将作业提交到 Gradescope。截止时间前可以多次提交,只有最后一次提交会被评分。请确认代码已成功提交;具体步骤参见 Lab 0 ,其中有更详细的作业提交说明。

使用 Ok: 如果对 Ok 的使用有任何疑问,请参阅 这份指南。

阅读材料: 你可能会发现以下参考资料很有用:

评分: 家庭作业根据正确性评分。每个不正确的问题将使总分减少一分。 此家庭作业满分为 2 分。

作业题目

如果需要复习可变性,可以展开本节。也可以直接开始做题,遇到困难时再回来查阅。

Python 中有些对象(例如列表和字典)是 mutable的,也就是其内容或状态可以改变。另一些对象(例如数值、元组和字符串)是 不可变的,创建后就不能再改变。

列表最常见的两种修改操作是元素赋值和 append method.

>>> s = [1, 3, 4]
>>> t = s  # A second name for the same list
>>> t[0] = 2  # this changes the first element of the list to 2, affecting both s and t
>>> s
[2, 3, 4]
>>> s.append(5)  # this adds 5 to the end of the list, affecting both s and t
>>> t
[2, 3, 4, 5]

列表还有许多其他修改方法:

  • append(elem): Add elem 添加到列表末尾。返回 None.
  • extend(s):把可迭代对象中的所有元素添加到列表末尾。 s 添加到列表末尾。返回 None.
  • insert(i, elem):把 elem 插入索引 i. If i 大于或等于列表长度, elem 会被插到末尾。该操作不会替换现有元素,只会加入新元素 elem。返回 None.
  • remove(elem):移除列表中第一次出现的 elem 。返回 None。如果 elem 不在列表中,则报错。
  • pop(i):移除并返回指定索引处的元素 i.
  • pop():移除并返回最后一个元素。

字典也支持元素赋值(经常使用)和 pop (较少使用)。

>>> d = {2: 3, 4: 16}
>>> d[2] = 4
>>> d[3] = 9
>>> d
{2: 4, 4: 16, 3: 9}
>>> d.pop(4)
16
>>> d
{2: 4, 3: 9}

如果需要复习生成器,阅读下面的内容会有所帮助:

可以通过编写 生成器函数来创建自定义迭代器;它返回一种特殊的迭代器,称为 生成器。生成器函数的函数体中使用 yield 语句,而不是 return 语句。调用生成器函数会返回生成器对象,但 not 立即执行函数体。

例如,考虑下面这个生成器函数:

def countdown(n):
    print("Beginning countdown!")
    while n >= 0:
        yield n
        n -= 1
    print("Blastoff!")

Calling countdown(k) 会返回一个从指定数字倒数到 0 的生成器对象 k 。由于生成器也是迭代器,可以对结果对象调用 iter ;它只会返回同一个对象。注意,此时函数体尚未执行,不会打印或产出任何数字。

>>> c = countdown(5)
>>> c
<generator object countdown ...>
>>> c is iter(c)
True

那么倒数是怎样发生的?生成器也是迭代器,所以可以调用 next 取得下一个元素!第一次调用 next 时,从函数体第一行开始执行,直到遇到 yield 语句。该语句中表达式的求值结果会被返回。 yield 下面的交互会话接续前面的示例。

>>> next(c)
Beginning countdown!
5

与本课程此前见过的函数不同,生成器函数能够记住状态。后续每次调用 next时,都会从上一次执行的 yield 语句之后继续。与第一次调用一样, next会一直执行到下一个 yield 语句。正因如此, Beginning countdown! 不会再次被打印。

>>> next(c)
4
>>> next(c)
3

接下来 3 次调用 next 会继续依次产出递减整数,直到 0。再调用一次时会抛出 StopIteration 错误,因为已没有值可以产出(也就是在遇到下一个 yield 语句前,函数体已经结束)。

>>> next(c)
2
>>> next(c)
1
>>> next(c)
0
>>> next(c)
Blastoff!
StopIteration

分别调用 countdown 会创建各自拥有独立状态的生成器对象。生成器通常不会重新开始;若要重置序列,请再次调用生成器函数,创建新对象。

>>> c1, c2 = countdown(5), countdown(5)
>>> c1 is c2
False
>>> next(c1)
5
>>> next(c2)
5

总结如下:

  • 一个 生成器函数 has a yield 语句,并返回一个 生成器对象.
  • 调用 iter 并传入生成器对象,会原样返回该对象,不改变当前状态。
  • 在对生成器对象调用 next 之前,生成器函数体不会执行。调用 next 会计算并返回序列中的下一个对象。如果序列已经耗尽,则抛出 StopIteration
  • 生成器会“记住”状态,供下一次 next 调用使用。因此,

    • 第一次 next 调用按以下过程执行:

      1. 进入函数并运行,直到含有 yield.
      2. 返回 yield 语句中的值,同时保存函数状态供后续调用使用 next calls.
    • 后续的 next 调用按以下过程执行:

      1. 重新进入函数,从 上次执行的 yield 语句之后开始,运行到下一个 yield 语句。
      2. 返回 yield 语句中的值,同时保存函数状态供后续调用使用 next calls.
  • 调用生成器函数会返回全新的生成器对象(类似对可迭代对象调用 iter )。
  • 除非定义如此,否则生成器不会重新开始。若要从第一个元素开始,只需再次调用生成器函数创建新生成器。

生成器还有一个有用工具: yield from 语句。 yield from 会依次产出某个迭代器或可迭代对象中的所有值。

>>> def gen_list(lst):
...     yield from lst
...
>>> g = gen_list([1, 2, 3, 4])
>>> next(g)
1
>>> next(g)
2
>>> next(g)
3
>>> next(g)
4
>>> next(g)
StopIteration

下面再复习迭代器:

可迭代对象允许我们逐个访问其元素。它们可以用于 for 循环和列表推导式,也可以使用 list 函数转换为列表。目前见过的 Python 可迭代对象包括字符串、列表、元组和 range。
>>> for x in "cat":
...     print(x)
c
a
t
>>> [x*2 for x in (1, 2, 3)]
[2, 4, 6]
>>> list(range(4))
[0, 1, 2, 3]

请注意这里的抽象:给定某个可迭代对象,我们有一组明确的操作可以使用。本节将越过抽象屏障,看看可迭代对象在“底层”如何实现。

In Python, 可迭代对象 正式定义为可以传给内置函数 iter 并产生 迭代器的对象. An 迭代器的对象 是另一类对象,可以通过 next 函数逐个产生元素。

  • iter(iterable):返回一个遍历给定可迭代对象元素的迭代器。
  • next(iterator):返回迭代器的下一个元素;若已无元素,则抛出 StopIteration 异常。

例如,数字列表是可迭代对象,因为 iter 会生成遍历该序列的迭代器,我们可以使用 next 逐项访问:

>>> lst = [1, 2, 3]
>>> lst_iter = iter(lst)
>>> lst_iter
<list_iterator object ...>
>>> next(lst)
1
>>> next(lst)
2
>>> next(lst)
3
>>> next(lst)
StopIteration

迭代器非常简单,唯一的机制就是取得下一个元素: next。迭代器无法按索引访问,也不能后退。某个元素一旦被产出,除非事先保存,否则无法再次取得。

注意,迭代器自身也是可迭代对象;对迭代器调用 iter 只会返回同一个迭代器对象。

例如,可以看看对列表使用 iternext 时会发生什么:

>>> lst = [1, 2, 3]
>>> next(lst)             # Calling next on an iterable
TypeError: 'list' object is not an iterator
>>> list_iter = iter(lst) # Creates an iterator for the list
>>> next(list_iter)       # Calling next on an iterator
1
>>> next(iter(list_iter)) # Calling iter on an iterator returns itself
2
>>> for e in list_iter:   # Exhausts remainder of list_iter
...     print(e)
3
>>> next(list_iter)       # No elements left!
StopIteration
>>> lst                   # Original iterable is unaffected
[1, 2, 3]

其中 mapfilter 等此前学过的函数会返回迭代器对象。

树的复习:

一个 tree 是一种表示信息层次的数据结构。文件系统就是树结构的典型例子。例如,在你的 cs61a 文件夹中,会用不同文件夹区分 projects, lab 作业和 homework。下一层文件夹区分各次任务,例如 hw01, lab01, hog等;其内部则是起始文件和 ok等具体文件。下面是不完整的目录示意图,展示你的 cs61a 目录可能是什么样子。

cs61a_tree

可以看到,与自然界的树不同,树抽象数据类型通常把根画在顶部,把叶节点画在底部。

对于树 t:

  • 根标签可以是任意值,表达式 label(t) 会返回它。
  • 它的分支也都是树, branches(t) 会返回分支列表。
  • 可以用下面的表达式构造一棵完全相同的树: tree(label(t), branches(t)).
  • 可以调用接收树作为参数的函数,例如 is_leaf(t).
  • 这就是操作树的方式,不需要 t == xt[0]x in tlist(t), etc.
  • 在不破坏抽象屏障的前提下,没有办法改变一棵树。

下面是一棵示例树 t1,其中分支 branches(t1)[1] 的返回值是 t2.

t2 = tree(5, [tree(6), tree(7)])
t1 = tree(3, [tree(4), t2])

Example Tree

一个 path 是一串树,每棵树都是下一棵的父节点。

可变性

问题 1:拾取物品

实现函数 inventory_pickup ,它接收列表 inventory、列表 items和参数 capacity。函数应当就地改变列表 inventory ,并返回改变后的同一个列表。库存容量为 inventorycapacity 中的所有物品处理完后,如果库存元素数超过 items ,就从 capacity 前端开始移除元素(最旧的优先), inventory 直到库存恰好剩下 capacity 个元素,为新拾取的物品腾出空间。 items 必须改变原库存;不能每次替换或复制列表。如果还有其他变量引用该库存,它也必须反映这些变化。

Timing: 先处理所有物品(移除重复项并追加),只有全部添加后才检查容量并裁剪。裁剪时可能需要移除多个元素。

对于其中的每件物品: items:

  • 移除库存中该物品现有的全部副本(可能有 0、1 或多个); inventory
  • 把该物品的一份副本追加到库存末尾。 inventory

Important: 改变原始库存并返回它,不要返回库存的副本。

items 可能与 inventory是同一个列表对象。请思考:如果你正在遍历 items while inventory ,同时底层又改变了同一个列表,会发生什么?遍历列表时移除多个匹配元素又会怎样?

def inventory_pickup(inventory: list, items: list, capacity: int) -> list:
    """Simulate picking up every item in items and add them, one at a time, to the inventory IN PLACE.
    The function should return the mutated inventory.

    >>> inv = [1, 2, 1, 3, 1]
    >>> inv_test = inventory_pickup(inv, [1, 4], 10)
    >>> inv_test
    [2, 3, 1, 4]

    >>> inv2 = [11, 12, 13]
    >>> inv2_test = inventory_pickup(inv2, inv2, 7)
    >>> inv2_test
    [11, 12, 13]

    >>> inv3 = [1, 2, 1, 3, 1]
    >>> check_mutation = inv3
    >>> inv3_test = inventory_pickup(inv3, inv3, 3)
    >>> inv3_test
    [2, 3, 1]
    >>> check_mutation is inv3_test
    True

    >>> inv4 = [1, 2, 3, 4]
    >>> inv4_test = inventory_pickup(inv4, [5, 6, 7, 8, 9, 10], 10)
    >>> inv4_test
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

    >>> inv5 = [1, 2, 3, 4]
    >>> inv5_test = inventory_pickup(inv5, [5, 6, 7, 8], 6)
    >>> inv5_test
    [3, 4, 5, 6, 7, 8]

    >>> inv6 = ['hello', 'world']
    >>> inv6_test = inventory_pickup(inv6, ['hi', 'hello'], 4)
    >>> inv6_test
    ['world', 'hi', 'hello']
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q inventory_pickup

Trees

问题 2:寻找浆果!

校园里的松鼠需要你的帮助!校园中有很多树,它们想知道哪些树上有浆果。定义函数 berry_finder,它接收一棵树;如果树中有标签值为 True 的节点,则返回 'berry'False ;否则返回另一个布尔值。

提示:要遍历某棵树的各个分支,可以考虑使用 for 循环逐个取得分支。

def berry_finder(t):
    """Returns True if t contains a node with the value 'berry' and 
    False otherwise.

    >>> scrat = tree('berry')
    >>> berry_finder(scrat)
    True
    >>> sproul = tree('roots', [tree('branch1', [tree('leaf'), tree('berry')]), tree('branch2')])
    >>> berry_finder(sproul)
    True
    >>> numbers = tree(1, [tree(2), tree(3, [tree(4), tree(5)]), tree(6, [tree(7)])])
    >>> berry_finder(numbers)
    False
    >>> t = tree(1, [tree('berry',[tree('not berry')])])
    >>> berry_finder(t)
    True
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q berry_finder

问题 3:大小

定义函数 size_of_tree,它接收一棵树 t 并返回其中节点的数量。

def size_of_tree(t):
    """Return the number of entries in the tree.
    >>> numbers = tree(1, [tree(2), tree(3, [tree(4), tree(5)]), tree(6, [tree(7)])])
    >>> print_tree(numbers)
    1
      2
      3
        4
        5
      6
        7
    >>> size_of_tree(numbers)
    7
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q size_of_tree

问题 4:创建路径

实现 make_path,它接收树 t (标签互不相同)以及列表 p ;该列表以树的根标签开头 t。函数返回树 u ;它在满足下列条件的树中节点数最少: has_path(u, p) 返回 True has_path(u, q) 返回 True 对于所有列表 q ,只要 has_path(t, q) 返回 True。(也就是说, u 保留 t 的全部路径,并额外包含标签序列为给定列表的路径。) p.)

返回树中每个不对应原树节点的新节点 u t 都应放在父节点分支列表的最后。可以假设 t 中的标签互不相同。如果 has_path(t, p), then make_path(t, p) 应返回一棵结构和标签都与原树相同的树。 t.

def make_path(t, p):
    """Return a tree with all of the nodes of t and a path with labels p.

    >>> t2 = tree(5, [tree(6), tree(7)])
    >>> t1 = tree(3, [tree(4), t2])
    >>> make_path(t1, [3, 5, 7]) == t1
    True
    >>> print_tree(make_path(t1, [3, 8, 9, 1]))
    3
      4
      5
        6
        7
      8
        9
          1
    >>> print_tree(make_path(t1, [3, 4, 8, 9]))
    3
      4
        8
          9
      5
        6
        7
    >>> print_tree(make_path(tree(2, [tree(1), t1]), [2, 3, 5, 6, 8]))
    2
      1
      3
        4
        5
          6
            8
          7
    """
    assert p[0] == label(t), 'It is not possible to make this path'
    if len(p) == 1:
        return ____
    new_branches = []
    found_p1 = False
    for b in branches(t):
        if ____:
            "*** YOUR CODE HERE ***"
        else:
            new_branches.append(b)
    if not found_p1:
        new_branches.append(make_path(____, ____))
    return tree(____, new_branches)

由于已有 assert,基本情况条件 len(p) == 1 足以确认 p == [label(t)]。此时无需添加节点,就已经存在所需标签路径。 p is in t.

为了尽量少添加节点,先寻找能够扩展的现有分支。名称 found_p1 用于决定是否需要向树添加新分支 new_branches ;新分支在原树中没有对应项。 branches(t)找到标签匹配的分支时,把它更新为 Truelabel(b) == p[1] 可确保路径只添加到该分支。

需要进行哪些递归调用?

make_path(b, p[1:]) 只能在标签为指定值的 b 上调用 p[1]。要么找到这样的分支,要么创建一个。 tree(p[1]).

递归调用返回哪种值?

递归调用返回一棵包含所需标签路径的树。 p[1:].

可能的返回值分别表示什么?

该返回值会成为最终返回树的一个分支。 make_path(t, p).

怎样利用这些返回值完成实现?

把返回值放入分支列表。 new_branches.

既然已经断言 p[0] == label(t), if p 长度为 1,那么 t 已经包含所需标签路径, p因此直接返回即可。 t.

If p[1] 如果下一个标签属于某个现有分支,就应沿该分支创建路径;否则必须创建新分支。

遍历各分支,寻找标签匹配的分支,然后把对该分支递归调用的结果追加到 p[1]new_branches.

如果没有分支的标签匹配, p[1]就创建新分支,并使用 tree(p[1]), use make_path 构造其余路径,再把结果追加到分支列表。 new_branches.

使用 Ok 测试你的代码:

python3 ok -q make_path

迭代器 / 生成器

问题 5:合并

实现 merge(incr_a, incr_b),它接收两个元素有序的可迭代对象 incr_aincr_bmerge 按排序顺序产出两个输入中的元素 incr_aincr_b ,并去除重复。可以假设 incr_aincr_b 自身不含重复元素,并且任一输入的元素都不是 None。你可以 not 假设这些可迭代对象是有限的;但任一输入也可能产生无限结果流。

内置函数的双参数形式可能很有用: next 逐项访问: next(incr, v)next(incr)相同,但当 StopIteration when incr 耗尽时不会抛出异常,而是返回 v.

具体行为示例请参阅 doctest。

def merge(incr_a, incr_b):
    """Yield the elements of strictly increasing iterables incr_a and incr_b, removing
    repeats. Assume that incr_a and incr_b have no repeats. incr_a or incr_b may or may not
    be infinite sequences.

    >>> m = merge([0, 2, 4, 6, 8, 10, 12, 14], [0, 3, 6, 9, 12, 15])
    >>> type(m)
    <class 'generator'>
    >>> list(m)
    [0, 2, 3, 4, 6, 8, 9, 10, 12, 14, 15]
    >>> def big(n):
    ...    k = 0
    ...    while True: yield k; k += n
    >>> m = merge(big(2), big(3))
    >>> [next(m) for _ in range(11)]
    [0, 2, 3, 4, 6, 8, 9, 10, 12, 14, 15]
    """
    iter_a, iter_b = iter(incr_a), iter(incr_b)
    next_a, next_b = next(iter_a, None), next(iter_b, None)
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q merge

问题 6:产出路径

编写生成器函数 yield_paths ,它接收树 t 和目标标签 target。它产出从树根到任意标签为目标值的节点的每条路径。 t target.

每条路径以标签列表的形式返回,从根一直排列到匹配节点;路径可以按任意顺序产出。

提示: 如果不知如何开始,可以先想想:若它不是生成器函数,你会怎样解决?递归步骤会是什么样?

提示: 请记住,生成器对象也是迭代器,因此可以遍历!

def yield_paths(t, target):
    """
    Yields all possible paths from the root of t to a node with the label
    target as a list.

    >>> t1 = tree(1, [tree(2, [tree(3), tree(4, [tree(6)]), tree(5)]), tree(5)])
    >>> print_tree(t1)
    1
      2
        3
        4
          6
        5
      5
    >>> next(yield_paths(t1, 6))
    [1, 2, 4, 6]
    >>> path_to_5 = yield_paths(t1, 5)
    >>> sorted(list(path_to_5))
    [[1, 2, 5], [1, 5]]

    >>> t2 = tree(0, [tree(2, [t1])])
    >>> print_tree(t2)
    0
      2
        1
          2
            3
            4
              6
            5
          5
    >>> path_to_2 = yield_paths(t2, 2)
    >>> sorted(list(path_to_2))
    [[0, 2], [0, 2, 1, 2]]
    """
    if label(t) == target:
        yield ____
    for b in branches(t):
        for ____ in ____:
            yield ____

使用 Ok 测试你的代码:

python3 ok -q yield_paths

Survey

这份问卷计入课程 2 个问卷分中的 1 分,不能延期。

问题 7:期中反馈

作为本次作业的一部分,请填写 期中反馈问卷 form.

完成问卷后会看到一个口令。请把它作为字符串填到 Python 文件中标有相应提示的那一行。 passphrase = 'REPLACE_THIS_WITH_PASSPHRASE' 例如,如果口令是 abc,该行应写为 passphrase = 'abc'.

使用 Ok 测试你的代码:

python3 ok -q midsem_survey

在本地查看你的分数

你可以通过运行以下命令在本地查看你在本次作业中每个题目的得分

python3 ok --score

这不会提交作业! 确认得分符合预期后,请将作业提交到 Gradescope,以获得作业学分。

提交作业

请上传所有你修改过的文件,将本次作业提交到 对应的 Gradescope 作业入口。 实验 00 中提供了详细说明。

考试练习

家庭作业还会附上往年考试难度的题目供你练习。这些题目无需提交;如果想挑战自己,可以自由尝试!

  1. 2019 夏季期中考试问题 3: 节礼日
  2. 2021 秋季期中考试 2 问题 3bc: 尚气
  3. 2023 秋季期末考试问题 2: 路径运算