You are on page 1of 24

More information: www.itspiritclub.

net Linked list basics Translater: huahongquan2007

Tóm tắt:
Tài liệu này giới thiệu về những cấu trúc và kĩ thuật cơ bản để xây dựng một danh sách liên kết với nhiều giải
thích, hình vẽ, mã nguồn mẫu và bài tập. Tài liệu này thì rất cần thiết nếu bạn muốn hiểu về danh sách liên kết hay muốn
xem những ví dụ sử dụng con trỏ một cách sâu sắc. Một tài liệu khác đi kèm là, những vấn đề về danh sách liên kết sẽ bao
hàm độ khó rộng hơn.

Danh sách liên kết thì cần phải học vì hai lý do. Rõ ràng nhất, danh sách liên kết đơn là một cấu trúc dữ liệu mà
bạn sẽ muốn dùng trong chương trình thực tế. Việc biết được sức mạnh và điểm yếu của danh sách liên kết sẽ mang đến
cho bạn sự đánh giá đúng hơn về không gian, thời gian và những vấn đề về mã nguồn để suy nghĩ về bất cứ kiểu dữ liệu
nào nói chung. Thứ hai, danh sách liên kết là một cách tuyệt vời để học về con trỏ. Trong thực tế, bạn có thể sẽ không
dùng một danh sách liên kết trong chương trình thực tế, nhưng bạn chắc chắn sẽ dùng nhiều con trỏ. Các vấn đề về danh
sách liên kết là một sự kết hợp tuyệt đẹp giữa thuật toán và các thao tác con trỏ. Theo truyền thống, danh sách liên kết là
một lĩnh vực mà các lập trình viên mới bắt đầu thực tập để hiểu về con trỏ.

Độc giả:
Tài liệu này thừa nhận các vấn đề cơ bản của lập trình và con trỏ. Các bài viết dùng cú pháp C cho ví dụ nhưng giải thích
tránh sử dụng C nhiều nhất có tể - thật sự thì bài viết hướng tới những khái niệm quan trong của các thao tác con trỏ và
thuật toán danh sách liên kết.

Nội dung tài liệu:


Phần 1 –Cấu trúc danh sách liên kết cơ bản

Phần 2 – Xây dựng danh sách liên kết cơ bản

Phần 3 – Các kỹ thuật code danh sách liên kết

Phần 4 – Các mã nguồn ví dụ

Bài viết được nhóm itspirit dịch từ tài liệu về linked list của trường Stanford và có một số thông tin từ
hoangtran.wordpress.com. Tài liệu dịch về “những vấn đề về danh sách liên kết” sẽ được cung cấp cho các bạn trong
thời gian sớm nhất. Xin cám ơn các bạn .

1
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Phần 1 –Cấu trúc danh sách liên kết cơ bản


Tại sao lại sử dụng danh sách liên kết?

Danh sách liên kết và mảng thì tương tự nhau khi chúng đều chứa tập hợp các dữ liệu. Mảng và các danh sách liên kết đều
chứa các phần tử (elements). Trong danh sách liên kết, loại dữ liệu cụ thể của phần tử thì không quan trọng vì về cơ bản
cấu trúc dữ liệu này có thể chứa bất cứ loại phần tử nào. Một cách để nghĩ về danh sách liên kết là xem mảng hoạt động
như thế nào và nghĩ về cách tiếp cận tương tự.

Ôn lại mảng.
Mảng là cấu trúc dữ liệu thường dùng nhất để lưu tập hợp các phần tử. Trong nhiều ngôn ngữ, mảng thì thuận tiện để khai
báo và với việc dùng cú pháp [ ] để truy xuất các phần tử qua số chỉ thị. Ví dụ sau sẽ cho thấy vào đoạn mã nguồn tiêu
biểu của mảng và chỉ ra cách nhìn mảng trong bộ nhớ. Mã nguồn cấp phát cho mảng int giá trị [100], và đặt 3 phần tử đầu
là 1 2 3 và để phần còn lại của mảng không được khởi gán.

void ArrayTest() {

int scores[100];

// operate on the elements of the scores array...

scores[0] = 1;

scores[1] = 2;

scores[2] = 3;

Sau đây là bản vẽ thể hiện các giá trị của mảng được lưu thế nào trong bộ nhớ. Vấn đề chính là toàn bộ mảng được khai
báo trong một khối của bộ nhớ. Mỗi phần tử của mảng có ô nhớ riêng của chúng trong mảng. Bất cứ phần tử nào trong
mảng cũng có thể được truy xuất trực tiếp thông qua sử dụng cú pháp [ ].

Bất lợi của mảng là …

1) Kích thước của một mảng thì cố định – trong trường hợp này là 100 phần tử. Hầu như kích thước này được xác
định lúc biên dịch với khai báo như trên. Với một chút cố gắng, kích thước của mảng có thể được hoãn lại cho tới
khi mảng được tạo lúc chạy, như sau đó vẫn cố định kích thước

2
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
2) Bởi vì điều (1), cách thuận tiện nhất cho người lập trình là khai báo một mảng mà “vừa đủ lớn”. Mặc dù thuận
tiện như vậy, chiến lược này vẫn có 2 bất lợi (a) phần lớn thời gian thì chỉ dùng 20 hoặc 30 phần tử của dãy và
70% bộ nhớ của mảng thì bị lãng phí. (b) Nếu chương trình cần truy xuất hơn 100 dữ liệu, mã nguồn sẽ bị hỏng.
Điều bất ngờ là một lượng lớn các mã nguồn thương mại thì dùng kiểu khai báo khờ khạo này, cái mà lãng phí bộ
nhớ ở hầu hết trường hợp và bị hỏng khi ở trường hợp đậc biệt.
3) Việc chèn những phần tử mới vào trước thì khá là tốn kém vì những phần tử đã có cần phải dịch chuyển qua để
tạo chỗ trống.

Danh sách liên kết thì cũng có điểm mạnh và điểm yếu của nó nhưng chúng thường tốt hơn ở những chỗ mà mảng yếu.
Các đặc điểm của mảng sinh ra từ chiến lược cấp phát bộ nhớ cho những phần tử của nó trong một khối bộ nhớ. Danh
sách liên kết dùng một chiến lược hoàn toàn khác. Như chúng ta sẽ thấy, danh sách liên kết cấp phát bộ nhớ chỗ mỗi phần
tử một cách riêng biệt và chỉ khi chúng cần thiết.

