实验 4:序列、树递归与树

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

起始文件

下载 lab04.zip.

出勤要求

要获得实验课学分,除了到场参加实验课外,你还需要提交实验题目。

如果你因合理原因(例如生病或时间冲突)缺席实验课,或者由于某种原因未能完成签到,请在一周内发送邮件至 cs61a@berkeley.edu ,以补记出勤学分。

序列

序列是有顺序的值集合,支持选取元素,并且具有长度。我们已经使用过列表;包括字符串在内的其他 Python 类型也属于序列。

下面通过一个例子看看可以对字符串进行哪些操作。

>>> x = 'Hello there Oski!'
>>> x
'Hello there Oski!'
>>> len(x)
17
>>> x[6:]
'there Oski!'
>>> x[::-1]
'!iksO ereht olleH'

字符串既然是序列,就能进行许多与列表相同的操作。我们甚至可以像遍历列表一样遍历字符串:

>>> x = 'I am not Oski.'
>>> vowel_count = 0
>>> for i in range(len(x)):
...     if x[i] in 'aeiou':
...         vowel_count += 1
>>> vowel_count
5

for 语句会对列表、range 等序列中的每个元素执行一次代码。每次执行时,紧跟在 for 后面的名称都会绑定到序列中的一个不同元素。

for <name> in <expression>:
    <suite>

首先,对 <expression> 求值,其结果必须是一个序列。随后按顺序遍历序列中的每个元素,并把

  1. <name> 绑定到当前元素。
  2. <suite> 会被执行。

下面是一个例子:

for x in [-1, 4, 2, 0, 5]:
    print("Current elem:", x)

它会显示:

Current elem: -1
Current elem: 4
Current elem: 2
Current elem: 0
Current elem: 5

字典由键值对组成,可以使用方括号按键查找对应的值。字典中的每个键必须唯一。

>>> d = {2: 4, 'two': ['four'], (1, 1): 4}
>>> d[2]
4
>>> d['two']
['four']
>>> d[(1, 1)]
4

可以分别使用 .keys().values().items().

>>> for k in d.keys():
...     print(k)
...
2
two
(1, 1)
>>> for v in d.values():
...     print(v)
...
4
['four']
4
>>> for k, v in d.items():
...     print(k, v)
...
2 4
two ['four']
(1, 1) 4

默认情况下,遍历字典就是遍历它的键。

>>> for x in d:
...     print(x)
...
2
two
(1, 1)

可以使用 in检查字典中是否包含某个键:

>>> 'two' in d
True
>>> 4 in d
False

尝试访问字典中不存在的键会引发错误。可以使用 .get(<key>, <default>);它会返回字典中与 <key> 对应的值,如果该键不存在则返回 <default>.

>>> d[3]
KeyError: 3
>>> d.get(3, "fun")
"fun"
>>> d.get(2, "fun")
4

可以使用下面的方式修改字典内容: =检查字典中是否包含某个键:

>>> d
{2: 4, 'two': ['four'], (1, 1): 4}
>>> d[(1, 1)] = "61a"
>>> d[(1, 1)]
'61a'

字典推导式是一种求值得到新字典的表达式。

>>> {3*x: 3*x + 1 for x in range(2, 5)}
{6: 7, 9: 10, 12: 13}

树递归

树递归函数会在一次执行中多次调用自身,从而形成树状的调用过程。

例如,下面是 Virahanka-Fibonacci 数列: 0, 1, 1, 2, 3, 5, 8, 13, ....

每一项都是前两项之和。下面这个树递归函数用于计算第 n个 Virahanka-Fibonacci 数。

def virfib(n):
    if n == 0 or n == 1:
        return n
    return virfib(n - 1) + virfib(n - 2)

调用 virfib(6) 会产生一个形似倒置树的调用结构(其中 fvirfib):

Virahanka-Fibonacci tree.

每次递归调用 f(i) 都会调用 f(i-1) 以及 f(i-2)。每当遇到 f(0)f(1) 调用时,都可以直接返回 01 ,无须继续递归。这些调用就是基本情况。

基本情况不依赖其他调用的结果就能直接返回答案。到达基本情况后,我们便可以逐层返回,求出此前引向它的递归调用。

