Chuyển tới nội dung chính

CHƯƠNG 3: CẤU TRÚC DANH SÁCH LIÊN KẾT

DSLK = Danh sách liên kết (tiếng Anh: Linked List).
Đây là viết tắt dùng xuyên suốt giáo trình, đề thi và thực hành. Khi thấy “DSLK đơn / kép / vòng”, hãy đọc là “danh sách liên kết đơn / kép / vòng”.


🎯 MỤC TIÊU CHƯƠNG 3

Sau khi học xong chương này, sinh viên có thể:

  1. Giải thích được đối tượng dữ liệu con trỏ, phân biệt Stack/Heap, cấp phát và giải phóng bộ nhớ động.
  2. Nêu định nghĩa danh sách liên kết, so sánh với mảng, và phân biệt các hình thức tổ chức (tuần tự / liên kết).
  3. Tổ chức và cài đặt DSLK đơn theo cách cấp phát liên kết (NODE, LIST, pHead, pTail).
  4. Thực hiện thành thạo các thao tác: khởi tạo, duyệt, tìm, thêm, xóa, hủy danh sách — kèm mã giả, code và độ phức tạp.
  5. Sắp xếp DSLK bằng đổi chỗ trực tiếp (đúng đề thi), biết relink và Merge Sort; trộn hai danh sách đã sắp.
  6. Cài đặt DSLK képDSLK vòng, nêu ưu nhược điểm và chọn đúng loại cho bài toán.
  7. Áp dụng DSLK vào bài toán thực tế và giải bài tập từ cơ bản đến nâng cao.

📋 NỘI DUNG CHƯƠNG 3

3.1. Giới thiệu đối tượng dữ liệu con trỏ
3.2. Danh sách liên kết (DSLK)
3.2.1. Định nghĩa DSLK
3.2.2. Các hình thức tổ chức DSLK
3.3. DSLK đơn
3.3.1. Tổ chức DSLK đơn theo cách cấp phát liên kết
3.3.2. Các thao tác cơ bản trên DSLK đơn
3.3.3. Sắp xếp danh sách
3.4. Một số DSLK khác
3.4.1. DSLK kép
A. Vì sao cần kép? Định nghĩa
B. Tổ chức DNODE / DLIST, bất biến
C. Dựng list và duyệt hai chiều
D. Thêm đầu / thêm cuối
E. Thêm sau / thêm trước / thêm tại vị trí
F. Xóa nút đang cầm, xóa đầu, xóa cuối, xóa giá trị
G. So sánh đơn–kép, ưu nhược, bẫy
H. Ứng dụng: Back/Forward, Undo/Redo, LRU, deque
3.4.2. DSLK vòng
A. Vì sao cần vòng? Tổ chức chỉ giữ pTail
B. Duyệt vòng (do-while) — không còn NULL
C. Thêm đầu / thêm cuối (cùng 2 phép, khác pTail)
D. Xóa đầu / xóa cuối / xóa giá trị / hủy
E. Vòng kép
F. Ứng dụng: round-robin, playlist, Josephus
G. Ưu nhược, chọn loại
H. Bảng bốn loại đơn / kép / vòng / vòng kép

Lưu ý cài đặt: Phần 3.1 dùng C để thấy rõ malloc/free (bản chất con trỏ). Từ mục 3.3 trở đi dùng C++ theo đúng mẫu giáo trình thực hành và đề thi: NODE, LIST, pHead, pTail, pNext. Con trỏ trong C và C++ là cùng một ý tưởng.


📖 CÁCH ĐỌC TÀI LIỆU NÀY (TỪ ZERO ĐẾN HERO)

MốcĐọc mụcKhi nào thì được sang bước sau
Nền3.1 → 3.2Vẽ được ô nhớ của int *p = &a và nói được DSLK khác mảng chỗ nào
Trọng tâm đề thi3.3.1 → 3.3.2 → 3.3.3.A + 3.3.3.DTự viết ThemDau, ThemCuoi, XoaDau, XoaCuoi, SapXep (đổi chỗ trực tiếp), Noi không nhìn tài liệu
Mở rộng chương3.4.1 → 3.4.2Vẽ thêm/xóa kép (sửa đủ 4 liên kết); duyệt vòng bằng do-while; Josephus n=5,k=2n=5,k=2
Cao cấp3.5 → 3.6Chỉ học khi đã code được 3.3; không cần thuộc để qua môn

Mỗi thao tác cơ bản được viết theo một khuôn: ý tưởng → sơ đồ trước/sau → mã giả → code → độ phức tạp → bẫy. Gặp chỗ đánh (nâng cao) thì có thể bỏ qua lần đọc đầu.


📚 BẢNG THUẬT NGỮ

Thuật ngữ / viết tắtNghĩa tiếng ViệtGhi nhớ nhanh
DSLKDanh sách liên kếtDãy nút nắm tay nhau bằng con trỏ
Nút / NODEMột phần tử của DSLKGồm data + con trỏ liên kết
pHeadCon trỏ đầu danh sáchCửa vào của DSLK thẳng
pTailCon trỏ cuối danh sáchThêm cuối O(1)O(1); vòng đơn chỉ cần pTail (head = pTail->pNext)
pNext / pPrevCon trỏ nút sau / nút trướcĐơn chỉ có pNext; kép có cả hai
NULLKhông trỏ tới ô nhớ nàoLính canh hết list thẳng; vòng không còn NULL
DSLK képMỗi nút hai chiềuĐi lùi, xóa nút đang cầm, xóa cuối — O(1)O(1)
DSLK vòngĐuôi nắm đầuDữ liệu tuần hoàn; duyệt bằng do-while
Bất biếnĐiều luôn đúng sau mọi thao tácKép: hai chiều khớp; vòng: pTail->pNext là head
Cấp phát tuần tựLưu bằng mảng, ô nhớ kề nhaua[i]
Cấp phát liên kếtMỗi nút tự “ôm” địa chỉ nút kếKhông có a[i]
HeapVùng nhớ xin bằng new/mallocNode sống đến khi delete/free
Stack (bộ nhớ)Vùng nhớ biến cục bộHết hàm là mất — không để node ở đây
Memory leakRò rỉ bộ nhớnew mà quên delete
Dangling pointerCon trỏ treoTrỏ tới ô đã delete
ADTKiểu dữ liệu trừu tượng“Danh sách là gì” trước khi cài bằng gì
RelinkNối lại con trỏĐổi chỗ nút mà không copy data
Dummy / header nodeNút giả không chứa dữ liệu thậtLàm code thêm/xóa gọn hơn (khóa này không dùng)

3.1. GIỚI THIỆU ĐỐI TƯỢNG DỮ LIỆU CON TRỎ

Danh sách liên kết không tồn tại nếu không có con trỏ. Trước khi học DSLK, phải làm chủ bốn thứ:

  1. Địa chỉ ô nhớ và biến con trỏ.
  2. Toán tử & (lấy địa chỉ) và * (giải tham chiếu).
  3. Con trỏ tới struct và toán tử ->.
  4. Cấp phát / giải phóng bộ nhớ trên Heap.

Nếu bỏ qua mục này, các thao tác thêm/xóa node sẽ thành “copy code cho chạy” chứ không hiểu vì sao phải đổi con trỏ.


3.1.1. Bộ nhớ máy tính: biến, địa chỉ, giá trị

Mỗi biến khi chương trình chạy được hệ điều hành cấp một (hoặc nhiều) ô nhớ. Ô nhớ có:

Thành phầnÝ nghĩaVí dụ
TênCách lập trình viên gọi biếna
KiểuCách diễn giải các bitint (thường 4 byte)
Giá trịDữ liệu đang chứa10
Địa chỉVị trí ô nhớ trên RAM0x7ffe1234
int a = 10;
printf("Gia tri a = %d\n", a);
printf("Dia chi a = %p\n", (void*)&a); /* &a = dia chi cua a */

Ẩn dụ: Căn nhà có số nhà (địa chỉ) và người đang ở (giá trị). Muốn đến nhà đó, bạn cần số nhà, không cần nhớ người đang ở là ai.


3.1.2. Con trỏ là gì?

Định nghĩa: Con trỏ (pointer) là biến chuyên lưu địa chỉ của một biến (hoặc vùng nhớ) khác.

a (int) p (int*)
┌────────┐ ┌──────────┐
│ 10 │ <──────────────│ &a │
└────────┘ └──────────┘
địa chỉ: 0x100 địa chỉ: 0x200

Khai báo và hai toán tử bắt buộc

int a = 10;
int *p = &a; /* p luu dia chi cua a */

printf("%d\n", a); /* 10 - gia tri cua a */
printf("%p\n", (void*)p); /* 0x100 - gia tri cua p (la dia chi) */
printf("%d\n", *p); /* 10 - lay gia tri tai dia chi ma p dang tro */
Ký hiệuTênViệc nó làm
&xAddress-ofLấy địa chỉ của x
*pDereference / gián tiếpĐọc/ghi giá trị tại ô nhớp đang trỏ
int *pKhai báop là con trỏ tới int

Quy tắc vàng:

  • p = địa chỉ.
  • *p = dữ liệu nằm ở địa chỉ đó.
  • Muốn đổi dữ liệu qua con trỏ: gán *p = ....
  • Muốn đổi chỗ đang trỏ: gán p = ....
int a = 10, b = 20;
int *p = &a;

*p = 99; /* a thanh 99, p van tro toi a */
p = &b; /* p doi sang tro toi b, a van la 99 */
*p = 5; /* b thanh 5 */

Đi từng dòng trên ô nhớ (địa chỉ minh họa). Đây là bài tập “zero” bắt buộc — đọc chậm:

Sau câu lệnhabp (chứa địa chỉ)*p (giá trị tại chỗ p đang trỏ)
int a = 10;10 tại 0x100
int b = 20;1020 tại 0x104
int *p = &a;10200x10010
*p = 99;99200x100 (không đổi)99
p = &b;99200x10420
*p = 5;9950x1045

Hai thao tác không được lẫn:

  • *p = 99 = “vào nhà số p, đổi người đang ở”. Biến a đổi, bản thân p không đổi.
  • p = &b = “chuyển p sang giữ số nhà khác”. a không bị đụng.

Cách viết khai báo đều hợp lệ và cùng nghĩa: int *p, int* p, int * p. Giáo trình dùng NODE* p (kiểu nhìn rõ hơn).


3.1.3. Con trỏ NULL và con trỏ hoang

int *p = NULL; /* p khong tro toi o nho hop le nao */

NULL (trong C++11 trở đi thường viết nullptr) nghĩa là “không trỏ tới đâu cả”. Đây là giá trị lính canh của DSLK: node cuối luôn có pNext = NULL.

Cấm tuyệt đối giải tham chiếu con trỏ NULL hoặc con trỏ chưa khởi tạo:

int *p; /* wild pointer: chua gan, chua biet tro toi dau */
*p = 10; /* UNDEFINED BEHAVIOR - co the crash */

int *q = NULL;
*q = 10; /* crash: segmentation fault */

Trước khi dùng *p, luôn kiểm tra:

if (p != NULL) {
printf("%d\n", *p);
}

3.1.4. Con trỏ và struct — toán tử ->

Node của DSLK là một struct. Truy cập thành viên qua con trỏ dùng ->.

struct NODE {
int data;
struct NODE *pNext;
};

struct NODE n;
n.data = 5;
n.pNext = NULL;

struct NODE *p = &n;
p->data = 7; /* tuong duong (*p).data = 7 */
p->pNext = NULL; /* tuong duong (*p).pNext = NULL */

Ghi nhớ:

p->data ≡ (*p).data

Cả chương này, mọi thao tác DSLK đều là: đọc/ghi p->data và đổi p->pNext.


3.1.5. Stack và Heap — hai “kho” bộ nhớ

StackHeap
Ai cấp phát?Compiler, tự động khi vào hàmLập trình viên (malloc / new)
Thời gian sốngHết hàm là mấtSống đến khi free / delete
Tốc độRất nhanhChậm hơn (phải tìm ô trống)
Kích thướcCố định, nhỏ (vài MB)Lớn, gần như RAM còn lại
Ví dụint a = 10; int arr[100];Node của DSLK
#include <stdlib.h>
void f() {
int x = 5; /* Stack: chet khi thoat f() */
int *p = (int*)malloc(sizeof(int));
if (p == NULL) return;
*p = 5;
free(p);
}

Vì sao DSLK phải dùng Heap? Vì số node không biết trước, và node phải tồn tại sau khi hàm tạo node kết thúc. Nếu cấp phát node trên Stack, khi hàm return thì node biến mất, con trỏ thành dangling.


3.1.6. Cấp phát động trong C: malloc, calloc, free

#include <stdio.h>
#include <stdlib.h>

int main(void) {
/* Xin 1 o int tren Heap */
int *p = (int*)malloc(sizeof(int));
if (p == NULL) { /* BAT BUOC kiem tra: het RAM */
printf("Khong cap phat duoc\n");
return 1;
}
*p = 42;
printf("%d\n", *p);
free(p); /* Tra lai he dieu hanh */
p = NULL; /* Tranh dangling pointer */
return 0;
}
HàmViệc làm
malloc(n)Xin n byte, không xóa rác trong ô nhớ
calloc(k, sz)Xin k*sz byte, điền 0
realloc(p, n)Đổi kích thước vùng nhớ (có thể copy sang chỗ mới)
free(p)Trả vùng nhớ. Gọi 2 lần trên cùng pdouble free (lỗi nặng)

Cấp phát một node (tư duy sẽ dùng suốt chương):

struct NODE *p = (struct NODE*)malloc(sizeof(struct NODE));
if (p == NULL) { /* xu ly loi */ }
p->data = 10;
p->pNext = NULL;

3.1.7. Cấp phát động trong C++: new / delete

Giáo trình thực hành dùng C++:

NODE* p = new NODE; // cap phat 1 node
p->data = 10;
p->pNext = NULL;

delete p; // giai phong 1 node
p = NULL;
CC++
malloc + ép kiểunew NODE
free(p)delete p
Không gọi constructorCó gọi constructor

Không lẫn: malloc đi với free, new đi với delete. Không free bộ nhớ new, không delete bộ nhớ malloc.


3.1.8. Bốn lỗi con trỏ “chết người” trong DSLK

1. Memory leak (rò rỉ bộ nhớ)new mà quên delete:

NODE* p = new NODE;
p->data = 1;
p = new NODE; // mat dia chi node cu → LEAK

2. Dangling pointer — xóa rồi vẫn dùng:

delete p;
p->data = 5; // p tro toi o nho da tra lai HĐH

3. Mất liên kết — đổi pNext trước khi giữ địa chỉ node sau:

/* SAI: mat het phan duoi danh sach */
pHead = pHead->pNext; // neu khong luu node cu thi khong delete duoc
// va neu day la node duy nhat con lai thi OK,
// nhung neu muon xoa node cu: phai giu temp

Cách đúng khi xóa đầu:

NODE* temp = l.pHead;
l.pHead = l.pHead->pNext;
delete temp;

4. Double freedelete cùng node hai lần.


3.1.9. Con trỏ cấp hai (NODE**) và truyền tham chiếu

Khi hàm cần đổi chính con trỏ pHead, phải truyền địa chỉ của con trỏ.

/* Cach 1: con tro cap 2 (C) */
void ThemDau(NODE **pHead, int x) {
NODE* p = new NODE;
p->data = x;
p->pNext = *pHead;
*pHead = p; /* doi head o ben ngoai ham */
}

/* Cach 2: tham chieu C++ — dung trong giao trinh */
void ThemDau(LIST &l, NODE* p) {
p->pNext = l.pHead;
l.pHead = p;
}

Từ đây, giáo trình dùng cách 2: LIST &l. Dấu & ở đây là tham chiếu C++ (không phải lấy địa chỉ &a của mục 3.1.2): hàm sửa được pHead/pTail của người gọi.

Đoạn ThemDau phía trên cố ý rút gọn để chỉ ra ý “đổi được head”. Bản đủ (xét rỗng, cập nhật pTail) nằm ở mục 3.3.2.


3.1.10. Con trỏ và mảng (để đối chiếu với DSLK)

int a[5] = {1, 2, 3, 4, 5};
int *p = a; /* ten mang chinh la dia chi phan tu dau */
printf("%d\n", *(p + 2)); /* 3 — so hoc con tro */
printf("%d\n", a[2]); /* 3 — tuong duong */

Mảng: phần tử nằm liền nhau, nên p+1 nhảy đúng sizeof(int) byte.

DSLK: phần tử rải rác, p+1 không ra node kế. Muốn sang node sau bắt buộc p = p->pNext. Đây là lý do DSLK không truy cập ngẫu nhiên O(1) được.


3.1.11. Ví dụ tổng hợp: tạo 3 node rời rồi nối tay

Đọc ví dụ này xong là đã “nhìn thấy” DSLK.

#include <iostream>
using namespace std;

struct NODE {
int data;
NODE* pNext;
};

int main() {
NODE* a = new NODE; a->data = 10; a->pNext = NULL;
NODE* b = new NODE; b->data = 20; b->pNext = NULL;
NODE* c = new NODE; c->data = 30; c->pNext = NULL;

/* Noi: 10 -> 20 -> 30 -> NULL */
a->pNext = b;
b->pNext = c;

NODE* p = a;
while (p != NULL) {
cout << p->data << " -> ";
p = p->pNext;
}
cout << "NULL\n";

delete a; delete b; delete c;
return 0;
}

Sơ đồ bộ nhớ (địa chỉ minh họa) — khớp code a->pNext = b; b->pNext = c;:

biến a


Heap: 0x10 0x50 0x90
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼─────►│ 20 │ ●─┼─────►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

pNext của 10 chứa 0x50, không phải “ô bên cạnh”

Ba node không nằm kề nhau (0x10, 0x50, 0x90). Chúng chỉ “biết nhau” nhờ pNext.


✅ Kiểm tra nhanh 3.1

Tự trả lời rồi lật đáp án ở dưới.

  1. Khác nhau giữa p*p?
  2. Vì sao node DSLK không khai báo NODE n; trong hàm rồi return &n;?
  3. malloc thất bại trả về gì? Phải làm gì?
  4. Muốn xóa nút đầu: nếu viết delete pHead; rồi mới pHead = pHead->pNext thì sao? Nếu viết pHead = pHead->pNext mà không giữ nút cũ để delete thì sao?

Đáp án:

  1. pđịa chỉ; *pgiá trị nằm tại địa chỉ đó.
  2. n nằm trên Stack, chết khi hàm return → địa chỉ trả về thành dangling. Node phải new trên Heap.
  3. Trả về NULL. Phải kiểm tra, không được p->data = .... (Còn new kiểu C++ chuẩn thì ném exception, không trả NULL; trong bài tập nhập môn vẫn hay kiểm tra NULL cho thống nhất tư duy “cấp phát có thể thất bại”.)
  4. delete trước rồi đọc pHead->pNext = dùng ô nhớ đã trả (dangling, crash). Đổi pHead trước mà không delete nút cũ = leak. Cách đúng: temp = pHead; pHead = pHead->pNext; delete temp;.

3.2. DANH SÁCH LIÊN KẾT (DSLK)

DSLK chính là Danh sách liên kết. Phần này trả lời hai câu: nó là gì?cài nó bằng cách nào?

3.2.1. Định nghĩa DSLK

A. Danh sách tuyến tính (ôn lại)

Danh sách tuyến tính (linear list) là tập hữu hạn các phần tử cùng kiểu, có thứ tự:

L=(a0,a1,a2,,an1)L = (a_0, a_1, a_2, \dots, a_{n-1})
  • a0a_0 là phần tử đầu, an1a_{n-1} là phần tử cuối.
  • aia_i đứng trước ai+1a_{i+1}, đứng sau ai1a_{i-1}.
  • Các thao tác trừu tượng (ADT List): khởi tạo rỗng, kiểm tra rỗng, lấy độ dài, lấy phần tử thứ ii, tìm, thêm, xóa, duyệt.

