You are on page 1of 95

TRƯỜNG ĐẠI HỌC KIÊN GIANG

KHOA THÔNG TIN & TRUYỀN THÔNG

BÀI GIẢNG

Biên soạn: ThS. Huỳnh Minh Trí

Lưu hành nội bộ - Năm 2019

1
LỜI NÓI ĐẦU

Giải thuật là lĩnh vực nghiên cứu gắn liền các lĩnh vực lập trình, tính toán, thiết
kế chương trình trong những lĩnh vực nghiên cứu lâu đời của khoa học máy tính. Hầu
hết các chương trình được viết ra, chạy trên máy tính, dù lớn hay nhỏ, dù đơn giản hay
phức tạp, đều phải sử dụng các cấu trúc dữ liệu tuân theo các trình tự, logic, cách thức
làm việc theo những nguyên tắc cụ thể, chính là các giải thuật. Việc hiểu biết về các cấu
trúc dữ liệu và thuật toán cho phép các lập trình viên, các nhà khoa học máy tính có
nền tảng lý thuyết vững chắc, có nhiều lựa chọn hơn trong việc đưa ra các giải pháp
cho các bài toán thực tế. Vì vậy việc học tập môn học phân tích thiết kế thuật toán là
một điều rất quan trọng.
Tài liệu này dựa trên những kinh nghiệm và nghiên cứu mà tác giả đã đúc kết,
rút kinh nghiệm, thu thập trong quá trình giảng dạy và nghiên cứu, tham khảo các tài
liệu giảng dạy của các trường đại học, các tác giả đồng ngiệp tại các khoa Công nghệ
Thông tin của các trường Đại học Hàng hải Việt nam, Đại học Cần Thơ, Đại học Công
nhiệp Tp Hồ Chí Minh cùng với sự tham khảo của các tài liệu của các đồng nghiệp,
các tác giả trong và ngoài nước, từ điển trực tuyến Wikipedia. Với các chương được
chia thành các chủ đề khác nhau từ các khái niệm cơ bản cho tới thuật toán, tính độ
phức tạp, các phương pháp sắp xếp, tìm kiếm, … hy vọng sẽ cung cấp cho các em sinh
viên, các bạn đọc giả một tài liệu bổ ích. Mặc dù đã rất cố gắng song vẫn không tránh
khỏi một số thiếu sót, và có vay mượn một số thuật ngữ, từ ngũ của đồng nghiệp, hy
vọng sẽ được các bạn bè đồng nghiệp, các em sinh viên, các bạn độc giả ủng hộ và
góp ý chân thành để tôi có thể hoàn thiện hơn nữa tài liệu này.
Xin gửi lời cảm ơn chân thành tới các bạn bè đồng nghiệp và khoa Thông tin và
Truyền thông, Trường Đại học Kiên Giang đã tạo điều kiện giúp đỡ để tài liệu này có
thể hoàn thành tốt.

2
MỤC LỤC
CHƯƠNG 1: THUẬT TOÁN VÀ HIỆU QUẢ CỦA THUẬT TOÁN .................. 1
SỰ CẦN THIẾT PHẢI PHÂN TÍCH THUẬT TOÁN........................................ 1
Thuật toán (giải thuật) - Algorithm................................................................................. 1
Độ phức tạp thuật toán – Algorithm Complexity ............................................................ 4
Tỷ suất tăng và độ phức tạp thuật toán ......................................................................... 6
Phân tích các chương trình ðệ quy ................................................................................11
Các hàm tiến triển khác ........................................................................... 14
Thiết kế thuật toán...................................................................................... 14
Phương pháp Vét cạn (Brute force) ............................................................................15
Phương pháp Quay lui (Back tracking / try and error).............................................15
Phương pháp Chia để trị (Divide and Conquer) .......................................................18
Phương pháp tham lam (Greedy) ................................................................................26
Qui hoạch động (Dynamic Programming)..................................................................35
Bài tập ......................................................................................................... 38
CHƯƠNG 2: TÌM KIẾM (SEARCHING) ......................................................... 62
Bài toán tìm kiếm ...................................................................................... 62
Tìm kiếm tuần tự (Sequential search) ................................................... 62
Tìm kiếm nhị phân (binary search) ........................................................ 63
Sắp xếp topo .............................................................................................. 65
Bài tập ......................................................................................................... 70
CHƯƠNG 3: SẮP XẾP (SORTING) ...................................................................... 71
Bài toán sắp xếp ......................................................................................... 71
Sắp xếp gián tiếp....................................................................................... 71
Các tiêu chuẩn đánh giá một thuật toán sắp xếp ................................ 72
Các phương pháp sắp xếp cơ bản ........................................................ 73
Sắp xếp chọn (Selection sort) .........................................................................................73
Sắp xếp đổi chỗ trực tiếp (Exchange sort) ....................................................................74
Cài đặt của thuật toán: ........................................................................................ 75
Sắp xếp chèn (Insertion sort) ..........................................................................................75
Sắp xếp nổi bọt (Bubble sort) .........................................................................................78
Cài đặt thuật toán: ................................................................................................ 78
Sắp xếp nhanh (Quick sort) .............................................................................................79
Sắp xếp trộn (merge sort) ................................................................................................83
Cấu trúc dữ liệu Heap, sắp xếp vun đống (Heap sort). ................................................86
Các thuật toán khác .........................................................................................................90
Bài tập.......................................................................................................... 92

3
CHƯƠNG 1: THUẬT TOÁN VÀ HIỆU QUẢ CỦA THUẬT TOÁN

SỰ CẦN THIẾT PHẢI PHÂN TÍCH THUẬT TOÁN


Trong khi giải một bài toán chúng ta có thể có một số giải thuật khác nhau,
vấn đề là cần phải đánh giá các giải thuật đó để lựa chọn một giải thuật tốt (nhất).
Thông thường thì ta sẽ căn cứ vào các tiêu chuẩn sau:
Giải thuật đúng đắn.
Giải thuật đơn giản.
Giải thuật thực hiện nhanh.
Với yêu cầu (1), để kiểm tra tính đúng đắn của giải thuật chúng ta có thể cài đặt
giải thuật đó và cho thực hiện trên máy với một số bộ dữ liệu mẫu rồi lấy kết quả thu được
so sánh với kết quả đã biết. Thực ra thì cách làm này không chắc chắn bởi vì có thể giải
thuật đúng với tất cả các bộ dữ liệu chúng ta đã thử nhưng lại sai với một bộ dữ liệu
nào đó. Vả lại cách làm này chỉ phát hiện ra giải thuật sai chứ chưa chứng minh được
là nó đúng. Tính đúng đắn của giải thuật cần phải được chứng minh bằng toán học. Tất
nhiên điều này không đơn giản và do vậy chúng ta sẽ không đề cập đến ở đây.
Khi chúng ta viết một chương trình để sử dụng một vài lần thì yêu cầu (2) là
quan trọng nhất. Chúng ta cần một giải thuật dễ viết chương trình để nhanh chóng có
được kết quả, thời gian thực hiện chương trình không được đề cao vì dù sao thì chương
trình đó cũng chỉ sử dụng một vài lần mà thôi.
Tuy nhiên khi một chương trình được sử dụng nhiều lần thì thì yêu cầu tiết
kiệm thời gian thực hiện chương trình lại rất quan trọng đặc biệt đối với những chương
trình mà khi thực hiện cần dữ liệu nhập lớn do đó yêu cầu (3) sẽ được xem xét một cách
kĩ càng. Ta gọi nó là hiệu quả thời gian thực hiện của giải thuật.
Thuật toán (giải thuật) - Algorithm
1.1.1.1 Định nghĩa thuật toán
Có rất nhiều các định nghĩa cũng như cách phát biểu khác nhau về định nghĩa
của thuật toán. Theo như cuốn sách giáo khoa nổi tiếng viết về thuật toán là
“Introduction to Algorithms” (Second Edition của Thomas H. Cormen, Charles E.
Leiserson, Ronald L. Rivest và Clifford Stein) thì thuật toán được định nghĩa như sau:
“một thuật toán là một thủ tục tính toán xác định (well-defined) nhận các giá trị hoặc
một tập các giá trị gọi là input và sinh ra ra một vài giá trị hoặc một tập giá trị được
gọi là output”.

1
Nói một cách khác các thuật toán giống như là các cách thức, qui trình để
hoàn thành một công việc cụ thể xác định (well-defined) nào đó. Vì thế một đoạn mã
chương trình tính các phần tử của dãy số Fibonaci là một cài đặt của một thuật toán cụ
thể. Thậm chí một hàm đơn giản để cộng hai số cũng là một thuật toán hoàn chỉnh,
mặc dù đó là một thuật toán đơn giản.
1.1.1.2 Đặc trưng của thuật toán
Tính đúng đắn: Thuật toán cần đảm bảo cho một kết quả đúng sau khi thực
hiện đối với các bộ dữ liệu đầu vào. Đây có thể nói là đặc trưng quan trọng nhất với một
thuật toán.
Tính dừng: thuật toán cần phải đảm bảo sẽ dừng sau một số hữu hạn bước.
Tính xác định: Các bước của thuật toán phải được phát biểu rõ ràng, cụ thể,
tránh gây nhập nhằng hoặc nhầm lẫn đối với người đọc và hiểu, cài đặt thuật toán.
Tính hiệu quả: thuật toán được xem là hiệu quả nếu như nó có khả năng giải
quyết hiệu quả bài toán đặt ra trong thời gian hoặc các điều kiện cho phép trên thực tế
đáp ứng được yêu cầu của người dùng.
Tính phổ quát: thuật toán được gọi là có tính phố quát (phổ biến) nếu nó có
thể giải quyết được một lớp các bài toán tương tự.
Ngoài ra mỗi thuật toán theo định nghĩa đều nhận các giá trị đầu vào được gọi
chung là các giá trị dữ liệu Input. Kết quả của thuật toán (thường là một kết quả cụ thể
nào đó tùy theo các bài toán và thuật toán cụ thể) được gọi là Output.
1.1.1.3 Biểu diễn thuật toán
Có nhiều phương pháp biểu diễn thuật toán .Có thể biểu diễn thuật toán bằng
danh sách các bước, các bước được diễn đạt bằng ngôn ngữ thông thường và các ký
hiệu toán học. Có thể biểu diễn thuật toán bằng sơ đồ khối. Tuy nhiên, để đảm bảo tính
xác định của thuật toán như đã trình bày trên, thuật toán cần được viết trên các ngôn
ngữ lập trình. Một chương trình là sự biểu diễn của một thuật toán trong ngôn ngữ lập
trình đã chọn. Thông thường ta dùng ngôn ngữ lập trình Pascal, một ngôn ngữ thường
được chọn để trình bày các thuật toán trong sách báo. Ngôn ngữ thuật toán là ngôn ngữ
dùng để miêu tả thuật toán .Thông thường ngôn ngữ thuật toán bao gồm ba loại: Ngôn
ngữ liệt kê từng bước; Sơ đồ khối; Ngôn ngữ lập trình;
1.1.1.4 Phương pháp liệt kê từng bước
Ngôn ngữ liệt kê từng bước nội dung như sau:
Thuật toán: Tên thuật toán và chức năng.

2
Vào: Dữ liệu vào với tên kiểu.
Ra: Các dữ liệu ra với tên kiểu. Biến phụ (nếu có) gồm tên kiểu. Hành động là
các thao tác với các lệnh có nhãn là các số tự nhiên. Để giải phương trình bậc hai ax2 + bx
+c = 0, ta có thể mô tả thuật toán bằng ngôn ngữ liệt kê sau:
Bước 1: Xác định các hệ số a, b, c.
Bước 2: Kiểm tra các hệ số a, b, c có khác 0 hay không. Nếu a=0 quay lại thực
hiện bước 1
Bước 3: Tính biểu thức Δ= b2 – 4*a*c.
Bước 4: Nếu <0 thông báo phương trình vô nghiệm và chuyển sang bước 8.
Bước 5: Nếu Δ=0, tính x1=x2= 2−∗ba và chuyển sang bước 7.
Bước 6: Tính x1 = −b2+a√Δ , x2= −b2−a√Δ và chuyển sang bước 7.
Bước 7: Thông báo các nghiệm x1, x2.
Bước 8: Kết thúc thuật toán.
1.1.1.5 Phương pháp sơ đồ
Phương pháp dùng sơ đồ khối mô tả thuật toán là dùng mô tả theo sơ đồ trên mặt
phẳng các bước của thuật toán. Sơ đồ khối có ưu điểm là rất trực giác dễ bao quát. Để mô
tả thuật toán bằng sơ đồ khối ta cần dựa vào các nút sau đây:
Nút thao tác: Biểu diễn bằng hình chữ nhật,

Câu lệnh

Nút điều khiển: Được biểu diễn bằng hình thoi, trong đó ghi điều kiện cần kiểm tra
trong quá trình tính toán.
Điều kiện

Nút khởi đầu, kết thúc: Thường được biểu diễn bằng hình tròn thể hiện sự bắt đầu
hay kết thúc quá trình. Cung: Đoạn nối từ nút này đến nút khác và có mũi tên chỉ hướng.

Bắt đầu

Khối 1: Khối bắt đầu thuật toán, chỉ có duy nhất một đường ra;
Khối 2: Khối kết thúc thuật toán, có thể có nhiều đường vào;
Khối 3: Thực hiện câu lệnh (có thể là một hoặc nhiều câu lệnh); gồm một đường
vào và một đường ra;

3
Khối 4: Rẽ nhánh, kiểm tra biểu thức điều kiện (biểu thức Boolean), nếu biểu thức
đúng thuật toán sẽ đi theo nhánh Đúng (True), nếu biểu thức sai thuật toán sẽ đi theo nhánh
Sai (False).
Khối 5: Các câu lệnh nhập và xuất dữ liệu.

Độ phức tạp thuật toán – Algorithm Complexity


1.1.2.1 Các tiêu chí đánh giá thuật toán
Thông thường để đánh giá mức độ tốt, xấu và so sánh các thuật toán cùng
loại, có thể dựa trên hai tiêu chuẩn:
 Thuật toán đơn giản, dễ hiểu, dễ cài đặt.
 Dựa vào thời gian thực hiện và tài nguyên mà thuật toán sử dụng để thực
hiện trên các bộ dữ liệu.
Trên thực tế các thuật toán hiệu quả thì không dễ hiểu, các cài đặt hiệu quả
cũng không dễ dàng thực hiện và hiểu được một cách nhanh chóng. Và một điều có
vẻ nghịch lý là các thuật toán càng hiệu quả thì càng khó hiểu, cài đặt càng phức tạp
lại càng hiệu quả (không phải lúc nào cũng đúng). Vì thế để đánh giá và so sánh các

4
thuật toán người ta thường dựa trên độ phức tạp về thời gian thực hiện của thuật toán,
gọi là độ phức tạp thuật toán (algorithm complexity). Về bản chất độ phức tạp thuật
toán là một hàm ước lượng (có thể không chính xác) số phép tính mà thuật toán cần
thực hiện (từ đó dễ dàng suy ra thời gian thực hiện của thuật toán) đối với một bộ dữ
liệu input có kích thước N. N có thể là số phần tử của mảng trong trường hợp bài toán
sắp xếp hoặc tìm kiếm, hoặc là độ lớn của số trong bài toán kiểm tra số nguyên tố
chẳng hạn.
Một phương pháp để xác định hiệu quả thời gian thực hiện của một giải thuật là
lập trình nó và đo lường thời gian thực hiện của hoạt động trên một máy tính xác định đối
với tập hợp được chọn lọc các dữ liệu vào. Thời gian thực hiện không chỉ phụ thuộc vào
giải thuật mà còn phụ thuộc vào tập các chỉ thị của máy tính, chất lượng của máy tính và
kĩ xảo của người lập trình. Sự thi hành cũng có thể điều chỉnh để thực hiện tốt trên tập đặc
biệt các dữ liệu vào được chọn. Ðể vượt qua các trở ngại này, các nhà khoa học máy tính
đã chấp nhận tính phức tạp của thời gian được tiếp cận như một sự đo lường cơ bản sự thực
thi của giải thuật. Thuật ngữ tính hiệu quả sẽ đề cập đến sự đo lường này và đặc biệt đối
với sự phức tạp thời gian trong trường hợp xấu nhất.
Thời gian thực hiện chương trình: Thời gian thực hiện một chương trình là một
hàm của kích thước dữ liệu vào, ký hiệu T(n) trong đó n là kích thước (độ lớn) của dữ liệu
vào. Chương trình tính tổng của n số có thời gian thực hiện là T(n) = cn trong đó c là một
hằng số. Thời gian thực hiện chương trình là một hàm không âm, T(n) ≥ 0 với mọi n ≥ 0.
Ðơn vị đo thời gian thực hiện: Ðơn vị của T(n) không phải là đơn vị đo thời gian
bình thường như giờ, phút giây... mà thường được xác định bởi số các lệnh được thực hiện
trong một máy tính lý tưởng. Khi ta nói thời gian thực hiện của một chương trình là T(n) =
Cn thì có nghĩa là chương trình ấy cần Cn chỉ thị thực thi.
Thời gian thực hiện trong trường hợp xấu nhất: Nói chung thì thời gian thực hiện
chương trình không chỉ phụ thuộc vào kích thước mà còn phụ thuộc vào tính chất của dữ
liệu vào. Nghĩa là dữ liệu vào có cùng kích thước nhưng thời gian thực hiện chương trình
có thể khác nhau. Chẳng hạn chương trình sắp xếp dãy số nguyên tăng dần, khi ta cho vào
dãy có thứ tự thì thời gian thực hiện khác với khi ta cho vào dãy chưa có thứ tự, hoặc khi
ta cho vào một dãy đã có thứ tự tăng thì thời gian thực hiện cũng khác so với khi ta cho
vào một dãy đã có thứ tự giảm. Vì vậy thường ta coi T(n) là thời gian thực hiện chương
trình trong trường hợp xấu nhất trên dữ liệu vào có kích thước n, tức là: T(n) là thời gian
lớn nhất để thực hiện chương trình đối với mọi dữ liệu vào có cùng kích thước n.

5
Tỷ suất tăng và độ phức tạp thuật toán
1.1.3.1 Tỷ suất tăng:
Ta nói rằng hàm không âm T(n) có tỷ suất tăng (growth rate) f(n) nếu tồn tại các
hằng số C và N0 sao cho T(n) ≤ Cf(n) với mọi n ≥ N0. Ta có thể chứng minh được rằng
“Cho một hàm không âm T(n) bất kỳ, ta luôn tìm được tỷ suất tăng f(n) của nó”.
Giả sử T(0) = 1, T(1) = 4 và tổng quát T(n) = (n+1)2. Ðặt N0 = 1 và C = 4 thì với
mọi n ≥1 chúng ta dễ dàng chứng minh được rằng T(n) = (n+1)2 ≤ 4n 2 với mọi n ≥ 1, tức
là tỷ suất tăng của T(n) là n2. Tỷ suất tăng của hàm T(n) = 3n3 + 2n2 là n3. Thực vậy, cho
N0 = 0 và C = 5 ta dễ dàng chứng minh rằng với mọi n ≥ 0 thì 3n2 + 2n2 ≤ 5n3
1.1.3.2 Độ phức tạp của giải thuật
Giả sử ta có hai giải thuật P1 và P2 với thời gian thực hiện tương ứng là T1(n) =
100n2 (với tỷ suất tăng là n2) và T2(n) = 5n3 (với tỷ suất tăng là n3). Giải thuật nào sẽ thực
hiện nhanh hơn? Câu trả lời phụ thuộc vào kích thước dữ liệu vào. Với n < 20 thì P2 sẽ
nhanh hơn P1 (T2<T1), do hệ số của 5n3 nhỏ hơn hệ số của 100n2 (5<100). Nhưng khi n >
20 thì ngươc lại do số mũ của 100n2 nhỏ hơn số mũ của 5n3 (2<3). Ở đây chúng ta chỉ nên
quan tâm đến trường hợp n>20 vì khi n<20 thì thời gian thực hiện của cả P1 và P2 đều
không lớn và sự khác biệt giữa T1 và T2 là không đáng kể. Như vậy một cách hợp lý là ta
xét tỷ suất tăng của hàm thời gian thực hiện chương trình thay vì xét chính bản thân thời
gian thực hiện.
Cho một hàm T(n), T(n) gọi là có độ phức tạp f(n) nếu tồn tại các hằng C, N 0
sao cho T(n) ≤ Cf(n) với mọi n ≥ N 0 (tức là T(n) có tỷ suất tăng là f(n)) và kí hiệu T(n) là
O(f(n)) ( đọc là “O của f(n)”)
T(n)= (n+1)2 có tỷ suất tăng là n2 nên T(n)= (n+1)2 là O(n2)
Chú ý: O(C.f(n))=O(f(n)) với C là hằng số. Ðặc biệt O(C) = O(1)
Nói cách khác độ phức tạp tính toán của giải thuật là một hàm chặn trên của hàm
thời gian. Vì hằng nhân tử C trong hàm chặn trên không có ý nghĩa nên ta có thể bỏ qua vì
vậy hàm thể hiện độ phức tạp có các dạng thường gặp sau: log2n, n, nlog2n, n2, n3, 2n, n!,
nn. Ba hàm cuối cùng ta gọi là dạng hàm mũ, các hàm khác gọi là hàm đa thức. Một giải
thuật mà thời gian thực hiện có độ phức tạp là một hàm đa thức thì chấp nhận được tức là
có thể cài đặt để thực hiện, còn các giải thuật có độ phức tạp hàm mũ thì phải tìm cách cải
tiến giải thuật. Vì ký hiệu log2n thường có mặt trong độ phức tạp nên trong khuôn khổ tài
liệu này, ta sẽ dùng logn thay thế cho log2n với mục đích duy nhất là để cho gọn trong cách
viết. Khi nói đến độ phức tạp của giải thuật là ta muốn nói đến hiệu quả của thời gian thực

6
hiện của chương trình nên ta có thể xem việc xác định thời gian thực hiên của chương trình
chính là xác định độ phức tạp của giải thuật. Ta có thể tham khảo bảng bên dưới:
Độ phức tạp Giá trị N lớn nhất
100 000 000
40 000 000

10 000

500

90

20

11

Thời gian thực hiện của các thuật toán có độ phức tạp khác nhau
O(Log(N)) 10-7 giây
O(N) 10-6 giây
O(N*Log(N)) 10-5 giây
O(N2) 10-4 giây
O(N6) 3 phút
O(2N) 1014 năm
O(N!) 10142 năm

1.1.3.3 Chú ý về phân tích thuật toán.


Thông thường khi chúng ta trình bày một thuật toán cách tốt nhất để nói về độ
phức tạp thời gian của nó là sử dụng các chặn. Tuy nhiên trên thực tế chúng ta hay dùng
ký pháp big-O các kiểu khác không có nhiều giá trị lắm, vì cách này rất dễ gõ và cũng được
nhiều người biết đến và hiểu rõ hơn. Nhưng đừng quên là big-O là chặn trên và thường thì
chúng ta sẽ tìm môt chặn trên càng nhỏ càng tốt.
Ví dụ: Cho một mảng đã được sắp A. Hãy xác định xem trong mảng A có hai
phần tử nào mà hiệu của chúng bằng D hay không. Hãy xem đoạn mã chương trình sau:
int j=0;
for (int i=0; i<N; i++){
while ((j<N-1) && (A[i]-A[j] > D))
j++;

7
if (A[i]-A[j] == D)
return 1;
}
Rất dễ để nói rằng thuật toán trên là O(N2): vòng lặp while bên trong được gọi
đến N lần, mỗi lần tăng j lên tối đa N lần. Nhưng một phân tích tốt hơn sẽ cho chúng ta
thấy rằng thuật toán là O(N) vì trong cả thời gian thực hiện của thuật toán lệnh tăng j không
chạy nhiều hơn N lần.
Nếu chúng ta nói rằng thuật toán là O(N2) chúng ta vẫn đúng nhưng nếu nói là
thuật toán là O(N) thì chúng ta đã đưa ra được thông tin chính xác hơn về thuật toán.
1.1.3.4 Cách tính Ðộ phức tạp
Cách tính độ phức tạp của một giải thuật bất kỳ là một vấn đề không đơn giản.
Tuy nhiên ta có thể tuân theo một số nguyên tắc sau:
Qui tắc cộng: Nếu T1(n) và T2(n) là thời gian thực hiện của hai đoạn chương
trình P1 và P2; và T1(n)=O(f(n)), T2(n)=O(g(n)) thì thời gian thực hiện của đoạn hai
chương trình đó nối tiếp nhau là T(n)=O(max(f(n),g(n))). Lệnh gán x:=15 tốn một hằng
thời gian hay O(1), Lệnh đọc dữ liệu READ(x) tốn một hằng thời gian hay O(1). Vậy thời
gian thực hiện cả hai lệnh trên nối tiếp nhau là O(max(1,1))=O(1)
Qui tắc nhân: Nếu T1(n) và T2(n) là thời gian thực hiện của hai đoạn chương trình
P1và P2 và T1(n)=O(f(n)), T2(n) = O(g(n)) thì thời gian thực hiện của đoạn hai đoạn
chương trình đó lồng nhau là T(n) = O(f(n).g(n))
1.1.3.5 Qui tắc tổng quát để phân tích một chương trình
Thời gian thực hiện của mỗi lệnh gán, READ, WRITE là O(1). Thời gian thực hiện
của một chuỗi tuần tự các lệnh được xác định bằng qui tắc cộng. Như vậy thời gian này là
thời gian thi hành một lệnh nào đó lâu nhất trong chuỗi lệnh. Thời gian thực hiện cấu trúc
IF là thời gian lớn nhất thực hiện lệnh sau THEN hoặc sau ELSE và thời gian kiểm tra điều
kiện. Thường thời gian kiểm tra điều kiện là O(1). Thời gian thực hiện vòng lặp là tổng
(trên tất cả các lần lặp) thời gian thực hiện thân vòng lặp. Nếu thời gian thực hiện thân vòng
lặp không đổi thì thời gian thực hiện vòng lặp là tích của số lần lặp với thời gian thực hiện
thân vòng lặp. Tính thời gian thực hiện của thủ tục sắp xếp “nổi bọt”
PROCEDURE
Bubble(VAR a: ARRAY[1..n] OF integer);
VAR i,j,temp: Integer;