之后会看到,树递归通常很适合处理存在多个分支选择的问题:每个选择对应一次递归调用。

数据抽象

数据抽象 是一组用于组合和拆分复合值的函数。其中,称为 构造器 的函数把两个或更多部分组合成一个整体(例如有理数,也就是分数);而称为 选择器 的函数则返回整体中的某个部分(例如分子或分母)。

def rational(n, d):
    "Return a fraction n / d for integers n and d."

def numer(r):
    "Return the numerator of rational number r."

def denom(r):
    "Return the denominator of rational number r."

关键在于,即使不知道这些函数如何实现,也可以使用数据抽象。例如,只要知道 mul_rationals 的行为,就能判断 rationalnumerdenom 是否实现正确,而不需要了解这些函数的内部实现。

def mul_rationals(r1, r2):
    "Return the rational number r1 * r2."
    return rational(numer(r1) * numer(r2), denom(r1) * denom(r2))

不过,要让 Python 真正运行程序,数据抽象仍然需要具体实现。使用实现细节就越过了抽象屏障;这道屏障把依赖数据抽象实现的程序部分与不依赖实现的部分分隔开来。良好的程序会尽量减少依赖具体实现的代码,使以后更换实现时无须重写大量代码。

使用已经提供的数据抽象时,应让程序即使在数据抽象的具体实现发生变化后仍然正确。

tree 是一种表示信息层级关系的数据结构。文件系统就是树结构的典型例子。例如,在你的 cs61a 文件夹中,会有分别存放 projectslab 作业以及 homework的文件夹。下一层又按不同作业分为 hw01lab01hog等文件夹,里面才是起始文件和 ok等具体文件。下面是不完整的 cs61a 目录结构示意图。

cs61a_tree

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

对于树 t检查字典中是否包含某个键:

  • ,根节点的标签可以是任意值,使用 label(t) 可以返回这个标签。
  • 它的分支本身也是树,使用 branches(t) 可以返回分支列表。
  • 可以用以下代码构造一棵完全相同的树: tree(label(t), branches(t)).
  • 可以调用接收树作为参数的函数,例如 is_leaf(t).
  • 这就是操作树的方式,不要使用 t == xt[0]x in tlist(t)等实现细节。
  • 在不违反抽象屏障的前提下,不能直接修改一棵树。

下面是一棵示例树 t1,它的分支 branches(t1)[1]t2.

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

Example Tree

路径 是一个树序列,其中每棵树都是下一棵树的父节点。

我们的 tree 抽象数据类型由一个根节点及其 branches列表组成。创建树以及访问根值和分支时,应使用下面的构造器与选择器接口:

  • 构造器

    • tree(label, branches=[]):创建一个树对象,其根节点使用给定的 label 值,并具有给定的 branches列表。请注意,构造器的第二个参数 branches是可选的;如果要创建没有分支的树,可以省略这个参数。
  • 选择器

    • label(tree):返回 tree.
    • branches(tree):返回给定 tree 的分支列表(其中每个分支也都是树)。
  • 便捷函数

    • is_leaf(tree):如果 True ,则返回 treebranches 列表为空;否则返回 False

例如,由下面代码生成的树

number_tree = tree(1,
         [tree(2),
          tree(3,
               [tree(4),
                tree(5)]),
          tree(6,
               [tree(7)])])

如下所示:

   1
 / | \
2  3  6
  / \  \
 4   5  7

要从这棵树中取出数字 3 ,也就是第二个分支根节点的标签,可以这样做:

label(branches(number_tree)[1])

print_tree 函数会以便于阅读的形式打印树。格式与上图一致:根节点不缩进,每深入一层分支就多缩进一级。

def print_tree(t, indent=0):
    """Print a representation of this tree in which each node is
    indented by two spaces times its depth from the root.

    >>> print_tree(tree(1))
    1
    >>> print_tree(tree(1, [tree(2)]))
    1
      2
    >>> 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
    """
    print('  ' * indent + str(label(t)))
    for b in branches(t):
        print_tree(b, indent + 1)

必做题

序列

重要: 对于所有 WWPD 题,如果你认为答案是 Function ,请输入 <function...>Error ;如果会报错,输入 Nothing ;如果没有任何显示,则输入