Nhắc lại về con trỏ


Sau đây chúng ta sẽ ôn lại nhanh về các thuật ngữ và luật của con trỏ. Danh sách liên kết đơn cũng theo sau bởi những
điều luật này.

 Pointer/Pointee: Một con trỏ “pointer” sẽ lưu một reference đến một biến khác được biết như là pointee của nó.
Con trỏ có thể được thiết lập giá trị NULL có nghĩa là nó refer đến một pointee nào. (Trong C và C++, giá trị
NULL có thể được sử dụng như là giá trị boolean false).
 Dereference: Toán tử dereference trên con trỏ cho phép truy nhập vào pointee của nó. Một pointer chỉ có thể bị
dereference sau khi nó được thiết lập trỏ đến một pointee cụ thể. Một pointer mà không có pointee thì là bad
pointer và không thể bị dereference.
 Bad pointer: Một pointer mà không được trỏ vào một pointee thì là “bad” và không thể dereference. Trong C và
C++, việc dereference một bad pointer đôi khi gây xung đột ngay lập tức và làm hỏng bộ nhớ của chương trình
đang chạy, gây nên “không biết đường nào mà lần”. Kiểu lỗi này rất khó để theo dõi. Trong C và C++, tất cả các
pointer bắt đầu bằng bad values (những giá trị ngẫu nhiên), do đó rất dễ tình cờ sử dụng bad pointer. Những đoạn
mã đúng sẽ thiết lập mỗi pointer có một good value trước khi sử dụng chúng. Chính vì vậy sử dụng bad pointer là
một lỗi rất phổ biến trong C/C++. Với Java và các ngôn ngữ khác, các pointers được tự động bắt đầu với giá trị
NULL, do đó quá trình dereference sẽ được dễ dàng detect nên các chương trình Java dễ gỡ lỗi này hơn nhiều.
 Pointer assignment: Một phép gán giữa hai con trỏ như p = q; sẽ làm cho hai pointer trỏ vào cùng một pointee. Nó
sẽ không copy vùng nhớ của pointee. Sau phép gán thì cả hai pointer sẽ chỉ vào cùng một vùng nhớ của pointee.
 malloc(): malloc() là một hàm hệ thống mà cấp pháp một vùng nhớ trong “heap” và trả về con trỏ tới vùng nhớ
mới đó. Prototype của malloc() và các hàm khác ở trong stdlib.h. Tham số của malloc() là một số nguyên là kích
thước của vùng nhớ cần cấp phát tính theo bytes. Không giống như các biến cục bộ (“stack”), vùng nhớ heap
không tự động giải phóng khi hàm tạo thoát ra. malloc() sẽ trả về NULL nếu nó không thế đáp ứng được yêu cầu
cấp phát. Bạn nên kiểm tra trường hợp NULL với assert() nếu bạn mong nó an toàn. Hầu hết các hệ điều hành tiên
tiến sẽ ném ra một exception hoặc làm việc bắt lỗi tự động trong việc cấp phát bộ nhớ của chúng, do đó không
nhất thiết là trong đoạn mã của bạn phải kiểm tra việc cấp phát bộ nhớ thất bại.
 free(): free() thì ngược với malloc(). Gọi hàm free() trên vùng nhớ trên heap để chỉ ra rằng hệ thống đã thực hiện
xong và giải phóng vùng nhớ đó. Tham số của free là một con trỏ tới vùng nhớ trên heap – con trỏ mà chúng ta đã
có được thông qua lời gọi tới hàm malloc().

Một danh sách liên kết là như thế nào ?


Một mảng thì cấp phát bộ nhớ để cho tất cả các phần tử của nó thì tập trung lại như một khối ở trong bộ nhớ.
Ngược lại, một danh sách liên kết thì cấp phát vùng nhớ cho mỗi phần tử riêng biệt trong những khối bộ nhớ riêng gọi là

3
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
“các phần tử của danh sách liên kết” hay “node”. Danh sách thì có một cấu trúc tổng quát bằng các sử dụng các con trỏ để
kết nối các node lại với nhau như các dây xích.

Mỗi node thì chứa hai trường giá trị : một trường “data” để chứa dữ liệu và một trường “next” để chứa con trỏ
dùng để nối một node với node khác. Mỗi node thì được cấp phát ở trong heap với một hàm malloc(), nên bộ nhớ node
vẫn luôn tồn tại cho tới khi nó bị giải phóng bằng một hàm free(). Trước một danh sách là một con trỏ trỏ tới phần tử node
đầu tiên. Sau đây là mô tả về một danh sách chứa các số 1, 2, 3 :

Hình vẽ này thể hiện một danh sách được tạo trong bộ nhớ bằng hàm BuildOneTwoThree() (mã nguồn sẽ
được đưa ra ở dưới). Phần khởi đầu của danh sách liên kết thì được chứa trong một con trỏ “head” – cái mà trỏ tới node
đầu tiên. Node đầu tiên này chứa một con trỏ để trỏ tới node thứ hai. Node thứ hai chứa một con trỏ tới node thứ ba…và
cứ như thế. Con trỏ cuối cùng có trường “next” của nó được gán bằng NULL để đánh dấu kết thúc của danh sách. Mã
nguồn có thể truy cập và bất cứ node nào trong danh sách bắt đầu tử head thông qua con trỏ next. Các thao tác tới phần
đầu của danh sách thì diễn ra nhanh, trong khi truy xuất tới các phần tử ở phía sau sẽ tốn nhiều thời gian hơn. Kiểu truy
xuất dữ liệu này thì tốn kém hơn kiểu truy xuất bằng [ ] của mảng. Trong khía cạnh này, danh sách liên kết thì kém hiệu
quả hơn mảng. Việc vẽ ra bảng trên thì khá cần thiết trong việc suy nghĩ về mã nguồn con trỏ, nên phần lớn ví dụ trong tài
liệu này sẽ kết hợp code với việc mô tả bằng hình vẽ. Trong trường hợp này, con trỏ head thì là một biến cục bộ bình
thường, nên nó được vẽ tách biệt bên trái để thể hiện nó được lưu trong stack. Còn các node thì được vẽ bên phải để thể
hiện chúng được cấp phát ở trong heap.

