CS 61A  /  作业解析
LAB 08

Lab 8:链表(Linked Lists)ok 7 项通过

用一个只有 first 和 rest 两个属性的类,把「序列」这个概念从零重建一遍:递归地构造、递归地拆解、就地修改,以及当 rest 指回自己时会发生什么。

对应讲次:Lecture 16 链表、Lecture 17 效率 官方题面:cs61a.org/lab/lab08 代码:labs/lab08/lab08.py

0. 这份作业在练什么

前面几周你一直在用 Python 内置的 list。它很好用,但它是一个「黑盒」:你不知道 lst[3] 为什么那么快,也不知道 lst.insert(0, x) 为什么那么慢。这次作业要做的事情是——把序列这个抽象自己造一遍。造出来的东西叫链表(linked list),在这门课里由 Link 类实现。

链表的定义只有一句话,但这句话是递归的,请把它背下来:

链表的递归定义

一个链表要么是 Link.empty(空链表),要么是一个 Link 实例,它有两个实例属性:

  • first:这个链表的第一个元素(可以是任何值);
  • rest:剩下的部分,而剩下的部分本身又是一个链表——要么是另一个 Link 实例,要么是 Link.empty。

rest 永远不应该是 None,也不应该是一个普通的数或字符串。

这个定义为什么重要?因为它决定了这次五道题的全部解法形状。当一个数据结构的定义是「一个 X 由一个头部 + 一个更小的 X 组成」时,处理它的函数几乎必然长成这样:

def f(s):
    if s is Link.empty:
        # base case:最小的那个 X,直接给答案
        ...
    else:
        # 用 s.first 做点事,然后把 f 递归地用在 s.rest 上
        ... s.first ... f(s.rest) ...

这就是 CS 61A 反复强调的那句话:数据的形状决定代码的形状。你在做「树」那一周会再见到它,在 Scheme 那几周会天天见到它。

先把 Link 类读懂

这是 labs/lab08/lab08.py 里真实的 Link 类实现(作业不要求你写它,但每道题都建立在它之上):

class Link:
    """A linked list.

    >>> s = Link(1)
    >>> s.first
    1
    >>> s.rest is Link.empty
    True
    >>> s = Link(2, Link(3, Link(4)))
    >>> s.first = 5
    >>> s.rest.first = 6
    >>> s.rest.rest = Link.empty
    >>> s                                    # Displays the contents of repr(s)
    Link(5, Link(6))
    >>> s.rest = Link(7, Link(Link(8, Link(9))))
    >>> s
    Link(5, Link(7, Link(Link(8, Link(9)))))
    >>> print(s)                             # Prints str(s)
    (5 7 (8 9))
    """
    empty = ()

    def __init__(self, first, rest=empty):
        assert rest is Link.empty or isinstance(rest, Link)
        self.first = first
        self.rest = rest

    def __repr__(self):
        if self.rest is not Link.empty:
            rest_repr = ', ' + repr(self.rest)
        else:
            rest_repr = ''
        return 'Link(' + repr(self.first) + rest_repr + ')'

    def __str__(self):
        string = '('
        while self.rest is not Link.empty:
            string += str(self.first) + ' '
            self = self.rest
        return string + str(self.first) + ')'

逐条拆开看,这里面有四个设计决定,每一个都会在后面的题目里咬你一口:

1 empty = () 是类属性(class attribute),不是实例属性。也就是说全世界只有一个空链表对象,它就是那个空元组。因为只有一个,所以判断「是不是空」可以也应该用 is:s is Link.empty。用 == () 也能得到 True,但那样你就依赖了「empty 恰好是元组」这个实现细节,破坏了抽象屏障(abstraction barrier)。
2 def __init__(self, first, rest=empty) 中的默认参数 rest=empty。注意这里写的是裸的 empty 而不是 Link.empty——因为这行代码是在类体内部执行的,此刻 Link 这个名字还没绑定到全局,但 empty 已经在类体的局部作用域里了。这个默认值让 Link(4) 直接构造出长度为 1 的链表。
3 assert rest is Link.empty or isinstance(rest, Link)。这是一道主动设置的护栏:它保证任何一个 Link 实例的 rest 一定是合法链表。你写 Link(1000, 2000) 会当场 AssertionError,而不是等到某个函数里 2000.rest 才报 AttributeError。让错误尽早暴露是好的库设计。
4 __repr__ 递归、__str__ 迭代。repr 给出的是「怎么把它造出来」的表达式 Link(5, Link(6));str 给出的是 Scheme 风格的紧凑写法 (5 6)。交互式解释器直接回显一个值时用 repr,print() 用 str。这个区别在下面 Q1 里会被直接考。
直觉:为什么值得造这么个东西

Python 的 list 是一整块连续内存,所以 lst[i] 是 $\Theta(1)$,但在开头插入一个元素要把后面所有元素往后挪,是 $\Theta(n)$。链表反过来:每个节点独立分配、靠 rest 串起来,所以在已知位置插入一个新节点只需要改两个指针,是 $\Theta(1)$;但要取第 i 个元素必须从头走 i 步,是 $\Theta(i)$。没有免费的午餐,只有取舍。

操作Python listLink 链表
取第 i 个元素$\Theta(1)$$\Theta(i)$,得一路 .rest 走过去
在开头插入$\Theta(n)$,后面全要挪$\Theta(1)$,Link(x, s) 就完了
在已到达的节点后插入$\Theta(n)$$\Theta(1)$,改 rest
求长度$\Theta(1)$(长度被存着)$\Theta(n)$,只能数
共享尾部(结构共享)做不到,切片必复制天然支持,多个链表可共用同一段 rest
本次要点
  • Q1 WWPD:确认你真的懂 first / rest、is Link.empty、构造器里的 assert、以及 repr 与 str 的差别。
  • Q2 without:递归构造一个新链表,同时不动原表——练「什么时候必须复制、什么时候可以共享」。
  • Q3 duplicate_link:迭代就地修改链表——练指针改写,以及怎样避免把自己刚插进去的副本又复制一遍导致死循环。
  • Q4 slice_link:带两个「窗口」参数的递归——练如何用参数的变化表示「我走到哪了」。
  • Q5 has_cycle:环检测。先用 $\Theta(n)$ 空间的朴素法,再用 Floyd 快慢指针做到 $\Theta(1)$ 空间。这道题会逼你彻底分清 is(同一个对象)和 ==(值相等)。

做之前该掌握什么

类与实例属性(Lecture 12–13 的对象系统)、__init__ / __repr__ / __str__ 这三个特殊方法的触发时机、可变对象与别名(aliasing)、以及递归的基本套路(base case + 递归调用 + 用返回值拼答案)。如果「递归调用返回的东西要拿来干嘛」这一步还不熟,Q2 会很难受——那道题的关键正是「递归调用替我把后面全做完了,我只负责我这一个节点」。

本地评分结果

在本仓库的 labs/lab08/ 目录下运行 python3 ok --local,结果是 7 个测试用例全部通过,也就是 Q1 的三组 WWPD 加上 Q2–Q5(含选做的 has_cycle_constant)的全部函数测试。下面贴出的每一段作业代码都与该目录下的真实文件逐字一致。

1. WWPD:Linked Lists

题目要什么

这是一组「What Would Python Display?」(Python 会显示什么)概念题,通过 python3 ok -q link -u 交互作答。它不写代码,只考你对 Link 类语义的理解。规则:如果答案是 <function ...> 就填 Function;如果会报错就填 Error;如果什么都不显示就填 Nothing。

题目分三组,对应 labs/lab08/tests/link.py 里的三个 case。下面逐条讲为什么是那个输出。

第一组:构造与护栏

>>> link = Link(1000)
>>> link.first
1000
>>> link.rest is Link.empty
True
>>> link = Link(1000, 2000)
Error
>>> link = Link(1000, Link())
Error
1 link = Link(1000):调用 Link.__init__(self, 1000),rest 走默认值 Link.empty。assert 检查 rest is Link.empty —— 成立,通过。于是 self.first = 1000,self.rest = ()。赋值语句本身不显示任何东西。
2 link.first 求值得到 1000,交互式解释器回显 repr(1000),即 1000。
3 link.rest is Link.empty:link.rest 就是构造时存进去的那个 Link.empty 对象本身,is 比较的是身份(identity)——是不是同一个对象。是,所以 True。
4 Link(1000, 2000):assert 2000 is Link.empty or isinstance(2000, Link)。2000 既不是那个空元组,也不是 Link 实例,断言为假,抛出 AssertionError。ok 里填 Error。
5 Link(1000, Link()):这次错在内层。Link() 一个参数都不给,但 __init__ 的 first 是必填的位置参数(只有 rest 有默认值),于是 Python 在真正进入函数体之前就抛 TypeError: __init__() missing 1 required positional argument: 'first'。注意求值顺序:算子数(operand)先于外层调用求值,所以外层的 Link(1000, ...) 根本没机会执行。ok 里同样填 Error。
注意:ok 只区分 Error / 不 Error