Mảng là một cách cài ADT này. DSLK là cách khác.

B. Định nghĩa danh sách liên kết

Danh sách liên kết là cấu trúc dữ liệu tuyến tính, trong đó:

  • Mỗi phần tử được đặt trong một nút (node / NODE).
  • Nút gồm hai phần: thông tin (data) và liên kết (con trỏ tới nút khác).
  • Các nút không cần nằm liên tục trong bộ nhớ. Thứ tự logic do con trỏ quyết định, không do địa chỉ vật lý.
  • Nút cuối trỏ tới NULL (DSLK thẳng) hoặc trỏ về nút đầu (DSLK vòng).

Ẩn dụ: Mảng là toa tàu hàn chết với nhau — muốn chèn toa giữa phải đẩy cả đoàn. DSLK là nhóm người nắm tay nhau — muốn chèn người mới chỉ cần hai người kề nhau buông tay rồi nắm tay người mới.

C. Nút (NODE) — viên gạch của DSLK

Mỗi nút là một hộp hai ngăn: ngăn trái chứa dữ liệu, ngăn phải chứa địa chỉ nút kế.

┌────────────┬────────────┐
│ data │ pNext │
│ 10 │ ●──────┼──► nút kế (hoặc / = NULL nếu là nút cuối)
└────────────┴────────────┘
thông tin liên kết
  • data: số nguyên, sinh viên, đơn hàng… — payload.
  • pNext: địa chỉ nút kế. Đây là thứ biến “đám node rời” thành “danh sách”.

D. So sánh mảng và DSLK (nắm để chọn đúng cấu trúc)

Tiêu chíMảng (cấp phát tuần tự)DSLK (cấp phát liên kết)
Vị trí RAMLiên tụcRải rác
Kích thướcCố định (hoặc phải realloc)Tăng/giảm từng nút
Truy cập phần tử thứ iiO(1)O(1)O(n)O(n) — phải đi từ đầu
Thêm/xóa đầuO(n)O(n) — dồn mảngO(1)O(1)
Thêm/xóa giữa (đã có sẵn con trỏ tới vị trí)O(n)O(n) — dồnO(1)O(1) — chỉ sửa 1–2 con trỏ
Thêm/xóa giữa (phải tìm vị trí)O(n)O(n)O(n)O(n) tìm + O(1)O(1) sửa
Bộ nhớ phụChỉ dữ liệuThêm 1 con trỏ / nút (overhead)
Cục bộ cacheTốt (nằm kề nhau)Kém (nhảy lung tung trên RAM)
Tìm kiếm nhị phânLàm được nếu đã sortKhông làm trực tiếp (không có chỉ số)

Khi nào chọn DSLK?

  • Không biết trước số phần tử.
  • Thêm/xóa đầu hoặc giữa diễn ra rất thường xuyên.
  • Không cần truy cập ngẫu nhiên a[i].
  • Cần cấu trúc linh hoạt: ngăn xếp, hàng đợi, đa thức, đồ thị (danh sách kề)…

Khi nào chọn mảng?

  • Cần a[i] thường xuyên.
  • Dữ liệu ít thay đổi kích thước.
  • Cần tận dụng cache CPU (xử lý số lớn, ma trận).

E. Định nghĩa hình thức (toán học) — có thể bỏ qua lần đọc đầu

Mục này dành cho ai muốn phát biểu “cho chặt”. Không cần thuộc để cài code.

DSLK đơn là bộ (N,H)(N, H) trong đó:

  • NN là tập nút; mỗi nút xx có trường data(x)next(x) ∈ N ∪ {NULL}.
  • HH (head) là nút đầu, hoặc NULL nếu danh sách rỗng.
  • Mọi nút (trừ NULL) đều đến được từ HH bằng cách lặp next.
  • Không có chu trình (với DSLK thẳng): đi theo next luôn tới NULL.

3.2.2. Các hình thức tổ chức DSLK

hai cách lớn để cài một danh sách tuyến tính, và trong cách liên kết lại có vài biến thể.

A. Tổ chức tuần tự (sequential allocation)

Các phần tử nằm kề nhau trong một khối nhớ (mảng):

Chỉ số: 0 1 2 3 4
┌─────┬─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ 40 │ │
└─────┴─────┴─────┴─────┴─────┘
địa chỉ: 1000 1004 1008 1012 ...

Phần tử thứ ii ở địa chỉ:

addr(ai)=addr(a0)+i×sizeof(a0)\text{addr}(a_i) = \text{addr}(a_0) + i \times \text{sizeof}(a_0)

Đó là lý do truy cập O(1)O(1). Nhược điểm: chèn vào giữa phải dịch các phần tử bên phải.

Chèn 25 vào giữa mảng [10, 20, 30, 40]:
TRƯỚC [ 10 | 20 | 30 | 40 | ]
CHÈN [ 10 | 20 | 25 | 30 | 40 ] ← 30 và 40 phải dời sang phải

chỗ mới: copy hàng loạt, O(n)
/* Chen x vao vi tri k trong mang a[0..n-1] */
for (int i = n; i > k; i--)
a[i] = a[i - 1];
a[k] = x;
n++;

Độ phức tạp: O(n)O(n).

Trên DSLK, cùng việc chèn chỉ sửa hai con trỏ, không dời 30 và 40 (xem hình mục 3.2.2.B và thao tác ThemSau).

B. Tổ chức liên kết (linked allocation) — trọng tâm chương

Mỗi phần tử “ôm” địa chỉ phần tử kế:

pHead


┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘
0x10 0x88 0x24 (địa chỉ rải rác)
data next data next data NULL

Quy ước vẽ dùng suốt chương (học thuộc để đọc mọi hình sau):

┌────┬────┐
│ 10 │ ●─┼───► ●─┼───► = con trỏ pNext đang trỏ tới nút kế
└────┴────┘
│ 30 │ / │──► NULL / = NULL (hết danh sách thẳng)

Không còn công thức địa chỉ theo chỉ số. Muốn tới phần tử thứ ii phải đi ii bước.

Ưu: chèn/xóa tại chỗ đã đứng chỉ sửa con trỏ, không dồn dữ liệu. Nhược: tốn RAM cho con trỏ; truy cập chậm; cache kém.

C. Danh sách liên kết tĩnh (cursor / mảng nút)

Khi ngôn ngữ không có con trỏ (Pascal cổ, Fortran) hoặc muốn quản lý bộ nhớ thủ công, dùng mảng các nút và liên kết bằng chỉ số nguyên:

struct StaticNode {
int data;
int next; // chi so o mang, -1 = NULL
};

StaticNode pool[100];
int pHead = 0; // nut dau o chi so 0
// pool[0] = {10, 2}
// pool[2] = {20, 5}
// pool[5] = {30, -1}

Đây vẫn là “tổ chức liên kết”, chỉ khác là liên kết bằng chỉ số chứ không bằng địa chỉ.

Đọc ví dụ pHead = 0, pool[0]={10,2}, pool[2]={20,5}, pool[5]={30,-1}:

Chỉ số: 0 1 2 3 4 5
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ 2 │ │ 20 │ 5 │ │ 30 │ -1 │
└────┴────┘ └────┴────┘ └────┴────┘
▲ │ ▲ │ ▲
pHead=0 ───┘ └── nhảy tới ô 2 ─┘ └── nhảy tới ô 5 ──┘ └── hết (-1)

Thứ tự LOGIC: 10 → 20 → 30 (ô nhớ 0, 2, 5 không liền nhau)

Trong môn này ta dùng con trỏ thật (NODE*). Biết cursor để khi hỏi “ngôn ngữ không có con trỏ thì tổ chức liên kết thế nào?” vẫn trả lời được.

D. Phân loại theo chiều liên kết và hình dạng

DANH SÁCH LIÊN KẾT

┌─────────────────┼─────────────────┐
│ │ │
DSLK đơn DSLK kép DSLK vòng
(1 con trỏ) (2 con trỏ) (đuôi nối đầu)
│ │ │
│ │ ┌───────┴───────┐
│ │ vòng đơn vòng kép
LoạiCon trỏ mỗi nútDuyệtNút cuối trỏ tới
Đơn (singly)pNextMột chiềuNULL
Kép (doubly)pPrev, pNextHai chiềupNext = NULL, pPrev của đầu = NULL
Vòng đơnpNextMột chiều, không gặp NULLNút đầu
Vòng képpPrev, pNextHai chiều khép kínĐầu ↔ cuối

Bốn hình — nhìn là phân biệt được:

1. DSLK đơn (thẳng):

pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

2. DSLK kép (thẳng, hai chiều):

pHead pTail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │──► NULL
└────┴────┴────┘ └─▲──┴────┴────┘ └─▲──┴────┴────┘
▲ │ │
└─────────────────┴──────────────────┘
pPrev (mỗi nút nhớ cả người đứng trước)
NULL◄── ngăn prev của nút đầu

Cột trái = pPrev, cột giữa = data, cột phải = pNext.

3. DSLK vòng đơn (đuôi nắm đầu, không còn NULL):

┌──────────────────────────────────────────┐
▼ │
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ │
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼────┘
└────┴────┘ └────┴────┘ └────┴────┘
▲ ▲
head pTail
(head = pTail->pNext)

4. DSLK vòng kép (khép kín hai chiều):

┌─────────────────────────────────────────────────┐
▼ │
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐│
│ ● │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ ●─┼┘
└────┴────┴────┘ └─▲──┴────┴────┘ └─▲──┴────┴────┘
▲ │ │
└────────────────┴──────────────────┘
head->pPrev == pTail pTail->pNext == head

E. Biến thể tổ chức: có / không có nút giả (dummy / header node)

Không dummy (giáo trình dùng cách này):

pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

Danh sách rỗng: pHead == NULL. Mọi hàm thêm/xóa phải xét riêng trường hợp rỗng và trường hợp 1 nút.

Có dummy (nâng cao, code gọn hơn):

pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ - │ ●─┼───►│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘
dummy
(không chứa dữ liệu có nghĩa)

Dummy không chứa dữ liệu có nghĩa. Danh sách “rỗng” vẫn còn 1 nút giả ⇒ thêm đầu luôn là ThemSau(dummy, p), không cần if (pHead == NULL).

Khóa này theo giáo trình: không dùng dummy, luôn xử lý rỗng / 1 nút tường minh. Biết dummy để đọc tài liệu tiếng Anh không bị bỡ ngỡ.

F. Tổ chức quản lý danh sách: chỉ pHead hay thêm pTail?

struct LIST {
NODE* pHead; // bat buoc
NODE* pTail; // khuyen dung
};
Chỉ pHeadpHead + pTail
Thêm đầuO(1)O(1)O(1)O(1)
Thêm cuốiO(n)O(n)O(1)O(1)
Xóa đầuO(1)O(1)O(1)O(1)
Xóa cuốiO(n)O(n)O(n)O(n) với DSLK đơn (vẫn phải tìm nút trước tail)
Bộ nhớ1 con trỏ2 con trỏ

Đề thi và thực hành dùng cả pHead lẫn pTail. Phải cập nhật pTail mỗi khi thêm/xóa ảnh hưởng nút cuối, nếu quên thì danh sách hỏng âm thầm.

✅ Kiểm tra nhanh 3.2

  1. DSLK viết tắt của gì? Khác mảng ở chỗ nào về vị trí RAM?
  2. Vì sao truy cập phần tử thứ ii của DSLK là O(n)O(n)?
  3. Tổ chức tuần tự và tổ chức liên kết khác nhau thế nào?
  4. pTail rồi, xóa cuối DSLK đơn có O(1)O(1) không? Vì sao?

Đáp án: (1) Danh sách liên kết; nút rải rác, mảng liền kề. (2) Không có công thức địa chỉ, phải đi ii bước từ pHead. (3) Tuần tự = mảng kề nhau; liên kết = mỗi nút giữ địa chỉ (hoặc chỉ số) nút kế. (4) Không — không có pPrev, phải tìm nút kế cuối.


3.3. DSLK ĐƠN

Đã có định nghĩa và các hình thức tổ chức. Từ đây cài một loại cụ thể: danh sách liên kết đơn, cấp phát động bằng con trỏ — đúng trọng tâm đề thi.

DSLK đơn (Singly Linked List): mỗi nút chỉ biết nút đứng sau. Không đi ngược được.

pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘

Đứng ở nút 30: biết 40, KHÔNG biết 20 → muốn xóa 40 phải đi lại từ pHead

Tính chất:

  • Đi từ pHead theo pNext sẽ thăm hết nút, kết thúc ở NULL.
  • Từ một nút không biết nút trước — muốn biết phải duyệt lại từ đầu. Đây là lý do xóa cuối / thêm trước nút pp tốn O(n)O(n).

3.3.1. Tổ chức DSLK đơn theo cách cấp phát liên kết

A. Khai báo cấu trúc — học thuộc, viết đúng trong mọi bài

#include <iostream>
#include <climits>
using namespace std;

struct NODE {
int data; // thong tin
NODE* pNext; // lien ket toi nut ke
};

struct LIST {
NODE* pHead; // nut dau
NODE* pTail; // nut cuoi
};

Mỗi lần new NODEmột lần cấp phát liên kết: hệ điều hành tìm một chỗ trống trên Heap, trả về địa chỉ, ta cất địa chỉ đó vào pNext của nút trước.

struct NODE struct LIST
┌────┬────┐ ┌────────┬────────┐
│data│pNext│ │ pHead │ pTail │
└────┴────┘ └────────┴────────┘
▲ │ │
│ │ └──► nút cuối
└── new NODE └──────────► nút đầu

B. Khởi tạo danh sách rỗng

LIST l
┌────────┬────────┐
│ pHead │ pTail │
└───┬────┴───┬────┘
│ │
▼ ▼
NULL NULL ← chưa có nút nào trên Heap

Mã giả:

THUẬT TOÁN KhoiTao(l)
Vào: l — danh sách
Ra: l rỗng
1. l.pHead ← NULL
2. l.pTail ← NULL
void KhoiTao(LIST &l) {
l.pHead = l.pTail = NULL;
}

bool IsEmpty(LIST l) {
return l.pHead == NULL;
}

C. Tạo một nút — đơn vị cấp phát

Mã giả:

THUẬT TOÁN TaoNode(x) → p
1. p ← cấp phát một NODE
2. Nếu p = NULL thì báo lỗi, dừng
3. p.data ← x
4. p.pNext ← NULL // nut moi chua noi vao ai
5. Trả về p
NODE* TaoNode(int x) {
NODE* p = new NODE; // het RAM: C++ nem bad_alloc, khong tra NULL
p->data = x;
p->pNext = NULL; // bat buoc truoc khi noi vao list
return p;
}

Giáo trình thực hành cũng viết như trên (không kiểm NULL). Muốn new trả NULL khi hết RAM: NODE* p = new (nothrow) NODE; rồi mới if (p == NULL).

Quy tắc: nút mới luônpNext = NULL trước khi gắn vào danh sách. Gắn xong mới nối.

TaoNode(10) trả về p:

p


┌────┬────┐
│ 10 │ / │──► NULL ← hộp đứng một mình, chưa ai nắm, chưa nắm ai
└────┴────┘

D. Mô hình cấp phát khi thêm lần lượt 10, 20, 30 vào cuối

Bước 0 — rỗng:

pHead ──► NULL pTail ──► NULL

Bước 1 — thêm 10 (danh sách rỗng: head = tail = nút mới):

pHead, pTail


┌────┬────┐
│ 10 │ / │──► NULL
└────┴────┘

Bước 2 — thêm 20: pTail->pNext = p rồi pTail = p

pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ / │──► NULL
└────┴────┘ └────┴────┘

Bước 3 — thêm 30:

pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

Trên RAM, ba nút có thể ở 0x1A00, 0x20F8, 0x0C10 — không liền kề. Logic vẫn là 10→20→30 vì ta gán con trỏ, không phải vì địa chỉ tăng dần.

E. Duyệt danh sách — thao tác nền của mọi thuật toán

Muốn “nhìn” hết danh sách, chỉ có một cách: đi từ pHead đến khi gặp NULL.

Mã giả:

THUẬT TOÁN Duyet(l)
1. p ← l.pHead
2. while p ≠ NULL
3. xử lý p.data // in, dem, so sanh...
4. p ← p.pNext
5. hết
void Xuat(LIST l) {
NODE* p = l.pHead;
cout << "Danh sach: ";
while (p != NULL) {
cout << p->data << " -> ";
p = p->pNext;
}
cout << "NULL\n";
}

int DemSoPhanTu(LIST l) {
int dem = 0;
for (NODE* p = l.pHead; p != NULL; p = p->pNext)
dem++;
return dem;
}
Thời gianKhông gian
Duyệt / Đếm / XuấtO(n)O(n)O(1)O(1)

Lỗi kinh điển khi duyệt:

while (p != NULL) {
cout << p->data;
/* quen p = p->pNext → vong lap vo han */
}
while (p->pNext != NULL) { // SAI neu muon xu ly ca nut cuoi
cout << p->data;
p = p->pNext;
}

Điều kiện p != NULL thì thăm mọi nút. Điều kiện p->pNext != NULL thì bỏ nút cuối — chỉ dùng khi cố ý dừng ở nút trước cuối (ví dụ xóa cuối).

F. Hai cách “cầm” danh sách trong hàm

void Xuat(LIST l); // copy 2 con tro head/tail, khong sua duoc danh sach goc
void ThemDau(LIST &l, NODE* p); // sua duoc pHead, pTail

In/đếm/tìm: truyền LIST l. Thêm/xóa/sắp xếp: truyền LIST &l.


3.3.2. Các thao tác cơ bản trên DSLK đơn

Thứ tự logic khi học (và khi viết chương trình):

Tạo rỗng → Tạo nút → Thêm (đầu/cuối) → Xuất
→ Tìm / Đếm / Min-max
→ Thêm giữa / Xóa
→ Hủy

Mục 3.3.1 đã xong khối “tạo rỗng, tạo nút, xuất”. Dưới đây lần lượt: tìm → thêm → xóa → thống kê → hủy. Mỗi thao tác: ý tưởng → sơ đồ → mã giả → code → độ phức tạp → bẫy.

Muốn thử ngay: gõ KhoiTao + ThemCuoi + Xuat trước, rồi mới học xóa. Đừng học xóa khi chưa dựng được một list.


① Tìm kiếm theo giá trị

Ý tưởng: duyệt tuyến tính, so sánh data. DSLK không binary search được — không có chỉ số, không nhảy giữa list được.

Ví dụ: tìm 30 trong 10 → 20 → 30 → 40. Con trỏ p đi từng hộp, không nhảy:

pHead


┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘
▲ ▲ ▲
B1: 10≠30 B2: 20≠30 B3: 30=30 → trả về p

Không tìm thấy: p đi tới NULL, trả về NULL (không phải -1 như trên mảng — vì không có chỉ số).

Mã giả:

THUẬT TOÁN Tim(l, x) → p
1. p ← l.pHead
2. while p ≠ NULL
3. nếu p.data = x thì trả về p
4. p ← p.pNext
5. trả về NULL
NODE* Tim(LIST l, int x) {
NODE* p = l.pHead;
while (p != NULL) {
if (p->data == x) return p;
p = p->pNext;
}
return NULL;
}
Trường hợpĐộ phức tạp
Tốt nhất (nút đầu)O(1)O(1)
Trung bình / xấu nhấtO(n)O(n)

② Thêm vào đầu — O(1)O(1) — thao tác “rẻ” nhất của DSLK đơn

Ý tưởng: nút mới trỏ tới head cũ, rồi head trỏ tới nút mới.

Trường hợp rỗng:

Trước: pHead ──► NULL pTail ──► NULL

Sau: pHead, pTail


┌────┬────┐
│ 5 │ / │──► NULL
└────┴────┘

Trường hợp đã có nút — đúng thứ tự ① rồi ②:

