Linked List — Khi Array không đủ linh hoạt
Ở bài trước, chúng ta đã thấy điểm yếu chí mạng của Array: Chèn và xóa ở giữa mảng cực kỳ tốn kém (O(n)). Nếu bạn đang làm một ứng dụng yêu cầu thay đổi thứ tự dữ liệu liên tục, Array sẽ bóp nghẹt hiệu năng của hệ thống.
Chào mừng bạn đến với cấu trúc dữ liệu khắc phục trực tiếp nhược điểm đó: Linked List (Danh sách liên kết).
📋 Agenda
Thời gian đọc ước tính: ~10 phút
Sau bài này, bạn sẽ:
- ✅ Hiểu được cơ chế hoạt động của Linked List dưới bộ nhớ.
- ✅ Tự tay implement một Singly Linked List hoàn chỉnh từ đầu bằng TypeScript.
- ✅ Giải thích được tại sao thêm/xóa trong Linked List chỉ tốn
O(1). - ✅ Phân biệt được lúc nào nên dùng Array, lúc nào nên dùng Linked List.
Yêu cầu đầu vào:
- 🔹 Có kiến thức cơ bản về Class và Generics trong TypeScript.
❓ Tại sao lại cần Linked List?
Vấn đề (Problem Statement):
Mảng (Array) đòi hỏi một khối lượng bộ nhớ liên tiếp. Khi máy chủ của bạn phân mảnh bộ nhớ (có nhiều vùng trống nhỏ nhưng không có vùng trống nào đủ lớn liền mạch), hệ điều hành sẽ từ chối cấp phát bộ nhớ cho một Array khổng lồ, dẫn đến lỗi "Out of Memory".
Hơn nữa, mỗi lần unshift() hoặc splice() trên Array, hàng ngàn phần tử phải dịch chuyển vị trí rất tốn CPU.
Giải pháp (Solution): Linked List giải quyết bài toán này bằng cách phân tán dữ liệu. Mỗi phần tử (Node) nằm ở bất kỳ đâu trong bộ nhớ. Để chúng không bị lạc mất nhau, Node hiện tại sẽ cầm một "sợi dây" trỏ thẳng đến vị trí của Node tiếp theo. Việc chèn thêm một phần tử mới giờ đây chỉ đơn giản là... cắt đứt sợi dây và nối lại vào người mới. Không ai phải dịch chuyển cả!
📖 Linked List hoạt động thế nào?
Định nghĩa: Linked List là một tập hợp các nút (Nodes). Mỗi Node chứa hai thông tin: Dữ liệu (Value) và Tham chiếu (Pointer/Next) trỏ tới Node tiếp theo trong danh sách.
Trực quan hóa kiến trúc
- Head: Điểm bắt đầu. Mất Head là mất toàn bộ danh sách.
- Tail: Nút cuối cùng, con trỏ
nextcủa nó luôn lànull. - Node: Viên gạch cấu trúc cơ bản gồm
valuevànext.
So sánh Time Complexity với Array
| Thao tác | Array | Linked List | Lý do của Linked List |
|---|---|---|---|
| Đọc (Read) | O(1) 🏆 | O(n) | Không có index, phải duyệt từ Head |
| Thêm/Xóa cuối | O(1) | O(1) | Có con trỏ Tail trỏ sẵn về cuối |
| Thêm/Xóa đầu | O(n) | O(1) 🏆 | Chỉ cần nối lại con trỏ ở Head |
| Thêm/Xóa giữa | O(n) | O(n) tìm kiếm + O(1) chèn | Phải tìm đến vị trí cần chèn (O(n)), nhưng thao tác chèn chỉ là đổi tham chiếu (O(1)) |
🔨 Tự tay implement bằng TypeScript
Chúng ta sẽ không dùng thư viện. Áp dụng chuẩn "Interface-first", hãy cùng định nghĩa hợp đồng trước:
Bước 1: Khởi tạo Interface & Class Node
// filename: linked-list.ts
// 💡 1. Khởi tạo một "Viên gạch" (Node)
class ListNode<T> {
value: T;
next: ListNode<T> | null;
constructor(value: T) {
this.value = value;
this.next = null; // Mặc định chưa trỏ đi đâu cả
}
}
// 💡 2. Hợp đồng quy định các thao tác có thể làm với List
interface ILinkedList<T> {
append(value: T): void; // Thêm vào cuối
prepend(value: T): void; // Thêm vào đầu
find(value: T): ListNode<T> | null; // Tìm kiếm
delete(value: T): void; // Xóa một giá trị
}
Bước 2: Implement Logic
// filename: linked-list.ts
class LinkedList<T> implements ILinkedList<T> {
private head: ListNode<T> | null = null;
private tail: ListNode<T> | null = null; // Theo dõi Tail để append O(1)
// 1. Thêm vào cuối (Append) - O(1)
append(value: T): void {
const newNode = new ListNode(value);
// Nếu list trống, Node mới là Head và Tail
if (!this.head || !this.tail) {
this.head = newNode;
this.tail = newNode;
return;
}
// Nếu đã có Tail, trỏ Tail hiện tại sang Node mới
this.tail.next = newNode;
// Cập nhật lại Tail chính là Node mới
this.tail = newNode;
}
// 2. Thêm vào đầu (Prepend) - O(1) 🏆 (Thứ mà Array thèm khát)
prepend(value: T): void {
const newNode = new ListNode(value);
// Nối Node mới với Head cũ
newNode.next = this.head;
// Cập nhật Head mới
this.head = newNode;
if (!this.tail) {
this.tail = newNode;
}
}
// 3. Xóa một Node (Delete)
delete(value: T): void {
if (!this.head) return;
// Trường hợp cần xóa chính là Head
if (this.head.value === value) {
this.head = this.head.next; // Dịch Head sang Node thứ 2
// Nếu danh sách vừa xóa xong thành rỗng
if (this.head === null) this.tail = null;
return;
}
// Duyệt tìm Node chứa giá trị
let current = this.head;
// 💡 Logic chèn/xóa: current.next === Node cần xóa
while (current.next) {
if (current.next.value === value) {
// Cắt đứt liên kết, trỏ vượt qua Node cần xóa
current.next = current.next.next;
// Nếu vừa xóa trúng Tail, phải cập nhật lại Tail
if (current.next === null) {
this.tail = current;
}
return;
}
current = current.next;
}
}
}