You are on page 1of 94

ĐẠI HỌC ĐÀ NẴNG

TRƯỜNG ĐẠI HỌC BÁCH KHOA

KHOA CÔNG NGHỆ THÔNG TIN

BÀI GIẢNG MÔN :

PHÂN TÍCH VÀ THIẾT KẾ GIẢI THUẬT

LƯU HÀNH NỘI BỘ

ĐÀ NẴNG 2021

Cấu trúc dữ liệu – trang 1


MỤC LỤC
Chương 1: Giới thiệu
1.1. Giải thuật
1.2. Bài toán xây dựng hệ thống đèn màu
1.3. Các kiểu dữ liệu trừu tượng
1.4. Kiểu dữ liệu, cấu trúc dữ liệu và kiểu dữ liệu trừu
tượng Chương 2: Độ phức tạp của giải thuật
2.1. Mục đích của giải thuật
2.2. Đánh giá thời gian thực hiện chương trình
2.3. Độ phức tạp của giải thuật
Chương 3: Giải thuật Đệ quy
3.1. Giới thiệu
3.2. Các bài toán ứng dụng
3.2.1. Tính giai thừa
3.2.2. Tính ước số chung lớn nhất
Chương 4: Giải thuật Chia để trị
4.1. Giới thiệu
4.2. Các bài toán ứng dụng
4.2.1. Tìm giá trị lớn nhất và giá trị nhỏ nhất
4.2.2. Bài toán Tháp Hà Nội
4.2.3. Xây dựng lịch thi đấu Tennis
4.2.4. Bài toán nhân các số nguyên lớn
4.2.5. Nhân ma trận (giải thuật Stracen)
4.2.6. Thay đổi hai phần trong một dãy
4.2.7. Sự cân bằng các bài toán con
Chương 5: Giải thuật Quy hoạch động
5.1. Giới thiệu
5.2. Các bài toán ứng dụng
5.2.1. Dãy số Fibonacci
5.2.2. Tìm tất cả các đường đi ngắn nhất (giải thuật Floyd)
5.2.3. World Series Olds
Cấu trúc dữ liệu – trang 2
5.2.4. Bài toán chia tam giác
5.2.5. Tính tích n ma trận
5.2.6. Bài toán du lịch
Chương 6: Giải thuật Tham lam
6.1. Giới thiệu
6.2. Các bài toán ứng dụng
6.2.1. Bài toán đổi tiền
6.2.2. Bài toán Xếp ba lô
6.2.3. Cây bao trùm tối thiểu (Minimum spanning trees)
6.2.4. Bài toán Tìm các đường đi ngắn nhất từ một đỉnh (giải thuật Dijkstra)
6.2.5. Bài toán lập lịch
Chương 7: Giải thuật Quay lui
7.1. Giới thiệu
7.2. Các bài toán ứng dụng
7.2.1. Bài toán 8 hậu
7.2.2. Bài toán Các tập con có tổng cho trước
7.2.3. Bài toán hoán vị

Cấu trúc dữ liệu – trang 3


CHƯƠNG 1 : GIỚI THIỆU
1.1. GIẢI THUẬT:
- Giải thuật (hay thuật toán):
Giải thuật là tập hợp các bước theo một trình tự nhất định để giải một bài toán.
1.2. BÀI TOÁN XÂY DỰNG HỆ THỐNG ĐÈN MÀU:
Ví dụ 1.1 : Dùng mô hình toán là đồ thị để thiết kế hệ thống đèn màu hướng dẫn
giao thông ở giao lộ phức tạp (ngã 4, 5...).
Để thiết lập màu đèn, ta xây dựng chương trình mà đầu vào là tập tất cả các tuyến
đi hợp lệ tại giao lộ. Chương trình sẽ chia tập ấy ra nhiều nhóm nhỏ, sao cho các tuyến
đi trong mỗi nhóm là hợp lệ, nghĩa là không mâu thuẫn nhau (không tông nhau). Sau
đó ta cho mỗi nhóm một màu đèn. Ngoài ra, chương trình còn phải giải quyết vấn đề
tối thiểu số nhóm hợp lệ, để lập hệ thống đèn có số màu ít nhất.
Ví dụ : Giao lộ (ngã năm) trên hình 1.1. đường C và E là một chiều, còn lại đều là
đường hai chiều. Ta có 13 tuyến đi có thể có tại ngã năm này ( AB , AC , AD , BA , BC
, BD , DA , DB , DC , EA , EB , EC , ED ) . Một số cặp tuyến đi (turns), chẳng hạn AB
(đi từ A đến B) và EC là cặp tuyến đi hợp lệ, có thể cùng đi, trong khi AB và BC là cặp
tuyến đi mâu thuẩn (có thể tông nhau) nên không thể cùng đi. Màu đèn tại ngã năm
phải cho phép các tuyến đi thực hiện theo thứ tự sao cho AB và BC không đi cùng một
lúc, còn AB và EC thì có thể cùng đi.

C
D

B
E

Hình 1.1. Ngã năm

Cấu trúc dữ liệu – trang 4


Đối với bài toán này ta có thể lập đồ thị mà mỗi đỉnh là một tuyến đi, và các cạnh
nối từng cặp đỉnh tương ứng là cặp tuyến đi mâu thuẩn. Tổ 1 = Nhóm Xanh

AB AC AD

BA BC BD

DA DB DC
s

EA EB EC ED

Xanh: AB , AC , AD , BA , DC , ED
Vàng: BC , BD , EA
Đỏ: DA , DB
Tím: EB , EC
Hình 1. 2. Đồ thị các tuyến đi mâu thuẫn. AB phải khác nhóm với BC , DA , EA.
Hình 1.3 là biểu diễn bảng của đồ thị hình 1.2 với 1 ở dòng i cột j là có cạnh nối
đỉnh i với đỉnh j.
1 2 3 4
AB AC AD BA BC BD DA DB DC EA EB EC ED
AB 0 0 0 0 1 1 1 0 0 1 0 0 0
AC 0 0 0 0 0 1 1 1 0 1 1 0 0
AD 0 0 0 0 0 0 0 0 0 1 1 1 0
BA 0 0 0 0 0 0 0 0 0 0 0 0 0
BC 1 0 0 0 0 0 0 1 0 0 1 0 0
BD 1 1 0 0 0 0 1 0 0 0 1 1 0
DA 1 1 0 0 0 1 0 0 0 0 0 0 0
DB 0 1 0 0 1 0 0 0 0 0 0 1 0
DC 0 0 0 0 0 0 0 0 0 0 0 0 0
EA 1 1 1 0 0 0 0 0 0 0 0 0 0
EB 0 1 1 0 1 1 1 0 0 0 0 0 0
EC 0 0 1 0 0 1 1 1 0 0 0 0 0
Cấu trúc dữ liệu – trang 5
ED 0 0 0 0 0 0 0 0 0 0 0 0 0

Hình 1.3. Bảng các tuyến đi mâu thuẫn.


Để giải bài toán Xây dựng hệ thống đèn màu này thì ta chuyển sang giải bài toán
tương đương là bài toán Tô màu đồ thị là: Hãy tô màu cho các đỉnh của đồ thị sao cho
những đỉnh có nối cạnh với nhau thì phải tô màu khác nhau, và tô với số màu ít nhất.
Đây là bài toán thuộc lớp các bài toán phức tạp NP (NP - complete problems), mà
cách giải là “thử hết các khả năng”. Đầu tiên gán cho một đỉnh nào đó màu thứ nhất,
sau đó gán màu thứ hai cho đỉnh kế có cạnh nối với đỉnh đầu, rồi màu thứ ba, thứ tư...
nếu vẫn còn tìm được màu để tô.
Tìm nghiệm tối ưu cho bài toán này thật tốn kém. Ta thử thực hiện một trong ba
cách sau :
Cách 1: Nếu đồ thị nhỏ ta có thể thử hết khả năng để tìm nghiệm tối ưu.
Cách 2: Có thể dựa vào các tính chất đặc biệt của đồ thị để hạn chế bớt số trường
hợp cần xét duyệt để tìm được nghiệm tối ưu.
Cách 3: Dùng phương pháp kinh nghiệm (heuristic) là phương pháp tìm nhanh
nghiệm, mà một trong các phương pháp này là Giải thuật tham lam (Greedy). Đối với
bài toán tô màu đồ thị, đó là: Hiện nay có thể tô màu đang dùng cho đỉnh nào được thì
cứ tô. Như sau:
Đầu tiên tô màu thứ nhất Xanh cho đỉnh AB, khi đó ta có thể tô Xanh cho các
đỉnh AC, AD, BA, DC, ED.
Tiếp theo ta tô màu thứ hai Vàng cho đỉnh BC, khi đó ta có thể tô Vàng cho các
đỉnh BD, EA.
Tiếp theo ta tô màu thứ ba Đỏ cho đỉnh DA, khi đó ta có thể tô Đỏ cho các đỉnh
DB.
Tiếp theo ta tô màu thứ tư Tím cho đỉnh EB, EC.
Vậy nghiệm của bài toán là 4 màu, trong đó tô thứ nhất cho các đỉnh AB, AC,
AD, BA, DC, ED. Tô màu thứ hai cho các đỉnh BC, BD, EA. Tô màu thứ ba cho các
đỉnh DA, DB. Tô màu thứ tư cho các đỉnh EB, EC.

MÀU CÁC TUYẾN ĐI CÁC TUYẾN PHỤ


Xanh AB,AC,AD,BA,DC,ED
Vàng BC,BD,EA BA,DC,ED
Đỏ DA,DB BA,DC,ED,AD
Tím EB,EC BA,DC,ED,EA

Cấu trúc dữ liệu – trang 6


Hình 1.5. Màu đồ thị hình 1.2
Các tuyến phụ (extras) cùng màu với các tuyến trên một dòng, nghĩa là khi đèn
tím sáng chẳng hạn thì các tuyến đi EB, EC và các tuyến phụ cùng dòng BA, DC, EA,
ED có thể cùng di chuyển.
Trong lý thuyết đồ thị, khái niệm K-elique (nhóm K) là tập k đỉnh mà mỗi cặp
đỉnh bất kỳ đều nối nhau bởi một cạnh. Rõ ràng là phải k màu để tô K-elique này, vì
không có cặp đỉnh nào cùng tô được một màu.
Trong đồ thị hình 1.2, bốn đỉnh AC, DA, BD, EB là 4-elique. Vậy không thể
dùng ba màu hoặc ít hơn để tô đồ thị hình 1.2, nghĩa là lời giải trong hình 1.5 là tối ưu.
Hệ thống đèn màu cho giao lộ hình 1.1 không thể dưới 4 màu.
- Ngôn ngữ giả và việc tinh chế từng bước :
Sau khi có mô hình toán, ta viết Giải thuật và tinh chế dần từng bước theo hướng
chương trình C hoàn chỉnh. Tinh chế đầu tiên của Giải thuật greedy là chọn một số
đỉnh chưa tô để tô cùng một màu. Trong đó GRAPH và SET là các kiểu dữ liệu trừu
tượng sẽ được định nghĩa bằng ngôn ngữ C (hình 1.6).
void greedy(Graph &G, Set &newcolor)
{ // greedy gán cho tập newcolor các đỉnh của G có thể tô cùng một màu
(1) newcolor =  ; { : tập rỗng}
(2) for ( mỗi đỉnh V chưa tô trong G)
(3) if (V không có cạnh đến đỉnh nào trong newcolor)
{
(4) tô màu đỉnh V;
(5) bổ sung đỉnhV vào tập newcolor;
}
}
Hình 1.6 : Tinh chế đầu tiên của Giải thuật greedy.
Lệnh if ở dòng (3) có thể tinh chế ra mã thuận tiện hơn. Để kiểm tra xem V có
nối với đỉnh nào đó trong newcolor không ta xét từng phần tử W trong newcolor và
kiểm tra đồ thị G xem có cạnh (W,V) không. Ta dùng found là biến boolean để chỉ ra
việc tìm ra cạnh (W,V). Biến found=0 nếu đỉnh V không nối với đỉnh nào trong tập
newcolor cả, biến found=1 nếu đỉnh V có nối với 1 đỉnh nào đó trong newcolor. Khi đó
ta thay các dòng (3)-(5) trong hình 1.6 bởi các dòng ở hình 1.7 như sau :
void greedy(Graph &G, Set &newcolor)
{ (1) newcolor = ; // tập rỗng

Cấu trúc dữ liệu – trang 7


(2) for ( mỗi đỉnh V chưa tô trong G)
(3) {
(3.1) found=0;
(3.2) for (mỗi đỉnh W trong newcolor)
(3.3) if (có cạnh giữa V và W trong G)
(3.4) found=1;
(3.5) if (found == 0)
{
(4) tô màu đỉnh V;
(5) bổ sung V nào newcolor;
}
}
}
Hình 1.7 : Tinh chế một phần của hình 1.6
Như vậy là ta đã đưa Giải thuật đến các phép toán làm việc trên hai tập hợp của các
đỉnh. Vòng lặp ngoài (2)-(5) lặp trên tập các đỉnh chưa tô màu của G. Vòng lặp trong
(3.2)-(3.4) lặp trên các đỉnh của tập newelr. Dòng (5) bổ sung đỉnh vừa tô vào newelr.
Có nhiều cách biểu diễn tập hợp trong ngôn ngữ lập trình tựa Pascal. Ở đây ta
dùng kiểu LIST, có thể thực hiện bằng danh sách các số nguyên, kết thúc bằng giá trị
đặc biệt là nul (có thể dùng 0).
Bây giờ ta có thể thay lệnh for của dòng (3.2) hình 1.7 bằng vòng lặp với W nhận trị
đầu tiên là phần tử đầu của newelr và thay đổi đến phần tử tiếp mỗi lần lặp. Ta cũng có
thể tinh chế vòng lặp for ở dòng (2) hình 1.6 và thủ tục được trình bày trong hình 1.8.
void greedy(Graph &G, Set &newcolor)
{ integer V, W, found;
newcolor = ;
V = đỉnh đầu tiên chưa tô trong G;
while (V != NULL )
{ found = 0;
W = đỉnh đầu tiên trong newcolor;
while ( (W != NULL) && ( !found) )
{ if ( tồn tại cạnh (W,V) trong G )
found =1;

Cấu trúc dữ liệu – trang 8


W = đỉnh tiếp theo trong newcolor;
}
if ( found = = 0 )
{ tô màu đỉnh V;
bổ sung đỉnh V vào tập newcolor;
}
V = đỉnh chưa tô tiếp theo trong G
}
}
Hình 1.8 : Thủ tục greedy đã tinh chế.
Kết luận :
Hình 1.9 mô tả quá trình lập trình trong ba khối. Đầu tiên là dùng mô hình toán
chẳng hạn như đồ thị, và lời giải bài toán là Giải thuật chưa chính thức. Bước tiếp theo
là Giải thuật viết trong ngôn ngữ giả và bước cuối cùng là mã hóa chúng bằng các câu
lệnh của ngôn ngữ Pascal để có chương trình Pascal hoàn chỉnh.

Mô hình Các kiểu Cấu trúc


toán dữ liệu dữ liệu
trừu tượng
Giải Chương
thuật Chương trình
chưa trình Ngôn Pascal
hình

Hình 1.9 : Quá trình giải bài toán.

1.3. CÁC KIỂU DỮ LIỆU TRỪU TƯỢNG (ABSTRACT DATA TYPES ADT)
Định nghĩa :
ADT là mô hình toán cùng với tập hợp các phép toán xác định trên mô hình.
Ví dụ các số nguyên và tập các phép toán : cộng, trừ, nhân là một ADT đơn giản.
Trong ADT, các phép toán có thể sử dụng như các toán hạng không chỉ là những thành
phần của chính ADT đó mà có thể là các kiểu khác của các toán hạng, nghĩa là các số
nguyên hoặc các thành phần của ADT khác. Nhưng chúng ta giả thiết có ít nhất một
toán hạng hoặc kết quả của một phép toán nào đó là của ADT.
Hai tính chất của các thủ tục nêu trên là : Tính khái quát (generalization) và tính
khép kín (encapsulation) áp dụng như nhau đối với ADT. ADT là sự khái quát các kiểu dữ
liệu nguyên thủy (nguyên, thực...) cũng như các thủ tục là sự khái quát của các phép toán
nguyên thủy (+, -, ...). ADT khép kín kiểu dữ liệu theo nghĩa là định nghĩa kiểu và tất cả
các phép toán trên kiểu đó có thể cục bộ hóa trong một phần của chương trình. Nếu
Cấu trúc dữ liệu – trang 9
chúng ta muốn thay đổi các thực hiện của ADT, chúng ta sẽ biết được nó ở đâu và chỉ
cần xem xét một phần nhỏ ta có thể tin chắc rằng có chỗ khác trong chương trình bị sai
liên quan đến kiểu dữ liệu này. Một điều cần đặc biệt lưu ý là một số phép toán lại có
thể xuất hiện trong nhiều ADT, và sự tham chiếu của chúng phải xuất hiện trong các
phần của ADT.
Để minh họa ta xét thủ tục greedy của hình 1.8 sử dụng các phép toán nguyên
thủy trên kiểu dữ liệu LIST (của các số nguyên).
Các phép toán thực hiện trên LIST newcolor là :
1. Làm trống danh sách (list).
2. Nhận phần tử đầu của danh sách và trả về null nếu danh sách trống.
3. Nhận phần tử tiếp theo của danh sách và trả về null nếu không có phần tử tiếp
(next) và ...
4. Chèn số nguyên vào danh sách.
Nếu trong hình 1.8 ta thay các phép toán bằng các lệnh :
1. MAKENULL (newcolor) ;
2. W = FIRST (newcolor) ;
3. W = NEXT (newcolor) ;
4. INSERT (V, newcolor) ;
thì ta thấy tầm quan trọng của kiểu dữ liệu trừu tượng (ADT). Chúng ta có thể dùng
một kiểu dữ liệu bất kỳ và các chương trình chẳng hạn như hình 1.8 sử dụng các đối
tượng của kiểu đó sẽ không thay đổi, chỉ có các thủ tục thực hiện các phép toán trên
kiểu đó là cần thay đổi.
Khi quay lại kiểu dữ liệu trừu tượng GRAPH, chúng ta thấy cần các phép toán sau
:
1. Nhận đỉnh chưa tô màu đầu tiên;
2. Kiểm tra xem có cạnh nối hai đỉnh không;
3. Tô màu đỉnh;
4. Nhận đỉnh chưa tô tiếp theo;
Còn có các phép toán cần thiết ngoài thủ tục greedy như bổ sung các đỉnh và các
cạnh vào đồ thị và tô màu tất cả các đỉnh chưa tô. Có nhiều cấu trúc dữ liệu cần các đồ
thị có các phép toán đó - bạn đọc tự nghiên cứu ở các chương 6 - 7 của [1].
Chúng ta không hạn chế các phép toán trên các đối tượng của mô hình toán. Mỗi
tập phép toán định nghĩa riêng cho một ADT. Một ví dụ về các phép toán định nghĩa
cho kiểu dữ liệu trừu tượng LIST là :
1. MAKENULL (A) - thủ tục lấy tập null làm giá trị cho tập A.

Cấu trúc dữ liệu – trang 10


2. UNION (A, B, C) - lấy trị các đối số của hai tập A, B và gán tổng số chúng
cho
C.
3. SIZE (A) - đếm số phần tử của tập A, trả về số nguyên là số phần tử của tập A.
Việc thực hiện của ADT là dịch ra các lệnh của ngôn ngữ lập trình, các khai báo
nhằm định nghĩa các biến của kiểu dữ liệu trừu tượng cộng với các thủ tục cho mỗi
phép toán của ADT. Việc thực hiện cũng sẽ chọn cấu trúc dữ liệu (data structure) để
biểu diễn ADT, mỗi cấu trúc dữ liệu được xây dựng từ các kiểu dữ liệu cơ sở của ngôn
ngữ lập trình cơ bản sử dụng các kiểu cấu trúc dữ liệu có thể (available data structuring
facilities). Các cấu trúc mảng (array) và bản ghi (record) là hai kiểu cấu trúc dữ liệu
quan trọng được dùng làm biến trong Pascal. Ví dụ có thể thể hiện biến S của kiểu SET
là một mảng chứa các thành phần của S.
Điều quan trọng là hai ADT có thể khác nhau nếu cùng mô hình nhưng khác nhau
các phép toán, và việc thể hiện các ADT ấy phụ thuộc rất nhiều vào các phép toán.
Một cách lý tưởng là : ta muốn viết chương trình trong một ngôn ngữ mà các kiểu
dữ liệu nguyên thủy và các phép toán trên nó là đóng kín đối với các mô hình và các
phép toán của ADT. Pascal không dựa nhiều vào việc thể hiện các trường hợp chung
của ADT, nhưng không có ngôn ngữ nào mà ADT được mô tả trực tiếp hơn.

1.4. KIỂU DỮ LIỆU, CẤU TRÚC DỮ LIỆU VÀ KIỂU DỮ LIỆU TRỪU