TRƯỚC p (nút mới, chưa nối)
pHead pTail │
│ │ ▼
▼ ▼ ┌────┬────┐
┌────┬────┐ ┌────┬────┐ │ 5 │ / │
│ 10 │ ●─┼───►│ 20 │ / │ └────┴────┘
└────┴────┘ └────┴────┘

① p->pNext = l.pHead (5 nắm lấy 10 — PHẢI làm trước)
② l.pHead = p (cửa vào chuyển sang 5)
pTail không đổi

SAU
pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 5 │ ●─┼───►│ 10 │ ●─┼───►│ 20 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

Sai nếu đảo ② trước ①: pHead = p khi p->pNext còn NULL → mất đường tới 10 và 20 (rò rỉ cả list cũ).

Mã giả:

THUẬT TOÁN ThemDau(l, p)
1. nếu l.pHead = NULL
2. l.pHead ← p
3. l.pTail ← p
4. ngược lại
5. p.pNext ← l.pHead
6. l.pHead ← p
void ThemDau(LIST &l, NODE* p) {
if (p == NULL) return;
if (l.pHead == NULL) {
l.pHead = l.pTail = p;
} else {
p->pNext = l.pHead;
l.pHead = p;
}
}

void ThemDauGiaTri(LIST &l, int x) {
ThemDau(l, TaoNode(x));
}

Thứ tự gán bắt buộc: phải p->pNext = l.pHead trước, rồi mới l.pHead = p. Đảo ngược thì mất cả danh sách cũ.

Độ phức tạp: O(1)O(1) — không duyệt.


③ Thêm vào cuối — O(1)O(1) nhờ pTail

Không có pTail: phải duyệt đến nút cuối rồi nối → O(n)O(n). pTail: nối thẳng vào tail → O(1)O(1).

TRƯỚC
pHead pTail p (nút mới)
│ │ │
▼ ▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ / │ │ 30 │ / │
└────┴────┘ └────┴────┘ └────┴────┘

① l.pTail->pNext = p (20 nắm lấy 30)
② l.pTail = p (cửa cuối chuyển sang 30)

SAU
pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

Mã giả:

THUẬT TOÁN ThemCuoi(l, p)
1. nếu l.pHead = NULL
2. l.pHead ← p
3. l.pTail ← p
4. ngược lại
5. l.pTail.pNext ← p
6. l.pTail ← p
void ThemCuoi(LIST &l, NODE* p) {
if (p == NULL) return;
if (l.pHead == NULL) {
l.pHead = l.pTail = p;
} else {
l.pTail->pNext = p;
l.pTail = p;
}
}

Bẫy: sau ThemCuoi, p->pNext phải là NULL (đã làm trong TaoNode). Nếu p đang là một chuỗi nút, cả chuỗi sẽ bị gắn vào — hữu ích khi nối hai list, nguy hiểm nếu quên.

Độ phức tạp: O(1)O(1) với pTail, O(n)O(n) nếu chỉ có pHead.


④ Thêm sau một nút q cho trước — O(1)O(1)

Đã cầm sẵn con trỏ q thì không cần tìm.

TRƯỚC (q đang cầm nút 20)
pHead q pTail
│ │ │
▼ ▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

p = nút 30. Thứ tự BẮT BUỘC:
① p->pNext = q->pNext (30 nắm 40 — giữ phần sau trước khi cắt)
② q->pNext = p (20 nắm 30)
nếu q == pTail thì pTail = p

SAU
pHead q p pTail
│ │ │ │
▼ ▼ ▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘

Mã giả:

THUẬT TOÁN ThemSau(l, q, p)
Vào: q ≠ NULL, p là nút mới
1. p.pNext ← q.pNext
2. q.pNext ← p
3. nếu q là l.pTail thì l.pTail ← p
void ThemSau(LIST &l, NODE* q, NODE* p) {
if (q == NULL || p == NULL) return;
p->pNext = q->pNext;
q->pNext = p;
if (q == l.pTail)
l.pTail = p;
}

Thứ tự hai dòng 1–2 không được đảo: nếu gán q->pNext = p trước thì mất liên kết tới phần sau.


⑤ Thêm trước một nút qO(n)O(n) trên DSLK đơn

DSLK đơn không có pPrev, nên phải tìm nút đứng trước q.

Mã giả:

THUẬT TOÁN ThemTruoc(l, q, p)
1. nếu q = l.pHead thì ThemDau(l, p); return
2. t ← l.pHead
3. while t ≠ NULL và t.pNext ≠ q
4. t ← t.pNext
5. nếu t ≠ NULL thì ThemSau(l, t, p)
void ThemTruoc(LIST &l, NODE* q, NODE* p) {
if (q == NULL || p == NULL) return;
if (q == l.pHead) {
ThemDau(l, p);
return;
}
NODE* t = l.pHead;
while (t != NULL && t->pNext != q)
t = t->pNext;
if (t != NULL)
ThemSau(l, t, p);
}

Độ phức tạp: O(n)O(n) vì phải tìm nút trước. Trên DSLK kép thao tác này là O(1)O(1).

Sơ đồ (thêm 15 trước nút 20q cầm 20):

TRƯỚC
pHead q
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

Không có pPrev → phải đi từ pHead tìm t sao cho t->pNext == q
t cầm nút 10. Rồi ThemSau(l, t, p) như mục ④.

SAU
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 15 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘

Bẫy: nếu q không thuộc list, vòng while đi tới NULL rồi không thêm — phải kiểm tra t != NULL.


⑥ Thêm tại vị trí k (k đếm từ 0)

k = 0 → ThemDau
k = số phần tử → ThemCuoi
0 < k < n → đứng ở nút k-1, ThemSau
k không hợp lệ → báo lỗi, không thêm
void ThemTaiViTri(LIST &l, int k, int x) {
int n = DemSoPhanTu(l);
if (k < 0 || k > n) {
cout << "Vi tri khong hop le!\n";
return;
}
NODE* p = TaoNode(x);
if (k == 0) { ThemDau(l, p); return; }
NODE* q = l.pHead;
for (int i = 0; i < k - 1; i++)
q = q->pNext;
ThemSau(l, q, p);
}

Thời gian: O(n)O(n) (đi tới vị trí). Việc gắn nút: O(1)O(1).

Ví dụ chạy tay: list 10 → 20 → 30 (n=3n = 3).

kkÝ nghĩaKết quả
0Thêm đầux → 10 → 20 → 30
1Đứng ở nút 0, thêm sau10 → x → 20 → 30
3Thêm cuối (k=nk = n)10 → 20 → 30 → x
4Không hợp lệKhông thêm

⑥b. Lấy phần tử thứ kk (truy cập)

Không có a[k]. Phải đi kk bước từ pHead.

NODE* LayTaiViTri(LIST l, int k) {
if (k < 0) return NULL;
NODE* p = l.pHead;
int i = 0;
while (p != NULL && i < k) {
p = p->pNext;
i++;
}
return p; // NULL nếu k >= n
}

O(k)O(k), xấu nhất O(n)O(n). Đây là lý do DSLK thua mảng khi cần lấy phần tử giữa thường xuyên.


⑦ Xóa đầu — O(1)O(1)

TRƯỚC
pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

temp = pHead (giữ 10 để delete)
pHead = pHead->pNext (cửa vào nhảy sang 20)
delete temp

SAU
pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ │ │ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘
đã delete

Mã giả:

THUẬT TOÁN XoaDau(l)
1. nếu l.pHead = NULL thì return // rong
2. p ← l.pHead
3. l.pHead ← l.pHead.pNext
4. nếu l.pHead = NULL thì l.pTail ← NULL // vua xoa nut duy nhat
5. delete p
void XoaDau(LIST &l) {
if (l.pHead == NULL) return;
NODE* p = l.pHead;
l.pHead = l.pHead->pNext;
if (l.pHead == NULL)
l.pTail = NULL;
delete p;
}

Bẫy 1: quên cập nhật pTail khi xóa nút duy nhất → pTail dangling. Bẫy 2: delete rồi mới pHead = pHead->pNext → đọc ô nhớ đã giải phóng.


⑧ Xóa cuối — O(n)O(n) trên DSLK đơn

Phải tìm nút kế cuối (nút có pNext == pTail). Có pTail không giúp xóa cuối xuống O(1)O(1), vì không có pPrev. Muốn O(1)O(1) phải dùng DSLK kép.

Vì sao phải tìm nút kế cuối? Xóa nút cuối rồi, nút đứng trước nó phải trỏ NULL và trở thành pTail mới. DSLK đơn không đi lùi được nên phải đi từ đầu.

TRƯỚC
pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘
p (p->pNext == pTail)

p đi từ đầu đến nút KẾ CUỐI (20).
delete pTail (30)
p->pNext = NULL
pTail = p

SAU
pHead pTail
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ / │──► NULL
└────┴────┘ └────┴────┘

Ba trường hợp bắt buộc:

ListCách xử lý
RỗngKhông làm gì
1 nút (pHead == pTail)delete nút đó, cả hai con trỏ về NULL
≥ 2 nútTìm kế cuối như sơ đồ

Mã giả:

THUẬT TOÁN XoaCuoi(l)
1. nếu rỗng thì return
2. nếu chỉ 1 nút thì XoaDau(l); return
3. p ← l.pHead
4. while p.pNext ≠ l.pTail
5. p ← p.pNext // p dung o nut ke cuoi
6. delete l.pTail
7. p.pNext ← NULL
8. l.pTail ← p
void XoaCuoi(LIST &l) {
if (l.pHead == NULL) return;
if (l.pHead == l.pTail) {
delete l.pHead;
l.pHead = l.pTail = NULL;
return;
}
NODE* p = l.pHead;
while (p->pNext != l.pTail)
p = p->pNext;
delete l.pTail;
p->pNext = NULL;
l.pTail = p;
}

⑨ Xóa sau nút qO(1)O(1) nếu đã có q

TRƯỚC (q cầm 20, xóa nút SAU q)
pHead q p=q->pNext pTail
│ │ │ │
▼ ▼ ▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘

① q->pNext = p->pNext (20 nhảy cóc sang 40)
② nếu p == pTail thì pTail = q
③ delete p

SAU
pHead q pTail
│ │ │
▼ ▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘

Mã giả:

THUẬT TOÁN XoaSau(l, q)
1. nếu q = NULL hoặc q.pNext = NULL thì return // khong co nut sau de xoa
2. p ← q.pNext
3. q.pNext ← p.pNext
4. nếu p = l.pTail thì l.pTail ← q
5. delete p
void XoaSau(LIST &l, NODE* q) {
if (q == NULL || q->pNext == NULL) return;
NODE* p = q->pNext; // nut bi xoa
q->pNext = p->pNext;
if (p == l.pTail)
l.pTail = q;
delete p;
}

Bẫy: quên dòng 4 khi xóa đúng nút cuối → pTail treo. Không được XoaSau khi muốn xóa chính q — hàm này xóa nút đứng sau q.


⑩ Xóa theo giá trị (nút đầu tiên có data == x)

Hai nhánh: xóa đầu, hoặc tìm nút trước nút cần xóa rồi XoaSau.

List: xóa x = 30
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼───►│ 40 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘
q q->pNext->data == 30
→ XoaSau(l, q)

Nếu x nằm ở đầu: không có nút trước → gọi XoaDau.

void XoaNodeTheoGiaTri(LIST &l, int x) {
if (l.pHead == NULL) return;
if (l.pHead->data == x) {
XoaDau(l);
return;
}
NODE* q = l.pHead;
while (q->pNext != NULL && q->pNext->data != x)
q = q->pNext;
if (q->pNext != NULL)
XoaSau(l, q);
else
cout << "Khong tim thay " << x << "\n";
}

Hàm XoaMin (xóa phần tử nhỏ nhất — hay ra đề) = tìm min rồi gọi hàm này; xem mục ⑪.


⑩b. Xóa tại vị trí kk (đếm từ 0)

Đề bài và bài tập thường hỏi hàm này. Tư duy giống thêm tại vị trí, nhưng gọi xóa.

k = 0 → XoaDau
k > 0 → đứng ở nút k-1, XoaSau
k không hợp lệ (k < 0 hoặc k ≥ n) → không xóa
void XoaTaiViTri(LIST &l, int k) {
int n = DemSoPhanTu(l);
if (k < 0 || k >= n) {
cout << "Vi tri khong hop le!\n";
return;
}
if (k == 0) {
XoaDau(l);
return;
}
NODE* q = l.pHead;
for (int i = 0; i < k - 1; i++)
q = q->pNext;
XoaSau(l, q);
}

O(n)O(n). Lưu ý: thêm tại kk cho phép k=nk = n (thêm sau phần tử cuối); xóa tại kk không cho k=nk = n vì không có phần tử đó.


void XoaTatCaGiaTri(LIST &l, int x) {
while (Tim(l, x) != NULL)
XoaNodeTheoGiaTri(l, x);
}

Độ phức tạp: O(n)O(n) một lần xóa; O(n2)O(n^2) nếu xóa hết bản sao theo cách naive ở trên. Cách một lần duyệt O(n)O(n):

void XoaTatCaGiaTri_O_n(LIST &l, int x) {
while (l.pHead != NULL && l.pHead->data == x)
XoaDau(l);
NODE* q = l.pHead;
while (q != NULL && q->pNext != NULL) {
if (q->pNext->data == x)
XoaSau(l, q); // q dung yen, vi nut sau da doi
else
q = q->pNext;
}
}

⑪ Tìm min / max, thống kê — mẫu đề thi

int TimMin(LIST l) {
if (l.pHead == NULL) return INT_MAX;
int m = l.pHead->data;
for (NODE* p = l.pHead->pNext; p != NULL; p = p->pNext)
if (p->data < m) m = p->data;
return m;
}

NODE* TimNodeMax(LIST l) {
if (l.pHead == NULL) return NULL;
NODE* mx = l.pHead;
for (NODE* p = l.pHead->pNext; p != NULL; p = p->pNext)
if (p->data > mx->data) mx = p;
return mx;
}

bool LaSoNguyenTo(int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; i++)
if (n % i == 0) return false;
return true;
}

int DemSoNguyenTo(LIST l) {
int dem = 0;
for (NODE* p = l.pHead; p != NULL; p = p->pNext)
if (LaSoNguyenTo(p->data)) dem++;
return dem;
}

void LietKeSoChan(LIST l) {
for (NODE* p = l.pHead; p != NULL; p = p->pNext)
if (p->data % 2 == 0) cout << p->data << " ";
cout << "\n";
}

Tất cả O(n)O(n).

void XoaMin(LIST &l) { // mau de thi
if (l.pHead == NULL) return;
XoaNodeTheoGiaTri(l, TimMin(l));
}

Hai lần duyệt: một lần tìm min, một lần xóa → vẫn O(n)O(n).

Vì sao TimMin bắt đầu từ pHead->pNext?m đã lấy pHead->data làm mốc. So sánh lại nút đầu là thừa, không sai.


⑫ Tách danh sách theo điều kiện (mẫu đề thi)

Tách l thành l1 (số nguyên tố) và l2 (còn lại). Không phá l gốc nếu đề yêu cầu — tạo node mới.

Ví dụ: l = 7 → 4 → 9 → 2 → 8

l gốc (không bị phá):
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 7 │ ●─┼──►│ 4 │ ●─┼──►│ 9 │ ●─┼──►│ 2 │ ●─┼──►│ 8 │ / │
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘

l1 nguyên tố: ┌────┬────┐ ┌────┬────┐
│ 7 │ ●─┼──►│ 2 │ / │
└────┴────┘ └────┴────┘
l2 không: ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 4 │ ●─┼──►│ 9 │ ●─┼──►│ 8 │ / │
└────┴────┘ └────┴────┘ └────┴────┘
void Tach(LIST l, LIST &l1, LIST &l2) {
KhoiTao(l1);
KhoiTao(l2);
for (NODE* p = l.pHead; p != NULL; p = p->pNext) {
NODE* q = TaoNode(p->data);
if (LaSoNguyenTo(p->data)) ThemCuoi(l1, q);
else ThemCuoi(l2, q);
}
}

O(n)O(n). Nếu được phép di chuyển node (không new lại) thì phải cắt/nối pNext cẩn thận và cập nhật 3 cái tail.


⑬ Hủy toàn bộ danh sách — chống memory leak

THUẬT TOÁN HuyDanhSach(l)
1. while l.pHead ≠ NULL
2. XoaDau(l)
void HuyDanhSach(LIST &l) {
while (l.pHead != NULL)
XoaDau(l);
}

O(n)O(n). Sau khi hủy: pHead = pTail = NULL.

Sai: delete l.pHead một lần rồi thôi — chỉ giải phóng nút đầu, n1n-1 nút leak.


⑭ Bảng tổng hợp độ phức tạp DSLK đơn (pHead + pTail)

Thao tácThời gianGhi chú
Khởi tạo / Rỗng?O(1)O(1)
Truy cập thứ iiO(n)O(n)Không có a[i]
Tìm theo giá trịO(n)O(n)
Thêm/Xóa đầuO(1)O(1)
Thêm cuốiO(1)O(1)Nhờ pTail
Xóa cuốiO(n)O(n)Phải tìm nút kế cuối
Thêm/Xóa sau nút qO(1)O(1)Đã có q
Thêm/Xóa trước nút qO(n)O(n)Phải tìm nút trước
Thêm/Xóa vị trí kkO(n)O(n)ThemTaiViTri / XoaTaiViTri
Duyệt / Đếm / HủyO(n)O(n)
Không gianO(n)O(n)Thêm 1 con trỏ / nút

⑮ Ví dụ chạy tay cả chuỗi thao tác (đọc để “thấy” pHead / pTail)

Bắt đầu list rỗng. Theo dõi từng thay đổi — đây là cách debug DSLK trên giấy trước khi gõ code.

BướcThao tácDanh sáchpHeadpTail
0KhoiTao(rỗng)NULLNULL
1ThemCuoi(5)555
2ThemDau(2)2 → 525
3ThemCuoi(9)2 → 5 → 929
4XoaDau5 → 959
5ThemTaiViTri(1, 7)5 → 7 → 959
6XoaCuoi5 → 757
7XoaNodeTheoGiaTri(5)777
8XoaDau(rỗng)NULLNULL

Bước 8 là chỗ hay quên: xóa nút duy nhất phải đưa cả pHeadpTail về NULL. Nếu quên pTail, lần ThemCuoi sau sẽ ghi vào ô nhớ đã delete → crash.

⑯ Chương trình tối thiểu — copy, biên dịch, chạy được

Ghép các hàm đã học: KhoiTao, TaoNode, ThemDau, ThemCuoi, Xuat, XoaDau, SapXep, HuyDanhSach. Lưu dslk.cpp, chạy:

c++ -std=c++11 -o dslk dslk.cpp && ./dslk
#include <iostream>
using namespace std;

struct NODE {
int data;
NODE* pNext;
};
struct LIST {
NODE* pHead;
NODE* pTail;
};

void KhoiTao(LIST &l) { l.pHead = l.pTail = NULL; }

NODE* TaoNode(int x) {
NODE* p = new NODE;
p->data = x;
p->pNext = NULL;
return p;
}

void ThemDau(LIST &l, NODE* p) {
if (p == NULL) return;
if (l.pHead == NULL) l.pHead = l.pTail = p;
else { p->pNext = l.pHead; l.pHead = p; }
}

void ThemCuoi(LIST &l, NODE* p) {
if (p == NULL) return;
if (l.pHead == NULL) l.pHead = l.pTail = p;
else { l.pTail->pNext = p; l.pTail = p; }
}

void Xuat(LIST l) {
for (NODE* p = l.pHead; p != NULL; p = p->pNext)
cout << p->data << " -> ";
cout << "NULL\n";
}

void XoaDau(LIST &l) {
if (l.pHead == NULL) return;
NODE* p = l.pHead;
l.pHead = l.pHead->pNext;
if (l.pHead == NULL) l.pTail = NULL;
delete p;
}