许多编程语言都为序列提供 mapfilterreduce 函数。Python 也提供了这些函数(课程后面会正式介绍),但为了帮助你理解其工作方式,下面几道题将由你亲自实现它们。

在 Python 中,内置的 mapfilter 与这里要定义的 my_mapmy_filter 函数在行为上略有不同。

问题 1:Map

my_map 接收单参数函数 fn 以及序列 seq ,返回一个列表,其中包含把 fn 应用到 seq.

函数体只能使用一行代码。 (提示:使用列表推导式。)

def my_map(fn, seq):
    """Applies fn onto each element in seq and returns a list.
    >>> my_map(lambda x: x*x, [1, 2, 3])
    [1, 4, 9]
    >>> my_map(lambda x: abs(x), [1, -1, 5, 3, 0])
    [1, 1, 5, 3, 0]
    >>> my_map(lambda x: print(x), ['cs61a', 'summer', '2023'])
    cs61a
    summer
    2023
    [None, None, None]
    """
    return ______

使用 Ok 测试你的代码:

python3 ok -q my_map

使用 Ok 运行本地语法检查器,它会检查函数体是否只有一行:

python3 ok -q my_map_syntax_check

问题 2:Filter

my_filter 接收谓词函数 pred 以及序列 seq ,返回一个列表,其中包含 seq 中所有使 pred 返回 True的元素。(谓词函数接收一个参数,并返回 TrueFalse中的一个。)

函数体只能使用一行代码。 (提示:使用列表推导式。)

def my_filter(pred, seq):
    """Keeps elements in seq only if they satisfy pred.
    >>> my_filter(lambda x: x % 2 == 0, [1, 2, 3, 4])  # new list has only even-valued elements
    [2, 4]
    >>> my_filter(lambda x: (x + 5) % 3 == 0, [1, 2, 3, 4, 5])
    [1, 4]
    >>> my_filter(lambda x: print(x), [1, 2, 3, 4, 5])
    1
    2
    3
    4
    5
    []
    >>> my_filter(lambda x: max(5, x) == 5, [1, 2, 3, 4, 5, 6, 7])
    [1, 2, 3, 4, 5]
    """
    return ______

使用 Ok 测试你的代码:

python3 ok -q my_filter

使用 Ok 运行本地语法检查器,它会检查函数体是否只有一行:

python3 ok -q my_filter_syntax_check

问题 3:Reduce

my_reduce 接收双参数函数 combiner 以及非空序列 seq ,使用 seq 把其中的元素合并成一个值。 combiner.

def my_reduce(combiner, seq):
    """Combines elements in seq using combiner.
    seq will have at least one element.
    >>> my_reduce(lambda x, y: x + y, [1, 2, 3, 4])  # 1 + 2 + 3 + 4
    10
    >>> my_reduce(lambda x, y: x * y, [1, 2, 3, 4])  # 1 * 2 * 3 * 4
    24
    >>> my_reduce(lambda x, y: x * y, [4])
    4
    >>> my_reduce(lambda x, y: x + 2 * y, [1, 2, 3]) # (1 + 2 * 2) + 2 * 3
    11
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q my_reduce

数据抽象

城市

假设我们为城市定义了一种数据抽象。每座城市都有名称、纬度坐标和经度坐标。

这个数据抽象有一个 构造器检查字典中是否包含某个键:

  • make_city(name, lat, lon):使用给定的名称、纬度和经度创建城市对象。

还提供以下 选择器 来获取城市信息:

  • get_name(city):返回城市名称
  • get_lat(city):返回城市纬度
  • get_lon(city):返回城市经度

下面演示如何使用构造器和选择器创建城市并提取信息:

>>> berkeley = make_city('Berkeley', 122, 37)
>>> get_name(berkeley)
'Berkeley'
>>> get_lat(berkeley)
122
>>> new_york = make_city('New York City', 74, 40)
>>> get_lon(new_york)
40

如果好奇,可以在实验文件中查看所有构造器和选择器的实现。不过,数据抽象的意义就在于:编写处理城市的程序时,我们不需要了解这些实现。

问题 4:距离

现在实现函数 distance,用于计算两个城市对象之间的距离。回忆一下,两个坐标点 (x1, y1)(x2, y2) 之间的距离可以通过计算 sqrt 得到: (x1 - x2)**2 + (y1 - y2)**2。我们已经为你导入了 sqrt 。把城市的纬度和经度作为坐标,并使用选择器获取这些信息。

from math import sqrt
def distance(city_a, city_b):
    """
    Returns the distance between city_a and city_b according to their
    coordinates.

    >>> city_a = make_city('city_a', 0, 1)
    >>> city_b = make_city('city_b', 0, 2)
    >>> distance(city_a, city_b)
    1.0
    >>> city_c = make_city('city_c', 6.5, 12)
    >>> city_d = make_city('city_d', 2.5, 15)
    >>> distance(city_c, city_d)
    5.0
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q distance