Danh sách rỗng – NULL


Danh sách ở trên là một danh sách có “độ dài ba” vì nó được tạo bởi ba node với trường .next của node cuối gán bằng
NULL. Ta cũng cần có một cách biểu diễn một danh sách rỗng – danh sách có 0 node. Các biểu diễn thông dụng nhất
được chọn cho danh sách rỗng là một con trỏ head NULL. Tất cả các mã nguồn được thể hiện trong tài liệu này thì vẫn
hoạt động đúng cho danh sách rỗng. Khi làm việc với mã nguồn danh sách liên kết, việc nhớ để kiểm tra danh sách rỗng là
một thói quen tốt. Thỉnh thoảng, trường hợp danh sách rỗng hoạt động giống như các trường hợp khác. Nhưng đôi khi nó
cần một trường hợp đặc biệt. Dù sao đi nữa thì tốt nhất là cứ nghĩ tới nó.

4
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
Kiểu của danh sách liên kết: Node và Pointer
Trước khi viết mã nguồn để xây dựng danh sách ở trên, chúng ta cần hai kiểu dữ liệu

 NODE: Kiểu dữ liệu cho nodes sẽ tạo ra phần thân cho danh sách. Chúng được cấp phát ở trong heap. Mỗi node
chứa một phần tử dữ liệu và một con trỏ tới node tiếp theo. Hãy ghi : struct node{ }

struct node {

int data;

struct node* next;

};

 Node Pointer : Kiểu dữ liệu cho con trỏ tới các nodes. Đây sẽ là kiểu dữ liệu của con trỏ head và các trường .next
trong mỗi node. Trong C và C++, không cần phải khai báo một loại riêng, đơn giản là kiểu của node + “*”. Hãy
ghi: struct node *

BuildOneTwoThree() Function
Đây là một hàm đơn giản mà dùng phép toán con trỏ để tạo ra một danh sách {1,2,3}. Hàm này giải thích cách gọi
malloc() và phép gán con trỏ để xây dựng một cấu trúc con trỏ trong heap.

5
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
Bài tập:
Hỏi: Viết một đoạn code với số lượng phép gán (= ) ít nhất mà sẽ xây dựng cấu trúc bộ nhớ trên

Trả lời: Cần 3 lần gán để malloc(). 3 phép gán int để tạo các biến int. 4 phép gán con trỏ để tạo head và ba trường .next
còn lại. Với một ít thông minh và sự hiểu biết về ngôn ngữ C, bạn có thể hoàn thành chúng với 7 phép gán (=).

Hàm Length() lấy số phần tử của danh sách


Hàm Length() sẽ nhận một danh sách liên kết và tính toán số phần tử của danh sách. Hàm Length() là một hàm
danh sách đơn giản nhưng nó giải thích nhiều khái niệm mà sẽ dùng sau này , trong những hàm danh sách phức tạp hơn.

/*
Given a linked list head pointer, compute
and return the number of nodes in the list.
*/
int Length(struct node* head) {
struct node* current = head;
int count = 0;
while (current != NULL) {
count++;
current = current->next;
}
return count;
}

Có hai đặc trưng của danh sách liên kết được mô tả trong hàm Length () :

1) Truyền một danh sách bằng cách truyền con trỏ Head
Một danh sách liên kết được truyền vào trong hàm Length () thông qua một con trỏ Head. Con trỏ được truyền
tham trị vào hàm Length(). Việc sao chép con trỏ này thì không nhân đôi cả danh sách. Nó chỉ sao chép con trỏ để
hàm gọi và hàm Length() sẽ có những con trỏ trỏ tới cùng một cấu trúc dữ liệu.
2) Lặp đi lặp lại danh sách với một con trỏ cục bộ
Mã nguồn trên lặp đi lặp lại qua tất cả các phần tử.

struct node* current = head;


while (current != NULL) {
// do something with *current node
current = current->next;
}

Điểm đáng chú ý của đoạn mã trên là:

1) Con trỏ cục bộ - current trong trường hợp này – bắt đầu với việc trỏ tới cùng node như con trỏ head với current =
head . Khi hàm này được thoát ra, con trỏ current sẽ tự động giải phóng do nó chỉ là cục bộ nhưng các nodes ở
trong heap thì vẫn giữ nguyên

6
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
2) Vòng lặp while kiểm tra liệu đã tới cuối danh sách hay chưa (current != NULL). Cách kiểm tra này cũng diễn ra
tốt cho trường hợp danh sách rỗng – current sẽ là NULL trong vòng lặp đầu tiên và vòng lặp while sẽ thoát ra.
3) Ở cuối cùng của vòng lặp while: current = current->next; sẽ tăng con trỏ cục bộ tới node tiếp theo trong danh
sách. Khi mà không còn liên kết nào nữa, con trỏ khi đó bằng NULL.

Gọi hàm Length()


Dưới đây là một đoạn code tiêu biểu để gọi hàm Length(). Đầu tiên nó sẽ gọi hàm BuildOneTwoThree() để tạo ra
một danh sách và lưu con trỏ head trong một biến cục bộ. Sau đó sẽ gọi hàm Length() với danh sách và lưu giá trị trả về
qua một biến int.

void LengthTest() {
struct node* myList = BuildOneTwoThree();
int len = Length(myList); // results in len == 3
}

Vẽ mô phỏng bộ nhớ
Các tốt nhất để thiết kế và suy nghĩ về một danh sách liên kết là dùng một hình vẽ để thấy cách con trỏ hoạt động
trong bộ nhớ. Những hình vẽ dưới đây sẽ thể hiện trạng thái của bộ nhớ trước và trong quá trình gọi hàm Length() – hãy
tận dụng cơ hội này để tập xem hình vẽ bộ nhớ và dùng chúng để suy nghĩ về các mã nguồn dùng con trỏ. Bạn sẽ có khả
năng hiểu nhiều hơn sau này.