8
BEGIN
{1} FOR i:=1 TO n-1 DO
{2} FOR j:=n DOWNTO i+1 DO
{3} IF a[j-1]>a[j]THEN BEGIN{hoán vị a[i], a[j]}
{4} temp := a[j-1];
{5} a[j-1] := a[j];
{6} a[j] := temp;
END;
END;
Về giải thuật sắp xếp nổi bọt, chúng ta sẽ bàn kĩ hơn trong chương 2. Ở đây,
chúng ta chỉ quan tâm đến độ phức tạp của giải thuật. Ta thấy toàn bộ chương trình chỉ
gồm một lệnh lặp {1}, lồng trong lệnh {1} là lệnh {2} , lồng trong lệnh {2} là lệnh {3} và
lồng trong lệnh {3} là 3 lệnh nối tiếp nhau {4} , {5} và {6}. Chúng ta sẽ tiến hành tính độ
phức tạp theo thứ tự từ trong ra. Trước hết, cả ba lệnh gán {4}, {5} và {6} đều tốn O(1)
thời gian, việc so sánh a[j-1] > a[j] cũng tốn O(1) thời gian, do đó lệnh {3} tốn O(1) thời
gian. Vòng lặp {2} thực hiện (n-i) lần, mỗi lần O(1) do đó vòng lặp {2} tốn O((n-i).1) = O(n-
i).Vòng lặp {1} lặp có I chạy từ 1 đến n-1nên thời gian thực hiện của vòng lặp {1} và cũng
là độ phức tạp của giải thuật là :

Chú ý: Trong trường hợp vòng lặp không xác định được số lần lặp thì chúng ta
phải lấy số lần lặp trong trường hợp xấu nhất. Tìm kiếm tuần tự. Hàm tìm kiếm Search
nhận vào một mảng a có n số nguyên và một số nguyên x, hàm sẽ trả về giá trị logic TRUE
nếu tồn tại một phần tử a[i] = x, ngược lại hàm trả về FALSE. Giải thuật tìm kiếm tuần tự
là lần lượt so sánh x với các phần tử của mảng a, bắt đầu từ a[1], nếu tồn tại a[i] = x thì
dừng và trả về TRUE, ngược lại nếu tất cả các phần tử của a đều khác X thì trả về FALSE.
FUNCTION Search(a:ARRAY[1..n] OF Integer; x:Integer): Boolean;
VAR i:Integer; Found:Boolean;
BEGIN
{1} i:=1;
{2} Found:=FALSE;
{3} WHILE(i<=n)AND (not Found) DO
{4} IF A[i]=X THEN Found:=TRUE ELSE i:=i+1;

9
{5} Search:=Found;
END;
Ta thấy các lệnh {1}, {2}, {3} và {5} nối tiếp nhau, do đó độ phức tạp của hàm
Search chính là độ phức tạp lớn nhất trong 4 lệnh này. Dễ dàng thấy rằng ba lệnh {1}, {2}
và {5} đều có độ phức tạp O(1) do đó độ phức tạp của hàm Search chính là độ phức tạp
của lệnh {3}. Lồng trong lệnh {3} là lệnh {4}. Lệnh {4} có độ phức tạp O(1). Trong trường
hợp xấu nhất (tất cả các phần tử của mảng a đều khác x) vòng lặp {3} thực hiện n lần, vậy
T(n) = O(n).
1.1.3.6 Chương trình con không đệ quy
Nếu chúng ta có một chương trình với các chương trình con không đệ quy, để
tính thời gian thực hiện của chương trình, trước hết chúng ta tính thời gian thực hiện của
các chương trình con không gọi các chương trình con khác. Sau đó chúng ta tính thời gian
thực hiện của các chương trình con chỉ gọi các chương trình con mà thời gian thực hiện
của chúng đã được tính. Chúng ta tiếp tục quá trình đánh giá thời gian thực hiện của mỗi
chương trình con sau khi thời gian thực hiện của tất cả các chương trình con mà nó gọi đã
được đánh giá. Cuối cùng ta tính thời gian cho chương trình chính. Giả sử ta có một hệ
thống các chương trình gọi nhau theo sơ đồ sau:

Sơ đồ gọi thực hiện chương trìn con không đệ qui


Chương trình A gọi hai chương trình con là B và C, chương trình B gọi hai chương
trình con là B1 và B2, chương trình B1 gọi hai chương trình con là B11 và B12. Ðể tính
thời gian thực hiện của A, ta tính theo các bước sau:
 Tính thời gian thực hiện của C, B2, B11 và B12. Vì các chương trình con này không
gọi chương trình con nào cả.
 Tính thời gian thực hiện của B1. Vì B1 gọi B11 và B12 mà thời gian thực hiện của
B11 và B12 đã được tính ở bước 1.
 Tính thời gian thực hiện của B. Vì B gọi B1 và B2 mà thời gian thực hiện của B1
đã được tính ở bước 2 và thời gian thực hiện của B2 đã được tính ở bước 1.
 Tính thời gian thực hiện của A. Vì A gọi B và C mà thời gian thực hiện của B đã

10
được tính ở bước 3 và thời gian thực hiện của C đã được tính ở bước 1.
Ta có thể viết lại chương trình sắp xếp bubble như sau: Trước hết chúng ta viết
thủ tục Swap để thực hiện việc hoàn đổi hai phần tử cho nhau, sau đó trong thủ tục Bubble,
khi cần ta sẽ gọi đến thủ tục Swap này.
PROCEDURE Swap (VAR x, y: Integer);
VAR temp: Integer;
BEGIN END;
temp := x; x := y; y := temp;
PROCEDURE Bubble
( VAR a: ARRAY[1..n] OF integer);
VAR i,j :Integer;
BEGIN
{1} FOR i:=1 TO n-1 DO
{2} FOR j:=n DOWNTO i+1 DO
{3} IF a[j-1]>a[j] THEN
Swap(a[j-1], a[j]);
END;
Trong cách viết trên, chương trình Bubble gọi chương trình con Swap, do đó để
tính thời gian thực hiện của Bubble, trước hết ta cần tính thời gian thực hiện của Swap. Dễ
thấy thời gian thực hiện của Swap là O(1) vì nó chỉ bao gồm 3 lệnh gán. Trong Bubble,
lệnh {3} gọi Swap nên chỉ tốn O(1), lệnh {2} thực hiện n-i lần, mỗi lần tốn O(1) nên tốn
O(n-i). Lệnh {1} thực hiện n-1 lần nên:

Phân tích các chương trình ðệ quy


Với các chương trình có gọi các chương trình con đệ quy, ta không thể áp dụng
cách tính như vừa trình bày trong mục 1.5.4 bởi vì một chương trình đệ quy sẽ gọi chính
bản thân nó. Có thể thấy hình ảnh chương trình đệ quy A như sau:

11
Với phương pháp tính độ phức tạp đã trình bày trong mục 1.5.4 thì không thể thực
hiện được. Bởi vì nếu theo phương pháp đó thì, để tính thời gian thực hiên của chương
trình A, ta phải tính thời gian thực hiện của chương trình A và cái vòng luẩn quẩn ấy không
thể kết thúc được. Với các chương trình đệ quy, trước hết ta cần thành lập các phương trình
đệ quy, sau đó giải phương trình đệ quy, nghiệm của phương trình đệ quy sẽ là thời gian
thực hiện của chương trình đệ quy.
1.1.4.1 Thành lập phương trình đệ quy
Phương trình đệ quy là một phương trình biểu diễn mối liên hệ giữa T(n) và T(k),
trong đó T(n) là thời gian thực hiện chương trình với kích thước dữ liệu nhập là n, T(k)
thời gian thực hiện chương trình với kích thước dữ liệu nhập là k, với k < n. Ðể thành lập
được phương trình đệ quy, ta phải căn cứ vào chương trình đệ quy. Thông thường một
chương trình đệ quy để giải bài toán kích thước n, phải có ít nhất một trường hợp dừng ứng
với một n cụ thể và lời gọi đệ quy để giải bài toán kích thước k ( k<n ). Để thành lập
phương trình đệ quy, ta gọi T(n) là thời gian để giải bài toán kích thước n, ta có T(k) là thời
gian để giải bài toán kích thước k. Khi đệ quy dừng, ta phải xem xét khi đó chương trình
làm gì và tốn hết bao nhiêu thời gian, chẳng hạn thời gian này là c(n). Khi đệ quy chưa
dừng thì phải xét xem có bao nhiêu lời gọi đệ quy với kích thước k ta sẽ có bấy nhiêu T(k).
Ngoài ra ta còn phải xem xét đến thời gian để phân chia bài toán và tổng hợp các lời giải,
chẳng hạn thời gian này là d(n). Dạng tổng quát của một phương trình đệ quy sẽ là:

Trong đó C(n) là thời gian thực hiện chương trình ứng với trường hợp đệ quy
dừng. F(T(k)) là một đa thức của các T(k). d(n) là thời gian để phân chia bài toán và tổng
hợp các kết quả. Xét hàm tính giai thừa viết bằng giải thuật đệ quy như sau:
FUNCTION Giai_thua(n:Integer): Integer;
BEGIN END;
IF n=0 then Giai_thua :=1
ELSE Giai_thua := n* Giai_thua(n-1);
Gọi T(n) là thời gian thực hiện việc tính n giai thừa, thì T(n-1) là thời gian thực
hiện việc tính n-1 giai thừa. Trong trường hợp n = 0 thì chương trình chỉ thực hiện một lệnh
gán Giai_thua:=1, nên tốn O(1), do đó ta có T(0) = C1. Trong trường hợp n>0 chương trình

12
phải gọi đệ quy Giai_thua(n-1), việc gọi đệ quy này tốn T(n-1), sau khi có kết quả của việc
gọi đệ quy, chương trình phải nhân kết quả đó với n và gán cho Giai_thua. Thời gian để
thực hiện phép nhân và phép gán là một hằng C2. Vậy ta có

Ðây là phương trình đệ quy để tính thời gian thực hiện của chương trình đệ quy
Giai_thua. Chúng ta xét thủ tục MergeSort một cách phác thảo như sau:
FUNCTION MergeSort (L:List; n:Integer):List;
VAR L1,L2:List;
BEGIN
IF n=1
THEN RETURN(L)
ELSE
BEGIN Chia đôi L thành L1 và L2, với độ dài n/2;
RETURN(Merge(MergeSort(L1,n/2),MergeSort(L2,n/2)));
END;
END;