问题 5:较近的城市

接下来实现 closer_city。它接收纬度、经度和两座城市,并返回较近城市的 名称

本题只能使用选择器 get_name get_lat get_lon、构造器 make_city以及刚刚定义的 distance 函数。

提示:怎样使用 distance 分别求出给定位置到两座城市的距离?

def closer_city(lat, lon, city_a, city_b):
    """
    Returns the name of either city_a or city_b, whichever is closest to
    coordinate (lat, lon). If the two cities are the same distance away
    from the coordinate, consider city_b to be the closer city.

    >>> berkeley = make_city('Berkeley', 37.87, 112.26)
    >>> stanford = make_city('Stanford', 34.05, 118.25)
    >>> closer_city(38.33, 121.44, berkeley, stanford)
    'Stanford'
    >>> bucharest = make_city('Bucharest', 44.43, 26.10)
    >>> vienna = make_city('Vienna', 48.20, 16.37)
    >>> closer_city(41.29, 174.78, bucharest, vienna)
    'Bucharest'
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q closer_city

问题 6:不要违反抽象屏障!

注意:如果前两题实现正确,本题不需要编写代码。

编写使用数据抽象的函数时,应尽可能通过构造器和选择器操作数据,而不是假设其内部实现。依赖数据抽象的底层实现称为 违反抽象屏障.

即使违反了抽象屏障,前几题的 doctest 也可能通过。请运行下面的命令进行检查:

使用 Ok 测试你的代码:

python3 ok -q check_city_abstraction

check_city_abstraction 函数只为 doctest 服务:它会临时替换原数据抽象的实现,运行前两部分测试,然后恢复原实现。

抽象屏障保证:只要正确使用构造器和选择器,更换数据抽象的实现就不应影响使用它的程序。

如果前几题的 Ok 测试通过而本题失败,修复很简单:把所有违反抽象屏障的代码替换为相应的构造器或选择器。

继续之前,请确保函数在数据抽象的两种实现下都能通过测试,并理解它为什么对两种实现都有效。

问题 7:WWPD——树

使用 Ok 完成下面的“Python 会显示什么?”(WWPD)题,检查你对知识的掌握情况:

python3 ok -q wwpd -u

请输入 Error (如果代码报错)或 Nothing (如果没有显示任何内容)。遇到困难时,可以在白板或纸上把树画出来。

>>> from lab04 import *
>>> t = tree(1, tree(2))
______
Error
>>> t = tree(1, [tree(2)])
______
Nothing
>>> label(t)
______
1
>>> label(branches(t)[0])
______
2
>>> x = branches(t) >>> len(x)
______
1
>>> is_leaf(x[0])
______
True
>>> branch = x[0] >>> label(t) + label(branch)
______
3
>>> len(branches(branch))
______
0
>>> from lab04 import *
>>> b1 = tree(5, [tree(6), tree(7)])
>>> b2 = tree(8, [tree(9, [tree(10)])])
>>> t = tree(11, [b1, b2])
>>> for b in branches(t):
...     print(label(b))
______
5 8
>>> for b in branches(t): ... print(is_leaf(branches(b)[0])) ...
______
True False
>>> [label(b) + 100 for b in branches(t)]
______
[105, 108]
>>> [label(b) * label(branches(b)[0]) for b in branches(t)]
______
[30, 72]

问题 8:完全平衡

请实现 sum_tree,返回树中所有标签之和 t.

def sum_tree(t):
    """Add all elements in a tree.

    >>> t = tree(4, [tree(2, [tree(3)]), tree(6)])
    >>> sum_tree(t)
    15
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q sum_tree