void SapXep(LIST &l) {
if (l.pHead == NULL || l.pHead == l.pTail) return;
for (NODE* p = l.pHead; p != NULL; p = p->pNext)
for (NODE* q = p->pNext; q != NULL; q = q->pNext)
if (p->data > q->data) {
int t = p->data; p->data = q->data; q->data = t;
}
}

void HuyDanhSach(LIST &l) {
while (l.pHead != NULL) XoaDau(l);
}

int main() {
LIST l;
KhoiTao(l);
ThemCuoi(l, TaoNode(40));
ThemCuoi(l, TaoNode(10));
ThemCuoi(l, TaoNode(30));
ThemCuoi(l, TaoNode(20));
ThemDau(l, TaoNode(5));
cout << "Truoc sort: "; Xuat(l); // 5 -> 40 -> 10 -> 30 -> 20 -> NULL
SapXep(l);
cout << "Sau sort: "; Xuat(l); // 5 -> 10 -> 20 -> 30 -> 40 -> NULL
XoaDau(l);
cout << "Sau xoa dau:"; Xuat(l); // 10 -> 20 -> 30 -> 40 -> NULL
HuyDanhSach(l);
return 0;
}

Kết quả kỳ vọng:

Truoc sort: 5 -> 40 -> 10 -> 30 -> 20 -> NULL
Sau sort: 5 -> 10 -> 20 -> 30 -> 40 -> NULL
Sau xoa dau:10 -> 20 -> 30 -> 40 -> NULL

3.3.3. Sắp xếp danh sách

Sắp xếp trên DSLK khác sắp xếp trên mảng:

Trên mảngTrên DSLK
a[i], a[j] O(1)O(1)Không có chỉ số — Quick Sort “chia theo pivot index” khó viết
Swap 2 phần tử O(1)O(1)Swap giá trị O(1)O(1); swap nút (relink) phức tạp hơn
Merge Sort tốn mảng phụ O(n)O(n)Merge Sort rất hợp: trộn bằng sửa con trỏ, không cần mảng phụ

Hai chiến lược:

  1. Hoán đổi data — đúng đề thi và giáo trình thực hành (“sắp xếp bằng hoán đổi giá trị”). Nút đứng yên, số bên trong đổi chỗ.
  2. Relink nút — đổi dây pNext, không copy data. Dùng khi data là struct lớn.

A. Đổi chỗ trực tiếp — hàm SapXep đúng giáo trình / đề thi

Đây không phải Selection Sort. Giáo trình lồng hai vòng: với mỗi p, so với mọi q phía sau; hễ p->data > q->data thì đổi ngay.

Ý tưởng tiếng Việt: “Người đứng ở p nhìn hết những người phía sau. Gặp ai nhỏ hơn mình thì đổi số với người đó, rồi nhìn tiếp.”

Mã giả:

THUẬT TOÁN SapXep(l) // tang dan, doi cho truc tiep
1. nếu rỗng hoặc 1 nút thì return
2. p ← l.pHead
3. while p ≠ NULL
4. q ← p.pNext
5. while q ≠ NULL
6. nếu p.data > q.data thì hoán đổi p.data ↔ q.data
7. q ← q.pNext
8. p ← p.pNext
void SapXep(LIST &l) { // dung dung ten + dung dung giai thuat giao trinh
if (l.pHead == NULL || l.pHead == l.pTail) return;
NODE* p = l.pHead;
while (p != NULL) {
NODE* q = p->pNext;
while (q != NULL) {
if (p->data > q->data) {
int temp = p->data;
p->data = q->data;
q->data = temp;
}
q = q->pNext;
}
p = p->pNext;
}
}

Chạy tay 40 → 10 → 30 → 20bắt buộc nhớ: sau mỗi lần đổi, p->data đã đổi, vòng trong so tiếp với giá trị mới:

Ban đầu: 40 → 10 → 30 → 20
Vòng p (nút, không phải số)Việc xảy ra trong vòng trongList sau vòng
Nút 1 (đang 40)40>10 đổi → nút 1 thành 10; 10>30? không; 10>20? không10 → 40 → 30 → 20
Nút 2 (đang 40)40>30 đổi → 30; 30>20 đổi → 2010 → 20 → 40 → 30
Nút 3 (đang 40)40>30 đổi10 → 20 → 30 → 40
Nút 4 (đang 40)không còn q10 → 20 → 30 → 40

Sai nếu viết “nút 1 đổi với cả 10, 30 và 20”: sau lần đổi đầu, nút 1 đã là 10, không còn là 40.

  • Thời gian O(n2)O(n^2), không gian O(1)O(1).
  • Giảm dần: đổi > thành <.
  • Nút không bị cắt/nối; pHead/pTail giữ nguyên địa chỉ.

Khác Selection Sort: Selection tìm một min rồi swap một lần mỗi vòng ngoài. Đổi chỗ trực tiếp có thể swap nhiều lần trong một vòng. Đề thi yêu cầu bản đổi chỗ trực tiếp ở trên — đừng nộp nhầm Selection trừ khi đề nói “chọn phần tử nhỏ nhất”.

void SapXepChon(LIST &l) { // bien the: selection, khong phai de mau
for (NODE* p = l.pHead; p != NULL; p = p->pNext) {
NODE* min = p;
for (NODE* q = p->pNext; q != NULL; q = q->pNext)
if (q->data < min->data) min = q;
if (min != p) {
int tmp = p->data; p->data = min->data; min->data = tmp;
}
}
}

B. Bubble Sort trên DSLK

Ý tưởng: chỉ so hai nút kề nhau. Số lớn “nổi” dần về cuối, giống bọt nước. Khác mục A: mục A so p với cả đoạn sau; Bubble chỉ so p với p->pNext.

Mỗi lượt duyệt: nếu p->data > p->pNext->data thì đổi. Lặp đến khi một lượt không còn đổi nào.

void BubbleSort(LIST &l) {
if (l.pHead == NULL) return;
bool swapped;
do {
swapped = false;
for (NODE* p = l.pHead; p->pNext != NULL; p = p->pNext) {
if (p->data > p->pNext->data) {
int tmp = p->data;
p->data = p->pNext->data;
p->pNext->data = tmp;
swapped = true;
}
}
} while (swapped);
}

O(n2)O(n^2), ổn định nếu chỉ swap khi >. Dùng để hiểu; thi thì viết SapXep mục A.


Ý tưởng: dựng một list sorted ban đầu rỗng. Mỗi lần cắt nút đầu của list gốc, cắm vào đúng chỗ trong sorted (như sắp bài trên tay).

Gốc: 30 → 10 → 40 → 20
sorted: (rỗng)

Lấy 30: sorted = 30
Lấy 10: 10 nhỏ hơn 30 → cắm đầu → 10 → 30
Lấy 40: 40 lớn hơn 30 → cắm cuối → 10 → 30 → 40
Lấy 20: 10 < 20 < 30 → cắm giữa → 10 → 20 → 30 → 40

Chèn nút đã cầm sẵn chỉ O(1)O(1) phép gán con trỏ; thời gian chủ yếu là tìm chỗ O(n)O(n).

void InsertionSortRelink(LIST &l) {
NODE* sorted = NULL;
NODE* cur = l.pHead;
while (cur != NULL) {
NODE* next = cur->pNext;
if (sorted == NULL || cur->data <= sorted->data) {
cur->pNext = sorted;
sorted = cur;
} else {
NODE* p = sorted;
while (p->pNext != NULL && p->pNext->data < cur->data)
p = p->pNext;
cur->pNext = p->pNext;
p->pNext = cur;
}
cur = next;
}
l.pHead = sorted;
l.pTail = sorted;
if (l.pTail != NULL) {
while (l.pTail->pNext != NULL)
l.pTail = l.pTail->pNext;
}
}

Ổn định. Rất tốt khi dữ liệu gần như đã sắp.


D. Trộn hai danh sách đã sắpO(n+m)O(n+m) (mẫu đề thi)

l1: ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 1 │ ●─┼───►│ 4 │ ●─┼───►│ 7 │ / │
└────┴────┘ └────┴────┘ └────┴────┘
l2: ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 2 │ ●─┼───►│ 3 │ ●─┼───►│ 8 │ / │
└────┴────┘ └────┴────┘ └────┴────┘

So từng cặp đầu list: 1≤2 lấy 1; 4>2 lấy 2; 4>3 lấy 3; 4≤8 lấy 4; 7≤8 lấy 7; còn 8.

l3: ┌───┬──┐ ┌───┬──┐ ┌───┬──┐ ┌───┬──┐ ┌───┬──┐ ┌───┬──┐
│ 1 │●─┼►│ 2 │●─┼►│ 3 │●─┼►│ 4 │●─┼►│ 7 │●─┼►│ 8 │/ │
└───┴──┘ └───┴──┘ └───┴──┘ └───┴──┘ └───┴──┘ └───┴──┘

Mã giả (tạo list mới, không phá l1, l2):

THUẬT TOÁN Noi(l1, l2, l3)
1. KhoiTao(l3)
2. p ← l1.pHead, q ← l2.pHead
3. while p ≠ NULL và q ≠ NULL
4. nếu p.data ≤ q.data
5. ThemCuoi(l3, TaoNode(p.data)); p ← p.pNext
6. ngược lại
7. ThemCuoi(l3, TaoNode(q.data)); q ← q.pNext
8. while p ≠ NULL: ThemCuoi(l3, TaoNode(p.data)); p ← p.pNext
9. while q ≠ NULL: ThemCuoi(l3, TaoNode(q.data)); q ← q.pNext
void Noi(LIST l1, LIST l2, LIST &l3) {
KhoiTao(l3);
NODE* p = l1.pHead;
NODE* q = l2.pHead;
while (p != NULL && q != NULL) {
if (p->data <= q->data) {
ThemCuoi(l3, TaoNode(p->data));
p = p->pNext;
} else {
ThemCuoi(l3, TaoNode(q->data));
q = q->pNext;
}
}
while (p != NULL) { ThemCuoi(l3, TaoNode(p->data)); p = p->pNext; }
while (q != NULL) { ThemCuoi(l3, TaoNode(q->data)); q = q->pNext; }
}

Nếu đề cho phép tái sử dụng nút (không TaoNode), dùng relink: mỗi bước cắt nút nhỏ hơn gắn vào l3. O(1)O(1) phụ, nhưng l1/l2 bị rỗng sau khi trộn.


E. Merge Sort trên DSLK — thuật toán “cao cấp” nên dùng thật

Trên mảng, Merge Sort tốn mảng phụ. Trên DSLK, trộn bằng con trỏ nên đây là lựa chọn O(nlogn)O(n \log n) tự nhiên nhất.

Chia — trị — trộn, ví dụ 40 → 10 → 30 → 20:

40 → 10 → 30 → 20
/ \
40 → 10 30 → 20
/ \ / \
40 10 30 20
\ / \ /
10 → 40 20 → 30
\ /
10 → 20 → 30 → 40

Chia: tìm nút giữa bằng hai con trỏ chậm/nhanh (slow đi 1, fast đi 2), cắt mid->pNext = NULL. Trị: gọi đệ quy từng nửa. Trộn: giống mục D nhưng relink nút, không TaoNode lại.

NODE* TimGiua(NODE* head) {
NODE* slow = head;
NODE* fast = head;
while (fast->pNext != NULL && fast->pNext->pNext != NULL) {
slow = slow->pNext;
fast = fast->pNext->pNext;
}
return slow;
}

NODE* MergeRelink(NODE* a, NODE* b) {
if (a == NULL) return b;
if (b == NULL) return a;
NODE dummy;
NODE* tail = &dummy;
dummy.pNext = NULL;
while (a != NULL && b != NULL) {
if (a->data <= b->data) {
tail->pNext = a;
a = a->pNext;
} else {
tail->pNext = b;
b = b->pNext;
}
tail = tail->pNext;
}
tail->pNext = (a != NULL) ? a : b;
return dummy.pNext;
}

NODE* MergeSortNodes(NODE* head) {
if (head == NULL || head->pNext == NULL) return head;
NODE* mid = TimGiua(head);
NODE* half = mid->pNext;
mid->pNext = NULL;
NODE* left = MergeSortNodes(head);
NODE* right = MergeSortNodes(half);
return MergeRelink(left, right);
}

void MergeSortList(LIST &l) {
l.pHead = MergeSortNodes(l.pHead);
l.pTail = l.pHead;
if (l.pTail != NULL)
while (l.pTail->pNext != NULL)
l.pTail = l.pTail->pNext;
}
Thời gianKhông gian (stack đệ quy)
Selection / Bubble / InsertionO(n2)O(n^2)O(1)O(1)
Merge SortO(nlogn)O(n \log n)O(logn)O(\log n)

Không dùng binary search sau khi sort DSLK: mỗi lần “lấy phần tử giữa” đã tốn O(n)O(n) → tổng trở thành O(n)O(n) chẳng hơn linear search.


F. Ưu / nhược khi sắp xếp trên DSLK

Ưu: Merge Sort không cần mảng phụ; insertion bằng relink không dồn phần tử.

Nhược: không Quick Sort-index; không Heap Sort (heap cần truy cập cha/con theo chỉ số); cache kém nên O(nlogn)O(n \log n) trên DSLK thường chậm hơn O(nlogn)O(n \log n) trên mảng.

Kiểm tra đã tăng dần (bài tập thực hành hay hỏi):

bool DaTangDan(LIST l) {
if (l.pHead == NULL) return true;
for (NODE* p = l.pHead; p->pNext != NULL; p = p->pNext)
if (p->data > p->pNext->data) return false;
return true;
}

O(n)O(n). Một cặp lệch là đủ kết luận “chưa sort”.

✅ Kiểm tra nhanh 3.3

  1. ThemDau phải gán p->pNext = pHead trước hay pHead = p trước? Vì sao?
  2. pTail thì thêm cuối O(?)O(?), xóa cuối O(?)O(?).
  3. Đề thi SapXep dùng thuật toán nào: đổi chỗ trực tiếp hay Selection?
  4. Noi yêu cầu hai list đầu vào điều kiện gì?

Đáp án: (1) Gán p->pNext trước, không thì mất list cũ. (2) O(1)O(1)O(n)O(n). (3) Đổi chỗ trực tiếp (hai vòng, gặp lớn hơn thì swap ngay) — đúng code giáo trình. (4) Cả hai đã sắp cùng chiều.


3.4. MỘT SỐ DSLK KHÁC

DSLK đơn (mục 3.3) đủ cho gần hết bài tập và đề thi. Nó còn hai hạn chế, mỗi hạn chế sinh ra một biến thể:

Hạn chế của DSLK đơnHệ quả cụ thểBiến thể giải quyết
Mỗi nút chỉ biết nút sau (pNext)Không đi lùi; xóa cuối O(n)O(n); thêm/xóa trước một nút đang cầm phải duyệt lại từ pHeadDSLK kép — thêm pPrev
Nút cuối trỏ NULL — list thẳng, có điểm dừngKhông mô tả dữ liệu tuần hoàn; muốn “đi hết một vòng rồi quay lại” phải tự nhảy về pHeadDSLK vòng — nút cuối trỏ về đầu

Lộ trình học 3.4 (đừng nhảy cóc):

3.4.1 kép = DSLK đơn + một con trỏ ngược (vẫn thẳng, vẫn có NULL)
3.4.2 vòng = đổi điều kiện dừng (đuôi nắm đầu, không còn NULL)
vòng kép = ghép cả hai ý trên

Khuôn mỗi thao tác giống 3.3: ý tưởng → sơ đồ trước/sau → mã giả → code → OO → bẫy.

Học kép trước vì cấu trúc nút đổi, nhưng điều kiện duyệt vẫn là p != NULL. Học vòng sau vì cấu trúc nút không đổi so với đơn, nhưng điều kiện duyệt đổi hẳn — chỗ này dễ vòng lặp vô hạn nếu hấp tấp.


3.4.1. DSLK kép (Doubly Linked List)

A. Vì sao cần kép? Định nghĩa

Ẩn dụ. DSLK đơn như hàng người chỉ nắm vai người phía trước: biết ai đứng sau, không biết ai đứng trước. Muốn xóa người cuối, phải đi từ đầu hàng hỏi từng người “bạn có phải người áp chót không?”.

DSLK kép như hàng người nắm hai tay: tay phải nắm người sau (pNext), tay trái nắm người trước (pPrev). Đứng ở bất kỳ chỗ nào cũng bước tới hoặc bước lùi được một bước.

Định nghĩa. Danh sách liên kết kép là danh sách liên kết trong đó mỗi nút chứa:

  1. data — thông tin.
  2. pNext — địa chỉ nút đứng sau (giống DSLK đơn).
  3. pPrev — địa chỉ nút đứng trước (điểm mới).

Hai đầu danh sách thẳng vẫn kết thúc bằng NULL:

  • Nút đầu: pPrev = NULL (không có ai đứng trước).
  • Nút cuối: pNext = NULL (không có ai đứng sau).
pHead pTail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼────►│ ● │ 20 │ ●─┼────►│ ● │ 30 │ / │────► NULL
│ │ │ │◄────┼─● │ │ │◄────┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
prev data next prev data next prev data next

└── NULL (không có nút trước 10)

Quy ước vẽ xuyên 3.4.1:
cột trái = pPrev cột giữa = data cột phải = pNext
mũi tên ──► đi xuôi mũi tên ◄── đi ngược
/ nghĩa là NULL

Quy tắc đối xứng (thuộc lòng):

Nếu A.pNext = B thì bắt buộc B.pPrev = A
Nếu B.pPrev = A thì bắt buộc A.pNext = B

Một chiều đứt, chiều kia còn → list hỏng, duyệt xuôi ra một thứ, duyệt ngược ra thứ khác. Đây là nguồn bug số 1 của DSLK kép.

Ba việc DSLK đơn làm kém, kép làm tốt:

ViệcDSLK đơn (đã cầm con trỏ tới nút)DSLK kép
Đi lùi một bướcKhông — phải duyệt lại từ pHeadp = p->pPrevO(1)O(1)
Xóa chính nút đang cầmPhải tìm nút trước → O(n)O(n)Sửa 2 liên kết → O(1)O(1)
Xóa cuối (có pTail)Phải tìm nút áp chót → O(n)O(n)pTail->pPrev chính là áp chót → O(1)O(1)

Giá phải trả: mỗi nút thêm một con trỏ; mỗi thao tác sửa nhiều liên kết hơn.


B. Tổ chức DNODE / DLIST và bất biến

Khai báo cùng phong cách NODE / LIST của mục 3.3, chỉ thêm pPrev. Tên DNODE/DLIST để khỏi lẫn với đơn khi cả hai cùng nằm trong một file.

struct DNODE {
int data;
DNODE* pPrev; // nut dung truoc — NULL neu la dau
DNODE* pNext; // nut dung sau — NULL neu la cuoi
};

struct DLIST {
DNODE* pHead;
DNODE* pTail;
};
struct DNODE struct DLIST
┌──────┬──────┬──────┐ ┌────────┬────────┐
│pPrev │ data │pNext │ │ pHead │ pTail │
└──────┴──────┴──────┘ └────────┴────────┘
▲ ▲ ▲ │ │
│ │ └── giống DSLK đơn │ └──► nút cuối
│ └── thông tin └──────────► nút đầu
└── CHỈ CÓ Ở KÉP

Khởi tạo rỗngtạo nút — nút mới đứng một mình: cả hai con trỏ đều NULL.

THUẬT TOÁN KhoiTaoD(l)
1. l.pHead ← NULL
2. l.pTail ← NULL

THUẬT TOÁN TaoDNode(x) → p
1. p ← cấp phát một DNODE
2. p.data ← x
3. p.pPrev ← NULL
4. p.pNext ← NULL
5. Trả về p
void KhoiTaoD(DLIST &l) {
l.pHead = l.pTail = NULL;
}

bool IsEmptyD(DLIST l) {
return l.pHead == NULL;
}

DNODE* TaoDNode(int x) {
DNODE* p = new DNODE;
p->data = x;
p->pPrev = p->pNext = NULL; // nut moi chua noi ai, chua ai noi
return p;
}
TaoDNode(10) trả về p:

