qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

链表两个节点都写着7,能说明它们在同一位置吗?

沿 next 一直走,有时会到头,有时会绕圈。用快慢指针检查节点身份,再把首次相遇、环入口和环长分开找清楚。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先给节点贴编号,再看它里面保存的值
  4. 一位走一步,一位走两步,先判断会不会相遇
  5. 第一次追上发生在4号,入口却是2号
  6. 把一位放回头部,两位都改成每轮一步
  7. 从入口出发,完整绕回一次就得到环长
  8. 用记录所有访问位置的方法,独立检查两个指针
  9. 把核心状态、观察轨迹和参考账本的空间分开计算
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 4 个解释
对象身份
是否为同一个对象,而不是对象里面的值是否相同。
快慢指针
两位沿同一条next链移动,一位每轮一步,另一位每轮两步。
环入口
从给定起点出发,第一次进入重复环的那个节点。
环长
从环内一个节点沿next走,第一次回到它所需的步数。
打开完整术语小抄 ↗

四个节点都保存7,依次连成一条最后指向 None 的链。两个指针分别站在第二和第三个节点上,读到的数字一样,位置却不同。若用数字相等判断“又回到老地方”,一条没有环的链也会被误报。

我们让一个指针每轮走一步,另一个走两步。相遇时比较是否指向同一个节点;确认成环以后,再找首次进入环的节点,以及绕一圈经过多少个节点。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。固定有限next链,允许重复负载;8477种next映射、50069个起点与visited身份账本核对,另含长前缀/长环、空链、头自环及4类错误输入;只读取链接,核心与轨迹空间分开。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

数值都等于 7,如何判断是同一个节点?

正在准备实验控件…

节点 0
值 7
next → 1
节点 1
值 7
next → 2
节点 2
值 7
next → 3
节点 3
值 7
next → 4
节点 4
值 7
next → 5
节点 5
值 7
next → 2

1/11:先走再比较:慢指针一步、快指针两步

第一根指针
节点 1
第二根指针
节点 2
本阶段检查轮数
1

比较节点身份,不能比较两个节点都存着的数值 7。

节点编号表示不同对象的身份,数值都固定为 7。next 关系在整个过程保持不变;图解存储轨迹方便回看,额外空间不是 O(1)。正文的核心算法在关闭轨迹时只保存固定数量的引用与计数器。

先给节点贴编号,再看它里面保存的值

本文用自己的 Node 类,每个节点有 value 和 next。value 是负载,可以重复;next 指向另一个 Node 或 None。图上的0、1、2是方便观察的节点编号,不是 value,也不是内存地址。算法本身不依赖这些编号。

默认六个节点的 value 全是7,连接为0→1→2→3→4→5→2。从0出发,前两个节点只经过一次,随后在2、3、4、5之间重复。入口是2,环长是4。运行期间这些节点和连接保持不变。

Python 的 a is b 明确询问两个引用是否指向同一个对象。本篇实际比较 slow is fast。slow.value == fast.value 只比较负载;用户类还可以改写 == 的含义,所以不能把任意 == 表达式都当作对象身份检查。

一位走一步,一位走两步,先判断会不会相遇

slow 和 fast 都从头节点出发,但必须先移动再比较。若一开始就检查它们相等,任何非空链都会因为“都站在起点”而被判成有环。每轮 slow 沿 next 走一次,fast 沿 next 走两次。

若走到 None,说明从这个起点沿 next 最终会结束,返回入口 None、环长0。next_node 让从 None 再取一步仍得到 None,并拒绝把整数等其他对象当作链接;空链和单个无环节点因此能直接结束。

如果链有环,两个指针最终都会进入环。此后快指针每轮相对慢指针多走一个节点;在有限个环位置中,这个相对距离会绕回零,于是它们相遇。这个理由依赖每个节点至多一个 next;一般多出边的图要选择其他遍历方法。

第一次追上发生在4号,入口却是2号