Bắt đầu với mã nguồn Length() và LengthTest() và một trang giấy trắng. Hãy theo dấu các hoạt động của code và
cập nhật bức ảnh của bạn để xem trạng thái bộ nhớ cho mỗi bước. Các hình vẽ bộ nhớ sẽ phân biệc rõ vùng nhớ heap và
stack. Hãy lưu ý là: malloc() cấp phát vùng nhớ trong heap và chỉ bị giải phóng bằng cách gọi hàm free(). Ngược lại, các
biến cục bộ stack cho mỗi hàm thì tự động cấp phát khi vào hàm và giải phóng khi ra khỏi hàm.

7
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Hình 1: trước khi gọi hàm Length()


Ở dưới là trạng thái của bộ nhớ trước khi gọi hàm Length() trong hàm LengthTest() ở trên. Hàm
BuildOneTwoThree() xây dựng một danh sách {1 , 2, 3} trong heap và trả về một con trỏ head. Con trỏ head được lưu
trong một biến cục bộ myList. Biến cục bộ len có giá trị ngẫu nhiên – nó chỉ được gán bằng 3 khi hàm Length() trả về.

Hình 2: Ở giữa hàm Length()


Đây là trạng thái của bộ nhớ ở giữa quá trình chạy hàm Length(). Biến head và current cục bộ của hàm Length()
tự động được cấp phát. Con trỏ curretn bắt đầu chỉ vào node đầu tiên và sau đó vòng lặp đầu tiên của while đã tăng nó để
trỏ tới node thứ hai.

Chú ý các biến cục bộ trong hàm Length( head và current) thì được tách biệt ra so với các biến cục bộ trong hàm
LengthTest() ( myList và len). Các biến cục bộ của hàm Length sẽ được giải phóng tự động khi ra khỏi hàm. Danh sách
liên kết được cấp phát trên heap vẫn sẽ tồn tại mặc dù con trỏ trỏ tới nó bị xóa.

8
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
Bài tập:

Hỏi: Chuyện gì nếu chúng ta gọi lệnh head = NULL ở cuối hàm Length() – Liệu nó sẽ làm danh sách myList của chúng ta
rối loạn lên ?

Trả lời: Không, head là một biến cục bộ của hàm Length() nên thay đổi nó sẽ không làm ảnh hưởng tới biến thật sự trong
hàm gọi.

Hỏi: Nếu chúng ta truyền vào danh sách không có phần tử nào, liệu Length() có thể xử lý đúng được không?

Trả lời: Có. Đại diện của một danh sách rỗng là một con trỏ head NULL. Hãy xem lại hàm Length() để xem cách hàm xử
lý trường hợp này.

Phần 2 –Xây dựng danh sách liên kết


BuildOneTwoThree() là một ví dụ tốt về các thao tác con trỏ nhưng nó không phải là kỹ thuật thông thường để
xay dựng danh sách. Giải pháp tốt nhất sẽ là một hàm tự do để thêm một node mới vào bất kì danh sách nào. Chúng ta có
thể gọi hàm đó bao nhiêu lần tùy thích để xây dựng một danh sách. Trước khi đi vào mã nguồn cụ thể, chúng ta có thể
nhận thấy 3 bước để thêm một node vào trước một danh sách liên kết. Ba bước đó là:

1) Cấp phát (Allocate): Cấp phát một node mới trong heap và gán giá trị cho phần tử .data.
struct node* newNode;
newNode = malloc(sizeof(struct node));
newNode->data = data_client_wants_stored;

2) Tạo liên kết next: Gán con trỏ .next của node mới để trỏ tới node đầu tiên của danh sách. Đây thực tế chỉ là
một phép gán con trỏ - nhớ rằng : “ gán một con trỏ tới một cái khác chỉ làm chúng trỏ tới cùng một nơi”

newNode->next = head;

3) Tạo liên kết Head: Thay đổi con trỏ head để trỏ tới node mới để nó bây giờ trở node đầu tiên của danh sách

head = newNode;

Ba bước trên trong mã nguồn:

void LinkTest() {
struct node* head = BuildTwoThree(); // suppose this builds the {2, 3} list
struct node* newNode;
newNode= malloc(sizeof(struct node)); // allocate
newNode->data = 1;
newNode->next = head; // link next
head = newNode; // link head
// now head points to the list {1, 2, 3}
}

9
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Ba bước trong hình vẽ:


Dưới đây là hình vẽ minh họa 3 bước trên ( con trỏ bị ghi đè được thể hiện màu xám)

Hàm Push()
Có vẻ từ 3 bước trên thì việc viết hàm Push() để thêm một node vào đầu danh sách rất dễ dàng. Hãy xem hàm sau
và tìm chỗ sai trước khi xem lời giải.

WrongPush()
Không may rằng, hàm Push() trong C gặp phải một vấn đề cơ bản: đối số cho hàm Push() là gì?. Đây là một vấn
đề rắc rối trong C. Dưới đây là một cách dễ dàng và rõ ràng để viết hàm Push() , nó nhìn khá đúng nhưng thật sự thì sai.
Việc tìm ra chỗ sai sẽ giúp ích trong việc thực hành vẽ bộ nhớ và giúp bạn trở thành lập trình viên tốt hơn.

void WrongPush(struct node* head, int data) {


struct node* newNode = malloc(sizeof(struct node));
newNode->data = data;
newNode->next = head;
head = newNode; // NO this line does not work!
}

void WrongPushTest() {
List head = BuildTwoThree();
WrongPush(head, 1); // try to push a 1 on front -- doesn't work
}

10
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
WrongPush() thì gần đúng. Nó theo đúng như 3 bước trên. Vấn đề là ở dòng cuối cùng của hàm. Nó ra lệnh để
chúng ta thay đổi con trỏ head để trỏ tới một node mới. Thật sự thì dòng head = newNode; làm được gì trong
WrongPush() ? Nó gán giá trị tới con trỏ head, nhưng không phải là con trỏ head đúng. Nó gán giá trị cho một biến cục
bộ head ở trong hàm WrongPush(). Điều đó không thay đổi biến head mà chúng ta thật sự quan tâm ở trong hàm gọi.