p


┌────┬────┬────┐
│ / │ 10 │ / │ ← hộp đứng một mình
└────┴────┴────┘
prev data next

Bốn bất biến (invariant) — phải đúng sau MỌI thao tác:

#Bất biếnÝ nghĩa
1Rỗng ⇔ pHead == NULL pTail == NULLKhông được một bên NULL, một bên còn trỏ
2Nếu không rỗng: pHead->pPrev == NULLĐầu không có người trước
3Nếu không rỗng: pTail->pNext == NULLCuối không có người sau
4Với mọi cặp kề: a->pNext == bb->pPrev == aHai chiều luôn khớp

Hàm kiểm tra (dùng khi debug, không cần thuộc để thi):

bool HopLeKep(DLIST l) {
if (l.pHead == NULL)
return l.pTail == NULL; // bat bien 1
if (l.pTail == NULL) return false;
if (l.pHead->pPrev != NULL) return false; // bat bien 2
if (l.pTail->pNext != NULL) return false; // bat bien 3

DNODE* p = l.pHead;
while (p->pNext != NULL) {
if (p->pNext->pPrev != p) return false; // bat bien 4
p = p->pNext;
}
return p == l.pTail; // di xuoi phai dung dung pTail
}

Quy tắc vàng khi sửa kép: mỗi lần “cắt–nối”, hãy hỏi: mình đã sửa đủ cả hai phía của mỗi mối nối chưa? Một mối nối ABA \leftrightarrow B cần hai phép gán: A->pNext = B B->pPrev = A.


C. Dựng list và duyệt hai chiều

Dựng [10]↔[20]↔[30] bằng thêm cuối — nhìn từng bước để thuộc pHead/pTail.

Bước 0 — rỗng:

pHead ──► NULL pTail ──► NULL

Bước 1 — thêm 10 (list rỗng: đầu = cuối = nút mới, hai con trỏ vẫn NULL):

pHead, pTail


┌────┬────┬────┐
│ / │ 10 │ / │──► NULL
└────┴────┴────┘

Bước 2 — thêm 20 (nối hai chiều giữa 10 và 20, dời pTail):

pHead pTail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ / │──► NULL
│ │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘

Bước 3 — thêm 30:

pHead pTail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │──► NULL
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘

Trên RAM ba nút không cần liền kề. Logic 10↔20↔30 nhờ hai con trỏ mỗi cặp, không nhờ địa chỉ tăng dần.


Duyệt xuôi — giống DSLK đơn, từ pHead theo pNext đến NULL.

Duyệt ngược — việc đơn không làm được (trừ khi dùng stack phụ): từ pTail theo pPrev đến NULL.

THUẬT TOÁN XuatXuoi(l)
1. p ← l.pHead
2. while p ≠ NULL
3. in p.data
4. p ← p.pNext

THUẬT TOÁN XuatNguoc(l)
1. p ← l.pTail
2. while p ≠ NULL
3. in p.data
4. p ← p.pPrev
void XuatXuoi(DLIST l) {
cout << "Xuoi : ";
for (DNODE* p = l.pHead; p != NULL; p = p->pNext)
cout << p->data << " ";
cout << "\n";
}

void XuatNguoc(DLIST l) {
cout << "Nguoc: ";
for (DNODE* p = l.pTail; p != NULL; p = p->pPrev)
cout << p->data << " ";
cout << "\n";
}

int DemSoPhanTuKep(DLIST l) {
int dem = 0;
for (DNODE* p = l.pHead; p != NULL; p = p->pNext)
dem++;
return dem;
}

DNODE* TimKep(DLIST l, int x) {
for (DNODE* p = l.pHead; p != NULL; p = p->pNext)
if (p->data == x) return p;
return NULL;
}

List [10]↔[20]↔[30]:

XuatXuoi → 10 20 30
XuatNguoc → 30 20 10
Thao tácThời gianKhông gian
Xuôi / ngược / đếm / tìmO(n)O(n)O(1)O(1)

Tìm kiếm vẫn O(n)O(n). Có pPrev không giúp nhảy giữa list; không có a[i].

Bẫy duyệt:

  • Dùng p->pNext != NULL khi muốn in mọi nút → bỏ nút cuối (giống đơn).
  • Duyệt ngược nhưng xuất phát từ pHead rồi p = p->pPrev → vòng không chạy (head.prev đã NULL) hoặc crash nếu quên kiểm tra rỗng.
  • Tìm từ hai đầu “cho nhanh” vẫn O(n)O(n) — chỉ giảm hằng số, đề thi không đòi.

D. Thêm đầu / thêm cuối — cả hai O(1)O(1)

① Thêm đầu — O(1)O(1)

Ý tưởng. Nút mới trở thành người đứng đầu hàng: nó nắm pHead cũ bằng pNext; pHead cũ nắm nó bằng pPrev; cửa pHead chuyển sang nút mới.

Trường hợp rỗng — không có “head cũ” để sửa pPrev:

Trước: pHead ──► NULL pTail ──► NULL

Sau (thêm 10):
pHead, pTail


┌────┬────┬────┐
│ / │ 10 │ / │
└────┴────┴────┘

Trường hợp đã có nút — 4 phép, đúng thứ tự ①② rồi mới ③④:

TRƯỚC p (nút 10, chưa nối)
pHead pTail │
│ │ ▼
▼ ▼ ┌────┬────┬────┐
┌────┬────┬────┐ ┌────┬────┬────┐ │ / │ 10 │ / │
│ / │ 20 │ ●─┼─►│ ● │ 30 │ / │ └────┴────┴────┘
│ │ │ │◄─┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘

① p->pNext = pHead (10 nắm 20 — GIỮ list cũ trước khi dời cửa)
② p->pPrev = NULL (10 là đầu mới, không ai đứng trước)
③ pHead->pPrev = p (20.prev nhớ 10 — ĐÂY LÀ PHÉP ĐƠN KHÔNG CÓ)
④ pHead = p (cửa vào chuyển sang 10)
pTail không đổi

SAU
pHead pTail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘

Mã giả:

THUẬT TOÁN ThemDauKep(l, p)
Vào: p là nút mới, pPrev = pNext = NULL
1. nếu l.pHead = NULL
2. l.pHead ← p
3. l.pTail ← p
4. return
5. p.pNext ← l.pHead
6. p.pPrev ← NULL
7. l.pHead.pPrev ← p // head cũ nhớ người mới đứng trước
8. l.pHead ← p
void ThemDauKep(DLIST &l, DNODE* p) {
if (p == NULL) return;
if (l.pHead == NULL) {
l.pHead = l.pTail = p;
return;
}
p->pNext = l.pHead; // ①
p->pPrev = NULL; // ② (TaoDNode da lam, ghi lai cho ro)
l.pHead->pPrev = p; // ③
l.pHead = p; // ④
}

void ThemDauKepGiaTri(DLIST &l, int x) {
ThemDauKep(l, TaoDNode(x));
}

Bẫy: quên bước ③ → duyệt xuôi 10 → 20 → 30 đúng, duyệt ngược từ 20 đi về gặp NULL thay vì 10. “Nửa list”.
Bẫy: làm ④ trước ① → mất địa chỉ head cũ, rò rỉ cả list (giống bẫy ThemDau đơn).

O(1)O(1) — không duyệt.


② Thêm cuối — O(1)O(1)

Ý tưởng. Đối xứng thêm đầu: nắm pTail thay vì pHead. Nút mới đứng sau người cuối; người cuối nắm nó bằng pNext; nó nắm người cuối bằng pPrev; cửa pTail chuyển sang nút mới.

TRƯỚC
pHead pTail p (nút 30)
│ │ │
▼ ▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ / │ │ / │ 30 │ / │
│ │ │ │◄──┼─● │ │ │ └────┴────┴────┘
└────┴────┴────┘ └────┴────┴────┘

① p->pPrev = pTail (30 nắm 20)
② p->pNext = NULL (30 là cuối mới)
③ pTail->pNext = p (20 nắm 30)
④ pTail = p (cửa cuối chuyển sang 30)

SAU
pHead pTail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
THUẬT TOÁN ThemCuoiKep(l, p)
1. nếu l.pTail = NULL
2. l.pHead ← p
3. l.pTail ← p
4. return
5. p.pPrev ← l.pTail
6. p.pNext ← NULL
7. l.pTail.pNext ← p
8. l.pTail ← p
void ThemCuoiKep(DLIST &l, DNODE* p) {
if (p == NULL) return;
if (l.pTail == NULL) {
l.pHead = l.pTail = p;
return;
}
p->pPrev = l.pTail;
p->pNext = NULL;
l.pTail->pNext = p;
l.pTail = p;
}

Nhìn đối xứng để nhớ, đừng học vẹt hai hàm rời:

Thêm đầuThêm cuối
Nút mới nắm phía listp->pNext = pHeadp->pPrev = pTail
Nút mới phía còn lạip->pPrev = NULLp->pNext = NULL
Nút cũ nhớ nút mớipHead->pPrev = ppTail->pNext = p
Dời cửapHead = ppTail = p

O(1)O(1) với pTail. Không có pTail thì thêm cuối vẫn O(n)O(n) — phải đi đến nút có pNext == NULL.


E. Thêm sau / thêm trước / thêm tại vị trí

③ Thêm sau nút qO(1)O(1) nếu đã cầm q

Ý tưởng. Chèn p vào khe giữa qq->pNext. Bốn mối nối: qpq \leftrightarrow ppp \leftrightarrow (nút sau cũ). Nếu q đang là tail thì không có nút sau → p thành tail mới.

TRƯỚC q cầm 20, thêm p = 25
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
q

┌─────────────────────────────────────────────────────────────────┐
│ HIỂU CÚ PHÁP TRƯỚC KHI ĐỌC CÁC BƯỚC │
│ │
│ p->pNext = q->pNext đọc từ PHẢI sang TRÁI: │
│ │
│ q->pNext = "lấy địa chỉ đang chứa trong ô pNext của q" │
│ → q là nút 20, q->pNext đang GIỮ địa chỉ 30 │
│ │
│ p->pNext = "ghi địa chỉ đó vào ô pNext của p" │
│ → p là nút 25 mới, pNext của 25 = địa chỉ 30 │
│ │
│ Kết quả: cả 20 và 25 đều "biết" chỗ của 30. │
│ (Không viết p->pNext = 30 vì 30 là giá trị dữ liệu, │
│ địa chỉ của nút 30 chỉ q->pNext mới đang giữ.) │
│ │
│ Hình dung: A(20) đang cầm số ĐT của C(30). │
│ B(25) mới vào → hỏi A: "cho tao số C" = p->pNext = q->pNext │
│ Bây giờ cả A và B đều có số của C. │
│ Sau đó A xóa số C, lưu số B = q->pNext = p (bước ③) │
│ B vẫn còn số C → chuỗi không bị đứt. │
└─────────────────────────────────────────────────────────────────┘

Cách nhớ: "nút MỚI (25) lo xong việc của mình TRƯỚC,
rồi mới bảo các nút CŨ cập nhật lại":

Bước ①② → nút 25 tự nối hai đầu của mình vào đúng vị trí
Bước ③④ → nút 20 và nút 30 "quay sang" nhận 25 là hàng xóm mới

Vì sao ① phải trước ③?
Nếu làm ③ trước (q->pNext = p), thì q->pNext bị ghi đè = 25,
mất luôn địa chỉ 30. Bước ① không còn biết 30 ở đâu → chuỗi đứt.
→ Luôn "cầm địa chỉ 30" (①) trước khi "cắt dây 20→30" (③).

Thứ tự BẮT BUỘC — giữ 30 trước khi cắt 20.next:

Trạng thái ban đầu: 10 ←→ [20] ←→ 30 p=25 lơ lửng
q

① p->pNext = q->pNext 10 ←→ [20] ←→ 30
(25 nắm địa chỉ 30) q ↑
[25]─────┘ 25.next → 30

② p->pPrev = q 10 ←→ [20] ←→ 30
(25.prev = địa chỉ 20) q ↑
20 ←─ [25]────────┘ 25 hai chiều xong

③ q->pNext = p 10 ←→ [20] ──► [25] ──► 30
(20 trỏ sang 25) q p 20 đã trỏ 25

④ p->pNext->pPrev = p 10 ←→ [20] ←→ [25] ←→ 30 ✓ hoàn chỉnh
(30.prev = địa chỉ 25) q p

SAU
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼─►│ ● │ 20 │ ●─┼─►│ ● │ 25 │ ●─┼─►│ ● │ 30 │ / │
│ │ │ │◄─┼─● │ │ │◄─┼─● │ │ │◄─┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
q p
THUẬT TOÁN ThemSauKep(l, q, p)
Vào: q ≠ NULL, p là nút mới
1. p.pNext ← q.pNext
2. p.pPrev ← q
3. q.pNext ← p
4. nếu p.pNext ≠ NULL
5. p.pNext.pPrev ← p
6. ngược lại
7. l.pTail ← p
void ThemSauKep(DLIST &l, DNODE* q, DNODE* p) {
if (q == NULL || p == NULL) return;
p->pNext = q->pNext;
p->pPrev = q;
q->pNext = p;
if (p->pNext != NULL)
p->pNext->pPrev = p;
else
l.pTail = p;
}

Bẫy: làm ③ trước ① → mất địa chỉ 30, không gán được ④, đứt đuôi.
Bẫy: quên nhánh else pTail = p khi chèn sau tail → pTail còn trỏ 20, xuất xuôi in được 25 nhưng các hàm dựa vào pTail (thêm cuối, xóa cuối, xuất ngược) sai.


④ Thêm trước nút qO(1)O(1) ← thắng lợi so với đơn O(n)O(n)

Ý tưởng. DSLK đơn không có pPrev nên phải tìm nút đứng trước q (O(n)O(n)). Kép đã cầm q->pPrev sẵn: thêm trước q = thêm sau q->pPrev. Nếu q là head thì gọi ThemDauKep.

TRƯỚC q cầm 20, thêm p = 15 (chèn giữa 10 và 20)
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
q->pPrev q

q không phải head → ThemSauKep(l, q->pPrev, p)
tức là ThemSau(10, 15)

SAU
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼─►│ ● │ 15 │ ●─┼─►│ ● │ 20 │ ●─┼─►│ ● │ 30 │ / │
│ │ │ │◄─┼─● │ │ │◄─┼─● │ │ │◄─┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
THUẬT TOÁN ThemTruocKep(l, q, p)
1. nếu q = NULL thì return
2. nếu q = l.pHead thì ThemDauKep(l, p); return
3. ThemSauKep(l, q.pPrev, p)
void ThemTruocKep(DLIST &l, DNODE* q, DNODE* p) {
if (q == NULL || p == NULL) return;
if (q == l.pHead) {
ThemDauKep(l, p);
return;
}
ThemSauKep(l, q->pPrev, p);
}

Có thể viết bung 4 phép (đối xứng ThemSauKep) nếu đề thi bắt “không gọi hàm khác”:

p.pPrev ← q.pPrev
p.pNext ← q
q.pPrev.pNext ← p // 10 nắm 15 (an toàn vì q không phải head)
q.pPrev ← p // 20.prev = 15

Phân tích: tìm q nếu chỉ biết giá trị vẫn O(n)O(n); chèn khi đã cầm q mới là O(1)O(1). Đề bài “thêm trước nút có data = x” = tìm O(n)O(n) + chèn O(1)O(1).


⑤ Thêm tại vị trí kk (đếm từ 0)
THUẬT TOÁN ThemTaiViTriKep(l, k, p)
1. nếu k ≤ 0 thì ThemDauKep(l, p); return
2. q ← l.pHead; i ← 0
3. while q ≠ NULL và i < k - 1
4. q ← q.pNext; i ← i + 1
5. nếu q = NULL thì ThemCuoiKep(l, p) // k vượt độ dài
6. ngược lại ThemSauKep(l, q, p)
void ThemTaiViTriKep(DLIST &l, int k, DNODE* p) {
if (p == NULL) return;
if (k <= 0 || l.pHead == NULL) {
ThemDauKep(l, p);
return;
}
DNODE* q = l.pHead;
int i = 0;
while (q != NULL && i < k - 1) {
q = q->pNext;
i++;
}
if (q == NULL)
ThemCuoiKep(l, p);
else
ThemSauKep(l, q, p);
}

Tìm vị trí O(k)O(k), chèn O(1)O(1) → tổng O(n)O(n) xấu nhất. Kép không làm ThemTaiViTri xuống O(1)O(1) — vẫn phải đi bộ tới chỗ kk.


F. Xóa — lý do kép tồn tại

⑥ Xóa một nút p đang cầm sẵnO(1)O(1)

Ý tưởng. Nút trước p và nút sau p nắm lấy nhau, bỏ p ra khỏi chuỗi, rồi delete p. Không cần tìm nút trước (đơn bắt buộc phải tìm).

Bốn nhánh, vẽ trên giấy trước khi gõ:

NhánhNhận ra bằngViệc làm
List 1 nútp == pHead && p == pTailpHead = pTail = NULL
Xóa đầup == pHeadpHead = p->pNext; pHead->pPrev = NULL
Xóa cuốip == pTailpTail = p->pPrev; pTail->pNext = NULL
Xóa giữacòn lạip->pPrev->pNext = p->pNext; p->pNext->pPrev = p->pPrev

Xóa giữa (minh họa chính):

TRƯỚC xóa nút 20 (đã cầm p)
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ / │
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
p

① p->pPrev->pNext = p->pNext (10.next ← 30)
② p->pNext->pPrev = p->pPrev (30.prev ← 10)
delete p

SAU
┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼────────────────────►│ ● │ 30 │ / │
│ │ │ │◄────────────────────┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘
pHead pTail

Xóa đầu (list ≥ 2 nút):

TRƯỚC p = pHead = 10
┌────┬────┬────┐ ┌────┬────┬────┐
│ / │ 10 │ ●─┼──►│ ● │ 20 │ ...
│ │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘
p=pHead

pHead = 20
pHead->pPrev = NULL // 20 không còn nhớ 10
delete 10

SAU
pHead


┌────┬────┬────┐
│ / │ 20 │ ...
│ │ │ │ (pPrev = NULL = /)
└────┴────┴────┘

Xóa cuối — đối xứng, O(1)O(1) (đơn phải O(n)O(n)):

TRƯỚC p = pTail = 30, list 10↔20↔30

pTail = 20 // p->pPrev
pTail->pNext = NULL // 20 không còn nắm 30
delete 30
THUẬT TOÁN XoaNodeKep(l, p)
1. nếu p = NULL hoặc list rỗng thì return
2. nếu p là head VÀ tail // 1 nút
3. pHead ← pTail ← NULL
4. ngược lại nếu p là head
5. pHead ← p.pNext
6. pHead.pPrev ← NULL
7. ngược lại nếu p là tail
8. pTail ← p.pPrev
9. pTail.pNext ← NULL
10. ngược lại
11. p.pPrev.pNext ← p.pNext
12. p.pNext.pPrev ← p.pPrev
13. delete p
void XoaNodeKep(DLIST &l, DNODE* p) {
if (p == NULL || l.pHead == NULL) return;

if (p == l.pHead && p == l.pTail) {
l.pHead = l.pTail = NULL;
} else if (p == l.pHead) {
l.pHead = p->pNext;
l.pHead->pPrev = NULL;
} else if (p == l.pTail) {
l.pTail = p->pPrev;
l.pTail->pNext = NULL;
} else {
p->pPrev->pNext = p->pNext;
p->pNext->pPrev = p->pPrev;
}
delete p;
}

void XoaDauKep(DLIST &l) { XoaNodeKep(l, l.pHead); }
void XoaCuoiKep(DLIST &l) { XoaNodeKep(l, l.pTail); } // O(1) !

Bẫy thứ tự if: phải xét 1 nút trước xóa đầu/cuối. Nếu viết if (p == pHead) { pHead = p->pNext; pHead->pPrev = NULL; } khi list 1 nút thì pHead thành NULL rồi NULL->pPrev → crash.