TƯỢNG :
(Data types, data structures and Abstract data types)
Các từ nêu trên có vẻ giống nhau, nhưng chúng có nghĩa khác nhau.
- Trong ngôn ngữ lập trình kiểu dữ liệu của biến (data type of variable) là tập các
giá trị mà biến có thể chấp nhận. Ví dụ kiểu boolean là trị : true và false chứ không có gì
khác ngoài chúng. Kiểu dữ liệu cơ sở (basic data type) khác nhau đối với mỗi ngôn ngữ.
Trong Pascal, chúng là integer, real, boolean và character.
Qui tắc tổ hợp kiểu dữ liệu cơ sở để tạo ra kiểu mới cũng khác nhau đối với từng
ngôn ngữ.
ADT là mô hình toán + các phép toán trên mô hình. Chúng ta sẽ thiết kế giải
thuật trong thuật ngữ của ADT, nhưng để thực hiện một giải thuật trong một ngôn ngữ
lập trình cụ thể chúng ta phải tìm cách thể hiện ADT trong thuật ngữ của các kiểu dữ
liệu và các phép toán do ngôn ngữ ấy cung cấp.
- Để biểu diễn mô hình toán trong ADT chúng ta sử dụng data structures là tập
hợp các biến và các dữ liệu khác nhau liên kết theo nhiều cách.
- Cell là một khối cơ sở của data structures.
Chúng ta coi cell như một cái hộp (box) có thể chứa trị của kiểu dữ liệu cơ sở hoặc
kiểu dữ liệu tổ hợp nào đó. Cấu trúc dữ liệu được xây dựng bằng cách gán tên cho các
Cấu trúc dữ liệu – trang 11
cells và có thể kèm sự giải thích các giá trị của các cells nào đó như biểu diễn sự liên
kết trong các cells (chẳng hạn các con trỏ (pointers)).
- Cơ chế tập hợp đơn giản nhất trong Pascal và phần lớn các ngôn ngữ khác là
mảng một chiều (one-dimension array), đó là mảng các cells của kiểu đã cho, ta hay
gọi là cell type (kiểu cơ sở). Ta có thể hình dung mảng như một ánh xạ từ tập chỉ số
(index set) (ví dụ các số nguyên 1, 2..., n) vào cell type. Cell trong mảng có thể tham
chiếu được bằng cách cho tên mảng cùng với giá trị chỉ số cụ thể lấy từ tập chỉ số của
mảng. Trong Pascal tập chỉ số có thể không phải số, ví dụ (north, east, south, west)
hoặc miền con như 1...10. Các giá trị của cell có thể bất kỳ.
Ví dụ : Khai báo : celltype name[indextype] ;
Là mô tả name cho dãy các cells kiểu chỉ số và kiểu cell, với nội dung mỗi cell là
thành phần bất kỳ kiểu celltype.
- Cơ chế khác để nhóm các cells trong ngôn ngữ lập trình là cấu trúc bản ghi
(record structure). Record là cell và được tạo ra từ tập hợp các cells gọi là các fields
(trường) có thể khác kiểu. Các record thường nhóm lại thành các mảng (arrays). Các
kiểu của fields là các “celltypes” của record.
Ví dụ : khai báo C
struct element
{float data;
int next;
};
element Reclist[4];
Là mô tả reclist cho mảng 4 phần tử, mỗi cell của mảng là một record có hai
fields data và next. (từ dãy và mảng dùng như nhau).
- Phương pháp thứ ba trong C và một số ngôn ngữ khác nhóm các cells là file.
File tương tự như mảng một chiều, là dãy các giá trị có kiểu riêng. Nhưng file không có
kiểu chỉ số. Các phần tử file được truy xuất theo thứ tự xuất hiện của nó trong file. Cả
hai cấu trúc array và record là cấu trúc truy xuất ngẫu nhiên (“random-access”
structures), nghĩa là thời gian cần truy xuất đến một phần tử trong mảng hoặc record là
độc lập với giá trị của chỉ số mảng hoặc bộ chọn trường (array index or field selector).
Bù vào đó, số phần tử của file có thể thay đổi không hạn chế.
- Pointers và cursors (chỉ điểm và con chạy).
Cách nhóm các cells của ngôn ngữ lập trình cho phép ta biểu diễn quan hệ giữa
chúng bằng cách dùng pointers và cursors. Pointer là cell mà giá trị của nó chỉ đến một
cell khác. Khi vẽ các cấu trúc dữ liệu, cell A là pointer đối với cell B, ta vẽ mũi tên từ
A đến B.
Trong C, ta xây dựng biến pointer ptr chỉ đến cell có kiểu celltype nhờ khai báo :
Cấu trúc dữ liệu – trang 12
celltype *Ptr ;
biến Ptr là biến con trỏ chỉ đến một phần tử thuộc kiểu celltype
Ví dụ 1.2 :
Hình 1.10 vẽ một cấu trúc dữ liệu hai phần gồm một dãy chuyển các cells chứa
các cursors đối với array redist đã định nghĩa ở trên. Mục đích của field next trong
reclisrt là chỉ đến record khác trong array.

Cấu trúc dữ liệu – trang 13


Header 4 2
Data next

1 1.2 3
2 3.4 0
1.2 3
3 5.6 2
3.4 0
4 7.8 1
5.6 2
reclist
7.8 1

Hình 1.10 : Ví dụ cấu trúc dữ liệu