然后实现 balanced,判断 t 的每个分支是否具有相同的标签总和,并且这些分支本身也都平衡。

Example Tree

  • 例如,上面的树是平衡的,因为每个分支的标签总和相同,而且每个分支自身也平衡。
def balanced(t):
    """Checks if each branch has same sum of all elements and
    if each branch is balanced.

    >>> t = tree(1, [tree(3), tree(1, [tree(2)]), tree(1, [tree(1), tree(1)])])
    >>> balanced(t)
    True
    >>> t = tree(1, [t, tree(1)])
    >>> balanced(t)
    False
    >>> t = tree(1, [tree(4), tree(1, [tree(2), tree(1)]), tree(1, [tree(3)])])
    >>> balanced(t)
    False
    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q balanced

挑战: 这两个函数都分别只用一行代码完成。

在本地检查得分

你可以运行以下命令,在本地检查本次作业每道题的得分:

python3 ok --score

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

提交作业

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

正确完成所有题目可得 1 分。 Please ensure your TA has taken your attendance before leaving.

可选题

以下题目为选做题。即使不完成,你仍能获得本次作业的学分;不过它们很适合用来练习,建议还是做一做!

问题 9:树的数量

满二叉树 是每个节点要么有两个分支、要么没有分支,但绝不会只有一个分支的树。

请编写一个函数,返回恰好具有 n 个叶节点的不同满二叉树结构数量。doctest 展示了具有 1、2、3 个叶节点时可能的结构。

提示:可以把两棵较小的满二叉树连接到同一个根节点,构造一棵新的满二叉树。如果两棵小树分别有 ab 个叶节点,那么新树会有 a + b 个叶节点。例如,下方第一幅图把一棵有 3 个叶节点的满二叉树(黄色)与一棵有 1 个叶节点的满二叉树(橙色)连接起来,得到有 4 个叶节点的满二叉树。也可以连接两棵各有 2 个叶节点的满二叉树得到相同叶节点数的结构(第二幅图)。 4-leaf Full Binary Tree 1

4-leaf Full Binary Tree 2

如果你对组合数学感兴趣,这道题确实存在 闭式解):

def num_trees(n):
    """Returns the number of unique full binary trees with exactly n leaves. E.g.,

    1   2        3       3    ...
    *   *        *       *
       / \      / \     / \
      *   *    *   *   *   *
              / \         / \
             *   *       *   *

    >>> num_trees(1)
    1
    >>> num_trees(2)
    1
    >>> num_trees(3)
    2
    >>> num_trees(8)
    429

    """
    "*** YOUR CODE HERE ***"

使用 Ok 测试你的代码:

python3 ok -q num_trees

问题 10:仅保留指定路径

请实现 only_paths,它接收一棵标签为数字的树 t 以及数字 n。它返回一棵新树,只保留 t 中位于根到叶路径上、且路径标签之和为 n的节点;如果不存在这样的路径,则返回 Nonen.

下面的图示对应 doctest 中涉及 t.

only_paths

def only_paths(t, n):
    """Return a tree with only the nodes of t along paths from the root to a leaf of t 
    for which the node labels of the path sum to n. If no paths sum to n, return None.

    >>> print_tree(only_paths(tree(5, [tree(2), tree(1, [tree(2)]), tree(1, [tree(1)])]), 7))
    5
      2
      1
        1
    >>> t = tree(3, [tree(4), tree(1, [tree(3, [tree(2)]), tree(2, [tree(1)]), tree(5), tree(3)])])
    >>> print_tree(only_paths(t, 7))
    3
      4
      1
        2
          1
        3
    >>> print_tree(only_paths(t, 9))
    3
      1
        3
          2
        5
    >>> print(only_paths(t, 3))
    None
    """
    if ____:
        return t
    new_branches = [____ for b in branches(t)]
    if ____(new_branches):
        return tree(label(t), [b for b in new_branches if ____])

使用 Ok 测试你的代码:

python3 ok -q only_paths