Bài 33: Linked List — Danh Sách Liên Kết¶
Tác giả: FPTOJ Team
Nội dung tham khảo từ: GeeksforGeeks, CP-Algorithms
Bản chất vấn đề¶
Bài toán: Quản lý danh sách thay đổi kích thước¶
Bạn có một playlist nhạc. Người dùng có thể thêm bài vào giữa, xóa bài ở bất kỳ vị trí nào, hoặc di chuyển bài hát.
Với mảng (array): Thêm hoặc xóa ở giữa buộc phải dời toàn bộ phần tử phía sau — mất \(O(N)\) mỗi lần.
Với linked list: Thêm hoặc xóa tại một vị trí (khi đã có con trỏ trỏ đến node) chỉ mất \(O(1)\) vì chỉ cần đổi vài con trỏ.
So sánh mảng và linked list¶
| Thao tác | Mảng | Linked List |
|---|---|---|
| Truy cập phần tử thứ \(i\) | \(O(1)\) — tính địa chỉ | \(O(N)\) — phải duyệt từ đầu |
| Thêm/xóa ở đầu | \(O(N)\) — dời toàn bộ | \(O(1)\) — đổi con trỏ |
| Thêm/xóa ở cuối | \(O(1)\) amortized | \(O(1)\) nếu biết tail |
| Thêm/xóa ở giữa (khi có con trỏ) | \(O(N)\) | \(O(1)\) |
| Bộ nhớ | Liền mạch, cache-friendly | Phân tán, cache-unfriendly |
Kết luận: Linked list tối ưu khi thao tác chèn/xóa diễn ra thường xuyên, không cần truy cập ngẫu nhiên.
Tư duy cốt lõi¶
Cấu trúc của singly linked list¶
Mỗi node chứa hai phần: giá trị data và con trỏ next trỏ đến node kế tiếp. Node cuối cùng có next là nullptr.
Thao tác 1: Thêm node vào đầu — \(O(1)\)¶
Tạo node mới, cho next của nó trỏ vào head cũ, rồi cập nhật head trỏ sang node mới.
Thao tác 2: Thêm node vào giữa — \(O(1)\) (khi có con trỏ)¶
Khi đã có con trỏ trỏ đến node đứng trước vị trí cần chèn, chỉ cần 2 bước: cho next của node mới trỏ vào node sau, rồi cho next của node trước trỏ vào node mới.
Thao tác 3: Xóa node — \(O(1)\) (khi có con trỏ node trước)¶
Cho next của node trước nhảy qua node cần xóa, trỏ thẳng đến node sau. Node bị xóa trở thành "mồ côi", cần giải phóng bộ nhớ.
Cài đặt singly linked list¶
Doubly Linked List¶
Mỗi node có hai con trỏ: prev (trước) và next (sau). Cho phép duyệt ngược và xóa node trong \(O(1)\) mà không cần biết node trước.
Ứng dụng: Stack và Queue bằng linked list¶
LRU Cache¶
LRU Cache (Least Recently Used) kết hợp Doubly Linked List với Hash Map:
- Duyệt và xóa bất kỳ node: \(O(1)\)
- Đưa node lên đầu danh sách: \(O(1)\)
- Tra cứu key: \(O(1)\) qua hash map
Đây là cấu trúc nền tảng cho nhiều hệ thống thực tế như bộ nhớ đệm trình duyệt, quản lý trang trong OS.
Phân tích tính đúng đắn¶
Tại sao thêm/xóa là \(O(1)\) khi có con trỏ?¶
Giả sử có con trỏ p trỏ đến node cần thao tác. Thao tác thêm node mới q sau p chỉ gồm:
q->next = p->next— gán 1 con trỏp->next = q— gán 1 con trỏ
Không có vòng lặp, không phụ thuộc vào kích thước danh sách. Do đó độ phức tạp là \(O(1)\).
Tại sao tìm kiếm là \(O(N)\)?¶
Linked list không hỗ trợ truy cập ngẫu nhiên. Muốn tìm phần tử có giá trị key, phải duyệt từ head lần lượt theo con trỏ next cho đến khi tìm thấy hoặc đến cuối danh sách. Trường hợp xấu nhất duyệt qua toàn bộ \(N\) node.
Mất con trỏ — lỗi phổ biến nhất¶
Khi xóa một node, nếu quên lưu con trỏ đến node đó trước khi đổi liên kết, bộ nhớ bị rò rỉ (memory leak):
- Phải lưu node cần xóa vào biến tạm (
temp) - Nối node trước với node sau
- Giải phóng biến tạm
Thứ tự này rất quan trọng. Nếu giải phóng trước khi nối, toàn bộ danh sách phía sau bị mất.
Đánh giá độ phức tạp¶
Độ phức tạp thời gian¶
| Thao tác | Singly Linked List | Doubly Linked List |
|---|---|---|
| Tìm kiếm | \(O(N)\) | \(O(N)\) |
| Thêm vào đầu | \(O(1)\) | \(O(1)\) |
| Thêm vào cuối (có tail) | \(O(1)\) | \(O(1)\) |
| Thêm vào cuối (không tail) | \(O(N)\) | \(O(N)\) |
| Xóa node khi có con trỏ | \(O(1)\) | \(O(1)\) |
| Xóa node khi chỉ biết giá trị | \(O(N)\) | \(O(N)\) |
| Duyệt xuôi | \(O(N)\) | \(O(N)\) |
| Duyệt ngược | Không hỗ trợ | \(O(N)\) |
Độ phức tạp bộ nhớ¶
- Singly Linked List: Mỗi node lưu 1 giá trị + 1 con trỏ → \(O(N)\) bộ nhớ, hằng số nhân lớn hơn mảng.
- Doubly Linked List: Mỗi node lưu 1 giá trị + 2 con trỏ → \(O(N)\) bộ nhớ, gấp rưỡi singly.
- Mảng: Chỉ lưu giá trị, overhead tối thiểu.
Khi nào nên dùng linked list?¶
- Cần chèn/xóa thường xuyên ở đầu hoặc giữa danh sách
- Không biết trước kích thước dữ liệu
- Cần triển khai stack, queue, hoặc các cấu trúc dữ liệu khác
- Cần duyệt ngược (doubly linked list)
Khi nào không nên dùng linked list?¶
- Cần truy cập ngẫu nhiên phần tử thứ \(i\)
- Dữ liệu nhỏ và cố định (mảng hiệu quả hơn)
- Yêu cầu cache performance cao
Bài tập luyện tập¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| Reverse Linked List | LeetCode | Dễ | Đảo ngược linked list |
| Merge Two Sorted Lists | LeetCode | Trung bình | Gộp 2 linked list |
| Linked List Cycle | LeetCode | Trung bình | Phát hiện chu trình (Floyd) |
| Remove Nth Node From End | LeetCode | Trung bình | Two pointers |
| LRU Cache | LeetCode | Khó | Doubly LL + Hash Map |
| Josephus Problem I | CSES | Trung bình | Vòng tròn xóa người |
| Josephus Problem II | CSES | Khó | Josephus với skip |
Tài liệu tham khảo¶
Bài tiếp theo: Bài 34: Queue cơ bản