官方题面在这两行分别标了 AssertionError 和 TypeError,但 tests/link.py 里期望的答案都是 Error。你在 ok 里填 Error 即可;不过能说出是哪一种错误、错在哪一步,才说明你真的读懂了 __init__。

第二组:可变性、别名与环

>>> link = Link(1, Link(2, Link(3)))
>>> link.first
1
>>> link.rest.first
2
>>> link.rest.rest.rest is Link.empty
True
>>> link.first = 9001
>>> link.first
9001
>>> link.rest = link.rest.rest
>>> link.rest.first
3
>>> link = Link(1)
>>> link.rest = link
>>> link.rest.rest is Link.empty
False
>>> link.rest.rest.rest.rest.first
1
>>> link = Link(2, Link(3, Link(4)))
>>> link2 = Link(1, link)
>>> link2.first
1
>>> link2.rest.first
2

把 Link(1, Link(2, Link(3))) 画成盒子指针图(box-and-pointer diagram):

link ──▶ ┌───────┬───────┐   ┌───────┬───────┐   ┌───────┬───────┐
         │ first │ rest  │──▶│ first │ rest  │──▶│ first │ rest  │──▶ Link.empty
         │   1   │       │   │   2   │       │   │   3   │       │
         └───────┴───────┘   └───────┴───────┘   └───────┴───────┘
             节点 A              节点 B              节点 C
1 link.first → 节点 A 的 first → 1。link.rest.first → 先取 A 的 rest(节点 B),再取 B 的 first → 2。属性访问从左到右一步步走,没有魔法。
2 link.rest.rest.rest → A→B→C→C 的 rest,也就是 Link.empty。is Link.empty 得 True。这就是「链表末端」的标准判定方式。
3 link.first = 9001:这是属性赋值,它修改的是节点 A 这个对象内部的 first 槽位。赋值语句不显示东西。随后 link.first 显示 9001。链表是可变的,这是它和 Scheme 里不可变 pair 的重要区别,也是 Q3 的全部基础。
4 link.rest = link.rest.rest:右边先算——link.rest 是 B,B.rest 是 C,所以右边求值为节点 C。然后把 A 的 rest 改成指向 C。图变成 A(9001) → C(3) → empty,节点 B 被绕过了(没人再指向它,会被垃圾回收)。所以 link.rest.first 是 3。

接下来这三行是整组题最值钱的部分:

>>> link = Link(1)
>>> link.rest = link

link.rest = link 让节点自己指向自己。注意 assert 拦不住这个:断言只在 __init__ 里跑一次,而这是事后的属性赋值;而且 link 确实是一个合法的 Link 实例,即使它拦了也拦不下来。

          ┌─────────────┐
          │             │
          ▼             │
link ──▶ ┌───────┬──────┴┐
         │ first │ rest  │   rest 指回自己
         │   1   │   ●───┼───┐
         └───────┴───────┘   │
                 ▲───────────┘
5 link.rest.rest is Link.empty:link.rest 是它自己,link.rest.rest 还是它自己,是一个 Link 实例,不是空元组。所以 False。
6 link.rest.rest.rest.rest.first:无论你写多少个 .rest,都还在同一个节点上打转,最后 .first 取到 1。这就是「环形链表」。它无限长——如果你对它调用 print(),__str__ 里的 while self.rest is not Link.empty 永远不会停,程序会挂住直到内存耗尽。Q5 就是专门来检测这种情况的。
7 最后一段:link2 = Link(1, link) 并没有复制 link,只是让新节点的 rest 指向已存在的 link。所以 link2.first 是 1,link2.rest.first 是 link.first 即 2。这种「新表和旧表共享同一条尾巴」的现象叫结构共享,Q2 会主动利用它。

第三组:repr 与 str

>>> link = Link(5, Link(6, Link(7)))
>>> link                  # Look at the __repr__ method of Link
Link(5, Link(6, Link(7)))
>>> print(link)           # Look at the __str__ method of Link
(5 6 7)

在交互式解释器里单独求值一个表达式,显示的是 repr(值);而 print(x) 显示的是 str(x)。两个方法的实现风格恰好相反,值得对照:

__repr____str__
触发时机解释器回显、放进容器里显示print()、str()、f-string
实现方式递归:repr(self.rest)迭代:while 循环挪 self
输出风格能重新求值造出这个对象Scheme 风格 (5 6 7),给人看
对空链表不会被调用(Link.empty 是元组,显示 ())同左

推演 repr(Link(5, Link(6, Link(7)))):最内层 Link(7) 的 rest is Link.empty,所以 rest_repr = '',返回 'Link(7)';中层拿到它,返回 'Link(' + '6' + ', Link(7)' + ')' 即 'Link(6, Link(7))';最外层同理得到 'Link(5, Link(6, Link(7)))'。

推演 str:循环条件是 self.rest is not Link.empty,所以循环体只处理「不是最后一个」的节点,把 '5 '、'6 ' 依次拼进去,然后 self 挪到下一个;当 self 是 Link(7) 时循环退出,最后 return string + str(self.first) + ')' 补上 '7)'。这样最后一个元素后面就不会多一个空格,得到 (5 6 7)。

常见误区

误区一:以为 Link(1000, 2000) 会静默接受。 很多人第一反应是「rest 反正就是存个值,存 2000 有什么问题」。问题在于链表的不变量(invariant)被破坏了:一旦 rest 不是链表,所有递归函数在 s.rest.first 处都会炸成 AttributeError: 'int' object has no attribute 'first',而且报错位置离真正的错误现场很远。assert 的价值就是把爆炸点搬到犯错的那一行。

误区二:把 link.rest = link.rest.rest 读成「删掉第二个元素的值」。 它删的不是值,是指针。节点 B 这个对象还好端端地存在于内存里,只是没人指着它了。区分「对象」和「指向对象的名字/属性」是这门课的核心功。

误区三:用 == 判断空。 s == Link.empty 在这份实现下确实能工作(因为 empty 是元组,而 Link 没定义 __eq__),但它依赖了实现细节。官方 checkoff 问题明确说了:应该用 s is Link.empty,因为 Link.empty 是唯一的类属性,is 是最可靠的判定。

验证

运行 python3 ok -q link -u 逐条作答,三个 case 全部通过;答案已在 tests/link.py 中以明文形式解锁('locked': False),与上面推演的结果一致。

2. Without One without

题目要什么

写一个函数 without(s, i),接收一个链表 s 和一个非负整数 i,返回一个新的链表,内容和 s 一样,但少了下标为 i 的那个元素(s.first 算下标 0)。硬性要求:原链表 s 不能被改动。

>>> s = Link(3, Link(5, Link(7, Link(9))))
>>> without(s, 0)
Link(5, Link(7, Link(9)))
>>> without(s, 2)
Link(3, Link(5, Link(9)))
>>> without(s, 4)  # There is no index 4, so all of s is retained.
Link(3, Link(5, Link(7, Link(9))))

把 docstring 翻译成人话,并把边界情况点清楚:

情况该返回什么为什么
i == 0去掉头,返回后面全部要删的就是当前这个头
i 大于等于长度原样返回全部元素第三个 doctest 明确规定:没有这个下标就什么都不删
s 是 Link.emptyLink.empty空表里没有任何元素可删,也没什么可保留
调用后再看 s必须和调用前一模一样题目明说 “The original linked list s should not be changed”

特别注意第三个 doctest 的存在感:without(s, 4) 在一个长度为 4 的表上被调用,下标 4 越界。它没有报错、没有返回 None,而是把整个表原样交回来。这条 doctest 其实是在告诉你 base case 该怎么写——很多题面里那种「看似多余的例子」,实际上是在替你指定边界行为。

怎么想到的

先说一条走不通、或者说很痛苦的路,因为大多数人第一反应就是它。