Bài tập:

Hãy vẽ mô phỏng bộ nhớ trong WrongPushTest() để thấy lí do nó không hoạt động. Hãy nhớ rằng các biến cục bộ
của hàm WrongPushTest() và hàm WrongPush() thì được tách riêng ra trong bộ nhớ.

Truyền tham chiếu trong C


Chúng ta đang gặp phải một vấn đề cơ bản trong ngôn ngữ C rằng: sự thay đổi với biến cục bộ thì không được
truyền ngược về hàm gọi. Chúng tôi sẽ đưa ra giải pháp truyền thống cho vấn đề này, nhưng bạn có thể muốn tham khảo
các tài liệu C khác để biết sâu hơn.

Chúng ta cần Push() có khả năng thay đổi giá trị trong bộ nhớ của hàm gọi - ở đây là biến head. Cách truyền
thống để cho phép điều này là truyền một con trỏ tới hàm Push() thay vì một bản sao. Trong trường hợp này, giá trị mà
chúng ta muốn thay đổi là struct node*, chúng ta sẽ thay bằng struct node **. Hai dấu sao ( ** ) thì hơi đáng sợ nhưng
thật sự nó chỉ là một cách áp dụng trực tiếp của nguyên tắc trên. Do chúng ta giá trị chúng ta truyền vào đã có sẵn một dấu
sao ( * ) nên đối số phải có 2 dấu *. Thay vì khai báo WrongPush(struct node* head, int data); chúng ta khai báo
Push(struct node** headRef, int data);. Hãy nhớ nguyên tắc là: để thay đổi giá trị bộ nhớ của hàm gọi, hãy truyền một
con trỏ trỏ tới bộ nhớ đó. Đối số có một từ Ref để nhắc nhỏ rằng đây là một con trỏ tham chiếu tới con trỏ head ban đầu.

Mã nguồn Push() chính xác


Dưới đây là hàm Push() và PushTest() hoạt động đúng. Danh sách được truyền vào thông qua một con trỏ tới con trỏ
head. Trong mã nguồn này, chính là việc dùng “&” ở trong hàm gọi và cách dùng “*” ở trong hàm con.

/*
Takes a list and a data value.
Creates a new link with the given data and pushes
it onto the front of the list.
The list is not passed in by its head pointer.
Instead the list is passed in as a "reference" pointer
to the head pointer -- this allows us
to modify the caller's memory.
*/
void Push(struct node** headRef, int data) {
struct node* newNode = malloc(sizeof(struct node));
newNode->data = data;
newNode->next = *headRef; // The '*' to dereferences back to the real head
*headRef = newNode; // ditto
}
void PushTest() {
struct node* head = BuildTwoThree();// suppose this returns the list {2, 3}
Push(&head, 1); // note the &
Push(&head, 13);
// head is now the list {13, 1, 2, 3}
}

11
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Hình vẽ mô phỏng bộ nhớ chính xác:


Dưới đây là hình vẽ mô phỏng bộ nhớ ngay trước khi hàm Push() thoát ra. Giá trị gốc của con trỏ head thì có màu
xám. Chú ý cách mà đối số headRef trong Push() trỏ ngược lại con trỏ thật ở trong PushTest(). Push() dùng *headRef để
truy xuất và thay đổi giá trị của con trỏ thật.

Bài tập:
Hình vẽ trên mô phỏng bộ nhớ trong lần gọi đầu tiên của hàm Push() trong PushTest(). Hãy mở rộng hình vẽ để
theo dấu tới lần gọi thứ hai của hàm Push(). Kết quả của danh sách khi đó sẽ phải là {13, 1, 2 ,3 }.

Bài tập:
Hàm sau xây dựng một danh sách ba phần tử chỉ dùng hàm Push(). Hãy vẽ hình vẽ mô phỏng bộ nhớ để theo vết
các hoạt động của nó và chỉ ra trạng thái cuối cùng của danh sách. Điều này cũng chứng minh rằng Push() hoạt động tốt
với danh sách rỗng.

void PushTest2() {
struct node* head = NULL; // make a list
with no elements
Push(&head, 1);
Push(&head, 2);
Push(&head, 3);
// head now points to the list {3, 2, 1}
}

12
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Trong C++ thì sao?


Với C++ thì có hỗ trợ truyền tham chiếu (reference &) vào hàm, nên hàm Push có thể viết như sau với C++

/*
Push() in C++ -- we just add a '&' to the right hand
side of the head parameter type, and the compiler makes
that parameter work by reference. So this code changes
the caller's memory, but no extra uses of '*' are necessary --
we just access "head" directly, and the compiler makes that
change reference back to the caller.
*/
void Push(struct node*& head, int data) {
struct node* newNode = malloc(sizeof(struct node));
newNode->data = data;
newNode->next = head; // No extra use of * necessary on head -- the compiler
head = newNode; // just takes care of it behind the scenes.
}
void PushTest() {
struct node* head = BuildTwoThree();// suppose this returns the list {2, 3}
Push(head, 1); // No extra use & necessary -- the compiler takes
Push(head, 13); // care of it here too. Head is being changed by
// these calls.
// head is now the list {13, 1, 2, 3}
}

13
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Phần 3: Các kỹ thuật trong lập trình


Phần này sẽ tóm tắt các kỹ thuật chính cho danh sách liên kết. Các kỹ thuật này sẽ được minh họa trong các ví dụ cụ
thể ở phần tiếp theo.

Đây là một kỹ thuật rất thường xuyên sử dụng trong danh sách liên kết để duyệt qua các node trong danh sách
thông qua con trỏ. Theo truyền thống, cách này được viết như một vòng lặp while. Con trỏ head thì được sao chép trong
một biến cục bộ current để duyệt xuống danh sách. Kiểm tra hết danh sách với current != NULL. Tiến con trỏ tới node
tiếp theo bằng current = current ->next.

// Trả về số lượng node của danh sách


int Length(struct node* head) {
int count = 0;
struct node* current = head;
while (current != NULL) {
count++;
current = current->next
}
return(count);
}

Có thể thay vòng lặp bằng

