Bài 34.3: Bài tập thực hành danh sách liên kết đơn trong C++

Bài 34.3: Bài tập thực hành danh sách liên kết đơn trong C++
Chào mừng bạn đến với bài thực hành chuyên sâu về danh sách liên kết đơn (Singly Linked List) trong C++. Sau khi đã tìm hiểu về lý thuyết, đây là lúc chúng ta xắn tay áo lên và làm quen với việc cài đặt thực tế, đặc biệt là làm việc hiệu quả với con trỏ - linh hồn của cấu trúc dữ liệu này.
Danh sách liên kết đơn là một trong những cấu trúc dữ liệu cơ bản nhưng cực kỳ mạnh mẽ. Nó cho phép chúng ta lưu trữ dữ liệu một cách linh hoạt về kích thước, không bị ràng buộc bởi bộ nhớ cấp phát tĩnh như mảng. Tuy nhiên, sự linh hoạt này đến từ việc quản lý các con trỏ để liên kết các phần tử (gọi là node) lại với nhau. Việc này đòi hỏi sự tỉ mỉ và hiểu rõ cách con trỏ hoạt động.
Bài viết này sẽ tập trung vào việc cài đặt các thao tác cơ bản trên danh sách liên kết đơn, giúp bạn làm quen với việc điều chỉnh các con trỏ next
để thêm, xóa, duyệt và tìm kiếm phần tử.
Nền tảng: Cấu trúc Node
Mọi danh sách liên kết đều được xây dựng từ các khối cơ bản gọi là node. Trong danh sách liên kết đơn, mỗi node chứa hai phần:
- Dữ liệu: Giá trị mà node đang lưu trữ (có thể là bất kỳ kiểu dữ liệu nào).
- Con trỏ
next
: Một con trỏ trỏ đến node tiếp theo trong danh sách. Node cuối cùng sẽ có con trỏnext
trỏ đếnnullptr
(hoặcNULL
trong C cũ) để đánh dấu điểm kết thúc.
Chúng ta có thể định nghĩa cấu trúc Node
trong C++ như sau:
#include <iostream>
#include <stdexcept>
struct Node {
int data;
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
Trong đoạn code trên:
struct Node
định nghĩa kiểu dữ liệu mới cho node.int data;
là nơi lưu trữ giá trị (ở đây là số nguyên).Node* next;
là con trỏ kiểuNode*
, nó sẽ giữ địa chỉ của node tiếp theo.Node(int val) : data(val), next(nullptr) {}
là một constructor. Khi tạo một node mới vớinew Node(value)
, nó sẽ tự động gánvalue
vàodata
và khởi tạonext
lànullptr
.
Lớp Danh sách Liên kết (LinkedList)
Để quản lý toàn bộ danh sách, chúng ta thường sử dụng một lớp hoặc cấu trúc chứa con trỏ đến node đầu tiên của danh sách, thường gọi là head
. Node head
là điểm truy cập duy nhất vào danh sách. Nếu head
là nullptr
, danh sách rỗng.
class LinkedList {
private:
Node* head;
public:
LinkedList() : head(nullptr) {}
~LinkedList();
void themCuoi(int g);
void themDau(int g);
void xoa(int g);
void in();
int kichThuoc();
bool tim(int g);
};
Lớp LinkedList
này:
- Có một thành viên
private
làhead
, chỉ lớp này mới có thể trực tiếp truy cậphead
. - Constructor
LinkedList()
khởi tạohead
lànullptr
, tạo ra một danh sách rỗng ban đầu. - Destructor
~LinkedList()
là cực kỳ quan trọng trong C++ để giải phóng bộ nhớ đã cấp phát động (new
) cho các node, tránh rò rỉ bộ nhớ (memory leak). - Các phương thức
public
định nghĩa các thao tác mà người dùng có thể thực hiện trên danh sách.
Bây giờ, hãy đi sâu vào cài đặt chi tiết từng phương thức.
Cài đặt các thao tác cơ bản
1. Thêm node vào cuối danh sách (append
)
Thao tác này thêm một node mới vào vị trí cuối cùng của danh sách.
- Nếu danh sách rỗng (
head == nullptr
), node mới sẽ trở thànhhead
. - Nếu danh sách không rỗng, chúng ta cần duyệt qua danh sách để tìm đến node cuối cùng (node có
next == nullptr
), sau đó cho con trỏnext
của node cuối cùng đó trỏ đến node mới.
void LinkedList::themCuoi(int g) {
Node* m = new Node(g);
if (!head) {
head = m;
return;
}
Node* h = head;
while (h->next) {
h = h->next;
}
h->next = m;
}
Giải thích:
Node* m = new Node(g);
cấp phát bộ nhớ động cho một node mới và khởi tạo nó với giá trịg
.- Điều kiện
if (!head)
xử lý trường hợp danh sách ban đầu trống rỗng. - Vòng lặp
while (h->next)
là cách chúng ta duyệt qua danh sách. Chúng ta dừng lại khih
đang trỏ đến node mà con trỏnext
của nó lànullptr
(tức làh
chính là node cuối cùng). h->next = m;
là bước quan trọng nhất: chúng ta cập nhật con trỏnext
của node cuối cùng cũ để nó trỏ đến node mới vừa tạo, nối node mới vào cuối danh sách.
2. Thêm node vào đầu danh sách (prepend
)
Thao tác này đơn giản hơn append
vì chúng ta chỉ cần thay đổi con trỏ head
.
- Tạo node mới.
- Cho con trỏ
next
của node mới trỏ đến node hiện tại màhead
đang trỏ tới. - Cập nhật
head
để trỏ đến node mới.
void LinkedList::themDau(int g) {
Node* m = new Node(g);
m->next = head;
head = m;
}
Giải thích:
m->next = head;
kết nối node mới với phần còn lại của danh sách (bắt đầu từhead
cũ).head = m;
đặt node mới làm node đầu tiên của danh sách. Thao tác này hoạt động đúng ngay cả khi danh sách rỗng ban đầu (head
lànullptr
).
3. Xóa node theo giá trị (deleteByValue
)
Thao tác này tìm node đầu tiên có giá trị khớp với giá trị cần xóa và loại bỏ nó khỏi danh sách. Đây là một thao tác phức tạp hơn một chút vì cần quản lý con trỏ của node trước node cần xóa.
- Cần theo dõi cả node hiện tại (
h
) và node đứng trước nó (t
). - Duyệt qua danh sách.
- Nếu tìm thấy node cần xóa:
- Nếu đó là node
head
, cập nhậthead
để trỏ tới node kế tiếp và giải phóng bộ nhớ của node cũ. - Nếu không phải node
head
, cho con trỏnext
của nodet
trỏ tới node sau nodeh
(tức làh->next
), sau đó giải phóng bộ nhớ của nodeh
.
- Nếu đó là node
- Nếu duyệt hết danh sách mà không tìm thấy, không làm gì cả hoặc thông báo lỗi.
void LinkedList::xoa(int g) {
Node* h = head;
Node* t = nullptr;
if (h && h->data == g) {
head = h->next;
delete h;
return;
}
while (h && h->data != g) {
t = h;
h = h->next;
}
if (!h) return;
t->next = h->next;
delete h;
}
Giải thích:
- Chúng ta dùng hai con trỏ:
h
để duyệt vàt
để giữ địa chỉ của node ngay trướch
. - Trường hợp xóa
head
được xử lý riêng vì không có nodet
nào trướchead
. - Trong vòng lặp,
t
luôn được gán bằngh
trước khih
nhảy sang node kế tiếp. Điều này đảm bảot
luôn trỏ đến node đứng ngay trướch
. - Khi
h
trỏ đến node cần xóa, chúng ta thực hiệnt->next = h->next;
. Thao tác này bỏ qua nodeh
và nốit
trực tiếp với node sauh
. delete h;
là bắt buộc để trả lại bộ nhớ đã cấp phát cho hệ thống. Nếu quên bước này, sẽ xảy ra rò rỉ bộ nhớ.
4. In danh sách (printList
)
Thao tác này duyệt qua danh sách từ đầu đến cuối và in ra dữ liệu của từng node.
void LinkedList::in() {
Node* h = head;
if (!h) {
cout << "Danh sach rong." << endl;
return;
}
cout << "Danh sach: ";
while (h) {
cout << h->data;
h = h->next;
if (h) {
cout << " -> ";
}
}
cout << endl;
}
Giải thích:
Node* h = head;
khởi tạo một con trỏ tạm thời để duyệt. Chúng ta không thay đổi con trỏhead
gốc khi in.- Vòng lặp
while (h)
tiếp tục cho đến khih
vượt ra ngoài node cuối cùng. cout << h->data;
in dữ liệu của node hiện tại.h = h->next;
là bước di chuyển sang node kế tiếp.
5. Lấy kích thước danh sách (getSize
)
Một cách đơn giản để lấy kích thước là duyệt toàn bộ danh sách và đếm số node. (Cách hiệu quả hơn là duy trì một biến kichThuoc
trong lớp và cập nhật nó khi thêm/xóa, nhưng cách duyệt giúp luyện tập).
int LinkedList::kichThuoc() {
int n = 0;
Node* h = head;
while (h) {
n++;
h = h->next;
}
return n;
}
Giải thích:
- Khởi tạo biến đếm
n = 0
. - Dùng con trỏ
h
để duyệt từhead
. - Mỗi lần di chuyển sang node kế tiếp, tăng biến đếm.
- Trả về giá trị
n
khi duyệt xong.
6. Tìm kiếm giá trị (search
)
Thao tác này kiểm tra xem một giá trị có tồn tại trong danh sách hay không.
bool LinkedList::tim(int g) {
Node* h = head;
while (h) {
if (h->data == g) return true;
h = h->next;
}
return false;
}
Giải thích:
- Duyệt danh sách bằng con trỏ
h
. - Trong mỗi bước lặp, kiểm tra xem
h->data
có bằng giá trị cần tìm hay không. - Nếu khớp, trả về
true
và kết thúc hàm. - Nếu vòng lặp kết thúc (tức là
h
trở thànhnullptr
) mà chưa tìm thấy, trả vềfalse
.
7. Destructor (~LinkedList
) - Giải phóng bộ nhớ
Đây là phần quan trọng nhất để tránh rò rỉ bộ nhớ khi đối tượng LinkedList
bị hủy. Chúng ta cần duyệt qua tất cả các node và giải phóng bộ nhớ của từng node một.
LinkedList::~LinkedList() {
Node* h = head;
Node* t;
while (h) {
t = h->next;
delete h;
h = t;
}
head = nullptr;
cout << "Danh sach da duoc giai phong bo nho." << endl;
}
Giải thích:
- Chúng ta dùng
h
để duyệt. - Trước khi gọi
delete h;
, chúng ta phải lưu lại con trỏh->next
vào biến tạmt
. Lý do là sau khidelete h;
,h
trở thành con trỏ treo (dangling pointer) và không còn an toàn để truy cậph->next
nữa. - Sau khi giải phóng node hiện tại, chúng ta cập nhật
h = t;
để tiếp tục duyệt sang node kế tiếp. - Vòng lặp dừng lại khi
h
(trước khi cập nhật) lànullptr
, tức là đã xử lý hết các node. head = nullptr;
đảm bảo trạng thái của danh sách sau khi hủy là rỗng.
Ví dụ minh họa sử dụng
Bây giờ, hãy xem cách sử dụng lớp LinkedList
đã cài đặt trong hàm main
.
#include <iostream>
#include <stdexcept>
struct Node {
int data;
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
class LinkedList {
private:
Node* head;
public:
LinkedList() : head(nullptr) {}
~LinkedList();
void themCuoi(int g);
void themDau(int g);
void xoa(int g);
void in();
int kichThuoc();
bool tim(int g);
};
void LinkedList::themCuoi(int g) {
Node* m = new Node(g);
if (!head) {
head = m;
return;
}
Node* h = head;
while (h->next) {
h = h->next;
}
h->next = m;
}
void LinkedList::themDau(int g) {
Node* m = new Node(g);
m->next = head;
head = m;
}
void LinkedList::xoa(int g) {
Node* h = head;
Node* t = nullptr;
if (h && h->data == g) {
head = h->next;
delete h;
return;
}
while (h && h->data != g) {
t = h;
h = h->next;
}
if (!h) return;
t->next = h->next;
delete h;
}
void LinkedList::in() {
Node* h = head;
if (!h) {
cout << "Danh sach rong." << endl;
return;
}
cout << "Danh sach: ";
while (h) {
cout << h->data;
h = h->next;
if (h) {
cout << " -> ";
}
}
cout << endl;
}
int LinkedList::kichThuoc() {
int n = 0;
Node* h = head;
while (h) {
n++;
h = h->next;
}
return n;
}
bool LinkedList::tim(int g) {
Node* h = head;
while (h) {
if (h->data == g) return true;
h = h->next;
}
return false;
}
LinkedList::~LinkedList() {
Node* h = head;
Node* t;
while (h) {
t = h->next;
delete h;
h = t;
}
head = nullptr;
cout << "Danh sach da duoc giai phong bo nho." << endl;
}
int main() {
LinkedList ds;
ds.themCuoi(10);
ds.themCuoi(20);
ds.themCuoi(30);
ds.in();
ds.themDau(5);
ds.in();
cout << "Kich thuoc danh sach: " << ds.kichThuoc() << endl;
cout << "Tim kiem 20: " << (ds.tim(20) ? "Tim thay" : "Khong tim thay") << endl;
cout << "Tim kiem 100: " << (ds.tim(100) ? "Tim thay" : "Khong tim thay") << endl;
ds.xoa(20);
ds.in();
ds.xoa(5);
ds.in();
ds.xoa(40);
ds.in();
ds.xoa(30);
ds.in();
return 0;
}
Output:
Danh sach: 10 -> 20 -> 30
Danh sach: 5 -> 10 -> 20 -> 30
Kich thuoc danh sach: 4
Tim kiem 20: Tim thay
Tim kiem 100: Khong tim thay
Danh sach: 5 -> 10 -> 30
Danh sach: 10 -> 30
Danh sach: 10 -> 30
Danh sach rong.
Danh sach da duoc giai phong bo nho.
(Lưu ý: Để chạy được code này, bạn cần ghép toàn bộ các phần định nghĩa Node
, LinkedList
và cài đặt các phương thức vào cùng một file .cpp trước hàm main
.)
Trong hàm main
:
- Chúng ta tạo một đối tượng
LinkedList
tên làds
. - Gọi các phương thức
themCuoi
,themDau
,in
,kichThuoc
,tim
,xoa
để thao tác và kiểm tra kết quả. - Khi chương trình kết thúc hàm
main
, đối tượngds
sẽ bị hủy. Lúc này, destructor~LinkedList()
mà chúng ta đã cài đặt sẽ tự động chạy, đảm bảo toàn bộ bộ nhớ đã cấp phát cho các node được giải phóng một cách sạch sẽ. Đây là lý do việc cài đặt destructor chính xác là rất quan trọng khi làm việc với cấp phát bộ nhớ động trong C++.
Comments