TRUONG DAI HOC DA LAT

KHOA TOAN - TIN HOC
Y Z



TRUONG CHÍ TÍN






CAU TRUC DU LIEU VA GIAI THUAT 1
(Giaùo Trình)














-- Löu haønh noäi boä --
Y Ñaø Laït 2008 Z
LỜI MỞ ĐẦU

Giáo trình này nhằm cung cấp cho sinh viên các kiến thức căn bản về các
cấu trúc dữ liệu cơ sở có cấu trúc tuyến tính tĩnh, động (danh sách liên kết), cấu
trúc cây và các giải thuật cơ bản liên quan đến chúng như sắp xếp, tìm kiếm ở bộ
nhớ trong, cũng như so sánh độ phức tạp của các giải thuật này. Để có thể nắm bắt
các kiến thức trình bày học phần này, sinh viên cần nắm được các kiến thức về tin
học đại cương, nhập môn lập trình. Ngôn ngữ lập trình được chọn để minh họa các
kiến thức trên là C++. Các kiến thức này sẽ tạo điều kiện cho học viên tiếp tục dễ
dàng nắm bắt các kiến thức các học phần tin học về sau như: cấu trúc dữ liệu và
giải thuật nâng cao, phân tích và thiết kế giải thuật, đồ hoạ, hệ điều hành, trí tuệ
nhân tạo, ...

Nội dung giáo trình gồm 4 chương:

- Chương 1: Giới thiệu các khái niệm ban đầu về mối liên hệ mật thiết giữa
cấu trúc dữ liệu và giải thuật, kiểu dữ liệu, thiết kế và phân tích giải thuật, độ
phức tạp giải thuật, ...
- Chương 2: Giới thiệu các phương pháp cơ bản về tìm kiếm và sắp xếp trong
trên kiểu dữ liệu tuyến tính mảng. Thông qua đó, trình bày một số ý tưởng và kỹ
thuật cơ bản nhằm cải tiến các giải thuật.
- Chương 3: Trình bày kiểu dữ liệu con trỏ. Trên cơ sở đó, trình bày các kiểu
dữ liệu động tuyến tính và có nhiều ứng dụng trong tin học là các kiểu danh sách
liên kết khác nhau, ngăn xếp, hàng đợi, cũng như một số ứng dụng của chúng.
- Chương 4: Giới thiệu một loại cấu trúc dữ liệu động khác là cây và các thao
tác cơ bản trên cây nhị phân, cây nhị phân tìm kiếm, cây cân bằng AVL.

Nhằm mục đích dành thời gian nhiều hơn cho sinh viên để làm các bài tập
lớn, nên trong một số phần tác giả đã trình bày khá chi tiết các dạng cài đặt biến
thể khác nhau cho các giải thuật. Các phần thứ yếu hoặc khá phức tạp sẽ được in
cỡ chữ nhỏ dành cho sinh viên đọc thêm.

Chắn chắn rằng trong giáo trình sẽ còn nhiều khiếm khuyết, tác giả mong
muốn nhận được và rất biết ơn các ý kiến quí báu đóng góp của đồng nghiệp cũng
như bạn đọc để giáo trình này có thể hoàn thiện hơn nữa về mặt nội dung cũng
như hình thức trong lần tái bản sau.


Đà lạt, 04/2008
Tác giả

MỤC LỤC

Chương I. GIỚI THIỆU CẤU TRÚC DỮ LIỆU,
PHÂN TÍCH GIẢI THUẬT
Trang
I.1. Quan hệ giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu I.1
I.1.1. Biểu diễn dữ liệu I.1
I.1.2. Quan hệ giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu
I.1
I.1.3. Các bước chính để giải một bài toán trên máy tính I.2
I.2. Thiết kế và phân tích giải thuật I.4
I.2.1. Thiết kế giải thuật theo phương pháp Top-Down I.4
I.2.2. Các chiến lược khác để thiết kế giải thuật I.5
I.2.3. Phân tích giải thuật và độ phức tạp của giải thuật I.5
I.2.4. Qui ước về ngôn ngữ mã giả I.9


Chương II. TÌM KIẾM VÀ SẮP XẾP TRONG
II.1. Giới thiệu về sắp xếp và tìm kiếm II.1
II.1.1. Sắp xếp II.1
a. Định nghĩa sắp xếp II.1
b. Phân loại phương pháp sắp xếp II.1
c. Vài qui uớc về kiểu dữ liệu khi xét các giải thuật sắp xếp II.1
II.1.2. Tìm kiếm II.3
a. Định nghĩa phép tìm kiếm II.3
b. Phân loại các phương pháp tìm kiếm II.3
II.2. Phương pháp tìm kiếm trong II.3
II.2.1. Phương pháp tìm kiếm tuyến tính II.3
a. Dãy chưa được sắp II.3
b. Dãy đã được sắp II.5
II.2.2. Phương pháp tìm kiếm nhị phân II.6
II.3. Phương pháp sắp xếp trong
II.7
II.3.1. Phương pháp sắp xếp chọn đơn giản II.8
II.3.2. Phương pháp sắp xếp chèn đơn giản II.9
II.3.3. Phương pháp sắp xếp đổi chỗ đơn giản II.10
II.3.4. Phương pháp sắp xếp đổi chỗ cải tiến (Shake Sort) II.12
II.3.5. Phương pháp sắp xếp chèn cải tiến (Shell Sort) II.14
II.3.6. Phương pháp sắp xếp phân hoạch (Quick Sort) II.16
II.3.7. Phương pháp sắp xếp trên cây có thứ tự (HeapSort) II.19
II.3.8. Phương pháp sắp xếp trộn (Merge Sort) II.25
II.3.9. Phương pháp sắp xếp dựa trên cơ số (Radix Sort) II.28
II.3.10. So sánh các phương pháp sắp xếp trong II.31
Trang
Chương III. CẤU TRÚC DANH SÁCH LIÊN KẾT
III.1. Giới thiệu đối tượng dữ liệu con trỏ III.1
III.1.1. So sánh cấu trúc dữ liệu tĩnh và cấu trúc dữ liệu động III.1
III.1.2. Kiểu dữ liệu con trỏ III.1
a. Định nghĩa III.1
b. Khai báo III.2
c. Các thao tác trên kiểu dữ liệu con trỏ III.3
III.1.3. Biến động III.4
a. Đặc trưng của biến động III.4
b. Truy xuất biến động III.4
c. Hai thao tác cơ bản trên biến động III.5
III.2. Danh sách liên kết (DSLK) III.7
III.2.1. Định nghĩa danh sách III.7
III.2.2. Các cách tổ chức danh sách III.7
III.3. DSLK đơn III.8
III.3.1. Tổ chức DSLK đơn, các thao tác cơ bản, tìm kiếm và sắp xếp
trên kiểu DSLK đơn III.8
a. Tổ chức DSLK đơn (không có nút câm) III.8
b. Các thao tác cơ bản trên kiểu DSLK đơn III.9
c. Sắp xếp trên kiểu DSLK đơn: sắp xếp chèn, QuickSort,
MergeSort, RadixSort III.17
III.3.2. Vài ứng dụng của DSLK đơn III.24
III.3.2.1. Ngăn xếp: định nghĩa, cài đặt, các phép toán cơ bản
và ứng dụng của ngăn xếp III.24
III.3.2.2. Hàng đợi: định nghĩa, cài đặt, các phép toán cơ bản
và ứng dụng của hàng đợi III.31
III.4. Một số kiểu DSLK khác III.34
III.4.1. DSLK đơn có nút câm III.34
III.4.2. DSLK vòng III.37
III.4.3. DSLK đối xứng III.38
a. Cấu trúc dữ liệu biểu diễn DSLK đối xứng III.39
b. Các thao tác cơ bản trên kiểu DSLK đối xứng III.39
c. Ứng dụng của DSLK đối xứng: hàng đợi hai đầu III.47
III.4.4. DS đa liên kết III.48
III.4.5. Một số ứng dụng khác của DSLK III.51
a. DS có thứ tự và DS tổ chức lại III.51
b. Biểu diễn tập hợp bằng DSLK III.53
c. Biểu diễn đa thức rời rạc bằng DSLK III.54
d. Biểu diễn ma trận thưa nhờ DSLK III.56
e. Sắp xếp tôpô III.57

Trang
Chương IV. CẤU TRÚC CÂY
IV.1. Định nghĩa và các khái niệm cơ bản IV.1
IV.1.1. Định nghĩa cây IV.1
IV.1.2. Các khái niệm khác IV.1
IV.2. Cây nhị phân IV.3
IV.2.1. Định nghĩa IV.3
IV.2.2. Vài tính chất của cây nhị phân IV.3
IV.2.3. Biểu diễn cây nhị phân IV.3
IV.2.4. Duyệt cây nhị phân IV.4
IV.2.5. Một cách biểu diễn khác của cây nhị phân IV.7
IV.2.6. Biểu diễn cây n - phân bằng cây nhị phân IV.8
IV.2.7. Xây dựng cây nhị phân cân bằng hoàn toàn IV.8
IV.3. Cây nhị phân tìm kiếm IV.9
IV.3.1. Định nghĩa cây nhị phân tìm kiếm IV.9
IV.3.2. Tìm kiếm một phần tử trên cây BST IV.10
IV.3.3. Chèn một phần tử vào cây BST, xây dựng cây BST IV.11
IV.3.4. Phương pháp sắp xếp bằng cây BST IV.13
IV.3.5. Xóa một phần tử khỏi cây BST, hủy cây nhị phân IV.13
IV.4. Cây nhị phân tìm kiếm cân bằng IV.16
IV.4.1. Định nghĩa IV.17
IV.4.2. Chiều cao của cây cân bằng IV.17
IV.4.3. Chỉ số cân bằng và việc cân bằng lại cây AVL IV.18
IV.4.4. Chèn một phần tử vào cây AVL IV.24
IV.4.5. Xóa một phần tử khỏi cây AVL IV.25

Bài tập. BT.1
Bài tập chương I BT.1
Bài tập chương II BT.4
Bài tập chương III BT.6
Bài tập chương IV BT.11

Tài liệu tham khảo








Chương I
GIỚI THIỆU CẤU TRÚC DỮ LIỆU
VÀ PHÂN TÍCH GIẢI THUẬT


I.1. Quan hệ giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu

I.1.1. Biểu diễn dữ liệu
Một mục tiêu quan trọng của tin học là nhằm giải quyết tự động những bài
toán trong thế giới thực bằng máy tính điện tử. Các thông tin về bài toán cần giải
quyết trên máy tính luôn được mã hoá dưới dạng nhị phân. Các thông tin này gồm
dữ liệu và các thao tác trên các dữ liệu đó.

Việc biểu diễn dữ liệu ở dạng nhị phân rất bất tiện cho con người trong khi
xử lý các bài toán, đặc biệt là các bài toán lớn và phức tạp. Chính vì lý do đó, các
ngôn ngữ lập trình bậc cao đã cung cấp sẵn các cách biểu diễn dữ liệu trừu tượng
đơn giản và có cấu trúc, nhằm giúp người lập trình không phải mất nhiều thời
gian và công sức thực hiện thường xuyên lặp lại các thao tác sơ cấp nặng nề trên
các kiểu dữ liệu nhị phân ở mức thấp. Tính trừu tượng của dữ liệu thể hiện ở chỗ
nó không quá chú trọng đến những đặc điểm và ý nghĩa riêng của từng đối tượng
cụ thể mà chỉ rút ra và phản ánh những tính chất chung nhất mà các đối tượng
thuộc cùng một lớp có được.


I.1.2. Quan hệ giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu
Dựa vào bản chất chung của từng nhóm dữ liệu, các đối tượng dữ liệu được
phân thành các lớp. Mỗi lớp dữ liệu được thể hiện qua một kiểu dữ liệu. Một kiểu
dữ liệu T là một tập hợp nào đó, mỗi phần tử của tập được gọi là một thể hiện của
kiểu.

Ta đã biết giải thuật (hay giải thuật) là một dãy câu lệnh rõ ràng, xác định
một trình tự các thao tác trên một số đối tượng nào đó (input) sao cho sau một số
hữu hạn bước thực hiện (chú ý đến tính khả thi về thời gian), ta đạt được kết quả
(output) mong muốn. Giải thuật phản ánh các phép xử lý, còn đối tượng để xử lý
bởi giải thuật chính là dữ liệu: dữ liệu (input) đưa vào, dữ liệu trung gian và kết
qủa (output) cuối cùng.

Đối với bất kỳ một lớp dữ liệu nào, nếu để ý kỹ, ta thấy trên đó luôn tồn tại
những thao tác cơ bản mật thiết gắn liền với các đối tượng dữ liệu cùng kiểu đó.
Khi cách biểu diễn dữ liệu thay đổi thì các thao tác gắn liền với chúng cũng thay
đổi theo. Vì nếu không thì trong nhiều trường hợp việc xử lý sẽ gượng ép, thiếu tự
Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.2

nhiên, khó hiểu, phức tạp không cần thiết và chương trình kém hiệu quả, lãng phí
tài nguyên trên máy tính (CPU và bộ nhớ).

Chẳng hạn, đối với một chuỗi ký tự, ta có ít nhất hai cách biểu diễn chúng
như được thể hiện trong ngôn ngữ lập trình Pascal và C. Với mỗi cách biểu diễn,
ta sẽ có những cách xây dựng các thao tác tương ứng trên chúng khác nhau.

Một ví dụ khác, sẽ thấy rõ hơn trong các chương tiếp theo, đối với một dãy
các phần tử dữ liệu cùng loại, ta có thể lưu trữ chúng ít nhất bằng hai cách: lưu
bằng mảng (tĩnh, động) hay lưu trữ bằng danh sách liên kết động. Khi đó, các
thao tác cơ bản trên chúng như chèn, xóa, sắp xếp sẽ thực hiện theo những cách
thức khác nhau và do đó có hiệu quả khác nhau.

Do đó, khi nói đến một kiểu dữ liệu T, ta thường chú ý đến hai đặc trưng
quan trọng và liên hệ mật thiết với nhau:

- tập V các giá trị thuộc kiểu, đó là tập các giá trị hợp lệ mà đối tượng kiểu
T có thể nhận và lưu trữ;
- tập O các phép toán (hay thao tác xử lý) xác định có thể thực hiện trên các
đối tượng dữ liệu kiểu đó.
Người ta thường viết: T = <V, O>.

Trong một ngôn ngữ lập trình cấp cao cụ thể, người ta thường xây dựng sẵn
một số kiểu dữ liệu đơn giản hay sơ cấp xác định, chẳng hạn với C++, ta có các
kiểu dữ liệu: số (nguyên, thực), ký tự, lôgic. Với kiểu số nguyên, các phép toán
thường gặp là: các phép toán số học +, -, *, / (chia nguyên), % (mod, lấy phần dư)
và các phép toán so sánh như: ==, !=, ≥, ≤, >, <. Với kiểu số thực, các phép toán
thường gặp là: các phép toán số học +, -, *, /, và các phép toán so sánh như: ==,
!=, ≥, ≤, >, <. Với kiểu lôgic, các phép toán thường gặp là: ! (not), && (and), ||
(or). Với kiểu ký tự, các phép toán thường gặp là: phép toán ép kiểu và các phép
toán so sánh như: ==, !=, ≥, ≤, >, <, …

Dựa trên các kiểu đơn giản đã có và các phương pháp xác định của ngôn
ngữ lập trình qui định, ta có thể xây dựng nên các cấu trúc dữ liệu hay kiểu dữ
liệu có cấu trúc phức tạp hơn nhằm phản ánh tốt hơn các loại dữ liệu phong phú
và đa dạng trong thế giới thực. Chẳng hạn như: kiểu mảng, kiểu cấu trúc, kiểu
hợp, kiểu file, … Một trong những phép toán cơ bản trên các kiểu dữ liệu đó là:
truy cập đến từng phần tử hay từng thành phần của đối tượng dữ liệu.

I.1.3. Các bước chính để giải một bài toán trên máy tính
Để giải một bài toán trên máy tính, ta thường trải qua các giai đoạn chính
sau đây:
Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.3

- Đặt bài toán, phân tích, đặc tả và mô hình hoá bài toán
- Chọn cấu trúc dữ liệu để biểu diễn bài toán và phát triển giải thuật (chọn
kiểu dữ liệu)
- Mã hóa chương trình
- Thử nghiệm chương trình
- Bảo trì chương trình.
Hai giai đoạn đầu rất quan trọng, nó góp phần quyết định tính đúng đắn và
hiệu quả của chương trình nhằm giải bài toán.

Vai trò của kiểu dữ liệu trong việc giải một bài toán trên máy tính
Khi đề cập đến một thao tác, cần phải xác định nó tác động lên loại đối
tượng hay trên cấu trúc dữ liệu hoặc trong kiểu dữ liệu nào?
Với mỗi mô hình dữ liệu, có thể có nhiều cách cài đặt bởi các cấu trúc dữ
liệu khác nhau. Trong mỗi cách cài đặt, có thể có một số phép toán được thực hiện
thuận lợi, nhưng một số phép toán khác lại không thuận tiện. Khi đề cập đến một
thao tác, cần phải xác định rõ nó tác động trên loại đối tượng hoặc kiểu dữ liệu
nào? Khi cấu trúc dữ liệu thay đổi thì các giải thuật cơ bản tương ứng với nó cũng
thay đổi theo. Vì vậy việc chọn cấu trúc dữ liệu nào để biểu diễn mô hình sẽ phụ
thuộc vào từng ứng dụng cụ thể.

Để việc chọn cấu trúc dữ liệu biểu diễn bài toán một cách phù hợp, cần
chú ý đến những quan hệ giữa các đối tượng và thành phần dữ liệu với nhau;
ngoài ra, ta còn cần phải lưu ý đến những phép toán cơ bản nào sẽ được thực hiện
thường xuyên trên các đối tượng dữ liệu đó. Chẳng hạn, đối với một dãy các đối
tượng dữ liệu cùng loại, nếu số lượng các đối tượng này không quá lớn (để có thể
lưu ở bộ nhớ trong), biến động nhiều, hơn nữa các phép toán thêm và hủy các đối
tượng xảy ra rất thường xuyên thì ta nên chọn kiểu dữ liệu là danh sách liên kết
động hơn là kiểu mảng tĩnh để lưu trữ dãy đối tượng này.

Khi xây dựng các giải thuật nhằm giải quyết một bài toán, ta phải dựa trên
các yêu cầu cần xử lý để xem xét kỹ lưỡng, cũng như nên dựa trên các đặc trưng
của bài toán và tài nguyên (tốc độ xử lý và khả năng lưu trữ của hệ thống máy
tính) thực tế hiện có.

Tóm lại, khi xây dựng các kiểu dữ liệu nhằm giải quyết một bài toán cụ thể,
ta nên để ý các tiêu chuẩn sau:
- Phản ánh đúng thực tế: có dự trù đến khả năng biến đổi của dữ liệu trong
chu trình sống của nó. Đây là tiêu chuẩn rất quan trọng nhằm quyết định tính đúng
đắn của toàn bộ bài toán.
- Cấu trúc dữ liệu được xây dựng cần phù hợp với các thao tác trên đó (đặc
biệt là các thao tác được sử dụng nhiều nhất). Khi đó, việc phát triển các giải thuật
sẽ đơn giản, tự nhiên hơn và đạt hiệu quả cao về mặt tốc độ và bộ nhớ.
Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.4

- Tiết kiệm tài nguyên (tốc độ xử lý và dung lượng bộ nhớ): Đối với các
giải thuật không quá tầm thường, hai yêu cầu này thường mâu thuẫn nhau và khó
đạt được tối ưu đồng thời. Tùy theo yêu cầu của bài toán và tài nguyên thực tế, ta
nên chọn giải thuật cho phù hợp.


I.2. Thiết kế và phân tích giải thuật

I.2.1. Thiết kế giải thuật theo phương pháp Top-Down
Các bài toán giải được trên máy tính ngày càng đa dạng và phức tạp. Việc
xây dựng mô hình cùng với các giải thuật và cách cài đặt các chương trình giải
chúng ngày càng có quy mô lớn và phức tạp, thường đòi hỏi công sức đồng thời
của cả một tập thể các nhóm phân tích - thiết kế viên cũng như các thảo chương
viên. Mặt khác, việc thử nghiệm, sửa chữa, bổ sung, mở rộng, bảo trì các hệ
chương trình lớn chiếm tỷ lệ thời gian đáng kể so với tổng thời gian xây dựng hệ
chương trình.

Để chương trình trở nên dễ hiểu, dễ kiểm tra, dễ bảo trì và dễ mở rộng hơn,
đặc biệt là trong môi trường làm việc theo nhóm, người ta thường áp dụng chiến
thuật “chia để trị” bằng phương pháp thiết kế từ trên xuống (top-down design)
hay thiết kế từ khái quát đến chi tiết. Đó là cách phân tích bài toán, xuất phát từ
dữ kiện và các mục tiêu đặt ra nhằm đưa ra các công việc chủ yếu (theo cấu trúc
phân cấp, chưa vội sa đà vào tiểu tiết), rồi mới chia dần từng công việc lớn thành
các công việc (module) chi tiết hơn; nếu các module này vẫn còn phức tạp ta lại
chia tiếp chúng thành các module nhỏ hơn cho tới khi đạt đến các phần việc cơ
bản mà ta đã biết cách giải quyết. Việc giải bài toán lớn ban đầu qui về việc kết
hợp những lời giải của các bài toán con. Đó cũng là cơ sở của kỹ thuật lập trình có
cấu trúc.

Khi thiết kế từng module nên chú ý đến tính độc lập tương đối của chúng
đối với các module khác. Phương pháp thiết kế này hỗ trợ đắc lực trong việc lập
trình theo nhóm của công nghệ phần mềm. Khi đó, nhiều người có thể cùng chia
xẻ giải quyết các vấn đề lớn mà không cần quan tâm tới chi tiết phần việc của
người khác mà sau đó vẫn có thể nối kết các module nhỏ để cả bài toán lớn được
giải quyết. Quá trình này làm cho việc tìm hiểu cũng như sửa lỗi, bổ sung, mở
rộng chương trình trở nên dễ dàng và đơn giản hơn.

Việc phân tích và thiết kế bài toán lớn thành các bài toán con thường chiếm
thời gian lẫn công sức lớn hơn nhiều so với nhiệm vụ lập trình (coding).



Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.5

I.2.2. Các chiến lược khác để thiết kế giải thuật
Ngoài chiến lược chia để trị, người ta còn dùng các phương pháp thiết kế giải thuật sau:
phương pháp tham lam, phương pháp qui hoạch động, phương pháp quay lui, phương pháp nhánh
và cận.
Phương pháp tham lam thường dùng để tìm nghiệm tối ưu trong một tập nghiệm chấp
nhận được S nào đó được xây dựng theo một hàm chọn để bổ sung những phần tử vào S theo một
cách thích hợp.
Phương pháp qui hoạch động sử dụng kỹ thuật “đi từ dưới lên”: xuất phát từ nghiệm của
những bài toán con sơ cấp (được lưu giữ trong một bảng nhằm tránh mất công sức giải lại những
bài toán con này sẽ phát sinh khi cần giải những bài con lớn hơn sau này), ta xây dựng nghiệm
của những bài toán con lớn hơn và lưu tiếp vào bảng; cứ tiếp tục như vậy cho đến khi tìm được
nghiệm của bài toán lớn ban đầu từ bảng.
Phương pháp quay lui thường dùng để tìm một hoặc tất cả nghiệm của bài toán dưới dạng
một vectơ nghiệm có thể chưa biết trước độ dài của nó và có thể được xác định dần trong quá
trình giải. Đây là một kỹ thuật rất quan trọng trong việc thiết kế giải thuật.
Phương pháp nhánh và cận là một dạng cải tiến của phương pháp quay lui để tìm nghiệm
tối ưu của bài toán. Trong quá trình từng bước mở rộng nghiệm từng phần để đạt đến nghiệm tối
ưu của bài toán (dưới dạng vectơ), nếu biết các nghiệm mở rộng đều có hàm giá lớn hơn giá của
nghiệm tốt nhất ở thời điểm đó, thì ta không cần mở rộng nghiệm một phần theo nhánh này nữa
và quay lui sang tìm nghiệm trên nhánh khác có triển vọng hơn.
Các chiến lược này sẽ được nghiên cứu chi tiết trong các học phần tiếp theo.



I.2.3. Phân tích giải thuật và độ phức tạp của giải thuật

a. Các vấn đề cần lưu ý khi phân tích giải thuật
- Tính đúng đắn của giải thuật: cần trả lời câu hỏi liệu giải thuật có thể hiện
đúng lời giải của bài toán hay không? Thông thường người ta cài đặt giải thuật đó
trên máy tính và thử nghiệm nó với một số bộ dữ liệu mẫu nào đó rồi so sánh kết
quả thử nghiệm với kết quả được lấy từ những thông tin và phương pháp khác mà
ta đã biết chắc đúng. Nhưng cách thử này chỉ phát hiện được tính sai chứ chưa
thể bảo đảm được tính đúng của giải thuật. Để chứng minh được tính đúng đắn
của giải thuật nhiều khi đòi hỏi phải sử dụng các công cụ toán học khá phức tạp,
nhưng đây là một công việc không phải luôn luôn dễ dàng.
- Tính đơn giản của giải thuật: thể hiện qua tính dễ hiểu, tự nhiên, dễ lập
trình, dễ chỉnh lý. Thông thường các giải thuật quá đơn sơ chưa hẳn là cách tốt
nhất và nó thường gây tổn phí thời gian và bộ nhớ khi thực hiện. Nhưng trên thực
tế ta nên cân nhắc giữa tính đơn giản của giải thuật và thời gian lẫn công sức để
xây dựng các giải thuật tinh tế, hiệu quả hơn nhưng chỉ sử dụng quá ít lần với bộ
dữ liệu quá nhỏ với điều kiện thời gian hạn chế trong một môi trường lập trình
thực tế.
- Tốc độ thực hiện và dung lượng bộ nhớ cần chiếm dụng của giải thuật:
Thông thường hiếm khi cả hai yêu cầu tối ưu về thời gian và bộ nhớ được thỏa
mãn đồng thời. Các giải thuật không tầm thường nếu có tốc độ thực hiện cao thì
Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.6

thường chiếm bộ nhớ nhiều và ngược lại. Ở đây ta hạn chế chỉ xét yêu cầu về thời
gian thực hiện của giải thuật.


b. Độ phức tạp của giải thuật
• Thời gian thực hiện một giải thuật phụ thuộc vào khá nhiều yếu tố:
- Kích thước dữ liệu n đưa vào: ta gọi thời gian thực hiện của giải thuật
trên bộ dữ liệu này là một hàm của n : T(n)
- Các kiểu lệnh và tốc độ xử lý của máy tính, ngôn ngữ lập trình và chương
trình dịch ngôn ngữ ấy. Nhưng các loại yếu tố này phụ thuộc vào cách cài đặt và
loại máy tính trên đó giải thuật được cài đặt. Vì vậy khi xây dựng T(n) không nên
dựa vào chúng.
- Khi xây dựng hàm T(n) cho một giải thuật người ta thường chỉ xét các
thao tác đặc trưng cho giải thuật đó (thời gian thực hiện các thao tác này nhiều
hơn đáng kể so với thời gian thực hiện các loại thao tác khác). Chẳng hạn, khi xét
các giải thuật sắp xếp n mục dữ liệu với cấu trúc “lưu trữ trong” ta thường chú ý
tới số lần đổi chỗ và so sánh các mục dữ liệu theo một trường khoá nào đó.
- Tình trạng của dữ liệu: Thời gian thực hiện giải thuật không chỉ phụ
thuộc vào kích thước n của dữ liệu mà còn phụ thuộc vào chính tình trạng của dữ
liệu đó. Chẳng hạn, số các thao tác cơ bản để sắp xếp theo thứ tự tăng một dãy số
đưa vào đã có đúng thứ tự sẽ khác nhiều so với dãy chưa được sắp hay đã sắp
theo thứ tự ngược lại. Vì vậy, khi xét độ phức tạp T(n) của giải thuật ta thường xét
các trường hợp: thuận lợi nhất, xấu nhất và trung bình (thường khó xét vì trong
nhiều trường hợp đòi hỏi các công cụ toán học phức tạp).

Cách đánh giá thời gian thực hiện giải thuật độc lập với máy tính và chỉ
phụ thuộc vào bản thân giải thuật và dữ liệu như vậy sẽ dẫn tới khái niệm “độ
phức tạp của giải thuật” hay cấp độ lớn của thời gian thực hiện giải thuật.

• Gọi T(n) là độ phức tạp của một giải thuật, nếu tồn tại: một hàm g(n)
không âm, các hằng số dương C và n
0
sao cho:
T(n) ≤ C g(n) khi n ≥ n
0
(1)
Khi đó ta nói: T(n) có cấp g(n) và viết: T(n) = O(g(n)).

+ Lưu ý:
- Ta nên chọn cận trên g(n) có “cấp nhỏ nhất” thỏa mãn tính chất (1).
- T(n) có cấp g(n) nếu :
lim
) (
) (
n g
n T
= C > 0, n→∞


- Thông thường ta dùng các hàm sau để đánh giá độ phức tạp của giải thuật:
1 << log
2
n << n << n log
2
n << n
2
<< … << n
k
(k>= 2, độ phức tạp loại đa
thức) << (độ phức tạp loại mũ) 2
n
<< n! << n
n

Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.7

trong đó, ký hiệu : f(n) << g(n) có nghĩa là “f(n) nhỏ hơn g(n) rất nhiều” khi n
đủ lớn hay:
lim
) (
) (
n g
n f
= 0, n→∞



Bảng sau đây cho ta hình dung về độ tăng nhanh của các lớp giải thuật có
độ phức tạp đa thức và mũ theo số lượng n các mục dữ liệu đầu vào. Giả sử ta cài
đặt các giải thuật trên một máy tính với tốc độ xử lý 1 tỉ phép tính trong 1 giây
(s).

N Log
2
(n) (s) n (s) n*Log
2
(n) (s) n*n (s) 2
n
(năm) n! (năm) n
n
(năm)
10 3 e-09 1 e-08 3 e-08 1 e-07 3 e-14 1 e-10 3 e-07
50 6 e-09 5 e-08 3 e-07 3 e-06 4 e-02 1 e+48 3 e+68
100 7 e-09 1 e-07 7 e-07 1 e-05 4 e+13 3 e+141 3 e+183



c Một số quy tắc để xác định độ phức tạp của giải thuật
Giả sử T
1
(n) và T
2
(n) là thời gian thực hiện của hai đoạn chương trình P
1

và P
2
mà T
1
(n) = O(f(n)) và T
2
(n) = O(g(n)).

- Quy tắc tổng: Thời gian thực hiện liên tiếp P
1
và P
2
là: T
1
(n) + T
2
(n) =
O(max(f(n),g(n))).

Ví dụ: nếu f(n) ≤ g(n), ∀n ≥ n
0
thì O(f(n) + g(n)) = O(g(n))

- Quy tắc nhân: Thời gian thực hiện P
1
và P
2
lồng nhau là: T
1
(n) T
2
(n) =
O(f(n).g(n)).

Ví du: P
1
là một vòng lặp, P
2
là một thao tác trong P
1
.


d. Các bước phân tích giải thuật
- Xác định đặc trưng dữ liệu được dùng làm dữ liệu nhập và quyết định sự
phân tích nào là phù hợp.
- Xác định các thao tác cơ bản trừu tượng của giải thuật để tách biệt sự
phân tích với sự cài đặt.
- Phân tích về mặt toán học độ phức tạp của giải thuật trong các trường
hợp: tốt nhất, xấu nhất và trung bình. Để đánh giá độ phức tạp của giải thuật trong
trường hợp trung bình thường đòi hỏi những công cụ toán học khá tinh vi và khó;
vì vậy trong nhiều trường hợp, ta thường hạn chế trên những đánh giá ước lượng
chặn trên và tránh sa đà vào các tiểu tiết phức tạp.

Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.8

* Ví du: Xét giải thuật tìm xem một phần tử X có mặt trong một vector có
n phần tử V = {v
1
,v
2
, .., v
n
} cho trước hay không?

Boolean TìmKiếm(ptu X, ptu V[], int n)
Bước 1: Thấy = False;
Thứ = 1;
Bước 2: Trong khi (not(Thấy) and Thứ ≤ n)
{ if (v
Thứ
== X) Thấy = True;
else Thứ = Thứ + 1;
}
Bước 3: Trả về trị Thấy;

Phép toán cơ bản trong giải thuật tìm kiếm trên là phép so sánh khóa dữ
liệu v
Thứ
với X.

- Trường hợp tốt nhất xảy ra khi X bằng v
1
:
T
tốt
(n) = O(1).

- Trường hợp xấu nhất xảy ra khi X chỉ bằng v
n
hoặc không tìm thấy:
T
xấu
(n) = O(n).

- Trường hợp trung bình: Gọi q là xác suất để X rơi vào một phần tử nào đó
của V và giả sử X có phân bố đều trên n phần tử phân biệt của V thì xác suất để X
rơi vào phần tử v
i
là: p
i
= q/n; còn xác suất để X không rơi vào phần tử nào của
V sẽ là: 1 - q.
Độ phức tạp trung bình của giải thuật là:
T
tb
(n) =

=
n
i 1
p
i
.i + (1-q)n
T
tb
(n) = q

=
n
i 1
i/n + (1-q)n
= q(n+1)/2 + (1-q)n

= n(1-q/2) + q/2

Nếu q=1 (nghĩa là luôn tìm thấy X trong V) thì : T
tb
(n) = (n+1)/2
Nếu q=1/2 (nghĩa là khả năng tìm thấy và không tìm thấy X trong V bằng
nhau) thì : T
tb
(n) = (3n+1)/4
Nếu q= 0 (nghĩa là không tìm thấy X trong V) thì : T
tb
(n) = n
Tóm lại: T
tb
(n) = O(n).


I.2.4. Qui ước về ngôn ngữ mã giả
Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I.9

Để tiện cho việc thực hành cho học viên (trên ngôn ngữ lập trình C hay
C++), trong giáo trình sẽ sử dụng ngôn ngữ mã giả tựa ngôn ngữ C++ (thật ra nó
chỉ khác ngôn ngữ mã giả tựa Pascal không đáng kể) để mô tả cấu trúc dữ liệu và
các cấu trúc điều khiển trong các giải thuật.

- Lệnh ghép: dãy lệnh nằm giữa cặp dấu ngoặc kép { … }
- Cấu trúc điều khiển: “nếu (điều kiện đúng) thì thực hiện lệnh S”:
if (ĐiềuKiện) S;
hoặc:
if (ĐiềuKiện) S
1
;
else S
2
;
- Cấu trúc điều khiển nhiều chọn lựa:
switch (BiểuThứcVôHướng)
{ case Trị_1: S
1
; break;
case Trị_2: S
2
; break;

case Trị_n: S
n
; break;
[default : S;]
};
- Cấu trúc lặp:
for (LệnhKhởiĐầu; ĐiềuKiệnLặp; LệnhThayĐổiĐiềuKiệnLặp) S;
while (ĐiềuKiện) S;
do S while (ĐiềuKiện);
repeat S until (ĐiềuKiện);
- Phép gán: =
- Phép toán lôgic: && (and), || (or), ! (not) và trị lôgic kiểu boolean: True, False.
- Quan hệ so sánh: ==, !=, >, <, ≤, ≥
- Khai báo chương trình con viết dưới dạng hàm:
KiểuTrảVềCủaHàm TênHàm(KiểuThamTrị ThamTrị, KiểuThamChiếu &ThamChiếu)

Chương II
TÌM KIẾM VÀ SẮP XẾP TRONG


II.1. Giới thiệu về sắp xếp và tìm kiếm

II.1.1. Sắp xếp
a. Định nghĩa sắp xếp
Cho dãy X gồm n phần tử x
1
, x
2
,..., x
n
có cùng một kiểu dữ liệu T
0
. Sắp thứ
tự n phần tử này là một hoán vị các phần tử thành dãy x
k1
, x
k2
,..., x
kn
sao cho
với một hàm thứ tự f cho trước, ta có :
f(x
k1
) ∝ f(x
k2
) ∝ ... ∝ f(x
kn
).
trong đó: ∝ là một quan hệ thứ tự. Ta thường gặp ∝ là quan hệ thứ tự "≤"
thông thường.

b. Phân loại phương pháp sắp xếp
Dựa trên tiêu chuẩn lưu trữ dữ liệu ở bộ nhớ trong hay ngoài mà ta chia các
phương pháp sắp xếp thành hai loại:
* Sắp xếp trong: Với các phương pháp sắp xếp trong, toàn bộ dữ liệu được
đưa vào bộ nhớ trong (bộ nhớ chính). Đặc điểm của phương pháp sắp xếp trong là
khối lượng dữ liệu bị hạn chế nhưng bù lại, thời gian sắp xếp lại nhanh.
* Sắp xếp ngoài: Với các phương pháp sắp xếp ngoài, toàn bộ dữ liệu được
lưu ở bộ nhớ ngoài. Trong quá trình sắp xếp, chỉ một phần dữ liệu được đưa vào
bộ nhớ chính, phần còn lại nằm trên thiết bị trữ tin. Đặc điểm của loại sắp xếp
ngoài là khối lượng dữ liệu ít bị hạn chế, nhưng thời gian sắp xếp lại chậm (do
thời gian chuyển dữ liệu từ bộ nhớ phụ vào bộ nhớ chính để xử lý và kết quả xử
lý được đưa trở lại bộ nhớ phụ thường khá lớn).

c. Vài qui uớc về kiểu dữ liệu khi xét các thuật toán sắp xếp
Thông thường, T
0
có kiểu cấu trúc gồm m trường thành phần T
1
, T
2
, …, T
m
.
Hàm thứ tự f là một ánh xạ từ miền trị của kiểu T
0
vào miền trị của một số thành
phần {T
ik
}
1≤ ik ≤ p
, trên đó có một quan hệ thứ tự α.
Không mất tính tổng quát, ta có thể giả sử f là ánh xạ từ miền trị của T
0
vào
miền trị của một thành phần dữ liệu đặc biệt (mà ta gọi là khóa- key) , trên đó có
một quan hệ thứ tự α.
Khi đó, kiểu dữ liệu chung T
0
của các phần tử x
i
thường được cài đặt bởi
cấu trúc:
typedef struct { KeyType key;
DataType Data;
} ElementType;
Khi đó bài toán đưa về sắp xếp dãy {x
i
.key}
1≤i≤n
.
Tìm kieám vaø saép xeáp trong II.2


Để đơn giản trong trình bày, ta có thể giả sử T
0
chỉ gồm trường khóa, α là
quan hệ thứ tự ≤ thông thường và f là hàm đồng nhất và ta chỉ cần xét các
phương pháp sắp xếp tăng trên dãy đơn giản {x
i
}
1≤i≤n
. Trong chương này, khi xét
các phương pháp sắp xếp trong, dãy x thường được lưu trong mảng tĩnh như sau:

#define MAX_SIZE …
// Kích thước tối đa của mảng cần sắp theo thứ tự tăng
typedef .... ElementType; // Kiểu dữ liệu chung cho các phần tử của
mảng
typedef ElementType mang[MAX_SIZE] ; // Kiểu mảng
mang x;


Trong phần cài đặt các thuật toán sắp xếp sau này, ta thường sử dụng các
phép toán: đổi chỗ HoánVị(x,y), gán Gán(x,y), so sánh SoSánh(x,y) như sau:

void HoánVị(ElementType &x, ElementType &y)
{ ElementType tam;
Gán(tam, x);
Gán(x, y);
Gán(y, tam);
return ;
}

void Gán(ElementType &x, ElementType y)
{
// Gán y vào x, tùy từng kiểu dữ liệu mà ta có phép gán cho hợp lệ
return;
}

int SoSánh(ElementType x, ElementType y)
{
// Hàm trả về trị: 1 nếu x > y
// 0 nếu x == y
// -1 nếu x < y
// tùy theo kiểu ElementType mà ta dùng các quan hệ <, >, == cho hợp lệ
}


Tìm kieám vaø saép xeáp trong II.3

Khi đánh giá độ phức tạp của mỗi thuật toán sắp xếp, ta thường chỉ tính số
lần so sánh khóa (SS), số lần hoán vị khóa (HV) hoặc số lần Gán (G) trong thuật
toán đó.



II.1.2. Tìm kiếm
a. Định nghĩa tìm kiếm
Cho trước một phần tử Item và dãy X gồm n phần tử x
1
, x
2
,..., x
n
đều có
cùng kiểu T
0
. Bài toán tìm kiếm là xem Item có mặt trong dãy X hay không? (hay
tổng quát hơn: xem trong dãy X có phần tử nào thỏa mãn một tính chất TC cho
trước nào đó liên quan đến Item hay không?)

b. Phân loại các phương pháp tìm kiếm
Cũng tương tự như sắp xếp, ta cũng có 2 loại phương pháp tìm kiếm trong
và ngoài tùy theo dữ liệu được lưu trữ ở bộ nhớ trong hay ngoài.
Với từng nhóm phương pháp, ta lại phân biệt các phương pháp tìm kiếm
tùy theo dữ liệu ban đầu đã được sắp hay chưa. Chẳng hạn đối với trường hợp dữ
liệu đã được sắp và lưu ở bộ nhớ trong, ta có 2 phương pháp tìm kiếm: tuyến tính
hay nhị phân.
Khi cài đặt các thuật toán tìm kiếm, ta cũng có các qui ước tương tự cho
kiểu dữ liệu và các phép toán cơ bản trên kiểu đó như đối với các phương pháp
sắp xếp đã trình bày ở trên.
Trong chương này, ta chỉ hạn chế xét các phương pháp tìm kiếm và sắp xếp
trong.


II.2. Phương pháp tìm kiếm trong

Bài toán:
Input : - dãy X = {x
1
, x
2
,..., x
n
} gồm n mục dữ liệu
- Item: mục dữ liệu cần tìm cùng kiểu dữ liệu với các phần tử của
X
Output: Trả về:
- trị 0, nếu không thấy Item trong X
- vị trí đầu tiên i (1 ≤ i ≤ n) trong X sao cho x
i
≡ Item.

II.2.1. Phương pháp tìm kiếm tuyến tính
a. Dãy chưa được sắp
Đối với dãy bất kỳ chưa được sắp thứ tự, thuật toán tìm kiếm đơn giản nhất
là tìm tuần tự từ đầu đến cuối dãy.

Tìm kieám vaø saép xeáp trong II.4

• Thuật toán
int TìmTuyếnTính(x, n, Item)
- Bước 1: VịTrí = 1;
- Bước 2: if ((VịTrí ≤ n) and (x
VịTrí
!= Item))
{ VịTrí = VịTrí + 1;
Quay lại đầu bước 2;
}
else chuyển sang bước 3;
- Bước 3: if (VịTrí > n) VịTrí = 0; //không thấy
Trả về trị VịTrí;

• Cài đặt
int TìmTuyếnTính (mang x, int n, ElementType Item)
{ int VịTrí = 0;
while ((VịTrí < n) && (x[VịTrí] != Item))
VịTrí = VịTrí + 1 ;
if (VịTrí ≥ n) VịTrí = 0; //không thấy
else VịTrí++;
return(VịTrí);
}

* Chú ý: Để cài đặt thuật toán trên (cũng tương tự như thế với các thuật toán tiếp theo)
với danh sách tuyến tính nói chung thay cho cách cài đặt danh sách bằng mảng, ta chỉ cần thay
các câu lệnh hay biểu thức sau:
VịTrí = 1; VịTrí = VịTrí + 1; (VịTrí ≤ n) ; x
VịTrí
;
trong thuật toán tương ứng bởi:
ĐịaChỉ = ĐịaChỉ phần tử (dữ liệu) đầu tiên; ĐịaChỉ = ĐịaChỉ phần tử kế tiếp;
(ĐịaChỉ != ĐịaChỉ kết thúc); Dữ liệu của phần tử tại ĐịaChỉ;

* Độ phức tạp của thuật toán tìm kiếm tuyến tính (trên dãy chưa được sắp)
trong trường hợp:
- tốt nhất (khi Item ≡ x
1
): T
tốt
(n) = O(1)
- tồi nhất (khi không có Item trong dãy hoặc Item chỉ trùng với x
n
):
T
xấu
(n) = O(n)
- trung bình: T
tbình
(n) = O(n)

* Thuật toán tìm kiếm tuyến tính cải tiến bằng kỹ thuật lính canh
Để giảm bớt phép so sánh chỉ số trong biểu thức điều kiện của lệnh if hay
while trong thuật toán trên, ta dùng thêm một biến phụ đóng vai trò lính canh bên
phải (hay trái) x
n+1
= Item (hay x
0
= Item).

• Thuật toán
int TìmTuyếnTính_CóLínhCanh(x, n, Item)
Tìm kieám vaø saép xeáp trong II.5

- Bước 1: VịTrí = 1; x
n+1
= Item; // phần tử cầm canh
- Bước 2: if (x
VịTrí
!= Item)
{ VịTrí = VịTrí + 1;
Quay lại đầu bước 2;
}
else chuyển sang bước 3;
- Bước 3: if (VịTrí == n+1) VịTrí = 0; // thấy giả hay không thấy !
Trả về trị VịTrí;

• Cài đặt
int TìmTuyếnTính_CóLínhCanh(mang x, int n, ElementType Item)
{ int VịTrí = 0;
x[n] = Item; // phần tử cầm canh
while (x[VịTrí] != Item) VịTrí = VịTrí + 1;
if (VịTrí == n) VịTrí = 0; // thấy giả hay không thấy !
else VịTrí++;
return(VịTrí);
}

b. Dãy đã được sắp
Đối với dãy đã được sắp thứ tự (không mất tính tổng quát, ta có thể giả sử tăng
dần), ta có thể cải tiến thuật toán tìm kiếm tuyến tính có lính canh như sau: ta sẽ dừng
việc tìm kiếm khi tìm thấy hoặc tại thời điểm i đầu tiên gặp phần tử x
i
mà: x
i
≥ Item.
• Thuật toán
int TìmTuyếnTính_TrongMảngĐãSắp_CóLínhCanh(a, Item, n)
- Bước 1: VịTrí = 1; x
n+1
= Item; // phần tử cầm canh
- Bước 2: if (x
VịTrí
< Item)
{ VịTrí = VịTrí + 1;
Quay lại đầu bước 2;
}
else chuyển sang bước 3;
- Bước 3: if ((VịTrí == n+1) or (VịTrí < n+1 and x
VịTrí
> Item))
VịTrí = 0; // thấy giả hoặc không thấy !
Trả về trị VịTrí;

• Cài đặt
int TìmTuyếnTính_TrongMảngĐãSắp_CóLínhCanh (mang x, ElementType Item, int n)
{ int VịTrí = 0;
x[n] = Item; // phần tử cầm canh
while (x[VịTrí] < Item) VịTrí = VịTrí + 1;
if (VịTrí < n && (x[VịTrí] == Item)) VịTrí++;
else VịTrí = 0; // thấy giả hoặc không thấy !
return(VịTrí);
Tìm kieám vaø saép xeáp trong II.6

}

* Tuy có tốt hơn phương pháp tìm kiếm tuyến tính trong trường hợp mảng chưa
được sắp, nhưng trong trường hợp này thì độ phức tạp trung bình vẫn có cấp là n:
T
tbình
= O(n)
Đối với mảng đã được sắp, để giảm hẳn độ phức tạp trong trường hợp trung bình
và kể cả trường hợp xấu nhất, ta sử dụng ý tưởng “chia đôi” thể hiện qua phương pháp
tìm kiếm nhị phân sau đây.


II.2.2. Phương pháp tìm kiếm nhị phân.
Ý tưởng của phương pháp: Trước tiên, so sánh Item với phần tử đứng giữa
dãy x
giữa
, nếu thấy (Item = x
giữa
) thì dừng; ngược lại, nếu Item < x
giữa
thì ta sẽ tìm
Item trong dãy con trái: x
1
, …, x
giữa-1
, nếu không ta sẽ tìm Item trong dãy con
phải: x
giữa+1
, …, x
n
. Ta sẽ thể hiện ý tưởng trên thông qua thuật toán lặp sau đây.

• Thuật toán
int TìmNhịPhân(x, Item, n)
- Bước 1: ChỉSốĐầu = 1; ChỉSốCuối = n;
- Bước 2: if (ChỉSốĐầu <= ChỉSốCuối)
{ ChỉSốGiữa = (ChỉSốĐầu + ChỉSốCuối)/2; // lấy thương
nguyên
if (Item == x
ChỉSốGiữa
) Chuyển sang bước 3;
else { if (Item < x
ChỉSốGiữa
) ChỉSốCuối = ChỉSốGiữa -1;
else ChỉSốĐầu = ChỉSốGiữa +1;
Quay lại đầu bước 2; // Tìm tiếp trong nửa dãy con còn lại
}
}
- Bước 3: if (ChỉSốĐầu <= ChỉSốCuối) return (ChỉSốGiữa);
else return (0); // Không thấy

• Cài đặt
int TimNhiPhan(mang x, ElementType Item, int n)
{ int Đầu = 0, Cuối = n-1;
while (Đầu ≤ Cuối)
{ Giữa = (Đầu + Cuối)/2;
if (Item == x[Giữa]) break;
else if (Item < x[Giữa]) Cuối = Giữa -1
else Đầu = Giữa + 1;
}
if (Đầu ≤ Cuối) return (Giữa+1);
else return (0);
Tìm kieám vaø saép xeáp trong II.7

}

Dựa trên ý tưởng đệ qui của thuật toán, ta cũng có thể viết lại thuật toán
trên dưới dạng đệ qui, tất nhiên khi đó sẽ lãng phí bộ nhớ hơn ! Tại sao ? (xem
như bài tập).

• Độ phức tạp của thuật toán trong trường hợp trung bình và xấu nhất:
T
tbình
(n) = T
xấu
(n) = O(log
2
n)
Do đó đối với dãy được sắp, phương pháp tìm kiếm nhị phân sẽ hiệu quả
hơn nhiều so với phép tìm kiếm tuyến tính, đặc biệt khi n lớn.



II.3. Phương pháp sắp xếp trong

Có 3 nhóm chính các thuật toán sắp xếp trong (đơn giản và cải tiến):

* Phương pháp sắp xếp chọn (Selection Sort): Trong nhóm các phương
pháp này, tại mỗi bước, dùng các phép so sánh, ta chọn phần tử cực trị toàn cục
(nhỏ nhất hay lớn nhất) rồi đặt nó vào đúng vị trí mút tương ứng của dãy con còn
lại chưa sắp (phương pháp chọn trực tiếp). Trong quá trình chọn, có thể xáo trộn
các phần tử ở các khoảng cách xa nhau một cách hợp lý (sao cho những thông tin
đang tạo ra ớ bước hiện tại có thể có ích hơn cho các bước sau) thì sẽ được
phương pháp sắp chọn cải tiến HeapSort.
* Phương pháp sắp xếp đổi chỗ (Exchange Sort): Thay vì chọn trực tiếp
phần tử cực trị của các dãy con, trong phương pháp sắp xếp đổi chỗ, ở mỗi bước ta
dùng các phép hoán vị liên tiếp trên các cặp phần tử kề nhau không đúng thứ tự
để xuất hiện các phần tử này ở mút của các dãy con còn lại cần sắp (phương pháp
nổi bọt BubbleSort, ShakeSort). Nếu cũng sử dụng các phép hoán vị nhưng trên
các cặp phần tử không nhất thiết luôn ở kề nhau một cách hợp lý thì ta định vị
đúng được các phần tử (không nhất thiết phải luôn ở mép các dãy con cần sắp) và
sẽ thu được phương pháp QuickSort rất hiệu quả.
* Phương pháp sắp xếp chèn (Insertion Sort): Theo cách tiếp cận từ dưới
lên (Down-Top), trong phương pháp chèn trực tiếp, tại mỗi bước, xuất phát từ dãy
con liên tục đã được sắp, ta tìm vị trí thích hợp để chèn vào dãy con đó một phần
tử mới để thu được một dãy con mới dài hơn vẫn được sắp (phương pháp chèn
trực tiếp). Thay vì chọn các dãy con liên tục được sắp dài hơn, nếu ta chọn các
dãy con ở các vị trí cách xa nhau theo một qui luật khoảng cách giảm dần hợp lý
thì sẽ thu được phương pháp sắp chèn cải tiến ShellSort.


II.3.1. Phương pháp sắp xếp chọn đơn giản
Tìm kieám vaø saép xeáp trong II.8

a. Ý tưởng phương pháp
Với mỗi bước lặp thứ i (i = 1, ..., n-1) chọn trực tiếp phần tử nhỏ nhất x
min_i
trong từng
dãy con có thể chưa được sắp x
i
, x
i+1
, ..., x
n
và đổi chỗ phần tử x
min_i
với phần tử x
i
. Cuối
cùng, ta được dãy sắp thứ tự x
1
, x
2
, ..., x
n.


Ví dụ: Sắp xếp tăng dãy:
44, 55, 12, 42, 94, 18, 06, 67
Ở bước thứ 1 (i=1), tìm được x
min_1
= x
7
= 6, đổi chỗ, x
min_1
với x
1
:

44, 55, 12, 42, 94, 18, 06, 67
Kết qủa sau mỗi bước lặp:
i = 1 : 06 55 12 42 94 18 44 67
i = 2 : 06 12 55 42 94 18 44 67
i = 3 : 06 12 18 42 94 55 44 67
i = 4 : 06 12 18 42 94 55 44 67
i = 5 : 06 12 18 42 44 55 94 67
i = 6 : 06 12 18 42 44 55 94 67
i = 7 : 06 12 18 42 44 55 67 94

b. Thuật toán
SắpXếpChọn(x, n)
- Bước 1: i = 1;
- Bước 2: Tìm phần tử x
ChiSoMin
nhỏ nhất trong dãy x
i
, x
i+1
, ..., x
n

Hoán Vị x
i
và x
ChiSoMin
;
// Chuyển phần tử nhỏ nhất vào vị trí của x
i

-Bước 3: if (i < n)
{ i = i+1;
Quay lại đầu bước 2;
}
else Dừng;

c. Cài đặt
void SắpXếpChọn(mang x, int n)
{ int ChiSoMin;
for (int i = 0; i < n -1 ; i++)
{ ChiSoMin = i;
for (int j = i + 1; j < n; j++)
if (x[j] < x[ChiSoMin]) ChiSoMin = j;
if (ChiSoMin > i) HoánVị(x[i],x[ChiSoMin]);
}
return;
}

d. Độ phức tạp thuật toán
+ Do, trong mọi trường hợp, ở bước thứ i (∀i = 1, ..., n-1) luôn cần n-i phép so sánh
khóa nên:
SS
xấu
= SS
tốt
=


=
1
1
n
i
(n-i) =
2
) 1 ( − n n

Tìm kieám vaø saép xeáp trong II.9

+ Trong trường hợp xấu nhất (khi dãy đã được sắp theo thứ tự ngược lại), ở bước thứ i
ta phải đổi chỗ khóa 1 lần :
HV
xấu
=


=
1
1
n
i
1 = n -1
+ Trong trường hợp tốt nhất (khi dãy đã được sắp), ở bước thứ i ta không phải đổi chỗ
khóa lần nào:
HV
tốt
=


=
1
1
n
i
0 = 0
Tóm lại, độ phức tạp thuật toán:
T(n) = T
tốt
(n)

= T
xấu
(n) = O(n
2
).

II.3.2. Phương pháp sắp xếp chèn đơn giản

a. Ý tưởng phương pháp:
Giả sử dãy x
1
, x
2
, ..., x
i-1
đã được sắp thứ tự. Khi đó, tìm vị trí thích hợp để chèn x
i
vào
dãy x
1
, x
2
, ..., x
i-1
, sao cho dãy mới dài hơn một phần tử x
1
, x
2
, …, x
i-1
, x
i
vẫn được sắp thứ tự.
Thực hiện cách làm trên lần lượt với mỗi i = 2, 3, ..., n, ta sẽ thu được dãy có thứ tự.

Ví du : Sắp xếp dãy
67, 33, 21, 84, 49, 50, 75.

Kết qủa sau mỗi bước lặp:
i=2 33 67 21 84 49 50 75
i=3 21 33 67 84 49 50 75
i=4 21 33 67 84 49 50 75
i=5 21 33 49 67 84 50 75
i=6 21 33 49 50 67 84 75
i=7 21 33 49 50 67 75 84

b. Nội dung thuật toán
Để tăng tốc độ tìm kiếm (bằng cách giảm số biểu thức so sánh trong điều kiện lặp), ta
dùng thêm lính canh bên trái x
0
= x
i
trong việc tìm vị trí thích hợp để chèn x
i
vào dãy đã sắp
thứ tự x
1
, x
2
, ..., x
i-1
để được một dãy mới vẫn tăng x
1
, x
2
, ..., x
i-1
, x
i
, (với i = 2,..., n).
SắpXếpChèn(x, n)
- Bước 1: i = 2; // xuất phát từ dãy x
1
, x
2
, ..., x
i-1
đã được sắp
- Bước 2: x
0
= x
i
; // lưu x
i
vào x
0
- đóng vai trò lính canh trái
Tìm vị trí j thích hợp trong dãy x
1
, x
2
, ..., x
i-1
để chèn x
i
vào;
//vị trí j đầu tiên từ phải qua trái bắt đầu từ x
i-1
sao cho x
j
≤ x
0

-Bước 3: Dời chỗ các phần tử x
j+1
, ..., x
i-1
sang phải một vị trí;
if (j < i-1) x
j+1
= x
0
;
-Bước 4: if (i < n)
{ i = i+1;
Quay lại đầu bước 2;
}
else Dừng;

c. Cài đặt thuật toán
Tìm kieám vaø saép xeáp trong II.10

Áp dụng một mẹo nhỏ, có thể áp dụng (một cách máy móc !) ý tưởng trên để cài đặt thuật
toán trong C (bài tập). Lưu ý rằng trong C hay C++, với n phần tử của mảng x[i], i được đánh số
bắt đầu từ 0 tới n -1; do đó, để cài đặt thuật toán này, thay cho lính canh trái như trình bày ở trên,
ta sẽ dùng lính canh bên phải x
n+1
(≡ x[n]) và chèn x
i
thích hợp vào dãy đã sắp tăng x
i+1
, ..., x
n
để
được một dãy mới vẫn tăng x
i
, x
i+1
, ..., x
n
, với mọi i = n-1, ..., 1.
void SắpXếpChèn(mang x, int n)
{
for ( int i = n -2 ; i >= 0 ; i--)
{ x[n] = x[i]; // lính canh phải
j = i+1;
while (x[ j ] < x[n])
{ x[ j-1] = x[ j ]; // dời x[ j] qua trái một vị trí
j++;
}
if (j > i+1) x[ j-1] = x[n];
}
return ;
}
Có thể cải tiến việc tìm vị trí thích hợp để chèn x
i
bằng phép tìm nhị phân (bài tập).

d. Độ phức tạp của thuật toán
+ Trường hợp tồi nhất xảy ra khi dãy có thứ tự ngược lại: để chèn x
i
cần i lần so sánh
khóa với x
i-1
, ..., x
1
, x
0
.
SS
xấu
=

=
n
i
i
2
=
2
) 1 ( + n n
-1
HV
xấu
=

=
+
n
i
i
2
3 / ) 1 ( =
6
) 3 ( + n n
-
3
2

+ Trong trường hợp tốt nhất (khi dãy đã được sắp):
HV
tốt
=

=
n
i 2
3 / 1 = (n -1)/3
SS
tốt
=

=
n
i 2
1 = n -1
Tóm lại, độ phức tạp thuật toán:
T
tốt
(n) = O(n).
T
xấu
(n) = O(n
2
).



II.3.3. Phương pháp sắp xếp đổi chỗ đơn giản
(phương pháp nổi bọt hay Bubble Sort)

a. Ý tưởng phương pháp:
Duyệt dãy x
1
, x
2
, ..., x
n
. Nếu x
i
> x
i+1
thì hoán vị hai phần tử kề nhau x
i
và x
i+1
. Lặp lại
quá trình duyệt (các phần tử “nặng” - hay lớn hơn - sẽ “chìm xuống dưới” hay chuyển dần về
cuối dãy) cho đến khi không còn xảy ra việc hoán vị hai phần tử nào nữa.

Ví dụ: Sắp xếp tăng dãy :
Tìm kieám vaø saép xeáp trong II.11

44, 55, 12, 42, 94, 18, 06, 67

Viết lại dãy dưới dạng cột, ta có bảng chứa các kết quả sau mỗi bước lặp:

Bước lặp 0 1 2 3 4 5 6

44 44 12 12 12 12 06
55 12 42 42 18 06 12
12 42 44 18 06 18 18
42 55 18 06 42 42 42
94 18 06 44 44 44 44
18 06 55 55 55 55 55
06 67 67 67 67 67 67
67 94 94 94 94 94 94



b. Nội dung thuật toán
Để giảm số lần so sánh thừa trong những trường hợp dãy đã gần được sắp trong phương
pháp nổi bọt nguyên thủy, ta lưu lại:
- VịTríCuối: là vị trí của phần tử cuối cùng xảy ra hoán vị ở lần duyệt hiện thời
- SốCặp = VịTríCuối -1 là số cặp phần tử cần được so sánh ở lần duyệt sắp tới.

BubbleSort(x, n)
- Bước 1: SốCặp = n -1;
- Bước 2: Trong khi (SốCặp ≥ 1) thực hiện:
{ VịTríCuối = 1;
i = 1;
Trong khi (i < SốCặp) thực hiện:
{ if (x
i
> x
i+1
)
{ Hoán vị x
i
và x
i+1
;
VịTríCuối = i;
}
i = i +1;
}
SốCặp = VịTríCuối -1;
}

c. Cài đặt thuật toán
void BubbleSort(mang x, int n)
{ int ChỉSốCuối, SốCặp = n -1;
while (SốCặp > 0)
{ ChỉSốCuối = 0;
for (int i = 0; i< SốCặp; i++)
if (x[i] > x[i+1])
{ HoánVị(x[i], x[i+1]);
ChỉSốCuối = i;
}
SốCặp = ChỉSốCuối;
}
Tìm kieám vaø saép xeáp trong II.12

return ;
}

d. Độ phức tạp của thuật toán nổi bọt
+ Trong trường hợp tồi nhất (dãy có thứ tự ngược lại), ta tính được:
HV
xấu
= SS
xấu
=


=
1
1
n
i
(n-i) =
2
) 1 ( − n n

+ Trong trường hợp tốt nhất (dãy đã được sắp):
HV
tốt
=


=
1
1
n
i
0 = 0
SS
tốt
= n -1
Tóm lại, độ phức tạp thuật toán:
T
tốt
(n) = O(n).
T
xấu
(n) = O(n
2
).


II.3.4. Phương pháp sắp xếp đổi chỗ cải tiến (ShakerSort)

a. Ý tưởng phương pháp:
Phương pháp sắp xếp nổi bọt có nhược điểm là: các phần tử có trị lớn được
tìm và đặt đúng vị trí nhanh hơn các phần tử có trị bé. Phương pháp ShakerSort
khắc phục nhược điểm trên bằng cách duyệt 2 lượt từ hai phía để đẩy các phần tử
nhỏ (lớn) về đầu (cuối) dãy; với mỗi lượt, lưu lại vị trí hoán vị cuối cùng xảy ra,
nhằm ghi lại các đoạn con cần sắp xếp và tránh các phép so sánh thừa ngoài đoạn
con đó.

Ví dụ: Sắp xếp tăng dãy :
44, 55, 12, 42, 94, 18, 06, 67
Viết lại dãy dưới dạng cột, ta có bảng chứa các kết quả sau mỗi bước lặp:
(L,R) = (1,8) (2,7) (3,4) (4,4)
Bước 0 1 2 3

44 06 06 06
55 44 12 12
12 12 18 18
42 42 42 42
94 55 44 44
18 18 55 55
06 67 67 67
67 94 94 94

b. Nội dung thuật toán
ShakerSort(x, n)
Tìm kieám vaø saép xeáp trong II.13

- Bước 1: L = 1; R = n;
- Bước 2:
* Bước 2a: // Duyệt từ dưới lên để đẩy phần tử nhỏ về đầu dãy: L
j = R; ChỉSốLưu = R;
Trong khi (j > L) thực hiện:
{ if (x
j
< x
j-1
)
{ Hoán vị x
j
và x
j-1
;
ChỉSốLưu = j;
}
j = j -1;
}
L = ChỉSốLưu; // Không xét các phần tử đã sắp ở đầu dãy
* Bước 2b:// Duyệt từ trên xuống để đẩy phần tử lớn về cuối dãy: R
j = L; ChỉSốLưu = L;
Trong khi (j < R) thực hiện:
{ if (x
j
> x
j+1
)
{ Hoán vị x
j
và x
j+1
;
ChỉSốLưu = j;
}
j = j +1;
}
R = ChỉSốLưu; // Không xét các phần tử đã sắp ở cuối
dãy
- Bước 3: if (L < R) Quay lại bước 2;
else Dừng.

c. Cài đặt thuật toán
void ShakerSort(mang x, int n)
{ int ChỉSốLưu, j, L = 0, R = n-1;
do
{// Duyệt từ dưới lên để đẩy phần tử nhỏ về đầu dãy: L
ChỉSốLưu = R;
for (j = R; j > L; j--)
{ if (x[ j ] < x[ j -1])
{ HoánVị(x[ j ], x[ j -1]);
ChỉSốLưu = j;
}
}
L = ChỉSốLưu; // không xét các phần tử đã sắp ở đầu dãy
// Duyệt từ trên xuống để đẩy phần tử lớn về cuối dãy: R
ChỉSốLưu = L;
for (j = L; j < R; j++)
Tìm kieám vaø saép xeáp trong II.14

{ if (x[ j ] > x[ j +1])
{ HoánVị(x[ j ], x[ j +1]);
ChỉSốLưu = j;
}
}
R = ChỉSốLưu; // không xét các phần tử đã sắp ở cuối dãy
} while (L < R);
return ;
}

d. Độ phức tạp của thuật toán
+ Trong trường hợp tồi nhất (dãy có thứ tự ngược lại), ta tính được:
HV
xấu
= SS
xấu
=

=
2 /
1
n
i
(n-i) =
8
) 2 3 ( − n n

+ Trong trường hợp tốt nhất (dãy đã được sắp):
HV
tốt
=


=
1
1
n
i
0 = 0
SS
tốt
= (n -1)
Tóm lại, độ phức tạp thuật toán:
T
tốt
(n) = O(n).
T
xấu
(n) = O(n
2
).

Phương pháp ShakerSort tuy có tốt hơn Bubble Sort, nhưng độ phức tạp
được cải tiến không đáng kể. Lý do là hai phương pháp này chỉ mới đổi chỗ các
cặp phần tử liên tiếp không đúng thứ tự. Nếu các cặp phần tử không đúng thứ tự
ở xa nhau hơn được đổi chỗ thì độ phức tạp có thể được cải tiến đáng kể như ta sẽ
thấy trong phương pháp QuickSort sẽ được trình bày ở phần sau.


II.3.5. Phương pháp sắp xếp chèn cải tiến (ShellSort)
a. Ý tưởng phương pháp
Một cải tiến của phương pháp chèn trực tiếp là ShellSort. Ý tưởng của
phương pháp này là phân chia dãy ban đầu thành những dãy con gồm các phần tử
ở cách nhau h vị trí. Tiến hành sắp xếp từng dãy con này theo phương pháp chèn
trực tiếp. Sau đó giảm khoảng cách h và tiếp tục quá trình trên cho đến khi h = 1.
Ta có thể chọn dãy giảm độ dài {h
j
}
1≤ j ≤ k
thỏa h
k
= 1 từ hệ thức đệ qui:
h
j -1
= 2* h
j
+ 1, ∀j: 2≤ j ≤ k = ⎣ log
2
n ⎦ -1, j=2..k (1)
hoặc:
h
j -1
= 3* h
j
+ 1, ∀j: 2≤ j ≤ k = ⎣ log
3
n ⎦ -1, j=2..k (2)

b. Nội dung thuật toán
Tìm kieám vaø saép xeáp trong II.15

ShellSort(x, n)
- Bước 1: Chọn k và dãy h
1
, h
2
, …, h
k
= 1; j = 1;
- Bước 2: Phân dãy ban đầu thành các dãy con cách nhau h
j
khoảng
cách. Sắp mỗi dãy con bằng phương pháp chèn trực tiếp.
- Bước 3: j = j +1;
if (j ≤ k) Quay lại bước 2;
else Dừng;

* Ví dụ: Sắp tăng dãy:

6 2 8 5 1 12 4 15

Xét dãy bước: h[1]=3, h[2]= 1 (k=2).

Với h[1] = 3, sắp các dãy con có độ dài 3 bằng phương pháp chèn trực tiếp, ta
được:
4 1 8 5 2 12 6 15

Với h[2] = 1, sắp các dãy con có độ dài 1 bằng phương pháp chèn trực tiếp như
thông thường, ta được:
1 2 4 5 6 8 12 15

c. Cài đặt thuật toán
void ShellSort(mang x, int n)
{ int i, j, k, h[MAX_BUOC_CHIA], len;
ElemenetType tam;
TaoDayBuocChia(n,k,h); // Xác định k và dãy h
1
, h
2
, …, h
k
= 1;
for (int step = 0; step < k; step++)
{ len = h[step];
for (i = len; i < n; i++)
{ tam = x[i];
j = i - len; // x[ j ] là phần tử đứng kề trước x[i] trong cùng dãy con
// sắp xếp dãy con chứa trị x[i] = tam bằng phương pháp chèn trực tiếp
while (j >= 0 && tam < x[ j ])
{ x[ j + len] = x[j];
j = j - len;
}
x[ j + len] = tam;
}
}
return;
}
Tìm kieám vaø saép xeáp trong II.16


d. Độ phức tạp của thuật toán
Người ta chứng minh được rằng, nếu chọn dãy bước chia{h
j
} theo (1) thì
thuật toán ShellSort có độ phức tạp cỡ: n
1,2
<< n
2
.


II.3.6. Phương pháp sắp xếp phân hoạch (QuickSort)
Phương pháp Quick Sort (hay sắp xếp kiểu phân đoạn) là một cải tiến của
phương pháp sắp xếp kiểu đổi chỗ, do C.A.R. Hoare đề nghị, dựa vào cách hoán vị
các cặp phần tử không đúng thứ tự có thể ở những vị trí xa nhau.

a. Ý tưởng phương pháp:
Chọn một phần tử bất kỳ (ta thường chọn phần tử giữa) g của dãy làm mốc.
Sau đó thực hiện phân hoạch dãy thành 2 dãy con: dãy con trái gồm những phần
tử có giá trị không lớn hơn g và dãy con phải gồm những phần tử có giá trị không
nhỏ hơn g (bằng cách duyệt dãy từ bên trái cho đến khi có một phần tử x
i
≥ g,
sau đó duyệt dãy từ bên phải cho đến khi có một phần tử x
j
≤ g. Đổi chỗ x
i

x
j
. Tiếp tục quá trình duyệt và đổi chỗ cho tới khi hai phía vượt qua nhau: i > j).
Sau khi phân hoạch, ta tách dãy thành 3 phần:
x
k
≤ g với mọi k = 1, ..., j (Dãy con trái hay dãy con thấp);
x
m
≥ g với mọi m = i, ..., n (Dãy con phải hay dãy con cao);
x
p
= g với mọi p = j+1, ..., i-1, nếu i-1 ≥ j+1.

Vì thế phương pháp này còn gọi là phương pháp sắp xếp bằng phân hoạch.
Khi đó, nếu i-1 ≥ j+1 thì các phần tử x
j+1
, ..., x
i-1
được định vị đúng:
x
k
x
m

x
p
=g

Với từng dãy con trái và phải (có độ dài lớn hơn 1) ta lại phân hoạch (đệ
qui) chúng tương tự như trên.

Ví dụ: Xét dãy
44 55 12 42 94 18 06 67
Sau 2 lần đổi chỗ, phân hoạch dãy trên thành
06 18 12 42 94 55 44 67

Dãy con thấp Dãy con cao
Đúng vị trí

Kết quả phân hoạch qua từng bước đệ qui:
L=1, R=8, x
4
=42; j=3, i=5:
44 55 12 42 94 18 06 67
Tìm kieám vaø saép xeáp trong II.17


06 18 12 94 55 44 67

L=1, R=3, x
2
= 18; j= 2, i=3:

06 12

L=1, R=2, x
1
= 6; j= 0, i=2:

12

L=5, R=8, x
6
=55; j=5, i=7:

44 94 67

L=7, R=8, x
6
=94; j=7, i=8:

67


Cuối cùng, kết hợp các kết quả đệ qui, ta có dãy được sắp:
06 12 18 42 44 55 67 94

b. Nội dung thuật toán sắp xếp nhanh dãy: x
L
, x
L+1
, ..., x
R

SắpXếpNhanh(x, L, R)
- Bước 1: Phân hoạch dãy x
L
, ..., x
R
thành các dãy con:
- dãy con thấp: x
L
, ..., x
j
≤ g

- dãy con giữa: x
j+1
= ... = x
i-1
= g, nếu i-1 ≥ j+1

- dãy con thấp: x
i
, ..., x
R
≥ g

- Bước 2: if (L < j) phân hoạch dãy x
L
, ..., x
j

if (i < R) phân hoạch dãy x
i
, ..., x
R


Nội dung thuật toán phân hoạch dãy: x
L
, x
L+1
, ..., x
R
thành các dãy con
PhânHoạch(x, L, R)
- Bước 1: Chọn tùy ý một phần tử g = x
k
;(L ≤ k ≤ R, thường chọn k =
(L+R)/2)); i = L; j = R;
- Bước 2: Tìm và hoán vị các cặp phần tử x
i
và x
j
đặt sai vị trí:
- Trong khi (x
i
< g) i = i + 1;
- Trong khi (x
j
> g) j = j -1;
- if (i ≤ j)
{ Hoán vị x
i
và x
j
;
i = i + 1; j = j -1;
}
4
1
0
5
9
Tìm kieám vaø saép xeáp trong II.18

- Bước 3: if (i ≤ j) Quay lên bước 2;
else Dừng;

c. Cài đặt thuật toán
void PhânHoạch(mang x, int L, int R)
// L, r : lần lượt là chỉ số trái và phải của dãy con của mảng x cần phân hoạch
{ int i = L; j = R;
ElementType giua = x[(L+R)/2]; // Chọn phần tử “giữa” làm mốc
do
{ while (giua>x[i]) i = i+1;
while (giua<x[j]) j = j-1;
if (i <= j)
{ HoánVị(x[i],x[j]);
i++ ; j-- ;
}
} while (i <= j);
if (L < j) PhânHoạch(x, L, j);
if (R > i) PhânHoạch(x, i, R);
return;
}

void SắpXếpNhanh (mang x, int n)
{ PhânHoạch(x, 0, n-1);
return;
}

d. Độ phức tạp của thuật toán
Người ta chứng minh được rằng:
+ Trong trường hợp xấu nhất (khi phân hoạch mọi dãy thành hai dãy con,
luôn có một dãy con có độ dài không, chẳng hạn, chọn g = x
L
và dãy ban đầu
được sắp theo thứ tự ngược lại):
T
xấu
(n) = O(n
2
)
nghĩa là, sắp xếp nhanh (QuickSort) không hơn gì các phương pháp sắp xếp trực
tiếp đơn giản, nhưng trường hợp này hiếm khi xảy ra: để tránh tình trạng này, ta
thường chọn g= x
giữa
.
+ Trong trường hợp tốt nhất: sau mỗi phân hoạch, ta đều chọn đúng mốc là
phần tử median cho dãy con (phần tử có trị nằm giữa dãy). Khi đó, ta sẽ cần
log
2
(n) lần phân hoạch thì sắp xếp xong. Độ phức tạp trong mỗi lần phân hoạch là
O(n). Vậy: T
tốt
(n) = O(nlog
2
n)
+ Trong trường hợp trung bình thì :
T
tbình
(n) = O(nlog
2
n)
Tìm kieám vaø saép xeáp trong II.19

QuickSort là phương pháp sắp xếp trong trên mảng rất hiệu quả được biết
cho đến nay.


II.3.7. Phương pháp sắp xếp trên cây có thứ tự (HeapSort)
Với phương pháp sắp xếp Quick Sort, thời gian thực hiện trung bình khá
tốt, nhưng trong trường hợp xấu nhất nó vẫn là O(n
2
). Phương pháp HeapSort mà
ta sẽ xét sau đây có độ phức tạp trong trường hợp xấu nhất là O(nlog
2
n).
Nhược điểm của phương pháp chọn trực tiếp là ở lần chọn hiện thời không
tận dụng được kết quả so sánh và hoán vị của các lần chọn trước đó. Phương
pháp dựa trên khối HeapSort khắc phục được nhược điểm này bằng cách đưa dãy
cần sắp vào cây nhị phân có thứ tự (hay Heap) và chúng được lưu trữ kế tiếp
bằng mảng.

a. Định nghĩa và tính chất của khối (Heap)
Định nghĩa: Dãy x
m
, ..., x
n
là một Heap nếu :
x
k
≥ x
2k
,
x
k
≥ x
2k+1
,
với mọi k mà : m ≤ k < 2k < 2k+1 ≤ n.
Tính chất:
- Nếu dãy x
1
, ..., x
n
có thứ tự thì nó là một Heap. Chú ý điều ngược lại
chưa chắc đúng, nghĩa là: nếu dãy x
1
, ..., x
n
là một Heap thì chưa chắc dãy đã có
thứ tự.
- Nếu dãy x
1
, ..., x
n
là một Heap thì x
1
là phần tử lớn nhất trong dãy và
nếu bỏ đi một số phần tử liên tiếp ở hai đầu của dãy thì nó vẫn là một Heap.
- Với dãy bất kỳ x
1
, ..., x
n
thì dãy x
[n/2]+1
, ..., x
n
(nửa đuôi dãy) là một
Heap.
- Nếu dãy x
1
, ..., x
n
là một Heap thì ta có thể biểu diễn “liên tiếp” những
phần tử của dãy này lên một cây nhị phân có tính chất: con trái (nếu có) của x
i

x
2i
≤ x
i
và con phải (nếu có) của x
i
là x
2i+1
≤ x
i
.

x1

x2 x3


x4 x5 x6 x7



b. Ý tưởng phương pháp:
Nếu biểu diễn một Heap x
1
, ..., x
n
lên cây nhị phân có thứ tự, ta sẽ thu được
dãy có thứ tự bằng cách :
Tìm kieám vaø saép xeáp trong II.20

- Hoán vị nút gốc x
1
(lớn nhất) với nút cuối x
n

- Khi đó x
2
, ..., x
n-1
vẫn là một heap. Bổ sung x
1
vào heap cũ x
2
, ..., x
n-1
để
được heap mới dài hơn x
1
, ..., x
n-1
.
Lặp lại quá trình trên cho đến khi cây chỉ còn một nút.

Ví dụ: Sắp xếp dãy số
44 55 12 42 94 18 06 67

Giả sử tồn tại thủ tục để tạo một Heap đầy đủ ban đầu từ dãy trên :
94 67 18 44 55 12 06 42

Cây nhị phân biểu diễn Heap ban đầu

94

67 18


44 55 12 06


42
Hoán vị nút 94 với nút 42 và bổ sung 42 vào heap cũ: 67, 18, 44, 55, 12,
06 để được heap mới dài hơn: 67, 55, 18, 44, 42, 12, 06. Để ý rằng, ta chỉ xáo
trộn không quá một nhánh (nhánh trái có gốc là 67) với gốc (42) của cây cũ.


1 42

67 18
2

44 55 12 06


94


67

55 18


44 42 12 06

Tìm kieám vaø saép xeáp trong II.21


94

Tiếp tục quá trình trên cho đến khi dãy chỉ còn một phần tử thì ta sẽ được
dãy tăng:
06 12 18 42 44 55 67 94

c. Nội dung thuật toán HeapSort
• Giai đoạn 1: Từ Heap ban đầu: x
[n/2]+1
, ..., x
n
, tạo Heap đầy đủ ban đầu
• Giai đoạn 2: Sắp xếp dãy dựa trên Heap:
- Bước 1: r = n;
- Bước 2: Đưa phần tử lớn nhất về cuối dãy đang xét: Hoán vị x
1
và x
r

- Bước 3: . Loại phần tử lớn nhất ra khỏi Heap: r = r –1;
. Bổ sung x
1
vào heap cũ: x
2
, ..., x
r
để được heap mới dài
hơn: x
1
, ..., x
r
. // dùng thủ tục Shift(x, 1, r)
- Bước 4: if (r > 1) Quay lên bước 2
else Dừng //Heap chỉ còn một phần tử

* Nội dung thuật toán Shift: Bổ sung x
L
vào heap cũ: x
L+1
, ..., x
r
để được
heap mới dài hơn: x
L
, ..., x
r
.
Shift (x, L, R)
- Bước 1: ChỉSốCha = L; ChỉSốCon = 2* ChỉSốCha; Cha = x
ChỉSốCha
;
LàHeap = False;
- Bước 2: Trong khi (Chưa LàHeap and ChỉSốCon ≤ R) thực hiện:
{ if (ChỉSốCon < R) // nếu Cha có con phải, tìm con lớn nhất
if (x
ChỉSốCon
< x
ChỉSốCon+1
) ChỉSốCon = ChỉSốCon +1;
if (x
ChỉSốCon
≤ Cha) LàHeap = True;
else { x
ChỉSốCha
= x
ChỉSốCon
; // đưa nút con lớn hơn lên vị trí nút cha
ChỉSốCha = ChỉSốCon;
ChỉSốCon = 2* ChỉSốCha;
}
}
- Bước 3: x
ChỉSốCha
= Cha;

c. Cài đặt thuật toán
* Thủ tục Shift:
// Thêm x
L
vào Heap x
L+1,
..., x
r
để tạo Heap mới dài hơn một phần tử x
L,
...,
x
r
,
void Shift(mang x, int L, int R)
{ int ChỉSốCha = L, ChỉSốCon = 2* ChỉSốCha, LàHeap = 0;
ElementType Cha = x[ChỉSốCha];
Tìm kieám vaø saép xeáp trong II.22

while (!LàHeap && (ChỉSốCon ≤ R))
{ if (ChỉSốCon < R) // Chọn nút có khóa lớn nhất trong 2 nút con của nút Cha
if (x[ChỉSốCon] < x[ChỉSốCon+1]) ChỉSốCon++;
if (Cha >= x[ChỉSốCon]) LàHeap = 1;
else { x[ChỉSốCha] = x[ChỉSốCon]; // Chuyển nút con lớn hơn lên nút cha
ChỉSốCha = ChỉSốCon;
ChỉSốCon = 2* ChỉSốCha;
}
}
x[ChỉSốCha] = Cha;
return ;
}

Chú ý rằng, với dãy ban đầu bất kỳ x
1,
..., x
n ,
thì x
[n/ 2]+1,
..., x
n
là Heap ban
đầu (không đầy đủ). Sau đó áp dụng liên tiếp thuật toán Shift bổ sung phần tử kề
bên trái vào các Heap đã có, ta được các Heap mới nhiều hơn một phần tử ...
Cuối cùng, ta đựơc Heap đầy đủ ban đầu: x
1,
..., x
n
.

* Tạo Heap đầy đủ ban đầu từ Heap ban đầu của dãy x
1,
..., x
n

void TạoHeapĐầyĐủ(mang x, int n)
{ int L = n/2, R = n-1;
while (L >= 0) Shift(x, L--, R);
return ;
}

* Ví du: Từ dãy 44 55 12 42 94 18 06 67

Heap ban đầu
L=3 44 55 12 67 94 18 06 42
L=2 44 55 18 67 94 12 06 42
L=2 44 94 18 67 55 12 06 42
L=1 94 67 18 44 55 12 06 42

Heap đầy đủ đã tạo xong

* Thủ tục HeapSort
void HeapSort(mang x, int n)
{ TạoHeapĐầyĐủ(x, n);
int L = 0, R = n -1;
while (R > 0)
{ HoánVị(x[0], x[R]);
Shift(x, L, --R);
Tìm kieám vaø saép xeáp trong II.23

}
return ;
}

Ví dụ: Với Heap ban đầu:
94 67 18 44 55 12 06 42
Ta có biểu diễn cây của dãy sau mỗi bước lặp:
1 42

67 18
2

44 55 12 06


94

67

55 18


44 42 12 06

94



55

44 18


06 42 12 67

94


44

42 18


06 12 55 67

Tìm kieám vaø saép xeáp trong II.24

94


42

12 18


06 44 55 67


94


18

12 06


42 44 55 67


94


12

06 18


42 44 55 67


94


06

12 18


42 44 55 67


94

Tìm kieám vaø saép xeáp trong II.25


Duyệt các cây theo chiều rộng, ta có kết quả dưới dạng dãy sau mỗi bước lặp:
67 55 18 44 42 12 06 94
55 44 18 06 42 12 67 94
44 42 18 06 12 55 67 94
42 12 18 06 44 55 67 94
18 12 06 42 44 55 67 94
12 06 18 42 44 55 67 94
06 12 18 42 44 55 67 94

d. Độ phức tạp của thuật toán
Người ta chứng minh được rằng trong trường hợp tồi nhất, độ phức tạp
của thuật toán Heap Sort là:
T
xấu
(n) = O(nlog
2
n).
Trong thuật toán đệ quy QuickSort cần không gian nhớ cho ngăn xếp (để
lưu thông tin về các phân đoạn sẽ được xử lý tiếp theo và do đó sẽ phụ thuộc vào
kích cỡ dữ liệu đầu vào). Đối với thuật toán HeapSort (dưới dạng lặp), ta cần
không gian nhớ phụ là hằng (nhỏ) không phụ thuộc vào kích cỡ dữ liệu đầu vào.


II.3.8. Phương pháp sắp xếp trộn (Merge Sort)
a. Ý tưởng phương pháp:
Dựa trên ý tưởng “chia để trị”, phương pháp sắp xếp trộn được xây dựng
dựa vào nhận xét: với mỗi dãy con, ta đều có thể tách chúng thành tập các dãy
con được sắp. Nếu ta trộn các dãy con (được sắp) này thì sẽ được các dãy con
(được sắp) dài hơn, với số lượng dãy con mới ít hơn khoảng một nửa. Lặp lại quá
trình trên cho đến khi tập ban đầu chỉ còn duy nhất một dãy con, nghĩa là các phần
tử của chúng được sắp xếp.
Trong phương pháp trộn trực tiếp, ta xét các dãy con có chiều dài cố định
2
k-1
trong lần tách thứ k. Khi đó, ta sẽ không tận dụng được trật tự tự nhiên của
các dãy con ban đầu hay sau mỗi lần trộn. Để khắc phục nhược điểm này, ta cần
đến khái niệm đường chạy tự nhiên. Thay vì trộn các đường chạy có chiều dài cố
định ta sẽ trộn các đường chạy tự nhiên thành các đường chạy dài hơn.
* Định nghĩa 1: (đường chạy tự nhiên - với chiều dài không cố định)
Một đường chạy (tự nhiên) r (theo trường khóa key) trong dãy x là một
dãy con được sắp (tăng) lớn nhất gồm các đối tượng r = {d
m
, d
m+1
, …,d
n
}
thỏa các tính chất sau:
d
i
.key ≤ d
i+1
.key , ∀ i ∈ [m,n)
d
m-1
.key > d
m
.key
d
n
.key > d
n+1
.key

* Định nghĩa 2: (thao tác trộn)
Tìm kieám vaø saép xeáp trong II.26

Trộn 2 đường chạy r
1
, r
2
có chiều dài lần lượt là d
1
và d
2
là tạo ra đường
chạy mới r (gồm tất cả các đối tượng từ r
1
và r
2
) có chiều dài d
1
+ d
2
.

* Ví dụ
Sắp xếp tăng dần bằng phương pháp trộn tự nhiên dãy sau:
x: 75 55 15 20 85 30 35 10 60 40 50 25 45 80 70 65
Các bước tách và trộn trong mỗi bước lặp:
* Tách (lần 1, đưa những đường chạy tự nhiên trong dãy x lần lươt vào các
dãy phụ y, z):
y: 75 15 20 85 10 60 25 45 80 65
z: 55 30 35 40 50 70
- Trộn (trộn những đường chạy tự nhiên tương ứng trong các dãy phụ y, z
thành các đường chạy mới dài hơn vào dãy x ):
x : 55 75 15 20 30 35 40 50 70 85 10 60 25 45 80 65

* Tách (lần 2):
y: 55 75 10 60 65
z: 15 20 30 35 40 50 70 85 25 45 80
- Trộn:
x: 15 20 30 35 40 50 55 70 75 85 10 25 45 60 65 80

* Tách (lần 3):
y: 15 20 30 35 40 50 55 70 75 85
z: 10 25 45 60 65 80
- Trộn:
x: 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85

b. Nội dung thuật toán
TrộnTựNhiên(x, n)
Lặp lại các bước sau:
1. Gọi thuật toán “Tách” để chia dãy x thành các dãy con và đưa chúng
lần lượt vào dãy y và z ;
2. Gọi thuật toán “Trộn” để trộn các dãy con trong dãy y và z vào lại x
và đếm SốĐườngChạy mỗi khi trộn một cặp đường chạy;
cho đến khi SốĐườngChạy = 1.

c. Cài đặt thuật toán
Để tiết kiệm bộ nhớ, ta có thể cải tiến thuật toán trên bằng cách chỉ dùng một dãy phụ y
(có cỡ n). (Mỗi khi tách được hai dãy con tự nhiên của dãy x, ta đưa chúng vào dãy phụ y từ hai
phía, sau đó trộn ngay chúng trở lại vào x).

void TronTuNhien(mang x, int n)
{ int SoDChay, BDau1, Cuoi1, BDau2, Cuoi2, HếtDãy; // kết thúc dãy x
Tìm kieám vaø saép xeáp trong II.27

mang y; // mảng phụ
do
{ SoDChay = 0; BDau1 = 0; HếtDãy = 0;
// Tach va tron x thanh cac duong chay tu nhien dai nhat
while (!HếtDãy)
{ Tim1DChay(x,n -1,BDau1,Cuoi1,HếtDãy); SoDChay++;
if (!HếtDãy)
{ BDau2=Cuoi1+1;
Tim1DChay(x,n -1,BDau2,Cuoi2,HếtDãy);
// Trộn 2 dãy con tăng thành dãy con tăng (chỉ dùng một mảng phụ y)
Tron(x,y,BDau1,Cuoi1,BDau2,Cuoi2);
BDau1 = Cuoi2+1;
}
}
} while (SoDChay>1);
return;
}

// Tìm 1 đường chạy trên x, bắt đầu từ chỉ số BDau <= KThuc, trả về chỉ số Cuối đường chạy
(tăng):
// Neu Cuối < KThuc: HếtDãy = 0; ngược lại, HếtDãy = 1.
int Tim1DChay(mang x, int KThuc, int BDau, int &Cuoi, int &HếtDãy)
{ int Truoc = BDau;
Cuoi = Truoc+1;
while (Cuoi<=KThuc && x[Truoc] <= x[Cuoi])
{ Truoc = Cuoi;
Cuoi++;
}
if (Cuoi > KThuc)
{ Cuoi = KThuc;
HếtDãy = 1; return 1;
}
else // x[Truoc] > x[Cuoi]
{ Cuoi--;
HếtDãy = 0; return 0;
}
}

//BDau1 <= Cuoi1 < BDau2 = Cuoi1+1 <= Cuoi2
void Tron (mang x, mang y, int BDau1, int Cuoi1, int BDau2, int Cuoi2)
{ int k, i, j;
for (i = Cuoi1; i >= BDau1; i--) y[ i ] = x[ i];
for (j = BDau2; j <= Cuoi2; j++) y[Cuoi2+BDau2-j] = x[ j ];
i = BDau1; j = Cuoi2;
for (k = BDau1; k <= Cuoi2; k++)
if (y[ i ] < y[ j ])
{x[k] = y[ i ]; i++;
}
else {x[k] = y[ j ]; j--;
Tìm kieám vaø saép xeáp trong II.28

}
return;
}

Đó là cách tiếp cận từ dưới lên (Down-Top) của thuật toán trộn dưới dạng lặp. Ta cũng
có thể tiếp cận thuật toán trộn theo hướng từ trên xuống (Top-Down) dưới dạng đệ qui (cho đơn
giản và tự nhiên: bài tập).

d. Độ phức tạp của thuật toán
- Trong trường hợp tồi nhất (khi các mục có thứ tự ngược lại), phương
pháp này giống như phương pháp “trộn trực tiếp” (ứng với các đường chạy có độ
dài: 1, 2, 4, 8, 16,...). Để sắp xếp một dãy gồm n đối tượng, cần đòi hỏi log
2
n thao
tác “Tách” và mỗi đối tượng trong n mục phải được xử lý trong mỗi thao tác. Do
đó, độ phức tạp trong trường hợp tồi nhất là:
T
xấu
(n) = O(nlog
2
n).
- Phương pháp trộn tự nhiên hiệu qủa về mặt thời gian nhưng tốn bộ nhớ
phụ cho các dãy phụ. Dựa trên ý tưởng của phương pháp trộn tự nhiên, nếu dãy
được cài đặt bằng danh sách liên kết (sẽ trình bày trong chương sau) thì nhược
điểm trên sẽ được khắc phục.
- Có thể cải biên phương pháp này để sắp xếp trên bộ nhớ ngoài (xem giáo
trình “Cấu trúc dữ liệu và thuật toán 2”).


II.3.9. Phương pháp sắp xếp dựa trên cơ số (Radix Sort)
a. Ý tưởng phương pháp
Radix Sort là một phương pháp sắp xếp không dựa vào việc so sánh trị của
các phần tử như các phương pháp đã trình bày trên đây, mà dựa vào việc phân loại
và trình tự phân loại sẽ tạo ra thứ tự cho các phần tử, tương tự như việc phân
loại trước khi phát thư của bưu điện (theo cây phân cấp địa phương).
Giả sử các phần tử cần sắp x
1
, ..., x
n
, là các số nguyên có tối đa m chữ số.
Ta phân loại các phần tử lần lượt theo các chữ số hàng đơn vị, hàng chục, …

b. Nội dung thuật toán
RadixSort(x, n)
- Bước 1: k = 0; // k = 0: hàng đơn vị, k = 1: hàng chục, …
// k cho biết chữ số thứ k được dùng để phân loại
- Bước 2: Khởi tạo 10 lô để chứa các phần tử được phân loại:B
0
, ..., B
9

- Bước 3: Với mỗi i=1, …, n: đặt x
i
vào lô B
t
(t là chữ số thứ k của
x
i
)
- Bước 4: k = k +1;
if (k < m) Quay lại bước 2;
else Dừng;

Tìm kieám vaø saép xeáp trong II.29

* Chú ý: Sau lần phân phối thứ k các phần tử của dãy X vào các lô B
0
, ...,
B
9
, rồi lấy các phần tử từ những lô này theo thứ tự của chỉ số i của B
i
từ 0 đến
9 trở lại X; nếu chỉ xét k+1 chữ số, thì các phần tử của dãy X sẽ tăng.

* Ví dụ: Sắp tăng dãy:
0701 1725 0999 9170 3252 4518 7009 1424 0428 1239 8425
Phân loại dãy vào các lô B theo hàng đơn vị:
ChỉSố Mảng x 0 1 2 3 4 5 6 7 8 9
1 0701 9170 0701 3252 1424 1725 4518 0999
2 1725 8425 0428 7009
3 0999 1239
4 9170
5 3252
6 4518
7 7009
8 1424
9 0428
10 1239
11 8425

Phân loại dãy vào các lô B theo hàng chục:
ChỉSố Mảng x 0 1 2 3 4 5 6 7 8 9
1 9170 0701 4518 1424 1239 3252 9170 0999
2 0701 7009 1725
3 3252 8425
4 1424 0428
5 1725
6 8425
7 4518
8 0428
9 0999
10 7009
11 1239

Phân loại dãy vào các lô B theo hàng trăm:
ChỉSố Mảng x 0 1 2 3 4 5 6 7 8 9
1 0701 7009 9170 1239 1424 4518 0701 0999
2 7009 3252 8425 1725
3 4518 0428
4 1424
5 1725
6 8425
7 0428
8 1239
Tìm kieám vaø saép xeáp trong II.30

9 3252
10 9170
11 0999

Phân loại dãy vào các lô theo hàng ngàn:
ChỉSố Mảng x 0 1 2 3 4 5 6 7 8 9
1 7009 0428 1239 3252 4518 7009 8425 9170
2 9170 0701 1424
3 1239 0999 1725
4 3252
5 1424
6 8425
7 0428
8 4518
9 0701
10 1725
11 0999
Đưa lần lượt các phần tử của các lô B
0
, ..., B
9
vào lại dãy X, ta được dãy
tăng: 0428 0701 0999 1239 1424 1725 3252 4518 7009 8425 9170

c. Cài đặt thuật toán (bài tập)
Chú ý: Do tổng các mục dữ liệu trải trên các lô B
0
, ..., B
9
luôn bằng n, nên
cài đặt mỗi lô bằng mảng là không hiệu quả. Khi đó, nếu dùng danh sách liên kết
động (xem chương tiếp) được cài đặt bởi con trỏ sẽ hiệu quả hơn.

d. Độ phức tạp của thuật toán
- Thuật toán RadixSort thực hiện m lần các thao tác phân bố dãy X vào các
lô và ghép các lô trở lại dãy X. Trong mỗi thao tác này, mỗi phần tử được xét
(tính địa chỉ một lần và hai phép gán) đúng một lần. Vậy độ phức tạp của thuật
toán (số phép hoán vị, trong cả 3 trường hợp về tình trạng dữ liệu, đều như nhau)
là cỡ tuyến tính:
T(n) =
3
2
mn = O(n)
- Trên thực tế, thuật toán cần thêm thời gian để tính toán địa chỉ (trích chữ
số thứ k của phần tử nguyên) khi phân lô. Việc cài đặt thuật toán sẽ thuận tiện
hơn nếu các phần tử có dạng chuỗi (chi phí để trích ra phần tử thứ k ít hơn)
- Thuật toán này sẽ hiệu quả, nếu khóa không quá dài


II.3.10. So sánh các phương pháp sắp xếp trong
Các phương pháp sắp xếp trực tiếp (chọn trực tiếp, nổi bọt, chèn trực tiếp),
sắp xếp ShakerSort, nói chung, chúng đều có độ phức tạp cỡ đa thức cấp 2:
T(n) = O(n
2
).
Tìm kieám vaø saép xeáp trong II.31

Phương pháp sắp xếp ShellSort có độ phức tạp tốt hơn:
T(n) = O(n
1,2
).
Các phương pháp QuickSort, HeapSort và trộn (tự nhiên) trong hầu hết
trường hợp có độ phức tạp tốt hơn nhiều:
T(n) = O(nlog
2
n)
Khác với cách tiếp cận của các phương pháp sắp xếp trên là dựa vào phép
so sánh khoá, phương pháp sắp xếp theo cơ số RadixSort không dựa trên phép so
sánh khóa mà dựa vào việc phân loại các chữ số trong mỗi số của dãy số có tối đa
m chữ số. Khi đó, các phép toán cơ bản là lấy ra chữ số thứ k (1≤ k ≤ m) của mỗi
số và phép gán các phần tử số. RadixSort có độ phức tạp là:
T(n) = O(nm) = O(n)
* Các số liệu thực nghiệm về thời gian (đơn vị là sao) chạy các thuật toán
đã trình bày trên máy PC- Pentium III, 600MHz, 64 MB-RAM, theo các bộ số
liệu (dãy các số nguyên dương) cỡ: n = 130.000 và xét tình trạng dữ liệu trong 3
trường hợp: dãy ngẫu nhiên có phân bố đều, dãy đã được sắp theo thứ tự thuận và
ngược.

Ngẫu nhiên Thứ tự thuận Thứ tự ngược
P.Pháp n 130000 Chậm Nhanh 130000 Chậm Nhanh 130000 Chậm Nhanh
Chọn trực tiếp 23 909 x 23 794 X 30 029 x
Chèn trực tiếp 11 326 x 6 X 32 384 x
Nổi bọt 65 144 X 0 X 92 741 X
Shaker Sort 39 689 X 0 X 59 215 X
Shell Sort 33 X 11 X 11 X
Heap Sort 16 X 11 X 11 X
Quick Sort 11 X 5 X 5 X
Trộn tự nhiên 27 X 5 X 22 X
Radix Sort 286 x 264 x 253 x

- Với bộ dữ liệu khá lớn gồm n = 5.000.000 số nguyên, ba phương pháp
QuickSort, HeapSort và ShellSort tỏ ra xứng đáng là “đại diện” tốt cho 3 nhóm
phương pháp sắp xếp chính đã nêu ở trên (nó nhanh hơn hẳn so với các phương
pháp khác trong cùng nhóm).
Để ý rằng, cả 3 phương pháp đại diện này đều dựa trên ý tưởng “chia đôi”
(“chia để trị”). Với 3 phương pháp đại diện này, ta có kết qủa thực nghiệm như
sau:

Ngẫu nhiên Thứ tự thuận Thứ tự ngược
P.Pháp n 5*10
6
ChậmNhanh 5*10
6
ChậmNhanh 5*10
6
Chậm Nhanh
Shell Sort 1862 X 643 X 698 X
Heap Sort 1571 X 516 X 561 X
Quick Sort 489 X 291 x 297 X
NaturalMergeSort
1851 X 22 X 1049 X
Tìm kieám vaø saép xeáp trong II.32


Trên thực tế, với nhiều cơ sở dữ liệu lớn, số lần phải sắp xếp những bộ dữ
liệu ngẫu nhiên thường ít. Ta thường gặp tình huống phải sắp xếp lại các bộ dữ
liệu “gần được sắp” sau một số lần cập nhật trên bộ dữ liệu đã được sắp trước đó.
Khi đó, QuickSort và sắp trộn tự nhiên là hai phương pháp đáng lưu ý. Đặc biệt,
thuật toán sắp trộn tự nhiên còn được sử dụng hiệu quả trên bộ nhớ ngoài.


Chương III
CẤU TRÚC DANH SÁCH LIÊN KẾT


III.1. Giới thiệu kiểu dữ liệu con trỏ

III.1.1. So sánh kiểu dữ liệu tĩnh và kiểu dữ liệu động
Do đặc điểm và hạn chế của các kiểu dữ liệu cơ sở và kiểu có cấu trúc đơn
giản đã xét (gọi là kiểu dữ liệu tĩnh) là tính cố định và cứng nhắc do không thay
đổi được kích thước và cấu trúc trong chu trình sống, (mặc dù các thao tác trên
chúng có thể nhanh và thuận tiện trong một số tình huống); vì vậy, nó khó mô tả
một cách thật tự nhiên và đúng bản chất của thực tế vốn sinh động và phong phú.
Khi xây dựng chương trình, nếu cần biểu diễn các đối tượng có số lượng ổn
định và có thể dự đoán trước kích thước của chúng, ta có thể sử dụng biến không
động (biến tĩnh hay nửa tĩnh). Chúng thường được khai báo tường minh được truy
xuất trực tiếp bằng một định danh rõ ràng (tương ứng với địa chỉ vùng nhớ lưu
trữ biến này), tồn tại trong phạm vi khai báo và chỉ mất khi ra khỏi phạm vi này,
được khai báo trong vùng Data segment (vùng dữ liệu) hoặc trong vùng Stack
segment (biến cục bộ) và có kích thước không đổi trong suốt phạm vi sống.
Kiểu dữ liệu tĩnh (và do đó cả các thao tác cơ bản tương ứng) sẽ khó:
- biểu diễn, cài đặt và xác định kích thước của các kiểu dữ liệu đệ qui;
- cài đặt một cách hiệu quả và tự nhiên (mặc dù nó có thể đơn giản) các đối
tượng dữ liệu có số lượng các phần tử khó dự đoán trước và biến động nhiều
trong quá trình sống (có thể do các thao tác thêm vào và loại ra xảy ra thường
xuyên). Khi đó, nhiều thao tác cơ bản trên chúng sẽ phức tạp, kém tự nhiên, làm
chương trình trở nên khó đọc, khó bảo trì cũng như việc sử dụng bộ nhớ kém hiệu
quả (do thiếu hay lãng phí bộ nhớ quá nhiều);
- biểu diễn hiệu quả (do sử dụng bộ nhớ kém hiệu quả) các đối tượng dữ
liệu lớn chỉ tồn tại nhất thời hay không thường xuyên trong quá trình hoạt động
của chương trình.
Đối với các kiểu dữ liệu có đặc tính: số lượng biến động, kích thước thay
đổi hay chỉ tồn tại nhất thời trong chu trình sống, … trong nhiều trường hợp nếu
dùng kiểu dữ liệu động để biểu diễn sẽ đúng bản chất và tự nhiên hơn cũng như
thuận lợi hơn trong các thao tác tương ứng trên chúng.
Trong chương này, ta sẽ xét một kiểu dữ liệu động đơn giản nhất là danh
sách liên kết.

III.1.2. Kiểu dữ liệu con trỏ
a. Định nghĩa
Cho trước một kiểu T = <V, O>. Kiểu con trỏ PT tương ứng với kiểu T là
kiểu:
PT = <Vp, Op>
Caáu truùc danh saùch lieân keát III.2

trong đó:
- Vp chứa các địa chỉ lưu trữ các đối tượng kiểu T hoặc là NULL (NULL là
một địa chỉ đặc biệt tượng trưng cho một giá trị không quan tâm, thường được
dùng để chỉ địa chỉ “kết thúc”);
- Op chứa các thao tác liên quan đến việc định địa chỉ của một đối tượng có
kiểu T thông qua con trỏ tương ứng chứa địa chỉ của đối tượng đó. Chẳng hạn,
thao tác tạo một con trỏ chứa địa chỉ một vùng nhớ để lưu trữ một đối tượng có
kiểu T.
Nói một cách khác, kiểu con trỏ tương ứng với kiểu T là một kiểu dữ liệu
của các đối tượng dùng để chứa địa chỉ vùng nhớ cho các đối tượng có kiểu T.
Đối tượng dữ liệu thuộc kiểu con trỏ tương ứng với kiểu T (hay gọi tắt là
đối tượng con trỏ kiểu T) là đối tượng dữ liệu mà giá trị của nó là địa chỉ vùng nhớ
của một đối tượng dữ liệu có kiểu T hoặc là trị đặc biệt NULL. Khi nói đến đối
tượng con trỏ kiểu T, ta để ý đến hai thuộc tính sau:
(kiểu dữ liệu T, địa chỉ của một đối tượng dữ liệu có kiểu T)
Thông tin về kiểu dữ liệu T nhằm giúp xác định dung lượng vùng nhớ cần thiết để
lưu trị của một biến có kiểu T.
Đối tượng dữ liệu con trỏ nhận trị nguyên không âm có kích thước qui định
sẵn tùy thuộc vào môi trường hệ điều hành làm việc và ngôn ngữ lập trình đang sử
dụng (chẳng hạn, với ngôn ngữ lập trình C, biến con trỏ có kích thước 2 hoặc 4
bytes cho môi trường 16 bits và có kích thước 4 hoặc 8 bytes cho môi trường 32
bits tùy vào con trỏ near (chỉ lưu địa chỉ offset) hay far (lưu cả địa chỉ offset và
segment)).

b. Khai báo (trong C hay C++)
Kiểu và biến con trỏ được khai báo theo cú pháp sau:
typedef KiểuCơSởT *KiểuConTrỏ;
KiểuConTrỏ BiếnConTrỏ;
hoặc khai báo trực tiếp biến con trỏ thông qua kiểu cơ sở T:
KiểuCơSởT *BiếnConTrỏ, BiếnCơSởT;
KiểuCơSởT có thể là kiểu cơ sở, kiểu dữ liệu có cấu trúc đơn giản, kiểu file
hoặc thậm chí là kiểu con trỏ khác. Ngoài ra, ta còn có các cấu trúc tự trỏ, con trỏ
hàm. Có thể dùng con trỏ để truyền tham đối cho hàm.

* Ví dụ: typedef int *kieu_con_tro_nguyen; // cách 1
kieu_con_tro_nguyen bien_con_tro_nguyen_2, p2;
int *bien_con_tro_nguyen_1, *p1, x, y; // cách 2: trực tiếp
p1 = &x; ( & trong &biến_x là toán tử lấy địa chỉ bắt đầu của một
biến_x)
*p1 = 3;
(* trong *p1 là toán tử lấy nội dung trị của biến do p1 trỏ đến, khi đó x=*p1=3)
y = 34;
Caáu truùc danh saùch lieân keát III.3

p2 = &y; // khi đó *p2 = y = 34

Giả sử a, b lần lượt là địa chỉ bắt đầu của vùng nhớ lưu trị của các biến
nguyên x và y tương ứng.
p1 a p2 b
a x≡*p1= 3 b y ≡*p2 =34

Khi đó, ta nói :
. p1, p2 là hai biến con trỏ kiểu nguyên trỏ đến hai biến kiểu nguyên x và y.
. *p1, *p2 là nội dung của hai biến nguyên x, y mà p1 và p2 trỏ tới.

c. Các thao tác trên kiểu dữ liệu con trỏ
Giả sử ta có khai báo:
KiểuCơSởT *BiếnConTrỏ_1, *BiếnConTrỏ_2, BiếnCơSởT;
- Toán tử gán địa chỉ cho biến con trỏ:
BiếnConTrỏ = địa_chỉ;
Đặc biệt, địa chỉ này có thể là NULL. Có thể gán hằng NULL cho bất kỳ
biến con trỏ nào.
BiếnConTrỏ_1 = BiếnConTrỏ_2;
BiếnConTrỏ = &BiếnCơSởT;
trong đó: & là toán tử lấy địa chỉ của biến BiếnCơSởT có kiểu KiểuCơSởT, khi đó
ta nói: BiếnConTrỏ trỏ đến (hay chỉ đến) BiếnCơSởT;
BiếnConTrỏ = địa_chỉ + trị_nguyên;
- Toán tử truy xuất nội dung của đối tượng do biến con trỏ BiếnConTrỏ trỏ
đến:
*BiếnConTrỏ
Khi đó, nếu BiếnConTrỏ = &BiếnCơSởT thì *BiếnConTrỏ ≡ BiếnCơSởT.

* Ví dụ: Giả sử cho hai biến con trỏ p, q trỏ đến hai biến kiểu ký tự e, f . Biến e,
f có địa chỉ bắt đầu lần lượt là a, b:
char e, f, *p, *q;
e = ‘c’; f = ‘d’;
p = &e; q = &f; // giả sử p, q có nội dung lần lượt là a và b

Ta có sơ đồ (1) sau đây:
e f
p a q b
a *p ≡ ‘c’ b *q ≡ ‘d’ (A)


* Sau lệnh gán hai con trỏ cùng kiểu q = p của sơ đồ (A) ta có sơ đồ (A’)
thay đổi như sau:
Caáu truùc danh saùch lieân keát III.4

e f
p a q b
a *q≡*p≡‘c’ a ‘d’ (A’)


* Sau lệnh gán hai biến do hai con trỏ cùng kiểu chỉ đến *q = *p của sơ đồ
(A) ta lại có sơ đồ (A’’) thay đổi như sau:
e f
p a q b
a *p ≡ ‘c’ b *q ≡ ‘c’ (A’’)

Hãy kiểm tra lại kết quả của các dãy lệnh trên một chương trình trong C++
(bài tập).


III.1.3. Biến động
Khi xây dựng các kiểu dữ liệu để biểu diễn các đối tượng trong một bài
toán cụ thể, dựa trên các đặc điểm của chúng, nếu ta không thể dự đoán hay xác
định trước kích thước của chúng (do sự tồn tại, phát sinh và mất đi của chúng tùy
thuộc vào ngữ cảnh của chương trình hoặc vào người sử dụng chương trình) thì ta
có thể sử dụng biến động để biểu diễn chúng.

a. Đặc trưng của biến động (hay biến được cấp phát động):
- không được khai báo tường minh (không có tên);
- được cấp phát bộ nhớ (trong vùng Heap segment) hoặc giải tỏa vùng nhớ
đã chiếm dụng (để về sau có thể sử dụng lại vùng nhớ này cho các mục đích khác)
theo yêu cầu của người sử dụng khi chương trình đang thi hành (chứ không phải ở
thời điểm biên dịch chương trình). Vì vậy, chúng không tuân theo qui tắc phạm vi
như biến tĩnh;
- Số lượng các biến động có thể thay đổi trong quá trình sống (khi chương
trình đang thi hành).

b. Truy xuất biến động
Khi biến động được tạo ra (cấp phát vùng nhớ để lưu trữ chúng), ta phải
dùng một biến con trỏ (biến không động và có định danh rõ ràng) BiếnConTrỏ có
kiểu tương ứng để lưu giữ địa chỉ bắt đầu của vùng nhớ này. Sau đó, ta có thể
truy xuất đến biến động thông qua biến con trỏ đó:
*BiếnConTrỏ
Nếu dùng biến con trỏ p chỉ đến một biến động có kiểu cấu trúc với các
thành phần {Field
i
}
1≤ i ≤ m
thì ta có thể truy cập đến thành phần thứ i: Field
i
của
biến động đó thông qua con trỏ p như sau:
p->Field
i

Caáu truùc danh saùch lieân keát III.5

hoặc: (*p).Field
i


c. Hai thao tác cơ bản trên biến động: tạo và hủy một biến động do biến
con trỏ trỏ đến.
* Tạo một biến động do biến con trỏ trỏ đến: bằng cách cấp phát vùng nhớ
(địa chỉ bắt đầu và kích thước vùng nhớ tương ứng với kiểu) cho biến động để lưu
trữ đối tượng và ta dùng một biến con trỏ để lưu giữ địa chỉ vùng nhớ đó.
Trong C++, ta dùng hàm new để cấp phát vùng nhớ cho một biến động có
kiểu cơ sở T theo cú pháp sau:
BiếnConTrỏ = new KiểuCơSởT; // (1)
BiếnĐộng
BiếnConTrỏ x
x

Khi đó, ta có thể truy xuất đến (nội dung) biến động (không có định danh
riêng) thông qua biến con trỏ như sau: *BiếnConTrỏ.
Hàm new còn có một cách sử dụng khác là:
BiếnConTrỏ = new KiểuCơSởT [ SốLượng] ; // (2)
để cấp phát vùng nhớ cho SốLượng đối tượng có cùng kiểu KiểuCơSởT mà địa chỉ
bắt đầu của vùng nhớ này được lưu giữ trong biến con trỏ BiếnConTrỏ.
Khi đó: địa chỉ bắt đầu vùng nhớ của đối tượng được cấp phát động thứ i
(0 ≤ i ≤ SốLượng -1) được truy xuất bởi:
BiếnConTrỏ + i
và nội dung của đối tượng được cấp phát động thứ i (0 ≤ i ≤ SốLượng -1) được
truy xuất bởi:
*(BiếnConTrỏ + i) hoặc BiếnConTrỏ[ i ]
Cú pháp truy xuất trên cũng đúng với “mảng động” đã biết:
ptử *BiếnMảngĐộng;
BiếnMảngĐộng = new ptử [MAX];

* Hủy một biến động đã được cấp phát bởi toán tử new do biến con trỏ trỏ
đến:
Để giải tỏa vùng nhớ của biến động đã được cấp phát trước đó bằng toán tử
new do biến con trỏ BiếnConTrỏ trỏ đến, ta dùng toán tử delete trong C++ như
sau:
delete BiếnConTrỏ;
hoặc: delete [ ]BiếnConTro;
tương ứng với toán tử cấp phát vùng nhớ new ở dạng (1) hoặc (2) ở trên.
* Ví dụ:
typedef struct { int diem;
int tuoi;
} hs;
Caáu truùc danh saùch lieân keát III.6

hs *con_tro;
int *p, *q;

p = new int;
*p = 6;
con_tro = new hs;
con_tro->diem = 9; // hoặc: (*con_tro).diem = 9;
con_tro->tuoi = 18;

Minh họa một phần bộ nhớ Heap segment:


6 *p
9 *con_tro
18


Sau đó thi hành các lệnh:
delete con_tro; // giải toả vùng nhớ do con_tro chiếm giữ
q = new int;
Khi đó q có thể trỏ đến vùng nhớ do biến con_tro trước đây trỏ đến.
*q = 8;


6 *p
8 *q



delete p;


8 *q




Dựa trên kiểu dữ liệu động cơ sở là con trỏ, ta có thể xây dựng các kiểu dữ
liệu động phong phú khác có nhiều ứng dụng trên thực tế như: danh sách liên kết
động, cấu trúc cây, đồ thị, …


Caáu truùc danh saùch lieân keát III.7

III.2. Danh sách liên kết (DSLK)

III.2.1. Định nghĩa danh sách
Cho kiểu dữ liệu T. Kiểu dữ liệu danh sách TL gồm các phần tử thuộc kiểu
T được định nghĩa là:
TL = <VL, OL >
với:
- VL là tập các phần tử có kiểu T được móc nối theo kiểu thứ tự tuyến
tính.
- OL gồm các toán tử: tạo danh sách, duyệt danh sách, tìm một đối tượng
(thỏa một tính chất nào đó) trên danh sách, chèn một đối tượng vào danh sách, hủy
một đối tượng khỏi danh sách, sắp xếp danh sách theo một quan hệ thứ tự nào đó,


III.2.2. Các cách tổ chức danh sách
Có hai cách chính để tổ chức danh sách tùy thuộc vào cách tổ chức trình tự
tuyến tính các phần tử của danh sách theo kiểu ngầm hay tường minh.

Ta có thể tổ chức trình tự tuyến tính theo kiểu ngầm thông qua chỉ số (như
mảng hay file). Phần tử x
i+1
được xem là phần tử kề sau của x
i
. Với cách này, các
phần tử của danh sách sẽ được lưu trữ liên tiếp trong một vùng nhớ liên tục. Việc
truy nhập các phần tử được thực hiện thông qua công thức dịch địa chỉ để xác
định địa chỉ bắt đầu của phần tử thứ i (nếu phần tử đầu tiên được đánh số là 0):
Địa chỉ bắt đầu danh sách + i*(kích thước của T)

Áp dụng cách tổ chức này, mảng có hạn chế là số phần tử tối đa của mảng
bị giới hạn cố định (vùng nhớ được cấp phát liên tục cho mảng được thực hiện khi
biên dịch đoạn chương trình chứa khai báo biến mảng đó); do đó việc sử dụng bộ
nhớ sẽ ít linh động và kém hiệu quả. Ngoài ra, các thao tác thêm và hủy sẽ bất tiện
và chiếm nhiều thời gian để dời chỗ các dãy con của danh sách. Bù lại, việc truy
xuất trực tiếp các phần tử của mảng trên vùng nhớ liên tục sẽ nhanh.

Để khắc phục các hạn chế trên, ta có thể tổ chức danh sách tuyến tính theo
kiểu móc nối (hay liên kết và gọi là danh sách liên kết) ở dạng tường minh: mỗi
phần tử ngoài thành phần thông tin về dữ liệu còn chứa thêm liên kết (địa chỉ)
đến phần tử kế tiếp trong danh sách. Khi đó, các phần tử của danh sách không nhất
thiết phải được lưu trữ kế tiếp trong một vùng nhớ liên tục. Tuy nhiên, do việc
truy xuất đến các phần tử của danh sách là tuần tự, nên một số thuật toán trên danh
sách được cài đặt theo kiểu liên kết sẽ bị chậm hơn.

Caáu truùc danh saùch lieân keát III.8

Sau đây, ta sẽ chủ yếu tập trung khảo sát các kiểu danh sách liên kết động
được cài đặt bởi con trỏ: DSLK đơn (có hoặc không có nút câm), DSLK đối xứng,
DSLK vòng, DSLK đa liên kết và một số ứng dụng của chúng.


III.3. DSLK đơn

III.3.1. Tổ chức DSLK đơn, các thao tác cơ bản, tìm kiếm và sắp xếp trên DSLK đơn

a. Tổ chức DSLK đơn (không có nút câm)
Mỗi phần tử (còn được gọi là nút) của danh sách chứa hai thành phần :
- Thành phần dữ liệu Data: chứa thông tin dữ liệu của bản thân phần tử.
- Thành phần liên kết Next: chứa địa chỉ của nút kế tiếp trong danh sách
hoặc trị NULL đối với nút cuối danh sách.

Phần tử đầu Tail Phần tử cuối
Head
Data Next Data Next ...... Data •

Con trỏ chỉ đến Con trỏ rỗng NULL
phần tử đầu danh sách

Để truy cập đến các phần tử của DSLK, ta chỉ cần biết địa chỉ Head của nút
dữ liệu đầu tiên. Sau đó, khi cần thiết, theo trường Next ta có thể biết được địa chỉ
(và do đó, nội dung dữ liệu) của nút kế tiếp.

Khi biết nút đầu Head, để truy nhập đến nút cuối của danh sách, ta cần chi
phí O(n) để duyệt qua lần lượt tất cả n nút của nó. Mặt khác, để thao tác tìm kiếm
tuần tự (rất thường gặp khi khai thác thông tin) được hiệu quả, ta thường sử dụng
thêm lính canh ở cuối danh sách. Vì vậy, để chi phí việc truy nhập đến nút cuối là
hằng O(1), khi quản lý DSLK, ngoài việc lưu trữ (địa chỉ) nút đầu Head, ta còn
lưu thêm (địa chỉ) nút cuối Tail.

* Biểu diễn danh sách liên kết (bằng con trỏ)
- Trong C hay C++, mỗi nút của DSLK được cài đặt bởi cấu trúc sau:
typedef .... ElementType; // Kiểu dữ liệu cơ sở của mỗi phần tử
typedef struct node {ElementType Data;
struct node *Next;
} NodeType;
typedef NodeType *NodePointer;
typedef struct { NodePointer Head, Tail;
} LL;
Caáu truùc danh saùch lieân keát III.9

LL List;

- Trong PASCAL, mỗi nút của DSLK được cài đặt bởi cấu trúc sau:
Type ElementType = ....; // Kiểu dữ liệu cơ sở của mỗi phần tử
NodePointer = ^NodeType;
NodeType = record Data: ElementType;
Next: NodePointer;
end;
LL = record Head: NodePointer;
Tail: NodePointer;
end;
var List : LL;

Ngoài việc dùng kiểu dữ liệu con trỏ, ta còn có thể biểu diễn một DSLK
bằng mảng như sau:
#define MAXSIZE ... // Kích thước tối đa của mảng
typedef ..... ElementType; // Kiểu dữ liệu của nút
typedef unsigned int IndexType; // Miền chỉ số của nút
typedef struct { ElementType Data;
IndexType Next;
} NodeType;
typedef NodeType Table [MAXSIZE];
typedef struct { Table DS;
IndexType StartIndex;
} Table_List;

Những thao tác cơ bản trên DS với kiểu cài đặt này là đơn giản (xem như
bài tập). Cách cài đặt này gặp hạn chế do kích thước của mảng cố định.

b. Các thao tác cơ bản trên kiểu DSLK đơn

Để tiện theo dõi và thống nhất trong trình bày, ta qui ước các khai báo sau:
ElementType x; // x là dữ liệu chứa trong một nút
NodePointer new_ele; // new_ele là biến con trỏ chỉ đến nút mới được cấp
phát

Để việc trình bày phần cài đặt các thao tác cơ bản được gọn hơn, ta sẽ sử
dụng thủ tục cấp phát động bộ nhớ cho một nút của DSLK sau đây:


Cấp phát vùng nhớ chứa dữ liệu x cho một nút của DSLK

Head
x •
Caáu truùc danh saùch lieân keát III.10


Tail

- Thuật toán
NodePointer CreateNodeLL (x)
. Cấp phát vùng nhớ cho một nút new_ele;
. new_ele ->Data = x;
. new_ele ->Next = NULL;

- Cài đặt
NodePointer CreateNodeLL (ElementType x)
{ NodePointer new_ele;
if ((new_ele = new NodeType) ==NULL)
cout << “\nLỗi cấp phát vùng nhớ cho một nút mới !”;
else { Gán(new_ele ->Data, x); new_ele ->Next = NULL;
}
return new_ele;
}

• Khởi tạo một DSLK rỗng.
- Thuật toán
LL CreateEmptyLL ()
List.Head = List.Tail = NULL;

- Cài đặt
LL CreateEmptyLL ()
{ LL List;
List.Head = List.Tail = NULL;
return List;
}

• Kiểm tra một DSLK có rỗng hay không
- Thuật toán
Boolean EmptyLL(LL List)
if (List.Head == NULL)
// hay chặt chẽ hơn (List.Head == NULL) && (List.Tail == NULL)
Trả về trị True; // List rỗng;
else Trả về trị False; // List khác rỗng;

- Cài đặt
int EmptyLL(LL List)
Caáu truùc danh saùch lieân keát III.11

{ return(List.Head == NULL);
// hay chặt chẽ hơn return ((List.Head == NULL) && (List.Tail == NULL));
}

• Duyệt qua một DSLK: Duyệt là đi qua mọi phần tử của DSLK theo một
quy luật nào đó (chẳng hạn, từ đầu đến cuối) và mỗi phần tử được xử
lý đúng một lần.

List.Head List.Tail
… •

CurrPtr

- Thuật toán
TraverseLL(List)
. CurrPtr = List.Head;
. Trong khi chưa hết DSLK thực hiện:
{ XửLý nút được trỏ bởi CurrPtr;
CurrPtr = CurrPtr->Next; // chuyển đến nút kế tiếp
}

- Cài đặt
int TraverseLL(LL List)
{ NodePointer CurrPtr = List.Head;
if (EmptyLL(List)) return 0;
else { while (CurrPtr != NULL) // hoặc while (CurrPtr)
{ XửLý (CurrPtr);
CurrPtr = CurrPtr->Next;
}
return 1;
}
}

void XửLý(NodePointer CurrPtr)
{ // Xử lý nút CurrPtr tùy theo từng yêu cầu cụ thể. Có hai loại xử lý:
// 1. Xử lý chỉ liên quan đến thông tin một nút
// 2. Xử lý liên quan đến thông tin của nhiều nút của DSLK
return ;
}

• Thêm một phần tử mới vào DS
Caáu truùc danh saùch lieân keát III.12

* Thêm một phần tử vào sau một nút được trỏ bởi con trỏ PredPtr
(qui ước: nếu PredPtr == NULL thì chèn x vào đầu DSLK)

List.Head List.Tail
… •

2 1

PredPtr x
new_ele
Áp dụng thao tác cơ bản trên, để cho gọn trong việc trình bày các phần sau, ta xây dựng
thêm các thao tác sau:

- Thuật toán: Thêm một nút new_ele vào sau một nút được trỏ bởi PredPtr
InsertNodeAfterLL(&List, new_ele, PredPtr)
. if (PredPtr)
{ new_ele->Next = PredPtr->next;
PredPtr->Next = new_ele;
}
else { new_ele->Next = List.Head; // chèn new_ele vào đầu List
List.Head = new_ele;
}
// Nếu chèn new_ele vào cuối DS thì cần cập nhật lại đuôi của List
. if (PredPtr == List.Tail) List.Tail = new_ele;

- Cài đặt
void InsertNodeAfterLL(LL &List, NodePointer new_ele, NodePointer PredPtr)
{ if (PredPtr)
{ new_ele->Next = PredPtr->next;
PredPtr->Next = new_ele;
}
else { new_ele->Next = List.Head;
List.Head = new_ele;
}
if (PredPtr == List.Tail) List.Tail = new_ele;
return ;
}

- Thuật toán: chèn thêm phần tử x vào sau một nút được trỏ bởi PredPtr.
Hàm này trả về địa chỉ nút mới thêm vào, nếu đủ vùng nhớ cấp phát cho
nó; ngược lại, nó sẽ trả trị NULL.
NodePointer InsertElementAfterLL (&List, x, PredPtr)
. if ((new_ele = CreateNode (x)) == NULL) return NULL;
. Thêm nút new_ele vào sau nút được trỏ bởi PredPtr; Trả về new_ele;

- Cài đặt
NodePointer InsertElementAfterLL (LL &List, ElementType x, NodePointer PredPtr)
Caáu truùc danh saùch lieân keát III.13

{ NodePointer new_ele;
if (! (new_ele = CreateNode (x)) return NULL;
InsertNodeAfterLL (List, new_ele, PredPtr);
return (new_ele);
}

* Thêm một phần tử vào cuối một DSLK
- Thuật toán: Thêm một nút new_ele vào cuối DSLK List
InsertNodeTailLL(&List, new_ele)
. Thêm nút new_ele vào sau nút được trỏ bởi List.Tail.

- Cài đặt
void InsertNodeTailLL(LL &List, NodePointer new_ele)
{
InsertNodeAfterLL (List, new_ele, List.Tail);
return ;
}

- Thuật toán: Thêm phần tử x vào cuối List
NodePointer InsertElementTailLL (&List, x)
. Thêm phần tử x vào sau nút được trỏ bởi List.Tail.

- Cài đặt
NodePointer InsertElementTailLL (LL &List, ElementType x)
{
return (InsertElementAfterLL (List, x, List.Tail));
}

* Thêm một phần tử vào đầu một DSLK
- Thuật toán: Thêm một nút new_ele vào đầu DSLK List
InsertNodeHeadLL(&List, new_ele)
. Thêm nút new_ele vào đầu List (hay sau nút được trỏ bởi NULL).

- Cài đặt
void InsertNodeHeadLL(LL &List, NodePointer new_ele)
{
InsertNodeAfterLL (List, new_ele, NULL);
return ;
}

- Thuật toán: Thêm phần tử x vào đầu List
NodePointer InsertElementHeadLL (&List, x)
. Thêm phần tử x vào đầu List (hay sau nút được trỏ bởi NULL).

- Cài đặt
NodePointer InsertElementHeadLL (LL &List, ElementType x)
{
return (InsertElementAfterLL (List, x, NULL));
Caáu truùc danh saùch lieân keát III.14

}


• Tìm kiếm một phần tử trên DSLK
Tìm một phần tử x trong DSLK List. Nếu tìm thấy thì, thông qua đối cuối
của hàm, trả về địa chỉ PredPtr của nút đứng trước nút tìm thấy đầu tiên. Nếu nút
tìm thấy là nút đầu của List thì trả về con trỏ NULL. Để tăng tốc độ tìm kiếm
(bằng cách giảm số lần so sánh trong biểu thức điều kiện của vòng lặp), ta đặt
thêm lính canh ở cuối List.

List.Head List.Tail new_ele (lính canh)
• x •

• CurrPtr …
PredPtr

- Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy chưa được
sắp:
Boolean SearchLinearLL(List, x, &PredPtr)
. Chèn nút mới new_ele chứa x vào cuối List (đóng vai trò lính canh)
. PredPtr = NULL; CurrPtr = List.Head; // PredPtr đứng kề trước CurrPtr
. Trong khi (CurrPtr->Data ≠ x) thực hiện
{ PredPtr = CurrPtr; CurrPtr = CurrPtr->Next;
}
. if (CurrPtr ≠ new_ele) Thấy = True; // Thông báo thấy x;
else Thấy = False; // Thông báo không thấy x;
. Xóa nút (new_ele) đứng sau nút được trỏ bởi List.Tail;
. Trả về trị Thấy;

- Cài đặt
int SearchLinearLL(LL List, ElementType x, NodePointer &PredPtr)
{ NodePointer CurrPtr = List.Head, OldTail= List.Tail,
new_ele = InsertElementTailLL(List, x);
PredPtr = NULL;
int Thấy;
while (SoSánh(CurrPtr->Data, x) != 0)
{ PredPtr = CurrPtr ; CurrPtr = CurrPtr->Next;
}
if (CurrPtr != new_ele) Thấy = 1; // thấy thật sự
else Thấy = 0; // thấy giả hay không thấy !
RemoveAfterLL(List, OldTail, x); // xóa new_ele;
return Thấy;
Caáu truùc danh saùch lieân keát III.15

}

- Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy được sắp (tăng):
int SearchLinearOrderLL(List, x, &PredPtr)
. Chèn nút mới new_ele chứa x vào cuối List (đóng vai trò lính canh)
. PredPtr = NULL; CurrPtr = List.Head;
. Trong khi (CurrPtr->Data < x) thực hiện
{ PredPtr = CurrPtr ; CurrPtr = CurrPtr->Next;
}
. if ((CurrPtr ≠ new_ele) and (CurrPtr->Data ≡ x)) Thấy = True; // thấy x;
else Thấy = False; // không thấy x;
. Xóa nút (new_ele) đứng sau nút được trỏ bởi List.Tail;
. Trả về trị Thấy;

- Cài đặt
int SearchLinearOrderLL(LL List, ElementType x, NodePointer &PredPtr)
{ NodePointer CurrPtr = List.Head, OldTail = List.Tail,
new_ele = InsertElementTailLL(List, x);
PredPtr = NULL;
int Thấy;
while (SoSánh(CurrPtr->Data, x) < 0)
{ PredPtr = CurrPtr;
CurrPtr = CurrPtr->Next;
}
if ((CurrPtr != new_ele) && SoSánh(CurrPtr->Data, x) == 0) Thấy = 1;
else Thấy = 0;
RemoveAfterLL(List, OldTail, x); // xóa new_ele;
return Thấy;
}

Có một cách cài đặt khác cho DSLK đơn là: thay vì nhận biết hết DSLK
bằng con trỏ NULL, ta có thể tạo mới ngay từ đầu một nút gọi là nút KẾT_THÚC
có liên kết vòng đến chính nó như sau:

List.Head List.Tail KẾT_THÚC
?

CurrPtr …

Khi đó, để nhận biết nút CurrPtr (không xử lý dữ liệu của nút này) có phải
là nút kết thức hay không, ta dùng điều kiện (CurrPtr->Next != CurrPtr) thay cho
(CurrPtr != NULL) trong biểu thức điều kiện để kết thúc vòng lặp while. Trong
nhiều trường hợp, nút kết thúc này được sử dụng như nút lính canh để tăng tốc độ
thực hiện của các thuật toán cần dùng lính canh ở cuối. Hãy viết lại các thuật toán
cơ bản trên DSLK đơn được cài đặt theo cách này (bài tập).

Caáu truùc danh saùch lieân keát III.16


• Xóa một phần tử khỏi DSLK
* Xóa một nút sau một nút được trỏ bởi con trỏ PredPtr
(qui ước: nếu PredPtr == NULL thì xóa nút đầu)

List.Head List.Tail
… •

PredPtr Temp

- Thuật toán
RemoveAfterLL(&List, PredPtr, &x)
. if (PredPtr)
{ Temp = PredPtr->Next;
if (Temp) PredPtr->Next = Temp->Next;
}
else // xóa nút đầu
{ Temp = List.Head;
List.Head = Temp->Next;
}
. if (Temp == List.Tail) List.Tail = PredPtr;//nếu xóa đuôi, cần cập nhật lại đuôi
. x = Temp->Data; delete Temp;

- Cài đặt
int RemoveAfterLL(LL &List, NodePointer PredPtr, ElementType &x)
{ NodePointer Temp;
if (EmptyLL(List))
{ cout << “\nDS rỗng !”; // không có gì để xoá !
return 0;
}
if (PredPtr)
{ Temp = PredPtr->Next;
if (Temp == NULL) return 0; // không thể xóa nút sau nút cuối !
else PredPtr->Next = Temp->Next;
}
else { Temp = List.Head; // xóa nút đầu
List.Head = Temp->Next;
}
if (Temp == List.Tail) List.Tail = PredPtr; //nếu xóa đuôi, cần cập nhật lại đuôi
Gán(x, Temp->Data);
delete Temp;
Caáu truùc danh saùch lieân keát III.17

return 1; // xóa thành công
}

* Xóa nút đầu của DSLK
- Thuật toán: Xóa nút đầu của DSLK List
int RemoveHeadLL(&List, &x)
. Xóa nút đầu (hay sau nút được trỏ bởi NULL) của List.

- Cài đặt
int RemoveHeadLL(LL &List, ElementType &x)
{
return RemoveAfterLL (List, NULL, x);
}

* Xóa một phần tử x khỏi DSLK
- Thuật toán:
int RemoveElementLL(&List, x)
. Tìm x trong List;
. Nếu thấy thì:
- Trả về biến con trỏ PredPtr chỉ đến nút đứng trước nút tìm thấy;
- Xóa nút đứng sau nút được trỏ bởi PredPtr.
Ngược lại thì kết thúc;

- Cài đặt
int RemoveElementLL(LL &List, ElementType x)
{ NodePointer PredPtr;
if (!SearchLinearLL(List, x, PredPtr)) return 0;
else return RemoveAfterLL (List, x, PredPtr);
}


c. Sắp xếp trên kiểu DSLK đơn
Có hai cách chính thực hiện các thuật toán sắp xếp trên DSLK:
* Cách 1: Hoán vị nội dung dữ liệu (trường Data) của các nút trên DSLK
tương tự như cách sắp xếp trên mảng đã trình bày trong chương trước. Điểm
khác biệt là việc truy xuất đến các phần tử trên DSLK sẽ theo trường liên kết Next
thay vì theo chỉ số như trên mảng. Với cách tiếp cận này, nếu kích thước trường
dữ liệu lớn thì chi phí cho việc hoán vị các cặp phần tử sẽ rất lớn (do đó, tốc độ
thực hiện các thuật toán sắp xếp sẽ rất chậm). Vả lại, cách làm như vậy sẽ không
tận dụng được ưu điểm linh hoạt của DSLK động trong các thao tác chèn và xóa
(chẳng hạn đối với thuật toán sắp xếp chèn trực tiếp).
* Cách 2: Thay vì hoán vị nội dung dữ liệu của các nút, ta chỉ thay đổi
thích hợp các trường liên kết Next giữa những nút để được thứ tự mong muốn.
Kích thước của trường liên kết: không phụ thuộc vào bản thân nội dung dữ liệu
của các phần tử, cố định trong mỗi môi trường 16 bits hay 32 bits và thường là khá
nhỏ so với kích thước của trường dữ liệu trong các ứng dụng lớn trên thực tế. Tuy
Caáu truùc danh saùch lieân keát III.18

nhiên, các thao tác trên trường liên kết này thường phức tạp hơn trên trường dữ
liệu.
Trong phần này, ta sẽ xét một số thuật toán sắp xếp có tận dụng các ưu thế
của DSLK động.

• Sắp xếp chèn trực tiếp trên DSLK
Trước hết, ta minh họa thuật toán sắp xếp chèn trực tiếp một dãy các đối
tượng được cài đặt bằng DSLK động thông qua kiểu con trỏ. Lưu ý rằng, tận dụng
ưu điểm liên kết động của con trỏ trong thao tác chèn, thay vì phải dời chỗ (chi
phí dời chỗ phụ thuộc vào chiều dài của dãy con và do đó chiếm rất nhiều thời
gian) các dãy con nhằm tìm vị trí thích hợp để chèn phần tử mới vào dãy con cũ đã
được sắp, ta chỉ phải thay đổi liên kết của không quá ba nút (chi phí hằng, không
phụ thuộc vào chiều dài dãy con, do đó sẽ rút ngắn thời gian đáng kể cho những
phép hoán vị hay dời chỗ các phần tử ).


List.Head 3 1
List.Tail
… … •

2

SubPred SubCurr Pred Curr


- Thuật toán
SắpXếpChènLL(&List)
- Bước 1: Pred = List.Head; // DS từ đầu đến PredPtr đã được sắp
Curr = Pred->Next; // Con trỏ Curr kề sau Pred
- Bước 2: Trong khi (Curr ≠ NULL) thực hiện:
. Bước 2.1: SubCurr = List.Head; // Bắt đầu tìm từ List.Head
SubPred = NULL; // nút đứng trước SubCurr
// Tìm vị trí SubPred thích hợp để chèn Curr sau
// SubPred, dùng Curr làm lính canh
. Bước 2.2:Trong khi (SubCurr->Data<Curr->Data) thực
hiện:
{ SubPred = SubCurr;
SubCurr = SubCurr->Next;
}
. Bước 2.3: if (SubCurr ≠ Curr)
{ Pred->Next = Curr->Next;
Chèn nút Curr sau SubPred;
}
Caáu truùc danh saùch lieân keát III.19

else Pred = Curr; // Curr đã đặt đúng
vị trí
. Bước 2.4: Curr = Pred->Next;

- Cài đặt
void SắpXếpChènLL(LL &List)
{ NodePointer Pred = List.Head, // DS con từ List.Head đến PredPtr đã được sắp
Curr = Pred->Next, // Curr là con trỏ đứng sau Pred
SubCurr, SubPred;
// SubPred là nút kề trước SubCurr, dùng để tìm vị trí để chèn Curr trong dãy con
while (Curr)
{ SubPred = NULL; SubCurr = List.Head; // Bắt đầu tìm từ List.Head
while (SoSánh(SubCurr->Data, Curr->Data) < 0)
{ SubPred = SubCurr; SubCurr = SubCurr->Next;
}
if (SubCurr != Curr) // Chèn Curr sau SubPred
{ Pred->Next = Curr->Next;
InsertNodeAfterLL(List, Curr, SubPred);
}
else Pred = Curr;
Curr = Pred->Next;
}
return ;
}

Sau đây, ta sẽ xét thêm một số thuật toán sắp xếp khác được cài đặt bằng
DSLK động thể hiện một cách đơn giản và rõ hơn bản chất của phương pháp và tỏ
ra khá hiệu qủa: Quick sort, Natural Merge sort (sắp trộn tự nhiên) và Radix sort.

• Phương pháp QuickSort trên DSLK
Do đặc điểm của DSLK đơn, để giảm chi phí tìm kiếm, ta nên chọn mốc
là phần tử ở đầu DSLK.

- Thuật toán
QuickSortLL(&List)
- Bước 1: Chọn phần tử đầu List.Head làm mốc g. Loại g khỏi List.
- Bước 2:Tách List thành hai DSLK con List_1 (gồm những phần tử
có trị nhỏ hơn g) và List_2 (gồm những phần tử có trị lớn
hơn hoặc bằng hơn g)
- Bước 3: if (List_1 ≠ NULL) QuickSortLL (List_1);
if (List_2 ≠ NULL) QuickSortLL (List_2);
- Bước 4: Nối List_1, g, List_2 theo trình tự đó thành List được sắp.
Chú ý rằng, khi tách List thành hai DSLK con List_1 và List_2, ta không sử
dụng thêm bộ nhớ phụ (mà phụ thuộc vào chiều dài danh sách).

Caáu truùc danh saùch lieân keát III.20

* Ví dụ Sắp xếp tăng DSLK sau:
List.Head List.Tail
6 3 8 4 6 •

. Chọn nút đầu tiên làm mốc: g = 6. Tách List thành hai DSLK con:
List_1.Head List_1.Tail
3 4 •
List_2.Head List_2.Tail
8 6 •

. Với List_2, chọn g = 8. Tách List_2 thành hai DSLK con. Sau đó nối lại, ta
được:
List_2.Head List_2.Tail
6 8 •
. Nối List_1, g = 6 và List_2, ta được List được sắp:
List.Head List.Tail
3 4 6 6 8 •

- Cài đặt
void QuickSortLL(LL &List)
{ NodePointer g, Temp;
LL List_1, List_2;
if (List.Head == List.Tail) return; // List được sắp nếu nó: rỗng hay có 1 phần tử
g = List.Head;
List.Head = List.Head->Next; // tách g ra khỏi List
List_1 = CreateEmptyLL();
List_2 = CreateEmptyLL();
while (!EmptyLL(List))
{ Temp = List.Head;
List.Head = List.Head->Next; Temp->Next = NULL;
if (SoSánh(Temp->Data, g->Data) < 0) InsertNodeTailLL(List_1,Temp);
else InsertNodeTailLL(List_2,Temp);
}
QuickSortLL(List_1);
QuickSortLL(List_2);
// Nối g sau List_1
if (EmptyLL(List_1)) List.Head = g;
else { List.Head = List_1.Head;
List_1.Tail->Next = g;
}
g->Next = List_2; // Nối List_2 sau g
Caáu truùc danh saùch lieân keát III.21

if ((EmptyLL(List_2)) List.Tail = g; //Cập nhật lại đuôi của List
else List.Tail = List_2.Tail;
return;
}

• Phương pháp NaturalMergeSort trên DSLK
Khi cài đặt dãy cần sắp bằng phương pháp trộn tự nhiên trên DSLK đơn,
bằng cách thay đổi các liên kết cho phù hợp ta có dãy được sắp mà không cần phải
dùng dãy phụ lớn (kích thước phụ thuộc vào cỡ dãy) như đã làm trên mảng.

- Thuật toán
NaturalMergeSortLL (&List)
- Bước 1: Phân phối luân phiên từng đường chạy của List vào hai
DSLK List_1 và List_2;
- Bước 2: if (List_1 ≠ NULL) NaturalMergeSortLL (List_1);
if (List_2 ≠ NULL) NaturalMergeSortLL (List_2);
- Bước 3: Trộn List_1 và List_2 đã sắp để có List được sắp;

* Ví dụ Sắp xếp tăng DSLK sau:
List.Head List.Tail
6 3 8 4 6 •
. Tách luân phiên các đường chạy tự nhiên của List vào 2 DSLK con:
List_1.Head List_1.Tail
6 4 6 •
List_2.Head List_2.Tail
3 8 •
. Lại tách luân phiên các đường chạy tự nhiên của List_1 vào 2 DSLK con, rồi sau
đó trộn lại, ta được List_1 tăng:
List_1.Head List_1.Tail
4 6 6 •
. Trộn List_1 và List_2, ta được List tăng:
List.Head List.Tail
3 4 6 6 8 •

- Cài đặt
void NaturalMergeSortLL (LL &List)
{ LL List_1, List_2;
if (List.Head == List.Tail) return; // List được sắp nếu nó: rỗng hay có 1 phần tử
List_1 = CreateEmptyLL(); List_2 = CreateEmptyLL();
// Phân phối các đường chạy của List vào List_1 và List_2
DistributeLL(List, List_1, List_2);
if (Empty(List_2) { List = List_1; return; }
NaturalMergeSortLL (List_1); NaturalMergeSortLL (List_2);
Caáu truùc danh saùch lieân keát III.22

// Trộn hai DSLK đã sắp List_1 và List_2 thành List
MergeLL(List_1, List_2, List);
return;
}

void MergeLL(LL &List_1, LL &List_2, LL &List)
{ NodePointer Temp;
while (!EmptyLL(List_1) && !EmptyLL(List_2))
{ if (SoSánh(List_1.Head->Data, List_2.Head->Data) <= 0)
{ Temp = List_1.Head; // Tách Temp ra khỏi List_1
List_1.Head = List_1.Head->Next;
}
else { Temp = List_2.Head; // Tách Temp ra khỏi List_2
List_2.Head = List_2.Head->Next;
}
Temp->Next = NULL;
InsertNodeTailLL(List, Temp);
}
LL ListCònLại = List_1;
if (EmptyLL(List_1)) ListCònLại = List_2;
if (!EmptyLL(ListCònLại))
{ List.Tail->Next = ListCònLại.Head;
List.Tail = ListCònLại.Tail;
}
return ;
}

void DistributeLL(LL &List, LL &List_1, LL &List_2)
{ NodePointer Temp;
do
{ Temp = List.Head; // Tách Temp ra khỏi List
List.Head = List.Head->Next ;
Temp->Next = NULL;
InsertNodeTailLL(List_1, Temp);
} while (List.Head && (Sosánh(Temp->Data, List.Head->Data) <= 0));
if (List.Head) DistributeLL(List, List_2, List_1);
else List.Tail = NULL; //Cập nhật lại đuôi rỗng cho List, chuẩn bị cho phép trộn
return ;
}

Chú ý: Trong vòng lặp của thủ tục DistributeLL trên đây để tìm và đưa một đường chạy
tự nhiên vào một DSLK con, ta thực hiện thừa các phép nối thêm những nút của List vào đuôi
của DSLK con (chi phí thực hiện các phép nối thêm này phụ thuộc vào độ dài mỗi đường chạy).
Ta có thể viết thêm các module con: tìm một đường chạy tự nhiên từ vị trí hiện hành (chỉ có phép
so sánh) và phép nối một đường chạy đó vào đuôi của DSLK con tương ứng. Khi đó chi phí cho
phép nối thêm này là hằng, không phụ thuộc vào độ dài mỗi đường chạy (tại sao ? Bài tập).


• Phương pháp RadixSort trên DSLK
Caáu truùc danh saùch lieân keát III.23

Khi cài đặt thuật toán RadixSort trên cấu trúc dữ liệu mảng, ta lãng phí bộ
nhớ quá nhiều. Các cài đặt thuật toán này trên DSLK động sẽ trình bày sau đây sẽ
khắc phục được nhược điểm trên. Giả sử ta cần sắp (tăng) một dãy số nguyên mà
số chữ số tối đa của chúng là m.

- Thuật toán
RadixSortLL (&List, m) // m là số ký số tối đa của dãy số cần sắp
- Bước 1: k = 0; // k = 0: hàng đơn vị, k = 1: hàng chục, …
- Bước 2: .Khởi tạo 10 DSLK (lô) rỗng: B
0
, ..., B
9
;
.Trong khi (List ≠ rỗng) thực hiện:
{ Temp = List.Head; List.Head = List.Head->Next;
Temp->Next = NULL; //Tách nút đầu Temp ra khỏi List
Chèn nút Temp vào cuối DSLK B
i
;
// với i là chữ số thứ i của Temp->Data;
}
- Bước 3: Nối lần lượt các DSLK B
0
, ..., B
9
thành List;
- Bước 4: k = k +1;
if (k < m) Quay lại bước 2;
else Dừng;

- Cài đặt
#define MAX_LO 10
void RadixSortLL (LL &List, int m)
{ LL B[MAX_LO];
NodePointer Temp;
int i, k;
if (List.Head == List.Tail) return ;// List được sắp nếu nó: rỗng hay có 1 phần tử
for (k = 0; k < m; k++)
{ for (i = 0; i < MAX_LO; i++) CreateEmptyLL(B[i]);
while (!EmptyLL(List))
{ Temp = List.Head; List.Head = List.Head->Next;
Temp->Next = NULL; //Tách nút đầu Temp ra khỏi List
InsertNodeTailLL(B[GetDigit(Temp->Data, k)], Temp);
}
List = B[0];
for (i = 1; i < MAX_LO; i++) AppendList(List,B[i]); // Nối B[i] vào cuối List
}
return ;
}

void AppendList(LL &List, LL List_1) // Nối List_1 vào cuối List
{ if (Empty(List_1)) return;
if (Empty(List)) List = List_1;
else
{ List.Tail->Next = List_1.Head;
List.Tail = List_1.Tail;
Caáu truùc danh saùch lieân keát III.24

}
return ;
}

int GetDigit(unsigned long N, int k) // Lấy chữ số thứ k của số nguyên N
{
return ((unsigned long)(N/pow(10,k)) % 10); // pow (x, y) ≡ x^y
}



III.3.2. Vài ứng dụng của DSLK đơn

III.3.2.1. Ngăn xếp
a. Định nghĩa
Ngăn xếp (stack) là kiểu dữ liệu tuyến tính nhằm biểu diễn các đối tượng
được xử lý theo kiểu "vào sau ra trước" (LIFO: Last In, First Out). Ta có thể
dùng danh sách để biểu diễn ngăn xếp, các phép toán thêm vào và lấy ra được
thực hiện cùng ở một đầu danh sách (gọi là đỉnh của ngăn xếp).
Ta cũng có thể định nghĩa stack là một kiểu dữ liệu trừu tượng tuyến tính,
trong đó có hai thao tác chính:
- Push(O): thêm một đối tượng O vào đầu stack;
- Pop(): lấy ra một đối tượng ở đầu stack và trả về trị của nó, nếu stack
rỗng sẽ gặp lỗi;
và thêm hai thao tác phụ trợ khác:
- EmptyStack(): kiểm tra xem stack có rỗng hay không;
- Top(): Trả về trị của phần tử ở đầu stack mà không loại nó khỏi stack, nếu
stack rỗng sẽ gặp lỗi.

* Ví dụ: Ta có thể dùng ngăn xếp để cài đặt thuật toán đổi một số nguyên
dương từ cơ số 10 sang cơ số 2 (bài tập).
Ta có thể dùng mảng hay DSLK động để biểu diễn stack.

b. Cài đặt ngăn xếp bằng mảng
• Cài đặt cấu trúc dữ liệu
Ta còn có thể cài đặt ngăn xếp S bằng mảng 1 chiều có kích thước tối đa là
N, các phần tử của nó được đánh số bắt đầu từ 0 (đến N-1), phần tử ở đỉnh stack
có chỉ số là t. Dựa trên cơ sở đó, trong C++, stack có thể được quản lý thông qua
cấu trúc sau:
typedef struct { ElementType mang[N];
int t ; // chỉ số của đỉnh stack
} StackType;
StackType S;
Caáu truùc danh saùch lieân keát III.25


S.mang[0] S.mang[1] … S.mang[t-1] t
X y Z

• Các phép toán cơ bản trên stack
StackType CreateEmptyStack()
{ StackType S;
S.t == 0; return S;
}

int EmptyStack(StackType S)
{ return (S.t == 0);
}

Do kích thước của mảng cố định, trước khi chèn ta phải kiểm tra ngăn xếp đã đầy hay
chưa thông qua hàm FullStack sau đây.

int FullStack(StackType S)
{ return (S.t >= N);
}

int Push(StackType &S, ElementType x)
{ if (FullStack(S)) return 0; // Stack đầy, chèn không thành công
else { S.mang[t++] = x; return 1;
}
}

int Pop (StackType &S, ElementType &x)
{ if (EmptyStack(S)) return 0; // Stack rỗng, không lấy được phần tử ở đỉnh S
else { x = S.mang[--t]; return 1;
}
}

int Top (StackType S, ElementType &x)
{ if (EmptyStack(S)) return 0; // Stack rỗng, không xem được phần tử ở đỉnh S
else { x = S.mang[t-1]; return 1;
}
}

• Nhận xét:
- Các thao tác trên đều đơn giản, hiệu quả và có chi phí hằng số O(1)
- Hạn chế của cách cài đặt này: kích thước của stack bị giới hạn và kém linh động, do đó
việc sử dụng bộ nhớ kém hiệu quả (thiếu hay lãng phí bộ nhớ).
Sau đây, ta sẽ tập trung khảo sát cách cài đặt ngăn xếp bằng DSLK động.

c. Cài đặt ngăn xếp bằng DSLK động
• Cài đặt.
Caáu truùc danh saùch lieân keát III.26

Ta có thể cài đặt ngăn xếp bằng danh sách liên kết động (tương tự như
DSLK đơn, chỉ khác là không lưu đến nút cuối hay đáy của ngăn xếp) như
sau:
typedef .... ElementType; // Kiểu dữ liệu của nút
typedef struct node { ElementType Data;
struct node *Next;
} NodeType;
typedef NodeType *NodePointer;
NodePointer Stack;


• Các phép toán cơ bản trên stack
Các thao tác khởi tạo một stack rỗng và kiểm tra xem môt stack cho trước
có rỗng hay không tương tự như DSLK đơn. Ta chỉ chú trọng đến hai thao tác đặc
trưng của ngăn xếp là lấy ra Pop và thêm vào Push ở đỉnh ngăn xếp.
Gọi Stack là con trỏ chỉ đến phần tử ở đỉnh của ngăn xếp.
* Thao tác Push đẩy một mục dữ liệu x vào đỉnh ngăn xếp
Thao tác Push tương tự thao tác InsertElementHeadLL, nếu ta quản lý thêm
nút ở đáy stack.
Stack


Temp x Đỉnh ngăn xếp
2 1
Stack





Hoặc ta có thể viết trực tiếp như sau:
int Push(NodePointer &Stack, ElementType x)
{ NodePointer Temp;
if ((Temp = CreateNodeLL(x)) == NULL) return(0);
else { Temp->Next = Stack;
Stack = Temp;
return 1 ;
}
}

* Thao tác Pop lấy ra một phần tử ở đỉnh ngăn xếp
Caáu truùc danh saùch lieân keát III.27

Thao tác Pop tương tự thao tác RemoveHeadLL, nếu ta quản lý thêm nút ở
đáy stack.


Temp 1 Data Next Đỉnh ngăn xếp

Stack
2




Ta có thể viết trực tiếp thao tác này như sau:
int Pop(NodePointer &Stack, ElementType &x)
{ NodePointer Temp;
if (EmptyStack(Stack))
{ cout << “\nNgăn xếp rỗng. Không thể lấy phần tử ở đỉnh ngăn xếp
!";
return 0;
}
else { Gan (x, Stack->Data);
Temp = Stack; Stack = Stack->Next;
delete Temp;
return 1;
}
}

* Thao tác Top xem một phần tử ở đỉnh ngăn xếp
int Top(NodePointer Stack, ElementType &x)
{ NodePointer Temp;
if (EmptyStack(Stack))
{ cout << “\nNgăn xếp rỗng. Không thể xem phần tử ở đỉnh ngăn xếp !";
return 0;
}
else { Gan (x, Stack->Data); return 1;
}
}

d. Ứng dụng của ngăn xếp
Ngăn xếp có rất nhiều ứng dụng trong tin học: cài đặt phép đệ qui, khử đệ
qui, lưu vết trong thuật toán quay lui, vét cạn hay tìm kiếm theo chiều sâu, trong
Caáu truùc danh saùch lieân keát III.28

việc chuyển đổi giữa các dạng kí pháp khác nhau cũng như đánh giá các biểu
thức chứa các toán tử không quá hai ngôi như biểu thức số học, lô-gic, …
Sau đây, ta dùng ký pháp nghịch đảo Balan (ký pháp hậu tố RPN - Reverse
Polish Notation) để đánh giá các biểu thức số học. Một biểu thức số học InfixeExp
thông thường được viết theo ký pháp trung tố (toán tử đặt ở giữa hai toán hạng).
Ta sẽ ứng dụng ngăn xếp để: chuyển InfixeExp sang dạng hậu tố SuffixeExp (toán
tử đặt sau các toán hạng) và tính trị của SuffixeExp.

* Ví dụ:
Biến đổi Đánh giá
(1 + 5) * (8 - (4 - 1)) 1 5 + 8 4 1 - - * 30
(Ký pháp trung tố ) (Ký pháp hậu tố )

Ta sẽ lần lượt xét hai thuật toán:
- Biến đổi biểu thức từ dạng kí pháp trung tố thành biểu thức dạng RPN.
- Đánh giá biểu thức số học dưới dạng RPN.

* Thuật toán chuyển biểu thức dạng trung tố sang dạng hậu tố RPN
1. Khởi tạo ngăn xếp (dùng để chứa các toán tử) S rỗng;
2. Lặp lại các việc sau cho đến khi dấu kết thúc biểu thức được đọc:
. Đọc phần tử tiếp theo (hằng, biến, toán tử, ‘(‘, ‘)’ ) trong biểu thức trung
tố;
. Nếu phần tử là:
- Dấu ‘(‘: đẩy nó vào S;
- Dấu ‘)’: hiển thị các phần tử của S cho đến khi dấu ‘(‘ (không
hiển thị) được đọc;
- Toán tử:
Nếu S rỗng: đẩy toán tử vào S; // (1)
Ngược lại:
Nếu toán tử đó có độ ưu tiên cao hơn toán tử ở đỉnh S thì:
đẩy toán tử đó vào S;
Ngược lại: lấy ra và hiển thị toán tử ở đỉnh S ;
Quay lại (1);
- Toán hạng (hằng hoặc biến): Hiển thị nó;
3. Khi đạt đến dấu kết thúc biểu thức thì lấy ra và hiển thị các toán tử của
S cho đến khi S rỗng;
(trong đó, ta xem dấu ‘(‘ có độ ưu tiên thấp hơn độ ưu tiên các toán tử +, -, *, /,
%)

Ví dụ: Chuyển biểu thức 7*8-(2+3) sang dạng hậu tố.
Biểu thức kí pháp trung tố Stack S Hiển thị

Caáu truùc danh saùch lieân keát III.29

7*8-(2+3) 7
Lấy ra

*8-(2+3) * 7


8-(2+3) * 7 8


-(2+3) - 7 8 *
(ĐộƯuTiên[-] < ĐộƯuTiên[*]: lấy và hiển thị * ; S rỗng: đẩy – vào
S)

(
(2+3) - 7 8 *


(
2+3) - 7 8 * 2

+
(
+3) - 7 8 * 2
(ĐộƯuTiên[+] > ĐộƯuTiên[(]: đẩy + vào S)

+
(
3) - 7 8 * 2 3


) - 7 8 * 2 3 +

[Lấy ra + ( ]

Kết quả
Dấu kết thúc biểu thức, lấy - ra 7 8 * 2 3 + -


* Thuật toán đánh giá biểu thức dạng RPN

1. Khởi tạo ngăn xếp S rỗng;
2. Lặp lại các việc sau cho đến khi dấu kết thúc biểu thức được đọc:
Caáu truùc danh saùch lieân keát III.30

. Đọc phần tử (toán hạng, toán tử) tiếp theo trong biểu thức;
. Nếu phần tử là toán hạng: đẩy nó vào S;
Ngược lại: // phần tử là toán tử
- Lấy từ đỉnh S hai toán hạng;
- Áp dụng toán tử đó vào 2 toán hạng (theo thứ tự ngược);
- Đẩy kết qủa vừa tính trở lại S;
3. Khi gặp dấu kết thúc biểu thức, giá trị của biểu thức chính là giá trị ở
đỉnh S;

Ví du: Tính giá trị của biểu thức hậu tố: 1 5 + 8 4 1 - - *

Biểu thức hậu tố Stack S


1 5 + 8 4 1 - - * 1


5
5 + 8 4 1 - - * 1


+ 8 4 1 - - * 6
(Thực hiện phép toán +, lưu kết quả 6 vào S)


8
8 4 1 - - * 6



4
8
4 1 - - * 6

1
4
8
1 - - * 6

3
8
- - * 6
Caáu truùc danh saùch lieân keát III.31

(Thực hiện phép toán 4 -1, lưu kết quả 3 trở lại S)

5
- * 6
(Thực hiện phép toán 8 -3, lưu kết quả 5 trở lại S)

* 30
(Thực hiện phép toán 6 * 5, lưu kết quả 30 trở lại S)
Kết qủa
Dấu kết thúc biểu thức 30



Chú ý rằng, trong các thuật toán không kiểm tra biểu thức đưa vào có đúng
cú pháp hay không? Hãy bổ sung chức năng kiểm tra cú pháp cho các biểu thức
(bài tập).
Ta có thể dùng ngăn xếp để khử đệ qui. Hãy khử đệ qui và viết lại dưới
dạng lặp thuật toán Quick Sort (bài tập). Chú ý, để tiết kiệm bộ nhớ cho stack, ta
nên lưu vào ngăn xếp các cặp chỉ số của dãy con nào dài hơn !


III.3.2.2. Hàng đợi
a. Định nghĩa
Hàng đợi (queue) là kiểu dữ liệu tuyến tính nhằm biểu diễn các đối tượng
được xử lý theo kiểu "vào trước ra trước" (FIFO: First In, First Out). Ta có thể
dùng danh sách để biểu diễn hàng đợi, các phép toán thêm vào và lấy ra được
thực hiện ở hai đầu khác nhau của danh sách.
Ta cũng có thể định nghĩa hàng đợi là một kiểu dữ liệu trừu tượng tuyến
tính, trong đó các hai thao tác chính:
- EnQueue(O): thêm một đối tượng O vào đuôi hàng đợi;
- DeQueue(): lấy ra một đối tượng ở đầu hàng đợi và trả về trị của nó, nếu
hàng đợi rỗng sẽ gặp lỗi;
và thêm hai thao tác phụ trợ khác:
- EmptyQueue(): kiểm tra xem hàng đợi có rỗng hay không;
- Front (): Trả về trị của phần tử ở đầu hàng đợi mà không loại nó khỏi
hàng đợi, nếu hàng đợi rỗng sẽ gặp lỗi.
Ta có thể dùng mảng vòng hay DSLK động để biểu diễn hàng đợi.

b. Cài đặt hàng đợi bằng mảng vòng
• Cài đặt cấu trúc dữ liệu
Ta có thể biểu diễn hàng đợi Q bằng một mảng 1 chiều có kích thước tối đa
là N. Để có thể sử dụng linh hoạt bộ nhớ mà mảng được cấp phát, ta tổ chức mảng
Caáu truùc danh saùch lieân keát III.32

theo kiểu xoay vòng (nghĩa là phần tử thứ N-1 được xem là kề trước phần tử thứ
0). Ngoài ra, ta còn lưu trữ thêm hai chỉ số F và R để lưu vị trí phần tử ở đầu và
đuôi hàng đợi Q.
Trong C++, ta có thể quản lý hàng đợi thông qua mảng như sau:
typedef struct { ElementType mang[N];
int F, R ; // chỉ số của phần tử đầu và đuôi hàng đợi
} QueueType;
QueueType Q;
Q.mang[0] Q.mang[1] … Q.mang[N-1]
X X X
F R
Sau quá trình cập nhật (dãy các thao tác xóa, chèn), hàng đợi Q có thể
“xoay vòng” như sau (X dùng để chỉ những vị trí chứa dữ liệu thật sự đang quan
tâm trong hàng đợi):
Q.mang[0] Q.mang[1] … Q.mang[N-1]
X X X
R F

• Các phép toán cơ bản
void CreateEmptyQueue (QueueType &Q)
{ Q.F = Q.R = -1; return ;
}

int EmptyQueue (QueueType Q)
{ return(Q.F == -1); // hoặc: return(Q.F == -1 && Q.R == -1);
}

int FullQueue (QueueType Q)
{ int IndexTemp = (Q.R == N -1) ? 0 : Q.R+1;
return(Q.F == IndexTemp);
}

int EnQueue (QueueType &Q, ElementType x)
{ if (FullQueue(Q))
{ cout << "\nHàng đợi đầy !";
return 0;
}
if (Q.R == N-1) Q.R = 0; // xoay vòng chỉ số đuôi của hàng đợi
else Q.R++;
Gán (Q.mang[Q.R], x);
// Cập nhật lại đầu hàng đợi rỗng sau khi thêm phần tử đầu tiên
if (Q.F == -1) Q.F++;
return 1;
}

int DeQueue (QueueType &Q, ElementType &x)
Caáu truùc danh saùch lieân keát III.33

{ if (EmptyQueue(Q))
{ cout << "\nHàng đợi rỗng !"; return 0;
}
Gán (x, Q.mang[Q.F]);
if (Q.F == Q.R) // xóa trên hàng đợi chỉ còn một phần tử: Q sẽ rỗng !
{ Q.F = Q.R = -1;
}
else if (Q.F == N-1) Q.F = 0; // xoay vòng chỉ số đầu hàng đợi
else Q.F++;
return 1;
}

int FrontQueue(QueueType &Q, ElementType &x)
{ if (EmptyQueue(Q))
{ cout << "\nHàng đợi rỗng !"; return 0;
}
Gán (x, Q.mang[Q.F]);
return 1;
}

c. Cài đặt hàng đợi bằng DSLK động
• Cài đặt cấu trúc dữ liệu
Ta dùng kiểu dữ liệu con trỏ để cài đặt hàng đợi giống như cách cài đặt DSLK
đơn.
Queue.Head Queue.Tail
… •
Lấy ra ở đầu Thêm vào đuôi

• Các phép toán cơ bản
Cách cài đặt các thao tác trên hàng đợi đều giống với các thao tác tương
ứng trên DSLK đơn như: khởi tạo hàng đợi rỗng, kiểm tra xem hàng đợi có rỗng
hay không, …
int EnQueue (LL &Queue, ElementType x)
{ return InsertElementTailLL(Queue, x);
}

int DeQueue (LL &Queue, ElementType &x)
{ return RemoveHeadLL(Queue, x);
}

int FrontQueue(LL &Queue, ElementType &x)
{ if (EmptyQueue(Queue)) return 0;
Gán(x, Queue.Head->Data);
return 1;
Caáu truùc danh saùch lieân keát III.34

}

d. Ứng dụng của hàng đợi
Hàng đợi có nhiều ứng dụng trong tin học như:
- Cơ chế vùng đệm cho các thao tác nhập – xuất trên bàn phím, máy in,
thiết bị nhớ ngoài, …
- Hàng đợi lưu các tiến trình chờ được xử lý trong các hệ điều hành, trình
biên dịch, …


III.4. Một số kiểu DSLK khác

III.4.1. DSLK đơn có nút câm
Qua các thao tác cơ bản trên DSLK đơn (không có nút câm trước đây), ta
nhận thấy có sự khác biệt trong cách xứ lý giữa nút đầu (không có nút đứng trước,
ta thường qui ước PredPtr là NULL) với các nút khác (luôn có nút đứng trước
PredPtr). Để đơn giản khi viết các thao tác trên (khỏi phải phân biệt hai tình
huống xử lý đó) người ta tạo thêm một nút giả (hay nút câm, ta không quan tâm
đến dữ liệu của nút này) đứng trước nút dữ liệu đầu tiên của DSLK đơn thông
thường và gọi nó là DSLK (đơn) có nút câm.
DList.Head Nút câm Nút dữ liệu đầu DList.Tail
? x y … z •

Khi đó, các thao tác cơ bản trên DSLK có nút câm, sẽ được viết lại, trong
một số trường hợp (chẳng hạn chèn, xóa) sẽ đơn giản hơn .

Cấp phát vùng nhớ cho một nút (không quan tâm đến dữ liệu)
NodePointer CreateNode ()
{ NodePointer new_ele;
if ((new_ele = new NodeType) ==NULL)
cout << “\nLỗi cấp phát vùng nhớ cho một nút mới !”;
else new_ele ->Next = NULL;
return new_ele;
}

• Khởi tạo một DSLK có nút câm rỗng
LL CreateEmptyLL2 ()
{ LL List;
List.Head = CreateNode();
List.Tail = List.Head;
return List;
}

Caáu truùc danh saùch lieân keát III.35

• Kiểm tra một DSLK với nút câm có rỗng hay không
int EmptyLL2(LL List)
{
return(List.Head->Next == NULL);
}

• Duyệt qua một DSLK có nút câm
int TraverseLL2(LL List)
{ NodePointer CurrPtr = List.Head->Next;
if (EmptyLL2(List)) return 0;
else { while (CurrPtr)
{ XửLý (CurrPtr);
CurrPtr = CurrPtr->Next;
}
return 1;
}
}

• Thêm một phần tử x vào sau một nút được trỏ bởi con trỏ PredPtr
* Thêm một nút mới vào sau một nút được trỏ bởi con trỏ PredPtr
List.Head List.Tail
? … •
2 1
PredPtr x
new_ele
void InsertNodeAfterLL2(LL &List, NodePointer new_ele, NodePointer PredPtr)
{ new_ele->Next = PredPtr->next;
PredPtr->Next = new_ele;
if (PredPtr == List.Tail) List.Tail = new_ele;
return ;
}

* Thêm một phần tử x vào sau một nút được trỏ bởi con trỏ PredPtr
int InsertElementAfterLL2(LL &List, ElementType x, NodePointer PredPtr)
{ NodePointer new_ele;
if ((new_ele = CreateNodeLL(x)) == NULL) return 0;
InsertNodeAfterLL2(List, new_ele, PredPtr);
return 1;
}

Thêm một phần tử x vào đầu DSLK có nút câm
int InsertElementHeadLL2(LL &List, ElementType x)
{ return InsertElementAfterLL2(List, x, List.Head);
}
Caáu truùc danh saùch lieân keát III.36


Thêm một phần tử x vào cuối DSLK có nút câm
int InsertElementTailLL2(LL &List, ElementType x)
{ return InsertElementAfterLL2(List, x, List.Tail);
}

• Tìm kiếm một phần tử trên DSLK đơn có nút câm
Tìm một phần tử x trong DSLK List. Nếu tìm thấy thì, thông qua đối cuối
của hàm, trả về địa chỉ PredPtr của nút đứng trước nút tìm thấy đầu tiên. Để tăng
tốc độ tìm kiếm (bằng cách giảm số lần so sánh trong biểu thức điều kiện của vòng
lặp), ta đặt thêm lính canh ở cuối List.

List.Head List.Tail new_ele (lính canh)
? • x •

PredPtr CurrP …


- Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy chưa được
sắp:
Boolean SearchLinearLL2(List, x, &PredPtr)
. Chèn nút mới new_ele chứa x vào cuối List (đóng vai trò lính canh)
. PredPtr = List.Head;
CurrPtr = List.Head->Next; // PredPtr đứng kề trước CurrPtr
. Trong khi (CurrPtr->Data ≠ x) thực hiện
{ PredPtr = CurrPtr; CurrPtr = CurrPtr->Next;
}
. if (CurrPtr ≠ new_ele) Thấy = True; // Thông báo thấy x;
else Thấy = False; // Thông báo không thấy x;
. Xóa nút (new_ele) đứng sau nút được trỏ bởi List.Tail;
. Trả về trị Thấy;

- Cài đặt
int SearchLinearLL2(LL List, ElementType x, NodePointer &PredPtr)
{ NodePointer CurrPtr = List.Head->Next, OldTail = List.Tail,
new_ele = InsertElementTailLL2(List, x);
PredPtr = List.Head;
int Thấy;
while (SoSánh(CurrPtr->Data, x) != 0)
{ PredPtr = CurrPtr ; CurrPtr = CurrPtr->Next;
}
if (CurrPtr != new_ele) Thấy = 1; // thấy thật sự
Caáu truùc danh saùch lieân keát III.37

else Thấy = 0; // thấy giả hay không thấy !
RemoveAfterLL2(List, OldTail, x); // xóa nút new_ele;
return Thấy;
}

• Xóa một nút sau một nút được trỏ bởi con trỏ PredPtr
int RemoveAfterLL2(LL &List, NodePointer PredPtr, ElementType &x)
{ NodePointer Temp;
if (EmptyLL2(List))
{ cout << “\nDS rỗng !”; return 0;
}
Temp = PredPtr->Next;
if (Temp == NULL) return 0; // không xóa được nút sau nút cuối ?!
else PredPtr->Next = Temp->Next;
if (Temp == List.Tail) List.Tail = PredPtr; //nếu xóa đuôi, cần cập
nhật lại đuôi
Gán(x, Temp->Data);
delete Temp;
return 1; // xóa thành công
}

Việc viết lại các thao tác cơ bản còn lại trên DSLK đơn có nút câm được
xem như bài tập. Qua đó, ta thấy rõ mối liên quan mật thiết giữa cấu trúc dữ liệu
và thuật toán, được thể hiện qua “công thức” của Niklaus Wirth:

Cấu trúc dữ liệu + Thuật toán = Chương trình



III.4.2. DSLK vòng
DSLK vòng là DSLK mà nút cuối là nút kề trước của nút đầu.
Nếu cài đặt DSLK vòng bằng kiểu con trỏ thì con trỏ của nút cuối trỏ đến
nút đầu tiên. Trong DSLK vòng, ta có thể lấy bất cứ nút nào làm nút đầu tiên xuất
phát. Cấu trúc dữ liệu cho mỗi nút của DSLK vòng hoàn toàn giống như DSLK
đơn.


CList.Head CList.Tail



Caáu truùc danh saùch lieân keát III.38

Một số thao tác cơ bản cho DSLK vòng sẽ được viết lại sau đây, các thao tác khác được
xem như bài tập.

• Khởi tạo một DSLK vòng rỗng
LL CreateEmptyCLL ()
{ LL CList;
CList.Head = CList.Tail = NULL;
return List;
}

• Kiểm tra một DSLK vòng có rỗng hay không
int EmptyCLL(LL CList)
{
return(CList.Head == NULL && CList.Tail == NULL);
}

• Duyệt qua một DSLK vòng
int TraverseCLL(LL CList)
{ NodePointer CurrPtr = CList.Head
if (EmptyCLL(CList)) return 0;
do
{ XửLý (CurrPtr);
CurrPtr = CurrPtr->Next;
} while (CurrPtr->Next != Clist.Head);
return 1;
}


III.4.3. DSLK đối xứng
Trong nhiều thao tác trên kiểu DSLK đơn, khi làm việc với một nút ta cần
biết nút đứng kề trước của nó. Lý do là DSLK đơn chỉ có một liên kết đi theo một
chiều từ nút đứng trước đến nút đứng sau. Để tăng độ linh hoạt trong các thao tác
trên DSLK, có thể di chuyển từ đầu đến đuôi của danh sách hay ngược lại, ta xét
kiểu DSLK đối xứng (hay DSLK kép) mà mỗi nút có hai trường liên kết ngược
chiều nhau, một liên kết chỉ đến nút đứng sau và liên kết kia chỉ đến nút đứng
trước.
DList.Head Prev Data Next DList.Tail
• •



a. Cấu trúc dữ liệu biểu diễn DSLK đối xứng
Trong C hay C++, mỗi nút của DSLK đối xứng được cài đặt bởi cấu trúc
sau:
typedef .... ElementType; // Kiểu dữ liệu cơ sở của mỗi phần tử
typedef struct Dnode {ElementType Data;
Caáu truùc danh saùch lieân keát III.39

struct Dnode *Next, *Prev;
} DNodeType;
typedef DNodeType *DNodePointer;
typedef struct { DNodePointer Head, Tail;
} DLL;
DLL DList;

b. Các thao tác cơ bản trên DSLK đối xứng
Các thao tác cơ bản về sau sẽ sử dụng thủ tục cấp phát động vùng nhớ cho
một nút của DSLK đối xứng sau đây:
• Cấp phát vùng nhớ chứa dữ liệu x cho một nút của DSLK đối xứng

Head
• x •

Tail

- Thuật toán
DNodePointer CreateNodeDLL (x)
. Cấp phát vùng nhớ cho một nút new_ele;
. new_ele ->Data = x; new_ele ->Next = NULL; new_ele ->Prev = NULL;
. Trả về new_ele;

- Cài đặt
DNodePointer CreateNodeDLL (ElementType x)
{ DNodePointer new_ele;
if ((new_ele = new DNodeType) ==NULL)
cout << “\nLỗi cấp phát vùng nhớ cho một nút mới !”;
else { Gán(new_ele ->Data, x);
new_ele ->Next = new_ele ->Prev = NULL;
}
return new_ele;
}

• Khởi tạo một DSLK đối xứng rỗng.
- Thuật toán
DLL CreateEmptyDLL ()
. DList.Head = DList.Tail = NULL;
. Trả về DList;

- Cài đặt
DLL CreateEmptyDLL ()
{ DLL List;
DList.Head = DList.Tail = NULL;
return DList;
Caáu truùc danh saùch lieân keát III.40

}

• Kiểm tra một DSLK đối xứng có rỗng hay không
- Thuật toán
Boolean EmptyDLL(DLL DList)
if (DList.Head == NULL)
// hay (DList.Head == NULL) && (DList.Tail == NULL). Tại sao ? Hãy so sánh !
Trả trị True; // DList rỗng;
else Trả trị False; // DList khác rỗng;

- Cài đặt
int EmptyDLL(DLL DList)
{ return(DList.Head == NULL);
// hay return ((DList.Head == NULL) && (DList.Tail == NULL));
}

• Duyệt qua một DSLK đối xứng
Ta có thể duyệt Dlist theo chiều thuận (hay ngược) tùy theo chiều con trỏ
Next (hay Prev).
- Thuật toán
TraverseLL(DList)
. CurrPtr = DList.Head; // hay CurrPtr = DList.Tail;
. Trong khi chưa hết DSLK thực hiện:
{ XửLý nút được trỏ bởi CurrPtr;
CurrPtr = CurrPtr->Next; // chuyển đến nút kề sau
// hay CurrPtr = CurrPtr->Prev; chuyển đến nút kề trước
}


- Cài đặt
int TraverseDLL(DLL DList)
{ DNodePointer CurrPtr = DList.Head; // hay CurrPtr = DList.Tail;
if (EmptyDLL(DList)) return 0;
else { while (CurrPtr != NULL) // hoặc while (CurrPtr)
{ XửLý (CurrPtr);
CurrPtr = CurrPtr->Next; // hay CurrPtr = CurrPtr->Prev;
}
return 1;
}
}

Caáu truùc danh saùch lieân keát III.41

void XửLý(DNodePointer CurrPtr)
{ // Xử lý nút CurrPtr tùy theo từng yêu cầu cụ thể
return ;
}

• Thêm một phần tử mới vào DSLK đối xứng
* Thêm một phần tử vào sau một nút được trỏ bởi con trỏ PredPtr
(nếu PredPtr == NULL thì chèn phần tử vào đầu DSLK)

DList.Head Prev Data Next DList.Tail
• •
3 2 1 4
PredPtr X
new_ele

- Thuật toán: Thêm một nút new_ele vào sau một nút được trỏ bởi PredPtr
InsertNodeAfterDLL(&DList, new_ele, PredPtr)
. if (PredPtr)
{ new_ele->Next = PredPtr->Next; new_ele->Prev = PredPtr;
PredPtr->Next = new_ele;
if (new_ele->Next) (new_ele->Next)->Prev = new_ele;
// else: trường hợp chèn new_ele vào đuôi DList, không cập nhật nút sau nút new_ele
}
else // chèn new_ele vào đầu Dlist
{ new_ele->Next = DList.Head;
if (DList.Head) DList.Head->Prev = new_ele;
// else DS rỗng !
DList.Head = new_ele; //cập nhật lại nút đầu DS
}
// nếu chèn nút mới vào đuôi, cần cập nhật lại đuôi mới
. if (PredPtr == DList.Tail) DList.Tail = new_ele;


- Cài đặt
void InsertNodeAfterDLL(DLL &DList, DNodePointer new_ele,DNodePointer PredPtr)
{ if (PredPtr)
{ new_ele->Next = PredPtr->next; new_ele->Prev = PredPtr;
PredPtr->Next = new_ele;
if (new_ele->Next) (new_ele->Next)->Prev = new_ele;
}
else { new_ele->Next = DList.Head;
if (DList.Head) DList.Head->Prev = new_ele;
DList.Head = new_ele;
}
if (PredPtr == DList.Tail) DList.Tail = new_ele;
return ;
}

Caáu truùc danh saùch lieân keát III.42

• Thuật toán: Thêm phần tử x vào sau một nút được trỏ bởi con trỏ
PredPtr
DNodePointer InsertElementAfterDLL (&DList, x, PredPtr)
. new_ele = CreateNodeDLL (x);
. if (new_ele ≠ NULL) Thêm nút new_ele vào sau nút được trỏ bởi
PredPtr;
. Trả về trị new_ele;

- Cài đặt
DNodePointer InsertElementAfterDLL(DLL &DList,ElementType x,DNodePointer PredPtr)
{ DNodePointer new_ele;
if ((new_ele = CreateNodeDLL (x)))
InsertNodeAfterDLL (DList, new_ele, PredPtr);
return (new_ele);
}

Tương tự, ta có thao tác thêm một nút (hay phần tử) vào trước một nút được trỏ bởi con
trỏ SuccPtr (bài tập).
• Thêm một phần tử vào cuối một DSLK đối xứng
- Thuật toán: Thêm một nút new_ele vào cuối DSLK DList
InsertNodeTailDLL(&DList, new_ele)
. Thêm nút new_ele vào sau nút được trỏ bởi DList.Tail.

- Cài đặt
void InsertNodeTailDLL(DLL &DList, DNodePointer new_ele)
{
InsertNodeAfterDLL (DList, new_ele, DList.Tail);
return ;
}

• Thuật toán: Thêm phần tử x vào cuối Dlist
DNodePointer InsertElementTailDLL (&DList, x)
. Thêm phần tử x vào sau nút được trỏ bởi DList.Tail.

- Cài đặt
DNodePointer InsertElementTailDLL (DLL &DList, ElementType x)
{
return (InsertElementAfterDLL (DList, x, DList.Tail));
}

• Thêm một phần tử vào đầu một DSLK đối xứng
- Thuật toán: Thêm một nút new_ele vào đầu DSLK DList
InsertNodeHeadDLL(&DList, new_ele)
. Thêm nút new_ele vào đầu DList (hay sau nút được trỏ bởi NULL).

- Cài đặt
Caáu truùc danh saùch lieân keát III.43

void InsertNodeHeadDLL(DLL &DList, DNodePointer new_ele)
{
InsertNodeAfterDLL (DList, new_ele, NULL);
return ;
}

• Thuật toán: Thêm phần tử x vào đầu Dlist
DNodePointer InsertElementHeadDLL (&DList, x)
. Thêm phần tử x vào đầu DList (hay sau nút được trỏ bởi NULL).

- Cài đặt
DNodePointer InsertElementHeadDLL (DLL &DList, ElementType x)
{
return (InsertElementAfterDLL (DList, x, NULL));
}


• Tìm kiếm một phần tử trên DSLK đối xứng
Thuật toán tìm kiếm trên DSLK đối xứng hoàn toàn tương tự như trên
DSLK đơn. Nếu tìm thấy phần tử trên danh sách thì trả về con trỏ chứa địa chỉ nút
vừa thấy, nếu không thấy trả về NULL.

- Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy chưa được
sắp:
DNodePointer SearchLinearDLL(DList, x)
. Chèn nút mới new_ele chứa x vào cuối DList (đóng vai trò lính canh)
. CurrPtr = DList.Head;
. Trong khi (CurrPtr->Data ≠ x) thực hiện
CurrPtr = CurrPtr->Next;
. if (CurrPtr ≠ new_ele) Thông báo thấy x;
else { Thông báo không thấy x; // thấy giả !
CurrPtr = NULL;
}
. Xoá nút new_ele; Trả về CurrPtr;

- Cài đặt
DNodePointer SearchLinearDLL(DLL DList, ElementType x)
{ DNodePointer new_ele = InsertElementTailDLL(DList, x),
CurrPtr = DList.Head;
while (SoSánh(CurrPtr->Data, x) != 0)
CurrPtr = CurrPtr->Next;
if (CurrPtr == new_ele) CurrPtr = NULL; // không thấy
RemoveNodeDLL(DList, new_ele);
return CurrPtr;
Caáu truùc danh saùch lieân keát III.44

}

- Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy được sắp (tăng):
DNodePointer SearchLinearOrderDLL(DList, x)
. Chèn nút mới new_ele chứa x vào cuối DList (đóng vai trò lính canh)
. CurrPtr = DList.Head;
. Trong khi (CurrPtr->Data < x) thực hiện
CurrPtr = CurrPtr->Next;
. if ((CurrPtr ≠ new_ele) and (CurrPtr->Data ≡ x)) Thông báo thấy x;
else { Thông báo không thấy x;
CurrPtr = NULL;
}
. Xoá nút new_ele; Trả về CurrPtr;

- Cài đặt
DNodePointer SearchLinearOrderDLL(DLL List, ElementType x)
{ DNodePointer new_ele = InsertElementTailDLL(DList, x),
CurrPtr = DList.Head;
while (SoSánh(CurrPtr->Data, x) < 0)
CurrPtr = CurrPtr->Next;
if ((CurrPtr == new_ele) || (SoSánh(CurrPtr->Data, x) > 0))) CurrPtr = NULL;
RemoveNodeDLL(DList, new_ele);
return CurrPtr;
}

Với DSLK đối xứng, ta có thể tìm kiếm theo chiều ngược lại, bằng cách
xuất phát từ DList.Tail và tìm từ cuối về đầu theo trường con trỏ Prev (bài tập).


• Xóa một phần tử khỏi DSLK đối xứng
* Xóa một nút được trỏ bởi con trỏ CurrPtr



DList.Head 1 2 CurrPtr DList.Tail
• •



- Thuật toán
int RemoveNodeDLL(&DList, CurrPtr, &x)
. if (CurrPtr == DList.Head) // xóa nút đầu
// hay dở hơn, tại sao ? if (CurrPtr->Prev == NULL)
{ DList.Head = CurrPtr->Next;
if (DList.Head ==NULL) // xóa trên DS chỉ có 1 nút
Caáu truùc danh saùch lieân keát III.45

DList.Tail = NULL;
else DList.Head->Prev = NULL;
}
else { if (CurrPtr == DList.Tail) //xóa nút cuối
// hay dở hơn, tại sao ? if (CurrPtr->Next == NULL)
{ DList.Tail = CurrPtr->Prev;
//không cần ? if (DList.Tail ==NULL) DList.Head = NULL;//xóa DS có 1
nút
DList.Tail->Next = NULL;
}
else {(CurrPtr->Next)->Prev = CurrPtr->Prev;
(CurrPtr->Prev)->Next = CurrPtr->Next;
}
}
. Gán(x, Temp->Data); delete CurrPtr;


- Cài đặt
int RemoveNodeDLL(DLL &DList, DNodePointer CurrPtr, ElementType &x)
{
if (EmptyDLL(DList))
{ cout << “\nDS rỗng !”; return 0;
}
if (CurrPtr->Prev == NULL) //xóa nút đầu
{ DList.Head = CurrPtr->Next;
if (DList.Head ==NULL) // xóa trên DS chỉ có 1 nút
DList.Tail = NULL;
Else DList.Head->Prev = NULL;
}
else { if (CurrPtr->Next == NULL) //xóa nút cuối
{ DList.Tail = CurrPtr->Prev;
DList.Tail->Next = NULL;
}
else {(CurrPtr->Next)->Prev = CurrPtr->Prev;
(CurrPtr->Prev)->Next = CurrPtr->Next;
}
}
Gán(x, Temp->Data);
delete CurrPtr;
return 1; // xóa thành công
}
Caáu truùc danh saùch lieân keát III.46


* Xóa nút đầu của DSLK đối xứng
int RemoveHeadDLL(DLL &DList, ElementType &x)
{ return RemoveNodeDLL (DList, DList.Head, x);
}

* Xóa nút cuối của DSLK đối xứng
int RemoveTailDLL(DLL &DList, ElementType &x)
{ return RemoveNodeDLL (DList, DList.Tail, x);
}

* Xóa một phần tử x khỏi DSLK
- Thuật toán:
int RemoveElementDLL(&DList, x)
. Tìm x trong DList, nếu thấy thì trả về biến con trỏ CurrPtr chỉ đến nút tìm thấy.
. Xóa nút được trỏ bởi CurrPtr.

- Cài đặt
int RemoveElementDLL(DLL &DList, ElementType x)
{ DNodePointer CurrPtr;
if ((CurrPtr = SearchLinearDLL(DList, x) == NULL) return 0; // không thấy
else return RemoveNodeDLL (DList, CurrPtr, x);
}

Việc hủy nút cuối trên DSLK đối xứng có chi phí O(1), chứ không phải tốn
chi phí O(n) như đối với DSLK đơn. Tuy vậy, việc cài đặt một dãy các đối tượng
bằng DSLK đối xứng tốn bộ nhớ lớn gấp đôi để lưu trữ hai liên kết và việc cập
nhật cũng nặng nề hơn.

Nếu kết hợp các tính chất: thêm nút câm, vòng và đối xứng thì ta sẽ được
kiểu DSLK “vòng đôi”. Hãy viết các thao tác cơ bản trên kiểu danh sách này (bài
tập).

CDList
Prev ? Next …




c. Ứng dụng của DSLK đối xứng
Ta có thể dùng DSLK đối xứng để cài đặt hàng đợi hai đầu (Dequeue –
Double ended queue). Tất nhiên, ta cũng có thể biểu diễn Dequeue bằng DSLK
đơn nhưng bất tiện hơn. Hàng đợi hai đầu sẽ được sử dụng trong các thuật toán
tìm kiếm trong lý thuyết đồ thị và trí tuệ nhân tạo.

Caáu truùc danh saùch lieân keát III.47

Hàng đợi hai đầu là danh sách mà việc thêm và hủy đều có thể thực hiện ở
hai đầu danh sách, trên đó có các thao tác chính sau:
- Thêm phần tử x vào đầu hàng đợi hai đầu Dequeue:
InsertHead (Dequeue, x) hay chính là InsertElementHeadDLL(Dequeue, x);
- Thêm phần tử x vào cuối Dequeue:
InsertTail (Dequeue, x) hay chính là InsertElementTailDLL(Dequeue, x);
- Lấy ra phần tử ở đầu Dequeue:
RemoveHead (Dequeue, x) hay chính là RemoveHeadDLL(Dequeue, x);
- Lấy ra phần tử ở cuối Dequeue:
RemoveTail (Dequeue, x) hay chính là RemoveTailDLL(Dequeue, x);

Ngoài ra, trên Dequeue còn hỗ trợ các thao tác sau:
- Kiểm tra xem Dequeue có rỗng không:
EmptyDequeue(Dequeue) hay chính là EmptyDLL(Dequeue);
- Xem giá trị ở đầu Dequeue mà không hủy nó khỏi Dequeue:
Head(Dequeue) hay chính là Dequeue.Head;
- Xem giá trị ở cuối Dequeue mà không hủy nó khỏi Dequeue:
Tail(Dequeue) hay chính là Dequeue.Tail;

Ta có thể dùng hàng đợi hai đầu để biểu diễn ngăn xếp và hàng đợi như
được minh họa trong bảng sau. Lưu ý rằng tất cả các thao tác này trên Dequeue
đều có độ phức tạp hằng O(1).

Dequeue Queue Stack
InsertHead (Dequeue, x) Push (Stack, x)
InsertTail (Dequeue, x) EnQueue (Queue, x)
RemoveHead (Dequeue, x) DeQueue (Queue, x) Pop (Stack, x)
RemoveTail (Dequeue, x)
EmptyDequeue(Dequeue) EmptyQueue (Queue) EmptyStack (Stack)
Head(Dequeue, x) Front (Queue) Top (Stack, x)
Tail(Dequeue, x)


III.4.4. Danh sách đa liên kết
Danh sách đa liên kết là danh sách mà mỗi nút của nó, ngoài thành phần dữ
liệu (có thể có nhiều trường), còn gồm nhiều thành phần liên kết khác phục vụ cho
những mục đích khác nhau.
Chẳng hạn, ta có thể dùng danh sách liên kết động có hai liên kết (không
nhất thiết phải đối xứng) để lưu trữ và sắp xếp dãy các mẫu tin theo hai quan hệ
thứ tự khác nhau, chẳng hạn theo hai trường khóa khác nhau nào đó.

Caáu truùc danh saùch lieân keát III.48

Ví dụ: Ta muốn lưu danh sách sau, sao cho theo những trường khóa khác
nhau chúng được sắp xếp theo những thứ tự nào đó.

Tên Mã Số

Smith 2537
Doe 2897
Adams 1932
Jones 1570
List


Ten ? Data ? Link1 Link2

Nút câm NULL

Smith 2537 Link1 Link2


Doe 2897 Link1 Link2


Adams 1932 Link1 Link2


Jones 1570 Link1 Link2


NULL


Với mỗi mẫu tin, ngoài trường dữ liệu, ta còn lưu thêm hai trường con trỏ:
Link1 hay NextTên để sắp tăng các mẫu tin này theo trường Tên, còn Link2 hay
NextMãSố để sắp giảm các mẫu tin này theo trường MãSố.

Ta dùng danh sách đa (trong ví dụ này là hai) liên kết có nút câm để lưu trữ
danh sách các mục dữ liệu. Nếu đi theo Link1, ta được danh sách tăng theo thứ tự
Tên; nếu đi theo Link2, ta được danh sách giảm theo thứ tự Mã Số.

a. Cài đặt cấu trúc dữ liệu cho DS đa liên kết
typedef unsigned long So;
typedef struct {char Ten[MAX_TEN];
Caáu truùc danh saùch lieân keát III.49

So MaSo;
} ElementType;
typedef struct MultiNode *MultiPtr;
struct MultiNode { ElementType Data;
MultiPtr NextTen, NextMaSo;
};
MultiPtr MList;

b. Vài thao tác cơ bản trên DS đa liên kết
Cấp phát vùng nhớ cho một nút của DS đa liên kết
MultiPtr CreateNodeML()
{ MultiPtr new_ele;
if ((new_ele = new MultiNode) == NULL)
cout << “\n Lỗi cấp phát bộ nhớ cho một nút của DS đa LK !";
else new_ele->NextTen = new_ele->NextMaSo = NULL;
return Temp;
}

• Thủ tục thêm một nút vào DS đa liên kết
Sau khi thêm mẫu tin mới {Ten0, MaSo0} vào DSLK cũ, vẫn bảo đảm thứ
tự tăng theo Tên và giảm theo MãSố trong DSLK mới thu được.

int InsertOrderMulti(MultiPtr MList, char Ten0[MAX_TEN], So MaSo0)
{ MultiPtr new_ele, PredPtr, CurrPtr;
if ((new_ele = CreateNodeML()) == NULL) return 0;
Gan ((new_ele->Data).Ten,Ten0); Gan((new_ele ->Data).MaSo,MaSo0);
// Tìm vị trí chèn (tăng) new_ele theo trường NextTen
PredPtr = MList;
CurrPtr = PredPtr->NextTen;
while (CurrPtr && SoSanh((CurrPtr ->Data).Ten, Ten0) < 0)
{ PredPtr = CurrPtr;
CurrPtr = CurrPtr ->NextTen;
}
PredPtr->NextTen = new_ele;
new_ele->NextTen = CurrPtr;
// Tìm vị trí chèn (giảm) new_ele theo trường NextMaSo
PredPtr = MList;
CurrPtr = PredPtr->NextMaSo;
while (CurrPtr && (CurrPtr ->Data).MaSo > MaSo0)
{ PredPtr = CurrPtr;
CurrPtr = CurrPtr ->NextMaSo;
}
PredPtr->NextMaSo = new_ele;
Caáu truùc danh saùch lieân keát III.50

new_ele ->NextMaSo = CurrPtr ;
return 1;
}

• Thủ tục xóa một nút từ DS đa liên kết
int DeleteOrderMulti(MultiPtr Mlist, char Ten0[MAX_TEN], So MaSo0)
{ MultiPtr LưuVịTrí, PredPtr, CurrPtr;
// Tìm vị trí trùng tên Ten0 theo trường NextTen
PredPtr = MList;
CurrPtr = MList->NextTen;
while (CurrPtr && SoSanh((CurrPtr ->Data).Ten, Ten0) != 0
&& (CurrPtr->Data).MaSo != MaSo0)
if (SoSanh(CurrPtr ->Data).Ten, Ten0) > 0) // không thấy
CurrPtr = NULL;
else { PredPtr = CurrPtr; // chưa thấy
CurrPtr = CurrPtr ->NextTen;
}
if (CurrPtr == NULL) return 0; // Không thấy nên không xóa được
LưuVịTrí = CurrPtr;
PredPtr->NextTen = CurrPtr ->NextTen; // Đã thấy tên trùng với Ten0

// Tìm vị trí trùng mã số MaSo0 theo trường NextMaSo
PredPtr = Mlist;
CurrPtr = MList->NextMaSo;
while (CurrPtr != LưuVịTrí)
{ PredPtr = CurrPtr;
CurrPtr = CurrPtr ->NextMaSo;
}
PredPtr->NextMaSo = CurrPtr ->NextMaSo;
delete LưuVịTrí;
return 1;
}


III.4.5. Một số ứng dụng khác của DSLK
a. DS có thứ tự và DS tổ chức lại
Danh sách có thứ tự (Order List) là loại danh sách mà các phần tử của nó
được tổ chức lưu trữ thỏa mãn một quan hệ thứ tự nào đó dựa trên các thành
phần dữ liệu của chúng nhằm phục vụ cho việc khai thác dữ liệu (chẳng hạn tìm
kiếm và cập nhật) được nhanh chóng và thuận lợi hơn. Với kiểu DS này, hầu hết
các thao tác cơ bản trên DSLK đều được giữ nguyên. Riêng thao tác chèn (và xoá)
Caáu truùc danh saùch lieân keát III.51

một phần tử mới x vào một DSLK OList đã được sắp cho trước cần được viết lại
để thu được danh sách mới vẫn được sắp.

• Trước hết, ta xây dựng thuật toán tìm một nút PredPtr xa nhất chứa dữ
liệu trên DSLK có thứ tự (giả sử tăng) OList sao cho PredPtr->Data <
x, nếu không có nút thỏa mãn tính chất này (trường hợp x ≤ dữ liệu nút
đầu tiên) ta qui ước cho PredPtr = NULL. Sau đó, ta sẽ chèn x vào sau
nút PredPtr.
- Thuật toán:
NodePointer SearchLinearOrderLL(OList, x)
. Chèn nút mới new_ele chứa x vào cuối OList (đóng vai trò lính canh)
. PredPtr = NULL; CurrPtr = OList.Head;
. Trong khi (CurrPtr->Data < x) thực hiện
{ PredPtr = CurrPtr ; CurrPtr = CurrPtr->Next;
}
. Xóa nút new_ele (sau nút OList.Tail); Trả về PredPtr;

- Cài đặt
NodePointer SearchLinearOrderLL(LL OList, ElementType x)
{ NodePointer CurrPtr = OList.Head, PredPtr = NULL,
new_ele = InsertElementTailLL(OList, x);
while (SoSánh(CurrPtr->Data, x) < 0)
{ PredPtr = CurrPtr;
CurrPtr = CurrPtr->Next;
}
RemoveAfterLL(OList, OList.Tail, x);
return PredPtr;
}

• Chèn tăng một phần tử x vào DSLK đơn OList đã sắp tăng
- Thuật toán
InsertOrderLL (&OList, x)
. PredPtr = SearchLinearOrderLL(OList, x);
. Thêm phần tử x vào sau nút được trỏ bởi PredPtr;

- Cài đặt
int InsertOrderLL (LL &OList, ElementType x)
{ NodePointer PredPtr = SearchLinearOrderLL(OList, x);
return InsertElementAfterLL (OList, x, PredPtr);
}

Caáu truùc danh saùch lieân keát III.52

Danh sách có thứ tự có thể được cài đặt bằng DSLK đối xứng, khi đó cần
viết lại thao tác chèn tương ứng với cách cài đặt này (bài tập). Khi đó ta có thể
dùng DSLK đối xứng và có thứ tự để cài đặt hàng đợi có ưu tiên (được ứng dụng
nhiều trong tin học, chẳng hạn việc quản lý những tiến trình trong các hệ điều
hành).
Trên thực tế, trong nhiều trường hợp khi khai thác dữ liệu trên một DSLK
đã có một quan hệ thứ tự cho trước trên miền dữ liệu chung của các phần tử, ta
thấy hiện tượng sau thường xảy ra: có nhiều phần tử (có thể không ở gần đầu danh
sách) được khai thác thường xuyên hơn các phần tử khác. Khi đó, để giảm chi phí
tìm kiếm trong khai thác dữ liệu, ta có thể tổ chức lại danh sách (gọi là danh sách
tổ chức lại) bằng cách chèn những phần tử này vào đoạn đầu của danh sách.
Nhưng với cách tổ chức như thế, quan hệ thứ tự cũ bị phá vỡ, do đó ta không tận
dụng được các thao tác hiệu quả trên DSLK được sắp thứ tự, dẫn đến chi phí tìm
kiếm các phần tử khác tăng lên!

Một cách tiếp cận khác là tổ chức lại dữ liệu, bằng cách tạo ra quan hệ
thứ tự mới dựa trên việc bổ sung thêm một thành dữ liệu cho mỗi nút là số lần
mà nó được khai thác với độ ưu tiên nào đó cho thỏa đáng so với độ ưu tiên của
các thành phần dữ liệu khác. Tất nhiên, cách tổ chức này tuy làm tăng tốc độ tìm
kiếm khi khai thác dữ liệu, nhưng lại phải trả giá về chi phí bộ nhớ tăng lên ! May
mắn cho chúng ta là không gian nhớ giành thêm để lưu trữ số lần khai thác mỗi
mục dữ liệu thường không đáng kể so với kích thước rất lớn của dữ liệu trong các
bài toán thực tế thường gặp khi lưu trữ các cơ sở dữ liệu lớn. Đó là vấn đề thường
xuyên xảy ra khi cải tiến thuật toán: việc giảm chi phí về thời gian thường tăng chi
phí về không gian bộ nhớ và ngược lại! Chọn cách tổ chức kiểu dữ liệu nào sẽ tùy
thuộc vào đặc điểm của từng bài toán và mục đích tiết kiệm tài nguyên về khía
cạnh cụ thể nào là quan trọng nhất.

b. Biểu diễn tập hợp bằng DSLK (có nút câm)
Như đã biết, ta có thể biểu diễn tập hợp theo dãy bit bằng cách dùng một
mảng các bit để biểu diễn tập hợp con bất kỳ của một tập phổ dụng. Hạn chế của
cách biểu diễn nàylà khi tập hợp con thực sự rất bé nhưng tập phổ dụng lại rất lớn
sẽ gây lãng phí bộ nhớ.
Sau đây, ta đưa ra một cách tiếp cận khác: dùng DSLK đơn có nút câm để
biểu diễn tập hợp, trong đó ta không phân biệt thứ tự của các phần tử cũng như
không có sự trùng lặp giữa các phần tử trong DSLK.

Dùng DSLK với nút câm cài đặt tập hợp
Ví dụ: Ta biểu diễn các tập hợp sau bằng DSLK đơn có nút câm:
A = {0,2,4}, B = {8,6}, C = A U B.
A
? 0 2 4 •
Caáu truùc danh saùch lieân keát III.53


B
? 8 6 •

C
? 0 2 4 8 6 •


• Thủ tục thêm một phần tử vào tập hợp
int AddElement(LL S, ElementType x)
{
return InsertElementHeadLL2(S, x);
}

• Kiểm tra (hay tìm kiếm) xem một phần tử x có thuộc tập S hay không
int IsAMember (LL S, ElementType x)
{ NodePointer PredPtr;
return SearchLinearLL2(S, x, PredPtr);
}

• Phép hợp A U B
int Union(LL A, LL B, LL &AUB)
{ NodePointer ptrA, ptrB;
if ((AUB = CreateNode()) == NULL) return 0;
ptrA = A->Next;
while (ptrA)
{ if (!AddElement(AUB, ptrA->Data)) return 0;
ptrA = ptrA->Next;
}
ptrB = B->Next;
while (ptrB)
{ if (!IsAMember(A, ptrB->Data))
if (!AddElement(AUB, ptrB->Data)) return 0;
ptrB = ptrB->Next;
}
return 1;
}

Tương tự, ta có thể cài đặt các phép toán tập hợp còn lại như: giao, hiệu,
hiệu đối xứng, các quan hệ giữa hai tập hợp, ...

c. Biểu diễn đa thức rời rạc bằng DSLK (có nút câm)
Caáu truùc danh saùch lieân keát III.54

Xét đa thức bậc n (a
n
≠ 0):
P(x) = a
0
+ a
1
x + a
2
x
2
+ ... + a
n
x
n
Ta có thể biểu diễn đa thức trên bằng mảng a[n+1] để lưu các hệ số: a[i] =
a
i
, ∀ i = 0, …, n. Với cách biểu diễn này, các phép toán trên đa thức sẽ được thực
hiện đơn giản và nhanh chóng. Trong trường hợp đa thức rời rạc (đa thức có rất ít
hệ số khác 0), cài đặt mảng không hiệu qủa vì rất lãng phí bộ nhớ.
Một cách tiếp cận khác là dùng DSLK với nút câm để cài đặt đa thức rời
rạc.
* Ví dụ: Xét đa thức
P(x) = 5 + x
99

= 5 + 0*x + 0*x
2
+ ... + 0*x
98
+ 1*x
99


P
? ? 5 0 1 99 •


Mỗi nút có dạng:
Hệ số (Coef)

Next


Số mũ (Expo)

• Cài đặt đa thức rời rạc
Trường dữ liệu Data của mỗi nút được biểu diễn bởi:
typedef double CoefType;
typedef int ExpoType;
typedef struct { CoefType Coef;
ExpoType Expo;
} ElementType;

• Thủ tục Attach thêm một số hạng x ≡ {x.Coef, x.Expo} vào cuối đa thức P
int Attach(LL P , ElementType x)
{
return InsertElementTailLL2(P, x);
}

• Thủ tục cộng hai đa thức
Giả sử các số hạng của các đa thức được lưu tăng theo số mũ vào DSLK đơn có nút câm.
int AddPolynome (LL A, LL B, LL &A_PLUS_B)
{ NodePointer RestList, ptrA, ptrB;
CoefType Sum;
ElementType TempData;
Caáu truùc danh saùch lieân keát III.55

if ((A_PLUS_B = CreateEmptyLL2 ( )) == NULL) return 0;
ptrA = A->Next;
ptrB = B->Next;
while (ptrA && ptrB)
{ if ((ptrA->Data).Expo < (ptrB->Data).Expo)
{ if (!Attach(A_PLUS_B, ptrA->Data)) return 0;
ptrA = ptrA->Next;
}
else if ((ptrA->Data).Expo > (ptrB->Data).Expo)
{ if (!Attach(A_PLUS_B, ptrB->Data)) return 0;
ptrB = ptrB->Next;
}
else { TempData.Coef = (ptrA->Data).Coef + (ptrB->Data).Coef;
if (TempData.Coef != 0) //chỉ lưu các số hạng có hệ số khác 0
{ TempData.Expo = ptrtA->Expo;
if (!Attach(A_PLUS_B, TempData)) return 0;
}
ptrA = ptrA->Next;
ptrB = ptrB->Next;
}
}
RestList = ptrA;
if (RestList) RestList = ptrB; // Temp chỉ đến đa thức còn lại có thể chưa hết
while (RestList)
{ if (!Attach(A_PLUS_B, RestList ->Data)) return 0;
RestList = RestList ->Next;
}
return 1;
}

Các thao tác cơ bản khác như: trừ, nhân hai đa thức, lấy thương và phần dư trong phép
chia hai đa thức, … được xem như bài tập.

d. Biểu diễn ma trận thưa nhờ DSLK
Thông thường ta cài đặt ma trận cấp m x n bằng mảng 2 chiều. Nhưng
trong các bài toán thực tế (chẳng hạn các bài toán trong kết cấu xây dựng, kinh tế,
...) ta thường gặp các ma trận thưa (ma trận có rất ít phần tử khác 0) có cấp rất
lớn, cách cài đặt bởi mảng sẽ không hiệu qủa vì lãng phí bộ nhớ (thậm chí còn
không khả thi về tốc độ thực hiện khi phải thao tác và lưu trữ những mảng cực lớn
trên bộ nhớ phụ), do phải chứa quá nhiều các phần tử 0 không chứa đựng nhiều
thông tin đặc trưng của bài toán. Do đó, cần chọn một kiểu cài đặt khác sao cho
chỉ cần lưu lại các phần tử khác 0 của ma trận.

* Ví du: Cho ma trận thưa
9 0 0 8 0
7 0 0 0 0
A = 0 0 0 0 0
Caáu truùc danh saùch lieân keát III.56

-1 6 0 -8 0
Một trong những cách cài đặt là dùng mảng 1 chiều A[m], trong đó mỗi hàng
A[i] là một DSLK chỉ chứa các phần tử khác 0 của hàng thứ i+1 của ma trận, ∀ i
= 0 .. m-1. Mỗi nút của DSLK có cấu trúc:


Col Value Next

Cột Giá trị khác 0
Từ đó, ta có :
A[0]
? ? 1 9 4 8 •

A[1]
? ? 1 7 •

A[2]
? ? •

A[3]
? ? 1 -1 2 6 4 -8 •

• Cài đặt cấu trúc dữ liệu cho ma trận thưa
// m là số dòng của ma trận
#define m ...
typedef double ElememtType; // Kiểu của phần tử của ma trận
typedef NodeType *NodePointer;
typedef struct Node {unsigned int Col;
ElementType Value;
NodePointer Next;
} NodeType;
NodePointer PointerArray[m];
PointerArray A;

Đối với ma trận có rất nhiều dòng bằng 0, cần phải thay đổi cách cài đặt
cho ma trận thưa để việc lưu trữ và các thao tác trên ma trận thưa có hiệu quả hơn
bằng cách sử dụng kiểu DSLK tổng quát, nghĩa là DSLK mà mỗi nút có thể lại là
một kiểu DSLK nào đó. Kiểu DSLK này còn được ứng dụng trong lý thuyết đồ thị,
trí tuệ nhân tạo, …
Sau đây, ta minh họa một ứng dụng của DSLK tổng quát vào bài toán sắp
xếp tôpô sau đây. Qua đó ta càng thấy rõ tính linh hoạt của kiểu DSLK động.

Caáu truùc danh saùch lieân keát III.57

e. Sắp xếp tôpô
Bài toán sắp xếp tôppô dùng để sắp xếp dãy các đối tượng của tập S gồm
hữu hạn phần tử, trên đó có một quan hệ “thứ tự bộ phận” p thỏa 3 tính chất
sau:
1. Nếu x p y và y p z thì x pz (tính bắc cầu)
2. Nếu x p y thì không thể có y p x (tính không đối xứng)
3. Không thể có x p x (tính không phản xạ)
Ta có thể biểu diễn tập S như thế bằng một đồ thị định hướng, không có
chu trình (do hai tính chất đầu ở trên), trong đó mỗi đỉnh là một phần tử của S và
có một cung nối từ x đến y nếu x, y thỏa quan hệ p: x p y.

• Bài toán sắp thứ tự tôpô: là đưa thứ tự bộ phận về thứ tự tuyến tính;
hay sắp xếp các đỉnh của đồ thị thành một hàng sao cho tất cả các mũi
tên nối các cung đều hướng sang phải.
Điều kiện đồ thị không có chu trình bảo đảm đưa thứ tự bộ phận về được
thứ tự tuyến tính.
Bài toán trên có nhiều ứng dụng trong thực tế. Chẳng hạn, khi quản lý một
đề án nào đó, một công việc lớn thường được chia thành nhiều công việc nhỏ.
Thông thường, một việc nhỏ nào đó cần phải được hoàn thành trước các công việc
nhỏ khác. Nếu việc v phải xong trước w, ta ký hiệu v ∝ w. Sắp xếp tôpô là tổ chức
lịch trình thực hiện các công việc sao cho khi thực hiện một công việc nào đó thì
mọi việc mà công việc này cần đều phải đã hoàn thành.

* Ví dụ: Sắp xếp tôpô một tập có quan hệ thứ tự bộ phận được biểu diễn
bởi đồ thị sau:










hoặc được cho bởi dãy các cặp phần tử sau: 1 ∝ 2, 2 ∝ 4, 4 ∝ 6, 2 ∝ 10, 4 ∝ 8, 6
∝ 3, 1 ∝ 3, 3 ∝ 5, 5 ∝ 8, 7 ∝ 5, 7 ∝ 9, 9 ∝ 4, 9 ∝ 10.
Ta sẽ được (không nhất thiết duy nhất) dãy thứ tự tuyến tính:




1
2 10
4 6 8
9
3 5
7
Caáu truùc danh saùch lieân keát III.58

7 9 1 2 4 6 3 5 8 10



• Cài đặt cấu trúc dữ liệu: Mỗi phần tử của tập được biểu diễn bởi cấu
trúc:
typedef int KieuPTu;
typedef struct Leader
{ KieuPTu key;
int count;
struct Leader *next;
struct Trailer *trail;
} LeaderType;
typedef struct Trailer
{ struct Leader *id;
struct Trailer *next;
} TrailerType;
typedef LeaderType *LRef;
typedef TrailerType *TRef;
typedef struct { LRef head, tail;
} LL;
LL leaders;

trong đó: tập các phần tử được lưu trong DSLK leaders kiểu LRef; trường count
dùng để đếm số phần tử đứng trước key; trường trail dùng để lưu địa chỉ phần tử
đầu của dãy các địa chỉ id của các nút chứa các phần tử đứng sau key; dãy các địa
chỉ này được lưu trong DSLK kiểu TRef;

• Thuật toán
Ý tưởng: Bắt đầu chọn một phần tử bất kỳ mà không có phần tử nào đứng
trước nó (luôn chọn được vì đồ thị không có chu trình). Tập còn lại, sau khi loại
phần tử này, vẫn có thứ tự bộ phận và ta tiếp tục áp dụng cách chọn này cho đến
khi tập trở thành rỗng.
Thuật toán gồm 3 giai đoạn:
- Giai đoạn 1: giai đoạn nhập. Lặp lại việc đọc các cặp phần tử của tập S
thỏa quan hệ p và chèn nó vào DSLK leaders, cũng như cập nhật lại
các trường đếm số phần tử đứng trước một nút và thêm vào DSLK (kiểu
trail) các nút chỉ đến nút đứng sau của một nút.
Ta có kết quả của giai đoạn nhập dữ liệu (lấy từ các cặp phần tử trong ví dụ trên)
head tail


Key
1 2 4 6 10 8 3 5 7 9
Caáu truùc danh saùch lieân keát III.59

Count
0 1 2 1 2 2 2 2 0 1
Next

Trail

°

°
x

?
Id
°
Next

°

°

°
°


Id

Next °

°

°

°

°


- Giai đoạn 2: tạo (chẳng hạn, chèn vào đầu) DSLK chứa các phần tử
mà chúng không có phần tử nào đứng trước (cũng gọi là leaders, được
tạo ra theo thứ tự ngược).
Chẳng hạn, với ví dụ trên, ta có:
Leaders.head
1 7
0 0
°


Danh sách các trails lưu địa chỉ các nút đứng sau 7

- Giai đoạn 3: giai đoạn xuất các dãy con có thứ tự bộ phận. Dựa vào
leaders ở giai đoạn 2, duyệt từng nút q: xuất (lấy ra khỏi leaders) và
giảm đi 1 đơn vị cho trường count của mọi nút đứng sau q; nếu q-
>count == 0 thì chèn q vào đầu danh sách leaders.

• Cài đặt
void TopoSortLL()
{ LL leaders;
int SoPTu = 0;//số phần tử của DS leaders
NhapDayCapVaoDSach (leaders, SoPTu);
TachDSCacPTuBatDau(leaders);
TopoSort(leaders,SoPTu);
return;
}

int NhapDayCapVaoDSach (LL & leaders, int &SoPTu)
{ KieuPTu x, y;
LRef p, q;
TRef t;
leaders = CreateEmptyLL2();
while (Nhap1PTu(x))
{ Nhap1PTu(y);
p = TimChen(x, leaders, SoPTu);
Caáu truùc danh saùch lieân keát III.60

q = TimChen(y, leaders, SoPTu);
t = CreateTrailer();
if (p && q && t)
{ t->next = p->trail; p->trail = t; // chèn t vào đầu dãy con p->trail của p
t->id = q; // t trỏ đến nút q chứa phần tử đứng sau phần tử trên nút p
q->count ++;
}
else return 0;
}
return 1;
}

int TachDSCacPTuBatDau(List & leaders)
{ LRef p, q;
p = leaders.head;
leaders.head = NULL;
while (p != leaders.tail)
{ q = p; p = p->next;
if (q->count == 0)
{ q->next = leaders.head;
leaders.head = q; // chèn q vào đầu DS leaders
}
}
return 1;
}

int TopoSort(List &leaders, int &SoPTu)
{ LRef p, q = leaders.head;
TRef t;
while (q)
{ cout << q->key << '\t'; SoPTu --;
t = q->trail; q = q->next;
while (t)
{ p = t->id; p->count --;
if (p->count == 0) //Chen p vao ds q
{ p->next = q; q = p;
}
t = t->next;
}
}
if (SoPTu)
{ cout << "\nTập này không được sắp bộ phận !"; return 0;
}
return 1;
}

LRef TimChen(KieuPTu w, List &leaders, int &SoPTu)
{ LRef h = leaders.head;
(leaders.tail)->key = w; // lưu lính canh ở cuối
Caáu truùc danh saùch lieân keát III.61

(leaders.tail)->next = NULL; (leaders.tail)->trail = NULL;
while (h->key != w) h = h->next;
if (h == leaders.tail) //khong co phan tu co khoa trong DS leaders
{ if ((leaders.tail = CreateLeader()) == NULL) return NULL;
SoPTu ++;
h->count = 0; h->trail = NULL; h->next = ds.tail;
}
return h;
}

#define THOAT 0
int Nhap1PTu(KieuPTu &x)
{
cout << "Nhap 1 ptu:"; cin >> x;
if (x==THOAT) return 0;
else return 1;
}

Như vậy, chúng ta đã làm quen với hai dạng đơn giản của cấu trúc dữ liệu
động là DSLK và cây nhị phân với nhiều cách biểu diễn và cài đặt, cũng như các
thao tác cơ bản và một số ứng dụng của chúng. Các phương pháp tìm kiếm và sắp
xếp đã được giới thiệu trên cấu trúc mảng tĩnh, DSLK động cũng như cấu trúc
cây nhị phân.
Chương IV
CẤU TRÚC CÂY


Trong cấu trúc dữ liệu động được tổ chức theo kiểu tuần tự như danh sách
liên kết, tuy có ưu điểm trong các thao tác chèn, xóa, nhưng tốc độ thực hiện trong
các thao tác truy cập đến các phần tử của nó hay tìm kiếm thường rất chậm. Để
khắc phục các nhược điểm trên nhưng vẫn duy trì các ưu điểm của cấu trúc dữ liệu
động trong các thao tác chèn, xóa, ta có thể dùng một cấu trúc dữ liệu động khác
là cây tìm kiếm được xét trong chương này để lưu trữ và khai thác dữ liệu hiệu
quả hơn.

IV.1. Định nghĩa và các khái niệm cơ bản
IV.1.1. Định nghĩa cây
Cây là một tập hợp N các phần tử gọi là nút (hay đỉnh), trong đó có duy
nhất một đỉnh đặc biệt gọi là gốc, và một tập hợp các cạnh có hướng A (A ⊂ NxN)
nối các cặp nút với nhau gọi là cung hay nhánh. Mỗi nút trên cây đều được nối với
gốc bằng duy nhất một dãy các cặp cung liên liếp.

1 nút gốc ; mức 1


2 3 cha của 5,6,7; mức 2
nút trong

4 5 6 7 mức 3


8 9 nút lá (con của 4); mức 4
(Cây tam phân, có chiều cao là 4)
Bậc của nút 1 là 2, bậc của nút 2 là 1, bậc của nút 3 là 3, bậc của nút 8 là 0.

IV.1.2. Các khái niệm khác
* Mỗi cung a
i
= (n
i
, n
i+1
) ∈ A có hai nút ở đầu, nút trên n
i
gọi là cha, nút
dưới n
i+1
gọi là con.
* Nút gốc là nút (duy nhất) không có nút cha. Mọi nút khác có đúng một nút
cha.
* Một đường đi p từ n
1
đến n
k
là một dãy các đỉnh {n
1
, n
2
, … , n
k
} sao cho:
a
i
= (n
i
, n
i+1
) ∈ A, ∀ i = 1, .. , k-1
* Độ dài đường đi L
x,y
từ x đến y là số cung trên đường đi từ x đến y. Ký
hiệu L
x
là độ dài đường đi từ gốc đến x.
* Độ dài đường đi trung bình của cây là:
Caáu truùc caây IV.2


( Σ L
x
)/n, n là số nút của cây hay số phần tử của N

x ∈ N
trong đó, L
x
là độ dài đường đi từ gốc đến đỉnh x.
* Mọi nút khác gốc được nối với gốc bằng một đường đi duy nhất bắt đầu từ
gốc và kết thúc ở nút đó. Trong cây không có chu trình.
* Bậc của nút là số cây con của nút đó.
* Bậc của cây là bậc lớn nhất của các nút của cây. Cây bậc n gọi là cây n -
phân.
* Nút trong là nút có bậc lớn hơn không. Nút lá là nút có bậc bằng không. Mỗi
nút trong cùng với các con của nó tạo thành cây con.
* Mức của 1 nút (khác nút gốc) là số đỉnh trên đường đi từ gốc đến nút đó.
Mức của nút gốc bằng 1:
Mức(gốc) = 1;
Mức(con) = Mức(cha) + 1, ∀ (cha,con) ∈ A
* Chiều cao của một cây là mức lớn nhất của các nút lá.

* Ví dụ: cây có nhiều ứng dụng để biểu diễn các loại dữ liệu trong thực tế. Chẳng
hạn:
- Biểu thức số học: ((a*b)+c)/((d*e)+(f-g)) được biểu diễn dưới dạng cây.
Ta biểu diễn: toán tử bởi nút gốc và toán hạng bởi nút lá.
/

+ +

* c * -

a b d e f g
- Sơ đồ tổ chức của một quốc gia, địa phương hay cơ quan cũng có dạng
cây.
- Mục lục sách theo hệ thống phân loại nào đó, …

* Cây có thứ tự : là cây mà các nút của nó được xếp theo thứ tự nào đó và
có để ý đến vị trí (thứ tự) của các nút con.
Trong cây có thứ tự khi ta thay đổi vị trí của các cây con thì ta sẽ có một
cây mới. Chẳng hạn, hai cây có thứ tự sau đây được xem là khác nhau:
+ +

* c c *

a b a b

Caáu truùc caây IV.3


* Cây nhị phân: là cây mà mỗi nút có tối đa 2 nút con (con trái và con phải;
do phân biệt vị trí các nút nên cây nhị phân được xem là cây có thứ tự ).
* Từ một cây có tổng quát (cây n- phân) ta có thể chuyển về cây nhị phân
(xem II.6.) nghĩa là có thể dùng cây nhị phân để biểu diễn cây tổng quát. Do tính
chất đơn giản và tầm quan trọng như vậy, trước hết ta khảo sát cây nhị phân.


IV.2. Cây nhị phân
IV.2.1. Định nghĩa: cây nhị phân là cây (có thứ tự) mà số lớn nhất các nút
con của các nút là 2.
Ta còn có thể xem cây nhị phân như là một cấu trúc dữ liệu đệ qui.
* Định nghĩa đệ qui: Một cây nhị phân (Binary tree) :
+ hoặc là rỗng ( phần neo hay trường hợp cơ sở);
+ hoặc là một nút mà nó có 2 cây con nhị phân không giao nhau, gọi là cây
con bên trái và cây con bên phải (phần đệ qui).

IV.2.2. Vài tính chất của cây nhị phân
Gọi h và n lần lượt là chiều cao và số phần tử của cây nhị phân.
- Số nút ở mức i ≤ 2
i-1
, hay nói chính xác hơn số nút tối đa ở mức i là 2
i-1
.
Do đó, số nút lá tối đa của nó là 2
h-1
.
- Số nút tối đa trong cây nhị phân là 2
h
–1, hay n ≤ 2
h
–1.
Do đó, chiều cao của nó: n ≥ h ≥ log
2
(n+1)

IV.2.3. Biểu diễn cây nhị phân
Ta chọn cấu trúc động để biểu diễn mỗi nút trên cây nhị phân:

LChild RChild

Data

trong đó: LChild, RChild lần lượt là các con trỏ chỉ đến nút con bên trái và nút con
phải. LChild hay RChild là con trỏ rỗng nếu không có nút con bên trái hay bên
phải.

Nút lá có dạng:
LChild RChild

• Data •


Trong ngôn ngữ C hay C++, ta khai báo kiểu dữ liệu cho một nút của cây
nhị phân như sau:
Caáu truùc caây IV.4


typedef ..... ElementType; /* Kiểu mục dữ liệu của nút */
typedef struct TN { ElementType Data; //Để đơn giản, ta xem Data là trường khóa của dữ liệu
struct TN * LChild, *RChild;
} TreeNode;
typedef TreeNode *TreePointer;

* Ví dụ: Ta biểu diễn biểu thức số học: a * b + c bởi cây nhị phân:

+

* c

a b




+ Nút gốc

* • c •

• a • • b •

Trong các thuật toán thuộc chương này, ta sẽ sử dụng hàm CấpPhát() để
cấp phát vùng nhớ cho một nút mới của cây nhị phân. Hàm trả về địa chỉ bắt đầu
vùng nhớ được cấp phát cho một nút nếu việc cấp phát thành công và trả trị NULL
nếu ngược lại. Trong C++, hàm trên có thể được viết như sau:

TreePointer CấpPhát ()
{TreePointer Tam= new TreeNode;
if (Tam == NULL)
cout << “\nLỗi cấp phát vùng nhớ cho một nút mới của cây nhị phân !”;
return Tam;
}

IV.2.4. Duyệt cây nhị phân
IV.2.4.1. Định nghĩa: Duyệt qua cây nhị phân là quét qua mọi nút của cây
nhị phân sao cho mỗi nút được xử lý đúng một lần.
Dựa vào định nghĩa đệ qui ta chia cây nhị phân ra làm 3 phần: gốc, cây con
bên trái, cây con bên phải. Ta có 3 phương pháp chính duyệt cây nhị phân tùy
theo trình tự duyệt 3 phần trên:
+ Duyệt qua theo thứ tự giữa (LNR)
Caáu truùc caây IV.5


+ Duyệt qua theo thứ tự đầu (NLR)
+ Duyệt qua theo thứ tự cuối (LRN).
trong đó:
L : quét cây con trái của một nút
R : quét cây con phải của một nút
N : xử lý nút.

IV.2.4.2. Các thuật toán duyệt cây nhị phân
* Thuật toán duyệt qua theo thứ tự giữa (LNR: Trái - Gốc - Phải) :
+Duyệt qua cây con trái theo thứ tự giữa;
+Duyệt qua gốc;
+Duyệt qua cây con phải theo thứ tự giữa.
* Thuật toán duyệt qua theo thứ tự đầu (NLR: Gốc - Trái - Phải):
+Duyệt qua gốc;
+Duyệt qua cây con trái theo thứ tự đầu;
+Duyệt qua cây con phải thứ tự đầu.
Thuật toán NLR sẽ duyệt cây theo chiều sâu.
* Thuật toán duyệt qua theo thứ tự cuối (LRN: Trái - Phải - Gốc):
+Duyệt qua cây con trái theo thứ tự cuối;
+Duyệt qua cây con phải theo thứ tự cuối;
+Duyệt qua gốc.

* Ví dụ: Biểu diễn biểu thức: A - B * C + D lên cây nhị phân:
+

- D

A *


B C

Duyệt cây theo các thứ tự khác nhau:
LNR: A - B * C + D ( biểu thức trung tố )
NLR: + - A * B C D ( biểu thức tiền tố )
LRN: A B C * - D + ( biểu thức hậu tố )

Với cách biểu diễn một biểu thức số học dưới dạng cây nhị phân, dựa trên
cách duyệt LRN ta có thể tính giá trị của biểu thức đó (Bài tập).
Do định nghĩa đệ quy của cây nhị phân, các thuật toán duyệt qua cây theo
kiểu đệ quy là thích hợp.

Caáu truùc caây IV.6


IV.2.4.3. Cài đặt thuật toán duyệt qua cây nhị phân LNR
a. Cài đặt thuật toán LNR dưới dạng đệ qui :
/* Input: - Root : con trỏ chỉ đến nút gốc của cây nhị phân
Output: - Duyệt qua và xử lý mọi nút của cây nhị phân theo thứ tự giữa LNR
*/
void LNRĐệQuy (TreePointer Root)
{ if (Root != NULL)
{ LNRĐệQuy (Root->LChild);
Xử lý (Root); //Xử lý theo yêu cầu cụ thể, chẳng hạn: Xuất(Root->Data);
LNRĐệQuy (Root->RChild) ;
}
return;
}

Thuật toán duyệt cây nhị phân theo thứ tự giữa (LNR) có thể viết lại dưới
dạng lặp, bằng cách sử dụng một stack để lưu lại địa chỉ các nút gốc trước khi đi
đến cây con trái của nó. Trước hết, ta khai báo cấu trúc một nút của stack trên:
typedef struct NS { TreePointer Data;
struct NS * Next;
} NodeStack;
typedef NodeStack * StackType;

b. Cài đặt thuật toán LNR dưới dạng lặp :
/* Input: - Root : con trỏ chỉ đến nút gốc của cây nhị phân
Output: - Duyệt qua và xử lý mọi nút của cây nhị phân theo thứ tự giữa LNR
*/

void LNRLap(TreePointer Root)
{ TreePointer p;
int TiepTuc = 1;
StackType S;
p = Root; S = CreateEmptyStack(); // Khởi tạo ngăn xếp rỗng
do
{ while (p != NULL)
{ Push(S,p); // Đẩy p vào stack S
p = p->LChild;
}
if (!EmptyStack(S)) // Nếu stack S khác rỗng
{ Pop(S,p); // Lấy ra phần tử p ở đỉnh stack S
XuLy(p);
p = p->RChild;
}
Caáu truùc caây IV.7


else TiepTuc = 0;
} while (TiepTuc);
return ;
}

Với hai trường hợp duyệt cây còn lại (NLR và LRN), ta cũng có thể cài đặt
chúng dưới dạng đệ quy và lặp (bài tập). Một cách tổng quát, ta có thể viết lại ba
thuật toán duyệt này dưới một dạng lặp duy nhất (bài tập).

IV.2.5. Một cách biểu diễn khác của cây nhị phân
Trong một số trường hợp, khi biểu diễn cây nhị phân, người ta không chỉ
quan tâm đến quan hệ một chiều từ cha đến con mà cả chiều ngược lại: từ con đến
cha. Khi đó, ta có thể dùng cấu trúc sau:
Parent

Data

LChild RChild

trong đó: LChild, RChild lần lượt là các con trỏ chỉ đến nút con trái và nút con
phải. Parent là con trỏ chỉ đến nút cha.
Trong ngôn ngữ C hay C++, ta khai báo kiểu dữ liệu cho một nút của cây
nhị phân dạng này như sau:
typedef ..... ElementType; /* Kiểu mục dữ liệu của nút */
typedef struct TNP {ElementType Data; //Để đơn giản, ta xem Data là trường khóa của dữ
liệu
struct TNP * LChild, *Rchild, *Parent;
} TreeNodeP;
typedef TreeNodeP *TreePointer;

* Ví dụ:

e

f c

a b d


IV.2.6. Biểu diễn cây n - phân bởi cây nhị phân.
Phương pháp cài đặt cây n - phân bằng mảng có n vùng liên kết chỉ có lợi
khi hầu hết các nút của cây có bậc là n. Khi đó n vùng liên kết đều được sử dụng,
Caáu truùc caây IV.8


nhưng với cây có nhiều nút có bậc nhỏ hơn n sẽ gây nên việc lãng phí bộ nhớ vì
có nhiều vùng liên kết không sử dụng tới.
Do cây nhị phân là cấu trúc dữ liệu cây cơ bản và đơn giản đã được nghiên
cứu, nên để mô tả cây n-phân, người ta tìm cách biểu diễn nó thông qua cây nhị
phân. Gọi: T là cây n-phân, T2 là cây nhị phân tương ứng với T. Ta gọi các nút
con của cùng một nút là anh em với nhau. Để biểu diễn T bằng T2, ta theo các qui
tắc sau:
+ Nút gốc trong T được biểu diễn tương ứng với nút gốc của T2.
+ Con đầu tiên (trái nhất) của một nút trong T là con trái của nút tương ứng
trong T2.
+ Nút anh em kề phải P của một nút Q trong T tương ứng với một nút P2
trong T2 qua liên kết phải của nút Q2 tương ứng trong T2.

Cây n-phân T
a

Q b P c d

e f g h i

j k l m n


a cây nhị phân T2 tương ứng
Q2 P2
b c d

e f g h i

j k l m n


IV.2.7. Xây dựng cây nhị phân cân bằng hoàn toàn

IV.2.7.1. Định nghĩa: Cây nhị phân cân bằng hoàn toàn (CBHT) là cây nhị
phân mà đối với mỗi nút của nó, số nút của cây con trái chênh lệch không quá 1 so
với số nút của cây con phải.

* Ví dụ:

e

Caáu truùc caây IV.9


f c

a b d


IV.2.7.2. Xây dựng cây nhị phân cân bằng hoàn toàn
Xây dựng cây nhị phân cân bằng hoàn toàn có n phần tử:
TreePointer TạoCâyCBHT(Nguyên n)
{ TreePointer Root;
Nguyên nl, nr;
ElementType x;
if (n<=0) return NULL;
nl = n/2; nr = n-nl-1;
Nhập1PhầnTử(x);
if ((Root =CấpPhát()) == NULL) return NULL;
Root->Data = x;
Root->LChild = TạoCâyCBHT(nl);
Root->RChild = TạoCâyCBHT(nr);
return Root;
}

* Nhận xét:
- Một cây CBHT có n nút sẽ có chiều cao bé nhất h ≈ log
2
n.
- Một cây CBHT rất dễ mất cân bằng sau khi thêm hay hủy các nút trên
cây, việc chi phí cân bằng lại cây rất lớn vì phải thao tác lại trên toàn
bộ cây. Do đó cây CBHT có cấu trúc kém ổn định, ít được sử dụng
trong thực tế.


IV.3. Cây nhị phân tìm kiếm (BST)
IV.3.1. Định nghĩa cây nhị phân tìm kiếm (BST)
Cây BST là một cây nhị phân có tính chất giá trị khóa ở mỗi nút lớn hơn
giá trị khoá của mọi nút thuộc cây con bên trái (nếu có) và nhỏ hơn giá trị khoá
của mọi nút thuộc cây con bên phải (nếu có) của nó.

* Ví dụ: Xét cây BST sau đây lưu các giá trị: 46, 17, 63,2, 25, 97. Ta biểu diễn
quá trình tìm kiếm 2 phần tử 25, 55 trên cây BST qua hình dưới đây:


46
25<46 55>46 (không thấy 55)
17 63
Caáu truùc caây IV.10


25>17 (thấy 25)
2 25 97

Với loại cấu trúc dữ liệu động danh sách liên kết, ta rất khó áp dụng hiệu
qủa ý tưởng tìm kiếm nhị phân trên mảng. Nhưng với loại cấu trúc dữ liệu động
cây BST thì việc thể hiện ý tưởng này là đơn giản.

IV.3.2. Tìm kiếm một phần tử trên cây BST
(Thuật toán tìm kiếm nhị phân sau đây tương tự phép tìm kiếm nhị phân trên mảng).
IV.3.2.1. Thuật toán tìm kiếm dạng đệ qui:
/* Input: - Root: con trỏ chỉ đến nút gốc của cây BST.
- Item: giá trị khóa của phần tử cần tìm .
Output: - Trả về con trỏ LocPtr chỉ đến 1 nút trên cây BST chứa Item nếu tìm
thấy Item trên cây BST
- Trả trị NULL nếu ngược lại */
TreePointer TìmBSTĐệQuy (TreePointer Root, ElementType Item)
{
if (Root)
{if (Item== Root->Data) return Root;
else if (Item > Root->Data) return TìmBSTĐệQuy (Root-
>RChild,Item);
else return TìmBSTĐệQuy (Root->LChild,Item);
}
else return(NULL);
}

* Thủ tục được viết dưới dạng đệ qui thích hợp với lối tư duy tự nhiên của
giải thuật và định nghĩa đệ qui của cây nhị phân. Song trong trường hợp này thủ
tục viết dưới dạng lặp lại tỏ ra hiệu quả hơn.

IV.3.2.2. Thuật toán tìm kiếm dạng lặp:
/* Input: - Root: con trỏ chỉ đến nút gốc của cây BST.
- Item: giá trị khóa của phần tử cần tìm .
Output: - Trả về con trỏ LocPtr chỉ đến 1 nút trên cây BST chứa Item và con trỏ
Parent chỉ đến nút cha của nút chứa Item đó nếu tìm thấy Item trên cây
BST
- Trả trị NULL nếu ngược lại */
TreePointer TìmBSTLặp(TreePointer Root, ElementType Item, TreePointer &Parent)
{ TreePointer LocPtr = Root;
Parent = NULL;
while (LocPtr != NULL)
if (Item==LocPtr->Data) return (LocPtr);
Caáu truùc caây IV.11


else {Parent = LocPtr;
if (Item > LocPtr->Data) LocPtr = LocPtr->RChild;
else LocPtr = LocPtr->LChild;
}
return(NULL);
}

Với cấu trúc cây, việc tìm kiếm theo khóa sẽ nhanh hơn nhiều so với cấu
trúc danh sách liên kết. Chi phí tìm kiếm (độ phức tạp) trung bình trên cây nhị
phân có n nút khoảng log
2
n.


IV.3.3. Chèn một phần tử vào cây BST, xây dựng cây BST
Việc chèn thêm một phần tử Item vào cây BST cần phải thỏa ràng buộc
trong định nghĩa cây BST. Trước khi chèn Item, ta cần tìm khóa của Item có trong
cây BST hay không, nếu có thì khỏi chèn (do trên cây BST ta chỉ chứa những
phần tử có khóa khác nhau); nếu ngược lại, khi chấm dứt thao tác tìm kiếm thì ta
cũng biết được vị trí chèn (ở nút lá).

* Ví dụ: Giả sử ta đã có cây BST (với các nút có khóa khác nhau):
O

E T

C M P U


Ta cần thêm phần tử ‘R’:

O
(R > O)
E (R<T) T

C M P U
(R>P)
R
Parent
Yêu cầu “vào – ra” của thao tác chèn:
/* Input: - Root: con trỏ chỉ đến nút gốc của cây BST.
- Item: giá trị dữ liệu của nút cần chèn
Output: - Trả trị 1 và con trỏ Root chỉ đến nút gốc mới của cây BST nếu chèn được
- Trả trị -1 nếu Item đã có trên cây
Caáu truùc caây IV.12


- Trả trị 0 nếu gặp lỗi cấp phát bộ nhớ cho một nút mới của cây */

IV.3.3.1. Thao tác chèn một nút Item vào cây BST (dạng lặp):
int ChènBSTLặp(TreePointer &Root, ElementType Item)
{ TreePointer LocPtr, Parent;
if (TìmBSTLặp(Root, Item, Parent))
{ cout << “\nĐã có phần tử “<< Item << “ trong cây !“ ;
return -1;
}
else { if ((LocPtr=CấpPhát ())==NULL) return 0;
LocPtr->Data = Item;
LocPtr->LChild = NULL; LocPtr->RChild = NULL;
if (Parent == NULL)
Root = LocPtr; // cây rỗng
else if (Item < Parent->Data) Parent->LChild = LocPtr;
else Parent->RChild = LocPtr;
return 1;
}
}

IV.3.3.2. Thủ tục chèn một nút Item vào cây BST (dạng đệ qui):
int ChènBSTĐệQui(TreePointer &Root, ElementType Item)
{ TreePointer LocPtr;
if (Root == (TreePointer) NULL) // chèn nút vào cây rỗng
{ if ((Root = CấpPhát ()) == NULL) return 0;
Root ->Data = Item;
Root ->LChild = NULL; Root ->RChild = NULL;
}
else if (Item < Root->Data) ChènBSTĐệQui (Root->LChild,Item);
else if (Item > Root->Data) ChènBSTĐệQui(Root->RChild,Item);
else { cout << “\nĐã có phần tử “<< Item << “ trong cây”;
return -1;
}
return 1;
}

IV.3.3.3. Xây dựng cây BST
Ta có thể xây dựng cây BST bằng cách lặp lại thao tác chèn một phần tử
vào cây BST trên đây, xuất phát từ cây rỗng. Hàm TạoCâyBST(Root) sau đây trả
về trị 0 nếu gặp lỗi cấp phát vùng nhớ cho một nút mới của cây Root và trả về trị 1
nếu việc chèn các nút vào cây thành công (không chèn các nút có khóa đã trùng
với khóa của nút đã chèn).
Caáu truùc caây IV.13


int TạoCâyBST(PointerType &Root)
{ ElementType Item;
Root = NULL;
while (CònLấyDữLiệu(Item))
if (!ChènBSTLặp(Root, Item)) return 0;
return 1;
}

IV.3.4. Phương pháp sắp xếp bằng cây BST
Ta nhận xét rằng sau khi duyệt một cây BST theo thứ tự giữa LNR thì ta sẽ
thu được một dãy tăng theo khóa. Từ đó, ta có phương pháp sắp xếp dựa trên cây
BST như sau. Giả sử ta cần sắp xếp dãy X các phần tử.
* Giải thuật BSTSort:
- Bước 1: Đưa lần lượt mọi phần tử của dãy X lên cây BST.
- Bước 2: Khởi tạo lại dãy rỗng X. Duyệt cây BST theo thứ tự giữa (LNR),
trong đó thao tác XửLý(Nút) lưu Nút->Data vào phần tử tiếp theo của dãy
X.

* Ví dụ: Giả sử cần sắp xếp một dãy gồm n phần tử được lưu trong mảng
X. Khi đó ta có thuật toán sau:

1.Khởi tạo cây BST rỗng.
2.for (i = 0; i< n; i++) Chèn X[i] vào cây BST;
3.Đặt lại i = 0;
4.Duyệt qua theo thứ tự giữa LNR, việc XửLý(Nút) một nút khi duyệt qua
cây là:
- Gán X[i] ← Nút->Data;
- Tăng i lên 1;

IV.3.5. Xóa một phần tử khỏi cây BST, hủy cây nhị phân
Giả sử ta cần xóa một nút (trên cây BST) được trỏ bởi x. Việc xoá một
phần tử trên cây BST cũng cần phải thoả các ràng buộc về cây BST, nhưng việc
xóa phức tạp hơn so với chèn. Ta phân biệt 3 trường hợp : x trỏ đến nút lá, x trỏ
đến nút chỉ có một con, x trỏ đến nút có hai con.
a). Xoá nút lá:

C x C
Xoá nút lá D
B D B NULL

- Đặt con trỏ phải (hay trái) của nút cha của x thành NULL
- Giải tỏa nút D
Caáu truùc caây IV.14


b). Xoá nút có một nút con:
- Đặt con trỏ phải (hoặc trái) của nút cha của nút cần xóa trỏ đến nút con
khác rỗng của nút cần xóa
- Giải tỏa nút cần xóa
Giả sử ta cần xóa nút trong E có một nút con:
C x C
Xoá nút E
B E có 1 nút con B D

D
Kết hợp hai trường hợp trên thành một trường hợp: x trỏ đến nút có nhiều
nhất một cây con khác rỗng. Gọi:
+ x chỉ đến nút cần xóa
+ SubTree chỉ đến cây con (khác rỗng , nếu có) của x
+ Parent chỉ đến nút cha của nút được trỏ bởi x (nếu x chỉ đến gốc thì
Parent=NULL).
Ta có giải thuật xóa cho trường hợp này là:
SubTree = x->LChild;
if (SubTree == NULL ) SubTree = x->RChild;
//SubTree là cây con khác rỗng (nếu có) của x
if (Parent == NULL) Root = SubTree; // xoá nút gốc
else if (Parent->LChild == x) Parent->LChild = SubTree ;
else Parent->RChild = SubTree;
delete x;
c). Xoá nút có hai nút con:
Giả sử ta cần xoá nút E có 2 nút con của cây BST sau :
C
x
B E

D K

(Nút kế tiếp E I L
theo thứ tự giữa)
J
Đưa về 1 trong 2 trường hợp đầu bằng cách sau: Thay trị của nút mà x trỏ
đến bởi trị của nút kế tiếp theo thứ tự giữa (nút kế tiếp là nút cực trái xa nhất
theo nhánh con phải của x, hay là nút nhỏ nhất (tất nhiên là theo trường khóa)
trong số những nút lớn hơn x->Data). Sau đó xoá nút kế tiếp này (nút kế tiếp này
sẽ là nút có tối đa 1 nút con ).

C
Caáu truùc caây IV.15


x
B E (Thay E bởi I)

D K

(Xóa nút I) I L

J



C
x
B I

D K

J L


* Sau đây ta xây dựng thủ tục XóaBST để xóa một nút Item trong một cây
BST. Trong thủ tục này có dùng đến thủ tục TìmBSTLặp. Thủ tục XoáBST tìm nút
có khóa Item và xoá nó khỏi cây BST.
Gọi:
- x: trỏ đến nút chứa Item
- xSucc: phần tử kế tiếp của x theo thứ tự giữa (nếu x có 2 con)
- Parent: trỏ đến cha của x hay xSucc
- SubTree: trỏ đến cây con của x.
/* Input: - Root: con trỏ chỉ đến nút gốc của cây BST.
- Item: giá trị dữ liệu của nút cần xóa
Output: - Trả trị 1 và con trỏ Root chỉ đến nút gốc mới của cây BST nếu tìm thấy
nút
chứa Item và xoá được
- Trả trị 0 nếu ngược lại */


int XóaBST (TreePointer &Root, ElementType Item)
{ TreePointer x,Parent, xSucc,SubTree;
if ((x = TìmBSTLặp(Root,Item,Parent)) ==NULL) return 0;//không thấy Item
else { if ((x->LChild != NULL) && (x->RChild != NULL)) // nút có 2 con
{ xSucc = x->RChild;
Parent = x;
Caáu truùc caây IV.16


while (xSucc->LChild != NULL)
{ Parent = xSucc;
xSucc = xSucc->LChild;
}
x->Data = xSucc->Data; x = xSucc;
} //đã đưa nút có 2 con về nút có tối đa 1 con
SubTree = x->LChild;
if (SubTree == NULL) SubTree = x->RChild;
if (Parent == NULL) Root = SubTree; // xoá nút gốc
else if (Parent->LChild == x) Parent->LChild = SubTree;
else Parent->RChild = SubTree;
delete x;
return 1;
}
}


Ta có thể hủy toàn bộ cây BST bằng cách sử dụng ý tưởng duyệt cây theo
thứ tự cuối LRN: hủy cây con trái, hủy cây con phải rồi mới hủy nút gốc.

void HủyCâyNhịPhân (PointerType &Root)
{ if (Root)
{ HủyCâyNhịPhân (Root->LChild);
HủyCâyNhịPhân (Root->RChild);
delete Root;
}
return ;
}


IV.4. Cây nhị phân tìm kiếm cân bằng
Trên cây nhị phân tìm kiếm BST có n phần tử mà là cây CBHT (cân bằng
hoàn toàn), phép tìm kiếm một phần tử trên nó sẽ thực hiện rất nhanh: trong
trường hợp xấu nhất, ta chỉ cần thực hiện log
2
n phép so sánh. Nhưng cây CBHT
có cấu trúc kém ổn định trong các thao tác cập nhật cây, nên nó ít được sử dụng
trong thực tế. Vì thế, người ta tận dụng ý tưởng cây CBHT để xây dựng một cây
nhị phân tìm kiếm có trạng thái cân bằng yếu hơn, nhưng việc cân bằng lại chỉ xảy
ra ở phạm vi cục bộ đồng thời chi phí cho việc tìm kiếm vẫn dạt ở mức O(log
2
n).
Đó là cây nhị phân tìm kiếm cân bằng.

IV.4.1. Định nghĩa
Caáu truùc caây IV.17


Cây nhị phân tìm kiếm gọi là cây nhị phân tìm kiếm cân bằng (gọi tắt là cây
cân bằng hay cây AVL do 3 tác giả Adelson-Velskii-Landis đưa ra vào năm 1962)
nếu tại mỗi nút của nó, độ cao của cây con trái và độ cao của cây con phải chênh
lệch không quá 1.
Rõ ràng, một cây nhị phân tìm kiếm cân bằng hoàn toàn là cây cân bằng,
nhưng điều ngược lại không đúng. Chẳng hạn cây nhị phân tìm kiếm trong ví dụ
sau là cân bằng nhưng không phải là cân bằng hoàn toàn:

* Ví dụ: (cây nhị phân tìm kiếm cân bằng nhưng không cân bằng hoàn
toàn)

O

E T

C M

Cây cân bằng AVL vẫn thực hiện việc tìm kiếm nhanh tương đương cây (nhị
phân tìm kiếm) cân bằng hoàn toàn và vẫn có cấu trúc ổn định hơn hẳn cây cân
bằng hoàn toàn mà nó được thể hiện qua các thao tác cơ bản sẽ được trình bày
trong các phần tiếp theo.

IV.4.2. Chiều cao của cây cân bằng
* Định lý (AVL): Gọi h
b
(n) là độ cao của cây AVL có n nút, khi đó:
log
2
(n+1) ≤ h
b
(n) < 1.4404 * log
2
(n+2) –0.3277
Cây AVL là tối ưu (trong trường hợp tốt nhất, nó có chiều cao bé nhất) khi nó là
cây cân bằng hoàn toàn có n nút với: n = 2
k
-1. Một cây AVL không bao giờ cao
quá 45% cây cân bằng hoàn toàn tương ứng của nó.
Chứng minh: Bất đẳng thức thứ nhất ở bên trái có được do tính chất của cây nhị phân
(phần II.2).
Để chứng minh bất đẳng thức thứ hai ở bên phải, ta gọi N(h) là số nút ít nhất của cây
AVL T(h) có chiều cao h.
Ta có: N(0) = 0 ứng với cây rỗng T(0) và N(1) = 1 ứng với cây chỉ có 1 nút T(1). Khi h >
1, gốc của cây T(h) sẽ có hai cây con cũng có số nút ít nhất, một cây có chiều cao là h -1, cây con
kia có chiều cao là h -2. Do đó:
N(h) = 1 + N(h –1) + N(h –2), ∀ h >1
N(0) = 0, N(1) = 1.

Đặt F(h) = N(h) + 1. Khi đó:

F(h) = F(h –1) + F(h –2), ∀ h >1
F(0) = 1, F(1) = 2.

Giải hệ thức truy hồi trên (bằng cách nào ? Bài tập), ta được:
n + 1 ≥ N(h) + 1 = F(h) = (r
1
h+2
– r
2
h+2
) / 5 > (r
1
h+2
– 1) / 5
Caáu truùc caây IV.18


với: r
1
= (1+ 5 ) /2, r
2
= (1 - 5 ) /2 ∈ (-1; 1)
=> h +2 < log
r1
(1+ 5 (n + 1)) < log
r1
( 5 (n + 2)) < log
r1
(n + 2) + log
r1
( 5 )
h < log
2
(n + 2)/ log
2
(r
1
) + log
r1
( 5 ) - 2 ≈ 1.44042 log
2
(n + 2) – 0.32772
Vậy một cây AVL có n nút sẽ có chiều cao tối đa (trong trường hợp xấu
nhất) là O(log
2
n).


IV.4.3. Chỉ số cân bằng và việc cân bằng lại cây AVL

* Định nghĩa: Chỉ số cân bằng (CSCB) của một nút p là hiệu của chiều cao
cây con phải và cây con trái của nó.
Ký hiệu: h
L
(p) hay h
L
là chiều cao cây con trái (của p),
h
R
(p) hay h
R
là chiều cao cây con phải (của p),
EH = 0, RH = 1, LH = -1.
CSCB(p) = EH Ù h
R
(p) =h
L
(p):2 cây con cao bằng nhau
CSCB(p) = RH Ù h
R
(p) > h
L
(p) : cây lệch phải
CSCB(p) = LH Ù h
R
(p) < h
L
(p) : cây lệch trái

Với mỗi nút của cây AVL, ngoài các thuộc tính thông thường như cây nhị
phân, ta cần lưu thêm thông tin về chỉ số cân bằng trong cấu trúc của một nút:
typedef ..... ElementType; /* Kiểu mục dữ liệu của nút */
typedef struct AVLTN { ElementType Data; //Ở đây ta xem Data là trường khóa của dữ liệu
int Balfactor; //Chỉ số cân bằng
struct AVLTN * Lchild, *Rchild;
} AVLTreeNode;
typedef AVLTreeNode *AVLTree;

Việc thêm hay hủy một nút trên cây AVL có thể làm cây tăng hay giảm
chiều cao, khi đó ta cần phải cân bằng lại cây. Để giảm tối đa chi phí cân bằng lại
cây, ta chỉ cân bằng lại cây AVL ở phạm vi cục bộ.


Các trường hợp mất cân bằng

Ngoài các thao tác thêm và hủy, đối với cây cân bằng, ta còn có thêm thao
tác cơ bản là cân bằng lại cây AVL trong trường hợp thêm hoặc hủy một nút của
nó. Khi đó, độ lệch giữa chiều cao cây con phải và trái sẽ là 2. Do các trường hợp
cây lệch trái và phải tương ứng là đối xứng nhau, nên ta chỉ xét trường hợp cây
AVL lệch trái.

Trường hợp a: cây con T1 lệch trái

T
Caáu truùc caây IV.19




h-1
L R R


h L1 R1 h-1





Trường hợp b: cây con T1 lệch phải



h-1
L R R


h-1 L1 R1 h





Trường hợp c: cây con T1 không lệch



h-1
L R


h L1 R1 h




Việc cân bằng lại trong trường hợp b (cây con T1 lệch phải) là phức tạp
nhất.

T1
T
T1
T
T1
Caáu truùc caây IV.20


Trường hợp a: cây con T1 lệch trái



h-1
L R R


h L1 R1 h-1





Cân bằng lại bằng phép quay đơn Left-Left, ta được cây T1 không lệch:




h L1 h+1


h-1 R1 R h-1


Trường hợp c: cây con T1 không lệch



h-1
L R R


h L1 R1 h





Cân bằng lại bằng phép quay đơn Left-Left (khi đó ta được cây T1 lệch phải):


T
T1
T
T1
T
T1
T
T1
Caáu truùc caây IV.21




h L1 h+2


h R1 R h-1



Trường hợp b: cây con T1 lệch phải, biểu diễn lại cây R1 = <L2, T2, R2> như sau:



h-1
L R


h-1 L1

R1 h

L2 R2




Cân bằng lại bằng phép quay kép Left – Right, ta được cây T2 không lệch như sau:



h+1


h-1 L1 L2 R2 R h-1



* Nhận xét:
- Trước khi cân bằng lại, cây T lệch (và mất cân bằng) và có chiều cao là
h+2 trong cả 3 trường hợp. Nhưng sau khi cân bằng lại cây T, nó vẫn
lệch (lệch phải, nhưng tất nhiên vẫn cân bằng) và có chiều cao là h+2
chỉ trong trường hợp c; còn trong hai trường hợp a và b, cây T mới (là
T
T1
T2
T
T1
T2
Caáu truùc caây IV.22


T1 hay T2 tương ứng với trường hợp a hay b) không lệch và có chiều
cao là h+1.
- Các thao tác cân bằng lại trong mọi trường hợp đều có độ phức tạp là
O(1).

Sau đây là phần cài đặt các phép quay đơn và kép cho cây T mất cân bằng
trong hai trường hợp nó bị lệch trái và lệch phải.
//Phép quay đơn Left – Left
void RotateLL(AVLTree &T)
{ AVLTree T1 = T->Lchild;
T->Lchild = T1->Rchild;
T1->Rchild = T;
switch (T1->Balfactor)
{case LH: T->Balfactor = EH;
T1->Balfactor = EH; break;
case EH: T->Balfactor = LH;
T1->Balfactor = RH; break;
}
T = T1;
return ;
}

//Phép quay đơn Right – Right
void RotateRR (AVLTree &T)
{ AVLTree T1 = T->Rchild;
T->Rchild = T1->Lchild;
T1->Lchild = T;
switch (T1->Balfactor)
{case RH: T->Balfactor = EH;
T1->Balfactor = EH; break;
case EH: T->Balfactor = RH;
T1->Balfactor = LH; break;
}
T = T1;
return ;
}

//Phép quay kép Left – Right
void RotateLR(AVLTree &T)
{ AVLTree T1 = T->Lchild, T2 = T1->Rchild;
T->Lchild = T2->Rchild; T2->Rchild = T;
T1->Rchild = T2->Lchild; T2->Lchild = T1;
Caáu truùc caây IV.23


switch (T2->Balfactor)
{case LH: T->Balfactor = RH;
T1->Balfactor = EH; break;
case EH: T->Balfactor = EH;
T1->Balfactor = EH; break;
case RH: T->Balfactor = EH;
T1->Balfactor = LH; break;
}
T2->Balfactor = EH;
T = T2;
return ;
}

//Phép quay kép Right-Left
void RotateRL(AVLTree &T)
{ AVLTree T1 = T->RLchild, T2 = T1->Lchild;
T->Rchild = T2->Lchild; T2->Lchild = T;
T1->Lchild = T2->Rchild; T2->Rchild = T1;
switch (T2->Balfactor)
{case LH: T->Balfactor = EH;
T1->Balfactor = RH; break;
case EH: T->Balfactor = EH;
T1->Balfactor = EH; break;
case RH: T->Balfactor = LH;
T1->Balfactor = EH; break;
}
T2->Balfactor = EH;
T = T2;
return ;
}

Sau đây là thao tác cân bằng lại khi cây bị lệch trái hay lệch phải.
//Cân bằng lại khi cây bị lệch trái
int LeftBalance(AVLTree &T)
{ AVLTree T1 = T->Lchild;
switch (T1->Balfactor)
{ case LH : RotateLL(T); return 2; //cây T giảm độ cao và không bị lệch
case EH : RotateLL(T); return 1;//cây T không giảm độ cao và bị lệch phải
case RH : RotateLR(T); return 2;
}
return 0;
}
Caáu truùc caây IV.24



//Cân bằng lại khi cây bị lệch phải
int RightBalance(AVLTree &T)
{ AVLTree T1 = T->Rchild;
switch (T1->Balfactor)
{ case LH : RotateRL(T); return 2; //cây T không bị lệch
case EH : RotateRR(T); return 1; //cây T bị lệch trái
case RH : RotateRR(T); return 2;
}
return 0;
}


IV.4.4. Chèn một phần tử vào cây AVL
Việc chèn một phần tử vào cây AVL xảy ra tương tự như trên cây nhị phân
tìm kiếm. Tuy nhiên, sau khi chèn xong, nếu chiều cao của cây thay đổi tại vị trí
thêm vào, ta phải lần ngược lên gốc để kiểm tra xem có nút nào bị mất cân bằng
hay không. Nếu có, ta chỉ phải cân bằng lại ở nút này. (Việc cân bằng lại chỉ cần
thực hiện một lần tại nơi mất cân bằng)
Hàm chèn trả về các trị –1, 0, 1 hay 2 tương ứng khi: không đủ bộ nhớ cấp
phát cho một nút của cây hoặc gặp nút đã có trên cây hoặc thành công hoặc chiều
cao của cây bị tăng sau khi chèn.
Khi chèn một nút vào cây AVL, ta cần sử dụng hàm cấp phát bộ nhớ cho
một nút của cây AVL.

AVLTree CấpPhátAVL()
{ AVLTree Tam= new AVLTreeNode;
if (Tam == NULL)
cout << “\nKhông đủ bộ nhớ cấp phát cho một nút của cây AVL !”;
return Tam;
}

int ChènAVL( AVLTree &T, ElementType x)
{ int Kquả;
if (T)
{ if (T->Data == x) return 0; //Đã có nút trên cây
if (T-> Data > x)
{ Kqủa=ChènAVL(T->Lchild,x);//chèn x vào cây con trái của T
if (Kqủa < 2) return Kqủa;
switch (T->Balfactor)
{ case LH: LeftBalance(T); return 1;//trước khi chèn,T lệch trái
case EH: T->Balfactor=LH;return 2;//trước khi chèn,T không lệch
Caáu truùc caây IV.25


caseRH:T->Balfactor=EH; return 1;//trước khi chèn,T lệch phải
}
}
else // T-> Data < x
{ Kqủa=ChènAVL(T->Rchild,x);//chèn x vào con phải của T
if (Kqủa < 2) return Kqủa;
switch (T->Balfactor)
{ case LH: T->Balfactor = EH; return 1; //trước khi chèn,T lệch trái
case EH:T->Balfactor=RH;return 2;//trước khi chèn,T không lệch
case RH : RightBalance(T); return 1; //trước khi chèn,T lệch phải
}
}
}
else //T==NULL
{ if ((T = CấpPhátAVL()) == NULL) return –1; //Thiếu bộ nhớ
T->Data = x;
T->Balfactor = EH;
T->Lchild = T->Rchild = NULL;
return 2; //thành công và chiều cao của cây tăng
}
}


IV.4.5. Xóa một phần tử khỏi cây AVL
Việc xóa một phần tử ra khỏi cây AVL diễn ra tương tự như đối với cây
nhị phân tìm kiếm; chỉ khác là sau khi hủy, nếu cây AVL bị mất cân bằng, ta phải
cân bằng lại cây. Việc cân bằng lại cây có thể xảy ra phản ứng dây chuyền.
Hàm XóaAVL sẽ trả về trị 1 hoặc 0 hoặc 2 tùy theo việc hủy thành công
hoặc không có x trên cây hoặc sau khi hủy, chiều cao của cây bị giảm.
int XóaAVL(AVLTree &T, ElementType x)
{ int Kqủa;
if (T== NULL) return 0; // không có x trên cây
if (T-> Data > x)
{ Kqủa = XoáAVL(T->Lchild,x); // tìm và xóa x trên cây con trái của
T
if (Kqủa < 2) return Kqủa;
switch (T->Balfactor)
{ case LH : T->Balfactor = EH; return 2; //trước khi xóa,T lệch trái
case EH : T->Balfactor = RH; return 1;//trước khi xóa,T không lệch
case RH : return RightBalance(T); //trước khi xóa,T lệch phải
}
}
Caáu truùc caây IV.26


else if (T-> Data < x)
{ Kqủa = XoáAVL(T->Rchild,x); // tìm và xóa x trên cây con phải của T
if (Kqủa < 2) return Kqủa;
switch (T->Balfactor)
{ case LH : return LeftBalance(T); //trước khi xóa,T lệch trái
case EH : T->Balfactor = LH; return 1;//trước khi xóa,T không lệch
case RH : T->Balfactor = EH; return 2; //trước khi xóa,T lệch phải
}
}
else //T->Data== x
{ AVLTree p = T;
if (T->Lchild == NULL)
{ T = T->Rchild; Kqủa = 2;
}
else if (T->Rchild == NULL)
{ T = T->Lchild; Kqủa = 2;
}
else // T có cả 2 con
{ Kqủa = TìmPhầnTửThayThế(p,T->Rchild);
// tìm phần tử thay p để xóa trên nhánh phải của
T
if (Kqủa == 2) switch (T->Balfactor)
{ case LH : Kquả = LeftBalnce(T); break;
case EH: T->Balfactor=LH; Kquả = 1; break;
case RH: T->Balfactor=EH; Kquả = 2; break;
}
}
delete p;
return Kquả;
}
}

// Tìm phần tử thay thế
int TìmPhầnTửThayThế(AVLTree &p, AVLTree &q)
{ int Kqủa;
if (q->Lchild)
{ Kqủa = TìmPhầnTửThayThế(p, q->Lchild);
if (Kqủa < 2) return Kquả;
switch (q->Balfactor)
{ case LH : q->Balfactor = EH; return 2;
case EH : q->Balfactor = RH; return 1;
case RH : return RightBalance(q);
}
Caáu truùc caây IV.27


}
else { p->Data = q->Data;
p = q;
q = q->Rchild;
return 2;
}
}

* Nhận xét:
- Thao tác thêm một nút có độ phức tạp O(1).
- Thao tác huỷ một nút có độ phức tạp O(h)
- Với cây cân bằng, trung bình: 2 lần thêm vào cây thì cần 1 lần cân bằng
lại, 5 lần hủy thì cần 1 lần cân bằng lại.
- Việc hủy một nút có thể phải cân bằng dây chuyền các nút từ gốc đến
phần tử bị hủy, trong khi thêm vào 1 nút chỉ cần 1 lần cân bằng cục bộ.
- Độ dài đường tìm kiếm trung bình trong cây AVL gần bằng cây cân
bằng hoàn toàn (log
2
n), nhưng việc cân bằng lại đơn giản hơn nhiều.
- Một cây cân bằng AVL không bao giờ cao hơn 45% cây cân bằng hoàn
toàn tương ứng.

BÀI TẬP
“CẤU TRÚC DỮ LIỆU & GIẢI THUẬT 1”

Mục đích các bài tập:
- Kiểm tra, củng cố việc hiểu các cấu trúc dữ liệu và các thuật toán có liên
quan.
- Rèn luyện kỹ năng lập trình và vận dụng lý thuyết vào việc chọn lựa các
cấu trúc dữ liệu và các thuật toán phù hợp có liên quan cho một bài toán
cụ thể.
- Phát triển và tổng hợp các kết quả lý thuyết nhằm chuẩn bị cho học viên
làm quen với quá trình giải quyết hoàn chỉnh một bài toán không tầm
thường nào đó.

Các bài tập có đánh dấu (*) là các bài tập khó hoặc cần nhiều thời gian để
thực hiện dành cho các học viên khá giỏi. Có thể kết hợp nhiều bài tập (*) có liên
quan hoặc bổ sung thêm các ứng dụng thực tế để hình thành tiểu luận của môn
học. Phần in đậm có gạch chân là yêu cầu tối thiểu học viên cần thực hiện trong
giờ thực hành.


Bài tập chương I (Giới thiệu cấu trúc dữ liệu, phân tích thuật toán)
(Kiểu dữ liệu có cấu trúc)
1) Giả sử quy tắc tổ chức quản lý nhân viên của một công ty như sau:
• Thông tin về một nhân viên bao gồm lý lịch và bảng chấm công:

* Lý lịch nhân viên:
- Mã nhân viên : chuỗi 10 ký tự
- Tên nhân viên : chuỗi 30 ký tự
- Tình trạng gia đình : 1 ký tự (M = Married, S = Single)
- Số con : số nguyên ≤ 20
- Trình độ văn hoá : chuỗi 2 ký tự (C1 = cấp 1,C2=cấp 2,C3=cấp 3;
DH = đại học, CH = cao học, TS = tiến sĩ)
- Lương căn bản : số ≤ 1 000 000

* Chấm công nhân viên:
- Số ngày nghỉ có phép trong tháng : số ≤ 28
- Số ngày nghỉ không phép trong tháng : số ≤ 28
- Số ngày làm thêm trong tháng : số ≤ 28
- Kết quả công việc : chuỗi 2 ký tự
(T = tốt, TB = trung bình, K = Kém)
- Lương thực lĩnh trong tháng : số ≤ 2 000 000

Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.2
• Quy tắc tính lương:
Lương thực lĩnh = Lương căn bản + Phụ trội
Trong đó nếu:
- số con > 2 : Phụ trội = +5% Lương căn bản
- trình độ văn hoá = CH : Phụ trội = +10% Lương căn bản
- làm thêm : Phụ trội = +4% Lương căn bản / 1 ngày
- nghỉ không phép : Phụ trội = -5% Lương căn bản / 1 ngày

• Các chức năng yêu cầu:
- Cập nhật lý lịch, bảng chấm công cho nhân viên (thêm, xóa, sửa một hay
mọi mẫu tin thoả mãn một tính chất nào đó)
- Xem bảng lương hàng tháng
- Khai thác (chẳng hạn tìm) thông tin của nhân viên
Hãy chọn cấu trúc dữ liệu thích hợp (và giải thích tại sao?) để biểu diễn các
thông tin trên và cài đặt chương trình theo các chức năng đã mô tả. Biết rằng số
nhân viên tối đa là 50 người, chú ý các thông tin tĩnh và “động” hay thay đổi và là
hệ quả của những thông tin khác.
2) Viết chương trình cài đặt chuỗi ký tự theo một trong hai cách (giả sử kiểu chuỗi
chưa có sẵn trong ngôn ngữ lập trình bạn đang dùng) sau:
a. phần tử đầu chỉ số ký tự của chuỗi;
b. chuỗi được kết thúc bởi ký tự có mã ASCII bằng 0.
Sau đó viết lại các thao tác cơ bản trên chuỗi (tính chiều dài chuỗi, nối, sao chép
một phần của chuỗi, chặt ngắn chuỗi, kiểm tra chuỗi con, ...)

(Độ phức tạp của thuật toán)
3) Hãy nêu một thuật toán mà độ phức tạp tính toán của nó là: O(1), O(n), O(n
2
).
4) Hãy xác định mục đích của từng thuật toán sau (xác định phép toán đặc trưng
cơ bản của nó) và tính độ phức tạp tính toán của nó trong trường hợp xấu nhất, tốt
nhất:
a) Sum = 0;
for (i = 1; i <= n; i++)
{ cin >> x; // Nhập một số x;
Sum = Sum + x;
}
b) for (i = 1; i <= n; i++)
for ( j = 1; j <= n; j++)
{ C[i,j] = 0;
for (k = 1; k <= n; k++) C[i,j] = C[i,j] + A[i,k]*B[k,j];
}
c) for (i = 1; i <= n -1; i++)
{ for ( j = i; j <= n -1; j++)
if (X[ j] > X[ j+1])
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.3
{ Temp = X[ j];
X[ j] = X[ j+1];
X[ j+1] = Temp;
};
}
d) (*) int Max(int i, int n) // x là mảng các số nguyên; n=2
k
>=i; gọi
Max(1, n)
{ int m1, m2;
if (n == i) return x[n-1];
else { m1 = Max(i, (n+i)/2);
m2 = Max((n+i)/2+1, n);
if (m1 < m2) return m2;
else return m1;
}
}

5) Viết giải thuật đệ qui và giải thuật lặp để:
a) Tính ước số chung lớn nhất của 2 số nguyên không âm.
b) Tính tổ hợp chập k của n phần tử
c) Tìm chuỗi đảo ngược của một chuỗi ký tự cho trước.






















Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.4
Bài tập chương II (Tìm kiếm và sắp xếp trên mảng)

(Tìm kiếm)
1) Xét các dãy số nguyên sau:
α. -9 -9 -5 -2 0 3 7 7 10 15
β. 15 10 7 7 3 0 -2 -5 -9 -9
γ. 66, 22, 36, 6, 79, 26, 45, 75, 13, 31,
62, 27, 76, 33, 16, 47
Với mỗi mảng số nguyên, hãy:
a. Đếm số lần tìm kiếm (so sánh) trung bình một phần tử x nào đó trên dãy
(x có thể có hoặc không có mặt trong dãy);
b. Kiểm tra lại kết quả câu a) bằng một chương trình trên máy tính và so
sánh lại với kết quả đánh giá độ phức tạp của các thuật toán:
- tìm kiếm tuyến tính (trên dãy chưa được hoặc đã được sắp tăng),
- tìm kiếm nhị phân.
2) Xây dựng và cài đặt thuật toán tìm:
a. phần tử lớn nhất (hay nhỏ nhất),
b. tất cả các số nguyên tố,
c. tìm phần tử đầu tiên trên dãy mà thỏa một tính chất TC nào đó;
d. (*) dãy con (là một dãy các phần tử liên tiếp của dãy) tăng dài nhất,
trong một dãy các phần tử cho trước được cài đặt bằng mảng.
3) (*) Xây dựng và cài đặt thuật toán tìm phần tử median (phần tử đứng giữa về
mặt giá trị) trong một dãy được cài đặt bằng mảng.

(Sắp xếp)
4) Với mỗi bộ dữ liệu của bài tập 1), hãy:
a. Thực hiện từng bước và đếm số phép so sánh và gán trong các thuật toán
sắp xếp tăng dãy đã cho;
b. Kiểm tra lại kết quả ở câu a) bằng một chương trình trên máy tính;
c. (*) Tổng quát câu b) trên bộ dữ liệu lớn được tạo ra tự động một cách ngẫu
nhiên trong ba tình huống: xấu nhất, tốt nhất và trung bình ngẫu nhiên;
thống kê các kết quả trên và thời gian chạy của từng thuật toán dưới dạng
bảng;
d. (**) Thể hiện trực quan bằng đồ thị kết quả của câu c) và cho nhận xét
bằng các phương pháp sắp xếp sau:
- sắp đổi chỗ trực tiếp BubbleSort, ShakerSort và QuickSort,
- sắp chèn trực tiếp và ShellSort,
- sắp chọn trực tiếp và HeapSort,
- sắp trộn tự nhiên,
- sắp dựa trên cơ số RadixSort.
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.5
5) Hãy viết thuật toán và chương trình sắp xếp bằng phương pháp chọn hai đầu:
tại mỗi bước chọn đồng thời cả phần tử nhỏ nhất và lớn nhất trong dãy chưa được
sắp còn lại.
6) (*) Cho các ví dụ để minh họa ưu điểm của các thuật toán sắp xếp cải tiến so
với các thuật toán sắp xếp trực tiếp tương ứng.
7) Xét thuật toán phân hoạch trong thuật toán QuickSort được viết lại như sau:
i = 0; j = n -1; y = x[n/2];
do
{ while (x[i] < y) i++;
while (x[ j] > y) j--;
HoánVị(x[i], x[ j]);
} while (i <= j);
Có bộ dữ liệu x[0], x[1], …, x[n-1] nào làm đoạn chương trình trên sai hay
không ? Cho ví dụ minh họa.
8) Viết hàm đếm số đường chạy (tự nhiên) của một dãy gồm n phần tử cho
trước.
9) Hãy cài đặt thêm thuật toán xuất bảng lương nhân viên (trong bài tập 1 -
chương 1) theo thứ tự tiền lương tăng dần.
10) (*) Hãy viết lại giải thuật QuickSort dưới dạng lặp.
11) (*) Cải tiến hai thuật toán QuickSort viết dưới dạng đệ qui và lặp [gợi ý: ta nên
thực hiện sắp xếp trước dãy con nào ngắn hơn].
12) (*) Xây dựng ví dụ để trường hợp xấu nhất của thuật toán QuickSort xảy ra.





















Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.6
Bài tập chương III (Cấu trúc danh sách liên kết)

1) Xét đoạn chương trình tạo một DSLK đơn có 4 nút (không quan tâm đến dữ
liệu) sau đây:
NodePointer p, Dx = NULL;
p = Dx; Dx = new NodeType;
for (i = 0; i < 4; i++)
{ p = p->Next;
p = new NodeType;
}
p->Next = NULL;
Đoạn chương trình này có thực hiện đúng như mục đích đã đưa ra không ?
Tại sao ? Nếu không thì cần sửa lại như thế nào cho đúng ?
2) Hãy thực hiện các yêu cầu sau đối với từng loại danh sách liên kết:
i) DSLK không có nút câm
ii) DSLK có nút câm
iii) DSLK vòng (không có nút câm)
iv) DSLK đối xứng
v) DSLK vòng đôi
a. Tạo bản sao của một DSLK cho trước.
b. Nối hai DSLK cho trước.
c. Tính số lượng các nút dữ liệu.
d. Tìm nút dữ liệu đầu tiên trong DSLK thỏa một tính chất nào đó, chẳng hạn:
- nút thứ k,
- hoặc có trường dữ liệu trùng với một giá trị cùng kiểu K cho trước.
Nếu có thì trả về con trỏ chỉ đến nút đứng trước nút tìm thấy.
e. Xóa một (hay mọi) nút dữ liệu trong DSLK thỏa một tính chất nào đó, ví
dụ:
- nút thứ k,
- hoặc có trường dữ liệu trùng với một giá trị cùng kiểu K cho trước.
f. Bổ sung một nút L vào sau một (hay mọi) nút dữ liệu trong DSLK thỏa
một tính chất nào đó, chẳng hạn:
- nút thứ k,
- hoặc có trường dữ liệu trùng với một giá trị cùng kiểu K cho trước.
g. Đảo ngược DSLK nói trên theo hai cách : tạo DSLK mới hay sửa lại chiều
con trỏ trong DSLK ban đầu.
h. Gọi M là con trỏ chỉ tới một nút đã có trong DSLK trên và P là con trỏ chỉ
tới một DSLK khác cùng loại. Hãy chèn DSLK P này vào sau nút trỏ bởi
M.
i. Tách thành 2 DSLK mà DS sau được trỏ bởi M (giả thiết như câu h).
j. So sánh 2 DSLK (có trường dữ liệu của các nút liên tiếp tương ứng bằng
nhau hay không ?)
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.7
3) Hãy viết chương trình nhằm thực hiện các yêu cầu của bài tập 1 – chương 1
(biết rằng số lượng nhân viên biến động nhiều, không dự đoán được giới hạn của
nó) bằng cách dùng DSLK để cài đặt.
4) Hãy viết thuật toán và chương trình để trộn hai DSLK tăng A, B cho trước
thành một DSLK C cũng tăng theo hai cách:
a. C là DSLK mới (cấp phát bộ nhớ mới cho mọi nút của C) và bảo toàn hai
DSLK cũ A, B;
b. C là DSLK mới do A, B hợp thành (do đổi chỗ vị trí các con trỏ sẵn có
trên A, B). Khi đó cấu trúc hai DSLK A, B có thể bị thay đổi.
5) Một số giới hạn vé (MAX_VE) cho buổi hòa nhạc sẽ được bán vào ngày mai.
Người nào đăng ký trước sẽ được mua trước. Hãy viết một chương trình:
a. Đọc các tên, tuổi của những người đăng ký cùng với số vé họ mua và lưu
vào một DSLK (chú ý kiểm tra không có người nào được đăng ký nhiều lần).
b. Hiện ra màn hình DSLK trên.
6) (Bài toán Josephus) Một nhóm binh sĩ bị kẻ thù bao vây và một binh sĩ được
chọn để đi cầu cứu. Việc chọn được thực hiện theo cách sau đây. Một số nguyên n
và một binh sĩ được chọn ngẫu nhiên. Các binh sĩ được sắp theo vòng tròn và họ
đếm từ binh sĩ được chọn ngẫu nhiên. Khi đạt đến n, binh sĩ tương ứng được lấy ra
khỏi vòng và việc đếm lại được bắt đầu từ binh sĩ tiếp theo. Quá trình này tiếp tục
cho đến khi chỉ còn lại một binh sĩ là người gặp may (hoặc không may) được chọn
để đi cầu cứu. Hãy viết một thuật toán cài đặt cách chọn này, dùng danh sách liên
kết vòng để lưu trữ các tên của binh sĩ.

(Ngăn xếp và hàng đợi)
7) Cho X là ngăn xếp chứa các ký tự. Giả sử có hàm sau trong C++:
void Out(StackType &S, ElementType &Item)
{ Pop(S,Item); cout << Item<< endl;
}
Ta cần sử dụng luân phiên các phép toán Push(S, Item) và Out(S,Item) như
thế nào (nếu có thể) từ bộ các ký tự : ‘A’, ‘B’, ‘C’, ‘D’, ‘E’, ‘F’ để thu được các
anagram (hoán vị) sau đây của nó:
a) BDCFEA
b) BDACEF
c) ABCDEF
d) EBFCDA
e) FEDCBA
8) Xét một cơ cấu đường tàu và kho sửa chữa như hình sau:
Giả sử ở đường vào có 4 đường tàu được đánh số 1, 2, 3, 4. Gọi V là phép
đưa một đầu tàu vào kho sửa chữa, R là phép đưa một đầu tàu ra khỏi kho.
a. Nếu thực hiện dãy VVRVVRRR thì thứ tự các đầu tàu lúc ra là gì ? (Có
thể xem đây là một cách hoán vị các số được không ?)
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.8
b. Xét trường hợp có 6 đầu tàu:1, 2, 3, 4, 5, 6 có thể thực hiện một dãy các
phép V và R thế nào để đổi thứ tự đầu tàu ở đường ra là: 3, 2, 5, 6, 4, 1 ? và 1, 5,
4, 6, 2, 3 ?
Ra Vào







Kho sửa chữa


9) Xét chuỗi:
EAS*Y**QUE***ST***I*ON
Trong đó, mỗi chữ cái tượng trưng cho thao tác thêm nó vào một DSLK List, dấu
* tượng trưng cho thao tác lấy nó ra khỏi List và xuất ra màn hình.
Trong từng trường hợp sau, với List là:
a. ngăn xếp
b. hàng đợi
hãy cho biết:
- Nội dung của List sau mỗi thao tác cơ bản trên ?
- Kết quả cuối cùng xuất ra trên màn hình ?
- Hãy kiểm tra lại các kết quả trên bằng một chương trình hoàn chỉnh.
10) Viết các thao tác cơ bản trên ngăn xếp và thêm vào các thao tác sau đây:
a. ElementType XemPTửThứ_2CủaNX(StackType S) có tác dụng xem
phần tử thứ 2 kể từ đỉnh ngăn xếp S mà không làm S thay đổi.
b. ElementType LấyPTửThứ_2CủaNX(StackType &S) có tác dụng trả về
phần tử thứ 2 của ngăn xếp S và S bị mất đi 2 phần tử ở đỉnh của nó.
c. ElementType LấyĐáyNX(StackType &S) có tác dụng trả về phần tử ở
đáy ngăn xếp S và làm S trở thành rỗng.
d. ElementType XemĐáyNX(StackType S) có tác dụng trả về phần tử ở đáy
ngăn xếp S và S không thay đổi.
11) Để có thể duyệt ngăn xếp hay hàng đợi theo cả hai chiều, ta có thể tổ chức
chúng theo kiểu DSLK đối xứng như sau:
Top Bottom

S
A B C D

Hãy thực hiện các phép toán sau trên ngăn xếp:
a. Thực hiện phép duyệt qua DSLK từ dưới lên.
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.9
b. Thực hiện phép duyệt qua DSLK từ trên xuống.
c. Thực hiện phép bổ sung một phần tử vào (đầu và đuôi) DSLK.
d. Thực hiện phép loại bỏ một phần tử (ở đầu và đuôi) khỏi DSLK.
12) a. Cho Q là hàng đợi rỗng. Cho biết kết quả của Q sau một dãy các phép toán
thêm vào và lấy ra các ký tự sau đây:
EnQueue(Q, ’A’), EnQueue(Q, ’B’), EnQueue(Q, ’C’), DeQueue(Q, Item),
EnQueue(Q, ’D’), EnQueue(Q, ’E’), DeQueue(Q, Item), DeQueue(Q, Item),
EnQueue(Q, ’F’), DeQueue(Q, Item).
b. Viết các thao tác cơ bản trên hàng đợi và thêm vào các thao tác sau đây:
duyệt hàng đợi từ đầu đến đuôi của nó và ngược lại.
13) Dùng các phép toán cơ bản trên ngăn xếp và hàng đợi để đảo ngược thứ tự
các phần tử trên hàng đợi.
14) Phân tích một số thành tích các thừa số nguyên tố theo thứ tự giảm dần. Ví
dụ: phân tích: 60 = 5*3*2*2.
15) Dùng ngăn xếp để kiểm tra một chuỗi ký tự S1 có phải là palyndrome của một
chuỗi ký tự S2 hay không ?
16) (*) Viết một chương trình đọc một xâu ký tự chứa các dấu ngoặc và xác định
xâu đó có chứa các dấu ngoặc tương ứng hợp lệ hay không. Ví dụ:
- các xâu sau là hợp lệ: a*(b+c), a(), b[d(e+f-)], d-{[a(b)d]}
- các xâu sau là không hợp lệ: (, ], a*(b+c], a[), b[d(e+f-]), d-{[a((b)d]}

(Các ứng dụng khác của DSLK)
17) a. Chuyển các biểu thức trung tố sau đây sang dạng hậu tố:
a/(b*c), a/b*c, a∧b∧c, (a∧b)∧c, a-b-c, a-(b-c), a
5
+ 4a
3
- 3a
2
+ 7, (a+b)*(c-
d), S
a+b

b. Viết biểu thức sau đây dưới dạng hậu tố: (A * B)/(C + D). Minh họa thông
qua hình ảnh Stack để tính giá trị biểu thức hậu tố này ứng với: A= 20, B = 4, C =
9, D = 7.
c. (**) Cài đặt một chương trình để :
i) Chuyển một biểu thức từ dạng trung tố sang dạng hậu tố (có kiểm tra cú
pháp của biểu thức).
ii) Tính giá trị của một biểu thức cho trước ở dạng hậu tố.
iii) Vẽ đồ thị của một hàm giải tích cho trước được đưa vào dưới dạng biểu
thức chuỗi.
iv) Có thể viết lại chương trình trên khái quát hơn để có thể áp dụng cho
các biểu thức lôgic mệnh đề hay không ?
18) (**) Hãy viết một chương trình thực hiện các yêu cầu tương tự của bài tập 4 -
chương 2 để cài đặt các thuật toán sắp xếp sau trên DSLK động (DSLK đơn,
DSLK kép):
a. QuickSort
b. MergeSort
c. RadixSort
d. Các phương pháp sắp xếp trực tiếp: chèn, chọn, đổi chỗ
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.10
19) (*) Hãy lập các giải thuật cộng, trừ, nhân hai đa thức và tính đạo hàm, nguyên
hàm của một đa thức cho trước trong hai trường hợp:
a) Khi các hệ số của đa thức được lưu đầy đủ trong mảng.
b) (*) Khi chỉ các hệ số khác không và các số mũ tương ứng được lưu trong
một danh sách liên kết.
20) (*) Hãy cài đặt tập hợp bằng DSLK và thực hiện các phép toán trên tập hợp
(quan hệ một phần tử có thuộc vào một tập không; quan hệ bao hàm, bằng nhau
giữa hai tập; phép toán giao, hiệu, hợp hai tập hợp, ...)
21) (**) Viết các phép toán cơ bản trên ma trận thưa được cài đặt bằng DSLK tổng
quát.
22) a. Hãy cài đặt các thao tác cơ bản trên DSLK có thứ tự và tổ chức lại, hàng
đợi ưu tiên. So sánh thời gian tìm kiếm của cách tổ chức này với các cách tổ chức
bình thường.
b. Tìm một ứng dụng thực tế của hàng đợi ưu tiên.
23) (*) Áp dụng thuật toán sắp xếp tôpô vào bài toán sắp lịch giảng dạy (tuyến
tính) cho dãy các học phần thỏa điều kiện “học trước” đã biết.























Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.11
Bài tập chương IV (Cấu trúc cây)

1) Xuất ra theo thứ tự : giữa, đầu, cuối các phần tử trên cây nhị phân sau:

A

P R

Q E T

M N D B C

2) a. Tìm cây nhị phân thỏa đồng thời hai điều kiện kết xuất sau:
theo thứ tự đầu NLR của nó là dãy ký tự sau:
A, B, C, D, E, Z, U, T, Y
và theo thứ tự giữa LNR của nó là dãy ký tự sau:
D, C, E, B, A, U, Z, T, Y
b. (*) Khi cho trước 2 trong 3 kết quả duyệt NLR, LNR, LRN thì có luôn xác
định duy nhất cây nhị phân thỏa điều kiện nêu ra không ? Dùng chương trình để
kiểm chứng ?
3) a. Biểu diễn mỗi biểu thức số học dưới đây trên cây nhị phân, từ đó rút ra
dạng biểu thức hậu tố của chúng:
i. a/(b*c)
ii. a
5
+ 4a
3
-3a
2
+ 7
iii. (a+b)*(c-d)
iv. S
a+b
b. (*) Viết thuật toán và chương trình:
- Chuyển một biểu thức số học ký hiệu lên cây nhị phân (có kiểm tra biểu
thức đã cho có hợp cú pháp không ?).
- Xuất ra biểu thức số học đó dưới dạng: trung tố, hậu tố, tiền tố.
- Sau đó nhập trị cho các ký hiệu trong biểu thức, hãy đánh giá biểu thức hậu
tố tương ứng.
4) Xây dựng cây tìm kiếm nhị phân BST và cây AVL từ mỗi bộ mục dữ liệu đầu
vào như sau:
a. 1, 2, 3, 4, 5
b. 5, 4, 3, 2, 1
c. fe, cx, jk, ha, gc, ap, aa, by, my, da
d. 8, 9, 11, 15, 19, 20, 21, 7, 3, 2, 1, 5, 6, 4, 13, 10, 12, 17, 16, 18.
Sau đó xóa lần lượt các nút sau: 2, 10, 19, 8, 20, 6, 1.
5) Viết một chương trình có các tác dụng sau:
a. Nhập từ bàn phím các số nguyên vào một cây nhị phân tìmkiếm (BST) mà
nút gốc được trỏ bởi con trỏ Root.
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.12
b. Xuất các phần tử trên cây BST trên theo thứ tự : đầu, giữa, cuối theo dòng
và vẽ sơ đồ cây (*) (chỉ yêu cầu trường hợp khi số phần tử của cây nhị phân
không quá lớn !).
c. Tìm và xóa (nếu có thể) phần tử trên cây Root có dữ liệu trùng với một mục
dữ liệu Item cho trước được nhập từ bàn phím.
d. Sắp xếp n mục dữ liệu (được cài đặt bằng mảng hay DSLK) bằng phương
pháp cây nhị phân tìm kiếm BSTSort.
Yêu cầu: viết các thao tác trên bằng 2 phương pháp: đệ quy và lặp (*).
(**) Riêng với duyệt cây, hãy viết dưới dạng lặp cả 3 phương pháp duyệt
trong một hàm duy nhất có tính khái quát.
e. Kiểm tra lại kết quả của bài tập 4) bằng chương trình vừa xây dựng.
6) Tương tự bài tập 5, nhưng mỗi nút có thêm trường con trỏ Parent chỉ đến nút
cha của nó.
7) (*) Xây dựng các thao tác cơ bản trên cây n-phân được biểu diễn bởi cây nhị
phân: chèn một nút, tạo cây n-phân, xóa một nút, hủy cây n-phân.
8) Cho cây nhị phân T. Viết chương trình chứa các hàm có tác dụng xác định:
a. Tổng số nút của cây. Số nút tối đa của cây nhị phân có chiều cao h là bao
nhiêu? Chứng minh điều khẳng định bằng qui nạp và kiểm nghiệm lại bằng
chương trình.
b. (*) Số nút của cây ở mức k. Số nút tối đa ở mức k của cây nhị phân là bao
nhiêu ? Chứng minh điều khẳng định bằng qui nạp và kiểm nghiệm lại bằng
chương trình.
c. Số nút lá.
d. (*) Chiều cao của cây.
e. Tổng giá trị trường dữ liệu (số !) trên các nút của cây.
f. Kiểm tra xem nó có phải là một cây nhị phân chặt (là cây nhị phân mà mỗi
nút khác nút lá đều có đúng 2 con) hay không ?
g. Kiểm tra xem T có phải là cây cân bằng hoàn toàn hay không ?
h. Số nút có đúng 2 con khác rỗng
i. Số nút có đúng 1 con khác rỗng
j. Số nút có khóa nhỏ hơn x trên cây nhị phân hoặc cây BST
k. Số nút có khóa lớn hơn x trên cây nhị phân hoặc cây BST
l. Số nút có khóa nhỏ hơn x và lớn hơn y (y ≤ x) trên cây nhị phân hoặc cây
BST
m. Duyệt cây theo chiều rộng
n. Duyệt cây theo chiều sâu
o. Độ lệch lớn nhất của các nút trên cây (độ lệch của một nút là trị tuyệt đối
của hiệu số giữa chiều cao của cây con phải và cây con trái của nó)
p. Đảo nhánh trái và phải của mọi nút trên một cây nhị phân
Yêu cầu: viết các thao trên bằng 2 phương pháp: đệ quy và lặp (*).
9) Viết chương trình xây dựng cây nhị phân tìm kiếm có chiều cao bé nhất từ một
dãy có thứ tự tăng của các phần tử được lưu trữ trên một danh sách liên kết.
10) a. Hãy vẽ cây AVL có chiều cao cực đại có 12 nút
Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.13
b. Hãy tìm một ví dụ về một cây AVL có chiều cao là 6 và khi hủy một nút lá
(chỉ ra cụ thể), việc cân bằng lại cây lan truyền lên tận gốc.
c. (*) Viết chương trình thể hiện các thao tác cơ bản trên cây AVL: chèn một
nút, xóa một nút, tạo cây AVL, hủy cây AVL. Kiểm tra lại bằng chương
trình với dữ liệu của câu a. và b. trên đây.
11) (*) Viết chương trình cho phép tạo, thêm, bớt, tra cứu, sửa chữa từ điển.



TÀI LIỆU THAM KHẢO

[1] A.V. AHO , J.E. HOPCROFT , J.D. ULMANN: Data structures and
algorithms. Addition Wesley - 1983.
[2] DONALD KNUTH: The Art of Programming. (vol.1: Fundamental
Algorithms, vol. 3: Sorting and Searching). Addition Wesley Puplishing
Company - 1973.
[3] ĐINH MẠNH TƯỜNG: Cấu trúc dữ liệu và giải thuật. NXB KHKT - 2001.
[4] ĐỖ XUÂN LÔI: Cấu trúc dữ liệu và giải thuật. NXB KHKT - 1995.
[5] LARRY N. HOFF, SANFORD LEESTMA: Lập trình nâng cao bằng Pascal
với các cấu trúc dữ liệu. Bản dịch của Lê Minh Trung. Công ty Scitec - 1991.
[6] NGUYỄN TRUNG TRỰC: Cấu trúc dữ liệu. Trung tâm điện toán, trường ĐH
Bách khoa TP. HCM – 1992.
[7] NIKLAUS WIRTH: Cấu trúc dữ liệu + Giải thuật = Chươngtrình (Nguyễn
Quốc Cường dịch). NXB ĐH và THCN – 1991
[8] TRẦN HẠNH NHI & DƯƠNG ANH ĐỨC: Nhập môn cấu trúc dữ liệu và
giải thuật. Khoa Công nghệ thông tin, ĐH KHTN TP HCM – 2000.

LỜI MỞ ĐẦU
Giáo trình này nhằm cung cấp cho sinh viên các kiến thức căn bản về các cấu trúc dữ liệu cơ sở có cấu trúc tuyến tính tĩnh, động (danh sách liên kết), cấu trúc cây và các giải thuật cơ bản liên quan đến chúng như sắp xếp, tìm kiếm ở bộ nhớ trong, cũng như so sánh độ phức tạp của các giải thuật này. Để có thể nắm bắt các kiến thức trình bày học phần này, sinh viên cần nắm được các kiến thức về tin học đại cương, nhập môn lập trình. Ngôn ngữ lập trình được chọn để minh họa các kiến thức trên là C++. Các kiến thức này sẽ tạo điều kiện cho học viên tiếp tục dễ dàng nắm bắt các kiến thức các học phần tin học về sau như: cấu trúc dữ liệu và giải thuật nâng cao, phân tích và thiết kế giải thuật, đồ hoạ, hệ điều hành, trí tuệ nhân tạo, ... Nội dung giáo trình gồm 4 chương: - Chương 1: Giới thiệu các khái niệm ban đầu về mối liên hệ mật thiết giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu, thiết kế và phân tích giải thuật, độ phức tạp giải thuật, ... - Chương 2: Giới thiệu các phương pháp cơ bản về tìm kiếm và sắp xếp trong trên kiểu dữ liệu tuyến tính mảng. Thông qua đó, trình bày một số ý tưởng và kỹ thuật cơ bản nhằm cải tiến các giải thuật. - Chương 3: Trình bày kiểu dữ liệu con trỏ. Trên cơ sở đó, trình bày các kiểu dữ liệu động tuyến tính và có nhiều ứng dụng trong tin học là các kiểu danh sách liên kết khác nhau, ngăn xếp, hàng đợi, cũng như một số ứng dụng của chúng. - Chương 4: Giới thiệu một loại cấu trúc dữ liệu động khác là cây và các thao tác cơ bản trên cây nhị phân, cây nhị phân tìm kiếm, cây cân bằng AVL. Nhằm mục đích dành thời gian nhiều hơn cho sinh viên để làm các bài tập lớn, nên trong một số phần tác giả đã trình bày khá chi tiết các dạng cài đặt biến thể khác nhau cho các giải thuật. Các phần thứ yếu hoặc khá phức tạp sẽ được in cỡ chữ nhỏ dành cho sinh viên đọc thêm. Chắn chắn rằng trong giáo trình sẽ còn nhiều khiếm khuyết, tác giả mong muốn nhận được và rất biết ơn các ý kiến quí báu đóng góp của đồng nghiệp cũng như bạn đọc để giáo trình này có thể hoàn thiện hơn nữa về mặt nội dung cũng như hình thức trong lần tái bản sau. Đà lạt, 04/2008 Tác giả

MỤC LỤC
Chương I. GIỚI THIỆU CẤU TRÚC DỮ LIỆU,

PHÂN TÍCH GIẢI THUẬT
I.1. Quan hệ giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu I.1.1. Biểu diễn dữ liệu I.1.2. Quan hệ giữa cấu trúc dữ liệu và giải thuật, kiểu dữ liệu I.1 I.1.3. Các bước chính để giải một bài toán trên máy tính I.2. Thiết kế và phân tích giải thuật I.2.1. Thiết kế giải thuật theo phương pháp Top-Down I.2.2. Các chiến lược khác để thiết kế giải thuật I.2.3. Phân tích giải thuật và độ phức tạp của giải thuật I.2.4. Qui ước về ngôn ngữ mã giả Trang I.1 I.1 I.2 I.4 I.4 I.5 I.5 I.9

Chương II. TÌM KIẾM VÀ SẮP XẾP TRONG
II.1. Giới thiệu về sắp xếp và tìm kiếm II.1.1. Sắp xếp a. Định nghĩa sắp xếp b. Phân loại phương pháp sắp xếp c. Vài qui uớc về kiểu dữ liệu khi xét các giải thuật sắp xếp II.1.2. Tìm kiếm a. Định nghĩa phép tìm kiếm b. Phân loại các phương pháp tìm kiếm II.2. Phương pháp tìm kiếm trong II.2.1. Phương pháp tìm kiếm tuyến tính a. Dãy chưa được sắp b. Dãy đã được sắp II.2.2. Phương pháp tìm kiếm nhị phân II.3. Phương pháp sắp xếp trong II.7 II.3.1. Phương pháp sắp xếp chọn đơn giản II.3.2. Phương pháp sắp xếp chèn đơn giản II.3.3. Phương pháp sắp xếp đổi chỗ đơn giản II.3.4. Phương pháp sắp xếp đổi chỗ cải tiến (Shake Sort) II.3.5. Phương pháp sắp xếp chèn cải tiến (Shell Sort) II.3.6. Phương pháp sắp xếp phân hoạch (Quick Sort) II.3.7. Phương pháp sắp xếp trên cây có thứ tự (HeapSort) II.3.8. Phương pháp sắp xếp trộn (Merge Sort) II.1 II.1 II.1 II.1 II.1 II.3 II.3 II.3 II.3 II.3 II.3 II.5 II.6 II.8 II.9 II.10 II.12 II.14 II.16 II.19 II.25

MergeSort.2 c.4.1. cài đặt. Cấu trúc dữ liệu biểu diễn DSLK đối xứng III.3.1 a. Kiểu dữ liệu con trỏ III.2. Đặc trưng của biến động III.1 III.4. các thao tác cơ bản.5 III. RadixSort III.3.4.1 b.3.7 III. cài đặt. Khai báo III. Một số ứng dụng khác của DSLK III.17 III. Các cách tổ chức danh sách III.1.34 III. Một số kiểu DSLK khác III.2. Biểu diễn ma trận thưa nhờ DSLK III.4 a. Sắp xếp trên kiểu DSLK đơn: sắp xếp chèn. Phương pháp sắp xếp dựa trên cơ số (Radix Sort) II.8 a. So sánh các phương pháp sắp xếp trong II.2. DSLK vòng III.9 c.3. Tổ chức DSLK đơn. Các thao tác cơ bản trên kiểu DSLK đơn III. Định nghĩa III.2. Ứng dụng của DSLK đối xứng: hàng đợi hai đầu III.39 c. Hai thao tác cơ bản trên biến động III.3.5.3 III. Danh sách liên kết (DSLK) III.56 .39 b. DS đa liên kết III. Truy xuất biến động III.2.3.47 III.8 III.7 III.4 b.4. DSLK đơn có nút câm III.2.4 c.37 III.28 II.8 b.1. Ngăn xếp: định nghĩa. Biến động III.51 a.3.2.51 III.54 d.7 III. Giới thiệu đối tượng dữ liệu con trỏ III.II. tìm kiếm và sắp xếp trên kiểu DSLK đơn III.31 Trang Chương III. DS có thứ tự và DS tổ chức lại III.2.9.1 III.3. các phép toán cơ bản và ứng dụng của ngăn xếp III.53 b. Các thao tác cơ bản trên kiểu DSLK đối xứng III.34 III.3.31 III. Các thao tác trên kiểu dữ liệu con trỏ III.4.1. các phép toán cơ bản và ứng dụng của hàng đợi III.1. DSLK đơn III.38 a. CẤU TRÚC DANH SÁCH LIÊN KẾT III.48 III. Định nghĩa danh sách III.10. QuickSort.2. Biểu diễn tập hợp bằng DSLK c.1.4.1.1.1.24 III. Biểu diễn đa thức rời rạc bằng DSLK III. DSLK đối xứng III.24 III. Vài ứng dụng của DSLK đơn III.4.2. So sánh cấu trúc dữ liệu tĩnh và cấu trúc dữ liệu động III. Hàng đợi: định nghĩa. Tổ chức DSLK đơn (không có nút câm) III.

4 BT.6.13 IV. Biểu diễn cây n .11 Bài tập.2.4. Định nghĩa cây nhị phân tìm kiếm IV.4.3.e.2.1 IV.2. xây dựng cây BST IV.1 IV. Chỉ số cân bằng và việc cân bằng lại cây AVL IV.3.9 IV.25 BT.8 IV. Định nghĩa IV. Xây dựng cây nhị phân cân bằng hoàn toàn IV. Xóa một phần tử khỏi cây AVL IV. Định nghĩa và các khái niệm cơ bản IV.4. Một cách biểu diễn khác của cây nhị phân IV.3.11 IV.2.1.1.2.phân bằng cây nhị phân IV.2.5.4. Chiều cao của cây cân bằng IV. Các khái niệm khác IV.3 IV.1 BT.3 IV. hủy cây nhị phân IV. Định nghĩa IV.3 IV.1.17 IV.3.3.5. Định nghĩa cây IV.4 IV.4.3. Chèn một phần tử vào cây BST. Cây nhị phân tìm kiếm IV. Vài tính chất của cây nhị phân IV.2.1. Biểu diễn cây nhị phân IV. Bài tập chương I Bài tập chương II Bài tập chương III Bài tập chương IV Tài liệu tham khảo .24 IV.3.9 IV.1 BT.7.2.1.18 IV.5. Tìm kiếm một phần tử trên cây BST IV.1.2.4.4.1 IV.2.4.4. Chèn một phần tử vào cây AVL IV.2.17 IV.3.3.8 IV.2. Phương pháp sắp xếp bằng cây BST IV. Sắp xếp tôpô III. Cây nhị phân IV.7 IV.10 IV.3 IV.16 IV.57 Trang Chương IV. Cây nhị phân tìm kiếm cân bằng IV. CẤU TRÚC CÂY IV.13 IV. Xóa một phần tử khỏi cây BST.6 BT. Duyệt cây nhị phân IV.1.

mỗi phần tử của tập được gọi là một thể hiện của kiểu. nếu để ý kỹ. Quan hệ giữa cấu trúc dữ liệu và giải thuật. thiếu tự . còn đối tượng để xử lý bởi giải thuật chính là dữ liệu: dữ liệu (input) đưa vào. Quan hệ giữa cấu trúc dữ liệu và giải thuật.1. Khi cách biểu diễn dữ liệu thay đổi thì các thao tác gắn liền với chúng cũng thay đổi theo. kiểu dữ liệu I. nhằm giúp người lập trình không phải mất nhiều thời gian và công sức thực hiện thường xuyên lặp lại các thao tác sơ cấp nặng nề trên các kiểu dữ liệu nhị phân ở mức thấp. Các thông tin này gồm dữ liệu và các thao tác trên các dữ liệu đó.1. Đối với bất kỳ một lớp dữ liệu nào. xác định một trình tự các thao tác trên một số đối tượng nào đó (input) sao cho sau một số hữu hạn bước thực hiện (chú ý đến tính khả thi về thời gian). Chính vì lý do đó. ta đạt được kết quả (output) mong muốn. Mỗi lớp dữ liệu được thể hiện qua một kiểu dữ liệu. Một kiểu dữ liệu T là một tập hợp nào đó. Việc biểu diễn dữ liệu ở dạng nhị phân rất bất tiện cho con người trong khi xử lý các bài toán. I. Ta đã biết giải thuật (hay giải thuật) là một dãy câu lệnh rõ ràng. Các thông tin về bài toán cần giải quyết trên máy tính luôn được mã hoá dưới dạng nhị phân. Tính trừu tượng của dữ liệu thể hiện ở chỗ nó không quá chú trọng đến những đặc điểm và ý nghĩa riêng của từng đối tượng cụ thể mà chỉ rút ra và phản ánh những tính chất chung nhất mà các đối tượng thuộc cùng một lớp có được.2.Chương I GIỚI THIỆU CẤU TRÚC DỮ LIỆU VÀ PHÂN TÍCH GIẢI THUẬT I. Biểu diễn dữ liệu Một mục tiêu quan trọng của tin học là nhằm giải quyết tự động những bài toán trong thế giới thực bằng máy tính điện tử.1. đặc biệt là các bài toán lớn và phức tạp. các ngôn ngữ lập trình bậc cao đã cung cấp sẵn các cách biểu diễn dữ liệu trừu tượng đơn giản và có cấu trúc. dữ liệu trung gian và kết qủa (output) cuối cùng. kiểu dữ liệu Dựa vào bản chất chung của từng nhóm dữ liệu.1. Vì nếu không thì trong nhiều trường hợp việc xử lý sẽ gượng ép. các đối tượng dữ liệu được phân thành các lớp. ta thấy trên đó luôn tồn tại những thao tác cơ bản mật thiết gắn liền với các đối tượng dữ liệu cùng kiểu đó. Giải thuật phản ánh các phép xử lý.

các phép toán thường gặp là: các phép toán số học +.2 nhiên. || (or). ta có các kiểu dữ liệu: số (nguyên. lấy phần dư) và các phép toán so sánh như: ==. Với mỗi cách biểu diễn. -. … Dựa trên các kiểu đơn giản đã có và các phương pháp xác định của ngôn ngữ lập trình qui định. chẳng hạn với C++. -. ≤. % (mod. ≥. khó hiểu. và các phép toán so sánh như: ==. xóa. ta có thể xây dựng nên các cấu trúc dữ liệu hay kiểu dữ liệu có cấu trúc phức tạp hơn nhằm phản ánh tốt hơn các loại dữ liệu phong phú và đa dạng trong thế giới thực. sẽ thấy rõ hơn trong các chương tiếp theo. Do đó. … Một trong những phép toán cơ bản trên các kiểu dữ liệu đó là: truy cập đến từng phần tử hay từng thành phần của đối tượng dữ liệu. đối với một dãy các phần tử dữ liệu cùng loại. <. >. ta thường chú ý đến hai đặc trưng quan trọng và liên hệ mật thiết với nhau: .1. / (chia nguyên). các thao tác cơ bản trên chúng như chèn. Chẳng hạn như: kiểu mảng. các phép toán thường gặp là: các phép toán số học +. Trong một ngôn ngữ lập trình cấp cao cụ thể. && (and). Với kiểu số thực. đối với một chuỗi ký tự. /. các phép toán thường gặp là: ! (not). Khi đó. Một ví dụ khác. !=. >. ta có thể lưu trữ chúng ít nhất bằng hai cách: lưu bằng mảng (tĩnh.tập O các phép toán (hay thao tác xử lý) xác định có thể thực hiện trên các đối tượng dữ liệu kiểu đó. Chẳng hạn. thực). O>.3. ≤.tập V các giá trị thuộc kiểu.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. người ta thường xây dựng sẵn một số kiểu dữ liệu đơn giản hay sơ cấp xác định. lôgic. các phép toán thường gặp là: phép toán ép kiểu và các phép toán so sánh như: ==. I. <. Các bước chính để giải một bài toán trên máy tính Để giải một bài toán trên máy tính. . !=. động) hay lưu trữ bằng danh sách liên kết động. kiểu file. ta có ít nhất hai cách biểu diễn chúng như được thể hiện trong ngôn ngữ lập trình Pascal và C. kiểu cấu trúc. Người ta thường viết: T = <V. !=. đó là tập các giá trị hợp lệ mà đối tượng kiểu T có thể nhận và lưu trữ. ≥. ≥. *. <. khi nói đến một kiểu dữ liệu T. ký tự. ta thường trải qua các giai đoạn chính sau đây: . lãng phí tài nguyên trên máy tính (CPU và bộ nhớ). *. >. ta sẽ có những cách xây dựng các thao tác tương ứng trên chúng khác nhau. Với kiểu ký tự. Với kiểu lôgic. phức tạp không cần thiết và chương trình kém hiệu quả. kiểu hợp. ≤. Với kiểu số nguyên. sắp xếp sẽ thực hiện theo những cách thức khác nhau và do đó có hiệu quả khác nhau.

cần phải xác định nó tác động lên loại đối tượng hay trên cấu trúc dữ liệu hoặc trong kiểu dữ liệu nào? Với mỗi mô hình dữ liệu. Khi đề cập đến một thao tác. Vì vậy việc chọn cấu trúc dữ liệu nào để biểu diễn mô hình sẽ phụ thuộc vào từng ứng dụng cụ thể. Khi xây dựng các giải thuật nhằm giải quyết một bài toán. nếu số lượng các đối tượng này không quá lớn (để có thể lưu ở bộ nhớ trong). ta nên để ý các tiêu chuẩn sau: . đối với một dãy các đối tượng dữ liệu cùng loại.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. Vai trò của kiểu dữ liệu trong việc giải một bài toán trên máy tính Khi đề cập đến một thao tác. Hai giai đoạn đầu rất quan trọng. ta còn cần phải lưu ý đến những phép toán cơ bản nào sẽ được thực hiện thường xuyên trên các đối tượng dữ liệu đó. cần phải xác định rõ nó tác động trên loại đối tượng hoặc kiểu dữ liệu nào? Khi cấu trúc dữ liệu thay đổi thì các giải thuật cơ bản tương ứng với nó cũng thay đổi theo. Đây là tiêu chuẩn rất quan trọng nhằm quyết định tính đúng đắn của toàn bộ bài toán. ta phải dựa trên các yêu cầu cần xử lý để xem xét kỹ lưỡng. khi xây dựng các kiểu dữ liệu nhằm giải quyết một bài toán cụ thể. cũng như nên dựa trên các đặc trưng của bài toán và tài nguyên (tốc độ xử lý và khả năng lưu trữ của hệ thống máy tính) thực tế hiện có. nó góp phần quyết định tính đúng đắn và hiệu quả của chương trình nhằm giải bài toán.Phản ánh đúng thực tế: có dự trù đến khả năng biến đổi của dữ liệu trong chu trình sống của nó. tự nhiên hơn và đạt hiệu quả cao về mặt tốc độ và bộ nhớ.Cấu trúc dữ liệu được xây dựng cần phù hợp với các thao tác trên đó (đặc biệt là các thao tác được sử dụng nhiều nhất). ngoài ra. hơn nữa các phép toán thêm và hủy các đối tượng xảy ra rất thường xuyên thì ta nên chọn kiểu dữ liệu là danh sách liên kết động hơn là kiểu mảng tĩnh để lưu trữ dãy đối tượng này.Thử nghiệm chương trình . Để việc chọn cấu trúc dữ liệu biểu diễn bài toán một cách phù hợp. có thể có một số phép toán được thực hiện thuận lợi. đặc tả và mô hình hoá bài toán .Bảo trì chương trình.Mã hóa chương trình . phân tích. Trong mỗi cách cài đặt. có thể có nhiều cách cài đặt bởi các cấu trúc dữ liệu khác nhau.Chọn cấu trúc dữ liệu để biểu diễn bài toán và phát triển giải thuật (chọn kiểu dữ liệu) . . . Chẳng hạn. Tóm lại. Khi đó. cần chú ý đến những quan hệ giữa các đối tượng và thành phần dữ liệu với nhau. biến động nhiều.Đặt bài toán. việc phát triển các giải thuật sẽ đơn giản. nhưng một số phép toán khác lại không thuận tiện.3 .

chưa vội sa đà vào tiểu tiết). việc thử nghiệm.2. dễ kiểm tra. bổ sung. Việc phân tích và thiết kế bài toán lớn thành các bài toán con thường chiếm thời gian lẫn công sức lớn hơn nhiều so với nhiệm vụ lập trình (coding). sửa chữa.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. bảo trì các hệ chương trình lớn chiếm tỷ lệ thời gian đáng kể so với tổng thời gian xây dựng hệ chương trình. Việc xây dựng mô hình cùng với các giải thuật và cách cài đặt các chương trình giải chúng ngày càng có quy mô lớn và phức tạp. thường đòi hỏi công sức đồng thời của cả một tập thể các nhóm phân tích .4 . ta nên chọn giải thuật cho phù hợp. mở rộng. . Thiết kế giải thuật theo phương pháp Top-Down Các bài toán giải được trên máy tính ngày càng đa dạng và phức tạp. rồi mới chia dần từng công việc lớn thành các công việc (module) chi tiết hơn. Đó là cách phân tích bài toán. xuất phát từ dữ kiện và các mục tiêu đặt ra nhằm đưa ra các công việc chủ yếu (theo cấu trúc phân cấp. Thiết kế và phân tích giải thuật I. nhiều người có thể cùng chia xẻ giải quyết các vấn đề lớn mà không cần quan tâm tới chi tiết phần việc của người khác mà sau đó vẫn có thể nối kết các module nhỏ để cả bài toán lớn được giải quyết. I. Khi thiết kế từng module nên chú ý đến tính độc lập tương đối của chúng đối với các module khác. Khi đó. Để chương trình trở nên dễ hiểu.2.Tiết kiệm tài nguyên (tốc độ xử lý và dung lượng bộ nhớ): Đối với các giải thuật không quá tầm thường. người ta thường áp dụng chiến thuật “chia để trị” bằng phương pháp thiết kế từ trên xuống (top-down design) hay thiết kế từ khái quát đến chi tiết. hai yêu cầu này thường mâu thuẫn nhau và khó đạt được tối ưu đồng thời. mở rộng chương trình trở nên dễ dàng và đơn giản hơn. Đó cũng là cơ sở của kỹ thuật lập trình có cấu trúc. đặc biệt là trong môi trường làm việc theo nhóm.1. Việc giải bài toán lớn ban đầu qui về việc kết hợp những lời giải của các bài toán con. Phương pháp thiết kế này hỗ trợ đắc lực trong việc lập trình theo nhóm của công nghệ phần mềm. Quá trình này làm cho việc tìm hiểu cũng như sửa lỗi.thiết kế viên cũng như các thảo chương viên. dễ bảo trì và dễ mở rộng hơn. bổ sung. Tùy theo yêu cầu của bài toán và tài nguyên thực tế. Mặt khác. nếu các module này vẫn còn phức tạp ta lại chia tiếp chúng thành các module nhỏ hơn cho tới khi đạt đến các phần việc cơ bản mà ta đã biết cách giải quyết.

2.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. Phương pháp qui hoạch động sử dụng kỹ thuật “đi từ dưới lên”: xuất phát từ nghiệm của những bài toán con sơ cấp (được lưu giữ trong một bảng nhằm tránh mất công sức giải lại những bài toán con này sẽ phát sinh khi cần giải những bài con lớn hơn sau này).2. . cứ tiếp tục như vậy cho đến khi tìm được nghiệm của bài toán lớn ban đầu từ bảng. .Tốc độ thực hiện và dung lượng bộ nhớ cần chiếm dụng của giải thuật: Thông thường hiếm khi cả hai yêu cầu tối ưu về thời gian và bộ nhớ được thỏa mãn đồng thời. Trong quá trình từng bước mở rộng nghiệm từng phần để đạt đến nghiệm tối ưu của bài toán (dưới dạng vectơ). phương pháp qui hoạch động. Đây là một kỹ thuật rất quan trọng trong việc thiết kế giải thuật. Các chiến lược khác để thiết kế giải thuật Ngoài chiến lược chia để trị. dễ chỉnh lý.3. phương pháp quay lui. Phương pháp nhánh và cận là một dạng cải tiến của phương pháp quay lui để tìm nghiệm tối ưu của bài toán. I.2. dễ lập trình. người ta còn dùng các phương pháp thiết kế giải thuật sau: phương pháp tham lam. nếu biết các nghiệm mở rộng đều có hàm giá lớn hơn giá của nghiệm tốt nhất ở thời điểm đó. Phương pháp tham lam thường dùng để tìm nghiệm tối ưu trong một tập nghiệm chấp nhận được S nào đó được xây dựng theo một hàm chọn để bổ sung những phần tử vào S theo một cách thích hợp. Thông thường các giải thuật quá đơn sơ chưa hẳn là cách tốt nhất và nó thường gây tổn phí thời gian và bộ nhớ khi thực hiện. tự nhiên. Để chứng minh được tính đúng đắn của giải thuật nhiều khi đòi hỏi phải sử dụng các công cụ toán học khá phức tạp. thì ta không cần mở rộng nghiệm một phần theo nhánh này nữa và quay lui sang tìm nghiệm trên nhánh khác có triển vọng hơn. Phương pháp quay lui thường dùng để tìm một hoặc tất cả nghiệm của bài toán dưới dạng một vectơ nghiệm có thể chưa biết trước độ dài của nó và có thể được xác định dần trong quá trình giải. nhưng đây là một công việc không phải luôn luôn dễ dàng.Tính đơn giản của giải thuật: thể hiện qua tính dễ hiểu. Phân tích giải thuật và độ phức tạp của giải thuật a. Các chiến lược này sẽ được nghiên cứu chi tiết trong các học phần tiếp theo. hiệu quả hơn nhưng chỉ sử dụng quá ít lần với bộ dữ liệu quá nhỏ với điều kiện thời gian hạn chế trong một môi trường lập trình thực tế. Các giải thuật không tầm thường nếu có tốc độ thực hiện cao thì . phương pháp nhánh và cận. Nhưng cách thử này chỉ phát hiện được tính sai chứ chưa thể bảo đảm được tính đúng của giải thuật. ta xây dựng nghiệm của những bài toán con lớn hơn và lưu tiếp vào bảng. Nhưng trên thực tế ta nên cân nhắc giữa tính đơn giản của giải thuật và thời gian lẫn công sức để xây dựng các giải thuật tinh tế.5 I. Các vấn đề cần lưu ý khi phân tích giải thuật .Tính đúng đắn của giải thuật: cần trả lời câu hỏi liệu giải thuật có thể hiện đúng lời giải của bài toán hay không? Thông thường người ta cài đặt giải thuật đó trên máy tính và thử nghiệm nó với một số bộ dữ liệu mẫu nào đó rồi so sánh kết quả thử nghiệm với kết quả được lấy từ những thông tin và phương pháp khác mà ta đã biết chắc đúng.

n→∞ g ( n) . Chẳng hạn.T(n) có cấp g(n) nếu : lim T ( n) = C > 0. Ở đây ta hạn chế chỉ xét yêu cầu về thời gian thực hiện của giải thuật. Cách đánh giá thời gian thực hiện giải thuật độc lập với máy tính và chỉ phụ thuộc vào bản thân giải thuật và dữ liệu như vậy sẽ dẫn tới khái niệm “độ phức tạp của giải thuật” hay cấp độ lớn của thời gian thực hiện giải thuật. khi xét độ phức tạp T(n) của giải thuật ta thường xét các trường hợp: thuận lợi nhất.6 thường chiếm bộ nhớ nhiều và ngược lại. Chẳng hạn. . các hằng số dương C và n0 sao cho: (1) T(n) ≤ C g(n) khi n ≥ n0 Khi đó ta nói: T(n) có cấp g(n) và viết: T(n) = O(g(n)). ngôn ngữ lập trình và chương trình dịch ngôn ngữ ấy. Vì vậy khi xây dựng T(n) không nên dựa vào chúng. Nhưng các loại yếu tố này phụ thuộc vào cách cài đặt và loại máy tính trên đó giải thuật được cài đặt. nếu tồn tại: một hàm g(n) không âm.Khi xây dựng hàm T(n) cho một giải thuật người ta thường chỉ xét các thao tác đặc trưng cho giải thuật đó (thời gian thực hiện các thao tác này nhiều hơn đáng kể so với thời gian thực hiện các loại thao tác khác).Kích thước dữ liệu n đưa vào: ta gọi thời gian thực hiện của giải thuật trên bộ dữ liệu này là một hàm của n : T(n) .Ta nên chọn cận trên g(n) có “cấp nhỏ nhất” thỏa mãn tính chất (1). Độ phức tạp của giải thuật • Thời gian thực hiện một giải thuật phụ thuộc vào khá nhiều yếu tố: .Thông thường ta dùng các hàm sau để đánh giá độ phức tạp của giải thuật: 1 << log2n << n << n log2n << n2 << … << nk (k>= 2. b. + Lưu ý: . xấu nhất và trung bình (thường khó xét vì trong nhiều trường hợp đòi hỏi các công cụ toán học phức tạp).Các kiểu lệnh và tốc độ xử lý của máy tính. . .Tình trạng của dữ liệu: Thời gian thực hiện giải thuật không chỉ phụ thuộc vào kích thước n của dữ liệu mà còn phụ thuộc vào chính tình trạng của dữ liệu đó. • Gọi T(n) là độ phức tạp của một giải thuật. độ phức tạp loại đa thức) << (độ phức tạp loại mũ) 2 n << n! << nn .Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. số các thao tác cơ bản để sắp xếp theo thứ tự tăng một dãy số đưa vào đã có đúng thứ tự sẽ khác nhiều so với dãy chưa được sắp hay đã sắp theo thứ tự ngược lại. Vì vậy. khi xét các giải thuật sắp xếp n mục dữ liệu với cấu trúc “lưu trữ trong” ta thường chú ý tới số lần đổi chỗ và so sánh các mục dữ liệu theo một trường khoá nào đó.

xấu nhất và trung bình. P2 là một thao tác trong P1. Để đánh giá độ phức tạp của giải thuật trong trường hợp trung bình thường đòi hỏi những công cụ toán học khá tinh vi và khó. N 10 50 100 Log2(n) (s) 3 e-09 6 e-09 7 e-09 n (s) 1 e-08 5 e-08 1 e-07 n*Log2(n) (s) 3 e-08 3 e-07 7 e-07 n*n (s) 1 e-07 3 e-06 1 e-05 2n(năm) 3 e-14 4 e-02 4 e+13 n! (năm) 1 e-10 1 e+48 3 e+141 nn (năm) 3 e-07 3 e+68 3 e+183 c Một số quy tắc để xác định độ phức tạp của giải thuật Giả sử T1(n) và T2 (n) là thời gian thực hiện của hai đoạn chương trình P1 và P2 mà T1(n) = O(f(n)) và T2 (n) = O(g(n)). d.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. ∀n ≥ n0 thì O(f(n) + g(n)) = O(g(n)) .Xác định đặc trưng dữ liệu được dùng làm dữ liệu nhập và quyết định sự phân tích nào là phù hợp. ký hiệu : f(n) << g(n) có nghĩa là “f(n) nhỏ hơn g(n) rất nhiều” khi n đủ lớn hay: lim f ( n) = 0. . Các bước phân tích giải thuật .Xác định các thao tác cơ bản trừu tượng của giải thuật để tách biệt sự phân tích với sự cài đặt. . . Giả sử ta cài đặt các giải thuật trên một máy tính với tốc độ xử lý 1 tỉ phép tính trong 1 giây (s). n→∞ g ( n) Bảng sau đây cho ta hình dung về độ tăng nhanh của các lớp giải thuật có độ phức tạp đa thức và mũ theo số lượng n các mục dữ liệu đầu vào.7 trong đó. .g(n)). Ví dụ: nếu f(n) ≤ g(n). ta thường hạn chế trên những đánh giá ước lượng chặn trên và tránh sa đà vào các tiểu tiết phức tạp.g(n))). vì vậy trong nhiều trường hợp.Quy tắc nhân: Thời gian thực hiện P1 và P2 lồng nhau là: T1(n) T2(n) = O(f(n). Ví du: P1 là một vòng lặp.Phân tích về mặt toán học độ phức tạp của giải thuật trong các trường hợp: tốt nhất.Quy tắc tổng: Thời gian thực hiện liên tiếp P1 và P2 là: T1(n) + T2(n) = O(max(f(n).

.v2. Phép toán cơ bản trong giải thuật tìm kiếm trên là phép so sánh khóa dữ liệu vThứ với X.8 * Ví du: Xét giải thuật tìm xem một phần tử X có mặt trong một vector có n phần tử V = {v1. ptu V[]. còn xác suất để X không rơi vào phần tử nào của V sẽ là: 1 . . . .2. } Bước 3: Trả về trị Thấy.i + (1-q)n Ttb (n) = q ∑ i/n + (1-q)n i =1 = q(n+1)/2 + (1-q)n = n(1-q/2) + q/2 Nếu q=1 (nghĩa là luôn tìm thấy X trong V) thì : Ttb (n) = (n+1)/2 Nếu q=1/2 (nghĩa là khả năng tìm thấy và không tìm thấy X trong V bằng nhau) thì : Ttb (n) = (3n+1)/4 Nếu q= 0 (nghĩa là không tìm thấy X trong V) thì : Ttb (n) = n Tóm lại: Ttb (n) = O(n). Bước 2: Trong khi (not(Thấy) and Thứ ≤ n) { if (vThứ == X) Thấy = True.Trường hợp tốt nhất xảy ra khi X bằng v1: Ttốt(n) = O(1)...4.Trường hợp trung bình: Gọi q là xác suất để X rơi vào một phần tử nào đó của V và giả sử X có phân bố đều trên n phần tử phân biệt của V thì xác suất để X rơi vào phần tử vi là: pi = q/n. Thứ = 1. vn} cho trước hay không? Boolean TìmKiếm(ptu X. int n) Bước 1: Thấy = False. Qui ước về ngôn ngữ mã giả .Trường hợp xấu nhất xảy ra khi X chỉ bằng vn hoặc không tìm thấy: Txấu(n) = O(n).q.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. else Thứ = Thứ + 1. I. Độ phức tạp trung bình của giải thuật là: Ttb (n) = ∑ i =1 n n pi.

Cấu trúc lặp: for (LệnhKhởiĐầu. LệnhThayĐổiĐiềuKiệnLặp) S. trong giáo trình sẽ sử dụng ngôn ngữ mã giả tựa ngôn ngữ C++ (thật ra nó chỉ khác ngôn ngữ mã giả tựa Pascal không đáng kể) để mô tả cấu trúc dữ liệu và các cấu trúc điều khiển trong các giải thuật. ! (not) và trị lôgic kiểu boolean: True. … case Trị_n: Sn. || (or). . . do S while (ĐiềuKiện).Lệnh ghép: dãy lệnh nằm giữa cặp dấu ngoặc kép { … } . [default : S.Khai báo chương trình con viết dưới dạng hàm: KiểuTrảVềCủaHàm TênHàm(KiểuThamTrị ThamTrị. . break.Cấu trúc điều khiển: “nếu (điều kiện đúng) thì thực hiện lệnh S”: if (ĐiềuKiện) S. case Trị_2: S2. ≥ . . ĐiềuKiệnLặp. hoặc: if (ĐiềuKiện) S1. KiểuThamChiếu &ThamChiếu) . >. ≤.Cấu trúc điều khiển nhiều chọn lựa: switch (BiểuThứcVôHướng) { case Trị_1: S1.9 Để tiện cho việc thực hành cho học viên (trên ngôn ngữ lập trình C hay C++). False. !=.Phép toán lôgic: && (and). .Phép gán: = .] }. break. break. while (ĐiềuKiện) S. else S2.Giôùi thieäu caáu truùc döõ lieäu vaø phaân tích giaûi thuaät I. repeat S until (ĐiềuKiện).Quan hệ so sánh: ==. <.

toàn bộ dữ liệu được đưa vào bộ nhớ trong (bộ nhớ chính). Sắp xếp a.key) . xk2. * Sắp xếp ngoài: Với các phương pháp sắp xếp ngoài. xkn sao cho với một hàm thứ tự f cho trước..1. toàn bộ dữ liệu được lưu ở bộ nhớ ngoài. ∝ f(xkn).. Tm.1. chỉ một phần dữ liệu được đưa vào bộ nhớ chính. phần còn lại nằm trên thiết bị trữ tin.Chương II TÌM KIẾM VÀ SẮP XẾP TRONG II. c. ta có : f(xk1 ) ∝ f(xk2) ∝ . Giới thiệu về sắp xếp và tìm kiếm II. Ta thường gặp ∝ là quan hệ thứ tự "≤" thông thường. Đặc điểm của loại sắp xếp ngoài là khối lượng dữ liệu ít bị hạn chế.. b. DataType Data.. thời gian sắp xếp lại nhanh. Vài qui uớc về kiểu dữ liệu khi xét các thuật toán sắp xếp Thông thường. . kiểu dữ liệu chung T0 của các phần tử xi thường được cài đặt bởi cấu trúc: typedef struct { KeyType key... } ElementType. xn có cùng một kiểu dữ liệu T0. Khi đó. Đặc điểm của phương pháp sắp xếp trong là khối lượng dữ liệu bị hạn chế nhưng bù lại. T0 có kiểu cấu trúc gồm m trường thành phần T1. nhưng thời gian sắp xếp lại chậm (do thời gian chuyển dữ liệu từ bộ nhớ phụ vào bộ nhớ chính để xử lý và kết quả xử lý được đưa trở lại bộ nhớ phụ thường khá lớn). Sắp thứ tự n phần tử này là một hoán vị các phần tử thành dãy xk1. Trong quá trình sắp xếp.key}1≤i≤n. Định nghĩa sắp xếp Cho dãy X gồm n phần tử x1. trên đó có một quan hệ thứ tự α. ta có thể giả sử f là ánh xạ từ miền trị của T0 vào miền trị của một thành phần dữ liệu đặc biệt (mà ta gọi là khóa. trong đó: ∝ là một quan hệ thứ tự. trên đó có một quan hệ thứ tự α.. x2.1. Không mất tính tổng quát.. …. Hàm thứ tự f là một ánh xạ từ miền trị của kiểu T0 vào miền trị của một số thành phần {Tik}1≤ ik ≤ p. Phân loại phương pháp sắp xếp Dựa trên tiêu chuẩn lưu trữ dữ liệu ở bộ nhớ trong hay ngoài mà ta chia các phương pháp sắp xếp thành hai loại: * Sắp xếp trong: Với các phương pháp sắp xếp trong. T2.. Khi đó bài toán đưa về sắp xếp dãy {xi..

} int SoSánh(ElementType x. α là quan hệ thứ tự ≤ thông thường và f là hàm đồng nhất và ta chỉ cần xét các phương pháp sắp xếp tăng trên dãy đơn giản {xi}1≤i≤n. ta có thể giả sử T0 chỉ gồm trường khóa. return . Trong chương này. ta thường sử dụng các phép toán: đổi chỗ HoánVị(x.. Gán(x. // Kiểu mảng mang x. dãy x thường được lưu trong mảng tĩnh như sau: #define MAX_SIZE … // Kích thước tối đa của mảng cần sắp theo thứ tự tăng typedef .y) như sau: void HoánVị(ElementType &x.. Gán(tam. ElementType y) { // Gán y vào x.2 Để đơn giản trong trình bày. == cho hợp lệ } . ElementType. >. Gán(y. x).y). ElementType &y) { ElementType tam. y). } void Gán(ElementType &x. // Kiểu dữ liệu chung cho các phần tử của mảng typedef ElementType mang[MAX_SIZE] .. ElementType y) { // Hàm trả về trị: 1 nếu x > y // 0 nếu x == y // -1 nếu x < y // tùy theo kiểu ElementType mà ta dùng các quan hệ <. tùy từng kiểu dữ liệu mà ta có phép gán cho hợp lệ return. khi xét các phương pháp sắp xếp trong.y).Tìm kieám vaø saép xeáp trong II. so sánh SoSánh(x. Trong phần cài đặt các thuật toán sắp xếp sau này. gán Gán(x. tam).

Phân loại các phương pháp tìm kiếm Cũng tương tự như sắp xếp. II.3 Khi đánh giá độ phức tạp của mỗi thuật toán sắp xếp. ..2. II.trị 0.1. xn đều có cùng kiểu T0.. Tìm kiếm a. Bài toán tìm kiếm là xem Item có mặt trong dãy X hay không? (hay tổng quát hơn: xem trong dãy X có phần tử nào thỏa mãn một tính chất TC cho trước nào đó liên quan đến Item hay không?) b. ta chỉ hạn chế xét các phương pháp tìm kiếm và sắp xếp trong. Với từng nhóm phương pháp. II. Chẳng hạn đối với trường hợp dữ liệu đã được sắp và lưu ở bộ nhớ trong. Khi cài đặt các thuật toán tìm kiếm. ta lại phân biệt các phương pháp tìm kiếm tùy theo dữ liệu ban đầu đã được sắp hay chưa. thuật toán tìm kiếm đơn giản nhất là tìm tuần tự từ đầu đến cuối dãy. Dãy chưa được sắp Đối với dãy bất kỳ chưa được sắp thứ tự.. x2. xn} gồm n mục dữ liệu .Item: mục dữ liệu cần tìm cùng kiểu dữ liệu với các phần tử của X Output: Trả về: . ta có 2 phương pháp tìm kiếm: tuyến tính hay nhị phân.Tìm kieám vaø saép xeáp trong II... Phương pháp tìm kiếm trong Bài toán: Input : . Trong chương này. Phương pháp tìm kiếm tuyến tính a.2.dãy X = {x1. nếu không thấy Item trong X . số lần hoán vị khóa (HV) hoặc số lần Gán (G) trong thuật toán đó. ta cũng có các qui ước tương tự cho kiểu dữ liệu và các phép toán cơ bản trên kiểu đó như đối với các phương pháp sắp xếp đã trình bày ở trên..vị trí đầu tiên i (1 ≤ i ≤ n) trong X sao cho xi ≡ Item.2. Định nghĩa tìm kiếm Cho trước một phần tử Item và dãy X gồm n phần tử x1..1.. ta cũng có 2 loại phương pháp tìm kiếm trong và ngoài tùy theo dữ liệu được lưu trữ ở bộ nhớ trong hay ngoài. x2. ta thường chỉ tính số lần so sánh khóa (SS).

VịTrí = VịTrí + 1. ĐịaChỉ = ĐịaChỉ phần tử kế tiếp. n.tốt nhất (khi Item ≡ x1): Ttốt (n) = O(1) . if (VịTrí ≥ n) VịTrí = 0. } * Chú ý: Để cài đặt thuật toán trên (cũng tương tự như thế với các thuật toán tiếp theo) với danh sách tuyến tính nói chung thay cho cách cài đặt danh sách bằng mảng. • Cài đặt int TìmTuyếnTính (mang x.Bước 1: VịTrí = 1. Dữ liệu của phần tử tại ĐịaChỉ. ta chỉ cần thay các câu lệnh hay biểu thức sau: VịTrí = 1. return(VịTrí). * Độ phức tạp của thuật toán tìm kiếm tuyến tính (trên dãy chưa được sắp) trong trường hợp: . . xVịTrí .tồi nhất (khi không có Item trong dãy hoặc Item chỉ trùng với xn): Txấu(n) = O(n) .4 • Thuật toán int TìmTuyếnTính(x. //không thấy Trả về trị VịTrí. Item) . ElementType Item) { int VịTrí = 0. ta dùng thêm một biến phụ đóng vai trò lính canh bên phải (hay trái) xn+1 = Item (hay x0 = Item).Tìm kieám vaø saép xeáp trong II. Quay lại đầu bước 2. • Thuật toán int TìmTuyếnTính_CóLínhCanh(x. n. while ((VịTrí < n) && (x[VịTrí] != Item)) VịTrí = VịTrí + 1 . Item) . int n.Bước 3: if (VịTrí > n) VịTrí = 0.Bước 2: if ((VịTrí ≤ n) and (xVịTrí != Item)) { VịTrí = VịTrí + 1. (VịTrí ≤ n) . . (ĐịaChỉ != ĐịaChỉ kết thúc). } else chuyển sang bước 3.trung bình: Ttbình(n) = O(n) * Thuật toán tìm kiếm tuyến tính cải tiến bằng kỹ thuật lính canh Để giảm bớt phép so sánh chỉ số trong biểu thức điều kiện của lệnh if hay while trong thuật toán trên. //không thấy else VịTrí++. trong thuật toán tương ứng bởi: ĐịaChỉ = ĐịaChỉ phần tử (dữ liệu) đầu tiên.

5 - - Bước 1: VịTrí = 1.Bước 3: if ((VịTrí == n+1) or (VịTrí < n+1 and xVịTrí > Item)) VịTrí = 0.Tìm kieám vaø saép xeáp trong II. int n) { int VịTrí = 0. . // phần tử cầm canh . // thấy giả hoặc không thấy ! Trả về trị VịTrí. ta có thể cải tiến thuật toán tìm kiếm tuyến tính có lính canh như sau: ta sẽ dừng việc tìm kiếm khi tìm thấy hoặc tại thời điểm i đầu tiên gặp phần tử xi mà: xi ≥ Item. . • Thuật toán int TìmTuyếnTính_TrongMảngĐãSắp_CóLínhCanh(a. ta có thể giả sử tăng dần).Bước 2: if (xVịTrí < Item) { VịTrí = VịTrí + 1.Bước 1: VịTrí = 1. Quay lại đầu bước 2. } b. if (VịTrí == n) VịTrí = 0. else VịTrí = 0. Quay lại đầu bước 2. n) . ElementType Item. x[n] = Item. Dãy đã được sắp Đối với dãy đã được sắp thứ tự (không mất tính tổng quát. // phần tử cầm canh while (x[VịTrí] < Item) VịTrí = VịTrí + 1. • Cài đặt int TìmTuyếnTính_TrongMảngĐãSắp_CóLínhCanh (mang x. return(VịTrí). Trả về trị VịTrí. Bước 2: if (xVịTrí != Item) { VịTrí = VịTrí + 1. x[n] = Item. xn+1 = Item. Bước 3: if (VịTrí == n+1) VịTrí = 0. } else chuyển sang bước 3. xn+1 = Item. // phần tử cầm canh // thấy giả hay không thấy ! • Cài đặt int TìmTuyếnTính_CóLínhCanh(mang x. } else chuyển sang bước 3. // thấy giả hoặc không thấy ! return(VịTrí). // phần tử cầm canh while (x[VịTrí] != Item) VịTrí = VịTrí + 1. // thấy giả hay không thấy ! else VịTrí++. if (VịTrí < n && (x[VịTrí] == Item)) VịTrí++. ElementType Item) { int VịTrí = 0. Item. int n.

nếu không ta sẽ tìm Item trong dãy con phải: xgiữa+1. • Thuật toán int TìmNhịPhân(x. else { if (Item < xChỉSốGiữa) ChỉSốCuối = ChỉSốGiữa -1. xgiữa-1.Bước 1: ChỉSốĐầu = 1. n) . Phương pháp tìm kiếm nhị phân. else if (Item < x[Giữa]) Cuối = Giữa -1 else Đầu = Giữa + 1. ElementType Item. .Bước 3: if (ChỉSốĐầu <= ChỉSốCuối) return (ChỉSốGiữa). để giảm hẳn độ phức tạp trong trường hợp trung bình và kể cả trường hợp xấu nhất.2. if (Item == xChỉSốGiữa) Chuyển sang bước 3. ChỉSốCuối = n.6 } * Tuy có tốt hơn phương pháp tìm kiếm tuyến tính trong trường hợp mảng chưa được sắp.Bước 2: if (ChỉSốĐầu <= ChỉSốCuối) { ChỉSốGiữa = (ChỉSốĐầu + ChỉSốCuối)/2. else ChỉSốĐầu = ChỉSốGiữa +1. else return (0). } if (Đầu ≤ Cuối) return (Giữa+1). xn. ngược lại. nếu Item < xgiữa thì ta sẽ tìm Item trong dãy con trái: x1.Tìm kieám vaø saép xeáp trong II. Ta sẽ thể hiện ý tưởng trên thông qua thuật toán lặp sau đây. Item. …. if (Item == x[Giữa]) break. Quay lại đầu bước 2. else return (0). int n) { int Đầu = 0. nếu thấy (Item = xgiữa) thì dừng. // Không thấy • Cài đặt int TimNhiPhan(mang x. // lấy thương nguyên } . // Tìm tiếp trong nửa dãy con còn lại } .2. Cuối = n-1. while (Đầu ≤ Cuối) { Giữa = (Đầu + Cuối)/2. so sánh Item với phần tử đứng giữa dãy xgiữa. Ý tưởng của phương pháp: Trước tiên. II. nhưng trong trường hợp này thì độ phức tạp trung bình vẫn có cấp là n: Ttbình = O(n) Đối với mảng đã được sắp. …. ta sử dụng ý tưởng “chia đôi” thể hiện qua phương pháp tìm kiếm nhị phân sau đây.

tất nhiên khi đó sẽ lãng phí bộ nhớ hơn ! Tại sao ? (xem như bài tập). xuất phát từ dãy con liên tục đã được sắp.3. Phương pháp sắp xếp trong Có 3 nhóm chính các thuật toán sắp xếp trong (đơn giản và cải tiến): * Phương pháp sắp xếp chọn (Selection Sort): Trong nhóm các phương pháp này. Nếu cũng sử dụng các phép hoán vị nhưng trên các cặp phần tử không nhất thiết luôn ở kề nhau một cách hợp lý thì ta định vị đúng được các phần tử (không nhất thiết phải luôn ở mép các dãy con cần sắp) và sẽ thu được phương pháp QuickSort rất hiệu quả. II. có thể xáo trộn các phần tử ở các khoảng cách xa nhau một cách hợp lý (sao cho những thông tin đang tạo ra ớ bước hiện tại có thể có ích hơn cho các bước sau) thì sẽ được phương pháp sắp chọn cải tiến HeapSort. tại mỗi bước. trong phương pháp sắp xếp đổi chỗ. nếu ta chọn các dãy con ở các vị trí cách xa nhau theo một qui luật khoảng cách giảm dần hợp lý thì sẽ thu được phương pháp sắp chèn cải tiến ShellSort. Trong quá trình chọn. tại mỗi bước. ở mỗi bước ta dùng các phép hoán vị liên tiếp trên các cặp phần tử kề nhau không đúng thứ tự để xuất hiện các phần tử này ở mút của các dãy con còn lại cần sắp (phương pháp nổi bọt BubbleSort. Phương pháp sắp xếp chọn đơn giản . Thay vì chọn các dãy con liên tục được sắp dài hơn.Tìm kieám vaø saép xeáp trong II. phương pháp tìm kiếm nhị phân sẽ hiệu quả hơn nhiều so với phép tìm kiếm tuyến tính. II. trong phương pháp chèn trực tiếp. • Độ phức tạp của thuật toán trong trường hợp trung bình và xấu nhất: Ttbình (n) = Txấu (n) = O(log2 n) Do đó đối với dãy được sắp. ta tìm vị trí thích hợp để chèn vào dãy con đó một phần tử mới để thu được một dãy con mới dài hơn vẫn được sắp (phương pháp chèn trực tiếp). * Phương pháp sắp xếp đổi chỗ (Exchange Sort): Thay vì chọn trực tiếp phần tử cực trị của các dãy con. * Phương pháp sắp xếp chèn (Insertion Sort): Theo cách tiếp cận từ dưới lên (Down-Top). ta cũng có thể viết lại thuật toán trên dưới dạng đệ qui. ta chọn phần tử cực trị toàn cục (nhỏ nhất hay lớn nhất) rồi đặt nó vào đúng vị trí mút tương ứng của dãy con còn lại chưa sắp (phương pháp chọn trực tiếp).1. dùng các phép so sánh.3. ShakeSort). đặc biệt khi n lớn.7 } Dựa trên ý tưởng đệ qui của thuật toán.

. trong mọi trường hợp. xn. đổi chỗ. . i < n -1 . 42. Thuật toán SắpXếpChọn(x. 18. c.. 18. if (ChiSoMin > i) HoánVị(x[i]. i++) { ChiSoMin = i. Quay lại đầu bước 2. . for (int j = i + 1. 94. 06. n-1) luôn cần n-i phép so sánh khóa nên: SSxấu = SStốt = ∑ i =1 n −1 (n-i) = n(n − 1) 2 . .8 a.x[ChiSoMin]).Tìm kieám vaø saép xeáp trong II. } d. . 42.. // Chuyển phần tử nhỏ nhất vào vị trí của xi -Bước 3: if (i < n) { i = i+1. . tìm được xmin_1 = x7 = 6. ta được dãy sắp thứ tự x1. x2.. 12. Ví dụ: Sắp xếp tăng dãy: 44. 12. xn và đổi chỗ phần tử xmin_i với phần tử xi. 55. Ý tưởng phương pháp Với mỗi bước lặp thứ i (i = 1. n) .. } return. xmin_1 với x1: 44. ở bước thứ i (∀i = 1. n-1) chọn trực tiếp phần tử nhỏ nhất xmin_i trong từng dãy con có thể chưa được sắp xi... j++) if (x[j] < x[ChiSoMin]) ChiSoMin = j. 94.Bước 2: Tìm phần tử xChiSoMin nhỏ nhất trong dãy xi. } else Dừng. 67 Kết qủa sau mỗi bước lặp: 06 55 12 42 94 18 06 12 55 42 94 18 06 12 18 42 94 55 06 12 18 42 94 55 06 12 18 42 44 55 06 12 18 42 44 55 06 12 18 42 44 55 i=1: i=2: i=3: i=4: i=5: i=6: i=7: 44 44 44 44 94 94 67 67 67 67 67 67 67 94 b.. Cài đặt void SắpXếpChọn(mang x. Độ phức tạp thuật toán + Do. Cuối cùng.. xi+1. 55.. xn Hoán Vị xi và xChiSoMin.Bước 1: i = 1.. j < n... int n) { int ChiSoMin. 67 Ở bước thứ 1 (i=1). for (int i = 0.. xi+1. 06. ..

Tìm kieám vaø saép xeáp trong

II.9

+ Trong trường hợp xấu nhất (khi dãy đã được sắp theo thứ tự ngược lại), ở bước thứ i ta phải đổi chỗ khóa 1 lần : HVxấu =


i =1

n −1

1 = n -1

+ Trong trường hợp tốt nhất (khi dãy đã được sắp), ở bước thứ i ta không phải đổi chỗ khóa lần nào: HVtốt =


i =1

n −1

0 = 0

Tóm lại, độ phức tạp thuật toán: T(n) = Ttốt (n) = Txấu (n) = O(n2). II.3.2. Phương pháp sắp xếp chèn đơn giản a. Ý tưởng phương pháp: Giả sử dãy x1, x2, ..., xi-1 đã được sắp thứ tự. Khi đó, tìm vị trí thích hợp để chèn xi vào dãy x1, x2, ..., xi-1, sao cho dãy mới dài hơn một phần tử x1, x2, …, xi-1, xi vẫn được sắp thứ tự. Thực hiện cách làm trên lần lượt với mỗi i = 2, 3, ..., n, ta sẽ thu được dãy có thứ tự. Ví du : Sắp xếp dãy 67, 33, 21, 84, 49, 50, 75. Kết qủa sau mỗi bước lặp:

i=2 i=3 i=4 i=5 i=6 i=7

33 21 21 21 21 21

67 33 33 33 33 33

21 67 67 49 49 49

84 84 84 67 50 50

49 49 49 84 67 67

50 50 50 50 84 75

75 75 75 75 75 84

b. Nội dung thuật toán Để tăng tốc độ tìm kiếm (bằng cách giảm số biểu thức so sánh trong điều kiện lặp), ta dùng thêm lính canh bên trái x0 = xi trong việc tìm vị trí thích hợp để chèn xi vào dãy đã sắp thứ tự x1, x2, ..., xi-1 để được một dãy mới vẫn tăng x1, x2, ..., xi-1, xi, (với i = 2,..., n). SắpXếpChèn(x, n) - Bước 1: i = 2; // xuất phát từ dãy x1, x2, ..., xi-1 đã được sắp - Bước 2: x0 = xi; // lưu xi vào x0 - đóng vai trò lính canh trái Tìm vị trí j thích hợp trong dãy x1, x2, ..., xi-1 để chèn xi vào; //vị trí j đầu tiên từ phải qua trái bắt đầu từ xi-1 sao cho xj ≤ x0 -Bước 3: Dời chỗ các phần tử xj+1, ..., xi-1 sang phải một vị trí; if (j < i-1) xj+1 = x0; -Bước 4: if (i < n) { i = i+1; Quay lại đầu bước 2; } else Dừng; c. Cài đặt thuật toán

Tìm kieám vaø saép xeáp trong

II.10

Áp dụng một mẹo nhỏ, có thể áp dụng (một cách máy móc !) ý tưởng trên để cài đặt thuật toán trong C (bài tập). Lưu ý rằng trong C hay C++, với n phần tử của mảng x[i], i được đánh số bắt đầu từ 0 tới n -1; do đó, để cài đặt thuật toán này, thay cho lính canh trái như trình bày ở trên, ta sẽ dùng lính canh bên phải xn+1 (≡ x[n]) và chèn xi thích hợp vào dãy đã sắp tăng xi+1, ..., xn để được một dãy mới vẫn tăng xi, xi+1, ..., xn, với mọi i = n-1, ..., 1. void SắpXếpChèn(mang x, int n) { for ( int i = n -2 ; i >= 0 ; i--) { x[n] = x[i]; // lính canh phải j = i+1; while (x[ j ] < x[n]) { x[ j-1] = x[ j ]; // dời x[ j] qua trái một vị trí j++; } if (j > i+1) x[ j-1] = x[n]; } return ; } Có thể cải tiến việc tìm vị trí thích hợp để chèn xi bằng phép tìm nhị phân (bài tập). d. Độ phức tạp của thuật toán + Trường hợp tồi nhất xảy ra khi dãy có thứ tự ngược lại: để chèn xi cần i lần so sánh khóa với xi-1, ..., x1, x0.

n(n + 1) -1 2 i =2 n n(n + 3) 2 HVxấu = ∑ (i + 1) / 3 = 6 3 i =2
SSxấu =

∑i

n

=

+ Trong trường hợp tốt nhất (khi dãy đã được sắp): HVtốt = SStốt =

∑1 / 3 = (n -1)/3 ∑1 = n -1
i =2 i =2 n

n

Tóm lại, độ phức tạp thuật toán: Ttốt(n) = O(n). Txấu(n) = O(n2).

II.3.3. Phương pháp sắp xếp đổi chỗ đơn giản (phương pháp nổi bọt hay Bubble Sort) a. Ý tưởng phương pháp: Duyệt dãy x1, x2, ..., xn. Nếu xi > xi+1 thì hoán vị hai phần tử kề nhau xi và xi+1. Lặp lại quá trình duyệt (các phần tử “nặng” - hay lớn hơn - sẽ “chìm xuống dưới” hay chuyển dần về cuối dãy) cho đến khi không còn xảy ra việc hoán vị hai phần tử nào nữa. Ví dụ: Sắp xếp tăng dãy :

Tìm kieám vaø saép xeáp trong

II.11

44, 55, 12, 42, 94, 18, 06, 67 Viết lại dãy dưới dạng cột, ta có bảng chứa các kết quả sau mỗi bước lặp: Bước lặp 0 44 55 12 42 94 18 06 67 1 44 12 42 55 18 06 67 94 2 12 42 44 18 06 55 67 94 3 12 42 18 06 44 55 67 94 4 12 18 06 42 44 55 67 94 5 12 06 18 42 44 55 67 94 6 06 12 18 42 44 55 67 94

b. Nội dung thuật toán Để giảm số lần so sánh thừa trong những trường hợp dãy đã gần được sắp trong phương pháp nổi bọt nguyên thủy, ta lưu lại: - VịTríCuối: là vị trí của phần tử cuối cùng xảy ra hoán vị ở lần duyệt hiện thời - SốCặp = VịTríCuối -1 là số cặp phần tử cần được so sánh ở lần duyệt sắp tới. BubbleSort(x, n) - Bước 1: SốCặp = n -1; - Bước 2: Trong khi (SốCặp ≥ 1) thực hiện: { VịTríCuối = 1; i = 1; Trong khi (i < SốCặp) thực hiện: { if (xi > xi+1) { Hoán vị xi và xi+1; VịTríCuối = i; } i = i +1; } SốCặp = VịTríCuối -1; } c. Cài đặt thuật toán void BubbleSort(mang x, int n) { int ChỉSốCuối, SốCặp = n -1; while (SốCặp > 0) { ChỉSốCuối = 0; for (int i = 0; i< SốCặp; i++) if (x[i] > x[i+1]) { HoánVị(x[i], x[i+1]); ChỉSốCuối = i; } SốCặp = ChỉSốCuối; }

12 return .4) Bước 0 1 2 3 44 55 12 42 94 18 06 67 06 44 12 42 55 18 67 94 06 12 18 42 44 55 67 94 06 12 18 42 44 55 67 94 b. 18. 67 Viết lại dãy dưới dạng cột. 06. Txấu(n) = O(n2). n) .R) = (1. Ví dụ: Sắp xếp tăng dãy : 44. Độ phức tạp của thuật toán nổi bọt + Trong trường hợp tồi nhất (dãy có thứ tự ngược lại). với mỗi lượt. } d.Tìm kieám vaø saép xeáp trong II. ta có bảng chứa các kết quả sau mỗi bước lặp: (L. 42.4) (4.3. nhằm ghi lại các đoạn con cần sắp xếp và tránh các phép so sánh thừa ngoài đoạn con đó.8) (2. ta tính được: HVxấu = SSxấu = n −1 i =1 ∑ i =1 n −1 (n-i) = n(n − 1) 2 + Trong trường hợp tốt nhất (dãy đã được sắp): HVtốt = ∑ 0 = 0 SStốt = n -1 Tóm lại. Phương pháp ShakerSort khắc phục nhược điểm trên bằng cách duyệt 2 lượt từ hai phía để đẩy các phần tử nhỏ (lớn) về đầu (cuối) dãy.7) (3. 12. II. lưu lại vị trí hoán vị cuối cùng xảy ra. độ phức tạp thuật toán: Ttốt(n) = O(n). Ý tưởng phương pháp: Phương pháp sắp xếp nổi bọt có nhược điểm là: các phần tử có trị lớn được tìm và đặt đúng vị trí nhanh hơn các phần tử có trị bé. 55. 94. Phương pháp sắp xếp đổi chỗ cải tiến (ShakerSort) a. Nội dung thuật toán ShakerSort(x.4.

Bước 2: * Bước 2a: // Duyệt từ dưới lên để đẩy phần tử nhỏ về đầu dãy: L j = R. } L = ChỉSốLưu. j--) { if (x[ j ] < x[ j -1]) { HoánVị(x[ j ]. ChỉSốLưu = R. j > L. ChỉSốLưu = j. Trong khi (j < R) thực hiện: { if (xj > xj+1) { Hoán vị xj và xj+1. } j = j +1. Trong khi (j > L) thực hiện: { if (xj < xj-1) { Hoán vị xj và xj-1. .Bước 1: L = 1. } R = ChỉSốLưu. } } L = ChỉSốLưu. ChỉSốLưu = j. R = n. } j = j -1. for (j = R. j++) . R = n-1. // Không xét các phần tử đã sắp ở đầu dãy * Bước 2b:// Duyệt từ trên xuống để đẩy phần tử lớn về cuối dãy: R j = L.Bước 3: if (L < R) Quay lại bước 2.13 . do {// Duyệt từ dưới lên để đẩy phần tử nhỏ về đầu dãy: L ChỉSốLưu = R. for (j = L. j < R. // Không xét các phần tử đã sắp ở cuối dãy . // không xét các phần tử đã sắp ở đầu dãy // Duyệt từ trên xuống để đẩy phần tử lớn về cuối dãy: R ChỉSốLưu = L.Tìm kieám vaø saép xeáp trong II. int n) { int ChỉSốLưu. else Dừng. ChỉSốLưu = j. L = 0. Cài đặt thuật toán void ShakerSort(mang x. j. ChỉSốLưu = L. x[ j -1]). c.

II.14 { if (x[ j ] > x[ j +1]) { HoánVị(x[ j ]. Tiến hành sắp xếp từng dãy con này theo phương pháp chèn trực tiếp. Nếu các cặp phần tử không đúng thứ tự ở xa nhau hơn được đổi chỗ thì độ phức tạp có thể được cải tiến đáng kể như ta sẽ thấy trong phương pháp QuickSort sẽ được trình bày ở phần sau. Ý tưởng của phương pháp này là phân chia dãy ban đầu thành những dãy con gồm các phần tử ở cách nhau h vị trí. Sau đó giảm khoảng cách h và tiếp tục quá trình trên cho đến khi h = 1. Ta có thể chọn dãy giảm độ dài {hj}1≤ j ≤ k thỏa hk = 1 từ hệ thức đệ qui: hj -1 = 2* hj + 1.Tìm kieám vaø saép xeáp trong II. return .k b. ∀j: 2≤ j ≤ k = ⎣ log2n ⎦ -1. Phương pháp sắp xếp chèn cải tiến (ShellSort) a.5.3. Txấu(n) = O(n2). } d. Độ phức tạp của thuật toán + Trong trường hợp tồi nhất (dãy có thứ tự ngược lại). Ý tưởng phương pháp Một cải tiến của phương pháp chèn trực tiếp là ShellSort. ta tính được: HVxấu = SSxấu = ∑ i =1 n/2 (n-i) = n(3n − 2) 8 + Trong trường hợp tốt nhất (dãy đã được sắp): HVtốt = ∑ i =1 n −1 0 = 0 SStốt = (n -1) Tóm lại.. ∀j: 2≤ j ≤ k = ⎣ log3n ⎦ -1. j=2.k (1) hoặc: (2) hj -1 = 3* hj + 1. // không xét các phần tử đã sắp ở cuối dãy } while (L < R). ChỉSốLưu = j. Phương pháp ShakerSort tuy có tốt hơn Bubble Sort.. } } R = ChỉSốLưu. Lý do là hai phương pháp này chỉ mới đổi chỗ các cặp phần tử liên tiếp không đúng thứ tự. độ phức tạp thuật toán: Ttốt(n) = O(n). x[ j +1]). nhưng độ phức tạp được cải tiến không đáng kể. Nội dung thuật toán . j=2.

ElemenetType tam.len. k. } } . . for (i = len. len.len. i < n. Với h[1] = 3. j = 1. step++) { len = h[step]. } x[ j + len] = tam. n) . . * Ví dụ: Sắp tăng dãy: 6 2 8 5 1 12 4 15 Xét dãy bước: h[1]=3.k. hk = 1. else Dừng. h2. sắp các dãy con có độ dài 1 bằng phương pháp chèn trực tiếp như thông thường. if (j ≤ k) Quay lại bước 2. } return.15 ShellSort(x. j. TaoDayBuocChia(n. h[2]= 1 (k=2). ta được: 4 1 8 5 2 12 6 15 Với h[2] = 1.Tìm kieám vaø saép xeáp trong II. h2.h). int n) { int i.Bước 1: Chọn k và dãy h1. // Xác định k và dãy h1.Bước 3: j = j +1. Sắp mỗi dãy con bằng phương pháp chèn trực tiếp. j = j . ta được: 1 2 4 5 6 8 12 15 c. // x[ j ] là phần tử đứng kề trước x[i] trong cùng dãy con // sắp xếp dãy con chứa trị x[i] = tam bằng phương pháp chèn trực tiếp while (j >= 0 && tam < x[ j ]) { x[ j + len] = x[j].Bước 2: Phân dãy ban đầu thành các dãy con cách nhau hj khoảng cách. …. sắp các dãy con có độ dài 3 bằng phương pháp chèn trực tiếp. …. hk = 1. step < k. j = i . Cài đặt thuật toán void ShellSort(mang x. h[MAX_BUOC_CHIA]. i++) { tam = x[i]. for (int step = 0.

6..R.3.. Độ phức tạp của thuật toán Người ta chứng minh được rằng. ta tách dãy thành 3 phần: xk ≤ g với mọi k = 1. x4=42. do C.. Đổi chỗ xi và xj . j (Dãy con trái hay dãy con thấp).Tìm kieám vaø saép xeáp trong II. xm ≥ g với mọi m = i. nếu i-1 ≥ j+1. n (Dãy con phải hay dãy con cao). Vì thế phương pháp này còn gọi là phương pháp sắp xếp bằng phân hoạch. dựa vào cách hoán vị các cặp phần tử không đúng thứ tự có thể ở những vị trí xa nhau.. nếu chọn dãy bước chia{hj} theo (1) thì thuật toán ShellSort có độ phức tạp cỡ: n1. . Hoare đề nghị... xp = g với mọi p = j+1.16 d. i-1. nếu i-1 ≥ j+1 thì các phần tử xj+1. xi-1 được định vị đúng: xm xk xp=g Với từng dãy con trái và phải (có độ dài lớn hơn 1) ta lại phân hoạch (đệ qui) chúng tương tự như trên. R=8.. Phương pháp sắp xếp phân hoạch (QuickSort) Phương pháp Quick Sort (hay sắp xếp kiểu phân đoạn) là một cải tiến của phương pháp sắp xếp kiểu đổi chỗ.. Ví dụ: Xét dãy 44 55 12 42 94 18 06 67 Sau 2 lần đổi chỗ.2 << n2. . phân hoạch dãy trên thành 06 18 12 42 94 55 44 67 Dãy con thấp Dãy con cao Đúng vị trí Kết quả phân hoạch qua từng bước đệ qui: L=1.. j=3. . Sau đó thực hiện phân hoạch dãy thành 2 dãy con: dãy con trái gồm những phần tử có giá trị không lớn hơn g và dãy con phải gồm những phần tử có giá trị không nhỏ hơn g (bằng cách duyệt dãy từ bên trái cho đến khi có một phần tử xi ≥ g. II. Tiếp tục quá trình duyệt và đổi chỗ cho tới khi hai phía vượt qua nhau: i > j). Ý tưởng phương pháp: Chọn một phần tử bất kỳ (ta thường chọn phần tử giữa) g của dãy làm mốc. .. i=5: 44 55 12 42 94 18 06 67 . Khi đó. a. Sau khi phân hoạch. sau đó duyệt dãy từ bên phải cho đến khi có một phần tử xj ≤ g..A..

i=7: 44 5 94 67 L=7. R=8. xj ≤ g . . i=3: 06 12 1 L=1. xj if (i < R) phân hoạch dãy xi...Bước 2: if (L < j) phân hoạch dãy xL. L.Bước 1: Chọn tùy ý một phần tử g = xk. xR thành các dãy con: .. x6=55. L. i=2: 0 12 L=5..Bước 2: Tìm và hoán vị các cặp phần tử xi và xj đặt sai vị trí: . } . x1 = 6. kết hợp các kết quả đệ qui.. R=8.dãy con thấp: xL.if (i ≤ j) { Hoán vị xi và xj... x2 = 18. R=3.. j=7. . .. . xR ≥ g . i=8: 67 9 Cuối cùng. xR SắpXếpNhanh(x.dãy con thấp: xi. . xL+1.Tìm kieám vaø saép xeáp trong II. j = j -1. R) . = xi-1 = g.. nếu i-1 ≥ j+1 .Trong khi (xj > g) j = j -1.. i = L.. .17 06 18 12 4 94 55 44 67 L=1.. . thường chọn k = (L+R)/2)). x6=94..(L ≤ k ≤ R. j= 0.. . R) . xR thành các dãy con PhânHoạch(x. xL+1. R=2.... .dãy con giữa: xj+1 = . i = i + 1.Trong khi (xi < g) i = i + 1. xR Nội dung thuật toán phân hoạch dãy: xL.Bước 1: Phân hoạch dãy xL.... j = R. ta có dãy được sắp: 06 12 18 42 44 55 67 94 b. . j= 2. Nội dung thuật toán sắp xếp nhanh dãy: xL. j=5...

n-1). if (L < j) PhânHoạch(x. Độ phức tạp trong mỗi lần phân hoạch là O(n).Tìm kieám vaø saép xeáp trong II. ta sẽ cần log2(n) lần phân hoạch thì sắp xếp xong. Độ phức tạp của thuật toán Người ta chứng minh được rằng: + Trong trường hợp xấu nhất (khi phân hoạch mọi dãy thành hai dãy con. return. c. if (i <= j) { HoánVị(x[i]. ElementType giua = x[(L+R)/2]. j = R. luôn có một dãy con có độ dài không.18 . + Trong trường hợp tốt nhất: sau mỗi phân hoạch. ta đều chọn đúng mốc là phần tử median cho dãy con (phần tử có trị nằm giữa dãy). Cài đặt thuật toán void PhânHoạch(mang x. chọn g = xL và dãy ban đầu được sắp theo thứ tự ngược lại): Txấu(n) = O(n2) nghĩa là. i++ . j). return. Vậy: Ttốt (n) = O(nlog2n) + Trong trường hợp trung bình thì : Ttbình(n) = O(nlog2n) . sắp xếp nhanh (QuickSort) không hơn gì các phương pháp sắp xếp trực tiếp đơn giản.x[j]). if (R > i) PhânHoạch(x. R)..Bước 3: if (i ≤ j) Quay lên bước 2. else Dừng. // Chọn phần tử “giữa” làm mốc do { while (giua>x[i]) i = i+1. chẳng hạn. r : lần lượt là chỉ số trái và phải của dãy con của mảng x cần phân hoạch { int i = L. int R) // L. Khi đó. L. j-. } d. i. } void SắpXếpNhanh (mang x. ta thường chọn g= xgiữa. 0. nhưng trường hợp này hiếm khi xảy ra: để tránh tình trạng này. int n) { PhânHoạch(x. int L. while (giua<x[j]) j = j-1. } } while (i <= j).

..... a. Ý tưởng phương pháp: Nếu biểu diễn một Heap x1. ..19 QuickSort là phương pháp sắp xếp trong trên mảng rất hiệu quả được biết cho đến nay. nhưng trong trường hợp xấu nhất nó vẫn là O(n2). ..Nếu dãy x1... . Định nghĩa và tính chất của khối (Heap) Định nghĩa: Dãy xm..3. xn thì dãy x[n/2]+1. . .. .Với dãy bất kỳ x1. .. thời gian thực hiện trung bình khá tốt.Nếu dãy x1.. nghĩa là: nếu dãy x1.Nếu dãy x1. xn lên cây nhị phân có thứ tự. x1 x2 x4 x5 x6 x3 x7 … b.. xn là một Heap thì chưa chắc dãy đã có thứ tự.. xk ≥ x2k+1. ta sẽ thu được dãy có thứ tự bằng cách : .7.Tìm kieám vaø saép xeáp trong II. xn là một Heap thì ta có thể biểu diễn “liên tiếp” những phần tử của dãy này lên một cây nhị phân có tính chất: con trái (nếu có) của xi là x2i ≤ xi và con phải (nếu có) của xi là x2i+1 ≤ xi... Chú ý điều ngược lại chưa chắc đúng. . .. Nhược điểm của phương pháp chọn trực tiếp là ở lần chọn hiện thời không tận dụng được kết quả so sánh và hoán vị của các lần chọn trước đó. xn là một Heap thì x1 là phần tử lớn nhất trong dãy và nếu bỏ đi một số phần tử liên tiếp ở hai đầu của dãy thì nó vẫn là một Heap.. xn (nửa đuôi dãy) là một Heap. . II. Tính chất: .. xn là một Heap nếu : xk ≥ x2k. Phương pháp sắp xếp trên cây có thứ tự (HeapSort) Với phương pháp sắp xếp Quick Sort.. . Phương pháp HeapSort mà ta sẽ xét sau đây có độ phức tạp trong trường hợp xấu nhất là O(nlog2n).. với mọi k mà : m ≤ k < 2k < 2k+1 ≤ n... xn có thứ tự thì nó là một Heap. Phương pháp dựa trên khối HeapSort khắc phục được nhược điểm này bằng cách đưa dãy cần sắp vào cây nhị phân có thứ tự (hay Heap) và chúng được lưu trữ kế tiếp bằng mảng.

. 12. 18. Để ý rằng.20 .Hoán vị nút gốc x1 (lớn nhất) với nút cuối xn . 1 67 2 44 94 67 55 44 42 12 18 06 55 12 06 42 18 .. 18.... xn-1.. . xn-1 để được heap mới dài hơn x1. 55. Ví dụ: Sắp xếp dãy số 44 55 12 42 94 18 06 67 Giả sử tồn tại thủ tục để tạo một Heap đầy đủ ban đầu từ dãy trên : 94 67 18 44 55 12 06 42 Cây nhị phân biểu diễn Heap ban đầu 94 67 44 42 55 12 18 06 Hoán vị nút 94 với nút 42 và bổ sung 42 vào heap cũ: 67... 42. Lặp lại quá trình trên cho đến khi cây chỉ còn một nút.. .Tìm kieám vaø saép xeáp trong II. 06. 44. 12. 44. 55. ta chỉ xáo trộn không quá một nhánh (nhánh trái có gốc là 67) với gốc (42) của cây cũ.Khi đó x2. xn-1 vẫn là một heap.. 06 để được heap mới dài hơn: 67. Bổ sung x1 vào heap cũ x2.

void Shift(mang x.. . . r) . } } . ElementType Cha = x[ChỉSốCha]. xr để tạo Heap mới dài hơn một phần tử xL. xr để được heap mới dài hơn: x1.Tìm kieám vaø saép xeáp trong II. .. // dùng thủ tục Shift(x. xr để được heap mới dài hơn: xL .. xr. c. xr. xr.. ..Bước 2: Đưa phần tử lớn nhất về cuối dãy đang xét: Hoán vị x1 và xr . ChỉSốCon = 2* ChỉSốCha.. ChỉSốCon = 2* ChỉSốCha. int L.. .. Shift (x.21 94 Tiếp tục quá trình trên cho đến khi dãy chỉ còn một phần tử thì ta sẽ được dãy tăng: 06 12 18 42 44 55 67 94 c. else { xChỉSốCha = xChỉSốCon.Bước 4: if (r > 1) Quay lên bước 2 else Dừng //Heap chỉ còn một phần tử * Nội dung thuật toán Shift: Bổ sung xL vào heap cũ: xL+1. LàHeap = False.Bước 1: ChỉSốCha = L. tạo Heap đầy đủ ban đầu • Giai đoạn 2: Sắp xếp dãy dựa trên Heap: ..Bước 2: Trong khi (Chưa LàHeap and ChỉSốCon ≤ R) thực hiện: { if (ChỉSốCon < R) // nếu Cha có con phải. . tìm con lớn nhất if (xChỉSốCon < xChỉSốCon+1) ChỉSốCon = ChỉSốCon +1. L.. if (xChỉSốCon ≤ Cha) LàHeap = True. Cài đặt thuật toán * Thủ tục Shift: // Thêm xL vào Heap xL+1. Bổ sung x1 vào heap cũ: x2..Bước 3: ... xn. LàHeap = 0.. ChỉSốCon = 2* ChỉSốCha.. . 1... int R) { int ChỉSốCha = L. . // đưa nút con lớn hơn lên vị trí nút cha ChỉSốCha = ChỉSốCon.. Loại phần tử lớn nhất ra khỏi Heap: r = r –1.Bước 3: xChỉSốCha = Cha.. . R) .Bước 1: r = n. . Cha = xChỉSốCha. Nội dung thuật toán HeapSort • Giai đoạn 1: Từ Heap ban đầu: x[n/2]+1. ...

n). R).. L--. return . return .. xn void TạoHeapĐầyĐủ(mang x. . .. int n) { int L = n/2. .. if (Cha >= x[ChỉSốCon]) LàHeap = 1. } } x[ChỉSốCha] = Cha. ta được các Heap mới nhiều hơn một phần tử .22 while (!LàHeap && (ChỉSốCon ≤ R)) { if (ChỉSốCon < R) // Chọn nút có khóa lớn nhất trong 2 nút con của nút Cha if (x[ChỉSốCon] < x[ChỉSốCon+1]) ChỉSốCon++. ta đựơc Heap đầy đủ ban đầu: x1.. thì x[n/ 2]+1. while (R > 0) { HoánVị(x[0]. Shift(x. else { x[ChỉSốCha] = x[ChỉSốCon]. Cuối cùng. R = n-1. xn . L. * Tạo Heap đầy đủ ban đầu từ Heap ban đầu của dãy x1. ChỉSốCon = 2* ChỉSốCha. Sau đó áp dụng liên tiếp thuật toán Shift bổ sung phần tử kề bên trái vào các Heap đã có. while (L >= 0) Shift(x.. } Chú ý rằng. với dãy ban đầu bất kỳ x1. // Chuyển nút con lớn hơn lên nút cha ChỉSốCha = ChỉSốCon. } * Ví du: Từ dãy 44 55 12 42 94 18 06 67 L=3 L=2 L=2 L=1 44 44 44 94 55 55 94 67 12 18 18 18 67 67 67 44 94 94 55 55 Heap ban đầu 18 06 42 12 06 42 12 06 42 12 06 42 Heap đầy đủ đã tạo xong * Thủ tục HeapSort void HeapSort(mang x.Tìm kieám vaø saép xeáp trong II... xn .. int L = 0. --R)... R = n -1. .. x[R])... int n) { TạoHeapĐầyĐủ(x. . xn là Heap ban đầu (không đầy đủ).

23 } return . } Ví dụ: Với Heap ban đầu: 94 67 18 44 55 12 06 42 Ta có biểu diễn cây của dãy sau mỗi bước lặp: 1 67 2 44 94 67 55 44 94 42 18 55 12 06 18 42 12 06 55 44 06 94 18 42 12 67 44 42 06 12 55 18 67 .Tìm kieám vaø saép xeáp trong II.

24 94 42 12 06 94 44 55 18 67 18 12 42 94 44 55 06 67 12 06 42 94 44 55 18 67 06 12 42 94 44 55 18 67 .Tìm kieám vaø saép xeáp trong II.

3. ta có kết quả dưới dạng dãy sau mỗi bước lặp: 67 55 44 42 18 12 06 55 44 42 12 12 06 12 18 18 18 18 06 18 18 44 06 06 06 42 42 42 42 42 12 44 44 44 44 12 12 55 55 55 55 55 06 67 67 67 67 67 67 94 94 94 94 94 94 94 d.key > dn+1. Ý tưởng phương pháp: Dựa trên ý tưởng “chia để trị”.với chiều dài không cố định) Một đường chạy (tự nhiên) r (theo trường khóa key) trong dãy x là một dãy con được sắp (tăng) lớn nhất gồm các đối tượng r = {dm. Trong thuật toán đệ quy QuickSort cần không gian nhớ cho ngăn xếp (để lưu thông tin về các phân đoạn sẽ được xử lý tiếp theo và do đó sẽ phụ thuộc vào kích cỡ dữ liệu đầu vào). Khi đó.dn} thỏa các tính chất sau: ∀ i ∈ [m. ta đều có thể tách chúng thành tập các dãy con được sắp. Để khắc phục nhược điểm này.key * Định nghĩa 2: (thao tác trộn) . ta cần đến khái niệm đường chạy tự nhiên. độ phức tạp của thuật toán Heap Sort là: Txấu(n) = O(nlog2n).key . ta sẽ không tận dụng được trật tự tự nhiên của các dãy con ban đầu hay sau mỗi lần trộn. phương pháp sắp xếp trộn được xây dựng dựa vào nhận xét: với mỗi dãy con. Trong phương pháp trộn trực tiếp.n) di. ….Tìm kieám vaø saép xeáp trong II.key > dm.key ≤ di+1. ta xét các dãy con có chiều dài cố định k-1 2 trong lần tách thứ k. Lặp lại quá trình trên cho đến khi tập ban đầu chỉ còn duy nhất một dãy con. nghĩa là các phần tử của chúng được sắp xếp.8. Đối với thuật toán HeapSort (dưới dạng lặp).key dn. Nếu ta trộn các dãy con (được sắp) này thì sẽ được các dãy con (được sắp) dài hơn. Độ phức tạp của thuật toán Người ta chứng minh được rằng trong trường hợp tồi nhất. với số lượng dãy con mới ít hơn khoảng một nửa. dm-1. dm+1. Phương pháp sắp xếp trộn (Merge Sort) a. * Định nghĩa 1: (đường chạy tự nhiên .25 Duyệt các cây theo chiều rộng. ta cần không gian nhớ phụ là hằng (nhỏ) không phụ thuộc vào kích cỡ dữ liệu đầu vào. Thay vì trộn các đường chạy có chiều dài cố định ta sẽ trộn các đường chạy tự nhiên thành các đường chạy dài hơn. II.

ta có thể cải tiến thuật toán trên bằng cách chỉ dùng một dãy phụ y (có cỡ n). BDau2. Nội dung thuật toán TrộnTựNhiên(x. ta đưa chúng vào dãy phụ y từ hai phía.Trộn: x: 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 b. Cài đặt thuật toán Để tiết kiệm bộ nhớ. // kết thúc dãy x . BDau1.26 Trộn 2 đường chạy r1. đưa những đường chạy tự nhiên trong dãy x lần lươt vào các dãy phụ y. Gọi thuật toán “Tách” để chia dãy x thành các dãy con và đưa chúng lần lượt vào dãy y và z . Cuoi1. sau đó trộn ngay chúng trở lại vào x). 2. * Ví dụ Sắp xếp tăng dần bằng phương pháp trộn tự nhiên dãy sau: x: 75 55 15 20 85 30 35 10 60 40 50 25 45 80 70 65 Các bước tách và trộn trong mỗi bước lặp: * Tách (lần 1.Trộn (trộn những đường chạy tự nhiên tương ứng trong các dãy phụ y. int n) { int SoDChay. void TronTuNhien(mang x. n) Lặp lại các bước sau: 1. z): y: 75 15 20 85 10 60 25 45 80 65 z: 55 30 35 40 50 70 . cho đến khi SốĐườngChạy = 1. z thành các đường chạy mới dài hơn vào dãy x ): x : 55 75 15 20 30 35 40 50 70 85 10 60 25 45 80 65 * Tách (lần 2): y: 55 75 10 60 65 z: 15 20 30 35 40 50 70 85 25 45 80 . (Mỗi khi tách được hai dãy con tự nhiên của dãy x. c. HếtDãy. Gọi thuật toán “Trộn” để trộn các dãy con trong dãy y và z vào lại x và đếm SốĐườngChạy mỗi khi trộn một cặp đường chạy.Tìm kieám vaø saép xeáp trong II. r2 có chiều dài lần lượt là d1 và d2 là tạo ra đường chạy mới r (gồm tất cả các đối tượng từ r1 và r2) có chiều dài d1+ d2. Cuoi2.Trộn: x: 15 20 30 35 40 50 55 70 75 85 10 25 45 60 65 80 * Tách (lần 3): y: 15 20 30 35 40 50 55 70 75 85 z: 10 25 45 60 65 80 .

HếtDãy = 1. int BDau1. HếtDãy = 1. Cuoi = Truoc+1. j. Cuoi++. i = BDau1. j++) y[Cuoi2+BDau2-j] = x[ j ]. i--) y[ i ] = x[ i].HếtDãy). HếtDãy = 0.Cuoi2).27 mang y. i++. ngược lại.n -1. for (k = BDau1. } } //BDau1 <= Cuoi1 < BDau2 = Cuoi1+1 <= Cuoi2 void Tron (mang x. trả về chỉ số Cuối đường chạy (tăng): // Neu Cuối < KThuc: HếtDãy = 0. BDau1 = Cuoi2+1. // Tach va tron x thanh cac duong chay tu nhien dai nhat while (!HếtDãy) { Tim1DChay(x. } // Tìm 1 đường chạy trên x. int Cuoi2) { int k. // mảng phụ do { SoDChay = 0. for (j = BDau2. Tim1DChay(x. j--. k++) if (y[ i ] < y[ j ]) {x[k] = y[ i ].Cuoi1.BDau1. } else {x[k] = y[ j ]. bắt đầu từ chỉ số BDau <= KThuc.BDau2. // Trộn 2 dãy con tăng thành dãy con tăng (chỉ dùng một mảng phụ y) Tron(x. } else // x[Truoc] > x[Cuoi] { Cuoi--. if (!HếtDãy) { BDau2=Cuoi1+1. int Tim1DChay(mang x. for (i = Cuoi1.n -1. return 0.y. mang y. return 1. BDau1 = 0. int KThuc. int &Cuoi. } } } while (SoDChay>1).BDau2. j <= Cuoi2. int &HếtDãy) { int Truoc = BDau. int BDau. i. j = Cuoi2. .HếtDãy).Cuoi2. while (Cuoi<=KThuc && x[Truoc] <= x[Cuoi]) { Truoc = Cuoi. int Cuoi1. SoDChay++.BDau1. i >= BDau1. return. HếtDãy = 0. k <= Cuoi2. } if (Cuoi > KThuc) { Cuoi = KThuc. int BDau2.Tìm kieám vaø saép xeáp trong II.Cuoi1.

. .. Dựa trên ý tưởng của phương pháp trộn tự nhiên.9. . là các số nguyên có tối đa m chữ số..Bước 4: k = k +1. 16.Trong trường hợp tồi nhất (khi các mục có thứ tự ngược lại).). Ý tưởng phương pháp Radix Sort là một phương pháp sắp xếp không dựa vào việc so sánh trị của các phần tử như các phương pháp đã trình bày trên đây. else Dừng. .Có thể cải biên phương pháp này để sắp xếp trên bộ nhớ ngoài (xem giáo trình “Cấu trúc dữ liệu và thuật toán 2”). …. Ta phân loại các phần tử lần lượt theo các chữ số hàng đơn vị.28 } return.3.Tìm kieám vaø saép xeáp trong II. if (k < m) Quay lại bước 2. Giả sử các phần tử cần sắp x1. } Đó là cách tiếp cận từ dưới lên (Down-Top) của thuật toán trộn dưới dạng lặp.. d.. B9 ..Phương pháp trộn tự nhiên hiệu qủa về mặt thời gian nhưng tốn bộ nhớ phụ cho các dãy phụ.Bước 1: k = 0. hàng chục. phương pháp này giống như phương pháp “trộn trực tiếp” (ứng với các đường chạy có độ dài: 1. II. n: đặt xi vào lô Bt (t là chữ số thứ k của x i) .. Do đó. // k = 0: hàng đơn vị. độ phức tạp trong trường hợp tồi nhất là: Txấu(n) = O(nlog2n). k = 1: hàng chục. xn. . Phương pháp sắp xếp dựa trên cơ số (Radix Sort) a. Ta cũng có thể tiếp cận thuật toán trộn theo hướng từ trên xuống (Top-Down) dưới dạng đệ qui (cho đơn giản và tự nhiên: bài tập). mà dựa vào việc phân loại và trình tự phân loại sẽ tạo ra thứ tự cho các phần tử.. nếu dãy được cài đặt bằng danh sách liên kết (sẽ trình bày trong chương sau) thì nhược điểm trên sẽ được khắc phục. 4. Nội dung thuật toán RadixSort(x. … b.Bước 2: Khởi tạo 10 lô để chứa các phần tử được phân loại:B0. tương tự như việc phân loại trước khi phát thư của bưu điện (theo cây phân cấp địa phương). 2. cần đòi hỏi log2n thao tác “Tách” và mỗi đối tượng trong n mục phải được xử lý trong mỗi thao tác. n) . 8.. … // k cho biết chữ số thứ k được dùng để phân loại .Bước 3: Với mỗi i=1. Để sắp xếp một dãy gồm n đối tượng. Độ phức tạp của thuật toán ..

. .Tìm kieám vaø saép xeáp trong II. rồi lấy các phần tử từ những lô này theo thứ tự của chỉ số i của Bi từ 0 đến 9 trở lại X.29 * Chú ý: Sau lần phân phối thứ k các phần tử của dãy X vào các lô B0. thì các phần tử của dãy X sẽ tăng. * Ví dụ: Sắp tăng dãy: 0701 1725 0999 9170 3252 4518 7009 1424 0428 1239 8425 Phân loại dãy vào các lô B theo hàng đơn vị: ChỉSố 1 2 3 4 5 6 7 8 9 10 11 Mảng x 0 1 2 3 0701 9170 0701 3252 1725 0999 9170 3252 4518 7009 1424 0428 1239 8425 4 5 6 1424 1725 8425 7 8 9 4518 0999 0428 7009 1239 Phân loại dãy vào các lô B theo hàng chục: ChỉSố 1 2 3 4 5 6 7 8 9 10 11 Mảng x 0 1 9170 0701 4518 0701 7009 3252 1424 1725 8425 4518 0428 0999 7009 1239 2 3 4 1424 1239 1725 8425 0428 5 6 3252 7 8 9170 9 0999 Phân loại dãy vào các lô B theo hàng trăm: ChỉSố 1 2 3 4 5 6 7 8 Mảng x 0 1 2 3 0701 7009 9170 1239 7009 3252 4518 1424 1725 8425 0428 1239 4 5 6 1424 4518 8425 0428 7 8 0701 1725 9 0999 ... nếu chỉ xét k+1 chữ số. B9.

Tìm kieám vaø saép xeáp trong II. ta được dãy tăng: 0428 0701 0999 1239 1424 1725 3252 4518 7009 8425 9170 c. nên cài đặt mỗi lô bằng mảng là không hiệu quả. Việc cài đặt thuật toán sẽ thuận tiện hơn nếu các phần tử có dạng chuỗi (chi phí để trích ra phần tử thứ k ít hơn) . B9 luôn bằng n. Độ phức tạp của thuật toán .. Vậy độ phức tạp của thuật toán (số phép hoán vị. mỗi phần tử được xét (tính địa chỉ một lần và hai phép gán) đúng một lần.. Trong mỗi thao tác này. nếu dùng danh sách liên kết động (xem chương tiếp) được cài đặt bởi con trỏ sẽ hiệu quả hơn. trong cả 3 trường hợp về tình trạng dữ liệu...Trên thực tế.Thuật toán RadixSort thực hiện m lần các thao tác phân bố dãy X vào các lô và ghép các lô trở lại dãy X. Cài đặt thuật toán (bài tập) Chú ý: Do tổng các mục dữ liệu trải trên các lô B0.10. d. nói chung.3. Khi đó. So sánh các phương pháp sắp xếp trong Các phương pháp sắp xếp trực tiếp (chọn trực tiếp. nếu khóa không quá dài II..30 9 10 11 3252 9170 0999 Phân loại dãy vào các lô theo hàng ngàn: ChỉSố 1 2 3 4 5 6 7 8 9 10 11 Mảng x 7009 9170 1239 3252 1424 8425 0428 4518 0701 1725 0999 0 0428 0701 0999 1 2 1239 1424 1725 3 4 5 3252 4518 6 7 8 9 7009 8425 9170 Đưa lần lượt các phần tử của các lô B0. . sắp xếp ShakerSort. chèn trực tiếp). thuật toán cần thêm thời gian để tính toán địa chỉ (trích chữ số thứ k của phần tử nguyên) khi phân lô.. chúng đều có độ phức tạp cỡ đa thức cấp 2: T(n) = O(n2). . đều như nhau) là cỡ tuyến tính: T(n) = 2 mn = O(n) 3 .Thuật toán này sẽ hiệu quả. B9 vào lại dãy X. nổi bọt. .

HeapSort và trộn (tự nhiên) trong hầu hết trường hợp có độ phức tạp tốt hơn nhiều: T(n) = O(nlog2n) Khác với cách tiếp cận của các phương pháp sắp xếp trên là dựa vào phép so sánh khoá. Khi đó. ta có kết qủa thực nghiệm như sau: Ngẫu 5*106 1862 1571 489 NaturalMergeSort 1851 P.000 số nguyên. RadixSort có độ phức tạp là: T(n) = O(nm) = O(n) * Các số liệu thực nghiệm về thời gian (đơn vị là sao) chạy các thuật toán đã trình bày trên máy PC. 64 MB-RAM.Pháp n Chọn trực tiếp Chèn trực tiếp Nổi bọt Shaker Sort Shell Sort Heap Sort Quick Sort Trộn tự nhiên Radix Sort Ngẫu 130000 23 909 11 326 65 144 39 689 33 16 11 27 286 nhiên Chậm x x X X Thứ tự thuận Thứ tự ngược Nhanh 130000 Chậm Nhanh 130000 Chậm 23 794 X 30 029 x 6 X 32 384 x 0 X 92 741 X 0 X 59 215 X X 11 X 11 X 11 X 11 X 5 X 5 X 5 X 22 x 264 x 253 Nhanh X X X X x . HeapSort và ShellSort tỏ ra xứng đáng là “đại diện” tốt cho 3 nhóm phương pháp sắp xếp chính đã nêu ở trên (nó nhanh hơn hẳn so với các phương pháp khác trong cùng nhóm). phương pháp sắp xếp theo cơ số RadixSort không dựa trên phép so sánh khóa mà dựa vào việc phân loại các chữ số trong mỗi số của dãy số có tối đa m chữ số. Để ý rằng. ba phương pháp QuickSort. dãy đã được sắp theo thứ tự thuận và ngược. 600MHz. các phép toán cơ bản là lấy ra chữ số thứ k (1≤ k ≤ m) của mỗi số và phép gán các phần tử số. P. cả 3 phương pháp đại diện này đều dựa trên ý tưởng “chia đôi” (“chia để trị”).000 và xét tình trạng dữ liệu trong 3 trường hợp: dãy ngẫu nhiên có phân bố đều. Các phương pháp QuickSort.Tìm kieám vaø saép xeáp trong II.2).000.Pentium III.Pháp n Shell Sort Heap Sort Quick Sort nhiên Chậm Nhanh X X X X Thứ tự 5*106 643 516 291 22 thuận Chậm Nhanh X X x X Thứ tự 5*106 698 561 297 1049 ngược Chậm X Nhanh X X X . theo các bộ số liệu (dãy các số nguyên dương) cỡ: n = 130.Với bộ dữ liệu khá lớn gồm n = 5. Với 3 phương pháp đại diện này.31 Phương pháp sắp xếp ShellSort có độ phức tạp tốt hơn: T(n) = O(n1.

với nhiều cơ sở dữ liệu lớn. Đặc biệt.Tìm kieám vaø saép xeáp trong II.32 Trên thực tế. Khi đó. QuickSort và sắp trộn tự nhiên là hai phương pháp đáng lưu ý. thuật toán sắp trộn tự nhiên còn được sử dụng hiệu quả trên bộ nhớ ngoài. số lần phải sắp xếp những bộ dữ liệu ngẫu nhiên thường ít. Ta thường gặp tình huống phải sắp xếp lại các bộ dữ liệu “gần được sắp” sau một số lần cập nhật trên bộ dữ liệu đã được sắp trước đó. .

2. Chúng thường được khai báo tường minh được truy xuất trực tiếp bằng một định danh rõ ràng (tương ứng với địa chỉ vùng nhớ lưu trữ biến này). nhiều thao tác cơ bản trên chúng sẽ phức tạp.1. Kiểu dữ liệu con trỏ a. làm chương trình trở nên khó đọc.biểu diễn hiệu quả (do sử dụng bộ nhớ kém hiệu quả) các đối tượng dữ liệu lớn chỉ tồn tại nhất thời hay không thường xuyên trong quá trình hoạt động của chương trình. được khai báo trong vùng Data segment (vùng dữ liệu) hoặc trong vùng Stack segment (biến cục bộ) và có kích thước không đổi trong suốt phạm vi sống. Trong chương này.cài đặt một cách hiệu quả và tự nhiên (mặc dù nó có thể đơn giản) các đối tượng dữ liệu có số lượng các phần tử khó dự đoán trước và biến động nhiều trong quá trình sống (có thể do các thao tác thêm vào và loại ra xảy ra thường xuyên). ta sẽ xét một kiểu dữ liệu động đơn giản nhất là danh sách liên kết.1. … trong nhiều trường hợp nếu dùng kiểu dữ liệu động để biểu diễn sẽ đúng bản chất và tự nhiên hơn cũng như thuận lợi hơn trong các thao tác tương ứng trên chúng. So sánh kiểu dữ liệu tĩnh và kiểu dữ liệu động Do đặc điểm và hạn chế của các kiểu dữ liệu cơ sở và kiểu có cấu trúc đơn giản đã xét (gọi là kiểu dữ liệu tĩnh) là tính cố định và cứng nhắc do không thay đổi được kích thước và cấu trúc trong chu trình sống. ta có thể sử dụng biến không động (biến tĩnh hay nửa tĩnh). Đối với các kiểu dữ liệu có đặc tính: số lượng biến động. cài đặt và xác định kích thước của các kiểu dữ liệu đệ qui. O>. Op> . Khi xây dựng chương trình. Kiểu con trỏ PT tương ứng với kiểu T là kiểu: PT = <Vp. khó bảo trì cũng như việc sử dụng bộ nhớ kém hiệu quả (do thiếu hay lãng phí bộ nhớ quá nhiều). kích thước thay đổi hay chỉ tồn tại nhất thời trong chu trình sống. kém tự nhiên. . vì vậy. Giới thiệu kiểu dữ liệu con trỏ III. Kiểu dữ liệu tĩnh (và do đó cả các thao tác cơ bản tương ứng) sẽ khó: .Chương III CẤU TRÚC DANH SÁCH LIÊN KẾT III. nó khó mô tả một cách thật tự nhiên và đúng bản chất của thực tế vốn sinh động và phong phú. III. (mặc dù các thao tác trên chúng có thể nhanh và thuận tiện trong một số tình huống). Định nghĩa Cho trước một kiểu T = <V.1. nếu cần biểu diễn các đối tượng có số lượng ổn định và có thể dự đoán trước kích thước của chúng. .biểu diễn. tồn tại trong phạm vi khai báo và chỉ mất khi ra khỏi phạm vi này.1. Khi đó.

Khai báo (trong C hay C++) Kiểu và biến con trỏ được khai báo theo cú pháp sau: typedef KiểuCơSởT *KiểuConTrỏ. x. khi đó x=*p1=3) y = 34.2 trong đó: . ta còn có các cấu trúc tự trỏ. Chẳng hạn. địa chỉ của một đối tượng dữ liệu có kiểu T) Thông tin về kiểu dữ liệu T nhằm giúp xác định dung lượng vùng nhớ cần thiết để lưu trị của một biến có kiểu T. Khi nói đến đối tượng con trỏ kiểu T. p2. Ngoài ra. kiểu dữ liệu có cấu trúc đơn giản. . BiếnCơSởT. ( & trong &biến_x là toán tử lấy địa chỉ bắt đầu của một biến_x) *p1 = 3. kiểu file hoặc thậm chí là kiểu con trỏ khác. biến con trỏ có kích thước 2 hoặc 4 bytes cho môi trường 16 bits và có kích thước 4 hoặc 8 bytes cho môi trường 32 bits tùy vào con trỏ near (chỉ lưu địa chỉ offset) hay far (lưu cả địa chỉ offset và segment)). *p1. b. Nói một cách khác. kieu_con_tro_nguyen bien_con_tro_nguyen_2. Đối tượng dữ liệu thuộc kiểu con trỏ tương ứng với kiểu T (hay gọi tắt là đối tượng con trỏ kiểu T) là đối tượng dữ liệu mà giá trị của nó là địa chỉ vùng nhớ của một đối tượng dữ liệu có kiểu T hoặc là trị đặc biệt NULL. với ngôn ngữ lập trình C.Vp chứa các địa chỉ lưu trữ các đối tượng kiểu T hoặc là NULL (NULL là một địa chỉ đặc biệt tượng trưng cho một giá trị không quan tâm. ta để ý đến hai thuộc tính sau: (kiểu dữ liệu T.Caáu truùc danh saùch lieân keát III. . // cách 2: trực tiếp p1 = &x. int *bien_con_tro_nguyen_1. (* trong *p1 là toán tử lấy nội dung trị của biến do p1 trỏ đến. Đối tượng dữ liệu con trỏ nhận trị nguyên không âm có kích thước qui định sẵn tùy thuộc vào môi trường hệ điều hành làm việc và ngôn ngữ lập trình đang sử dụng (chẳng hạn. thao tác tạo một con trỏ chứa địa chỉ một vùng nhớ để lưu trữ một đối tượng có kiểu T. // cách 1 * Ví dụ: typedef int *kieu_con_tro_nguyen. hoặc khai báo trực tiếp biến con trỏ thông qua kiểu cơ sở T: KiểuCơSởT *BiếnConTrỏ. Có thể dùng con trỏ để truyền tham đối cho hàm. y.Op chứa các thao tác liên quan đến việc định địa chỉ của một đối tượng có kiểu T thông qua con trỏ tương ứng chứa địa chỉ của đối tượng đó. KiểuConTrỏ BiếnConTrỏ. thường được dùng để chỉ địa chỉ “kết thúc”). kiểu con trỏ tương ứng với kiểu T là một kiểu dữ liệu của các đối tượng dùng để chứa địa chỉ vùng nhớ cho các đối tượng có kiểu T. con trỏ hàm. KiểuCơSởT có thể là kiểu cơ sở.

BiếnConTrỏ = &BiếnCơSởT. nếu BiếnConTrỏ = &BiếnCơSởT thì *BiếnConTrỏ ≡ BiếnCơSởT.Toán tử gán địa chỉ cho biến con trỏ: BiếnConTrỏ = địa_chỉ. Có thể gán hằng NULL cho bất kỳ biến con trỏ nào. trong đó: & là toán tử lấy địa chỉ của biến BiếnCơSởT có kiểu KiểuCơSởT. *q. f. p1. p1 a p2 b a x≡*p1= 3 b y ≡*p2 =34 Khi đó. // giả sử p. b: char e. e = ‘c’. f có địa chỉ bắt đầu lần lượt là a.Toán tử truy xuất nội dung của đối tượng do biến con trỏ BiếnConTrỏ trỏ đến: *BiếnConTrỏ Khi đó. địa chỉ này có thể là NULL.Caáu truùc danh saùch lieân keát III. q trỏ đến hai biến kiểu ký tự e. *p1. Biến e. y mà p1 và p2 trỏ tới. BiếnConTrỏ_1 = BiếnConTrỏ_2. . khi đó ta nói: BiếnConTrỏ trỏ đến (hay chỉ đến) BiếnCơSởT. f = ‘d’. *p. q có nội dung lần lượt là a và b Ta có sơ đồ (1) sau đây: e p a a *p ≡ ‘c’ f q b b *q ≡ ‘d’ (A) * Sau lệnh gán hai con trỏ cùng kiểu q = p của sơ đồ (A) ta có sơ đồ (A’) thay đổi như sau: .3 p2 = &y. // khi đó *p2 = y = 34 Giả sử a. p2 là hai biến con trỏ kiểu nguyên trỏ đến hai biến kiểu nguyên x và y. Các thao tác trên kiểu dữ liệu con trỏ Giả sử ta có khai báo: KiểuCơSởT *BiếnConTrỏ_1. . * Ví dụ: Giả sử cho hai biến con trỏ p. Đặc biệt. p = &e. BiếnConTrỏ = địa_chỉ + trị_nguyên. *p2 là nội dung của hai biến nguyên x. b lần lượt là địa chỉ bắt đầu của vùng nhớ lưu trị của các biến nguyên x và y tương ứng. c. . q = &f. ta nói : . BiếnCơSởT. *BiếnConTrỏ_2. f .

nếu ta không thể dự đoán hay xác định trước kích thước của chúng (do sự tồn tại. phát sinh và mất đi của chúng tùy thuộc vào ngữ cảnh của chương trình hoặc vào người sử dụng chương trình) thì ta có thể sử dụng biến động để biểu diễn chúng.không được khai báo tường minh (không có tên). chúng không tuân theo qui tắc phạm vi như biến tĩnh.4 e p a f q a a *q≡*p≡‘c’ b ‘d’ (A’) * Sau lệnh gán hai biến do hai con trỏ cùng kiểu chỉ đến *q = *p của sơ đồ (A) ta lại có sơ đồ (A’’) thay đổi như sau: e f p a q b a *p ≡ ‘c’ b *q ≡ ‘c’ (A’’) Hãy kiểm tra lại kết quả của các dãy lệnh trên một chương trình trong C++ (bài tập).Số lượng các biến động có thể thay đổi trong quá trình sống (khi chương trình đang thi hành).Caáu truùc danh saùch lieân keát III.3. Truy xuất biến động Khi biến động được tạo ra (cấp phát vùng nhớ để lưu trữ chúng). . a.1. ta có thể truy xuất đến biến động thông qua biến con trỏ đó: *BiếnConTrỏ Nếu dùng biến con trỏ p chỉ đến một biến động có kiểu cấu trúc với các thành phần {Fieldi}1≤ i ≤ m thì ta có thể truy cập đến thành phần thứ i: Fieldi của biến động đó thông qua con trỏ p như sau: p->Fieldi . III. . b. Sau đó. Vì vậy. dựa trên các đặc điểm của chúng. Biến động Khi xây dựng các kiểu dữ liệu để biểu diễn các đối tượng trong một bài toán cụ thể. ta phải dùng một biến con trỏ (biến không động và có định danh rõ ràng) BiếnConTrỏ có kiểu tương ứng để lưu giữ địa chỉ bắt đầu của vùng nhớ này.được cấp phát bộ nhớ (trong vùng Heap segment) hoặc giải tỏa vùng nhớ đã chiếm dụng (để về sau có thể sử dụng lại vùng nhớ này cho các mục đích khác) theo yêu cầu của người sử dụng khi chương trình đang thi hành (chứ không phải ở thời điểm biên dịch chương trình). Đặc trưng của biến động (hay biến được cấp phát động): .

Fieldi c. Hai thao tác cơ bản trên biến động: tạo và hủy một biến động do biến con trỏ trỏ đến. BiếnĐộng BiếnConTrỏ x x Khi đó. . hoặc: delete [ ]BiếnConTro. ta có thể truy xuất đến (nội dung) biến động (không có định danh riêng) thông qua biến con trỏ như sau: *BiếnConTrỏ. * Tạo một biến động do biến con trỏ trỏ đến: bằng cách cấp phát vùng nhớ (địa chỉ bắt đầu và kích thước vùng nhớ tương ứng với kiểu) cho biến động để lưu trữ đối tượng và ta dùng một biến con trỏ để lưu giữ địa chỉ vùng nhớ đó. Khi đó: địa chỉ bắt đầu vùng nhớ của đối tượng được cấp phát động thứ i (0 ≤ i ≤ SốLượng -1) được truy xuất bởi: BiếnConTrỏ + i và nội dung của đối tượng được cấp phát động thứ i (0 ≤ i ≤ SốLượng -1) được truy xuất bởi: *(BiếnConTrỏ + i) hoặc BiếnConTrỏ[ i ] Cú pháp truy xuất trên cũng đúng với “mảng động” đã biết: ptử *BiếnMảngĐộng.Caáu truùc danh saùch lieân keát III. int tuoi. ta dùng hàm new để cấp phát vùng nhớ cho một biến động có kiểu cơ sở T theo cú pháp sau: // (1) BiếnConTrỏ = new KiểuCơSởT. tương ứng với toán tử cấp phát vùng nhớ new ở dạng (1) hoặc (2) ở trên. Trong C++.5 hoặc: (*p). } hs. Hàm new còn có một cách sử dụng khác là: BiếnConTrỏ = new KiểuCơSởT [ SốLượng] . * Ví dụ: typedef struct { int diem. BiếnMảngĐộng = new ptử [MAX]. // (2) để cấp phát vùng nhớ cho SốLượng đối tượng có cùng kiểu KiểuCơSởT mà địa chỉ bắt đầu của vùng nhớ này được lưu giữ trong biến con trỏ BiếnConTrỏ. ta dùng toán tử delete trong C++ như sau: delete BiếnConTrỏ. * Hủy một biến động đã được cấp phát bởi toán tử new do biến con trỏ trỏ đến: Để giải tỏa vùng nhớ của biến động đã được cấp phát trước đó bằng toán tử new do biến con trỏ BiếnConTrỏ trỏ đến.

diem = 9.Caáu truùc danh saùch lieân keát III. con_tro = new hs. … 6 8 … … delete p. // giải toả vùng nhớ do con_tro chiếm giữ q = new int. Khi đó q có thể trỏ đến vùng nhớ do biến con_tro trước đây trỏ đến. // hoặc: con_tro->tuoi = 18.6 hs *con_tro. con_tro->diem = 9. p = new int. int *p. cấu trúc cây. … … 8 … … *q *p *q Dựa trên kiểu dữ liệu động cơ sở là con trỏ. *q = 8. *p = 6. ta có thể xây dựng các kiểu dữ liệu động phong phú khác có nhiều ứng dụng trên thực tế như: danh sách liên kết động. Minh họa một phần bộ nhớ Heap segment: … 6 9 18 … *p *con_tro Sau đó thi hành các lệnh: delete con_tro. đồ thị. *q. … . (*con_tro).

chèn một đối tượng vào danh sách. Khi đó. Ngoài ra.2. OL > với: .Caáu truùc danh saùch lieân keát III. Bù lại. Tuy nhiên. Danh sách liên kết (DSLK) III.1. Phần tử xi+1 được xem là phần tử kề sau của xi. các thao tác thêm và hủy sẽ bất tiện và chiếm nhiều thời gian để dời chỗ các dãy con của danh sách. duyệt danh sách.7 III. sắp xếp danh sách theo một quan hệ thứ tự nào đó. nên một số thuật toán trên danh sách được cài đặt theo kiểu liên kết sẽ bị chậm hơn. tìm một đối tượng (thỏa một tính chất nào đó) trên danh sách. các phần tử của danh sách sẽ được lưu trữ liên tiếp trong một vùng nhớ liên tục. hủy một đối tượng khỏi danh sách.2. các phần tử của danh sách không nhất thiết phải được lưu trữ kế tiếp trong một vùng nhớ liên tục. … III. Các cách tổ chức danh sách Có hai cách chính để tổ chức danh sách tùy thuộc vào cách tổ chức trình tự tuyến tính các phần tử của danh sách theo kiểu ngầm hay tường minh. do việc truy xuất đến các phần tử của danh sách là tuần tự. Để khắc phục các hạn chế trên.VL là tập các phần tử có kiểu T được móc nối theo kiểu thứ tự tuyến tính. . ta có thể tổ chức danh sách tuyến tính theo kiểu móc nối (hay liên kết và gọi là danh sách liên kết) ở dạng tường minh: mỗi phần tử ngoài thành phần thông tin về dữ liệu còn chứa thêm liên kết (địa chỉ) đến phần tử kế tiếp trong danh sách. . việc truy xuất trực tiếp các phần tử của mảng trên vùng nhớ liên tục sẽ nhanh.2.OL gồm các toán tử: tạo danh sách. Ta có thể tổ chức trình tự tuyến tính theo kiểu ngầm thông qua chỉ số (như mảng hay file). Kiểu dữ liệu danh sách TL gồm các phần tử thuộc kiểu T được định nghĩa là: TL = <VL. mảng có hạn chế là số phần tử tối đa của mảng bị giới hạn cố định (vùng nhớ được cấp phát liên tục cho mảng được thực hiện khi biên dịch đoạn chương trình chứa khai báo biến mảng đó). Định nghĩa danh sách Cho kiểu dữ liệu T. Với cách này.2. Việc truy nhập các phần tử được thực hiện thông qua công thức dịch địa chỉ để xác định địa chỉ bắt đầu của phần tử thứ i (nếu phần tử đầu tiên được đánh số là 0): Địa chỉ bắt đầu danh sách + i*(kích thước của T) Áp dụng cách tổ chức này. do đó việc sử dụng bộ nhớ sẽ ít linh động và kém hiệu quả.

* Biểu diễn danh sách liên kết (bằng con trỏ) . ta thường sử dụng thêm lính canh ở cuối danh sách. DSLK vòng.8 Sau đây. để truy nhập đến nút cuối của danh sách. Tổ chức DSLK đơn. typedef NodeType *NodePointer. Khi biết nút đầu Head... . Mặt khác.. DSLK đa liên kết và một số ứng dụng của chúng. DSLK đối xứng. khi cần thiết. các thao tác cơ bản..3.. DSLK đơn III. . Vì vậy. ta chỉ cần biết địa chỉ Head của nút dữ liệu đầu tiên. khi quản lý DSLK.. Data • Con trỏ rỗng NULL Tail Phần tử cuối Để truy cập đến các phần tử của DSLK. để chi phí việc truy nhập đến nút cuối là hằng O(1). để thao tác tìm kiếm tuần tự (rất thường gặp khi khai thác thông tin) được hiệu quả. Tổ chức DSLK đơn (không có nút câm) Mỗi phần tử (còn được gọi là nút) của danh sách chứa hai thành phần : .Thành phần dữ liệu Data: chứa thông tin dữ liệu của bản thân phần tử. ta còn lưu thêm (địa chỉ) nút cuối Tail.3.Thành phần liên kết Next: chứa địa chỉ của nút kế tiếp trong danh sách hoặc trị NULL đối với nút cuối danh sách.. ElementType. tìm kiếm và sắp xếp trên DSLK đơn a. typedef struct { NodePointer Head. } LL. theo trường Next ta có thể biết được địa chỉ (và do đó.Trong C hay C++.Caáu truùc danh saùch lieân keát III.1. Sau đó. ngoài việc lưu trữ (địa chỉ) nút đầu Head. ta cần chi phí O(n) để duyệt qua lần lượt tất cả n nút của nó. mỗi nút của DSLK được cài đặt bởi cấu trúc sau: typedef . // Kiểu dữ liệu cơ sở của mỗi phần tử typedef struct node {ElementType Data. ta sẽ chủ yếu tập trung khảo sát các kiểu danh sách liên kết động được cài đặt bởi con trỏ: DSLK đơn (có hoặc không có nút câm). nội dung dữ liệu) của nút kế tiếp. struct node *Next. } NodeType. Phần tử đầu Head Data Next Con trỏ chỉ đến phần tử đầu danh sách Data Next . Tail. III..

. b.. Những thao tác cơ bản trên DS với kiểu cài đặt này là đơn giản (xem như bài tập). end.. var List : LL. ta sẽ sử dụng thủ tục cấp phát động bộ nhớ cho một nút của DSLK sau đây: Cấp phát vùng nhớ chứa dữ liệu x cho một nút của DSLK Head x • . Các thao tác cơ bản trên kiểu DSLK đơn Để tiện theo dõi và thống nhất trong trình bày.9 LL List. ta qui ước các khai báo sau: ElementType x. Cách cài đặt này gặp hạn chế do kích thước của mảng cố định. ElementType. // Kích thước tối đa của mảng typedef . // Miền chỉ số của nút typedef struct { ElementType Data. // Kiểu dữ liệu của nút typedef unsigned int IndexType.. // x là dữ liệu chứa trong một nút NodePointer new_ele. mỗi nút của DSLK được cài đặt bởi cấu trúc sau: Type ElementType = . IndexType StartIndex.Caáu truùc danh saùch lieân keát III. // new_ele là biến con trỏ chỉ đến nút mới được cấp phát Để việc trình bày phần cài đặt các thao tác cơ bản được gọn hơn.Trong PASCAL.. typedef struct { Table DS. IndexType Next. NodeType = record Data: ElementType.. typedef NodeType Table [MAXSIZE].. Ngoài việc dùng kiểu dữ liệu con trỏ. // Kiểu dữ liệu cơ sở của mỗi phần tử NodePointer = ^NodeType... end.. } Table_List. } NodeType. Tail: NodePointer. LL = record Head: NodePointer. . Next: NodePointer. ta còn có thể biểu diễn một DSLK bằng mảng như sau: #define MAXSIZE .

Head == NULL) // hay chặt chẽ hơn (List. else Trả về trị False. . // List rỗng. .Head = List. } • Khởi tạo một DSLK rỗng.10 Tail Thuật toán NodePointer CreateNodeLL (x) . else { Gán(new_ele ->Data. new_ele ->Next = NULL. // List khác rỗng. return List. . Cấp phát vùng nhớ cho một nút new_ele.Tail = NULL.Thuật toán Boolean EmptyLL(LL List) if (List.Cài đặt LL CreateEmptyLL () { LL List. } return new_ele.Thuật toán LL CreateEmptyLL () List. . . } • Kiểm tra một DSLK có rỗng hay không .Head = List. if ((new_ele = new NodeType) ==NULL) cout << “\nLỗi cấp phát vùng nhớ cho một nút mới !”.Caáu truùc danh saùch lieân keát III.Cài đặt int EmptyLL(LL List) .Tail == NULL) Trả về trị True. new_ele ->Next = NULL.Head == NULL) && (List.Cài đặt NodePointer CreateNodeLL (ElementType x) { NodePointer new_ele.Tail = NULL. . new_ele ->Data = x. x). List.

Head … CurrPtr .Cài đặt int TraverseLL(LL List) { NodePointer CurrPtr = List. CurrPtr = List.Tail == NULL)). List. . từ đầu đến cuối) và mỗi phần tử được xử lý đúng một lần. } return 1.Caáu truùc danh saùch lieân keát III. Xử lý liên quan đến thông tin của nhiều nút của DSLK return .Head == NULL) && (List.Tail • void XửLý(NodePointer CurrPtr) { // Xử lý nút CurrPtr tùy theo từng yêu cầu cụ thể. Trong khi chưa hết DSLK thực hiện: { XửLý nút được trỏ bởi CurrPtr.Thuật toán TraverseLL(List) . else { while (CurrPtr != NULL) // hoặc while (CurrPtr) { XửLý (CurrPtr). Xử lý chỉ liên quan đến thông tin một nút // 2. CurrPtr = CurrPtr->Next. Có hai loại xử lý: // 1. } • Thêm một phần tử mới vào DS . // chuyển đến nút kế tiếp } . } } List. if (EmptyLL(List)) return 0. • Duyệt qua một DSLK: Duyệt là đi qua mọi phần tử của DSLK theo một quy luật nào đó (chẳng hạn.11 { } return(List. CurrPtr = CurrPtr->Next.Head.Head. // hay chặt chẽ hơn return ((List.Head == NULL).

Head. nếu đủ vùng nhớ cấp phát cho nó. ElementType x. return . .Cài đặt void InsertNodeAfterLL(LL &List.Head = new_ele. // chèn new_ele vào đầu List List. . PredPtr) . NodePointer InsertElementAfterLL (&List. Hàm này trả về địa chỉ nút mới thêm vào. NodePointer PredPtr) .Caáu truùc danh saùch lieân keát III. ngược lại.Cài đặt NodePointer InsertElementAfterLL (LL &List.Tail = new_ele. NodePointer PredPtr) { if (PredPtr) { new_ele->Next = PredPtr->next.Head. Thêm nút new_ele vào sau nút được trỏ bởi PredPtr. PredPtr->Next = new_ele. PredPtr->Next = new_ele. để cho gọn trong việc trình bày các phần sau.Tail • PredPtr x new_ele Áp dụng thao tác cơ bản trên. } - Thuật toán: chèn thêm phần tử x vào sau một nút được trỏ bởi PredPtr. } else { new_ele->Next = List.Thuật toán: Thêm một nút new_ele vào sau một nút được trỏ bởi PredPtr InsertNodeAfterLL(&List. NodePointer new_ele. if ((new_ele = CreateNode (x)) == NULL) return NULL.Tail) List. } else { new_ele->Next = List.Tail = new_ele. Trả về new_ele. PredPtr) . . new_ele. List. nó sẽ trả trị NULL.Tail) List. } if (PredPtr == List. if (PredPtr == List.Head = new_ele. ta xây dựng thêm các thao tác sau: . if (PredPtr) { new_ele->Next = PredPtr->next.12 * Thêm một phần tử vào sau một nút được trỏ bởi con trỏ PredPtr (qui ước: nếu PredPtr == NULL thì chèn x vào đầu DSLK) List. } // Nếu chèn new_ele vào cuối DS thì cần cập nhật lại đuôi của List .Head … 2 1 List. x.

Tail)).Tail.Thuật toán: Thêm một nút new_ele vào đầu DSLK List InsertNodeHeadLL(&List. if (! (new_ele = CreateNode (x)) return NULL. ElementType x) { return (InsertElementAfterLL (List. NodePointer new_ele) { InsertNodeAfterLL (List. return .Tail). } * Thêm một phần tử vào đầu một DSLK . } * Thêm một phần tử vào cuối một DSLK . x) .Tail.13 { NodePointer new_ele. return (new_ele). new_ele. .Cài đặt void InsertNodeHeadLL(LL &List. new_ele. new_ele) . .Cài đặt void InsertNodeTailLL(LL &List. } Thuật toán: Thêm phần tử x vào cuối List NodePointer InsertElementTailLL (&List.Thuật toán: Thêm một nút new_ele vào cuối DSLK List InsertNodeTailLL(&List. . Thêm phần tử x vào đầu List (hay sau nút được trỏ bởi NULL).Caáu truùc danh saùch lieân keát III. new_ele) . ElementType x) { return (InsertElementAfterLL (List. NULL). . NodePointer new_ele) { InsertNodeAfterLL (List. return .Cài đặt NodePointer InsertElementHeadLL (LL &List. x) . x. new_ele. x. Thêm nút new_ele vào sau nút được trỏ bởi List. InsertNodeAfterLL (List. Thêm nút new_ele vào đầu List (hay sau nút được trỏ bởi NULL). PredPtr). List.Cài đặt NodePointer InsertElementTailLL (LL &List. . } Thuật toán: Thêm phần tử x vào đầu List NodePointer InsertElementHeadLL (&List. Thêm phần tử x vào sau nút được trỏ bởi List. List. NULL)).

ta đặt thêm lính canh ở cuối List. CurrPtr = CurrPtr->Next. x) != 0) { PredPtr = CurrPtr . . // thấy giả hay không thấy ! RemoveAfterLL(List. return Thấy. thông qua đối cuối của hàm. Trong khi (CurrPtr->Data ≠ x) thực hiện { PredPtr = CurrPtr. x). CurrPtr = List. . OldTail= List. // Thông báo thấy x. while (SoSánh(CurrPtr->Data. Nếu nút tìm thấy là nút đầu của List thì trả về con trỏ NULL. x. Chèn nút mới new_ele chứa x vào cuối List (đóng vai trò lính canh) . // xóa new_ele. // PredPtr đứng kề trước CurrPtr . .Tail • … new_ele (lính canh) x • Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy chưa được sắp: Boolean SearchLinearLL(List.Caáu truùc danh saùch lieân keát III. } if (CurrPtr != new_ele) Thấy = 1. // Thông báo không thấy x.Head.14 } • Tìm kiếm một phần tử trên DSLK Tìm một phần tử x trong DSLK List. new_ele = InsertElementTailLL(List. Nếu tìm thấy thì. OldTail. . NodePointer &PredPtr) { NodePointer CurrPtr = List.Head. PredPtr = NULL.Cài đặt int SearchLinearLL(LL List. // thấy thật sự else Thấy = 0.Tail. } . List. PredPtr = NULL. &PredPtr) .Tail. CurrPtr = CurrPtr->Next. Để tăng tốc độ tìm kiếm (bằng cách giảm số lần so sánh trong biểu thức điều kiện của vòng lặp). x). if (CurrPtr ≠ new_ele) Thấy = True. Xóa nút (new_ele) đứng sau nút được trỏ bởi List. else Thấy = False. trả về địa chỉ PredPtr của nút đứng trước nút tìm thấy đầu tiên. ElementType x. Trả về trị Thấy.Head CurrPtr • PredPtr List. int Thấy.

} Có một cách cài đặt khác cho DSLK đơn là: thay vì nhận biết hết DSLK bằng con trỏ NULL.Head. để nhận biết nút CurrPtr (không xử lý dữ liệu của nút này) có phải là nút kết thức hay không. } . ElementType x. else Thấy = False. // xóa new_ele.Head.Tail. } if ((CurrPtr != new_ele) && SoSánh(CurrPtr->Data. // không thấy x. Hãy viết lại các thuật toán cơ bản trên DSLK đơn được cài đặt theo cách này (bài tập). NodePointer &PredPtr) { NodePointer CurrPtr = List. while (SoSánh(CurrPtr->Data. . x. . if ((CurrPtr ≠ new_ele) and (CurrPtr->Data ≡ x)) Thấy = True. x). // thấy x. return Thấy. Trong nhiều trường hợp. PredPtr = NULL.Tail KẾT_THÚC ? Khi đó. RemoveAfterLL(List. ta có thể tạo mới ngay từ đầu một nút gọi là nút KẾT_THÚC có liên kết vòng đến chính nó như sau: List. &PredPtr) .Tail. OldTail = List. Chèn nút mới new_ele chứa x vào cuối List (đóng vai trò lính canh) . .Caáu truùc danh saùch lieân keát III. CurrPtr = List. nút kết thúc này được sử dụng như nút lính canh để tăng tốc độ thực hiện của các thuật toán cần dùng lính canh ở cuối. . CurrPtr = CurrPtr->Next. new_ele = InsertElementTailLL(List. x) == 0) Thấy = 1. OldTail.15 } . Trong khi (CurrPtr->Data < x) thực hiện { PredPtr = CurrPtr . Trả về trị Thấy. ta dùng điều kiện (CurrPtr->Next != CurrPtr) thay cho (CurrPtr != NULL) trong biểu thức điều kiện để kết thúc vòng lặp while.Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy được sắp (tăng): int SearchLinearOrderLL(List. PredPtr = NULL.Cài đặt int SearchLinearOrderLL(LL List.Head CurrPtr … List. x) < 0) { PredPtr = CurrPtr. int Thấy. CurrPtr = CurrPtr->Next. . Xóa nút (new_ele) đứng sau nút được trỏ bởi List. else Thấy = 0. x).

Head … PredPtr Temp List. } . &x) . cần cập nhật lại đuôi . . } else { Temp = List. if (Temp == NULL) return 0. //nếu xóa đuôi.Cài đặt int RemoveAfterLL(LL &List. PredPtr. .Head. } else // xóa nút đầu { Temp = List. if (EmptyLL(List)) { cout << “\nDS rỗng !”.Head = Temp->Next. // không có gì để xoá ! return 0. if (Temp == List. } if (PredPtr) { Temp = PredPtr->Next. List.Tail) List.Caáu truùc danh saùch lieân keát III. x = Temp->Data. Temp->Data).Head. delete Temp. delete Temp.Tail • . NodePointer PredPtr.//nếu xóa đuôi.Head = Temp->Next.Tail = PredPtr. } if (Temp == List. // không thể xóa nút sau nút cuối ! else PredPtr->Next = Temp->Next.Tail) List. // xóa nút đầu List.Thuật toán RemoveAfterLL(&List.Tail = PredPtr.16 • Xóa một phần tử khỏi DSLK * Xóa một nút sau một nút được trỏ bởi con trỏ PredPtr (qui ước: nếu PredPtr == NULL thì xóa nút đầu) List. cần cập nhật lại đuôi Gán(x. ElementType &x) { NodePointer Temp. if (PredPtr) { Temp = PredPtr->Next. if (Temp) PredPtr->Next = Temp->Next.

.Trả về biến con trỏ PredPtr chỉ đến nút đứng trước nút tìm thấy. Với cách tiếp cận này. Ngược lại thì kết thúc. . x. x) . // xóa thành công } * Xóa nút đầu của DSLK . tốc độ thực hiện các thuật toán sắp xếp sẽ rất chậm).Xóa nút đứng sau nút được trỏ bởi PredPtr. . cố định trong mỗi môi trường 16 bits hay 32 bits và thường là khá nhỏ so với kích thước của trường dữ liệu trong các ứng dụng lớn trên thực tế.Caáu truùc danh saùch lieân keát III. else return RemoveAfterLL (List. NULL. ElementType x) { NodePointer PredPtr. .Cài đặt int RemoveElementLL(LL &List. Vả lại.Cài đặt int RemoveHeadLL(LL &List.Thuật toán: Xóa nút đầu của DSLK List int RemoveHeadLL(&List. PredPtr)) return 0. } * Xóa một phần tử x khỏi DSLK . PredPtr). cách làm như vậy sẽ không tận dụng được ưu điểm linh hoạt của DSLK động trong các thao tác chèn và xóa (chẳng hạn đối với thuật toán sắp xếp chèn trực tiếp). nếu kích thước trường dữ liệu lớn thì chi phí cho việc hoán vị các cặp phần tử sẽ rất lớn (do đó. Sắp xếp trên kiểu DSLK đơn Có hai cách chính thực hiện các thuật toán sắp xếp trên DSLK: * Cách 1: Hoán vị nội dung dữ liệu (trường Data) của các nút trên DSLK tương tự như cách sắp xếp trên mảng đã trình bày trong chương trước. if (!SearchLinearLL(List. Nếu thấy thì: . Tìm x trong List. x). x. Điểm khác biệt là việc truy xuất đến các phần tử trên DSLK sẽ theo trường liên kết Next thay vì theo chỉ số như trên mảng. &x) . } c. Kích thước của trường liên kết: không phụ thuộc vào bản thân nội dung dữ liệu của các phần tử. * Cách 2: Thay vì hoán vị nội dung dữ liệu của các nút.17 return 1. ElementType &x) { return RemoveAfterLL (List. Tuy . Xóa nút đầu (hay sau nút được trỏ bởi NULL) của List. ta chỉ thay đổi thích hợp các trường liên kết Next giữa những nút để được thứ tự mong muốn.Thuật toán: int RemoveElementLL(&List.

} .Head SubPred = NULL. Lưu ý rằng.Head.18 nhiên. Trong phần này. dùng Curr làm lính canh . ta sẽ xét một số thuật toán sắp xếp có tận dụng các ưu thế của DSLK động. do đó sẽ rút ngắn thời gian đáng kể cho những phép hoán vị hay dời chỗ các phần tử ). // Bắt đầu tìm từ List.Caáu truùc danh saùch lieân keát III. thay vì phải dời chỗ (chi phí dời chỗ phụ thuộc vào chiều dài của dãy con và do đó chiếm rất nhiều thời gian) các dãy con nhằm tìm vị trí thích hợp để chèn phần tử mới vào dãy con cũ đã được sắp. // DS từ đầu đến PredPtr đã được sắp Curr = Pred->Next. Bước 2. ta chỉ phải thay đổi liên kết của không quá ba nút (chi phí hằng.Head List.Bước 2: Trong khi (Curr ≠ NULL) thực hiện: . các thao tác trên trường liên kết này thường phức tạp hơn trên trường dữ liệu. Chèn nút Curr sau SubPred. List. Bước 2. không phụ thuộc vào chiều dài dãy con. ta minh họa thuật toán sắp xếp chèn trực tiếp một dãy các đối tượng được cài đặt bằng DSLK động thông qua kiểu con trỏ. } . // nút đứng trước SubCurr // Tìm vị trí SubPred thích hợp để chèn Curr sau // SubPred.Thuật toán SắpXếpChènLL(&List) .1: SubCurr = List.2:Trong khi (SubCurr->Data<Curr->Data) thực hiện: { SubPred = SubCurr.Tail 3 1 … 2 … Pred Curr • SubPred SubCurr .Head. SubCurr = SubCurr->Next. • Sắp xếp chèn trực tiếp trên DSLK Trước hết.3: if (SubCurr ≠ Curr) { Pred->Next = Curr->Next. // Con trỏ Curr kề sau Pred . Bước 2. tận dụng ưu điểm liên kết động của con trỏ trong thao tác chèn.Bước 1: Pred = List.

Head while (SoSánh(SubCurr->Data.Thuật toán QuickSortLL(&List) . // SubPred là nút kề trước SubCurr. // DS con từ List. } if (SubCurr != Curr) // Chèn Curr sau SubPred { Pred->Next = Curr->Next. List_2 theo trình tự đó thành List được sắp. Chú ý rằng. } return . ta không sử dụng thêm bộ nhớ phụ (mà phụ thuộc vào chiều dài danh sách). .19 else Pred = Curr.Bước 1: Chọn phần tử đầu List. // Curr là con trỏ đứng sau Pred SubCurr. InsertNodeAfterLL(List. vị trí . Bước 2. ta sẽ xét thêm một số thuật toán sắp xếp khác được cài đặt bằng DSLK động thể hiện một cách đơn giản và rõ hơn bản chất của phương pháp và tỏ ra khá hiệu qủa: Quick sort. . để giảm chi phí tìm kiếm. khi tách List thành hai DSLK con List_1 và List_2. • Phương pháp QuickSort trên DSLK Do đặc điểm của DSLK đơn. // Curr đã đặt đúng . ta nên chọn mốc là phần tử ở đầu DSLK. . if (List_2 ≠ NULL) QuickSortLL (List_2). } else Pred = Curr.Cài đặt void SắpXếpChènLL(LL &List) { NodePointer Pred = List. dùng để tìm vị trí để chèn Curr trong dãy con while (Curr) { SubPred = NULL.Head làm mốc g. } Sau đây.Head. SubPred). Natural Merge sort (sắp trộn tự nhiên) và Radix sort. Curr->Data) < 0) { SubPred = SubCurr.Bước 3: if (List_1 ≠ NULL) QuickSortLL (List_1). // Bắt đầu tìm từ List.Head. Curr = Pred->Next.Bước 4: Nối List_1.4: Curr = Pred->Next. SubPred. .Head đến PredPtr đã được sắp Curr = Pred->Next. Loại g khỏi List. g.Bước 2:Tách List thành hai DSLK con List_1 (gồm những phần tử có trị nhỏ hơn g) và List_2 (gồm những phần tử có trị lớn hơn hoặc bằng hơn g) .Caáu truùc danh saùch lieân keát III. Curr. SubCurr = List. SubCurr = SubCurr->Next.

LL List_1.Tail • .Head List_2.Head List_2. chọn g = 8.Tail->Next = g. else InsertNodeTailLL(List_2.Head 6 3 8 4 6 List. g->Data) < 0) InsertNodeTailLL(List_1. else { List.Caáu truùc danh saùch lieân keát III.Head = List_1.Tail 6 8 • . while (!EmptyLL(List)) { Temp = List. Nối List_1. Sau đó nối lại. // tách g ra khỏi List List_1 = CreateEmptyLL(). List_2 = CreateEmptyLL(). Chọn nút đầu tiên làm mốc: g = 6. ta được: List_2. // Nối g sau List_1 if (EmptyLL(List_1)) List. ta được List được sắp: List. // Nối List_2 sau g . QuickSortLL(List_2).Head = List.Head. } g->Next = List_2. // List được sắp nếu nó: rỗng hay có 1 phần tử g = List.Head List_1. } QuickSortLL(List_1). List.Temp).Head List.Head.Tail 3 4 • List_2. List_2. g = 6 và List_2.Head->Next.Tail) return. Với List_2.Head == List. if (SoSánh(Temp->Data.Tail 3 4 6 6 8 • .Cài đặt void QuickSortLL(LL &List) { NodePointer g. Temp.20 * Ví dụ Sắp xếp tăng DSLK sau: List.Head.Head = g.Temp). Tách List thành hai DSLK con: List_1.Head = List.Tail 8 6 • . Tách List_2 thành hai DSLK con. if (List. Temp->Next = NULL. List_1. List.Head->Next.

if (Empty(List_2) { List = List_1. .Tail 3 4 6 6 8 • . ta được List_1 tăng: List_1. rồi sau đó trộn lại. bằng cách thay đổi các liên kết cho phù hợp ta có dãy được sắp mà không cần phải dùng dãy phụ lớn (kích thước phụ thuộc vào cỡ dãy) như đã làm trên mảng. return.Tail = List_2. List_1. // Phân phối các đường chạy của List vào List_1 và List_2 DistributeLL(List.Head List_1.Caáu truùc danh saùch lieân keát III.Tail 3 8 • . List_2 = CreateEmptyLL(). Tách luân phiên các đường chạy tự nhiên của List vào 2 DSLK con: List_1. if (List_2 ≠ NULL) NaturalMergeSortLL (List_2). Lại tách luân phiên các đường chạy tự nhiên của List_1 vào 2 DSLK con.Cài đặt void NaturalMergeSortLL (LL &List) { LL List_1. NaturalMergeSortLL (List_2). List_2).21 if ((EmptyLL(List_2)) List. .Thuật toán NaturalMergeSortLL (&List) . * Ví dụ Sắp xếp tăng DSLK sau: List. } NaturalMergeSortLL (List_1). // List được sắp nếu nó: rỗng hay có 1 phần tử List_1 = CreateEmptyLL().Tail 6 3 8 4 6 • . .Tail.Head == List. Trộn List_1 và List_2.Tail) return.Tail = g. List_2. } • Phương pháp NaturalMergeSort trên DSLK Khi cài đặt dãy cần sắp bằng phương pháp trộn tự nhiên trên DSLK đơn. ta được List tăng: List.Bước 1: Phân phối luân phiên từng đường chạy của List vào hai DSLK List_1 và List_2.Bước 2: if (List_1 ≠ NULL) NaturalMergeSortLL (List_1).Tail 4 6 6 • .Head List_2.Head List_1. . return. if (List.Head List. //Cập nhật lại đuôi của List else List.Head List.Bước 3: Trộn List_1 và List_2 đã sắp để có List được sắp.Tail 6 4 6 • List_2.

List. LL &List_1.Head = List_2. List_2. } return . return. List_2. Temp). } else { Temp = List_2. // Tách Temp ra khỏi List_2 List_2.22 // Trộn hai DSLK đã sắp List_1 và List_2 thành List MergeLL(List_1.Head = List. if (EmptyLL(List_1)) ListCònLại = List_2. Ta có thể viết thêm các module con: tìm một đường chạy tự nhiên từ vị trí hiện hành (chỉ có phép so sánh) và phép nối một đường chạy đó vào đuôi của DSLK con tương ứng. LL &List) { NodePointer Temp.Tail = NULL. else List.Head->Data) <= 0)).Head->Next .Head->Next. List_1). InsertNodeTailLL(List. do { Temp = List.Head.Head. } void MergeLL(LL &List_1.Head && (Sosánh(Temp->Data. if (List. } while (List. InsertNodeTailLL(List_1. chuẩn bị cho phép trộn return . } Chú ý: Trong vòng lặp của thủ tục DistributeLL trên đây để tìm và đưa một đường chạy tự nhiên vào một DSLK con. LL &List_2. } void DistributeLL(LL &List. LL &List_2) { NodePointer Temp. List_2. while (!EmptyLL(List_1) && !EmptyLL(List_2)) { if (SoSánh(List_1.Head->Data.Tail->Next = ListCònLại. List). Temp->Next = NULL. // Tách Temp ra khỏi List_1 List_1.Head.Head) DistributeLL(List. không phụ thuộc vào độ dài mỗi đường chạy (tại sao ? Bài tập). Temp).Head. ta thực hiện thừa các phép nối thêm những nút của List vào đuôi của DSLK con (chi phí thực hiện các phép nối thêm này phụ thuộc vào độ dài mỗi đường chạy). } Temp->Next = NULL. //Cập nhật lại đuôi rỗng cho List.Caáu truùc danh saùch lieân keát III.Tail. } LL ListCònLại = List_1.Head = List_1. Khi đó chi phí cho phép nối thêm này là hằng. // Tách Temp ra khỏi List List.Head->Next. • Phương pháp RadixSort trên DSLK . List.Head->Data) <= 0) { Temp = List_1.Tail = ListCònLại. if (!EmptyLL(ListCònLại)) { List.

Temp->Next = NULL. i < MAX_LO.. int i. m) // m là số ký số tối đa của dãy số cần sắp ..Khởi tạo 10 DSLK (lô) rỗng: B0. if (Empty(List)) List = List_1. Temp->Next = NULL. . Temp). } List = B[0]. //Tách nút đầu Temp ra khỏi List InsertNodeTailLL(B[GetDigit(Temp->Data. k++) { for (i = 0. List. else Dừng. if (k < m) Quay lại bước 2.Bước 4: k = k +1. for (i = 1.Tail) return . int m) { LL B[MAX_LO].// List được sắp nếu nó: rỗng hay có 1 phần tử for (k = 0.Trong khi (List ≠ rỗng) thực hiện: { Temp = List.B[i]). i++) CreateEmptyLL(B[i]). if (List. //Tách nút đầu Temp ra khỏi List Chèn nút Temp vào cuối DSLK Bi.Cài đặt #define MAX_LO 10 void RadixSortLL (LL &List.Tail = List_1.Bước 2: . k = 1: hàng chục. // Nối B[i] vào cuối List } return . // với i là chữ số thứ i của Temp->Data. ta lãng phí bộ nhớ quá nhiều. B9 thành List.Bước 1: k = 0. i < MAX_LO..Head->Next. k)]. List.Head. i++) AppendList(List. } void AppendList(LL &List. .Bước 3: Nối lần lượt các DSLK B0.Head == List. } . LL List_1) // Nối List_1 vào cuối List { if (Empty(List_1)) return.Head->Next. k < m.Tail->Next = List_1.Caáu truùc danh saùch lieân keát III.. .Head = List.Head = List.23 Khi cài đặt thuật toán RadixSort trên cấu trúc dữ liệu mảng. B9. . NodePointer Temp.Thuật toán RadixSortLL (&List. k. else { List. . .. … .Head. List. Các cài đặt thuật toán này trên DSLK động sẽ trình bày sau đây sẽ khắc phục được nhược điểm trên.Tail. . while (!EmptyLL(List)) { Temp = List. // k = 0: hàng đơn vị.Head. Giả sử ta cần sắp (tăng) một dãy số nguyên mà số chữ số tối đa của chúng là m..

Ta có thể dùng mảng hay DSLK động để biểu diễn stack. Dựa trên cơ sở đó. Vài ứng dụng của DSLK đơn III. StackType S. và thêm hai thao tác phụ trợ khác: . .Top(): Trả về trị của phần tử ở đầu stack mà không loại nó khỏi stack.Push(O): thêm một đối tượng O vào đầu stack.Caáu truùc danh saùch lieân keát III. nếu stack rỗng sẽ gặp lỗi.2.3. int k) // Lấy chữ số thứ k của số nguyên N { return ((unsigned long)(N/pow(10. } int GetDigit(unsigned long N. . phần tử ở đỉnh stack có chỉ số là t.24 } return . các phần tử của nó được đánh số bắt đầu từ 0 (đến N-1).EmptyStack(): kiểm tra xem stack có rỗng hay không. .3. Ngăn xếp a. int t . * Ví dụ: Ta có thể dùng ngăn xếp để cài đặt thuật toán đổi một số nguyên dương từ cơ số 10 sang cơ số 2 (bài tập).Pop(): lấy ra một đối tượng ở đầu stack và trả về trị của nó. Định nghĩa Ngăn xếp (stack) là kiểu dữ liệu tuyến tính nhằm biểu diễn các đối tượng được xử lý theo kiểu "vào sau ra trước" (LIFO: Last In. y) ≡ x^y } III. b. Ta cũng có thể định nghĩa stack là một kiểu dữ liệu trừu tượng tuyến tính. stack có thể được quản lý thông qua cấu trúc sau: typedef struct { ElementType mang[N]. nếu stack rỗng sẽ gặp lỗi.k)) % 10). First Out).1. các phép toán thêm vào và lấy ra được thực hiện cùng ở một đầu danh sách (gọi là đỉnh của ngăn xếp).2. trong C++. Ta có thể dùng danh sách để biểu diễn ngăn xếp. Cài đặt ngăn xếp bằng mảng • Cài đặt cấu trúc dữ liệu Ta còn có thể cài đặt ngăn xếp S bằng mảng 1 chiều có kích thước tối đa là N. // chỉ số của đỉnh stack } StackType. // pow (x. trong đó có hai thao tác chính: .

mang[--t]. không lấy được phần tử ở đỉnh S else { x = S. hiệu quả và có chi phí hằng số O(1) .t == 0.Caáu truùc danh saùch lieân keát III. int FullStack(StackType S) { return (S. S. không xem được phần tử ở đỉnh S else { x = S. } int EmptyStack(StackType S) { return (S. // Stack rỗng. // Stack rỗng.Các thao tác trên đều đơn giản.mang[1] y … S. ElementType &x) { if (EmptyStack(S)) return 0.t == 0).t >= N). } } int Pop (StackType &S.mang[t-1] Z t • Các phép toán cơ bản trên stack StackType CreateEmptyStack() { StackType S. ta sẽ tập trung khảo sát cách cài đặt ngăn xếp bằng DSLK động. // Stack đầy. return S. return 1. trước khi chèn ta phải kiểm tra ngăn xếp đã đầy hay chưa thông qua hàm FullStack sau đây. ElementType &x) { if (EmptyStack(S)) return 0. return 1.Hạn chế của cách cài đặt này: kích thước của stack bị giới hạn và kém linh động. } } int Top (StackType S. } Do kích thước của mảng cố định. .mang[0] X S. chèn không thành công else { S. do đó việc sử dụng bộ nhớ kém hiệu quả (thiếu hay lãng phí bộ nhớ).mang[t-1]. Cài đặt ngăn xếp bằng DSLK động • Cài đặt. } } • Nhận xét: .mang[t++] = x. return 1.25 S. ElementType x) { if (FullStack(S)) return 0. } int Push(StackType &S. c. Sau đây.

NodePointer Stack. chỉ khác là không lưu đến nút cuối hay đáy của ngăn xếp) như sau: typedef .26 Ta có thể cài đặt ngăn xếp bằng danh sách liên kết động (tương tự như DSLK đơn. * Thao tác Push đẩy một mục dữ liệu x vào đỉnh ngăn xếp Thao tác Push tương tự thao tác InsertElementHeadLL. Stack = Temp. else { Temp->Next = Stack. ElementType. nếu ta quản lý thêm nút ở đáy stack. • Các phép toán cơ bản trên stack Các thao tác khởi tạo một stack rỗng và kiểm tra xem môt stack cho trước có rỗng hay không tương tự như DSLK đơn.. if ((Temp = CreateNodeLL(x)) == NULL) return(0).Caáu truùc danh saùch lieân keát III. struct node *Next. } } * Thao tác Pop lấy ra một phần tử ở đỉnh ngăn xếp . return 1 . Stack Temp 2 x Đỉnh ngăn xếp 1 Stack • Hoặc ta có thể viết trực tiếp như sau: int Push(NodePointer &Stack. // Kiểu dữ liệu của nút typedef struct node { ElementType Data.. typedef NodeType *NodePointer.. } NodeType. Ta chỉ chú trọng đến hai thao tác đặc trưng của ngăn xếp là lấy ra Pop và thêm vào Push ở đỉnh ngăn xếp. Gọi Stack là con trỏ chỉ đến phần tử ở đỉnh của ngăn xếp. ElementType x) { NodePointer Temp.

Không thể lấy phần tử ở đỉnh ngăn xếp !". Stack->Data). } else { Gan (x. Stack = Stack->Next. nếu ta quản lý thêm nút ở đáy stack. if (EmptyStack(Stack)) { cout << “\nNgăn xếp rỗng. } else { Gan (x. return 0. } } d. lưu vết trong thuật toán quay lui. Temp = Stack. Stack->Data). } } * Thao tác Top xem một phần tử ở đỉnh ngăn xếp int Top(NodePointer Stack. return 1. vét cạn hay tìm kiếm theo chiều sâu. trong .27 Thao tác Pop tương tự thao tác RemoveHeadLL. if (EmptyStack(Stack)) { cout << “\nNgăn xếp rỗng. return 0. delete Temp. khử đệ qui. return 1.Caáu truùc danh saùch lieân keát III. Ứng dụng của ngăn xếp Ngăn xếp có rất nhiều ứng dụng trong tin học: cài đặt phép đệ qui. ElementType &x) { NodePointer Temp. ElementType &x) { NodePointer Temp. Temp Stack 2 1 Data Next Đỉnh ngăn xếp • Ta có thể viết trực tiếp thao tác này như sau: int Pop(NodePointer &Stack. Không thể xem phần tử ở đỉnh ngăn xếp !".

3.Caáu truùc danh saùch lieân keát III.1)) (Ký pháp trung tố ) 1 5 + 8 4 1 . Khởi tạo ngăn xếp (dùng để chứa các toán tử) S rỗng. (trong đó. Ta sẽ ứng dụng ngăn xếp để: chuyển InfixeExp sang dạng hậu tố SuffixeExp (toán tử đặt sau các toán hạng) và tính trị của SuffixeExp. . .. // (1) Ngược lại: Nếu toán tử đó có độ ưu tiên cao hơn toán tử ở đỉnh S thì: đẩy toán tử đó vào S. /. . -. .Dấu ‘(‘: đẩy nó vào S. . ta dùng ký pháp nghịch đảo Balan (ký pháp hậu tố RPN .(4 .Biến đổi biểu thức từ dạng kí pháp trung tố thành biểu thức dạng RPN. Ngược lại: lấy ra và hiển thị toán tử ở đỉnh S .Dấu ‘)’: hiển thị các phần tử của S cho đến khi dấu ‘(‘ (không hiển thị) được đọc.Toán tử: Nếu S rỗng: đẩy toán tử vào S. … Sau đây. Lặp lại các việc sau cho đến khi dấu kết thúc biểu thức được đọc: . Nếu phần tử là: .Reverse Polish Notation) để đánh giá các biểu thức số học.Đánh giá biểu thức số học dưới dạng RPN. Quay lại (1). Đọc phần tử tiếp theo (hằng. Khi đạt đến dấu kết thúc biểu thức thì lấy ra và hiển thị các toán tử của S cho đến khi S rỗng. biến. 2.Toán hạng (hằng hoặc biến): Hiển thị nó. toán tử. Một biểu thức số học InfixeExp thông thường được viết theo ký pháp trung tố (toán tử đặt ở giữa hai toán hạng). * Ví dụ: Biến đổi (1 + 5) * (8 . lô-gic. * Thuật toán chuyển biểu thức dạng trung tố sang dạng hậu tố RPN 1. ta xem dấu ‘(‘ có độ ưu tiên thấp hơn độ ưu tiên các toán tử +. ‘(‘. *.* (Ký pháp hậu tố ) Đánh giá 30 Ta sẽ lần lượt xét hai thuật toán: .28 việc chuyển đổi giữa các dạng kí pháp khác nhau cũng như đánh giá các biểu thức chứa các toán tử không quá hai ngôi như biểu thức số học. %) Ví dụ: Chuyển biểu thức 7*8-(2+3) sang dạng hậu tố. ‘)’ ) trong biểu thức trung tố. Biểu thức kí pháp trung tố Stack S Hiển thị .

S rỗng: đẩy – vào S) (2+3) ( ( 7 8 * 2+3) 7 8 * 2 +3) + ( 7 8 * 2 (ĐộƯuTiên[+] > ĐộƯuTiên[(]: đẩy + vào S) + ( [Lấy ra + ( ] Kết quả 3) ) 7 8 * 2 3 7 8 * 2 3+ Dấu kết thúc biểu thức. Khởi tạo ngăn xếp S rỗng.Caáu truùc danh saùch lieân keát III.ra * Thuật toán đánh giá biểu thức dạng RPN 7 8* 2 3 + - 1. 2. lấy .29 7*8-(2+3) Lấy ra *8-(2+3) 8-(2+3) * * 7 7 7 8 -(2+3) 7 8* (ĐộƯuTiên[-] < ĐộƯuTiên[*]: lấy và hiển thị * . Lặp lại các việc sau cho đến khi dấu kết thúc biểu thức được đọc: .

Áp dụng toán tử đó vào 2 toán hạng (theo thứ tự ngược).. Ví du: Tính giá trị của biểu thức hậu tố: 1 5 + 8 4 1 .Lấy từ đỉnh S hai toán hạng.30 . toán tử) tiếp theo trong biểu thức.* Biểu thức hậu tố 1 5+8 4 1 --* Stack S 1 5 1 6 (Thực hiện phép toán +. . . 3. Nếu phần tử là toán hạng: đẩy nó vào S. Ngược lại: // phần tử là toán tử .Caáu truùc danh saùch lieân keát III.Đẩy kết qủa vừa tính trở lại S. Đọc phần tử (toán hạng. Khi gặp dấu kết thúc biểu thức. . giá trị của biểu thức chính là giá trị ở đỉnh S. lưu kết quả 6 vào S) 8 6 5+8 4 1 --* +8 4 1 --* 8 4 1 --* 4 1 --* 4 8 6 1 4 8 6 3 8 6 1 --* --* .

ta nên lưu vào ngăn xếp các cặp chỉ số của dãy con nào dài hơn ! III. Ta có thể dùng ngăn xếp để khử đệ qui. Hàng đợi a.3.2. nếu hàng đợi rỗng sẽ gặp lỗi.2. Định nghĩa Hàng đợi (queue) là kiểu dữ liệu tuyến tính nhằm biểu diễn các đối tượng được xử lý theo kiểu "vào trước ra trước" (FIFO: First In. Ta có thể dùng mảng vòng hay DSLK động để biểu diễn hàng đợi. Ta cũng có thể định nghĩa hàng đợi là một kiểu dữ liệu trừu tượng tuyến tính.Front (): Trả về trị của phần tử ở đầu hàng đợi mà không loại nó khỏi hàng đợi. Cài đặt hàng đợi bằng mảng vòng • Cài đặt cấu trúc dữ liệu Ta có thể biểu diễn hàng đợi Q bằng một mảng 1 chiều có kích thước tối đa là N. Ta có thể dùng danh sách để biểu diễn hàng đợi. . Chú ý.31 (Thực hiện phép toán 4 -1. ta tổ chức mảng . First Out).EnQueue(O): thêm một đối tượng O vào đuôi hàng đợi. b.DeQueue(): lấy ra một đối tượng ở đầu hàng đợi và trả về trị của nó. và thêm hai thao tác phụ trợ khác: . trong các thuật toán không kiểm tra biểu thức đưa vào có đúng cú pháp hay không? Hãy bổ sung chức năng kiểm tra cú pháp cho các biểu thức (bài tập). để tiết kiệm bộ nhớ cho stack. lưu kết quả 3 trở lại S) -* * 5 6 (Thực hiện phép toán 8 -3. các phép toán thêm vào và lấy ra được thực hiện ở hai đầu khác nhau của danh sách.Caáu truùc danh saùch lieân keát III. Để có thể sử dụng linh hoạt bộ nhớ mà mảng được cấp phát. lưu kết quả 30 trở lại S) Kết qủa Dấu kết thúc biểu thức 30 Chú ý rằng. Hãy khử đệ qui và viết lại dưới dạng lặp thuật toán Quick Sort (bài tập). lưu kết quả 5 trở lại S) 30 (Thực hiện phép toán 6 * 5. nếu hàng đợi rỗng sẽ gặp lỗi.EmptyQueue(): kiểm tra xem hàng đợi có rỗng hay không. . trong đó các hai thao tác chính: .

R == N-1) Q. // hoặc: return(Q.R == -1).R+1.F == -1 && Q.R = 0. } int DeQueue (QueueType &Q. // Cập nhật lại đầu hàng đợi rỗng sau khi thêm phần tử đầu tiên if (Q. ta còn lưu trữ thêm hai chỉ số F và R để lưu vị trí phần tử ở đầu và đuôi hàng đợi Q. } int EnQueue (QueueType &Q. ElementType x) { if (FullQueue(Q)) { cout << "\nHàng đợi đầy !". return . return 0. ElementType &x) . // xoay vòng chỉ số đuôi của hàng đợi else Q. Gán (Q. } int FullQueue (QueueType Q) { int IndexTemp = (Q. Ngoài ra.R++. Trong C++. Q.F == -1). } if (Q. ta có thể quản lý hàng đợi thông qua mảng như sau: typedef struct { ElementType mang[N].mang[1] … Q.Caáu truùc danh saùch lieân keát III. R .mang[0] Q. chèn).F == IndexTemp). } int EmptyQueue (QueueType Q) { return(Q.mang[N-1] X X X F R Sau quá trình cập nhật (dãy các thao tác xóa.32 theo kiểu xoay vòng (nghĩa là phần tử thứ N-1 được xem là kề trước phần tử thứ 0).mang[1] … Q. return 1. x).F == -1) Q.F = Q.R = -1.R].mang[0] Q.F++.mang[Q. QueueType Q. return(Q. int F.mang[N-1] X X X R F • Các phép toán cơ bản void CreateEmptyQueue (QueueType &Q) { Q.R == N -1) ? 0 : Q. hàng đợi Q có thể “xoay vòng” như sau (X dùng để chỉ những vị trí chứa dữ liệu thật sự đang quan tâm trong hàng đợi): Q. // chỉ số của phần tử đầu và đuôi hàng đợi } QueueType.

ElementType &x) { if (EmptyQueue(Queue)) return 0. if (Q.F]). … int EnQueue (LL &Queue.Tail … • Lấy ra ở đầu Thêm vào đuôi • Các phép toán cơ bản Cách cài đặt các thao tác trên hàng đợi đều giống với các thao tác tương ứng trên DSLK đơn như: khởi tạo hàng đợi rỗng. return 1. } Gán (x.F == Q. Queue.33 { if (EmptyQueue(Q)) { cout << "\nHàng đợi rỗng !".F = Q.Head->Data).R) // xóa trên hàng đợi chỉ còn một phần tử: Q sẽ rỗng ! { Q.F++. // xoay vòng chỉ số đầu hàng đợi else Q.F == N-1) Q. } else if (Q. return 1. . Q. Q. return 0.mang[Q.R = -1. Gán(x. kiểm tra xem hàng đợi có rỗng hay không.Caáu truùc danh saùch lieân keát III. } int FrontQueue(QueueType &Q. return 0. } c. } Gán (x. x). ElementType &x) { if (EmptyQueue(Q)) { cout << "\nHàng đợi rỗng !".F]). ElementType x) { return InsertElementTailLL(Queue. Cài đặt hàng đợi bằng DSLK động • Cài đặt cấu trúc dữ liệu Ta dùng kiểu dữ liệu con trỏ để cài đặt hàng đợi giống như cách cài đặt DSLK đơn. Queue.Head Queue. ElementType &x) { return RemoveHeadLL(Queue. return 1.mang[Q.F = 0. x). } int DeQueue (LL &Queue. } int FrontQueue(LL &Queue.

Cấp phát vùng nhớ cho một nút (không quan tâm đến dữ liệu) NodePointer CreateNode () { NodePointer new_ele. return List. else new_ele ->Next = NULL. DSLK đơn có nút câm Qua các thao tác cơ bản trên DSLK đơn (không có nút câm trước đây).Caáu truùc danh saùch lieân keát III.4. DList. } • Khởi tạo một DSLK có nút câm rỗng LL CreateEmptyLL2 () { LL List. Ứng dụng của hàng đợi Hàng đợi có nhiều ứng dụng trong tin học như: . các thao tác cơ bản trên DSLK có nút câm. List. máy in.Head Nút câm Nút dữ liệu đầu DList.Tail = List. thiết bị nhớ ngoài. if ((new_ele = new NodeType) ==NULL) cout << “\nLỗi cấp phát vùng nhớ cho một nút mới !”. … .34 } d.Tail ? x y … z • Khi đó. sẽ được viết lại. Để đơn giản khi viết các thao tác trên (khỏi phải phân biệt hai tình huống xử lý đó) người ta tạo thêm một nút giả (hay nút câm. … III.Head = CreateNode(). return new_ele. Một số kiểu DSLK khác III. } . trình biên dịch.Hàng đợi lưu các tiến trình chờ được xử lý trong các hệ điều hành. ta không quan tâm đến dữ liệu của nút này) đứng trước nút dữ liệu đầu tiên của DSLK đơn thông thường và gọi nó là DSLK (đơn) có nút câm.Cơ chế vùng đệm cho các thao tác nhập – xuất trên bàn phím. List.4.Head. trong một số trường hợp (chẳng hạn chèn.1. ta thường qui ước PredPtr là NULL) với các nút khác (luôn có nút đứng trước PredPtr). ta nhận thấy có sự khác biệt trong cách xứ lý giữa nút đầu (không có nút đứng trước. xóa) sẽ đơn giản hơn .

NodePointer PredPtr) { NodePointer new_ele.Head ? 2 … 1 PredPtr x new_ele void InsertNodeAfterLL2(LL &List. new_ele. ElementType x. if (EmptyLL2(List)) return 0. NodePointer PredPtr) { new_ele->Next = PredPtr->next. return . } } • Thêm một phần tử x vào sau một nút được trỏ bởi con trỏ PredPtr * Thêm một nút mới vào sau một nút được trỏ bởi con trỏ PredPtr List. return 1.Tail • * Thêm một phần tử x vào sau một nút được trỏ bởi con trỏ PredPtr int InsertElementAfterLL2(LL &List. PredPtr). } • Duyệt qua một DSLK có nút câm int TraverseLL2(LL List) { NodePointer CurrPtr = List.Head->Next. if (PredPtr == List.Head). } return 1.Tail) List. ElementType x) { return InsertElementAfterLL2(List. List. NodePointer new_ele. else { while (CurrPtr) { XửLý (CurrPtr). } List. PredPtr->Next = new_ele. x.Caáu truùc danh saùch lieân keát III.Tail = new_ele. if ((new_ele = CreateNodeLL(x)) == NULL) return 0. } . InsertNodeAfterLL2(List. CurrPtr = CurrPtr->Next.Head->Next == NULL). } Thêm một phần tử x vào đầu DSLK có nút câm int InsertElementHeadLL2(LL &List.35 • Kiểm tra một DSLK với nút câm có rỗng hay không int EmptyLL2(LL List) { return(List.

OldTail = List.Head. . } • Tìm kiếm một phần tử trên DSLK đơn có nút câm Tìm một phần tử x trong DSLK List. . Trong khi (CurrPtr->Data ≠ x) thực hiện { PredPtr = CurrPtr.Head. NodePointer &PredPtr) { NodePointer CurrPtr = List. CurrPtr = CurrPtr->Next. x).Head->Next.Head->Next. int Thấy. // PredPtr đứng kề trước CurrPtr . ta đặt thêm lính canh ở cuối List. Để tăng tốc độ tìm kiếm (bằng cách giảm số lần so sánh trong biểu thức điều kiện của vòng lặp). List. PredPtr = List.Tail). Chèn nút mới new_ele chứa x vào cuối List (đóng vai trò lính canh) . CurrPtr = List.Tail • new_ele (lính canh) x • Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy chưa được sắp: Boolean SearchLinearLL2(List. } . ElementType x. // Thông báo thấy x. Trả về trị Thấy. x.Caáu truùc danh saùch lieân keát III.36 Thêm một phần tử x vào cuối DSLK có nút câm int InsertElementTailLL2(LL &List. new_ele = InsertElementTailLL2(List. x.Head ? PredPtr CurrP … List. // thấy thật sự . CurrPtr = CurrPtr->Next. . Nếu tìm thấy thì. // Thông báo không thấy x. } if (CurrPtr != new_ele) Thấy = 1. x) != 0) { PredPtr = CurrPtr . else Thấy = False. ElementType x) { return InsertElementAfterLL2(List. trả về địa chỉ PredPtr của nút đứng trước nút tìm thấy đầu tiên.Cài đặt int SearchLinearLL2(LL List. &PredPtr) . if (CurrPtr ≠ new_ele) Thấy = True. while (SoSánh(CurrPtr->Data.Tail. List. Xóa nút (new_ele) đứng sau nút được trỏ bởi List. thông qua đối cuối của hàm. PredPtr = List.Tail.

Trong DSLK vòng. CList. OldTail.4. Qua đó. return 0. delete Temp. DSLK vòng DSLK vòng là DSLK mà nút cuối là nút kề trước của nút đầu. Temp->Data). cần cập nhật lại đuôi Gán(x. được thể hiện qua “công thức” của Niklaus Wirth: Cấu trúc dữ liệu + Thuật toán = Chương trình III. ta thấy rõ mối liên quan mật thiết giữa cấu trúc dữ liệu và thuật toán. Nếu cài đặt DSLK vòng bằng kiểu con trỏ thì con trỏ của nút cuối trỏ đến nút đầu tiên.Head … CList. if (Temp == List. return 1.Tail .Tail = PredPtr.Tail) List. Cấu trúc dữ liệu cho mỗi nút của DSLK vòng hoàn toàn giống như DSLK đơn. } Temp = PredPtr->Next.2. if (EmptyLL2(List)) { cout << “\nDS rỗng !”. ElementType &x) { NodePointer Temp. } // thấy giả hay không thấy ! // xóa nút new_ele.Caáu truùc danh saùch lieân keát III. return Thấy. x). ta có thể lấy bất cứ nút nào làm nút đầu tiên xuất phát.37 else Thấy = 0. // không xóa được nút sau nút cuối ?! else PredPtr->Next = Temp->Next. // xóa thành công } Việc viết lại các thao tác cơ bản còn lại trên DSLK đơn có nút câm được xem như bài tập. if (Temp == NULL) return 0. • Xóa một nút sau một nút được trỏ bởi con trỏ PredPtr int RemoveAfterLL2(LL &List. RemoveAfterLL2(List. NodePointer PredPtr. //nếu xóa đuôi.

Head = CList. • Khởi tạo một DSLK vòng rỗng LL CreateEmptyCLL () { LL CList. return 1. ElementType. CurrPtr = CurrPtr->Next. } III.Head == NULL && CList.4. do { XửLý (CurrPtr). // Kiểu dữ liệu cơ sở của mỗi phần tử typedef struct Dnode {ElementType Data..Tail = NULL.38 Một số thao tác cơ bản cho DSLK vòng sẽ được viết lại sau đây. mỗi nút của DSLK đối xứng được cài đặt bởi cấu trúc sau: typedef . } • Kiểm tra một DSLK vòng có rỗng hay không int EmptyCLL(LL CList) { return(CList. một liên kết chỉ đến nút đứng sau và liên kết kia chỉ đến nút đứng trước. CList.3. Lý do là DSLK đơn chỉ có một liên kết đi theo một chiều từ nút đứng trước đến nút đứng sau.. return List. khi làm việc với một nút ta cần biết nút đứng kề trước của nó. có thể di chuyển từ đầu đến đuôi của danh sách hay ngược lại.Caáu truùc danh saùch lieân keát III..Tail • • a.Tail == NULL). ta xét kiểu DSLK đối xứng (hay DSLK kép) mà mỗi nút có hai trường liên kết ngược chiều nhau.Head Prev Data Next DList. } while (CurrPtr->Next != Clist. . } • Duyệt qua một DSLK vòng int TraverseCLL(LL CList) { NodePointer CurrPtr = CList. các thao tác khác được xem như bài tập.Head if (EmptyCLL(CList)) return 0. DList. Để tăng độ linh hoạt trong các thao tác trên DSLK. DSLK đối xứng Trong nhiều thao tác trên kiểu DSLK đơn. Cấu trúc dữ liệu biểu diễn DSLK đối xứng Trong C hay C++.Head).

39 struct Dnode *Next. DLL DList. .Tail = NULL. Trả về new_ele. return DList. Tail. Cấp phát vùng nhớ cho một nút new_ele. . Các thao tác cơ bản trên DSLK đối xứng Các thao tác cơ bản về sau sẽ sử dụng thủ tục cấp phát động vùng nhớ cho một nút của DSLK đối xứng sau đây: • Cấp phát vùng nhớ chứa dữ liệu x cho một nút của DSLK đối xứng Head • x Tail Thuật toán DNodePointer CreateNodeDLL (x) . new_ele ->Data = x.Head = DList. new_ele ->Next = NULL.Caáu truùc danh saùch lieân keát III. . new_ele ->Prev = NULL. Trả về DList.Tail = NULL. x). DList. } return new_ele. typedef struct { DNodePointer Head.Cài đặt DNodePointer CreateNodeDLL (ElementType x) { DNodePointer new_ele. else { Gán(new_ele ->Data. . DList. typedef DNodeType *DNodePointer. new_ele ->Next = new_ele ->Prev = NULL. } DNodeType. b. . if ((new_ele = new DNodeType) ==NULL) cout << “\nLỗi cấp phát vùng nhớ cho một nút mới !”.Cài đặt DLL CreateEmptyDLL () { DLL List. } • Khởi tạo một DSLK đối xứng rỗng.Head = DList. } DLL. . *Prev.Thuật toán DLL CreateEmptyDLL () . • .

Trong khi chưa hết DSLK thực hiện: { XửLý nút được trỏ bởi CurrPtr. else Trả trị False.Caáu truùc danh saùch lieân keát III.Head == NULL) // hay (DList. // hay CurrPtr = DList.Head == NULL) && (DList.Head == NULL). . .Cài đặt int EmptyDLL(DLL DList) { return(DList.Thuật toán Boolean EmptyDLL(DLL DList) if (DList. // hay CurrPtr = CurrPtr->Prev. // hay return ((DList.Tail == NULL).Head. } return 1. CurrPtr = CurrPtr->Next. else { while (CurrPtr != NULL) // hoặc while (CurrPtr) { XửLý (CurrPtr). // hay CurrPtr = DList.Thuật toán TraverseLL(DList) .Tail == NULL)). chuyển đến nút kề trước } . CurrPtr = DList. // DList rỗng. // chuyển đến nút kề sau // hay CurrPtr = CurrPtr->Prev. .Tail.Cài đặt int TraverseDLL(DLL DList) { DNodePointer CurrPtr = DList.Head == NULL) && (DList.Tail. // DList khác rỗng. CurrPtr = CurrPtr->Next. if (EmptyDLL(DList)) return 0. Tại sao ? Hãy so sánh ! Trả trị True.40 } • Kiểm tra một DSLK đối xứng có rỗng hay không . } • Duyệt qua một DSLK đối xứng Ta có thể duyệt Dlist theo chiều thuận (hay ngược) tùy theo chiều con trỏ Next (hay Prev). } } .Head.

Caáu truùc danh saùch lieân keát

III.41

void XửLý(DNodePointer CurrPtr) { // Xử lý nút CurrPtr tùy theo từng yêu cầu cụ thể return ; } • Thêm một phần tử mới vào DSLK đối xứng
* Thêm một phần tử vào sau một nút được trỏ bởi con trỏ PredPtr (nếu PredPtr == NULL thì chèn phần tử vào đầu DSLK) DList.Head • 3 PredPtr 2 1 X new_ele 4 Prev Data Next DList.Tail •

- Thuật toán: Thêm một nút new_ele vào sau một nút được trỏ bởi PredPtr InsertNodeAfterDLL(&DList, new_ele, PredPtr) . if (PredPtr) { new_ele->Next = PredPtr->Next; new_ele->Prev = PredPtr; PredPtr->Next = new_ele; if (new_ele->Next) (new_ele->Next)->Prev = new_ele; // else: trường hợp chèn new_ele vào đuôi DList, không cập nhật nút sau nút new_ele } else // chèn new_ele vào đầu Dlist { new_ele->Next = DList.Head; if (DList.Head) DList.Head->Prev = new_ele; // else DS rỗng ! DList.Head = new_ele; //cập nhật lại nút đầu DS } // nếu chèn nút mới vào đuôi, cần cập nhật lại đuôi mới . if (PredPtr == DList.Tail) DList.Tail = new_ele; - Cài đặt void InsertNodeAfterDLL(DLL &DList, DNodePointer new_ele,DNodePointer PredPtr) { if (PredPtr) { new_ele->Next = PredPtr->next; new_ele->Prev = PredPtr; PredPtr->Next = new_ele; if (new_ele->Next) (new_ele->Next)->Prev = new_ele; } else { new_ele->Next = DList.Head; if (DList.Head) DList.Head->Prev = new_ele; DList.Head = new_ele; } if (PredPtr == DList.Tail) DList.Tail = new_ele; return ; }

Caáu truùc danh saùch lieân keát

III.42

• Thuật toán: Thêm phần tử x vào sau một nút được trỏ bởi con trỏ PredPtr DNodePointer InsertElementAfterDLL (&DList, x, PredPtr) . new_ele = CreateNodeDLL (x); . if (new_ele ≠ NULL) Thêm nút new_ele vào sau nút được trỏ bởi PredPtr; . Trả về trị new_ele; Cài đặt
DNodePointer InsertElementAfterDLL(DLL &DList,ElementType x,DNodePointer PredPtr)

{ DNodePointer new_ele; if ((new_ele = CreateNodeDLL (x))) InsertNodeAfterDLL (DList, new_ele, PredPtr); return (new_ele); }
Tương tự, ta có thao tác thêm một nút (hay phần tử) vào trước một nút được trỏ bởi con trỏ SuccPtr (bài tập). • Thêm một phần tử vào cuối một DSLK đối xứng - Thuật toán: Thêm một nút new_ele vào cuối DSLK DList InsertNodeTailDLL(&DList, new_ele) . Thêm nút new_ele vào sau nút được trỏ bởi DList.Tail. - Cài đặt void InsertNodeTailDLL(DLL &DList, DNodePointer new_ele) { InsertNodeAfterDLL (DList, new_ele, DList.Tail); return ; } • Thuật toán: Thêm phần tử x vào cuối Dlist DNodePointer InsertElementTailDLL (&DList, x) . Thêm phần tử x vào sau nút được trỏ bởi DList.Tail.

- Cài đặt DNodePointer InsertElementTailDLL (DLL &DList, ElementType x) { return (InsertElementAfterDLL (DList, x, DList.Tail)); } • Thêm một phần tử vào đầu một DSLK đối xứng Thuật toán: Thêm một nút new_ele vào đầu DSLK DList InsertNodeHeadDLL(&DList, new_ele) . Thêm nút new_ele vào đầu DList (hay sau nút được trỏ bởi NULL). Cài đặt

-

Caáu truùc danh saùch lieân keát

III.43

void InsertNodeHeadDLL(DLL &DList, DNodePointer new_ele) { InsertNodeAfterDLL (DList, new_ele, NULL); return ; } • Thuật toán: Thêm phần tử x vào đầu Dlist DNodePointer InsertElementHeadDLL (&DList, x) . Thêm phần tử x vào đầu DList (hay sau nút được trỏ bởi NULL).

- Cài đặt DNodePointer InsertElementHeadDLL (DLL &DList, ElementType x) { return (InsertElementAfterDLL (DList, x, NULL)); }

• Tìm kiếm một phần tử trên DSLK đối xứng Thuật toán tìm kiếm trên DSLK đối xứng hoàn toàn tương tự như trên DSLK đơn. Nếu tìm thấy phần tử trên danh sách thì trả về con trỏ chứa địa chỉ nút vừa thấy, nếu không thấy trả về NULL. Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy chưa được sắp: DNodePointer SearchLinearDLL(DList, x) . Chèn nút mới new_ele chứa x vào cuối DList (đóng vai trò lính canh) . CurrPtr = DList.Head; . Trong khi (CurrPtr->Data ≠ x) thực hiện CurrPtr = CurrPtr->Next; . if (CurrPtr ≠ new_ele) Thông báo thấy x; else { Thông báo không thấy x; // thấy giả ! CurrPtr = NULL; } . Xoá nút new_ele; Trả về CurrPtr; - Cài đặt DNodePointer SearchLinearDLL(DLL DList, ElementType x) { DNodePointer new_ele = InsertElementTailDLL(DList, x), CurrPtr = DList.Head; while (SoSánh(CurrPtr->Data, x) != 0) CurrPtr = CurrPtr->Next; if (CurrPtr == new_ele) CurrPtr = NULL; // không thấy RemoveNodeDLL(DList, new_ele); return CurrPtr; -

tại sao ? if (CurrPtr->Prev == NULL) { DList. x) .Tail • . RemoveNodeDLL(DList.Head ==NULL) // xóa trên DS chỉ có 1 nút . CurrPtr = DList. Xoá nút new_ele. bằng cách xuất phát từ DList. .Caáu truùc danh saùch lieân keát III. else { Thông báo không thấy x. while (SoSánh(CurrPtr->Data.Cài đặt DNodePointer SearchLinearOrderDLL(DLL List. } .Head. if (CurrPtr == DList. x). ta có thể tìm kiếm theo chiều ngược lại. CurrPtr = NULL. x) < 0) CurrPtr = CurrPtr->Next.Head = CurrPtr->Next. Chèn nút mới new_ele chứa x vào cuối DList (đóng vai trò lính canh) . Trả về CurrPtr. Trong khi (CurrPtr->Data < x) thực hiện CurrPtr = CurrPtr->Next. if ((CurrPtr == new_ele) || (SoSánh(CurrPtr->Data.Head. if ((CurrPtr ≠ new_ele) and (CurrPtr->Data ≡ x)) Thông báo thấy x. ElementType x) { DNodePointer new_ele = InsertElementTailDLL(DList. new_ele). } Với DSLK đối xứng. CurrPtr. x) > 0))) CurrPtr = NULL. • Xóa một phần tử khỏi DSLK đối xứng * Xóa một nút được trỏ bởi con trỏ CurrPtr DList.Thuật toán tìm kiếm tuyến tính (có lính canh) trên dãy được sắp (tăng): DNodePointer SearchLinearOrderDLL(DList.Head • 1 2 CurrPtr DList.Head) // xóa nút đầu // hay dở hơn. &x) .Thuật toán int RemoveNodeDLL(&DList. .Tail và tìm từ cuối về đầu theo trường con trỏ Prev (bài tập). . return CurrPtr.44 } . if (DList. CurrPtr = DList.

Temp->Data). tại sao ? if (CurrPtr->Next == NULL) { DList. } else {(CurrPtr->Next)->Prev = CurrPtr->Prev.Tail ==NULL) DList. //không cần ? if (DList.Head->Prev = NULL.Head->Prev = NULL. } } . ElementType &x) { if (EmptyDLL(DList)) { cout << “\nDS rỗng !”. .Tail->Next = NULL. } } Gán(x. DNodePointer CurrPtr.Tail = CurrPtr->Prev.Head = NULL. Temp->Data).Caáu truùc danh saùch lieân keát III. Gán(x. return 0.//xóa DS có 1 nút DList. delete CurrPtr. Else DList. (CurrPtr->Prev)->Next = CurrPtr->Next.Tail = NULL. if (DList. DList. } if (CurrPtr->Prev == NULL) //xóa nút đầu { DList.Head ==NULL) // xóa trên DS chỉ có 1 nút DList. } else { if (CurrPtr == DList.Tail) //xóa nút cuối // hay dở hơn.Tail->Next = NULL. delete CurrPtr.Head = CurrPtr->Next. else DList. } else {(CurrPtr->Next)->Prev = CurrPtr->Prev. (CurrPtr->Prev)->Next = CurrPtr->Next. } else { if (CurrPtr->Next == NULL) //xóa nút cuối { DList.Tail = NULL.Cài đặt int RemoveNodeDLL(DLL &DList. return 1.Tail = CurrPtr->Prev.45 DList. // xóa thành công } .

Tail. Xóa nút được trỏ bởi CurrPtr. Tuy vậy.Head. } Việc hủy nút cuối trên DSLK đối xứng có chi phí O(1). Hàng đợi hai đầu sẽ được sử dụng trong các thuật toán tìm kiếm trong lý thuyết đồ thị và trí tuệ nhân tạo. CDList Prev ? Next … c. DList. x) . // không thấy else return RemoveNodeDLL (DList. Nếu kết hợp các tính chất: thêm nút câm. vòng và đối xứng thì ta sẽ được kiểu DSLK “vòng đôi”. . . } * Xóa nút cuối của DSLK đối xứng int RemoveTailDLL(DLL &DList. x) == NULL) return 0. ElementType &x) { return RemoveNodeDLL (DList. x). ta cũng có thể biểu diễn Dequeue bằng DSLK đơn nhưng bất tiện hơn.Cài đặt int RemoveElementDLL(DLL &DList. chứ không phải tốn chi phí O(n) như đối với DSLK đơn. x).Caáu truùc danh saùch lieân keát III. x). DList. Tất nhiên. việc cài đặt một dãy các đối tượng bằng DSLK đối xứng tốn bộ nhớ lớn gấp đôi để lưu trữ hai liên kết và việc cập nhật cũng nặng nề hơn. nếu thấy thì trả về biến con trỏ CurrPtr chỉ đến nút tìm thấy. CurrPtr. . ElementType &x) { return RemoveNodeDLL (DList. } * Xóa một phần tử x khỏi DSLK .46 * Xóa nút đầu của DSLK đối xứng int RemoveHeadDLL(DLL &DList. Tìm x trong DList. Hãy viết các thao tác cơ bản trên kiểu danh sách này (bài tập). Ứng dụng của DSLK đối xứng Ta có thể dùng DSLK đối xứng để cài đặt hàng đợi hai đầu (Dequeue – Double ended queue). ElementType x) { DNodePointer CurrPtr. if ((CurrPtr = SearchLinearDLL(DList.Thuật toán: int RemoveElementDLL(&DList.

Chẳng hạn.Tail. ngoài thành phần dữ liệu (có thể có nhiều trường).Caáu truùc danh saùch lieân keát III. x) InsertTail (Dequeue.47 Hàng đợi hai đầu là danh sách mà việc thêm và hủy đều có thể thực hiện ở hai đầu danh sách.4. x) III. . trên đó có các thao tác chính sau: . x) RemoveTail (Dequeue. còn gồm nhiều thành phần liên kết khác phục vụ cho những mục đích khác nhau.Xem giá trị ở cuối Dequeue mà không hủy nó khỏi Dequeue: Tail(Dequeue) hay chính là Dequeue. chẳng hạn theo hai trường khóa khác nhau nào đó. x) DeQueue (Queue. x) RemoveHead (Dequeue.Lấy ra phần tử ở cuối Dequeue: RemoveTail (Dequeue. x) Tail(Dequeue. Dequeue InsertHead (Dequeue.Head. . x) hay chính là InsertElementHeadDLL(Dequeue.Xem giá trị ở đầu Dequeue mà không hủy nó khỏi Dequeue: Head(Dequeue) hay chính là Dequeue. x) Pop (Stack. x). . . . x). x) hay chính là RemoveTailDLL(Dequeue. Lưu ý rằng tất cả các thao tác này trên Dequeue đều có độ phức tạp hằng O(1).Thêm phần tử x vào đầu hàng đợi hai đầu Dequeue: InsertHead (Dequeue. x) Queue EnQueue (Queue. ta có thể dùng danh sách liên kết động có hai liên kết (không nhất thiết phải đối xứng) để lưu trữ và sắp xếp dãy các mẫu tin theo hai quan hệ thứ tự khác nhau.Kiểm tra xem Dequeue có rỗng không: EmptyDequeue(Dequeue) hay chính là EmptyDLL(Dequeue). x) hay chính là InsertElementTailDLL(Dequeue. x) hay chính là RemoveHeadDLL(Dequeue.Lấy ra phần tử ở đầu Dequeue: RemoveHead (Dequeue. x). x) EmptyQueue (Queue) Front (Queue) Stack Push (Stack. x) EmptyDequeue(Dequeue) Head(Dequeue. x). trên Dequeue còn hỗ trợ các thao tác sau: .4.Thêm phần tử x vào cuối Dequeue: InsertTail (Dequeue. x) EmptyStack (Stack) Top (Stack. . Ta có thể dùng hàng đợi hai đầu để biểu diễn ngăn xếp và hàng đợi như được minh họa trong bảng sau. Ngoài ra. Danh sách đa liên kết Danh sách đa liên kết là danh sách mà mỗi nút của nó.

ngoài trường dữ liệu. Nếu đi theo Link1.48 Ví dụ: Ta muốn lưu danh sách sau. . sao cho theo những trường khóa khác nhau chúng được sắp xếp theo những thứ tự nào đó. còn Link2 hay NextMãSố để sắp giảm các mẫu tin này theo trường MãSố. a. Tên Smith Doe Adams Jones List Ten ? Data ? Link1 Link2 NULL Mã Số 2537 2897 1932 1570 Nút câm Smith 2537 Link1 Link2 Doe 2897 Link1 Link2 Adams 1932 Link1 Link2 Jones 1570 Link1 Link2 NULL Với mỗi mẫu tin. typedef struct {char Ten[MAX_TEN]. ta được danh sách giảm theo thứ tự Mã Số. Ta dùng danh sách đa (trong ví dụ này là hai) liên kết có nút câm để lưu trữ danh sách các mục dữ liệu.Caáu truùc danh saùch lieân keát III. nếu đi theo Link2. Cài đặt cấu trúc dữ liệu cho DS đa liên kết typedef unsigned long So. ta còn lưu thêm hai trường con trỏ: Link1 hay NextTên để sắp tăng các mẫu tin này theo trường Tên. ta được danh sách tăng theo thứ tự Tên.

Ten.Ten0). CurrPtr = PredPtr->NextMaSo. . Ten0) < 0) { PredPtr = CurrPtr. PredPtr. } • Thủ tục thêm một nút vào DS đa liên kết Sau khi thêm mẫu tin mới {Ten0. // Tìm vị trí chèn (tăng) new_ele theo trường NextTen PredPtr = MList.Caáu truùc danh saùch lieân keát III. Gan((new_ele ->Data). while (CurrPtr && SoSanh((CurrPtr ->Data).MaSo. int InsertOrderMulti(MultiPtr MList. else new_ele->NextTen = new_ele->NextMaSo = NULL. Vài thao tác cơ bản trên DS đa liên kết Cấp phát vùng nhớ cho một nút của DS đa liên kết MultiPtr CreateNodeML() { MultiPtr new_ele. new_ele->NextTen = CurrPtr.MaSo > MaSo0) { PredPtr = CurrPtr. while (CurrPtr && (CurrPtr ->Data). } ElementType. } PredPtr->NextMaSo = new_ele.MaSo0). b. MultiPtr NextTen. struct MultiNode { ElementType Data. NextMaSo. Gan ((new_ele->Data). vẫn bảo đảm thứ tự tăng theo Tên và giảm theo MãSố trong DSLK mới thu được. if ((new_ele = new MultiNode) == NULL) cout << “\n Lỗi cấp phát bộ nhớ cho một nút của DS đa LK !". } PredPtr->NextTen = new_ele. CurrPtr = PredPtr->NextTen.Ten. MaSo0} vào DSLK cũ. MultiPtr MList. if ((new_ele = CreateNodeML()) == NULL) return 0. return Temp. CurrPtr. CurrPtr = CurrPtr ->NextMaSo.49 So MaSo. typedef struct MultiNode *MultiPtr. CurrPtr = CurrPtr ->NextTen. char Ten0[MAX_TEN]. // Tìm vị trí chèn (giảm) new_ele theo trường NextMaSo PredPtr = MList. }. So MaSo0) { MultiPtr new_ele.

Caáu truùc danh saùch lieân keát III.5. char Ten0[MAX_TEN]. CurrPtr. // Không thấy nên không xóa được LưuVịTrí = CurrPtr. Một số ứng dụng khác của DSLK a. hầu hết các thao tác cơ bản trên DSLK đều được giữ nguyên.4. delete LưuVịTrí. CurrPtr = CurrPtr ->NextMaSo.Ten. return 1. PredPtr->NextTen = CurrPtr ->NextTen. } if (CurrPtr == NULL) return 0. Với kiểu DS này. while (CurrPtr != LưuVịTrí) { PredPtr = CurrPtr. Ten0) != 0 && (CurrPtr->Data). Riêng thao tác chèn (và xoá) .MaSo != MaSo0) if (SoSanh(CurrPtr ->Data). // Tìm vị trí trùng tên Ten0 theo trường NextTen PredPtr = MList. else { PredPtr = CurrPtr.50 new_ele ->NextMaSo = CurrPtr . while (CurrPtr && SoSanh((CurrPtr ->Data). CurrPtr = MList->NextMaSo. // chưa thấy CurrPtr = CurrPtr ->NextTen. CurrPtr = MList->NextTen. // Đã thấy tên trùng với Ten0 // Tìm vị trí trùng mã số MaSo0 theo trường NextMaSo PredPtr = Mlist. } III. So MaSo0) { MultiPtr LưuVịTrí. } PredPtr->NextMaSo = CurrPtr ->NextMaSo. return 1. DS có thứ tự và DS tổ chức lại Danh sách có thứ tự (Order List) là loại danh sách mà các phần tử của nó được tổ chức lưu trữ thỏa mãn một quan hệ thứ tự nào đó dựa trên các thành phần dữ liệu của chúng nhằm phục vụ cho việc khai thác dữ liệu (chẳng hạn tìm kiếm và cập nhật) được nhanh chóng và thuận lợi hơn. PredPtr. Ten0) > 0) // không thấy CurrPtr = NULL.Ten. } • Thủ tục xóa một nút từ DS đa liên kết int DeleteOrderMulti(MultiPtr Mlist.

new_ele = InsertElementTailLL(OList. x). x). } . return PredPtr. CurrPtr = OList. PredPtr). PredPtr = NULL. ta sẽ chèn x vào sau nút PredPtr. Chèn nút mới new_ele chứa x vào cuối OList (đóng vai trò lính canh) . } RemoveAfterLL(OList. . . nếu không có nút thỏa mãn tính chất này (trường hợp x ≤ dữ liệu nút đầu tiên) ta qui ước cho PredPtr = NULL. .Tail. x) . CurrPtr = CurrPtr->Next. Sau đó. Trả về PredPtr. x) < 0) { PredPtr = CurrPtr. ElementType x) { NodePointer PredPtr = SearchLinearOrderLL(OList.Tail). OList. x).Head.Thuật toán InsertOrderLL (&OList. Thêm phần tử x vào sau nút được trỏ bởi PredPtr.Thuật toán: NodePointer SearchLinearOrderLL(OList. . x) . x. ElementType x) { NodePointer CurrPtr = OList. CurrPtr = CurrPtr->Next.Cài đặt NodePointer SearchLinearOrderLL(LL OList. } • Chèn tăng một phần tử x vào DSLK đơn OList đã sắp tăng . ta xây dựng thuật toán tìm một nút PredPtr xa nhất chứa dữ liệu trên DSLK có thứ tự (giả sử tăng) OList sao cho PredPtr->Data < x. Trong khi (CurrPtr->Data < x) thực hiện { PredPtr = CurrPtr . x). • Trước hết.51 một phần tử mới x vào một DSLK OList đã được sắp cho trước cần được viết lại để thu được danh sách mới vẫn được sắp. } .Caáu truùc danh saùch lieân keát III.Head. while (SoSánh(CurrPtr->Data. Xóa nút new_ele (sau nút OList.Cài đặt int InsertOrderLL (LL &OList. return InsertElementAfterLL (OList. PredPtr = NULL. . PredPtr = SearchLinearOrderLL(OList.

nhưng lại phải trả giá về chi phí bộ nhớ tăng lên ! May mắn cho chúng ta là không gian nhớ giành thêm để lưu trữ số lần khai thác mỗi mục dữ liệu thường không đáng kể so với kích thước rất lớn của dữ liệu trong các bài toán thực tế thường gặp khi lưu trữ các cơ sở dữ liệu lớn. cách tổ chức này tuy làm tăng tốc độ tìm kiếm khi khai thác dữ liệu. Biểu diễn tập hợp bằng DSLK (có nút câm) Như đã biết. trong đó ta không phân biệt thứ tự của các phần tử cũng như không có sự trùng lặp giữa các phần tử trong DSLK. do đó ta không tận dụng được các thao tác hiệu quả trên DSLK được sắp thứ tự. Sau đây. quan hệ thứ tự cũ bị phá vỡ. Khi đó ta có thể dùng DSLK đối xứng và có thứ tự để cài đặt hàng đợi có ưu tiên (được ứng dụng nhiều trong tin học. bằng cách tạo ra quan hệ thứ tự mới dựa trên việc bổ sung thêm một thành dữ liệu cho mỗi nút là số lần mà nó được khai thác với độ ưu tiên nào đó cho thỏa đáng so với độ ưu tiên của các thành phần dữ liệu khác. để giảm chi phí tìm kiếm trong khai thác dữ liệu. Tất nhiên. dẫn đến chi phí tìm kiếm các phần tử khác tăng lên! Một cách tiếp cận khác là tổ chức lại dữ liệu. trong nhiều trường hợp khi khai thác dữ liệu trên một DSLK đã có một quan hệ thứ tự cho trước trên miền dữ liệu chung của các phần tử.Caáu truùc danh saùch lieân keát III. Nhưng với cách tổ chức như thế. A ? 0 2 4 • .52 Danh sách có thứ tự có thể được cài đặt bằng DSLK đối xứng. Dùng DSLK với nút câm cài đặt tập hợp Ví dụ: Ta biểu diễn các tập hợp sau bằng DSLK đơn có nút câm: A = {0. Hạn chế của cách biểu diễn nàylà khi tập hợp con thực sự rất bé nhưng tập phổ dụng lại rất lớn sẽ gây lãng phí bộ nhớ. b. khi đó cần viết lại thao tác chèn tương ứng với cách cài đặt này (bài tập). ta đưa ra một cách tiếp cận khác: dùng DSLK đơn có nút câm để biểu diễn tập hợp. ta có thể biểu diễn tập hợp theo dãy bit bằng cách dùng một mảng các bit để biểu diễn tập hợp con bất kỳ của một tập phổ dụng. ta thấy hiện tượng sau thường xảy ra: có nhiều phần tử (có thể không ở gần đầu danh sách) được khai thác thường xuyên hơn các phần tử khác. chẳng hạn việc quản lý những tiến trình trong các hệ điều hành). Trên thực tế. Khi đó. Đó là vấn đề thường xuyên xảy ra khi cải tiến thuật toán: việc giảm chi phí về thời gian thường tăng chi phí về không gian bộ nhớ và ngược lại! Chọn cách tổ chức kiểu dữ liệu nào sẽ tùy thuộc vào đặc điểm của từng bài toán và mục đích tiết kiệm tài nguyên về khía cạnh cụ thể nào là quan trọng nhất.2. C = A U B. B = {8. ta có thể tổ chức lại danh sách (gọi là danh sách tổ chức lại) bằng cách chèn những phần tử này vào đoạn đầu của danh sách.6}.4}.

while (ptrA) { if (!AddElement(AUB. ElementType x) { NodePointer PredPtr. return SearchLinearLL2(S. ptrB. PredPtr). các quan hệ giữa hai tập hợp. if ((AUB = CreateNode()) == NULL) return 0..Caáu truùc danh saùch lieân keát III. while (ptrB) { if (!IsAMember(A. LL B. ptrB->Data)) if (!AddElement(AUB. ElementType x) { return InsertElementHeadLL2(S. Biểu diễn đa thức rời rạc bằng DSLK (có nút câm) . x). } Tương tự. hiệu đối xứng. ptrB = ptrB->Next. hiệu. } ptrB = B->Next. . x. c. ptrA->Data)) return 0. ptrB->Data)) return 0. } • Phép hợp A U B int Union(LL A.53 B ? C ? 0 2 4 8 6 • 8 6 • • Thủ tục thêm một phần tử vào tập hợp int AddElement(LL S. } • Kiểm tra (hay tìm kiếm) xem một phần tử x có thuộc tập S hay không int IsAMember (LL S. } return 1.. ptrA = A->Next. ptrA = ptrA->Next. LL &AUB) { NodePointer ptrA. ta có thể cài đặt các phép toán tập hợp còn lại như: giao.

LL B. } ElementType. x.. ptrA. * Ví dụ: Xét đa thức P(x) = 5 + x99 = 5 + 0*x + 0*x2 + . • Thủ tục Attach thêm một số hạng x ≡ {x. Với cách biểu diễn này.. n. . int AddPolynome (LL A.. ptrB. + 0*x98 + 1*x99 P ? ? 5 0 1 99 • Mỗi nút có dạng: Hệ số (Coef) Next Số mũ (Expo) • Cài đặt đa thức rời rạc Trường dữ liệu Data của mỗi nút được biểu diễn bởi: typedef double CoefType. CoefType Sum. ElementType x) { return InsertElementTailLL2(P. ElementType TempData. typedef int ExpoType.Coef. typedef struct { CoefType Coef. Trong trường hợp đa thức rời rạc (đa thức có rất ít hệ số khác 0). x). các phép toán trên đa thức sẽ được thực hiện đơn giản và nhanh chóng.Expo} vào cuối đa thức P int Attach(LL P . Một cách tiếp cận khác là dùng DSLK với nút câm để cài đặt đa thức rời rạc. + an xn Ta có thể biểu diễn đa thức trên bằng mảng a[n+1] để lưu các hệ số: a[i] = ai. cài đặt mảng không hiệu qủa vì rất lãng phí bộ nhớ. ∀ i = 0. ExpoType Expo.54 Xét đa thức bậc n (an ≠ 0): P(x) = a0 + a1 x + a2 x2 + . …. } • Thủ tục cộng hai đa thức Giả sử các số hạng của các đa thức được lưu tăng theo số mũ vào DSLK đơn có nút câm. LL &A_PLUS_B) { NodePointer RestList.Caáu truùc danh saùch lieân keát III..

} ptrA = ptrA->Next. } return 1. nhân hai đa thức.55 if ((A_PLUS_B = CreateEmptyLL2 ( )) == NULL) return 0. ptrB = ptrB->Next. ptrA = ptrA->Next. lấy thương và phần dư trong phép chia hai đa thức. * Ví du: Cho ma trận thưa 9 0 0 8 0 7 0 0 0 0 A = 0 0 0 0 0 .Coef. } } RestList = ptrA.Coef != 0) //chỉ lưu các số hạng có hệ số khác 0 { TempData. if (!Attach(A_PLUS_B.Expo) { if (!Attach(A_PLUS_B.Expo = ptrtA->Expo. do phải chứa quá nhiều các phần tử 0 không chứa đựng nhiều thông tin đặc trưng của bài toán.Expo) { if (!Attach(A_PLUS_B. ptrA->Data)) return 0. Biểu diễn ma trận thưa nhờ DSLK Thông thường ta cài đặt ma trận cấp m x n bằng mảng 2 chiều.. ptrA = A->Next. ptrB->Data)) return 0. Do đó.Coef + (ptrB->Data). ptrB = B->Next. .Caáu truùc danh saùch lieân keát III. while (ptrA && ptrB) { if ((ptrA->Data).Expo > (ptrB->Data).Expo < (ptrB->Data).. RestList ->Data)) return 0. ptrB = ptrB->Next. } else if ((ptrA->Data).) ta thường gặp các ma trận thưa (ma trận có rất ít phần tử khác 0) có cấp rất lớn. if (RestList) RestList = ptrB. d. kinh tế.Coef = (ptrA->Data). RestList = RestList ->Next. if (TempData. … được xem như bài tập. } Các thao tác cơ bản khác như: trừ. } else { TempData. // Temp chỉ đến đa thức còn lại có thể chưa hết while (RestList) { if (!Attach(A_PLUS_B. Nhưng trong các bài toán thực tế (chẳng hạn các bài toán trong kết cấu xây dựng. cách cài đặt bởi mảng sẽ không hiệu qủa vì lãng phí bộ nhớ (thậm chí còn không khả thi về tốc độ thực hiện khi phải thao tác và lưu trữ những mảng cực lớn trên bộ nhớ phụ). cần chọn một kiểu cài đặt khác sao cho chỉ cần lưu lại các phần tử khác 0 của ma trận. TempData)) return 0.

} NodeType. Qua đó ta càng thấy rõ tính linh hoạt của kiểu DSLK động. trí tuệ nhân tạo. typedef struct Node {unsigned int Col.. typedef double ElememtType. Đối với ma trận có rất nhiều dòng bằng 0..Caáu truùc danh saùch lieân keát III. Mỗi nút của DSLK có cấu trúc: Col Cột Từ đó. // Kiểu của phần tử của ma trận typedef NodeType *NodePointer. 1 7 • 4 8 • Value Next Giá trị khác 0 . trong đó mỗi hàng A[i] là một DSLK chỉ chứa các phần tử khác 0 của hàng thứ i+1 của ma trận. ta có : A[0] ? ? 1 9 A[1] ? ? A[2] ? ? • A[3] ? ? 1 -1 2 6 4 -8 • • Cài đặt cấu trúc dữ liệu cho ma trận thưa // m là số dòng của ma trận #define m . … Sau đây. nghĩa là DSLK mà mỗi nút có thể lại là một kiểu DSLK nào đó. NodePointer PointerArray[m]. ElementType Value. PointerArray A.56 -1 6 0 -8 0 Một trong những cách cài đặt là dùng mảng 1 chiều A[m]. m-1.. cần phải thay đổi cách cài đặt cho ma trận thưa để việc lưu trữ và các thao tác trên ma trận thưa có hiệu quả hơn bằng cách sử dụng kiểu DSLK tổng quát. ta minh họa một ứng dụng của DSLK tổng quát vào bài toán sắp xếp tôpô sau đây. Kiểu DSLK này còn được ứng dụng trong lý thuyết đồ thị. ∀ i = 0 . NodePointer Next.

không có chu trình (do hai tính chất đầu ở trên).57 e. Điều kiện đồ thị không có chu trình bảo đảm đưa thứ tự bộ phận về được thứ tự tuyến tính. 2 ∝ 4. Nếu x p y thì không thể có y p x (tính không phản xạ) 3. Thông thường. y thỏa quan hệ p : x p y. 6 ∝ 3. 9 ∝ 4. 4 ∝ 8. 7 ∝ 5. một việc nhỏ nào đó cần phải được hoàn thành trước các công việc nhỏ khác. • Bài toán sắp thứ tự tôpô: là đưa thứ tự bộ phận về thứ tự tuyến tính. 5 ∝ 8. Nếu việc v phải xong trước w. * Ví dụ: Sắp xếp tôpô một tập có quan hệ thứ tự bộ phận được biểu diễn bởi đồ thị sau: 1 2 10 9 6 4 8 7 3 5 hoặc được cho bởi dãy các cặp phần tử sau: 1 ∝ 2. 2 ∝ 10. khi quản lý một đề án nào đó. hay sắp xếp các đỉnh của đồ thị thành một hàng sao cho tất cả các mũi tên nối các cung đều hướng sang phải. trên đó có một quan hệ “thứ tự bộ phận” p thỏa 3 tính chất sau: (tính bắc cầu) 1. Sắp xếp tôpô Bài toán sắp xếp tôppô dùng để sắp xếp dãy các đối tượng của tập S gồm hữu hạn phần tử. 1 ∝ 3. một công việc lớn thường được chia thành nhiều công việc nhỏ. ta ký hiệu v ∝ w. Nếu x p y và y p z thì x p z (tính không đối xứng) 2. 9 ∝ 10.Caáu truùc danh saùch lieân keát III. Chẳng hạn. Ta sẽ được (không nhất thiết duy nhất) dãy thứ tự tuyến tính: . Không thể có x p x Ta có thể biểu diễn tập S như thế bằng một đồ thị định hướng. 7 ∝ 9. Sắp xếp tôpô là tổ chức lịch trình thực hiện các công việc sao cho khi thực hiện một công việc nào đó thì mọi việc mà công việc này cần đều phải đã hoàn thành. trong đó mỗi đỉnh là một phần tử của S và có một cung nối từ x đến y nếu x. 4 ∝ 6. Bài toán trên có nhiều ứng dụng trong thực tế. 3 ∝ 5.

trường count dùng để đếm số phần tử đứng trước key. struct Trailer *trail. Lặp lại việc đọc các cặp phần tử của tập S thỏa quan hệ p và chèn nó vào DSLK leaders. Tập còn lại. } LL. typedef struct { LRef head.Caáu truùc danh saùch lieân keát III. } LeaderType. struct Leader *next. typedef struct Leader { KieuPTu key. Thuật toán gồm 3 giai đoạn: . } TrailerType. dãy các địa chỉ này được lưu trong DSLK kiểu TRef.58 7 9 1 2 4 6 3 5 8 10 • Cài đặt cấu trúc dữ liệu: Mỗi phần tử của tập được biểu diễn bởi cấu trúc: typedef int KieuPTu. struct Trailer *next. vẫn có thứ tự bộ phận và ta tiếp tục áp dụng cách chọn này cho đến khi tập trở thành rỗng. trường trail dùng để lưu địa chỉ phần tử đầu của dãy các địa chỉ id của các nút chứa các phần tử đứng sau key. int count. LL leaders. Ta có kết quả của giai đoạn nhập dữ liệu (lấy từ các cặp phần tử trong ví dụ trên) head Key tail 1 2 4 6 10 8 3 5 7 9 . sau khi loại phần tử này. typedef LeaderType *LRef. trong đó: tập các phần tử được lưu trong DSLK leaders kiểu LRef. • Thuật toán Ý tưởng: Bắt đầu chọn một phần tử bất kỳ mà không có phần tử nào đứng trước nó (luôn chọn được vì đồ thị không có chu trình).Giai đoạn 1: giai đoạn nhập. cũng như cập nhật lại các trường đếm số phần tử đứng trước một nút và thêm vào DSLK (kiểu trail) các nút chỉ đến nút đứng sau của một nút. tail. typedef struct Trailer { struct Leader *id. typedef TrailerType *TRef.

Chẳng hạn. nếu q>count == 0 thì chèn q vào đầu danh sách leaders. Dựa vào leaders ở giai đoạn 2. int SoPTu = 0. leaders = CreateEmptyLL2(). chèn vào đầu) DSLK chứa các phần tử mà chúng không có phần tử nào đứng trước (cũng gọi là leaders. TopoSort(leaders. q. SoPTu). • Cài đặt void TopoSortLL() { LL leaders. return. p = TimChen(x. } int NhapDayCapVaoDSach (LL & leaders.SoPTu). y. SoPTu). leaders. while (Nhap1PTu(x)) { Nhap1PTu(y).head 1 7 0 0 ° Danh sách các trails lưu địa chỉ các nút đứng sau 7 - Giai đoạn 3: giai đoạn xuất các dãy con có thứ tự bộ phận. int &SoPTu) { KieuPTu x. TRef t. với ví dụ trên. TachDSCacPTuBatDau(leaders). LRef p. duyệt từng nút q: xuất (lấy ra khỏi leaders) và giảm đi 1 đơn vị cho trường count của mọi nút đứng sau q.//số phần tử của DS leaders NhapDayCapVaoDSach (leaders. được tạo ra theo thứ tự ngược).59 Count 0 Next Trail Id Next Id Next ° 1 2 1 2 ° 2 ° 2 2 0 1 x ? ° ° ° ° ° ° ° ° ° - Giai đoạn 2: tạo (chẳng hạn.Caáu truùc danh saùch lieân keát III. ta có: Leaders. .

if (p->count == 0) //Chen p vao ds q { p->next = q. q = leaders. TRef t. if (q->count == 0) { q->next = leaders. q. while (p != leaders. } LRef TimChen(KieuPTu w. leaders.Caáu truùc danh saùch lieân keát III.tail)->key = w. } return 1. leaders.head. // t trỏ đến nút q chứa phần tử đứng sau phần tử trên nút p q->count ++. SoPTu). // chèn t vào đầu dãy con p->trail của p t->id = q. p = leaders. return 0.head.head. } t = t->next. if (p && q && t) { t->next = p->trail. int &SoPTu) { LRef p.tail) { q = p. p = p->next. } int TachDSCacPTuBatDau(List & leaders) { LRef p. t = q->trail. while (q) { cout << q->key << '\t'.head = q. q = p. q = q->next.head = NULL. } } if (SoPTu) { cout << "\nTập này không được sắp bộ phận !". // chèn q vào đầu DS leaders } } return 1. t = CreateTrailer().head. (leaders. leaders. while (t) { p = t->id. // lưu lính canh ở cuối . } return 1. SoPTu --. } int TopoSort(List &leaders. } else return 0. List &leaders.60 q = TimChen(y. p->trail = t. p->count --. int &SoPTu) { LRef h = leaders.

if (h == leaders. chúng ta đã làm quen với hai dạng đơn giản của cấu trúc dữ liệu động là DSLK và cây nhị phân với nhiều cách biểu diễn và cài đặt.tail. cin >> x. h->count = 0.tail) //khong co phan tu co khoa trong DS leaders { if ((leaders. } Như vậy.tail)->next = NULL. } return h. (leaders. DSLK động cũng như cấu trúc cây nhị phân.Caáu truùc danh saùch lieân keát III. cũng như các thao tác cơ bản và một số ứng dụng của chúng.tail = CreateLeader()) == NULL) return NULL.tail)->trail = NULL. Các phương pháp tìm kiếm và sắp xếp đã được giới thiệu trên cấu trúc mảng tĩnh. . h->trail = NULL. else return 1. h->next = ds. SoPTu ++.61 (leaders. while (h->key != w) h = h->next. if (x==THOAT) return 0. } #define THOAT 0 int Nhap1PTu(KieuPTu &x) { cout << "Nhap 1 ptu:".

2. k-1 * Độ dài đường đi Lx. Mọi nút khác có đúng một nút cha.. … . mức 4 (Cây tam phân. xóa. * Mỗi cung ai = (ni .1. 7 mức 1 mức 2 mức 3 9 nút lá (con của 4). IV. Định nghĩa và các khái niệm cơ bản IV. Các khái niệm khác . nk} sao cho: ai = (ni . 1 2 nút trong 4 8 5 3 6 nút gốc . bậc của nút 8 là 0.6. xóa. ni+1) ∈ A có hai nút ở đầu. có chiều cao là 4) Bậc của nút 1 là 2. bậc của nút 3 là 3. * Nút gốc là nút (duy nhất) không có nút cha. ni+1) ∈ A. ∀ i = 1. n2.Chương IV CẤU TRÚC CÂY Trong cấu trúc dữ liệu động được tổ chức theo kiểu tuần tự như danh sách liên kết. * Độ dài đường đi trung bình của cây là: IV. cha của 5. .1.y từ x đến y là số cung trên đường đi từ x đến y. trong đó có duy nhất một đỉnh đặc biệt gọi là gốc. nút trên ni gọi là cha. và một tập hợp các cạnh có hướng A (A ⊂ NxN) nối các cặp nút với nhau gọi là cung hay nhánh. Để khắc phục các nhược điểm trên nhưng vẫn duy trì các ưu điểm của cấu trúc dữ liệu động trong các thao tác chèn.1. Định nghĩa cây Cây là một tập hợp N các phần tử gọi là nút (hay đỉnh).1. Ký hiệu Lx là độ dài đường đi từ gốc đến x. . Mỗi nút trên cây đều được nối với gốc bằng duy nhất một dãy các cặp cung liên liếp. nút dưới ni+1 gọi là con. ta có thể dùng một cấu trúc dữ liệu động khác là cây tìm kiếm được xét trong chương này để lưu trữ và khai thác dữ liệu hiệu quả hơn. nhưng tốc độ thực hiện trong các thao tác truy cập đến các phần tử của nó hay tìm kiếm thường rất chậm.7. tuy có ưu điểm trong các thao tác chèn. * Một đường đi p từ n1 đến nk là một dãy các đỉnh {n1. bậc của nút 2 là 1.

Caáu truùc caây IV.con) ∈ A * Chiều cao của một cây là mức lớn nhất của các nút lá. Nút lá là nút có bậc bằng không. Cây bậc n gọi là cây n phân. Trong cây có thứ tự khi ta thay đổi vị trí của các cây con thì ta sẽ có một cây mới. địa phương hay cơ quan cũng có dạng cây. ∀ (cha.Mục lục sách theo hệ thống phân loại nào đó. * Bậc của nút là số cây con của nút đó. * Mức của 1 nút (khác nút gốc) là số đỉnh trên đường đi từ gốc đến nút đó. Chẳng hạn: . * Ví dụ: cây có nhiều ứng dụng để biểu diễn các loại dữ liệu trong thực tế.Biểu thức số học: ((a*b)+c)/((d*e)+(f-g)) được biểu diễn dưới dạng cây.Sơ đồ tổ chức của một quốc gia. n là số nút của cây hay số phần tử của N x∈N trong đó. * Nút trong là nút có bậc lớn hơn không. * Mọi nút khác gốc được nối với gốc bằng một đường đi duy nhất bắt đầu từ gốc và kết thúc ở nút đó.2 ( Σ Lx )/n. … * Cây có thứ tự : là cây mà các nút của nó được xếp theo thứ tự nào đó và có để ý đến vị trí (thứ tự) của các nút con. hai cây có thứ tự sau đây được xem là khác nhau: + + * a b c a c b * . Mức của nút gốc bằng 1: Mức(gốc) = 1. . Mỗi nút trong cùng với các con của nó tạo thành cây con. * Bậc của cây là bậc lớn nhất của các nút của cây. Mức(con) = Mức(cha) + 1. Ta biểu diễn: toán tử bởi nút gốc và toán hạng bởi nút lá. Lx là độ dài đường đi từ gốc đến đỉnh x. Chẳng hạn. / + * c * + - a b d e f g . Trong cây không có chu trình.

Do tính chất đơn giản và tầm quan trọng như vậy.phân) ta có thể chuyển về cây nhị phân (xem II.2.6.1. số nút lá tối đa của nó là 2h-1. Cây nhị phân IV. Vài tính chất của cây nhị phân Gọi h và n lần lượt là chiều cao và số phần tử của cây nhị phân. hay nói chính xác hơn số nút tối đa ở mức i là 2i-1. Do đó.2. IV.2. chiều cao của nó: n ≥ h ≥ log2(n+1) IV.2.3 * Cây nhị phân: là cây mà mỗi nút có tối đa 2 nút con (con trái và con phải.2. * Định nghĩa đệ qui: Một cây nhị phân (Binary tree) : + hoặc là rỗng ( phần neo hay trường hợp cơ sở). Định nghĩa: cây nhị phân là cây (có thứ tự) mà số lớn nhất các nút con của các nút là 2.) nghĩa là có thể dùng cây nhị phân để biểu diễn cây tổng quát. Biểu diễn cây nhị phân Ta chọn cấu trúc động để biểu diễn mỗi nút trên cây nhị phân: LChild Data trong đó: LChild.3. RChild lần lượt là các con trỏ chỉ đến nút con bên trái và nút con phải. . IV. trước hết ta khảo sát cây nhị phân. .Số nút ở mức i ≤ 2i-1. Ta còn có thể xem cây nhị phân như là một cấu trúc dữ liệu đệ qui. hay n ≤ 2h –1. Do đó. * Từ một cây có tổng quát (cây n. gọi là cây con bên trái và cây con bên phải (phần đệ qui). LChild hay RChild là con trỏ rỗng nếu không có nút con bên trái hay bên phải. + hoặc là một nút mà nó có 2 cây con nhị phân không giao nhau. ta khai báo kiểu dữ liệu cho một nút của cây nhị phân như sau: . Nút lá có dạng: LChild • Data • RChild RChild Trong ngôn ngữ C hay C++.Caáu truùc caây IV. do phân biệt vị trí các nút nên cây nhị phân được xem là cây có thứ tự ).Số nút tối đa trong cây nhị phân là 2h –1.

2. Trong C++.. cây con bên trái. *RChild.Caáu truùc caây IV.4. Hàm trả về địa chỉ bắt đầu vùng nhớ được cấp phát cho một nút nếu việc cấp phát thành công và trả trị NULL nếu ngược lại. Ta có 3 phương pháp chính duyệt cây nhị phân tùy theo trình tự duyệt 3 phần trên: + Duyệt qua theo thứ tự giữa (LNR) . hàm trên có thể được viết như sau: TreePointer CấpPhát () {TreePointer Tam= new TreeNode.1. return Tam. typedef TreeNode *TreePointer. } IV.4 typedef . ElementType.4. } TreeNode... Dựa vào định nghĩa đệ qui ta chia cây nhị phân ra làm 3 phần: gốc. if (Tam == NULL) cout << “\nLỗi cấp phát vùng nhớ cho một nút mới của cây nhị phân !”. cây con bên phải. Định nghĩa: Duyệt qua cây nhị phân là quét qua mọi nút của cây nhị phân sao cho mỗi nút được xử lý đúng một lần. ta sẽ sử dụng hàm CấpPhát() để cấp phát vùng nhớ cho một nút mới của cây nhị phân.. Duyệt cây nhị phân IV. //Để đơn giản. ta xem Data là trường khóa của dữ liệu struct TN * LChild.2. /* Kiểu mục dữ liệu của nút */ typedef struct TN { ElementType Data. * Ví dụ: Ta biểu diễn biểu thức số học: a * b + c bởi cây nhị phân: + * a b c + * • a • •b • • c • Nút gốc Trong các thuật toán thuộc chương này.

A * B C D LRN: A B C * .Phải) : +Duyệt qua cây con trái theo thứ tự giữa. +Duyệt qua gốc.Phải . IV.5 + Duyệt qua theo thứ tự đầu (NLR) + Duyệt qua theo thứ tự cuối (LRN). * Thuật toán duyệt qua theo thứ tự đầu (NLR: Gốc .Gốc): +Duyệt qua cây con trái theo thứ tự cuối. các thuật toán duyệt qua cây theo kiểu đệ quy là thích hợp. Thuật toán NLR sẽ duyệt cây theo chiều sâu. .2. +Duyệt qua cây con trái theo thứ tự đầu.Phải): +Duyệt qua gốc. * Ví dụ: Biểu diễn biểu thức: A .B * C + D NLR: + . trong đó: L : quét cây con trái của một nút R : quét cây con phải của một nút N : xử lý nút. Do định nghĩa đệ quy của cây nhị phân. +Duyệt qua cây con phải theo thứ tự giữa.2. +Duyệt qua cây con phải thứ tự đầu.4. +Duyệt qua cây con phải theo thứ tự cuối.D + * C tự khác nhau: ( biểu thức trung tố ) ( biểu thức tiền tố ) ( biểu thức hậu tố ) D Với cách biểu diễn một biểu thức số học dưới dạng cây nhị phân.Gốc .B * C + D lên cây nhị phân: + A B Duyệt cây theo các thứ LNR: A . +Duyệt qua gốc.Caáu truùc caây IV. * Thuật toán duyệt qua theo thứ tự cuối (LRN: Trái . dựa trên cách duyệt LRN ta có thể tính giá trị của biểu thức đó (Bài tập). Các thuật toán duyệt cây nhị phân * Thuật toán duyệt qua theo thứ tự giữa (LNR: Trái .Trái .

4. // Khởi tạo ngăn xếp rỗng do { while (p != NULL) { Push(S.Root : con trỏ chỉ đến nút gốc của cây nhị phân Output: . b. typedef NodeStack * StackType.Caáu truùc caây IV. Xử lý (Root). } return. int TiepTuc = 1. Cài đặt thuật toán LNR dưới dạng lặp : /* Input: . Cài đặt thuật toán duyệt qua cây nhị phân LNR a. S = CreateEmptyStack().p). p = Root. } Thuật toán duyệt cây nhị phân theo thứ tự giữa (LNR) có thể viết lại dưới dạng lặp. bằng cách sử dụng một stack để lưu lại địa chỉ các nút gốc trước khi đi đến cây con trái của nó. ta khai báo cấu trúc một nút của stack trên: typedef struct NS { TreePointer Data.Duyệt qua và xử lý mọi nút của cây nhị phân theo thứ tự giữa LNR */ void LNRLap(TreePointer Root) { TreePointer p. chẳng hạn: Xuất(Root->Data). //Xử lý theo yêu cầu cụ thể. StackType S. } .6 IV.p). } NodeStack. p = p->RChild. struct NS * Next. } if (!EmptyStack(S)) // Nếu stack S khác rỗng { Pop(S.3.2. LNRĐệQuy (Root->RChild) .Duyệt qua và xử lý mọi nút của cây nhị phân theo thứ tự giữa LNR */ void LNRĐệQuy (TreePointer Root) { if (Root != NULL) { LNRĐệQuy (Root->LChild). // Đẩy p vào stack S p = p->LChild. Cài đặt thuật toán LNR dưới dạng đệ qui : /* Input: . // Lấy ra phần tử p ở đỉnh stack S XuLy(p). Trước hết.Root : con trỏ chỉ đến nút gốc của cây nhị phân Output: .

khi biểu diễn cây nhị phân.. RChild lần lượt là các con trỏ chỉ đến nút con trái và nút con phải. ta có thể dùng cấu trúc sau: Parent Data LChild RChild trong đó: LChild. Biểu diễn cây n . người ta không chỉ quan tâm đến quan hệ một chiều từ cha đến con mà cả chiều ngược lại: từ con đến cha.2. * Ví dụ: e f a b c d IV. ta xem Data là trường khóa của dữ liệu struct TNP * LChild. Trong ngôn ngữ C hay C++. ElementType.phân bằng mảng có n vùng liên kết chỉ có lợi khi hầu hết các nút của cây có bậc là n. } Với hai trường hợp duyệt cây còn lại (NLR và LRN). } TreeNodeP. ta cũng có thể cài đặt chúng dưới dạng đệ quy và lặp (bài tập). Một cách tổng quát.phân bởi cây nhị phân. Khi đó. ta khai báo kiểu dữ liệu cho một nút của cây nhị phân dạng này như sau: typedef .Caáu truùc caây IV. Parent là con trỏ chỉ đến nút cha. //Để đơn giản. *Rchild. /* Kiểu mục dữ liệu của nút */ typedef struct TNP {ElementType Data. Khi đó n vùng liên kết đều được sử dụng. return ... ta có thể viết lại ba thuật toán duyệt này dưới một dạng lặp duy nhất (bài tập).6. typedef TreeNodeP *TreePointer. Một cách biểu diễn khác của cây nhị phân Trong một số trường hợp.2.5. *Parent. } while (TiepTuc).7 else TiepTuc = 0.. . IV. Phương pháp cài đặt cây n .

nên để mô tả cây n-phân.Caáu truùc caây IV. + Con đầu tiên (trái nhất) của một nút trong T là con trái của nút tương ứng trong T2. Định nghĩa: Cây nhị phân cân bằng hoàn toàn (CBHT) là cây nhị phân mà đối với mỗi nút của nó. ta theo các qui tắc sau: + Nút gốc trong T được biểu diễn tương ứng với nút gốc của T2. số nút của cây con trái chênh lệch không quá 1 so với số nút của cây con phải. Ta gọi các nút con của cùng một nút là anh em với nhau. Cây n-phân T a Q b e j a Q2 b e j P2 c f k k f P c g l d h m i n cây nhị phân T2 tương ứng d g h l i m n IV.2. Để biểu diễn T bằng T2. người ta tìm cách biểu diễn nó thông qua cây nhị phân.2.7.8 nhưng với cây có nhiều nút có bậc nhỏ hơn n sẽ gây nên việc lãng phí bộ nhớ vì có nhiều vùng liên kết không sử dụng tới. + Nút anh em kề phải P của một nút Q trong T tương ứng với một nút P2 trong T2 qua liên kết phải của nút Q2 tương ứng trong T2. * Ví dụ: e .1. Gọi: T là cây n-phân. T2 là cây nhị phân tương ứng với T.7. Xây dựng cây nhị phân cân bằng hoàn toàn IV. Do cây nhị phân là cấu trúc dữ liệu cây cơ bản và đơn giản đã được nghiên cứu.

Root->RChild = TạoCâyCBHT(nr). Nhập1PhầnTử(x). ElementType x. if ((Root =CấpPhát()) == NULL) return NULL.Caáu truùc caây IV. .7. Xây dựng cây nhị phân cân bằng hoàn toàn Xây dựng cây nhị phân cân bằng hoàn toàn có n phần tử: TreePointer TạoCâyCBHT(Nguyên n) { TreePointer Root. Định nghĩa cây nhị phân tìm kiếm (BST) Cây BST là một cây nhị phân có tính chất giá trị khóa ở mỗi nút lớn hơn giá trị khoá của mọi nút thuộc cây con bên trái (nếu có) và nhỏ hơn giá trị khoá của mọi nút thuộc cây con bên phải (nếu có) của nó.2. 55 trên cây BST qua hình dưới đây: 46 25<46 17 55>46 (không thấy 55) 63 . ít được sử dụng trong thực tế. IV. 97.Một cây CBHT rất dễ mất cân bằng sau khi thêm hay hủy các nút trên cây. nl = n/2. Ta biểu diễn quá trình tìm kiếm 2 phần tử 25. * Ví dụ: Xét cây BST sau đây lưu các giá trị: 46. việc chi phí cân bằng lại cây rất lớn vì phải thao tác lại trên toàn bộ cây. nr. Cây nhị phân tìm kiếm (BST) IV. Nguyên nl. 17. } * Nhận xét: .Một cây CBHT có n nút sẽ có chiều cao bé nhất h ≈ log2n. Do đó cây CBHT có cấu trúc kém ổn định. nr = n-nl-1.9 f a b c d IV.2. return Root. Root->LChild = TạoCâyCBHT(nl).3. Root->Data = x.2. 63.1. 25.3. if (n<=0) return NULL.

TreePointer &Parent) { TreePointer LocPtr = Root. IV. Output: .Item: giá trị khóa của phần tử cần tìm . Output: .Trả trị NULL nếu ngược lại */ TreePointer TìmBSTĐệQuy (TreePointer Root. else return TìmBSTĐệQuy (Root->LChild.2. Parent = NULL. ElementType Item) { if (Root) {if (Item== Root->Data) return Root.2.2. IV.2. IV. } else return(NULL).10 2 25>17 (thấy 25) 25 97 Với loại cấu trúc dữ liệu động danh sách liên kết.Root: con trỏ chỉ đến nút gốc của cây BST. ElementType Item. else if (Item > Root->Data) return TìmBSTĐệQuy (Root>RChild.Caáu truùc caây IV.Item: giá trị khóa của phần tử cần tìm . Tìm kiếm một phần tử trên cây BST (Thuật toán tìm kiếm nhị phân sau đây tương tự phép tìm kiếm nhị phân trên mảng).Item). } * Thủ tục được viết dưới dạng đệ qui thích hợp với lối tư duy tự nhiên của giải thuật và định nghĩa đệ qui của cây nhị phân. while (LocPtr != NULL) if (Item==LocPtr->Data) return (LocPtr). .Item).Root: con trỏ chỉ đến nút gốc của cây BST. .Trả về con trỏ LocPtr chỉ đến 1 nút trên cây BST chứa Item và con trỏ Parent chỉ đến nút cha của nút chứa Item đó nếu tìm thấy Item trên cây BST . Song trong trường hợp này thủ tục viết dưới dạng lặp lại tỏ ra hiệu quả hơn. Nhưng với loại cấu trúc dữ liệu động cây BST thì việc thể hiện ý tưởng này là đơn giản.3. Thuật toán tìm kiếm dạng đệ qui: /* Input: . . ta rất khó áp dụng hiệu qủa ý tưởng tìm kiếm nhị phân trên mảng.3. Thuật toán tìm kiếm dạng lặp: /* Input: .3.Trả về con trỏ LocPtr chỉ đến 1 nút trên cây BST chứa Item nếu tìm thấy Item trên cây BST .Trả trị NULL nếu ngược lại */ TreePointer TìmBSTLặp(TreePointer Root.1.

Trả trị -1 nếu Item đã có trên cây . IV.3. * Ví dụ: Giả sử ta đã có cây BST (với các nút có khóa khác nhau): O E C M P T U Ta cần thêm phần tử ‘R’: O E C (R > O) (R<T) T M P U (R>P) R Parent Yêu cầu “vào – ra” của thao tác chèn: /* Input: . if (Item > LocPtr->Data) LocPtr = LocPtr->RChild. } Với cấu trúc cây.11 else {Parent = LocPtr. xây dựng cây BST Việc chèn thêm một phần tử Item vào cây BST cần phải thỏa ràng buộc trong định nghĩa cây BST.3. nếu có thì khỏi chèn (do trên cây BST ta chỉ chứa những phần tử có khóa khác nhau). ta cần tìm khóa của Item có trong cây BST hay không. khi chấm dứt thao tác tìm kiếm thì ta cũng biết được vị trí chèn (ở nút lá).Item: giá trị dữ liệu của nút cần chèn Output: . Trước khi chèn Item. việc tìm kiếm theo khóa sẽ nhanh hơn nhiều so với cấu trúc danh sách liên kết. nếu ngược lại. else LocPtr = LocPtr->LChild. Chi phí tìm kiếm (độ phức tạp) trung bình trên cây nhị phân có n nút khoảng log2 n. } return(NULL).Trả trị 1 và con trỏ Root chỉ đến nút gốc mới của cây BST nếu chèn được .Root: con trỏ chỉ đến nút gốc của cây BST. Chèn một phần tử vào cây BST.Caáu truùc caây IV. .

Caáu truùc caây

IV.12

- Trả trị 0 nếu gặp lỗi cấp phát bộ nhớ cho một nút mới của cây */
IV.3.3.1. Thao tác chèn một nút Item vào cây BST (dạng lặp):

int ChènBSTLặp(TreePointer &Root, ElementType Item) { TreePointer LocPtr, Parent; if (TìmBSTLặp(Root, Item, Parent)) { cout << “\nĐã có phần tử “<< Item << “ trong cây !“ ; return -1; } else { if ((LocPtr=CấpPhát ())==NULL) return 0; LocPtr->Data = Item; LocPtr->LChild = NULL; LocPtr->RChild = NULL; if (Parent == NULL) Root = LocPtr; // cây rỗng else if (Item < Parent->Data) Parent->LChild = LocPtr; else Parent->RChild = LocPtr; return 1; } }
IV.3.3.2. Thủ tục chèn một nút Item vào cây BST (dạng đệ qui):

int ChènBSTĐệQui(TreePointer &Root, ElementType Item) { TreePointer LocPtr; if (Root == (TreePointer) NULL) // chèn nút vào cây rỗng { if ((Root = CấpPhát ()) == NULL) return 0; Root ->Data = Item; Root ->LChild = NULL; Root ->RChild = NULL; } else if (Item < Root->Data) ChènBSTĐệQui (Root->LChild,Item); else if (Item > Root->Data) ChènBSTĐệQui(Root->RChild,Item); else { cout << “\nĐã có phần tử “<< Item << “ trong cây”; return -1; } return 1; }
IV.3.3.3. Xây dựng cây BST Ta có thể xây dựng cây BST bằng cách lặp lại thao tác chèn một phần tử vào cây BST trên đây, xuất phát từ cây rỗng. Hàm TạoCâyBST(Root) sau đây trả về trị 0 nếu gặp lỗi cấp phát vùng nhớ cho một nút mới của cây Root và trả về trị 1 nếu việc chèn các nút vào cây thành công (không chèn các nút có khóa đã trùng với khóa của nút đã chèn).

Caáu truùc caây

IV.13

int TạoCâyBST(PointerType &Root) { ElementType Item; Root = NULL; while (CònLấyDữLiệu(Item)) if (!ChènBSTLặp(Root, Item)) return 0; return 1; } IV.3.4. Phương pháp sắp xếp bằng cây BST Ta nhận xét rằng sau khi duyệt một cây BST theo thứ tự giữa LNR thì ta sẽ thu được một dãy tăng theo khóa. Từ đó, ta có phương pháp sắp xếp dựa trên cây BST như sau. Giả sử ta cần sắp xếp dãy X các phần tử. * Giải thuật BSTSort: - Bước 1: Đưa lần lượt mọi phần tử của dãy X lên cây BST. - Bước 2: Khởi tạo lại dãy rỗng X. Duyệt cây BST theo thứ tự giữa (LNR), trong đó thao tác XửLý(Nút) lưu Nút->Data vào phần tử tiếp theo của dãy
X.

* Ví dụ: Giả sử cần sắp xếp một dãy gồm n phần tử được lưu trong mảng X. Khi đó ta có thuật toán sau: 1.Khởi tạo cây BST rỗng. 2.for (i = 0; i< n; i++) Chèn X[i] vào cây BST; 3.Đặt lại i = 0; 4.Duyệt qua theo thứ tự giữa LNR, việc XửLý(Nút) một nút khi duyệt qua cây là: - Gán X[i] ← Nút->Data; - Tăng i lên 1; IV.3.5. Xóa một phần tử khỏi cây BST, hủy cây nhị phân Giả sử ta cần xóa một nút (trên cây BST) được trỏ bởi x. Việc xoá một phần tử trên cây BST cũng cần phải thoả các ràng buộc về cây BST, nhưng việc xóa phức tạp hơn so với chèn. Ta phân biệt 3 trường hợp : x trỏ đến nút lá, x trỏ đến nút chỉ có một con, x trỏ đến nút có hai con. a). Xoá nút lá: C B D x Xoá nút lá D B C NULL

- Đặt con trỏ phải (hay trái) của nút cha của x thành NULL - Giải tỏa nút D

Caáu truùc caây

IV.14

b). Xoá nút có một nút con: - Đặt con trỏ phải (hoặc trái) của nút cha của nút cần xóa trỏ đến nút con khác rỗng của nút cần xóa - Giải tỏa nút cần xóa Giả sử ta cần xóa nút trong E có một nút con: C x C Xoá nút E B E có 1 nút con B D D Kết hợp hai trường hợp trên thành một trường hợp: x trỏ đến nút có nhiều nhất một cây con khác rỗng. Gọi: + x chỉ đến nút cần xóa + SubTree chỉ đến cây con (khác rỗng , nếu có) của x + Parent chỉ đến nút cha của nút được trỏ bởi x (nếu x chỉ đến gốc thì Parent=NULL). Ta có giải thuật xóa cho trường hợp này là: SubTree = x->LChild; if (SubTree == NULL ) SubTree = x->RChild; //SubTree là cây con khác rỗng (nếu có) của x if (Parent == NULL) Root = SubTree; // xoá nút gốc else if (Parent->LChild == x) Parent->LChild = SubTree ; else Parent->RChild = SubTree; delete x; c). Xoá nút có hai nút con: Giả sử ta cần xoá nút E có 2 nút con của cây BST sau : C x B E D (Nút kế tiếp E I theo thứ tự giữa) K L

J Đưa về 1 trong 2 trường hợp đầu bằng cách sau: Thay trị của nút mà x trỏ đến bởi trị của nút kế tiếp theo thứ tự giữa (nút kế tiếp là nút cực trái xa nhất theo nhánh con phải của x, hay là nút nhỏ nhất (tất nhiên là theo trường khóa) trong số những nút lớn hơn x->Data). Sau đó xoá nút kế tiếp này (nút kế tiếp này sẽ là nút có tối đa 1 nút con ). C

SubTree.Root: con trỏ chỉ đến nút gốc của cây BST.Item.SubTree: trỏ đến cây con của x.Parent.Parent)) ==NULL) return 0.//không thấy Item else { if ((x->LChild != NULL) && (x->RChild != NULL)) // nút có 2 con { xSucc = x->RChild.Parent: trỏ đến cha của x hay xSucc . Gọi: .xSucc: phần tử kế tiếp của x theo thứ tự giữa (nếu x có 2 con) . ElementType Item) { TreePointer x.Trả trị 0 nếu ngược lại */ int XóaBST (TreePointer &Root. Trong thủ tục này có dùng đến thủ tục TìmBSTLặp. if ((x = TìmBSTLặp(Root.Caáu truùc caây IV. Parent = x.x: trỏ đến nút chứa Item . . .Item: giá trị dữ liệu của nút cần xóa Output: .15 x B D (Xóa nút I) I J E (Thay E bởi I) K L C x B D J I K L * Sau đây ta xây dựng thủ tục XóaBST để xóa một nút Item trong một cây BST. /* Input: . xSucc.Trả trị 1 và con trỏ Root chỉ đến nút gốc mới của cây BST nếu tìm thấy nút chứa Item và xoá được . Thủ tục XoáBST tìm nút có khóa Item và xoá nó khỏi cây BST.

Nhưng cây CBHT có cấu trúc kém ổn định trong các thao tác cập nhật cây.4.16 while (xSucc->LChild != NULL) { Parent = xSucc. if (SubTree == NULL) SubTree = x->RChild.Caáu truùc caây IV. } } Ta có thể hủy toàn bộ cây BST bằng cách sử dụng ý tưởng duyệt cây theo thứ tự cuối LRN: hủy cây con trái. // xoá nút gốc else if (Parent->LChild == x) Parent->LChild = SubTree. } //đã đưa nút có 2 con về nút có tối đa 1 con SubTree = x->LChild. hủy cây con phải rồi mới hủy nút gốc. nhưng việc cân bằng lại chỉ xảy ra ở phạm vi cục bộ đồng thời chi phí cho việc tìm kiếm vẫn dạt ở mức O(log2n). delete x. xSucc = xSucc->LChild. nên nó ít được sử dụng trong thực tế. phép tìm kiếm một phần tử trên nó sẽ thực hiện rất nhanh: trong trường hợp xấu nhất. if (Parent == NULL) Root = SubTree. x = xSucc.4. else Parent->RChild = SubTree. Đó là cây nhị phân tìm kiếm cân bằng. người ta tận dụng ý tưởng cây CBHT để xây dựng một cây nhị phân tìm kiếm có trạng thái cân bằng yếu hơn. ta chỉ cần thực hiện log2n phép so sánh. return 1. } x->Data = xSucc->Data. IV. void HủyCâyNhịPhân (PointerType &Root) { if (Root) { HủyCâyNhịPhân (Root->LChild). Cây nhị phân tìm kiếm cân bằng Trên cây nhị phân tìm kiếm BST có n phần tử mà là cây CBHT (cân bằng hoàn toàn). Định nghĩa . delete Root. } return . HủyCâyNhịPhân (Root->RChild).1. Vì thế. } IV.

Để chứng minh bất đẳng thức thứ hai ở bên phải.17 Cây nhị phân tìm kiếm gọi là cây nhị phân tìm kiếm cân bằng (gọi tắt là cây cân bằng hay cây AVL do 3 tác giả Adelson-Velskii-Landis đưa ra vào năm 1962) nếu tại mỗi nút của nó. độ cao của cây con trái và độ cao của cây con phải chênh lệch không quá 1. một cây có chiều cao là h -1. Do đó: N(h) = 1 + N(h –1) + N(h –2). Khi đó: F(h) = F(h –1) + F(h –2).4404 * log2(n+2) –0. ∀ h >1 F(0) = 1. Chẳng hạn cây nhị phân tìm kiếm trong ví dụ sau là cân bằng nhưng không phải là cân bằng hoàn toàn: * Ví dụ: (cây nhị phân tìm kiếm cân bằng nhưng không cân bằng hoàn toàn) O E C M T Cây cân bằng AVL vẫn thực hiện việc tìm kiếm nhanh tương đương cây (nhị phân tìm kiếm) cân bằng hoàn toàn và vẫn có cấu trúc ổn định hơn hẳn cây cân bằng hoàn toàn mà nó được thể hiện qua các thao tác cơ bản sẽ được trình bày trong các phần tiếp theo. cây con kia có chiều cao là h -2.4.Caáu truùc caây IV. Chứng minh: Bất đẳng thức thứ nhất ở bên trái có được do tính chất của cây nhị phân (phần II.3277 Cây AVL là tối ưu (trong trường hợp tốt nhất. Chiều cao của cây cân bằng * Định lý (AVL): Gọi hb(n) là độ cao của cây AVL có n nút. gốc của cây T(h) sẽ có hai cây con cũng có số nút ít nhất. Một cây AVL không bao giờ cao quá 45% cây cân bằng hoàn toàn tương ứng của nó.2). N(1) = 1. nhưng điều ngược lại không đúng. F(1) = 2. một cây nhị phân tìm kiếm cân bằng hoàn toàn là cây cân bằng. ∀ h >1 N(0) = 0. nó có chiều cao bé nhất) khi nó là cây cân bằng hoàn toàn có n nút với: n = 2k-1. khi đó: log2(n+1) ≤ hb(n) < 1. ta được: n + 1 ≥ N(h) + 1 = F(h) = (r1h+2 – r2h+2) / 5 > (r1h+2 – 1) / 5 . Rõ ràng. Khi h > 1. IV.2. Đặt F(h) = N(h) + 1. Ta có: N(0) = 0 ứng với cây rỗng T(0) và N(1) = 1 ứng với cây chỉ có 1 nút T(1). Giải hệ thức truy hồi trên (bằng cách nào ? Bài tập). ta gọi N(h) là số nút ít nhất của cây AVL T(h) có chiều cao h.

//Ở đây ta xem Data là trường khóa của dữ liệu int Balfactor. nên ta chỉ xét trường hợp cây AVL lệch trái. EH = 0. Do các trường hợp cây lệch trái và phải tương ứng là đối xứng nhau. đối với cây cân bằng.Caáu truùc caây IV. IV.5 ) /2 ∈ (-1..2 ≈ 1. 1) => h +2 < log r1 (1+ 5 (n + 1)) < log r1 ( 5 (n + 2)) < logr1 (n + 2) + log r1 ( 5 ) h < log2 (n + 2)/ log2 (r1) + log r1 ( 5 ) .18 với: r1 = (1+ 5 ) /2. Trường hợp a: cây con T1 lệch trái T .32772 Vậy một cây AVL có n nút sẽ có chiều cao tối đa (trong trường hợp xấu nhất) là O(log2n). ta còn có thêm thao tác cơ bản là cân bằng lại cây AVL trong trường hợp thêm hoặc hủy một nút của nó.44042 log2 (n + 2) – 0.. LH = -1. Việc thêm hay hủy một nút trên cây AVL có thể làm cây tăng hay giảm chiều cao.3. Ký hiệu: hL(p) hay hL là chiều cao cây con trái (của p). ngoài các thuộc tính thông thường như cây nhị phân. khi đó ta cần phải cân bằng lại cây. độ lệch giữa chiều cao cây con phải và trái sẽ là 2. ta cần lưu thêm thông tin về chỉ số cân bằng trong cấu trúc của một nút: typedef .. //Chỉ số cân bằng struct AVLTN * Lchild. Chỉ số cân bằng và việc cân bằng lại cây AVL * Định nghĩa: Chỉ số cân bằng (CSCB) của một nút p là hiệu của chiều cao cây con phải và cây con trái của nó. ElementType. hR(p) hay hR là chiều cao cây con phải (của p). } AVLTreeNode. CSCB(p) = EH hR(p) =hL(p):2 cây con cao bằng nhau CSCB(p) = RH hR(p) > hL(p) : cây lệch phải CSCB(p) = LH hR(p) < hL(p) : cây lệch trái Với mỗi nút của cây AVL. Để giảm tối đa chi phí cân bằng lại cây. ta chỉ cân bằng lại cây AVL ở phạm vi cục bộ.. Khi đó. RH = 1. Các trường hợp mất cân bằng Ngoài các thao tác thêm và hủy.4. /* Kiểu mục dữ liệu của nút */ typedef struct AVLTN { ElementType Data. *Rchild. typedef AVLTreeNode *AVLTree. r2 = (1 .

.19 T1 h-1 L h L1 R1 h-1 R R Trường hợp b: cây con T1 lệch phải T T1 h-1 L h-1 L1 R1 h R R Trường hợp c: cây con T1 không lệch T T1 h-1 L h L1 R1 h R Việc cân bằng lại trong trường hợp b (cây con T1 lệch phải) là phức tạp nhất.Caáu truùc caây IV.

20 Trường hợp a: cây con T1 lệch trái T T1 h-1 L h L1 R1 h-1 R R Cân bằng lại bằng phép quay đơn Left-Left. ta được cây T1 không lệch: T1 T h L1 h-1 R1 R h-1 h+1 Trường hợp c: cây con T1 không lệch T T1 h-1 L h L1 R1 h R R Cân bằng lại bằng phép quay đơn Left-Left (khi đó ta được cây T1 lệch phải): T1 T .Caáu truùc caây IV.

nhưng tất nhiên vẫn cân bằng) và có chiều cao là h+2 chỉ trong trường hợp c. cây T mới (là . biểu diễn lại cây R1 = <L2. ta được cây T2 không lệch như sau: T2 T1 T h+1 R h-1 h-1 L1 L2 R2 * Nhận xét: . còn trong hai trường hợp a và b. T2. nó vẫn lệch (lệch phải. Nhưng sau khi cân bằng lại cây T. cây T lệch (và mất cân bằng) và có chiều cao là h+2 trong cả 3 trường hợp. R2> như sau: T T1 h-1 L T2 R h-1 L1 R1 h L2 R2 Cân bằng lại bằng phép quay kép Left – Right.21 h L1 h R1 R h-1 h+2 Trường hợp b: cây con T1 lệch phải.Caáu truùc caây IV.Trước khi cân bằng lại.

. } //Phép quay đơn Right – Right void RotateRR (AVLTree &T) { AVLTree T1 = T->Rchild. switch (T1->Balfactor) {case LH: T->Balfactor = EH. T->Rchild = T1->Lchild. Các thao tác cân bằng lại trong mọi trường hợp đều có độ phức tạp là O(1). return . T1->Balfactor = LH. T2->Rchild = T. break.Caáu truùc caây IV. T1->Rchild = T. case EH: T->Balfactor = RH. Sau đây là phần cài đặt các phép quay đơn và kép cho cây T mất cân bằng trong hai trường hợp nó bị lệch trái và lệch phải. T1->Balfactor = EH. T->Lchild = T1->Rchild. T1->Rchild = T2->Lchild. //Phép quay đơn Left – Left void RotateLL(AVLTree &T) { AVLTree T1 = T->Lchild. } //Phép quay kép Left – Right void RotateLR(AVLTree &T) { AVLTree T1 = T->Lchild. return . case EH: T->Balfactor = LH. break. T2->Lchild = T1. switch (T1->Balfactor) {case RH: T->Balfactor = EH. } T = T1.22 - T1 hay T2 tương ứng với trường hợp a hay b) không lệch và có chiều cao là h+1. T1->Balfactor = EH. } T = T1. T1->Lchild = T. T1->Balfactor = RH. break. T->Lchild = T2->Rchild. break. T2 = T1->Rchild.

switch (T2->Balfactor) {case LH: T->Balfactor = EH. return 2. return . } T2->Balfactor = EH. return 1. break. break.//cây T không giảm độ cao và bị lệch phải case RH : RotateLR(T). break. return . } . case EH: T->Balfactor = EH. case RH: T->Balfactor = EH. break. } T2->Balfactor = EH. T1->Balfactor = LH. T1->Balfactor = EH. T2 = T1->Lchild. } //Phép quay kép Right-Left void RotateRL(AVLTree &T) { AVLTree T1 = T->RLchild. T2->Lchild = T. T1->Balfactor = EH. } return 0. T = T2. case RH: T->Balfactor = LH. //Cân bằng lại khi cây bị lệch trái int LeftBalance(AVLTree &T) { AVLTree T1 = T->Lchild. break. } Sau đây là thao tác cân bằng lại khi cây bị lệch trái hay lệch phải. switch (T1->Balfactor) { case LH : RotateLL(T). T1->Balfactor = EH. T->Rchild = T2->Lchild. //cây T giảm độ cao và không bị lệch case EH : RotateLL(T). T1->Lchild = T2->Rchild.Caáu truùc caây IV. T1->Balfactor = RH.23 switch (T2->Balfactor) {case LH: T->Balfactor = RH. T = T2. T2->Rchild = T1. break. return 2. case EH: T->Balfactor = EH. T1->Balfactor = EH.

Nếu có. if (Tam == NULL) cout << “\nKhông đủ bộ nhớ cấp phát cho một nút của cây AVL !”. return 1.x). ta phải lần ngược lên gốc để kiểm tra xem có nút nào bị mất cân bằng hay không. 0. (Việc cân bằng lại chỉ cần thực hiện một lần tại nơi mất cân bằng) Hàm chèn trả về các trị –1.T lệch trái case EH: T->Balfactor=LH. return Tam. //cây T bị lệch trái case RH : RotateRR(T).Caáu truùc caây IV.4.4. //Đã có nút trên cây if (T-> Data > x) { Kqủa=ChènAVL(T->Lchild. Khi chèn một nút vào cây AVL.T không lệch . return 1. return 2.//trước khi chèn.//trước khi chèn. ta cần sử dụng hàm cấp phát bộ nhớ cho một nút của cây AVL. switch (T1->Balfactor) { case LH : RotateRL(T). Tuy nhiên. nếu chiều cao của cây thay đổi tại vị trí thêm vào. } return 0. 1 hay 2 tương ứng khi: không đủ bộ nhớ cấp phát cho một nút của cây hoặc gặp nút đã có trên cây hoặc thành công hoặc chiều cao của cây bị tăng sau khi chèn. AVLTree CấpPhátAVL() { AVLTree Tam= new AVLTreeNode. } IV. ta chỉ phải cân bằng lại ở nút này. ElementType x) { int Kquả. sau khi chèn xong. if (T) { if (T->Data == x) return 0.24 //Cân bằng lại khi cây bị lệch phải int RightBalance(AVLTree &T) { AVLTree T1 = T->Rchild. Chèn một phần tử vào cây AVL Việc chèn một phần tử vào cây AVL xảy ra tương tự như trên cây nhị phân tìm kiếm. //cây T không bị lệch case EH : RotateRR(T). return 2.return 2.//chèn x vào cây con trái của T if (Kqủa < 2) return Kqủa. switch (T->Balfactor) { case LH: LeftBalance(T). } int ChènAVL( AVLTree &T.

Việc cân bằng lại cây có thể xảy ra phản ứng dây chuyền. nếu cây AVL bị mất cân bằng.x).x). if (T== NULL) return 0. ta phải cân bằng lại cây.5. ElementType x) { int Kqủa. Xóa một phần tử khỏi cây AVL Việc xóa một phần tử ra khỏi cây AVL diễn ra tương tự như đối với cây nhị phân tìm kiếm. //trước khi chèn.T lệch phải } } else // T-> Data < x { Kqủa=ChènAVL(T->Rchild. // tìm và xóa x trên cây con trái của T if (Kqủa < 2) return Kqủa.T không lệch case RH : return RightBalance(T).T không lệch case RH : RightBalance(T). T->Lchild = T->Rchild = NULL.T lệch phải } } } else //T==NULL { if ((T = CấpPhátAVL()) == NULL) return –1.T lệch trái case EH:T->Balfactor=RH.//chèn x vào con phải của T if (Kqủa < 2) return Kqủa.25 caseRH:T->Balfactor=EH. chỉ khác là sau khi hủy. chiều cao của cây bị giảm. return 2. switch (T->Balfactor) { case LH: T->Balfactor = EH. return 2. int XóaAVL(AVLTree &T.//trước khi chèn. return 1. //trước khi chèn. //trước khi xóa. return 1. Hàm XóaAVL sẽ trả về trị 1 hoặc 0 hoặc 2 tùy theo việc hủy thành công hoặc không có x trên cây hoặc sau khi hủy. //trước khi xóa.4. return 1. T->Balfactor = EH. //thành công và chiều cao của cây tăng } } IV.return 2.Caáu truùc caây IV.//trước khi chèn. return 1. //Thiếu bộ nhớ T->Data = x.T lệch phải } } . // không có x trên cây if (T-> Data > x) { Kqủa = XoáAVL(T->Lchild. switch (T->Balfactor) { case LH : T->Balfactor = EH.//trước khi xóa.T lệch trái case EH : T->Balfactor = RH.

break. // tìm phần tử thay p để xóa trên nhánh phải của T if (Kqủa == 2) switch (T->Balfactor) { case LH : Kquả = LeftBalnce(T). Kqủa = 2. } else if (T->Rchild == NULL) { T = T->Lchild. Kquả = 1. if (T->Lchild == NULL) { T = T->Rchild. case RH: T->Balfactor=EH. if (Kqủa < 2) return Kquả. case EH : q->Balfactor = RH.T lệch trái { case LH : return LeftBalance(T). AVLTree &q) { int Kqủa.T->Rchild). if (q->Lchild) { Kqủa = TìmPhầnTửThayThế(p. } } delete p. Kqủa = 2. } else // T có cả 2 con { Kqủa = TìmPhầnTửThayThế(p. return 1. Kquả = 2. break. q->Lchild). return 2.26 else if (T-> Data < x) { Kqủa = XoáAVL(T->Rchild.T lệch phải } } else //T->Data== x { AVLTree p = T.Caáu truùc caây IV. case EH: T->Balfactor=LH. } } // Tìm phần tử thay thế int TìmPhầnTửThayThế(AVLTree &p. break. // tìm và xóa x trên cây con phải của T if (Kqủa < 2) return Kqủa.T không lệch case RH : T->Balfactor = EH. //trước khi xóa. switch (T->Balfactor) //trước khi xóa. return 2. case EH : T->Balfactor = LH. switch (q->Balfactor) { case LH : q->Balfactor = EH. case RH : return RightBalance(q). return 1.//trước khi xóa. } .x). return Kquả.

trung bình: 2 lần thêm vào cây thì cần 1 lần cân bằng lại. } } * Nhận xét: .Thao tác thêm một nút có độ phức tạp O(1). . . 5 lần hủy thì cần 1 lần cân bằng lại.Caáu truùc caây IV. p = q.Một cây cân bằng AVL không bao giờ cao hơn 45% cây cân bằng hoàn toàn tương ứng.Thao tác huỷ một nút có độ phức tạp O(h) .27 else } { p->Data = q->Data. .Với cây cân bằng.Việc hủy một nút có thể phải cân bằng dây chuyền các nút từ gốc đến phần tử bị hủy. return 2. nhưng việc cân bằng lại đơn giản hơn nhiều. q = q->Rchild.Độ dài đường tìm kiếm trung bình trong cây AVL gần bằng cây cân bằng hoàn toàn (log2 n). trong khi thêm vào 1 nút chỉ cần 1 lần cân bằng cục bộ. . .

C3=cấp 3.Kiểm tra.Tên nhân viên .Lương căn bản : chuỗi 10 ký tự : chuỗi 30 ký tự : 1 ký tự (M = Married. CH = cao học. . DH = đại học.Trình độ văn hoá .Phát triển và tổng hợp các kết quả lý thuyết nhằm chuẩn bị cho học viên làm quen với quá trình giải quyết hoàn chỉnh một bài toán không tầm thường nào đó.Số ngày nghỉ không phép trong tháng : số ≤ 28 . Có thể kết hợp nhiều bài tập (*) có liên quan hoặc bổ sung thêm các ứng dụng thực tế để hình thành tiểu luận của môn học.BÀI TẬP “CẤU TRÚC DỮ LIỆU & GIẢI THUẬT 1” Mục đích các bài tập: . .Lương thực lĩnh trong tháng : số ≤ 2 000 000 . TS = tiến sĩ) : số ≤ 1 000 000 * Chấm công nhân viên: .Số con . S = Single) : số nguyên ≤ 20 : chuỗi 2 ký tự (C1 = cấp 1.Số ngày nghỉ có phép trong tháng : số ≤ 28 .Số ngày làm thêm trong tháng : số ≤ 28 . K = Kém) .Kết quả công việc : chuỗi 2 ký tự (T = tốt. Phần in đậm có gạch chân là yêu cầu tối thiểu học viên cần thực hiện trong giờ thực hành.Rèn luyện kỹ năng lập trình và vận dụng lý thuyết vào việc chọn lựa các cấu trúc dữ liệu và các thuật toán phù hợp có liên quan cho một bài toán cụ thể.Tình trạng gia đình . Bài tập chương I (Giới thiệu cấu trúc dữ liệu.Mã nhân viên . củng cố việc hiểu các cấu trúc dữ liệu và các thuật toán có liên quan.C2=cấp 2. TB = trung bình. phân tích thuật toán) (Kiểu dữ liệu có cấu trúc) 1) Giả sử quy tắc tổ chức quản lý nhân viên của một công ty như sau: • Thông tin về một nhân viên bao gồm lý lịch và bảng chấm công: * Lý lịch nhân viên: . Các bài tập có đánh dấu (*) là các bài tập khó hoặc cần nhiều thời gian để thực hiện dành cho các học viên khá giỏi.

chuỗi được kết thúc bởi ký tự có mã ASCII bằng 0. nối. i <= n. sửa một hay mọi mẫu tin thoả mãn một tính chất nào đó) . } b) for (i = 1. tốt nhất: a) Sum = 0. Biết rằng số nhân viên tối đa là 50 người.số con > 2 : Phụ trội = +5% Lương căn bản . bảng chấm công cho nhân viên (thêm.nghỉ không phép : Phụ trội = -5% Lương căn bản / 1 ngày • Các chức năng yêu cầu: .Xem bảng lương hàng tháng . Sum = Sum + x. i <= n. } c) for (i = 1. for (i = 1. chặt ngắn chuỗi. O(n). j <= n. i++) for ( j = 1. O(n2). sao chép một phần của chuỗi. i++) { for ( j = i.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. for (k = 1.Khai thác (chẳng hạn tìm) thông tin của nhân viên Hãy chọn cấu trúc dữ liệu thích hợp (và giải thích tại sao?) để biểu diễn các thông tin trên và cài đặt chương trình theo các chức năng đã mô tả. k++) C[i. j <= n -1.. Sau đó viết lại các thao tác cơ bản trên chuỗi (tính chiều dài chuỗi.j] + A[i.Cập nhật lý lịch. i <= n -1. j++) { C[i. phần tử đầu chỉ số ký tự của chuỗi.. kiểm tra chuỗi con.j] = 0. // Nhập một số x. 2) Viết chương trình cài đặt chuỗi ký tự theo một trong hai cách (giả sử kiểu chuỗi chưa có sẵn trong ngôn ngữ lập trình bạn đang dùng) sau: a. j++) if (X[ j] > X[ j+1]) .làm thêm : Phụ trội = +4% Lương căn bản / 1 ngày . xóa. chú ý các thông tin tĩnh và “động” hay thay đổi và là hệ quả của những thông tin khác.trình độ văn hoá = CH : Phụ trội = +10% Lương căn bản .) (Độ phức tạp của thuật toán) 3) Hãy nêu một thuật toán mà độ phức tạp tính toán của nó là: O(1). b. k <= n.j].j] = C[i. .k]*B[k. 4) Hãy xác định mục đích của từng thuật toán sau (xác định phép toán đặc trưng cơ bản của nó) và tính độ phức tạp tính toán của nó trong trường hợp xấu nhất. i++) { cin >> x.2 • Quy tắc tính lương: Lương thực lĩnh = Lương căn bản + Phụ trội Trong đó nếu: .

int n) // x là mảng các số nguyên. else { m1 = Max(i. n=2k>=i.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. }. b) Tính tổ hợp chập k của n phần tử c) Tìm chuỗi đảo ngược của một chuỗi ký tự cho trước. m2 = Max((n+i)/2+1. . n). X[ j] = X[ j+1]. gọi Max(1. (n+i)/2). if (n == i) return x[n-1].3 { Temp = X[ j]. } } 5) Viết giải thuật đệ qui và giải thuật lặp để: a) Tính ước số chung lớn nhất của 2 số nguyên không âm. m2. n) { int m1. } d) (*) int Max(int i. X[ j+1] = Temp. if (m1 < m2) return m2. else return m1.

36. 13. -9 -9 -5 -2 0 3 7 7 10 15 β. Kiểm tra lại kết quả câu a) bằng một chương trình trên máy tính và so sánh lại với kết quả đánh giá độ phức tạp của các thuật toán: . .sắp chọn trực tiếp và HeapSort. 76. 16. Đếm số lần tìm kiếm (so sánh) trung bình một phần tử x nào đó trên dãy (x có thể có hoặc không có mặt trong dãy). (Sắp xếp) 4) Với mỗi bộ dữ liệu của bài tập 1). d. (*) dãy con (là một dãy các phần tử liên tiếp của dãy) tăng dài nhất. .tìm kiếm nhị phân. 27.sắp dựa trên cơ số RadixSort. 2) Xây dựng và cài đặt thuật toán tìm: a. b. Kiểm tra lại kết quả ở câu a) bằng một chương trình trên máy tính. b. 22. d. thống kê các kết quả trên và thời gian chạy của từng thuật toán dưới dạng bảng. tìm phần tử đầu tiên trên dãy mà thỏa một tính chất TC nào đó. 47 Với mỗi mảng số nguyên. phần tử lớn nhất (hay nhỏ nhất).sắp trộn tự nhiên. . c. 3) (*) Xây dựng và cài đặt thuật toán tìm phần tử median (phần tử đứng giữa về mặt giá trị) trong một dãy được cài đặt bằng mảng.sắp đổi chỗ trực tiếp BubbleSort. . . 62. 31. hãy: a. 66. 26. 45.4 Bài tập chương II (Tìm kiếm và sắp xếp trên mảng) (Tìm kiếm) 1) Xét các dãy số nguyên sau: α. 6. Thực hiện từng bước và đếm số phép so sánh và gán trong các thuật toán sắp xếp tăng dãy đã cho. (*) Tổng quát câu b) trên bộ dữ liệu lớn được tạo ra tự động một cách ngẫu nhiên trong ba tình huống: xấu nhất.sắp chèn trực tiếp và ShellSort. . trong một dãy các phần tử cho trước được cài đặt bằng mảng.tìm kiếm tuyến tính (trên dãy chưa được hoặc đã được sắp tăng). tốt nhất và trung bình ngẫu nhiên. b. c. 15 10 7 7 3 0 -2 -5 -9 -9 γ. 79. 75. hãy: a. tất cả các số nguyên tố. ShakerSort và QuickSort. (**) Thể hiện trực quan bằng đồ thị kết quả của câu c) và cho nhận xét bằng các phương pháp sắp xếp sau: . 33.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT.

9) Hãy cài đặt thêm thuật toán xuất bảng lương nhân viên (trong bài tập 1 chương 1) theo thứ tự tiền lương tăng dần. Có bộ dữ liệu x[0]. x[1]. . …. } while (i <= j). 6) (*) Cho các ví dụ để minh họa ưu điểm của các thuật toán sắp xếp cải tiến so với các thuật toán sắp xếp trực tiếp tương ứng. j = n -1. HoánVị(x[i]. y = x[n/2]. 7) Xét thuật toán phân hoạch trong thuật toán QuickSort được viết lại như sau: i = 0. 10) (*) Hãy viết lại giải thuật QuickSort dưới dạng lặp. 12) (*) Xây dựng ví dụ để trường hợp xấu nhất của thuật toán QuickSort xảy ra. 8) Viết hàm đếm số đường chạy (tự nhiên) của một dãy gồm n phần tử cho trước. 11) (*) Cải tiến hai thuật toán QuickSort viết dưới dạng đệ qui và lặp [gợi ý: ta nên thực hiện sắp xếp trước dãy con nào ngắn hơn].5 5) Hãy viết thuật toán và chương trình sắp xếp bằng phương pháp chọn hai đầu: tại mỗi bước chọn đồng thời cả phần tử nhỏ nhất và lớn nhất trong dãy chưa được sắp còn lại. x[n-1] nào làm đoạn chương trình trên sai hay không ? Cho ví dụ minh họa. do { while (x[i] < y) i++. while (x[ j] > y) j--.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. x[ j]).

hoặc có trường dữ liệu trùng với một giá trị cùng kiểu K cho trước. i. Tạo bản sao của một DSLK cho trước. i < 4. Tách thành 2 DSLK mà DS sau được trỏ bởi M (giả thiết như câu h). Dx = new NodeType. for (i = 0. Hãy chèn DSLK P này vào sau nút trỏ bởi M. ví dụ: . Bổ sung một nút L vào sau một (hay mọi) nút dữ liệu trong DSLK thỏa một tính chất nào đó. p = new NodeType. . chẳng hạn: . Nếu có thì trả về con trỏ chỉ đến nút đứng trước nút tìm thấy. c. chẳng hạn: .6 Bài tập chương III (Cấu trúc danh sách liên kết) 1) Xét đoạn chương trình tạo một DSLK đơn có 4 nút (không quan tâm đến dữ liệu) sau đây: NodePointer p.nút thứ k. i++) { p = p->Next. Dx = NULL. .hoặc có trường dữ liệu trùng với một giá trị cùng kiểu K cho trước. d. h. . Gọi M là con trỏ chỉ tới một nút đã có trong DSLK trên và P là con trỏ chỉ tới một DSLK khác cùng loại. g. Nối hai DSLK cho trước. b. } p->Next = NULL. j. f. e.nút thứ k. Xóa một (hay mọi) nút dữ liệu trong DSLK thỏa một tính chất nào đó.nút thứ k. Đoạn chương trình này có thực hiện đúng như mục đích đã đưa ra không ? Tại sao ? Nếu không thì cần sửa lại như thế nào cho đúng ? 2) Hãy thực hiện các yêu cầu sau đối với từng loại danh sách liên kết: i) DSLK không có nút câm ii) DSLK có nút câm iii) DSLK vòng (không có nút câm) iv) DSLK đối xứng v) DSLK vòng đôi a. p = Dx.hoặc có trường dữ liệu trùng với một giá trị cùng kiểu K cho trước. Tính số lượng các nút dữ liệu. Tìm nút dữ liệu đầu tiên trong DSLK thỏa một tính chất nào đó. So sánh 2 DSLK (có trường dữ liệu của các nút liên tiếp tương ứng bằng nhau hay không ?) .Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. Đảo ngược DSLK nói trên theo hai cách : tạo DSLK mới hay sửa lại chiều con trỏ trong DSLK ban đầu.

Item). Nếu thực hiện dãy VVRVVRRR thì thứ tự các đầu tàu lúc ra là gì ? (Có thể xem đây là một cách hoán vị các số được không ?) .Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. Item) và Out(S. (Ngăn xếp và hàng đợi) 7) Cho X là ngăn xếp chứa các ký tự. Đọc các tên. Việc chọn được thực hiện theo cách sau đây. B). Giả sử có hàm sau trong C++: void Out(StackType &S. tuổi của những người đăng ký cùng với số vé họ mua và lưu vào một DSLK (chú ý kiểm tra không có người nào được đăng ký nhiều lần). ‘F’ để thu được các anagram (hoán vị) sau đây của nó: a) BDCFEA b) BDACEF c) ABCDEF d) EBFCDA e) FEDCBA 8) Xét một cơ cấu đường tàu và kho sửa chữa như hình sau: Giả sử ở đường vào có 4 đường tàu được đánh số 1. 4) Hãy viết thuật toán và chương trình để trộn hai DSLK tăng A. 6) (Bài toán Josephus) Một nhóm binh sĩ bị kẻ thù bao vây và một binh sĩ được chọn để đi cầu cứu. ElementType &Item) { Pop(S. không dự đoán được giới hạn của nó) bằng cách dùng DSLK để cài đặt. ‘B’.Item) như thế nào (nếu có thể) từ bộ các ký tự : ‘A’.7 3) Hãy viết chương trình nhằm thực hiện các yêu cầu của bài tập 1 – chương 1 (biết rằng số lượng nhân viên biến động nhiều. cout << Item<< endl. ‘C’. R là phép đưa một đầu tàu ra khỏi kho. Người nào đăng ký trước sẽ được mua trước. ‘E’. Khi đó cấu trúc hai DSLK A. 2. ‘D’. 3. Hãy viết một chương trình: a. b. Các binh sĩ được sắp theo vòng tròn và họ đếm từ binh sĩ được chọn ngẫu nhiên. C là DSLK mới (cấp phát bộ nhớ mới cho mọi nút của C) và bảo toàn hai DSLK cũ A. B hợp thành (do đổi chỗ vị trí các con trỏ sẵn có trên A. Khi đạt đến n. Hiện ra màn hình DSLK trên. 5) Một số giới hạn vé (MAX_VE) cho buổi hòa nhạc sẽ được bán vào ngày mai. Gọi V là phép đưa một đầu tàu vào kho sửa chữa. B cho trước thành một DSLK C cũng tăng theo hai cách: a. 4. a. C là DSLK mới do A. binh sĩ tương ứng được lấy ra khỏi vòng và việc đếm lại được bắt đầu từ binh sĩ tiếp theo. B có thể bị thay đổi. Một số nguyên n và một binh sĩ được chọn ngẫu nhiên. b. Hãy viết một thuật toán cài đặt cách chọn này. } Ta cần sử dụng luân phiên các phép toán Push(S. dùng danh sách liên kết vòng để lưu trữ các tên của binh sĩ. Quá trình này tiếp tục cho đến khi chỉ còn lại một binh sĩ là người gặp may (hoặc không may) được chọn để đi cầu cứu. B.

4. 11) Để có thể duyệt ngăn xếp hay hàng đợi theo cả hai chiều. Thực hiện phép duyệt qua DSLK từ dưới lên.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. Trong từng trường hợp sau. 6. ta có thể tổ chức chúng theo kiểu DSLK đối xứng như sau: Top Bottom S A B C D Hãy thực hiện các phép toán sau trên ngăn xếp: a. với List là: a. ElementType XemPTửThứ_2CủaNX(StackType S) có tác dụng xem phần tử thứ 2 kể từ đỉnh ngăn xếp S mà không làm S thay đổi. Xét trường hợp có 6 đầu tàu:1. 3. . 4. 2. 5. hàng đợi hãy cho biết: . 6 có thể thực hiện một dãy các phép V và R thế nào để đổi thứ tự đầu tàu ở đường ra là: 3. mỗi chữ cái tượng trưng cho thao tác thêm nó vào một DSLK List. 2. b.Hãy kiểm tra lại các kết quả trên bằng một chương trình hoàn chỉnh. dấu * tượng trưng cho thao tác lấy nó ra khỏi List và xuất ra màn hình. ElementType LấyĐáyNX(StackType &S) có tác dụng trả về phần tử ở đáy ngăn xếp S và làm S trở thành rỗng. 10) Viết các thao tác cơ bản trên ngăn xếp và thêm vào các thao tác sau đây: a. 1 ? và 1. 3 ? Ra Vào Kho sửa chữa 9) Xét chuỗi: EAS*Y**QUE***ST***I*ON Trong đó. 5. 5. 4. c. ElementType XemĐáyNX(StackType S) có tác dụng trả về phần tử ở đáy ngăn xếp S và S không thay đổi. ngăn xếp b.Kết quả cuối cùng xuất ra trên màn hình ? .Nội dung của List sau mỗi thao tác cơ bản trên ? . d. 2. ElementType LấyPTửThứ_2CủaNX(StackType &S) có tác dụng trả về phần tử thứ 2 của ngăn xếp S và S bị mất đi 2 phần tử ở đỉnh của nó. 6.8 b.

3a2 + 7. Chuyển các biểu thức trung tố sau đây sang dạng hậu tố: a/(b*c).các xâu sau là hợp lệ: a*(b+c). EnQueue(Q. 15) Dùng ngăn xếp để kiểm tra một chuỗi ký tự S1 có phải là palyndrome của một chuỗi ký tự S2 hay không ? 16) (*) Viết một chương trình đọc một xâu ký tự chứa các dấu ngoặc và xác định xâu đó có chứa các dấu ngoặc tương ứng hợp lệ hay không.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. b[d(e+f-]). a(). d-{[a((b)d]} (Các ứng dụng khác của DSLK) 17) a. QuickSort b.các xâu sau là không hợp lệ: (. iii) Vẽ đồ thị của một hàm giải tích cho trước được đưa vào dưới dạng biểu thức chuỗi.9 b. a-b-c. ii) Tính giá trị của một biểu thức cho trước ở dạng hậu tố. a*(b+c]. a∧b∧c. DeQueue(Q. 13) Dùng các phép toán cơ bản trên ngăn xếp và hàng đợi để đảo ngược thứ tự các phần tử trên hàng đợi. Thực hiện phép duyệt qua DSLK từ trên xuống. a5 + 4a3 . Các phương pháp sắp xếp trực tiếp: chèn. đổi chỗ . Thực hiện phép loại bỏ một phần tử (ở đầu và đuôi) khỏi DSLK. EnQueue(Q. Thực hiện phép bổ sung một phần tử vào (đầu và đuôi) DSLK. a/b*c. Ví dụ: phân tích: 60 = 5*3*2*2. Cho biết kết quả của Q sau một dãy các phép toán thêm vào và lấy ra các ký tự sau đây: EnQueue(Q. b[d(e+f-)]. a-(b-c). EnQueue(Q. EnQueue(Q. Viết biểu thức sau đây dưới dạng hậu tố: (A * B)/(C + D). C = 9. ’B’). MergeSort c. RadixSort d. EnQueue(Q. Cho Q là hàng đợi rỗng. c. c. ’C’). ’A’). (a+b)*(cd). Item). ’F’). DeQueue(Q. Sa+b b. d-{[a(b)d]} . ’D’). 14) Phân tích một số thành tích các thừa số nguyên tố theo thứ tự giảm dần. ’E’). B = 4. Item). Ví dụ: . chọn. DSLK kép): a. 12) a. Item). Item). DeQueue(Q. a[). b. d. (a∧b)∧c. Minh họa thông qua hình ảnh Stack để tính giá trị biểu thức hậu tố này ứng với: A= 20. DeQueue(Q. iv) Có thể viết lại chương trình trên khái quát hơn để có thể áp dụng cho các biểu thức lôgic mệnh đề hay không ? 18) (**) Hãy viết một chương trình thực hiện các yêu cầu tương tự của bài tập 4 chương 2 để cài đặt các thuật toán sắp xếp sau trên DSLK động (DSLK đơn. D = 7. ]. (**) Cài đặt một chương trình để : i) Chuyển một biểu thức từ dạng trung tố sang dạng hậu tố (có kiểm tra cú pháp của biểu thức). Viết các thao tác cơ bản trên hàng đợi và thêm vào các thao tác sau đây: duyệt hàng đợi từ đầu đến đuôi của nó và ngược lại.

Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. . 22) a. phép toán giao. hợp hai tập hợp. quan hệ bao hàm. bằng nhau giữa hai tập.. 20) (*) Hãy cài đặt tập hợp bằng DSLK và thực hiện các phép toán trên tập hợp (quan hệ một phần tử có thuộc vào một tập không. hàng đợi ưu tiên. Tìm một ứng dụng thực tế của hàng đợi ưu tiên.10 19) (*) Hãy lập các giải thuật cộng. So sánh thời gian tìm kiếm của cách tổ chức này với các cách tổ chức bình thường.. nguyên hàm của một đa thức cho trước trong hai trường hợp: a) Khi các hệ số của đa thức được lưu đầy đủ trong mảng. hiệu.) 21) (**) Viết các phép toán cơ bản trên ma trận thưa được cài đặt bằng DSLK tổng quát. b) (*) Khi chỉ các hệ số khác không và các số mũ tương ứng được lưu trong một danh sách liên kết. Hãy cài đặt các thao tác cơ bản trên DSLK có thứ tự và tổ chức lại. trừ. b. nhân hai đa thức và tính đạo hàm. 23) (*) Áp dụng thuật toán sắp xếp tôpô vào bài toán sắp lịch giảng dạy (tuyến tính) cho dãy các học phần thỏa điều kiện “học trước” đã biết. .

cx. E. Y b. 8. U. 1 c. 5. 2. 5. 7. 5) Viết một chương trình có các tác dụng sau: a. (*) Viết thuật toán và chương trình: . U. a/(b*c) ii. Sau đó xóa lần lượt các nút sau: 2. 20. 20. 16. . D. 1. từ đó rút ra dạng biểu thức hậu tố của chúng: i. LRN thì có luôn xác định duy nhất cây nhị phân thỏa điều kiện nêu ra không ? Dùng chương trình để kiểm chứng ? 3) a. tiền tố. 4. 1.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. my. LNR. Nhập từ bàn phím các số nguyên vào một cây nhị phân tìmkiếm (BST) mà nút gốc được trỏ bởi con trỏ Root. A. 5 b. by. 10. Biểu diễn mỗi biểu thức số học dưới đây trên cây nhị phân. 6. 3. a5 + 4a3 -3a2 + 7 iii. 19. Z. 17. 19. 2. 4. hãy đánh giá biểu thức hậu tố tương ứng. Tìm cây nhị phân thỏa đồng thời hai điều kiện kết xuất sau: theo thứ tự đầu NLR của nó là dãy ký tự sau: A. 10. hậu tố. Sa+b b. 18. 4. T. 6. cuối các phần tử trên cây nhị phân sau: A P Q M N E D B R T C 2) a. jk. 3.Sau đó nhập trị cho các ký hiệu trong biểu thức. Z. 8. aa. 1. . ha. T.Chuyển một biểu thức số học ký hiệu lên cây nhị phân (có kiểm tra biểu thức đã cho có hợp cú pháp không ?). fe. gc. C. E. 21. đầu. ap. (*) Khi cho trước 2 trong 3 kết quả duyệt NLR. 11. 15. (a+b)*(c-d) iv. 13. 4) Xây dựng cây tìm kiếm nhị phân BST và cây AVL từ mỗi bộ mục dữ liệu đầu vào như sau: a. da d.11 Bài tập chương IV (Cấu trúc cây) 1) Xuất ra theo thứ tự : giữa. 3. Y và theo thứ tự giữa LNR của nó là dãy ký tự sau: D. 9. B. .Xuất ra biểu thức số học đó dưới dạng: trung tố. C. 2. B. 12.

c. 7) (*) Xây dựng các thao tác cơ bản trên cây n-phân được biểu diễn bởi cây nhị phân: chèn một nút. Số nút có đúng 2 con khác rỗng i. 10) a. f. 9) Viết chương trình xây dựng cây nhị phân tìm kiếm có chiều cao bé nhất từ một dãy có thứ tự tăng của các phần tử được lưu trữ trên một danh sách liên kết. (*) Chiều cao của cây. Số nút có đúng 1 con khác rỗng j.12 b. hủy cây n-phân. (**) Riêng với duyệt cây. Số nút có khóa lớn hơn x trên cây nhị phân hoặc cây BST l. Sắp xếp n mục dữ liệu (được cài đặt bằng mảng hay DSLK) bằng phương pháp cây nhị phân tìm kiếm BSTSort. Số nút có khóa nhỏ hơn x và lớn hơn y (y ≤ x) trên cây nhị phân hoặc cây BST m. Duyệt cây theo chiều rộng n. Duyệt cây theo chiều sâu o. Xuất các phần tử trên cây BST trên theo thứ tự : đầu. Yêu cầu: viết các thao tác trên bằng 2 phương pháp: đệ quy và lặp (*). Số nút có khóa nhỏ hơn x trên cây nhị phân hoặc cây BST k. nhưng mỗi nút có thêm trường con trỏ Parent chỉ đến nút cha của nó. Số nút tối đa của cây nhị phân có chiều cao h là bao nhiêu? Chứng minh điều khẳng định bằng qui nạp và kiểm nghiệm lại bằng chương trình. d. Số nút tối đa ở mức k của cây nhị phân là bao nhiêu ? Chứng minh điều khẳng định bằng qui nạp và kiểm nghiệm lại bằng chương trình. 6) Tương tự bài tập 5. Kiểm tra lại kết quả của bài tập 4) bằng chương trình vừa xây dựng. e. Tìm và xóa (nếu có thể) phần tử trên cây Root có dữ liệu trùng với một mục dữ liệu Item cho trước được nhập từ bàn phím. giữa. (*) Số nút của cây ở mức k. Kiểm tra xem T có phải là cây cân bằng hoàn toàn hay không ? h. Độ lệch lớn nhất của các nút trên cây (độ lệch của một nút là trị tuyệt đối của hiệu số giữa chiều cao của cây con phải và cây con trái của nó) p. hãy viết dưới dạng lặp cả 3 phương pháp duyệt trong một hàm duy nhất có tính khái quát. Hãy vẽ cây AVL có chiều cao cực đại có 12 nút . Số nút lá. Tổng số nút của cây. Kiểm tra xem nó có phải là một cây nhị phân chặt (là cây nhị phân mà mỗi nút khác nút lá đều có đúng 2 con) hay không ? g. d. Đảo nhánh trái và phải của mọi nút trên một cây nhị phân Yêu cầu: viết các thao trên bằng 2 phương pháp: đệ quy và lặp (*). e. b. Tổng giá trị trường dữ liệu (số !) trên các nút của cây. 8) Cho cây nhị phân T. Viết chương trình chứa các hàm có tác dụng xác định: a. xóa một nút. cuối theo dòng và vẽ sơ đồ cây (*) (chỉ yêu cầu trường hợp khi số phần tử của cây nhị phân không quá lớn !).Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. c. tạo cây n-phân.

hủy cây AVL. 11) (*) Viết chương trình cho phép tạo. (*) Viết chương trình thể hiện các thao tác cơ bản trên cây AVL: chèn một nút. thêm. xóa một nút. bớt.13 b.Baøi taäp Caáu truùc döõ lieäu vaø Thuaät toùan 1 BT. . việc cân bằng lại cây lan truyền lên tận gốc. tra cứu. trên đây. và b. tạo cây AVL. c. Kiểm tra lại bằng chương trình với dữ liệu của câu a. Hãy tìm một ví dụ về một cây AVL có chiều cao là 6 và khi hủy một nút lá (chỉ ra cụ thể). sửa chữa từ điển.

[6] NGUYỄN TRUNG TRỰC: Cấu trúc dữ liệu. NXB ĐH và THCN – 1991 [8] TRẦN HẠNH NHI & DƯƠNG ANH ĐỨC: Nhập môn cấu trúc dữ liệu và giải thuật.E. HOPCROFT .1995. Bản dịch của Lê Minh Trung.1983. Addition Wesley Puplishing Company .TÀI LIỆU THAM KHẢO [1] A. 3: Sorting and Searching). ULMANN: Data structures and algorithms.D. J. HCM – 1992. Khoa Công nghệ thông tin.V. NXB KHKT . trường ĐH Bách khoa TP. (vol. vol. [4] ĐỖ XUÂN LÔI: Cấu trúc dữ liệu và giải thuật. Trung tâm điện toán.1973.1: Fundamental Algorithms. Addition Wesley . HOFF.1991. Công ty Scitec . [7] NIKLAUS WIRTH: Cấu trúc dữ liệu + Giải thuật = Chươngtrình (Nguyễn Quốc Cường dịch). AHO . [3] ĐINH MẠNH TƯỜNG: Cấu trúc dữ liệu và giải thuật. J. NXB KHKT .2001. ĐH KHTN TP HCM – 2000. [5] LARRY N. SANFORD LEESTMA: Lập trình nâng cao bằng Pascal với các cấu trúc dữ liệu. . [2] DONALD KNUTH: The Art of Programming.