Bẫy: delete p rồi còn dùng p->pNext → dangling pointer.

Bẫy: xóa giữa mà chỉ sửa pPrev->pNext, quên pNext->pPrev → xuất xuôi đúng, xuất ngược đứt.

Xóa đầu / xóa cuối khi đã có con trỏO(1)O(1). Xóa theo giá trị vẫn phải tìm trước.


⑦ Xóa theo giá trị (nút đầu tiên có data == x) — O(n)O(n)

Tìm O(n)O(n) + xóa nút đang cầm O(1)O(1).

void XoaGiaTriKep(DLIST &l, int x) {
DNODE* p = TimKep(l, x);
if (p != NULL)
XoaNodeKep(l, p);
}

void XoaTaiViTriKep(DLIST &l, int k) {
if (k < 0) return;
DNODE* p = l.pHead;
int i = 0;
while (p != NULL && i < k) {
p = p->pNext;
i++;
}
if (p != NULL)
XoaNodeKep(l, p);
}

⑧ Hủy toàn bộ — chống memory leak
void HuyDanhSachKep(DLIST &l) {
while (l.pHead != NULL)
XoaDauKep(l);
}

O(n)O(n). Mỗi XoaDauKepO(1)O(1), lặp nn lần. Sau hàm: pHead == pTail == NULL.

Có thể viết tay không gọi XoaDauKep: giữ pNext trước khi delete, vì sau delete không được đọc p->pNext.

void HuyDanhSachKep2(DLIST &l) {
DNODE* p = l.pHead;
while (p != NULL) {
DNODE* t = p->pNext;
delete p;
p = t;
}
l.pHead = l.pTail = NULL;
}

⑨ Ví dụ chạy tay cả chuỗi (kép)

List rỗng. Thực hiện: ThemCuoi 20, ThemDau 10, ThemCuoi 40, ThemSau(20, 30), XoaCuoi, XoaNode(20).

0. rỗng
1. ThemCuoi 20 → [20]
2. ThemDau 10 → [10]↔[20]
3. ThemCuoi 40 → [10]↔[20]↔[40]
4. ThemSau(20,30) → [10]↔[20]↔[30]↔[40]
5. XoaCuoi → [10]↔[20]↔[30] (O(1), đơn phải đi tìm 20)
6. XoaNode(20) → [10]↔[30] (O(1), đơn phải đi tìm 10)
XuatXuoi = 10 30
XuatNguoc = 30 10

G. So sánh đơn–kép, ưu nhược, bẫy

Bảng giá (đã cầm con trỏ tới nút cần đụng, có pHead + pTail):

Thao tácDSLK đơnDSLK kép
Thêm/xóa đầuO(1)O(1)O(1)O(1)
Thêm cuốiO(1)O(1)O(1)O(1)
Xóa cuốiO(n)O(n)O(1)O(1)
Thêm/xóa sau một nútO(1)O(1)O(1)O(1)
Thêm/xóa trước một nútO(n)O(n)O(1)O(1)
Xóa chính nút đang cầmO(n)O(n)O(1)O(1)
Duyệt xuôi
Duyệt ngượcKhông
Tìm theo giá trị / lấy thứ kkO(n)O(n)O(n)O(n)
RAM mỗi nút (máy 64-bit, xấp xỉ)~16 byte~24 byte

Ưu điểm

  1. Duyệt hai chiều — in ngược, so palindrome tại chỗ, đi lùi playlist.
  2. Xóa nút đang cầm O(1)O(1) — LRU, editor, kernel (struct có thể nằm trong list).
  3. Xóa cuối O(1)O(1) — deque, cache “bỏ phần tử cũ nhất”.
  4. Thêm trước một nút O(1)O(1).
  5. Cài deque (hai đầu) tự nhiên: mọi thao tác đầu/cuối đều O(1)O(1).

Nhược điểm

  1. Thêm một con trỏ pPrev → tốn RAM (~+8 byte/nút trên máy 64-bit). 1000 int: đơn ~16 KB, kép ~24 KB (xem 3.5.2).
  2. Mỗi thao tác sửa nhiều con trỏ hơn → dễ gán sai, khó debug. Một chiều đứt, chiều kia còn.
  3. Vẫn không truy cập ngẫu nhiên O(1)O(1); tìm kiếm vẫn O(n)O(n); cache CPU kém hơn mảng.

Khi nào chọn kép, khi nào đủ đơn?

Chọn kép khi…Đủ đơn khi…
Cần Back/Forward, Undo/RedoChỉ thêm/xóa đầu, duyệt một chiều
Cần xóa cuối / xóa nút đang cầm rất nhiềuList ngắn, RAM eo hẹp (vi điều khiển)
Cài std::list-like, deque, LRUBài tập/đề thi chỉ yêu cầu DSLK đơn

Bảng bẫy thường gặp:

BẫyTriệu chứngCách tránh
Quên sửa chiều ngượcXuôi đúng, ngược saiSau mỗi thao tác chạy HopLeKep / XuatNguoc
Xét xóa đầu trước nhánh 1 nútCrash NULL->pPrevif (head == tail) đứng đầu
Đảo thứ tự gán khi thêmMất phần sau / leakGiữ next/prevtrước khi ghi đè
Quên cập nhật pTail khi chèn/xóa cuốiThêm cuối lần sau ghi đè sai chỗMỗi nhánh đụng nút cuối phải đụng pTail
delete rồi còn đọc p->…DanglingLưu pNext/pPrev ra biến tạm trước

H. Ứng dụng: Back/Forward, Undo/Redo, LRU, deque

Không học thuộc tên cho oai. Mỗi ứng dụng dưới đây dùng đúng một thế mạnh của kép.


1. Lịch sử trình duyệt — Back / Forward

Mỗi trang là một nút. current là trang đang xem. Back = đi pPrev, Forward = đi pNext.

Chạy tay:

Visit A current → [A]
Visit B [A]↔[B] current = B
Visit C [A]↔[B]↔[C] current = C
Back current lùi về B (không xóa C — còn Forward)
Back current lùi về A
Visit D cắt nhánh Forward (xóa C), nối D sau A
[A]↔[D] current = D
(B, C bị hủy — giống trình duyệt thật)
Tại sao kép? Back/Forward là đi lùi/tới một bước O(1).
Visit trang mới khi đang đứng giữa: XoaNode từ current->pNext đến cuối
rồi ThemCuoi — xóa từng nút đang cầm O(1).

2. Undo / Redo trong editor

Mỗi hành động gõ/xóa là một nút. current = trạng thái hiện tại.

Gõ "a" [a] current = a
Gõ "b" [a]↔[b] current = b
Undo current lùi về a (b vẫn còn để Redo)
Gõ "c" cắt nhánh redo (xóa b), nối c
[a]↔[c] current = c

Cùng hình học với trình duyệt: đi lùi/tới O(1)O(1) + cắt đuôi O(soˆˊ nuˊt bị bỏ)O(\text{số nút bị bỏ}).


3. Deque (hàng đợi hai đầu)

Cần cả bốn thao tác O(1)O(1): thêm đầu, thêm cuối, xóa đầu, xóa cuối. DSLK đơn thất bại ở xóa cuối. DSLK kép làm đủ.

PushFront(x) = ThemDauKep
PushBack(x) = ThemCuoiKep
PopFront() = XoaDauKep
PopBack() = XoaCuoiKep ← chỗ đơn không O(1)

std::list trong C++ STL là DSLK kép. std::deque thường là khối mảng, nhưng ADT deque cài bằng kép là cách giáo khoa.


4. LRU Cache — case study (nâng cao, nắm ý là đủ)

Bài toán. Cache dung lượng kk. get(key) / put(key) phải O(1)O(1). Khi đầy, vứt phần tử lâu nhất chưa dùng (Least Recently Used).

Cấu trúc: HashMap key → DNODE* (tìm nút O(1)O(1)) + DSLK kép lưu thứ tự “mới dùng”:

pHead = MỚI dùng nhất pTail = CŨ nhất (nạn nhân khi đầy)
│ │
▼ ▼
[trang X] ↔ [trang Y] ↔ [trang Z]

Ba thao tác nguyên thủy của kép, mỗi cái O(1)O(1):

Sự kiệnLàm gì trên kép
get trúng / put key đã cóXoaNodeKep nút đó rồi ThemDauKep (đưa lên đầu)
put key mới, cache chưa đầyThemDauKep
put key mới, cache đầyXoaCuoiKep (vứt LRU) rồi ThemDauKep

Chạy tay k=3k = 3:

put(1) [1] map: 1
put(2) [2]↔[1] map: 1, 2
put(3) [3]↔[2]↔[1] đầy
get(1) đưa 1 lên đầu → [1]↔[3]↔[2]
put(4) đầy, vứt tail=2, thêm 4 [4]↔[1]↔[3]
map xóa 2, thêm 4

get(2) → miss (đã vứt)
get(1) → hit, đưa 1 lên đầu [1]↔[4]↔[3]

Không cần code HashMap trong chương này. Ý cần nắm: kép cho phép di chuyển một nút đang cầm tới đầu list trong O(1)O(1) — đơn không làm được vì không biết nút trước.


5. Playlist “bài trước / bài sau”

Nút = một bài hát. current->pNext = bài sau, current->pPrev = bài trước. Nút Prev trên UI chính là pPrev. Nếu muốn hết danh sách quay lại bài đầu → nâng thành vòng kép (mục 3.4.2.E).


3.4.2. DSLK vòng (Circular Linked List)

A. Vì sao cần vòng? Tổ chức chỉ giữ pTail

Ẩn dụ. DSLK đơn/kép thẳng như hàng người có người đầungười cuối; người cuối không nắm ai (NULL). DSLK vòng như người ngồi quanh bàn: người cuối nắm vai người đầu. Đi mãi sẽ gặp lại chỗ xuất phát — không có “điểm dừng NULL”.

Dữ liệu nào cần vòng? Thứ tự tuần hoàn, không có “phần tử cuối” về nghĩa logic:

  • CPU chia time-slice cho P1,P2,,PnP_1, P_2, \ldots, P_n rồi quay lại P1P_1 (round-robin).
  • Playlist bật “lặp danh sách”.
  • Trò chơi đi vòng quanh bàn, bài toán Josephus.
  • Token ring: token đi hết trạm rồi về trạm 0.

Ép những bài này vào DSLK thẳng cũng được, nhưng mỗi lần hết list phải tự viết p = pHead — dễ quên, và không còn tính chất “đứng ở nút bất kỳ cũng đi được hết vòng”.

Định nghĩa vòng đơn. Mỗi nút vẫn chỉ có pNext (như DSLK đơn). Khác một điểm:

Nút cuối không trỏ NULL mà trỏ về nút đầu.

┌──────────────────────────────────────────┐
▼ │
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ │
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ ●─┼────┘
└────┴────┘ └────┴────┘ └────┴────┘
▲ ▲
head pTail

head = pTail->pNext
Không còn ô nào chứa NULL → while (p != NULL) sẽ CHẠY MÃI

Ba hình dạng phải thuộc (rỗng / 1 nút / nhiều nút):

RỖNG MỘT NÚT (tự trỏ mình)
pTail ──► NULL pTail


┌────┬────┐
┌─►│ 10 │ ●─┼─┐
│ └────┴────┘ │
└──────────────┘
p->pNext = p
head = pTail = p

NHIỀU NÚT (n = 3)
┌──────────────────────────────┐
▼ │
[10] ──► [20] ──► [30] ──────────┘
▲ ▲
head pTail

Vì sao chỉ giữ pTail, không giữ pHead?

Đây là mẹo tổ chức then chốt — học thuộc lý do, không chỉ thuộc code.

Chỉ giữThêm đầuThêm cuốiLý do
pHeadMuốn nối last->pNext = nút mới nhưng không có last → phải đi vòng O(n)O(n)Phải tìm last O(n)O(n)Mất đúng hai thao tác “rẻ”
pTailNút mới chèn ngay sau tail (tức ngay trước head) — O(1)O(1)Cùng chỗ chèn, rồi dời pTailO(1)O(1)pTail->pNext chính là head, luôn sẵn
head = pTail->pNext ← không cần biến pHead riêng

Giữ cả pHead lẫn pTail trên vòng cũng được, nhưng thừa:
pHead luôn bằng pTail->pNext, thêm một bất biến phải bảo trì.
Giáo trình / đề thi: một con trỏ pTail là đủ.
struct CLIST {
NODE* pTail; // NULL ⇔ rỗng; pTail->pNext = head
};

void KhoiTaoC(CLIST &l) { l.pTail = NULL; }

bool IsEmptyC(CLIST l) { return l.pTail == NULL; }

Dùng lại NODE / TaoNode của DSLK đơn (mục 3.3.1) — cấu trúc nút không đổi. Đổi cách nối đuôi với đầu, và đổi điều kiện dừng khi duyệt.

Bất biến vòng đơn:

#Bất biến
1Rỗng ⇔ pTail == NULL
2Một nút ⇔ pTail->pNext == pTail
3Nhiều nút: đi từ pTail->pNext theo pNext sẽ quay lại pTail->pNext sau đúng nn bước; không gặp NULL
4pTail luôn là nút đứng trước head (người cuối bàn)

B. Duyệt vòng — không viết while (p != NULL)

Vì không còn NULL, vòng while (p != NULL) không bao giờ kết thúc. Đây là bug số 1 khi mới học vòng.

Ý tưởng. Xuất phát tại head (pTail->pNext), in, tiến, lặp đến khi gặp lại head.

List 1 nút: head.next = chính head. Nếu dùng while (p != head) trước khi in thì thân vòng không chạy → bỏ sót nút duy nhất. Vì vậy phải làm rồi mới kiểm trado-while.

THUẬT TOÁN XuatVong(l)
1. nếu pTail = NULL thì return // rỗng
2. p ← pTail.pNext // head
3. lặp
4. in p.data
5. p ← p.pNext
6. until p = pTail.pNext // đã quay về head
List 10 → 20 → 30 → (về 10)

p bắt đầu = 10
in 10, p = 20 20 ≠ head → tiếp
in 20, p = 30 30 ≠ head → tiếp
in 30, p = 10 10 = head → dừng
In: 10 20 30 (đúng một vòng, không lặp vô hạn)
void XuatVong(CLIST l) {
if (l.pTail == NULL) return;
NODE* p = l.pTail->pNext; // head
do {
cout << p->data << " -> ";
p = p->pNext;
} while (p != l.pTail->pNext);
cout << "(quay ve dau)\n";
}

int DemSoPhanTuVong(CLIST l) {
if (l.pTail == NULL) return 0;
int dem = 0;
NODE* p = l.pTail->pNext;
do {
dem++;
p = p->pNext;
} while (p != l.pTail->pNext);
return dem;
}

NODE* TimVong(CLIST l, int x) {
if (l.pTail == NULL) return NULL;
NODE* p = l.pTail->pNext;
do {
if (p->data == x) return p;
p = p->pNext;
} while (p != l.pTail->pNext);
return NULL;
}

Bảng đối chiếu điều kiện dừng (thuộc để không lẫn 3.3 / 3.4.1 / 3.4.2):

Loại listXuất phátĐiều kiện lặpGặp NULL?
Đơn thẳngpHeadwhile (p != NULL)Có, ở nút cuối
Kép thẳng xuôipHeadwhile (p != NULL)
Kép thẳng ngượcpTailwhile (p != NULL) theo pPrevCó, ở nút đầu
Vòng đơnpTail->pNextdo { … p = p->pNext; } while (p != head)Không

Bẫy duyệt:

Code saiChuyện gì xảy ra
while (p != NULL)Treo máy / timeout
while (p != head) { in; p = p->pNext; } với p xuất phát tại headKhông in gì — kể cả list 1 nút lẫn nn nút
for (p = head; p != head; p = p->pNext)Cùng lỗi: điều kiện sai ngay từ đầu
Quên nhánh pTail == NULL rồi đọc pTail->pNextCrash trên list rỗng
Dùng p != pTail làm điều kiện dừng khi muốn in hếtBỏ nút cuối (tail không được in)

O(n)O(n) thời gian, O(1)O(1) phụ — giống duyệt thẳng, chỉ khác điều kiện dừng.


C. Thêm đầu / thêm cuối — cùng 2 phép, khác pTail

Ý tưởng then chốt của cả mục 3.4.2:

Trên vòng, nút mới luôn được chèn ngay sau pTail (khe giữa đuôi và đầu).

  • Thêm cuối: chèn xong, dời pTail sang nút mới → nút mới thành đuôi.
  • Thêm đầu: chèn xong, không dời pTail → nút mới đứng ngay sau đuôi, tức là thành head mới (head = pTail->pNext).

Hai hàm gần như giống nhau. Nhầm một dòng pTail = p là đổi đầu thành cuối.

khe chèn luôn ở đây
┌──────────┐
│ ▼
tail head
│ │
[30] ──► [10] ──► [20] ──► (về 30)

└── thêm đầu: nút mới ngồi vào khe, pTail đứng yên
thêm cuối: nút mới ngồi vào khe, rồi pTail nhảy sang nút mới

① Thêm cuối — O(1)O(1)

List rỗng: nút tự trỏ tới chính nó (hình “một nút” mục A). Đây là nhánh bắt buộc — không có pTail->pNext để đọc.

pTail


┌────┬────┐
┌─►│ 30 │ ●─┼─┐
│ └────┴────┘ │
└──────────────┘
p->pNext = p

List đã có nút — 3 phép, không được đảo ① và ②:

TRƯỚC head = 10, pTail = 30, thêm p = 40
┌────────────────────────────────┐
▼ │
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼──►│ 20 │ ●─┼──►│ 30 │ ●─┼┘
└────┴────┘ └────┴────┘ └────┴────┘
▲ ▲
head pTail

① p->pNext = pTail->pNext (40 nắm head = 10 — GIỮ head trước khi cắt)
② pTail->pNext = p (30 nắm 40)
③ pTail = p (40 thành đuôi)

SAU head vẫn 10, pTail = 40
┌──────────────────────────────────────────┐
▼ │
[10] ──► [20] ──► [30] ──► [40] ─────────────┘
▲ ▲
head pTail
THUẬT TOÁN ThemCuoiVong(l, p)
1. nếu pTail = NULL
2. pTail ← p
3. p.pNext ← p // 1 nút tự trỏ mình
4. return
5. p.pNext ← pTail.pNext // p nắm head cũ
6. pTail.pNext ← p // tail cũ nắm p
7. pTail ← p // p thành đuôi
void ThemCuoiVong(CLIST &l, NODE* p) {
if (p == NULL) return;
if (l.pTail == NULL) {
l.pTail = p;
p->pNext = p;
return;
}
p->pNext = l.pTail->pNext; // ① giữ head
l.pTail->pNext = p; // ②
l.pTail = p; // ③
}

Bẫy: đảo ① và ② → pTail->pNext = p trước, lúc đó p->pNext còn NULL (nếu TaoNode) hoặc rác; mất địa chỉ head, list đứt + rò rỉ.
Bẫy: quên p->pNext = p khi rỗng → nút không khép vòng, duyệt do-while không bao giờ gặp lại head (hoặc crash).


② Thêm đầu — O(1)O(1)

Cùng ①② với thêm cuối, bỏ bước ③.

TRƯỚC head = 10, pTail = 30, thêm p = 5

① p->pNext = pTail->pNext (5 nắm 10)
② pTail->pNext = p (30 nắm 5)
pTail KHÔNG đổi (đuôi vẫn 30)

SAU head mới = pTail->pNext = 5
┌──────────────────────────────────────────┐
▼ │
[5] ──► [10] ──► [20] ──► [30] ──────────────┘
▲ ▲
head pTail
void ThemDauVong(CLIST &l, NODE* p) {
if (p == NULL) return;
if (l.pTail == NULL) {
l.pTail = p;
p->pNext = p;
return;
}
p->pNext = l.pTail->pNext;
l.pTail->pNext = p;
/* pTail khong doi — chi khac ThemCuoiVong dung 1 dong nay */
}