默认连接中,移动后的两个编号依次为(1,2)、(2,4)、(3,2)、(4,4)。第四轮在4号节点相遇,足以确认有环;但4号不是从头进入环时遇到的第一个节点。直接返回这次相遇的位置,会回答错入口问题。

把环前面的步数记作 μ,把环长记作 λ。第一次相遇时,慢指针共走了 t 步,快指针走了2t步;在同一个环位置相遇,说明多走的 t 步是若干个完整环,也就是 t 能被 λ 整除。

这三个量作用不同:μ 描述进入环之前的长度,λ 描述绕一圈的长度,t 描述第一阶段追上的时间。本例 μ=2、λ=4、t=4;数值可以偶然相等,含义仍要分清。

把一位放回头部,两位都改成每轮一步

一位放回头节点,另一位留在相遇位置,然后两位一起每轮走一步。本例先是(0,4),接着(1,5),再到(2,2),这次相遇才是入口。

为什么刚好如此?相遇位置距入口的环内偏移是 (t−μ) 对 λ 取余。再走 μ 步,偏移变成 t 对 λ 取余,等于0;与此同时,从头出发的那位也恰好走到入口。入口前的节点都不在环里,因此两位不会在那之前相遇。

如果头节点本身就在环上,μ=0;重置之后两位已经站在入口,无需再移动。头节点指向自己的自环也属于这个情况,不能强制多走一步再认入口。

从入口出发,完整绕回一次就得到环长

找到入口后,将一个指针留在入口,另一个先走一步,计数从1开始。继续走,直到再次指向同一个入口对象。本例依次经过3、4、5、2,共4步,得到环长4。

自环第一次走一步就回到自身,所以环长1。这里也用对象身份判断绕回;环上的值全部是7时,比较 value 会错误地在第一步停止。

完整返回值是(入口对象, 环长)。无环返回(None,0),并没有虚构一个入口。调用方若需要显示编号,可以另外维护标签映射;显示用的映射并不是定位入口算法所必需的状态。

用记录所有访问位置的方法,独立检查两个指针

保存完整代码为 demo.py,运行 python demo.py。2026-10-23 已在 Windows、CPython 3.14.0 实跑,只用标准库。输出先展示默认六节点的全部阶段,再检查相同值无环、空链、单个节点、头自环和从头开始的二节点环。

visited_oracle 采用另一种方法:每经过一个节点,就保存它的身份和首次到达步数;再次访问相同身份时,它就是入口,步数差就是环长。它不调用快慢指针逻辑,代价是保存已经走过的节点身份。

程序穷举0到5个节点的全部8,477种 next 映射,每个 next 可指向 None 或任意节点;再从 None 和每个节点分别出发,共50,069个起点检查。另测长度1,000的长前缀与长环,并拒绝4类错误头部或链接。所有核心检查使用显式 AssertionError,不依赖 assert 开关。

展开收起代码 · 小实验python · 169 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Identity-based cycle entry and length for a finite, unchanged next chain."""
from itertools import product


class Node:
    __slots__ = ("value", "next")

    def __init__(self, value):
        self.value = value
        self.next = None


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def next_node(node):
    if node is None:
        return None
    if type(node) is not Node:
        raise TypeError("head and next must be Node or None")
    successor = node.next
    if successor is not None and type(successor) is not Node:
        raise TypeError("head and next must be Node or None")
    return successor


def floyd_cycle(head, emit=None):
    """Return (entry_node, cycle_length), or (None, 0). Do not mutate nodes.

    With emit=None the core stores only a fixed number of references/counters.
    A callback may observe states but must not change nodes or links.
    """
    slow = fast = head
    while True:
        # Move before comparing: both pointers initially equal head.
        slow = next_node(slow)
        fast = next_node(next_node(fast))
        if emit is not None:
            emit("search", slow, fast)
        if slow is None or fast is None:
            return None, 0
        if slow is fast:  # Identity, never slow.value == fast.value.
            break

    entry = head
    if emit is not None:
        emit("reset", entry, slow)
    while entry is not slow:
        entry = next_node(entry)
        slow = next_node(slow)
        if emit is not None:
            emit("entry", entry, slow)

    length = 1
    cursor = next_node(entry)
    if emit is not None:
        emit("length", entry, cursor)
    while cursor is not entry:
        cursor = next_node(cursor)
        length += 1
        if emit is not None:
            emit("length", entry, cursor)
    return entry, length


def visited_oracle(head):
    """Independent small-test reference: store each visited identity's step."""
    seen = {}
    current = head
    step = 0
    while current is not None:
        identity = id(current)
        if identity in seen:
            return current, step - seen[identity]
        seen[identity] = step
        step += 1
        current = current.next
    return None, 0