for (current = head; current != NULL; current = current->next) {

Nhiều hàm cần phải thay đổi con trỏ head mặc dù nó được truyền vào hàm số theo kiểu pass-by-value. Để làm
điều này trong C thì hãy truyền một pointer đến head pointer thay vì chỉ truyền head pointer. Kiểu pointer chỉ đến một
pointer khác thường được gọi là reference pointer.

Hãy xem hàm ChangeToNull để cho con trỏ head trỏ vào NULL

// Change the passed in head pointer to be NULL


// Uses a reference pointer to access the caller's memory
void ChangeToNull(struct node** headRef) {
// Takes a pointer to the value of interest
*headRef = NULL; // use '*' to access the value of interest
}

14
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

void ChangeCaller() {
struct node* head1;
struct node* head2;
ChangeToNull(&head1); // use '&' to compute and pass a pointer to
ChangeToNull(&head2); // the value of interest
// head1 and head2 are NULL at this point
}

Đây là hình minh họa cách con trỏ headRef trong ChangeToNull() trỏ ngược về biến trong hàm gọi

Xem cách dùng hàm Push() ở trên như một ví dụ cho kỹ thuật này.

Cách dễ nhất để xây dưng một danh sách là thêm nodes vào đầu với Push(). Đoạn mã nguồn thì ngắn và chạy
nhanh. Bất lợi là các phần tử sẽ xuất hiện trong danh sách với thứ tự ngược lại khi bạn thêm vào. Nếu bạn không quan tâm
thứ tự, cách thêm vào đầu là cách tốt nhất.

struct node* AddAtHead() {


struct node* head = NULL;
int i;
for (i=1; i<6; i++) {
Push(&head, i);
}
// head == {5, 4, 3, 2, 1};
return(head);
}

Nếu ta muốn thêm nodes vào cuối danh sách thì phải làm thế nào? Thêm node vào cuối danh sách thường bao
gồm việc xác định vị trí node cuối trong danh sách và thay đổi giá trị .next của nó từ NULL sang trỏ tới node mới

Hình sau mô tả cách thêm node 3 vào cuối danh sách {1, 2}...

15
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

newNode->next = NULL;
tail->next = newNode;
tail = tail->next;

Tuy nhiên thì có một trường hợp đặc biệt khi phần tử mới thêm vào danh sách là phần tử đầu tiên của list. Hãy
xem xét tiếp các kỹ thuật sau.

Xem xét vấn đề trong khi xây dựng một danh sách {1, 2 ,3, 4 ,5} bằng việc thêm các node vào cuối. Khó khăn là
node đầu tiên phải được thêm vào con trỏ head nhưng các node khác thì được thêm vào sau bằng cách sử dụng con trỏ
tail. Cách đơn giản để giải quyết cả 2 trường hợp là tách riêng 2 trường hợp trong mã nguồn. Đoạn mã nguồn đặc biệt đầu
tiên thêm node head {1}. Sau đó làm một vòng lặp riêng dùng con trỏ tail để thêm tất cả các node còn lại. Con trỏ tail vẫn
tiếp tục trỏ tới node cuối cùng và mỗi node thì được thêm vào ở tail->next. Vấn đề duy nhất của giải pháp này là việc viết
riêng biệt mã nguồn cho node đầu tiên. Dù sao đi nữa, cách tiếp cận này là một cách cơ sở cho việc tạo mã nguồn – nó
nhanh và đơn giản.

struct node* BuildWithSpecialCase() {


struct node* head = NULL;
struct node* tail;
int i;
// Deal with the head node here, and set the tail pointer
Push(&head, 1);
tail = head;
// Do all the other nodes using 'tail'
for (i=2; i<6; i++) {
Push(&(tail->next), i); // add node at tail->next
tail = tail->next; // advance tail to point to last node
}
return(head); // head == {1, 2, 3, 4, 5};
}

16
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Một giải pháp khác là dùng một node dummy tạm ở đầu của danh sách suốt quá trình tính toán. Dummy node sẽ
đóng vai trò là phần tử đầu tiên và tất cả các node “thực sự” sẽ được thêm vào sau dummy node.

struct node* BuildWithDummyNode() {


struct node dummy; // Dummy node is temporarily the first node
struct node* tail = &dummy; // Start the tail at the dummy.
// Build the list on dummy.next (aka tail->next)
int i;
dummy.next = NULL;
for (i=1; i<6; i++) {
Push(&(tail->next), i);
tail = tail->next;
}
// The real result list is now in dummy.next
// dummy.next == {1, 2, 3, 4, 5};
return(dummy.next);
}

Mội vài cách thể hiện danh sách liên kết giữ dummy node như là một phần vĩnh cửu của danh sách. Đối với chiến
lược “dummy vĩnh cửu” này, một danh sách rỗng thì không phải thể hiện bởi NULL mà thay vào đó là danh sách có một
dummy node ở đầu. Các thuật toán sẽ thông qua dummy node cho tất cả các phép tính. Nhưng tôi không khuyến khích sử
dụng kỹ thuật này.

Chiến lược “dummy-in-the stack” ở trên thì có một chút khác biệt, nhưng nó tránh được việc tạo ra một thành phần
dummy vĩnh cữu trong danh sách. Vài ví dụ trong tài liệu này sẽ dùng cách “dummy tạm thời”này. Mã nguồn của chiến
lược “dummy vĩnh cữu” thì cực kì đơn giản nhưng nó không được đưa ra ở đây.

Cuối cùng đây là một giải pháp rất “mẹo mực” mà không phải sự dụng dummy node. Đó là sử dụng một local
“reference pointer” mà luôn trỏ vào pointer cuối cùng của list chứ không phải node cuối cùng. Tất cả việc thêm vào danh
sách thì được làm thông qua một con trỏ tham chiếu. Con trỏ tham chiếu bằng đầu bằng việc trỏ vào con trỏ head. Sau đó,
nó trỏ tới trường .next bên trong node cuối cùng của danh sách. (giải thích chi tiết ở sau)

17
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
struct node* BuildWithLocalRef() {
struct node* head = NULL;
struct node** lastPtrRef= &head; // Start out pointing to the head pointer
int i;
for (i=1; i<6; i++) {
Push(lastPtrRef, i); // Add node at the last pointer in the list
lastPtrRef= &((*lastPtrRef)->next); // Advance to point to the
// new last pointer
}
// head == {1, 2, 3, 4, 5};
return(head);
}

Kĩ thuật này thì ngắn nhưng vòng lặp bên trong thì đáng sợ. Kĩ thuật này hiếm khi được sử dụng. Dưới đây là cách nó
hoạt động:

1) Ở đầu của vòng lặp, lastPtrRef trỏ tới con trỏ cuối cùng của danh sách. Ban đầu, nó trỏ tới con trỏ head. Sau đó
nó trỏ tới trường .next trong node cuối cùng của danh sách.
2) Push(lastPtrRef, i); thêm một node mới tại con trỏ cuối. Node mới trở thành node cuối của danh sách.
3) lastPtrRef= &((*lastPtrRef)->next); đẩy lastPtrRef để trỏ tới trường .next của node cuối mới.
Dưới đây là hình vẽ mô tả trạng thái của bộ nhớ cho đoạn mã nguồn trên trước khi node thứ ba được thêm vào.
Giá trị cũ của lastPtrRef thì có màu xám…