Bảng nhớ 1 dòng:

HàmChèn sau pTailpTail = p?Nút mới thành
ThemDauVongKhôngHead
ThemCuoiVongTail

Nhánh rỗng của hai hàm giống hệt: một nút vừa là đầu vừa là cuối.

O(1)O(1) cả hai. Đây là lý do chọn giữ pTail.


③ Thêm sau một nút qO(1)O(1)

Giống DSLK đơn: p->pNext = q->pNext; q->pNext = p. Nếu q == pTail thì đây chính là thêm cuối → phải dời pTail.

void ThemSauVong(CLIST &l, NODE* q, NODE* p) {
if (q == NULL || p == NULL) return;
p->pNext = q->pNext;
q->pNext = p;
if (q == l.pTail)
l.pTail = p;
}

Thêm trước q trên vòng đơn vẫn O(n)O(n): không có pPrev, phải đi tìm nút t sao cho t->pNext == q (đi nhiều nhất một vòng). Muốn O(1)O(1) thì dùng vòng kép.


④ Dựng vòng [10]→[20]→[30] bằng thêm cuối
0. rỗng: pTail = NULL
1. +10: [10]↺ pTail = 10, 10.next = 10
2. +20: [10]→[20]↺ pTail = 20, 20.next = 10
3. +30: [10]→[20]→[30]↺ pTail = 30, 30.next = 10

So với dựng DSLK đơn: bước 1 khác (tự trỏ mình, không trỏ NULL); mỗi bước sau chèn vào khe tail–head thay vì “nối vào NULL”.


D. Xóa đầu / xóa cuối / xóa giá trị / hủy

⑤ Xóa đầu — O(1)O(1)

Head luôn là pTail->pNext. Cắt head ra = cho tail nắm nút sau head.

Một nút: sau khi xóa, list rỗng → pTail = NULL.

TRƯỚC xóa head = 10, pTail = 30
┌────────────────────────────────┐
▼ │
[10] ──► [20] ──► [30] ────────────┘
▲ ▲
head pTail

headMoi = 20
pTail->pNext = 20
delete 10

SAU
┌──────────────────┐
▼ │
[20] ──► [30] ───────┘
▲ ▲
head pTail
THUẬT TOÁN XoaDauVong(l)
1. nếu pTail = NULL thì return
2. head ← pTail.pNext
3. nếu pTail = head // 1 nút
4. delete head
5. pTail ← NULL
6. return
7. pTail.pNext ← head.pNext // tail nắm head mới
8. delete head
void XoaDauVong(CLIST &l) {
if (l.pTail == NULL) return;
NODE* head = l.pTail->pNext;
if (l.pTail == head) {
delete head;
l.pTail = NULL;
return;
}
l.pTail->pNext = head->pNext;
delete head;
}

Bẫy: quên nhánh 1 nút → pTail->pNext = head->pNext nhưng head->pNext == head (đã delete) → pTail trỏ vùng nhớ chết.


⑥ Xóa cuối — O(n)O(n) trên vòng đơn

Đây là chỗ hay hiểu sai.

pTail không biến xóa cuối thành O(1)O(1) trên vòng đơn.
Xóa tail cần nút đứng trước tail (prev->pNext = head, rồi pTail = prev).
Vòng đơn không lưu pPrev → phải đi gần một vòng từ head đến khi p->pNext == pTail.

TRƯỚC xóa tail = 30, cần tìm prev = 20
[10] → [20] → [30] ↺
▲ ▲ ▲
head prev pTail

prev->pNext = head = 10
pTail = prev = 20
delete 30

SAU [10] → [20] ↺

Một nút: giống xóa đầu — pTail = NULL.

void XoaCuoiVong(CLIST &l) {
if (l.pTail == NULL) return;

NODE* head = l.pTail->pNext;
if (l.pTail == head) { // 1 nut
delete head;
l.pTail = NULL;
return;
}

NODE* prev = head;
while (prev->pNext != l.pTail) // di tim nut ap chot — O(n)
prev = prev->pNext;

prev->pNext = head; // khép vòng bỏ tail
delete l.pTail;
l.pTail = prev;
}
Vòng đơn + pTailVòng képDSLK kép thẳng
Xóa đầuO(1)O(1)O(1)O(1)O(1)O(1)
Xóa cuốiO(n)O(n)O(1)O(1)O(1)O(1)

Muốn xóa cuối O(1)O(1) trên dữ liệu tuần hoàn → vòng kép (mục E).


⑦ Xóa theo giá trị — O(n)O(n)

Đi một vòng với cặp (prev, p). Khi gặp p->data == x: prev->pNext = p->pNext. Nếu p là tail thì pTail = prev. Nếu p là head thì pTail->pNext tự thành head mới vì prev đang là tail.

void XoaGiaTriVong(CLIST &l, int x) {
if (l.pTail == NULL) return;

NODE* head = l.pTail->pNext;
if (head == l.pTail && head->data == x) { // 1 nut
delete head;
l.pTail = NULL;
return;
}

NODE* prev = l.pTail;
NODE* p = head;
do {
if (p->data == x) {
prev->pNext = p->pNext;
if (p == l.pTail)
l.pTail = prev;
delete p;
return;
}
prev = p;
p = p->pNext;
} while (p != head);
}

do-while để list 1 nút vẫn được xét (nhánh 1 nút đã return trước, nhưng vòng này còn dùng khi n2n \ge 2). Không thấy x thì đi hết một vòng rồi dừng — không treo.


⑧ Hủy vòng — nhớ cắt vòng trước

Nếu gọi XoaDauVong trong while (pTail != NULL) thì được, vì mỗi lần xóa giảm nn, hết thì pTail = NULL.

Cách an toàn hơn khi muốn tái sử dụng code hủy DSLK thẳng: cắt vòng thành list thẳng, rồi delete như 3.3.

[10] → [20] → [30] ↺ cắt: pTail->pNext = NULL
[10] → [20] → [30] → NULL giờ while (p != NULL) an toàn
void HuyDanhSachVong(CLIST &l) {
if (l.pTail == NULL) return;
NODE* p = l.pTail->pNext; // head
l.pTail->pNext = NULL; // CẮT VÒNG — bien thanh DSLK don thang
l.pTail = NULL;
while (p != NULL) {
NODE* t = p->pNext;
delete p;
p = t;
}
}

Bẫy: hủy bằng while (p != NULL) mà không cắt vòng → treo, không delete hết, rò rỉ + chương trình không kết thúc.


⑨ Ví dụ chạy tay cả chuỗi (vòng đơn)
0. rỗng
1. ThemCuoi 20 → [20]↺
2. ThemDau 10 → [10]→[20]↺ (pTail vẫn 20)
3. ThemCuoi 30 → [10]→[20]→[30]↺ (pTail = 30)
4. XoaDau → [20]→[30]↺
5. XoaGiaTri 30 → [20]↺ (xóa tail, pTail = 20)
6. XoaCuoi → rỗng
XuatVong bước 3: 10 -> 20 -> 30 -> (quay ve dau)

E. DSLK vòng kép (circular doubly)

Ghép ý 3.4.1 và 3.4.2: mỗi nút có pPrev + pNext, hai đầu nắm nhau.

┌───────────────────────────────────────────────────┐
▼ │
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐ │
│ ● │ 10 │ ●─┼──►│ ● │ 20 │ ●─┼──►│ ● │ 30 │ ●─┼──┘
│ │ │ │◄──┼─● │ │ │◄──┼─● │ │ │
└────┴────┴────┘ └────┴────┴────┘ └────┴────┴────┘
▲ ▲
head tail = head->pPrev

Bất biến vòng kép (không rỗng):
head->pPrev == tail
tail->pNext == head
mọi cặp kề: a.pNext = b ⇔ b.pPrev = a
Một nút: p->pNext == p->pPrev == p

Tổ chức: chỉ cần giữ head. Tail suy ra: head->pPrev. Không bắt buộc thêm pTail (khác vòng đơn — vòng đơn phải giữ tail mới thêm đầu/cuối O(1)O(1)).

struct CDNODE {
int data;
CDNODE *pPrev, *pNext;
};

CDNODE* TaoCDNode(int x) {
CDNODE* p = new CDNODE;
p->data = x;
p->pPrev = p->pNext = p; // mac dinh 1 nut tu khép hai chieu
return p;
}

Duyệt: cùng do-while với vòng đơn; muốn ngược thì p = p->pPrev.

void XuatVongKep(CDNODE* head) {
if (head == NULL) return;
CDNODE* p = head;
do {
cout << p->data << " ";
p = p->pNext;
} while (p != head);
cout << "\n";
}

Thêm cuối O(1)O(1):

TRƯỚC head=10, tail=head->pPrev=30, thêm p=40
10 ↔ 20 ↔ 30 (30.next=10, 10.prev=30)

p.next = head (40 nắm 10)
p.prev = tail (40 nắm 30)
tail.next = p (30 nắm 40)
head.prev = p (10.prev = 40)
head không đổi

SAU 10 ↔ 20 ↔ 30 ↔ 40 ↺
head tail = 40 = head->pPrev
void ThemCuoiVongKep(CDNODE* &head, CDNODE* p) {
if (p == NULL) return;
if (head == NULL) {
p->pNext = p->pPrev = p;
head = p;
return;
}
CDNODE* tail = head->pPrev;
p->pNext = head;
p->pPrev = tail;
tail->pNext = p;
head->pPrev = p;
}

Thêm đầu O(1)O(1): thêm cuối xong, head = p (nút mới vừa là đuôi logic, dời cửa head sang nó thì nó thành đầu). Hoặc chèn vào khe tail ↔ head rồi head = p.

void ThemDauVongKep(CDNODE* &head, CDNODE* p) {
ThemCuoiVongKep(head, p);
if (head != NULL)
head = p; // p vua la tail, dời head sang p → p thành đầu
}

Xóa một nút đang cầm O(1)O(1) — kể cả xóa cuối (thắng vòng đơn):

void XoaNodeVongKep(CDNODE* &head, CDNODE* p) {
if (head == NULL || p == NULL) return;

if (p->pNext == p) { // 1 nut
delete p;
head = NULL;
return;
}
p->pPrev->pNext = p->pNext;
p->pNext->pPrev = p->pPrev;
if (p == head)
head = p->pNext;
delete p;
}

void XoaDauVongKep(CDNODE* &head) {
if (head != NULL) XoaNodeVongKep(head, head);
}

void XoaCuoiVongKep(CDNODE* &head) {
if (head != NULL) XoaNodeVongKep(head, head->pPrev); // O(1)!
}

Hủy: cắt vòng (head->pPrev->pNext = NULLhead->pPrev = NULL) rồi xóa như kép thẳng; hoặc while (head != NULL) XoaDauVongKep(head).

Khi nào dùng vòng kép? Playlist lặp có nút Prev, game đi vòng hai chiều, buffer tròn cần xóa hai đầu O(1)O(1). Đề thi ít bắt cài đủ ADT này — hiểu bất biến + thêm cuối + xóa nút đang cầm là đạt.


F. Ứng dụng: round-robin, playlist, Josephus


1. Round-robin scheduling (hệ điều hành)

CPU có nn tiến trình, mỗi tiến trình được chạy một quantum (lát thời gian), xong thì đến tiến trình kế, hết vòng thì về tiến trình đầu. Đúng hình vòng.

pTail trỏ tiến trình VỪA chạy xong
pTail->pNext = tiến trình SẼ chạy (head của “hàng chờ vòng”)

Lát kế:
chạy head
nếu head còn việc → coi như “chuyển head ra cuối”
= ThemCuoi ý niệm: chỉ dời pTail = pTail->pNext
(không cấp phát nút mới — chỉ xoay cửa)
nếu head xong việc → XoaDauVong

Chạy tay. Ba tiến trình, thời gian còn lại (P1=3, P2=2, P3=1)(P_1=3,\ P_2=2,\ P_3=1), quantum =1= 1.

Ban đầu vòng: P1(3) → P2(2) → P3(1) ↺ sắp chạy P1

Q1: P1 chạy → P1(2). Chưa xong, xoay: P2(2) → P3(1) → P1(2)
Q2: P2 chạy → P2(1). Xoay: P3(1) → P1(2) → P2(1)
Q3: P3 chạy → P3(0). XONG, xóa P3: P1(2) → P2(1)
Q4: P1 chạy → P1(1). Xoay: P2(1) → P1(1)
Q5: P2 chạy → P2(0). XONG, xóa P2: P1(1)
Q6: P1 chạy → P1(0). XONG. Vòng rỗng.

Thứ tự hoàn thành: P3, P2, P1

“Xoay” không cần ThemCuoi thật: pTail = pTail->pNext là đủ — vì list đã là vòng, dời tail một bước = đưa head cũ thành tail, head mới là nút kế. Đây là thao tác chỉ vòng mới có (O(1)O(1), không cấp phát).


2. Playlist lặp / slide show

  • Vòng đơn: nút Next. Hết bài cuối → bài đầu tự động (current = current->pNext, không cần if (cuoi) current = head).
  • Vòng kép: thêm nút Prev. UI “bài trước” = current = current->pPrev.
  • Thêm bài sau bài đang phát = ThemSau. Xóa bài đang phát = xóa nút đang cầm (vòng kép O(1)O(1); vòng đơn phải có prev).

3. Game: lượt chơi quanh bàn

Người chơi là nút vòng. current = người đang đi. Hết lượt: current = current->pNext. Người bị loại: xóa nút (Josephus là trường hợp đếm kk rồi loại).


4. Circular buffer ≠ DSLK vòng

Sinh viên hay gọi cả hai là “vòng”. Phân biệt:

DSLK vòngCircular buffer (mảng vòng)
LưuNút rải Heap, nối con trỏMảng cố định, hai chỉ số front/rear
Hết chỗCòn RAM là thêm đượcĐầy khi (rear+1)modn=front(rear+1) \bmod n = front
Dùng khinn không biết trước, thêm/xóa giữaHàng đợi kích thước kịch trần (buffer âm thanh, IO)

Cùng ý “quay lại đầu”, khác cấu trúc. Chương 4 (Queue) sẽ gặp mảng vòng.


5. Bài toán Josephus — ứng dụng kinh điển

nn người đứng vòng, đánh số 1..n1..n. Đếm kk người thì loại 1, tiếp tục từ người kế, đến khi còn 1 người. Tìm người sống sót.

Mô hình: vòng đơn; xóa nút đang cầm cần con trỏ đứng trước người bị loại.

Chạy tay n=5n=5, k=2k=2 (bắt đầu đếm từ người 1; code đứng ở người trước người sắp bị loại):

Ban đầu:
┌─────────────────────────────────┐
▼ │
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 1 │──►│ 2 │──►│ 3 │──►│ 4 │──►│ 5 │┘
└───┘ └───┘ └───┘ └───┘ └───┘

k=2: loại người thứ 2 kể từ chỗ đếm.
Lần 1: đếm 1, 2 → loại 2. Còn 1 → 3 → 4 → 5 → 1
Lần 2: từ 3, đếm 3, 4 → loại 4. Còn 1 → 3 → 5 → 1
Lần 3: từ 5, đếm 5, 1 → loại 1. Còn 3 → 5 → 3
Lần 4: từ 3, đếm 3, 5 → loại 5. Còn 3 → 3
Sống sót: 3

Truy vết con trỏ (khớp code dưới: p đứng trước người bị loại; với k=2k=2 thì không cần vòng for, p->pNext chính là nạn nhân):

Ban đầu p = người 1 (head). k=2 → p phải đứng trước nạn nhân.
Vòng while còn > 1 người:
for i = 1 .. k-2: p đi tới (k=2: for không chạy)
chet = p->pNext
p->pNext = chet->pNext (bỏ chet khỏi vòng)
nếu chet là pTail thì pTail = p
delete chet
p = p->pNext (người kế tiếp bắt đầu đếm mới)

Lần 1: p=1, chet=2, 1 nắm 3, p ← 3
Lần 2: p=3, chet=4, 3 nắm 5, p ← 5
Lần 3: p=5, chet=1, 5 nắm 3, p ← 3
Lần 4: p=3, chet=5, 3 nắm 3 (tự trỏ), p ← 3
Dừng: p->pNext == p, sống = 3
THUẬT TOÁN Josephus(n, k) → người sống sót
1. nếu n < 1 hoặc k < 1 thì trả về -1
2. nếu k = 1 thì trả về n // loại lần lượt người hiện tại
3. dựng vòng 1..n bằng ThemCuoiVong
4. p ← head
5. while p.pNext ≠ p // còn > 1 người
6. lặp k-2 lần: p ← p.pNext // p đứng trước nạn nhân
7. chet ← p.pNext
8. p.pNext ← chet.pNext
9. nếu chet là pTail thì pTail ← p
10. delete chet
11. p ← p.pNext
12. sống ← p.data; delete p; trả về sống
int Josephus(int n, int k) {
if (n < 1 || k < 1) return -1;
if (k == 1) return n; // giet lan luot nguoi hien tai: con lai nguoi n

CLIST l;
KhoiTaoC(l);
for (int i = 1; i <= n; i++)
ThemCuoiVong(l, TaoNode(i));

NODE* p = l.pTail->pNext; // bat dau o nguoi 1
while (p->pNext != p) { // con > 1 nguoi
for (int i = 1; i < k - 1; i++)
p = p->pNext;
NODE* chet = p->pNext;
p->pNext = chet->pNext;
if (chet == l.pTail) l.pTail = p;
delete chet;
p = p->pNext;
}
int song = p->data;
delete p;
l.pTail = NULL;
return song;
}

Code đúng với n=5,k=2n=5, k=2 (sống sót =3= 3) và k2k \ge 2. k=1k=1 xử lý riêng: nếu để vào vòng for thì p “đứng trước nạn nhân” không còn đúng nghĩa khi nạn nhân là chính p.

Thêm một test (bài tập 12): n=5,k=3n=5, k=3 → loại 3, 1, 5, 2 → sống sót 4.

Độ phức tạp mô phỏng: O(nk)O(n \cdot k) naive. Chỉ cần vị trí sống sót, không cần dựng list: công thức f(1)=0f(1)=0, f(i)=(f(i1)+k)modif(i)=(f(i-1)+k) \bmod i (0-based) — O(n)O(n), không thuộc trọng tâm chương.


G. Ưu / nhược điểm DSLK vòng

Ưu

  • Từ mọi nút đi được hết danh sách, không cần quay về biến pHead.
  • Thêm đầu thêm cuối O(1)O(1) nếu giữ pTail (vòng đơn).
  • “Xoay” cửa O(1)O(1): pTail = pTail->pNext — round-robin không cấp phát.
  • Mô hình tự nhiên cho dữ liệu tuần hoàn.

Nhược

  • Dễ vòng lặp vô hạn nếu quên điều kiện dừng / quên cắt vòng khi hủy.
  • Không có NULL lính canh → hầu hết hàm viết do-while + nhánh rỗng/1 nút.
  • Vòng đơn: xóa cuối vẫn O(n)O(n).
  • Debug khó hơn list thẳng (in mãi một vòng trông giống treo).

Chọn loại vòng:

Nhu cầuChọn
Round-robin, Josephus, chỉ đi tớiVòng đơn + pTail
Playlist Prev/Next, xóa cuối O(1)O(1)Vòng kép
Buffer kích thước cố địnhMảng vòng (không phải DSLK)

H. Bảng bốn loại — nhìn là chọn được

