Fast & Slow Pointers: Phát hiện vòng lặp bằng thuật toán Floyd
Hướng dẫn Fast & Slow Pointers và Floyd’s Cycle Detection bằng TypeScript: phát hiện cycle, tìm entry, cycle length, functional graph và các giới hạn thực tế.
ShanverseCập nhật 12 phút đọc— lượt xemĐang tải lượt xem
Algorithms
Data Structures
Bài toán
Một Linked List bình thường kết thúc tại null:
Linked List có cycle quay lại một node đã đi qua:
Nếu duyệt bằng một con trỏ mà không lưu lịch sử, chương trình sẽ chạy mãi. Cách trực quan là dùng Set để ghi các node đã thấy, nhưng cần O(N) memory.
Floyd phát hiện cycle mà không cần lưu toàn bộ đường đi.
Bình luận
Mô hình node trong TypeScript
Điều quan trọng: cycle được xác định bằng object identity của node, không phải node.value. Hai node khác nhau có cùng value không tạo cycle.
Phase 1: phát hiện có cycle
slow đi 1 edge mỗi vòng.
fast đi 2 edge mỗi vòng.
Nếu fast hoặc fast.next là null, danh sách không có cycle.
Nếu slow === fast, hai con trỏ đang trỏ đến cùng một node và cycle tồn tại.
Guard phải kiểm tra đúng thứ tự
JavaScript short-circuit từ trái sang phải, nên chỉ đọc fast.next sau khi biết fast không phải null.
Trả về meeting point
Tách logic tìm điểm gặp giúp tái sử dụng cho các phase sau:
Meeting point nằm trong cycle, nhưng không nhất thiết là entry node.
Vì sao hai con trỏ chắc chắn gặp nhau?
Ký hiệu:
μ (mu): số edge từ head đến cycle entry.
λ (lambda): số edge trong cycle.
Sau khi slow vào cycle, fast cũng ở trong cycle. Mỗi iteration, khoảng cách tương đối của fast so với slow tăng 1 modulo λ:
Trên một vòng có λ vị trí, khoảng cách modulo λ cuối cùng trở thành 0, nên chúng gặp nhau trong tối đa λ bước sau khi cả hai cùng ở trong cycle.
Một cách viết đại số: tại meeting, nếu slow đã đi t bước thì fast đi 2t. Hiệu t là bội số của λ:
Phase 2: tìm cycle entry
Sau khi gặp nhau:
Đặt một pointer về head.
Giữ pointer còn lại tại meeting point.
Cho cả hai đi 1 bước mỗi lần.
Node gặp tiếp theo là cycle entry.
Trực giác toán học
Tại meeting point, quãng đường còn lại đến entry theo vòng bằng μ mod λ. Vì vậy, một pointer đi từ head và một pointer đi từ meeting cùng tốc độ sẽ đến entry cùng lúc.
Không cần biết trước μ hay λ.
Phase 3: đo cycle length
Từ một node trong cycle, đi một vòng cho đến khi quay lại node đó:
Kết quả là λ.
Phân tích đầy đủ cycle
tailLength = μ.
cycleLength = λ.
Tổng số node unique reachable từ head là μ + λ.
Ví dụ dựng Linked List có cycle
Độ phức tạp chính xác hơn
Nếu có cycle:
Detection: O(μ + λ).
Tìm entry: O(μ) sau meeting.
Đếm cycle length: O(λ).
Auxiliary space: O(1).
Nếu không có cycle, fast đi đến cuối trong O(N).
O(1) chỉ nói về memory phụ; input list vẫn chiếm O(N).
So sánh Floyd với Set
Visited Set phù hợp khi cần path, index lần đầu gặp hoặc cấu trúc không có một successor duy nhất. Floyd tối ưu khi transition deterministic và mỗi state có đúng một next state.
Floyd trên Functional Graph
Linked List là một functional graph: mỗi node có tối đa một cạnh đi ra. Floyd áp dụng cho mọi hàm chuyển trạng thái deterministic:
Nếu tập state hữu hạn và transition không kết thúc, dãy cuối cùng phải đi vào cycle theo nguyên lý pigeonhole.
Generic implementation có trạng thái kết thúc
equals phải phản ánh identity thật của state. Nếu so sánh object theo value không đầy đủ, có thể báo cycle giả.
Ví dụ: Happy Number
Với số nguyên dương, transition là tổng bình phương các chữ số. Dãy hoặc đến 1, hoặc lặp:
Đây là cycle detection trên state sequence, không cần Linked List object.
“Luồng dữ liệu” nào dùng được Floyd?
Floyd dùng được khi luồng là kết quả của một state transition có thể tái tạo:
Pseudo-random generator state.
Simulation deterministic.
State machine.
Index chain trong array.
Sequence xₙ₊₁ = f(xₙ).
Floyd không tự động dùng được cho event stream bất kỳ từ network, sensor hoặc message queue.
Vì sao?
Để fast đi hai bước, cần:
Có thể gọi next hai lần độc lập.
State không bị tiêu thụ mất.
Transition deterministic.
Có cách so sánh state identity/equality đúng.
Một AsyncIterator duy nhất chỉ phát event tiếp theo; không thể cho hare “đi nhanh gấp đôi” mà vẫn giữ tortoise ở vị trí cũ nếu không buffer hoặc clone iterator. Khi đã buffer toàn bộ lịch sử, lợi thế O(1) memory có thể biến mất.
Giá trị event lặp lại cũng không có nghĩa stream đã cycle. Hai event cùng payload có thể là hai sự kiện khác nhau.
Khi Floyd không phù hợp
Graph có nhiều nhánh
Nếu một node có nhiều outgoing edges, dùng:
DFS với white/gray/black state cho directed graph.
Union-Find cho một số bài undirected graph.
Strongly Connected Components để phân tích cycle component.
Floyd chỉ theo một successor path.
Nondeterministic transition
Nếu next(state) có thể trả kết quả khác nhau giữa hai lần gọi, slow và fast không còn đi trên cùng một sequence.
Structure đang bị mutate
Nếu thread/callback khác sửa next trong lúc chạy, invariant bị phá. Có thể bỏ lỡ cycle, báo sai hoặc crash. Cần snapshot, lock hoặc ownership rõ ràng.
Cần đường đi đầy đủ
Floyd chỉ cho thông tin cycle; nếu cần mọi node đã đi qua, predecessor chain hoặc debug trace, dùng Set/Map hay lưu path.
Infinite acyclic sequence
Trên state space vô hạn như x → x + 1, Floyd không bao giờ kết thúc. Production code cần iteration budget, timeout hoặc cancellation.
Version có budget
limit-reached không có nghĩa “không cycle”; chỉ là chưa chứng minh được trong budget.
Các ứng dụng khác của Fast & Slow Pointers
Tìm middle của Linked List
Với list chẵn, code trả middle thứ hai. Nếu cần middle thứ nhất, guard/chuyển động phải thay đổi.
Kiểm tra palindrome Linked List
Tìm middle bằng fast/slow, reverse nửa sau, rồi so sánh hai nửa. Nếu API không được mutate input, phải restore nửa sau hoặc dùng memory bổ sung.
Tìm k-th node từ cuối
Đưa fast đi trước k bước rồi cho slow/fast cùng đi; khi fast đến cuối, slow ở vị trí cần tìm. Đây là two pointers với khoảng cách cố định, không phải Floyd cycle detection.
Lỗi thường gặp
1. So sánh value thay vì node
Phải so sánh identity:
2. Dereference fast.next.next không guard
Luôn kiểm tra fast !== null && fast.next !== null.
3. Reset sai pointer khi tìm entry
Sau meeting, reset một pointer về head, rồi cho cả hai đi 1 bước. Không tiếp tục hare đi 2 bước.
4. Meeting point bị hiểu là entry
Hai node có thể gặp ở bất kỳ vị trí nào trong cycle.
5. Dùng cho graph branching
Floyd chỉ phát hiện cycle trên path do một successor function tạo ra.
6. Cho rằng dữ liệu lặp nghĩa là state cycle
Payload trùng không đảm bảo cùng identity hay cùng future transition.
7. Không đặt budget cho sequence không bảo đảm hữu hạn
Có thể treo vĩnh viễn.
8. Mutate list để “đánh dấu” node
Thay đổi input có thể phá dữ liệu, không an toàn khi shared, và không cần thiết với Floyd.
Edge cases cần test
head = null.
Một node kết thúc tại null.
Một node self-loop.
Hai node không cycle.
Hai node tạo cycle.
Cycle bắt đầu ngay tại head (μ = 0).
Tail dài, cycle ngắn.
Tail ngắn, cycle dài.
Nhiều node có cùng value nhưng không cycle.
Entry, cycle length và tail length đúng.
Structure bị mutate phải bị cấm hoặc kiểm soát.
Generic transition kết thúc tại null.
Infinite acyclic transition trả limit-reached.
Cách tiếp cận chuẩn
Xác nhận mỗi state có tối đa một successor deterministic.
Xác định equality: object identity hay value canonical đầy đủ.
Dùng slow 1 bước, fast 2 bước.
Guard termination trước khi dereference.
Nếu chỉ cần boolean, dừng tại meeting.
Nếu cần entry, reset một pointer về start và đi cùng tốc độ.
Nếu cần λ, đi một vòng từ meeting.
Nếu state space có thể vô hạn, thêm budget/cancellation.
Không dùng trực tiếp cho branching graph hoặc arbitrary event stream.
Test bằng Set-based oracle trên input nhỏ.
Kết luận
Floyd’s Cycle Detection dựa trên chênh lệch vận tốc:
Nếu có cycle, hai pointer gặp nhau. Từ meeting point, reset một pointer về head để tìm entry; đi một vòng để đo cycle length. Thuật toán đạt O(μ + λ) time và O(1) memory.
Điều kiện quan trọng nhất không phải dữ liệu được gọi là “stream”, mà là nó tạo thành một sequence deterministic với đúng một successor cho mỗi state. Với Linked List và functional graph, Floyd là lựa chọn thanh lịch; với graph phân nhánh, event stream bất định hoặc nhu cầu lưu path, hãy dùng thuật toán/cấu trúc phù hợp khác.
Hướng dẫn Trie (Prefix Tree) để xây dựng Search Suggestion và Autocomplete: TypeScript, top-K ranking, Unicode normalization, radix tree và production architecture.