开始前,会这些就够了
- 认识对象、变量和 while 循环;能顺着 next 找到下一个节点。
- 先读 数组、链表、栈与队列,分清节点中的值与连接关系。
- 先读 图的遍历与拓扑排序:比较一般有向图与每节点至多一个next的特殊输入条件。
遇到不熟悉的词,先看这 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,如何判断是同一个节点?
正在准备实验控件…
节点编号表示不同对象的身份,数值都固定为 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 开关。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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,仍沿用静态链的证明。
自己试一小步
让首次相遇位置和入口不同,再把所有节点值改成相同数字,结果会怎样?
- 画至少7个节点,指定一个不在头部的环入口;先写下每条 next。
- 手算每轮快慢指针位置、重置后的同步移动,以及从入口绕回的步数。
- 保留同样连接,只修改所有 value,再运行两种算法对照。
- 去掉最后一条回边,并分别测试空链与头自环。
做到这些,就算完成
- 节点编号与 value 分开记录。
- 入口用对象身份核对,环长用一步一步走的账本核对。
- 写清哪些状态属于核心算法,哪些为了显示而额外保存。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Competitive Programmer's Handbook:Cycle detection (新标签页)↗
每节点具有唯一后继的模型,以及快慢指针相遇、定位入口、测量环长的三个阶段。
- Python 3.14:对象身份比较 (新标签页)↗
is 判断是否为同一个对象;用户类可以自行定义值比较,不能由负载相等推断节点相同。