弯路:迭代 + 手动接线。 思路是「用一个 while 循环走到第 i-1 个节点,然后把它的 rest 改成 rest.rest」。这个想法在「删除节点」这件事上是对的,但它修改了原表,直接违反题目要求。那就退一步:先把整个表复制一份,再在副本上做这件事。可是「复制一个链表」本身就得写一个循环,而且复制时你需要维护一个「尾指针」来往后接新节点,还要单独处理「新表还是空的、没有尾指针」这个第一步——代码会膨胀到十几行,边界情况一堆。题面给的 Hint 也直说了:“Using recursive approach might be easier than the iterative approach.”

转向:把问题写成它自己的小一号版本。 关键提问是:「without(s, i) 的答案,和 without(s.rest, i-1) 的答案之间是什么关系?」

盯着例子想。设 s = Link(3, Link(5, Link(7, Link(9)))),要算 without(s, 2):

  • 下标 2 不是 0,说明当前这个元素 3 要留下来。
  • 剩下要处理的是 Link(5, Link(7, Link(9))),而在这个更短的表里,原来的「下标 2」现在变成了「下标 1」。
  • 所以答案 = 3 接在 without(s.rest, 1) 的结果前面。

这就是递归关系。三种情况一一对应三个分支:

1 i == 0:当前元素正是要删的那个。删掉它以后,剩下的部分就是 s.rest——它已经完全正确了,直接返回,不需要任何加工。
2 s is Link.empty:走到头都没等到 i 归零,说明下标越界。返回 Link.empty。注意这个分支必须排在前面,否则 i > 0 分支里的 s.first 会在空表上抛 AttributeError。
3 其余(i > 0 且表非空):保留当前元素,把它装进一个全新的 Link,尾巴接上 without(s.rest, i - 1)。

这里有一个非常值得单独拎出来讲的细节,它是这道题真正的考点:

关键一步:哪些节点必须新建,哪些可以直接共享

i == 0 分支里直接 return s.rest——返回的是原表里那一段,不是副本。这不算「改动原表」吗?不算。「不改动」指的是不能修改任何已存在节点的 first 或 rest 属性。我们只是让新表和旧表共用一条尾巴,没有写入任何东西,两个表都还是完整正确的。

反过来,i > 0 分支里就必须新建:因为这个节点的 rest 在新表里要指向「删过元素的后半段」,而在原表里必须继续指向原来的后半段。同一个节点不可能同时有两个不同的 rest,所以只能造一个新的。

结论:只复制「rest 需要改变」的那部分前缀,改变点之后的整条尾巴原样共享。 这是函数式数据结构(persistent data structure)的标准手法,也是链表相对数组的一大优势——数组切片必须整块复制,链表可以共享。

代码

def without(s: Link, i: int) -> Link:
    """Return a new linked list like s but without the element at index i.

    >>> s = Link(3, Link(5, Link(7, Link(9))))
    >>> without(s, 0)
    Link(5, Link(7, Link(9)))
    >>> without(s, 2)
    Link(3, Link(5, Link(9)))
    >>> without(s, 4)  # There is no index 4, so all of s is retained.
    Link(3, Link(5, Link(7, Link(9))))
    """
    if s is Link.empty:
        # Ran off the end of s, so index i never existed. Nothing left to keep.
        return Link.empty
    elif i == 0:
        # Drop this element. Everything after it is already correct, so we can
        # reuse s.rest directly without copying it.
        return s.rest
    else:
        # Keep this element, but in a brand new Link so that s is not mutated.
        return Link(s.first, without(s.rest, i - 1))

逐行说明为什么是这样而不是别样:

1 if s is Link.empty: —— 用 is 不用 ==,理由见第 0 节。这个判断放在最前面是有讲究的:如果把 elif i == 0 提到前面,遇到 without(Link.empty, 0) 会返回 ().rest,元组没有 rest 属性,直接 AttributeError: 'tuple' object has no attribute 'rest'。永远先检查「还有没有东西」,再检查「这个东西怎么样」。
2 return Link.empty —— 返回的是那个唯一的空链表对象,而不是 None、不是 () 字面量、也不是 Link(None)。返回 None 会让上一层的 Link(s.first, None) 触发 assert 失败。
3 elif i == 0: return s.rest —— 一行完事。很多人在这里会忍不住写成 return without(s.rest, -1) 之类,想「统一处理」。不必要,而且 i 变负数后 i == 0 永远不成立,反而会把后面的元素全复制一遍(虽然结果碰巧还是对的,但白白多做了 $\Theta(n)$ 的复制)。当答案已经现成时,直接返回。
4 return Link(s.first, without(s.rest, i - 1)) —— 这一行同时做了三件事:(a) 用 Link(...) 新建节点,保证不碰原表;(b) s.first 原样搬过去,元素本身不复制(如果元素是可变对象,新旧表共享同一个元素对象,这是符合预期的浅拷贝语义);(c) i - 1 表示「往后走了一步,目标下标也要跟着往前挪一格」。这个「参数随位置同步变化」的技巧在 Q4 里会被用两次。
5 整个函数没有一处赋值语句,全是 return。这不是巧合——纯递归构造天然不需要中间变量,因为「递归调用的返回值」就充当了那个变量。如果你发现自己在递归函数里写了一堆临时变量和循环,通常说明还没找到正确的递归关系。

验证

手动追踪 without(s, 2),其中 s = Link(3, Link(5, Link(7, Link(9))))。为方便称呼,把四个节点叫 A(3)、B(5)、C(7)、D(9)。

