Algos 101: Bài 4, Hướng dẫn chi tiết cách giải bài Remove Nth Node From End of List bằng Python
Chào các bạn! Trong quá trình chuẩn bị cho các buổi phỏng vấn kỹ thuật tại các công ty công nghệ lớn, các bài toán liên quan đến Danh Sách Liên Kết (Linked List) luôn là một chủ đề cực kỳ quen thuộc. Một trong những bài toán kinh điển nhất chính là Remove Nth Node From End of List (Xóa node thứ N tính từ cuối danh sách).
Hôm nay, chúng ta sẽ cùng nhau đi từ ý tưởng thô sơ đến giải pháp tối ưu chỉ với một lần duyệt duy nhất bằng kỹ thuật hai con trỏ (Fast & Slow Pointers).
📌 Phân Tích Đề Bài
Đề bài yêu cầu: Cho đầu (head) của một danh sách liên kết, hãy xóa node thứ n tính từ cuối danh sách và trả về đầu danh sách sau khi đã chỉnh sửa.
- Input:
head = [1, 2, 3, 4, 5],n = 2 - Output:
[1, 2, 3, 5] - Giải thích: Node thứ 2 tính từ cuối là node có giá trị
4. Sau khi xóa node này, danh sách còn lại là[1, 2, 3, 5].
Các Ràng Buộc (Constraints):
- Số lượng node trong danh sách là
szvà \(1 \le sz \le 30\). - Giá trị mỗi node: \(0 \le Node.val \le 100\).
- Vị trí cần xóa: \(1 \le n \le sz\).
- Thách thức: Giải bài toán in-place (không dùng thêm bộ nhớ phụ trợ) với độ phức tạp thời gian là $O(N)$ và chỉ duyệt qua danh sách đúng một lần (single pass).
💡 Ý Tưởng Thuật Toán
1. Cách Tiếp Cận Duyệt Hai Lần (Brute-Force)
Ý tưởng đơn giản nhất xuất hiện ngay trong đầu chúng ta là:
- Duyệt qua toàn bộ danh sách để đếm tổng số node (gọi là
L). - Node thứ
ntừ cuối thực chất là node thứL - n + 1từ đầu. - Duyệt lại lần thứ hai đến node thứ
L - n(node ngay trước node cần xóa), cập nhật con trỏnextcủa nó bỏ qua node thứL - n + 1.
- Độ phức tạp:
- Thời gian: $O(N)$ (nhưng phải duyệt danh sách 2 lần).
- Không gian: $O(1)$.
Câu hỏi đặt ra từ người phỏng vấn: "Làm thế nào để giải quyết bài toán này chỉ với duy nhất một lần duyệt?"
2. Giải Pháp Tối Ưu: Kỹ Thuật Hai Con Trỏ (Fast & Slow Pointers)
Để tìm được node thứ n từ cuối chỉ trong 1 lần duyệt, chúng ta có thể tạo ra một khoảng cách cố định bằng n giữa hai con trỏ:
- Đặt một con trỏ
fastđi trước. Cho nó đi trướcnbước. - Sau đó, cho cả hai con trỏ
fastvàslowcùng di chuyển với tốc độ như nhau (mỗi lượt 1 bước). - Khi con trỏ
fastchạm đến cuối danh sách (giá trịNone), con trỏslowsẽ dừng lại ngay tại vị trí node ngay trước node cần xóa.
Sử Dụng Node Giả (Dummy Node)
Một kỹ thuật cực kỳ quan trọng khi xử lý Linked List là sử dụng một Dummy Node. dummy node trỏ đến head giúp chúng ta:
- Tránh việc phải xử lý trường hợp đặc biệt khi node cần xóa chính là node đầu tiên (
head). - Giữ tham chiếu đến đầu danh sách mới một cách dễ dàng thông qua
dummy.next.
📊 Minh Họa Từng Bước Chạy (State Walkthrough)
Hãy cùng chạy thử ví dụ với head = [1, 2, 3, 4, 5] và n = 2.
Khởi tạo: Tạo
dummytrỏ đếnhead. Đặtfastvàslowởdummy.[dummy] -> [1] -> [2] -> [3] -> [4] -> [5] -> None ^ fast, slowCho
fastđi trướcn + 1bước (ở đây là 3 bước):- Bước 1:
fasttrỏ tới1 - Bước 2:
fasttrỏ tới2 - Bước 3:
fasttrỏ tới3
[dummy] -> [1] -> [2] -> [3] -> [4] -> [5] -> None ^ ^ slow fast- Bước 1:
Di chuyển cả
fastvàslowcùng lúc cho đến khifastlàNone:- Lần dịch 1:
[dummy] -> [1] -> [2] -> [3] -> [4] -> [5] -> None ^ ^ slow fast - Lần dịch 2:
[dummy] -> [1] -> [2] -> [3] -> [4] -> [5] -> None ^ ^ slow fast - Lần dịch 3:
Lúc này[dummy] -> [1] -> [2] -> [3] -> [4] -> [5] -> None ^ ^ slow fastfastđã trở thànhNone. Dừng vòng lặp.
- Lần dịch 1:
Xóa node:
slow.nexthiện tại trỏ đến4. Ta thực hiệnslow.next = slow.next.next(bỏ qua4và trỏ thẳng tới5).[dummy] -> [1] -> [2] -> [3] ------> [5] -> None ^ slow
Trả về dummy.next chính là [1, 2, 3, 5].
💻 Cài Đặt Python (Python Implementation)
Dưới đây là mã nguồn Python hoàn chỉnh được triển khai tối ưu:
from typing import Optional
class ListNode:
def __init__(self, val: int = 0, next: Optional['ListNode'] = None):
self.val = val
self.next = next
def remove_nth_from_end(head: Optional[ListNode], n: int) -> Optional[ListNode]:
if not head:
return None
# Tạo dummy node trỏ đến head để xử lý trường hợp xóa node đầu tiên dễ dàng
dummy = ListNode(0, head)
fast = dummy
# 1. Cho con trỏ fast đi trước n + 1 bước
for i in range(n + 1):
if fast is None:
return head
fast = fast.next
slow = dummy
# 2. Di chuyển cả hai con trỏ cùng lúc
while fast:
fast = fast.next
slow = slow.next
# 3. slow hiện tại ở ngay trước node cần xóa. Thực hiện xóa node.
if slow and slow.next:
slow.next = slow.next.next
# Trả về head thực sự của danh sách đã cập nhật
return dummy.next
📈 Phân Tích Độ Phức Tạp
- Thời gian (Time Complexity): $O(N)$ với $N$ là số lượng node trong danh sách. Chúng ta chỉ duyệt qua danh sách đúng một lần duy nhất bằng con trỏ
fast. - Không gian (Space Complexity): $O(1)$ auxiliary space. Chúng ta chỉ sử dụng một vài con trỏ bổ sung (
fast,slow,dummy) và thay đổi liên kết trực tiếp trên các node có sẵn.
💡 Trường Hợp Biên & Lưu Ý Khi Phỏng Vấn
Khi phỏng vấn về danh sách liên kết, các kỹ sư phỏng vấn rất chú trọng đến cách bạn xử lý các góc cạnh (edge cases):
- Xóa node đầu tiên (Head): Khi \(n = sz\), node cần xóa chính là
head. Nhờ códummy nodenằm ở trướchead,slowsẽ đứng ởdummyvàslow.next = slow.next.nextsẽ tự động cập nhậtheadmới một cách trơn tru. - Danh sách chỉ có 1 phần tử: Ví dụ
head = [1],n = 1.dummytrỏ tới1.fastdịch 2 bước thànhNone.slowvẫn ởdummy. Thực hiện xóa node1, trả vềNone. Code hoạt động hoàn hảo. - Hỏi về Garbage Collection: Trong Python, khi ta ngắt liên kết
slow.next = slow.next.next, node bị ngắt kết nối sẽ không còn được tham chiếu và sẽ được dọn dẹp tự động bởi bộ dọn rác (Garbage Collector). Tuy nhiên, trong C/C++, bạn cần phải giải phóng vùng nhớ (free/delete) của node bị xóa để tránh rò rỉ bộ nhớ (memory leak).
🏁 Kết Luận
Kỹ thuật khoảng cách cố định (sliding window/two pointers) áp dụng trên Linked List cực kỳ hiệu quả để giải quyết các bài toán liên quan đến vị trí tương đối từ cuối lên mà không cần biết trước chiều dài danh sách.
Chúc các bạn luyện tập tốt và chinh phục thành công các buổi phỏng vấn DSA sắp tới! Đừng ngần ngại để lại bình luận nếu có bất kỳ thắc mắc nào nhé! 😉