Cả hai giải pháp sử dụng temporary-dummy và reference pointer có hơi chút “không bình thường” nhưng nó rất tốt
để chắc chắn rằng chúng ta hiểu về pointer bởi vì chúng sử dụng pointer theo cách “không bình thường”.

18
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Phần 4: Các ví dụ tiêu biểu


Phần này giới thiệu các ví dụ để minh họa cho các kĩ thuật trên. Để biết thêm nhiều ví dụ hơn, các bạn hãy xem
phần tiếp theo của tài liệu này trong “ Các vấn đề về danh sách liên kết” – nhóm sẽ đưa tới các bạn trong thời gian sớm
nhất.

AppendNode()

Hãy nghĩ một hàm AppendNode() thì giống như Push(), ngoại trừ nó thêm vào node mới ở cuối danh sách thay vì
đầu danh sách. Nếu nó là một danh sách rỗng, nó sẽ dùng con trỏ tham chiếu để thay đổi con trỏ head. Mặc dù nó dùng
một vòng lặp để xác định vị trí của node cuối cùng trong danh sách. Phiên bản này không sử dụng Push(). Nó xây dựng
node mới một cách trực tiếp

struct node* AppendNode(struct node** headRef, int num) {


struct node* current = *headRef;
struct node* newNode;
newNode = malloc(sizeof(struct node));
newNode->data = num;
newNode->next = NULL;
// special case for length 0
if (current == NULL) {
*headRef = newNode;
}
else {
// Locate the last node
while (current->next != NULL) {
current = current->next;
}
current->next = newNode;
}
}

AppendNode() với Push()

Phiên bản này thì rất đơn giản, nhưng nó phụ thuộc vào Push() để xây dựng một node mới. Để hiểu phiên bản
này, cần phải hiểu được về con trỏ tham chiếu

19
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
struct node* AppendNode(struct node** headRef, int num) {
struct node* current = *headRef;
// special case for the empty list
if (current == NULL) {
Push(headRef, num);
} else {
// Locate the last node
while (current->next != NULL) {
current = current->next;
}
// Build the node after the last node
Push(&(current->next), num);
}
}

Hãy nghĩ một hàm CopyList() là nhận một danh sách và trả về một bản copy của danh sách đó. Một con trỏ có thể
chạy qua suốt danh sách gốc bằng cách thông thường. Hai con trỏ khác sẽ theo vết của danh sách mới: một con trỏ head
và một con trỏ tail - cái mà luôn luôn trỏ tới node cuối của danh sách mới. Node đầu tiên thì được hoàn thành như một
trường hợp đặc biệt và sau đó con trỏ tail dùng các cách thông thường cho các node còn lại.

struct node* CopyList(struct node* head) {


struct node* current = head; // used to iterate over the original list
struct node* newList = NULL; // head of the new list
struct node* tail = NULL; // kept pointing to the last node in the new list
while (current != NULL) {
if (newList == NULL) { // special case for the first new node
newList = malloc(sizeof(struct node));
newList->data = current->data;
newList->next = NULL;
tail = newList;
}
else {
tail->next = malloc(sizeof(struct node));
tail = tail->next;
tail->data = current->data;
tail->next = NULL;
}
current = current->next;
}
return(newList);
}

20
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Hình vẽ mô phỏng hàm CopyList()


Dưới đây là trạng thái của bộ nhớ khi CopyList() hoàn thành việc sao chép danh sách {1,2}

Bài tập CopyList() với Push()


Cách biểu diễn ở trên thì không thỏa mãn vì 3 bước tạo node bị lặp lại trong hàm. Viết một CopyList2() dùng Push() để
xử lý việc cấp phát và thêm node mới và tránh lặp lại mã nguồn.

Đáp án:

// Variant of CopyList() that uses Push()


struct node* CopyList2(struct node* head) {
struct node* current = head; // used to iterate over the original list
struct node* newList = NULL; // head of the new list
struct node* tail = NULL; // kept pointing to the last node in the new list
while (current != NULL) {
if (newList == NULL) { // special case for the first new node
Push(&newList, current->data);
tail = newList;
}
else {
Push(&(tail->next), current->data); // add each node at the tail
tail = tail->next; // advance the tail to the new last node
}
current = current->next;
}
return(newList);
}

21
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

CopyList() với Dummy Node


Một chiến lược khác cho CopyList() là dùng một dummy node tạm thời để xử lý trường hợp node đầu tiên. Dummy node
là node đầu tiên tạm thời của danh sách và con trỏ tail bắt đầu bằng việc trỏ tới nó. Tất cả các node được thêm vào con trỏ
tail

// Dummy node variant


struct node* CopyList(struct node* head) {
struct node* current = head; // used to iterate over the original
list
struct node* tail; // kept pointing to the last node in the new list
struct node dummy; // build the new list off this dummy node
dummy.next = NULL;
tail = &dummy; // start the tail pointing at the dummy
while (current != NULL) {
Push(&(tail->next), current->data); // add each node at the tail
tail = tail->next; // advance the tail to the new last node
}
current = current->next;
}
return(dummy.next);
}