Ví dụ :
Reclist [4].next là 1, nghĩa là record 1 theo sau record 4.
Giả sử record 4 là đầu tiên thì next field của reclist chỉ thứ tự các record : 4, 1, 3,
2 next field 0 trong record 2 chỉ ra rằng không có record nào theo sau record 2. Ta dùng
0 như “NIL pointer” khi các cursors đã dùng hết. Vậy chỉ số đầu tiên của array phải bắt
đầu là 1 chứ không được 0. Các cells trong hình 1.10 có kiểu :
struct recordtype
{ int cursor;
recordtype *ptr;
}
Dây chuyền được dẫn dắt bằng biến có tên header, có kiểu ^recordtype; header
chỉ đến record vô danh có kiểu recordtype (record vô danh vì nó sẽ tạo ra nhờ gọi new
(header). Record này có trị là 4 trong cursor - field của nó. Ta xem 4 này như chỉ số
vào trong mảng reclist. Record có chỉ điểm thật trong field ptr chỉ đến record vô danh.
Record được chỉ đến có chỉ số trong field cursor của nó chỉ vị trí 2 của reclist; nó cũng
có nil pointer trong field ptr.
---o-O-o---

Cấu trúc dữ liệu – trang 14


CHƯƠNG 2 : ĐỘ PHỨC TẠP
2.1. Mục đích của giải thuật:
Để giải một bài toán ta cần chọn giải thuật. Mục đích chọn là:
1. Giải thuật cần rõ ràng, dễ hiểu, dễ mã hóa và dễ hiệu chỉnh.
2. Giải thuật sử dụng có hiệu quả các tài nguyên của máy tính, đặc biệt thời gian
thực hiện chương trình càng nhanh càng tốt.
Khi viết chương trình để thực hiện một ít lần thì mục đích (1) là quan trọng hơn.
Khi viết chương trình để thực hiện nhiều lần thì mục đích (2) là quan trọng hơn.
Nói chung người lập trình phải biết viết chương trình chạy nhanh và biết khi nào thì áp
dụng kỹ thuật này hay kỹ thuật khác, hoặc kết hợp các kỹ thuật với nhau.
2.2. Đánh giá thời gian thực hiện chương trình :
Thời gian thực hiện chương trình nhanh hay chậm thường phụ thuộc vào các yếu
tố sau:
1. Số lượng dữ liệu đầu vào n.
2. Chất lượng mã sinh của chương trình dịch – Phần mềm máy tính.
3. Trạng thái và tốc độ các lệnh chạy trên máy – Phần cứng máy tính.
4. Độ phức tạp thời gian của giải thuật.
Yếu tố (1) là chức năng nhập. Kích thước của input ví dụ là n và ta thường ký
hiệu T(n) là một đại lượng thời gian cần thiết để giải bài toán kích thước n. Yếu tố (2)
và yếu tố (3) đánh giá cũng khó khăn.
2.3. Độ phức tạp của giải thuật:
2.3.1. Định nghĩa:
Gọi n : là số lượng dữ liệu đầu vào, vậy n0 .
Gọi T : là thời gian thực hiện chương trình. T là hàm số của n, ta viết T(n).
Định nghĩa: Ta nói thời gian thực hiện T(n) của chương trình có độ phức tạp là f(n), ký

hiệu: T(n)=O(f(n)) ( hằng số c>0,  hằng số n0>0 : n  n0 => T(n)  c * f(n) )
Ví dụ: Giả sử T(0)=1, T(1)=4, T(2)=9, T(3)=16.
2
Tổng quát T(n) = (n+1)
2 2 2
Ta sẽ chứng minh hàm T(n)=(n+1) có độ phức tạp T(n)=O(n ) tức f(n)=n
2 2
Tức ta sẽ chứng minh: (c>0, n0>0 : n n0 => (n+1)  c * n )
2 2
Chứng minh:0<a<=b => a <=b
2 2
Ta có: n  n0 => n0  n => 0<n+n0  2n => (n+n0)  4n
2 2
Nếu ta chọn c=4, n0=1 thì ta có: n  n0 => (n+1)  c * n .

Cấu trúc dữ liệu – trang 15



Nếu cùng một bài toán mà có 2 cách giải. Cách thứ nhất có thời gian thực hiện
2 3
T1(n)=5n và cách thứ hai có thời gian thực hiện T2(n)=2n
n 0 1 2 3 4 5 6
T1(n)=5n
2 0 5 20 45 80 125 180
T2(n)=2n
3 0 2 16 54 128 250 532
Nhận xét, với n0=3 thì với n  n0 ta có T1(n)  T2(n) . Vậy cách giải thứ nhất tốt
hơn cách giải thứ hai.
Chương trình chạy với thời gian 0(f(n)) ta nói nó phát triển tỉ lệ với f(n).
Khi nói T(n) là 0(f(n)) thì f(n) là chặn trên của T(n).
Để nói chặn dưới của T(n) ta dùng ký hiệu  - lớn.
Ta nói T(n) = O (g(n)) nếu  const c>0, n0  0, để T(n)  c x g(n) , n  n0.
3 2 3
ra T(n) = n + 2n là O (n ) ta đặt c = 1
3
Thì : T(n)  cn  n = 0, 1, ... (n0 = 0).
2.3.2. Thường người ta hay dùng các độ phức tạp tăng dần sau O(1) , O(logn) ,
2 3 n n
O(n) , O(n*logn) , O(n ) , O(n ) , … , O(2 ) , O(3 ) , …
2.3.3. Hệ quả 1: Nếu T(n)=hằng số thì T(n)=O(1). 2*3 1234*5678
2.3.4. Trong các ngôn ngữ lập trình thì ta xem thời gian thực hiện các phép toán
là một hằng số, và ta cũng xem thời gian thực hiện các lệnh gán là một hằng số, vậy nó
có độ phức tạp là O(1).
α α
2.3.5. Hệ quả 2: Nếu T(n) = a * n với a, α là các hằng số thì T(n)=O(n ) .
6 6 6
Ví dụ nếu T(n)=8n thì T(n)=O(n ). 8000000=8*10
2.3.6. Hệ quả 3: Trong các chương trình thì chương trình nào có thời gian thực
hiện nhỏ hơn thì chương trình đó tốt hơn. Tương tự chương trình nào có độ phức tạp
nhỏ hơn thì chương trình đó tốt hơn.
Ví dụ :
Trong hình dưới đây, bốn chương trình có độ phức tạp khác nhau. Giả sử trong
3
10 giây thì 4 chương trình giải các bài toán có kích thước tối đa trong cột 2. Nếu có
máy tốt tốc độ tăng 10 lần, thì kích thước tối đa tương ứng bốn chương trình trình bày
ở cột 3. Tỉ lệ ở hai cột 2 và 3 là ghi ở cột 4. Như vậy nếu đầu tư về tốc độ 1000% (10
lần) thì chỉ thu lợi có 30% về kích thước bài toán nếu dùng chương trình có độ phức
n
tạp 0(2 ). Do đó chỉ nên dùng chương trình này cho các bài toán kích thước nhỏ.

Cấu trúc dữ liệu – trang 16


T(n
) 2’ n2/2 5n2
300 100
0 n
200

0
100

0
n
0 5 10 15 20
Hình 1.11 : Thời gian chạy 4 chương trình.

Thời gian chạy Kích thước bài Kích thước bài toán Tỉ lệ tăng về kích
toán tối đa cho 4
T(n) 3 tối đa cho 10 giây thước
10 gy
100n 10 100 10.0 lần
5n
2 14 45 3.2 lần
3
n /2 12 27 2.3 lần
2
n 10 13 1.3 lần
Hình 1.12. Hiệu quả khi tăng tốc độ tính toán.
2.3.7. Quy tắc cộng:
Giả sử T1(n) và T2(n) là thời gian thực hiện chương trình P1 và P2 tương ứng có
độ phức tạp là 0(f(n)) và 0(g(n)).
Khi đó thời gian thực hiện chương trình P1 xong rồi thực hiện chương trình P2 là:
T(n)=T1(n)+T2(n) và T(n)=0(max(f(n), g(n))
Chứng minh : Ta cần chứng minh:

T(n)=O(f(n)) ( hằng số c>0,  hằng số n0>0 : n  n0 => T(n)  c * f(n) )
T(n)=O(max(f(n), g(n))

T(n)=O(max(f(n),g(n))) (c>0, n0>0 : n  n0 => T(n)  c * max(f(n),g(n) )
Có: T1(n)=O(f(n)) => (c1>0, n01>0 : nn01 => T1(n)  c1 * f(n) )
Có: T2(n)=O(g(n)) => (c2>0, n02>0 : nn02 => T2(n)  c2 * g(n) )
Nếu chọn n0=max(n01, n02) thì khi nn0 ta
sẽ có n  n01 => T1(n)  c1 * f(n)

Cấu trúc dữ liệu – trang 17


và có n  n02 => T2(n)  c2 * g(n)

T1(n) + T2(n)  c1 * f(n) + c2 * g(n)

T1(n) + T2(n)  (c1+c2) * max(f(n),g(n)) . c2*g(n) <= c2*max(f(n),g(n))

Vậy nếu chọn c=c1+c2, n0=max(n01,n02) thì nếu có nn0 thì ta sẽ có:
T1(n)+T2(n)  c * max(f(n),g(n)) , là điều cần phải chứng minh.
2.3.8. Hệ quả 4: Nếu một đoạn chương trình chung gồm nhiều đoạn chương trình
thành phần nối tiếp nhau thì đoạn chương trình chung sẽ có độ phức tạp là độ phức tạp
lớn nhất trong các độ phức tạp thành phần.
α α-1 α-2
2.3.9. Hệ quả 5: Nếu T(n) = a0n + a1n +a2n + . . . với a, α là các hằng số thì
α
T(n)=O(n )
6 5 4 3 2 1 0
Ví dụ : Nếu T(n)= 8n + 3n + 4n + 0n + 9n + 7n + 5n
6 5 4 2 6 6 5
Tức T(n)= 8n + 3n + 4n + 9n + 7n + 5 thì T(n)=O(n ). 834975=8*10 +6*10 +
2 3
Có 3 chương trình có thời gian thực hiện tương ứng là 0(n ) , 0(n ) và 0(n*logn).
2 3 3
Thì thời gian thực hiện 3 chương trình nối tiếp nhau là 0(max(n ,n ,n*logn) sẽ là 0(n ).
Nói chung thời gian chạy một dãy cố định các bước là thời gian chạy lớn nhất của
một bước nào đó trong dãy cũng có trường hợp có hai hay nhiều bước có thời gian
chạy không tương xứng (incomensurate) (không lớn hơn mà cũng không nhỏ hơn). Ví
dụ các bước của 0(f(n)) và 0(g(n)) là :
4 2
f(n) = n nếu n chẵn g(n)= n nếu n chẵn
2 3
n nếu n lẻ n nếu n lẻ.
Khi đó qui tắc tổng phải áp dụng trực tiếp, thời gian chạy là 0(max(f(n), g(n)) là
4 3
n nếu n chẵn và n nếu n lẻ.
Nếu g(n)  f(n) với n  n0; n0 là const nào đó thì 0(f(n) + g(n)) sẽ là 0(f(n)).
2 2
Ví dụ O(n +n) cũng bằng O(n ).
2.3.10. Quy tắc nhân:
Giả sử T1(n) và T2(n) là thời gian thực hiện chương trình P1 và P2 tương ứng có
độ phức tạp là O(f(n)) và O(g(n)).
Khi đó thời gian thực hiện 2 chương trình P1 và P2 lồng nhau là:
T(n)=T1(n)*T2(n) và T(n) = O( f(n)*g(n) )
For (i=1;i<=5; i++)
{s=s+a;
a=a+1
;
}

Cấu trúc dữ liệu – trang 18


2.3.11. Nếu các lệnh vòng lặp có số lần lặp tỉ lệ tuyến tính bậc α của n thì sẽ có
độ phức tạp là O(nα ) .
Ví dụ: for (i=1 ; i<=n ; i++) thì có độ phức tạp là O(n)
for (i=1 ; i<=n*n ; i++) 2
thì có độ phức tạp là O(n )
for (i=0 ; i<=n*n*n ; i++) 3
thì có độ phức tạp là O(n )
for (i=2 ; i<=(n+7)/3 ; i++) thì có độ phức tạp là O(n)
Ví dụ: Tính độ phức tạp của chương trình con SelectSort sắp xếp dãy số thực
A[1..n] theo thứ tự tăng dần bằng phương pháp lựa chọn.
void SelectSort ( float A[] , int n)
{ // Sắp xếp dãy số thực A[1], A[2], ... ,A[n] theo thứ tự tăng dần bằng phương
pháp lựa chọn.
int i, j ; float tg;
(1) for (i=1; i<=n-1; i++)
(2) for (j=i+1 ; j <= n; j++)
(3) if (A[i] > A[j])
{
(4) tg = A[i];
(5) A[i] = A[j];
(6) A[j] = tg ;
}
}
n là số phần tử của mảng, cũng chính là số lượng dữ liệu đầu vào của bài toán.
Ta tính độ phức tạp từ trong chi tiết nhất ra ngoài như sau:
Dòng (4): Vì lệnh gán có thời gian thực hiện là một hằng số nên có độ phức tạp O(1)
Tương tự, dòng (5) và dòng (6) có độ phức tạp là O(1).
Dòng lệnh (4-6): Theo Qui tắc cộng thì dòng lệnh (4-6) có độ phức tạp là O(max(1, 1,
1) ) = O(1).
Dòng (3) là lệnh điều kiện if. Để kiểm tra điều kiện ( A[i] > A[j] ) là đúng hay sai ta
mất một hằng thời gian, vậy có O(1).
Dòng (3-6): Ta không chắc thân lệnh if (4-6) có được thực hiện hay không, khi đó ta
xét trường hợp trung bình hoặc xấu nhất. Ở đây ta xét trường hợp xấu nhất, đó là điều
kiện đúng. Khi đó theo Qui tắc cộng thì O(max(1,1)) = O(1).
Dòng (2): Là vòng lặp tuyến tính với n nên nó có độ phức tạp là O(n).
Dòng (2-6): Theo Qui tắc nhân thì có độ phức tạp: O(n*1) = O(n).
Cấu trúc dữ liệu – trang 19
Dòng (1): Là vòng lặp tuyến tính với n nên nó có độ phức tạp là O(n).
Dòng (1-6) = Toàn bộ chương trình: Theo Qui tắc nhân thì có độ phức tạp là: O(n*n) =
2
O(n ).
2
Kết luận: Chương trình trên có độ phức tạp là O(n ).
Ví dụ: Tìm độ phức tạp của hàm con Tìm ước số chung lớn nhất của 2 số nguyên
m và n bằng giải thuật Euclid:
USCLN(m, 0) = m
USCLN(m, n) = USCLN(n, m % n)
int USCLN(int m, int n)
{ int d;
d = m % n; (1)
while (d!=0) (2)
{
m = n; (3)
n = d; (4)
d = m % n; (5)
}
return n; (6)
}
Thời gian thực hiện giải thuật phụ thuộc vào số nhỏ nhất trong 2 số nguyên m và
n. Giả sử m >= n > 0 , do đó cỡ của dữ liệu vào là n.
Mỗi lệnh trong các lệnh (1) , (3), (4), (5), (6) đều có độ phức tạp là O(1) . Vì vậy
thời gian thực hiện giải thuật là thời gian thực hiện lệnh while. Độ phức tạp của đoạn
lệnh (3-5) là O(1). Ta xét xem số lần lặp của vòng lặp while.
Ta có: m = n * q1 + d1 , với 0 <= d1 < n
n = d1 * q2 + d2 , với 0 <= d2 <d1
Nếu d1 <= n/2 thì d2 < d1 <= n/2 nên d2 < n/2
Còn nếu d1 > n/2 thì q2 = 1, khi đó n = d1 + d2, nên d2 <n/2
Vậy ta luôn có d2 < n/2. Như vậy, cứ 2 lần thực hiện vòng lặp while thì phần dư d giảm
k
đi hơn một nửa của n. Gọi k là số nguyên lớn nhất sao cho 2 <= n . Số lần lặp tối đa là
2k+1<=2log2n +1. Vậy độ phức tạp của vòng lặp (2-5) là O(log 2n) . Đây cũng là độ
phức tạp của hàm con đã cho.
Có những phương pháp sắp thứ tự nhanh hơn, tốn thời gian chỉ 0(nlogn), ví dụ
quicksort, heapsort,...
Cấu trúc dữ liệu – trang 20
Không có các qui tắc đầy đủ để phân tích chương trình. Nói chung thời gian chạy
một lệnh hoặc một nhóm lệnh có thể là một hàm của kích thước các input hoặc một
hoặc nhiều biến. Nhưng chỉ có n - kích thước của bài toán là thông số cho phép đối với
thời gian chạy chương trình.
1. Thời gian chạy mỗi lệnh assignment, read và write có giả thiết là 0(1). Có một
ngoại lệ nhỏ, như trong PL/1 các lệnh assignments cho phép đối với mảng lớn tùy ý; và
trong ngôn ngữ cho phép gọi hàm trong các lệnh assignments.
2. Thời gian chạy của một dãy các lệnh xác định theo qui tắc tổng, nghĩa là thời
gian chạy của dãy là thời gian lớn nhất của một lệnh nào đó trong dãy.
3. Thời gian chạy lệnh if là thời gian thực hiện lệnh điều kiện cộng với thời gian
kiểm tra điều kiện (thường là 0(1)). Thời gian thực hiện lệnh if có cấu trúc if - then -
else là thời gian kiểm tra điều kiện cộng thời gian lớn nhất của một trong hai lệnh rẽ
nhánh true và false.
4. Thời gian thực hiện vòng lặp là tổng thời gian thực hiện thân vòng lặp và thời
gian kiểm tra điều kiện kết thúc lặp (thường là 0(1)). Cần xét mỗi vòng lặp riêng biệt.
Chú ý trường hợp vòng lặp có thể lặp vô hạn.
- Gọi thủ tục (Procedure calls)
Nếu chương trình có các thủ tục và không có các thủ tục nào là đệ qui (recursive)...
thì ta có thể tính thời gian chạy của các thủ tục cùng một lúc, bắt đầu từ các thủ tục không
gọi đến các thủ tục khác. Tất nhiên phải có ít nhất một thủ tục như vậy trong trường hợp
này nếu không phải có ít nhất một thủ tục đệ qui. Sau đó ta có thể đánh giá thời gian chạy
của các thủ tục có gọi đến các thủ tục không chứa lời gọi đã được đánh giá. Cứ như thế ta
lại đánh giá thời gian chạy của các thủ tục có lời gọi đến các thủ tục đã đánh giá, nghĩa là
mỗi thủ tục được đánh giá sau khi đánh giá hết các thủ tục được nó gọi.
Nếu có các thủ tục đệ qui thì không thể tìm được thứ tự của tất cả các thủ tục sao
cho mỗi thủ tục chỉ gọi đến các thủ tục đã đánh giá. Khi đó ta phải lập mối liên hệ giữa
mỗi thủ tục đệ qui với một hàm thời gian chưa biết T(n), trong đó n là kích thước của
đối số của thủ tục. Lúc đó ta có thể nhận được sự truy hồi (recurrence) đối với T(n),
nghĩa là một phương trình diễn tả T(n) qua các T(k) với các giá trị k khác nhau. Cách
xây dựng và giải các phương trình qui hồi này sẽ xét trong chương 5. Ở đây ta xét một
ví dụ đơn giản của chương trình đệ qui.
Ví dụ: Tính độ phức tạp của chương trình tính n giai thừa bằng đệ quy:
Hình 1.4 là chương trình đệ qui tính n giai thừa : n!, trong đó n là kích thước của
hàm nêu trên. Ta ký hiệu T(n) là thời gian chạy để tính hàm fact(n) là n! .Thời gian chạy
đối với các dòng (1) và (2) là 0(1) và đối với dòng (3) là 0(1)+T(n-1). Vậy với các hằng b
và a nào đó ta có phương trình: (a là thời gian kiểm tra xem số n có bằng 0 hay không, b
là thời gian vừa kiểm tra xem số n bằng 0 không, cộng thêm thời gian thực hiện phép
toán nhân nào đó.) n=3. 3!=1*2*3=
n=4. 4!=1*2*3*4=
Cấu trúc dữ liệu – trang 21
n=n n!=1*2*3*…*(n-1)*n=(n-1)!*n
Qui ước thêm: 0!=1. n>=0
ế >0
()={ + ( −1)

ế =0

long fact (int n) {fact (n) tính n !}


{
(1) if (n==0) // a
(2) return 1;
else return fact(n-1)*n ; // b
}
Gọi T(n) là thời gian thực hiện fact(n) để tính n! fact(n-1)
Gọi a là thời gian để kiểm tra xem một số nào đó có bằng số 0 hay không.
Gọi b là thời gian thực hiện một phép toán (nhân) nào đó.
Với n=0 thì T(0) = a
Với n=1 thì T(1) = a + ( T(0) + b ) = a + ( a + b) = 2a+b
Với n=2 thì T(2) = a + ( T(1) + b ) = a + ( 2a + b + b) = 3a+2b Với
n=3 thì T(3) = a + ( T(2) + b ) = a + ( 3a + 2b + b) = 4a+3b Tổng
quát: T(n)=(n+1)a + n*b = n*a + a + n*b = (a+b)n + a = O(n) Vậy
chương trình trên có độ phức tạp là O(n).
- Chương trình có GOTO :
Khi phân tích thời gian chạy của chương trình, ta ngầm giả thiết rằng các quá
trình điều khiển trong thủ tục được xác định bằng các cấu trúc lặp và rẽ nhánh. Để tính
thời gian chạy của nhiều nhóm lệnh ta giả thiết là ta chỉ cần qui tắc cộng để nhóm đồng
thời dãy các lệnh. Do đó các lệnh goto sẽ gây thêm khó khăn cho việc nhóm các lệnh,
nhưng Pascal vẫn dùng các lệnh goto để thoát khỏi các vòng lặp.
(1) Dùng lệnh goto từ trong vòng lặp nhảy đến nhãn ở đầu hoặc giữa vòng lặp để
lặp tiếp. Đó là những trường hợp chỉ sử dụng khi thật cần thiết. Vì câu lệnh goto thực
hiện bên trong vòng lặp nên ta có thể “giả vờ” như không có các lệnh này.
(2) Lệnh goto đưa chương trình đến lệnh tiếp theo sau khi việc lặp kết thúc nên
vai trò của goto vẫn cần thiết, và việc bỏ nó cũng không làm thời gian thực hiện
chương trình tăng lên.
(3) Trường hợp có lệnh goto ngược về phần chương trình đã thực hiện là không
thể bỏ qua vì goto này có thể tạo ra vòng lặp tốn thêm thời gian.

Cấu trúc dữ liệu – trang 22


Các vòng lặp có thể có cấu trúc hợp lệ như song song hoặc lồng nhau, và ta phải
xác định được các cấu trúc đó. Vì vậy chúng ta có thể áp dụng phương pháp phân tích
này cho các chương trình ngôn ngữ tựa Fortran.
Kinh nghiệm lập trình :
Khi thực hành chúng ta thường mất nhiều thời gian vì những vấn đề tưởng như
tầm thường. Vì vậy cần đưa những vấn đề ấy vào kế hoạch thực hiện. Những bước cần
chú ý là :
1. Kế hoạch thiết kế chương
trình : Gồm các bước :
- Vẽ sơ đồ Giải thuật một cách không chính
thức. - Viết chương trình giả (preudo - program).
- Tinh chế dần chương trình giả để có chương trình hoàn
chỉnh. Đó là chiến thuật “sketch - then - detail”.
2. Tính đóng kín (encapsulate)
Sử dụng các thủ tục ADT để đưa mã của phép toán chính và kiểu dữ kiện vào
một chỗ trong listing chương trình. Sau đó nếu cần thay đổi thì phần mã này có thể
được cục bộ hóa.
3. Sử dụng hoặc sửa chữa chương trình đã có.
4. Tính khái quát (be a toolsmith).
Nên viết chương trình có tính khái quát nghĩa là có thể áp dụng cho nhiều trường
hợp tương tự.
Ví dụ viết một chương trình vừa có thể giải quyết bài toán tô màu đồ thị sao cho
với số đỉnh đã cho tô ít màu nhất, vừa có thể giải quyết bài toán lập lịch thi. Trong
“ngữ cảnh” lịch thi số đỉnh là số lớp, số màu là số kỳ thi và cạnh nối hai đỉnh tương
ứng cạnh nối hai lớp có nghĩa là các lớp có chung sinh viên (có sinh viên ở chung hai
lớp). Chương trình tô màu cần có các chương trình con chuyển danh sách các lớp ra
các đỉnh đồ thị và các màu ra thời gian và ngày thi. Khi đó ta sẽ có chương trình lập
lịch thi. Chương trình lập hệ thống đèn hướng dẫn giao thông ở giao lộ chính là ứng
dụng từ chương trình tô màu.
5. Chương trình ở cấp lệnh - tức là chương trình đã viết ra dạng các câu lệnh của
một ngôn ngữ cụ thể
---o-O-o---

Cấu trúc dữ liệu – trang 23


CHƯƠNG 3. GIẢI THUẬT ĐỆ QUY
3.1. Giới thiệu:
Trong thân một chương trình mà có lệnh gọi ngay chính nó thực hiện thì gọi là
tính đệ qui của chương trình.
Các lưu ý khi dùng giải thuật đệ quy:
- Tham số hóa bài toán: để thể hiện kích cỡ của bài toán.
- Tìm trường hợp dễ nhất: mà ta biết ngay kết quả bài toán.
- Tìm trường hợp tổng quát: để đưa bài toán với kích cỡ lớn về bài toán tương tự
có kích cỡ nhỏ hơn.
3.2. Các bài toán ứng dụng:
3.2.1. Tính giai thừa:
Viết chương trình tính giai thừa của số nguyên không âm n bằng giải thuật đệ qui.
- Tham số hóa bài toán: Gọi n là số nguyên không âm cần tính giai thừa.
- Trường hợp dễ nhất: Nếu n=0 thì n!=1
- Trường hợp tổng quát: Nếu n>0 thì n!=(n-1)!*n
#include <stdio.h>
#include <conio.h>
long fact(int n)
{ if (n==0) return 1; else
return n*fact(n-1);
}
main()
{ int n; long kq;
printf("\n Nhap so can tinh giai thua n="); scanf("%d",
&n); kq=fact(n);
printf("\n Ket qua %d! = %ld", n, kq);
getch();
}
3.2.2. Tính Ước số chung lớn nhất:
Viết chương trình tính ước số chung lớn nhất của 2 số nguyên x và y.
- Tham số hóa bài toán: Gọi x và y là 2 số nguyên cần tính ước số chung lớn nhất.
- Trường hợp dễ nhất: Nếu x=y thì ước chung lớn nhất của chúng là x.

Cấu trúc dữ liệu – trang 24


- Trường hợp tổng quát: Nếu x< >y thì
USCLN(x, y)=USCLN(x , y-x) nếu
x<y USCLN(x, y)=USCLN(x-y , y)
nếu y<x
Chẳng hạn, tìm ước số chung lớn nhất của 30 và 18.
30 có các ước số là: 1,2,3 , 5 ,6,10,15,30
18 có các ước số là: 1 , 2 , 3 , 6 ,9,18
30 và 18 có các ước số chung là: 1 , 2 , 3 , 6
Ước số chung lớn nhất của 30 và 18 là: 6
#include <stdio.h>
#include <conio.h>
int uscln ( int x , int y)
{ if ( x==y ) return x ;
else if ( x < y ) return uscln ( x , y-x ) ;
else return uscln ( x-y , y) ;
}
main()
{ int a , b , kq ;
printf ( " \n Nhap so nguyen thu nhat : " ) ; scanf ( "%d" , &a ) ;
printf ( " \n Nhap so nguyen thu hai : ") ; scanf ( " %d " , &b ) ;
kq = uscln ( a , b ) ;
printf ( "\n Uoc so chung lon nhat la: %d " , kq );
getch();
}
---o-O-o---
Cấu trúc dữ liệu – trang 25
CHƯƠNG 4: GIẢI THUẬT CHIA ĐỂ TRỊ
4.1. Giới thiệu:
Đây là giải thuật quan trọng rất hay ứng dụng để thiết kế các giải thuật hữu hiệu.
Ý chính của nó là: Từ bài toán ban đầu kích thước lớn, ta chia nó ra thành nhiều
bài toán con tương tự như bài toán ban đầu nhưng có kích thước nhỏ hơn, rồi lại chia
mỗi bài toán con ra thành nhiều bài toán con tương tự có kích thước nhỏ hơn nữa, tiếp
tục cho đến khi ta nhận được các bài toán con có kích thước đủ nhỏ mà ta dễ dàng
giải được thì dừng chia và giải các bài toán con, sau đó tổng hợp nghiệm các bài toán
con này ta sẽ có được nghiệm của các bài toán con lớn hơn, và tiếp tục thì cuối cùng
ta có được nghiệm của bài toán ban đầu.
Các lưu ý khi dùng giải thuật Chia để trị:
- Tham số hóa bài toán: để thể hiện kích thước của bài toán.
- Tìm trường hợp dễ nhất: mà ta biết ngay được kết quả bài toán.
- Tìm trường hợp tổng quát: để đưa bài toán với kích thước lớn về bài toán tương
tự có kích thước nhỏ hơn.
4.2. Các bài toán ứng dụng:
4.2.1. Tìm giá trị lớn nhất và giá trị nhỏ nhất
Ví dụ 1: Tìm giá trị lớn nhất:
Cho trước mảng số thực A[x..y] gồm từ phần tử thứ x đến phần tử thứ y.
Bài toán: Tìm giá trị lớn nhất trong mảng số thực A[x..y].
Tham số hóa bài toán: Gọi x là chỉ số đầu, gọi y là chỉ số cuối của mảng A.
Trường hợp dễ nhất: Nếu x=y thì mảng A có duy nhất 1 phần tử, vậy giá trị lớn
nhất là phần tử A[x].
Trường hợp tổng quát: Nếu x<y thì mảng A có nhiều phần tử, khi đó ta dùng giải
thuật Chia để trị như sau: Chia mảng A[x..y] thành 2 nửa A[x..(x+y)/2] và A[(x+y)/2+1
.. y] , tìm giá trị lớn nhất của mỗi nửa, sau đó trả về giá trị lớn nhất trong hai giá trị lớn
nhất đó.
Ví dụ 2: Tìm giá trị nhỏ nhất:
Bài toán: Tìm giá trị nhỏ nhất trong mảng A[x..y].
Trường hợp dễ nhất: Nếu x=y thì mảng A có duy nhất 1 phần tử, vậy giá trị nhỏ
nhất là phần tử A[x].
Nếu x<y thì mảng A có nhiều phần tử, khi đó ta dùng giải thuật Chia để trị như
sau: Chia mảng A[x..y] thành 2 nửa A[x..(x+y)/2] và A[(x+y)/2+1 .. y] , tìm giá trị nhỏ
nhất của mỗi nửa, sau đó trả về giá trị nhỏ nhất trong hai giá trị nhỏ nhất đó.
Ví dụ 3: Vừa tìm giá trị lớn nhất và vừa tìm giá trị nhỏ nhất:

Cấu trúc dữ liệu – trang 26


Bài toán: Tìm giá trị lớn nhất và giá trị nhỏ nhất trong mảng A[x..y].
Trường hợp dễ nhất: Nếu x=y thì mảng A có duy nhất 1 phần tử, vậy giá trị lớn
nhất và giá trị nhỏ nhất đều là phần tử A[x].
Nếu x<y thì mảng A có nhiều phần tử, khi đó ta dùng giải thuật Chia để trị như
sau: Chia mảng A[x..y] thành 2 nửa A[x..(x+y)/2] và A[(x+y)/2+1 .. y] , tìm giá trị lớn
nhất và giá trị nhỏ nhất của mỗi nửa, sau đó trả về giá trị nhỏ nhất trong hai giá trị nhỏ
nhất, và trả về giá trị lớn nhất trong hai giá trị lớn nhất.

Algorithm MaxMin(a, x, y)
Input: mảng a[x..y] với x là chỉ số trái nhất và y là chỉ số phải nhất.
Output: giá trị lớn nhất max và giá trị nhỏ nhất min.
Begin
If (y-x ≤ 1) then
Return ( max(a[x], a[y]) , min(a[x], a[y]) )
Else
(max1, min1) ← MaxMin(a, x, (x+y)/2)
(max2, min2) ← MaxMin(a, ((x+y)/2)+1,y)
Return (max(max1, max2) , min(min1, min2) )
Endif
end
trong đó max() và min() tương ứng là các hàm đơn giản tính giá trị lớn nhất và giá trị
nhỏ nhất của 2 số.
void gtlnnn(float A[] , int x , int y , float &min , float &max)
{ if (x==y) { min=A[x]; max=A[x];}
else { float min1 , max1 , min2 , max2;
gtlnnn(A , x , (x+y)/2 , min1 , max1);
gtlnnn(A , (x+y)/2+1 , y , min2,
max2);
if (max1>max2) max = max1 ; else max = max2; if
(min1 < min2) min = min1 ; else min = min2;
}
}
4.2.2. Bài toán Tháp Hà Nội (The towers of Hanoi)
Cấu trúc dữ liệu – trang 27
Hãy viết chương trình chuyển n đĩa từ cột A (trong đó đĩa lớn ở dưới, đĩa nhỏ ở
trên) sang cột B các với điều kiện:
- Mỗi lần chỉ được chuyển một đĩa.
- Trên các cọc: luôn luôn đĩa lớn ở dưới, đĩa nhỏ ở trên.
- Được dùng cọc trung gian thứ ba C.
Tất nhiên có nhiều cách giải. Ví dụ cách đơn giản là: ta hình dung các cột xếp
theo hình tam giác (3 đỉnh A, B, C). Trong số lần chuyển lẻ ta chuyển đĩa nhỏ nhất theo
chiều kim đồng hồ, còn các lần chẵn, ta chọn cách chuyển hợp lệ, trừ đĩa nhỏ nhất.
A B C

3
2
1

Giải thuật này ngắn gọn và đúng nhưng khó hiểu vì sao làm như vậy. Bây giờ ta
xét phương pháp Divide - and - conquer.
Bài toán chuyển n đĩa từ A sang B có thể gồm hai bài toán con kích thước n-1
.Đầu tiên chuyển n-1 đĩa nhỏ nhất (các đĩa phía trên) từ A sang C, giữ lại đĩa dưới cùng
trên A. Sau đó chuyển đĩa này từ A sang B, sau đó chuyển n-1 đĩa từ C sang B.
Việc chuyển n-1 đĩa đã hoàn tất do áp dụng đệ qui của phương pháp. Mặc dù cụ
thể khó thấy do chứa các lần gọi đệ qui trong stack, nhưng giải thuật là dễ hiểu và dễ
chứng minh sự đúng đắn. Đó là sự rõ ràng của giải thuật divide - conquer và ta sẽ thấy
nó có hiệu quả hơn nhiều giải thuật khác trong nhiều trường hợp. Từ giải thuật (hình
5.1) ta dễ thấy độ phức tạp thời gian của nó xác định bằng phương trình:
1 ế =1
( )={

2 ( − 1) + 1 ế > 1

Giải: - Tham số hóa bài toán:


Gọi n: là số lượng đĩa cần chuyển.
x: cọc xuất phát.
y: cọc đích.
z: cọc trung gian.
Hàm con CHUYEN(n, x, y, z) dùng để chuyển n đĩa từ cọc xuất phát x sang cọc đích y
với cọc trung gian z. CHUYEN ( n-1 , z , y , x )
- Tìm trường hợp dễ nhất:
n=1 : khi đó ta chuyển đĩa từ cọc x sang cọc y.

Cấu trúc dữ liệu – trang 28


- Tìm trường hợp tổng quát:
B1: Chuyển n-1 đĩa từ cọc xuất phát x sang cọc trung gian z.
B2: Chuyển 1 đĩa từ cọc xuất phát x sang cọc đích y.
B3: Chuyển n-1 đĩa từ cọc trung gian z sang cọc đích y.
#include <stdio.h>
#include <conio.h>
int i;
void CHUYEN(int n, char x, char y, char z)
{ if (n==1)
{ i++;
printf("\n %d : %c --> %c", i, x, y);
}
else { CHUYEN(n-1, x, z, y);
CHUYEN(1, x, y, z);
CHUYEN(n-1, z, y, x);
}
}
main()
{ int n;
printf("\n Nhap so dia can chuyen:"); scanf("%d", &n);
CHUYEN(n, 'A', 'B', 'C');
getch();
}
4.2.3. Xây dựng lịch thi đấu Tennis :
k
Cần lập lịch đấu Tennis theo vòng tròn. Số cầu thủ là n=2 =2, 4, 8, 16, … Mỗi
cầu thủ phải đấu tay đôi với một cầu thủ khác. Mỗi ngày một cầu thủ chỉ đấu 1 trận,
đấu trong n-1 ngày. Lập lịch thi đấu sao cho n người đấu vòng tròn với số ngày ít nhất.
Kỹ thuật Chia để trị xây dựng lịch cho một nửa số cầu thủ. Lịch này được lập nên
do áp dụng đệ qui của Giải thuật bằng cách tìm lịch cho một nửa số cầu thủ đó... Khi
chỉ còn hai cầu thủ thì ta có trường hợp cơ sở là một cặp giản đơn.
Giả sử có tám cầu thủ. Lịch thi cho 4 cầu thủ từ 1 đến 4 lấp đầy góc trái trên (4
dòng, 3 cột) coi như đã lập xong. Góc trái dưới (4 dòng, 3 cột) phải cho vào các cầu thủ
có số thứ tự cao (5-8) ngược với các tổ khác. Lịch biểu con này tạo ra bằng cách cộng 4
cho mỗi số nguyên của góc trái trên.
Cấu trúc dữ liệu – trang 29
Bây giờ ta đã làm đơn giản bài toán. Tất cả phần còn lại là các cầu thủ có số thấp
chơi với số cầu thủ có số cao. Điều đó dễ hiểu là cầu thủ từ 1-4 chơi với các cầu thủ từ
5-8 tương ứng từ ngày thứ tư và hoàn vị theo chu kỳ 5 đến 8 trong các ngày tiếp theo
(xem bảng hình 5.3)
Ngày 1 Ngày 1 2 3 Ngày 1 2 3 4 5 6 7

Cầu thủ 1 2 1 2 3 4 12345678


Cầu thủ
Cầu thủ 2 1 2 1 4 3 21436785
3 4 1 2 3 4 1 2 7 8 5 6
4 3 2 1 4 3 2 1 8 5 6 7
5 6 7 8 1 4 3 2
6 5 8 7 2 1 4 3
7 8 5 6 3 2 1 4
8 7 6 5 4 3 2 1

Hình 5.3 : Lịch thi đấu vòng tròn 8 người.


k
Ta có thể vận dụng tư tưởng này để lập lịch thi đấu cho n=2 cầu thủ với k bất kỳ.
Ta xây dựng bảng ma trận A gồm n hàng, n-1 cột. Dòng trên cùng ghi các ngày,
cột trái ngoài cùng ghi thứ tự cầu thủ. Trong ngày thứ j, cầu thủ thứ i sẽ đấu với cầu
thủ ghi tại dòng i cột j trong ma trận A. (i=1,2,...n ; j=1,2,...,n-1).
int n, i, j;
int A[129][128];
void Tennis(int n)
{ int m, i, j, x;
if (n==2)
{ A[1][1]=2; A[2][1]=1;
}
else
{ m=n/2;
Tennis(m); // vung 1 tren trai
for (i=m+1; i<=n; i++)
for (j=1; j<=m-1; j++)
A[i][j]=A[i-m][j]+m; // vung 2 duoi trai
for (i=1; i<=m; i++)
for (j=m; j<=n-1; j++)
{ x=i+j;
Cấu trúc dữ liệu – trang 30
if (x>n) x=x-m;
A[i][j]=x; // vung 3 tren phai
A[x][j]=i; // vung 4 duoi phai
}
}
}
Hình 5.4. Giải thuật lập lịch thi đấu cho n cầu thủ.
4.2.4. Bài toán nhân các số nguyên lớn :
Xét bài toàn nhân hai số nguyên n bit : X và Y. Thường thường phải nhân n lần
2
cho mỗi bít, tức Giải thuật tốn 0(n ) bước. Kỹ thuật divide - and - conquer trong trường
hợp này là tách mỗi X và Y ra hai số nguyên n/2 bít. Để đơn giản ta giả thiết n là lũy
thừa c2.
X:= A B n/2
X=A*2 +B

Y:= C D n/2
Y= C*2 +D
Khi đó ta có thể viết :
n n/2
X * Y = A * C * 2 + (A * D + B * C) * 2 + B * D (4.1)
Nếu đánh giá trực tiếp X*Y thì tốn bốn phép nhân số nguyên dài n/2 bit, ba phép
n n/2
cộng các số nguyên dài nhất là 2n bit và hai phép dịch chuyển (nhân cho 2 và 2 ).
Các phép cộng và dịch chuyển mất 0(n) bước. Nếu T(n) là tổng các phép toán bít để
nhân hai số nguyên dài n bit theo (5.1) thì ta có phương trình truy hồi :
T (1) = 1
T (n) = 4T (n/2) + cn (4.2)
Tương tự ví dụ 5.4 ta có thể lấy hằng c trong (4.2) là 1, hàm tái d(n) đúng là n và
2
sau đó suy ra nghiệm thuần nhất và nghiệm riêng đều là 0(n ). Như vậy công thức nhân
hai số nguyên theo (4.1) cũng không gì hơn phương pháp nhân trong trường phổ thông.
Nếu dùng lại (4.2) để cải tiến thì phải giảm số bài toán con. Muốn vậy ta xét công thức
sau đây :
n n/2
X * Y = A*C * 2 + [(A - B) * (D - C) + A*C + B*D]*2 + B * D (5.3)
(4.3) tuy phức tạp hơn (4.1) nhưng chỉ có ba phéo nhân số nguyên n/2 bit. Sáu
phép cộng hoặc trừ và hai phép chuyển. Ta có công thức cho T(n) :
T(1)=1
T (n) = 3T(n/2) + cn
3 1.59
Nghiệm T (n) 0 (nlog ) hay 0 (n )

Cấu trúc dữ liệu – trang 31


Giải thuật hoàn chỉnh và còn cải tiến là nhân các số nguyên âm, dương tùy ý n/2
bit, cho trong hình 5.2.
Function Mult (X, Y, n : integer) : integer;
n
{X, Y là hai số nguyên có dấu < 2 , n lũy thừa 2, hàm trả về X, Y}
var s : integer; [giữ dấu của X, Y]
m1, m2, m3 : integer; [3 tích số]
A, B, C, D : integer; [các nửa trái và phải của X, Y]
Begin
(1) s : = sign (X) * sign (Y)
(2) X : = abs (X);
(3) Y : = abs (Y);
(4) if : n = 1 then
(5) ìf : (X = 1) and (Y = 1) then return (s)
(6) else return (0)
else
begin
(7) A : = left n/2 bit của X;
(8) B : = right n/n bit của X;
(9) C : = left n/2 bit của Y;
(10) D : = right n/2 bit của Y;
(11) m1 : = Mult (A, C, n/2);
(12) m2 : = Mult (A - B, C - D, n/2);
(13) m3 : = Mult (B, D, n/2);
n n/2
(14) return (s*(m1 * 2 + (m1 + m2 + m3) * 2 + m3))
end;
end; [Mult]
Hình 4.2. Giải thuật Nhân số nguyên theo phương pháp Chia để trị.
n n/2
Nhận xét : Các dòng (7) - (10) thực hiện bằng việc copy các bit. Nhân 2 và 2
trong dòng (14) là dịch chuyển các bit, nhân cho S là đưa dấu vào kết quả. Giải thuật
nhanh hơn nhưng không phổ biến trong học sinh vì mô tả phức tạp trên máy, khó học,
hơn nữa ta cũng bỏ qua các hằng số tỷ lệ.
4.2.5. Nhân ma trận (Giải thuật Stracen)

Cấu trúc dữ liệu – trang 32


Cho A và B là ma trận trên cấp n x n, với n là lũy thừa của 2. Khi đó có thể chia
mỗi ma trận A và B ra 4 ma trận con cấp (n/2 x n/2) và có thể biểu diễn tích hai ma
trận A x B là C = A.B qua 4 ma trận con đó.
A 11
A 12  B 11
B 12  C C 
     11 12

A A B B C C
 21 22
  21 22
  21 22

Trong đó : C11 = A11 B11 + A12
B21
C12 = A11 B12 + A12
B22
C21 = A21 B11 + A22
B21
C22 = A21 B12 + A22
B22
Nhưng để nhận 2 ma trận cấp (2 x 2) ta thấy chỉ cần 7 phép nhân và 18 phép
cộng trừ như sau :
c c  a a  b b
 11
12  11 12   11

12 
c c a a b b
 21 22   21 22   21 22 
Đầu tiên tính 7 tích :
m1 = (a12 - a22) (b21 + b22)
m2 = (a11 + a22) (b11 + b22)
m3 = (a11 - a21) (b11 + b12)
m4 = (a11 + a12) b22
m5 = a11 (b12 - b22)
m6 = a22 (b21 - b21)
m7 = (a21 - a22) b11
Sau đó tính theo công thức :
c11 = m1 + m2 - m4 + m6
c12 = m4 + m5
c21 = m6 + m7
c22 = m2 - m3 + m5 - m7
Vậy nếu A và B là hai ma trận cấp (2x2) mà các thành phần mỗi ma trận là các
ma trận cấp (n/2 x n/2) thì tích của nó có thể biểu diễn qua 18 tổng và 7 tích của các ma
trận cấp (n/2 x n/2) (công thức (5.3)). Do đó bằng cách sử dụng đệ qui của giải thuật
này ta có thể nhân hai ma trận cấp (n x n) với thời gian T(n) được tính là :
2
T(n) = 7T (n/2) + 18 (n/2) n>2
logn 7 3
Tức O(7 ) hay O(n*log ), bình thường mất O(n ).
4.2.6. Thay đổi hai phần trong một dãy :

Cấu trúc dữ liệu – trang 33


Giả sử T là dãy (array) n phần tử. Ta cần chuyển m phần tử từ vị trí i sang vị trí j
của mảng mà không dùng một mảng phụ. Ví dụ ta muốn chuyển ba phần tử đầu (i = 1)
ra cuối (j = 9) như sau : (hình 4.4).

i = 1 m = 3 j = 9
m = 3

Mảng đầu

Mảng kết quả

M phàn tử m phần
tử

i Hoán vị J

Hình 4.4. Chuyển m phần tử từ vị trí i sáng vị trí j.


Điều kiện : m < i + m  j  n - m + 1
Chúng ta có thể giải bài toán này bằng cách sử dụng nhiều lần thủ tục hoán vị
từng nhóm phần tử, ví dụ để hoán vị m phần tử từ vị trí i cho m phần tử từ vị trí j ta viết
exchange (i, j, m). Đặc biệt nếu m=n/2 (n chẵn) tức là i=1 , j=n/2+1 thì bài toán được
giải chỉ cần một lần gọi exchange(1,n/2+1,n/2) (với n chẵn).
Thời gian thực hiện hoán vị m phần tử rõ ràng là tốn O(m) thời gian :
Procedure EXCHANGE (I,j,m: int eger; var T : array [1...n] of real);
Var k : integer;
Begin
For k : = 0 to m – 1 do hoán vị (T[I + k], T[j – k]);
End ;
Để đơn giản ta giả thiết chuyển m phần tử từ đầu mảng ra cuối mảng (xem Giải
thuật hình 5.5 và minh hoạ hình 5.6).
Procedure TRANSPOSE (var T : array [1..n] of real: m : integer)
var I : integer;
begin
I : = m ; j : = n – m; m : = m + 1;
While –I < > j do
If I > j then begin exchange (m – I, m, j, T);
Cấu trúc dữ liệu – trang 34
I : = I – j ; end
else begin j : = j – 1 ; exchange (m-I, m+j, I, T);
end; Exchange (m – I, m, I, T);
End; {TRANSPOSE}
Hình 4.5. Giải thuật chuyển m phần tử ở đầu mảng đến cuối mảng.

Hình 4.6. Quá trình của TRANSPOSE (T,3)


Nếu ta đặt T (I,j) số phần từ cần hoán vị để chuyển khối I phần tử và khối j phần
tử, thì ta có phương trình truy hồi như sau :
T(I,j) = I nếu i=j
T(I,j) = j + T(i-j, j) nếu I >j
T(I,j) = I + T(I, j-i) nếu j>i
Theo ví dụ cụ thể ở hình 5.6 thì :
T(3,8) =3+T(3,5) =3+3+T(3,2)
=3+3+2+T(1,2) =3+3+2+1+T(1,1)
=3+3+2+1+1 =10
Có thể kiểm tra để thấy là :
T(I, j) = I + j – ged (I, j)
Trong đó ged – là hàm ước số chung lớn nhất của I và j.
4.2.7. Sự cân bằng các bài toán con :
Cấu trúc dữ liệu – trang 35
Đây là vấn đề quan trọng, ví dụ nếu sort n phần tử mà chia thành hai bài toán con
kích thước 1 và n – 1 thì giá phải trả để trộn hai dãy đã sort từ hai bài toán là nghiệm
phương trình :
T (n) = T (1) + T (n – 1) + n
2
Tức là cấp O(n ), trong khi đó nếu tách thành 2 bài toán con kích thước n/2 (n –
lũy thừa 2) như trong giải thuật MergeSort thì chỉ mất thời gian O(n*logn).

BÀI TẬP CHƯƠNG:


1. Cho mảng A gồm n số thực A[1..n]
a. Dùng giải thuật Chia để trị, viết hàm con tính tổng các phần tử của mảng.
b. Dùng giải thuật Chia để trị, viết hàm con tính tổng các phần tử dương của
mảng.
c. Dùng giải thuật Chia để trị, viết hàm con đếm số lượng phần tử dương của
mảng.
2. Dùng giải thuật Chia để trị, viết hàm con tính tổng n số hạng sau đây:
2 2 2 2
S =10 +13 +16 +19 +...
3. Dùng giải thuật Chia để trị, viết hàm con tính tổng n số hạng sau đây:
2 3 4
S=x+x +x +x +...
Với x là 1 số thực, n là 1 số nguyên nào đó cho trước.
---o-O-o---

Cấu trúc dữ liệu – trang 36


CHƯƠNG 5: GIẢI THUẬT QUY HOẠCH
ĐỘNG 5.1. Giới thiệu
Giải thuật Đệ quy chỉ có hiệu quả khi tổng kích thước các bài toán con là tỉ lệ
với n. Khi đó ta có độ phức tạp cấp đa thức. Ngược lại nếu bài toán cỡ n chia ra n bài
toán con, mỗi bài toán con cỡ n-1 thì dùng giải thuật đệ qui đạt độ phức tạp cấp mũ (ví
dụ bài toán Tháp Hà Nội - dùng giải thuật đệ quy). Chia Để Trị tính từ lớn đến nhỏ.
Giải thuật Qui hoạch động dựa vào nguyên lý tối ưu của Bellman là: Trong một
dãy tối ưu của các lựa chọn thì mọi dãy con của nó cũng là tối ưu. Quy Hoạch Động
tính từ nhỏ đến lớn.
Quá trình tính toán của giải thuật này là lập bảng lưu lại các nghiệm của các bài
toán con.
F6=?
Chia để trị:
f6=f4+f5=(f2+f3)+(f3+f4)=(f2+f1+f2)+(f1+f2+f2+f3)=(1+1+1)+(1+1+1+2)=
Quy hoạch động:f1=1, f2=1, f3=f1+f2=2, f4=f2+f3=1+2=3, f5=f3+f4=2+3=5,
F6=f4+f5=3+5=8 f[4]=3;
5.2. Các bài toán ứng dụng:
5.2.1. Dãy số Fibonacci:
Dãy số Fibonacci: 1, 1, 2, 3, 5, 8, 13 , 21 , 34 , 55 , 89 , 144 , …
f1=1, f2=1, f3=2, f4=3, f5=5, f6=8, f7=13, . . .
Bắt đầu từ số thứ 3 trở đi, mỗi số tiếp theo bằng tổng của hai số liền trước.
Dãy số Fibonacci được định nghĩa bởi hệ thức truy hồi như sau:
f1=1 và f2=1
fn=fn-1 + fn-2 với n≥3
Hãy viết hàm tính giá trị của phần tử thứ n của dãy.
Giải thuật Chia để trị:
long Fibo1(int n)
{ if (n==1 || n==2) return 1;
else return Fibo1(n-2)+Fibo1(n-1);
}
Giải thuật Chia để trị này rất đơn giản, dễ hiểu, tuy nhiên khi thực hiện lời gọi
hàm Fibo1(n) để tính số Fibonacci thứ n thì có rất nhiều lời gọi lặp lại hàm.

Cấu trúc dữ liệu – trang 37


Giải thuật Quy hoạch động: Khi áp dụng giải thuật Quy hoạch động tính số
Fibonacci, nhằm tránh việc lặp lại tính toán, ta dùng mảng f [] để lưu lại các giá trị
được tính toán trước đó:
f [1]=1 và f [2]=1
f [n]=f [n-1] + f [n-2] với n≥3
long f[100];
void Fibo2(int n)
{ int i;
f [1]=1; f [2]=1; for
(i=3; i<=n; i++)
f [i]=f [i-1]+f [i-2];
}
Để tiết kiệm bộ nhớ, ta không dùng mảng f như trên, mà chỉ cần dùng 2 biến f1 và f2 để lưu giữ hai giá trị đứng liền trước số cần
tính, và dùng biến f3 lưu giữ giá trị số tiếp
theo. f1 f2 f3
Dãy số Fibonacci: 1, 1, 2, 3, 5, 8, 13 , 21 , 34 , 55 , 89 , 144 , …
f1=1, f2=1, f3=2, f4=3, f5=5, f6=8, f7=13, . . .
long Fibo3(int n)
{ int i;
long f1=1, f2=1, f3;
for (i=3; i<=n; i++)
{ f3=f1+f2;
f1=f2;
f2=f3;
}
return f3;
}
5.2.2. Tìm tất cả các đường đi ngắn nhất (Giải thuật Floyd):
Cho đồ thị có hướng G=(V,E), với tập đỉnh V={1, 2, ..., n} , tập cạnh E. Mỗi cạnh
(i,j) có độ dài không âm, C[i][j] là cái giá phải trả để đi từ đỉnh i đến đỉnh j.
Ma trận giá C, với:

Cấu trúc dữ liệu – trang 38


>0 ế ó ạ ℎ( , )
[][]={0 ế =
 ế ℎô ó ạ ℎ( , )

Cần tìm đường đi ngắn nhất cho mỗi cặp đỉnh bất kỳ.
Theo nguyên lý tối ưu của Quy hoạch động: Nếu k là đỉnh trên đường đi ngắn
nhất đi từ đỉnh i đến đỉnh j thì đoạn đường đi từ i đến k và đoạn đường từ k đến j đó
cũng phải là ngắn nhất.
Ta xây dựng ma trận A mà mỗi phần tử A[i][j] là độ dài đường đi ngắn nhất hiện tại đi từ đỉnh i đến đỉnh j. Lúc đầu gán C cho A.
Sau đó lặp n lần. Sau lần lặp thứ k (k=1,2,…,n), A sẽ cho độ dài các đường đi ngắn nhất trong k đỉnh {1, 2,..., k}. Lặp bước thứ n sẽ cho kết
quả cuối cùng. Ở bước lặp k ta tính A theo công thức:

[][] −1[ ][ ]
={
[ ][ ]
−1
[ ][ ]+ −1

Chỉ số k chỉ giá trị ma trận sau khi lặp bước k.


Lưu ý: Nếu k=i hoặc k=j thì Ak[i][j]=Ak-1[i][j]
Ví dụ: Tìm tất cả các đường đi ngắn nhất giữa 2 cặp đỉnh bất kỳ của đồ thị có hướng
sau: 2->1
22
1 2

30
10 15

Ma trận giá C (độ dài đường đi trực tiếp giữa các đỉnh):
Tập đỉnh V={ 1 , 2 , 3 } . n=3 đỉnh.
0 22 ∞

= [30 0
15]= 0

10 ∞ 0

Lặp n=3 lần.


- Lặp lần k=1 (hàng 1 và cột 1 như cũ):
0 22 ∞

0 ?]
1=[30

10 ? 0

Cấu trúc dữ liệu – trang 39


0[2][3] 15
[2][3] ={ ={ = 15
1
30+∞

0 [2][1] + 0[1][3]

0[3][2] ∞
1 [3][2] ={ ={ = 32
[1][2] 10+22
0 [3][1] + 0

0 22 ∞

0 15]
1=[30

10 32 0

- Lặp lần k=2 (hàng 2 và cột 2 như cũ):


0 22 ?

0 15]
2=[30

? 32 0

1[1][3] ∞
2 [1][3] ={ ={ = 37
[2][3] 22+15
1 [1][2] + 1

[3][1] ={ 1[3][1]
={ 10 = 10
2
[2][1] 32+30

1 [3][2] + 1

0 22 37

0 15]
2=[30

10 32 0

- Lặp lần k=3 (hàng 3 và cột 3 như cũ):


0 ? 37

0 15]
3=[?

10 32 0

2[1][2] 22
3[1][2] ={ ={ = 22
[3][2]
37+32
[1][3] + 2
2

2[2][1] 30
3[2][1] ={ ={ = 25
[3][1]
15+10
[2][3] + 2
2

Cấu trúc dữ liệu – trang 40


0 22 37
3=[25 0 15]
10 32 0

Kết luận:

1 2:22

1 3: 37 1->2->3

2 1:

2 3:

3 1:

3 2:
Chương trình con:
void Floyd(int C[20][20], int n)
{
for (i=1; i<=n; i++)
for (j=1; j<=n; j++)
A[i][j]=C[i][j];
for (k=1; k<=n; k++)
for (i=1; i<=n; i++)
for (j=1; j<=n; j++)
if (A[i][j]>A[i][k]+A[k][j])
A[i][j]=A[i][k]+A[k][j];
}
5.2.3 World Series Olds:
Giả sử có hai đội A và B thi đấu. Giả sử khả năng thắng của mỗi đội là như nhau
tức 50% cho mỗi trận đấu. Hai đội thi đấu và đội nào đầu tiên thắng n trận với n đặc
biệt nào đó. The World Series là cuộc thi như vậy với n = 4. Đặt P(i,j) - là xác suất sao
cho A cần j trận thắng và B cần đấu j trận, cuối cùng A thắng.
Ví dụ trong World Series nếu Dodgers thắng hai trận còn Yankees thắng một trận
thì : i = 2 và j = 3 và P (2,3) sẽ tính được là 11/5.
Để tính P (i;j) ta có thể dùng phương trình truy hồi hai biến. Ta có hai trường hợp
đặc biệt :
- Nếu i = 0 và j > o (A chưa đấu đã thắng) thì P (0, j) = 1, còn j = 0, i > 0 thì rõ ràng
P (i,0) = 0

Cấu trúc dữ liệu – trang 41


- Nếu i và j > 0 với ít nhất là đấu một trận thì P (i,j) phải là trung bình của P (i - 1,
j) và P (i,j -1) với hai trưởng hợp: một là xác suất để A thắng trận nếu nó thắng trận
tiếp, và hai là xác suất để A thắng một trận ngay cả khi nó thua trận tiếp.
Tóm lại :
P (i,j) =1 nếu i = 0 và j > 0
=0 nếu i > 0 và j = 0 (5.4)
= (P (i - 1,j) + P(i,j -1))/2 nếu i > 0 và j < 0
Trường hợp P (0,0) - Không xác định.
Nếu sử dụng (5.4) một cách đệ qui như hàm số, thì giả sử T(n) là thời gian tối đa
dùng để gọi P (i,j) với i + j = n, thì từ (5.4) ta có :
T (1) = c
T (n) = 2 T(n - 1) + d
n-1 n-1
Dễ thấy là : T (n)  2 c + (2 - 1) d
n i+j
Tức : T (n)  0 (2 ) hay 0 (2 )
Ta cần tính cận dưới của T (n). Khi gọi P (i,j) , tổng số lần gọi là :
i  j 
- tức là số cách i đưa ra từ i + j.
  c

i j
  i
i
 
Nếu i = j thì số đó là  (2n / n ) (n  i  j) thực tế có thể chứng minh đó cũng là 0
n
(2 / n ).

Bài toán có tính đệ qui là lặp việc tính P (i,j).


Ví dụ nếu muốn tính P(2,3) theo (5.4) ta cần tính P (1,2), nghĩa là ta phải tính
P(1,2) 2 lần.
Cách tính P (i,j) tốt hơn là lập bảng (hình 5.7)
Dòng dưới cùng tất cả bằng 0, cột phải là 1 do hai dòng đầu công thức (5.4). Mỗi
số trong một ô là 1/2 ô dưới và bên phải. Quá trình tính bảng là đi dọc theo đường chéo
từ góc phải dưới đi lên góc trái với hằng i + j (hình 5.8). Giải thuật để tính bảng cho
trong hình 5.9 với P là mảng hai chiều.

1/2 21/3 13/1 15/1 1 4


2 6 6
11/3 1/2 11/1 7/8 1 3
2 6
3/16 5/16 1/2 ¾ 1 2
Cấu trúc dữ liệu – trang 42
1/16 1/8 1/4 ½ 1 1j
0 0 0 0 0 0
i  3 2 1 0
4

Hình 5.7 Bảng tính P (i,j)


Hình 5.8 Hướng tính (i,j)

Function odds (i, j : integer) : real;


Var s, k : integer;
Begin
(1) for s : = 1 to i + j do
begin {tính đường chéo}
(2) P [0,s] : = 1.0;
(3) P [s,0] : = 0.0;
(4) for k : = 1 to s - 1 do
(5) P[k,s - k] : = (P[k - 1; s - k] + P (k, s - k -
1))/2.0 end;
(6) return (P[i,j])
end; [odds]
Hình 5.9 Giải thuật tính Odds.
Việc phân tích hàm Odds là rõ ràng. Vòng lặp ở dòng (4) - (5) mất ()(s) thời gian
và () (1) thời gian cho dòng (2), (3). Vòng lặp ngoài cũng mất thời gian là :

 n

2
0 s tức () (n ) với i + j = n
 s 1 
2
Vậy dùng QHĐ chỉ mất () (n ) thời gian, còn tính trực tiếp mất 0( 2n / n ) thời gian.
5.2.4. Bài toán chia tam giác (The Triangulation Problem):
Cho một đa giác (polyon). Cần chia đa giác ấy ra nhiều tam giác bởi các cung
(chords) nối hai đỉnh không kề nhau sao cho các cung ấy không chồng (trùng) lên nhau
và có tổng độ dài của chúng là bé nhất.
Ví dụ:

Cấu trúc dữ liệu – trang 43


Hình 5.10 cho một đa giác 7 đỉnh và (x,y) là các tọa độ ơ-cơ-líc của các đỉnh trên
mặt phẳng. Cách chia trong hình là không tối ưu. Giá của việc chia này là tổng độ dài
các cạnh :
(0, 2), (0, 3), (0, 5) và (3, 5)
Hoặc :
` 8 2 16 2  2
15
16 2  22 2  2 2  7 2 14 2  77.56

(8,26 (15,2
) 6)
2 3
(0,20 (27,2
) 1)
1 4

0 5 (22,1
2)
(0,10 0 (10,0
)
)

Hình 5.10. Hình đa giác 7 cạnh và các tam giác


Bài toán có nhiều ứng dụng. Ví dụ Fuchs, Kedem và Uselton (1977) đã dùng bài
toán cho mục đích tô màu ảnh. Bài toán tô màu ảnh hai chiều của một đối tượng mà bề
mặt của nó được xác bởi tập hợp các điểm trong không gian ba chiều. Nguồn sáng đi từ
một hướng đã cho và sự rực rỡ của một điểm trên bề mặt ảnh phụ thuộc vào các góc
giữa hướng ánh sáng hướng nhìn và đường vuông góc đối với bề mặt tại điểm đó. Để
đánh giá hướng của bề mặt tại một điểm ta có thể phân chia thành nhiều tam giác với
tổng độ dài của cung là min của các điểm xác định bề mặt ảnh.
Mỗi tam giác xác định một mặt phẳng trong không gian ba chiều và vì việc chia
tam giác tối thiếu đã tìm được, nên các tam giác đạt được rất bé. Rõ ràng để tìm hướng
vuông góc đối với mặt phẳng ta có thể tính cường độ ánh sáng đối với các điểm của
mỗi tam giác với giả thiết là bề mặt có thể coi như mặt phẳng tam giác trong vùng đã
cho. Nếu các tam giác là không đủ nhỏ để nguồn sáng xem được trọn (dễ chịu) thì việc
trung bình cục bộ (local everaging) có thể cải tiến được ảnh.
Trước khi giải toán theo QHĐ giả thiết đa giác có n đỉnh là : 0, 1,..., n - 1 đánh
thứ tự theo chiều kim đồng hồ, và xét hai vấn đề :
1. Trong bất kỳ sự "phân tam" nào (chia tam giác) của một đa giác lớn hơn ba
đỉnh, mỗi cặp đỉnh kề nhau được nối ít nhất một cung.

Cấu trúc dữ liệu – trang 44


2. Nếu (i, j) là một cung khi phân tam thì phải có một đỉnh k nào đó sao cho (i,
k) và (k, j) là những cạnh (edges) khác của đa giác hoặc các cung (chords) khác của
việc "phân tam". Ngược lại (i, j) sẽ là biên gới của đa giác tức cạnh của đa giác.
Để bắt đầu tìm kiếm cho sự "phân tam" tối thiểu, ta chọn hai đỉnh kề nhau, chẳng
hạn 0 và 1.
Nhờ hai đặc điểm trên mà trong mọi trường hợp "phân tam" đều tồn tại một đỉnh
k sao cho (1, k) và (k, 0) là hai cạnh (edges) hoặc hai cung (chords), hoặc vừa cạnh
vừa cung.
Với mỗi lựa chọn k, ta có một cách phân tam. Nếu đa giác có n đỉnh thì ta có n - 2
sự lựa chọn k.
Với mỗi lựa chọn k ta có hai bài toán con, như hinh vẽ khi K = 3. (Hình 5.11)

2 0

1 4

3
0
5

0
0

Hình 5.11. Hai bài toán con khi chọn 3


Tiếp theo ta lại phân tam cho các đa giác hình 5.11a và 5.11b. theo thói quen ta
lại xem xét hết tất cả các cung các đỉnh không kề nhau.
Ví dụ trong hình 5.11 (b) ta chọn (3, 5). Ta lại có hai đa giác (0, 3, 5, 6) và
(3, 4, 5),... .Cách này dẫn đến Giải thuật có độ phức tạp cấp mũ.
Nói chung, việc xác định bài toán con kích thước s bắt đầu từ đỉnh i ta ký hiệu là
Sis gồm s đỉnh theo chiều kim đồng hồ i, i+1, ..., i + s -1 cung trong Sis là (i, i + s - 1).
Ví dụ hình 4.11 (a) là S04 và (b) là S35.
Để giải bài toán Sis ta phải xem ba lựa chọn sau :
1. Có thể chọn đỉnh i + s - 2 để tạo tam giác với các cạnh (i,i+s-1), (i, i+s-2) và
(i+s-2, i+s-1) và sau đó giải bài toán con Si, s-1.
2. Ta có thể chọn đỉnh i+1 để tạo tam giác với các cạnh (i, i+s-1) (i+1,i+s-1) và
(i, i+1) và sau đó giải bài toán con Si+1, s-1.
3. Với K nào đó giữa 2 và S - 3 ta có thể chọn đỉnh 1 + k và ta có tam giác với các
cạnh (i, i+k) (i+k, i + s -1) và (i, i+s-1) và sau đó ta giải các bài toán con Si,k+1 và Si+k, e-k.

Cấu trúc dữ liệu – trang 45


Hình 5.12 là minh họa cách lựa chọn thứ ba.

i+s-1
S1+K,S-K

i+k - i
S1, K+1

Hình 5.12. Chia bài toán Sis ra hai bài toán con si,k+i và si+k, s-k
Ta biết rằng việc giải các bài toán 3 cạnh hoặc ít hơn là không tốn sức, nếu ta sử
dụng kỹ thuật đệ qui để giải các bài toán con kích thước S  4 thì có thể chứng minh
s-4
rằng mỗi lần gọi bài toán con kích thước S sẽ cho ta sự tăng lên đến tổng số 3 lần gọi
đệ qui. Vậy tổng số bước thực hiện do gọi thủ tục đệ qui cho bài toán ban đầu n đỉnh là
cấp mũ n.
Qua quá trình phân tích ta chỉ thấy ngoài bài toán ban đầu đã cho, ta chỉ có n(n-4)
bài toán con khác nhau cần phải giải. Đó là các bài toán S is so với 0  i <n, 4  s < n.
Nhưng khi sử dụng kỹ thuật đệ qui không phải tất cả các bài toán con là khác biệt nhau.
Ví dụ nếu trong hình 5.10 ta chọn cung (0, 3) và sau đó trong bài toán con của hình
5.11(b) ta chọn 4 thì ta phải giải bài toán con S 44. Nhưng ta cũng sẽ giải bài toán đó
nếu đầu tiên ta chọn cung (0, 4) hoặc nếu ta chọn cung (1, 4) và sau đó khi giải bài
toán con S45 ta lại chọn đỉnh V0 để tạo ra tam giác với i và 4.
Vì vậy ta cần áp dụng QHĐ, tức lập bảng cho các giá C is để giải bài toán Sis với
tất cả i và S, vì nghiệm của bất kỳ bài toán nào cũng chỉ phụ thuộc vào nghiệm của bài
toán kích thước nhỏ nhất, nên thứ tự hợp lý khi lập bảng là theo thứ tự tăng kích
thước : S = 4,5,...n-1. Với mỗi S ta tìm giá min cho các bài toán S is với mọi đỉnh i, chú
ý là giá Cis = 0 nếu S < 4 với mọi i.
Bằng qui tắc (1) - (3) ở trên ta tìm các bài toán con và công thức tính Cis cho S  4 là :
Cis = min [Ci, K+1 + Ci+K, s - K + D(i, i+k) + D(i+k, i+s-1)] (5.5)
1 k S-2
Trong đó D(p, q) là độ dài cung (p, q), nều p, q không kề nhau trong đa giác,
ngược lại D (p, q) = 0 khi p, q kề nhau.
Ví dụ hình 5.3 là bảng giá Cis với 0  1  6 và 4  S  6 cho đa giác và các
khoảng cách cho trước trong hình 4.10. Giá ở các dòng với s<3 đều bằng 0. Ta tính C 07
ở cột 0 dòng 7 (s=7) số này cũng như các số khác trong các dòng biểu diễn quá trình
phân tam giác cho toàn bộ đa giác.
Cấu trúc dữ liệu – trang 46
7 C07 =
75.43
6 C06 = C16 = C26 = C36 = C46 = C56 = C66 =
53.54 55.22 57.58 64.69 64.69 59.78 63.62
5 C05 = C15 = C25 = C45 = C45 = C55 = C65 =
37.54 31.31 49.35 37.74 37.74 45.50 38.09
4 C04 = C14 = C24 = C34 = C44 = C54 = C64 =
16.16 16.16 15.65 15.65 15.65 22.69 17.89
S I=0 1 2 3 4 5 6
Hình 5.13. Bảng Cis
Xem cách tính C65 = 38.09 (i = 6, s = 5). Theo (5.5). C65 là min của ba tổng ứng
với k = 1,2 và 3 là :
K=1 C2 + C04 + D (p, q) + D (p, q) (i + k = 6 + 1 = 7 là 0)
K=2 C63 + C13 + D (6, 1) + D (1, 3)
K=3 C64 + C22 + D (6, 2) + D (2, 3)
D (2, 3) = D (6, 0) = 0D (0, 2) = 26.08 D (1, 3) =
16.16 D (6, 1) = 22.36, D (0, 3) = 21.93
Vậy ba tổng trên tương ứng với K = 1,2 và 3 là 38.09, 38,52 và 43.97. Do đó C65
= 36.09. Nhưng để đạt min này ta phải giải hai bài toán con S 62 và S04 (xem hình 5.12),
nghĩa là chọn cung (0, 3) và sau đó giải bài toán con S04. Ta thấy tốt nhất là chọn
(1,3) vì D (1, 3) = 16.16 nhỏ hơn D (0, 3) = 21.93.
Tìm nghiệm từ bảng :
Bảng không cho ta lời giải trực tiếp, ta cần tìm k để (5.5) đạt min. Sau khi có k ta
suy ra nghiệm bao gồm các cung (i, i+k) và (i+k, i+s-1) một trong hai cạnh không
phải cung (chord) dù cho đường dây nối là ám chỉ bằng nghiệm của Si,k+1 và Si+k, s-k.
Ví dụ trong bảng 5.13 - C07 là nghiệm cuối cùng của bài toán đạt được khi k = 5
trong công thức (5.5). Điều đó có nghĩa là bài toán S 07 tách thành hai bài toán con S06
và S52. S06 gồm 6 đỉnh 0, 1,..., 5. Còn S52 gồm hai đỉnh (5, 6) có giá C52 là 0. Do
đó ta đưa vào cung (0, 5) có độ dài 22.09 và phải giải bài toán S06.
C06 đạt min khi k = 2 trong (5.5) và S 06 tách thành hai bài toán con S 03 và S24 với
các nhóm đỉnh tương ứng là (0, 1, 2) và (2, 3, 4, 5). S03 không cần giải. Giải S24
là tính độ dài (0, 2) và (2, 5).
Độ dài này tương ứng là 17.89 và 19.80. Min cho C 24 là k = 1 trong (4.5) và sinh
ra hai bài toán con S22 và S23 có giá đều bằng 0 vì S 3.
Vậy cung (3, 5) được đưa vào nghiệm với giá C24 là 15.65
Cấu trúc dữ liệu – trang 47
Kết quả bài toán S07 với giá min C07 = 75.43 và hình dưới đây :
2 3

1 4

0 5

6

Hình 5.4. Kết quả phân tam giác đạt giá min.

5.2.5. Tính tích n ma trận :


Cần tính M = M1, M2,..., Mn
Trong đó Mi là ma trận ri-1 dòng ri cột (i = 1,2,...,n)
Cần tìm một thứ tự nhãn tối ưu, nghĩa là thứ tự nhãn sao cho số phép toán tối
thiểu không phụ thuộc Giải thuật nhân 2 ma trận. Ta biết rằng M(p x q) * M(q x r) cần
pqx phép toán theo Giải thuật thông thường.
Ví dụ nhãn 4 ma trận sau :
M = M1 x M2 x M3 x M4
[10 x 20] [20 x 50] [50 x 1] [51 x 100]
Nếu theo thứ tự M1 x (M2 x (M3 x M4)) thì cần 125.000 phép toán. Còn nếu theo
thứ tự (M1 x (M2 x M3)) x M4 chỉ cần 2.200 phép toán. Nếu thử hết mọi khả năng để
tìm một thứ tự tối ưu thì sẽ đạt số phép thử cấp mũ. Chẳng hạn nếu đặt T(n) là số khả
năng các thứ tự lựa chọn có thể nhận n ma trận, và i là số thứ tự ma trận từ đấy tách ra
hai nhóm, chẳng hạn.
M = (M1, M2...M1) (Mi+1 Mi +2 ...Mn) với i = 1,2,...,n-1
Thì T(i) là số khả năng tìm các thứ tự nhóm 1, T(n-i) là số khả năng tìm các thứ
tự nhóm 2. Rõ ràng ta có phương trình truy hồi đối với T(n).
n 1

T(n)  T (i ) T (n 1)
i 1

Có thể chứng minh :


1  1
T (n)  2C
n
2n  2 hay T (n) (4n / n 2 )
Áp dụng nguyên lý tối ưu của Bellman : Nếu dặt mij là số phép toán ít nhất để
thực hiện tích Mi Mi + 1... Mj thì rõ ràng ta có công thức tính truy hồi :
Cấu trúc dữ liệu – trang 48
0 khi i 
j
 min (m  mK ij  ri 1 rK khi j 
mij  iK
 rj i

i k j

Trong đó :
miK là số phép toán tối thiểu của tích M1, M2...MK =M'
mk+ij là số phép toán tối thiểu của tích MK+1, MK+2...Mj =
M'' ri-1 rk rj là số phép toán của tích tích M' * M'' .
Theo QHĐ, ta lập bảng tính tất cả mij với mọi i, j = 1,2,...,n. Đầu tiên tính mij = 0
với i = 1,2,...,n sau đó mij+1 rồi mij+2,... Ngoài ra mik và mK+ij sẽ tính khi tính mij vì i 
K
< j nên j - i phải > K - i và J - i > j - (K+1)
Giải thuật tính m1n
for i : = 1 to n do mij : = 0
for l : = 1 to n - 1 do
for i : = 1 to n - l do
begin j : = i + l;
mij : = min (miK + mK+1j + ri-1 * rK * r2)
i K<j
end;
write (m1n);
Ví dụ : M = M1 M2 M3 M4 với r0 r1 r3 r4 tương ứng là 10, 20, 50 và 100 như ở
trên thì ta tính được m14 = 2.200, xem bảng kết quả hình 5.15.

i = 1 i = 2 i = 3 i = 4

M11 = 0 M22 = 0 M33 = 0 M44=0


i = M12 = M23 = M34 =
1 10000 1000 5000
i = M13 = 1200 M24 =
2 3000
i = M14 = 2200
3 Hình 5.15 : Bảng tính m14

3
Giải thuật lập bảng có độ phức tạp 0(n ).
5.2.6. Bài toán du lịch
Cho đồ thị có hướng G=(V,E) với tập đỉnh V = {1,2,...,n} và ma trận giá C, với cij
độ dài giữa hai đỉnh i và j: cij=0 nếu i=j và cij>0 nếu i  j, cij= nếu không có cạnh
(i,j). Không mất tính tổng quát ta giả sử đi từ đỉnh 1, đi qua mỗi đỉnh đúng một lần, về
lại
Cấu trúc dữ liệu – trang 49
đỉnh 1, sao cho tổng độ dài đường đi là ngắn nhất. Điều đó có nghĩa là nó gồm cạnh
(1,j), j  1, sau đó là đường đi từ j đến 1 qua mỗi đỉnh trong tập đỉnh còn lại V\{1,j}
đúng 1 lần. Nếu hành trình là tối ưu thì đường đi từ j đến 1 cũng tối ưu (theo nguyên
lý tối ưu).
Ta xét các đỉnh S  V \ {1} và đỉnh i  V\S, nếu i = 1 thì chỉ có S = V\ {1}. Đặt
g(i,S) là độ dài đường đi ngắn nhất từ i, đi qua mỗi đỉnh trong S đúng một lần, về lại đỉnh
1. Theo định nghĩa này thì g(1 , V\{1}) là độ dài cuộc hành trình tối ưu, ta có :
G (1, V\{1}) = min (cij + g (j,V\{1,j})) (1)
2 j n
Tổng quát hơn :
nếu: i  1, S  , S  V \ {1} và i  S
G(i,S) = min (cij + g(j,S\{j})) , j  S (2)
Và : g(i,) = ci1, i = 1,2,3...n
Biết g(i,S) khi tập S trống, ta có thể áp dụng công thức (2) để tính hàm g với tất
cả các tập S có đúng một đỉnh (khác đỉnh 1) rồi sau đó tính g với mọi tập S có đúng 2
đỉnh (khác đỉnh 1)... Khi đã tính được giá trị của g (j,V\{1,j}) với mọi đỉnh j  1 ta áp
dụng công thức (5.6) để tính g (1,V\{1}) là nghiệm bài toán.
Ví dụ : Cho G = (V,E) với C và độ thị tương ứng ở hình 5.19

5
1 14 2

15 6 9

13 10 7 16
11

4 3

12
8

Hình 5.19 Đồ thị có hướng cho bài toán du lịch


Ma trận giá C:

Cấu trúc dữ liệu – trang 50


 0141615 
5 0109 

C  8 7 0 11 
 

6 13 12 0 
 
- Xét tập S rỗng:
g(2,) = c21 = 5 g(3,) = c31 = 6 g(4,) = c41 = 8
- Xét tập S có đúng 1 phần tử:
g(3,{2}) = c32 + g(2,) = 7+5=12
g(4,{2}) = c42 + g(2,) = 13+5=18
g(2,{3}) = c23 + g(3,) = 10 +8=18
g(4,{3}) = c43 + g(3,) = 12 +8=20
g(2,{4}) = c24 + g(4,) = 9 +6=15
g(3,{4}) = c34 + g(4,) = 11 +6=17
- Xét tập S có đúng 2 phần tử:
g (4,{2,3}) = min { c42+g(2,{3}) , c43+g(3,{2}) } = min { 13+18
, 12+12 } = 24
g (3,{2,4}) = min { c32+g(2,{4}) , c34+g(4,{2}) } = min { 7+15 ,
11+18 } = 22
g (2,{3,4}) = min { c23+g(3,{4}) , c24+g(4,{3}) } = min { 10+17
, 9+20 } = 27
- Xét tập S có đúng 3 phần tử:
g (1,{2,3,4}) = min { c12+g(2,{3,4}) , c13+g(3,{2,4}) , c14+g(4,{2,3}) } = min {14+27 ,
16+22 , 15+24} = 38
Vậy độ dài đường đi ngắn nhất là 38 với lộ trình cụ thể là:
1->3->2->4->1
---o-O-o---

Cấu trúc dữ liệu – trang 51


CHƯƠNG 6: GIẢI THUẬT THAM LAM
6.1. Giới thiệu:
Các giải thuật giải quyết các bài toán tối ưu thường gồm một chuỗi các bước, và
tại mỗi bước gồm một tập các lựa chọn. Giải thuật Tham lam luôn thực hiện một lựa
chọn dường như tốt nhất tại mỗi bước. Nghĩa là, giải thuật Tham lam thực hiện lựa
chọn tối ưu một cách cục bộ tại mỗi bước và kỳ vọng rằng lựa chọn này sẽ dẫn đến giải
pháp tổng thể tối ưu.
Giải thuật Tham lam có thể cho ra nghiệm tối ưu hoặc không tối ưu, nhưng nếu
không tối ưu thì cũng khá tốt. Giải thuật Tham lam rất hữu ích và giải quyết tốt một lớp
lớn các bài toán tối ưu.
6.2. Các bài toán ứng dụng:
6.2.1. Bài toán Đổi tiền :
Cho một hệ thống tiền tệ gồm các loại tờ tiền giấy có mệnh giá là 1, 2, 5, 10, 20,
50. Cần đổi một số tiền S sao cho số tờ tiền là ít nhất. Ví dụ, cần đổi S=99 thì giải pháp
tối ưu là sử dụng 6 tờ tiền, gồm 1 tờ mệnh giá 50, 2 tờ mệnh giá 20, 1 tờ mệnh giá 5, 2
tờ mệnh giá 2.
6.2.2. Bài toán Xếp ba lô:
Một kẻ trộm đột nhập vào một cửa hàng chứa n đồ vật, đồ vật thứ i có giá trị v i và
trọng lượng wi , trong đó vi và wi là các số nguyên dương. Kẻ trộm muốn lấy các đồ vật
có tổng giá trị lớn nhất. Kẻ trộm nên lấy những đồ vật nào ?
Có 2 loại bài toán Xếp ba lô:
Bài toán Xếp ba lô nhị phân: Một đồ vật thì hoặc lấy hết hoặc không lấy.
Bài toán Xếp ba lô từng phần: Một đồ vật có thể lấy một phần. Trong trường hợp
này, gọi xi là một phần của đồ vật thứ i, với 0 ≤ x i ≤ 1 , xi có trọng lượng xiwi và có giá
trị xivi
Hai bài toán Xếp ba lô trên tương tự nhau, nhưng bài toán Xếp ba lô từng phần
được giải quyết bởi giải thuật Tham lam, trong khi bài toán Xếp ba lô nhị phân thì
được giải quyết bởi giải thuật Quy hoạch động.
Đối với bài toán Xếp ba lô từng phần, tập các ứng cử viên là các đồ vật. Ở mỗi
bước, ta chọn đồ vật dường như tốt nhất, nghĩa là có giá trị lớn nhất mà chiếm ít trọng
lượng nhất, và xếp vào ba lô một phần lớn nhất có thể của đồ vật đó. Khi đó, đối với
các đồ vật được chọn trước thì xếp toàn bộ đồ vật vào ba lô (vì ba lô đủ sức chứa), còn
đồ vật được chọn cuối cùng thì có thể chỉ xếp một phần của đồ vật vào ba lô. Vấn đề
đặt ra là chọn lựa đồ vật dường như tốt nhất theo tiêu chí nào ?
Chúng ta có thể thực hiện các phép thử chọn lựa Tham lam khác nhau như sau:
1. Chọn đồ vật theo thứ tự giá trị giảm dần.
2. Chọn đồ vật theo thứ tự trọng lượng tăng dần.
Cấu trúc dữ liệu – trang 52
3. Chọn đồ vật theo thứ tự tỷ lệ giá trị trên trọng lượng (vi/wi) giảm dần (tức là đơn giá
= giá trị tính theo một đơn vị trọng lượng của vật đó).
Ví dụ có 5 loại đồ vật có Giá trị, Trọng lượng và Đơn giá sau:
STT Giá trị Trọng lượng Đơn giá
1 8 2 4
2 10 5 2
3 42 6 7
4 15 3 5
5 36 4 9
Và ba lô chỉ chứa trọng lượng lớn nhất là 8.
Phép thử 1: Chọn đồ vật theo thứ tự giá trị giảm dần.
STT Giá trị Trọng lượng Đơn giá
1 42 6 7
2 36 4 9
3 15 3 5
4 10 5 2
5 8 2 4
Tổng giá trị các đồ vật được chọn là:
42+36/4*2=42+18=60
Phép thử 2: Chọn đồ vật theo thứ tự trọng lượng tăng dần.
STT Giá trị Trọng lượng Đơn giá
1 8 2 4
2 15 3 5
3 36 4 9
4 10 5 2
5 42 6 7
Tổng giá trị các đồ vật được chọn là:
8+15+36/4*3=8+15+27=50
Phép thử 3: Chọn đồ vật theo thứ tự đơn giá giảm dần.
STT Giá trị Trọng lượng Đơn giá
1 36 4 9

Cấu trúc dữ liệu – trang 53


2 42 6 7
3 15 3 5
4 8 2 4
5 10 5 2
Tổng giá trị các đồ vật được chọn là:
36+42/6*4=36+28=64
Như vậy chọn đồ vật theo thứ tự đơn giá giảm dần là tốt nhất.
Bài tập: Viết chương trình C hoàn chỉnh giải bài toán Xếp ba lô từng phần bằng
giải thuật Tham lam theo phép thử chọn theo đơn giá giảm dần:
1. Khai báo các biến:
- Biến mảng A các số nguyên gồm 100 hàng (hàng 0 đến hàng 99) và gồm 3 cột
(cột 0, cột 1, cột 2). Trong đó bỏ hàng 0, dữ liệu vật thứ i chứa ở hàng i, giá trị chứa ở
cột 0, trọng lượng chứa ở cột 1, đơn giá chứa ở cột 2.
2. Các công việc:
- Nhập trọng lượng M tối đa ba lô có thể chứa được.
- Nhập số lượng n các đồ vật n.
- Nhập giá trị và trọng lượng của n đồ vật, rồi tính đơn giá cho mỗi đồ vật.
- Sắp mảng A theo thứ tự đơn giá giảm dần.
- Chọn các đồ vật.
6.2.3. Bài toán Cây bao trùm tối thiểu:
Cho đồ thị vô hướng G=(V, E) , với tập đỉnh V={1, 2, ..., n} , tập cạnh E. Mỗi
cạnh (i,j) có độ dài không âm, C[i][j] là cái giá phải trả để đi từ đỉnh i đến đỉnh j.
Ma trận giá C, với : >0 ế ó ạ ℎ( , )
[][]={0 ế =
 ế ℎô ó ạ ℎ( , )

Cây bao trùm tối thiểu của đồ thị G=(V,E) là đồ thị G’=(V,T) với T là tập con
của E, T nối hết các đỉnh trong G mà có tổng độ dài ngắn nhất.
Ta xét hai giải thuật : Kruskal và Prim.
1. Giải thuật Kruskal:
Các cạnh trong E xếp theo độ dài tăng dần. Lúc đầu cho T trống. Ban đầu tập
đỉnh V gồm n thành phần liên thông một phần tử riêng biệt. Quá trình của giải thuật
sẽ bổ sung dần các cạnh vào T. Mỗi bước của giải thuật là:

Cấu trúc dữ liệu – trang 54


- Chọn cạnh ngắn nhất chưa xét có 2 đỉnh thuộc hai thành phần liên thông khác
nhau (cạnh nào có hai đỉnh ở trong cùng một thành phần liên thông thì bỏ qua).
- Bổ sung cạnh này vào T.
- Nối cạnh này để hợp 2 thành phần liên thông này thành 1 thành phần liên thông
chung.
Quá trình tiếp tục cho đến khi chỉ còn lại một thành phần liên thông duy nhất.
Ví dụ: Tìm cây bao trùm tối thiểu của đồ thị vô hướng sau:
32 36
1 2 3

28 22 42 20 26

4 38 5 24 6

40 30 34
7

Các cạnh sắp theo độ dài tăng dần:


(3,5) (2,4) (5,6) (3,6) (1,4) (5,7) (1,2) (6,7) (2,3) (4,5) (4,7) (2,5)
20 22 24 26 28 30 32 34 36 38 40 42
Bước Chọn cạnh Các thành phần liên thông
0 {1} {2} {3} {4} {5} {6} {7}
1 (3,5) {1} {2} {3,5} {4} {6} {7}
2 (2,4) {1} {2,4} {3,5} {6} {7}
3 (5,6) {1} {2,4} {3,5,6} {7}
4 (1,4) {1,2,4} {3,5,6} {7}
5 (5,7) {1,2,4} {3,5,6,7}
6 (2,3) {1,2,3,4,5,6,7}

Vậy tập cạnh T={(3,5) , (2,4) , (5,6) , (1,4) , (5,7) , (2,3)}


với tổng độ dài có thể ngắn nhất là 20+22+24+28+30+36 =
160
Cấu trúc dữ liệu – trang 55
36
1 2 3
28 22 20
4 5 24
6

30
7

G’=(V, T)

2. Giải thuật Prim:


Giải thuật Kruskal xuất phát từ n thành phần liên thông một đỉnh, tức từ một rừng
n cây một đỉnh. Sau đó chọn các cạnh ngắn nhất có đỉnh từ các cây để nối lại thành cây
bao trùm, miễn sao không tạo ra chu trình (cycle). Quá trình này có vẻ ngẫu nhiên.
Giải thuật Prim thực hiện "tự nhiên" hơn. Nó bắt đầu từ một đỉnh bất kỳ coi như gốc
của cây. Mỗi bước thực hiện là chọn cạnh ngắn nhất bổ sung vào cây đã xây dựng. Quá
trình dừng khi duyệt đến hết các đỉnh. Ta mô tả cụ thể hơn.
Cho G = (V,E), V = {1,2,...,n}; U - tập chứa các đỉnh cây bao trùm, lúc đầu U =
{1}. Mỗi bước chọn cạnh ngắn nhất (u,v) liên thông giữa U và V\U, sau đó bổ sung v
vào U. Lập quá trình này cho đến khi U = V. Ký hiệu CLOSET và LOWCOST là hai
mảng, mà CLOSET [i] với i  V \ U cho ta đỉnh trong U gần i nhất và LOWCOST [i]
là giá của cạnh (i,CLOSET[i]). Giải thuật ở hình 6.23.
Ví dụ đồ thị cho ở hình 6.22 (a) kết quả các bước của Giải thuật prim cho trong hình
6.24.
Procedure Prim (C : array [1..n, 1..n] of real); {In các cạnh cây bao
Trùm tối thiểu với V = {1,...,n} với ma trận giả C}
var LOWCOST : array [i..n] of real;
CLOSET : array [1..n] of integer ;
i, j, k, min : integer ;
begin
for i : = 2 to n do
begin {lúc đầu cho U = {1}}
Cấu trúc dữ liệu – trang 56
LOWCOST [i] : = C [1,i] ;
CLOSET [i] : = 1 ;
End ;
For i : = 2 to n do
begin {tìm đỉnh k ngoài U gần một đỉnh nào đó nhất trong U}
min : = LOWCOSST [2] ;
k : = 2;
for j : = 3 to n do
if LOWCOST [j] < min then
begin
min : = LOWCOST [j] ;
k:=j;
end ;
writeln ((k, CLOSET [k])) ; {in cạnh}
LOWCOST [k] : = infinity ;
{bổ sung k vào U} {infinity một số lớn hơn mọi C [i,
j]} for j := 2 to n do
if (C(k,j) < Lowcost [j])
and (Lowcost [J]< infinity) then
begin
LOWCOST [j] : = C [k,j] ;
CLOSET[j] : = k
end
end
end ; {prim}

Bước Chọn cạnh Các thành phần liên thông


0 {1}
1 (2,1) {1,2}
2 (3,2) {1,2,3}
3 (4,1) {1,2,3, 4}

Cấu trúc dữ liệu – trang 57


4 (5,4) {1,2,3, 4,5}
5 (7,4) {1,2,3,4,5,7}
6 (6,7) {1,2,3,4,5,6,7}
- Các cạnh được in ra : (2,1), (3,2), (4,1), (5,4), (7,4), (6,7)
Hình 6.24 Các bước thực hiện Giải thuật prim cho ví dụ 6.22 (a)
Độ phức tạp Giải thuật Kruskal cho đồ thị n đỉnh, e cạnh được đánh giá như sau :
* 0 (eloge) để sắp xếp e cạnh, tương đương 0 (elogn)
vì : n - 1  e  n (n-1)/2.
* 0 (n) để khởi động n tập là các thành phần liên thông một phần tử (tức một đỉnh).
* Nhiều nhất là 0 (e) cho các phép còn lại.
Tóm lại : Giải thuật Kruskal có độ phức tạp 0 (elogn).
2
Độ phức tạp Giải thuật Prim rõ ràng là 0 (n ) vì có 2 vòng lặp for lồng nhau mỗi
vòng n-1 lần.
6.2.4. Bài toán Tìm các đường đi ngắn nhất từ một đỉnh (Giải thuật Dijkstra):
Cho đồ thị có hướng G=(V,E), với tập đỉnh V={1, 2, ..., n} , tập cạnh E. Mỗi cạnh
(i,j) có độ dài không âm, C[i][j] là cái giá phải trả để đi từ đỉnh i đến đỉnh j.
Ma trận giá C, với : >0 ế ó ạ ℎ( , )
[][]={0 ế =
 ế ℎô ó ạ ℎ( , )

Không mất tính tổng quát, cần tìm các đường đi ngắn nhất từ đỉnh 1 đến từng
đỉnh còn lại.
Ta ký hiệu D là mảng một chiều (vector) với D[i] là độ dài đường đi ngắn nhất
hiện tại đi từ đỉnh xuất phát 1 đến đỉnh i. S là tập các đỉnh mà các đường đi ngắn nhất
đó chỉ được đi ngang qua những đỉnh có trong tập S.
- Đầu tiên cho tập S chỉ gồm đỉnh 1. 1 w u
- Lặp nhiều lần cho đến khi S=V, mỗi lần lặp:
. Chọn đỉnh w mà D[w] là min với w ∉ S.

. Bổ sung đỉnh w vào tập S.


. Tính lại các D[u]=min{D[u], D[w]+C[w][u] } với những u ∉ S .

Ví dụ: Tìm các đường đi ngắn nhất từ đỉnh 1 đến từng đỉnh còn lại.

Cấu trúc dữ liệu – trang 58


1
10 100
2 30 5

50 10 60

3 20 4

Bước w S D[2] D[3] D[4] D[5]


0 {1} 10 ∞ 30 100
1 2 {1,2} 10 60 30 100
2 4 {1,2,4} 10 50 30 90
3 3 {1,2,4,3} 10 50 30 60
Bước 1: Chọn đỉnh w=2
D[3]=min(D[3], D[2]+C[2][3])=min(∞ , 10+50)=60
D[4]=min(D[4], D[2]+C[2][4])=min(30 , 10+∞)=30
D[5]=min(D[5], D[2]+C[2][5])=min( 100,10+∞ )=100
Bước 2: Chọn đỉnh w=4
D[3]=min(D[3], D[4]+C[4][3])=min(60 ,30+20 )=50
D[5]=min(D[5], D[4]+C[4][5])=min(100 ,30+60 )=90
Bước 3: Chọn đỉnh w=3
D[5]=min(D[5], D[3]+C[3][5])=min( 90, 50+10)=60.
Kết quả:
 
1 2 : 10 với lộ trình cụ thể là: 1
 
1 3 : 50 với lộ trình cụ thể là: 1
 
1 4 : 30 với lộ trình cụ thể là: 1
 
1 5 : 60 với lộ trình cụ thể là: 1
Ý nghĩa "Greedy" của giải thuật là chọn đỉnh gần nhất lúc đầu:
S = [1], D[2] = 10, D[3] = , D[4] = 30, D[5] = 100. Bước một của vòng lặp từ dòng
(3) - (6) (hình 5.21), chọn w = 2 là đỉnh đầu tiên từ đỉnh 1 ra đi có độ dài nhỏ nhất tức
D - min. Sau đó tính D[v] với mỗi v  V \{1,2}, ta có:
D[3] = min (D[3], D[2]+C[2][3]) = min(∞,10+50) = 60,
D[4] = min (D[4] + C[2][4]) = min (30,) = 30,
Cấu trúc dữ liệu – trang 59
D[5] = min (D[5], D[5] + C[2][5]) = min (100, ) = 100...
Procedure DIJKSTRA; {Giải thuật có tính giá của các đường đi ngắn nhất từ
đỉnh 1 đến mỗi đỉnh của đồ thị}
Begin
(1) S:={1};
(2) for i : = 2 to n do D[i] : = C[1,i] ;
(3) for i : = 1 to n - 1 do
begin
(4) Chọn w  V\S sao cho D[w] là min
(5) Bổ sung w vào S;
(6) for mỗi v V \ S do
(7) D[v] : = min (D[V],D[w] + C[w][v])
(8) end
end; {DIJKSTRA}
Hình 6.21 Giải thuật Dijkstra
2
Độ phức tạp của Giải thuật Dijkstra rõ ràng là 0(n ), xác định từ hai vòng lặp (6)
và (3) nếu ta biểu diễn đồ thị bằng ma trận kề, với số đỉnh là n. Nếu biểu diễn đồ thị
2
bằng danh sách kề thì số cạnh e << n và dùng hàng ưu tiên (priority queue) để tổ chức
các đỉnh trong V\S thì độ phức tạp của Giải thuật có thể giảm xuống còn 0(n*logn).
Giải thuật Greedy không tối ưu. Ví dụ trong đồ thị sau theo dijkstra ta có con
đường s  a  b dài là 3 trong khi s  a  c  b có độ dài là 2.

2
b
1 -2
S a
3
c

6.2.5. Bài toán Lập lịch :


Ta xét hai bài toán: Phân phối thiết bị và lịch phục vụ các công việc có giới hạn
thời gian chờ.
1. Bài toán nhà phân phối thiết bị :
Dạng 1: (Tối ưu thời gian)
Cấu trúc dữ liệu – trang 60
Cho n thiết bị hoàn toàn giống nhau P1, P2,...,Pn và m công việc J1, J2, ..., Jm
Các thiết bị có thể làm việc đồng thời và làm việc nào cũng được. Mỗi việc đã làm
ở thiết bị nào thì làm đến cùng. Thời gian làm việc Ji là ti (i = 1,2,...,m). Cần xây dựng
một lịch biểu là thứ tự thực hiện các công việc sao cho tổng thời gian hoàn thành là
nhanh nhất.
Thường m>n, nên nếu thiết bị nào rãnh đầu tiên sẽ nhận công việc tiếp theo trong
lịch biểu. Nếu có nhiều thiết bị cùng rãnh thì chọn thiết bị có số thứ tự nhỏ nhất.
Phương pháp kinh nghiệm tham lam là lập lịch công việc bằng xếp thứ tự thực
hiện các công việc theo chiều thời gian giảm dần.
Ví dụ: Có n=3 thiết bị P1 , P2 , P3 và có m=6 công việc J1 , J2 , J3 , J4 , J5 , J6
tương ứng cần thời gian thực hiện t 1=2, t2=5, t3=8, t4=1, t5=5, t6=1 thì ta có lịch L=(J3,
J2, J5, J1, J4, J6) thực hiện trên P1, P2 và P3 như sau:

P1 J3
8
P2 J2 J1
5 2
P3 J5 J4 J6
5 1 1

Tổng thời gian thực hiện là T=8. Đây cũng là lịch tối ưu.
Dạng 2 : (Tối ưu thiết bị)
Cho m công việc J1, J2,..., Jm tương ứng thời gian thực hiện t1, t2..., tm và tập các
thiết bị P1 , P2 , P3 , ...
Với thời gian cho trước T0 - cố định để hoàn thành m công việc, cần bố trí các
công việc trên các thiết bị sao cho số thiết bị đạt min (giả thiết T 0  thời gian thực hiện
công việc dài nhất).
Bài toán này tương đương với bài toán Đóng gói sau đây: Mỗi thiết bị P i ta coi
như một thùng Bi, có kích thước T0 như nhau cho mọi thùng. Mỗi công việc tj ta coi
như một sản phẩm có kích thước tj bằng thời gian tj (j = 1, 2, ... , m) ta cần sắp các sản
phẩm này vào các thùng sao cho số thùng sử dụng ít nhất. Tất nhiên các sản phẩm
không thể tách ra nhỏ hơn.
Ví dụ : Có 5 sản phẩm, 2 sản phẩm có kích thước 3 và 3 sản phẩm có kích thước 2.
Mỗi thùng có kích thước 4, khi đó ta có thể dùng 4 thùng để xếp như sau:

2
3 3 4

Cấu trúc dữ liệu – trang 61


2 2

Thường ta có bốn phương pháp kinh nghiệm Tham lam sau đây:
1. FF - (First Fit) :
Cho L là một thứ tự nào đó các sản phẩm.
Sản phẩm thứ nhất cho vào thùng B1, sản phẩm thứ 2 cho vào B1 nếu được,
ngược lại cho vào B2. Nói chung một sản phẩm kế tiếp sẽ cho vào Bi nếu được với i là
chỉ số thùng nhỏ nhất trong các thùng có thể. Nếu sản phẩm không chứa được vào một
trong k thùng đã chứa một phần thì chứa nó vào thùng thứ k+1 (thùng này có thể đã
chứa một phần hoặc còn trống), lặp bước cơ bản này khi nào L chưa chứa hết.
2. FFD - (First Fit Decreasing) :
FFD chỉ khác FF là L - xếp các sản phẩm theo kích thước giảm dần.
3. BF (Besty Fit) :
Cho L là một thứ tự nào đó các sản phẩm. Bước cơ bản giống FF. Nhưng sản
phẩm kế tiếp cho vào thùng còn trống ít nhất nếu được. Ví dụ kích thước thùng là 6, và
có bốn thùng đã chứa một phần còn trống là 4, 3, 2, 1 thì sản phẩm kế tiếp có kích
thước 3 sẽ cho vào thùng thứ 2 cho đầy luôn.
4. BFD (Best Fit Decreasing) :
Giống BF nhưng L - xếp các sản phẩm theo kích thước giảm dần.
Có nhiều đánh giá cận trên giữa nghiệm gần đúng theo một trong các phương
pháp trên và nghiệm đúng :
Ví dụ : Theo Graham R.L trong "Proc. Spring Joint Comput Conf" 205-217
(1972), nếu ta ký hiệu N0 là số thùng tối thiểu cho bởi Giải thuật đúng, và NFFD - là
số thùng thu được theo giải thuật FFD thì với > 0 bất kỳ và N0 đủ lớn, ta có đánh giá:
N  11  
FFD

N0 9
Ta có :
N  N  11 1 2   0.22
FFD 0
0.23
N0 9 9

Nghĩa là sai số của FFD so với nghiệm đúng là không vượt 23%. Nói chung giải
thuật FFD làm việc khá tốt, và khó tìm ra một ví dụ mà giải thuật này làm việc một
cách kém hiệu quả nhất. Ta thử đưa ra một trường hợp xấu như vậy. Cho > 0 khá
nhỏ. Giả sử các thùng đều có kích thước 1, và cần xếp các sản phẩm có kích thước
tương ứng sau :
* 6 sản phẩm kích thước 1/2 + 
* 6 sản phẩm kích thước 1/4 + 
Cấu trúc dữ liệu – trang 62
* 12 sản phẩm kích thước 1/4 - 2
* 6 sản phẩm kích thước 1/4 + 2
Nếu sắp xếp các sản phẩm theo kích thước giảm dần ta được:
* 6 sản phẩm kích thước 1/2 + 
* 6 sản phẩm kích thước 1/4 + 2
* 6 sản phẩm kích thước 1/4 + 
* 12 sản phẩm kích thước 1/4 - 2
Cách xếp tối ưu là dùng 9 thùng vì cả 9 thùng đều đầy (hình 6.25 (a)) và theo giải
thuật FFD là 11 thùng (hình 6.25 (b)).

¼ - 2 ¼ - 2 Dư¼-3 dư¼-3 Dư8


     ¼ - 2
¼ - 2
¼ + 
¼ +   ¼ + 2 ¼ - 2
¼ + 2  ¼ +  
½ +   - 2
¼ +  ¼
½ +  
6 3 6 2 3
thùng thùng thùng thùng thùng
loại loại loại loại loại
(a) N0 = 9 (b) NFFD =
11
Hình 6.25 : Kết quả cho ví dụ giải thuật FFD làm việc "xấu nhất"
Dãy các sản phẩm xếp theo chiều giảm kích thước là :
 1  6lan, 1  2 6lan, 1  (6lan), 1 
L   2 (12lan) 
2 4 4 4
 
Bài toán đóng gói rất nhạy với sự thay đổi số liệu, ví dụ ta có các thùng kích
thước 1000 và các sản phẩm xếp theo chiều giảm là :
L=(760, 395, 395, 379, 379, 242, 200, 105, 105, 40)
Thì giải thuật FFD cho ta nghiệm NFFD = 3 thùng. Đây cũng là nghiệm tối ưu.
Nhưng nếu kích thước mỗi sản phẩm giảm xuống 1,
L=(759, 394, 394, 378, 378, 241, 199, 104, 104, 39)
thì giải thuật FFD lại cho nghiệm NFFD = 4, tất nhiên không tối ưu và ta cũng lại có tỉ
số NFFD/N0 = 11/9
Ví dụ khác :
Cho L=(7, 9, 7, 1, 6, 2, 4, 3) với thùng kích thước 13 thì giải thuật FF cho nghiệm NFF
= 3 thùng, cũng là nghiệm tối ưu.

Cấu trúc dữ liệu – trang 63


3
2 4 6

L =
1 9
(7,9,7,1,6,2,4,3)
7 NEF=3
7

Nhưng bỏ sản phẩm kích thước 1, L' = (7, 9, 7, 6, 2, 4, 3) thì : NFF = 4 thùng.
Dư Dư
2 Dư
2 6
6
2 10
4
L' =
7 7
7 (7,9,7,6,2,4,3)
9
NEF=4
3

2. Bài toán lập lịch có giới hạn :


Cho n công việc mỗi việc thực hiện mất 1 đơn vị thời gian. Mỗi thời điểm chỉ làm
một việc. Công việc i, 1  i  n mang lại lợi ích gi nếu và chỉ nếu nó thực hiện không
chậm hơn một thời gian di.
Ví dụ : n = 4, ta có các giá trị sau :
i 1 2 3 4
g1 50 10 15 30
di 2 1 2 1
Ta có các lịch thực hiện sau :
Dãy 1,3 lợi ích 65
2,1 60
2,3 25
3,1 65
Dãy 4,1 lợi ích 80  tối ưu
4,3 45

Cấu trúc dữ liệu – trang 64


Dãy [3,2] không chấp nhận vì công việc thứ 2 sẽ thực hiện ở thời gian t = 2 sau
thời gian của nó là d2 = 1. Dãy [4,1,3] hoặc [4,1,2] cũng không chấp nhận theo nghĩa
tương tự đối với công việc 2 và 3. Rõ ràng nếu có k công việc thì có k! hoán vị tức có
k! lịch biểu. Dưới đây ta giới thiệu một giải thuật Greedy làm việc nhanh cho bài toán
lập lịch có giới hạn.
Cho tập J gồm n công việc i = 1,2,...,n tương ứng với các giới hạn d 1, d2,...dn và các
lợi ích g1, g2,... gn đã xếp theo giảm dần. Ví dụ có sáu công việc với số liệu như sau :
i 1 2 3 4 5 6
gi 20 15 10 7 5 3
di 3 1 1 3 1 3
Giải thuật dựa vào bổ đề sau :
Dãy các công việc trong J gọi là chấp nhận được nếu và chỉ nếu mỗi công việc i
trong dãy đó thực hiện tại thời điểm t với t là số nguyên lớn nhất sao cho 0 < t  min
(n, di) và công việc thực hiện ở thời gian t này chưa xét đến.
Rõ ràng dãy các công việc chấp nhận được (tức là lịch) là dãy các vị trí từ 0,1,2.../
với :
l = min (n,max |d1|, 1  i  n)
Ta ký hiệu : K là tập hợp các vị trí của lịch F
(K) là số nhỏ nhất trong KL;
0 - là vị trí tự do hiển nhiên.
Giải thuật bao gồm các bước sau :
(i) Mỗi vị trí 0, s1.../ ta coi là một tập tức i = {i} và f (j) = t. i = 0,1,2...,/.
(i,i) Ta bổ sung một công việc có giới hạn d vào lịch như sau :
- Tìm tập chưa min (n,d), giả sử tập đó là
K. - Nếu f (K) = 0 thì bỏ qua công việc này
- Nếu f (K)  0 thì :
+ Gán công việc đó vào vị trí f(k)
+ Tìm tập chứa f (k) - l. ví dụ nó là tập L.
+ Gắn hai tập K và L. Giá trị của f đối tập mới này là giá trị cũ f(L).
---o-O-o---

Cấu trúc dữ liệu – trang 65


CHƯƠNG 7: GIẢI THUẬT QUAY LUI
7.1. Giới thiệu:
Bài toán tìm trường hợp tối ưu trong các liệt kê tổ hợp luôn là một trong những bài
toán được quan tâm hàng đầu hiện nay. Một bài toán liệt kê tổ hợp cần được đảm bảo
không được bỏ sót cũng như trùng lặp bất kỳ một trường hợp nào. Phương pháp Quay lui
là một trong những phương pháp liệt kê tổ hợp hữu hiệu và mang tính phổ dụng cao.
Trong các kỹ thuật cơ bản thiết kế thuật toán thì kỹ thuật Quay lui là một trong
những kỹ thuật quan trọng nhất. Nó có thể được áp dụng để thiết kế thuật toán tìm ra
một nghiệm hoặc tìm tất cả các nghiệm của bài toán.
Trong nhiều bài toán, việc tìm nghiệm có thể quy về việc tìm một vector hữu hạn x=(x 1, x2, . . . ) với độ dài có thể không được xác
định trước. Vector này cần thỏa mãn một số điều kiện nào đó. Các thành phần xi được chọn ra từ một tập hữu hạn Ai . xi ∈ Ai

Nguyên lý của giải thuật là xây dựng vector nghiệm dần từng bước. Bắt đầu từ
vector rỗng. Thành phần đầu tiên x1 được chọn từ tập S1=A1
Giả sử đã có được nghiệm một phần (x 1, x2, . . . , xi-1). Từ các thành phần x1,
x2, . . . , xi-1 ta có thể xác định được tập S i các giá trị có thể chọn làm thành phần x i , với
Si là tập con của tập Ai
Chọn một thành phần xi từ tập Si ta mở rộng nghiệm một phần (x1, x2, . . . , xi-1)
để được nghiệm một phần (x1, x2, . . . , xi-1, xi). Nếu không chọn được thành phần xi
(khi Si là rỗng) thì ta quay lui chọn một thành phần xi-1 khác của Si-1
Lược đồ tổng quát của kỹ thuật Quay lui có thể được biểu diễn như sau:
Void Quaylui()
{S1=A1;
i=1;
while (i>=1)
{ while ( Si != ∅)

{chọn x ∈ S S = S \ {x }
i i i i i

If (x1, x2, . . . , xi-1 ,xi) là nghiệm thì in ra nghiệm;


i++;
Xác định Si
}
i--; quay lui
}
}
Cấu trúc dữ liệu – trang 66
7.2. Các bài toán ứng dụng:
7.2.1. Bài toán 8 hậu:
Cần xếp 8 quân Hậu trên bàn cờ vua gồm 8 hàng 8 cột, sao cho 8 quân Hậu không
ăn lẫn nhau, tức cả 8 quân Hậu đều khác hàng, đều khác cột và đều khác 2 đường chéo.
Gọi n là số hàng số cột của bàn cờ vua.
Gọi Hi là quân hậu nằm ở hàng i.
Gọi xi là tọa độ cột của hậu Hi
n=8 hậu thì nghiệm là vector x=(x1,x2,...,x8), ví dụ x=(1, 5, 8, 6, 3, 7, 2, 4)
n=6 hậu thì nghiệm là vector x=(x1,x2,...,x6), ví dụ x=(2, 4, 6, 1, 3, 5)
Hậu Hi (i , xi ) và hậu Hj ( j , xj ) cùng cột: xi = xj
Hậu Hi(i,xi) và hậu Hj(j,xj) khác cột: xi != xj
Hậu Hi(i,xi) và hậu Hj(j,xj) cùng chéo chính: i+xi = j+xj hay i-j = xj-xi
Hậu Hi(i,xi) và hậu Hj(j,xj) khác chéo chính: i+xi != j+xj hay i-j != xj-xi
Hậu Hi(i,xi) và hậu Hj(j,xj) cùng chéo phụ: i-xi = j-xj hay i-j = xi-xj
Hậu Hi(i,xi) và hậu Hj(j,xj) khác chéo phụ: i-xi != j-xj hay i-j != xi-xj
Khi đã có (x1,x2,…xj...,xi-1) thì xi được chọn nếu thỏa mãn:
xi != xj
và i-j != xj – xi và i-j != xi – xj với mọi j=1,2,...,i-1
ok=1 là hợp lệ, ok=0 là sai.
1 2 3 4 5 6 15=10+5=9+6=8+7=11+4=12+3
H1
H2
H3
H4

x1=2 , x2=4 , x3=6 , x4=1 ,x5=3 , x6=5


x=(x1,x2,x3,x4,x5,x6)=(3,6,2,5,1,4)
Chương trình:
#include <stdio.h>
#include <conio.h>
int i, j, k, t, d=0, ok;
Cấu trúc dữ liệu – trang 67
const int n=8; // n=6 hoac n=8
int x[20];
void tamhau()
{ i=1; x[i]=0;
while (i>=1)

{ x[i] = x[i]+1;
ok=0;
while ( (x[i]<=n) && (!ok) )
{ j=1;
ok=1;
while ( (j<=i-1) && ok )
{ if ( (x[i]!=x[j]) && (i-j) != (x[j]-x[i])
& (i-j) != (x[i]-x[j]) )
j++;
else ok=0;
}
if (!ok) x[i] = x[i]+1;
}
if (ok)
{ if (i==n)
{ d++; printf("\n Nghiem %2d :", d);
for ( t=1 ; t<=n ; t++)
printf("%6d", x[t]);
if (d%20==0) { printf("\n"); getch(); }
}
else { i++;
x[i]=0;
}
}
else i--; // quay lui
}
Cấu trúc dữ liệu – trang 68
}
main()
{ tamhau();
getch();
}
7.2.2. Bài toán: Các tập con có tổng cho trước:
Cho trước tập A gồm n số thực dương và cho trước M là một số thực dương.
Hãy tìm tất cả các tập con các số trong A sao cho tổng của chúng bằng M.
Để giải quyết bài toán này, ta biểu diễn tập A dưới dạng mảng (a1, a2, . . . an). Ta
cần tìm dãy con (ax1, ax2, . . . , axi) gồm i phần tử , với 1  x1 < x2 < . . . < xi  n sao
cho ax1+ax2+ . . . + axi=M . Như vậy, nghiệm của bài toán là dãy (x1 , x2 , . . . , xi) sao
cho 1
 x1 < x2 < . . . < xi  n và ax1 + ax2 + . . . + axi = M.
Đương nhiên có thể chọn x1 là một trong các chỉ số 1, 2, . . . , n mà a x1  M . Khi
đã chọn được x1 , x2 , . . . , xi và S = ax1+ax2+ . . . + axi < M thì xi+1 có thể chọn là một
trong các chỉ số từ xi+1 tới n mà S + axi+1  M
Trong hàm con dưới đây, ta sử dụng mảng A[1 . . n] để lưu các số thực dương
thuộc tập đã cho. Mảng x[1..n] lưu chỉ số các thành phần thuộc tập con cần tìm. Biến S
lưu tổng các số của tập con trong quá trình hình thành.
int n, j, i, x[20];
float S, M, A[20];
void tongcon()
{ i=1; x[i]=0;
S=0; while
(i>0)

{ x[i]=x[i]+1;
if (x[i]<=n)
{ if (S+A[x[i]]<=M)
{ if (S+A[x[i]]==M)
{ for (j=1; j<=i; j++)
printf("%7.0f",
A[x[j]]);
printf("\n");
}
else
Cấu trúc dữ liệu – trang 69
{ S=S+A[x[i]];
x[i+1]=x[i];
i++;
}
}
}
else
{ i--;// quay lui
S=S-A[x[i]];
}
}
}
main()
{ n=5;
A[1]=50; A[2]=17; A[3]=73; A[4]=40; A[5]=33;
M=90;
tongcon();
getch();
}
Khi thực hiện sẽ có 3 nghiệm là:
50 40 1
x =(x1 , x2)=(1,4)
17 73 2
x =(x1 , x2)=(2,3)
17 40 33 3
x =(x1, x2, x3)=(2, 4, 5)
7.2.3. Bài toán Hoán vị:
Một hoán vị là một dãy có thứ tự gồm n thành phần khác nhau của tập hợp gồm
n số {1, 2, 3, . . . , n}.
Ví dụ với n=3 thì tập là {1, 2, 3} , thì có tất cả 1*2*3 = 6 hoán vị là:
123
132
213
231
312
Cấu trúc dữ liệu – trang 70
321
Có thứ tự (12 khác với 21) + Các thành phần không bắt buộc khác nhau:
111,112,113,121,122,123,131,132,133,211,212,213,221,…
Không có thứ tự + Các thành phần phải khác nhau:
123
Trong 3 vật A,B,C (đt,máy tính,máy ảnh) . Tôi cho anh lấy 2 vật:
AB,AC,BC
Không có thứ tự (12 cũng là 21) + các thành phần không bắt buộc khác nhau:
111 , 112 , 113 , 121
Viết chương trình nhập số nguyên dương n, rồi in ra tất cả các n hoán vị.
int s[1000]; int n;
int trienvong(int k)
{ int i;
for (i=1; i<=k-1; i++)
if (s[k]==s[i]) return 0;
return 1;
}
void hoanvi(int k)
{ int i;
if (k-1==n)
{ for (i=1; i<=n; i++)
printf("%d", s[i]);
printf("\n");
}
else for (i=1; i<=n; i++)
{ s[k]=i;
if (trienvong(k)) hoanvi(k+1);
}
}
---o-O-o--
KIỂM TRA GIỮA KỲ (02/08/2021-08/08/2021): 05/08/2021
- Thi viết.
Cấu trúc dữ liệu – trang 71
- Không dùng tài liệu.
- Nội dung:
o Viết chương trình.
o Rồi tính độ phức tạp.
o Giải thuật Đệ quy.
o Giải thuật Chia để trị.
ĐIỂM BÀI TẬP:
Điểm kiểm tra giữa kỳ + 1
THI CUỐI KỲ (30/08/2021-05/09/2021):
- Thi viết.
- Không dùng tài liệu.
- Nội dung:
o Độ phức tạp.
o Giải thuật Đệ quy.
o Giải thuật Chia để trị.
o Giải thuật Quy hoạch động.
o Giải thuật Tham lam.
o Giải thuật Quay lui.
CÁCH TÍNH ĐIỂM HỌC PHẦN:
= Điểm giữa kỳ * 0.2 + Điểm bài tập * 0.2 + Điểm cuối kỳ * 0.6
Thầy: Phan Chí Tùng , phanchitung@gmail.com , 0989.078.034

Cấu trúc dữ liệu – trang 72


BÀI TẬP CHƯƠNG 1
1. Có 6 đội bóng trong một liên đoàn bóng đá là : A, B, C, D, E, F.
- Đội A đã đấu với đội B và C - Đội B đã đấu với đội D và F
- Đội E đã đấu với đội C và F- Mỗi đội đấu một trận trong tuần.
Tìm lịch đấu sao cho tất cả các đội đấu với nhau trong số tuần ít nhất. Gợi ý : lập
một đồ thì với các đỉnh là các cặp đội chưa đấu với nhau. Các cạnh phải nối các đỉnh
đồ thị sao cho mỗi màu của đỉnh biểu diễn một trận đấu trong tuần.
2. Giả sử ta cần chia căn bậc 2 của 100 số nguyên từ 1 đến 100 ra hai phần (mỗi
phần 50 phần tử) sao cho sự chênh lệch 2 tổng này càng nhỏ càng tốt. Hãy viết Thuật
toán cho bài toán này.
3. Trong phần 1.2 ta xét kiểu dữ liệu trừu tượng ADT SET với các phép toán
MAKENULL, UNION VÀ SISE. Để đơn giản ta giả sử tất cả các tập hợp là các tập
con của tập (0,1,...31) và giả thiết ADT SET như kiểu dữ liệu tập hợp của Pascal set of
0...31. Hãy viết các thủ tục Pascal cho các phép toán trên theo cách thực hiện của SET.
4. Ước số chung lớn nhất (ƯCLN) của háiố nguyên p và q là một số d chia hết
cho p và q. Ta muốn viết chương trình ƯSCLN theo Thuật toán sau :
Giả sử r là số dư phép chia p và q. Nếu r = 0 thì d là ƯSCLN ngược lại thì dặt p
và q, rồi q bằng r và lặp lại quá trình.
a. Chứng minh rằng quá trình này sẽ tìm được đúng ƯSCLN.
b. Tinh chế Thuật toán thành chương trình ngôn ngữ giả.
c. Chuyển chương trình ngôn ngữ giả này ra chương trình Pascal.
5. Viết chương trình dạng một văn bản (text) sao cho các từ trên một dòng sát với
mép phải và trái chương trình có một từ đệm (word buffer) và một dòng đệm (line
bufer). Cả hai, ban đầu là trống. Từ đọc vào được đưa qua từ đệm. Nếu dòng đệm còn
đủ chỗ thì từ được chuyển vào dòng đệm, ngược lại thì chèn các ký tự trống vào giữa
các từ trên dòng đệm cho đầy và làm trống nó bằng cách xuất dòng này ra, tinh chế
Thuật toán này thành chương trình ngôn ngữ giả, sau đó thành chương trình pascal.
6. Cho tập n thành phố và bảng khoảng cách giữa các thành phố. Viết chương
trình ngôn ngữ giả tìm đường đi ngắn nhất qua hết các thành phố một lần và trở về
thành phố xuất phát bằng một Thuật toán kinh nghiệm, nào đó mà bản thân cho là tốt.
7. Cho hàm số n sau đây :
2
F1 (n) = n
2
F2 (n) = n + 1000n
3( ) = { ế ẻ
3 ế ℎẵ

Cấu trúc dữ liệu – trang 73


( )={ ế ≤ 100

ế > 100
3

Hãy chỉ ra từng cặp i, j khác nhau mà fi(n) là 0 (fj (n)) và fi (n) là  (fj (n)).
8. Xét các hàm của n sau :
2
1( ) = { 3 ớ ℎẵ ≥ 0

ớ ẻ≥1

( )={ ớ 0≤ ≤100
2

3
ớ > 100
2.5
3( )=

Hãy chỉ ra từng cặp i, j khác nhau để gi (n) là 0 (gi(n)) và gi(n) là  (gj(n)).
9. Hãy sử dụng ký hiệu "0 lớn" để xác định thời gian chạy của các thủ tục sau đây
như một hàm của n trong trường hợp xấu nhất :
a. Procedure matmpy (n : integer);
Var i, j, k : integer :
begin
for i : = 1 to n do
for j : = 1 to n do
begin
C [i,j] : = 0;
for k : = 1 to n to
C [i,j] : = C[i,j] + A[i,K] * B [k,j]
end
end;
b. Proceduremystery (n: integer);
Vari, j, k : integer;
begin
for i:=1 to n - 1 do
for j : = i + 1 to n do
for k : = 1 to j do
{phát biểu nào đó cần 0 (1) thời gian}
Cấu trúc dữ liệu – trang 74
end;
c. Procedure vervodd (n: integer);
Var x, i, j, y : integer;
Begin
for i ; = 1 to n do
if odd(f) then
begin
for j : = 1 to n do x : = x + 1;
for j : = 1 to i do y : = y + 1;
end;
end;
D. Function recursive (n : integer). Integer;
Begin
If n < = 1 then return (1)
else
return (recursive (n-1) + recursive (n-1))
end;
10. Chứng minh các kết luận sau là đúng :
a. 17 là 0 (1)
2
b. n (n-1) /2 là 0 (n );
3 2 3
c. max (n .10n ) là 0 (n );
n
k+1 k+1
d. i k là 0 (n ) và  (n ) với k - nguyên.
i1

k k
e. Nếu p(x) là đa thức bậc K với các hệ số dương thì p(n) là 0 (n ) và (n ).
11. Giả sử T1 (n) là  (f(n)). Và T2 (n) là  (g(n)). Kết luận nào sau đây là
đúng?
a. T1 (n) + T2 (n) là  (max (f(n), g(n))).
b. T1 (n) T2(n) là  (f(n), g(n)).
12. Một số tác giả định nghĩa ômega lớn () như sau :
f(n) là  (g(n)) nếu  n0 và c > 0 nào đó sao cho n  n0 ta có f(n)  c g(n).
a. Định nghĩa f(n) là  (g(n)) nếu và chỉ nếu g(n) là ( ) (f(n)) có đúng không?
b. Định nghĩa (a) có đúng với định nghĩa  trong mục 1.4 không?
c. Bài tập 12 a) hoặc b) có đúng với định nghĩa  không?
Cấu trúc dữ liệu – trang 75
13. Sắp xếp các hàm sau đây theo tỉ lệ phát triển :
a) n b) c) logn 2
n
d) loglogn e) log n f) n/logn h)
g) n log2 n n n
(1/3) i) (3/2) j) 17
14. Giả sử thông số n trong thủ tục sau là lũy thừa dương của 2 nghĩa là n =
2,4,8,16... Hãy lập công thức biểu diễn giá trị của biến coum qua giá trị của n khi thủ
tục kết thúc.
Procedure mystery (n : integer);
Var x, count : integer ;
begin
count : = 0;
x : = 2;
While x < n do
Begin
x : = 2 * x;
count : = count + 1
end;
writeln (count)
end;

15. Cho hàm max (i, n) là hàm trả phần tử lớn nhất từ vị trí i đến i + n - i của
mảng nguyên (integer array) A. Để cho tiện ta có thể giả thiết n là lũy thừa 2.

Function max (i,n : integer). Integer;


Var m1, m2 : integer;
Begin
If n = 1 then Return (A |i|)
Else
Begin
m1 : = max (i, n div 2);
n2 : = max (i + n div 2, n div 2);
if m1 < m2 then return (m2)
elsse Return (m1)
end;
Cấu trúc dữ liệu – trang 76
end;
a. Giả sử T(n) là thời gian thực hiện max với đối số thứ 2 là n trong trường hợp
xấu, nghĩa là số của các phần tử mà từ đó tìm ra phần tử lớn nhất. Viết chương trình
biểu diễn T(n) qua T(j) với 1 hoặc nhiều giá trị của j < n và hằng hoặc các hằng biểu
diễn thời gian mà các lệnh của chương trình max cần đến.
b. Cho cận trên nhỏ nhất T(n). Tìm , càng đơn giản càng tốt.

BÀI TẬP CHƯƠNG 2


1. Chứng minh rằng : g(n) là 0 (f(n)) nếu :
a. F(n)   với  > 0 nào đó và hầu mọi n (nghĩa là mọi n trừ một số hữu
hạn n).
b. Tồn tại các hằng c1 > 0, c2 > 0 sao cho g(n)  e1f(n) + e2 hầu mọi n  0.
2. Ta sẽ viết f (n)  g (n) nếu tồn tại hằng c > 0 để f(n)  c g(n) với mọi n.
Chứng minh rằng từ f1  g1 và f2  g2. Suy ra f1 + f2  g1 + g2. Cho biết
thêm những tính chất có quan hệ .
3. Viết chương trình cho RAM, RAMP và ngôn ngữ giả cho các bài toán.
a. Tính n! với n trị nhập.
b. Đọc n số dương, kết thúc bởi 0, sau đó in ra theo chiều tăng.
4. Phân tích độ phức tạp thời gian của chương trình bài tập 3 với cả hai tiêu
chuẩn đều và loga.
5. Chứng minh rằng với mỗi chương trình RAM với độ phức tạp thời gian T(n)
theo tiêu chuẩn loga thì tồn tại một chương trình RAM tương đương với độ phức tạp
2
thời gian 0 (T (n)) nếu không có lệnh MULT và DIV. Gọi y; hãy mô hình hoá lệnh
MULT và DIV bằng các routine (chương trình con) trong đó các thanh ghi chẵn dùng
làm bộ nhớ phụ. Trong trường hợp MULT, hãy chứng minh rằng nếu i nhân cho j thì có
l (j) tích riêng, tức là có 0 (l (j)) bước, mà mỗi bước mất thời gian 0 (l (i)).
6. Điều gì xảy ra đối với RAM và RAMP nếu bỏ hai lệnh MULT và DIV trong
tập lệnh của chúng. Điều đó phản ánh như thế nào theo tiêu chuẩn tính toán (tức đều
là loga).

BÀI TẬP CHƯƠNG 3


1. Cho A = {1,2,3}, B = {3,4,5}.Hỏi kết quả các phép toán sau :
a) UNION (A,B,C); b) INTERSECTION (A,B,C);
c) DIFFERENCE (A,B,C) d) MEMBER (1,A);
f) DELETE (1,4) g) MIN (A)
Cấu trúc dữ liệu – trang 77
2. Viết thủ tục theo thuật ngữ các phép toán cơ bản của tập hợp để in tất cả các
phần tử của một tập hữu hạn. Chúng ta có thể giả thiết là thủ tục in ra.
3. Có thể sử dụng cách biểu diễn vectơ bit đối với tập nếu tập "vạn năng" có thể
chuyển thành các số nguyên từ 1 đến n. Hãy mô tả cách chuyển này nếu tập vạn năng là
:
a. Các số nguyên từ 0, 1, 2, 3, ..., 99;
b. Các số nguyên từ n đến m với bất kỳ n  m.
c. Các số nguyên n, n + 2, n + 4, ..., n + 2K, với n, K bất kỳ.
d. Các ký tự 'a', 'b',...,'z'.
e. Các mảng (arrays) của hai ký tự lấy từ 'a' đến 'z'.
4. Viết các thủ tục MEKENULL, UNION, INTERSECTION, MEMBER, MIN
INSERT và DELETE đối với các tập được biểu diễn bằng linked lists (các danh sách
liên kết) sử dụng các phép toán trừu tượng đối với danh sách liên kết ADT. Nhận xét là
hình 4.5 là thủ tục INTERSECTION sử dụng biểu diễn đặc biệt của list ADT.
5. Lập lại bài tập 4 với tập được biểu diễn bởi :
a. Bảng hash mở (dùng các phép toán danh sách trừu tượng trong buckets).
b. Bảng hash đóng với cách giải quyết tuyến tính các mâu thuẩn (linear resolution
of collisions).
c. Danh sách không sắp xếp (unsorted list) dùng các phép toán danh sách
trừu tượng.
d. Mảng (array) có độ dài cố dịnh và pointer chỉ đến vị trí cuối cùng.
6. Với mỗi phép toán và mỗi cách biểu diễn tập trong bài tập 4 và 5 hãy đánh giá
thời gian chạy của các phép toán trên các tập kích thước n.
7. Giả sử ta băm các số nguyên ra 7 thùng (buckets) với hàm :
h(i) = i mod 7
a. Trình bày bảng kết quả băm mở nếu chen các lập phương sau:
1,8,27,64,125,216,343.
b. Lặp lại phần a) Cho bảng băm đóng với giải pháp tuyến tính khi gặp mâu thuẩn.
8. Giả sử ta dùng bảng băm đóng có 5 thùng với hàm
băm : h(i) = i mod 5
Hãy chen dãy 23,48,35,4,10 theo giải pháp tuyến tính gỡ mâu thuẫn.
9. Vẽ tất cả các cây tìm kiếm nhị phân của 4 phần tử 1,2,3,4.
10. Chen các phần tử 7,2,9,0,5,6,8 và 1 vào cây tìm kiếm nhị phân bằng cách lập
thủ tục INSERT ở hình 4.11
11. Trình bày kết quả sau khi xóa 7,sau đó xóa 2 trong cây bài tập 10.
Cấu trúc dữ liệu – trang 78
12. Khi xóa hai phần tử từ cây tìm kiếm nhị phân theo thủ tục DELETE hình
4.11. cây cuối cùng có phụ thuộc vào thứ tự các phần tử bị xóa không?
13. Giả sử ta có các phần tử là các chuỗi ký tự và hàm băm cho bảng 5 thùng (m
= 5).Đặt các giá trị tương ứng cho ký tự.A có trị 1, B có trị 2, ... chia kết quả cho 5
và lấy số dư. Viết ra nội dung bảng băm (mở) và các danh sách khi bổ sung các
chuỗi dasher, dancer, prancer, vixen, comet, cupid, donner, blitzen.
14. Bổ sung 8 chuỗi ở bài tập 13 vào cây tìm kiếm nhị phân. Đi theo dãy nút nào
để kiểm tra chuỗi rudolph có thuộc tập ấy không?
15. Chứng minh thủ tục SEARCH tìm kiếm nhị phân dưới đây :
Procedure SEARCH (a,f,l) {Tìm a trong mảng A đã sắp xếp theo
chiều tăng} {f,l - chỉ số
mảng}
if f>1 then return ('no')
else if a = A ([(f + 1)/2]) then return (yes')
return SEARCH (a, f, [(f +l)/ 2] - 1)
else return SEARCH (a, [(f + l)/2]+1,l)
Cho ta thời gian tìm kiếm trung bình nhỏ nhất nếu tất cả các phần tử cần tìm
kiếm có cùng xác suất.
16. Tìm cây tìm kiếm nhị phân tối ưu đối với các phần tử a, b, .., h nếu các phần
tử này xác suất tương ứng là 0,1; 0,2; 0,05; 0,1; 0,3; 0,05; 0,15; 0,05; xác suất các phần
tử còn lại là 0.
17. Chứng minh rằng Thuật toán ở hình 4.16 có thể hạn chế việc tìm số m ở dòng
8 tại các vị trí từ ri, j,l đến ri, l, j nhưng vẫn bảo đảm giá trị min của ci, K, 1+ cKj.
2
18. Bằng cách thay đổi ở bài tập 17, Thuật toán sẽ làm việc với thời gian 0 (n ).
19. Tìm một cấu trúc dữ liệu hiệu quả để biểu diễn tập con S các số nguyên nằm
giữa 1 và n.Chúng ta muốn thực hiện trên tập này hai phép toán :
a. Chọn và xóa một phần tử trong tập;
b. Bổ sung mốt số nguyên j vào tập
Giả sử không cần bổ sung số nguyên i vào tập khi đã có i trong tập. Cấu trúc dữ
liệu phải sao cho thời gian chọn và xóa phần tử và thời gian bổ sung phần tử là không
phụ thuộc || S || (|| S || là kích thước tức số phần tử trong S).
20. Tìm cây kết quả sau khi Thuật toán ở hình 4.24 và 4.25 thực hiện dãy các phép
toán UNION và SEARCH được cho bởi chương trình dưới đây. Giả sử tập i lúc đầu là
{i} với 1  i  16.
begin
for i : = 1 to 15 step 2 do UNION ( i, i +2, i) ;
Cấu trúc dữ liệu – trang 79
for i : = 1 to 13 step 4 do UNION (j, i+2, i) ;
UNION (1,5,1);
UNION (9,13,9);
UNION (1,9,1);
for i : = 1 to 13 step 4 do SEARCH (i)
end;

BÀI TẬP CHƯƠNG 4


1. Viết chương trình truy hồi cho g - sau với n lũy thừa 2.
Function path (s, t, n : integer) : boolean;
begin
if n = 1 then
if edge (s, t) then return (true)
else return (false) {nếu n > 1}
for i : = 1 to n do
if path (s, i, n div 2) and path (i, t, n, div 2) then return
(true); return (false).
End; {path}
Hàm edge (i,j) trả về true nếu tồn tại cạnh (i,j) của đồ thị n đỉnh hoặc i = j và false
nếu ngược lại. Chương trình làm việc như thế nào?
2. Giải các phương trình truy hồi sau đây khi T (1) = 1 và khi n  2. T(n) thỏa :

a.T(n) = 3T (n/2) + n.
b. T(n) = 3T (n/2) + n2
c.T(n) = 8T (n/2) + n3
3. Giải các phương trình truy hồi sau đây với
T(1) = 1 và T(n) thỏa khi n  2.
a. T(n) = 4T (n/3) + n.
2
b. T(n) = 4T (n/3) + n
3
c. T(n) = 9T (n/3) + n
4. Tìm cận 0 lớn và  của T(n) xác định bởi các phương trình truy hồi sau. Giả
thiết T(1) = 1
a. T(n) = T (n/2) + 1
Cấu trúc dữ liệu – trang 80
b. T(n) = 2T (n/2) + logn
c. T(n) = 2T (n/2) + n
2
d. T(n) = 2T (n/2) + n .
5. Giải phương trình truy hồi :
a. T (1) = 2
T(n) = 2T(n-1) + 1 khi n  2
b. T (1) = 1
T(n) = 2T(n-1) + n khi n  2
6. Giải bài tập 5 bằng phương pháp thay thế.
7. Tổng quát hóa bài 6 bằng cách giải tất cả các dạng truy hồi
T(1) = 1
T (n) = aT(n-1) + d(n) n 1
Theo a và d (n).
n
8. Giả thíêt trong bài 7, d(n) = c với c  1 nào đó. Khi đó T(n) phụ thuộc vào a
và c như thế nào? Tìm dạng T(n)?
9. Giải phương trình sau để tìm dạng T(n).
T(n) = 1
T(n) = n T( n )  n khi n  2
(Hướng dẫn : xem cách giải trong cuốn [3]) (trang 75)

10. Tìm các tổng :


n n n n
n 
a) i b) i K
c) 2'   
 

i 0 i 0 i 0 i  i 
0

11. Chứng minh rằng số thứ tự khác nhau trong việc nhân n ma trận. được cho i
phương trình truy hồi.
T(1)=1
n1
T(n) T(I) T n 1
i1
Chứng minh : 1 2n  (gọi là số calatan)
 
T(n  1)   

n n 
1 

BÀI TẬP CHƯƠNG 5

Cấu trúc dữ liệu – trang 81


n
1. Có thể xác định theo đệ qui số các tổ hợp m từ n được ký hiệu là  với n 



 1
và 0  m  n  m
Như sau :
n  1
m nếu m = 0 hoặc m = n
 
 n   n 1  n 
1 nếu 0 < m < n
      

m 
m  
m 1

     
n
a. Hãy xây dựng hàm đệ qui tính 
 
 

 m
b. Tính thời gian chạy trong trường hợp xấu nhất là hàm của n?
n
c. Hãy viết Thuật toán qui hoạch động để tính 
 
 

 m
Gợi ý : Thuật toán xây dựng bảng như dạng tam giác Pascal.
d. Tính thời gian chạy của Thuật toán của bạn ở c) như hàm của n.
n
2. Cách tính  khác là tính (n) (n-1) (n-2)...(n-m+1)/(1)(2)...(m).
 
 

 m
Tính thời gian chạy trong trường hợp xấu nhất như một hàm của n?
2
3. Thuật toán ở hình 6.9 tốn bộ nhớ 0 (n ). Hãy viết lại Thuật toán này sao cho
chỉ tốn bộ nhớ 0(n).
4. Xây dựng cây tìm kiếm nhị phân tối ưu nếu cho các nút từ c1 đến c3 tương ứng
xác suất như sau :
Ci 1 2 3 4 5
Pi 0.50 0.05 0.08 0.45 0.12
---o-O-o---
Cấu trúc dữ liệu – trang 82
TÀI LIỆU THAM KHẢO
[1] Data structures and Algoritluns (1983)
Alfred V. Ano - John E.Hoperofi - Jeffrey D. Uliman.
[2] The design and analysis of computer Algorthms (cùng tác giả [1]) (1976)
[3] Algoritthmies - Theory and Practice. Gilles Brassard, Paul Braley
[4] Introduction to the design and analyssis of
Algorithans S.E Goodman. S.T.
Hedetniemi (1997).
---o-O-o---
Cấu trúc dữ liệu – trang 83

You might also like