def make_graph(successors):
    # Equal payloads deliberately do not identify these separate objects.
    nodes = [Node(7) for _ in successors]
    for node, index in zip(nodes, successors):
        node.next = None if index == -1 else nodes[index]
    return nodes


def compare(head):
    entry, length = floyd_cycle(head)
    wanted, wanted_length = visited_oracle(head)
    require(entry is wanted and length == wanted_length, "entry/length mismatch")


def main():
    nodes = make_graph([1, 2, 3, 4, 5, 2])
    labels = {id(node): i for i, node in enumerate(nodes)}
    events = []

    def show(phase, first, second):
        a = None if first is None else labels[id(first)]
        b = None if second is None else labels[id(second)]
        events.append((phase, a, b))

    entry, length = floyd_cycle(nodes[0], show)
    require(entry is nodes[2] and length == 4, "known entry/length")
    meeting = [a for phase, a, b in events if phase == "search" and a == b][0]
    require(meeting == 4, "first meeting is not the entry here")
    print("all_node_values=[7, 7, 7, 7, 7, 7]")
    print("next_indices=[1, 2, 3, 4, 5, 2]")
    for phase, a, b in events:
        print(f"{phase}: first={a}; second={b}")
    print(f"meeting_index={meeting}; entry_index={labels[id(entry)]}; cycle_length={length}")

    chain = make_graph([1, 2, 3, -1])
    require(chain[1].value == chain[2].value and chain[1] is not chain[2], "value vs identity")
    require(floyd_cycle(chain[0]) == (None, 0), "equal values must not create a cycle")
    require(floyd_cycle(None) == (None, 0), "empty chain")
    single = make_graph([-1])
    require(floyd_cycle(single[0]) == (None, 0), "single noncycle")
    self_loop = make_graph([0])
    found, size = floyd_cycle(self_loop[0])
    require(found is self_loop[0] and size == 1, "head self-loop")
    head_cycle = make_graph([1, 0])
    found, size = floyd_cycle(head_cycle[0])
    require(found is head_cycle[0] and size == 2, "cycle begins at head")
    print("same_values_without_cycle=None; empty=None; single=None; head_self_loop=1; head_cycle=2")

    graphs = starts = 0
    for n in range(6):
        # Every node independently points to None or one of n nodes.
        for successors in product(range(-1, n), repeat=n):
            graph = make_graph(successors)
            before = [node.next for node in graph]
            for head in [None] + graph:
                compare(head)
                starts += 1
            require(all(node.next is old for node, old in zip(graph, before)), "links mutated")
            graphs += 1
    require(graphs == 8477 and starts == 50069, "enumeration coverage")

    # Long prefix and long cycle are separate from the small graph enumeration.
    long_graph = make_graph(list(range(1, 1000)) + [997])
    compare(long_graph[0])
    found, size = floyd_cycle(long_graph[0])
    require(found is long_graph[997] and size == 3, "long prefix")
    long_cycle = make_graph(list(range(1, 1000)) + [0])
    compare(long_cycle[0])
    require(floyd_cycle(long_cycle[0])[1] == 1000, "long cycle")

    malformed = Node(7)
    malformed.next = 42
    invalid = [42, "node", object(), malformed]
    rejected = 0
    for head in invalid:
        try:
            floyd_cycle(head)
        except TypeError:
            rejected += 1
        else:
            raise AssertionError("invalid link accepted")
    print(f"enumerated_graphs={graphs}; enumerated_starts={starts}; long_cases=2; rejected_inputs={rejected}")
    print("PASS: identity/entry/length match visited oracle; core does not mutate links")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。固定有限next链,允许重复负载;8477种next映射、50069个起点与visited身份账本核对,另含长前缀/长环、空链、头自环及4类错误输入;只读取链接,核心与轨迹空间分开。