13
Chẳng hạn để sắp xếp danh sách L gồm 8 phần tử 7, 4, 8, 9, 3, 1, 6, 2 ta có mô hình
minh họa của MergeSort như sau: Hàm MergeSort nhận một danh sách có độ dài n và trả về
một danh sách đã được sắp xếp. Thủ tục Merge nhận hai danh sách đã được sắp L1 và L2 mỗi
danh sách có độ dài n/2 trộn chúng lại với nhau để được một danh sách gồm n phần tử có thứ
tự. Giải thuật chi tiết của Merge ta sẽ bàn sau, chúng ta chỉ để ý rằng thời gian để Merge các
danh sách có độ dài n/2 là O(n). Gọi T(n) là thời gian thực hiện MergeSort một danh sách n
phần tử thì T(n/2) là thời gian thực hiện MergeSort một danh sách n/2 phần tử. Khi L có độ dài 1
(n = 1) thì chương trình chỉ làm một việc duy nhất là return(L), việc này tốn O(1) = C1 thời gian.
Trong trường hợp n > 1, chương trình phải thực hiện gọi đệ quy MerSort hai lần cho L1 và L2
với độ dài n/2 do đó thời gian để gọi hai lần đệ quy này là 2T(n/2). Ngoài ra còn phải tốn thời
gian cho việc chia danh sách L thành hai nửa bằng nhau và trộn hai danh sách kết quả (Merge).
Người ta xác đinh được thời gian để chia danh sách và Merge là O(n) = C2n. Vậy ta có phương
trình đệ quy như sau:
𝑐1 , 𝑛=1
𝑇 (𝑛 ) = { 𝑛
2𝑇 ( ) + 𝑐2 , 𝑛≥1
2
Các hàm tiến triển khác
Trong trường hợp hàm tiến triển không phải là một hàm nhân thì chúng ta không thể
áp dụng các công thức ứng với ba trường hợp nói trên mà chúng ta phải tính trực tiếp nghiệm
riêng, sau đó so sánh với nghiệm thuần nhất để lấy nghiệm lớn nhất trong hai nghiệm đó làm
nghiệm của phương trình.
Ví dụ 2-17: Giải phương trình đệ quy sau:
T(1) = 1
T(n) = 2T(n/2) + nlogn
Phương trình đã cho thuộc dạng phương trình tổng quát nhưng d(n) = nlogn không
phải là một hàm nhân. Ta có nghiệm thuần nhất = nlog a = nlog2 = n. Do d(n) = nlogn không
phải là hàm nhân nên ta phải tính nghiệm riêng bằng cách xét trực tiếp

Theo giả thiết trong phương trình tổng quát thì n = bk nên k = logbn, ở đây do b=2
nên 2k= n và k=logn, chúng ta có nghiệm riêng là O(nlog2n), nghiệm này lớn hơn nghiệm
thuần nhất do đó T(n) = O(nlog2n).

Thiết kế thuật toán.

14
Không có một phương pháp nào có thể giúp chúng ta xây dựng (thiết kế) nên các
thuậ toán cho tất cả các loại bài toán. Các nhà khoa học máy tính đã nghiên cứu và đưa ra
các chiến lược thiết kế các giải thuật chung nhất áp dụng cho các loại bài toán khác nhau.

Phương pháp Vét cạn (Brute force)


Đây là chiến lược đơn giản nhất nhưng cũng là không hiệu quả nhất. Chiến lược
vét cạn đơn giản thử tất cả các khả năng xem khả năng nào là nghiệm đúng của bài toán
cần giải quyết. Ví dụ thuật toán duyệt qua mảng để tìm phần tử có giá trị lớn nhất chính
là áp dụng chiến lược vét cạn. Hoặc bài toán kiểm tra và in ra tất cả các số nguyên tố có 4
chữ số abcd sao cho ab = cd (các số có 2 chữ số) được thực hiện bằng thuật toán vét cạn
như sau:
for(a=1;a<=9;a++)
for(b=0;b<=9;b++)
for(c=0;c<=9;c++)
for(d=0;d<=9;d++)
if(ktnguyento(a*1000+b*100+c*10+d) && (10*a+b==10*c+d))
printf(“%d%d%d%d”, a, b, c, d);
Hàm ktnguyento() kiểm tra xem một số nguyên có phải là số nguyên tố hay
không. Các thuật toán áp dụng chiến lược vét cạn thuộc loại: tìm tất cả các nghiệm có thể
có. Về mặt lý thuyết, chiến lược này có thể áp dụng cho mọi loại bài toán, nhưng có một
hạn chế khiến nó không phải là chìa khóa vạn năng về mặt thực tế: do cần phải thử tất cả
các khả năng nên số trường hợp cần phải thử của bài toán thường lên tới con số rất lớn
và thường quá lâu so với yêu cầu của bài toán đặt ra.

Phương pháp Quay lui (Back tracking / try and error)


Đây là một trong những chiến lược quan trọng nhất của việc thiết kế thuật toán.
Tương tự như chiến lược vét cạn song chiến lược quay lui có một điểm khác: nó lưu giữ
các trạng thái trên con đường đi tìm nghiệm của bài toán. Nếu tới một bước nào đó, không
thể tiến hành tiếp, thuật toán sẽ thực hiện thao tác quay lui (back tracking) về trạng thái
trước đó và lựa chọn các khả năng khác. Bài toán mà loại thuật toán này thường áp dụng
là tìm một nghiệm có thể có của bài toán hoặc tìm tất cả các nghiệm sau đó chọn lấy một
nghiệm thỏa mãn một điều kiện cụ thể nào đó (chẳng hạn như tối ưu nhất theo một tiêu chí
nào đó), hoặc cũng có thể là tìm tất cả các nghiệm của bài toán. Và cũng như chiến lược
vét cạn, chiến lược quay lui chỉ có thể áp dụng cho các bài toán kích thước input nhỏ.

15
Vecto nghiệm
Một trong các dạng bài toán mà chiến lược quay lui thường áp dụng là các bài
toán mà nghiệm của chúng là các cấu hình tổ hợp. Tư tưởng chính của giải thuật là xây
dựng dần các thành phần của cấu hình bằng cách thử lần lượt tất cả các khả năng có thể
có. Nếu tồn tại một khả năng chấp nhận được thì tiến hành bước kế tiếp, trái lại cần lùi
lại một bước để thử lại các khả năng chưa được thử. Thông thường giải thuật này thường
được gắn liền với cách diễn đạt qui nạp và có thể mô tả chi tiết như sau:
Trước hết ta cần hình thức hóa việc biểu diễn một cấu hình. Thông thường ta có thể trình
bày một cấu hình cần xây dựng như là một bộ có thứ tự (vecto) gồm N thành phần: X =
(x1, x2,…,xN) thoả mãn một số điều kiện nào đó. Giả thiết ta đã xây dựng xong i-1 thành
phần x1, x2, …, xi-1, bây giờ là bước xây dựng thành phần xi. Ta lân lượt thử các khả năng
có thể có cho xi. Xảy ra các trường hợp: Tồn tại một khả năng j chấp nhận được. Khi đó xi
sẽ được xác định theo khả năng này. Nếu xi là thành phần cuối (i=n) thì đây là một nghiệm,
trái lại (i<n) thì tiến hành các bước tiếp theo qui nạp. Tất cả các khả năng đề cử cho xi đều
không chấp nhận được. Khi đó cần lùi lại bước trước để xác định lại xi-1. Để đảm bảo cho việc
vét cạn (exhausted) tất cả các khả năng có thể có, các giá trị đề cử không được bỏ sót. Mặt
khác để đảm bảo không trùng lặp, khi quay lui để xác định lại giá trị xi-1 cần không được thử
lại những giá trị đã thử rổi (cần một kỹ thuật đánh dấu các giá trị được thử ở các bước trước).
Trong phần lớn các bài toán, điều kiện chấp nhận j không những chỉ phụ thuộc vào
j mà còn phụ thuộc vào việc xác định i-1 thành phần trước, do đó cần tổ chức một số biến
trạng thái để cất giữ trạng thái của bài toán sau khi đã xây dựng xong một thành phần đẻ
chuẩn bị cho bước xây dựng tiếp. Trường hợp này cần phải hoàn nguyên lại trạng thái cũ
khi quay lui để thử tiếp các khả năng trong bước trước.
Thủ tục đệ qui
Thủ tục cho thuật toán quay lui được thiết kế khá đơn giản theo cơ cấu đệ qui của
thủ tục try dưới đây (theo cú pháp ngôn ngữ C).
void try(i: integer){
// xác định thành phần xi bằng đệ qui int j;
for j in <tập các khả năng đề cử> do
if(chấp nhận j){
<xác định xi theo khả năng j>
<ghi nhận trạng thái mới>
If(i=n) top

16
else <ghi nhận một nghiệm>
try(i+1);
<trả lại trạng thái cũ>
}
}
Trong chương trình chính chỉ cần gọi tới try(1) để khởi động cơ cấu đệ qui hoạt
động. Tất nhiên, trước đấy cần khởi tạo các giá trị ban đầu cho các biến. Thông thường
việc này được thực hiện qua một thủ tục nào đó mà ta gọi là init (khởi tạo).
Hai điểm mấu chốt quyết định độ phức tạp của thuật toán này trong các trường
hợp cụ thể là việc xác định các giá trị đề cử tại mỗi bước dành cho xi và xác định điều
kiện chấp nhận được cho các giá trị này.
Các giá trị đề cử
Các giá trị đề cử thông thường lớn hơn nhiều so với số các trường hợp có thể chấp
nhận được. Sự chênh lệch này càng lớn thì thời gian phải thử càng nhiều, vì thế càng thu
hẹp được điều kiện đề cử càng nhiều càng tốt (nhưng không được bỏ sót). Việc này phụ
thuộc vào việc phân tích các điều kiện ràng buộc của cấu hình để phát hiện những điều
kiện cần của cấu hình đang xây dựng. Lý tưởng nhất là các giá trị đề cử được mặc nhiên
chấp nhận. Trong trường hợp này mệnh đề <chấp nhận j> được bỏ qua (vì thế cũng không
cần các biến trạng thái).
Ví dụ 1: Sinh các dãy nhị phân độ dài N (N ≤ 20)
Ví dụ dưới đây trình bày chương trình sinh các dãy nhị phân độ dài N, mỗi dãy
nhị phân được tổ chức như một màng n thành phần: x[0], x[1], …, x[n-1]. Trong đó mỗi
x[i] có thể lấy một trong các giá trị từ 0 tới 1, có nghĩa là mỗi phần tử x[i] của vecto
nghiệm có 2 giá trị đề cử, và vì cần sinh tất cả các xâu nhị phân nên các giá trị đề cử này
đều được chấp nhận. Thủ tục chính của chương trình đơn giản như sau:
void try(int k){
int j; if(k==n)
in_nghiem();
else
for(j=0;j<=1;j++){
x[k] = j;
try(k+1);
}

17
}
Trong đó in_nghiem() là hàm in nghiệm tìm được ra màn hình. Dưới đây là toàn
bộ chương trình. Trong chương trình có khai báo thêm biến count để đếm các chỉnh hợp
được tạo.

Phương pháp Chia để trị (Divide and Conquer)


Chiến lược chia để trị là một chiến lược quan trọng trong việc thiết kế các giải
thuật. Ý tưởng của chiến lược này nghe rất đơn giản và dễ nhận thấy, đó là: khi cần giải
quyết một bài toán, ta sẽ tiến hành chia bài toán đó thành các bài toán nhỏ hơn, giải các
bài toán nhỏ hơn đó, sau đó kết hợp nghiệm của các bài toán nhỏ hơn đó lại thành nghiệm
của bài toán ban đầu.
Tuy nhiên vấn đề khó khăn ở đây nằm ở hai yếu tố: làm thế nào để chia tách bài
toán một cách hợp lý thành các bài toán con, vì nếu các bài toán con lại được giải quyết
bằng các thuật toán khác nhau thì sẽ rất phức tạp, yếu tố thứ hai là việc kết hợp lời giải
của các bài toán con sẽ được thực hiện như thế nào?.
Bài toán ví dụ
Giả sử ta có thuật toán α để giải bài toán kích thước dữ liệu với thời gian bị chặn bởi
cn2. Xét thuật toán β để giải chính bài toán đó bằng cách
• Bước 1 : Chia bài toán cần giải ra thành 3 bài toán con với kích thước n/2
• Bước 2 : Giải 3 bài toán con bằng thuật toán α
• Bước3 : Tổng hợplời giải của 3 bài toán con để thu được lời giải của bài toán
Giả sử bước 3 được thực hiện với thời gian d.n

Gọi T (n) : thời gian của thuật toán α


m

T p (n): thời gian của thuật toán β

Khi đó T (n) = cn
m
2

T (n) =3 T
p m (n) + dn= cn2 + dn

Nên nếu dn<cn2/4 (d<cn/4) thì thuật toán β nhanh hơn α . Điều này luôn đúng với
nđủ lớn. Tuy nhiên ta thấy thuật toán β mới chỉ thay đổi được nhân tử hằng số chưa thay
đổiđược bậc nhưng cũng hiệu quả khi n lớn. Do đó, nếu ta tiếp tục chia bài toán con nhỏ nữa
tới n0≤ 4 d/c ta sẽ thu được một thuật toán hiệu quả hơn. Xét thuật toán sau :

18
ProcedureGamma(n) (* n kích thước bài toán*)
Begin
If n ≤ n0 Then
Giải bài toán bằng thuật toán α
Else
Begin
End
End;
1. Chia bài toán thành ba bài toán con kích thước n/2
2. Giải mỗi bài toán con bằng thuật toán Gamma
3. Tổng hợp lời giải của các bài toán con
Gọi T (n) là thời gian tính của thuật toán trên, và thời gian tổng hợp lời giải của các
bài toán con là? (n) thì
𝑐𝑛2 , 𝑛 < 𝑛0
𝑇 (𝑛 ) = { 𝑛
3𝑇 ( ) + 𝑑𝑛, 𝑛 ≥ 𝑛0
2

Ta có được: T(n) = O(nlog3)


Thuật toán thu được có thời gian tính là tốt hơn cả thuật toánαvà thuật toán β. Hiệu
quả thu được trong thuật toán? có được là nhờ ta đã triệt để khai thác hiệu quả việc sử dụng
thuật toán β. Để có được mô tả chi tiết thuật toán chia để trị chúng ta cần phải xác định:
1. Kích thước tới hạn n0 (Bài toán có kích thước nhỏ hơn n0 sẽ không cần chia nhỏ)
2. Kích thước của mỗi bài toán con trong cách chia
3. Số lượng các bài toán con như vậy
4. Thuật toán tổng hợp lời giải của các bài toán con
Các phần xác định trong 2 và 3 phụ thuộc vào 4. Chia như thế nào để khi tổng hợp có
hiệu quả (thường là tuyến tính). Tổng hợp lời giải của r bài toán con để thu được lời giải
của bài toán gốc
Procedure D_and_C(n)
Begin
If n < n0 Then
Giải bài toán một cách trực tiếp
Else
Chia bài toán thành r bài toán con kích thước n/k

19
For (Mỗi bài toán trong r bài toán con) Do
D_and_C(n)
End;
Thuật toán tìm kiếm nhị phân
Bài toán : Cho mảng x[1..n] được sắp xếp theo thứ tự không giảm và y. Tìm i sao cho x[i]
= y. (Giả thiết i tồn tại).
Phân tích giải thuật: Số y cho trước
• Hoặc là bằng phần tử nằm ở vị trí giữa mảng x
• Hoặc là nằm ở nửa bên trái (y < phần tử ở giữa mảng x )
• Hoặc là nằm ở nửa bên phải (y < phần tử ở giữa mảng x ) Từ nhận xét đó ta có
giải thuật sau
Function Bsearch(x[1..n],Start,Finish)
Begin
Middle := (Start + Finish)/2;
If (y = x[Middle]) then return middle
Else
If ( y < x[Middle] then
return Bsearch(x,Start,Middle-1)
Else
Return Bsearch (x,Middle+1,Finish)
End;
Phân tích hiệu quả thuật toán : T(n) :
𝑐
𝑛
𝑇(1) = {𝑇 ( ) + 𝑐
2

ta có được : T(n) = O(logn)


Phép nhân các số nguyên lớn
Xét lại vấn đề nhân các số nguyên lớn. Nhớ lại rằng thuật toán cổ điển mà phần lớn
chúng ta đều được học ở trường đòi hỏi thời gian tính là Ρ(n2) để nhân các số nguyên m
có n chữ số. Chúng ta cũng quen với thuật toán này đến mức có thể còn chẳng bao giờ thắc
mắc về tính tối ưu của nó. Liệu chúng ta có thể làm được tốt hơn không? .
Một thuật toán được bàn đến gọi là kỹ thuật Chia để trị bao gồm việc rút gọn phép
nhân hai số nguyên n chữ số xuống thành bốn phép nhân hai số nguyên n/2 chữ số.

20
Việc nhân 2 số nguyên có 1 chữ số có thể thực hiện một cách trực tiếp (neo đệ qui),
thời gian thực hiện là O(1). Chia : n >1 thì tích của hai số nguyên có n chữ số có thể biểu
diễn qua tích 4 số nguyên có n/2 chữ số,thời gian thực hiện là 4. T(n /2) ( trong đó T(n) là
thời gian thực hiện nhân hai số nguyên cón chữ số).
Tổng hợp : Cộng và dịch phải, khi đó thời gian thực hiện sẽ là?( n ) Khi đó ta có thời
gian thực hiện thuật toán là
1, 𝑛=1
𝑇 (𝑛 ) = { 𝑛
4𝑇( ), 𝑛≥1
2
2
Theo định lý thợ ta có độ phức tạp của thuật toán là T n = (On ) .Như vậy thuật toán thu

được cũng không gặt hái được bất kỳ cải thiện nào so với thuật toán nhân cổ điển mặc dù
chúng ta đã khôn ngoan hơn. Để vượt được thuật toán cổ điển và như vậy mới hoàn toàn thấy

21
rõ được công dụng của phép Chia để trị, chúng ta phải tìm cách rút gọn phép nhân nguyên
thuỷ không phải về bốn mà là ba phép nhân hai nửa.
Chúng ta minh hoạ quá trình này bằng việc nhân 981 với 1234. Trước tiên chúng ta
điền thêm vào toán hạng ngắn hơn một số không vô nghĩa để làm cho hai toán hạng có cùng
độ dài, vậy là 981 trở thành 0981. Sau đó tách từng toán hạng thành hai nửa: 0981 cho ra w
= 09 và x = 81, còn 1234 thành y = 12 và z= 34.
Lưu ý rằng 981 = 102w + x và 1234 = 102y + z.
Do đó, tích cần tìm có thể tính được là
981 x 1234 =(102w + x)( 102yz)
= 104 wy + 102(wz + xy) +xz
= 1080000 + 127800 + 2754 =1210554
Thủ tục trên đến bốn phép nhân hai nửa:wy, wz, xy và xz. Để ý điểm mấu chốt ở đây
là thực ra thì không cần tính cả wz lẫn xy, mà là tổng của hai số hạng này. Liệu có thể thu
được wz + xy với chi phí của một phép nhân mà thôi hay không? Điều này có vẻ như không
thể được cho đến khi chúngta nhớ ra rằng mình cũng cần những giá trị wy và xz để đưa vào
công thức trên. Lưu ý về điểm này, hãy xét tích:
r = (w + x)(y+z) = wy +(wz + xy) + xz
Chỉ sau một phép nhân, chúng ta thu được tổng của tất cả ba số hạng cần thiết để tính
được tích mình mong muốn. Điều này gợi ý một cách tiến hành như sau:
p = wy = 09 * 12 =108 q = xz = 81 * 34 =2754 r = (w + x)(y+z) = 90 *46 = 4140 và
cuối cùng 981 x 1234 =104p + 102(r – p – q ) + q = 1080000 + 127800 + 2754 =1210554.
Như vậy tích của 981 và 1234 có thể rútgọn về ba phép nhân của hai số có hai chữ số
(09 12, 81 34 và 90 46) cùng với một số nào đó phép dịch chuyển (nhân với luỹ thừa của 10),
phép cộng và phép trừ. Chắc chắn là số các phép cộng – coi phép trừ như là phép cộng có
nhiều hơn so với thuật toán Chia để trị nguyên thuỷ ở phần trước. Vậy thì có đáng để thực
hiện bốn phép cộng nhiều hơn để tiết kiệm một phép nhân hay không? Câu trả lời là không
nếu chúng ta đang nhân số nhỏ như những số trong ví dụ này. Tuy nhiên sẽ là đáng giá nếu
các số cần được nhân với nhau đủ lớn và chúng càng lớn thì lại càng đáng làm như vậy. Khi
các số hạng đủ lớn, thời gian cần cho các phép cộng và dịch chuyển trở thành bỏ qua được
so với thời gian cần cho chỉ một phép nhân. Như vậy là có lý do để kỳ vọng rằng rút gọn bốn
phép nhân về còn ba sẽ giúp chúng ta cắt giảm được 25% thời gian tính toán đòi hỏi cho việc
nhân các số lớn. Như chúng ta sẽ thấy, sự tiết kiệm sẽ tốt hơn một cách đáng kể.
Để giúp chúng ta hiểu thấu được những gì mình đạt được, hãy giả thiết rằng có một cài

22
đặt của thuật toán nhân cổđiển đòi hỏi thời gian h(n) = cn2 để nhân hai số có n chữ số,với
hằng số c phụ thuộc vào cài đặt đó. (ở đây đã có sự đơn giản hoá vì trên thực tế thì thời gian
đòi hỏi còn có dạng phức tạp hơn, chẳng hạn như cn2+ bn + a). Tương tự, cho g(n) là thời
gian mà thuật toán Chia để trị cần để nhân hai số n chữ số, không tính thời gian cần thiết để
thực hiện ba phép nhân hai nửa. Nói cách khác, g(n) là thời gian cần thiết cho các phép cộng,
dịch chuyển và các phép tính phụ thêm khác. Dễ dàng cài đặt các phép tính này sao cho
g(n)∈Ρ(n). Hãy tạm thời bỏ qua điều gì sẽ xảy ra nếu n lẻ và nếu các số hạng không có cùng
độ dài. Nếu từng trong số ba phép nhân hai nửa được thực hiện bằng thuật toán cổ điển, thời
gian cần thiết để nhân hai số có n chữ số là:
3h(n/2) + g(n) =3c(n/2)2+ g(n) = cn2+ g(n)= h(n) +g(n).
Vì h(n) rất nhỏ xo với Ρ(n2) và g(n) rất nhỏ xo với Ρ(n),số hạng g(n) là bỏ qua được so
với h(n) khi n đủ lớn, có nghĩa là chúng ta tăng được tốc độ lên khoảng 25% so với thuật toán
cổ điển như đã mong đợi. Mặc dù sự cải thiện này là không thể xem thường được nhưng
chúng ta vẫn không làm được thay đổi bậc của thời gian cần thiết:thuật toán mới vẫn cần thời
gian tính bậc hai.
Để có thể làm được tốt hơn thế, chúng ta trở lại với câu hỏi đặt ra ở đoạn mở đầu: các
bài toán con cần được giải như thế nào? Nếu chúng nhỏ thôi thì thuật toán cổ điển có vẫn còn
là cách làm tốt nhất. Tuy nhiên, khi những bài toán con cũng đủ lớn, chẳng lẽ sử dụng thuật
toán mới của chúng ta một cách đệ quy cũng không hơn gì hay sao? Ý tưởng này tương tự
như hưởng lợi nhuận từ một tài khoản ngân hàng có gộp vốn lẫn lãi! Nếu chúng ta làm như vậy
sẽ thu được một thuật toán có thể nhân hai số n chữ số trong một thời gian t(n) = 3t(n/2) + g(n)
khi n chẵn và đủ lớn. Điều này cũng giống như phép truy toán (đệ quy); giải ra ta thu được t(n)
∈ O(nlg3)|n là luỹ thừa của 2. Chúng ta cần phải bằng lòng với lời ghi chú tiệm cận có điều kiện
vì chưa đề cập đến câu hỏi là nhân các số có độ dài là lẻ như thế nào.
Vì lg3 = 1.585 nhỏ hơn 2, thuật toán này có thể nhân hai số nguyên lớn nhanh hơn rất
nhiều so với thuật toán nhân cổ điển và n càng lớn thì sự cải thiện này càng đáng giá. Mộ cài
đặt tốt có thể không sử dụng cơ số 10, mà sử dụng cơ số lớn nhất để với cơ số đó phần cứng cho
phép nhân trực tiếp hai “chữ số” với nhau.
Một nhân tố quan trọng trong hiệu suất thực tế của cách tiếp cận phép nhân này và của bất
kỳ thuật toán Chia để trị nào là biết khi nào cần dừng việc phân chia các bài toán và thay vào đó
sử dụng thuật toán cổ điển. Mặc dù cách tiếp cận Chia để trị trởnên có ích khi bài toán cần giải
đủ lớn, trên thực tế nó có thể chậm hơn so vớithuật toán cổ điển đối với những bài toán quá nhỏ.
Do đó thuật toán Chia để trị phải tránh việc thực hiện đệ quy khi kích thước của các bài toán con

23
không phù hợp nữa. Chúng ta sẽ trở lại vấn đề này ở phần sau.
Để đơn giản, một số vấn đề quan trọng đến nay đã bị bỏ qua. Làm thế nào để chúng ta giải
quyết được những số có độ dài lẻ? Mặc dù cả hai nửa của số nhân và số bị nhân đều có kích
thước n/2, có thể xảy ra trường hợp tổng của chúng bị tràn và có kích thước vượt quá 1. Do đó
sẽ không hoàn toàn chính xác khi nói rằng r = (w+x)(y+z) bao hàm phép nhân hai nửa. Điều này
ảnh hưởng tới việc phân tích thời gian chạy như thế nào? Làm thế nào để nhân hai số có kích
thước khác nhau? Còn những phép tính số học nào khác với phép nhân mà ta có thể xử lý hiệu
quả hơn so với dùng thuật toán cổ điển?
Những số có độ dài lẻ được nhân dễ dàng bằng cách tách chúng càng gần ở giữa càng
tốt: một số có n chữ số được tách thành một số có |n/2| chữ số và một số có |n/2| chữ số.
Câu hỏi thứ hai còn khắt khe hơn. Xét nhân 5678 với 6789. Thuật toán của chúng ta tách
các số hạng thành w = 56, x = 78, y = 67 và z = 89. Ba phép nhân hai nửa cần thực hiện là: p
= wy = 56.67 q = xz =78.89 và r = (w+x)(y+z)=134.156
Phép nhân thứ ba bao gồm những số 3 chữ số, do vậy nó không thực sự là một nửa so
với phép nhân nguyên thuỷ của các số có 4 chữ số. Tuy nhiên kích thước của w+x và y+z
không thể vượt quá 1 +|n/2|.
Để đơn giản hoá việc phân tích, cho t(n)là thời gian mà thuật toán này thực hiện trong
tìn huống xấu nhất để nhân hai số có kích thước tối đa là n (thay vì chính xác bằng n).
Theo định nghĩa thì t(n) là một hàm không giảm. Khi n đủ lớn thuật toán của chúng ta
rút gọn phép nhân hai số có kích thước tối đa n đó về ba phép nhân nhỏ hơn p = wy, qxz và
r = (w+x)(y+z) với kích thước tối đa tương ứng là |n/2|, |n/2| và 1 + |n/2|, thêm vào đó là
những thao tác đơn giản chiếm thời gian là O(n). Do đó ở đây tồn tại hằng số dương c sao
cho: t(n) = t(|n/2|) +t(|n/2|) + t(1+|n/2|) + cn với mọi n đủ lớn. Điều này chính xác là phép đệ
quy mà chúng ta đã nghiên cứu cho kết quả g iờ đây đã trở nên quen thuộc là t(n)∈O( nlg 3).
Do vậy luôn luôn có thể nhân các số n chữ số với thời gian O(nlg3). Phân tích tình huống tồi nhất
của thuật toán này chỉ rarằng trên thực tế t(n) Ρ( nlg3), nhưng điều này không được quan tâm
lắm vì còn có những thuật toán nhân nhanh hơn.
Quay lại với câu hỏi nhân các số có kích thước khác nhau, giả sử u và v là những số nguyên
có kích thước tương ứng là mvà n. Nếu m và n nằm trong khoảng đến hai lần của nhau, tốt nhất
là điền vào số hạng nhỏ hơn những số 0 vô nghĩa để làm cho nó có cùng độ dài như số hạng kia,
như chúng ta đã làm khi nhân 981 với 1234. Tuy nhiên cách tiếp cận này không được khuyến
khích khi một số hạng lớn hơn số hạng kia rất nhiều. Thậm chí nó có thể tồi hơn là dùng thuật
toán nhân cổ điển! Không làm mất đi tính tổng quát, giả sử rằng m≥n.Thuật toán Chia để trị sử

24
dụng điền số và thuật toán cổ điển có thời gian tươn gứng là Ρ(nlg3) và Ρ(mn) để tính các tích u
và v. Xét thấy rằng hằng số Nn của biểu thức trước có vẻ lớnhơn của biểu thức sau, chúng ta thấy
rằng Chia để trị sử dụng điền số là chậm hơn thuật toán cổ điển khi m = nlg(3/2) và như vậy
trường hợp đặc biệt khi m = n.Mặc dù vậy rất dễ dàng kết hợp cả hai thuật toán để thu được một
thuật toán thực sự tốt hơn. ý tưởng là cắt lát số hạng dài hơn v thành những đoạn có kích thước
m và sử dụng thuật toán Chia để trị để nhân u với từng đoạn của v sao cho thuật toán Chia để trị
được dùng để nhân những cặp số hạng có cùng kích thước. Tích cuối cùng của u và v sau đó
thuđược dễ dàng bằng các phép cộng và dịch chuyển đơn giản. Thời gian chạy tổng cộng chủ
yếu được dùng để thực hiện |n/m| phép nhân các số m chữ số. Vì mỗi phépnhân nhỏ hơn này
chiếm thời gian Ρ(mlg3) và vì |n/m|∈Ρ(n/m), thời gian chạy tổng cộng để nhân một số n chữ số
với một số m chữ số là Ρ(nmlg(3/2)) khi m = n. Sau đây là mô hình cải tiến thuật toán nhân số
nguyên lớn Cải tiến để còn lại 3 phép nhân :
Đặt U = a * c; V= b * d; W = (a+b) * ( c+d)
a*d+b*c=W–U–V
Z = U * 10n + (W – U - V )*10n/2 + V
Từ đó ta đưa ra thuật toán nhân số nguyên lớn là
Function Karatsuba(x,y,n);
Begin
If n = 1 then Return x[0]*y[0]
Else
Begin
a := x[n-1].. . x[n/2];
b := x[n/2-1] . . .x[0];
c := y[n-1]. .. y[n/2];
d := y[n/2-1] . . .y[0];
U :=Karatsuba(a,c,n/2);
V :=Karasuba(b,d,n/2);
W :=Karatsuba(a+b,c+d,n/2);
Return U*10n+ (W-U-V)*10n/2+ V
end
End;
Phân tích hiệu quả thuật toán : T ( n )
T(1) =1
T(n) = 3 T(n/2) + cn
25
=> Ta có được T(n)=Ρ(nlog3)

Phương pháp tham lam (Greedy)


Giải thuật tham lam (tiếng Anh: Greedy algorithm) là một thuật toán giải quyết một bài toán
theo kiểu metaheuristic để tìm kiếm lựa chọn tối ưu địa phương ở mỗi bước đi với hy vọng tìm
được tối ưu toàn cục.
Hiểu một cách đơn giản như sau : Bây giờ mẹ bạn cho bạn 2 tờ tiền mệnh giá 100.000 đ và
200.000 đ và bạn chỉ được chọn 1. Và đương nhiên mình sẽ chọn tờ 200.000 đ vì nó giá trị hơn
mặc dù số lượng và kích thước của 2 tờ đều như nhau.
Một ví dụ khác nhé . Ta có một ba lô có trọng lượng là 37 và 4 loại đồ vật với trọng lượng
và giá trị tương ứng, yêu cầu ở đây là bạn sẽ phải chọn tối đa số lượng đồ vật để vừa phù hợp với
trọng lượng của ba lô mà giá trị lấy được là lớn nhất.
Từ đó ta có kỹ thuật Tham lam áp dụng cho bài toán này là:
1) Tính đơn giá cho các loại đồ vật.
2) Xét các loại đồ vật theo thứ tự đơn giá từ lớn đến nhỏ.
3) Với mỗi đồ vật được xét sẽ lấy một số lượng tối đa mà trọng lượng còn lại của ba lô
cho phép.
4) Xác định trọng luợng còn lại của ba lô và quay lại bước 3 cho đến khi không còn có
thể chọn được đồ vật nào nữa.
Ta có một ba lô có trọng lượng là 37 và 4 loại đồ vật với trọng lượng và giá trị tương ứng
được cho như sau :
Loại đồ vật : A - B - C - D
Trọng lượng : 15 - 10 - 2 - 4
Giá trị : 30 - 25 - 2 - 6
Từ bảng đã cho ta tính đơn giá cho các loại đồ vật và sắp xếp các loại đồ vật này theo thứ
tự đơn giá giảm dần ta có bảng sau.
Loại đồ vật : B - A - D - C
Trọng lượng : 10 - 15 - 4 - 2
Giá trị : 25 - 30 - 6 - 2
Đơn giá : 2.5 - 2.0 - 1.5 - 1.0
Theo đó thì thứ tự ưu tiên để chọn đồ vật là là B, A, D và cuối cùng là C. Vật B được xét
đầu tiên và ta chọn tối đa 3 cái vì mỗi cái vì trọng lượng mỗi cái là 10 và ba lô có trọng lượng
37. Sau khi đã chọn 3 vât loại B, trọng lượng còn lại trong ba lô là 37 – 3*10 = 7. Ta xét đến vật
A, vì A có trọng lượng 15 mà trọng lượng còn lại của balô chỉ còn 7 nên không thể chọn vật A.

26
Xét vật D và ta thấy có thể chọn 1 vật D, khi đó trọng lượng còn lại của ba lô là 7-4 = 3. Cuối
cùng ta chọn được một vật C. Như vậy chúng ta đã chọn 3 cái loại B, một cái loại D và 1 cái loại
C. Tổng trọng lương là 310 + 14 + 12 = 36 và tổng giá trị là 325+16+12 = 83.
Thuật toán
Nói chung, giải thuật tham lam có năm thành phần:
 Một tập hợp các ứng viên (candidate), để từ đó tạo ra lời giải
 Một hàm lựa chọn, để theo đó lựa chọn ứng viên tốt nhất để bổ sung vào lời giải
 Một hàm khả thi (feasibility), dùng để quyết định nếu một ứng viên có thể được dùng
để xây dựng lời giải
 Một hàm mục tiêu, ấn định giá trị của lời giải hoặc một lời giải chưa hoàn chỉnh
 Một hàm đánh giá, chỉ ra khi nào ta tìm ra một lời giải hoàn chỉnh.
Có hai thành phần quyết định nhất tới quyết định tham lam:
 Tính chất lựa chọn tham lam
Chúng ta có thể lựa chọn giải pháp nào được cho là tốt nhất ở thời điểm hiện tại và sau đó
giải bài toán con nảy sinh từ việc thực hiện lựa chọn vừa rồi. Lựa chọn của thuật toán tham lam
có thể phụ thuộc vào các lựa chọn trước đó. Nhưng nó không thể phụ thuộc vào một lựa chọn
nào trong tương lai hay phụ thuộc vào lời giải của các bài toán con. Thuật toán tiến triển theo
kiểu thực hiện các chọn lựa theo một vòng lặp, cùng lúc đó thu nhỏ bài toán đã cho về một bài
toán con nhỏ hơn. Đấy là khác biệt giữa thuật toán này và giải thuật Quy Hoạnh Động. Giải thuật
quy hoạch động duyệt hết và luôn đảm bảo tìm thấy lời giải. Tại mỗi bước của thuật toán, quy
hoạch động đưa ra quyết định dựa trên các quyết định của bước trước, và có thể xét lại đường đi
của bước trước hướng tới lời giải. Giải thuật tham lam quyết định sớm và thay đổi đường đi thuật
toán theo quyết định đó, và không bao giờ xét lại các quyết định cũ. Đối với một số bài toán, đây
có thể là một thuật toán không chính xác.
 Cấu trúc con tối ưu
Một bài toán được gọi là “có cấu trúc tối ưu”, nếu một lời giải tối ưu của bài toán con chứa
lời giải tối ưu của bài toán lớn hơn.
Ta có thể thực hiện cài đặt bằng các thủ tục như sau:
1. Tính đơn giá của các sản phẩm.
struct DoVat {
char Ten [20];
float TrongLuong, GiaTri, DonGia;
int PhuongAn;//so luong do vat chon

27
};`
2. Tính đơn giá của các sản phẩm. Độ phức tạp thuật toán là O(n)
void TinhDonGia(DoVat sp[], int n){
for(int i = 1; i <= n; i++)
sp[i].DonGia = sp[i].GiaTri / sp[i].TrongLuong;
}
3. Sắp xếp giảm dần theo đơn giá. Độ phức tạp thuật toán O(n2)
void SapXep(DoVat sp[], int n) {
for(int i = 1; i <= n - 1; i++)
for(int j = i + 1; j <= n; j++)
if (sp[i].DonGia < sp[j].DonGia)
swap(sp[i], sp[j]);
}
4. Xác định sản phẩm cần lấy. Độ phức tạp thuật toán là O(n)
void Greedy(DoVat sp[], int n, float W) {
for (int i = 0; i < n; i++) {
sp[i].PhuongAn = W / sp[i].TrongLuong;
W -= sp[i].PhuongAn * sp[i].TrongLuong;
}
}
ĐẶC TRƯNG CỦA CHIẾN LƯỢC THAM LAM
BÀI TOÁN TỐI ƯU TỔ HỢP
 Là một dạng của bài toán tối ưu, nó có dạng tổng quát như sau:
 Cho hàm f(X) = xác định trên một tập hữu hạn các phần tử D. Hàm f(X) được gọi
là hàm mục tiêu.
 Mỗi phần tử X Є D có dạng X = (x1, x2... xn) được gọi là một phương án.
 Cần tìm một phương án X Є D sao cho hàm f(X) đạt min (max). Phương án X
như thế được gọi là phương án tối ưu.
Ta có thể tìm thấy phương án tối ưu bằng phương pháp “vét cạn” nghĩa là xét tất cả các
phương án trong tập D (hữu hạn) để xác đinh phương án tốt nhất. Mặc dù tập hợp D là hữu
hạn nhưng để tìm phương án tối ưu cho một bài toán kích thước n bằng phương pháp “vét
cạn” ta có thể cần một thời gian mũ. Các phần tiếp theo của chương này sẽ trình bày một số

28
kĩ thuật giải bài toán tối ưu tổ hợp mà thời gian có thể chấp nhận được.
Nội dung kĩ thuật tham ăn
Tham ăn hiểu một cách dân gian là: trong một mâm có nhiều món ăn, món nào ngon
nhất ta sẽ ăn trước và ăn cho hết món đó thì chuyển sang món ngon thứ hai, lại ăn hết món
ngon thứ hai này và chuyển sang món ngon thứ ba… Kĩ thuật tham ăn thường được vận dụng
để giải bài toán tối ưu tổ hợp bằng cách xây dựng một phương án X. Phương án X được xây
dựng bằng cách lựa chọn từng thành phần Xi của X cho đến khi hoàn chỉnh (đủ n thành phần).
Với mỗi Xi, ta sẽ chọn Xi tối ưu. Với cách này thì có thể ở bước cuối cùng ta không còn gì
để chọn mà phải chấp nhận một giá trị cuối cùng còn lại. Áp dụng kĩ thuật tham ăn sẽ cho
một giải thuật thời gian đa thức, tuy nhiên nói chung chúng ta chỉ đạt được một phương án
tốt chứ chưa hẳn là tối ưu. Có rất nhiều bài toán mà ta có thể giải bằng kĩ thuật này.
Đặc tính lựa chọn tham lam
Toàn bộ phương pháp tối ưu có thể đạt được từ việc chọn tối ưu trong từng bước chọn.
Về khía cạnh này giải thuật tham lam khác với giải thuật quy hoạch động ở chỗ: Trong qui
hoạch động chúng ta thực hiện chọn cho từng bước, nhưng việc lựa chọn này phụ thuộc vào
cách giải quyết các bài toán con. Với giải thuật tham lam, tại mỗi bước chúng ta chọn bất cứ
cái gì là tốt nhất vào thời điểm hiện tại, và sau đó giải quyết các vấn đề phát sinh từ việc chọn
này. Vấn đề chọn thực hiện bởi giải thuật tham lam không phụ thuộc vào việc lựa chọn trong
tương lai hay cách giải quyết các bài toán con. Vì vậy khác với quy hoạch động, giải quyết các
bài toán con theo kiểu bottom up (từ dưới lên), giải thuật tham lam thường sử dụng giải pháp
top-down (từ trên xuống). Chúng ta phải chứng minh rằng với giải thuật tham lam, toàn bộ bài
toán được giải quyết một cách tối ưu nếu mỗi bước việc chọn được thực hiện tối ưu. Các bước
chọn tiếp theo được thực hiện tương tự như bước đầu tiên, nhưng với bài toán nhỏ hơn. Ph- ương
pháp qui nạp được ứng dụng trong giải thuật tham lam có thể được sử dụng cho tất cả các
bước chọn
Cấu trúc con tối ưu
Một bài toán thực hiện optimal substructure nếu cách giải quyết tối ưu của bài toán chứa
đựng cách giải quyết tối ưu những bài toán con của nó. Tính chất này được đánh giá là một thành
phần có thể áp dụng được của thuật toán quy hoạch động tốt như thuật toán tham lam. Một ví dụ
của optimal substructure, nếu A là đáp án tối ưu của bài toán với hành động chọn đầu tiên là 1,
thì tập hợp A’= A-{1} là đáp án tối ưu cho bài toán S’= {i Є S: si ≥ f1}.
Đặc điểm chung của thuật toán tham lam
Mục đích xây dựng bài toán giải nhiều lớp bài toán khác nhau, đưa ra quyết định dựa

29
ngay vào thuật toán đang có, và trong tương lai sẽ không xem xét lại quyết định trong quá
khứ. Vì vậy thuật toán dễ đề xuất, thời gian tính nhanh nhưng thường không cho kết quả
đúng.
 Lời giải cần tìm có thể mô tả như là bộ gồm hữu hạn các thành phần thoả mãn
điều kiện nhất định, ta phải giải quyết bài toán một cách tối ưu -> hàm mục tiêu
 Để xây dựng lời giải ta có một tập các ứng cử viên
 Xuất phát từ lời giải rỗng, thực hiện việc xây dựng lời giải từng bước, mỗi bước
sẽ lựa chọn trong tập ứng cử viên để bổ xung vào lời giải hiện có.
 Xây dựng một hàm nhận biết tính chấp nhận được của lời giải hiện có -> Hàm
Solution(S) -> Kiểm tra thoả mãn điều kiện chưa.
Một hàm quan trọng nữa: Select(C) cho phép tại mỗi bước của thuật toán lựa chọn
ứng cử viên có triển vọng nhất để bổ xung vào lời giải hiện có -> dựa trên căn cứ vào ảnh
hưởng của nó vào hàm mục tiêu, thực tế là ứng cử viên đó phải giúp chúng ta phát triển tiếp
tục bài toán.
Xây dựng hàm nhận biết tính chấp nhận được của ứng cử viên được lựa chọn, để có
thể quyết định bổ xung ứng viên được lựa chọn bởi hàm Select vào lời giải -> Feasible(S x).
Procedure Greedy;
{*Giả sử C là tập các ứng cử viên*}
begin
S :=Ø ; {* S là lời giải xây dựng theo thuật toán *}
While(C≠ 0)andnotSolution(S)do
Begin
x←size12{leftarrow}
{}Select( C );
C:=C\x;
If feasible(S x) then S:=S x
End;
If solution(S) then return S;
End;
Chứng minh tính đúng đắn
• Công việc này không phải đơn giản. Ta sẽ nêu một lập luận được sử dụng để
chúng minh tính đúng đắn.
• Để chỉ ra thuật toán không cho lời giải đúng chỉ cần đưa ra một phần ví dụ

30
• Việc chứng minh thuật toán đúng khó hơn nhiều và ta sẽ nghiên cứu cụ thể trong
phần sau:
Lập luận biến đổi (Exchange A r gument)
Giả sử cần chứng minh thuật toán A cho lời giải đúng. A(I) là lời giải tìm được bởi thuật
toán A đối với bộ dữ liệu I. Còn O là lời giải tối ưu của bài toán với bộ dữ liệu này.
Ta cần tìm cách xây dựng phép biến đổi φ để biến đổi O thành O’ sao cho:
1. O’ cũng tốt không kém gì O (Nghĩa là O’ vẫn tối ưu)
2. O’ giống với A(I) nhiều hơn O.
Giả sử đã xây dựng được phép biến đổi vừa nêu. Để chứng minh tính đúng đắn dựa
vào hai sơ đồ chứng minh sau
Chứng minh bằng phản chứng: Giả sử A không đúng đắn, hãy tìm bộ dữ liệu I sao cho
A(I) khác với lời giải tối ưu của bài toán. Gọi O là lời giải tối ưu giống với A(I) nhất => A(I)
khác O. Dùng phép biến đổi φ chúng ta có thể biến đổi O → O’ sao cho O’ vẫn tối ưu và O’
giống với A(I) hơn => mâu thuẫn giả thiết O là lời giải tối ưu giống với A(I) nhất.
Chứng minh trực tiếp: O là lời giải tối u. Biến đổi O → O’ giống với A(I) hơn là O. Nếu
O’ = A(I) thì A(I) chính là phương án tối u ngược lại biến đổi O’ → O’’ giống với A(I) hơn. Cứ
thế ta thu được dãy O’, O’’, O’’’….. ngày càng giống hơn, và chỉ có một số hữu hạn điều kiện
để so sánh nên chỉ sau một số hữu hạn phép biến đổi sẽ kết thúc và đó là tại A(I).

BÀI TOÁN TRẢ TIỀN CỦA MÁY RÚT TIỀN TỰ ĐỘNG ATM
Trong máy rút tiền tự động ATM, ngân hàng đã chuẩn bị sẵn các loại tiền có mệnh giá
100.000 đồng, 50.000 đồng, 20.000 đồng và 10.000 đồng. Giả sử mỗi loại tiền đều có số
lượng không hạn chế. Khi có một khách hàng cần rút một số tiền n đồng (tính chẵn đến 10.000
đồng, tức là n chia hết cho 10000). Hãy tìm một phương án trả tiền sao cho trả đủ n đồng và
số tờ giấy bạc phải trả là ít nhất.
Gọi X = (X1, X2, X3, X4) là một phương án trả tiền, trong đó X1 là số tờ giấy bạc
mệnh giá 100.000 đồng, X2 là số tờ giấy bạc mệnh giá 50.000 đồng, X3 là số tờ giấy bạc
mệnh giá 20.000 đồng và X4 là số tờ giấy bạc mệnh giá 10.000 đồng. Theo yêu cầu ta phải
có X1 + X2 + X3 + X4 nhỏ nhất và X1 * 100.000 + X2 * 50.000 + X3 *20.000 + X4 *
10.000 = n.
Áp dụng kĩ thuật tham ăn để giải bài toán này là: để có số tờ giấy bạc phải trả (X1 +
X2 + X3 + X4) nhỏ nhất thì các tờ giấy bạc mệnh giá lớn phải được chọn nhiều nhất. Trước
hết ta chọn tối đa các tờ giấy bạc mệnh giá 100.000 đồng, nghĩa là X1 là số nguyên lớn nhất

31
sao cho X1 * 100.000 ≤ n. Tức là X1 = n DIV 100.000.
Xác định số tiền cần rút còn lại là hiệu n – X1 * 100000 và chuyển sang chọn loại giấy
bạc 50.000 đồng…
Khách hàng cần rút 1.290.000 đồng (n = 1290000), phương án trả tiền như sau:
X1 = 1290000 DIV 100000 = 12.
Số tiền cần rút còn lại là 1290000 – 12*100000= 90000.
X2 = 90000 DIV 50000 = 1.
Số tiền cần rút còn lại là 90000 – 1 * 50000 = 40000. X3 = 40000 DIV 20000 = 2.
Số tiền cần rút còn lại là 40000 – 2 * 20000 = 0. X4 = 0 DIV 10000 = 0.
Ta có X = (12, 1, 2, 0), tức là máy ATM sẽ trả cho khách hàng 12 tờ 100.000 đồng, 1
tờ 50.000 đồng và 2 tờ 20.000 đồng.

BÀI TOÁN VỀ CÁC ĐOẠN THẲNG KHÔNG GIAO NHAU


Đầu vào : Cho họ các đoạn thẳng mở
Đầu ra : Tập các đoạn thẳng không giao nhau có lực lượng lớn nhất.
Ứng dụng thực tế: Bài toán xếp thời gian biểu cho các hội thảo, bài toán phục vụ khách
hành trên một máy, bài toán lựa chọn hành động (Ví dụ có nlời mời dự tiệc bắt đầu bởi aikết
thúc bởi bi, hãy lựa chọn sao cho đi được nhiều tiệc nhất).
Đề xuất các thuật toán :
Greedy 1: Sắp xếp các đoạn thẳng theo thứ tự tăng dần của đầu mút trái, bắt đầu từ tập S
là tập rỗng ta lần lượt xếp các đoạn thẳng trong danh sách theo thứ tự đã xếp và bổ sung đoạn
thẳng đang xét vào S nếu nó không có điểm chung với bất cứ đoạn nào trong
Thuật toán :
Procedure Greedy1; Begin
S:=Ø;{*Slàtậpcácđoạnthẳngcần tìm*}
<Sắp xếp các đoạn th ẳ ng trong C theo thứ tự không
giảm của nút trái>
While C≠0 do
Begin
End;
(ac,bc)Є đoạn đầu tiên trong C;
C:=C\(ac,bc);
If <(ac,bc)không giao với bất cứ đoạn nào trong s>then

32
S := S (ac,bc)
End;
<S là tập cần tìm>
Độ phức tạp của thuật toán là O(nlogn) nằm trong đoạn sắp xếp.
Tuy nhiên Greedy1 không cho lời giải tối ưu. Ví dụ sau

Ta thấy rằng thuật toán sẽ lựa chọn dạ tiệc 1, trong khi phương án tối ưu của bài toán là
( Dạ tiệc 2, Dạ tiệc 3)
Greedy2: Ta chọn đoạn có độ dài ngắn nhất bổ xung vào S. Tuy nhiên thuật toán tham
lam này cũng không cho kết quả tối ưu. Sau đây là phản ví dụ

Khi đó thuật toán sẽ lựa chọn (dạ tiệc 1) trong khi lời giải tối ưu của thuật toán là (dạ
tiệc 2, dạ tiệc 3).
Greedy3: Xắp xếp các đoạn thẳng theo thứ tự không giảm của mút phải. Bắt đầu từ tập S
là tập rỗng ta lần lượt xét các đoạn trong danh sách theo thứ tự đã sắp xếp và bổ xung đoạn
thẳng đang xét vào S nếu nó không có điểm chung với bất cứ đoạn nào trong S.
(Dạ tiệc nào kết thúc sớm sẽ được xét trước ).
Mệnh đề 1: Thuật toán Greedy3 cho lời giải tối ưu của bài toán về các đoạn thẳng
không giao nhau.
Chứng Minh: Giả sử Greedy3 không cho lời giải đúng. Phải tìm bộ dữ liệu C sao cho
thuật toán không cho lời giải tối u. Giả sử G3(C) là lời giải tìm được bởi Greedy3. Gọi O
là lời giải tối ưu có số đoạn thẳng chung với G3(C) là lớn nhất. Gọi X là đoạn thẳng đầu
tiên có trong G3(C) nhưng không có trong O. Đoạn này tồn tại, nếu trái lại thì G3(C) ≡ O
(mâu thuẫn vì đã giả thiết G3(C) ≠ O) hay G3(C) Є O (Cũng mâu thuẫn vì khi đó thuật
toán phải chọn đoạn thẳng X) (O cũng được sắp xếp giống G3(C)). Gọi Y là đoạn đầu tiên
kể từ bên trái của O không có mắt trong G3(C). Đoạn Y cũng phải tồn tại (Chứng minh

33
tương tự như trên). Khi đó mút phải của đoạn X phải ở bên trái (nhỏ hơn) mút phải của
đoạn Y, vì nếu trái lại thuật toán sẽ chọn Y thay vì X.
𝑂′ = 𝑂 {𝑌} ∪ {𝑋}
 O’ gồm các đoạn thẳng không giao với nhau, bởi vì X không giao với bất kì đoạn nào ở bên
trái nó trong O’ (do G3(C) là chấp nhận được) cũng như không giao với bất cứ đoạn nào ở
bên phải nó trong O’ (Do mút phải của X nhỏ hơn mút phải của Y và Y không giao với bất
cứ đoạn nào ở bên phải Y trong O’).
 Do O’ có cùng lực lượng với O nên O’ cũng là tối ưu
 Tuy nhiên ta thấy rằng O’ giống với G3(C) hơn là O => mâu thuẫn với giả thiết.

BÀI TOÁN CÁI BALO


 Bài toán: cho n đồ vật, trong lượng tương ứng của từng đồ vật là wi, và giá trị là ci(), Ta
chất đồ vật vào túi có trọng lượng b, sao cho tổng trọng lượng không vượt quá b và đạt
giá trị lớn nhất. Ta có thể tóm tắt bài toán như sau:
C= {1,2,…,n}: tập chỉ số của đồ vật.
∑𝑖∈𝐼 𝑤𝑖 ≤ 𝑏
Tìm 𝐼 ⊂ 𝐶 sao cho : ∑𝑖∈𝐼 𝐶𝑖 → 𝑚𝑎𝑥
Đề xuất thuật toán tham lam
Greedy1: Sắp xếp theo thứ tự không tăng của giá trị. Xét các đồ vật theo thứ tự đã xếp,
lần lượt chất các đồ vật đang xét vào túi nếu dung lượng còn lại trong túi đủ chứa nó. Thuật toán
tham lam này không cho lời giải tối ưu. Sau đây là phản ví dụ:
Tham số của bài toán là n = 3; b = 19.
Đồ vật 1 2 3
Giá trị 20 16 8 -> giá trị lớn nhưng trọng lượng cũng rất lớn
Trọng lượng 14 6 10
Thuật toán sẽ lựa chọn đồ vật 1 với tổng giá trị là 20, trong khi lời giải tối ưu của bài
toán là lựa chọn (đồ vật 2, đồ vật 3 ) với tổng giá trị là 24.

Greedy2: Sắp xếp đồ vật không giảm của trọng lượng. Lần lượt chất các đồ vật vào túi
theo thứ tự đã sắp xếp. Thuật toán tham lam này cũng không cho kết quả tối ưu. Sau đây là phản
ví dụ:
Tham số của bài toán là n = 3; b = 11
Đồ vật 1 2 3

34
Giá trị 10 16 28 ->Đồ vật nhẹ nhưng giá tiền cũng rất nhẹ
Trọng lượng 5 6 10
Thuật toán sẽ lựa chọn (đồ vật 1, đồ vật 2) với tổng giá trị là 26, trong khi lời giải tối
ưu của bài toán là (đồ vật 3) với tổng giá trị là 28.
Greedy3: Sắp xếp các đồ vật theo thứ tự không tăng của giá trị một đơn vị trọng 1
lượng (ci/wi) . Lần lượt xét
𝑐1 𝑐2 𝑐𝑛
≥ ≥⋯ ≥
𝑤1 𝑤2 𝑤𝑛
Tuy nhiên Greedy3 không cho lời giải tối ưu. Sau đây là phản ví dụ của bài toán Tham
số của bài toán : n= 2; b≥ 2.
𝑐1 10 10𝑏 − 1 𝑐2
= ≥ =
𝑤1 1 𝑏 𝑤2
Khi đó thuật toán chỉ lựa chọn được đồ vật 1 với tổng giá trị là 10, trong khi lời giải
tối ưu của bài toán lựa chọn đồ vật 2 với tổng giá trị là 10b-1 ( ≥ 10.2-1 = 19 > 10).
Greedy4: Gọi Ij là lời giải thu được theo thuật toán Greedyj (j = 1, 2, 3). Gọi

𝑧 = max{∑ 𝑐𝑗 ∗ ∑ 𝑐𝑗 ∗ ∑ 𝑐𝑗 }
𝑗∈𝐼1 𝑗∈𝐼2 𝑗∈𝐼3

Định lý : Lời giải I4 thoả mãn bất đẳng thức ∑𝑗∈𝐼4 𝑐𝑖


Trong đó f* là giá trị tối u của bài toán.

Qui hoạch động (Dynamic Programming)


• Mục đich: Cải tiến thuật toán chia để trị hoặc quay lui vét cạn để giảm thời gian thực
hiện chương trình
• Ý tưởng: Lưu trữ cac kết quả của cac bai toan con trong BẢNG QUY HOẠCH (cơ
chế caching). Đổi bộ nhớ lấy thời gian (trade memory for time)
• Thiết kế giải thuật bằng kỹ thuật Quy hoạch động
– Phân tich bài toán dùng kỹ thuật chia để trị/quay lui
– Chia bai toan thanh cac bai toan con
– Tìm quan hệ giữa kết quả của bài toán lớn và kết quả của các bài toán con (truy hồi)
– Lập bảng quy hoạch
– Số chiều = số biến trong cong thức truy hồi
– Thiết lập quy tắc điền kết quả vao bảng quy hoạch
● Điền các ô không phụ thuộc trước

35
● Điền các ô phụ thuộc sau
– Tra bảng tim kết quả (thường chỉ tim được gia trị)
– Lần vết tren bảng để tim lời giải tối ưu
Vi dụ: tính tổ hợp
– C(n,k) = 1 nếu (n=k) hoặc k=0
– C(n,k) = C(n-1, k-1) + C(n-1, k)
42

Vi dụ: tính tổ hợp


int comb(int n, int k) {
if((k == 0) || (k == n))
return 1;
else
return comb(n-1, k-1) + comb(n-1, k);
}
Độ phức tạp giải thuật đệ quy: T(n) la thời gian để tinh số tổ hợp chập k của n, thì ta
co phương trinh đệ quy:
T(1) = C1
T(n) = 2T(n-1) + C2
=> Vậy độ phức tạp qua lớn: T(n) = O(2n)

36
int comb(int n, int k) {
int c[maxlen][maxlen], i, j;
c[0][0] = 1;
for(i = 1; i<=n; i++) {
c[i][0] = 1; c[i][i] = 1;
for(j=1; j<i; j++)
c[i][j] = c[i-1][j-1] + c[i-1][j];
}
return c[n][k];
}
int comb(int n, int k) {
int c[maxlen], i, j, p1, p2;
c[0] = 1; c[1] = 1;
for(i = 2; i<=n; i++) {
p1 = c[0];
for(j=1; j<i; j++) {
p2 = c[j];
c[j] = p1 + p2;
p1 = p2;
}
c[i] = 1;
}
return c[k];
}
Kết hợp quy hoạch động va đệ quy
• Sử dụng bảng quy hoạch để lưu kết quả bai toan con
• Khong cần điền hết tất cả bảng quy hoạch
Điền bảng quy hoạch theo yeu cầu. Bắt đầu từ bai toan gốc: Nếu trong bảng quy hoạch
chưa co KQ, gọi đệ quy để tim kết quả va lưu kết quả vào bảng quy hoạch. Nếu KQ đa co
trong bảng quy hoạch, sử dụng ngay kết quả này
• Có thể sử dụng bảng băm để lưu trữ bảng quy hoạch
ĐÁNH GIÁ HAI PHƯƠNG PHÁP
Chia để trị

37
• Ý tưởng
– Phân r. thành các bài toán con
– Tổng hợp kết quả
• Giải thuật:
– Đệ quy từ trên xuống
– Độ phức tạp thời gian lớn nếu có nhiều bài con giống nhau
– Không cần lưu trữ kết quả của tất cả các bài toán con
Quy hoạch động
• Ý tưởng. tưởng:
– Phân rả thành các bài toán con
– TÌm mối quan hệ
• Giải thuật:Giải thuật:
– Lập bảng quy hoạch và giải từ dưới lên
– Độ phức tạp thời gian nhỏ hơn nhờ sử dụng bảng quy hoạch
– Cần bộ nhớ để lưu trữ bảng quy hoạch

Bài tập
 Bài tập 1: Xây dựng sơ đồ giải thuật cho bài toán tính số Fibonaci thứ N, biết rằng
dãy số Fibonaci được định nghĩa như sau:
F[0] = F[1] = 1, F[N] = F[N-1] + F[N-2] với N ≥ 2.
 Bài tập 2: Xây dựng sơ đồ giải thuật cho bài toán tính biểu thức:

x  x ...  x
Với N số x thực nằm trong các dấu căn bậc hai, N và x nhập từ bàn phím.
 Bài tập 3: Cho một ma trận kích thước MxN gồm các số nguyên (có cả số âm và
dương). Hãy viết chương trình tìm ma trận con của ma trận đã cho sao cho tổng các
phần tử trong ma trận con đó lớn nhất có thể được (bài toán maximum sum plateau).
Hãy đưa ra đánh giá về độ phức tạp của thuật toán sử dụng.
 Bài tập 4: Viết chương trình nhập vào các hệ số của một đa thức (giả sử các hệ số là
nguyên và đa thức có biến x là một số nguyên) và một giá trị x0. Hãy tính giá trị của
đa thức theo công thức Horner sau:
Nếu f(x) = an*xn + an-1*xn-1+ .. +a1*x + a0 thì
f(x) = a0 + x*(a1+x*(a2+x*(….+x(an-1+an*x)…) (Công thức Horner).
 Bài tập 5: Cho 4 hình hộp kích thước bằng nhau, mỗi mặt của hình hộp được tô bằng

38
1 trong 4 màu xanh, đỏ, tím, vàng. Hãy đưa ra tất cả các cách xếp các hình hộp thành
1 dãy sao cho khi nhìn theo các phía trên xuống, đằng trước và đằng sau của dãy đều
có đủ cả 4 màu xanh, đỏ, tím vàng.
 Bài tập 6: Hãy viết chương trình nhanh nhất có thể được để in ra tất cả các số nguyên
số có hai chữ số.
 Bài tập 7: Áp dụng thuật toán sàng để in ra tất cả các số nguyên tố nhỏ hơn N.
 Một số bài toán tham khảo:
Bài toán tháp Hà Nội
void chuyen(int n, char a, char c){
printf(“Chuyen dia thu %d tu coc %c sang coc %c \n”,n,a,c);
return;
}
void thaphanoi(int n, char a, char c, char b){
if (n==1) chuyen(1, a, c);
else{
thaphanoi(n-1, a, b, c);
chuyen(n, a, c);
thaphanoi(n-1, b, c,a);
}
return;
}
Thuật toán sinh xâu nhị phân( sử dụng quay lui)
Void Try ( int i ) {
for (int j =0; j<=1; j++){
X[i] = j;
if ( i ==n) Result();
else Try (i+1);
}
}
Bài toán Mã đi tuần-Thuật toán quay lui
void ThuNuocTiepTheo {
Khởi tạo danh sách các nước đi kế tiếp;
do{

39
Lựa chọn 1 nước đi kế tiếp từ danh sách;
if Chấp nhận được {
Ghi lại nước đi;
if Bàn cờ còn ô trống {
ThuNuocTiepTheo;
if Nước đi không thành công
Hủy bỏ nước đi đã lưu ở bước trước
}
}
}while (nước đi không thành công)&&(vẫn còn nước đi)
}
void ThuNuocTiepTheo(int i, int x, int y, int *q){
int k, u, v, *q1;
k=0;
do{
*q1=0;
u=x+dx[k];
v=y+dy[k];
if ((0 <= u) && (u<n) && (0 <= v) && (v<n) &&
(Banco[u][v]==0)){
Banco[u][v]=i;
if (i<n*n) {
ThuNuocTiepTheo(i+1, u, v, q1)
if (*q1==0) Banco[u][v]=0; }
else *q1=1;
}
k=k+1;
}while ((*q1==0) && (k<8));
*q=*q1;
}

Bài toán xếp lịch


 Giả sử bạn có n hoạt động S={1,2,…n} sử dụng một tài nguyên nào đó, mà mỗi lần

40
chỉ một hoạt động sử dụng tài nguyên đó. Mỗi một hoạt động bao gồm : Thời gian
bắt đầu:Bi (i là chỉ số), Thời gian kết thúc :Ei. Với Bi <=Ei
 Yêu cầu: tìm tập hợp A có kích thước cực đại các hoạt động tương thích nhau mà
không xung đột. A là tập hợp cần tìm (kết quả của bài toán). Bạn nên sắp xếp dữ liệu
đầu vào hay nói cách khác là điều chỉnh dữ liệu đầu vào một cách hợp lý trước khi
giải quyết bài toán. Dữ liệu vào của bài toán đã được sắp xếp trước theo thứ tự tăng
dần của thời gian kết thúc
Thuật toán bài toán xếp lịch
Chạy 1 vòng lặp thực hiện việc: Kiểm tra ở tập B nếu hoạt động nào có thời gian
bắt đầu Bi không trùng hay đè lên thời gian kết thúc Ej của hoạt động đã được bổ sung
vào A trước đó thì cho vào A (Bi >= Ej trước). Ngược lại thì bỏ qua xét tiếp. Vòng lặp
dừng lại khi B rỗng.
Greedy-select (B,E){
n=length(B);
A=B[1];j=1;
for (i=2;i<=n;i++){
if ( B[i]>=E[j])
A=A+B[i];
j=i;
}
return A;
}
Bài toán tìm đường đi ngắn nhất
Thuật toán tìm đường đi ngắn nhất Dijkstra
http://en.wikipedia.org/wiki/Dijkstra%27s_algorithm
Thuật toán mã hóa dữ liệu Huffman coding
http://en.wikipedia.org/wiki/Huffman_tree
Thuật toán tìm cây phủ tối thiểu
Kruskal’s algorithm
http://en.wikipedia.org/wiki/Kruskal%27s_algorithm
Prim's algorithm
http://en.wikipedia.org/wiki/Prim%27s_algorithm

41
Bài toán ba lô
Đề bài: Cho n món hàng (n ≤ 50). Món thứ i có khối lượng là A[i] (số nguyên).
Cần chọn những món hàng nào để bỏ vào một ba lô sao tổng khối lượng của các món
hàng đã chọn là lớn nhất nhưng không vượt quá khối lượng W cho trước. (W ≤ 100).
Mỗi món chỉ chọn 1 hoặc không chọn.

Tổng khối lượng của các món hàng bỏ vào ba lô là 10. Khối lượng các món hàng
được chọn: 5 2 3
Hướng giải bài toán ba lô
1.Tổ chức dữ liệu
Fx[k, v] là tổng khối lượng của các món hàng bỏ vào ba lô khi có k món hàng đầu
tiên để chọn và khối lượng tối đa của ba lô là v. Với k ∈ [1, n], v ∈ [1, W]. Nói cách
khác: Khi có k món để chọn, Fx[k, v] là khối lượng tối ưu khi khối lượng tối đa của ba
lô là v. Khối lượng tối ưu luôn nhỏ hơn hoặc bằng khối lượng tối đa: Fx[k, v] ≤ v
Ví dụ: Fx[4, 10] = 8 Nghĩa là trong trường hợp tối ưu, tổng khối lượng của các
món hàng được chọn là 8, khi có 4 món đầu tiên để chọn (từ món thứ 1 đến món thứ 4)
và khối lượng tối đa của ba lô là 10. Không nhất thiết cả 4 món đều được chọn.
Thuật toán tạo bảng
Trường hợp đơn giản chỉ có 1 món để chọn: Ta tính Fx[1, v] với mọi v: Nếu có
thể chọn (nghĩa là khối lượng tối đa của ba lô >= khối lượng của các món hàng thứ 1),
thì chọn: Fx[1, v] = A[1];
Ngược lại ( v < A[1] ), không thể chọn, nghĩa là Fx[1, v] = 0; Giả sử ta đã tính
được Fx[k–1 , v ] đến dòng k–1, với mọi v ∈ [1, W]. Khi có thêm món thứ k để chọn, ta
cần tính Fx[k , v] ở dòng k, với mọi v∈[1,W] Nếu có thể chọn món hàng thứ k (v >=
A[k]), thì có 2 trường hợp:
Trường hợp 1: Nếu chọn thêm món thứ k bỏ vào ba lô, thì Fx[k, v] = Fx[ k–1 , u ]

42
+ A[k]; Với u là khối lượng còn lại sau khi chọn món thứ k. u = v – A[k]
Trường hợp 2: Ngược lại, không chọn món thứ k, thì Fx[k, v] = Fx[k–1, v ];
Trong 2 trường hợp trên ta chọn trường hợp nào có Fx[k, v] lớn hơn. Ngược lại (v
< A[k]), không thể chọn, nghĩa là Fx[k, v] = Fx[k–1, v]; Tóm lại: công thức đệ quy là:
if (v >= A[k])
Fx[k,v] = Max(Fx[k-1, v – A[k]] + A[k] , Fx[k-1,v])
else
Fx[k,v] = Fx[k-1, v];
Dưới đây là bảng Fx[k,v] tính được trong ví dụ trên:

Thuật toán tạo bảng trong ba lo


Thuật toán tra bảng để tìm các món hàng được chọn
Chú ý: Nếu Fx[k, v] = Fx[k–1, v] thì món thứ k không được chọn. Fx[n, W] là
tổng khối lượng tối ưu của các món hàng bỏ vào ba lô.
Bước 1: Bắt đầu từ k = n, v = W.
Bước 2: Tìm trong cột v, ngược từ dưới lên, ta tìm dòng k sao cho Fx[k,v] > Fx[k–
1,v]. Đánh dấu món thứ k được chọn: Chọn[k] = true;
Bước 3: v = Fx[k,v]–A[k]. Nếu v>0 thì thực hiện bước 2, ngược lại thực hiện bước
Bước 4: Dựa vào mảng Chọn để in ra các món hàng được chọn.
Cài đặt bài toán ba lô
Đây là một cách cài đặt demo cơ bản mà thg bạn nó cài bằng C, các bạn xem tham
khảo nha! Tuy không tối ưu nhưng đã demo được bài toán ba lô, các bạn có thể tự phát
triển thêm.
void docfile(int a[],int &n,int &w){
FILE *f;
f=fopen("balo1.txt","rt");
fscanf(f,"%d%d",&n,&w);
for(int i=1;i<=n;i++)

43
fscanf(f,"%d",&a[i]);
fclose(f);
}
void ghifile(int Chon[],int A[],int n){
FILE *f;
f=fopen("kqbalo1.text","wt");
for(int i=1;i<=n;i++)
if(Chon[i]==1)
fprintf(f,"%3d",A[i]);
}
int Max(int a, int b){
if(a>b)
return a;
return b;
}
void main(){
int Fx[101][101];//bang
int A[101];//A mang trong luong
int Chon[101];
int n;
int W;
docfile(A,n,W);
//Chua chon mon hang nao gan bang 0.
for (int k=1;k<=W;k++)
Fx[0][k]=0;
// Tao bang
// k so mon hang,v khoi luong toi da
for (int k=1;k<=n;k++){
for (int v=1;v<=W;v++) {
if (v>=A[k]) {
Fx[k][v]=Max(Fx[k-1][v-A[k]]+A[k],Fx[k-
1][v]);
}

44
else {
Fx[k][v]=Fx[k-1][v];
}
}
}
// Kiem tra bang tim ra cac mon hang duoc chon
int v=W;
int k=n;
while( v>0){
for(int k=n;k>=0;k--){
if(Fx[k][v]>Fx[k-1][v]) {
Chon[k]=1;
v=Fx[k][v]-A[k];
}
}
}
//In cac mon duoc chon
ghifile(Chon,A,n);
}

THUẬT TOÁN FORD-FULKERSON


Thuật toán Ford- Fulkerson (đặt theo L. R. Ford và D. R. Fulkerson) tính toán
luồng cực đại trong một mạng vận tải. Tên Ford-Fulkerson cũng thường được sử dụng
cho thuật toán Edmonds-Karp, một trường hợp đặc biệt của thuật toán Ford-Fulkerson.
Ý tưởng đằng sau thuật toán rất đơn giản: miễn là tồn tại một đường đi từ nguồn
(nút bắt đầu) đến điểm xả (nút cuối), với điều kiện tất cả các cung trên đường đi đó vẫn
còn khả năng thông qua, thì ta sẽ gửi đi một luồng dọc theo đường đi đó. Sau đó chúng
ta tìm một đường đi khác, và tiếp tục như vậy. Một đường đi còn khả năng thông qua là
một đường đi có khả năng mở rộng thêm hay một đường đi mà luồng qua đó còn khả
năng tăng thêm - gọi tắt là đường tăng.

Cho một đồ thị , với các khả năng thông qua và luồng
trên các cung từ đến , ta muốn tìm luồng cực đại từ đầu nguồn đến điểm thoát . Sau

45
mỗi bước, các điều kiện sau đây được duy trì:

 . Luồng từ tới không vượt quá khả năng thông qua.

 . Ta duy trì cân bằng luồng.

 cho tất cả các nút ngoại trừ và . Lượng dòng chảy vào một nút
bằng lượng chảy ra khỏi một nút.
Điều này có nghĩa là một luồng đi qua một mạng là một luồng hợp lệ sau mỗi vòng
của thuật toán. Chúng ta định nghĩa mạng còn dư là mạng với sức chứa

và luồng bằng không. Chú ý rằng không chắc chắn là


, bởi vì việc gửi luồng theo cung có thể làm ngắt (làm nó bão hòa), nhưng
lại mở một cung mới trong mạng còn dư.
Đầu vào: Đồ thị G với khả năng thông qua c, một nút nguồn s, và một nút thoát t
Kết quả: Luồng f sao cho f là cực đại từ s đến t

trên tất cả các cung


Trong khi còn có một đường đi từ đến trong :

Tìm một đường đi với và , sao cho

Tìm
(gửi luồng dọc theo đường đi)

(luồng có thể "quay lại" sau)


Có thể tìm đường đi trong bằng các phương pháp chẳng hạn như tìm
kiếm theo chiều rộng (breadth-first-search) hoặc tìm kiếm theo chiều sâu (depth-first-
search). Nếu sử dụng cách thứ nhất, thuật toán sẽ được gọi là Edmonds-Karp.
Độ phức tạp
Bằng cách thêm luồng trên đường tăng vào luồng đã được thiết lập trên đồ thị, ta
sẽ đạt đến luồng cực đại khi trên đồ thị không còn tìm được thêm đường tăng luồng nào
nữa. Tuy nhiên, không chắc chắn là tình huống này sẽ đạt được, do vậy điều tốt nhất có
thể được đảm bảo là: nếu thuật toán kết thúc thì kết quả sẽ là lời giải đúng. Trong trong
trường hợp thuật toán chạy vô hạn, luồng có thể không hội tụ về phía luồng cực đại. Tuy
nhiên, tình huống này chỉ xảy ra với luồng có giá trị vô tỷ. Khi khả năng thông qua là các
số tự nhiên, thời gian chạy của thuật toán Ford-Fulkerson bị chặn bởi O(E*f), trong đó E
là số cung của đồ thị và f là luồng cực đại trên đồ thị. Điều này là bởi vì mỗi đường tăng
được tìm ra trong trong thời gian O(E) và nó làm tăng luồng với một lượng có giá trị

46
nguyên không nhỏ hơn 1.
Một biến thể của thuật toán Ford-Fulkerson bảo đảm sự kết thúc và thời gian chạy
không phụ thuộc vào giá trị luồng cực đại là thuật toán Edmonds-Karp, chạy trong thời
gian O(VE2).
Ví dụ sau đây cho thấy những bước ban đầu của thuật toán Ford-Fulkerson trong
một mạng vận tải gồm 4 nút, nguồn A và thoát D. Các đường đi tăng được tìm bằng
phương pháp tìm kiếm theo chiều sâu, trong đó các đỉnh lân cận được duyệt theo thứ tự
bảng chữ cái. Ví dụ này cho thấy biểu hiện của trường hợp xấu nhất của thuật toán. Mỗi
bước chỉ gửi thêm được một luồng giá trị 1 qua mạng. Nếu sử dụng phép tìm theo chiều
rộng thay vì theo chiều sâu, ta sẽ chỉ cần hai bước.
Đường đi Khả năng thông qua Mạng đạt được

Mạng vận tải ban đầu

Mạng vận tải cuối cùng

Chú ý khi luồng bị "đẩy ngược" từ C đến B khi tìm được đường đi A,C,B,D.

GIẢI THUẬT FLOYD


Cho đơn đồ thị có hướng, có trọng số G=(V,E) với n đỉnh và m cạnh, Ma trận

47
trọng số C[u,v]. Bài toán đặt ra tính tất cả các d(u,v) là khoảng cách nhỏ nhất từ u đến v.
Ta sử dụng thuật toán FLOYD để giải quyết bài toán này. Từ ma trận trọng số ban đầu
C, ta tính lại các C[u,v] thành đường đi ngắn nhất từ u đến v theo công thức:
C[u,v] := min (C[u,v], C[u,k] + C[k,v]) với mọi đỉnh k xét từ 1 đến n.
Tức là: Nếu đường đi đang có từ u đến v dài hơn đường đi từ u đến k cộng với đường đi
từ k đến v thì ta ghi nhận lại đường đi ngắn nhất (hiện có) là đường đi qua k.
for k:=1 to n do
for u:=1 to n do
for v:=1 to n do
c[u,v] := min ( c[u,v] , c[u,k] + c[k,v] );
Chứng minh:
Gọi D[k,u,v] là độ dài đường đi ngắn nhất từ u đến v mà chỉ đi qua các đỉnh trung
gian thuộc tập {1,2,…,k}. Rõ ràng ban đầu, khi k=0 thì D[0,u,v] =C[u,v] (đường đi trực
tiếp). Giả sử đã tính được các D[k-1,u,v], D[k,u,v] sẽ được tính bằng cách:
* Không đi qua đỉnh k, tức là chỉ sử dụng các đỉnh trung gian từ 1 đến k-1 thì
D[k,u,v] := D[k-1,u,v];
* Đi qua đỉnh k, khi đó đường đi ngắn nhất từ u đến v sẽ là nối của hai đường, một
đường từ u đến k, một từ k đến v, và các đường con này chỉ đi qua các đỉnh trung gian
từ 1..k-1. D[k,u,v] := min ( D[k-1,u,k] + D[k-1,k,v] )
Cài đặt
program Shortest_Path_by_Floyd;
const
max = 100;
maxC = 10000;
var
c: array[1..max, 1..max] of Integer;
Trace: array[1..max, 1..max] of Integer;
n, S, F: Integer;
procedure LoadGraph;
var
i, m: Integer;
u, v: Integer;
begin

48
Readln(n, m, S, F);
for u := 1 to n do
for v := 1 to n do
if u = v then c[u, v] := 0 else c[u, v] := maxC;
for i := 1 to m do Readln(u, v, c[u, v]);
end;
procedure Floyd;
var
k, u, v: Integer;
begin
for u := 1 to n do
for v := 1 to n do Trace[u, v] := v;
for k := 1 to n do
for u := 1 to n do
for v := 1 to n do
if c[u, v] > c[u, k] + c[k, v] then
begin
c[u, v] := c[u, k] + c[k, v];
Trace[u, v] := Trace[u, k];
end;
end;
procedure PrintResult;
begin
if c[S, F] = maxC
then Writeln('Not found any path from ', S, ' to ', F)
else
begin
Writeln('Distance from ', S, ' to ', F, ': ', c[S, F]);
repeat
Write(S, '->');
S := Trace[S, F];
until S = F;
Writeln(F);

49
end;
end;

begin
Assign(Input, 'MINPATH.INP'); Reset(Input);
Assign(Output, 'MINPATH.OUT'); Rewrite(Output);
LoadGraph;
Floyd;
PrintResult;
Close(Input);
Close(Output);
end.

Đường đi ngắn nhất giữa mọi cặp đỉnh - Thuật toán FLOYD

Cho đơn đồ thị có hướng, có trọng số G=(V,E) với n đỉnh và m cạnh, Ma trận trọng số
C[u,v]. Bài toán đặt ra là tính {i}tất cả {/i}các d(u,v) là khoảng cách nhỏ nhất từ u đến
v. Ta sử dụng thuật toán FLOYD để giải quyết bài toán này. Từ ma trận trọng số ban đầu
C, ta tính lại các C[u,v] thành đường đi ngắn nhất từ u đến v theo công thức:
C[u,v] := min (C[u,v], C[u,k] + C[k,v]) với mọi đỉnh k xét từ 1 đến n. {i} Tức là: Nếu
đường đi đang có từ u đến v dài hơn đường đi từ u đến k cộng với đường đi từ k đến v
thì ta ghi nhận lại đường đi ngắn nhất (hiện có) là đường đi qua k.{/i}
for k:=1 to n do
for u:=1 to n do
for v:=1 to n do
c[u,v] := min ( c[u,v] , c[u,k] + c[k,v] );
Chứng minh:
Gọi D[k,u,v] là độ dài đường đi ngắn nhất từ u đến v mà chỉ đi qua các đỉnh trung
gian thuộc tập {1,2,…,k}. Rõ ràng ban đầu, khi k=0 thì D[0,u,v] =C[u,v] (đường đi trực
tiếp). Giả sử đã tính được các D[k-1,u,v], D[k,u,v] sẽ được tính bằng cách:
- Không đi qua đỉnh k, tức là chỉ sử dụng các đỉnh trung gian từ 1 đến k-1 thì
D[k,u,v] := D[k-1,u,v];
- Đi qua đỉnh k, khi đó đường đi ngắn nhất từ u đến v sẽ là nối của hai đường, một

50
đường từ u đến k, một từ k đến v, và các đường con này chỉ đi qua các đỉnh trung gian
từ 1..k-1. D[k,u,v] := min ( D[k-1,u,k] + D[k-1,k,v] )

Cài đặt :
program Shortest_Path_by_Floyd;
const
max = 100;
maxC = 10000;
var c: array[1..max, 1..max] of Integer;
Trace: array[1..max, 1..max] of Integer;
n, S, F: Integer;
procedure LoadGraph;
var i, m: Integer;
u, v: Integer;
begin
Readln(n, m, S, F);
for u := 1 to n do
for v := 1 to n do
if u = v then c[u, v] := 0 else c[u, v] := maxC;
for i := 1 to m do Readln(u, v, c[u, v]);
end;
procedure Floyd;
var k, u, v: Integer;
begin
for u := 1 to n do
for v := 1 to n do Trace[u, v] := v;
for k := 1 to n do
for u := 1 to n do
for v := 1 to n do
if c[u, v] > c[u, k] + c[k, v] then
begin
c[u, v] := c[u, k] + c[k, v];
Trace[u, v] := Trace[u, k];
end;

51
end;
procedure PrintResult;
begin
if c[S, F] = maxC
then Writeln('Not found any path from ', S, ' to ', F)
else
begin
Writeln('Distance from ', S, ' to ', F, ': ', c[S, F]);
repeat
Write(S, '->');
S := Trace[S, F];
until S = F;
Writeln(F);
end;
end;

begin
Assign(Input, 'MINPATH.INP'); Reset(Input);
Assign(Output, 'MINPATH.OUT'); Rewrite(Output);
LoadGraph;
Floyd;
PrintResult;
Close(Input);
Close(Output);
end.

THUẬT TOÁN DIJKSTRA: TÌM ĐƯỜNG ĐI NGẮN NHẤT


Cho G = (V, E) đơn, liên thông, có trọng số dương (w(uv) > 0 với mọi u khác v).
Tìm đường đi ngắn nhất từ u0 đến v và tính khoảng cách d(u0,v).
Phương pháp:
Xác định tuần tự các đỉnh có khoảng cách đến u0 từ nhỏ đến lớn.
Trước tiên đỉnh có khoảng cách nhỏ nhất đến u0 là u0.
Trong V\{u0} tìm đỉnh có khoảng cách đến u0 nhỏ nhất (đỉnh này phải là một trong
các đỉnh kề với u0), giả sử đó là u1.

52
Trong V\{u0, u1} tìm đỉnh có khoảng cách đến u0 nhỏ nhất (đỉnh này phải là một
trong các đỉnh kề với u0 hoặc u1), giả sử đó là u2.
Tiếp tục như trên cho đến bao giờ tìm được khoảng cách từ u0 đến mọi đỉnh.
Nếu G có n đỉnh thì: 0 = d(u0,u0) < d(u0,u1) ≤ d(u0,u2) ≤ … ≤ d(u0,un-1)
Thuật toán Dijkstra
Bước1:
i := 0
S := V\{u0}
L(u0) := 0
Với mọi v ∈ S, L(v) := ∞ và được đánh dấu bởi (∞,-)
Nếu n=1 thì xuất d(u0,u0) = 0 = L(u0)
Bước 2:
Với mọi v ∈ S và kề với ui (nếu đồ thị có hướng thì v là
đỉnh sau của ui),
L(v) := min{L(v), L(ui) + w(uiv)}
Xác định k := min{L(v), v ∈ S}
Nếu k = L(vj) thì xuất d(u0,vj)=k và đánh dấu vj bởi (L(vj),ui)
ui+1 := vj
S := S\{ui+1}
Bước 3:
i := i + 1
Nếu i = n-1 kết thúc, nếu không thì quay lại Bước 2.
Thuật toán Dijkstra
Thuật toán Dijkstra, mang tên của nhà khoa học máy tính người Hà Lan Edsger
ijkstra, là một thuật toán giải quyết bài toán đường đi ngắn nhất nguồn đơn trong một đồ
thị có hướng không có cạnh mang trọng số âm.
Bài toán
Cho một đồ thị có hướng G=(V,E), một hàm trọng số w: E → [0, ∞) và một đỉnh
nguồn s. Cần tính toán được đường đi ngắn nhất từ đỉnh nguồn s đến mỗi đỉnh của đồ thị.
Ví dụ: Chúng ta dùng các đỉnh của đồ thị để mô hình các thành phố và các cạnh để mô
hình các đường nối giữa chúng. Khi đó trọng số các cạnh có thể xem như độ dài của các
con đường (và do đó là không âm). Chúng ta cần vận chuyển từ thành phố s đến thành
phố t. Thuật toán Dijkstra sẽ giúp chỉ ra đường đi ngắn nhất chúng ta có thể đi.
Trọng số không âm của các cạnh của đồ thị mang tính tổng quát hơn khoảng cách hình

53
học giữa hai đỉnh đầu mút của chúng. Ví dụ, với 3 đỉnh A, B, C đường đi A-B-C có thể
ngắn hơn so với đường đi trực tiếp A-C.
Thuật toán
Thuật toán Dijkstra có thể mô tả như sau: Ta quản lý một tập hợp động S. Ban đầu
S={s}. Với mỗi đỉnh v, chúng ta quản lý một nhãn d[v] là độ dài bé nhất trong các đường
đi từ nguồn s đến một đỉnh u nào đó thuộc S, rồi đi theo cạnh nối u-v. Trong các đỉnh
ngoài S, chúng ta chọn đỉnh u có nhãn d[u] bé nhất, bổ sung vào tập S. Tập S được mở
rộng thêm một đỉnh, khi đó chúng ta cần cập nhật lại các nhãn d cho phù hợp với định
nghĩa. Thuật toán kết thúc khi toàn bộ các đỉnh đã nằm trong tập S, hoặc nếu chỉ cần tìm
đường đi ngắn nhất đến một đỉnh đích t, thì chúng ta dừng lại khi đỉnh t được bổ sung
vào tập S. Tính chất không âm của trọng số các cạnh liên quan chặt chẽ đến tính đúng
đắn của thuật toán. Khi chứng minh tính đúng đắn của thuật toán, chúng ta phải dùng đến
tính chất này.
Chứng minh
Ý tưởng của chứng minh như sau. Chúng ta sẽ chỉ ra, khi một đỉnh v được bổ sung
vào tập S, thì d[v] là giá trị của đường đi ngắn nhất từ nguồn s đến v. Theo định nghĩa
nhãn d, d[v] là giá trị của đường đi ngắn nhất trong các đường đi từ nguồn s, qua các đỉnh
trong S, rồi theo một cạnh nối trực tiếp u-v đến v. Giả sử tồn tại một đường đi từ s đến v
có giá trị bé hơn d[v]. Như vậy trong đường đi, tồn tại đỉnh giữa s và v không thuộc S.
Chọn w là đỉnh đầu tiên như vậy. Đường đi của ta có dạng s - ... - w - ... - v. Nhưng do
trọng số các cạnh không âm nên đoạn s - ... - w có độ dài không lớn hơn hơn toàn bộ
đường đi, và do đó có giá trị bé hơn d[v]. Mặt khác, do cách chọn w của ta, nên độ dài
của đoạn s - ... - w chính là d[w]. Như vậy d[w] < d[v], trái với cách chọn đỉnh v. Đây là
điều mâu thuẫn. Vậy điều giả sử của ta là sai. Ta có điều phải chứng minh.
Trường hợp ma trận trọng số không âm - thuật toán dijkstra
Trong trường hợp trọng số trên các cung là không âm thuật toán do Dijkstra đề
nghị làm việc hữu hiệu hơn rất nhiều so với thuật toán trình bày trong mục trước. Thuật
toán được xây dựng dựa trên cơ sở gán cho các đỉnh các nhãn tạm thời. Nhãn của mỗi
đỉnh cho biết cận của độ dài đường đi ngắn nhất từ s đến nó. Các nhãn này sẽ được biến
đổi theo một thủ tục lặp, mà ở mỗi bước lặp có một nhãn tạm thời trở thành nhãn cố định.
Nếu nhãn của một đỉnh nào đó trở thành một nhãn cố định thì nó sẽ cho ta không phải là
cận trên mà là độ dài của đường đi ngắn nhất từ đỉnh s đến nó. Thuật toán được mô tả cụ
thể như sau.

54
Procedure Dijstra;

Đầu vào: Đồ thị có hướng G=(v,E) với n đỉnh,s  V là đỉnh xuất phát, a[u,v], u,v  V, ma

trận trọng số; Giả thiết: a[u,v]≥0, u,v  V.

Đầu ra: Khoảng cách từ đỉnh s đến tất cả các đỉnh còn lại d[v], v  V. Truoc[v], v  V, ghi
nhận đỉnh đi trước v trong đường đi ngắn nhất từ s đến v
Begin (* Khởi tạo *)
for v  V do
begin
d[v]:=a[s,v];
Truoc[v]:=s;
end;
d[s]:=0; T:=V\ s ; (* T là tập các đỉnh cá nhãn tạm thời *)
(* Bước lặp *)
while T <>  do
begin
tìm đỉnh u  T thoả mãn d[u]=min d[z]:z  T ;
T:=T\ u ; (* Cố định nhãn của đỉnh u*)
For v T do
If d[v]>d[u]+a[u,v] then
Begin
d[v]:=d[u]+a[u,v];
Truoc[v]:=u;
End;
end;
End;
Định lý 1. Thuật toán Dijkstra tìm được đường đi ngắn nhất trên đồ thị sau thời gian cỡ O(n2).
Chứng minh.
Trước hết ta chứng minh là thuật toán tìm được đường đi ngắn nhất từ đỉnh s đến các
đỉnh còn lại của đồ thị. Giả sử ở một bước lặp nào đó các nhãn cố định cho ta độ dài các
đường đi ngắn nhất từ s đến các đỉnh có nhãn cố định, ta sẽ chứng minh rằng ở lần gặp tiếp
theo nếu đỉnh u* thu được nhãn cố định d(u*) chính là độ dài đường đi ngẵn nhất từ s đến u*.
Ký hiệu S1 là tập hợp các đỉnh có nhãn cố định còn S2 là tập các đỉnh có nhãn tạm thời
ở bước lặp đang xét. Kết thúc mỗi bước lặp nhãn tạm thời d(u*) cho ta độ dài của đường đi

55
ngắn nhất từ s đến u* không nằm trọng trong tập S1, tức là nó đi qua ít nhất một đỉnh của tập
S2. Gọi z  S2 là đỉnh đầu tiên như vậy trên đường đi này. Do trọng số trên các cung là không
âm, nên đoạn đường từ z đến u* có độ dài L>0 và d(z) < d(u*) – L < d(u*).
Bất đẳng thức này là mâu thuẫn với cách xác định đỉnh u* là đỉnh có nhãn tạm thời
nhỏ nhất. Vậy đường đi ngắn nhất từ s đến u* phải nằm trọn trong S1, và vì thế, d[u*] là độ
dài của nó. Do ở lần lặp đầu tiên S1 =  s và sau mỗi lần lặp ta chỉ thêm vào một đỉnh u* nên
giả thiết là d(v) cho độ dài đường đi ngắn nhất từ s đến v với mọi v  S1 là đúng với bước lặp
đầu tiên. Theo qui nạp suy ra thuật toán cho đường đi ngắn nhất từ s đến mọi đỉnh của đồ thị.
Bây giờ ta sẽ đánh giá số phép toán cần thực hiện theo thuật toán. Ơû mỗi bước lặp để
tìm ra đỉnh u cần phải thực hiện O(n) phép toán, và để gán nhãn lại cũng cần thực hiện một
số lượng phép toán cũng là O(n). thuật toán phải thực hiện n-1 bước lặp, vì vậy thời gian tính
toán của thuận toán cỡ O(n2).
Định lý được chứng minh.
Khi tìm được độ dài của đường đi ngắn nhất d[v] thì đường đi này có thể tìm dựa vào
nhãn Truoc[v], v V, theo qui tắc giống như chúng ta đã xét trong chương 3.
Thí dụ 2. Tìm đường đi ngắn nhất từ 1 đến các đỉnh còn lại của đồ thị ở hình 2.

Hình 2. Minh họa thuật toán Dijkstra


Kết quả tính toán theo thuật toán được trình bày theo bảng dưới đây. Qui ước viết hai
thành phần của nhãn theo thứ tự: d[v]. Đỉnh được đánh dấu * là đỉnh được chọn để cố định
nhãn ở bước lặp đang xét, nhãn của nó không biến đổi ở các bước tiếp theo, vì thế ta đánh
dấu -.

Bước Đỉnh 1 Đỉnh 2 Đỉnh 3 Đỉnh 4 Đỉnh 5 Đỉnh 6


lặp

Khởi tạo 0,1 1,1*  ,1  ,1  ,1  ,1

56
1 - - 6,2 3,2*  ,1 8,2

2 - - 4,4* - 7,4 8,2

3 - - - 7,4 5,3*

4 - - - 6,6* -

Chú ý:
Nếu chỉ cần tìm đường đi ngắn nhất từ s đến một đỉnh t nào đó thì có thể kết thúc thuật toán
khi đỉnh t trở thành có nhãn cố định.
Tương tự như trong mục 2, dễ dàng mô tả thuật toán trong trường hợp đồ thị cho bởi danh
sách kề. Để có thể giảm bớt khối lượng tính toán trong việc xác định đỉnh u ở mỗi bước lặp,
có thể sử dụng thuật toán Heasort (tương tự như trong chương 5 khi thể hiện thuật toán
Kruskal). Khi đó có thể thu được thuật toán với độ phức tạp tính toán là O(m log n).

GIẢI THUẬT PRIM


Trong khoa học máy tính, thuật toán Prim là một thuật toán tham lam để tìm cây bao
trùm nhỏ nhất của một đồ thị vô hướng có trọng số liên thông. Nghĩa là nó tìm một tập hợp
các cạnh của đồ thị tạo thành một cây chứa tất cả các đỉnh, sao cho tổng trọng số các cạnh
của cây là nhỏ nhất. Thuật toán được tìm ra năm 1930 bởi nhà toán học người Séc Vojtěch
Jarník và sau đó bởi nhà nghiên cứu khoa học máy tính Robert C. Prim năm 1957 và một lần
nữa độc lập bởi Edsger Dijkstra năm 1959. Do đó nó còn được gọi là thuật toán DJP, thuật
toán Jarník, hay thuật toán Prim–Jarník. Một vài thuật toán đơn giản khác cho bài toán này
bao gồm thuật toán Kruskal và thuật toán Borůvka.
Mô tả: Thuật toán Prim có nhiều ứng dụng, chẳng hạn như xây dựng mê cung trên, bằng
cách áp dụng thuật toán Prim cho một đồ thị lưới có trọng số ngẫu nhiên. Thuật toán xuất
phát từ một cây chỉ chứa đúng một đỉnh và mở rộng từng bước một, mỗi bước thêm một cạnh
mới vào cây, cho tới khi bao trùm được tất cả các đỉnh của đồ thị.
 Dữ liệu vào: Một đồ thị có trọng số liên thông với tập hợp đỉnh V và tập hợp cạnh E
(trọng số có thể âm). Đồng thời cũng dùng V và E để kí hiệu số đỉnh và số cạnh của đồ
thị.

57
 Khởi tạo: Vmới = {x}, trong đó x là một đỉnh bất kì (đỉnh bắt đầu) trong V, Emới = {}
 Lặp lại cho tới khi Vmới = V:
o Chọn cạnh (u, v) có trọng số nhỏ nhất thỏa mãn u thuộc Vmới và v không thuộc
Vmới (nếu có nhiều cạnh như vậy thì chọn một cạnh bất kì trong chúng)
o Thêm v vào Vmới, và thêm cạnh (u, v) vào Emới
 Dữ liệu ra: Vmới và Emới là tập hợp đỉnh và tập hợp cạnh của một cây bao trùm nhỏ nhất
Độ phức tạp tính toán
Cấu trúc dữ liệu tìm cạnh có trọng số nhỏ nhất Độ phức tạp thời gian (tổng cộng)

Tìm kiếm trên ma trận kề O(V2)

Đống nhị phân và danh sách kề O((V + E) log V) = O(E log V)

Đống Fibonacci và danh sách kề O(E + V log V)

Một cách lập trình đơn giản sử dụng ma trận kề và tìm kiếm toàn bộ mảng để tìm cạnh
có trọng số nhỏ nhất có thời gian chạy O(V2). Bằng cách sử dụng cấu trúc dữ liệu đống nhị
phân và danh sách kề, có thể giảm thời gian chạy xuống O(E log V). Bằng cách sử dụng cấu
trúc dữ liệu đống Fibonacci phức tạp hơn, có thể giảm thời gian chạy xuống O(E + V log V),
nhanh hơn thuật toán trước khi đồ thị có số cạnh E=ω(V).
Ví dụ
Hình minh họa U Cạnh (u,v) V\U Mô tả

Đây là đồ thị có trọng số ban


{A,B,C,D,
{} đầu. Các số là các trọng số của
E,F,G}
các cạnh.

58
Chọn một cách tùy ý đỉnh D là
đỉnh bắt đầu. Các đỉnh A, B, E
(D,A) = 5 V
và F đều được nối trực tiếp tới
(D,B) = 9 {A,B,C,E
{D} D bằng cạnh của đồ thị. A là
(D,E) = 15 ,F,G}
đỉnh gần D nhất nên ta chọn A
(D,F) = 6
là đỉnh thứ hai của cây và
thêm cạnh AD vào cây.

Đỉnh được chọn tiếp theo là


đỉnh gần D hoặc A nhất. B có
(D,B) = 9 khoảng cách tới D bằng 9 và
(D,E) = 15 {B,C,E,F, tới A bằng 7, E có khoảng
{A,D}
(D,F) = 6 V G} cách tới cây hiện tại bằng 15,
(A,B) = 7 và F có khoảng cách bằng 6. F
là đỉnh gần cây hiện tại nhất
nên chọn đỉnh F và cạnh DF.

(D,B) = 9
(D,E) = 15 Thuật toán tiếp tục tương tự
{B,C,E,G
{A,D,F} (A,B) = 7 V như bước trước. Chọn đỉnh B
}
(F,E) = 8 có khoảng cách tới A bằng 7.
(F,G) = 11

(B,C) = 8 Ở bước này ta chọn giữa C, E,


(B,E) = 7 V và G. C có khoảng cách tới B
(D,B) = 9 bằng 8, E có khoảng cách tới
{A,B,D,F} chu trình {C,E,G} B bằng 7, và G có khoảng
(D,E) = 15 cách tới F bằng 11. E là đỉnh
(F,E) = 8 gần nhất, nên chọn đỉnh E và
(F,G) = 11 cạnh BE.

59
(B,C) = 8
(D,B) = 9
chu trình
Ở bước này ta chọn giữa C và
(D,E) = 15
G. C có khoảng cách tới E
{A,B,D,E, chu trình
{C,G} bằng 5, và G có khoảng cách
F} (E,C) = 5 V
tới E bằng 9. Chọn C và cạnh
(E,G) = 9
EC.
(F,E) = 8 chu
trình
(F,G) = 11

(B,C) = 8
chu trình
(D,B) = 9
Đỉnh G là đỉnh còn lại duy
chu trình
nhất. Nó có khoảng cách tới F
{A,B,C,D, (D,E) = 15
{G} bằng 11, và khoảng cách tới E
E,F} chu trình
bằng 9. E ở gần hơn nên chọn
(E,G) = 9 V
đỉnh G và cạnh EG.
(F,E) = 8 chu
trình
(F,G) = 11

(B,C) = 8
chu trình
(D,B) = 9
Hiện giờ tất cả các đỉnh đã
chu trình
nằm trong cây và cây bao
{A,B,C,D, (D,E) = 15
{} trùm nhỏ nhất được tô màu
E,F,G} chu trình
xanh lá cây. Tổng trọng số của
(F,E) = 8 chu
cây là 39.
trình
(F,G) = 11
chu trình

Chứng minh tính đúng đắn


Đặt G là một đồ thị có trọng số liên thông. Trong mỗi bước, thuật toán Prim chọn một
cạnh nối một đồ thị con với một đỉnh không thuộc đồ thị con đó. Vì G liên thông nên luôn

60
tồn tại đường đi từ mỗi đồ thị con tới tất cả các đỉnh còn lại. Kết quả T của thuật toán Prim là
một cây, vì các cạnh và đỉnh được thêm vào T là liên thông và cạnh mới thêm không bao giờ
tạo thành chu trình với các cạnh cũ. Đặt T1 là một cây bao trùm nhỏ nhất của G. Nếu T1=T thì
T là cây bao trùm nhỏ nhất. Nếu không, đặt e là cạnh đầu tiên được thêm vào T mà không
thuộc T1, và V là tập hợp các đỉnh thuộc T trước khi thêm e. Một đầu của e thuộc V và đầu kia
không thuộc V. Vì T1 là một cây bao trùm của G, nên tồn tại đường đi trong T1 nối hai đầu
của e. Trên đường đi đó, nhất định tồn tại cạnh f nối một đỉnh trong V với một đỉnh ngoài V.
Trong bước lặp khi e được thêm vào Y, thuật toán cũng có thể chọn f thay vì e nếu như trọng
số của nó nhỏ hơn e. Vì f không được chọn nên

Đặt T2 là đồ thị thu được bằng cách xóa f và thêm e vào T1. Dễ thấyT2 liên thông, có cùng
số cạnh như T1, và tổng trọng số các cạnh không quá trọng số của T1, nên nó cũng là một
cây bao trùm nhỏ nhất của G và nó chứa e cũng như tất cả các cạnh được thuật toán chọn
trước nó. Lặp lại lập luận trên nhiều lần, cuối cùng ta thu được một cây bao trùm nhỏ nhất
của G giống hệt như T. Vì vậy T là một cây bao trùm nhỏ nhất

61
CHƯƠNG 2: TÌM KIẾM (SEARCHING)

Bài toán tìm kiếm


Tìm kiếm là một trong những vấn đề thuộc lĩnh vực nghiên cứu của ngành khoa học
máy tính và được ứng dụng rất rộng rãi trên thực tế. Bản thân mỗi con người chúng ta đã có
những tri thức, những phương pháp mang tính thực tế, thực hành về vấn đề tìm kiếm. Trong
các công việc hàng ngày chúng ta thường xuyên phải tiến hành tìm kiếm: tìm kiếm một cuốn
sách để đọc về một vấn đề cần quan tâm, tìm một tài liệu lưu trữ đâu đó trên máy tính hoặc
trên mạng, tìm một từ trong từ điển, tìm một bản ghi thỏa mãn các điều kiện nào đó trong một
cơ sở dữ liệu, tìm kiếm trên mạng Internet.
Trong môn học này chúng ta quan tâm tới bài toán tìm kiếm trên một mảng, hoặc một
danh sách các phần tử cùng kiểu. Thông thường các phần tử này là một bản ghi được phân chia
thành hai trường riêng biệt: trường lưu trữ các dữ liệu và một trường để phân biệt các phần
tử với nhau (các thông tin trong trường dữ liệu có thể giống nhau hoàn toàn) gọi là trường
khóa, tập các phần tử này được gọi là không gian tìm kiếm của bài toán tìm kiếm, không gian
tìm kiếm được lưu hoàn toàn trên bộ nhớ của máy tính khi tiến hành tìm kiếm.
Kết quả tìm kiếm là vị trí của phần tử thỏa mãn điều kiện tìm kiếm: có trường khóa
bằng với một giá trị khóa cho trước (khóa tìm kiếm). Từ vị trí tìm thấy này chúng ta có thể
truy cập tới các thông tin khác được chứa trong trường dữ liệu của phần tử tìm thấy. Nếu kết
quả là không tìm thấy (trong trường hợp này thuật toán vẫn kết thúc thành công) thì giá trị trả
về sẽ được gán cho một giá trị đặc biệt nào đó tương đương với việc không tồn tại phần tử nào
có ví trí đó: chẳng hạn như -1 đối với mảng và NULL đối với danh sách liên kết.
Các thuật toán tìm kiếm cũng có rất nhiều: từ các thuật toán tìm kiếm vét cạn, tìm kiếm
tuần tự, tìm kiếm nhị phân, cho tới những thuật toán tìm kiếm dựa trên các cấu trúc dữ liệu
đặc biệt như các từ điển, các loại cây như cây tìm kiếm nhị phân, cây cân bằng, cây đỏ đen
… Tuy nhiên ở phần này chúng ta sẽ xem xét hai phương pháp tìm kiếm được áp dụng với
cấu trúc dữ liệu mảng (dữ liệu tìm kiếm được chứa hoàn toàn trong bộ nhớ của máy tính).
Điều đầu tiên mà chúng ta cần lưu ý là đối với cấu trúc mảng này, việc truy cập tới các
phần tử ở các vị trí khác nhau là như nhau và dựa vào chỉ số, tiếp đến sẽ tập trung vào thuật
toán nên có thể coi như mỗi phần tử chỉ có các trường khóa là các số nguyên.

Tìm kiếm tuần tự (Sequential search)


Ý tưởng của thuật toán tìm kiếm tuần tự rất đơn giản: duyệt qua tất cả các phần tử của
mảng, trong quá trình duyệt nếu tìm thấy phần tử có khóa bằng với khóa tìm kiếm thì trả về vị

62
trí của phần tử đó. Còn nếu duyệt tới hết mảng mà vẫn không có phần tử nào có khóa bằng
với khóa tìm kiếm thì trả về -1 (không tìm thấy).
Thuật toán có sơ đồ giải thuật như sau:

k==a[i] đúng
return i;

sai

i = i + 1; sai i >= n

return -1; End

Cài đặt thuật toán:


int sequential_search(int a[], int n, int k){
int i; for(i=0;i<n;i++)
if(a[i]==k)
return i;
return -1;
}
Dễ dàng nhận ra thuật toán sẽ trả về kết quả là vị trí của phần tử đầu tiên thỏa mãn
điều kiện tìm kiếm nếu tồn tại phần tử đó. Độ phức tạp thuật toán trong trường hợp trung bình
và tồi nhất: O(n). Trong trường hợp tốt nhất thuật toán có độ phức tạp O(1).
Các bài toán tìm phần tử lớn nhất và tìm phần tử nhỏ nhất của một mảng, danh sách
cũng là thuật toán tìm kiếm tuần tự. Một điều dễ nhận thấy là khi số phần tử của mảng nhỏ
(cỡ 10000000) thì thuật toán làm việc ở tốc độ chấp nhận được, nhưng khi số phần tử của
mảng lên đến hàng tỷ, chẳng hạn như tìm tên một người trong số tên người của cả thế giới thì
thuật toán tỏ ra không hiệu quả.
Tìm kiếm nhị phân (binary search)
Thuật toán tìm kiếm nhị phân là một thuật toán rất hiệu quả, nhưng điều kiện để áp
dụng được thuật toán này là không gian tìm kiếm cần phải được sắp xếp trước theo khóa tìm
kiếm.
Mô tả thuật toán:
Input: mảng a[left..right] đã được sắp theo khóa tăng dần, khóa tìm kiếm k.

63
Output: vị trí của phần tử có khóa bằng k.
Thuật toán này thuộc loại thuật toán chia để trị, do mảng đã được sắp xếp, nên tại mỗi
bước thay vì duyệt qua các phần tử như thuật toán tìm kiếm tuần tự, thuật toán tìm kiếm nhị
phân xét phần tử ở vị trí giữa mảng tìm kiếm a[(left+right)/2], nếu đó là phần tử có khóa bằng
với khóa tìm kiếm k thì trả về vị trí đó và kết thúc quá trình tìm kiếm. Nếu không sẽ có hai
khả năng xảy ra, một là phần tử đó lớn hơn khóa tìm kiếm k, khi đó do mảng đã đước sắp nên
nếu trong mảng có phần tử có trường khóa bằng k thì vị trí của phần tử đó sẽ ở phần trước
a[(left+right)/2], có nghĩa là ta sẽ điều chỉnh right = (left+right)/2 - 1. Còn nếu a[(left+right)/2]
< k thì theo lý luận tương tự ta sẽ điều chỉnh left = (left+right)/2 + 1. Trong bất cứ trường hợp
nào thì không gian tìm kiếm đều sẽ giảm đi một nửa số phần tử sau mỗi bước tìm kiếm.
Sơ đồ thuật toán:

Begi
n

k==a[mi
d]

int binary_search(int a[],


int left, int right, int key){
// cài đặt không đệ qui
int mid; while(left<=right){
mid = (left + right)/2; if(a[mid] == key)
return mid; if(a[mid] < key)

64
left = mid + 1;
else
}
right = mid – 1;
return -1;
}

Cài đặt đệ qui:


int recursive_bsearch(int a[], int left, right, key){
// cài đặt đệ qui int mid;
mid = (left + right)/2; if(left>right)
return -1; if(a[mid] == key)
return mid;
else
if(a[mid] < key)
return recursive_bsearch(a, mid+1, right, key);
else
}
return recursive_bsearch(a, left, mid-1, key);
Để gọi hàm cài đặt với mảng a có n phần tử ta gọi như sau
int kq = binary_search(a, 0, n – 1, k); hoặc:
int kq = recursive_bsearch(a, 0, n – 1, k);
Thuật toán có độ phức tạp là hàm logarit O(log(N)). Với n = 6.000.000.000 thì số thao
tác cần thực hiện để tìm ra kết quả là log(n) = 31 thao tác, có nghĩa là chúng ta chỉ cần 31
bước thực hiện để tìm ra tên một người trong số tất cả dân số trên thế giới, thuật toán tìm
kiếm nhị phân thực sự là một thuật toán hiệu quả. Trên thực tế việc sắp các từ của từ điển là
một áp dụng của thuật toán tìm kiếm nhị phân.
Sắp xếp topo
Giải thuật sắp xếp Topo: Sau đây là giải thuật sắp xếp tôpô cho một đồ thị dag:
Topological_sort (G)
• Gọi DFS(G) để tính các thời điểm xem xét f[v] cho mỗi đỉnh v
• Với mỗi điểm đã được xem xét, cho nó vào đầu của một danh sách liên kết
• Trở lại danh sách liên kết các đỉnh

65
Hình chỉ ra cách các đỉnh được sắp xếp tôpô xuất hiện theo thứ tự ngược với thời điểm kết
thúc chúng.
Độ phức tạp tính toán:
Chúng ta có thể đánh giá độ phức tạp tính toán của giải thuật là Q(V+E) vì: tìm kiếm theo
chiều sâu mất Q(V+E) và để chèn một đỉnh (trong số |V| đỉnh) vào trước danh sách liên kết mất
O(1). Chúng ta chứng minh tính đúng đắn của giải thuật này bằng cách sử dụng một bổ đề quan
trọng nêu lên đặc tính của các đồ thị không chu trình có hướng.
Bổ đề 5: Một đồ thị có hướng G là không có chu trình nếu và chỉ nếu thực hiện tìm kiếm theo
chiều sâu trên G sẽ không có các cạnh back.
Chứng minh.
Điều kiện cần:
Giả sử rằng có một cạnh back (u,v). Khi đó đỉnh v là đỉnh trước (đỉnh tổ tiên) của đỉnh u trong
tìm kiếm theo chiều sâu. Vì thế sẽ có một đường đi từ v tới u trong G, do vậy khi kết hợp cạnh
(u,v) sẽ tạo thành một chu trình.
Điều kiện đủ:
Giả sử rằng G chứa một chu trình c. Chúng ta sẽ chỉ ra rằng khi tìm kiếm theo chiều sâu trên G sẽ
có một cạnh back. Gọi v là đỉnh đầu tiên được thăm trong c, và gọi (u, v) là cạnh trước c. Tại thời
điểm d[v], có một đường đi trắng từ v tới u. Theo định lý đường đi trắng, đỉnh u trở thành đỉnh
trước của đỉnh v trong tìm kiếm theo chiều sâu. Vì thế (u,v) là một cạnh back.
Định lý 6: Giải thuật sắp xếp tôpô TOPOLOGICAL_SORT(G) ở trên tạo ra một sắp xếp tôpô
đối với đồ thị không chu trình có hướng G.
Chứng minh:
Giả sử rằng thủ tục DFS được thực hiện trên một đồ thị dag G=(V,E) cho trước để xác
định thời điểm kết thúc thăm các đỉnh của đồ thị. Ta sẽ chỉ ra bất cứ một cặp đỉnh riêng biệt u,v
∈ V, nếu có một cạnh trong G từ u tới v thì f[v]<f[u].
Xét một cạnh bất kỳ (u,v) được thăm bởi DFS(G). Khi cạnh này được thăm, đỉnh v không
thể có màu xám vì khi đó v có thể là đỉnh trước u và (u,v) sẽ là một cạnh back, mâu thuẫn. Vì thế
v chỉ có thể là đỉnh màu trắng hoặc là đỉnh màu đen. Nếu v là đỉnh trắng nó thành đỉnh sau của u
và vì thế f[v]<f[u]. Nếu v là đỉnh đen khi đó cũng có f[v]<f[u]. Vì thế với bất kỳ cạnh (u,v) nào
trong đồ thị dag, chúng ta đều có f[v]<f[u]. Định lý được chứng minh.
Độ phức tạp tính toán
Chúng ta có thể đánh giá độ phức tạp tính toán của giải thuật là Q(V+E) vì: tìm kiếm theo
chiều sâu mất Q(V+E) và để chèn một đỉnh (trong số |V| đỉnh) vào trước danh sách liên kết mất
O(1) . Chúng ta chứng minh tính đúng đắn của giải thuật này bằng cách sử dụng một bổ đề quan

66
trọng nêu lên đặc tính của các đồ thị không chu trình có hướng.
Bổ đề 5: Một đồ thị có hướng G là không có chu trình nếu và chỉ nếu thực hiện tìm kiếm theo
chiều sâu trên G sẽ không có các cạnh back.
Chứng minh.
Điều kiện cần:
Giả sử rằng có một cạnh back (u,v). Khi đó đỉnh v là đỉnh trước (đỉnh tổ tiên) của đỉnh u
trong tìm kiếm theo chiều sâu. Vì thế sẽ có một đường đi từ v tới u trong G, do vậy khi kết hợp
cạnh (u,v) sẽ tạo thành một chu trình.
Điều kiện đủ:
Giả sử rằng G chứa một chu trình c. Chúng ta sẽ chỉ ra rằng khi tìm kiếm theo chiều sâu
trên G sẽ có một cạnh back. Gọi v là đỉnh đầu tiên được thăm trong c, và gọi (u, v) là cạnh trước
c. Tại thời điểm d[v], có một đường đi trắng từ v tới u. Theo định lý đường đi trắng, đỉnh u trở
thành đỉnh trước của đỉnh v trong tìm kiếm theo chiều sâu. Vì thế ( u, v) là một cạnh back.
Định lý 6: Giải thuật sắp xếp tôpô TOPOLOGICAL_SORT(G) ở trên tạo ra một sắp xếp tôpô
đối với đồ thị không chu trình có hướng G.
Chứng minh
Giả sử rằng thủ tục DFS được thực hiện trên một đồ thị dag G=(V,E) cho trước để xác định
thời điểm kết thúc thăm các đỉnh của đồ thị. Ta sẽ chỉ ra bất cứ một cặp đỉnh riêng biệt u,v ∈ V,
nếu có một cạnh trong G từ u tới v thì f[v]<f[u].
Xét một cạnh bất kỳ (u,v) được thăm bởi DFS(G). Khi cạnh này được thăm, đỉnh v không thể có
màu xám vì khi đó v có thể là đỉnh trước u và (u,v) sẽ là một cạnh back,mâu thuẫn. Vì thế v chỉ
có thể là đỉnh màu trắng hoặc là đỉnh màu đen. Nếu v là đỉnh trắng nó thành đỉnh sau của u và vì
thế f[v]<f[u]. Nếu v là đỉnh đen khi đó cũng có f[v]<f[u]. Vì thế với bất kỳ cạnh (u,v) nào trong
đồ thị dag, chúng ta đều có f[v]<f[u]. Định lý được chứng minh.
Các thành phần liên thông mạnh
Giới thiệu
Ta xét một ứng dụng kinh điển của việc tìm kiếm theo chiều sâu: phân rã một đồ thị có hướng
thành các thành phần liên thông mạnh. Trong phần này sẽ trình bày cách phân rã sử dụng hai lần tìm
kiếm theo chiều sâu. Có rất nhiều các thuật toán làm việc với đồ thị có hướng sử dụng phép phân rã
này; cách tiếp cận này cho phép chia một bài toán thành các bài toán nhỏ, mỗi bài toán tương ứng với
một thành phần liên thông mạnh. Kết hợp các giải pháp của các bài toán nhỏ theo kiến trúc liên kết
giữa các thành phần liên thông mạnh; kiến trúc này có thể được biểu diễn bởi một đồ thị được gọi là
đồ thị thành phần. Một thành phần liên thông mạnh của một đồ thị có hướng G=(V, E) là tập cực đại
các đỉnh U ⊆V sao cho mỗi cặp đỉnh u và v thuộc U ta có uv và vu có nghĩa là u và v đều có thể được

67
đi tới từ các đỉnh khác.
Thuật toán tìm kiếm các thành phần liên thông mạnh của một đồ thị G=(V, E) sử dụng đồ thị
đảo của G, đồ thị này gọi là GT = (V, ET), trong đó ET = {(u,v): (v,u) ∈ E}. ET bao gồm các cung
của đồ thị G với chiều đảo ngược. Với danh sách kề biểu diễn cho đồ thị G, thời gian để xây dựng GT
là O(V+E). Có một điều khá thú vị đó là G và GT có cùng số lượng các thành phần liên thông: u và v
có thể được đi tới từ các đỉnh thuộc G nếu và chỉ nếu chúng có thể được đi tới từ các đỉnh thuộc GT.
Hình 5.1(b) biểu diễn đồ thị đảo của đồ thị ở hình 5.1(a), các thành phần liên thông được tô đậm.
a) Một đồ thị có hướng G. Các thành phần liên thông mạnh của G là các vùng được tô đậm.
Mỗi đỉnh được đánh nhãn là thời gian thăm và thời gian kết thúc. Các cung của cây được tô đậm.
b) GT - đồ thị đảo của G. Cây tìm kiếm chiều sâu được tính toán ở dòng 3 của STRONGLY-
CONNECTED-COMPONENTS được chỉ ra với các cung của cây được tô đậm. Mỗi thành phần
liên thông mạnh tương ứng với một cây tìm kiếm chiều sâu. Các đỉnh b, c, g và h được tô đậm
hơn là tổ tiên của tất cả các đỉnh trong thành phần liên thông mạnh của chúng; các đỉnh này cũng
là các gốc của các cây tìm kiếm chiều sâu được tạo ra do tìm kiếm theo chiều sâu trên GT.
c) Đồ thị thành phần GSCC được thu được bằng cách co mỗi thành phần của G thành một
đỉnh đơn.
Thuật toán với thời gian tính tuyến tính sau tính toán các thành phần liên thông của đồ thị
có hướng G= (V, E) bằng cách sử dụng 2 lần tìm kiếm theo chiều sâu, một trên G và một trên GT.

STRONGLY-CONNECTED-COMPONENTS(G)
1. Gọi DFS(G) để tính toán thời gian kết thúc f[u] cho mỗi đỉnh u
2. tính GT
3. Gọi DFS(GT), nhưng trong vòng lặp chính của DFS, xem xét các đỉnh để giảm f[u] ( như
tính toán trong bước 1)
4. Đưa ra các đỉnh của mỗi cây trong rừng tìm kiếm theo chiều sâu của bước 3 như một thành
phần liên thông mạnh riêng rẽ
Thuật toán trông khá đơn giản và có vẻ như không liên quan gì tới các thành phần liên
thông mạnh. Tuy nhiên, bí mật thiết kế và tính đúng đắn của nó sẽ được nghiên cứu trong phần
tiếp theo.
Các bổ đề và định lý cơ bản
Bổ đề 7: Nếu 2 đỉnh cùng thuộc một thành phần liên thông mạnh thì không có cung nào giữa
chúng nằm tách rời thành phần liên thông mạnh đó.
Chứng minh:
Giả sử u và v là 2 đỉnh thuộc cùng thành phần liên thông mạnh. Theo định nghĩa về thành
phần liên thông mạnh, có các đường đi từ u tới v và từ v tới u. Gọi w là một đỉnh nằm trên đường
68
đi uwv do vậy w là đỉnh có thể được đi tới từ u. Hơn nữa, do có đường đi từ vu ta biết rằng u có
thể được đi tới từ w bởi đường đi wvu. Do vậy, u và w là ở trong cùng một thành phần liên thông
mạnh. Do w được chọn một cách tuỳ ý, định lý được chứng minh.
Bổ đề 8: Trong các phép tìm kiếm theo chiều sâu, tất cả các đỉnh thuộc cùng một thành phần liên
thông mạnh sẽ thuộc cùng cây tìm kiếm theo chiều sâu.
Chứng minh:
Với các đỉnh trong thành phần liên thông mạnh, gọi r là đỉnh đầu tiên được thăm. Do r là đỉnh
đầu tiên, các đỉnh khác trong thành phần liên thông mạnh là có màu trắng tại thời điểm nó được thăm.
Có các đường đi từ r tới tất cả các đỉnh còn lại trong thành phần liên thông mạnh, do các cung này
không nằm tách rời thành phần liên thông mạnh , tất cả các đỉnh thuộc chúng đều là trắng. Do
vậy, theo định lý đường đi trắng, tất cả các đỉnh trong thành phần liên thông mạnh trở thành con
cháu của r trong cây tìm kiếm chiều sâu.
Trong phần còn lại của phần này, ký hiệu d[u] và f[u] biểu diễn cho thời gian thă ra và thời
gian kết thúc được tính toán bởi lần tìm kiếm theo chiều sâu thứ nhất ở dòng 1 của thủ tục
STRONGLY-CONNECTED-COMPONENTS. Tương tự ký hiệu uv biểu diễn cho sự hiện diện
của một đường đi trong G chứ không phải GT.
Để chứng minh STRONGLY-CONNECTED-COMPONENTS đúng, ta giới thiệu khái
niệm ϕ(u) là tổ tiên của đỉnh u là một đỉnh w có thể được đi tới từ u và được kết thúc cuối cùng
trong lần tìm kiếm theo chiều sâu trong dòng 1. Nói cách khác:
ϕ( u) = w sao cho uw và f[w] là cực đại
Chú ý rằng ϕ(u) = u là có thể xảy ra do u là có thể được đi tới từ chính nó, và do vậy:
f[u] ≤ f[ϕ( u )] (*)
Ta cũng có thể chỉ ra rằng ϕ(ϕ(u)) = ϕ(u) bởi lý do sau: với u,v V U,v có nghĩa là
f[ϕ( v )] ≤ f[ϕ(u)] (**)do {w: v→→w } ⊆{w: u→→w }
Và đỉnh tổ tiên có thời gian kết thúc lớn nhất trong tất cả các đỉnh. Do ϕ(u) là có thể được
đi tới từ u công thức (**) có nghĩa là f[ϕ(ϕ(u))] ≤ f[ϕ(u)]. Ta cũng có f[ϕ(u)] ≤ f[ϕ(ϕ(u))] theo bất
đẳng thức (*). Do vậy f[ϕ(ϕ(u))] = f[ϕ(u)] nên ta có ϕ(ϕ(u)) = ϕ(u) do 2 đỉnh mà kết thúc tại cùng
thời điểm thì chính là một đỉnh.
Như ta đã thấy, tất cả các thành phần liên thông mạnh có một đỉnh là tổ tiên của tất cả các
đỉnh khác trong thành phần liên thông mạnh đó; đỉnh này gọi là đỉnh đại diện của thành phần liên
thông mạnh đó. Trong lần tìm kiếm theo chiều sâu của G, nó là đỉnh đầu tiên của thành phần liên
thông mạnh được thăm. Trong lần tìm kiếm theo chiều sâu trên GT, nó là gốc của cây tìm kiếm
chiều sâu. Chúng ta sẽ chứng minh các tính chất này Định lý đầu tiên chứng minh gọi ϕ( u) là tổ
tiên của u

69
Định lý 9: Trong một đồ thị có hướng G = (V, E), (u) của mọi đỉnh u V trong bất kỳ phép tìm
kiếm theo chiều sâu trên G là tổ tiên của u.
Chứng minh:
Nếu ϕ(u)= u, định lý hiển nhiên đúng. Nếu ϕ(u) ≠ u, xem xét màu của các đỉnh tại thời
điểm d[u]. Nếu ϕ(u) là màu đen, thì f[(u)] < f[u] mâu thuẫn với bất đẳng thức (*). Nếu ϕ( u) là
xám thì đó là một tổ tiên của u và do đó định lý được chứng minh. Ta cần chứng minh rằng ϕ(u)
không phải màu trắng. Có hai trường hợp, theo màu của các đỉnh trung gian trên đường đi từ u tới
ϕ( u ):
1. Nếu tất cả các đỉnh là màu trắng, thì ϕ(u) trở thành con cháu của u theo định lý đường đi
trắng. Nhưng từ đó ta có f[(u)] <f[u], mâu thuẫn với bất đẳng thức (*)
2. Nếu có một đỉnh trung gian nào đó không phải là trắng, gọi t là đỉnh không trắng cuối cùng
trên đường đi từ u tới ϕ(u). Do vậy, t phải là xám, do không có một cung nào từ một đỉnh đen tới một
đỉnh trắng, và đỉnh tiếp theo t là trắng. Nhưng từ đó do có một đường đi của các đỉnh trắng từ t tới
ϕ(u), và do vậy ϕ( u ) là một con cháu của t theo định lý đường đi trắng. Điều đó có nghĩa là f[t]
> f[(u)], mâu thuẫn với lựa chọn của chúng ta với ϕ( u), do vậy có một đường đi từ u tới t 3) và
tính chất r = F(r) ta thu được f[?(w)] ≥ f[?(r)] = ?(r), điều này mâu thuẫn với giả thiết f[?(w)] <
f[r]. Do vậy T sẽ chứa các đỉnh mà ϕ(w) = r. Có nghĩa là T tương đương với thành phần liên thông
mạnh C(r), định lý đã được chứng minh.

Bài tập
Bài tập 1: Viết chương trình nhập vào 1 mảng số nguyên và một số nguyên k, hãy đếm
xem có bao nhiêu số bằng k. Nhập tiếp 2 số x < y và đếm xem có bao nhiêu số lớn hơn x và
nhỏ hơn y.
Bài tập 2: Cài đặt thuật toán tìm kiếm tuyến tính theo kiểu đệ qui.
Bài tập 3: Viết chương trình nhập một mảng các số nguyên từ bàn phím, nhập 1 số
nguyên S, đếm xem có bao nhiêu cặp số của mảng ban đầu có tổng bằng S, có hiệu bằng S.

70
CHƯƠNG 3: SẮP XẾP (SORTING)

Bài toán sắp xếp


Sắp xếp được xem là một trong những lĩnh vực nghiên cứu cổ điển của khoa học máy
tính. Trước khi đi vào các thuật toán chi tiết chúng ta cần nắm vững một số khái niệm cơ bản
sau:
 Một trường (field) là một đơn vị dữ liệu nào đó chẳng hạn như tên, tuổi, số điện thoại
của một người ...
 Một bản ghi (record) là một tập hợp các trường
 Một file là một tập hợp các bản ghi
Sắp xếp (sorting) là một quá trình xếp đặt các bản ghi của một file theo một thứ tự nào
đó. Việc xếp đặt này được thực hiện dựa trên một hay nhiều trường nào đó, và các thông tin
này được gọi là khóa xắp xếp (key). Thứ tự của các bản ghi được xác định dựa trên các khóa
khác nhau và việc sắp xếp đối được thực hiện đối với mỗi khóa theo các thứ tự khác nhau.
Chúng ta sẽ tập trung vào các thuật toán xắp xếp và giả sử khóa chỉ gồm 1 trường duy nhất.
Hầu hết các thuật toán xắp xếp được gọi là các thuật toán xắp xếp so sánh: chúng sử dụng hai
thao tác cơ bản là so sánh và đổi chỗ (swap) các phần tử cần sắp xếp.
Các bài toán sắp xếp đơn giản được chia làm hai dạng.
Sắp xếp trong (internal sorting): Dữ liệu cần sắp xếp được lưu đầy đủ trong bộ nhớ
trong để thực hiện thuật toán sắp xếp.
Sắp xếp ngoài (external sorting): Dữ liệu cần sắp xếp có kích thước quá lớn và không
thể lưu vào bộ nhớ trong để sắp xếp, các thao tác truy cập dữ liệu mất nhiều thời gian hơn.
Trong phạm vi của môn học này chúng ta chỉ xem xét các thuật toán sắp xếp trong. Cụ
thể dữ liệu sắp xếp sẽ là một mảng các bản ghi (gồm hai trường chính là trường dữ liệu và
trường khóa), và để tập trung vào các thuật toán chúng ta chỉ xem xét các trường khóa của
các bản ghi này, các ví dụ minh họa và cài đặt đều được thực hiện trên các mảng số nguyên,
coi như là trường khóa của các bản ghi.
Sắp xếp gián tiếp
Khi các bản ghi có kích thước lớn việc hoán đổi các bản ghi là rất tốn kém, khi đó để
giảm chi phí người ta có thể sử dụng các phương pháp sắp xếp gián tiếp. Việc này có thể được
thực hiện theo nhiều cách khác nhau và môt trong những phương pháp đó là tạo ra một file

71
mới chứa các trường khóa của file ban đầu, hoặc con trỏ tới hoặc là chỉ số của các bản ghi
ban đầu. Chúng ta sẽ sắp xếp trên file mới này với các bản ghi có kích thước nhỏ và sau đó
truy cập vào các bản ghi trong file ban đầu thông qua các con trỏ hoặc chỉ số (đây là cách làm
thường thấy đối với các hệ quản trị cơ sở dữ liệu).
Ví dụ: chúng ta muốn sắp xếp các bản ghi của file sau đây:

Index Dept Last First Age ID number


1 123 Smith Jon 3 234-45-4586
2 23 Wilson Pete 4 345-65-0697
3 2 Charles Philip 9 508-45-6859
4 45 Shilst Greg 8 234-45-5784

Sau khi sắp xếp xong để truy cập vào các bản ghi theo thứ tự đã sắp xếp chúng ta sử
dụng thứ tự được cung cấp bởi cột index (chỉ số). Trong trường hợp này là 3, 2, 4, 1. (chúng
ta không nhất thiết phải hoán đổi các bản ghi ban đầu).
Các tiêu chuẩn đánh giá một thuật toán sắp xếp
Các thuật toán sắp xếp có thể được so sánh với nhau dựa trên các yếu tố sau đây:
 Thời gian thực hiện (run-time): số các thao tác thực hiện (thường là số các phép so sánh
và hoán đổi các bản ghi).
 Bộ nhớ sử dụng (Memory): là dung lượng bộ nhớ cần thiết để thực hiện thuật toán ngoài
dung lượng bộ nhớ sử dụng để chứa dữ liệu cần sắp xếp.
o Một vài thuật toán thuộc loại “in place” và không cần (hoặc cần một số cố định) thêm bộ
nhớ cho việc thực hiện thuật toán.
o Các thuật toán khác thường sử dụng thêm bộ nhớ tỉ lệ thuận theo hàm tuyến tính hoặc hàm
mũ với kích thước file sắp xếp.
o Tất nhiên là bộ nhớ sử dụng càng nhỏ càng tốt mặc dù việc cân đối giữa thời gian và bộ

72
nhớ cần thiết có thể là có lợi.
 Sự ổn định (Stability):Một thuật toán được gọi là ổn định nếu như nó có thể giữ được quan
hệ thứ tự của các khóa bằng nhau (không làm thay đổi thứ tự của các khóa bằng nhau).
Chúng ta thường lo lắng nhiều nhất là về thời gian thực hiện của thuật toán vì các thuật
toán mà chúng ta bàn về thường sử dụng kích thước bộ nhớ tương đương nhau.Ví dụ về
sắp xếp ổn định: Chúng ta muốn sắp xếp file sau đây dự trên ký tự đầu của các bản ghi và
dưới đây là kết quả sắp xếp của các thuật toán ổn định và không ổn định:

Chúng ta sẽ xem xét tại sao tính ổn định trong các thuật toán sắp xếp lại được đánh
giá quan trọng như vậy.
Các phương pháp sắp xếp cơ bản
Sắp xếp chọn (Selection sort)
Mô tả thuật toán: Tìm phần tử có khóa lớn nhất (nhỏ nhất), đặt nó vào đúng vị trí và
sau đó sắp xếp phần còn lại của mảng.
Formats: Selection-Sort(Arr, n);
Actions:
for (i =0; i<n-1; i++) { //duyệt các phần tử i=0,1,.., n-1
min_idx = i; //gọi min_idx là vị trí của phần tử nhỏ nhất trong dãy con
for ( j = i +1; j<n; j++ ) { //duyệt từ phần tử tiếp theo j=i+1,..,n.
if (Arr[i] > Arr[j] ) // nếu Arr[i] không phải nhỏ nhất trong dãy con
min_idx = j; //ghi nhận đây mới là vị trí phần tử nhỏ nhất.
}
//đặt phần tử nhỏ nhất vào vị trí đầu tiên của dãy con chưa được sắp
Temp = Arr[i] ; Arr[i] = Arr[min_idx]; Arr[min_idx] = temp;
End

73
void selection_sort(){
int i, j, k, temp;
for (i = 0; i< N; i++){
k = i;
for (j = i+1; j < N; j++){
if (a[j] < a[k]) k = j;
}
temp = a[i]; a[i] =a [k]; a[k] = temp;
}
}
Độ phức tạp trung bình của thuật toán là O(N * N/2) = O(N2/2) = O(N2).
Ví dụ:

Với mỗi giá trị của i thuật toán thực hiện (n – i – 1) phép so sánh và vì i chạy từ 0 cho
tới (n–2), thuật toán sẽ cần (n-1) + (n-2) + … + 1 = n(n-1)/2 tức là O(n2) phép so sánh. Trong
mọi trường hợp số lần so sánh của thuật toán là không đổi. Mỗi lần chạy của vòng lặp đối với
biến i, có thể có nhiều nhất một lần đổi chỗ hai phần tử nên số lần đổi chỗ nhiều nhất của thuật
toán là n. Như vậy trong trường hợp tốt nhất, thuật toán cần 0 lần đổi chỗ, trung bình cần n/2
lần đổi chỗ và tồi nhất cần n lần đổi chỗ.
Sắp xếp đổi chỗ trực tiếp (Exchange sort)
Tương tự như thuật toán sắp xếp bằng chọn và rất dễ cài đặt (thường bị nhầm với thuật
toán sắp xếp chèn) là thuật toán sắp xếp bằng đổi chỗ trực tiếp (một số tài liệu còn gọi là thuật
toán Interchange sort hay Straight Selection Sort).
Mô tả: Bắt đầu xét từ phần tử đầu tiên a[i] với i = 0, ta xét tất cả các phần tử đứng sau
a[i], gọi là a[j] với j chạy từ i+1 tới n-1 (vị trí cuối cùng). Với mỗi cặp a[i], a[j] đó, để ý là

74
a[j] là phần tử đứng sau a[i], nếu a[j] < a[i], tức là xảy ra sai khác về vị trí thì ta sẽ đổi chỗ
a[i], a[j].
Ví dụ minh họa: Giả sử mảng ban đầu là int a[] = {2, 6, 1, 19, 3, 12}. Các bước của
thuật toán sẽ được thực hiện như sau:
i=0, j=2: 1, 6, 2, 19, 3, 12
i=1, j=2: 1, 2, 6, 19, 3, 12
i=2, j=4: 1, 2, 3, 19, 6, 12
i=3, j=4: 1, 2, 3, 6, 19, 12
i=4, j=5: 1, 2, 3, 6, 12, 19
Kết quả cuối cùng: 1, 2, 3, 6, 12, 19.
Cài đặt của thuật toán:
void exchange_sort(int a[], int n){
int i, j; int tam;
for(i=0; i<n-1;i++)
for(j=i+1;j<n;j++) if(a[j] < a[i])
{// đổi chỗ a[i], a[j]
tam = a[i]; a[i] = a[j]; a[j] = tam;
}
}
Độ phức tạp của thuật toán: Có thể thấy rằng so với thuật toán sắp xếp chọn, thuật toán
sắp xếp bằng đổi chỗ trực tiếp cần số bước so sánh tương đương: tức là n*(n-1)/2 lần so sánh.
Nhưng số bước đổi chỗ hai phần tử cũng bằng với số lần so sánh: n*(n-1)/2. Trong trường hợp
xấu nhất số bước đổi chỗ của thuật toán bằng với số lần so sánh, trong trường hợp trung bình
số bước đổi chỗ là n*(n-1)/4. Còn trong trường hợp tốt nhất, số bước đổi chỗ bằng 0. Như vậy
thuật toán sắp xếp đổi chỗ trực tiếp nói chung là chậm hơn nhiều so với thuật toán sắp xếp
chọn do số lần đổi chỗ nhiều hơn.
Sắp xếp chèn (Insertion sort)
Mô tả thuật toán: Thuật toán dựa vào thao tác chính là chèn mỗi khóa vào một dãy
con đã được sắp xếp của dãy cần sắp. Phương pháp này thường được sử dụng trong việc sắp
xếp các cây bài trong quá trình chơi bài.
Có thể mô tả thuật toán bằng lời như sau: ban đầu ta coi như mảng a[0..i-1] (gồm i
phần tử, trong trường hợp đầu tiên i = 1) là đã được sắp, tại bước thứ i của thuật toán, ta sẽ tiến

75
hành chèn a[i] vào mảng a[0..i-1] sao cho sau khi chèn, các phần tử vẫn tuân theo thứ tự tăng
dần. Bước tiếp theo sẽ chèn a[i+1] vào mảng a[0..i] một cách tương tự. Thuật toán cứ thế tiến
hành cho tới khi hết mảng (chèn a[n-1] vào mảng a[0..n-2]). Để tiến hành chèn a[i] vào mảng
a[0..i-1], ta dùng một biến tạm lưu a[i], sau đó dùng một biến chỉ số j = i-1, dò từ vị trí j cho
tới đầu mảng, nếu a[j] > tam sẽ copy a[j] vào a[j+1], có nghĩa là lùi lại một vị trí để chèn tam
vào mảng. Vòng lặp sẽ kết thúc nếu a[j] < tam hoặc j = -1, khi đó ta gán a[j+1] = tam.
Formats: Insertion-Sort(Arr, n);
Actions:
for (i = 1; i < n; i++) { //lặp i=1, 2,..,n.
key = Arr[i]; //key là phần tử cần chèn váo dãy Arr[0],.., Arr[i-1]
j = i-1;
while (j >= 0 && Arr[j] > key) { //Duyệt lùi từ vị trí j=i-1
Arr[j+1] = Arr[j]; //dịch chuyển Arr[j] lên vị trí Arr[j+1]
j = j-1;
}
Arr[j+1] = key; // vị trí thích hợp của key trong dãy là Arr[j+1]
}
End.

Đoạn mã chương trình như sau:


void insertion_sort(){
int i, j, k, temp;
for (i = 1; i< N; i++){
temp = a[i];
j=i-1;
while ((a[j] > temp)&&(j>=0)) {
a[j+1]=a[j];
j--;
}
a[j+1]=temp;
}

76
}
Độ phức tạp trung bình của thuật toán là O(N2/4) = O(N2).
Ví dụ:

Thuật toán sắp xếp chèn là một thuật toán sắp xếp ổn định (stable) và là thuật toán
nhanh nhất trong số các thuật toán sắp xếp cơ bản. Với mỗi i chúng ta cần thực hiện so sánh
khóa hiên tại (a[i]) với nhiều nhất là i khóa và vì i chạy từ 1 tới n-1 nên chúng ta phải thực
hiện nhiều nhất: 1 + 2 + … + n-1 = n(n-1)/2 tức là O(n2) phép so sánh tương tự như thuật toán
sắp xếp chọn. Tuy nhiên vòng lặp while không phải lúc nào cũng được thực hiện và nếu thực
hiện thì cũng không nhất định là lặp i lần nên trên thực tế thuật toán sắp xếp chèn nhanh hơn

77
so với thuật toán sắp xếp chọn. Trong trường hợp tốt nhất, thuật toán chỉ cần sử dụng đúng
n lần so sánh và 0 lần đổi chỗ. Trên thực tế một mảng bất kỳ gồm nhiều mảng con đã được
sắp nên thuật toán chèn hoạt động khá hiệu quả. Thuật toán sắp xếp chèn là thuật toán nhanh
nhất trong các thuật toán sắp xếp cơ bản (đều có độ phức tạp O(n2)).
Sắp xếp nổi bọt (Bubble sort)
Mô tả thuật toán: Thuật toán sắp xếp nổi bọt dựa trên việc so sánh và đổi chỗ hai phần tử ở
kề nhau: Duyệt qua danh sách các bản ghi cần sắp theo thứ tự, đổi chổ hai phần tử ở kề
nhau nếu chúng không theo thứ tự. Lặp lại điều này cho tới khi không có hai bản ghi nào sai
thứ tự. Không khó để thấy rằng n pha thực hiện là đủ cho việc thực hiện xong thuật toán.
Thuật toán này cũng tương tự như thuật toán sắp xếp chọn ngoại trừ việc có thêm nhiều
thao tác đổi chỗ hai phần tử.
Formats: Bubble-Sort(Arr, n);
Actions:
for (i = 0; i < n; i++) { //lặp i=0, 1, 2,..,n.
for (j=0; j<n-i-1; j++ ) {//lặp j =0, 1,.., n-i-1
if (Arr[j] > Arr[j+1] ) { //nếu Arr[j]>Arr[j+1] thì đổi chỗ
temp = Arr[j];
Arr[j] = Arr[j+1];
Arr[j+1] = temp;
}
}
}
End.
Cài đặt thuật toán:
void bubble_sort1(int a[], int n){
int i, j;
for(i=n-1;i>=0;i--)
for(j=1;j<=i;j++) if(a[j-1]>a[j])
swap(a[j-1],a[j]);
}
void bubble_sort2(int a[], int n){
int i, j; for(i=0;i<n;i++)

78
for(j=n-1;j>i;j--)
if(a[j-1]>a[j])
swap(a[j-1],a[j]);
}
Thuật toán có độ phức tạp là O(N*(N-1)/2) = O(N2), bằng số lần so sánh và số lần đổi
chỗ nhiều nhất của thuật toán (trong trường hợp tồi nhất). Thuật toán sắp xếp nổi bọt là thuật
toán chậm nhất trong số các thuật toán sắp xếp cơ bản, nó còn chậm hơn thuật toán sắp xếp
đổi chỗ trực tiếp mặc dù có số lần so sánh bằng nhau, nhưng do đổi chỗ hai phần tử kề nhau
nên số lần đổi chỗ nhiều hơn.
So sánh các thuật toán sắp xếp cơ bản Sắp xếp chọn:
 Trung bình đòi hỏi n2/2 phép so sánh, n bước đổi chỗ.
 Trường hợp xấu nhất tương tự.
Sắp xếp chèn:
 Trung bình cần n2/4 phép so sánh, n2/8 bước đổi chỗ.
 Xấu nhất cần gấp đôi các bước so với trường hợp trung bình.
 Thời gian là tuyến tính đối với các file hầu như đã sắp và là thuật toán nhanh nhất trong
số các thuật toán sắp xếp cơ bản.
Sắp xếp nổi bọt:
 Trung bình cần n2/2 phép so sánh, n2/2 thao tác đổi chỗ.
 Xấu nhất cũng tương tự.
Sắp xếp nhanh (Quick sort)
Quick sort là thuật toán sắp xếp được C. A. R. Hoare đưa ra năm 1962.
Quick sort là một thuật toán sắp xếp dạng chia để trị với các bước thực hiện như sau:
 Selection: chọn một phần tử gọi là phần tử quay (pivot)
 Partition (phân hoạch): đặt tất cả các phần tử của mảng nhỏ hơn phần tử quay sang bên
trái phần tử quay và tất cả các phần tử lớn hơn phần tử quay sang bên phải phần tử quay.
Phần tử quay trở thành phần tử có vị trí đúng trong mảng.
 Đệ qui: gọi tới chính thủ tục sắp xếp nhanh đối với hai nửa mảng nằm 2 bên phần tử quay
Hàm phân hoạch partition:
 Lấy một số k: l ≤ k ≤ r.
 Đặt x = A[k] vào vị trí đúng của nó là p
 Giả sử A[j] ≤ A[p] nếu j < p

79
 A[j] ≥ A[p] nếu j > p
Đây không phải là cách duy nhất để định nghĩa Quicksort. Một vài phiên bản của
thuật toán quick sort không sử dụng phần tử quay thay vào đó định nghĩa các mảng con
trái và mảng con phải, và giả sử các phần tử của mảng con trái nhỏ hơn các phần tử của
mảng con phải.
Chọn lựa phần tử quay
Có rất nhiều cách khác nhau để lựa chọn phần tử quay:
 Sử dụng phần tử trái nhất để làm phần tử quay
 Sử dụng phương thức trung bình của 3 để lấy phần tử quay
 Sử dụng một phần tử ngẫu nhiên làm phần tử quay.
Sau khi chọn phần tử quay làm thế nào để đặt nó vào đúng vị trí và bảo đảm các tính
chất của phân hoạch? Có một vài cách để thực hiện điều này và chúng ta sử dụng phương
thức chọn phần tử quay là phần tử trái nhất của mảng. Các phương thức khác cũng có thể
cài đặt bằng cách sử đổi đôi chút phương thức này.
Formats: Quick-Sort(Arr, l, h);
Actions:
if( l<h) { // Nếu cận dưới còn nhỏ hơn cận trên
p = Partition(Arr, l, h); //thực hiện Partition() chốt h.
Quick-Sort(Arr, l, p-1); //Thực hiện Quick-Sort nửa bên trái.
Quick-Sort(Arr, p+1, h);//Thực hiện Quick-Sort nửa bên phải
}
End.

80
Cài đặt giải thuật:
void quick(int left, int right) {
int i,j;
int x,y;
i=left; j=right;
x= a[left];
do {
while(a[i]<x && i<right) i++;
while(a[j]>x && j>left) j--;
if(i<=j){
y=a[i];a[i]=a[j];a[j]=y;
i++;j--;
}
}while (i<=j);
if (left<j) quick(left,j);
if (i<right) quick(i,right);
}
Hàm chính:

81
void quick_sort(){
quick(0, n-1);
}
Độ phức tạp tính toán: Thời gian thực hiện thuật toán trong trƣờng hợp xấu nhất này
là khoảng N2/2, có nghĩa là O(N2). Trong trƣờng hợp tốt nhất, mỗi lần phân chia sẽ được 2
nửa dãy bằng nhau, khi đó thời gian thực hiện thuật toán được tính là: T(N) = 2T(N/2) + N.
Khi đó, T(N) ≈ NlogN. Trong trƣờng hợp trung bình, thuật toán cũng có độ phức tạp khoảng
2NlogN = O(NlogN).
Trong thủ tục trên chúng ta chọn phần tử trái nhất của mảng làm phần tử quay, chúng
ta duyệt từ hai đầu vào giữa mảng và thực hiện đổi chỗ các phần tử sai vị trí (so với phần tử
quay). Các phương pháp lựa chọn phần tử quay khác:
Phương pháp ngẫu nhiên:
Chúng ta chọn một phần tử ngẫu nhiên làm phần tử quay. Độ phức tạp của thuật toán
khi đó không phụ thuộc vào sự phân phối của các phần tử input
Phương pháp 3-trung bình:
Phần tử quay là phần tử được chọn trong số 3 phần tử a[l], a[(l+r)/2] hoặc a[r] gần với
trung bình cộng của 3 số nhất. Hãy suy nghĩ về các vấn đề sau:
Sửa đổi cài đặt của thủ tục phân hoạch lựa chọn phần tử trái nhất để nhận được cài
đặt của 2 phương pháp trên Có cách cài đặt nào tốt hơn không? Có cách nào tốt hơn để chọn
phần tử phân hoạch?
Các vấn đề khác:
Tính đúng đắn của thuật toán, để xem xét tính đúng đắn của thuật toán chúng ta cần
xem xét 2 yếu tố: thứ nhất do thuật toán là đệ qui vậy cần xét xem nó có dừng không, thứ hai
là khi dừng thì mảng có thực sự đã được sắp hay chưa. Tính tối ưu của thuật toán. Điều gì sẽ
xảy ra nếu như chúng ta sắp xếp các mảng con nhỏ bằng một thuật toán khác? Nếu chúng ta
bỏ qua các mảng con nhỏ? Có nghĩa là chúng ta chỉ sử dụng quicksort đối với các mảng con
lớn hơn một ngưỡng nào đó và sau đó có thể kết thúc việc sắp xếp bằng một thuật toán khác
để tăng tính hiệu quả?
Độ phức tạp của thuật toán:
Thuật toán phân hoạch có thể được thực hiện trong O(n). Chi phí cho các lời gọi tới
thủ tục phân hoạch tại bất cứ độ sâu nào theo đệ qui đều có độ phức tạp là O(n). Do đó độ
phức tạp của quicksort là độ phức tạp của thời gian phân hoạch độ sau của lời gọi đệ qui xa

82
nhất. Kết quả chứng minh chặt chẽ về mặt toán học cho thấy Quick sort có độ phức tạp là
O(n*log(n)), và trong hầu hết các trường hợp Quick sort là thuật toán sắp xếp nhanh nhất,
ngoại trừ trường hợp tồi nhất, khi đó Quick sort còn chậm hơn so với Bubble sort.
Sắp xếp trộn (merge sort)
Về mặt ý tưởng thuật toán merge sort gồm các bước thực hiện như sau:
 Chia mảng cần sắp xếp thành 2 nửa
 Sắp xếp hai nửa đó một cách đệ qui bằng cách gọi tới thủ tục thực hiện chính mergesort
 Trộn hai nửa đã được sắp để nhận được mảng được sắp.
Formats: Merge-Sort(Arr, l, r);
Actions:
if ( l< r ) {
m = (l + r -1) / 2; //phép chia Arr[] thành hai nửa
Merge-Sort(Arr, l, m);//trị nửa thứ nhất
Merge-Sort(Arr, m+1, r); //trị nửa thứ hai
Merge(Arr, l, m, r); //hợp nhất hai nửa đã sắp xếp
}
End.

Cài đặt thuật toán Merge sort:


void mergesort(int *A, int left, int right) {
if(left >= right)

83
return;
int mid = (left + right)/2;
mergesort(A, left, mid);
mergesort(A, mid+1, right);
merge(a, left, mid, right);
}
void merge(int *a, int al, int am, int ar){
int i=al, j=am+1, k;
for (k=al; k<=ar; k++){
if (i>am){
c[k]=a[j++];
continue;
}
if (j>ar){
c[k]=a[i++];
continue;
}
if (a[i]<a[j]) c[k]=a[i++];
else c[k]=a[j++];
}
for (k=al; k<=ar; k++) a[k]=c[k];
}

84
void Merge( int Arr[], int l, int m, int r){
int i, j, k, n1 = m - l + 1; n2 = r - m;
int L[n1], R[n2]; //tạo lập hai mảng phụ
// Copy dữ liệu vào L[] và R[]
for(i = 0; i < n1; i++) L[i] = Arr[l + i];
for(j = 0; j < n2; j++) R[j] = Arr[m + 1+ j];
// hợp nhất các mảng phụ và trả lại vào Arr[]
i = 0; j = 0; k = l;
while (i < n1 && j < n2){
if (L[i] <= R[j])
{ Arr[k] = L[i]; i++; }
else { Arr[k] = R[j]; j++; }
k++;
}
while (i < n1) { Arr[k] = L[i]; i++; k++;
}
while (j < n2) { Arr[k] = R[j]; j++; k++;

85
}
}

L[i]
3 4
R[j]
2 5 6 9
Arr[k]
2 3 4 5 6 9

Độ phức tạp của thuật toán sắp xếp trộn: Gọi T(n) là độ phức tạp của thuật toán sắp
xếp trộn. Thuật toán luôn chia mảng thành 2 nửa bằng nhau nên độ sâu đệ qui của nó luôn
là O(log n). Tại mỗi bước công việc thực hiện có độ phức tạp là O(n) do đó: T(n) =
O(n*log(n)).
Bộ nhớ cần dùng thêm là O(n), đây là một con số chấp nhận được, một trong các đặc
điểm nổi bật của thuật toán là tính ổn định của nó, ngoài ra thuật toán này là phù hợp cho các
ứng dụng đòi hỏi sắp xếp ngoài.
Chương trình hoàn chỉnh:
Các chứng minh chặt chẽ về mặt toán học cho kết quả là Mergesort có độ phức tạp là
O(n*log(n)). Đây là thuật toán ổn định nhất trong số các thuật toán sắp xếp dựa trên so sánh
và đổi chỗ các phần tử, nó cũng rất thích hợp cho việc thiết kế các giải thuật sắp xếp ngoài.
So với các thuật toán khác, Merge sort đòi hỏi sử dụng thêm một vùng bộ nhớ bằng với mảng
cần sắp xếp.
Cấu trúc dữ liệu Heap, sắp xếp vun đống (Heap sort).
Cấu trúc Heap
Trước khi tìm hiểu về thuật toán heapsort chúng ta sẽ tìm hiểu về một cấu trúc đặc biệt
gọi là cấu trúc Heap (heap data structure, hay còn gọi là đống).
Heap là một cây nhị phân đầy đủ và tại mỗi nút ta có key(child) ≤ key(parent). Hãy
nhớ lại một cây nhị phân đầy đủ là một cây nhị phân đầy ở tất cả các tầng của cây trừ tầng
cuối cùng (có thể chỉ đầy về phía trái của cây). Cũng có thể mô tả kỹ hơn là một cây nhị phân

86
mà các nút có đặc điểm sau: nếu đó là một nút trong của cây và không ở mức cuối cùng thì
nó sẽ có 2 con, còn nếu đó là một nút ở mức cuối cùng thì nó sẽ không có con nào nếu nút anh
em bên trái của nó không có con hoặc chỉ có 1 con và sẽ có thể có con (1 hoặc nếu như nút
anh em bên trái của nó có đủ 2 con, nói tóm lại là ở mức cuối cùng một nút nếu có con sẽ có
số con ít hơn số con của nút anh em bên trái của nó.
Heap sort là một giải thuật đảm bảo kể cả trong trƣờng hợp xấu nhất thì thời gian thực
hiện thuật toán cũng chỉ là O(NlogN).
Ý tƣởng cơ bản của giải thuật này là thực hiện sắp xếp thông qua việc tạo các heap,
trong đó heap là 1 cây nhị phân hoàn chỉnh có tính chất là khóa ở nút cha bao giờ cũng lớn
hơn khóa ở các nút con.
Việc thực hiện giải thuật này đƣợc chia làm 2 giai đoạn.
GĐ1: Tạo heap từ dãy ban đầu. Theo định nghĩa của heap thì nút cha bao giờ cũng lớn
hơn các nút con=> nút gốc của heap bao giờ cũng là phần tử lớn nhất.
GĐ2 : Sắp dãy dựa trên heap tạo đƣợc. Do nút gốc là nút lớn nhất nên nó sẽ đƣợc
chuyển về vị trí cuối cùng của dãy và phần tử cuối cùng sẽ đƣợc thay vào gốc của heap. Khi
đó ta có 1 cây mới, không phải heap, với số nút được bớt đi 1. Lại chuyển cây này về heap và
lặp lại quá trình cho tới khi heap chỉ còn 1 nút. Đó chính là phần tử bé nhất của dãy và đƣợc
đặt lên đầu.
Với heap ban đầu chỉ có 1 phần tử là phần tử đầu tiên của dãy, ta lần lƣợt lấy các phần
tử tiếp theo của dãy chèn vào heap sẽ tạo đƣợc 1 heap gồm toàn bộ n phần tử. Chèn một phần
tử x vào 1 heap đã có k phần tử, ta gán phần tử thứ k +1, a[k], bằng x, rồi gọi thủ tục upheap(k).
void upheap(int m){
int x;
x=a[m];
while ((a[(m-1)/2]<=x) && (m>0)){
a[m]=a[(m-1)/2];
m=(m-1)/2;
}
a[m]=x;
}
void insert_heap(int x){
a[m]=x;

87
upheap(m);
m++;
}
Ta có thủ tục downheap để chỉnh lại heap khi nút k không thoả mãn định nghĩa heap :
void downheap(int k){
int j, x;
x=a[k];
while (k<=(m-2)/2){
j=2*k+1;
if (j<m-1) if (a[j]<a[j+1]) j++;
if (x>=a[j]) break;
a[k]=a[j]; k=j;
}
a[k]=x;
}
Cuối cùng, thủ tục heap sort thực hiện việc sắp xếp trên heap đã tạo nhƣ sau:
int remove_node(){
int temp;
temp=a[0];
a[0]=a[m];
m--;
downheap(0);
return temp;
}
void heap_sort(){
int i;
m=0;
for (i=0; i<=n-1; i++) insert_heap(a[i]);
m=n-1;
for (i=n-1; i>=0; i--) a[i]=remove_node();
}
88
void max_heapify(int *A, int i, int n){
int j, temp; temp = A[i]; j = 2*i;
while (j <= n){
if (j < n && A[j+1] > A[j]) j = j+1;
if (temp > A[j]) break;
else if (temp <= A[j]){ A[j/2] = A[j]; j = 2*j; }
}
}
A[j/2] = temp;
}

89
void build_maxheap(int *A, int n){
for(int i = n/2; i >= 1; i--) max_heapify(A, i, n);
}
void heapsort(int *A, int n){ int i, temp;
for (i = n; i >= 2; i--){
temp = A[i]; A[i] = A[1]; A[1] = temp;
//Luôn đổi chỗ cho A[1]
max_heapify(A, 1, i - 1);
//tạọ Max-Heap cho i-1 số còn lại
}
}
Các thuật toán khác
1. Shell sort

2. Radix sort

90
91
Bài tập
Bài tập 1: Cài đặt các thuật toán sắp xếp cơ bản bằng ngôn ngữ lập trình C trên 1 mảng
các số nguyên, dữ liệu của chương trình được nhập vào từ file text được sinh ngẫu nhiên (số
phần tử khoảng 10000) và so sánh thời gian thực hiện thực tế của các thuật toán.
Bài tập 2: Cài đặt các thuật toán sắp xếp nâng cao bằng ngôn ngữ C với một mảng các
cấu trúc sinh viên (tên: xâu ký tự có độ dài tối đa là 50, tuổi: số nguyên, điểm trung bình: số
thức), khóa sắp xếp là trường tên. So sánh thời gian thực hiện của các thuật toán, so sánh với
hàm qsort() có sẵn của C.
Bài tập 3: Cài đặt của các thuật toán sắp xếp có thể thực hiện theo nhiều cách khác
nhau. Hãy viết hàm nhận input là mảng a[0..i] trong đó các phần tử ở chỉ số 0 tới chỉ số i-1 đã
được sắp xếp tăng dần, a[i] không chứa phần tử nào, và một số x, chèn x vào mảng a[0..i-1]
sao cho sau khi chèn kết quả nhận được là a[0..i] là một mảng được sắp xếp. Sử dụng hàm
vừa xây dựng để cài đặt thuật toán sắp xếp chèn.
Gợi ý: Có thể cài đặt thuật toán chèn phần tử vào mảng như phần cài đặt của thuật
toán sắp xếp chèn đã được trình bày hoặc sử dụng phương pháp đệ qui.

92

You might also like