逐步推演:展开调用栈
without(A, 2)
  A is not empty, i=2 ≠ 0  → 走 else 分支
  需要 without(B, 1) 的结果
  │
  └─ without(B, 1)
       B is not empty, i=1 ≠ 0 → 走 else 分支
       需要 without(C, 0) 的结果
       │
       └─ without(C, 0)
            C is not empty, i=0  → 走 elif 分支
            直接返回 C.rest,也就是节点 D 本身
            ⇒ 返回 D = Link(9)      ← 这是原表里的 D,没有复制

       拿到 D,构造新节点 B' = Link(B.first, D) = Link(5, Link(9))
       ⇒ 返回 B'

  拿到 B',构造新节点 A' = Link(A.first, B') = Link(3, Link(5, Link(9)))
  ⇒ 返回 A'

回显时调用 repr(A'),递归地得到 Link(3, Link(5, Link(9)))——与 doctest 一致。

再看内存里发生了什么。调用结束后的盒子指针图(' 表示新建节点):

原表 s ──▶ A(3) ──▶ B(5) ──▶ C(7) ──▶ D(9) ──▶ Link.empty
                                        ▲
新表   ──▶ A'(3) ─▶ B'(5) ──────────────┘

新建了 2 个节点:A' 和 B'
共享了 1 个节点:D(新表和原表都指着同一个 D)
绕过了 1 个节点:C(新表里没有它,原表里它还在)
原表 A、B、C、D 的 first/rest 全部未被写入 ⇒ s 完好无损

再验证越界的第三个 doctest,without(s, 4):

逐步推演:i 越界的情况
without(A, 4) → else → Link(3, without(B, 3))
without(B, 3) → else → Link(5, without(C, 2))
without(C, 2) → else → Link(7, without(D, 1))
without(D, 1) → else → Link(9, without(Link.empty, 0))
without(Link.empty, 0) → 第一个分支命中 → Link.empty

逐层回代:
  without(D, 1) = Link(9)
  without(C, 2) = Link(7, Link(9))
  without(B, 3) = Link(5, Link(7, Link(9)))
  without(A, 4) = Link(3, Link(5, Link(7, Link(9))))   ✓

注意这一趟走下来 i 一直没归零,所以每个节点都走 else 分支被复制了一份。结果是一个和 s 内容相同但节点全新的链表——恰好符合「返回新表且保留全部元素」。同时也说明:without(s, 4) is s 会是 False,但 doctest 只看 repr,所以通过。

顺带做一次复杂度分析:设表长 n,函数最多递归 min(i, n) + 1 层,每层 $\Theta(1)$ 工作,所以时间是 $\Theta(\min(i, n))$,新建节点数同量级,空间也是 $\Theta(\min(i, n))$。相比「先整体复制再删」的 $\Theta(n)$,共享尾巴省下的是实打实的。

常见误区

误区一:在 else 分支里写 s.rest = without(s.rest, i - 1); return s。 这样「省」掉了新建节点,但它就地改写了原表。运行 doctest 时第一条 without(s, 0) 还能过,第二条 without(s, 2) 就会在一个已经被破坏的 s 上跑,输出对不上。可变数据 + 复用输入 = 隐蔽的 bug,这是 61A 反复敲打的点。

误区二:忘记 Link.empty 分支,只写 i == 0 和 else。 前两条 doctest 会通过,第三条 without(s, 4) 会在走到表尾后继续访问 ().first,报 AttributeError: 'tuple' object has no attribute 'first'。这就是为什么题面要专门给一条越界的 doctest。

误区三:return Link(s.first, without(s.rest, i)),忘了减一。 这样 i 永远不会归零(除非一开始就是 0),函数会把整个表复制一遍,什么都没删。写递归时问自己:每一层递归调用,参数是否都朝着 base case 前进了? 这里「前进」有两条腿——表在变短、i 在变小,缺一条都可能不停。

3. Duplicate Link duplicate_link

题目要什么

写 duplicate_link(s, val):就地修改链表 s,让每一个等于 val 的元素后面紧跟一个新的 val(即复制一份)。函数返回 None。题面还警告了一句:小心别陷入「不断复制自己刚复制出来的副本」的死循环。

>>> x = Link(5, Link(4, Link(5)))
>>> duplicate_link(x, 5)
>>> x
Link(5, Link(5, Link(4, Link(5, Link(5)))))
>>> y = Link(2, Link(4, Link(6, Link(8))))
>>> duplicate_link(y, 10)
>>> y
Link(2, Link(4, Link(6, Link(8))))
>>> z = Link(1, Link(2, Link(2, Link(3))))
>>> duplicate_link(z, 2) # ensures that back to back links with val are both duplicated
>>> z
Link(1, Link(2, Link(2, Link(2, Link(2, Link(3))))))

这道题和 Q2 是镜像关系,务必对照着理解:

Q2 withoutQ3 duplicate_link
对原表绝对不能改必须改,而且只能改它
返回值新链表None(没有 return 语句)
典型写法递归构造迭代改指针(递归也行)
正确性怎么看看返回的表看调用之后原来那个名字指向的表

三条 doctest 各自在考什么,逐条点破:

1 x = Link(5, Link(4, Link(5))),复制 5。考的是基本功能,而且要求表尾的那个 5 也要被复制(很多人写完发现最后一个漏了)。
2 y 里根本没有 10。考的是一个都不匹配时不能出错、不能改动。
3 z = Link(1, Link(2, Link(2, Link(3)))),复制 2。注释写得很明白:“ensures that back to back links with val are both duplicated”。两个相邻的 2 必须各自被复制一份,最终有四个 2。这条是整道题的关键测试——它同时排除了「跳得太多导致漏掉后一个」和「跳得太少导致死循环」两种错法。
注意:duplicate_link(x, 5) 那一行下面是空的

doctest 里 >>> duplicate_link(x, 5) 之后没有期望输出,这意味着这个调用必须返回 None(交互式解释器对 None 不显示任何东西)。如果你手贱在末尾加了 return s,doctest 会报 “Expected nothing, got Link(...)” 而失败。「改还是返」要选一个,不要既改又返——这是 61A 一贯的风格要求。

怎么想到的

第一步先明确「在链表中间插入一个节点」这个原子操作长什么样。假设当前站在节点 s 上,想在它后面插一个值为 val 的新节点:

插入前:   s ──▶ [val | ●] ──▶ [下一个节点 ...]

第 1 步:记住原来的后继
           rest_of_list = s.rest

第 2 步:造一个新节点,让它的 rest 指向原来的后继
           new = Link(val, rest_of_list)

第 3 步:把 s 的 rest 改成指向新节点
           s.rest = new

插入后:   s ──▶ [val | ●] ──▶ [val | ●] ──▶ [下一个节点 ...]
                  原节点        新节点

顺序不能颠倒:如果先做 s.rest = Link(val),原来的后继就没人指着了,整条尾巴当场丢失。改指针之前必须先把要覆盖掉的那个指针存下来——这是链表操作的第一条铁律。

好,插入会写了。现在的问题是遍历时怎么走。

弯路一:无脑 s = s.rest。 写成这样:

# 错误示范
while s is not Link.empty:
    if s.first == val:
        s.rest = Link(val, s.rest)
    s = s.rest        # ← 只往前走一格

推演一下 x = Link(5, ...):站在第一个 5 上,插入一个新的 5,然后 s = s.rest 走到刚插入的那个新 5。新 5 的 first 也等于 val,于是又插一个……无限循环,程序卡死,最后 MemoryError 或者直接把机器内存吃光。这正是题面警告的那个坑。

弯路二:跳两格,但跳错了对象。 意识到要跳过副本,于是改成:

# 仍然有问题的示范
while s is not Link.empty:
    if s.first == val:
        s.rest = Link(val, s.rest)
        s = s.rest.rest    # 跳过新插入的那个
    else:
        s = s.rest

这一版其实是正确的(s.rest 是新节点,s.rest.rest 是原来的后继)。但它有个隐患:s.rest.rest 这个表达式必须在赋值之后才成立,读代码的人得在脑子里重放一遍赋值才能确认,容易写反成 s = s.rest 或者 s = s.rest.rest.rest。

最终写法:先把「原来的后继」存进一个有名字的变量,然后直接跳到它。 这样「跳到哪」是显式的、不依赖中间状态:

关键一步

rest_of_list = s.rest 这个变量一石二鸟:(a) 它是新节点的 rest,保证尾巴不丢;(b) 它同时也正是「处理完这两个 val 之后应该继续的地方」。所以插完直接 s = rest_of_list,一步到位跳过原节点和副本两个。

为什么跳过两个是对的、而不是漏处理?因为副本的值就是 val,如果不跳过它会被再次复制;而原节点已经处理完了。至于 rest_of_list 那个节点——它没有被跳过,循环下一轮正好站在它上面。所以 z 里两个相邻的 2 都会被各自处理,得到四个 2。

代码

def duplicate_link(s: Link, val: int) -> None:
    """Mutates s so that each element equal to val is followed by another val.

    >>> x = Link(5, Link(4, Link(5)))
    >>> duplicate_link(x, 5)
    >>> x
    Link(5, Link(5, Link(4, Link(5, Link(5)))))
    >>> y = Link(2, Link(4, Link(6, Link(8))))
    >>> duplicate_link(y, 10)
    >>> y
    Link(2, Link(4, Link(6, Link(8))))
    >>> z = Link(1, Link(2, Link(2, Link(3))))
    >>> duplicate_link(z, 2) # ensures that back to back links with val are both duplicated
    >>> z
    Link(1, Link(2, Link(2, Link(2, Link(2, Link(3))))))
    """
    while s is not Link.empty:
        if s.first == val:
            # Splice a copy of val in right after the current node, then jump
            # past BOTH copies so we never duplicate the copy we just inserted.
            rest_of_list = s.rest
            s.rest = Link(val, rest_of_list)
            s = rest_of_list
        else:
            s = s.rest
1 while s is not Link.empty: —— 标准的链表遍历循环。s 是局部形参,在函数里给它重新赋值只是让这个局部名字指向别的节点,不会影响调用者的 x。调用者的 x 始终指着第一个节点。这就是为什么函数能「修改链表」却不需要返回值:我们改的是节点对象内部的 rest 属性,而 x 指着的那个头节点从未被替换。
2 if s.first == val: —— 这里用 == 而不是 is,因为比的是值。val 是数字,小整数在 CPython 里会被缓存所以 is 碰巧也能过,但大整数(如 1000)用 is 就可能失败。比值用 ==,比对象身份用 is——Q5 里正好是反过来的场景。
3 rest_of_list = s.rest —— 在覆盖 s.rest 之前先存下来。这一行如果删掉,下一行就得写成 s.rest = Link(val, s.rest),靠 Python「右边先求值」的规则侥幸正确;能工作,但不如显式变量清楚,而且第三行也没法写了。
4 s.rest = Link(val, rest_of_list) —— 新节点的 first 用的是 val 而不是 s.first。这两者此刻相等,用哪个都对;写 val 更直白地表达了「我插的是那个被找的值」。
5 s = rest_of_list —— 越过原节点和副本,落在原来的后继上。这一行是整道题的答案。 它保证了:副本不会被再处理(避免死循环),后继会被处理(相邻的 val 不漏)。
6 else: s = s.rest —— 不匹配就正常前进一格。
7 函数没有 return 语句,所以自动返回 None,满足签名 -> None 和 doctest 的「无输出」要求。

验证

手动追踪第三条 doctest:z = Link(1, Link(2, Link(2, Link(3)))),duplicate_link(z, 2)。四个原始节点记为 P(1)、Q(2)、R(2)、S(3)。

逐步推演:循环每一轮的状态
初始:   z ──▶ P(1) ──▶ Q(2) ──▶ R(2) ──▶ S(3) ──▶ empty
         s = P

第 1 轮   s = P,P.first = 1 ≠ 2  → else 分支
         s = P.rest = Q
         链表未变

第 2 轮   s = Q,Q.first = 2 == 2 → if 分支
         rest_of_list = Q.rest = R
         Q.rest = Link(2, R)          ← 新节点记作 Q'
         s = rest_of_list = R
         此刻:z ─▶ P(1) ─▶ Q(2) ─▶ Q'(2) ─▶ R(2) ─▶ S(3) ─▶ empty
                                              ▲
                                              s 站在这里(不是 Q')

第 3 轮   s = R,R.first = 2 == 2 → if 分支
         rest_of_list = R.rest = S
         R.rest = Link(2, S)          ← 新节点记作 R'
         s = rest_of_list = S
         此刻:z ─▶ P(1) ─▶ Q(2) ─▶ Q'(2) ─▶ R(2) ─▶ R'(2) ─▶ S(3) ─▶ empty

第 4 轮   s = S,S.first = 3 ≠ 2  → else 分支
         s = S.rest = Link.empty

第 5 轮   循环条件 s is not Link.empty 为假 → 退出,函数返回 None

最终 z 指着 P,链表内容是 1, 2, 2, 2, 2, 3。repr 递归展开得到:

Link(1, Link(2, Link(2, Link(2, Link(2, Link(3))))))

与 doctest 完全一致。特别注意第 2 轮结束时 s 落在 R 而不是 Q'——正因如此,R 在第 3 轮被独立处理了一次,这才凑齐了四个 2。如果第 2 轮写成 s = s.rest(落在 Q'),Q' 又会被复制,无限下去。

再快速验证第二条 doctest duplicate_link(y, 10):y 里的 2、4、6、8 没有一个等于 10,每轮都走 else,四轮后 s 变成 Link.empty 退出。全程没有执行过任何属性赋值,链表一个字节都没改,repr 输出与原来相同。

复杂度:设原表长 n、其中有 k 个元素等于 val。循环访问的节点数是 n(副本被跳过了,一个都没重复访问),每轮 $\Theta(1)$,所以时间 $\Theta(n)$。额外空间除了 k 个必须新建的节点外是 $\Theta(1)$——没有辅助列表、没有递归栈。这正是链表擅长的场景:在已经走到的位置插入元素是 $\Theta(1)$,换成 Python 的 list 做同样的事,每次 insert 都要挪动后面所有元素,总共是 $\Theta(n \cdot k)$。

常见误区

误区一:s.rest = Link(val),忘了接尾巴。 新节点的 rest 用了默认值 Link.empty,于是链表在这里被截断了,后面的所有元素全部丢失。x 那条会输出 Link(5, Link(5)),doctest 直接对不上。改指针必先存旧值。

误区二:s = s.rest 一路走到底。 前面详细推演过:程序会卡住不动,等一会儿抛 MemoryError,或者在 ok 里表现为测试超时。如果你运行 ok 时发现某个测试一直转圈不出结果,八成就是这里。

误区三:末尾加了 return s。 doctest 报 Expected: (nothing) / Got: Link(5, Link(5, ...))。题目说了返回 None 就是 None。

误区四:想用递归但写成 duplicate_link(s.rest, val) 却在匹配时递归到副本上。 递归版是可以写的,正确的递归调用应该是 duplicate_link(rest_of_list, val)(跳过副本),和迭代版逻辑完全一样。但递归版会占 $\Theta(n)$ 栈空间,而这道题本质上是「一路向前、无需回溯」,用循环更贴合。需要「回代」才用递归;只是「走一遍」就用循环。

4. Slice slice_link

题目要什么

实现 slice_link(link, start, end),效果和 Python 列表切片 lst[start:end] 一样:取从下标 start 开始、到下标 end 前一个为止的那一段,返回一个新链表,不修改原链表。不需要支持负下标。

>>> link = Link(3, Link(1, Link(4, Link(1, Link(5, Link(9))))))
>>> new = slice_link(link, 1, 4)
>>> print(new)
(1 4 1)
>>> print(slice_link(link, 0, 2))
(3 1)
>>> print(slice_link(link, 0, 6))
(3 1 4 1 5 9)
>>> print(slice_link(link, 2, 2))
()
>>> print(slice_link(link, 2, 3))
(4)
>>> print(slice_link(link, 3, 100))
(1 5 9)
>>> print(slice_link(link, 10, 12))
()

七条 doctest,每一条都在钉死一种边界。把它们摊开看:

调用期望它在考什么
slice_link(link, 1, 4)(1 4 1)普通情况:取下标 1、2、3,不含 4(左闭右开)
slice_link(link, 0, 2)(3 1)start = 0,从头开始
slice_link(link, 0, 6)(3 1 4 1 5 9)end 正好等于长度,取全部
slice_link(link, 2, 2)()start == end,空切片
slice_link(link, 2, 3)(4)只取一个元素
slice_link(link, 3, 100)(1 5 9)end 远超长度,取到表尾为止就停
slice_link(link, 10, 12)()start 就已经越界,整段都取不到
注意:这里的 () 是打印出来的 Link.empty

print(slice_link(link, 2, 2)) 输出 ()——这不是 Link.__str__ 生成的,因为 Link.empty 根本不是 Link 实例,它就是那个空元组,print(()) 自然打出 ()。所以「返回空切片」在这道题里的正确做法就是 return Link.empty。如果你返回 None,会打出 None,测试挂掉。

怎么想到的

这道题的骨架和 Q2 without 是同一个模子——都是「递归地构造新链表」——但多了一个参数需要照顾。先建立正确的心理模型。

第一个念头:先跳 start 步,再拷 (end - start) 个。 这个想法本身没错,而且很自然,写出来大概是:

# 思路草稿(不是最终代码)
def slice_link(link, start, end):
    # 阶段一:跳过前 start 个
    while start > 0 and link is not Link.empty:
        link = link.rest
        start, end = start - 1, end - 1
    # 阶段二:拷贝接下来的 end 个
    ...

问题出在阶段二:迭代地「拷贝一段并保持顺序」需要维护头指针和尾指针,还要处理「新表为空」的第一步,很啰嗦。而且这两个阶段其实是同一件事的两种状态,硬拆成两段反而绕。

转向:把「我现在处于哪个阶段」编码进参数本身。

关键洞察是:start 和 end 是相对于当前 link 的下标,不是相对于最初那个表的绝对下标。每往后走一个节点,当前节点在剩余表里的编号就少 1,所以start 和 end 要同时减 1。有了这个约定,函数任何时刻的状态都能只看这三个参数读出来:

1 start > 0:窗口还没开始,当前元素在窗口左边,扔掉它,继续看 link.rest,同时 start - 1、end - 1。
2 start <= 0 且 end > 0:当前元素在窗口内,复制它,然后继续看 link.rest。此时 start 已经归零,后面一直保持 0 即可(所以递归调用里直接写死 0),end 继续减 1 表示「还能再要几个」。
3 end <= 0:窗口已经关闭,后面一个都不要了,返回 Link.empty。
4 link is Link.empty:表走完了,无论窗口开没开、关没关,都没有东西可取,返回 Link.empty。
关键一步:end 从「绝对终点」变成「还能再要几个」

一旦 start 归零进入窗口内,end 的含义就自动变成了「剩余配额」——因为窗口长度 = end - start,而 start = 0 时它就等于 end。所以整个函数只需要一个终止条件 end <= 0,不需要额外的计数器。把两个不同阶段的语义统一到同一组参数上,是这道题最漂亮的地方。

为什么写 end <= 0 而不是 end == 0?因为 slice_link(link, 10, 12) 这种情况下,start 会先归零……不,实际上表只有 6 个元素,走到第 6 步表就空了,先命中 link is Link.empty。但考虑 slice_link(link, 5, 3) 这类 start > end 的输入:end 会先减到 0 以下。用 <= 是防御性的——判断条件宁可宽一点,也不要留下一个「恰好跳过」的缝隙让程序无限递归下去。

代码

def slice_link(link: Link, start: int, end: int) -> Link:
    """Slices a linked list from start to end (as with a normal Python list) and returns
    a linked list. You do NOT have to support negative indices.

    >>> link = Link(3, Link(1, Link(4, Link(1, Link(5, Link(9))))))
    >>> new = slice_link(link, 1, 4)
    >>> print(new)
    (1 4 1)
    >>> print(slice_link(link, 0, 2))
    (3 1)
    >>> print(slice_link(link, 0, 6))
    (3 1 4 1 5 9)
    >>> print(slice_link(link, 2, 2))
    ()
    >>> print(slice_link(link, 2, 3))
    (4)
    >>> print(slice_link(link, 3, 100))
    (1 5 9)
    >>> print(slice_link(link, 10, 12))
    ()
    """
    if link is Link.empty or end <= 0:
        # No elements left, or the slice window has closed.
        return Link.empty
    elif start > 0:
        # Haven't reached the start of the window yet: discard this element and
        # shift both bounds one step left.
        return slice_link(link.rest, start - 1, end - 1)
    else:
        # Inside the window: copy this element and keep going.
        return Link(link.first, slice_link(link.rest, 0, end - 1))
1 if link is Link.empty or end <= 0: —— 两个 base case 合并成一个分支,因为它们的返回值相同。link is Link.empty 必须放在最前面:后面两个分支都要访问 link.rest 或 link.first,在空表上会 AttributeError。or 有短路特性,但这里两个操作数都不会出错,顺序只影响可读性。
2 elif start > 0: —— 「还没到窗口」。返回值直接是递归调用的结果,没有包一层 Link(...)——因为当前元素不属于切片,要被丢掉。这一步是纯粹的前进,不产生任何新节点。
3 slice_link(link.rest, start - 1, end - 1) —— 两个边界都减 1,这是整道题最容易写错的一行。只减 start 不减 end,窗口就会被拉长成 end - 0 个元素而不是 end - start 个。用 slice_link(link, 1, 4) 一验就露馅:会输出 (1 4 1 5),多一个。
4 else: —— 走到这里意味着 link 非空、end > 0、start <= 0,三个条件合起来就是「在窗口内」。
5 return Link(link.first, slice_link(link.rest, 0, end - 1)) —— 新建节点保证不动原表(和 Q2 同理)。递归调用第二个参数写死 0:因为一旦进入窗口就永远在窗口内了,start 不该再变负;写 start - 1 也能过测试(负数同样满足 start > 0 为假),但写 0 更明确地表达了「start 这个变量已经完成使命」。
6 end - 1 —— 配额用掉一个。当配额耗尽,下一层就命中第一个分支返回 Link.empty,成为新链表的末端。这个 Link.empty 就是新表的收尾,所以我们造出的切片是一条完全独立的新链,和原表不共享任何节点。
7 注意与 Q2 的差别:without 在 i == 0 时直接 return s.rest,与原表共享尾巴;slice_link 则一个节点都不共享,全是新建的。原因是切片的末端必须是 Link.empty,而原表在那个位置往往还有后续元素,所以无法共享。

验证

手动追踪第一条 doctest:link = Link(3, Link(1, Link(4, Link(1, Link(5, Link(9)))))),求 slice_link(link, 1, 4)。六个节点按下标记为 n0(3)、n1(1)、n2(4)、n3(1)、n4(5)、n5(9)。

逐步推演:展开调用栈
slice_link(n0, 1, 4)
  n0 非空,end=4>0,start=1>0  → elif 分支(丢掉 3)
  = slice_link(n1, 0, 3)
      n1 非空,end=3>0,start=0 不>0 → else 分支(保留 1)
      = Link(1, slice_link(n2, 0, 2))
          n2 非空,end=2>0,start=0 → else(保留 4)
          = Link(4, slice_link(n3, 0, 1))
              n3 非空,end=1>0,start=0 → else(保留 1)
              = Link(1, slice_link(n4, 0, 0))
                  n4 非空,但 end=0 <= 0 → 第一个分支
                  = Link.empty

逐层回代:
  slice_link(n4, 0, 0) = Link.empty
  slice_link(n3, 0, 1) = Link(1, Link.empty)      = Link(1)
  slice_link(n2, 0, 2) = Link(4, Link(1))
  slice_link(n1, 0, 3) = Link(1, Link(4, Link(1)))
  slice_link(n0, 1, 4) = Link(1, Link(4, Link(1)))   ← 同上,因为 elif 不包装

结果链表是三个新建节点,内容 1, 4, 1。print 调用 __str__:循环把 '1 '、'4 ' 拼进去,退出时补 '1)',得到 (1 4 1)。与 doctest 一致。

原表 link ─▶ n0(3) ─▶ n1(1) ─▶ n2(4) ─▶ n3(1) ─▶ n4(5) ─▶ n5(9) ─▶ empty
                       └────窗口 [1, 4) 覆盖 n1 n2 n3────┘

新表 new  ─▶ m0(1) ─▶ m1(4) ─▶ m2(1) ─▶ Link.empty
             全部是新建节点,与原表零共享;原表未被写入任何属性

再追两条边界情况,确认设计站得住:

逐步推演:两条边界 doctest
【slice_link(link, 2, 2) 期望 ()】
slice_link(n0, 2, 2)  n0 非空, end=2>0, start=2>0 → elif
= slice_link(n1, 1, 1)  n1 非空, end=1>0, start=1>0 → elif
= slice_link(n2, 0, 0)  n2 非空, 但 end=0 <= 0    → 第一分支
= Link.empty
print(Link.empty) 就是 print(()) → 输出 ()   ✓
关键:两个边界同步减,所以 start 归零的同时 end 也归零,窗口宽度 0。

【slice_link(link, 3, 100) 期望 (1 5 9)】
slice_link(n0, 3, 100) → elif → slice_link(n1, 2, 99)
                       → elif → slice_link(n2, 1, 98)
                       → elif → slice_link(n3, 0, 97)
n3 起进入窗口:
  = Link(1, slice_link(n4, 0, 96))
  = Link(1, Link(5, slice_link(n5, 0, 95)))
  = Link(1, Link(5, Link(9, slice_link(Link.empty, 0, 94))))
                                └─ link is Link.empty → Link.empty
  = Link(1, Link(5, Link(9)))
print → (1 5 9)   ✓
关键:end=94 还剩很多配额,但表已经走完,靠 link is Link.empty 兜住。

【slice_link(link, 10, 12) 期望 ()】
连续 6 次 elif 后 link 变成 Link.empty(此时 start=4, end=6)
→ 第一分支 → Link.empty → 输出 ()   ✓

这三条合起来说明:两个 base case 缺一不可。end <= 0 管「要够了」,link is Link.empty 管「没了」。少写任何一个,都会有 doctest 挂掉。

复杂度:递归层数最多是 min(end, n) + 1,每层 $\Theta(1)$,时间 $\Theta(\min(end, n))$;新建节点数是切片长度,递归栈深度同上,所以空间也是 $\Theta(\min(end, n))$。

常见误区

误区一:elif start > 0: return slice_link(link.rest, start - 1, end)——只减 start。 这是这道题最高频的错误。slice_link(link, 1, 4) 会输出 (1 4 1 5),多取一个。原因是当 start 归零时 end 还停在 4,被当成「还能要 4 个」,而正确答案是 4 - 1 = 3 个。只要移动了当前位置,所有相对于当前位置的下标都必须同步平移。

误区二:漏掉 link is Link.empty 分支。 前六条 doctest 里有五条能过,但 slice_link(link, 3, 100) 会在走完表以后继续 link.first,报 AttributeError: 'tuple' object has no attribute 'first'。而 slice_link(link, 10, 12) 则会在 elif 分支里 ().rest 崩掉。

误区三:else 分支里写成 link.rest = slice_link(...) 或者返回 link 本身。 这会破坏原表。因为 doctest 里 link 被连续切了七次,第一次切完如果改了原表,后面六条全会错乱。题目明说 “this function should return a new list and not modify the original list”。

误区四:base case 返回 None 而不是 Link.empty。 上一层的 Link(link.first, None) 会触发构造器的 assert rest is Link.empty or isinstance(rest, Link),抛 AssertionError。这时候你应该庆幸有那个 assert——否则你会在某个 print 里看到莫名其妙的 AttributeError,还得倒推半天。

5. Cycles has_cycle

题目要什么

Link 类可以表示带环的链表——某个节点的 rest 指回了前面已经出现过的节点,于是这条链没有终点。题面给的例子:

>>> s = Link(1, Link(2, Link(3)))
>>> s.rest.rest.rest = s
>>> s.rest.rest.rest.rest.rest.first
3

写 has_cycle(link),返回这个链表里有没有环。题面给的提示是:遍历链表,同时记录你已经见过哪些 Link 对象。

>>> s = Link(1, Link(2, Link(3)))
>>> s.rest.rest.rest = s
>>> has_cycle(s)
True
>>> t = Link(1, Link(2, Link(3)))
>>> has_cycle(t)
False
>>> u = Link(2, Link(2, Link(2)))
>>> has_cycle(u)
False

三条 doctest 的分工:第一条是有环,第二条是普通无环,第三条是整道题的陷阱——u 里三个节点的 first 都是 2,值完全相同,但它们是三个不同的对象,链表照样在第三个节点后终止于 Link.empty。答案必须是 False。

核心结论:环是关于「对象身份」的,不是关于「值」的

判断有没有环,唯一的依据是「我是不是又回到了同一个 Link 对象」。两个节点的 first 相等说明不了任何问题;哪怕整条链表所有元素都一样,只要每个节点是独立分配的对象,它就没有环。所以这道题从头到尾只能用 is,绝不能用 ==。

再补一句为什么这个函数有存在价值:Q1 里我们见过,环形链表调 print() 会永远转下去。任何遍历链表的函数(包括你自己写的 without、slice_link)遇到环都会挂死。has_cycle 是防御性的工具。

怎么想到的

第一个念头(多半是错的):走很多步,如果一直没到 Link.empty 就算有环。 比如「走 10000 步还没结束就返回 True」。这是启发式不是算法:一条长度 20000 的正常链表会被误判为有环。魔法数字解决不了正确性问题,必须找一个真正的判据。

第二个念头(正确,也是题面提示的):记账。 环意味着「我第二次踩到了同一块石头」。那就把踩过的石头全记下来,每到一个新节点先查一查在不在名单里:

  • 在名单里 → 说明绕回来了 → 有环,返回 True;
  • 不在 → 记上,继续走;
  • 一直走到 Link.empty → 链表有终点 → 无环,返回 False。

为什么这个算法一定会终止?因为链表里的节点数是有限的(设为 n)。如果有环,最多走 n + 1 步必然重复;如果无环,最多走 n 步就到 Link.empty。两种情况都会停。

接下来是这道题真正的技术难点:怎么查「在不在名单里」。

弯路:写 if link in seen:。 看起来天经地义,但 in 运算符对列表的语义是「存在某个元素 x 使得 x is link or x == link」。Python 会先试 is,不成再试 ==。Link 类没有定义 __eq__,所以 == 退化成身份比较,in 碰巧也能给出正确答案。但这是依赖运气:一旦有人给 Link 加上一个按值比较的 __eq__,第三条 doctest 立刻挂掉(三个 Link(2, ...) 会被认为相等)。既然我们心里清楚要比的是身份,就应该把这个意图写进代码里。

关键一步:用 any(...) 显式地做身份比较

any(link is node for node in seen) 逐个用 is 比对,只要有一个是同一个对象就返回 True。它和 link in seen 在当前实现下结果相同,但意图是明确的、不依赖 Link 有没有 __eq__。这就是所谓「让代码说出你真正的意思」。

代码

def has_cycle(link: Link) -> bool:
    """Return whether link contains a cycle.

    >>> s = Link(1, Link(2, Link(3)))
    >>> s.rest.rest.rest = s
    >>> has_cycle(s)
    True
    >>> t = Link(1, Link(2, Link(3)))
    >>> has_cycle(t)
    False
    >>> u = Link(2, Link(2, Link(2)))
    >>> has_cycle(u)
    False
    """
    seen = []                       # Every Link object we have already visited.
    while link is not Link.empty:
        # Compare by identity: two different Links may hold equal values.
        if any(link is node for node in seen):
            return True
        seen.append(link)
        link = link.rest
    return False                    # Reached Link.empty, so the list ends.
1 seen = [] —— 一个普通 Python 列表,存的是Link 对象本身的引用,不是它们的值。这是本函数唯一的额外空间。
2 while link is not Link.empty: —— 无环时这个条件负责终止;有环时它永远为真,靠循环体里的 return True 跳出。两条出口各管一种情况。
3 if any(link is node for node in seen): return True —— 检查必须放在 append 之前。如果顺序反了,先把自己加进去再查,那么每个节点第一次访问时就会在名单里找到它自己,函数对任何非空链表都返回 True。
4 seen.append(link) —— 记账。注意 append 存的是引用,不复制节点。
5 link = link.rest —— 前进。link 是形参,重新绑定它不会影响调用者,也不修改任何节点——这个函数是只读的,一个属性都没写。
6 return False —— 循环正常结束(走到了 Link.empty),说明链表有终点。这一行必须在 while 外面;写在里面(缩进多一级)会让函数第一轮就返回,只有第一条 doctest 侥幸通过。

验证

先追踪有环的 s。执行 s.rest.rest.rest = s 之后的结构:

s ──▶ ┌──────┐    ┌──────┐    ┌──────┐
      │ A: 1 │───▶│ B: 2 │───▶│ C: 3 │───┐
      └──────┘    └──────┘    └──────┘   │
         ▲                                │
         └────────────────────────────────┘
         C.rest 指回 A,形成一个长度为 3 的环,没有 Link.empty
逐步推演:has_cycle(s)
seen = []            link = A

第 1 轮  A is not empty → 进入循环
        any(A is node for node in [])  → False(空序列,any 返回 False)
        seen = [A]
        link = A.rest = B

第 2 轮  B is not empty
        any(B is node for node in [A]) → B is A 为 False → False
        seen = [A, B]
        link = B.rest = C

第 3 轮  C is not empty
        any(C is node for node in [A, B]) → 都不是 → False
        seen = [A, B, C]
        link = C.rest = A      ← 绕回起点

第 4 轮  A is not empty
        any(A is node for node in [A, B, C]) → 第一个就命中 A is A → True
        return True            ✓

再追踪无环的 t = Link(1, Link(2, Link(3))),节点记为 P、Q、R:

逐步推演:has_cycle(t)
第 1 轮  link=P,not in [] → seen=[P],link=Q
第 2 轮  link=Q,not in [P] → seen=[P,Q],link=R
第 3 轮  link=R,not in [P,Q] → seen=[P,Q,R],link=R.rest=Link.empty
第 4 轮  循环条件 link is not Link.empty 为假 → 退出
        return False           ✓

第三条 u = Link(2, Link(2, Link(2))) 是最重要的验证。三个节点记为 U1、U2、U3,first 都是 2:

逐步推演:has_cycle(u) —— 值相同但对象不同
第 1 轮  link=U1,seen=[] → 无命中 → seen=[U1],link=U2
第 2 轮  link=U2,seen=[U1]
        判断 U2 is U1 → False
        (注意:如果这里错用 U2.first == U1.first,会得到 2 == 2 → True
          函数就会错误地返回 True)
        → seen=[U1,U2],link=U3
第 3 轮  link=U3,seen=[U1,U2]
        U3 is U1 → False;U3 is U2 → False
        → seen=[U1,U2,U3],link=U3.rest=Link.empty
第 4 轮  退出循环 → return False    ✓

复杂度:设链表节点数 n。外层循环最多 n + 1 轮,每轮的 any(...) 要扫一遍 seen(长度最大 n),所以时间是 $\Theta(n^2)$,空间是 $\Theta(n)$。用 set 存 id(link) 可以把时间降到 $\Theta(n)$,但空间仍是 $\Theta(n)$——这正是下面那道选做题要攻克的地方。

选做:has_cycle_constant,只用常数空间

题面的额外挑战:不记录任何见过的节点,判断有没有环。解法不到 20 行,但需要一个巧妙的想法。

怎么想到的。 既然不能记账,就只能靠「走路」本身产生信息。想象一条环形跑道上有两个人,一个走得慢、一个走得快。如果跑道是直线(无环),快的那个先跑到终点,从此再也见不到慢的。如果跑道是环形(有环),两个人都会一直转下去,而快的会不断拉开距离——绕回来以后从后面追上慢的,最终必然在某一步踩到同一个位置。这就是 Floyd 的「龟兔赛跑」(tortoise and hare)算法。

为什么「必然踩到同一格」而不是「跨过去」?因为快指针每步比慢指针多走 1 格,所以它们之间的距离每步恰好缩小 1。一个每步减 1 的非负整数距离,一定会经过 0。如果快指针一次走 3 格,距离每步减 2,就可能在距离为 1 时跨过去——所以步长必须是 1 和 2。

def has_cycle_constant(link: Link) -> bool:
    """Return whether link contains a cycle.

    >>> s = Link(1, Link(2, Link(3)))
    >>> s.rest.rest.rest = s
    >>> has_cycle_constant(s)
    True
    >>> t = Link(1, Link(2, Link(3)))
    >>> has_cycle_constant(t)
    False
    """
    # Floyd's "tortoise and hare": slow advances one node per step, fast
    # advances two. If there is a cycle, fast eventually laps slow and they
    # land on the same object; if there is no cycle, fast falls off the end.
    slow, fast = link, link
    while fast is not Link.empty and fast.rest is not Link.empty:
        slow = slow.rest
        fast = fast.rest.rest
        if slow is fast:
            return True
    return False
1 slow, fast = link, link —— 两个指针从同一个节点出发。必须先走再比,否则第一次比较时 slow is fast 立刻为真,任何链表都被判为有环。代码里的比较放在两次移动之后,正好满足这一点。
2 while fast is not Link.empty and fast.rest is not Link.empty: —— 两个条件缺一不可,因为下一行要做 fast.rest.rest,需要保证 fast 和 fast.rest 都是真正的 Link。and 的短路在这里是必需的:如果 fast 已经是 Link.empty,右边的 fast.rest 会 AttributeError,全靠 and 不去求值它。这是「短路求值不只是优化,而是正确性前提」的经典例子。
3 slow = slow.rest / fast = fast.rest.rest —— 慢的走一格、快的走两格。slow 不需要判空,因为它永远落在 fast 走过的路上,fast 能走两格就说明 slow 走一格安全。
4 if slow is fast: return True —— 再次强调是 is:两个指针指向同一个对象才算相遇。
5 return False —— fast 掉出表尾,说明有终点。空间只用了两个名字,$\Theta(1)$。时间仍是 $\Theta(n)$(无环时 fast 走 n/2 步到头;有环时相遇发生在 $O(n)$ 步内)。
逐步推演:has_cycle_constant(s),环长 3
结构:A ─▶ B ─▶ C ─▶ A(回到 A)

初始    slow = A,fast = A

第 1 轮 条件:fast=A 非空,fast.rest=B 非空 → 进入
       slow = A.rest = B
       fast = A.rest.rest = C
       B is C? 否 → 继续

第 2 轮 条件:fast=C 非空,fast.rest=A 非空 → 进入
       slow = B.rest = C
       fast = C.rest.rest = A.rest = B
       C is B? 否 → 继续

第 3 轮 条件:fast=B 非空,fast.rest=C 非空 → 进入
       slow = C.rest = A
       fast = B.rest.rest = C.rest = A
       A is A? 是 → return True    ✓

【对照无环的 t:P ─▶ Q ─▶ R ─▶ empty】
初始    slow = P,fast = P
第 1 轮 fast=P 非空,fast.rest=Q 非空 → 进入
       slow = Q,fast = P.rest.rest = R
       Q is R? 否
第 2 轮 fast=R 非空,但 fast.rest = R.rest = Link.empty
       第二个条件为假 → 退出循环
       return False    ✓
常见误区

误区一:用 == 或者比 first。 写成 if link.first in [n.first for n in seen],第三条 doctest u = Link(2, Link(2, Link(2))) 会返回 True,而正确答案是 False。这条 doctest 就是专门为了抓这个错而存在的。

误区二:seen.append 写在检查之前。 每个节点一进来就发现自己在名单里,函数对所有非空链表返回 True。第二、三条 doctest 挂掉。

误区三:快慢指针的 while 条件只写 fast is not Link.empty。 当 fast 走到最后一个节点(fast.rest 是 Link.empty)时,fast.rest.rest 变成 ().rest,报 AttributeError: 'tuple' object has no attribute 'rest'。奇数长度和偶数长度的链表在这里表现不同,只测一个长度很容易漏掉。

误区四:快慢指针初始化成 slow, fast = link, link.rest 并把比较放在循环开头。 这个变体也能工作(很多教科书就这么写),但如果你在初始化时写了 link.rest 而 link 恰好是 Link.empty,就会当场崩。本仓库的写法从同一点出发、先走后比,不需要对入参做特判,更稳。

误区五:以为 has_cycle 只需要检查 link.rest is link。 那只能查出长度为 1 的自环。环可以任意长,甚至可以是「一条直尾巴 + 末端一个环」的 ρ 形结构,必须用通用算法。

整份作业回顾

这次五道题看起来是五个孤立的函数,实际上它们围着同一根轴转:链表的定义是递归的,所以处理它的代码也是递归的;链表的节点是可变对象,所以「改」和「造」是两条完全不同的路,走错了就会互相污染。

题目核心手法迁移到哪里
Q1 WWPD身份 vs 值;repr vs str;构造器 assert 作为不变量护栏任何自定义类的调试;后面的 Tree、Scheme pair
Q2 without递归构造新表;只复制会变的前缀,共享不变的尾巴持久化数据结构;Scheme 里所有 list 操作的默认姿势
Q3 duplicate_link就地改指针;改之前先存旧值;跳过自己刚插入的节点链表插入/删除/反转;任何「边遍历边修改」的场景
Q4 slice_link把「阶段」编码进参数;两个边界同步平移带窗口/区间的递归;树的层级遍历;动态规划的下标管理
Q5 has_cycle用集合记账换正确性;Floyd 快慢指针用推理换空间图的访问标记;找重复元素;判断迭代序列是否周期

真正学到的三件事

1 数据的形状决定代码的形状。 链表的定义是「头 + 一个更小的链表」,所以几乎每道题的解法都是「处理 first + 递归处理 rest」。Q2 和 Q4 是同一个模板的两个实例,差别只在于「递归调用的结果要不要包一层 Link」。以后遇到树(Tree)、遇到 Scheme 的 pair,你会发现是同一件事。
2 「改对象」和「改名字」是两件不同的事。 link = link.rest 只是让一个局部名字换个指向,调用者毫无感觉;link.rest = ... 才是真的写进了对象内部,全世界指着这个对象的人都会看到。Q3 之所以不需要返回值,正是因为它做的是后者;Q2 和 Q4 之所以必须 return,正是因为它们只做前者加上新建对象。写链表代码时,每写一行赋值都问自己:我改的是名字还是对象?
3 is 与 == 的选择是有含义的,不是风格问题。 判断空表用 is Link.empty(因为空表是唯一的单例);判断元素值用 ==(Q3 里 s.first == val);判断有没有绕回同一个节点用 is(Q5)。Q5 的第三条 doctest 存在的唯一目的,就是把「用错了会怎样」摆到你面前。
一句话速查
  • 遍历链表:while s is not Link.empty: ... ; s = s.rest
  • 递归骨架:先判 Link.empty,再判别的条件,最后 Link(s.first, f(s.rest, ...))
  • 插入节点:rest_of_list = s.rest → s.rest = Link(v, rest_of_list) → 想跳过副本就 s = rest_of_list
  • 不修改原表:任何要改 rest 的节点都得新建;不改的尾巴可以直接共享
  • base case 返回 Link.empty,永远不要返回 None(构造器的 assert 会替你抓住这个错)
  • 环检测:记账法 $\Theta(n)$ 空间简单可靠;Floyd 快慢指针 $\Theta(1)$ 空间,while fast is not Link.empty and fast.rest is not Link.empty 两个条件缺一不可

本地验证

在 labs/lab08/ 下运行 python3 ok --local,7 个测试用例全部通过:Q1 的三组 WWPD(tests/link.py 中的三个 case,均已解锁为明文),加上 without、duplicate_link、slice_link、has_cycle 的函数测试,以及选做的 has_cycle_constant。本页贴出的每一段代码都与 labs/lab08/lab08.py 中的真实文件逐字一致。