运行后对照结果
all_node_values=[7, 7, 7, 7, 7, 7]
next_indices=[1, 2, 3, 4, 5, 2]
search: first=1; second=2
search: first=2; second=4
search: first=3; second=2
search: first=4; second=4
reset: first=0; second=4
entry: first=1; second=5
entry: first=2; second=2
length: first=2; second=3
length: first=2; second=4
length: first=2; second=5
length: first=2; second=2
meeting_index=4; entry_index=2; cycle_length=4
same_values_without_cycle=None; empty=None; single=None; head_self_loop=1; head_cycle=2
enumerated_graphs=8477; enumerated_starts=50069; long_cases=2; rejected_inputs=4
PASS: identity/entry/length match visited oracle; core does not mutate links

把核心状态、观察轨迹和参考账本的空间分开计算

沿有限且稳定的 next 链,核心算法只保留固定数量的节点引用和计数器。按引用移动次数计,时间为 O(μ+λ),额外空间为 O(1) 个机器字;无环时按到达 None 前的节点数计为 O(n)。若严算任意大计数器的位数,整数存储也会随数值增长。

演示回调把每一步存进 events,标签字典保存节点编号;这些观察工具会占额外空间。visited_oracle 也会记录走过的身份。不能把整个测试程序和轨迹列表都算成常数空间;常数空间说的是 emit=None 时的核心定位过程。

函数不修改 next,也不修复环。它只检查从给定起点能沿 next 到达的那一条链;其他不相连的节点不在结果里。若并发修改链接、让观察回调改变图,或把多出边图直接塞进这个模型,本文的证明与测试前提就不再成立。

记住这些要点

  • 节点的值可以相等;成环检查比较对象身份。
  • 先移动再比较,避免把共同起点当作相遇证据。
  • 首次相遇、环入口和环长分别由三个阶段取得。
  • 常数额外空间只描述核心定位过程,轨迹和 visited 账本另计。

别把例子用过头

  • 用 slow.value == fast.value 判环,误报重复值链。
  • 把第一次相遇位置直接当成入口。
  • 忽略 None、头自环以及重置后已在入口的情况。
  • 一边遍历一边改 next,仍沿用静态链的证明。

自己试一小步

让首次相遇位置和入口不同,再把所有节点值改成相同数字,结果会怎样?

  1. 画至少7个节点,指定一个不在头部的环入口;先写下每条 next。
  2. 手算每轮快慢指针位置、重置后的同步移动,以及从入口绕回的步数。
  3. 保留同样连接,只修改所有 value,再运行两种算法对照。
  4. 去掉最后一条回边,并分别测试空链与头自环。

做到这些,就算完成

  • 节点编号与 value 分开记录。
  • 入口用对象身份核对,环长用一步一步走的账本核对。
  • 写清哪些状态属于核心算法,哪些为了显示而额外保存。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Competitive Programmer's Handbook:Cycle detection (新标签页)↗Antti Laaksonen / 作者 cphb 仓库 · 英文

    每节点具有唯一后继的模型,以及快慢指针相遇、定位入口、测量环长的三个阶段。

    2026-10-23 实际打开并阅读:Successor paths 的模型定义,以及 Cycle detection 全节正文和 Floyd 三阶段代码;实现与示例为本文原创;仅声明此范围,不代表通读整部资料。

  • Python 3.14:对象身份比较 (新标签页)↗Python Software Foundation · 英文

    is 判断是否为同一个对象;用户类可以自行定义值比较,不能由负载相等推断节点相同。

    2026-10-23 实际打开并阅读:6.10.3 Identity comparisons,以及6.10.1中默认与自定义值比较说明;仅声明此范围,不代表通读整部资料。