CopyList() với sử dụng tham chiếu cục bộ (local references)


Phiên bản cuối cùng và ít sử dụng nhất của tham chiếu cục bộ thay cho con trỏ tail. Chiến lược là dùng một con trỏ lastPtr
để trỏ tới con trỏ cuối cùng của danh sách. Tất cả các việc thêm node thì hoàn thành qua lastPtr và nó luôn trỏ tới con trỏ
cuối cùng của danh sách. Khi danh sách rỗng, nó trỏ tới con trỏ head. Sau đó nó trỏ tới trường .next trong node cuối cùng.

// Local reference variant


struct node* CopyList(struct node* head) {
struct node* current = head; // used to iterate over the original list
struct node* newList = NULL;
struct node** lastPtr;
lastPtr = &newList; // start off pointing to the head itself
while (current != NULL) {
Push(lastPtr, current->data); // add each node at the lastPtr
lastPtr = &((*lastPtr)->next); // advance lastPtr
current = current->next;
}
return(newList);
}

22
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007

Đệ quy với CopyList()


Và cuối cùng, là một phiên bản đệ quy của CopyList(). Nó có tính chất ngắn gọn dễ chịu mà các mã nguồn đệ quy thường
có. Tuy nhiên nó thì không tốt cho việc chế tạo mã nguồn vì nó dùng không gian stack tỉ lệ với chiều dài của danh sách.

// Recursive variant
struct node* CopyList(struct node* head) {
if (head == NULL) return NULL;
else {
struct node* newList = malloc(sizeof(struct node));
// make the one node
newList->data = current->data;
newList->next = CopyList(current->next); // recur for the rest
return(newList);
}
}

Có rất nhiều biến thể của danh sách liên kết cơ bản mà có thế mạnh riêng đối với danh sách cơ bản. Tốt nhất là nên có một
sự thấu hiểu nhất định với danh sách liên kết cơ bản và mã nguồn của nó trước khi lo lắng về các biến thể quá nhiều.

 Dummy Header : Bỏ qua trường hợp mà con trỏ head là NULL. Thay vào đó, sử dụng một node “dummy” mà
có trường .data không sử dụng. Thuận lợi của kĩ thuật này là trường hợp dùng pointer-to-pointer (đối số tham
chiếu) không xảy ra trong tính toán như Push(). Và một vài phép toán khác sẽ đơn giản hơn do chúng ta thừa nhận
sự có mặt của node dummy ở đầu. Bất lợi là việc cấp phát một danh sách rỗng thì lãng phí bộ nhớ. Vài thuật toán
thì bị xấu đi do nó phải bỏ qua trường hợp dummy node ở đầu. Cách dùng dummy header là để cho lập trình viên
tránh sử dụng đối số tham chiếu như trong hàm Push(). Các ngôn ngữ mà không cho phép đối số tham chiếu nhưu
Java thì cần dùng dummy header để làm việc.
 Circular ( Vòng): Thay vì thiết lập trường .next của node cuối thành NULL, thì gán nó để trỏ tới node đầu tiên.
Thay vì cần một đầu head cố định, bất cứ con trỏ nào trong danh sách đểu có thể làm việc đó.
 Con trỏ Tail : Danh sách thì không chỉ thể hiện bởi một con trỏ head duy nhất. Thay vào đó, danh sách có con trỏ
head trỏ tới node đầu tiên và con trỏ tail trỏ tới node cuối cùng. Con trỏ tail cho phép các phép toán ở cuối danh
sách như thêm phần tử cuối và ghép hai danh sách.
 Head struct : Một biến thể có một struct header đặc biệt cái mà chứa con trỏ head, con trỏ tail và có thể là độ dài
để giúp các thao tác trở nên dễ dàng. Nhiều vấn đề của đối số tham chiếu được loại bỏ đi do hầu hết các hàm có
thể xử lý với con trỏ tới một struct head. Đây là một cách tiếp cận tốt nhất để dùng trong các ngôn ngữ không sử
dụng đối số tham chiếu
 Liên kết kép: Thay vì chỉ có một trường .next, mỗi node có cả hai con trỏ .next và .previous. Việc thêm hay xóa
bây giờ cần nhiều thao tác hơn nhưng những thao tác thì đơn giản hơn. Đưa một con trỏ tới node, việc thêm và
xóa có thể được thực hiện trực tiếp. Trong khi trong liên kết đơn, việc lặp lại cần thực hiện để xác định vị trí trước
vị trí cần thay đổi trong danh sách để con trỏ .next có thể nối ra sau.

23
More information: www.itspiritclub.net Linked list basics Translater: huahongquan2007
 Danh sách “khúc” (Chunk list) : thay vì lưu trữ các phần tử đơn trong mỗi node, lưu tữ một mảng kích cỡ xác
định cho mỗi phần tử trong một node. Thay đổi số phần tử của mỗi node có thể cung cấp một đặc trưng khác:
nhiều phần tử/ node có đặc tính giống như mảng, ít phần tử/node thì đặc tính giống như danh sách liên kết. Chunk
List là một cách tốt để xây dựng danh sách liên kết với hiệu suất cao.
 Mảng động: Thay vì dùng danh sáhc liên kết, các phần tử có thể chứa trong một khối mảng (array block) được
cấp phát ở trên heap . Nó có thể lớn lên hay giảm kích thước của khối khi cần thiết với hàm realloc(). Quản lý
khối heap theo cách này thì khá là phức tạp, nhưng có thể là một cách hiệu quả nhất cho việc lưu trữ và truy xuất.
Ngược lại, danh sách liên kết có thể có một ít bất tiện, do nó có khuynh hướng lặp lại qua các vòng nhớ mà không
liền kề

Phần tiếp theo :”Các vấn đề với danh sách liên kết” sẽ được đưa tới các bạn trong thời gian sớm nhất.

Nếu các bạn có ý kiến đóng góp hoặc muốn tham gia dịch tài liệu với chúng tôi, các bạn hãy liên hệ nhóm itspirit qua
ym: whereareyou_sweetie

24

You might also like