Đơn thẳngKép thẳngVòng đơnVòng kép
Con trỏ mỗi nútpNextpPrev pNextpNextpPrev pNext
Nút cuối trỏNULLNULLHeadHead
Nút đầu pPrevNULLTail
Cửa vàopHead (+pTail)pHead+pTailpTailhead (tail = head->pPrev)
Duyệtp != NULLxuôi/ngược != NULLdo-while về headdo-while xuôi hoặc ngược
Thêm đầu / cuốiO(1)O(1) / O(1)O(1)O(1)O(1) / O(1)O(1)O(1)O(1) / O(1)O(1)O(1)O(1) / O(1)O(1)
Xóa cuốiO(n)O(n)O(1)O(1)O(n)O(n)O(1)O(1)
Thêm trước nút đang cầmO(n)O(n)O(1)O(1)O(n)O(n)O(1)O(1)
Đi lùiKhôngKhông
Ứng dụng mẫuStack, danh sách SVUndo, LRU, dequeRound-robin, JosephusPlaylist lặp 2 chiều

✅ Kiểm tra nhanh 3.4

  1. DSLK kép thắng đơn ở ba thao tác nào khi đã cầm con trỏ tới nút? Đánh đổi gì?
  2. Xóa nút giữa trên kép: hai phép gán bắt buộc? Vì sao phải xét nhánh “1 nút” trước nhánh “xóa đầu”?
  3. Thêm trước nút q trên kép vì sao O(1)O(1), trên đơn lại O(n)O(n)?
  4. LRU đầy: vứt nút nào, thêm nút mới vào đâu? Độ phức tạp mỗi bước (bỏ HashMap)?
  5. Vì sao vòng đơn chỉ giữ pTail chứ không chỉ giữ pHead?
  6. ThemDauVongThemCuoiVong khác nhau đúng một dòng nào?
  7. Vì sao while (p != NULL) trên vòng chạy mãi? Viết điều kiện dừng đúng. Vì sao phải do-while chứ không while (p != head)?
  8. Xóa cuối vòng đơn O(?)O(?). Muốn O(1)O(1) thì đổi sang loại nào?
  9. Hủy vòng: phải làm gì trước khi while (p != NULL) delete?
  10. Josephus n=5,k=2n=5, k=2, người sống sót? n=5,k=3n=5, k=3?

Đáp án:
(1) Đi lùi, xóa chính nút đang cầm, xóa cuối — đều O(1)O(1); tốn thêm 1 con trỏ/nút, sửa nhiều liên kết hơn, dễ lệch hai chiều.
(2) p->pPrev->pNext = p->pNextp->pNext->pPrev = p->pPrev. Một nút thì pHead->pNextNULL; gán pHead = NULL rồi còn pHead->pPrev sẽ crash.
(3) Kép có q->pPrev sẵn; đơn phải duyệt từ head tìm nút t với t->pNext == q.
(4) Vứt pTail (cũ nhất), thêm vào pHead (mới nhất); mỗi bước O(1)O(1) trên kép.
(5) pTail->pNext chính là head → thêm đầu và thêm cuối đều chèn sau tail trong O(1)O(1). Chỉ giữ head thì phải tìm last O(n)O(n).
(6) ThemCuoiVong có thêm l.pTail = p; ThemDauVong không.
(7) Không còn NULL. do { … p = p->pNext; } while (p != head). while (p != head) khi p đang ở head → thân vòng không chạy, bỏ hết dữ liệu.
(8) O(n)O(n); vòng kép (hoặc kép thẳng nếu không cần tuần hoàn).
(9) Cắt vòng: pTail->pNext = NULL.
(10) Người 3; người 4.


3.5. SO SÁNH TỔNG HỢP VÀ ỨNG DỤNG

3.5.1. Bảng chọn cấu trúc

Nhu cầuChọn
a[i] thường xuyên, ít thêm/xóa giữaMảng
Thêm/xóa đầu nhiều, kích thước không biết trướcDSLK đơn
Thêm cuối nhiềuDSLK đơn + pTail, hoặc vòng + pTail
Xóa cuối / đi lùi / undo-redoDSLK kép
Dữ liệu tuần hoàn, round-robinDSLK vòng
Cache LRU, dequeDSLK kép (+ hash nếu cần tìm O(1)O(1))

3.5.2. Overhead bộ nhớ (máy 64-bit, int 4 byte, con trỏ 8 byte, alignment)

NODE đơn: data 4 + padding 4 + pNext 8 ≈ 16 byte (chỉ 4 byte là dữ liệu)
DNODE kép: data 4 + pad 4 + pPrev 8 + pNext 8 ≈ 24 byte

1000 số nguyên:

  • Mảng: 1000×4=40001000 \times 4 = 4000 byte.
  • DSLK đơn: 16000\approx 16000 byte (gấp 4).
  • DSLK kép: 24000\approx 24000 byte (gấp 6).

DSLK đắt RAM. Đừng dùng khi chỉ cần lưu dãy số tĩnh.

3.5.3. Ứng dụng “zero to hero”

1. Đa thức — mỗi nút (hệ số, số mũ), chỉ lưu số hạng ≠ 0. Đây là ứng dụng kinh điển trong giáo trình CTDL.

P(x) = 3x^5 + 2x^2 + 1 ≡ [3,5] → [2,2] → [1,0] → NULL
coef exp

Quy ước: sắp theo mũ giảm dần, không có số hạng hệ số 0.

struct Term {
double coef;
int exp;
Term* pNext;
};

Cộng hai đa thức = đi song song hai list (giống Noi), so sánh mũ:

So sánh mũViệc làm
p->exp > q->expCopy số hạng p vào kết quả, p tiến
p->exp < q->expCopy số hạng q, q tiến
p->exp == q->expCộng hệ số; nếu tổng ≠ 0 thì ghi vào kết quả; cả pq tiến

Tính P(x0)P(x_0): duyệt list, tong += coef * pow(x0, exp). O(n)O(n).

2. Danh sách kề của đồ thị — chương sau: mỗi đỉnh một DSLK các đỉnh kề. Đồ thị thưa tiết kiệm hơn ma trận.

3. Ngăn xếp / hàng đợi — chương 4: Stack = thêm/xóa đầu DSLK; Queue = thêm cuối + xóa đầu.

4. Bộ nhớ hệ điều hành — free list: các khối RAM trống xâu thành DSLK.

5. Trình duyệt, editor, LRU, deque — DSLK kép (chi tiết chạy tay: mục 3.4.1.H). Round-robin / Josephus / playlist lặp — DSLK vòng (mục 3.4.2.F).


3.6. THUẬT TOÁN NÂNG CAO TRÊN DSLK ĐƠN

Các thuật toán dưới đây xuất hiện trong bài tập khó / phỏng vấn. Nên hiểu ý tưởng hai con trỏ.

3.6.1. Đảo ngược danh sách — O(n)O(n), O(1)O(1) phụ

10 → 20 → 30 → NULL thành 30 → 20 → 10 → NULL

Cần ba con trỏ vì mỗi lần lật cur->pNext sẽ mất địa chỉ nút sau, nên phải giữ next trước.

Ban đầu: prev=NULL cur


┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼───►│ 20 │ ●─┼───►│ 30 │ / │
└────┴────┘ └────┴────┘ └────┴────┘

Lần 1: next=20; lật 10 về prev (NULL)
prev cur
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ / │ │ 20 │ ●─┼───►│ 30 │ / │
└────┴────┘ └────┴────┘ └────┴────┘

Lần 2: next=30; lật 20 về 10
prev cur
│ │
▼ ▼
┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ / │◄──┤ 20 │ ● │ │ 30 │ / │
└────┴────┘ └────┴────┘ └────┴────┘

Lần 3: next=NULL; lật 30 về 20; cur=NULL → dừng
pHead=prev=30:

┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 30 │ ●─┼───►│ 20 │ ●─┼───►│ 10 │ / │──► NULL
└────┴────┘ └────┴────┘ └────┴────┘
▲ ▲
pHead pTail (head cũ)

Mã giả:

THUẬT TOÁN DaoNguoc(l)
1. prev ← NULL, cur ← pHead
2. while cur ≠ NULL
3. next ← cur.pNext
4. cur.pNext ← prev // lat chieu
5. prev ← cur
6. cur ← next
7. pTail ← pHead
8. pHead ← prev
void DaoNguoc(LIST &l) {
NODE* prev = NULL;
NODE* cur = l.pHead;
l.pTail = l.pHead;
while (cur != NULL) {
NODE* next = cur->pNext;
cur->pNext = prev;
prev = cur;
cur = next;
}
l.pHead = prev;
if (l.pTail) l.pTail->pNext = NULL;
}

Đệ quy: đảo phần sau, rồi head->pNext->pNext = head; head->pNext = NULL. O(n)O(n) stack — không dùng list dài.


3.6.2. Tìm nút giữa — hai con trỏ chậm/nhanh

slow đi 1 bước, fast đi 2. Khi fast không đi tiếp được, slow đang ở giữa — vì fast đã đi gấp đôi.

List 5 nút:
┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼──►│ 20 │ ●─┼──►│ 30 │ ●─┼──►│ 40 │ ●─┼──►│ 50 │ / │
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘

S,F xuất phát

Sau 1 nhịp (S+1, F+2):
S F
▼ ▼
[20] [40]

Sau 2 nhịp:
S F hết đường

[30] ← nút giữa, trả về slow

Với số nút chẵn, slow dừng ở nút trái-giữa (tùy điều kiện vòng lặp). Không cần DemSoPhanTu rồi chia 2 — một lần duyệt.

NODE* TimNutGiua(LIST l) {
if (l.pHead == NULL) return NULL;
NODE* slow = l.pHead;
NODE* fast = l.pHead;
while (fast->pNext != NULL && fast->pNext->pNext != NULL) {
slow = slow->pNext;
fast = fast->pNext->pNext;
}
return slow;
}

O(n)O(n) thời gian, O(1)O(1) phụ. Không cần đếm nn rồi đi n/2n/2.

Tìm nút thứ kk từ cuối: fast đi trước kk bước, rồi slowfast đi cùng tốc độ. Khi fast hết, slow là đáp án.


3.6.3. Phát hiện chu trình — Floyd (tortoise & hare)

Nếu ai đó gán nhầm pTail->pNext vào giữa list, duyệt while (p != NULL) treo.

┌────┬────┐ ┌────┬────┐ ┌────┬────┐ ┌────┬────┐
│ 10 │ ●─┼──►│ 20 │ ●─┼──►│ 30 │ ●─┼──►│ 40 │ ●─┼─┐
└────┴────┘ └────┴────┘ └────┴────┘ └────┴────┘ │
▲ │
└─────────────────────────────────┘
Có chu trình: 40 nắm lại 20. Không còn NULL. Duyệt thẳng sẽ không dừng.
bool CoChuTrinh(LIST l) {
NODE* slow = l.pHead;
NODE* fast = l.pHead;
while (fast != NULL && fast->pNext != NULL) {
slow = slow->pNext;
fast = fast->pNext->pNext;
if (slow == fast) return true;
}
return false;
}

Vì sao đúng: trên đoạn thẳng fast không bao giờ bị slow bắt. Khi cả hai vào vòng, mỗi nhịp khoảng cách giảm 1 (vì fast nhanh hơn 1 bước so với chu vi). Sau hữu hạn bước chúng đứng cùng một nút.

Tìm nút vào vòng: sau khi gặp nhau, đưa một con trỏ về pHead, cả hai đi từng bước; chỗ gặp lại là cửa vào vòng (Floyd bước 2).


3.6.4. Kiểm tra palindrome

Palindrome: đọc xuôi = đọc ngược, ví dụ 1 → 2 → 3 → 2 → 1.

Cách dễ (học trước): O(n)O(n) thời gian, O(n)O(n) bộ nhớ phụ — đẩy nửa đầu vào stack, so từng phần tử với nửa sau.

Cách O(1)O(1) phụ:

  1. Tìm nút giữa (mục 3.6.2).
  2. Đảo nửa sau (mục 3.6.1).
  3. So từng cặp từ pHead với đầu nửa sau.
  4. Đảo lại nửa sau để trả list như cũ.
1 → 2 → 3 → 2 → 1
giữa
đảo nửa sau: 1 → 2 → 3 và 1 → 2
so: 1=1, 2=2 → palindrome

3.7. BÀI TẬP

Làm theo thứ tự. Không nhìn code mẫu ở trên trừ khi đã viết nháp mã giả.

A. Lý thuyết — hiểu khái niệm

Bài 1. Phân biệt p, *p, &x. Vẽ ô nhớ cho:

int x = 7;
int *p = &x;
*p = 3;

Bài 2. Vì sao không được return địa chỉ biến cục bộ? Liên hệ với việc TaoNode phải new.

Bài 3. So sánh cấp phát tuần tự và cấp phát liên kết theo 5 tiêu chí: truy cập, thêm đầu, thêm giữa, bộ nhớ, cache.

Bài 4. DSLK đơn có pTail. Thao tác nào vẫn O(n)O(n)? Vì sao xóa cuối không xuống O(1)O(1)?

Bài 5. Nêu 4 con trỏ phải sửa khi thêm một nút vào giữa DSLK kép. Vẽ trước/sau.

Bài 6. Vì sao duyệt DSLK vòng không dùng while (p != NULL)? Viết điều kiện dừng đúng.

Bài 7. Sau SapXep (đổi chỗ data) trên DSLK, có binary search O(logn)O(\log n) được không? Giải thích.

Bài 8. Điền bảng: thêm đầu, thêm cuối, xóa đầu, xóa cuối cho (mảng, DSLK đơn+tail, DSLK kép, vòng+tail).


B. Truy vết (trace) — bắt buộc trước khi gõ code

Bài 9. List rỗng. Thực hiện: ThemCuoi 5, ThemDau 2, ThemCuoi 9, XoaDau, ThemTaiViTri(1, 7). Vẽ pHead, pTail sau mỗi bước.

Bài 10. [4]→[1]→[3]→[2]. Chạy SapXep (đổi chỗ trực tiếp, đúng giáo trình). Ghi data các nút sau mỗi vòng p.

Bài 11. DSLK kép [A]↔[B]↔[C]. Xóa B. Liệt kê giá trị pPrev/pNext của A và C sau khi xóa.

Bài 12. Vòng 1-2-3-4-5, Josephus k=3k=3. Liệt kê thứ tự loại.


C. Cài đặt cơ bản (CLO thao tác)

Bài 13. Cài đủ: KhoiTao, TaoNode, ThemDau, ThemCuoi, Xuat, IsEmpty, DemSoPhanTu, HuyDanhSach. Viết main menu.

Bài 14. Thêm: Tim, ThemTaiViTri, XoaDau, XoaCuoi, XoaNodeTheoGiaTri, XoaTaiViTri.

Bài 15. Tạo 50 số ngẫu nhiên 1..301..30 vào list (thêm cuối). Đếm số chẵn, in node max, xóa min.

Bài 16. Tach(l, l1, l2): l1 nguyên tố, l2 còn lại. Không phá l.


D. Sắp xếp và trộn

Bài 17. SapXep tăng dần bằng hoán đổi giá trị. In trước/sau.

Bài 18. Viết SapXepGiam. So sánh số lần swap với bản tăng trên cùng dữ liệu.

Bài 19. Noi(l1, l2, l3) hai list đã sort. Test: l1 = 1..10, l2 = 5..15.

Bài 20 (nâng cao). Cài MergeSortList. So thời gian (clock) với SapXep khi n=5000n = 5000.


E. Kép và vòng

Bài 21. Cài DSLK kép: thêm đầu/cuối, xóa đầu/cuối, xuất xuôi/ngược, hủy.

Bài 22. Từ DSLK đơn, xây DSLK kép cùng dữ liệu (duyệt đơn, ThemCuoiKep).

Bài 23. Cài vòng đơn với pTail: thêm đầu, thêm cuối, xuất, xóa giá trị.

Bài 24. Josephus nn, kk nhập từ bàn phím.

Bài 25. Playlist: vòng kép, lệnh next / prev / insert after current / delete current / lặp vô hạn in 20 bước.


F. Ứng dụng và cao cấp

Bài 26. Đa thức. Nút {double coef; int exp; NODE* pNext} sắp theo mũ giảm. Viết cộng và tính P(x0)P(x_0).

Bài 27. Đảo list (iterative). Kiểm tra bằng xuất xuôi.

Bài 28. Tìm nút giữa không đếm n. Tìm nút thứ kk từ cuối.

Bài 29. Viết CoChuTrinh. Tạo list có vòng (gán pTail->pNext = pHead->pNext) để test, nhớ cắt vòng trước khi HuyDanhSach.

Bài 30. Cho hai list số nguyên đã sort, in các giá trị giao (intersection) theo thứ tự tăng, mỗi giá trị một lần.

Bài 31. Kiểm tra list số nguyên có palindrome không.

Bài 32 (khó). Copy list có thêm con trỏ random (trỏ tới nút bất kỳ). Kết quả là list mới, random trỏ đúng nút tương ứng trên list mới. O(n)O(n) thời gian.


G. Câu hỏi tự luận / kiểm tra miệng

  1. Trình bày hình thức tổ chức tuần tự và liên kết. Vẽ sơ đồ.
  2. Viết mã giả thêm đầu, thêm cuối, xóa đầu DSLK đơn có pHead/pTail. Phân tích OO.
  3. Vì sao Merge Sort hợp DSLK hơn Quick Sort? Nêu thuật toán tìm giữa.
  4. So sánh DSLK đơn, kép, vòng: cấu trúc nút, thao tác đặc trưng, một ứng dụng mỗi loại.
  5. Liệt kê các lỗi bộ nhớ khi cài DSLK và cách tránh.

🎯 TÓM TẮT CHƯƠNG 3

Kiến thức cốt lõi

  1. Con trỏ + Heap là nền: new/delete, NULL, ->, không giải tham chiếu bừa, không leak.
  2. Cấp phát tuần tự (mảng): a[i] O(1)O(1), chèn giữa O(n)O(n).
  3. Cấp phát liên kết (DSLK): chèn/xóa tại chỗ O(1)O(1), truy cập thứ iiO(n)O(n).
  4. DSLK đơn: NODE + LIST(pHead, pTail). Thêm đầu/cuối O(1)O(1), xóa cuối O(n)O(n).
  5. Sắp xếp: đề thi dùng đổi chỗ trực tiếp trên data — hàm SapXep O(n2)O(n^2); thực tế trên DSLK nên Merge Sort O(nlogn)O(n \log n).
  6. DSLK kép: mỗi nút pPrev + pNext. Thêm trước / xóa nút đang cầm / xóa cuối O(1)O(1); duyệt hai chiều; tốn thêm 1 con trỏ/nút. Ứng dụng: Back/Forward, Undo/Redo, LRU, deque (mục 3.4.1).
  7. DSLK vòng: đuôi nắm đầu; chỉ giữ pTail (head = pTail->pNext). Duyệt do-while. Thêm đầu/cuối O(1)O(1), xóa cuối vòng đơn vẫn O(n)O(n). Round-robin, Josephus; vòng kép = ghép kép + vòng (mục 3.4.2).

Câu thần chú khi viết code

  • Nút mới: pNext = NULL trước khi nối (vòng 1 nút: pNext = p, tự trỏ mình).
  • Đổi liên kết: giữ địa chỉ phần còn lại trước khi ghi đè con trỏ.
  • Thêm/xóa: luôn xét rỗng / một nút / nút đầu / nút cuối.
  • pTail thì mọi nhánh thêm/xóa ảnh hưởng cuối phải cập nhật pTail.
  • Kép: mỗi mối nối sửa hai chiều — a->pNext = b kèm b->pPrev = a.
  • Vòng: không while (p != NULL); hủy phải cắt vòng (pTail->pNext = NULL) rồi mới delete.
  • Hết việc: HuyDanhSach.

Liên kết chương sau

  • Chương 4: Stack = thêm/xóa đầu DSLK; Queue = xóa đầu + thêm cuối.
  • Chương 5: Cây = nút có 2 (hoặc nhiều) con trỏ, không còn tuyến tính.
  • Đồ thị: danh sách kề là mảng các DSLK.

Hết chương 3. Làm bài tập 13–19 trước khi sang chương 4; bài 21–25 sau khi đọc 3.4 (kép và vòng); bài 20 và 26–32 là mức cao cấp.