🔗 Linked List – Danh sách liên kết
Linked List gồm các node được nối bằng con trỏ. Bài học tập trung vào cách tổ chức node, thay đổi liên kết khi chèn/xóa, duyệt danh sách, đảo danh sách và kỹ thuật slow/fast pointer.
🎯 Mục tiêu bài học
Cần hiểu
- Node, con trỏ
next,headvàtail. - Vì sao Linked List không hỗ trợ truy cập ngẫu nhiên O(1).
- Cách đổi liên kết khi chèn, xóa và đảo danh sách.
- Vai trò của node trước khi xóa.
- Kỹ thuật slow/fast pointer.
Cần làm được
- Chèn đầu, chèn cuối, chèn theo vị trí.
- Xóa đầu, xóa giá trị đầu tiên, xóa theo vị trí.
- Tìm kiếm và duyệt danh sách.
- Đảo danh sách liên kết đơn.
- Tìm node giữa và phát hiện chu kỳ.
1. Đặt vấn đề
Ta quản lý một dãy dữ liệu thường xuyên chèn và xóa ở đầu hoặc giữa. Nếu dùng mảng/vector, các phần tử phía sau có thể phải dịch chuyển.
| Thao tác | Vector/mảng | Linked List | Điều kiện |
|---|---|---|---|
| Truy cập phần tử thứ i | O(1) | O(i) | Linked List phải đi từ head. |
| Chèn đầu | O(n) | O(1) | Chỉ đổi một vài con trỏ. |
| Xóa đầu | O(n) | O(1) | Cập nhật head. |
| Chèn/xóa sau node đã biết | O(n) | O(1) | Đã có con trỏ tới node cần thao tác. |
| Tìm một giá trị | O(n) | O(n) | Đều phải duyệt trong trường hợp tổng quát. |
1.1. Ý tưởng thay vì dịch chuyển phần tử
Mỗi phần tử được đặt trong một node. Node lưu dữ liệu và địa chỉ node tiếp theo. Chèn/xóa được thực hiện bằng cách thay đổi liên kết.
2. Cấu tạo node và danh sách
giá trị
địa chỉ node sau
struct Node {
int data;
Node* next;
Node(int value)
: data(value), next(nullptr) {}
};
head
Trỏ tới node đầu tiên. Nếu danh sách rỗng, head == nullptr.
tail
Trỏ tới node cuối. Giúp chèn cuối O(1), nhưng phải cập nhật đúng khi danh sách thay đổi.
next
Node cuối có next == nullptr. Đây là điều kiện dừng khi duyệt.
3. Mô phỏng Linked List tương tác
Danh sách hiện tại
4. Các thao tác quan trọng
4.1. Chèn đầu
- Tạo node mới.
- Cho node mới trỏ tới head cũ.
- Cập nhật head thành node mới.
- Nếu danh sách ban đầu rỗng, cập nhật cả tail.
4.2. Chèn cuối
- Tạo node mới có next = nullptr.
- Nếu rỗng, head = tail = node mới.
- Nếu không rỗng, nối tail cũ tới node mới.
- Cập nhật tail.
4.3. Xóa một node
- Tìm node cần xóa và node đứng trước.
- Nối node trước tới node sau node bị xóa.
- Cập nhật head/tail nếu cần.
- Giải phóng bộ nhớ bằng delete.
4.4. Tìm kiếm
- Bắt đầu từ head.
- So sánh dữ liệu node hiện tại.
- Nếu chưa thấy, đi theo next.
- Dừng khi tìm thấy hoặc cur == nullptr.
head->data khi head có thể là nullptr. Luôn kiểm tra danh sách rỗng trước khi truy cập node đầu.5. Đảo Linked List bằng ba con trỏ
Ta cần giữ node tiếp theo trước khi đổi liên kết, nếu không sẽ mất phần còn lại của danh sách.
current->next = previous trước khi lưu nextNode, làm mất đường đi tới phần còn lại.6. Slow/Fast Pointer
6.1. Tìm node giữa
slow đi một bước, fast đi hai bước. Khi fast tới cuối, slow ở giữa.
6.2. Phát hiện chu kỳ
Nếu danh sách có chu kỳ, slow và fast cuối cùng sẽ gặp nhau. Nếu fast đạt nullptr, danh sách không có chu kỳ.
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
7. So sánh Linked List và vector
| Tiêu chí | vector | Linked List |
|---|---|---|
| Bộ nhớ | Liên tiếp, tốt cho cache. | Rời rạc, mỗi node tốn thêm con trỏ. |
| Truy cập chỉ số | O(1). | O(n). |
| Chèn/xóa đầu | O(n). | O(1). |
| Chèn/xóa giữa | O(n) do dịch chuyển. | O(1) nếu đã có node trước; O(n) nếu phải tìm. |
| Duyệt | Nhanh do cache locality. | Thường chậm hơn do truy cập bộ nhớ rời rạc. |
| STL tương ứng | vector. | forward_list, list. |
8. Phân tích thuật toán
| Thao tác | Không có tail | Có tail / có node trước | Ghi chú |
|---|---|---|---|
| Chèn đầu | O(1) | O(1) | Cập nhật head. |
| Chèn cuối | O(n) | O(1) với tail | Phải cập nhật tail. |
| Tìm giá trị | O(n) | O(n) | Không có truy cập ngẫu nhiên. |
| Xóa theo giá trị | O(n) | O(n) | Phải tìm node trước. |
| Xóa sau node đã biết | O(1) | O(1) | Không áp dụng nếu node cần xóa là head. |
| Đảo danh sách | O(n) | O(n) | O(1) bộ nhớ phụ. |
| Tìm giữa / chu kỳ | O(n) | O(n) | Slow/Fast, O(1) bộ nhớ. |
9. Code mẫu
#include <bits/stdc++.h>
using namespace std;
struct Node {
int data;
Node* next;
Node(int value)
: data(value), next(nullptr) {}
};
struct LinkedList {
Node* head = nullptr;
Node* tail = nullptr;
bool empty() const {
return head == nullptr;
}
void print() const {
for (Node* current = head;
current != nullptr;
current = current->next) {
cout << current->data << ' ';
}
}
~LinkedList() {
while (head != nullptr) {
Node* old = head;
head = head->next;
delete old;
}
}
};void pushFront(int value) {
Node* node = new Node(value);
node->next = head;
head = node;
if (tail == nullptr) {
tail = node;
}
}
void pushBack(int value) {
Node* node = new Node(value);
if (tail == nullptr) {
head = tail = node;
return;
}
tail->next = node;
tail = node;
}
bool insertAt(int position, int value) {
if (position < 0) return false;
if (position == 0) {
pushFront(value);
return true;
}
Node* previous = head;
for (int index = 1;
index < position && previous != nullptr;
index++) {
previous = previous->next;
}
if (previous == nullptr) return false;
if (previous == tail) {
pushBack(value);
return true;
}
Node* node = new Node(value);
node->next = previous->next;
previous->next = node;
return true;
}bool popFront() {
if (head == nullptr) return false;
Node* old = head;
head = head->next;
if (head == nullptr) {
tail = nullptr;
}
delete old;
return true;
}
bool eraseFirst(int value) {
if (head == nullptr) return false;
if (head->data == value) {
return popFront();
}
Node* previous = head;
Node* current = head->next;
while (current != nullptr
&& current->data != value) {
previous = current;
current = current->next;
}
if (current == nullptr) return false;
previous->next = current->next;
if (current == tail) {
tail = previous;
}
delete current;
return true;
}void reverseList() {
Node* previous = nullptr;
Node* current = head;
tail = head;
while (current != nullptr) {
Node* nextNode = current->next;
current->next = previous;
previous = current;
current = nextNode;
}
head = previous;
}Node* middleNode(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast != nullptr
&& fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
bool hasCycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast != nullptr
&& fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true;
}
}
return false;
}#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> values = {10, 20, 30};
values.push_front(5);
values.push_back(40);
auto iterator = values.begin();
advance(iterator, 2);
values.insert(iterator, 15);
for (int value : values) {
cout << value << ' ';
}
}10. Giải thích code
10.1. Vì sao constructor gán next = nullptr?
Node mới chưa nối tới node nào. Khởi tạo rõ ràng tránh con trỏ rác và giúp node có thể trở thành node cuối.
10.2. Vì sao pushFront phải cập nhật tail khi danh sách rỗng?
Node mới đồng thời là node đầu và node cuối. Nếu chỉ cập nhật head, tail sẽ sai.
10.3. Vì sao xóa phải giữ node previous?
Danh sách liên kết đơn không có con trỏ lùi. Muốn bỏ current khỏi chuỗi, phải thay đổi previous->next.
10.4. Vì sao phải delete node bị xóa?
Node được tạo bằng new. Nếu bỏ liên kết mà không delete, vùng nhớ vẫn tồn tại nhưng không còn truy cập được, gây memory leak.
10.5. Vì sao reverse cần nextNode?
Sau khi đổi current->next, liên kết cũ bị mất. nextNode giữ đường tới phần còn lại.
10.6. Danh sách chẵn có node giữa nào?
Với cách slow đi 1 và fast đi 2 từ head, danh sách chẵn trả về node giữa thứ hai. Có thể thay khởi tạo hoặc điều kiện nếu đề yêu cầu node giữa thứ nhất.
10.7. Vì sao destructor cần duyệt và delete toàn bộ?
Mỗi node được cấp phát động. Destructor giải phóng từng node để tránh rò rỉ bộ nhớ khi đối tượng LinkedList bị hủy.
11. Lỗi thường gặp
- Truy cập
head->datakhi head là nullptr. - Chèn vào danh sách rỗng nhưng quên cập nhật tail.
- Xóa node cuối nhưng không cập nhật tail.
- Thay đổi liên kết trước khi lưu node tiếp theo.
- Xóa liên kết nhưng quên
delete, gây memory leak. deletenode rồi tiếp tục dùng con trỏ tới node đó.- Cho rằng chèn/xóa theo chỉ số là O(1); thực tế phải tìm vị trí O(n).
- Không xử lý riêng vị trí 0 khi chèn/xóa.
- Slow/Fast nhưng không kiểm tra
fastvàfast->next. - Sao chép LinkedList chứa con trỏ thô mà không cài Rule of Three/Five, dẫn tới double delete.
12. Quiz và bài tập
📘 Cơ bản
- Chèn đầu/cuối.
- In danh sách.
- Đếm số node.
- Tìm giá trị x.
📗 Trung bình
- Chèn/xóa theo vị trí.
- Xóa mọi node bằng x.
- Tìm node thứ k từ cuối.
- Tìm node giữa.
📙 Nâng cao
- Đảo toàn bộ danh sách.
- Đảo đoạn [L,R].
- Gộp hai danh sách tăng.
- Kiểm tra palindrome.
🐉 HSG
- Floyd Cycle Detection.
- Tìm điểm bắt đầu chu kỳ.
- LRU Cache định hướng.
- Danh sách liên kết hai chiều.
💳 Quét mã ủng hộ tuỳ tâm nhé!