Lời nói đầu
Xây dựng một thuật toán tốt để giải bài bài toán đã cho là bước quan trọng nhất
trong việc giải bài toán đó trên máy tính điện tử. Để có được một thuật một thuật toán
tốt cần phải nắm vững các kỹ thuật thiết kế, phân tích, đánh giá thuật toán cùng các
thuật toán cơ bản cho một số lớp bài bài toán điển hình.
Tài liệu Thiết kế và đánh giá thuật toán được biên soạn nhằm phục vụ công việc
giảng dạy và học tập môn học Thiết kế và đánh giá thuật toán của ngành học Khoa học
máy tính thuộc khoa Công nghệ thông tin trường Đại học sư phạm kỹ thuật Nam Định.
Tài liệu cũng rất cần thiết cho tất cả các ngành học thuộc khoa Công nghệ thông tin.
Nội dung của tài liệu trình bày các kỹ thuật thiết kế thuật toán thông dụng và cơ
sở phân tích, đánh giá độ phức tạp của thuật toán. Tài liệu gồm 6 chương:
Chương 1: Tổng quan về thiết kế và đánh giá thuật toán
Chương 2: Kỹ thuật chia để trị
Chương 3: Kỹ thuật tham lam
Chương 4: Kỹ thuật quay lui
Chương 5: Kỹ thuật nhánh và cận
Chương 6: Kỹ thuật quy hoạch động
Trong từng chương các vấn đề đưa ra đều được minh họa bằng các ví dụ. Cuối
mỗi chương đều có một hệ thống các bài tập nhằm giúp người học củng cố các kiến
thức đã được học đồng thời rèn luyện khả năng vận dụng các kiến thức để giải quyết
một số bài toán trong thực tế. Với các bài tập khó tài liệu đã đưa ra hướng dẫn giải để
giúp người học thuận lợi trong qua trình nghiên cứu và giải quyết các bài tập. Cuối tài
liệu là phần cài đặt một số thuật toán đã được thiết kế nhằm giúp người học thuận lợi
hơn trong việc nắm bắt và vận dụng các kỹ thuật thiết kế thuật toán.
Tài liệu được biên soạn theo chương trình môn học Thiết kế và đánh giá thuật
toán của ngành học Khoa học máy tính thuộc khoa Công nghệ thông tin trường Đại học
sư phạm kỹ thuật Nam Định. Nội dung tài liệu được biên soạn dựa trên cơ sở nội dung
các bài giảng của tác giả trong một số năm qua tại khoa Công nghệ thông tin trường
Đại học sư phạm kỹ thuật Nam Định.
Trong quá trình biên soạn, tác giả đã nhận được nhiều ý kiến đóng góp cùng với
sự động viên, khích lệ của bạn bè đồng nghiệp trong khoa và trong trường. Tác giả xin
được tỏ lòng cảm ơn với những ý kiến đóng góp và động viên khích lệ này. i
Với lần biên soạn đầu tiên, mặc dù đã hết sức cố gắng song chắc chắn tài liệu
không thể tránh khỏi những thiếu sót. Rất mong nhận được các ý kiến đóng góp để tài
liệu ngày càng hoàn thiện hơn.
Phạm Cao Hào
ii
MỤC LỤC
Chương 1. Tổng quan về thiết kế và đánh giá thuật toán 1
1.1. Thuật toán 1
1.1.1. Khái niệm thuật toán 1
1.1.2. Các đặc trưng cơ bản của thuật toán 1
1.2. Sự cần thiết của thiết kế và đánh giá thuât toán 2
1.3. Diễn tả thuật toán 3
1.4. Thiết kế thuật toán 7
1.4.1. Modul hoá và thiết kế từ trên xuống 7
1.4.2. Phương pháp là mịn dần (tinh chỉnh từng bước) 7
1.4.3. Một số kỹ thuật thiết kế 8
1.5. Phân tích thuật toán 9
1.5.1. Thời gian thực hiên thuật toán 9
1.5.2. Độ phức tạp tính toán của thuật toán 10
1.5.3. Ðộ phức tạp của chương trình có gọi chương trình con không đệ qui 16
1.5.4. Phân tích các thuật toán đệ quy 17
1) Thành lập phương trình truy hồi 18
2) Giải phương trình truy hồi 19
Bài tập chương 1. 31
Chương 2. Kỹ thuật chia để trị 37
2.1 Nội dung kỹ thuật 37
2.2. Các ví dụ áp dụng 37
2.2.1. Tìm min và max 37
2.2.2. Một số thuật toán sắp xếp 40
1) Sắp xếp nhanh 40
2) Sắp xếp trộn 44
2.2.3. Tìm kiếm nhị phân 51
2.2.4. Nhân các số nguyên lớn 53
Bài tập chương 2. 57
Chương 3. Kỹ thuật tham lam 62
3.1. Nội dung kỹ thuật 62
3.1.1. Bài toán tối ưu tổ hợp 62
3.1.2. Nội dung kỹ thuật tham lam 62
3.2. Các ví dụ áp dụng 62
iii
3.2.1. Bài toán người giao hàng 62
3.2.2. Bài toán chiếc ba lô 65
3.2.3. Bài toán tô màu bản đồ 70
3.2.4. Tìm cây khung nhỏ nhất 74
3.2.5. Tìm đường đi ngắn nhất 77
3.2.6. Bài toán phân công công việc 79
Bài tập chương 3. 84
Chương 4. Kỹ thuật quay lui 86
4.1. Nội dung kỹ thuật 86
4.2. Các ví dụ áp dụng 87
4.2.1. Đưa ra các dãy nhị phân độ dài n 87
4.2.2. Đưa ra các hoán vị của n số nguyên 88
4.2.3. Đưa ra các tập con của tập gồm n số nguyên 90
4.2.4. Bài toán xếp hậu 92
4.2.5. Tìm đường đi trên đồ thị 94
4.2.6. Bài toán ngựa đi tuần 99
Bài tập chương 4 104
Chương 5. Kỹ thuật nhánh và cận 111
5.1. Nội dung kỹ thuật 111
5.2. Các ví dụ áp dụng 114
5.2.1. Bài toán người du lịch 114
5.2.2. Bài toán chiếc ba lô 128
Bài tập chương 5. 133
Chương 6. Kỹ thuật quy hoạch động 137
6.1. Nội dung kỹ thuật 137
6.2. Các ví dụ áp dụng 140
6.2.1. Tính số tổ hợp 140
6.2.2. Bài toán nhân nhiều ma trận 143
6.2.3. Bài toán chiếc ba lô 149
6.2.4. Xâu con chung dài nhất 154
Bài tập chương 6. 164
Phụ lục 171
Tài liệu tham khảo 195
iv
v
Chƣơng 1
TỔNG QUAN VỀ THIẾT KẾ VÀ ĐÁNH GIÁ THUẬT TOÁN
1.1. Thuật toán
1.1.1. Khái niệm thuật toán
Thuật toán (Algorithm) đã được biết đến từ rất lâu. Đầu tiên thuật toán được hiểu như là các qui tắc thực hiện các phép tính số học với các con số được viết trong
hệ thập phân. Cùng với sự phát triển của máy tính, khái niệm thuật toán được hiểu theo nghĩa rộng hơn. Khái niệm thuật toán được định nghĩa một cách hình thức
chính xác thông qua máy Turing. ở đây chúng ta sẽ xem xét khái niệm thuật toán
một cách trực quan.
Thuật toán (hay giải thuật, thuật giải) là một khái niệm cơ sở của tin học.
Mỗi bài toán trong thực tế bao gồm hai phần: - Input: Các đại lượng cho trước (đại lượng vào)
- Output: Các đại lượng cần tìm (đại lượng ra)
Như vậy việc giải bài toán là việc xác định tường minh output theo input
bằng một quá trình có thể thực hiện một cách hiệu quả. Đó chính là nội dung cơ bản
của lý thuyết tính toán. Khi cho bài toán, ta cần tìm ra một dãy hữu hạn các thao tác
đơn giản được sắp xếp theo một trình tự xác định sao cho theo đó, từ input ta sẽ tìm
ra được output theo yêu cầu.
Một cách trực quan thuật toán giải một bài toán là một dãy hữu hạn các chỉ dẫn (quy tắc, thao tác hay phép toán) hết sức rõ ràng và chính xác được sắp xếp theo
một trình tự xác định để sao cho sau một số hữu hạn lần thực hiên các chỉ dẫn đó thì
biến đổi được input thành output.
1.1.2. Các đặc trƣng cơ bản của thuật toán 1) Dữ liệu vào
Mỗi thuật toán có thể có một hoặc nhiều đại lượng vào mà ta thường gọi là
2) Dữ liệu ra Sau khi thực hiên xong thuật toán, tuỳ theo chức năng mà thuật toán đảm
dữ liệu vào nhiệm ta có thể thu được một số đại luợng ra mà ta gọi là dữ liệu ra. 3) Tính xác định
Tính xác định của thuật toán đòi hỏi ở chỗ ở mỗi bước các thao tác phải hết sức rõ ràng, không thể gây nên sự nhập nhằng, lẫn lộn, tuỳ tiện. Nói cách khác trong cùng một điều kiện hai bộ xử lý (người hoặc máy) thực hiện cùng một bước của thuật toán thì phải cho cùng một kết quả.
1
4) Tính dừng
Thuật toán phải dừng và cho kết quả sau một số hữu hạn bước thực hiện.
5) Tính hiệu quả Yêu cầu của tính hiệu quả là trong số các thuật toán thực hiện cùng một chức
năng ta cần chọn thuật toán tốt nhất. Tiêu chuẩn tốt ở đây được hiểu là: thuật toán
thực hiện nhanh, tốn ít thời gian nhất, dùng ít giấy hoặc từ nhớ để lưu trữ các kết
quả trung gian.
6) Tính phổ dụng Một thuật toán được xem là có tính phổ dụng cao nếu nó có thể dùng để giải
bất cứ bài toán nào trong một lớp các bài toán chứ không phải là một bài toán cụ
thể.
1.2. Sự cần thiết của thiết kế và đánh giá thuật toán
Xây dựng một thuật toán tốt để giải bài toán đã cho là bước quan trọng nhất
trong việc giải bài toán đó trên máy tính điện tử. Để có được một thuật toán tốt cần phải nắm vững các kỹ thuật thiết kế, phân tích, đánh giá thuật toán cùng các thuật
toán cơ bản cho một số lớp bài toán điển hình.
Trong khi giải một bài toán chúng ta có thể có một số thuật toán khác nhau, vấn đề là cần phải đánh giá các thuật toán đó để lựa chọn một thuật toán tốt (nhất).
Thông thường thì ta sẽ căn cứ vào các tiêu chuẩn sau:
(1) Thuật toán đúng đắn.
(2) Thuật toán đơn giản.
(3) Thuật toán thực hiện nhanh.
Với yêu cầu (1), để kiểm tra tính đúng đắn của thuật toán chúng ta có thể cài
đặt thuật toán đó 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ể thuật toán đú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 thuật toán sai chứ chưa chứng minh được là nó đúng. Tính đúng đắn của thuật toán cần phải được chứng minh bằng toán học. Đ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
2
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 thuật toán.
1.3. Diễn tả thuật toán
Có nhiều cách diễn tả thuật toán. Người ta thường diễn tả thuật toán bằng
một trong các cách sau:
1) Liệt kê từng buớc
Thuật toán có thể trình bày dưới dạng ngôn ngữ tự nhiện theo trình tự các
bước thực hiện trong thuật toán
2) Sơ đồ khối (Lưu đồ)
Dùng các hình vẽ (có qui ước) để diễn tả thuật toán. Lưu đồ cho hình ảnh
trực quan và tổng thể của thuật toán nên thường được sử dụng.
3) Ngôn ngữ lập trình
Dùng cấu trúc lệnh, dữ liệu của một ngôn ngữ lập trình nào đó để mô tả.
4) Dạng giả mã
Thuật toán trình bày dưới dạng văn bản bằng ngôn ngữ tự nhiên tuy dễ hiểu nhưng khó cài đặt. Dùng một ngôn ngữ lập trình nào đó để diễn tả thì phức tạp, khó
hiểu. Thông thường thuật toán cũng được trình bày dưới dạng văn bản và không
ràng buộc nhiều vào cú pháp qui định của ngôn ngữ lập trình, nhưng cũng tuân theo
một số qui ước ban đầu- Ta gọi dạng này là dạng giả mã. Tuỳ theo việc định hướng
cài đặt thuật toán theo ngôn ngữ lập trình nào mà tả fiễn đạt thuật toán gần với ngôn
ngữ ấy. Trong tài liệu naứy ta trình bày các thuật toán dưới dạng giả mã của ngôn
ngữ lập trình C. Dưới đây là một số quy ước của ngôn ngữ lập trình C:
* Các ký tự
- Bộ chữ cái: 26 chữ cái
- 10 chữ số thập phân.
- Các dấu phép toán số học.
- Các dấu phép toán quan hệ.
. . .
* Các phép toán số học và logic
Các từ sau xem như là các từ khoá : if, else, case, for, while , do while
...
3
* Các phép toán số học và logic
- Các phép toán số học : +, -, *, /, %.
- Các phép toán Logic : &&, ||, !
* Lệnh gán: biến=biểu thức;
* Khối lệnh:
{
A1;
A2;
...
An;
}
* Cấu trúc rẽ nhánh if
Toán tử if cho phép lựa chọn chạy theo một trong hai nhánh tuỳ thuộc vào sự
bằng không và khác không của biểu thức. Nó có hai cách viết sau :
if (biểu thức) if (biểu thức)
khối lệnh 1 khối lệnh 1
/* Dạng một */ else
khối lệnh 2
/* Dạng hai */
Sự lồng nhau của các toán tử if :
C cho phép sử dụng các toán tử if lồng nhau có nghĩa là trong các khối lệnh
(1 và 2) ở trên có thể chứa các toán tử if - else khác. Trong trường hợp này, nếu
không sử dụng các dấu đóng mở ngoặc cho các khối thì sẽ có thể nhầm lẫn giữa các if-else. Chú ý là máy sẽ gắn toán tử else với toán tử if không có else gần nhất.
* Cấu trúc rẽ nhánh - toán tử switch:
switch (biểu thức nguyên)
{
case n1
khối lệnh 1
case n2
4
khối lệnh 2
.......
case nk
khối lệnh k
[ default
khối lệnh k+1]
}
Với ni là các số nguyên, hằng ký tự hoặc biểu thức hằng. Các ni cần có giá
trị khác nhau. Đoạn chương trình nằm giữa các dấu { } gọi là thân của toán tử switch.
default là một thành phần không bắt buộc phải có trong thân của switch.
* Cấu trúc lặp với toán tử while :
Toán tử while dùng để xây dựng chu trình lặp dạng :
while (biểu thức)
Lệnh hoặc khối lệnh;
Như vậy toán tử while gồm một biểu thức và thân chu trình. Thân chu trình
có thể là một lệnh hoặc một khối lệnh.
Hoạt động của chu trình như sau :
Máy xác định giá trị của biểu thức, tuỳ thuộc giá trị của nó máy sẽ chọn cách
thực hiện như sau :
Nếu biểu thức có giá trị 0 (biểu thức sai), máy sẽ ra khỏi chu trình và chuyển
tới thực hiện câu lệnh tiếp sau chu trình trong chương trình.
Nếu biểu thức có giá trị khác không (biểu thức đúng), máy sẽ thực hiện lệnh hoặc khối lệnh trong thân của while. Khi máy thực hiện xong khối lệnh này nó lại
thực hiện xác định lại giá trị biểu thức rồi làm tiếp các bước như trên.
* Cấu trúc lặp với toán tử for :
Toán tử for dùng để xây dựng cấu trúc lặp có dạng sau :
for (biểu thức 1; biểu thức 2; biểu thức 3)
Lệnh hoặc khối lệnh ;
Toán tử for gồm ba biểu thức và thân for. Thân for là một câu lệnh hoặc một
khối lệnh viết sau từ khoá for. Bất kỳ biểu thức nào trong ba biểu thức trên có thể
5
vắng mặt nhưng phải giữ dấu ;
Thông thường biểu thức 1 là toán tử gán để tạo giá trị ban đầu cho biến điều
khiển, biểu thức 2 là một quan hệ logic biểu thị điều kiện để tiếp tục chu trình, biểu
thức ba là một toán tử gán dùng để thay đổi giá trị biến điều khiển.
Hoạt động của toán tử for :
Toán tử for hoạt động theo các bước sau :
Xác định biểu thức 1
Xác định biểu thức 2
Tuỳ thuộc vào tính đúng sai của biểu thức 2 để máy lựa chọn một trong hai
nhánh :
Nếu biểu thức 2 có giá trị 0 (sai), máy sẽ ra khỏi for và chuyển tới câu lệnh
sau thân for.
Nếu biểu thức 2 có giá trị khác 0 (đúng), máy sẽ thực hiện các câu lệnh trong
thân for.
Tính biểu thức 3, sau đó quay lại bước 2 để bắt đầu một vòng mới của chu
trình.
* Cấu trúc do-while
Khác với các toán tử while và for, việc kiểm tra điều kiện kết thúc đặt ở đầu
chu trình, trong chu trình do while việc kiểm tra điều kiện kết thúc đặt cuối chu
trình. Như vậy thân của chu trình bao giờ cũng được thực hiện ít nhất một lần.
do
Lệnh hoặc khối lệnh;
while (biểu thức) ;
Hoạt động của chu trình như sau :
Máy thực hiện các lệnh trong thân chu trình.
Khi thực hiện xong tất cả các lệnh trong thân của chu trình, máy sẽ xác định
giá trị của biểu thức sau từ khoá while rồi quyết định thực hiện như sau :
Nếu biểu thức đúng (khác 0) máy sẽ thực hiện lặp lại khối lệnh của chu trình
lần thứ hai rồi thực hiện kiểm tra lại biểu thức như trên.
Nếu biểu thức sai (bằng 0) máy sẽ kết thúc chu trình và chuyển tới thực hiện
lệnh đứng sau toán tử while.
6
Những điều lưu ý với toán tử while ở trên hoàn toàn đúng với do while.
* Câu lệnh break
Câu lệnh break cho phép ra khỏi các chu trình với các toán tử for, while, do
while và switch. Khi có nhiều chu trình lồng nhau, câu lệnh break sẽ đưa máy ra
khỏi chu trình bên trong nhất chứa nó không cần điều kiện gì.
* Câu lệnh continue :
Trái với câu lệnh break, lệnh continue dùng để bắt đầu một vòng mới của
chu trình chứa nó. Trong while và do while, lệnh continue chuyển điều khiển về thực hiện ngay phần kiểm tra, còn trong for điều khiển được chuyển về bước khởi
đầu lại (tức là bước : tính biểu thức 3, sau đó quay lại bước 2 để bắt đầu một vòng
mới của chu trình). Lệnh continue chỉ áp dụng cho chu trình chứ không áp dụng cho
switch.
1.4. Thiết kế thuật toán
1.4.1. Modul hoá và thiết kế từ trên xuống
Các bài toán giải được trên máy tính ngày càng phức tạp và đa dạng. Các
thuật toán giải chúng ngày càng có quy mô lớn đòi hỏi nhiều thời gian và công sức
của nhiều người. Tuy nhiên công việc sẽ đơn giản hơn nếu như ta chia bài toán ra
thành các bài toán nhỏ. Điều đó cũng có nghĩa là nếu coi bài toán là modul chính thì
cần chia thành các modul con. Đến lượt mình các modul con lại phân rã thành các
modul con thích hợp...
Như vậy việc tổ chức lời giải thể hiện theo một cấu trúc phân cấp. Chiến thuật
giải bài toán như vậy là “chia để trị”, thể hiện chiến thuật đó ta
dùng thiết kế từ trên xuống. Đó là cách nhìn nhận vấn đề một cách tổng quát, đề cập
đến các công việc chính, sau đó mới bổ sung dần các chi tiết.
1.4.2. Phƣơng pháp làm mịn dần (tinh chỉnh từng bƣớc)
Đầu tiên thuật toán được trình bày dưới dạng ngôn ngữ tự nhiên thể hiện ý
chính công việc. Các bước sau sẽ chi tiết hóa dần tương ứng với các công việc nhỏ
hơn. Đó là các bước làm mịn dần đặc tả thuật toán và hướng về ngôn ngữ lập trình
mà ta dự định cài đặt.
Quá trình thiết kế và phát triển thuật toán sẽ thể hiện dần từ ngôn ngữ tự
nhiên, sang ngôn ngữ mã giả rồi đến ngôn ngữ lập trình, và đi từ mức “làm cái
gì“đến “làm như thế nào”.
7
1.4.3. Một số kỹ thuật thiết kế
Trên cơ sở lý thuyết máy Turing, người ta chia được các bài toán thành 2 lớp
không giao nhau : Lớp giải được bằng thuật toán, và lớp không giải được bằng thuật
toán.
Đối với lớp các bài toán giải được bằng thuật toán, dựa vào các đặc trưng của
quá trình thiết kế của thuật toán, ta có thể chỉ ra một số các kỹ thuật thiết kế thuật
toán cơ bản sau đây :
1) Kỹ thuật chia để trị
Chia bài toán thành các bài toán đủ nhỏ, giải các bài toán nhỏ rồi tổng hợp kết
quả lại .
2) Kỹ thuật quay lui
Tìm kiếm theo ưu tiên.
Đối với mỗi bước thuật toán, ưu tiên theo độ rộng hay chiều sâu để tìm kiếm.
Chẳng hạn thuật toán giải bài toán 8 hậu.
3) Kỹ thuật tham lam
Ý tưởng là : Xác định trật tự xử lý để có lợi nhất, Sắp xếp dữ liệu theo trật tự
đó, rồi xử lý dữ liệu theo trật tự đã nêu. Công sức bỏ ra là tìm ra trật tự đó. Chẳng
hạn thuật toán tìm cây khung nhỏ nhất.
4) Kỹ thuật nhánh và cận
Trong quá trình tìm kiếm lời giải, ta phân hoạch tập các phương án của bài
toán ra thành hai hay nhiều tập con được biểu diễn như là các nút của cây tìm kiếm
và cố gắng bằng phép đánh giá cận cho các nút, tìm cách loại bỏ các nhánh của cây
mà ta biết chắc không chứa phương án tối ưu.
5) Kỹ thuật Quy hoạch động
Kỹ thuật quy hoạch động dựa vào một nguyên lý, gọi là nguyên lý tối ưu của
Bellman :
“ Nếu lời giải của bài toán là tối ưu thì lời giải của các bài toán con cũng tối
ưu ”.
Kỹ thuật này tổ chức tìm kiếm lời giải theo kiểu từ dưới lên. Xuất phát từ các
bài toán con nhỏ và đơn giản nhất, tổ hợp các lời giải của chúng để có lời giải của
bài toán con lớn hơn...và cứ như thế cuối cùng được lời giải của bài toán ban đầu.
8
1.5. 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ố thuật toán khác nhau, vấn đề là cần phải đánh giá các thuật toán đó để lựa chọn một thuật toán tốt (nhất).
Thông thường thì ta sẽ căn cứ vào các tiêu chuẩn sau:
- Thuật toán đơn giản
- Thuật toán thực hiện nhanh
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 thuật toán đơn giản là quan trọng. 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ì 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 thuật toán thực hiện nhanh 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 thuật toán. Hơn nữa khối lượng dữ liệu lớn mà dung lượng bộ nhớ lại có giới
hạn thì không thể bỏ qua yêu cầu về tiết kiệm bộ nhớ được. Tuy nhiên cân đối giữa
yêu cầu về thời gian và không gian không mấy khi có được một giải phấp trọn vẹn.
Sau đây ta sẽ chỉ chú ý đến việc phân tích thời gian thực hiện thuật toán.
1.5.1. Thêi gian thùc hiÖn thuËt toán
Một phương pháp để xác định hiệu quả thời gian thực hiện của một thuật toán 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 thuật toán 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 thuật toán. 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.
Nói chung thì thời gian thực hiện thuật toán 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 giải thuật 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
9
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.
Để đánh giá thời gian thực hiện thuật toán người ta tìm cách đánh giá độc lập với các yếu tố bên ngoài như máy tính hay các yếu tố liên quan đến máy tính. Cách đánh giá như vậy dẫn tới khái niệm về cấp độ lớn của thời gian thực hiện thuật toán
hay độ phức tạp tính toán của thuật toán.
1.5.2. Độ phức tạp tính toán của thuật toán
Nếu thời gian thực hiện một thuật toán là T(n) =cn2 (với c là hằng số, n là kích thước dữ liệu đầu vào) thì ta nói: Độ phức tạp tính toán của thuật toán này có cấp là n2 (hay cấp độ lớn của thời gian thực hiện thuật toán là n2) và ta ký hiệu:
T(n) = O(n2) (ký hiệu chữ O lớn)
Một cách tổng quát có thể định nghĩa:
Một hàm f(n) được xác định là O(g(n)) và viết là f(n) =O(g(n)) và được gọi
là cấp g(n) nếu tồn tại các hằng số c và n0 sao cho:
f(n) ≤ cg(n) khi n ≥ n0
nghĩa là f(n) bị chặn trên bởi một hằng số nhân với g(n), với mọi giá trị của n tăng từ một điểm nào đó. Thông thường các hàm thể hiện độ phức tạp tính toán của thuật
toán có dạng :
log2n, n, nlog2, n2, n3, 2n, n!, nn
Sau đây là bảng giá trị của một số hàm đó
n n2 n3 2n nlog2n Log2n
1 0 0 1 1 2
2 2 1 4 8 4
4 8 2 16 64 16
8 24 3 64 512 256
16 64 4 256 40963 65536
32 160 5 1024 32768 2.147.483.648
Hình 1.1. Bảng giá trị của một số hàm số
Các hàm như 2n , n!, nn được gọi là hàm loại mũ. Một thuật toán mà thời gian
10
thực hiện của nó có cấp là các hàm loại mũ thì tốc độ rất chậm. Các hàm như n3 , n2, nlog2n, n, log2n được gọi là các hàm loại đa thức. Một thuật toán 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 thuật toán có độ phức tạp hàm mũ thì phải tìm cách cải tiến thuật
toán.
Các quy tắc xác định độ phức tạp của thuật toán:
Xác định độ phức tạp tính toán của một thuật toán bất kỳ có thể dẫn tới những bài toán phức tạp. Tuy nhiên, trong thực tế, đối với một số thuật toán ta cũng
có thể phân tích được bằng một số quy tắc đơn giản.
* Quy tắc tổng:
Giả sử T1(n) và T2(n) là độ phức tạp tính toán của hai đoạn chương trình P1 và P2 mà T1(n) = O(f(n)); T2(n) = O(g(n)) thì độ phức tạp tính toán khi thực hiện P1 và tiếp theo là P2 sẽ là: T1(n) + T2(n) = O(max (f(n),g(n))
Chứng minh:
Vì T1(n) = O(f(n)); T2(n) = O(g(n)) nên theo định nghĩa tồn tại các hằng số
dương c1 , n1 và c2 , n2 sao cho:
T1(n) ≤ c1 f(n), với mọi n > n1
T2(n) ≤ c2 g(n) với mọi n > n2.
Chọn c = c1 + c2; n0 = max {n1, n2}.
Khi đó: T1(n) + T2(n) c1 f(n) + c2 g(n) c max(f(n), g(n)) , với mọi n
> n0.
Do vậy: O(f(n)) + O(g(n)) = O(max(f(n), g(n))).
Ví dụ 1.1.
Trong một chương trình có 3 bước thực hiện mà độ phức tạp tính toán từng bước lần lượt là O(n2), O(n3) và O(nlog2n) thì độ phức tạp tính toán hai bước đầu là O(max (n2, n3) = O(n3). Độ phức tạp tính toán của chương trình sẽ là: O(max(n3, nlog2n)) = O(n3).
Nhận xét:
Từ quy tắc này có thể nhận thấy rằng: nếu g(n) ≤ f(n) với mọi n ≥ n0 thì:
O(f(n)+g(n)) = O(f(n)).
Chẳng hạn: O(n4+n2) = O(n4)
O(n + log2n) = O(n).
11
* Quy tắc nhân:
Giả sử T1(n) và T2(n) là độ phức tạp tính toán của hai đoạn chương trình P1 và P2 mà T1(n) = O(f(n)); T2(n) = O(g(n)) thì độ phức tạp tính toán khi P1 và P2 lồng nhau sẽ là: T1(n).T2(n) = O(f(n).g(n))
Chứng minh:
Ta có: T1(n) = O(f(n)), T2(n) = O(g(n) theo định nghĩa tồn tại các hằng số
dương c1 , n1và c2 , n2 sao cho:
T1(n) ≤ c1 f(n), với mọi n > n1
T2(n) ≤ c2 g(n) với mọi n > n2.
Chọn c = c1 * c2; n0 = max {n1, n2}.
Khi đó: T1(n).T2(n) c1 f(n) c2 g(n) = c (f(n) g(n)).
Do vậy: T1(n).T2(n) = O(f(n).g(n)).
Ví dụ 1.2.
Câu lệnh gán : x = x+1 có thời gian thực hiện bằng c (hằng số) nên được
đánh giá là O(1).
Câu lệnh: for ( i=1; i<=n; i++) x =x+1;
Có độ phức tạp tính toán O(n.1) = O(n)
Câu lệnh : for ( i= 1; i<=n; i++)
for ( j= 1; j<=n; j++) x =x+1;
có độ phức tạp tính toán được đánh giá là: O(n.n)=O(n2)
Nhận xét:
Từ quy tắc nhân ta sẽ có:
O(cf(n)) = O(f(n))
Chẳng hạn O(n2/2) = O(n2)
Chú ý :
Dựa vào những nhận xét đã nêu ở trên về quy tắc khi đánh giá độ phức tạp tính toán của thuật toán ta chỉ cần chú ý tới các bước tương tự với một phép toán mà
ta gọi là phép toán tích cực. Đó là một phép toán thuộc thuật toán mà thời gian thực hiện nó không ít hơn thời gian thực hiện các phép toán khác (tất nhiên phép toán tích cực không phải là duy nhất) hay nói một cách khác: số lần thực hiện nó không
kém gì các phép toán khác.
12
Ví dụ 1.3.
Giải thuật tính giá trị của ex theo công thức gần đúng:
ex = 1 + x/1! +x2/2! + …+ xn/n! với x và n cho trước
EXP1();
{
scanf(x) ;
S =1;
for (i=1;i<=n;i++)
{
p =1;
for(j=1;j<=i;j++) p=p*x/j;
S = S +p;
}
}
Ta có thể coi phép toán tích cực ở đây là phép: p = p*x/j
Ta thấy nó được thực hiện: 1 +2+…+ n = n(n+1)/2 lần
Vậy độ phức tạp tính toán của thuật toán này được đánh giá là T(n) = O(n2)
Thuật toán có thể được viết theo một cách khác:
EXP2()
{
scanf(x);
S =1;
P =1;
for (i=1;i<=n;i++)
{
p =p*x/i;
S =S + p;
}
}
13
Bây giờ độ phức tạp tính toán lại là: T(n) = O(n). Vì phép gán p=p*x/i chỉ thực
hiện n lần.
Ví dụ 1.4.
Thuật toán sắp xếp kiểu nổi bọt
void Bubble(a)
{
for(i=1;i<=n-1; i++)
for(j=n; j>=i+1; j--)
if (a[j-1]>a[j])
{
tg:= a[j-1];
a[j-1] := a[j];
a[j]:= tg;
}
}
Trong thuật toán ta coi phép so sánh (a[j-1]>a[j]) là phép toán tích cực. Phép
toán này nằm trong vòng lặp for(j=n; j>=i+1; j--) nên nó được thực hiện (n-i) lần.
Vòng lặp for(j=n; j>=i+1; j--) nằm trong vòng lặp for(i=1;i<=n-1; i++) thực hiện
(n-1) lần. Do vậy số lần thực hiện phép toán tích cực (a[j-1]>a[j]) sẽ là:
Nên độ phức tạp tính toán của thuật toán là O(n2).
Chú ý:
Ta biết rằng thời gian thực hiện thuật toán không phải chỉ phụ thuộc vào
kích thước dữ liệu mà còn phụ thuộc vào tình trạng dữ liệu nhập nữa. Chẳng hạn, khi xếp tăng dần một dãy các số nguyên mà dãy các so nguyên đĩ đã có sẵn thứ tự tăng dần, hoặc ngược lại, hoặc ngẫu nhiên. Lúc đó khi phân tích thời gian thực hiện thuật toán ta sẽ phải xét tới: đối với mọi dữ liệu vào có kích thước n thì T(n) trong trường hợp tốt nhất, xấu nhất là như thế nào? T(n) trung bình? Việc xác định T(n) trung bình thường khó và phức tạp đòi hỏi những công cụ toán học đặc biệt, hơn
nữa việc tính trung bình có thể có nhiều cách quan niệm khác nhau. Trong trường hợp T(n) khó xác định người ta thường đánh giá thuật toán qua giá trị xấu nhất của T(n)
14
VÝ dô 1.5.
timkiem(v)
/*Cho vectơ V có n phần tử, thuật toán này thực hiện tìm trong V một phần tử có
giá trị bằng X cho trước. Nếu tìm thấy trả về chỉ số của phần tử đó, nếu không tìm
thấy trả về giá trị 0*/
i =1;
while ((V[i] !=X)&& (i<= n)) i= i+1;
if (i<=n) return(i);
else return(0);
Coi phép toán tích cực ở đây là phép so sánh V[i] với X. Có thể thấy số lần phép toán thực hiện phụ thuộc vào chỉ số i mà V[i] = X. Trường hợp thuận lợi nhất xảy ra
khi X bằng V[1]: một lần thực hiện.
Trường hợp xấu nhất khi X bằng V[n] hoặc không tìm thấy: n lần thực hiện
Vậy: Ttốt = O(1) và Txấu = O(n)
Thì ta xác định độ phức tạp tính toán của thuật toán là O(n)
* Qui tắc tổng quát để phân tích một chương trình:
Giả sử rằng, các lệnh gán không chứa các lời gọi hàm. Khi đó để đánh giá
thời gian thực hiện một chương trình, ta có thể áp dụng một số quy tắc sau
1. Thời gian thực hiện các lệnh đơn: gán, đọc, viết là O(1)
2. Lệnh hợp thành (khối lệnh) : thời gian thực hiện lệnh hợp thành được xác
định bởi luật tổng.
3. Lệnh if : Giả sử thời gian thực hiện các lệnh S1, S2 là O(f(n)) và O(g(n))
tương ứng. Khi đó thời gian thực hiện lệnh if là O(max (f(n), g(n)))
4. Lệnh witch: Lệnh này được đánh giá như lệnh if
5. Lệnh while : Giả sử thời gian thực hiện lệnh S (thân của while) là O(f(n)).
Giả sử g(n) là số tối đa các lần thực hiện lệnh S. Khi đó thời gian thực hiện
lệnh while là O(f(n)g(n)).
6. Lệnh do ...while :Giả sử thời gian thực hiện khối lệnh trong thân do ... while là O(f(n)). Giả sử g(n) là số lần tối đa các lần thực hiện khối lệnh trong thân
do ... while . Khi đó thời gian thực hiện lệnh do ... while là O(f(n)g(n)).
7. Lệnh for : Lệnh này được đánh giá tương tự như lệnh while.
1.5.3. Ðộ phức tạp của chƣơng trình có gọi chƣơng trình con không đệ qui
15
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:
Hình 1.2. Chương trình gọi chương trình con không đẹ quy
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.
Ðể xác định độ phức tạp tính toán của A, ta thực hiện theo các bước sau:
1. Xác định độ phức tạp tính toá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ả.
2. Xác định độ phức tạp tính toán của B1. Vì B1 gọi B11 và B12 mà độ phức
tạp tính toán của B11 và B12 đã được tính ở bước 1.
3. Xác định độ phức tạp tính toán của B. Vì B gọi B1 và B2 mà độ phức tạp tính toán của B1 đã được tính ở bước 2 và độ phức tạp tính toán của B2 đã được
tính ở bước 1.
4. Xác định độ phức tạp tính toán của A. Vì A gọi B và C mà độ phức tạp tính toán của B đã được tính ở bước 3 và độ phức tạp tính toán của C đã được tính ở bước 1.
Ví dụ 1.6.
Thuật toán sắp xếp nổi bọt.
Trước hết 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 sẽ gọi đến thủ tục Swap này.
void Swap (x, y)
16
{
temp = x;
x = y;
y = temp;
}
void Bubble (a)
{
for(i=1;i<=n-1; i++)
for(j=n; j>=i+1; j--)
if (a[j-1]>a[j])
Swap(a[j-1], a[j]);
}
Trong cách viết trên, hàm Bubble gọi hàm Swap, do đó để tính độ phức tạp
tính toán của Bubble, trước hết ta cần tính độ phức tạp tính toán của Swap. Dễ thấy
độ phức tạp tính toán của Swap là O(1) vì nó chỉ bao gồm 3 lệnh gán. Do vậy ta có
thể coi phép toán tích cực là phép so sánh (a[j-1]>a[j]) và khi đó dễ thấy độ phức
tạp tính toán của thuật toán là:
1.5.4. Phân tích các thuật toán đệ quy
Nhiều thuật toán dựa trên sự phân rã đệ qui một bài toán lớn thành các bài
toán nhỏ, rồi dùng lời giải các bài toán nhỏ để giải bài toán ban đầu. Thời gian chạy
của thuật toán như thế được xác định bởi kích thước và số lượng các bài toán con và
giá phải trả của sự phân rã. Nên các thuật toán đệ qui có thời gian chạy phụ thuộc
vào thời gian chạy cho các dữ liệu nhập có kích thước nhỏ hơn, điều này được diễn dịch thành một công thức toán học gọi là công thức truy hồi hay phương trình truy
hồi, hệ thức truy hồi. Do đó, để tính độ phức tạp của thuật toán, ta thường phải giải các phương trình truy hồi.
Với các thuật toán có các lời gọi đệ quy, ta không thể áp dụng cách tính như vừa
trình bày trong mục 1.3.3 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:
17
Hình 1.3. Chương trình đệ quy A
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 truy hồi, sau đó giải phương trình truy hồi, nghiệm của phương trình truy hồi sẽ là thời
gian thực hiện của chương trình đệ quy.
1) Thành lập phƣơng trình truy hồi
Phương trình truy hồi 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 truy hồi, 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 Để thành lập phương trình truy hồi, 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 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 truy hồi 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ả. Chú ý: Trong khi đánh giá độ phức tạp tính toán của thuật toán thì với T( ) ta sẽ hiểu là hoặc (Với x là một số thực thì: 18 x là số nguyên lớn nhất nhỏ hơn hoặc bằng x x là số nguyên nhỏ nhất lớn hơn hoặc bằng x) Ví dụ 1.7. Xét hàm tính giai thừa viết bằng thuật toán đệ quy như sau: int Giai_thua(n) { if (n<=1) gt=1; else gt=n* Giai_thua(n-1); return(gt); } 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 <=1 thì chương trình chỉ thực hiện một lệnh gán gt=1, nên tốn O(1), do đó ta có T(1) = C1. Trong trường hợp n>1 chương trình 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 gt. 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ó: T(n) = C1 nếu n 1
T(n-1)+C2 nếu n >1 Ðây là phương trình truy hồi để tính thời gian thực hiện của chương trình đệ quy Giai_thua. 2) Giải phƣơng trình truy hồi Một số phương pháp giải phương trình truy hồi: * Phương pháp thay thế Dùng đệ quy để thay thế bất kỳ T(m) với m < n vào phía phải của phương
trình cho đến khi tất cả T(m) với m > 1 được thay thế bởi biểu thức của các T(1)
hoặc T(0). Vì T(1) và T(0) luôn là hằng số nên chúng ta có công thức của T(n) chứa
các số hạng chỉ liên quan đến n và các hằng số. Từ công thức đó ta suy ra T(n). VÝ dô 1.8. Hàm tính n! trong ví dụ 1.7. int Giai_thua(n) { 19 if (n<=1) gt=1; else gt=n* Giai_thua(n-1); return(gt); } Ta đã có phương trình truy hồi để tính thời gian thực hiện của chương trình đệ quy Giai_thua là: T(n) = C1 nếu n 1
T(n-1)+C2 nếu n >1 - Với n 1, chỉ cần thực hiện lệnh gán gt = 1, do đó T(1) = O(1). - Với n > 1. cần thực hiện lệnh gán gt= n*Giai_thua(n - 1). Do đó thời gian T(n) là O(1) (để thực hiện phép nhân và phép gán) cộng với T(n-1) (để thực hiện lời gọi đệ qui Giai_thua(n – 1)). Tóm lại, ta có quan hệ sau: T(1) = O(1) T(n) = O(1) + T(n-1) Thay các O(1) bởi các hằng nào đó, ta nhận được quan hệ sau T(1) = C1 T(n) = C2 + T(n - 1) Để giải phương trình truy hồi, tìm T(n), chúng ta áp dụng phương pháp thế lặp. Ta có phương trình truy hồi T(m) = C2 + T(m-1), với m > 1 Thay m lần lượt bởi 2, 3,..., n - 1, n, ta nhận được các quan hệ sau: T(2) = C2 + T(1) T(3) = C2 + T(2) .......
T(n -1) = C2 + T(n -2) T(n) = C2 + T(n - 1) Bằng các phép thế liên tiếp, ta nhận được T(n) = (n - 1) C2 + T(1) hay T(n) = (n - 1) C2 + C1, trong đó C1 và C2 là các hằng nào đó. 20 Do đó, T(n) = O(n). Ví dụ 1.9. Giải phương trình truy hồi sau: c1 nếu n=1 T(n) = 2T( )+c2n nếu n>1 Ta có với n>1: T(n) = 2T( )+c2n = 2(2T( )+c2 ) + c2n = 4 T( ) + 2c2n = 22T( ) + 2c2n = 4( 2T( )+c2 ) + 2c2n = 8T( ) + 3c2n = 23T( ) + 3c2n ..... = 2iT( ) + ic2n Quá trình sẽ dừng khi: =1 n =2i i = log2n Khi đó: T(n) = nT(1) + c2nlog2n = nc1 + c2nlog2n Và T(n) = O(nlog2n) * Phương pháp đoán nghiệm 2 3 Ta đoán một nghiệm f(n) và dùng chứng minh quy nạp để chứng tỏ rằng T(n)
≤ f(n) với mọi n. Thông thường f(n) là một trong các hàm quen thuộc như log2n, n, n
, n!, nn. , n , 2 nlog2n, n 2 Ðôi khi chúng ta chỉ đoán dạng của f(n) trong đó có một vài tham số chưa xác định (chẳng hạn f(n) = an với a chưa xác định) và trong quá trình chứng minh quy nạp ta sẽ suy diễn ra giá trị thích hợp của các tham số Ví dụ 1.10. Giải phương trình truy hồi sau: 21 c1 nếu n=1 T(n)= 2T( )+c2n nếu n>1 Giả sử chúng ta đoán f(n) = anlog2n. Với n = 1 ta thấy rằng việc đoán như vậy không được bởi vì anlog2n có giá trị 0 không phụ thuộc vào giá trị của a. Vì thế ta thử tiếp theo f(n) = anlog2n + b. Với n = 1 ta có, T(1) = C1 và f(1) = b, muốn T(1) ≤ f(1) thì b ≥ C1 (*) Giả sử rằng T(k) ≤ f(k), tức là T(k) ≤ aklog2k + b với mọi k < n (giả thiết quy nạp). Ta phải chứng minh T(n) ≤ anlog2n + b với mọi n. Giả sử n ≥ 2, từ phương trình đã cho ta có T(n) = 2T( )+ c2n Áp dụng giả thiết quy nạp với k = ta có T(n) = 2T( )+ c2n 2(a log2 + b) +c2n T(n) ≤ (anlog2n - an + 2b) + c2n ≤ (anlog2n + b) + [b + (c2 - a)n] . Nếu lấy a ≥ c2 + b (**) ta được T(n) ≤ (anlog2n + b) + [b +(c2 - c2 - b )n ] T(n) ≤ (anlog2n + b) + (1-n)b T(n) ≤ anlog2n + b = f(n) (do b>0 và 1-n<0) Nếu ta lấy a và b sao cho cả (*) và (**) đều thoả mãn Tức là: a c2 + b b c1 thì T(n) ≤ anlog2n + b với mọi n Như vậy với b c1 và a c1 + c2 thì ta sẽ có T(n) ≤ (c1 + c2)nlog2n +c1 với mọi n.
Hay nói cách khác T(n) là O(nlog2n). * Phương pháp dùng phương trình đặc trưng với phương trình truy hồi tuyến tính thuần nhất hệ số hằng Phương trình truy hồi (công thức truy hồi) tuyến tính thuần nhất bậc k với hệ số hằng số có dạng: 22 an = c1an-1 + c2an-2 + ...+ ckan-k trong đó c1, c2, ..., ck là các số thực, ck 0 Nếu cho trước k điều kiện ban đầu a0 = c0, a1 = c1, ..., ak-1 = ck-1 ,thì theo
qui nạp toán học, dãy số thoả mãn phương trình truy hồi nêu trong định nghĩa sẽ
được xác định duy nhất. Phương pháp cơ bản để giải phương trình truy hồi tuyến tính thuần nhất là tìm nghiệm dưới dạng an = rn, trong đó r là hằng số. Ta có an = rn là nghiệm của phương trình truy hồi an = c1an-1 + c2an-2 + ...+ ckan-k khi và chỉ khi: rn = c1rn-1 + c2rn-2 + ... + ckrn-k
rn - c1rn-1 - c2rn-2 - ... - ckrn-k = 0 Hay Vậy, dãy {an} với an = rn là nghiệm khi và chỉ khi r là nghiệm của phương
trình đại số trên. Phương trình này được gọi là phương trình đặc trưng của công thức truy hồi và nghiệm của nó được gọi là nghiệm đặc trưng của phương trình truy hồi. Các nghiệm đặc trưng sẽ dùng cho công thức tường minh của tất cả các nghiệm của phương trình truy hồi. n Đối với phương trình truy hồi tuyến tính thuần nhất bậc 2 khi phương trình
đặc trưng có hai nghiệm phân biệt r1, r2. Khi đó dãy số {an} là nghiệm của công
thức truy hồi an = c1an-1 + c2an-2 khi và chỉ khi n + 2r2 an = 1r1 với n= 0, 1, 2,... ,trong đó 1, 2 là các hằng số. n + a2r2 Ngược lại, giả sử {an} là một nghiệm bất kỳ của phương trình truy hồi, ta sẽ
n thoả mãn các điều kiện đầu a0 chọn 1 và 2 sao cho dãy {an} với an = a1r1
= c0, a1 = c1. Thật vậy, đặt: a0 = c0 = 1 + 2 a1 = c1 = a1r1 + a2r2 Từ phương trình đầu ta được 2 = c0 - 1. Thay vào phương trình sau ta được: c1 = a1r1 + (c0 - 1)r2 = a1(r1 - r2) + c0r2 Suy ra và = 23 n + a2r2 n . n thoả
mãn các điều kiện đầu. Vì phương trình truy hồi và các điều kiện đầu xác định duy
nhất, nên an = a1r1 n + a2r2 Vậy khi chọn các giá trị a1 và a2 này thì dãy {an} với an = a1r1 Ví dụ 1.11. Giả sử rằng các con thỏ không bao giờ chết. Biết rằng một cặp thỏ sau 2
tháng tính từ khi ra đời sẽ sinh ra một cặp thỏ mới và sau đó cứ mỗi tháng lại sinh ra một cặp thỏ mới. Hỏi nếu tháng đầu có một cặp thỏ thì đến tháng thứ n sẽ có bao nhiêu cặp thỏ? Giải: Gọi số cặp thỏ có ở tháng thứ n là Fn thì theo giả thiết F1 = F2 = 1. Với n>2
ta nhận thấy rằng: Số cặp thỏ có ở tháng thứ n sẽ là số cặp thỏ có ở tháng thứ n-1 (là
Fn-1) cộng với số cặp thỏ mới được sinh ra ở tháng thứ n - chính là số cặp thỏ có ở
tháng thứ n-2 (là Fn-2). Tức là: Fn = Fn-1 + Fn-2 với n>2. Vì vậy có thể tính Fn theo hệ thức truy hồi sau: Fn = 1 nếu n 2
Fn-1+ Fn-2 nếu n >2 Dãy số thể hiện Fn với các giá trị của n được gọi là dãy số Fibonacci. Từ hệ thức truy hồi trên ta dễ dàng có được giải thuật sau để tính số cặp thỏ có ở tháng thứ n. Fibo(n) { if(n<=2) return(1); else return(Fibo(n-1) + Fibo(n-2)); } Ta đánh giá độ phức tạp tính toán của giải thuật. Gọi T(n) là thời gian thực hiện giải thuật thì ta có phương trình truy hồi sau: T(n) = C1 nếu n 2
T(n-1)+T(n-2) nếu n >2 24 Giải phương trình đặc trưng r2- r- 1 = 0 ta thu được hai nghiệm: n + 2r2 n (*) trong đó 1, 2 là các hằng số cần xác
định từ các giá trị ban đầu T(1) và T(2). Thay T(1) và T(1) vào (*) và giải ra ta được Khi đó ta có T(n) = 1r1 Khi đó ta có: Hay: T(n) = O Như vậy độ phức tạp tính toán của giải thuật là cấp hàm mũ. Trong các ví dụ sau tài liệu chỉ đưa ra các cách giải phương trình truy hồi (hệ thức truy hồi, công thức truy hồi) từ đó người đọc có thể dễ dàng vận dụng để xác
định độ phức tạp tính toán của giải thuật tương ứng. Ví dụ 1.12. Tìm nghiệm của công thức truy hồi an = an-1 + 2an-2, với a0 = 2, a1 = 7 Giải: Phương trình đặc trưng của công thức truy hồi này có dạng r2 - r -2 = 0.
Nghiệm của nó là r=2 và r=-1. Theo định lý 1 dãy {an} là nghiệm của công thức
truy hồi khi và chỉ khi an = 12n + 2(-1)n với các hằng số a1 và a2 nào đó. Từ
các điều kiện đầu ta suy ra: a0 = 2 = 1 + 2 a1 = 7 = a12 + a2(-1) Giải ra ta được 1 = 3 và 2 = -1. Vậy nghiệm của công thức truy hồi với điều kiện
đầu là dãy {an} với an = 3.2n - (-1)n 25 Trong trường hợp phương trình đặc trưng của công thức truy hồi tuyến tính
thuần nhất bậc 2 có nghiệm đặc trưng là nghiệm bội (chỉ có một nghiệm r0) khi đó
dãy số { an} là nghiệm của công thức truy hồi an = c1an-1 + c2an-2 khi và chỉ khi n + 2n r0 n với n= 0, 1, 2,..trong đó 1, 2 là các hằng số. an = 1r0 Ví dụ 1.13. Tìm nghiệm của hệ thức truy hồi an = 6an-1- 9an-2 với các điều kiện ban đầu a0= 1 và a1 =6. Giải: Phương trình đặc trưng r2- 6r+ 9 = 0 có nghiệm kép r = 3. Do đó nghiệm của hệ thức truy hồi có dạng: an = 1 3n + 2n3n. Từ điều kiện đầu a0= 1 và a1 =6 suy ra 1 = 1 và 2 = 1. Do vậy nghiệm của hệ thức truy hồi và các điều kiện ban đầu đã cho là: an = 3n + n3n Tổng quát hóa kết quả trên cho trường hợp hệ thức truy hồi tuyến tính thuần n nhất hệ số hằng bậc k > 2. Giả sử phương trình đặc trưng rk- c1rk-1- c2rk-2-...- ck = 0
có k nghiệm phân biệt r1, r2,..., rk. Khi đó dãy số {an} là nghiệm của hệ thức truy
hồi an = c1an-1 + c2an-2+...+ ckan-k khi và chỉ khi n + 2r2 n+...+ krk an = 1r1 với n= 0, 1, 2,... trong đó 1, 2 ,.., k là các hằng số. Ví dụ 1.14. Tìm nghiệm của hệ thức an = 6an-1- 11an-2 + 6an-3 với điều kiện đầu a0 = 2, a1 = 5, a2 = 15. Giải: Phương trình đặc trưng r3- 6r2 + 11r- 6 = 0 có 3 ngiệm r1 = 1, r2 = 2, r3 = 3. Vì vậy, nghiệm có dạng an = 11n + 22n+33n. Sử dụng các điều kiện đầu ta có 1=1, 2 =-1, 3 = 2. Vậy nghiệm của hệ thức đã cho là an = 1- 2n+2.3n. * Phương trình truy hồi có dạng: (1) (1 T(n) = 1 nếu n =1
aT(n/b)+d(n) nếu n >1 Để xác định độ phức tạp tính toán của thuật toán theo phương trình truy hồi trên ta thực hiên như sau: 26 Chia bài toán kích thước n thành a bài toán con mỗi bài toán con có kích
thước n/b. Giải các bài toán con này và tổng hợp các kết quả ta được lời giải của bài toán ban đầu. Với các bài toán con ta cũng áp dụng kỹ thuật này thì đến một lúc bài
toán con sẽ có kích thước là 1. Kỹ thuật này sẽ dẫn ta đến một giải thuật đệ quy. Gọi thời gian để giải quyết bài toán đã cho kích thước n là T(n), thời gian để giải quyết bài toán con kích thước n/b là T(n/b), thời gian để giải quyết bài toán con kích thước 1 là 1, thời gian để phân tích bài toán thành các bài toán con kích thước n/b và tổng hợp kết quả là d(n) thì ta sẽ có phương trình (1). Với phương trình (1) khi n>1 ta có: T(n) = aT( ) + d(n) = a(aT( ) + d( )) + d(n) = a2T( ) + d(n) + ad( ) = a2(aT( ) + d( ))+ d(n) + ad( ) = a3T( ) + d(n) + ad( )+ a2d( ) .... =aiT( ) + d(n) + ad( )+ a2d( ) +...+ai-1d( ) = aiT( ) + Giả sử n = bk khi đó ta sẽ có: T(n) = akT( ) + = akT(1) + = ak + = ak + (2) Trong (1) hàm d(n) được gọi là hàm tiến triển. 27 Trong (2) hàm ak được gọi là nghiệm thuần nhất. Nghiệm thuần nhất biểu diễn thời gian để giải các bài toán con.Ta có: bk = n k = logbn Do đó ak = = = Trong (2) hàm được gọi là nghiệm riêng. Nghiệm riêng biểu diễn thời gian để tạo các bài toán con và tổng hợp kết quả. Dễ thấy rằng nghiệm riêng phụ thuộc vào hàm tiến triển, số lượng và kích thước các bài toán con. Theo qui tắc tổng trong (2) ta nhận thấy trong hai nghiệm: nghiệm riêng và nghiệm thuần nhất nghiệm nào lớn hơn thì đó là nghiệm của phương trình truy hồi. Việc xác định nghiệm riêng nói chung là phức tạp, ở đây ta chỉ quan tâm đến một
lớp các phương trình dạng (1) mà ở đó hàm tiến triển có những dạng mà từ đó ta có thể dễ dàng tìm được nghiệm riêng. Hàm nhân: Một hàm f(n) được gọi là hàm nhân nếu với mọi n, m nguyên dương ta đều có f(n.m)=f(n).f(m) Ví dụ 1.15. Hàm f(n) = nk là một hàm nhân vì: f(n.m) = (nm)k = nk.mk = f(n).f(m) + Tìm nghiệm của (1) trong trường hợp d(n) là hàm nhân. Nếu d(n) là hàm nhân thì khi đó ta có: = = Như vậy nghiệm riêng của (2) sẽ là: (3) Khi đó xảy ra các trường hợp sau: 1- Nếu a > d(b) thì trong (3) ta có ak > dk(b), theo quy tắc tổng độ phức tạp của nghiệm riêng là O(ak) bằng độ phức tập của nghiệm thuần nhất và đều bằng O( ), tức la T(n) =O( ) 28 Ví dụ 1.16. Giải phương trình truy hồi sau: T(n) = 1 nếu n =1
4T(n/2)+ n nếu n >1 Giải: Phương trình có dạng (1) với a=4, b=2 và d(n) = n. Vì d(n) = n nên dễ thấy rằng d(n) là hàm nhân và d(b) = d(2) =2< a. Vậy T(n) = O( ) = O( ) = O(n2) 2- Nếu a < d(b) thì ak < dk(b) thì theo quy tắc tổng độ phức tạp nghiệm riêng là O(dk(b)) lớn hơn của nghiệm thuần nhất là O(ak). Do đó: T(n) = O(dk(b)) Ta có: Vậy T(n) =O(n ) Ví dụ 1.17. Giải phương trình truy hồi sau: T(n) = 1 nếu n =1
4T(n/2)+ n3 nếu n >1 Giải: Phương trình có dạng (1) với a=4, b=2 và d(n) = n3. Vì d(n) = n3 nên d(n) là hàm nhân và d(b) = d(2) =23 > a. Vậy T(n) = O( ) = O(n3) 3- Nếu a = d(b) thì (3) không xác định, trong trường hợp này ta phải tính trực tiếp nghiệm riêng. Ta có: Do bk = n k = logbn và ak = = = Nên nghiệm riêng sẽ là: logbn Nghiệm riêng lớn hơn nghiệm thuần nhất ( ) logbn > Vậy ta có: T(n) = O( logbn) Ví dụ 1.18. 29 Giải phương trình truy hồi sau: T(n) = 1 nếu n =1
2T(n/2)+ n nếu n >1 Giải: Phương trình có dạng (1) với a=2, b=2 và d(n) = n. Vì d(n) = n nên d(n) là hàm nhân và d(b) = d(2) =2 = a. n) n) = O(nlog2 Vậy T(n) = O( log2 + Tìm nghiệm của (1) trong trường hợp d(n) không phải là một hàm nhân.
Trong trường hợp này ta phải tính trực tiếp nghiệm riêng rồi lấy nghiệm lớn hơn trong hai nghiệm: nghiệm riêng và nghiệm thuần nhất làm nghiệm của (1). Ví dụ 1.19. Giải phương trình truy hồi sau: T(n) = 1 nếu n =1
2T(n/2)+ nlog2n nếu n >1 Giải: Phương trình có dạng (1) với a=2, b=2 và d(n) = nlog2n. Dễ thấy rằng d(n) không phải là hàm nhân nên ta trực tiếp tìm nghiệm riêng như sau: 2n) ( vì k = logbn = log2n) Ta có O(2k ) = O(2kk2) = O(nlog2 Nghiệm thuần nhất 2k = n 2n) Nghiệm riêng lớn hơn nghiệm thuần nhất nên ta có: T(n) = O(nlog2 30 1. Xác định độ phức tạp tính toán của giải thuật sau: sapxep1(a,n) { for(i=1;i<=n-1;i++) { m=i; for(j=i+1;j<=n;J++) if(a[j]
if(m!=i) { tg=a[i]; a[i]=a[m]; a[m]=tg; } } } 2. Xác định độ phức tạp tính toán của giải thuật sau: Tim_kiem(a,n,x) { i=1; while ((i<=n)&&(a[i]!=x)) i++; if(i>n) return(0) else return(i); } 3. Xác định độ phức tạp tính toán của đoạn chương trình sau: for(i=1;i<=n;i++) for(j=1;j<=n;j++) { c[i,j] := 0; 31 for(k=1;k<=n;k++) c[i,j] := c[i,j] + a[i,k] * b[k,j]; } 4. Xác định độ phức tạp tính toán của giải thuật sau: sap_xep(a,n) { for(i=2;i<=n;i++) { x=a[i]; j=i-1; while((j>=1)&&(x { a[j+1]=a[j]; j=j-1; } a[j+1]=x; } } 5. Xác định độ phức tạp tính toán của giải thuật sắp xếp kiểu vun đống Hướng dẫn: Dãy khoá K gồm n khoá. Để sắp xếp dãy khoá theo thứ tự tăng dần theo kiểu vun đống ta xây dựng các hàm ADJUST và HEAP_SORT. Hàm ADJUST(i,n) thực hiện việc chỉnh lý một cây nhị phân hoàn chỉnh với gốc i để cây trở thành đống. Cây con trái và cây con phải của i, tức là cây với gốc là 2i và 2i+1 đã thoả mãn điều kiện của đống. Không có nút nào ứng với chỉ số lớn
hơn n. ADJUST được gọi trong HEAP_SORT. ADJUST(i,n) { // Khởi đầu key=k[i]; j=2*i; 32 // Chọn con ứng với khoá lớn nhất trong hai con của i while(j<=n) { if((j j=j+1; // So sánh khoá cha với khoá con lớn nhất if (key>k[j]) { k[[j/2]]=key; return; } k[[j/2]]=k[j]; j=2*j; } k[[j/2]]=key; return; } Ta coi phép toán tích cực là phép so sánh (j là O(1). Thuật toán trên chỉ duyệt trên một nhánh (nhánh có khoá lớn hơn) của cây nhị phân. Do vậy sau mỗi lần lặp thì số nút chỉ còn lại một nửa. Nếu số nút lúc đầu
là n thì trong trường hợp xấu nhất thì lệnh lặp while phải thực hiện i lần sao cho 2i =
n tức là i = log2n. Mỗi lần lặp phép toán tích cực được thực hiện một lần, do đó độ
phức tạp tính toán của thuật toán là O(log2n). HEAP_SORT(k,n) { //Tạo đống ban đầu for(i=[n/2];i>=1;i--) ADJUST(i,n); // Sắp xếp for(i=n-1;i>=1;i--) { x=k[1]; k[1]=k[i+1]; k[i+1]=x; 33 } } Trong thuật toán này vòng lặp for(i=[n/2];i>=1;i--) thực hiện lặp n/2 lần, mỗi lần
lặp gọi ADJUST(i,n). Do vậy độ phức tạp tính toán của HEAP_SORT là O(nlog2n). 6. Giải các phương trình truy hồi sau: a) T(n) = 1 nếu n =1
3T(n/2)+ n nếu n >1 b) T(n) = 1 nếu n =1
3T(n/2)+ n2 nếu n >1 T(n) = c) 1 nếu n =1
8T(n/2)+ n3 nếu n >1 7. Giải các phương trình truy hồi sau: a) T(1) = 2 và T(n) = 2T(n-1) + 1 với n > 1 b) T(1) = 3, T(2)=6 và T(n) = T(n-1) + 6T(n-2) với n > 2 8. Cho tổng với n nguyên dương Viết một giải thuật không đệ quy và một giải thuật đệ quy tính tổng trên và đánh giá độ phức tạp tính toán của các giải thuật đó. Hướng dẫn: Thuật toán đệ quy: tong(n) { if(n==1) return(1); else return(tong(n-1)+1/n); } Đánh giá độ phức tạp: - Xây dựng phương trình truy hồi c1 nếu n=1 T(n)= T(n-1) + c2 nếu n>1 - Giải phương trình truy hồi T(n) = T(n-1) + c2 = T(n-2) + 2c2 34 .... = T(1) + (n-1)c2 - Độ phức tạp tính toán của giải thuật: T(n) =O(n). 9. Cho dãy a gồm n số nguyên đã được sắp xếp theo thứ tự tăng dần, các phần tử của dãy được đánh chỉ số từ 1 đến n. Xác định độ phức tạp tính toán của các giải thuật sau: a. Giải thuật tìm kiếm nhị phân TKNP(a,n,x) { l=1; r=n; while(l<=r) { m=[(l+r)/2]; if(x
else if(x>a[m]) l=m+1; else return(m) return(0); } b. Giải thuật tìm kiếm nhị phân đệ quy TKNP(l,r,a,x) { if(l>r) loc=0; else { m=[(l+r)/2]; if(x
else if(x>a[m]) loc=TKNP(m+1,r,a,x) else loc=m; return(loc); 35 } Hướng dẫn ý b. - Xây dựng phương trình truy hồi: c1 nếu n=1 T(n)= T( )+c2 nếu n>1 Với n>1 ta có: T(n) = T( )+ c2 = T( )+ 2c2 = T( )+ 3c2 .... = T( )+ ic2 Dừng khi =1 n=2i i=log2n Do đó T(n) = T(1) + c2log2n = c1 + c2log2n = O(log2n) 36 2.1. Néi dung kü thuËt Có thể nói rằng kĩ thuật quan trọng nhất, được áp dụng rộng rãi nhất để thiết kế các thuật toán có hiệu quả là kĩ thuật "chia để trị". Nội dung của nó là: Ðể giải một bài toán kích thước n, ta chia bài toán đã cho thành một số bài toán con có kích thưóc nhỏ hơn. Giải các bài toán con này rồi tổng hợp kết quả lại để được lời giải của bài toán ban đầu. Ðối với các bài toán con, chúng ta lại sử dụng kĩ thuật chia để trị để có được các bài toán kích thước nhỏ hơn
nữa. Quá trình trên sẽ dẫn đến những bài toán mà lời giải chúng là hiển nhiên hoặc dễ dàng thực hiện, ta gọi các bài toán này là bài toán cơ sở. Tóm lại kĩ thuật chia để trị bao gồm hai quá trình: Phân tích bài toán đã cho
thành các bài toán cơ sở và tổng hợp kết quả từ bài toán cơ sở để có lời giải của bài toán ban đầu. Tuy nhiên đối với một số bài toán, thì quá trình phân tích đã chứa đựng việc tổng hợp kết quả do đó nếu chúng ta đã giải xong các bài toán cơ sở thì bài toán ban đầu cũng đã được giải quyết. Ngược lại có những bài toán mà quá trình phân tích thì đơn giản nhưng việc tổng hợp kết quả lại rất khó khăn. Kĩ thuật này sẽ cho chúng ta một thuật toán đệ quy mà việc xác định độ phức tạp của nó sẽ phải giải một phương trình đệ quy như trong chương 1 đã trình bày. Sơ đồ chia-để-trị gồm ba bước ở mỗi mức của đệ quy: - Chia bài toán thành một số bài toán con. - Trị các bài toán con bằng cách giải chúng đệ quy. Nếu kích thước các bài toán con là đủ nhỏ (bài toán cơ sở), giải trực tiếp các bài toán này. - Kết hợp các lời giải của các bài toán con thành lời giải cho bài toán ban đầu. Phần tiếp sau trình bày một số ví dụ áp dụng kỹ thuật chia để trị để thiết kế thuật toán cùng với sự đánh giá độ phức tạp tính toán của thuật toán. 2.2. C¸c vÝ dô ¸p dông 2.2.1. T×m min vµ max 1) Bài toán Cho mảng a gồm n phần tử: a[1..n]. Tìm phần tử nhỏ nhất (min) và phần tử lớn nhất (max) trong dãy. 2) Thiết kế thuật toán 37 Tại mỗi bước chia đôi đoạn cần tìm rồi tìm min, max của từng đoạn, sau đó tổng hợp kết quả. Nếu đoạn chỉ có một phần tử thì min = max và bằng phần tử đó. Hàm MinMax(a,l,r,Min,Max) cho Min và Max trong đoạn a[l..r]. Thuật toán Input : a[1..n] Output : Min = Min (a[1],..,a[n]), Max = Max (a[1],..,a[n]). MinMax(a,l, r, Min, Max) if (l == r) { Min = a[l]; Max = a[l]; } Else { MinMax(a,l, (l+r)/ 2, Min1, Max1); MinMax(a,(l+r)/2 + 1, r , Min2, Max2); If (Min1 < Min2) Min = Min1; Else Min = Min2; If (Max1 > Max2) Max = Max1 Else Max = Max2; } 3) Đánh giá độ phức tạp tính toán của thuật toán - Với n = 1, chỉ cần thực hiện 2 lệnh, do đó T(1) = O(1). - Với n > 1 cần thực hiện lệnh các lệnh: MinMax(a,l, (l+r) / 2, Min1, Max1); MinMax(a,(l+r) /2 + 1, r , Min2, Max2); If (Min1 < Min2) 38 Min = Min1; Else Min = Min2; If (Max1 > Max2) Max = Max1 Else Max = Max2; Ở đây có 2 lời gọi đệ quy với thời gian là T( ). Khi kết thúc lời gọi đệ quy thì thực hiện phép so sánh và phép gán với thời gian là O(1). Tóm lại, ta có quan hệ sau: T(1) = O(1) T(n) = O(1) + 2T( ) Thay các O(1) bởi các hằng nào đó, ta nhận được phương trình đệ quy: C1 nếu n=1 T(n)= 2T( )+C2 nếu >1 Với n>1 ta có: T(n) = 2T( ) + C2 = 2(2T( ) + C2) + C2 = 4T( ) + 3C2 .... = 2iT( ) + (2i - 1)C2 Quá trình dừng khi =1 n = 2i i = log2n Khi đó: 39 T(n) = nT(1) +(2log2n -1)C2 = C1n + C2(2log2n -1) Vậy T(n) = O(n) 2.2.2. Một số thuật toán sắp xếp Bài toán: Cho dãy số a có n phần a1, a2, ..., an . Hãy sắp xếp dãy theo thứ tự tăng dần. 1) S¾p xÕp nhanh (Quicksort) * Ý tưởng: Chọn x là một phần tử làm biên (thường chọn là phần tử ở giữa dãy số). Phân hoạch dãy thành 3 dãy con 1. ak < x , với k = 1..i 2. ak = x , với k = i+1..j-1 3. ak > x , với k = j..n ak Hình 2.1. Phân hoạch dãy a thành 3 dãy con Nếu số phần tử trong dãy con 1, dãy con 3 lớn hơn 1 thì ta tiếp tục phân hoạch theo phương pháp trên. Ngược lại thì dừng. * Thiết kế thuật toán Phân hoạch dãy am, am+1, ., an thành 2 dãy con: Bước 1 : Chọn tùy ý một phần tử a[k] trong dãy là giá trị biên, m<= k <=n: x = a[k]; i = m; j = n; Bước 2 : Phát hiện và hiệu chỉnh cặp phần tử a[i], a[j] nằm sai vị trí: Bước 2a : Trong khi (a[i] Bước 2b : Trong khi (a[j]>x) j--; Bước 2c : Nếu i<= j // a[i]>= x; a[j]<=x mà a[j] đứng sau a[i] Hoán vị (a[i],a[j]); i++; j--; 40 Bước 3 : Nếu i < j: Lặp lại Bước 2.//chưa xét hết mảng Ngược lại: Dừng Có thể phát biểu thuật toán một cách đệ qui như sau : Bước 1 : Phân hoạch dãy am … an thành các dãy con : - Dãy con 1 : am.. aj < x - Dãy con 2 : aj+1.. ai-1 = x - Dãy con 3 : ai.. an > x Bước 2 : Nếu ( m < j ) // dãy con 1 có nhiều hơn 1 phần tử Phân hoạch dãy am.. aj Nếu ( i < n ) // dãy con 3 có nhiều hơn 1 phần tử
Phân hoạch dãy ai.. an Chẳng hạn cho dãy số a: 12 2 8 5 1 6 4 15 Phân hoạch đoạn l=1, r = 8: x = A[4] =5 Phân hoạch đoạn l =1, r = 3: x = A[2] = 2 41 Phân hoạch đoạn l = 5, r = 8: x = A[6] = 6 Phân hoạch đoạn l = 7, r = 8: x = A[7] = 6 Hình 2.2. Sắp xếp dãy a theo thứ tự tăng dần Quich-Sort(a,l,r) { i = l; j = r; x = a[(l+r)/2]; // Chọn phần tử giữa while (i <= j) { while (a[i] < x ) i++; while (a[j] > x) j--; if (i <= j) { tg = a[i]; a[i]=a[j] a[j]=tg; i++; 42 j-- } } if (l < j) QS(a,l,j); if (r > i ) QS(a,i,r); * Đánh giá độ phức tạp tính toán của thuật toán Gọi T(n) là thời gian thực hiện thuật toán, p(n) là thời gian để phân một dãy n phần tử thành hai dãy con thì: T(n) = p(n) + T(J-l) + T(r-i) = Cn + T(J-l) + T(r-i) (C là hằng số) Trường hợp xấu nhất xảy ra khi phần tử được chọn luôn là lớn nhất hoặc nhỏ nhất: sau khi phân hoạch một trong hai dãy con chỉ có một phần tử, dãy kia gồm n-1 phần tử (khi j = l hoặc r = i) Giả sử j = l thì ta có: T(n) = Cn + T(0) + T(n-1) = Cn + T(n-1) = Cn + C(n-1) + T(n-2) .... = Cn + C(n-1)+ ... +C + T(0) = C Vậy T(n) = O(n2) Trường hợp tốt nhất xảy ra khi dãy luôn được chia đôi. Khi đó ta có phương trình truy hồi: C nếu n=1 T(n)= 2T( ) + n nếu n>1 Trong đó: 2T( ) là thời gian sắp xếp hai dãy con n là thời gian kiểm tra mỗi phần tử 43 Ta có với n>1 thì: T(n) = 2T( ) + n = 2(2T( )+ ) + n = 4T( ) +2 n = 4(2T( )+ ) + 2n = 8T( ) +3n = 2iT( ) +i.n Dừng khi =1 n= 2i i= log2n Và khi đó ta có: T(n) = nT(1) + nlog2n = Cn + nlog2n = O(nlog2n) Trường hợp trung bình người ta đã chứng minh được rằng: T(n)= O(nlog2n) 2) Sắp xếp trộn * Ý tưởng Thuật toán sắp xếp kiểu trộn hai đường trực tiếp được thực hiện theo nhiều bước lặp. Mỗi bước lặp bao gồm hai giai đoạn : Giai đoạn 1: Phân bố luân phiên từng p phần tử từ dãy a vào các dãy trung gian b và c
trong khi chưa hết dãy a. Giai đoạn 2: Trộn từng bộ p phần tử trong dãy b với p phần tử trong c, kết quả trộn được đưa vào a, trong khi chưa hết dãy b và chưa hết dãy c. Các bước lặp còn được thực hiện trong khi p còn ≤ n . Bước đầu tiên p được khởi động bằng 1. 44 Mỗi bước lặp sau( sau một lần phân bố và trộn ), số phần tử p sẽ khởi động
lại là : p = p * 2 . Ví dụ 2.1. Giả sử a là dãy sau: 11 34 20 8 65 25 11 45 21 70 15 20 8 Đầu tiên ta phân bố luân phiên từng phần tử của dãy a vào các dãy trung gian b và c (p=1). + Lần phân bố đầu tiên (p=1) Dãy b: 11 20 65 11 21 15 8 Dãy c: 34 8 25 45 70 20 Thực hiện trộn hai đường từng phần tử của b với từng phần tử của c, kết quả đưa
vào dãy a. 11, 34 8, 20 25, 65 11, 45 21, 70 15, 20 8 + Lần phân bố thứ 2 (p=2) Dãy b: 11, 34 25, 65 21, 70 8 Dãy c: 8, 20 11, 45 15, 20 Thực hiện trộn hai đường p phần tử của b với p phần tử của c, kết quả đưa vào dãy
a. 8, 11, 20, 34 11, 25, 45, 65 15, 20, 21, 70 8 + Lần phân bố thứ 3 (p=4) Dãy b: 8, 11, 20, 34 15, 20, 21, 70 45 Dãy c: 11, 25, 45, 65 8 Thực hiện trộn hai đường p phần tử của b với p phần tử của c, kết quả đưa vào dãy a. 8, 11, 11, 20, 25, 34, 45, 65 8, 15, 20, 21, 70 + Lần phân bố thứ 4 (p=8) Dãy b: 8, 11, 11, 20, 25, 34, 45, 65 Dãy c: 8, 15, 20, 21, 70 Thực hiện trộn hai đường p phần tử của b với p phần tử của c, kết quả đưa vào dãy a. 8, 8, 11, 11, 15, 20, 20, 21, 25, 34, 45, 65, 70 Hình 2.3. Sắp xếp dãy a theo thứ tự tăng dần Lúc này a đã được sắp thứ tự. * Thiết kế thuật toán input a[1..n]; // dãy cần sắp Output a đã được sắp Mô tả: p = 1; Trong khi (p <= n) ta thực hiện : { - Trong khi chưa hết dãy a thì phân bố luân phiên từng p phần tử từ dãy a vào
các dãy trung gian b và c - Trong khi chưa hết dãy b và chưa hết dãy c thì trộn từng cặp p phần tử trong
dãy b với p phần tử trong dãy c, kết quả ghi vào a. - p = 2p } 46 Mô tả trên có thể viết thành hàm : mergesort() { p = 1; while ( p <= n ) { distribution (p); merge(p); p = p * 2; } } Như vậy, thuật toán chủ yếu được xây dựng trên 2 công việc : - distribution(p) : Phân bố luân phiên p phần tử từ dãy a vào các dãy trung gian b, c trong khi chưa hết dãy a. - merge(p) : Trộn từng cặp p phần tử trong b, và p phần tử trong c, kết quả lưu trử vào a, trong khi chưa hết dãy b và chưa hết dãy c. Với công việc thứ nhất: Phân bố luân phiên p phần tử từ dãy a vào các dãy trung gian b, c cho đến hết dãy a. Input a; Output b, c; Mô tả Thực hiện { Đọc từng p phần tử trong a và chép luân phiên vào b, c; } Trong khi (chưa hết dãy a); Trong mô tả trên, có 2 thao tác con cần phải lưu ý : Thao tác 1: Làm thế nào để xử lý một cách tự động việc chép luân phiên vào b và c. Ta thực hiện bằng cách : Dùng một khoá k, với k {1,2} và quy định : 47 Nếu k = 1 thì chép vào b; Nếu k = 2 thì chép vào c; Giả sử đầu tiên cho k = 1 để quyết định chép p phần tử của a vào b trước. Sau mỗi lần chép xong p phần tử, ta chỉ cần khởi động lại giá trị k = 3-k . Thao tác 2: Đọc p phần tử của a chép vào b như thế nào ? Ta đọc từng phần tử của a và
chép phần tử đó vào b; Việc đọc chép từng phần tử này còn được thực hiện trong
khi chưa đủ p phần tử và chưa hết dãy a. Vậy thao tác phân bố có thể mô tả chi tiết như sau : do { i = 1; while ( (i <= p) && (chưa hết dãy a) ) { Đọc phần tử thứ i trong a; if ( k == 1) chép vào b; else chép vào c; i++; } k = 3-k; } while(chưa hết dãy F); Thao tác phân bố cài đặt thành một hàm như sau : //F, F1, F2, n, h1,h2 là các biến toàn cục. distribute(p) { int i, k=1, l = 1; h1 = 0; h2 = 0; do { 48 i = 1; while( i<=p && l <= n) { if(k==1) { h1++; F1[h1] = F[l]; } else { h2++; F2[h2] = F[l]; } i++; l++; } k = 3-k; } while(l <= n); } Với công việc thứ hai: Trộn từng bộ p phần tử trong c, và p phần tử trong c, kết quả lưu tr? vào a, trong khi chưa hết b và c. Input b, c; Output a; Mô tả : Trong khi ( chưa hết dãy b và chưa hết dãy c ) { Trộn từng cặp p phần tử của b và của c vào a; } Trong khi (chưa hết dãy c) 49 chép các phần tử còn lại của c vào a; Trong khi (chưa hết dãy b) chép các phần tử còn lại của b vào a; Ta cần chỉ rõ công việc trộn từng cặp p phần tử của b và của c vào a hoạt động như thế nào ? Đó là : - (*) : Đọc từng phần tử trong b, trong c, so sánh giá trị của chúng, giá trị nào nhỏ hơn thì chép phần tử tương ứng vào a. Nếu là phần tử của b thì đọc tiếp 1 phần tử của b; ngược lại thì đọc tiếp 1 phần tử của c - ( ** ) :Thao tác trên còn được thực hiện trong khi : chưa đọc đủ p phần tử trong b và chưa đọc đủ p phần tử trong c và chưa hết dãy c và chưa hết dãy b; Vòng lặp (**) dừng khi đã đọc đủ p phần tử trong c, hoặc đã đọc đủ p phần tử trong b, hoặc hết dãy b hoặc hết dãy c; Và khi đó ta cần xử lý các trường hợp sau : Trong trường hợp chưa hết dãy b và chưa hết dãy c : Nếu chưa đủ p phần tử trong b, thì đọc và chép các phần tử của b vào a cho đủ p. Tương tự như vậy cho c. * Đánh giá độ phức tạp tính toán của thuật toán Ta nhận xét rằng, trong phương pháp sắp xếp bằng trộn hai đường trực tiếp, số lượng các bước sao chép các phần tử từ dãy này sang dãy kia còn lớn hơn số lượng các bước so sánh giữa các phần tử : Vì ứng vớùi một lần so sánh thì có một thao tác sao chép, nhưng nếu một dãy nào đó xử lý cạn (hết dãy) thì phần đuôi của dãy còn lại được sao chép mà không ứng với một phép so sánh nào. Vì thế, đối với phương pháp này, ta chọn phép sao chép làm căn cứ đánh giá thời gian thực hiện của thuật toán. Trong mỗi lần phân bố và trộn thì toàn bộ n phần tử được duyệt qua, so sánh và chép vào dãy đích (output). Như vậy thời gian chi phí cho mỗi bước có cấp là
O(n). Vì trong mỗi bước (bước thứ k) ta giải quyết được 2k = p giá trị và tiến trình
dừng khi p ≥ n nên ta có lg2n bước, do đó cấp thời gian chi phí cho phương pháp
này là O(nlg2n). Một nhượïc điểm của phương pháp sắp xếp bằng kiểu trộn hai đường trực
tiếp là chi phí cho không gian quá lớn: nó đòi hỏi cung cấp vùng nhớ 2n phần tử,
gấp đôi so với phương pháp thông thường. Do đó phương pháp này chỉ thích hợp khi ta thao tác trên các tệp. 50 Mặt khác, phương pháp sắp xếp kiểu trộn hai đường trực tiếp có một nhược
diểm quan trọng nữa là nó tự giới hạn số lượng các giá trị cố định là 1, 2, 4,.., 2k, trong đó 2k < n. Như vậy ta luôn luôn phải duyệt qua k bước chia và trộn. Nếu cho
phép số lượng các phần tử trong một lần trộn có kích thước khác thì số các bước có thể giảm đi và trong trường hợp này việc sắp xếp có khả năng kết thúc sớm. 2.2.3. T×m kiÕm nhÞ ph©n 1) Bài toán Cho dãy a gồm n phần tử đã được sắp tăng dần và một phần tử x. Tìm x có
trong a hay không? Nếu có x trong a thì cho kết quả là chỉ số của phần tử đó, ngược lại cho kết quả 0. 2) Ý tƣởng Chia đôi dãy, mỗi lần chia đôi so sánh phần tử giữa x với nếu x bằng phần tử
giữa thì dừng, ngược lại nếu x nhỏ hơn phần tử giữa thì lấy nửa trái, ngược lại thì lấy nửa phải 3) Thiết kế thuật toán + l là chỉ số dưới của dãy a + r là chỉ số dưới của dãy a + m là chỉ số giữa. Mô tả thuật toán: - Nếu l > r kết thúc và trả lại giá trị 0 (không tìm thấy) - Nếu l<=r thì: + tính m=[(l+r)/2]; + So sánh x và a[m]: Nếu x= a[m]: kết thúc và trả lại m ngược lại nếu x
ngược lại tìm kiếm (đệ quy) ở nửa phải- từ a[m+1] đến a[r] Thuật toán: KNP(l,r,a,x) { if(l>r) loc=0; 51 else { m=[(l+r)/2]; if(x
else if(x>a[m]) loc=TKNP(m+1,r,a,x) else loc=m; return(loc); } 4) Đánh giá độ phức tạp tính toán của thuật toán - Xây dựng phương trình truy hồi: c1 nếu n=1 T(n)= T( )+c2 nếu n>1 Với n>1 ta có: T(n) = T( )+ c2 = T( )+ 2c2 = T( )+ 3c2 .... = T( )+ ic2 Dừng khi =1 n=2i i=log2n Do đó T(n) = T(1) + c2log2n = c1 + c2log2n 52 = O(log2n) 2.2.4. Nh©n c¸c sè nguyªn lín. 1) Bài toán Cho hai số nguyên X và Y, mỗi số gồm n chữ số. Tính tích của hai số nguyên đó. 2) Ý tƣởng Biểu diễn X và Y dưới dạng X = A10n/2 + B và Y = C10n/2 + D Trong đó A, B, C, D là các số có n/2 chữ số. Chẳng hạn với X = 1234 thì A = 12 và B = 34 vì 12*102 + 34 = 1234 = X Với cách biểu diễn này thì XY = AC10n + (AD + BC)10n/2 + BD (*) Thay vì nhân trực tiếp 2 số có n chữ số, ta phân tích bài toán ban đầu thành một số bài toán nhân 2 số có n/2 chữ số. Sau đó, ta kết hợp các kết quả trung gian theo công thức (*). Việc phân chia này sẽ dẫn đến các bài toán nhân 2 số có 1 chữ số. Đây là bài toán cơ sở. Tóm lại: - Kích thước bài toán: số chữ số của X, Y - Phân tích: Chia bài toán ban đầu thành các bài toán nhân các số có n/2 chữ số. Quá trình phân chia dừng lại khi kích thước bài toán bằng 1. - Tổng hợp: Tổng hợp kết quả theo công thức (*). Chú ý: Với những số có một số lẻ chữ số ta thêm số 0 vào đầu để được số có một số chẵn chữ số. 3) Thiết kế thuật toán Theo công thức (*) ở trên việc nhân 2 số nguyên có n chữ số được phân chia
thành 4 phép nhân các số nguyên có n/2 chữ số, 3 phép cộng và 2 phép nhân với
10n và 10n/2. Phép cộng các số cần O(n). Phép nhân với 10n tốn O(n). Gọi T(n) là
thời gian nhân 2 số có n chữ số ta có phương trình truy hồi sau: C1 nếu n =1 T(n)= 4T( ) + C2n nếu n >1 Khi đó độ phức tạp tính toán sẽ được xác định như sau: Ta có với n>1: 53 T(n) = 4T( )+c2n = 4(4T( )+c2 ) + c2n = 16T( ) + 3c2n = 24T( ) + 3c2n = 16( 4T( )+c2 ) + 3c2n = 64T( ) + 7c2n = 26T( ) + 7c2n ..... = 22iT( ) + (2i - 1)c2n Quá trình sẽ dừng khi =1 n =2i i = log2n Khi đó T(n) = = C1n2 + C2(n-1)n
Và T(n) = O(n2) Như vậy ta không cải tiến được gì với giải thuật nhân thông thường. Ta biến đổi công thức (*) lại như sau: XY = AC10n + [(A -B)(D-C) + AC + BD]10n/2 + BD (**) Theo công thức này, ta chỉ cần 3 phép nhân các số nguyên có n/2 chữ số, 6 phép cộng trừ và 2 phép nhân với 10n, 10n/2. Ví dụ 2.2. 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 tách thành w = 09 và x = 81 1234 tách 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)( 102y + z) = 104wy + 102(wz + xy) + xz 54 = 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úng ta 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út gọn về ba phép nhân của hai số có
hai chữ số (09 với 12, 81 với 34 và 90 với 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ừ. Từ đó ta đưa ra thuật toán nhân số nguyên lớn là Nhan(x,y,n) { if (n == 1) Return x[0]*y[0]; Else { 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 = Nhan(a,c,n/2); 55 V = Nhan(b,d,n/2); W = Nhan(a+b,c+d,n/2); Return U*10n + (W-U-V)*10n/2 + V } } 4) Đánh giá độ phức tạp tính toán của thuật toán Ta có phương trình đệ quy sau: T(1) = 1 T(n) = 3T(n/2) + cn Giải phương trình ta được T(n) = O(nlog3) = O(n1.59). Rõ ràng cải tiến hơn
giải thuật cũ rất nhiều. 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á. 56 1. Hãy đưa ra hai thuật toán được thiết kế theo kỹ thuật chia để trị. 2. Dùng kỹ thuật chia để trị thiết kế thuật toán giải bài toán sau: Tính tổng: 1 + 1/2 + 1/3 + ... + 1/n với n là một số nguyên dương. 3. Dùng kỹ thuật chia để trị thiết kế thuật toán giải bài toán sau: Tìm ước số chung lớn nhất của hai số nguyên dương. 4. Bài toán tháp Hà nội Có n chiếc đĩa với đường kính khác nhau được đặt chồng lên nhau ở vị trí A, đĩa bé ở trên đĩa to. Cần chuyển chồng đĩa sang vị trí B được lấy vị trí C làm trung chuyển với điều kiện: Mỗi lần chỉ được chuyển một đĩa và trong quá trình chuyển
không bao giờ được đặt đĩa to ở trên đĩa bé. Hướng dẫn: Vận dụng kỹ thuật chia để trị: Bài toán chuyển N đĩa từ A sang B có thể chia thành các bài toán nhỏ hơn như sau: (a) Chuyển N-1 đĩa ở trên từ A sang C (b) Chuyển một đĩa từ A sang B (c) Chuyển N-1 đĩa từ C sang B. Như vậy bài toán đã được chuyển thành các bài toán tương tự với kích thước
nhỏ hơn. Công việc được tiếp tục với (n-1) đĩa và cứ như thế cho đến khi chỉ còn một đĩa. 5. Bài toán hoán đổi hai phần trong dãy Cho a[1..n] là một mảng gồm n phần tử. Ta cần chuyển m phần tử đầu tiên của mảng với phần còn lại của mảng (n-m phân tử) mà không dùng một mảng phụ . Chẳng hạn, với n = 8, a[8] = (1, 2, 3, 4, 5, 6, 7, 8) Nếu m = 3, thì kết quả là : ( 4, 5, 6, 7, 8, 1, 2, 3) Nếu m = 5, thì kết quả là : ( 6, 7, 8, 1, 2, 3, 4, 5) Nếu m = 4, thì kết quả là : ( 5, 6, 7, 8, 1, 2, 3, 4) Hướng dẫn: - Nếu m = n – m: Hoán đổi các phần tử của 2 nửa mảng có độ dài bằng nhau : 57 - Nếu m n–m + Nếu m < n – m : hoán đổi m phần tử đầu với m phân tử cuối của phần còn lại. Sau đó trong mảng a[1..n-m] ta chỉ cần hoán đổi m phần tử đầu với phần còn lại. + Nếu m > n – m : hoán đổi n-m phần tử đầu tiên với n-m phần tử của phần
sau. Sau đó trong mảng a[n-m+1 . . n] ta hoán đổi n-m phần tử cuối mảng với các phần tử của phần đầu. Như vậy, bằng cách áp dụng phương pháp chia để trị, ta chia bài toán thành 2 bài toán con : - Bài toán thứ nhất là hoán đổi hai mảng con có độ dài bằng nhau, cụ thể là
hoán đổi nửa số phần tử đầu và cuối của mảng cho nhau bằng cách đổi chỗ từng cặp phần tử tương ứng. - Bài toán thứ hai cùng dạng với bài toán đã cho nhưng kích thước nhỏ hơn, nên có thể gọi thuật toán đệ qui để giải và quá trình gọi đệ qui sẽ dừng khi đạt tới sự hoán đổi 2 phần có độ dài bằng nhau Vậy mấu chốt của thuật toán là thực hiện thao tác hoán đổi 2 nhóm phần tử,
lặp lại thao tác này trong khi số lượng phần tử của 2 nhóm còn khác nhau. Vì vậy ta sẽ thay thuật toán đệ qui bằng thuật toán lặp. // Hoán đổi m phần tử đầu của mảng với phần còn lại. Input : a[1..n], m. (m ≤n) Output : a[1..n] với tính chất m phần tử đầu mảng a (mảng nhập ) nằm cuối mảng kết quả, n-m phần tử cuối mảng nhập nằm ở đầu mảng kết quả. Mô tả thuật toán : Transpose(a,n,m) { i = m; j = n-m; m = m+1; Khi ( i != j) Nếu ( i > j) { Exchange(a,m-i,m,j); i = i – j; } Ngược lại { 58 j = j – i; Exchange(a,m-i,m+j,i); } Exchange(a,m-i,m,i); } Hàm exchange : input a, i,j, //vị trí m; // Số phần tử cần hoán đổi output a Exchange(a,i,j,m) { Với mọi p = 0 -> m-1 Đổichỗ (a[i+p], a[j+p]); } 6. Lập lịch thi đấu thể thao Có n = 2k đối thủ thi đấu với nhau theo thể thức vòng tròn một lượt: Mỗi đấu
thủ phải đấu với các đấu thủ khác 1 trận. Mỗi đấu thủ chỉ đấu nhiều nhất một trận mỗi ngày. Hãy xếp lịch thi đấu sao cho số ngày thi đấu là ít nhất. Hướng dẫn: Vì thể thức thi đấu là vòng tròn một lượt nên mỗi đấu thủ phải thi đấu đúng
n-1 trận. Ta dễ dàng thấy rằng vì n là một số chẵn nên ta có thể sắp nhiều nhất là n/2 cặp thi đấu trong một ngày và do đó cần ít nhất n-1 ngày. Ta sẽ tìm cách xây dựng lịch để số ngày thi đấu là n-1. Lịch thi đấu là một bảng n dòng và n-1 cột. Các dòng được đánh số từ 1 đến n và các cột được đánh số từ 1 đến n-1, trong đó dòng i biểu diễn cho đấu thủ i, cột j
biểu diễn cho ngày thi đấu j và ô(i, j) ghi đấu thủ phải thi đấu với đấu thủ i trong ngày j. Kỹ thuật chia để trị được áp dụng để xây dựng lịch thi đấu như sau: Ðể sắp
lịch cho n đấu thủ, ta sẽ sắp lịch cho n/2 đấu thủ, để sắp lịch cho n/2 đấu thủ, ta sẽ sắp lịch cho n/4 đấu thủ... Quá trình này sẽ dẫn đến bài toán cơ sở là sắp lịch thi đấu cho 2 đấu thủ. Hai đấu thủ này sẽ thi đấu một trận trong một ngày, lịch thi đấu cho họ thật dễ sắp. Khó khăn chính là ở chỗ từ các lịch thi đấu cho hai đấu thủ, ta tổng hợp lại để được lịch
59 thi đấu của 4 đấu thủ, 8 cấu thủ, ... Góc trên bên trái của bảng chính là lịch thi đấu của n/2 đấu thủ từ 1 đến n/2 trong các ngày từ ngày 1 đến ngày [(n-1)/2], từ đó ta có góc dưới bên trái là lịch thi đấu trong các ngày này của các đấu thủ từ n/2+1 đến n: nó sẽ bằng các phần tử ở
góc trên bên trái cộng thêm n/2. Góc dưới bên phải của bảng chính là góc trên bên trái và góc trên bên phải chính là góc dưới bên trái. Còn với cột [(n-1)/2] +1 thì n/2 đấu thủ đầu sẽ lần lượt đấu với n/2 đấu thủ sau và ngược lại. Chẳng hạn: Xuất phát từ lịch thi đấu cho hai đấu thủ ta có thể xây dựng lịch
thi đấu cho 4 đấu thủ như sau: Lịch thi đấu cho 4 đấu thủ sẽ là một bảng 4 dòng, 3 cột. Lịch thi đấu cho 2 đấu thủ 1 và 2 trong ngày thứ 1 chính là lịch thi đấu của hai đấu thủ (bài toán cơ sở). Như vậy ta có Ô(1,1) = “2” và Ô(2,1) = “1”. Tương tự ta có lịch thi đấu cho 2 đấu thủ 3 và 4 trong ngày thứ 1. Nghĩa là Ô(3,1) =“4” và
Ô(4,1) = “3”. (Ta cố thể thấy rằng Ô(3,1) = Ô(1,1) + 2 và Ô(4,1) = Ô(2,1) + 2 ). Ta lấy góc trên bên trái của bảng lắp vào cho góc dưới bên phải và lấy góc dưới bên
trái lắp cho góc trên bên phải. Bây giờ để hoàn thành lịch thi đấu cho 4 đấu thủ ta điền nốt cột 2. Lịch thi đấu cho 8 đấu thủ là một bảng gồm 8 dòng, 7 cột. Góc trên bên trái
chính là ch thi đấu trong 3 ngày đầu của 4 đấu thủ từ 1 đến 4. Các ô của góc dưới bên trái sẽ bằng các ô tương ứng của góc trên bên trái cộng với 4. Ðây chính là lịch thi đấu cho 4 đấu thủ 5, 6, 7 và 8 trong 3 ngày đầu. Bây giờ chúng ta hoàn thành việc sắp lịch bằng cách lấp đầy góc dưới bên phải bởi góc trên bên trái và góc trên bên phải bởi góc dưới bên trái và điền nốt cột 4. Dưới đây là các bảng xếp lịch thi đấu cho 2 đấu thủ, 4 đấu thủ và 8 đấu thủ theo cách xây dựng trên: 8 đấu thủ 4 đấu thủ 2 đấu thủ 60 7. Bài toán tìm cặp điểm gần nhất (láng giềng gần nhất) Trong mặt phẳng cho n điểm phân biệt. Hãy tìm hai điểm gần nhau nhất (khoảng cách Ơcolit là nhỏ nhất). Hướng dẫn: Xây dựng một thuật toán dựa trên kỹ thuật chia để trị với ý tưởng là: sắp xếp
các điểm theo một trục toạ độ, như trục x chẳng hạn, rồi dùng thứ tự này để chia tập điểm thành hai phần. Trong toàn bộ tập điểm đã cho, cặp điểm gần nhất hoặc là cặp gần nhất trong cùng một bên nào đó, hoặc là một cặp điểm cắt ngang đường thẳng phân giới giữa hai tập điểm thành phần. Dĩ nhiên, trường hợp đáng chú ý là khi cặp điểm gần nhất cắt ngang đường phân giới. Cặp điểm gần nhất trong mỗi nửa bên rõ ràng là tìm được bằng các lời gọi đệ quy, nhưng còn các cặp có mỗi điểm ở một bên đường phân giới sẽ được kiểm tra như thế nào? Điều tất nhiên là chúng ta sẽ chỉ cần xét những điểm trong khoảng cách min ở hai bên đường phân giới (vì đang tìm cặp điểm gần nhất), với min là khoảng cách nhỏ hơn giữa các cặp điểm gần nhất ở mỗi nửa bên. Tuy nhiên, trong trường hợp xấu nhất thì nhận xét này là không đủ, vì có thể có nhiều cặp gần với đường phân giới; ví dụ như tất cả các điểm ở mỗi nửa bên có thể sắp thành một hàng ngay cạnh
đường thẳng phân giới. Để xử lý tình huống trên, cần phải sắp các điểm theo y. Như vậy, chúng ra có thể giới hạn các khoảng cách phải tính như sau: - Xử lý các điểm theo chiều tăng của y - Kiểm tra xem mỗi điểm có nằm trong dải đứng chứa các điểm trong phạm vi min kể từ điểm phân giới - Với mỗi điểm trong dải trên, tính khoảng cách giữa điểm này với các điểm cũng trong dải và có tung độ y nhỏ hơn tung độ của điểm đang xét nhưng không nhỏ quá min. Khoảng cách giữa các điểm ở mỗi nửa bên tối thiểu là min nên số điểm phải kiểm tra sẽ ít hơn. Viết một hàm đệ quy vừa sắp theo y lại vừa tìm cặp điểm gần nhất. Thủ tục
này sẽ chia đôi tập điểm, rồi gọi lại chính nó để sắp hai nửa bên theo y và tìm cặp
điểm gần nhất trong mỗi nửa, sau đó trộn hai nửa bên để hoàn tất việc sắp theo y và
áp dụng lại thủ tục trên để hoàn tất việc tính cặp điểm gần nhất. Khi sắp theo y, việc chia đôi có thể làm bằng bất kỳ cách nào, nhưng với phép tính cặp điểm gần nhất, việc chia đôi yêu cầu có một nửa bên có hoành độ x
nhỏ hơn nửa bên còn lại. Điều này được thực hiên bằng cách sắp theo x trước khi
chia đôi tập điểm. 61 3.1. Néi dung kü thuËt 3.1.1. Bµi to¸n tèi u tæ hîp Bài toán tối ưu tổ hợp 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, tập D được gọi là tập các phương án Mỗi phần tử xD 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 định 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” thì độ phức tạp tính toán sẽ có có cấp hàm mũ. 3.1.2. Néi dung kü thuËt tham lam Tham lam (còn gọi là tham ăn, háu ă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 lam thường được vận dụng để giải bài toán tối ưu tổ hợp trong quá trình 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 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 lam 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. 3.2 C¸c vÝ dô ¸p dông 3.2.1. Bµi to¸n ng êi giao hµng 1) Bài toán Có một người giao hàng cần đi giao hàng tại n thành phố. Xuất phát từ một
thành phố nào đó, đi qua các thành phố khác để giao hàng và trở về thành phố ban 62 đầu. Mỗi thành phố chỉ đến một lần, khoảng cách từ một thành phố đến các thành phố khác là xác định được. Giả thiết rằng mỗi thành phố đều có đường đi đến các thành phố còn lại. Khoảng cách giữa hai thành phố có thể là khoảng cách địa lý, có
thể là cước phí di chuyển hoặc thời gian di chuyển. Ta gọi chung là độ dài. Hãy tìm một chu trình (một đường đi khép kín thỏa mãn điều kiện trên) sao cho tổng độ dài các cạnh là nhỏ nhất. Nếu sử dụng phương pháp vét cạn ta xét tất cả các chu trình, mỗi chu trình tính tổng độ dài các cạnh của nó rồi chọn một chu trình có tổng độ dài nhỏ nhất.
Như vậy chúng ta cần xét tất cả là (n-1)!/2 chu trình. Thực vậy, do mỗi chu trình đều đi qua tất cả các đỉnh (thành phố) nên ta có thể cố định một đỉnh. Từ đỉnh này ta có n-1 cạnh tới n-1 đỉnh khác, nên ta có n-1 cách chọn cạnh đầu tiên của chu trình.
Sau khi đã chọn được cạnh đầu tiên, chúng ta còn n-2 cách chọn cạnh thứ hai, do đó ta có (n-1)(n-2) cách chọn hai cạnh. Cứ lý luận như vậy ta sẽ thấy có (n-1)! cách chọn một chu trình. Tuy nhiên với mỗi chu trình ta chỉ quan tâm đến tổng độ dài các cạnh chứ không quan tâm đến hướng vì vậy có tất cả (n - 1)!/2 chu trình. Ðó là một thuật toán có độ phức tạp tính toán là hàm mũ. 2) Thiết kế thuật toán - Ý tưởng: Gọi n thành phố người giao hàng phải đi qua là các thành phố 1, 2, ..., n. Xuất phát từ một thành phố i nào đó đi đến thành gần thành phố i nhất trong trong n-1 thành phố còn lại, chẳng hạn thành phố j, từ thành phố j đi đến thành phố gần thành phố j nhất trong n-2 thành phố còn lại. Quá trình được lặp lại cho đến khi đã đi hết n thành phố thì quay về thành phố i - ta được một chu trình. - Thuật toán: Giaohang (a, n, dau) /* Mảng a mà a[i][j] là độ dài từ thành phố i đến thành phố j n số thành phố dau: thành phố xuất phát xet[v]: ghi nhận trạng thái thành phố v- chưa đến bằng 0, đã đến bằng 1 cost: lưu độ dài chu trình Mảng ct có các phần tử lưu trữ lần lượt n thành phố đi qua */ { for(k = 1; k <= n; k++) 63 xet[k] = 0; cost = 0; v = dau; i = 1; ct[i] = v; xet[v] = 1; while(i < n) { min = vc;/*do dai tu v den thanh pho bat ky chua den*/ for (k = 1; k <= n; k++) if(!xet[k]) if(min > a[v][k]) { min = a[v][k]; w = k; } v = w; i++; ct[i] = v; xet[v] = 1; cost += min; } return cost; } 3) Đánh giá độ phức tạp tính toán của thuật toán Phép toán tích cực là phép kiểm tra (!xet[k]), phép kiểm tra này nằm trong vòng lặp for (k = 1; k <= n; k++) và vòng lặp while(i < n). Mỗi vòng lặp thực hiện
n lần, do đó độ phức tạp tính toán là O(n2). Nhận xét: 64 Có thể giải quyết bài toán bằng kỹ thuật tham lam theo cách tiếp cận khác như sau: 1. Sắp xếp các cạnh theo thứ tự tăng dần của độ dài. 2. Lần lượt xét các cạnh có độ dài từ nhỏ đến lớn để đưa vào chu trình. Một cạnh sẽ được đưa vào chu trình nếu cạnh đó thỏa mãn hai điều kiện sau: • Không tạo thành một chu trình thiếu (không đi qua đủ n đỉnh) • Không tạo thành một đỉnh có cấp ≥ 3 (tức là không được có nhiều hơn hai
cạnh xuất phát từ một đỉnh, do yêu cầu của bài toán là mỗi thành phố chỉ được đến một lần: một lần đến và một lần đi) Cho đến khi xây dựng được một chu trình. Thuật toán Giaohang() /* E là tập hợp các cạnh đã được sắp xếp theo chiều tăng của độ dài, Chu_trinh là tập hợp các cạnh được chọn để đưa vào chu trình */ { Chu_Trinh = Φ; Gia = 0; while(E <> Φ) { if (cạnh e có thể chọn) { Chu_Trinh = Chu_Trinh + [e] ; Gia = Gia + độ dài của e; } E = E-[e]; } } Thuật toán trên có độ phức tạp tính toán là O(n2) 3.2.2. Bµi to¸n chiÕc ba l« 1) Bài toán 65 Cho một cái ba lô có thể đựng một trọng lượng W và n loại đồ vật, mỗi đồ vật i có một trọng lượng gi và một giá trị vi. Tất cả các loại đồ vật đều có số lượng không hạn chế. Tìm một cách lựa chọn các đồ vật đựng vào ba lô, chọn các loại đồ vật nào, mỗi loại lấy bao nhiêu sao cho tổng trọng lượng không vượt quá W và tổng giá trị là lớn nhất. 2) Thiết kế thuật toán Theo yêu cầu của bài toán thì ta cần những đồ vật có giá trị cao mà trọng lượng lại nhỏ để sao cho có thể mang được nhiều “đồ quý”, sẽ là hợp lý khi ta quan tâm đến yếu tố “đơn giá” của từng loại đồ vật tức là tỷ lệ giá trị/trọng lượng. Ðơn giá càng cao thì đồ càng quý. 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. Ví dụ 3.1. 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 trong bảng dưới: Tên đồ vật Trọng lượng Giá trị A 30 15 B 25 10 C 2 2 D 6 4 Hình 3.1. Trọng lượng và giá trị của 4 loại đồ vật Tính đơn giá cho các loại đồ vật: 66 Tên đồ vật Trọng lượng Giá trị Đơn giá 15 30 2 A 10 25 2,5 B 2 2 1 C 4 6 1,5 D Hình 3.2. Đơn giá của 4 loại đồ vật 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: Tên đồ vật Trọng lượng Giá trị Đơn giá 10 25 2,5 B 15 30 2 A 4 6 1,5 D 2 2 1 C Hình 3.3. Các đồ vật theo đơn giá giảm dần Theo đó thì thứ tự ưu tiên để chọn đồ vật là B, A, D, 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. 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à 3*10 + 1*4 + 1*2 = 36 Tổng giá trị là 3*25+1*6+1*2 = 83. Thuật toán giải bài toán cái ba lô bằng kĩ thuật tham lam như sau: Tổ chức dữ liệu: - Mỗi đồ vật được biểu diễn bởi một mẩu tin có các trường: • Ten: Lưu trữ tên đồ vật. • Trong_luong: Lưu trữ trọng lượng của đồ vật. • Gia_tri: Lưu trữ giá trị của đồ vật 67 • Don_gia: Lưu trữ đơn giá của đồ vật • Phuong_an: Lưu trữ số lượng đồ vật được chọn theo phương án. - Danh sách các đồ vật được biểu diễn bởi một mảng các đồ vật. Thuật toán: input: mảng dsdv mà các trường Ten, Trong_luong, Gia_tri, Don_gia đã có giá trị. ouput: Phương án chọn đồ vật - được thể hiện ở giá trị của các trường Phuong_an
của các đồ vật Hàm Chon(d,S) cho số lượng đồ vật có trọng lượng d được chọn, S là trọng lượng còn có thể cho thêm của ba lô. Chon(d,S) { i=0; while(S>d) { i++; S=S-d; } return(i); } Hàm Dovat(dsdv,W) tìm ra phương án chọn đồ vật Dovat(dsdv,W) { /*Sắp xếp mảng dsdv theo thứ tự giảm của don_gia*/ for(i=1;i<=n-1;i++) for(j=i+1;j<=n;j++) if(dsdv[i].don_gia { tg=dsdv[i]; dsdv[i]=dsdv[j]; dsdv[j]=tg; 68 } /* Xây dựng phương án*/ for(i=1;i<=n-1;i++) { Dsdv[i].Phuong_an:= Chon(dsdv[i].Trong_luong, W); W := W – dsdv[i].phuong_an * dsdv[i].Trong_luong; } } Trong trường hợp trọng lượng của các vật và W là các số nguyên thì ta bỏ hàm Chon(d,S) và khi đó thuật toán sẽ là: Dovat(dsdv,W) { /*Sắp xếp mảng dsdv theo thứ tự giảm của don_gia*/ for(i=1;i<=n-1;i++) for(j=i+1;j<=n;j++) if(dsdv[i].don_gia { tg=dsdv[i]; dsdv[i]=dsdv[j]; dsdv[j]=tg; } /* Xây dựng phương án*/ for(i=1;i<=n-1;i++) { Dsdv[i].Phuong_an:= W/dsdv[i].Trong_luong; W := W – dsdv[i].phuong_an * dsdv[i].Trong_luong; } } 3) Đánh giá độ phức tạp tính toán của thuật toán Trong thuật toán Dovat(dsdv,W) ta phải sắp xếp mảng dsdv, công việc này 69 mất thời gian O(n2). Tiếp theo là công việc xây dựng phương án chọn đồ vật. Công
việc này được thực hiện bởi vòng lặp for(i=1;i<=n-1;i++), vòng lặp được thực hiện n lần, mỗi lần lặp lại gọi hàm Chon(dsdv[i].Trong_luong, W). Trong hàm
Chon(dsdv[i].Trong_luong, W) có vòng lặp while thực hiện nhiều nhất là n phép so
sánh. Do đó công việc xây dựng phương án chọn đồ vật mất thời gian O(n2). Vậy độ phức tạp tính toán của thuật toán là O(n2). 3.2.3. Bµi to¸n t« mµu b¶n ®å 1) Bài toán Hãy dùng số màu ít nhất để tô màu cho một bản đồ với điều kiện: mỗi quốc
gia được tô một màu và hai quốc gia kề nhau (có chung đường biên giới ) thì phải có màu khác nhau. Ta xây dựng một đơn đồ thị vô hướng: Mỗi quốc gia tương ứng với một đỉnh
của đồ thị, hai quốc gia kề nhau thì hai đỉnh tương ứng kề nhau (có cạnh nối giữa chúng). Khi đó bài toán đã cho sẽ chính là bài toán: Hãy dùng số màu ít nhất để tô màu cho các đỉnh của một đơn đồ thị vô
hướng sao cho hai đỉnh kề nhau phải có màu khác nhau. (Bài toán tô màu đồ thị) 2) Thiết kế thuật toán Có nhiều kỹ thuật được sử dụng để thiết kế thuật toán giải bài toán tô màu đồ
thị và tìm được phương án tối ưu với thời gian hàm mũ. Ở đây chúng ta sẽ thiết kế thuật toán giải bài toán này theo tinh thần của kỹ thuật tham lam: kết quả có thể chỉ là một phương "tốt" chứ chưa đảm bảo là tối ưu nhưng thời gian là có thể chấp nhận được. Trước hết ta định nghĩa bậc của một đỉnh là số đỉnh trên đồ thị chưa được tô mầu mà đỉnh này được nối trực tiếp với đỉnh đang xét bằng một cạnh nối trực tiếp.
Nếu đỉnh đó không được nối với bất kỳ đỉnh nào thì bậc của đỉnh đó là 0 -Bước 1 : Tìm đỉnh có bậc cao nhất trên đồ thị ( gọi là đỉnh u ) -Bước 2 : Tăng số màu cần tô lên 1 và tô màu cho đỉnh vừa tìm -Bước 3 : Tìm đỉnh v để tô màu giống u thỏa mãn các điều kiện sau : v đi đến
đỉnh u thông qua duy nhất 1 đỉnh w khác và v có số bậc lớn nhất. Nếu không tìm
được đỉnh như vậy ta sẽ tìm đỉnh v có bậc lớn nhất mà không kề với u. Nếu tìm
được v thỏa mãn thì ta tô màu cho v chính là màu của đỉnh u. Sau đó nhập đỉnh u và 70 v vào làm 1 đỉnh. Tức : đỉnh w kể với v thì cũng coi như kề với u ( a[v,w] =1 thì a[u,w] =1 and a[w,u] = 1 ) -Bước 4 : Lập lại bước 3 cho đến khi v = 0. -Bước 5 : Lặp lại bước 1 cho đến khi tô hết mầu các đỉnh của đồ thị. Tổ chức dữ liệu: somau: Lưu trữ số màu cần tô n : Số đỉnh của đồ thị color : Mảng lưu màu của đỉnh đồ thị, color [i] = 0 nghĩa là đỉnh i chưa được tô màu a : ma trận kề - a[u,v] = 1 tức hai đỉnh u và c có cạnh nối, a[u,v] = 0 tức hai đỉnh không có cạnh nối count : Đếm số đỉnh đã tô màu ,Count = n: dừng Thuật toán Thuật toán được xây dựng qua các hàm sau: - dinh_bac_max(): Hàm tìm ra đỉnh có bậc lớn nhất - tomau(u ): tô màu cho đỉnh u - tim_dinh_cung_mau(u, v): tìm đỉnh v để tô màu cùng với màu đinh u - Nhapdinh(u,v): u,v được tô cùng màu thì các đỉnh kề với v được coi như kề với u. - main() Các hàm: dinh_bac_max () { max := -1;//Vi co the co dinh co bac la 0 for(i=1;i<=n;i++) if (color[i] == 0) /*Xét các đỉnh chưa được tô màu để tìm ra đỉnh bậc lớn nhất*/ { dem = 0; for(j=1;j<=n;j++) 71 if ((color[j] == 0) && (a[i][j] == 1)) dem++; if (dem > max) //Cập nhật giá trị lớn nhất { max = dem; u = i; } } return(u); } tomau(u); { count = count + 1; color[u] = somau; } tim_dinh_cung_mau(u, v ) { max = 0; for(i=1;i<=n;i++) if (color[i] == 0) && (a[u][i] == 0)) /* Xét các đỉnh mà u không kề mà đi đến u qua duy nhất 1 đỉnh khác */ { dem = 0; for(j=1;j<=n;j++) if (color[j] == 0) && (a[i][j] == 1) && (a[u][j] == 1)) dem++; if (dem > max) then // Cập nhật giá trị max { max = dem; 72 v = i; } } if (v > 0) return; /*Nếu tồn tại v chưa tô màu đi đến được u thông qua duy nhất 1 đỉnh khác*/ max = -1; // vì có thể tồn tại v mà bậc chỉ là 0 for(i=1;i<=n;i++) if (color[i] == 0) &&(a[u][i] ==0)) { dem = 0; for(j=1;j<=n;j++) if ((color[j] == 0) and (a[i][j] == 1)) dem++; if (dem > max) { max = dem; v = i; } } } Nhapdinh(u,v) { for(i=1;i<=n;i++) if (a[v,i] == 1) { a[u,i] = 1; a[i,u] = 1; } } 73 main() { somau = 0; do somau = somau+1; u = dinh_bac_max; tomau(u); do tim_dinh_cung_mau(u,v); /* Tìm đỉnh v có thể tô màu giống mầu của u*/ if (v > 0) { tomau(v); nhap_dinh(u,v); } while (v == 0); while (count == n); } 3) Đánh giá độ phức tạp tính toán của thuật toán Trong hàm main() có hai vòng lặp do ... while lồng nhau, mỗi vòng lặp thực hiện nhiều nhất n lần. Trong hai vòng lặp này có lời gọi hàm tim_dinh_cung_mau(u,v), trong hàm này phép toán tích cực là phép so sánh (có thời gian O(1)) được đặt trong hai vòng lặp lồng nhau là for(i=1;i<=n;i++) và for(j=1;j<=n;j++). Vậy độ phức tạp của thuật toán là O(n4) 3.2.4. Tìm cây khung nhỏ nhất 1) Bài toán Cho G = (V, E) là đồ thị vô hướng liên thông với tập đỉnh V = { 1,2,…,n} và tập cạnh E gồm m cạnh. Mỗi cạnh e của đồ thị G được gán với một số không âm c(e), gọi là độ dài của nó. Giả sử T =(V, ET) là cây khung của đồ thị G. Ta gọi độ dài c(T) của cây khung T là tổng độ dài của các cạnh của nó: 74 Bài toán đặt ra là trong số tất cả các cây khung của đồ thị G hãy tìm cây khung với độ dài nhỏ nhất. Cây khung như vậy được gọi là cây khung nhỏ nhất của đồ thị và bài toán đặt ra được gọi là bài toán cây khung nhỏ nhất. Để giải bài toán cây khung nhỏ nhất, tất nhiên có thể liệt kê tất cả các cây khung của đồ thị và chọn trong số chúng cây khung nhỏ nhất. Phương pháp như
vậy, trong trường hợp đồ thị đầy đủ, sẽ đòi hỏi thời gian cỡ nn-2 , và rõ ràng không
thể thực hiện được ngay cả với những đồ thị với số đỉnh cỡ hàng chục. Rất may đối với bài toán cây khung nhỏ nhất chúng ta đã có những thuật toán rất hiệu quả để giải chúng .Chúng ta sẽ xét một trong số những thuật toán như vậy, đó là thuật toán Prim 2) Thuật toán Prim Thuật toán Prim còn được gọi là phương pháp lân cận gần nhất, phương pháp này sử dụng kỹ thuật tham lam. Cụ thể, bắt đầu từ một đỉnh nào đó tuỳ ý của đồ thị chẳng hạn là đỉnh s, đầu tiên ta nối s với đỉnh lân cận có độ dài nhỏ nhất, chẳng hạn là đỉnh y. Nghĩa là trong
số các đỉnh kề của đỉnh s, cạnh(s,y) có độ dài nhỏ nhất. Tiếp theo, trong số các cạnh kề với hai đỉnh s hoặc y ta tìm cạnh có độ dài nhỏ nhất, cạnh này dẫn đến đỉnh thứ ba z, và ta thu được cây bộ phận gồm ba đỉnh và hai cạnh. Quá trình này sẽ được tiếp tục cho đến khi ta thu được cây gồm n đỉnh và n-1 cạnh, đó chính là cây khung nhỏ nhất cần tìm. Giả sử đồ thị cho bởi ma trận trọng số C= { c[i,j] i,j =1, 2,…, n} với qui ước c[x, x] = 0 và c[x,y] = nếu không có cạnh (x,y). Trong quá trình thực hiện thuật toán, ở mỗi bước để có thể nhanh chóng chọn đỉnh và cạnh cần bổ sung vào cây khung, các đỉnh của đồ thị sẽ được gán cho các nhãn. Nhãn của một đỉnh x sẽ gồm
hai thành phần và có dạng [ ax, bx], trong đó thành phần ax ghi nhận đỉnh của cây
khung gần x nhất (cây khung đang xây dựng) còn thành phần bx dùng để ghi nhận
độ dài của cạnh có độ dài nhỏ nhất trong số các cạnh nối đỉnh x với các đỉnh của
cây khung đang xây dựng (ta sẽ gọi là khoảng cách từ đỉnh x đến tập đỉnh của cây
khung), tức là: bx = min { c[x,y]: y VT } (VT là tập các đỉnh của cây khung đang xây dựng).
Các nhãn sẽ được chỉnh dần trong một quá trình lặp. Tại mỗi bước lặp tìm được một
đỉnh có nhãn “tốt nhất” để bổ sung vào cây khung. Đỉnh đã được chọn bổ sung vào 75 cây khung sẽ không được tham gia chỉnh nhãn trong các bước lặp tiếp theo. Quá
trình lặp sẽ kết thúc khi tập VT có n phần tử. Thuật toán Prim được thể hiện nhu sau: CKNN() { // khởi tạo //Chọn s là một đỉnh nào đó của đồ thị VT={s}; ET=; bs=0; as=s; for (x V \ VT) { ax = s; bx= c[s,x]; } kt=1; while(kt) { // tìm đỉnh có nhãn “tốt nhất” bổ sung vào cây khung p=v; //v là một đỉnh thuộc V\VT for (x V \ VT) if(bx VT =VT {p}; ET=ET{(p, ap)}; if (VT==n) { T= (VT, ET) là cây khung nhỏ nhất của đồ thị; kt=0; } else /* chỉnh nhãn */ 76 for (x V\VT) if (bx > c[p, x]) { bx:=c[p, x]; ax:=p; } } } 3) Độ phức tạp tính toán của thuật toán Trong thuật toán ta coi phép toán tích cực là phép so sánh (bx for (x V\VT), vòng lặp này thực hiện nhiều nhất (n-1) lần. Vòng lặp for (x V\VT) nằm trong vòng lặp while(kt) có số lần thực hiện nhiều nhất (n-1) lần. Vậy độ phức tạp tính toán của thuật toán là O(n2) 3.2.5. Tìm đƣờng đi ngắn nhất 1) Bài toán Cho đồ thị có hướng (hoặc vô hướng) G=(V,E), V =n, E =m với các cạnh được gán trọng số, nghĩa là, mỗi cạnh (u,v) E được đặt tương ứng với một số thực a(u,v) gọi là trọng số của nó. ở đây ta chỉ xét đồ thị có trọng số của các cạnh là không âm. Chúng ta sẽ đặt a(u,v) =, nếu (u,v) E. Nếu dãy v0, v1, ... , vp là một đường đi trên G, thì độ dài của nó được định nghĩa là tổng sau (nếu chúng ta gán trọng số cho tất cả các cạnh đều bằng 1, thì ta thu được định nghĩa độ dài của đường đi như là số cạnh của đường đi). Bài toán tìm đường đi ngắn nhất trên đồ thị có thể phát biểu như sau: Tìm đường đi có độ dài nhỏ nhất từ một đỉnh xuất phát sV đến đỉnh cuối cùng (đích) t V. Đường đi như vậy ta sẽ gọi là đường đi ngắn nhất từ s đến t. 77 Ta sẽ xem xét việc giải quyết bài toán này bằng thuật toán Dijkstra và xem xét tính chất tham lam trong thuật toán. 2) Thuật toán Dijkstra Thuật toán Dijkstra giải bài toán tìm đường đi ngắn nhất từ đỉnh s đến các đỉnh còn lại của đồ thị đượ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ột đỉnh x sẽ gồm hai thành phần và có dạng [ax, bx], trong đó
thành phần ax chỉ đỉnh đi trước x trong đường đi còn thành phần bx là cận trên của
độ dài đường đi ngắn nhất từ đỉnh xuất phát đến đỉnh x. Các nhãn của các đỉnh sẽ được chỉnh dần trong một quá trình lặp. Cứ mỗi bước lặp sẽ có có một nhãn tạm
thời được chọn làm nhãn cố định. Khi đỉnh x có nhãn trở thành cố định thì bx sẽ là
độ dài đường đi ngắn nhất từ đỉnh xuất phát đến đỉnh x. Các nhãn đã được cố định sẽ không tham gia sửa nhãn trong các bước lặp tiếp theo. Quá trình lặp sẽ kết thúc
khi nhãn đỉnh t được chọn làm nhãn cố định. Khi đó bt là độ dài đường đi nhắn nhất
từ đỉnh s đến đỉnh t và đường đi là: s ->...->at->t. Thuật toán input: Đồ thị G = (V,E) với n đỉnh, ma trận trọng số C với c(u,u)=0, c(u,v) =, nếu (u,v) E. s V là đỉnh xuất phát, t V là đỉnh đích output: Độ dài đường đi ngắn nhất từ đỉnh s đến đỉnh t. Dijkstra(c,s,t) { /* khởi tạo */ for x V do { bx= c[s,x]; ax=s; } U:= V\{s}; /* U là tập các đỉnh có nhãn tạm thời*/ p=s; while (p t) { p=v; //v là một đỉnh thuộc U 78 for (x U) if(bx U = U \ {p} ; /* Cố định nhãn đỉnh p */ for (x U) /* Gán lại nhãn các đỉnh trong U */ if (bx >bp + c[p,x] ) { bx = bp + c[p,x] ax= p; } } } Kỹ thuật tham lam thể hiện trong thuật toán: tại mỗi bước lặp sẽ chọn một nhãn tạm thời làm nhãn cố định: U}; Tìm đỉnh p U thoả mãn bp = min{ bu : u 3) Độ phức tạp tính toán của thuật toán Ở mỗi bước lặp để tìm ra đỉnh p 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 phải 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ậy thời gian tính toán của thuật toán là O(n2). Trong thuật toán ta coi phép toán tích cực là phép so sánh (bx đỉnh để cố định nhãn. Phép so sánh nằm trong vòng lặp for (xU), vòng lặp này thực hiện nhiều nhất (n-1) lần. Vòng lặp for (xU) nằm trong vòng lặp while (p t) có số lần thực hiện nhiều nhất (n-1) lần. Vậy độ phức tạp tính toán của thuật toán là O(n2) 3.2.6. Bµi to¸n ph©n c«ng c«ng viÖc 1) Bài toán Cần gia công m chi tiết máy J1, J2,...,Jm. Có n máy gia công giống nhau là P1,
P2, ...Pn. Mọi chi tiết đều có thể được gia công trên bất kỳ máy nào. Một khi đã gia
công một chi tiết trên một máy, công việc sẽ tiếp tục cho đến lúc hoàn thành, không
thể bị cắt ngang. Ðể gia công một chi tiết Ji trên một máy bất kỳ ta cần dùng một
thời gian tương ứng là ti. Hãy phân công công việc cho các máy sao cho thời gian 79 gia công xong toàn bộ m chi tiết là ít nhất. 2) Thiết kế thuật toán j2=5 P1 j5=5 P2 j1=2 j3=8 j4=1 P3 j6=1 Chúng ta xét bài toán trong trường hợp có 3 máy P1, P2, P3 và 6 chi tiết với
thời gian gia công là t1=2, t2=5, t3=8, t4=1, t5=5, t6=1. Ta có một phương án phân
công (L) như hình sau : Hình 3.4. Phương án phân công L Theo hình này, tại thời điểm t=0, ta tiến hành gia công chi tiết J2 trên máy P1,
J5 trên P2 và J1 tại P3. Tại thời điểm t=2, công việc J1 được hoàn thành, trên máy P3
ta gia công tiếp chi tiết J4. Trong lúc đó, hai máy P1 và P2 vẫn đang thực hiện công
việc đầu tiên mình...Sơ đồ phân việc theo hình ở trên được gọi là lược đồ Gantt. Theo lược đồ này, ta thấy thời gian để hoàn thành toàn bộ 6 công việc là 12. Nhận xét một cách cảm tính ta thấy rằng phương án (L) vừa thực hiện là một phương án
không tốt. Các máy P1 và P2 có quá nhiều thời gian rỗi. Xây dựng một thuật toán để tìm một phương án tối ưu cho bài toán này là
một bài toán khó, đòi hỏi các kỹ thuật phức tạp mà chúng ta sẽ không đề cập ở đây. Bây giờ ta xét đến một thuật toán được thiết kế theo tinh thần của kỹ thuật tham lam để giải bài toán này mà kết quả là ta được một phương án tốt chứ chưa chắc đã là tối ưu. Ý tưởng: 1. Sắp xếp các công việc theo thứ tự giảm dần về thời gian gia công. 2. Lần lượt sắp xếp các việc theo thứ tự đó vào máy còn dư nhiều thời gian nhất. Thuật toán: - Mảng T lưu trữ thời gian gia công các chi tiết. - Mảng TT lưu trữ thứ tự các chi tiết: TT[i]=j là chi tiết j có thứ tự i. 80 - Mảng TG lưu trữ thời gian gia công các chi tiết cho từng máy theo phân công. Ban đầu TG[i]=0 với 1 i n. Phancong(n,m) { // Khởi tạo mảng TG và TT for(i=1;i<=n;i++) TG[i]=0; for(i=1;i<=m;i++) TT[i]=i; // Sắp xếp mảng TT theo chiều giảm dần của thời gian gia công for(i=1;i<=m;i++) for(j=i+1;j<=m;j++) if(T[i] { tg=TT[i]; TT[i]=TT[j]; TT[j]=tg; } // Phân công công việc min=0; for(i=1;i<=m;i++) { j=1; while(TG[j]!=min) j++; Phân chi tiết TT[i] cho máy j; TG[j]=TG[j]+T[TT[i]]; min=TG[1] ; for(j=1;j<=n;j++) if (TG[j] min=TG[j]; } } 81 Với bài toán cụ thể trên theo thuật toán này ta sẽ có một phương án L* như j3=8 P1 j2=5 j1=2 P2 j4=1 j5=8 P3 j6=1 sau : Hình 3.5. Phương án L* Dễ nhận thấy rằng phương án L* vừa tìm được cũng chính là phương án tối
ưu vì thời gian hoàn thành là 8, đúng bằng thời gian gia công của chi tiết J3. Tuy
nhiên ta biết rằng thuật toán này chỉ cho kết quả là một phương án "tốt", không có
gì đảm bảo rằng phương án đó là phương án tối ưu. Trường hợp trên có thể coi là một trường hợp "may mắn". Để thấy điều này ta xét ví dụ sau: Ví dụ 3.2. Có 2 máy P1, P2 và 5 chi tiết j1, j2, j3, j4, j5 với thời gian gia công là: t1=3, t2=3, t3=2, t4=2, t5=2. Khi đó phương án tìm được theo thuật toán có thời gian gia công xong các chi tiết là
7, trong khi đó thời gian gia công xong 5 chi tiết đã cho theo phương án tối ưu là 6. j1=3 j3=2 j5=2 P1 j2=3 j4=2 P2 Đièu này được minh hoạ trong hình dưới đây: j1=3 j2=3 P1 j4=2 j3=3 j5=2 P2 Hình 3.6. Phương án tìm được theo thuật toán Hình 3.7. Phương án tối ưu 82 Nếu gọi T* là thời gian để gia công xong n chi tiết máy do thuật toán đưa ra và To là thời gian tối ưu thì người ta đã chứng minh được rằng: Với kết quả này, ta có thể xác lập được sai số mà chúng ta gặp phải nếu dùng thuật toán thay vì tìm một lời giải tối ưu. Chẳng hạn với số n=2 ta có: và đó chính là sai số cực đại mà trường hợp ở trên đã gánh chịu. Theo công thức
này, số máy càng lớn thì sai số càng lớn. Trong trường hợp n lớn thì 1/(3n) xem như bằng 0. Như vậy, sai số tối đa sẽ
là 4/3To, nghĩa là sai số tối đa là 33%. Tuy nhiên, khó tìm ra được những trường
hợp mà sai số đúng bằng giá trị cực đại, dù trong trường hợp xấu nhất. Thuật toán thiết kế theo kỹ thuật tham lam trong trường hợp này rõ ràng đã cho chúng ta những lời giải tương đối tốt. 3) Đánh giá độ phức tạp tính toán của thuật toán Ta có thể coi phép toán tích cực trong thuật toán là phép so sánh: if (TG[i] Phép so sánh này nằm trong vòng lặp for(j=1;j<=n;j++) nên được thực hiện n lần. Vòng lặp for(i=1;i<=n;i++) được đặt trong vòng lặp for(i=1;i<=m;i++) do vậy phép toán tích cực if (TG[i] toán của thuật toán là O(n.m). 83 1.Cho một cái ba lô có thể đựng một trọng lượng W và n loại đồ vật, mỗi đồ vật i có
một trọng lượng gi, một giá trị vi và số lượng là si. Dùng kỹ thuật tham lam tìm một
cách lựa chọn các đồ vật đựng vào ba lô sao cho tổng giá trị các đồ vật được đựng trong ba lô là lớn nhất. 2. Tìm số màu ít nhất để tô cho các đỉnh của các đồ thị sau sao cho không có hai
đỉnh nào kề nhau được tô cùng một màu: a. Đồ thị vô hướng đầy đủ Kn. b. Đồ thị vòng tròn Cn c. Đồ thị bánh xe Wn d. Đồ thị phân đôi đầy đủ Kn,m 3. Xếp lịch thi Một trường đại học tổ chức học theo tín chỉ. Nếu sinh viên tích lũy đủ số chứng chỉ cho một số môn quy định của một ngành là có quyền nhận bằng tốt nghiệp của ngành đó. Đối với các đại học như thế, việc học và thi không tổ chứa theo lớp mà theo các môn học. Hàng năm nhà trường thông báo các môn sẽ học để sinh viên tự đăng ký học các môn học theo ngành mình chọn. Cuối năm nhà trường tổ chức thi cho các môn đã giảng trong năm. Mỗi môn thi trong một ngày nhưng trong một ngày có thể tổ chức thi nhiều môn. Do một sinh viên có thể đăng ký thi nhiều môn nên lịch thi cần phải bố trí để nếu có một sinh viên đăng ký thi hai môn nào đó thì các môn đó không được thi cùng ngày. Dùng kỹ thuật tham lam thiết kế lịch thi sao cho tổng số ngày thi càng ít càng tốt. Hướng dẫn: Đưa bài toán về bài toán tô màu đồ thị. Ta xây dựng đồ thị như sau : + Mỗi đỉnh của đồ thị là một môn học + Hai đỉnh của đồ thị được gọi là có cạnh nối nếu có ít nhất một sinh viên tham gia học 2 môn đó. Như vậy ta xây dựng được một đồ thị vô hướng N đỉnh, trong đó a[i,j] = 1 nếu có cạnh nối, a[i,j] = 0 nếu không có cạnh nối. Sau khi xây dựng được đồ thị trên ta áp dụng thuật toán tô màu đồ thị . Ta sẽ tìm ra 84 được kết quả (số ngày ít nhất cần tổ chức thi ). 4. 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 dùng kỹ thuật tham lam xây dựng thuật toán tìm phương án trả tiền sao cho trả đủ n đồng và số tờ
giấy bạc phải trả càng ít càng tốt. Đánh giá độ phức tạp tính toán của thuật toán. Hướng dẫn: 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 lam để 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 sao cho X1 * 100.000 ≤ n. Tức là X1 = n DIV 100.000. Chảng hạn 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. 5. Chỉ ra việc sử dụng kỹ thuật tham lam trong thuật toán Kruskal giải bài toán cây
khung nhỏ nhất và đánh giá độ phức tạp tính toán của thuật toán. 85 4.1. Nội dung kỹ thuật Nét đặc trưng của kỹ thuật quay lui là các bước hướng tới lời giải hoàn toàn được làm thử. Tại mỗi bước, nếu có một lựa chọn được chấp nhận thì ghi nhận lựa chọn này và tiến hành các bước thử tiếp theo. Ngược lại nếu không có lựa chọn nào thích hợp thì làm lại bước trước, xoá bỏ sự ghi nhận và quay về chu trình thử các lựa chọn còn lại. Hµnh ®éng nµy ® îc gäi lµ quay lui, thuật toán sử dụng kỹ thuật này là thuật toán quay lui. Lời giải của bài toán thường được biểu diễn bằng một bộ gồm n thành phần x
= (x1,.., xn ) phải thoả mãn các điều kiện nào đó. Để chỉ ra lời giải x, ta phải xây
dựng dần các thành phần xi. Nhu vậy nội dung chính của kỹ thuật này là việc xây dựng dần các thành
phần xi bằng cách thử tất các khả năng. Giả sử đã xác định được i -1 thành phần x1,
x2,…, xi-1 (mà ta sẽ gọi là lời giải bộ phận cấp i- 1), bây giờ ta xác định thành phần
xi bằng cách duyệt tất cả các khả năng có thể đề cử cho nó (đánh số các khả năng từ
1 đến ni ). Với mỗi khả năng j, kiểm tra xem j có chấp nhận được không. Xảy ra hai
trường hợp: - Nếu chấp nhận j thì xác định xi theo j. Sau đó nếu i = n thì ta được một cấu hình, còn trái lại ta tiến hành xác định xi+1. - Nếu thử tất cả các khả năng mà không có khả năng nào chấp nhận được thì quay lại bước trước để xác định lại xi-1. Điểm quan trọng của thuật toán là phải ghi nhớ tại mỗi bước đã đi qua những khả năng nào đã thử để tránh trùng lặp. Rõ ràng những thông tin này cần được lưu
trữ theo cơ cấu ngăn xếp (Stack- Vào sau ra trước). Vì thế kỹ thuật này rất phù hợp
với việc lập trình trên một ngôn ngữ cho phép gọi đệ qui. Bước xác định xi có thể
diễn tả qua hàm được tổ chức đệ qui dưới đây: Try(i) { for(j=1;j if ( { 86 if (i == n) else try(i+1); } } Phần quan trọng nhất trong hàm trên là việc đưa ra một danh sách các khả
năng đề cử và việc xác định giá trị biểu thức bước trước. Trong những trường hợp như vậy, cần ghi nhớ trạng thái mới của quá trình tìm kiếm sau khi Try(i+1). Các trạng thái này được ghi nhận nhờ một số biến tổng thể, gọi là biến trạng thái. Sau khi xây dựng thủ tục đệ qui Try, đoạn chương trình chính giải bài toán liệt kê có dạng: main() { Init; Try(1); } trong đó Init là hàm khởi tạo các giá trị ban đầu (nhập các giá trị tham số, của bài
toán, khởi gán các biến trạng thái, biến đếm, ... Người ta đã chứng tỏ được rằng độ phức tạp tính toán của các thuật toán quay lui thường là hàm mũ. Ta công nhận điều này và trong các ví dụ áp dụng ta sẽ không đặt vấn đề xác định độ phức tạp tính toán của các thuật toán. Kỹ thuật quay lui thường được áp dụng cho các bài toán liệt kê tổ hợp. 4.2. Các ví dụ áp dụng 4.2.1. Đƣa ra các dãy nhị phân độ dài n 1) Bài toán Cho một số nguyên dương n. Hãy đưa ra các dãy nhị phân độ dài n 2) Thiết kế thuật toán Biểu diễn dãy nhị phân dưới dạng (b1, b2,…, bn), trong đó bi{0,1}. 87 Mỗi thành phần bi có các giá trị đề cử là 0 và 1. Các giá trị này mặc nhiên được
chấp nhận mà không phải thoả mãn điều kiện gì. Mảng b lưu trữ dãy nhị phân xây dựng được. Hàm Try(i) xác định thành phần thứ i và hàm Result() đưa ra dãy tìm được. result() { printf("\n"); for(i=1;i<=n;i++) printf(b[i]); } Try(i) { for(j=0;j<=1;j++) { b[i]:=j; if(i==n) result(); else try(i+1); } } main() { scanf(n); try(1); getch(); } 4.2.2. Đƣa ra các hoán vị của n số nguyên 1) Bài toán Cho X = { x1, x2, ..., xn}, trong đó xi (i=1, 2, ...,n) là các số nguyên Hãy đưa ra tất cả các hoán vị của X. 2) Thiết kế thuật toán 88 Ta nhận thấy rằng mỗi một hoán vị của tập X tương đương với một hoán vị của tập chỉ số {1, 2, ..., n} Biểu diễn hoán vị tập chỉ số dưới dạng a1, a2, …, an trong đó ai nhận giá trị từ
1 đến n và ai aj với i j. Các giá trị từ 1 đến n được lần lượt đề cử cho ai, trong đó
giá trị j được chấp nhận nếu nó chưa được dùng. Vì thế cần phải ghi nhớ đối với mỗi giá trị j xem nó đã được dùng hay chưa. Điều này được thực hiện nhờ một dãy
biến bj, trong đó bj bằng 1 nếu j chưa được dùng. Sau khi xác định ai theo j cần gán
giá trị 0 cho bj. Khi thực hiện xong Result hay Try(i+1) cần phải gán lại giá trị 1
cho bj. Tổ chức dữ liệu: - Mảng x: dãy số nguyên đã cho - Mảng a: lưu trữ một hoán vị của tập chỉ số {1, 2, ..., n} - Mảng b: lưu trữ trạng thái các phần tử của x với b[j]=1 thì x[j] đã được dùng, b[j]=0 thì x[j] chưa được dùng. Các hàm - Hàm init(): nhập n và khởi tạo - Hàm result(): đưa ra hoán vị vừa tìm được - Hàm Try(i): xác định thành phần thứ i của hoán vị. init() { scanf(n); for(i=1;i<=n;i++) b[i]=1; end; result() { for(i=1;i<=n;i++) printf(x[a[i]]); } Try(i) { for(i=1;i<=n;i++) 89 if(b[j]==1) { a[i]=j; b[j]=0; if(i==n) result(); else try(i+1); b[j]:=true; } } main() { init(); try(1); getch(); } 4.2.3. Đƣa ra các tập con của tập gồm n số nguyên 1) Bài toán Cho X = {x1, x2, ..., xn}, trong đó xi (i=1, 2, ...,n) là các số nguyên. Hãy đưa ra tất cả các tập con gồm m (m<=n) phần tử của X . 2) Thiết kế thuật toán Mỗi tập con của X gồm m phần tử tương đương với một tập con gồm m phần tử của tập chỉ số {1, 2, ..., n} Mỗi tập con gồm m phần tử của tập chỉ số {1, 2, ..., n} có thể biểu diễn bởi bộ có thứ tự gồm m thành phần a = (a1, a2, ... , am) thỏa mãn 1 a1 < a2 < .... Trên tập các tập con gồm m phần tử của tập chỉ số {1, 2, ..., n} ta xác định một thứ tự như sau: Ta nói tập con a = (a1, a2, ... , am) đi trước tập con a’ = (a1’, a2’, ... , am’) nếu tìm được chỉ số k (1 k m) sao cho: a1 = a’1 90 a2= a’2 ak-1 = a’k-1 ak < a’k. Chẳng hạn X = {1, 2, 3, 4, 5} thì các tập con gồm 3 phần tử của X được liệt kê theo thứ tự từ điển như sau: 1 2 3 1 2 4 1 2 5 1 3 4 1 3 5 1 4 5 2 3 4 2 3 5 2 4 5 3 4 5 Như vậy tập con đầu tiên trong thứ tự là (1, 2, ..., m) và tập con cuối cùng là (n - m +1, n - m + 2, ..., n). Từ đó suy ra các giá trị đề cử cho ai là từ ai-1+1 đến n - m +i. Để điều này đúng cho cả trường hợp i =1 cần có a0 = 0. Các giá trị đề cử này mặc nhiên được chấp nhận. Tổ chức dữ liệu: - Mảng x: dãy số nguyên đã cho - Mảng a: lưu trữ một tập con gồm m phần tử của tập chỉ số {1, 2, ..., n} Các hàm - Hàm init(): nhập n, m và khởi tạo - Hàm result(): đưa ra tập con gồm m phần tử của x vừa tìm được - Hàm Try(i): xác định thành phần thứ i của tập con. init() { scanf(n,m) 91 a[0]=0; end; result() { for(i=1;i<=m;i++) printf(x[a[i]]); } Try(i) { for(j=a[i-1]+1;j<=n-m+i;j++) { a[i]=j; if (i==m) result(); else try(i+1); } } main() { init; try(1); getch(); } 4.2.4. Bài toán xếp hậu 1) Bài toán Liệt kê tất cả các cách xếp 8 quân hậu trên bàn cờ sao cho chúng không ăn được lẫn nhau. 2) Thiết kế thuật toán Đánh số cột và số dòng của bàn cờ từ 1 đến 8. Mỗi dòng được xếp đúng một
quân hậu. Vấn đề là xem mỗi quân hậu trên mỗi hàng được xếp vào cột nào. Từ đó,
ta có thể biểu diễn một cách xếp bằng một bộ 8 thành phần (x1, x2, ..., x8) trong đó 92 xi = j nghĩa là quân hậu dòng i được xếp vào cột j. Các giá trị đề cử cho xi là từ 1
đến 8. Giá trị j là được chấp nhận nếu ô (i, j) chưa bị quân hậu nào chiếu đến (quân hậu có thể ăn ngang, dọc và hai đường chéo). Để kiểm soát được điều này, ta cần
phải ghi nhận trạng thái của bàn cờ trước cũng như sau khi xếp được một quân hậu. Việc kiểm soát theo chiều ngang là không cần thiết vì mỗi dòng được xếp đúng một quân hậu. Việc kiểm soát chiều dọc được ghi nhận nhờ dãy biến aj với qui ước rằng aj bằng 1 nếu cột j còn trống. Hai đường chéo đi qua ô (i, j) thì một đường chéo gồm những ô có i + j không đổi, còn một đường chéo gồm những ô có i - j không đổi (2 i+j 16, -7 i-j 7). Từ đó đường chéo thứ nhất được ghi nhận nhờ dãy biến bj (2 j 16) và đường chéo thứ hai nhờ nhờ dãy biến cj (-7 j 7) với qui ước các đường này còn trống nếu biến tương ứng có giá trị 1. Các biến trạng thái aj, bj, cj được khởi gán giá trị 1 trong hàm Init(). Như vậy
giá trị j được chấp nhận khi và chỉ khi cả 3 biến ai, bi+j, ci-j cùng có giá trị 1. Các
biến này phải được gán lại giá trị 0 khi xếp xong quân hậu thứ i và được trả lại giá trị 1 sau khi gọi Result() hay Try(i+1). Tổ chức dữ liệu: - Mảng x để lưu trữ cách xếp 8 quân hậu tìm được. - Mảng c với các phần tử c[j] để ghi nhận trạng thái cột j (c[j]=1 thì cột j chưa có quân hậu nào xếp, c[j]=0 thì cột j đã có quân hậu xếp) - Mảng ct để ghi nhận trạng thái đường chéo tổng i+j: + ct[i+j]=1 thì đường chéo i+j chưa có quân hậu nào chiếu đến + ct[i+j]=0 thì đường chéo i+j đã có quân hậu chiếu đến - Mảng ch để ghi nhận trạng thái đường chéo hiệu i-j: Để chỉ số của mảng chạy từ 1 nên với i-j chạy từ -7 đến 7 ta đặt tương ứng với i-j+8 chạy từ 1 đến 15. + ct[i-j+8]=1thì đường chéo i-j chưa có quân hậu nào chiếu đến + ct[i-j+8]=0 thì đường chéo i-j đã có quân hậu chiếu đến Các hàm: init() //Hàm khởi tạo { for(i=1;i<=8;i++) c[i]=1; 93 for(i=2;i<=2*8;i++) ct[i]=1; for(i=1;i<=2*8-1;i++) ch[i]=1; } try(i) // Xếp quân hậu hàng i; { for(j=1;j<=8;j++) if(c[j]&&ct[i+j]&&ch[i-j+8]) { x[i]=j; c[j]=0; ct[i+j]=0; ch[i-j+8]=0; if(i==8) ht(); // Hàm đưa ra cách xếp vừa tìm được else try(i+1); c[j]=1; ct[i+j]=1; ch[i-j+8]=1; } } main() { init(); try(1); } 4.2.5. Tìm đƣờng đi trên đồ thị 1) Bài toán 94 G = (V, E) là đơn đồ thị (có hướng hoặc vô hướng). V = {1,. ., n} là tập các đỉnh, E là tập cạnh (cung). Với s, t V, tìm tất cả các đường đi từ s đến t. Các thuật toán tìm kiếm cơ bản : Thuật toán DFS : Tìm kiếm theo chiều sâu. Thuật toán BFS : Tìm kiếm theo chiều rộng. 2) ThiÕt kÕ thuËt to¸n * Thuật toán DFS tiến hành tìm kiếm trong đồ thị theo chiều sâu. Thuật toán thực
hiện việc thăm tất cả các đỉnh có thể đạt được cho tới đỉnh t từ đỉnh s cho trước. Đỉnh được thăm càng muộn sẽ càng sớm được duyệt xong (cơ chế LIFO –Vào sau ra trước). Nên thuật toán có thể tổ chức bằng một thủ tục đệ quy quay lui. Input: G = (V,E), s, t Output: Tất cả các đường đi từ s đến t (nếu có). DFS (s) { for ( u = 1; u <= n; u++) { if (chấp nhận được) { Ghi nhận nó; if (u t) DFS(u); else In đường đi; bỏ việc ghi nhận; } } Ta cần mô tả dữ liệu đồ thị và các mệnh đề được phát biểu trong mô hình. Ðồ thị sẽ được biểu diễn bằng ma trận kề : a =(aij) 1<=i, j<=n và: aij = 1 nếu (i, j ) E aij = 0 nếu (i, j ) E Ghi nhận đỉnh được thăm để tránh trùng lặp khi quay lui bằng cách đánh dấu. Ta sữ dụng một mảng một chiều Daxet[] với qui ước : 95 Daxet[u] = 1 , u đã được thăm. Daxet[u] = 0 , u chưa được thăm. Mảng Daxet[] lúc đầu khởi t?o bằng 0 tất cả. Điều kiện chấp nhận được cho đỉnh u chính là u kề với v (avu = 1) và u chưa
được thăm ( Daxet[u] = 0). Để ghi nhận các đỉnh trong đường đi, ta dùng một mảng một chiều Truoc[ ], với qui ước : Truoc[u] = v với v là đỉnh đứng trước đỉnh u, và u kề với v Ta khởi tạo mảng Truoc[ ] bằng 0 tất cả. Thuật toán đượcc làm mịn hơn : Input G = (aij)nxn , s, t Output Tất cả các đường đi từ s đến t (nếu có). DFS(s) { daxet[s] = 1; for( u = 1;u <= n; u++) { if( a[s][u] && !daxet[u]) { Truoc[u] = s; if ( u == t ) duongdi(); else DFS(u); daxet[u] = 0; } } Mảng truoc[ ] lưu trử các đỉnh trên đường đi. Nếu kết thúc thuật toán, Daxet[t] = 0 ( Truoc[t] = 0 ) thì không có đường đi từ s đến t. Trong trường hợp tồn tại đường đi, xuất đường đi chính là xuất mảng Truoc[].
Thao tác này có thể viết như sau duongdi() 96 { printf(t,"<--"); j = t; while (truoc[j] != s) { printf(truoc[j],"<--"); j = truoc[j]; } printf(s); } * Thuật toán BFS tiến hành tìm kiếm trên đồ thị theo chiều rộng. Thuật toán thực
hiện việc thăm tất cả các đỉnh có thể đạt được cho tới đỉnh t từ đỉnh s cho trước theo từng mức kề. Đỉnh được thăm càng sớm thì sẽ càng sớm được duyệt xong (cơ chế FIFO – Vào trước ra trước). Input G = (V,E), s, t V; Output Đường đi từ s đến t. Mô tả : Bước 0 : A0 = {s}. Bước 1 : A1 = {x V \ A0 : ( s, x) E}. Bước 2 : A2 = {x ? V \ {A0?A1} : ?y A1 , ( y, x) E}. .... Bước i : Ai = {xV/ : yAi-1, (y,x)E} . … Thuật toán có không quá n bước lặp, một trong hai trường hợp sau xảy ra : - Nếu với mọi i, t Ai : không có đường đi từ s đến t; - Ngược lại, tAm với m nào đó. Khi đó tồn tại đường đi từ s tới t, và đó là một đường đi ngắn nhất từ s đến t. Trong trường hợp này, ta xác định được các đỉnh trên đường đi bằng cách quay 97 ngược lại từ t đến các đỉnh trước t trong từng các tập trước cho đến khi gặp s. Trong thuật toán BFS, đỉnh được thăm càng sớm sẽ càng sớm trở thành duyệt xong, nên các đỉnh được thăm sẽ được lưu tr? trong hàng đợi queue. Một đỉnh sẽ trở thành duyệt xong ngay sau khi ta xét xong tất cả các đỉnh kề của nó . Ta dùng một mảng Daxet[] để đánh dấu các đỉnh được thăm, Daxet[i]=0 là
đỉnh i chưa được thăm, Daxet[i]= là đỉnh i đã được thăm. Mảng này được khởi động bằng 0 tất cả để chỉ rằng lúc đầu chưa đỉnh nào được thăm. Một mảng truoc[ ] để lưu trữ các đỉnh nằm trên đường đi ngắn nhất cần tìm (nếu có), với ý nghĩa Truoc[i] là đỉnh đứng trước đỉnh i trong đường đi. Mảng Truoc[ ] được khởi tạo bằng 0 tất cả để chỉ rằng lúc đầu chưa có đỉnh nào. Đồ thị sẽ được biểu diễn bằng ma trận kề : a =(aij) 1<=i, j<=n và: aij = 1 nếu (i, j ) E aij = 0 nếu (i, j ) E Queue được tổ chức bằng mảng. Thuật toán được viết mịn hơn như sau : BFS(s) { dauQ = 1, cuoiQ = 1; queue[cuoiQ] = s; Daxet[s] = 1; while ( dauQ <= cuoiQ) { u = queue[dauQ]; dauQ++; for ( j = 1; j <= n; j++) if( a[u][j] == 1 && !Daxet[j] ) { cuoiQ++; queue[cuoiQ] = j; Daxet[j] = 1; Truoc[j] = u; } } Nhận xét : 98 Ta có thể thấy mỗi lần gọi DFS(s), BFS(s) thì mọi đỉnh thuộc cùng một thành
phần liên thông với s sẽ được thăm, nên sau khi thực hiện hàm trên thì : • Truoc[t] = 0 : không tồn tại đường đi từ s đến t, • Ngược lại, có đường đi từ s đến t. Khi đó lời giải được cho bởi : t ← p1 = Truoc[t] ← p2 = Truoc[p1] ← … ← s . 4.2.6. Bài toán ngựa đi tuần 1) Bài toán Cho bàn cờ có n x n ô. Một con ngựa được phép đi theo luật cờ vua, đầu tiên
được đặt x0 , y0. Một hành trình của ngựa là đi qua tất cả các ô của bàn cờ, mỗi ô đi
qua đúng một lần. Hãy đưa ra các hành trình (nếu có) của ngựa. 2) ThiÕt kÕ thuËt to¸n Cách giải quyết rõ ràng là xét xem có thể thực hiện một nước đi kế nữa hay không. Sơ đồ đầu tiên có thể phát thảo như sau : Try(i) { for ( j = 1 -> k) if ( xi chấp nhận được khả năng j) { Xác định xi theo khả năng j; Ghi nhận trạng thái; if( i < n2 ) Try(i+1); else Ghi nhận nghiệm; Hoàn trả trạng thái ; } } Để mô tả chi tiết thuật toán, ta phải qui định cách mô tả dữ liệu và các thao tác, đó là : - Biểu diễn bàn cờ . 99 - Các khả năng chọn lựa cho xi - Cách thức xác định xi theo j. - Cách thức gi nhận trạng thái mới, trả về trạng thái cũ. - Ghi nhận nghiệm. * Ta sẽ biểu diễn bàn cờ bằng 1 ma trận vuông cấp n: h[n][n] mà h[i][j] tương ứng với ô ở hàng i cột j (1 ≤ i, j ≤ n) và dùng các phần tử của ma trận để ghi nhận quá trình di chuyển của con ngựa với qui ước như sau: h[x][y] = 0: Ô h[x][y] = i : Ô * Các khả năng lựa chọn cho xi? Đó chính là các nước đi của ngựa mà xi có thể
chấp nhận được. Với ô bắt đầu thể đi đến. Giả sử chúng được đánh số từ 0 đến 7 như hình sau : ( ô có dấu * là ô ở hàng x cột y nơi ngựa đang đứng) 4 3 5 2 * 6 1 7 0 Hình 4.1. Các ô mà ngựa có thể đi đến Một cách tương đối các ô mà ngựa có thể đi qua khi đang đứng ở ô Ô đánh số 0: Ô đánh số 1: Ô đánh số 2: Ô đánh số 3: Ô đánh số 4: Ô đánh số 5: Ô đánh số 6: Ô đánh số 7: 100 Ta dùng mảng a lưu trữ các sai khác về chỉ số hàng của các ô trên so với x và
dùng mảng b lưu trữ caùc sai khác về chỉ số cột của các ô trên so với y thì mảng a và mảng b sẽ được khởi tạo như sau: Mảng a: a[0] = 2; a[1] = 1; a[2] = -1; a[3] = -2; a[4] = -2; a[5] = -1; a[6] = 1; a[7] = 2; Mảng b: b[0] = 1; b[1] = 2; b[2] = 2; b[3] = 1; b[4] = -1; b[5] = -2; b[6] = -2; b[7] = -1; Dùng một biến chỉ số k để đánh số các bước đi tiếp theo có thể thì việc duyệt các bước đi tiếp theo có thể được diễn tả là: for(k = 0; k <= 7; k++) u = x + a[k]; v = y + b[k]; Điều kiện kiện : Ô mới phải thuộc bàn cờ (1 ≤ u ≤ n và 1 ≤ v ≤ n) và ngựa chưa đi qua ô đó,
nghĩa là h[u,v] = 0; * Để ghi nhận nước đi hợp lệ ở bước i, ta gán h[u][v] = i (ghi nhận trạng 101 thái); và để hủy một nước đi thì ta gán h[u][v] = 0 (hoàn trả trạng thái). * Ma trận h ghi nhận kết quả nghiệm. Nếu có không có đường đi , còn ngược là h chứa đường đi của ngựa. Thuật toán có thể mô tả như sau : Input n, //Kích thước bàn cờ x, y;//Toạ độ xuất phát ở bước i Output h; Try(i, x, y) { for(k = 0; k <= 7; k++) { u = x + a[k]; v = y + b[k]; if (1 <= u ,v <= n &&h[u][v] == 0) { h[u][v] = i; if (i < n*n) Try(i+1,u,v); else in(); // In ma trận h h[u][v] = 0; } } } Hàm in(): hàm này sẽ in hành trình của ngựa được lưu trong ma trận h (nếu có) Hàm init(): nhập n, khởi tạo giá trị 0 cho tất cả các phần tử của mảng h. Khi đó ta có chương trình chính: main() { 102 init(); h[x0 ][y0 ] = 1; try(2,x0 , y0); } Với n=5 và x0=1, y0=1 thì thuật toán cho một trong các lời giải là: 1 6 15 10 21 14 9 20 5 16 19 2 7 22 11 8 13 24 17 4 25 18 3 12 23 Hình 4.2. Một lời giải Nếu chọn ô xuất phát chẳng hạn là (2,3) thì bài toán không có lời giải. 103 1.Cho một dãy số nguyên a1, a2, ..., an và một số nguyên S. Liệt kê tất cả các cách
phân tích S = ai1 + ai2 + ... + aik (ai1, ai2, ..., aik là các số của dãy đã cho) Hướng dẫn: Với mỗi giá trị k từ 1 đến n ta xây dựng các tập con gồm k phần tử của dãy
số nguyên a1, a2, ..., an. Với mỗi tập con này ta tính tổng các phần tử của nó, nếu
tổng bằng S thì hiển thị tổng đó ra. Ta áp dụng thuật toán quay lui để liệt kê các cách phân tích S = ai1 + ai2 + ... + aik Sử dụng : - Mảng a để lưu trữ n số nguyên a1, a2, ..., an - Mảng b với các phần tử b[i] dùng để ghi nhận chỉ số của phần tử của mảng a. Phần tử có chỉ số này của mảng a được gán cho c[i]. - Mảng c dùng để lưu trữ tập con gồm k phần tử xây dựng được. - Hàm khoitao để nhập n, nhập S, nhập n số nguyên a1, a2, ..., an - Hàm Try để xây dựng các tập con gồm k phần tử. - Hàm ht để hiển thị ra tổng thoả mãn điều kiện. Với mỗi tập con gồm k phần
tử lấy từ n số nguyên a1, a2, ..., an, hàm ht sẽ kiểm tra xem tổng của k phần tử này
có bằng S không? Nếu bằng thì hiển thị tổng đó ra. try(int i) {int j; for(j=b[i-1]+1;j<=n-k+i;j++) { b[i]=j; c[i]=a[j]; if(i==k) ht(c); else try(i+1); } } main() { khoitao(); 104 for(k=1;k<=n;k++) try(1); getch(); 2. Cho một dãy gồm n số nguyên a1, a2, ..., an. Hãy đưa ra các tổng đại số thành lập
được từ dãy này mà có tổng bằng 0. Ví dụ: Cho các số nguyên 1, 2, 3, 4, 5, 6, 7 thì một tổng đại số bằng 0 thành lập được là: - 1 + 2 - 3 -4 + 5 - 6 + 7 =0 Hướng dẫn: Ta phải điền các dấu + hoặc - vào các số từ a1 đến an. Áp dụng thuật toán
quay lui để giải quyết bài toán này, ta sẽ dùng hàm đệ quy Try(i). Giả sử ta đã điền
các dấu’+’ và ’-’ vào các số từ a1 đến ai, bây giờ cần điền dấu cho ai + 1. Ta có thể
chọn một trong hai khả năng: hoặc là điền dấu ’+’, hoặc là điền dấu ’-’ bằng cách gọi đệ quy Try(i+1). Ta sẽ lần lượt duyệt tất cả các khả năng đó để tìm tất cả các nghiệm của bài toán. Nếu i=n ta được một tổng đại số, khi đó kiểm tra xem tổng đại số đó có kết quả bằng 0 hay không. Nếu bằng 0 thì đưa ra tổng đó, ngược lại thì không đưa ra. Ta dùng hàm in() để thực hiện điều này Dùng mảng b mà phần tử bi lưu trữ dấu của số nguyên ai. Qui ước chẳng hạn 0 ứng với dấu + và 1 ứng với dấu - tức là: Nếu bi =0 thì dấu của ai là + Nếu bi =1 thì dấu của ai là - Try(i) { for(j=0;j<=1;j++) { b[i]:=j; if(i==n) in(); else try(i+1); } } in() 105 { s=0; for(i=1;i<=n;i++) if(b[i]==0) s=s+a[i]; else s= s-a[i]; if(s==0) // hiển thị tổng đại số for(i=1;i<=n;i++) if((i==1)&&(b[i]==0))// phần tử đầu dấu + không cần đưa ra dấu printf(a[i]); else if(b[i]==0) printf("+",a[i]); else printf("-",a[i]); } 3. Liệt kê các số có 6 chữ số với các chữ số khác nhau sao cho trong các số đó thì tổng 3 chữ số đầu bằng tổng 3 chữ số cuối. Hướng dẫn: Các số có 6 chữ số có dạng trong đó a1 nhận một trong các giá trị từ 1 đến 9, các chữ số a2, a3, a4, a5, a6 mỗi chữ số có thể nhận một trong các
giá trị từ 0 đến 9. Ta dùng thuật toán quay lui thực hiện liệt kê các số thoả mãn điều
kiện. - Mảng a để lưu trữ số có 6 chữ số khác nhau tìm được (ở đây ta chấp nhận cả số mà chữ số đầu tiên bằng 0, khi hiển thị ta không quan tâm tới những số này) - Mảng b mỗi phần tử dùng để ghi nhận một chữ số đã được dùng hay chưa. Nếu b[j]=0 thì chữ số j chưa được dùng, nếu b[j]=1 thì chữ số j đã được dùng - Hàm khoitao: khởi tạo giá trị các phần tử mảng b và biến d (biến d dùng để dếm các số thoả mãn điều kiện) 106 - Hàm ht kiểm tra số vừa tìm được, nếu thoả mãn điều kiện thì hiển thị số đó ra. - Hàm try để xây dựng các số có 6 chữ số khác nhau. main() { khoitao(); try(1); getch(); } khoitao() {for(i=0;i<=9;i++)b[i]=0; d=0; } ht(x) { if((x[1]!=0)&&(x[1]+x[2]+x[3]==x[4]+x[5]+x[6])) { d++; printf("\n%d: ",d); for(i=1;i<=6;i++)printf("%d",x[i]); } } try(i) { for(j=0;j<=9;j++) if(b[j]==0) { a[i]=j; b[j]=1; if(i==6) ht(a); 107 else try(i+1); b[j]=0; } } 4. Liệt kê các chỉnh hợp không lặp chập k của n số nguyên a1, a2, ..., an (k n). Hướng dẫn: Ta áp dụng thuật toán quay lui để liệt kê các chỉnh hợp không lặp chập k của tập n số nguyên a1, a2, ..., an. - Mảng a để lưu trữ n số nguyên a1, a2, ..., an - Mảng b với các phần tử b[i] dùng để ghi nhận số nguyên tương ứng a[i] đã
được dùng chưa. Nếu b[i]= 0 thì số nguyên a[i] chưa được dùng, nếu b[i]= 1 thì số nguyên a[i] đã được dùng. Đầu tiên b[i] = 0 với mọi i. - Mảng c dùng để lưu trữ chỉnh hợp không lặp chập k của tập n số nguyên a1, a2, ..., an xây dựng được. - Biến d để đếm số chỉnh hợp không lặp chập k của tập n số nguyên a1, a2, ..., an. - Hàm khoitao để nhập n, nhập k, khởi tạo biến đếm d, khởi tạo giá trị cho các phần tử của mảng b. - Hàm nhap để nhập các số nguyên. - Hàm Try để xây dựng các chỉnh hợp không lặp chập k của tập n số nguyên a1, a2, ..., an. - Hàm ht để hiển thị ra chỉnh hợp không lặp chập k của tập n số nguyên a1, a2, ..., an vừa xây dựng được. main() { khoitao(); nhap(); try(1); getch(); } 108 khoitao() { scanf(n); scanf(k); d=0; for(i=1;i<=n;i++) b[i]=0; } try(i) { for(j=1;j<=n;j++) if(b[j]==0) { c[i]=a[j]; b[j]=1; if(i==k) ht(); else try(i+1); b[j]=0; } } 5. Ma phương bậc n là ma trận vuông cấp n với các phần tử là các số tự nhiên từ 1
đến n2 thoả mãn tính chất: Tổng các phần tử trên mỗi hàng, mỗi cột và mỗi đường
chéo đều bằng nhau. Liệt kê các ma phương bậc 3. Hướng dẫn: - Các phần tử của ma trận vuông cấp ba A lần lượt được lưu trữ trong các phần tử từ x[1] đến x[9] của mảng x: a11 được lưu trữ trong x[1] a12 được lưu trữ trong x[2] 109 a13 được lưu trữ trong x[3] a21 được lưu trữ trong x[4] a22 được lưu trữ trong x[5] a23 được lưu trữ trong x[6] a31 được lưu trữ trong x[7] a32 được lưu trữ trong x[8] a33 được lưu trữ trong x[9] - Dùng thuật toán quay lui xây dựng tất cả các hoán vị của 9 phần tử 1, 2, 3,..., 9 Mỗi hoán vị sẽ được lưu trữ trong các phần tử của mảng x. - Mỗi khi được một hoán vị thì kiểm tra điều kiện, tức là kiểm tra các tổng: x[1] + x[2] + x[3]; x[4] + x[5] + x[6]; x[7] + x[8] + x[9] (tổng các hàng) x[1] + x[4] + x[7]; x[2] + x[5] + x[8]; x[3] + x[6] + x[9] (tổng các cột) x[1] + x[5] + x[9]; x[3] + x[5] + x[7] (tổng các đường chéo) nếu các tổng đó bằng nhau thì hiển thị các phần tử của mảng x ra màn hình dưới
dạng ma trận (Nếu i mod 3 = 0 thì xuống hàng) 110 5.1. Nội dung kỹ thuật Kỹ thuật quay lui, vét cạn có thể được sử dụng để giải các bài toán tối ưu tổ hợp bằng cách tiến hành duyệt các phương án của bài toán, đối với mỗi phương án ta đều tính giá trị hàm mục tiêu tại nó, sau đó so sánh giá trị hàm mục tiêu tại tất cả các phương án để tìm ra phương án tối ưu. Tuy nhiên trong thực tế với nhiều bài toán phương pháp áp dụng kỹ thuật này rất khó có thể chấp nhận được về thời gian. Vì vậy cần phải có những biện pháp nhằm hạn chế việc tìm kiếm thì mới có hy vọng giải được các bài toán tối ưu tổ hợp thực tế. Tất nhiên để có thể đề ra những biện pháp như vậy cần phải nghiên cứu kỹ tính chất của bài toán tối ưu tổ hợp cụ thể. Nhờ những nghiên cứu như vậy, trong một số trường hợp cụ thể ta có thể xây
dựng những thuật toán hiệu quả để giải bài toán đặt ra. Khi đó, một vấn đề đặt ra là trong quá trình liệt kê lời giải ta cần tận dụng các thông tin đã tìm được để loại bỏ những phương án chắc chắn không phải là tối ưu. Kỹ thuật nhánh và cận (gọi tắt là nhánh cận) là một cải tiến của kỹ thuật quay lui theo hướng này. Ta sẽ mô tả nội dung của kỹ thuật nhánh cận trên mô hình bài toán tối ưu tổ hợp tổng quát sau: min { f( ): } trong đó D là tập hữu hạn các phần tử. Giả thiết D được mô tả như sau: ... An: x thỏa mãn tính chất P}, với A1 A2 D = { x = (x1, x2, ..., xn)
A1, A2, ..., An là các tập hữu hạn, còn P là tính chất cho trên tích Đềcac ... An. A1 A2 Với giả thiết về tập D nêu trên, chúng ta có thể sử dụng thuật toán quay lui
để liệt kê các phương án của bài toán. Trong quá trình liệt kê theo thuật toán quay
lui, ta sẽ xây dựng dần các thành phần của phương án. Một bộ gồm k thành phần
(a1, a2, ..., ak) xuất hiện trong quá trình thực hiện thuật toán sẽ gọi là phương án bộ
phận cấp k. Kỹ thuật nhánh cận có thể áp dụng để giải bài toán đặt ra nếu như có thể tìm được một hàm g xác định trên tập tất cả các phương án bộ phận của bài toán thỏa
mãn bất đẳng thức sau: g(a1, a2, ..., ak) min{f(x): x D, xi =ai, i= 1,2, ..., k } (*) 111 với mọi lời giải bộ phận ( a1,a2, ..., ak ), và với mọi k= 1,2,... Bất đẳng thức (*) có nghĩa là giá trị của hàm g tại phuơng án bộ phận (a1, a2,
..., ak ) là không vượt quá giá trị nhỏ nhất của hàm mục tiêu của bài toán trên tập con
các phương án D(a1, a2, ..., ak ) = { x D :xi= ai , i = 1, 2, ..., k} hay nói một cách khác, g(a1, a2, ..., ak ) là cận dưới của giá trị hàm mục tiêu trên tập
D(a1, a2,..., ak). Vì lẽ đó, hàm g được gọi là hàm cận dưới , và giá trị g(a1, a2, ..., ak)
được gọi là cận dưới của tập D(a1, a2,..., ak). Do có thể đồng nhất tập D(a1, a2,...,ak)
với phương án bộ phận (a1, a2,..., ak), nên ta cũng gọi giá trị g(a1, a2, ..., ak) là cận
dưới của phương án bộ phận (a1, a2, ..., ak). Giả sử đã có hàm g. Ta xét cách sử dụng hàm này để giảm bớt khối lượng
duyệt trong quá trình duyệt tất cả các phương án theo thuật toán quay lui. Trong quá trình liệt kê các phương án có thể đã thu được một số phương án của bài toán. Gọi
x* là phương án với giá trị hàm mục tiêu nhỏ nhất trong số các phương đã tìm được,
kí hiệu f*= f(x*). Ta sẽ gọi x* là phương án tốt nhất hiện có, còn
là kỷ lục. Giả sử
ta đã có f*. Khi đó nếu g(a1, a2, ..., ak) > f* thì từ bất đẳng thức (*) suy ra f* < g(a1, a2, ..., ak) min {f(x): x D, xi =ai, i= 1,2,..., k }, vì thế tập con các phương án của bài toán D(a1, a2,...,ak) chắc chắn không chứa
phương án tối ưu. Trong trường hợp này ta không cần tiếp tục phát triển phương án
bộ phận (a1, a2,...,ak), nói cách khác là ta có thể loại bỏ các phương án trong tập
D(a1, a2,...,ak) khỏi quá trình tìm kiếm. Thuật toán quay lui liệt kê phương án cần sửa đổi lại như sau Try(k) /* Phát triển phương án bộ phận (a1, a2,...,ak) theo thuật toán quay lui có kiểm tra
cận dưới trước khi tiếp tục phát triển phương án */ { for ak Ak do if ( { xk = ak; 112 if(k == n) else f*) if(g((a1, a2,...,ak) Try(k+1) } } Khi đó, kỹ thuật nhánh cận được thực hiện như sau: Nhánh_cận() { f*= ; /* Nếu biết một phương án x* nào đó ta có thể đặt f*= f(x*) */ Try(1); if (f*< ) < f* là giá trị tối ưu, x* là phương án tối ưu > else < bài toán không có phương án>; } Chú ý rằng nếu trong hàm Try ta thay câu lệnh if(k == n) else f*) if(g((a1, a2,...,ak) Try(k+1) bởi : if(k == n) else Try(k+1) 113 thì hàm Try sẽ liệt kê toàn bộ các phương án của bài toán, và ta thu đựơc thuật toán
duyệt toàn bộ.Việc xây dựng hàm g phụ thuộc vào từng bài toán tối ưu tổ hợp cụ thể. Thông thường ta cố gắng xây dựng nó sao cho: Việc tính giá trị của g phải đơn giản hơn việc giải bài toán tối ưu tổ hợp ở vế phải cuả (*). Giá trị của g( a1, a2,..., ak) phải sát với giá trị của vế phải của (*). Tuy nhiên hai yêu cầu này trong thực tế thường đối lập nhau. 5.2. Các ví dụ áp dụng 5.2.1. Bài toán ngƣời du lịch 1) Bài toán Một người du lịch muốn đi tham quan n thành phố được đánh số là 1, 2, ...,
n. Xuất phát từ một thành phố nào đó người du lịch muốn đi qua tất cả các thành
phố còn lại, mỗi thành phố đúng một lần, rồi quay trở lại thành phố xuất phát. Biết cij là chi phí đi từ thành phố i đến thành phố j ( i, j = 1, 2,…, n), hãy tìm hành trình (một cách đi thoả mãn điều kiện đặt ra) với tổng chi phí là nhỏ nhất. Phân tích bài toán: Với nội dung như vậy của bài toán rất khó hình dung mô hình của nó, ta sẽ
phân tích và đưa về dạng tổng quát. Có hai yếu tố cần phải xác định và chỉ ra: Tập D và hàm mục tiêu F(x). Ta có thể thiết lập tương ứng 1-1 giữa hành trình (1) -> (2) ->…-> (n)-> (1) với một hoán vị = ((1), (2) ,…, (n)) của n số tự nhiên 1, 2, …, n. Đặt F() = c(1,(2) + ... + c(n-1),(n) + c(n),(1) và ký hiệu D là tập tất cả các hoán vị = ((1), (2),…, (n)) của n số tự nhiên 1, 2, …, n. Khi đó bài toán được đưa về dạng tổng quát như sau: min { F() : D }. 2) Thiết kế thuật toán Bài toán người du lịch dẫn về bài toán: Tìm cực tiểu của hàm: f(x1, x2, x3,...,xn) = c[x1, x2] + c[ x2, x3] +...+ c[ xn-1 + xn] + c[ xn, x1] Với điều kiện (x1, x2, x3,...,xn) là hoán vị của các số 1, 2, 3,..., n. 114 input: ma trận chi phí C = (cij) output: phương án tối ưu x* và f*=f(x*) Hàm try() có thể phác thảo như sau: Try (i) { for (j = 1 -> n) if( Chấp nhận J ) { Xác định xi theo j; Ghi nhận trạng thái; if(i == n) Cập nhật phương án tối ưu; else { Xác định cận g(x1 ,..., xi ) ; if( g(x1 ,..., xi ) ≤ f* ) Try (i+1); } Hoàn trả trạng thái; } } - Xác định cận g( x1 ,..., xi ) Ký hiệu Cmin = min{c[ i, j ], i, j = 1,2,...,n, i j } là chi phí đi lại nhỏ nhất giữa các thành phố. Giả sử ta đang có phương án bộ phận (u1, u2,...,ui). phương án này tương ứng với hành trình bộ phận qua i thành phố: u2 ... u1 ui-1 ui Vì vậy, chi phí phải trả theo hành trình bộ phận này sẽ là = c[u1,u2] + c[u2,u3] + ...+ c[ui-1,ui]. 115 Để phát triển hành trình bộ phận này thành hành trình đầy đủ, ta còn phải đi
qua n-k thành phố còn lại rồi quay trở về thành phố u1, tức là còn phải đi qua n-i+1
đoạn đường nữa. Do chi phí phải trả cho việc đi qua mỗi một trong số n-i+1 đoạn đường còn
lại đều không ít hơn cmin nên cận dưới cho phương án bộ phận (u1,u2,..,ui) có thể
tính theo công thức g(u1, u2,..., ui) = + (n-i+1).cmin - Điều kiện chấp nhận được của j là thành phố j chưa đi qua. Ta dùng một mảng Daxet[] để biểu diễn trang thái này với qui ước: + Daxet[j] = 0 : thành phố j chưa được đi qua + Daxet[j] = 1: thành phố j đã được đi qua Mảng Daxet[] được khởi tạo bằng 0 (mọi phần tử bằng 0) - Xác định xi theo j bằng câu lệnh : xi = j - Ghi nhận trạng thái : Daxet[j] = 1. - Cập nhật lại chi phí khi xác định được xi : S = S+ Cxi −1,xi - Cập nhật lời giải tối ưu : Tính chi phí hành trình vừa tìm được : Tong = S + Cxn, 1 ; Nếu (Tong < f*) thì
x* = x; f* = Tong; - Hoàn trả trạng thái : Daxet[j] = 0. Trả lại chi phí cũ : S = S- Cxi −1,xi - Ta cố định thành phố xuất phát là thành phố 1. Các hàm: Init() /* Hàm tính cmin, Ban đầu tất cả các thành phố đều chưa được đến; Khởi tao giá trị
tối ưu, xuất phát từ thành phố 1*/ { Cmin = maxint;//Chi phi nho nhat giua 2 thanh pho for(i = 1; i <= n; i++) Daxet[i] = 0; for(i = 1; i <= n; i++) 116 for(j = 1; j <= n; j++) if(Cmin>C[i][j]) Cmin = C[i][j];
f* = maxint;//Gia tri toi uu f* S = 0; // Chi phi x[1] = 1; // Xuat phat tu dinh 1 } Try(i) /* xác định thành phố đến thứ i*/ { for (j = 2; j <= n; j++) if(!Daxet[j]) { x[i] = j; Daxet[j] = 1; S = S + C[x[i-1]][x[i]]; // cap nhat chi phi if(i==n)//Cap nhat hanh trinh toi uu { Tong = S + C[x[n]][x[1]];
if(Tong < f*) {
x*=x;// phuong an tot nhat hien co
f* = Tong;// f* = f(x*) } } else { g = S + (n-i+1)*Cmin; //Danh gia can
if ( g < f*) Try(i+1); } // Hoan tra trang thai S = S - C[x[i-1]][x[i]]; 117 Daxet[j] = 0; } } Ví dụ 5.1. Giải bài toán người du lịch theo thuật toán trình bày trên với ma trận chi phí: 0 3 14 18 15 3 0 4 22 20 C = 17 9 0 16 4 6 5 7 0 12 9 15 11 5 0 Ta có cmin = 3. Quá trình thực hiện thật toán được mô tả bởi cây tìm kiếm f* = + ∞ (2)
=3
g=15 (3)
= 14
g= 16 (4)
= 18
g = 30 (5)
= 15
g = 27 (2,3)
= 7
g = 16 (2,4)
= 25
g = 34 (2,5)
= 23
g = 32 (2,3,4)
= 23
g = 29 (2,3,5)
= 11
g = 17 Các nhánh này bị loại vì
có cận dưới g > f*= 22 (2,3,4,5)
= 35
g = 38 (2,3,5,4)
= 16
g = 19 Hành trình x*= (1,2,3,4,5)
Chi phí là 44. Đặt f*=44 Hành trình x*= (1,2,3,5,4)
Chi phí là 22. Đặt f*=22 lời giải cho trong hình dưới đây: Hình 5.1. Cây tìm kiếm lời giải 118 Thông tin về một phương án bộ phận trên cây được ghi trong các ô ở trên hình vẽ tuơng ứng theo thứ tự sau: Đầu tiên là các thành phần của phương án, tiếp đến là chi phí theo hành trình bộ phận và g – cận duới. Kết thúc thuật toán , ta thu đựơc phương án tối ưu (1, 2, 3, 5, 4) tương ứng với hành trình 1 -> 2 -> 3 -> 4 ->5->1 với chỉ nhỏ nhất là 22. Một cải tiến của kỹ thuật nhánh cận giải bài toán ngƣời du lịch Ở trên chúng ta đã xem xét một cách xây dựng cận dưới khá đơn giản cho bài
toán người du lịch. Chương trình được cài đặt theo các thuật toán đó, tuy rằng làm việc tốt hơn nhiều so với duyệt toàn bộ, nhưng cũng chỉ có thể áp dụng để giải các bài toán với kích thước nhỏ. Muốn giải được bài toán người du lịch với kích thước lớn cần có cách đánh giá cận tốt hơn. Dưới đây ta sẽ xem xét một trong những
phương pháp xây dựng thuật toán theo kỹ thuật nhánh cho phép giải bài toán người du lịch với kích thước lớn hơn. Mỗi hành trình của người du lịch (1 , 2,.. , n) có thể là viết lại dưới dạng (1, 2), (2, (3),… (n-1, n), (n, 1) Trong đó mỗi thành phần (j-1, j) sẽ được gọi là một cạnh của hành trình. Trong bài toán người du lịch khi tiến hành tìm kiếm lời giải chúng ta sẽ phân
tập các hành trình ra thành hai tập con: một tập gồm những hành trình chứa một cạnh(i,j) nào đó còn tập kia gồm những hành trình không chứa cạnh này. Ta gọi việc làm đó là phân nhánh và mỗi tập con nói trên sẽ được gọi là nhánh hay một nút tìm Tập tất cả các hành trình HT không chứa
((i,j) hành trình
chứa (i,j) kiếm. Việc phân nhánh được minh hoạ bởi cây tìm kiếm: Hình 5.2. Phân nhánh bởi cạnh (i,j) Việc phân nhánh sẽ được thực hiện trên một quy tắc Ơristic nào đó cho phép
ta rút ngắn quá trình tìm kiếm phương án tối ưu. Sau khi phân nhánh chúng ta sẽ tính cận dưới của giá trị hàm mục tiêu trên mỗi một trong hai tập con nói trên. Việc 119 tìm kiếm sẽ được tiếp tục trên tập con có giá trị cận dưới nhỏ hơn. Thủ tục này sẽ được tiếp tục cho đến khi thu đươc một hành trình đầy đủ, tức là một phương án của bài toán người du lịch. Khi đó ta chỉ cần xét những tập con các phương án nào
có cận dưới nhỏ hơn giá trị mục tiêu tại phương án đã tìm được. Quá trình phân nhánh và tính cận trên tập các phương án của bài toán thông thường cho phép rút ngắn một cách đáng kể quá trình tìm kiếm do ta loại được khá nhiều tập con chắc chắn không chứa phương án tối ưu. Rút gọn ma trận (tính chi phí cận dưới) Rõ ràng tổng chi phí của một hành trình của người du lịch sẽ chứa đúng một phần tử của mỗi dòng và một phần tử của mỗi cột trong ma trận chi phí C Do đó, nếu ta trừ bớt mỗi phần tử của một dòng (hay một cột) của ma trận C đi cùng một số thì độ dài của tất cả các hành trình sẽ cùng giảm đi . Vì thế hành trình tối ưu cũng sẽ không thay đổi. Vì vậy nếu ta tiến hành trừ bớt các phần tử của mỗi dòng và mỗi cột đi một hằng số sao cho thu được ma trận gồm các phần tử không âm mà trong mỗi dòng và mỗi cột của nó đều có ít nhất một số không thì tổng các hằng số trừ đó sẽ cho ta cận dưới của mọi hành trình. Thủ tục trừ bớt này sẽ được gọi là thủ tục rút gọn. Các hằng số trừ ở mỗi dòng (cột) sẽ được gọi là hằng số rút gọn theo mỗi dòng (cột) còn ma trận thu được sẽ gọi là ma trận rút gọn. Reduce(A,k) /* Hàm này rút gon ma trận A có kích thước k*/ { sum:= 0 for(i=1;i<=k;i++) { r[i]= if r[i]>0 then { ; sum := sum +r[i]; } } for(j=1;j<=k;j++) 120 { s[j] = if s[j] >0 then { ; sum := sum +s[j] ; } } return(sum); } Chọn cạnh phân nhánh và phân nhánh Để phân các hành trình thành hai nhánh, ta phải chọn một cạnh (i, j) từ đó
phân các hành trình thành hai nhánh: một nhánh gồm những hành trình có chứa cạnh (i, j), một nhánh gồm những hành trình không chứa canh (i, j). Để chọn cạnh (i, j) trong ma trận rút gọn ta tìm số 0 nào mà khi ta thay nó bằng thì sẽ được tổng các hằng số rút gọn là lớn nhất khi đó cạnh (i, j) cần chọn sẽ chính là cạnh ứng với số 0 này. Intput: Ma trận rút gọn A kích thước k k Output: Cạnh phân nhánh (r,c) tổng hằng số rút gọn theo dòng r cột c là beta. BestEage (A,k,r,c,beta) /* Hàm tìm cạnh phân nhánh*/ { Beta:=-; for(i=1;i<=k;i++) for(j=1;j<=k;j++) If (a[i,j] ==0) { Minr = < phần tử nhỏ nhất trên dòng i khác với a[i;j] >; Minc =< phần tử nhỏ nhất trên cột j khác với a[i;j] >; Total = minr + minc; 121 If total > beta then { Beta = total: r = i ; /* chỉ số dòng của cạnh tốt nhất */ c = j ; /* chỉ số cột của cạnh tốt nhất */ } } return(i,j); } Khi chọn được cạnh phân nhánh (i, j) ta sẽ phân các hành trình thành hai nhánh: - Với nhánh gồm các hành trình có chứa cạnh (i, j) ta thay số 0 ở hàng i cột j bằng và tiến hành rút gọn ma trận, cận dưới của nhánh này sẽ được cộng thêm tổng các hằng số rút gọc ở hàng i cột j. - Với nhánh gồm những hành trình không chứa cạnh (i, j) ta xoá khỏi ma trận hàng i cột j, ngăn cấm việc tạo thành hành trình con và rút gọn ma trận (nếu được) Ngăn cấm tạo thành hành trình con Trong quá trình phân nhánh (theo nhánh có cận dưới nhỏ hơn) luôn phải chú ý ngăn cấm việc tạo thanh hành trình con. Đó là hành trình chưa đi qua tất cả các thành phố. Khi phân nhánh dựa vào cạnh (i, j) ta phải thêm cạnh này vào hành trình. ở mỗi bước khi lựa chọn cạnh để kết nạp ta cần phải xét xem nó có tạo với các cạnh đã có một hành trình con không, nếu có cần phải loại bỏ cạnh này và chọn
cạnh có độ ưu tiên tiếp theo. Việc ngăn cấm tạo thành hành trình con có thể thực hiện như sau: Input S danh sách các cạnh đã chọn, (i, j) cạnh mới. Nếu N(S) < n thì: - Sắp xếp lại các cạnh trong tập S’ = S (i, j) theo dạng danh sách - Nếu đỉnh đầu trùng với đỉnh cuối trong S’ thì kt=1, ngược lại kt=0 và S = S’ Output kt, S. 122 Tiếp tục việc chọn cạnh phân nhánh (và ngăn cấm việc tạo thành hành trình
con) thì kích thước của ma trận chi phí C sẽ giảm dần theo một nhánh (nhánh trái). Cuối cùng ma trận sẽ còn kích thước 2 x 2 thì chấm dứt việc phân nhánh và kết nạp
hai cạnh còn lại để thu được hành trình của người du lịch. Ma trận rút gọn cuối
cùng này chỉ có thể có một trong hai dạng sau: w x w x u 0 u 0 v 0 v 0 trong đó u,v,w, x có thể là bốn đỉnh khác nhau hoặc chỉ có 3 đỉnh khác nhau. Khi đó
ta sẽ chọn đưa vào hành trình các cạnh tương ứng với các phần tử 0 và ta được một hành trình T với chi phí thực tế là G. Nếu giá trị G nhỏ hơn tất cả các cận dưới của các nhánh đã bỏ qua thì hành trình nhận được là hành trình tối ưu, nếu không thì G lớn hơn giá trị cận dưới của một nhánh nào đó và khi đó thì hành trình nhận được chưa phải là hành trình tối ưu, ta tiếp tục áp dụng thuật toán nhánh cận cho nhánh này để tìm hành trình mới. Các bước chính của thuật toán nhánh cận giải bài toán người du lịch được mô tả trong hàm đệ qui TSP sau đây. Hàm TSP xét hành trình bộ phận với Edges cạnh đã được chọn và tiến hành tìm kiếm tiếp theo. Ta sử dụng các biến: Edges - số cạnh trong hành trình bộ phận ; A - ma trận chi phí tương ứng với kích thước (n-edges) (n-edges); cost -chi phí của hành trình bộ phận. Mincost - chi phí của hành trình tốt nhất đã tìm được. Trong hàm sử dụng hàm Reduce(A, K) và hàm BestEdge(A, k, r, c, beta) TSP(edges, cost, A) { cost= cost +reduce(A, n-edges); if(edges == n-2) { ; Mincost = cost; } 123 else { BestEdge(A,n-edges,g,c,beta); lowerBound= cost + beta; TSP(edges+1, cost, newA); /* đi theo nhánh trái */ If(LowerBound { /*đi theo nhánh phải*/ A[r,c]= ; TSP(edges, cost, A); A[r,c]=0 ; } } vào các dòng và các cột tương ứng */ } Ví dụ 5.2. ưu cho bài toán người du lịch với ma trận chi phí như sau: 2 3 4 5 1 1 7 11 6 8 C = 2 4 9 12 7 3 16 8 5 10 4 6 7 9 16 5 15 9 18 5 - Rút gọn ma trận 124 2 3 4 5 1 1 0 2 0 0 6 C = 2 0 2 8 1 4 3 11 2 0 3 5 4 0 0 0 8 6 5 10 3 10 0 5 1 3 2 Tổng các hằng số rút gọn: 6 + 4 + 5 + 6 + 5 + 1 + 3 + 2 = 32. Đây là cận dưới của
mọi hành trình. * Chọn cạnh phân nhánh Ta có số 0 ở hàng 5 cột 4 là số 0 mà khi thay bằng sẽ cho tổng các hằng số rút gọn là lớn nhất. Do đó ta sẽ chọn cạnh (5, 4) để phân nhánh. - Nhánh gồm các hành trình không chứa cạnh (5, 4) Với nhánh này ta thay số 0 ở hàng 5 cột 4 bằng rồi rút gọn ma trận thì ta được ma trận rút gọn là: 1 2 3 4 5 1 0 2 0 0 2 0 2 8 1 3 11 2 0 3 4 0 0 0 8 5 7 0 7 Cận dưới của nhánh là 35 - Nhánh gồm các hành trình có chứa cạnh (5, 4) Với nhánh này ta xoá khỏi ma trận rút gọn hàng 5 cột 4, thay giá trị ở hàng 4 cột 5 bằng (ngăn cấm việc tạo thành hành trình con) và rút gọn ma trận, cụ thể bớt mỗi phần tử ở hàng 3 một lượng là 2. Ta được ma trận rút gọn của nhánh này và
cận dưới của nhánh là 34. 125 2 3 5 1 1 0 2 0 2 0 2 1 3 9 0 1 4 0 0 0 Với nhánh này ta lặp lại việc chọn cạnh phân nhánh. Cụ thể, ta chọn cạnh (4, 3) để phân nhánh. - Với nhánh không chứa cạnh (4, 3) ta thay số 0 ở hàng 4 cột 3 bằng rồi rút gọn ma trận ta được cận dưới của nhánh là 36 và ma trận rút gọn của nhánh là: 1 2 3 5 1 0 0 0 2 0 0 1 3 9 0 1 4 0 0 - Với nhánh có chứa cạnh (4, 3) ta xoá khỏi ma trận hàng 4 cột 3, thay giá trị ở hàng 3 cột 5 bằng (ngăn cấm việc tạo thành hành trình con) ta được ma trận rút gọn của nhánh và cận dưới của nhánh là 34 1 2 5 1 0 0 2 0 1 3 9 0 - Với nhánh này ta lặp lại việc chọn cạnh phân nhánh. Cụ thể ta chọn cạnh (2, 1) để phân nhánh. - Với nhánh không chứa cạnh (2, 1) ta thay số 0 ở hàng 2 cột 1 bằng rồi rút gọn ma trận ta được cận dưới của nhánh là 44 và ma trận rút gọn của nhánh là: 1 2 5 1 0 0 2 0 3 0 0 126 - Với nhánh có chứa cạnh (2, 1) ta xoá khỏi ma trận hàng 2 cột 1, thay các giá trị ở hàng 3 cột 5 và hàng1 cột 2 bằng (ngăn cấm việc tạo thành hành trình con) ta được ma trận rút gọn của nhánh và cận dưới của nhánh là 34 2 5 1 0 3 0 Đến đây ta bổ sung thêm hai cạnh ứng với hai số 0 trong ma trận rút gọn trên ta được một hành trình đầy đủ gồm các cạnh: (5, 4); (4, 3); (2, 1); (1, 5); (3, 2) Chi phí của hành trình này là: 5 + 9 + 4 + 8 + 8 = 34. Tập tất cả
các hành
trình Tập hành trình
không chứa
(5, 4) Cận dưới = 32 Cận dưới = 35 Tập các hành
trình không chứa
(4,3) Tập các hành
trình chứa
(5, 4) Cận dưới = 34 Cận dưới = 36 Tập các hành
trình chứa
(4,3) Tập các hành trình
không chứa
(2,1) Cận dưới = 34 Cận dưới = 44 Tập các hành
trình chứa
(2,1) Cận dưới =34 Quá trình trên được mô tả như sau: Ta được một hành trình gồm các cạnh: (5, 4); (4, 3); (2, 1); (1, 5); (3, 2) có chi phí 34 Hình 5.3. Quá trình phân nhánh 127 Ta thấy tất cả các nhánh còn lại đều có cận dưới lớn hơn 34, do đó không cần
phải phát triển tiếp nữa và khẳng định hành trình tìm được ở trên là hành trình tối ưu. 5.2.2. Bµi to¸n chiÕc ba l« 1) Bµi to¸n Cho một cái ba lô có thể đựng một trọng lượng m và n loại đồ vật. Đồ vật i
có trọng lượng wi và giá trị vi. Tất cả các loại đồ vật đều có số lượng không hạn chế.
Tìm một cách lựa chọn các đồ vật đựng vào ba lô sao cho tổng trọng lượng không
vượt quá m và tổng giá trị là lớn nhất. (Với giả thiết m, wi, vi là các số nguyên) Phân tích bài toán: Ký hiệu tập các số nguyên không âm là Z* } Đặt : D = {x=(x1, x2, ..., xn): xi Z* và Thì D là tập các phương án. Ta xây dựng hàm f(x) với xD như sau: f(x)= Khi đó bài toán chiếc ba lô chuyển về bài toán sau : Tìm x* D : f* = f(x*) =max { f(x ): xD} Ta sẽ kết hợp đánh giá nhánh cận trong quá trình liệt kê các lời giải theo kỹ
thuật quay lui. 2) Thiết kế thuật toán Mô hình ban đầu có thể sử dụng như sau : Try(i) { for(j = 1 ? t) if(Chấp nhận j) { Xác định xi theo j; Ghi nhận trạng thái; if(i==n) 128 Cập nhật lời giải tối ưu; else { Xác định cận trên g; if( g(x1,..., xi) ≤ f*) Try(i+1); } Hoàn trả trạng thái; } } - Cách chọn vật : Đánh số các loại đồ vật từ 1 đến n. i=1, 2,..., n Tính đơn giá cho các loại đồ vật dgi = Sắp xếp vật theo thứ tự giảm dần của đơn giá và chọn vật theo thứ tự này. - Đánh giá cận trên : Giả sử đã tìm được lời giải bộ phận : (x1 , x2, ..., xk ) . Khi đó : + Giá trị của các đồ vật trong ba lô sẽ là : S = + Tổng trọng lượng các vật đã được xếp vào ba lô là : T = + Trọng lượng còn lại của ba lô là : mi = m – T = m - Giả sử ta có phương án bộ phận cấp i: (u1, u2, ..., ui) khi đó ta có: max{f(x): x=(x1, x2, ..., xn)D; xj=uj j=1, 2, ..., i } = max{S + : } 129 = S + max{ : ] } S + vi+1[ ( [ ] là phần nguyên của ) Do đó cận trên cho phương án bộ phân cấp i có thể được xác định là: ] g(u1, u2, ..., ui) = S + vi+1[ - Các giá trị có thể chấp nhận được cho xi+1 là: ] j = 0 -> [ - Ghi nhận trạng thái khi xác định xi: + Cập nhật tổng giá trị các vật trong ba lô: S = S + xivi + Cập nhật trọng lượng còn lại của ba lô: T = T - xiwi - Hoàn trả trạng thái : + S = S - xivi + T = T + xiwi - Cập nhật lời giải tối ưu : Khi tìm được một lời giải, ta so sánh lời giải này với lời giải mà ta coi là tốt
nhất đang có để chọn lời giải tối ưu. • Các khởi tạo giá trị ban đầu : + x* = 0 ;//Lời giải tối ưu của bài toán + f* = f(x*) = 0;// Giá trị tối ưu + S = 0;//Giá trị thu được từng bước của ba lô. + T =0 ;//Trọng lượng xếp vào ba lô từng bước. Hàm tr(y) sẽ được làm mịn hơn như sau: input: m, n v=(v1, . . ., vn) w=(w1,..., wn ) Output: 130 x* = (x1,..., xn) f* = f(x*) =max { f(x ): xD} Try(i) { t = (m-T)/wi ; for (j = t; j >=0 ; j--) { xi = j; // Ghi nhan trang thai T = T + wi*xi ; S = S + xi*vi; if(i==n)//Cap nhat toi uu { if(S > f*) { x* = x; f* = S; } } else { g = S + vi+1*(m-T)/wi+1; //Danh gia can if ( g > f*) Try(i+1); } // Hoan tra trang thai T = T – wi*xi; S = S - vi*xi; } 131 } Ví dụ 5.3. Giải bài toán chiếc ba lô với chiếc ba lô có thể đựng được trọng lượng m = 8, có 4 loại đồ vật với trọng lượng và giá trị của từng loại như sau: Đồ vật 1 2 3 4 Trọng lượng 5 3 2 4 Giá trị 10 5 3 6 Hình 5.4. Trọng lƣợng và giá trị của 4 loại đồ vật Quá trình giải bài toán trên theo thuật toán nhánh cận được mô tả trong cây f* = 0 x1=1 x1=0 (1):S=10;
T=5; g=15; (1):S=0;
T=0; g=10; x2=1 x2=0 (1,1):S=15;
T=8; g=15; tìm kiếm sau: x3=0 (1,1,0):S=15
T=8; g=15; C¸c nh¸nh nµy bÞ lo¹i v×
cã cËn d íi g < f*= 15 x4=0 (1,1,0,0):
S=15;T=8; x*= (1,2,3,4,5)
f*=15 Hình 5.5. Cây tìm kiếm 132 1. Dùng thuật toán nhánh cận (bằng cách chọn cạnh phân nhánh) tìm hành trình tối
ưu của người du lịch có ma trận chi phí sau: 1 2 3 4 5 6 1 5 23 6 18 5 C = 2 4 9 12 7 11 3 16 8 5 10 7 4 6 7 9 16 21 5 15 9 18 5 30 6 8 12 7 4 28 2. Giải bài toán chiếc ba lô sau bằng thuật toán nhánh cận: Chiếc ba lô có thể đựng được trọng lượng 19, có 4 loại đồ vật với số lượng mỗi loại là không hạn chế. Trọng lương và giá trị của từng loại đồ vật được cho trong bảng sau: Đồ vật 1 2 3 4 Trọng lượng 7 6 4 2 Giá trị 17 8 6 3 3. Giải bài toán chiếc ba lô sau bằng thuật toán nhánh cân: 16x1 + 9x2 + 7x3 + 5x4 -> max 6x1 + 5x2 + 3x3 + 2x4 17 xi 0, nguyên, i = 1, 2, 3, 4. 4. Viết chương trình giải bài toán chiếc ba lô bằng thuật toán nhánh cận. 5. Dãy ABC Cho trước một số nguyên dương n (n1000). Hãy tìm một xâu chỉ gồm các ký tự A, B, C thoả mãn điều kiện: a. Có độ dài n b. Hai đoạn con bất kỳ liền nhau đều khác nhau (đoạn con là một dãy ký tự liên tiếp của xâu) 133 c. Có ít ký tự C nhất Hướng dẫn: Ta coi xâu gồm n ký tự là một dãy x= (x1, x2, ..., xn). Ta nhận thấy rằng nếu
dãy x thoả mãn điều kiện: hai đoạn con bất kỳ liền nhau đều khác nhau thì với 4 ký tự liền nhau bất kỳ luôn phải có ít nhất một ký tự 'C'. Như vậy một dãy gồm k ký tự
liên tiếp của dãy x thì phải có ít nhất [k/4] ký tự 'C'. Giả sử với phương án bộ phận (a1, a2, ...ak) nếu ta đã có Sk ký tự 'C' trong
đoạn đã chọn từ a1 đến ak thì ở các bước chọn tiếp theo để được đủ n ký tự ta phải
chọn ít nhất [(n-k)/4] ký tự 'C'. Tức là nếu theo phương án bộ phận này thì xâu kết
quả sẽ có số ký tự 'C' ít nhất là Sk + [(n-k)/4]. Do vậy hàm cận dưới sẽ được xác
định như sau: g(a1, a2, ...ak)= Sk + [(n-k)/4] Thiết kế thuật toán: Dữ liệu: - Mảng x lưu trữ phương án đang xây dựng - Mảng t lưu trữ phương án tốt nhất tìm được - Mảng s có s[i] lưu trữ số ký tự 'C' của phương án bộ phận (a1, a2, ...ak) Các hàm: /*hàm kt1(i,l) cho biết đoạn gồm l ký tự kết thúc tại x[i] có khác đoạn gồm l ký tự liền trước đó không*/ kt1(i,l) { j=i-1; //j là vị trí cuối đoạn liền trước đó for(k=0;k<=l-1;k++) if(x[i-k]!=x[j-k]) return(1); return(0); } /*hàm kt(i) cho biết x[i] có thoả mãn yêu cầu b trong đoạn từ x[1] đến x[i] không*/ kt(i) { 134 for(l=1;l<=i/2;l++) // kiểm tr các đoạn độ dài l if(kt1(i,l)) //nếu có đoạn độ dài l trùng với xâu liền trước return(0); return(1) } /*hàm ketqua() giữ kết quả vừa tìm được: min lưu trữ số ký tự 'C' ít nhất; gán các
phần tử mảng x cho các phần tử mảng t*/ ketqua() { min=s[n] t=x; } /*hàm try(i): xác định thành phần thứ i của x */ try(i) { for(j='A';j<='C';j++) { x[i]=j; if(kt(i)) // x[i]=j thoả mãn điều kiện b trong đoạn từ x[1] đến x[i] { if(j=='C') s[i]=s[i-1]+1; else s[i]=s[i-1]; if(s[i]+(n-i)/4 if(i==n) ketqua(); else try(i+1); } } 135 } /*hàm hienthi(): hiển thị mảng t và min */ hienthi() { for(i=1;i printf(t[i]); printf(min); } main() { scanf(n); s[0]=0; min=n; try(1); hienthi(); } 136 6.1. Néi dung kü thuËt Giống như kỹ thuật chia để trị, quy hoạch động giải quyết các bài toán bằng
cách tổ hợp các nghiệm của bài toán con. Kỹ thuật chia để trị phân hoạch bài toán thành các bài toán con độc lập, giải quyết các bài toán con một cách đệ quy, rồi tổ
hợp các nghiệm của chúng để giải quyết bài toán ban đầu. Ngược lại, quy hoạch động có thể áp dụng khi các bài toán con không độc lập, nghĩa là khi các bài toán con chia sẻ các bài toán cháu. Trong ngữ cảnh này, một thuật toán chia để trị thực hiện công việc nhiều hơn mức cần thiết, liên tục giải quyết các bài toán con chung.
Trong khi đó một thuật toán theo kỹ thuật quy hoạch động giải quyết mọi bài toán con nhất loạt rồi lưu đáp án vào một bảng, nhờ đó tránh được việc phải tính toán lại mỗi khi gặp lại bài toán con. Kỹ thuật quy hoạch động thường được áp dụng cho các bài toán tối ưu hoá.
Trong các bài toán như vậy thường có thể có nhiều giải pháp khả dĩ. Mỗi giải pháp có một giá trị, và ta muốn tìm một giải pháp có giá trị tối ưu – cực tiểu hoặc cực đại. Ta gọi kiểu giải pháp như vậy là một giải pháp tối ưu cho bài toán, trái với tối ưu nghiệm, bởi có thể có vài nghiệm đạt được giá trị tối ưu. Các bƣớc thực hiện quy hoạch động Bước 1: Lập hệ thức Tìm cách chia quá trình giải bài toán thành từng giai đoạn, sau đó tìm hệ thức biểu diễn tương quan quyết định của bước đang xử lý với các bước đã xử lý trước đó. Hoặc tìm cách phân rã bài toán thành các “bài toán con” tương tự có kích thước nhỏ hơn, tìm hệ thức nêu quan hệ giữa kết quả bài toán kích thước đã cho với kết quả của các “bài toán con” cùng kiểu có kích thước nhỏ hơn của nó nhằm xây dựng phương trình đệ quy. Về một cách xây dựng phương trình đệ quy: Ta chia việc giải bài toán thành n giai đoạn. Mỗi giai đoạn i có trạng thái ban
đầu là t(i) và chịu tác động điều khiển d(i) sẽ biến thành trạng thái tiếp theo t(i+1)
của giai đoạn i+1 (i=1,2,…,n-1). Theo nguyên lý tối ưu của Bellman thì việc tối ưu giai đoạn cuối cùng không làm ảnh hưởng đến kết quả toàn bài toán. Với trạng thái
ban đầu là t(n) sau khi làm giai đoạn n tốt nhất ta có trạng thái ban đầu của giai
đoạn n-1 là t(n-1) và tác động điều khiển của giai đoạn n-1 là d(n-1), có thể tiếp tục
xét đến giai đoạn n-1. Sau khi tối ưu giai đoạn n-1 ta lại có t(n-2) và d(n-2) và lại có 137 thể tối ưu giai đoạn n-2 … cho đến khi các giai đoạn từ n giảm đến 1 được tối ưu thì
coi như hoàn thành bài toán. Gọi giá trị tối ưu của bài toán tính đến giai đoạn k là Fk
giá trị tối ưu của bài toán tính riêng ở giai đoạn k là Gk thì Fk = Fk-1 + Gk Hay là: Bước 2: Tổ chức dữ liệu và chƣơng trình Tổ chức dữ liệu sao cho đạt các yêu cầu sau: - Dữ liệu được tính toán dần theo các bước. - Dữ liệu được lưu trữ để giảm lượng tính toán lặp lại. - Kích thước miền nhớ dành cho lưu trữ dữ liệu càng nhỏ càng tốt, kiểu dữ liệu được chọn phù hợp, nên chọn đơn giản dễ truy cập. Cụ thể: - Các giá trị của Fk thường được lưu trữ trong một bảng (mảng một chiều hoặc hai, ba, v.v… chiều). quả của các bài - Cần lưu ý khởi tạo các giá trị ban đầu của bảng cho thích hợp, đó là các kết
toán đang
toán con có kích cỡ nhỏ nhất của bài giải: - Dựa vào công thức, phương trình đệ quy (*) và các giá trị đã có trong bảng để tìm dần các giá trị còn lại của bảng. - Ngoài ra còn cần mảng lưu trữ nghiệm tương ứng với các giá trị tối ưu trong từng gian đoạn. - Dựa vào bảng lưu trữ nghiệm và bảng giá trị tối ưu trong từng giai đoạn đã xây dựng, tìm ra kết quả bài toán. Bước 3: Làm tốt Làm tốt thuật toán bằng cách thu gọn hệ thức (*) và giảm kích thước miền
nhớ. Thường tìm cách dùng mảng một chiều thay cho mảng hai chiều nếu giá trị
một dòng (hoặc cột) của mảng hai chiều chỉ phụ thuộc một dòng (hoặc cột) kề
trước. Trong một số trường hợp có thể thay mảng hai chiều với các giá trị phần tử chỉ nhận giá trị 0, 1 bởi mảng hai chiều mới bằng cách dùng kỹ thuật quản lý bit. Vậy khi nào thì ta phải sử dụng phương pháp quy hoạch động cho một bài toán. 138 Dưới đây ta sẽ xem xét 2 nhân tố chính mà bài toán tối ưu hoá cần phải có để áp
dụng phương pháp quy hoạch động, đó là: cấu trúc con tối ưu và các bài toán phủ chồng. * Cấu trúc con tối ƣu: Bước đầu tiên trong quá trình giải quyết một bài toán tối ưu hoá bằng quy
hoạch động đó là định rõ đặc điểm cấu trúc của một giải pháp tối ưu. Ta nói rằng một bài toán thể hiện cấu trúc con tối ưu nếu một giải pháp tối ưu cho bài toán chứa
trong nó các cấu trúc con tối ưu cho bài toán con. Mỗi khi một bài toán thể hiện cấu trúc con tối ưu, nó là dấu hiệu tốt cho biết có thể áp dụng phương pháp quy hoạch động. Cấu trúc con tối ưu của một bài toán thường gợi ý một không gian thích hợp của các bài toán con ở đó có thể áp dụng phương pháp quy hoạch động. Thông
thường có vài lớp bài toán con mà ta có thể xem là tự nhiên cho bài toán quy hoạch động. Một thuật toán quy hoạch động dựa trên không gian các bài toán con này sẽ giải quyết thêm nhiều bài toán hơn so với mức bắt buộc. Quá trình kiểm tra cấu trúc con tối ưu của một bài toán lặp lại trên các trường
hợp bài toán con cho ta một cách tốt để nhận biết một không gian các bài toán con thích hợp cho phương pháp quy hoạch động. * Các bài toán con phủ chồng Thành tố thứ hai mà một bài toán tối ưu hoá phải có để có thể áp dụng kỹ thuật
quy hoạch động đó là không gian các bài toán con phải nhỏ theo nghĩa là một thuật toán đệ quy cho bài toán sẽ giải quyết lặp lại các bài toán con tương tự, thay vì luôn phát sinh các bài toán con mới. Khi một thuật toán đệ quy đề cập đến cùng một vấn đề của bài toán, ta nói rằng bài toán tối ưu hoá có các bài toán con phủ chồng. Ngược lại, một bài toán thích hợp với cách tiếp cận chia để trị thường phát sinh các bài toán hoàn toàn mới tại mỗi bước của đệ quy. Các thuật toán quy hoạch động thường vận dụng các bài toán con phủ chồng bằng cách giải quyết từng bài toán con một rồi lưu trữ giải pháp trong một bảng ở đó nó có thể được tra cứu khi cần, dùng
thời gian bất biến cho mỗi lần tra cứu. Hạn chế của quy hoạch động Việc tìm công thức, phương trình đệ quy hoặc tìm cách phân rã bài toán
nhiều khi đòi hỏi sự phân tích tổng hợp rất công phu, dễ sai sót, khó nhận ra như thế
nào là thích hợp, đòi hỏi nhiều thời gian suy nghĩ. Đồng thời không phải lúc nào kết
hợp lời giải của các bài toán con cũng cho kết quả của bài toán lớn hơn. 139 Khi bảng lưu trữ đòi hỏi mảng hai, ba chiều … thì khó có thể xử lý dữ liệu với kích cỡ mỗi chiều lớn hàng trăm. Có những bài toán không thể giải được bằng quy hoạch động. 6.2. C¸c vÝ dô ¸p dông 6.2.1. TÝnh số tổ hợp Một bài toán khá quen thuộc là tính số tổ hợp chập k của n theo công thức truy hồi: 1 nếu k = 0 hoặc k = n C + C nếu 0 < k < n Công thức trên đã gợi ý cho chúng ta một giải thuật đệ quy như sau: Comb(n, k) { if (k==0) || (k==n) return(1); else return(Comb(n-1,k-1) + Comb(n-1,k)); } Gọi T(n) là thời gian để tính số tổ hợp chập k của n, thì ta có phương trình đệ quy: T(1) = C1 T(n) = 2T(n-1) + C2 Giải phương trình này ta được T(n) = O(2n). Thật vậy: T(n) = 2T(n-1) + C2 = 2(2T(n-2) + C2) + C2 = 4T(n-2) + 3C2 ...
= 2iT(n-i) + (2i -1)C2 ...
= 2n-1T(1) + (2n-3)C2
= 2n-1C1 + (2n-3)C2 Do đó T(n) = O(2n) 140 Như vậy là một giải thuật thời gian mũ, trong khi chỉ có một đa thức các bài toán con. Ðiều đó chứng tỏ rằng có những bài toán con được giải nhiều lần. Chẳng hạn: Để tính Comb(4,2) ta phải tính Comb(3,1) và Comb(3,2). Ðể tính Comb(3,1) ta phải tính Comb(2,0) và Comb(2,1). Ðể tính Comb(3,2) ta phải tính Comb(2,1) và Comb(2,2). Như vậy để tính Comb(4,2) ta phải tính Comb(2,1) hai lần. Áp dụng kĩ thuật quy hoạch động để khắc phục tình trạng trên, ta xây dựng một bảng O gồm n+1 dòng (từ 0 đến n) và n+1 cột (từ 0 đến n) và điền giá trị cho O(i,j) theo quy tắc sau: (Quy tắc tam giác Pascal) O(0,0) = 1; O(i,0) =1; O(i,i) = 1 với 0 < i n; O(i,j) = O(i-1,j-1) + O(i-1,j) với 0 < j < i < n. Chẳng hạn với n = 4 ta có bảng dưới đây: 0 1 2 3 4 0 1 1 1 1 2 1 2 1 3 1 3 3 1 4 1 4 6 4 1 Hình 6.1. Quy tắc tam giác Pascal O(n,k) chính là Comb(n,k) và ta có giải thuật như sau: Comb(n, k) { C[0][0] = 1; for(i=1;i<=n;i++) { 141 C[i][0] = 1; C[i][i] = 1; for(j=1;j<=i-1;j++) C[i][j] =C[i-1][j-1] + C[i-1][j]; } return(C[n][k]) } Phép toán tích cực là phép toán C[i][j] =C[i-1][j-1] + C[i-1][j] có thời gian là O(1). Vòng lặp for(j=1;j<=i-1;j++) thực hiện i-1 lần, mỗi lần O(1). Vòng lặp
for(i=1;i<=n;i++) có i chạy từ 1 đến n, nên nếu gọi T(n) là thời gian thực hiện giải thuật thì ta có: Vậy T(n) = O(n2) Nhận xét: Thông qua việc xác định độ phức tạp, ta thấy rõ ràng giải thuật quy hoạch
động hiệu quả hơn nhiều so với giải thuật đệ qui (n2 < 2n). Tuy nhiên việc sử dụng
bảng (mảng hai chiều) như trên còn lãng phí ô nhớ, do đó ta sẽ cải tiến thêm một bước bằng cách sử dụng véctơ (mảng một chiều) để lưu trữ kết quả trung gian. Cách làm cụ thể như sau: Ta sẽ dùng một véctơ V có n+1 phần tử từ V[0] đến V[n]. Véctơ V sẽ lưu trữ các giá trị tương ứng với dòng i trong tam giác Pascal ở trên. Trong đó V[j] lưu trữ giá trị số tổ hợp chập j của i (j = 0 đến i). Dĩ nhiên do chỉ có một véctơ V mà phải n. lưu trữ nhiều dòng i do đó tại mỗi bước, V chỉ lưu trữ được một dòng và ở bước
cuối cùng, V lưu trữ các giá trị ứng với i = n, trong đó V[k] chính là Ck Khởi đầu, ứng với i =1, ta cho V[0] = 1 và V[1] = 1. Tức là: = 1 và = 1. Với các giá trị i từ 2 đến n, ta thực hiện như sau: - V[0] được gán giá trị 1 tức là = 1. Tuy nhiên giá trị V[0] = 1 đã được gán ở trên, không cần phải gán lại. 142 - Với j từ 1 đến i-1, ta vẫn áp dụng công thức = . Nghĩa là để tính các giá trị trong dòng i ta phải dựa vào dòng i-1. Tuy nhiên do chỉ có một véctơ V và lúc này nó sẽ lưu trữ các giá trị của dòng i, tức là dòng i-1 sẽ không còn. Để khắc phục điều này ta dùng thêm hai biến trung gian p1 và p2. Trong đó p1 dùng để lưu trữ và p2 dùng để lưu trữ . Khởi đầu p1 được gán V[0] tức là và p2 được gán V[j] tức là , V[j] lưu trữ giá trị sẽ được gán bới p1+p2, sau đó p1 được gán bởi p2, nghĩa là khi j tăng lên 1 đơn vị thành j+1 thì p1 là và nó được dùng để tính . - Cuối cùng với j = i thực hiện gán V[i] giá trị 1 tức là = 1. Giải thuật: Comb(n, k ) { V[0] = 1; V[1] = 1; for(i=2;i<=n;i++) { p1 := V[0]; for(j=1;i<=i-1;j++) { p2 := V[j]; V[j]:= p1+p2; p1:= p2; } V[i] := 1; } return(V[k]); } Dễ thấy rằng độ phức tạp của giải thuật vẫn là O(n2). 6.2.2. Bµi to¸n nh©n nhiÒu ma trËn 143 1) Bµi to¸n Tính tích các ma trận : A = A1×...× An sao cho số phép tính cần thực hiện là ít nhất (Với giả thiết các phép nhân là thực hiện được). 2) Phân tích bài toán Ta nhận thấy rằng: Do tính kết hợp của phép nhân ma trận, các ma trận Ai có thể nhóm lại theo
nhiều cách khác nhau, mà ma trận kết quả A không đổi. Tuy nhiên có sự khác biệt
về chi phí khi thay đổi các tổ hợp các ma trận Ai. Dưới đây là thuật toán nhân hai ma trận A và B, các thuộc tính rows và columns là số hàng và số cột của ma trận. MATRIX-MULTIPLY(A, B) { if (columns[A] rows[B]) return; else for(i=1;i<=rows[A];i++) for(j=1;j<=columns[B];j++) { C[i, j] = 0 for(k=1;k<=columns[A];k++) C[i, j] = C[i, j] + A[i, k] * B[k, j]; } return C; } Ta chỉ có thể nhân hai ma trận A và B khi và chỉ khi nó là phù hợp: số cột của ma trận A phải bằng số hàng của ma trận B. Nếu A là ma trận cỡ m n và B là ma trận n p thì ma trận kết quả C có cỡ là m p. Thời gian để tính C được quyết định bởi số phép tính C[i, j] = C[i, j] + A[i, k] * B[k, j], đó là m n p. Để minh hoạ cho sự khác nhau về thời gian tính C do cách đặt các cặp dấu
ngoặc đơn khác nhau trong phép nhân các ma trận ta xét các ma trận A, B, C, D với
các kích thước như sau: 144 A30x1 B1x40 C40x10 D10x25 Hãy tính ma trận tích A×B×C×D Ta sẽ thấy chi phí cho phép nhân các ma trận phụ thuộc vào cách tổ hợp các ma trận qua việc thực hiện nhân các ma trận này theo các thứ tự khác nhau và tính chi phí theo từng cách như sau: Thứ tự Chi phí ((AB)C)D 30×1×40 + 30×40×10 + 30×10×25 = 20700 (A(B(CD)) 40×10×25 + 1×40×25 + 30×1×25 = 11750 (AB)(CD) 30×1×40 + 40×10×25 + 30×40×25 = 41200 A((BC)D) 1×40×10 + 1×10×25 + 30×1×25 = 1400 Hình 6.2. Chi phí theo các cách tổ hợp ma trận 3) Ý tƣởng Ta giải bài toán bằng cách tiếp cận từ dưới lên. Ta sẽ tính toán và luu trữ lời giải tối ưu cho từng phần nhỏ để tránh tính toán lại cho bài toán lớn hơn. Trước hết là cho các bộ 2 ma trận, các bộ 3 ma trận . . . Chẳng hạn, để tính A×B×C ta có thể tính theo các cách : (A×B)×C hoặc A×(B×C). Nên chi phí để tính A×B×C là chi phí tính được từ 2 phần : Phần một là chi phí kq1×C, với kq1 = A×B ( chi phí này đã tính và được lưu trữ) Phần hai là chi phí A × kq2, với kq2 = B×C ( chi phí này đã được lưu trữ) So sánh 2 phần trên và lưu trử chi phí nhỏ hơn. . . 4) Thiết kế thuật toán Mấu chốt là tính chi phí nhân bộ các ma trận : Ai×...×Aj , với 1≤ i < j ≤ n, trong đó các bộ nhỏ hơn đã được tính và lưu trử kết quả. Với một cách tổ hợp các ma trận : Ai×...×Aj = (Ai×...×Ak) × (Ak+1×...×Aj) Chi phí để nhân các ma trận Ai,...,Aj sẽ bằng tổng : Chi phí để nhân Ai×...×Ak ( kq1), chi phí để nhân Ak+1×...×Aj ( kq2), và chi phí kq1×kq2. 145 Nếu gọi Mij là chi phí nhỏ nhất để nhân bộ các ma trận Ai×...×Aj ,1≤ i < j ≤ n
thì: * Mik là chi phí nhỏ nhất để nhân bộ các ma trận Ai×...×Ak * Mk+1,j là chi phí nhỏ nhất để nhân bộ các ma trận Ak+1×...×Aj Với ma trận kq1 cỡ di-1×dk và kq2 có cỡ dk×dj , nên chi phí để nhân kq1×kq2 là di-1dkdj. Do đó: i ≤ k ≤ j −1 M ij = Min {M ik + M k +1, j + d i −1 d k d j };1 ≤ i < j ≤ n M ii = 0 Ta có thể xem M là ma trận tam giác trên : (Mij)1≤i Tính Mij , ta cần biết Mik , Mk+1,j. Ta tính bảng dọc theo các đường chéo bắt đầu từ đường chéo kế trên đường chéo chính và thay đổi về hướng góc phải trên. Ta muốn biết thứ tự tốt nhất để nhân dãy các ma trận (theo nghĩa chi phí nhỏ nhất). Mỗi lần ta xác định được tổ hợp tốt nhất, ta dùng biến k để lưu trữ thứ tự này.
Đó là Oij = k, với M ik + M k +1, j + d i −1 d k d j đạt min. Các Mij ta lưu trữ trong mảng 2 chiều M. Các chỉ số k để xác định được Mij ta lưu trữ trong mảng 2 chiều O. Kích thước của các ma trận ta lưu trữ trong mảng 1 chiều d : Ai là ma trận có di-1 hàng , di cột. Thuật toán: Input d = (d0,d1,...,dn); Output M = (Mij) , O = (Oij); MO(d,n,O,M) { for (i = 1; i <= n; i++) M[i][i] = 0; for (diag = 1; diag <= n-1; diag++) for (i = 1; i <= n - diag; i++) 146 { j = i + diag; csm = i; min = M[i][i]+M[i+1][j]+d[i-1]*d[i]*d[j]; for (k= i; k <= j - 1; k++) if (min > (M[i][k]+M[k+1][j]+d[i-1]*d[k]*d[j] )) { min= M[i][k]+M[k+1][j]+d[i-1]*d[k]*d[j]; csm = k; } M[i][j] = min; O[i][j] = csm; } return M[1][n]; } Khi đó dùng hàm sau để xuất thứ tự các tổ hợp nhân ma trận: MOS(i, j, O) { if (i == j) printf(Ai); else { k = O[i][j]; printf("("); MOS(i,k,O); printf("*:); MOS (k+1,j,O); printf(")"); } } Ví dụ 6.1. Thuật toán được minh họa qua phép nhân các ma trận: A1×A2×A3×A4×A5×A6 147 Với kích cỡ của các ma trận là: Ma trận Cỡ 30 35 A1 35 15 A2 15 5 A3 5 10 A4 10 20 A5 20 25 A6 Hình 6.3. Kích cỡ các ma trận 6 1 5 15.125 2 i j 4 11.875 10.500 3 3 9.375 7.125 5.375 4 2 7.875 4.375 8.456 3.500 5 1 15.750 2.625 750 16.000 5.000 6 0 0 0 0 0 0 A1 A2 A3 A4 A5 A6 Để tiện theo dõi ta quay các mảng M và mảng O sao cho đường chéo chính nằm
ngang. 6 1 5 3 2 i j 4 3 3 3 3 3 3 3 4 2 1 3 3 5 5 1 2 3 4 5 Hình 6.4. Mảng M Hình 6.5. Mảng O 148 Chỉ có đường chéo chính và tam giác phía trên được dùng trong mảng M và chỉ tam
giác phía trên được dùng trong mảng O. Phần tử ở hàng i cột j của mảng M lưu trữ số ít nhất các phép tính để nhân
Ai×...×Aj. Chẳng hạn M[2][5]=7125 là số phép tính ít nhất để nhân A2×A3×A4×A5.
Như vậy M[1][6] = 15125 là số phép tính ít nhất để nhân A1×A2×A3×A4×A5×A6 ít nhất các phép tính để nhân Phần tử ở hàng i cột j của mảng 0 lưu trữ chỉ số k ứng với trường hợp phải
Ai×...×Aj bằng cách kết hợp dùng
(Ai×...×Ak)×(Ak+1×...×Aj) Ta có O[1][6] = 3 nên A1×A2×A3×A4×A5×A6 được kết hợp là: (A1×A2×A3)×(A4×A5×A6) Ta có O[1][3] = 1 nên (A1×A2×A3) được kết hợp là: A1×(A2×A3) Ta có O[4][6] = 5 nên (A4×A5×A6) được kết hợp là: (A4×A5)×A6 Và như vậy số phếp tính phải thực hiện ít nhất khi nhân
A1×A2×A3×A4×A5×A6 là nhân theo thứ tự: (A1×(A2×A3))×((A4×A5)×A6) 5) Độ phức tạp tính toán của thuật toán Trong thuật toán ta coi phép toán tích cực là phép toán so sánh: min > (M[i][k]+M[k+1][j]+d[i-1]*d[k]*d[j] Phép toán này nằm trong 3 vòng for lồng nhau, mỗi vòng for có số lần thực
hiện tối đa là n-1. Do vậy số lần thực hiện tối đa phép toán tích cực đó là (n-1)(n- 1)(n-1). Từ đó dễ thấy độ phức tạp tính toán của thuật toán là O(n3) 6.2.3. Bµi to¸n chiÕc ba l« 1) Bài toán Cho một cái ba lô có thể đựng một trọng lượng W và n loại đồ vật, mỗi đồ
vật i có một trọng lượng gi và một giá trị vi. Tất cả các loại đồ vật đều có số lượng
không hạn chế. Tìm một cách lựa chọn các đồ vật đựng vào ba lô, chọn các loại đồ
vật nào, mỗi loại lấy bao nhiêu sao cho tổng trọng lượng không vượt quá W và tổng
giá trị là lớn nhất. (Với giả thiết W, gi, vi là các số nguyên) 2) Thiết kế thuật toán 149 Giả sử X[k,V] là số lượng đồ vật k được chọn, F[k,V] là tổng giá trị của k đồ vật đã được chọn và V là trọng lượng còn lại của ba lô, k = 1..n, V = 1..W. Trong trường hợp đơn giản nhất, khi chỉ có một đồ vật, ta tính được X[1,V] và F[1,V] với mọi V từ 1 đến W như sau: X[1,V] = V DIV g1 và F[1,V] = X[1,V] * v1. Giả sử ta đã tính được F[k-1,V], khi có thêm đồ vật thứ k, ta sẽ tính được
F[k,V], với mọi V từ 1 đến W. Cách tính như sau: Nếu ta chọn xk đồ vật loại k, thì
trọng lượng còn lại của ba lô dành cho k-1 đồ vật từ 1 đến k-1 là U = V-xk*gk và
tổng giá trị của k loại đồ vật đã được chọn F[k,V] = F[k-1,U] + xk*vk, với xk thay
đổi từ 0 đến yk= V DIV gk và ta sẽ chọn xk sao cho F[k,V] lớn nhất. Ta có công thức truy hồi như sau: X[1,V] = V DIV g1 và F[1,V] = X[1,V] * v1. F[k,V] = Max(F[k-1,V-xk*gk] + xk*vk) với xk chạy từ 0 đến V DIV gk. Sau khi xác định được F[k,V] thì X[k,V] là xk ứng với giá trị F[k,V] được chọn trong công thức trên. Để lưu các giá trị trung gian trong quá trình tính F[k,V] theo công thức truy
hồi trên, ta sử dụng một bảng gồm n dòng từ 1 đến n, dòng thứ k ứng với đồ vật loại k và W+1 cột từ 0 đến W, cột thứ V ứng với trọng lượng V. Mỗi cột V bao gồm hai cột nhỏ, cột bên trái lưu F[k,V], cột bên phải lưu X[k,V]. Ta sẽ tổ chức hai bảng tách rời là F và X. Ví dụ 6.2. Bài toán cái ba lô với trọng lượng W=9, và 5 loại đồ vật được cho trong bảng sau: Đồ vật Trọng lượng Giá trị 1 4 3 2 5 4 3 6 5 4 3 2 5 1 1 Hình 6.6. Trọng lương và giá trị 5 loại đồ vật Ta có bảng F[k,V] và X[k,V] như sau, trong đó mỗi cột V có hai cột con, cột 150 bên trái ghi F[k,V] và cột bên phải ghi X[k,V]. 0 1 2 3 4 5 6 7 8 9 1 0 0 0 0 0 0 4 1 4 1 4 1 8 2 8 2 8 2 12 3 2 0 0 0 0 0 0 4 0 5 1 5 1 8 0 9 1 10 2 12 0 3 0 0 0 0 0 0 4 0 5 0 6 1 8 0 9 0 10 0 12 0 4 0 0 0 0 3 1 4 0 6 2 7 1 9 3 10 2 12 4 13 3 5 0 0 1 1 3 0 4 0 6 0 7 0 9 0 10 0 12 0 13 0 Hình 6.6. Bảng F[k,V] và X[k,V] Trong bảng trên, việc điền giá trị cho dòng 1 rất đơn giản bằng cách sử dụng công thức: X[1,V] = V DIV g1 và F[1,V] = X[1,V] * v1. Từ dòng 2 đến dòng 5, phải sử dụng công thức truy hồi: F[k,V] = Max(F[k-1,V-xk*gk + xk*vk) với xk chạy từ 0 đến V DIV gk Chẳng hạn, để tính F[2,7], ta có xk chạy từ 0 đến V DIV gk, trong trường hợp này là xk chạy từ 0 đến 7 DIV 4, tức xk có hai giá trị 0 và 1. Khi đó F[2,7] = Max(F[2-1, 7-0*4] + 0*5, F[2-1,7-1*4] + 1*5) = Max(F[1,7], F[1,3] + 5) = Max(8, 4+5) = 9. F[2,7] = 9 ứng với xk = 1 do đó X[2,7] = 1. Vấn đề bây giờ là cần phải tra trong bảng trên để xác định phương án. Khởi đầu, trọng lượng còn lại của ba lô V = W. Nếu X[k,V] > 0 thì chọn X[k,V] đồ vật loại k. Tính lại V = V - X[k,V] * gk. Chẳng hạn, trong bảng trên, ta sẽ xét các đồ vật từ 5 đến 1. Khởi đầu V = W = 9. Với k = 5, vì X[5,9] = 0 nên ta không chọn đồ vật loại 5. Với k = 4, vì X[4,9] = 3 nên ta chọn 3 đồ vật loại 4. Tính lại V = 9 – 3 * 2 = 3. Với k = 3, vì X[3,3] = 0 nên ta không chọn đồ vật loại 3. 151 Với k = 2, vì X[2,3] = 0 nên ta không chọn đồ vật loại 2. Với k = 1, vì X[1,3] = 1 nên ta chọn 1 đồ vật loại 1. Tính lại V = 3 – 1 * 3 = 0. Vậy tổng trọng lượng các vật được chọn là 3 * 2 + 1 * 3 = 9. Tổng giá trị các vật được chọn là 3 * 3 + 1 * 4 = 13. Giải thuật thiết kế theo kĩ thuật quy hoạch động như sau: Tổ chức dữ liệu: - Mỗi đồ vật được tổ chức là một cấu trúc gồm các trường: + Ten: tên đồ vật + Trong_luong: Trọng lượng đồ vật + Gia_tri: Giá trị đồ vật + Phuong_an: lưu trữ số lượng đồ vật theo phương án - Các đồ vật được lưu trữ trong một mảng - Bảng được biểu diễn bằng một mảng hai chiều các số nguyên để lưu trữ các giá trị F[k,v] và X[k,v] Hàm Tao_bang nhận vào ds_vat là danh sách các vật, n là số lượng các loại
vật, W là trọng lượng của ba lô. F và X là hai tham số thuộc kiểu Bang và được truyền bằng tham chiếu để nhận lại hai bảng F và X do hàm tạo ra. Tao_Bang (ds_vat, n,W, F, X) { for(v=0;v<=W;v++) { X[1, v] = v/ds_vat[1].trong_luong; F[1, v] = X[1, v] * ds_vat[1].gia_tri; } for(k=2;k<=n;k++) { X[k, 0] = 0; F[1, 0] = 0; for(v=1;v<=W;v++) 152 { FMax = F[k-1, v] ; XMax = 0; yk = v/ds_vat[k].trong_luong; for(xk=1;xk<=yk;xk++) if(F[k-1,v-xk*ds_vat[k].trong_luong]+xk*ds_vat[k].gia_tri>FMax) { FMax=F[k-1,v-k*ds_vat[k].trong_luong]+xk*ds_vat[k].gia_tri; XMax= xk; } F[k, v] = FMax; X[k, v] = XMax; } } } Hàm Tra_bang nhận vào hai bảng F và X; n là số lượng các loại đồ vật, W là trọng lượng của ba lô và trả ra ds_vat là một danh sách đồ vật đã được xác định
phương án. Tra_Bang(n,W, F,X) { v = W; for(k=n;k>=1;k--) if( X[k,v] > 0) { ds_vat[k].Phuong_an = X[k,v]; v = v - X[k, v] * ds_vat[k].trong_luong; } } 3) Độ phức tạp tính toán của thuật toán Trong thuật toán ta coi phép toán tích cực là phép toán so sánh: 153 F[k-1,v-xk*ds_vat[k].trong_luong]+xk*ds_vat[k].gia_tri>FMax Phép toán này nằm trong 3 vòng for lồng nhau. Vòng for thứ nhất có số lần thực hiện tối đa là n-1. Vòng for thứ hai số lần thực hiện tối đa là W. Vòng for thứ ba ta gọi trọng lượng nhỏ nhất trong n vật là g thì số lần thực hiện tối đa của vòng for này là [W/g]=m . Từ đó nhận thấy độ phức tạp tính toán của thuật toán là O(n.W.m) 6.2.4. Xâu con chung dài nhất 1) Bài toán Cho hai xâu ký tự X = xâu Z cũng là thứ tự trước sau của các ký tự trong hai xâu X và Y ban đầu. Bài toán đặt ra với hai xâu X và Y ban đầu hãy xác định xâu con chung Z có độ dài lớn nhất. Rõ ràng xâu con Z chính là xâu X và xâu Y sau khi xoá đi một số ký tự nào đó trong X và trong Y. Bài toán này có thể giải quyết một cách hiệu quả thông qua phương pháp quy hoạch động như sau: 2) Phân tích bài toán Một cách tiếp cận vấn đề đơn giản nhất để giải quyết bài toán xâu con chung
dài nhất đó là liệt kê tất cả các xâu con của X và kiểm tra mỗi xâu con này có phải là xâu con của Y hay không; đồng thời theo dõi xâu con dài nhất tìm thấy. Mỗi xâu con của X tương ứng với một tập hợp con gồm các chỉ số {1,2, ..., m} của X – với
m là độ dài của xâu X. Như vậy ta sẽ có 2m xâu con của X (kể cả xâu rỗng), do đó
việc giải bài toán này sẽ có độ phức tạp với thời gian mũ. Điều này khiến nó trở lên
không hiệu quả trong thực tiễn với các xâu dài (m lớn). Tuy nhiên, bài toán xâu con chung dài nhất có một tính chất cấu trúc con tối ưu. Để dễ hiểu ta đưa ra định nghĩa về tiền tố như sau: Tiền tố thứ i của X, với i = 0, 1, ..., m là Xi = của bài toán con tương ứng với các cặp tiền tố của hai xâu con đầu vào. Chẳng hạn: X = thì X5 = , còn X0 là rỗng. 154 Tính chất cấu trúc con tối ưu của một xâu con chung dài nhất được thể hiện rất rõ thông qua khẳng định sau: Cho X = z2, ..., zk> là một xâu con chung dài nhất bất kỳ của X và Y. Khi đó ta có: - Nếu xm = yn thì zk = xm = yn và Zk-1 là một xâu con chung dài nhất của Xm-1 và Yn-1 (1) - Nếu xm yn thì zk xm hàm ý Z là xâu con chung dài nhất của Xm-1 và Y, (2) - Nếu xm yn thì zk yn hàm ý Z là xâu con chung dài nhất của X và Yn-1. (3) Thật vậy: (1) Với xm = yn và Z là xâu con chung dài nhất của X, Y; giả sử zk xm thì ta có
thể nối xm = yn vào Z để có được một xâu con chung của X và Y có chiều dài k+1,
điều này mâu thuẫn với giả thiết cho rằng Z là xâu con chung dài nhất của X và Y.
Như vậy, ta phải có zk = xm = yn. Mặt khác ta nhận thấy, tiền tố Zk-1 là một xâu con chung có chiều dài k-1 của
Xm-1 và Yn-1; và ta phải chứng minh Zk-1 là một xâu con chung dài nhất của Xm-1 và
Yn-1. Thật vậy, giả sử có một xâu con chung T của Xm-1 và Yn-1 có chiều dài lớn hơn
k-1, khi đó việc thêm vào giá trị xm = yn sẽ khiến cho T trở thành xâu con chung của
X và Y có chiều dài lớn hơn k, điều này mâu thuẫn với giả thiết Z chỉ có độ dài k là xâu con chung dài nhất. Do đó ta suy ra điều phải chứng minh. (2). Với giả thiết là xm yn và zk xm, khi đó ta phải chứng minh Z là một xâu
con chung dài nhất của Xm-1 và Y. Thật vậy, trước hết ta nhận thấy Z sẽ là xâu con
chung của Xm-1 và Y; điều này có được là từ giả thiết ta suy ra việc xoá đi phần tử
xm không làm ảnh hưởng tính chất xâu con chung của Z. Mặt khác, giả sử có một
xâu con chung T của Xm-1 và Y có chiều dài lớn hơn k, khi đó T cũng sẽ là một xâu
con chung của X và Y, và điều này mâu thuẫn với giả thiết Z là xâu con chung dài
nhất của X và Y (độ dài của Z chỉ là k). Đây là điều phải chứng minh. (3) được chứng minh tương tự như 2. Khẳng định trên cho thấy một xâu con chung dài nhất của hai dãy chứa trong
nó một xâu con chung dài nhất của thành phần là hai tiền tố của hai dãy ban đầu.
Như vậy bài toán xâu con chung dài nhất có một tính chất cấu trúc con tối ưu. Một 155 giải pháp đệ quy cũng có tính chất các bài toán con phủ chồng như sẽ xem xét dưới đây. 3) Thiết kế thuật toán Khẳng định trên cho ta một ý tưởng chia bài toán xâu con chung dài nhất của hai xâu X = Nếu xm = yn thì ta giải bài toán con tìm một xâu con chung của Xm-1 và Yn-1.
Việc nối thêm xm = yn vào xâu con chung dài nhất này sẽ cho ra xâu con chung dài
nhất của X và Y. Ngược lại nếu xm yn thì ta giải quyết hai bài toán con: Bài toán 1: Tìm xâu con chung dài nhất của Xm-1 và Y. Bài toán 2: Tìm xâu con chung dài nhất của Yn-1 và X. Sau đó so sánh 2 xâu con chung dài nhất của 2 bài toán này, xâu nào có độ dài lớn hơn thì nó là xâu con chung dài nhất của X và Y. Ta dễ dàng nhận thấy tính chất các bài toán con phủ chồng trong bài toán tìm xâu con chung dài nhất. Để tìm một xâu con chung dài nhất của X và Y, ta có thể
cần phải tìm các xâu con chung dài nhất của X và Yn-1 và của Xm-1 và Y. Nhưng mỗi
bài toán con này lại chứa các bài toán con tìm xâu con chung dài nhất của Xm-1 và
Yn-1. Như vậy giải pháp đệ quy của ta đối với bài toán xâu con chung dài nhất có liên quan đến việc thiết lập một phương trình đệ quy cho mức hao phí của một giải pháp
tối ưu. Ta định nghĩa c[i,j] là chiều dài của một xâu con chung dài nhất của dãy Xi
và Yj. Nếu i = 0 hoặc j = 0 (tồn tại một trong các xâu có chiều dài bằng 0) thì xâu
con chung dài nhất có chiều dài bằng 0. Cấu trúc con tối ưu của bài toán xâu con chung dài nhất cho phương trình đệ quy sau: 0 nếu i = 0 hoặc j = 0, c[i,j] = c[i-1,j-1] + 1 nếu i, j > 0 và xi = yj, (*) max (c[i,j-1], c[i-1,j]) nếu i,j>0 và xi yj. Dựa trên phương trình (*) ta có thể dễ dàng viết một thuật toán đệ quy thời gian
mũ để tính toán chiều dài của một xâu con chung dài nhất. Tuy nhiên ta sẽ dùng kỹ
thuật quy hoạch động để tính toán các giải pháp từ dưới lên. Hàm DDXCCDN (độ dài xâu con chung dài nhất) nhận hai dãy X = xm>; và Y = 156 Mỗi giá trị c[i,j] trong bảng c[0..m, 0..n] được tính toán theo thứ tự chính là
hàng; nghĩa là hàng đầu tiên của c được điền từ trái qua phải, sau đó là hàng thứ 2 .v.v. Song song với bảng c, ta tính toán một bảng b[0..m, 0..n] có giá trị bảng tương
ứng với giải pháp của bài toán con tối ưu c[i,j]. Hàm trả về các bảng b và c; trong đó c[m,n] chứa chiều dài của một xâu con chung dài nhất của X, Y. - Xây dựng bảng c[m,n]; m = length(X), n = length(Y) trong đó: + c[i,j] chứa độ dài xâu con chung dài nhất của Xi và Yj; 0 i m, 0 j n; với c[i,j] được tính theo công thức (*); + c[m,n] chính là chiều dài của xâu con chung dài nhất. Ta hình dung bảng
c[m,n] được lấy gốc toạ độ c[0,0] là góc trên cùng, bên trái; còn trục tung và trục
hoành tương ứng với X và Y. - Xây dựng bảng b[i,j] tương ứng để truy vết của xâu con chung dài nhất bắt đầu từ b[m,n]; trong đó b[i,j] được tính như sau: + Nếu xi = yj đặt b[i,j] = 0; điều này có nghĩa đây chính là một thành phần
của xâu con chung dài nhất khi ta lần ngược từ dưới lên; {b[i,j] = 0 biểu thị hướng truy vết đi chéo lên của bảng c tại vị trí (i,j)}; + Nếu xi yj và c[i-1,j] c[i,j-1] đặt b[i,j] = 1; điều này khẳng định xâu con
chung dài nhất của Xi, Yj chính là xâu con chung dài nhất của Xi-1, Yj; hay nói cách
khác nếu xi-1 = yj thì đây chính là một thành phần của xâu con chung dài nhất;
{b[i,j] = 1 biểu thị hướng truy vết đi lên thẳng đứng của bảng c tại vị trí (i,j)}; + Nếu xi yj và c[i-1,j] c[i,j-1] thì b[i,j] = -1; điều này khẳng định xâu con
chung dài nhất của Xi, Yj chính là xâu con chung dài nhất của Xi, Yj-1; hay nói cách
khác nếu xi = yj-1 thì đây chính là một thành phần của xâu con chung dài nhất; {b[i,j]
= -1biểu thị hướng truy vết đi ngang sang trái của bảng c tại vị trí (i,j)} - Từ b[m,n] lần ngược lên trên đến b[0,0] tìm được xâu con chung dài nhất theo thứ tự đảo ngược lại của các vị trí; - Đảo ngược lại xâu con tìm được ta sẽ được xâu con chung dài nhất cần tìm. Thuật toán Xaucon(X,Y) /* Hàm này tính toán 2 bảng c và b từ dữ liệu đầu vào là hai xâu X và Y */ { m=length(X); n=length(Y); 157 for( i= 0; i<=m;i++) c[i,0]=0; for( i= 0; i<=n;i++) c[0,i]=0; for( i= 1; i<=m;i++) for( j= 1; j<=n;j++) if (X[i] == Y[j]) { c[i,j]= c[i-1,j-1]+1; b[i,j]= 0 ; } else if (c[i-1,j] c[i,j-1]) { c[i,j]:= c[i-1,j]; b[i,j]:= 1; } else { c[i,j]:= c[i,j-1]; b[i,j]:= -1; } } In ra xâu con chung dài nhất Sau khi đã xây dựng được các bảng b và c, ta sẽ sử dụng các bảng này để in ra xâu con chung dài nhất. Bắt đầu từ b[m,n] trong bảng b ta tiến hành dò ngược lên trên. Với một b[i,j] bất kỳ (0 i m, 0 j n) ta chia làm 3 trường hợp: - Nếu b[i,j] = -1 và (i > 0 và j > 0) thì ta truy vết ngang sang trái của bảng c, tức là xét bài toán con ứng với Xi-1 và Yj. - Nếu b[i,j] = 0 và (i > 0 và j > 0) thì xi = yj = zc[i,j] chính là một thành phần của xâu con chung dài nhất; xét bài toán con ứng với Xi-1, Yj-1 và in ra giá trị đó. 158 - Nếu b[i,j] = 1 và (i > 0 và j > 0) thì ta truy vết lên thẳng đứng của bảng c, tức là xét bài toán con ứng với Xi và Yj-1. Như vậy sau khi dò ngược lên trên ta in ra được xâu con chung dài nhất của X và Y. Thuật toán In_XCCDN in ra xâu con chung dài nhất In_XCCDN(b,c,i,j) { if ((i == 0)||(j == 0) return; if (b[i,j] == 0) { In_XCCDN(b,c,i-1,j-1) printf(X[i]) } else { if (b[i,j] == 1) In_XCCDN(b,c,i-1,j) else In_XCCDN(b,c,i,j-1); } Ví dụ 6.3. Cho xâu X=ABCDAD; xâu Y=BDCABAD; Tìm xâu con chung dài nhất Z; ta có thể mô tả các bước của thuật toán trong các bảng sau 2 0 1 3 4 5 6 7 j i yj B D C A B A D 0 0 0 0 0 0 0 0 0 xi A 1 0 0 0 0 1 1 1 1 B 2 0 1 1 1 1 2 2 2 C 3 0 1 1 2 2 2 2 2 D 4 0 1 2 2 2 2 2 3 A 5 0 1 2 2 3 3 3 3 D 6 0 1 2 2 3 3 3 4 Hình 6.8. Bảng c 159 5 6 7 1 2 3 4 -1 -1 -1 1 1 1 1 0 -1 -1 1 0 -1 -1 2 0 3 1 1 0 -1 1 1 1 4 1 0 1 1 1 1 0 5 1 1 1 0 -1 0 1 6 1 1 1 1 1 1 0 Hình 6.9. Bảng b Trong bảng c các phần tử của dòng ứng với i=0 và cột ứng với j=0 đều có giá
trị 0 do các lệnh: for( i= 0; i<=m;i++) c[i,0]=0; for( i= 0; i<=n;i++) c[0,i]=0; Từ hàng 1 đến hàng 6 các phần tử của các bảng b và c được xác định do các
lệnh: for( i= 1; i<=m;i++) for( j= 1; j<=n;j++) if (X[i] == Y[j]) { c[i,j]= c[i-1,j-1]+1; b[i,j]= 0 ; } else if (c[i-1,j] c[i,j-1]) { c[i,j]:= c[i-1,j]; b[i,j]:= 1; 160 } else { c[i,j]:= c[i,j-1]; b[i,j]:= -1; } Việc in ra xâu con chung dài nhất được thực hiện qua hàm In_XCCDN(b,c,6,7). Quá trình tìm (và in ra) xâu con chung dài nhất được minh họa trong các bảng qua các ô bôi đen, theo đó xâu con chung dài nhất được in ra sẽ là: ABAD 3) Đánh giá độ phức tạp của thuật toán Đối với thuật toán Xaucon(X,Y) phép toán tích cực có thể coi là phép so sánh X[i] == Y[j], phép này nằm trong hai vòng for lồng nhau là for( i= 1; i<=m;i++) for( j= 1; j<=n;j++) Do đó độ phức tạp của thuật toán là O(mn). Đối với In_XCCDN(b,c,i,j) gọi đệ quy lẫn nhau với đầu vào ban đầu là bộ
(b,c,i=m, j=n). Nhưng dù thế nào thì ở bước 2 hoặc bước 3 ít nhất giá trị của i hoặc của j (hoặc của cả 2) bị giảm đi 1. Thuật toán dừng khi i=0 hoặc j=0; do đó trong trường hợp xấu nhất thuật toán cũng chỉ mất m+n bước, hay nói cách khác độ phức tạp của thuật toán là O(m+n). Sau khi gọi Xaucon(X,Y) sẽ gọi In_XCCDN(b,c,i,j). Do vậy độ phức tạp tính toán của thuật toán tìm xâu con chung dài nhất của hai xâu sẽ là O(mn). * Nhận xét Đối với hai thuật toán Xaucon và In_XCCDN ở trên ta phải sử dụng thêm
một bảng phụ b[0..m,0..n] để truy vết khi xác định một xâu con chung dài nhất. Tuy
nhiên việc xác định một xâu con chung dài nhất này chỉ cần dựa vào thông tin của bảng c; hay nói cách khác từ c[m,n] truy ngược lên đến c[0,0], tại mỗi vị trí c[i,j] ta
có thể xác định được bước truy vết tiếp theo chỉ cần dựa vào bộ ba giá trị {c[i,j], c[i- 1,j], và c[i,j-1]} mà không cần xem xét bảng phụ b. Chính vì vậy ta có thể cải thiện không gian bộ nhớ khi bỏ qua bảng b. Khi đó các hàm Xaucon và In_XCCDN sẽ có sự thay đổi như sau: Xaucon(X,Y) 161 /* Hàm này tính toán bảng c từ dữ liệu đầu vào là hai xâu X và Y */ { m=length(X); n=length(Y); for( i= 0; i<=m;i++) c[i,0]=0; for( i= 0; i<=n;i++) c[0,i]=0; for( i= 1; i<=m;i++) for( j= 1; j<=n;j++) if (X[i] == Y[j]) c[i,j]= c[i-1,j-1]+1; else if (c[i-1,j] c[i,j-1]) c[i,j]:= c[i-1,j]; else c[i,j]:= c[i,j-1]; } In_XCCDN(c,i,j) /* Đưa ra xâu con chung dài nhất*/ { z= ’’; i= m; j= n; While ((i>0) && (j>0)) if (X[i] == Y[j]) { z= z + X[i]; i= i-1; 162 j= j-1; } else if (c[i-1,j] c[i,j-1]) i= i-1; else j:= j-1; return(Z); } 163 1. Cho hai dãy số nguyên X = 12, 5, 50, 35, 11, 45, 60 Y = 6, 5, 40, 35, 21, 55, 45, 60, 9 Hãy mô phỏng quá trình áp dụng thuật toán tìm xâu con chung dài nhất để tìm dãy
con chung dài nhất của X và Y. 2. Bài toán chia quà Minh và Nhật là hai anh em sinh đôi, trong ngày sinh nhật, hai anh em nhận
được n món quà. Trên món quà i có ghi giá tiền x[i]. Hai anh em quyết định phân chia n món quà thành hai phần, mỗi người được sở hữu một phần. Hãy sử dụng kỹ thuật quy hoạch động phân chia sao cho chênh lệch giá trị của hai phần quà là ít
nhất. Hướng dẫn: - Gọi t là tổng giá trị của n món quà : t = x[i] - Gọi m là nửa tổng giá trị của n món quà: m = t/2 - Phải tìm tập y {1,2, ... ,n} sao cho x[i] với iy gần m nhất - Để diễn đạt phần tử thứ i của tập {1,2,... ,n} thuộc tập y, ta sẽ ký hiệu y[i]=1, ngược lại ta ký hiệu y[i]=0 - Gọi f[i,j] là tổng giá trị lớn nhất trong trường hợp có i món quà và giá trị tối đa là j (nghĩa là tổng giá trị của các món quà được chọn không quá j) f[n,m] là giá trị cần tìm. - Xây dựng được công thức như sau: f[i,0]=0, i =1..n và f[0,j]=0, j =1..m f[i-1,j-x[i]]+x[i] (món quà thứ i được chọn) f[i,j] = max f[i-1,j] (món quà thứ i không được chọn) i =1..n và j =1..m Thuật toán: For i:=1 to n do For j:=1 to m do Nếu x[i]j thì 164 f[i,j]:= max(f[i-1,j - x[i]] + x[i] , f[i-1,j]) Nếu không thì f[i,j]:= f[i-1,j] Đánh giá độ phức tạp của thuật toán: 0(n.m) 3. Bài toán đường đi của người giao hàng Có một người giao hàng cần đi giao hàng tại n thành phố. Xuất phát từ một
thành phố nào đó, đi qua các thành phố khác để giao hàng và trở về thành phố ban
đầu. Mỗi thành phố chỉ đến một lần, khoảng cách từ một thành phố đến các thành phố khác là xác định được. Giả thiết rằng mỗi thành phố đều có đường đi đến các thành phố còn lại. Khoảng cách giữa hai thành phố có thể là khoảng cách địa lý, có thể là cước phí di chuyển hoặc thời gian di chuyển. Ta gọi chung là độ dài. Dùng kỹ thuật quy hoạch động tìm một chu trình (một đường đi khép kín thỏa mãn điều kiện
trên) sao cho tổng độ dài các cạnh là nhỏ nhất. Hướng dẫn: n thành phố được đánh số từ 1 đến n. Đường đi từ thành phố i đến thành phố
j xem như là cung đi từ đỉnh i đến đỉnh j của đơn đồ thị có hướng. Ðộ dài từ i đến j là trọng số m(i,j) của cung (i,j). Vậy bài toán trên có thể xem là tìm một chu trình xuất phát từ đỉnh i nào đó của đơn đồ thị có hướng có trọng số G=(V,E), đi qua mỗi đỉnh đúng 1 lần
sao cho có trọng số nhỏ nhất. Giả sử có một chu trình thỏa yêu cầu bài toán và bắt đầu từ đỉnh 1 về đỉnh 1. Khi đó chu trình này bao gồm cung (1,k), với k V\{1}, và đường đi từ k đến 1, đi qua mỗi đỉnh còn lại thuộc V\{1,k}, mỗi đỉnh đúng 1 lần. Nếu chu trình này có trọng số nhỏ nhất (tối ưu ), thì khi đó đường đi từ k đến 1 cũng có trọng số nhỏ nhất (tối ưu ). Biểu diễn G bằng ma trận kề : C = cij (1 i,jn) như sau: cij = m(i,j) nếu (i,j)E cij = 0 nếu i=j cij = nếu (i,j)E Xét tập S V\{1} và i(V\{1})\S. Ta gọi : d(i,S) = Trọng số của đường đi ngắn nhất đi từ đỉnh i đến đỉnh 1 đi qua mỗi đỉnh trong S đúng 1 lần. Vậy với 2 ≤ i ≤ n thì: 165 - Nếu S = , ta có : d(i, ) = Ci1 ; - Nếu S thì : d(i, S ) = Min{Cik + d(k, S \{k})} Khi đó, trọng số của chu trình ngắn nhất đi từ 1 đến 1 sẽ là : d (1, V \ {1}) = Min{C1k + d (k ,V \{1, k})} Để tính d(1,V \ {1}) ta cần có d(k ,V \ {1, k}) , với 2 k n. Tổng quát, ta cần tính các d(i, S ) , S V\{1} và i (V\{1})\S . Đầu tiên ta tính và lưu trữ d(i, ) ; d(i, S) với S chỉ có 1 phần tử ; d(i, S) với S có 2 phần tử , . . .cho đến khi tính được các d(k, V\{1, k}) với 2 ≤ k ≤ n. Input : C Output : d (1,V \ {1}) Mô tả: Bước 0 : Khởi tạo : d(i, ) = Ci1 ; 2 ≤ i ≤ n Bước 1 : Với S ? V\{1} và |S| = 1; ∀ i 1 và iS : d(i, S ) = Min{Cik + d(k, S\{k})} với kS ..... Bước n-2: Với S ? V\{1} và |S| = n-2; ∀ i1 và i S : d (i, S ) = Min{C ik + d (k , S \ {k})} với kS Bước n-1 : d(1, V\{1}) = Min{C1k + d(k ,V\{1, k})} với 2 ≤ k ≤ n Ðánh giá độ phức tạp tính toán: Ta xét thời gian thực hiện T(n) của d(i,S): i có n-1 lựa chọn. Với mọi i =2,..,n : Số các tập S có k phần tử khác 1,i là Do dĩ Mặt khác khi tính d(i,S) với S gồm k phần tử, ta cần thực hiện k-1 phép so sánh để xác định min. Nên thời gian thực hiện của thuật toán là O(n22n) 166 4. Dãy con tăng dài nhất Cho dãy số nguyên A = a1, a2, .., an. Áp dụng kỹ thuật quy hoạch động chọn
ra một dãy con tăng B của A (bao gồm một số các phần tử trong A nhưng vẫn giữ nguyên thứ tự) có độ dài lớn nhất. Ví dụ : A = ( 1, 2, 3, 4, 5, 9, 10, 5, 6, 7, 8) => B = (1, 2, 3, 4, 5, 6, 7, 8) Hướng dẫn: Ta thêm vào dãy ban đầu 2 phần tử : A[0] = - và A[n+1] = làm hai đầu mút. Vậy dãy con tăng dài nhất sẽ bắt đầu là A[0] và kết thúc là A[n+1].
Với i : 0<=i nghĩa là Fa[n+1] là độ dài của dãy con bắt đầu từ n+1. Dãy này chỉ có 1 phần tử nên có độ dài là 1 Fa[n+1] = 1. Ta sẽ bắt đầu xét các phần tử A[i] với i từ n đến 0 ta tính Fa[i] dựa trên các Fa[i+1],Fa[i+2],…,Fa[n+1] đã tính được trước đó. Dãy con đơn điệu tăng dài nhất bắt đầu từ A[i] có thể được cập nhật thêm A[i] theo cách như sau : Xét tất cả các chỉ số j trong khoảng i+1 đến n+1 mà A[j] >A[i], ta chọn ra chỉ số jmax có Fa[jmax] lớn nhất. Và lúc này thì : Fa[i] = Fa[jmax] +1. Để có thể lần lại được kết quả thì khi gán Fa[i]= Fa[jmax]+ 1, ta cũng gán T[i] = jmax để biết rằng dãy con bắt đầu từ A[i] thì sẽ có phần tử thứ 2 kế tiếp là A[jmax] Thuật toán daytang(A) { A[0] = -; A[n+1]= ; Fa[n+1]=1; for(i=n;i>=0;i--) { Jmax = n+1; for(j=i+1;j<=n+1;j++) if ((A[j] > A[i]) && (Fa[j]>Fa[jmax])) jmax=j; Fa[i]:=Fa[jmax]+1; 167 T[i]:= jmax; } } Việc in ra dãy con tăng dài nhất được thực hiện bởi hàm: hienthi(A,T) { I=T[0]; While (i<> n+1) { printf(A[i]); I=T[i]; } } 5. Giả sử có hai đội A và B tham gia một trận thi đấu thể thao, đội nào thắng trước n
hiệp thì sẽ thắng cuộc. Chẳng hạn một trận thi đấu bóng chuyền 5 hiệp, đội nào thắng trước 3 hiệp thì sẽ thắng cuộc. Giả sử hai đội ngang tài ngang sức. Đội A cần thắng thêm i hiệp để thắng cuộc còn đội B thì cần thắng thêm j hiệp nữa. Gọi P(i,j) là xác suất để đội A cần i hiệp nữa để chiến thắng, B cần j hiệp. Dĩ nhiên i,j đều là các số nguyên không âm. Ðể tính P(i,j) ta thấy rằng nếu i=0, tức là đội A đã thắng nên P(0,j) = 1. Tương tự nếu j=0, tức là đội B đã thắng nên P(i,0) = 0. Nếu i và j đều lớn hơn không thì ít nhất còn một hiệp nữa phải đấu và hai đội có khả năng 5 ăn, 5 thua trong hiệp này. Như vậy P(i,j) là trung bình cộng của P(i-1,j) và P(i,j-1). Trong đó
P(i-1,j) là xác suất để đội A thắng cuộc nếu thắng hiệp đó và P(i,j-1) là xác suất để A thắng cuộc nếu nó thua hiệp đó. Tóm lại ta có công thức tính P(i,j) như sau: P(i,j) =1Nếu i = 0 P(i,j) =0Nếu j = 0 P(i,j) =(P(i-1,j) + P(i,j-1))/2Nếu i > 0 và j > 0 a. Viết một hàm đệ quy để tính P(i,j). b. Dùng kĩ thuật quy hoạch động để viết hàm tính P(i,j). 168 6. Mét con tµu cã träng l îng W. Cã n mÆt hµng ® îc ®¸nh sè tõ 1, 2, … , n. Mçi
mÆt hµng lo¹i i cã sè l îng lµ qi, khèi l îng lµ ai vµ gi¸ trÞ lµ ci. Dïng kü thuËt quy
ho¹ch ®éng t×m c¸ch xÕp hµng ho¸ lªn tµu sao cho tæng gi¸ trÞ ®¹t ® îc lµ lín nhÊt. H íng dÉn: Gọi xi là số lượng hàng hoá loại i (i = 1 .. n ) Bước 1: Tìm cơ sở của phương án hay chính là tìm điều kiện dừng của bài toán. Xét trường hợp có 1 mặt hàng (n=1, có số lượng là q1, có khối lượng là a1, có giá trị là c1. - Cần xếp hàng lên tàu cho đến khi không xếp được nữa thì thôi (tổng khối lượng của hàng lớn hơn trọng tải của tàu), Vậy số mặt hàng xếp lên tàu có tổng khối lượng bao giờ cũng nhỏ hơn hoặc bằng trọng tải của tàu do vậy số lượng hàng
hoá xếp được lên tàu sẽ là x1=W div a1 - Số lượng mặt hàng q1 vẫn còn đủ (x1<= q1 ) thì giá trị đạt được là x1*c1 . - Số lượng mặt hàng q1 hết ( x1> q1 ) thì giá trị đạt được là q1*c1. Bước 2: Tìm công thức truy hồi: Xét trường hợp có n mặt hàng (n >= 1, có số lượng là qi, có khối lượng là ai, có giá trị là ci. *) Gọi f(k,v) là giá trị lớn nhất của tàu với k loại mặt hàng 1<= k <= n và v khối lượng (1 <= v <= W) *) Theo bước 1 ta có: + x1 = W div a1. + Nếu x1 > q1 thì f(1,v) = q1*c1. + Nếu x1<= q1 thì f(1,v) = x1*c1. *) Giả sử đã tính được f(l,m) với 1<= l<= k và 1<= m <= v + cần tính f(k,v) với 1<= k <= n và 1 <= v <= W + Gọi yk = v div ak ta có f(k,v) = max {f(k-1,m) + xk*ck} (Giá trị hàng hoá trên tàu tăng lên một lượng xk*ck) + Trong đó max được lấy: Nếu yk <= qk với xk = 1, 2, …, yk Nếu yk > qk với xk = 1, 2, …, qk + Giá trị lớn nhất của tàu sẽ là f(n,W). 169 + u = v - xk*ak (Trọng tải của tàu phải giảm xuống một lượng xk*ak) Bước 3: Lập bảng phương án - Dùng mảng hai chiều KQ[1..n, 1..W] để lưu bảng phương án trung gian - Một trường chứa giá trị tàu f(k, v) đạt được tại thời điểm k mà trong công thức truy hồi f(k,v) = max {f(k-1,m) + xk*ck} đạt tới max. - Một trường chứa số lượng mặt hàng xk. Bước 4: Tính nghiệm của bài toán con thông qua nghiệm của bài toán nhỏ hơn Sử dụng công thức truy hồi ở bước 3 để tính. Bước 5: Xây dựng nghiệm của bài toán dựa trên bảng phương án (ma trận KQ) - Từ công thức truy hồi f(k,v) = max {f(k-1,m) + xk*ck} giá trị của bảng KQ có thể tính lần lượt theo dòng 1, 2, …, n. - Từ mảng KQ đã làm đầy ta xác định giá trị x và f(n,W) + Ta thấy ô KQ[n,W] chứa f(n,W) và xn. Như vậy xác định được giá
trị của f(n,W) và xn. + Tính v = W – xn*an + Tìm đến ô KQ[n-1,v] biết được f(n,W) và xn-1 + Tiếp tục quá trình trên ta tìm được xn-2, xn-3, …, x1. + Không tìm xi khi giá trị v = 0. + Các xi tìm thấy lưu vào mảng X. Đánh giá thuật độ phức tạp tính toán: O(n2) 170 1. Cài đặt thuật toán quy hoạch động giải bài toán chiếc ba lô /* Doc du lieu tu file Balo.txt: Dong thu nhat ghi so do vat (n) va trong luong toi da
cua ba lo (m), n dong tiep theo ghi trong luong va gia tri cua vat*/ #define max 100 int v[max], w[max]; int n, W; int L[max][max]; int vmax; int pa[max]; void DocDL() { FILE *f; f = fopen("BaLo.txt", "r"); if(!f) { puts("Loi mo tep"); getch(); exit(0); } fscanf(f, "%d", &n); fscanf(f, "%d", &W); for(int i=1; i<=n; i++) { fscanf(f, "%d", &w[i]); fscanf(f, "%d", &v[i]); } fclose(f); } int Max(int a, int b) { 171 if(a>b) return a; return b; } void QHD() { int i, j; for(i = 1; i<=n; i++) for(j=0; j<=W; j++) if(j>=w[i]) L[i][j] = Max(v[i]+L[i-1][j-w[i]], L[i-1][j]); } void TruyVet() { int i, j; i = n; j = W; int k; k = 1; vmax = 0; while(i && j) { if(L[i][j] == L[i-1][j]) i--; if(L[i][j] == (v[i] + L[i-1][j-w[i]]) && (j>w[i])) { pa[k] = i; vmax += v[i]; j -= w[i]; k++; i--; } 172 } } void InPA() { int i; i = 1; while(pa[i]) { printf("%3d", pa[i]); i++; } printf("-%3d", vmax); } main() { DocDL(); QHD(); TruyVet(); InPA(); getch(); } 2. Cài đặt thuật toán quay lui giải bài toán chiếc ba lô /*Doc du lieu tu file Balo.txt Dong thu nhat ghi so do vat (n) va trong luong toi da cua ba lo (m) */ n dong tiep theo ghi trong luong va gia tri cua vat #include #include #include #define max 100 int n, W; 173 int w[max], v[max], vmax; int daxet[max]; int x[max]; int pa[max]; int vi, wi; void DocDL() { FILE *f; f = fopen("BaLo.txt", "r"); if(!f) { puts("Loi mo tep"); } fscanf(f, "%d", &n); fscanf(f, "%d", &W); for(int j=0; j { fscanf(f, "%d", &w[j]); fscanf(f, "%d", &v[j]); } } void LuuPa(int i) { //intf("%d\n", i); for(int j=0; j<=i; j++) pa[j] = x[j]; } void InPa() { int i = 0; while(pa[i]) 174 { printf("%3d", pa[i]); i++; } } void Try(int i) { for(int j=0; j if(!daxet[j] && (wi+w[j])<=W) { x[i] = j+1; daxet[j] = 1; vi += v[j]; wi += w[j]; if(vi>vmax) { vmax = vi; LuuPa(i); } Try(i+1); //x[i] = 0; daxet[j] = 0; wi -= w[j]; vi -= v[j]; } } int main() { DocDL(); wi = 0; vmax = 0; 175 vi = 0; Try(0); InPa(); getch(); } 3. Cài đặt thuật toán nhánh cận giải bài toán chiếc ba lô /*Doc du lieu tu file Balo.txt Dong thu nhat ghi so do vat (n) va trong luong toi da cua ba lo (m) n dong tiep theo ghi trong luong va gia tri cua vat */ #include #include #include #define max 100 int v[max], w[max]; int daxet[max]; int vi, wi, vmax; int n, W; int x[max]; int pa[max]; void DocDL() { FILE *f; f = fopen("BaLo.txt", "r"); if(!f) { puts("Loi mo tep"); getch(); exit(0); } fscanf(f, "%d", &n); 176 fscanf(f, "%d", &W); for(int i=1; i<=n; i++) { fscanf(f, "%d", &w[i]); fscanf(f, "%d", &v[i]); } fclose(f); } int sumv(int i) { int s = 0; for(int j=i; j<=n; j++) s += v[j]; return s; } void LuuPa(int i) { for(int j=1; j<=i; j++) pa[j] = x[j]; } void InPa() { int i = 1; while(pa[i]) { printf("%3d", pa[i]); i++; } } void Try(int i) { 177 for(int j=1; j<=n; j++) { int v1, w1; v1 = vi + v[j]; w1 = wi + w[j]; if(!daxet[j] && w1<=W) if((sumv(j+1)+v1)>vmax) { x[i] = j; daxet[j] = 1; vi = v1; wi = w1; if(vi>vmax) { vmax = vi; LuuPa(i); } Try(i+1); daxet[j] = 0; vi -= v[j]; wi -= w[j]; } } } int main() { DocDL(); vi = 0; wi = 0; vmax = 0; Try(1); 178 InPa(); getch(); } 4. Cài đặt thuật toán quy hoạch động giải bài toán dãy con chung dài nhất Input: Hai dãy d1, d2
Output: Dãy con chung có độ dài lớn nhất /* Đọc dữ liệu từ file DayConChung.txt: mỗi dòng ghi 1 dãy*/ #include #include #include #include #define max 100 char a[max], b[max]; int n, m; int L[max][max]; char c[max]; void DocDL() { FILE *f; f = fopen("DayConChung.txt", "r"); if(!f) { puts("Loi mo tep"); getch(); exit(0); } char s[max]; int i; fgets(s, 100, f); n = strlen(s); 179 s[n-1]='\0'; a[0] = ' '; strcat(a, s); fgets(s, 100, f); m = strlen(s); s[m-1]='\0'; b[0] = ' '; strcat(b, s); } int Max(int a, int b, int c) { if(a>=b && a>=c) return a; if(b>=a && b>=c) return b; return c; } void QHD() { int i, j; for(i=1; i<=n; i++) for(j=1; j<=m; j++) if(a[i] == b[j]) L[i][j] = Max(L[i-1][j], L[i][j-1], L[i-1][j-1] + 1); else L[i][j] = Max(L[i-1][j], L[i][j-1], L[i-1][j-1]); } void TruyVet() { int i, j; i=n-1; j=m-1; 180 int k=0; while(i && j) { if(L[i][j] == L[i-1][j]) i--; if(L[i][j] == L[i][j-1]) j--; if(a[i] == b[j]) { c[k] = a[i]; i--; j--; k++; } } c[k] = '\0'; } void InPA() { int l; l= strlen(c); for(int i=l-1; i>=0; i--) printf("%c", c[i]); } int main() { DocDL(); puts(a); puts(b); QHD(); TruyVet(); 181 InPA(); getch(); } 5. Cài đặt thuật toán quay lui giải bài toán Mã đi tuần #include #include #include int p; void InBanCo(int n, int *bc) { puts(""); for(int i=0; i { for(int j=0; j printf("%5d",*(bc+ i*n+j)); printf("\n"); } } void try(int i, int n, int *bc,int *x,int *y, int *dx, int *dy) { for(int j=0; j<8; j++) { int u; int v; u = *x + dx[j]; v = *y + dy[j]; if(u >=0 && u <8 && v>=0 && v <8 && *(bc+u*n+v)==0) { *(bc + u*n +v) = i; if(i == n*n) InBanCo(n,bc); 182 else { *x = u; *y = v; try(i+1,n,bc,x,y,dx,dy); if(p==0) { *x = u - dx[j]; *y = v - dy[j]; *(bc + u*n + v) = 0; } } if(p==1) { *x = u - dx[j]; *y = v - dy[j]; *(bc + u*n + v) =0; } } } } main() { int n, x, y, *bc; int dx[] = {-2, -1, 1, 2, 2, 1, -1, -2}; int dy[] = {1, 2, 2, 1, -1, -2, -2, -1}; printf("BAI TOAN MA DI TUAN\n\n"); printf("Nhap n = "); scanf("%d",&n); bc = (int *)calloc(n*n, sizeof(int)); printf("\nIn tat ca cac truong hop (1/0): ");
183 scanf("%d", &p); *bc = 1; x = 0; y = 0; try(2,n,bc,&x,&y,dx,dy); getch(); } 5. Cài đặt thuật toán thiết kế theo kỹ thuật tham lam giải bài toán người bán hàng. /* Dữ liệu lưu trong tệp NguoiBanHang.txt: ghi số thành phố và ma trận chi phí*/ #include #include #include #define max 100 int n; int matran[max][max]; int daxet[max]; int pa[max]; int chiphi; void DocDL() { FILE *f; f = fopen("NguoiBanHang.txt", "r"); if(!f) { puts("Loi mo tep"); getch(); exit(0); } fscanf(f, "%d", &n); int i, j; for(i=1; i<=n; i++) 184 for(j=1; j<=n; j++) fscanf(f, "%d", &matran[i][j]); fclose(f); } void InPa() { int i; puts(""); for(i=1; i<=n; i++) printf("%d -> ", pa[i]); printf("%d -%3d", pa[n+1], chiphi); } int ChiPhiMin(int i) { int chiso = -1; int j; for(j=1; j<=n; j++) if(matran[i][j] && !daxet[j]) break; if(j>n) return -1; else { chiso = j; for(j=1; j<=n; j++) if(matran[i][chiso] > matran[i][j] && matran[i][j] && !daxet[j]) chiso = j; return chiso; } } void Greed_TSP() 185 { int i; for(i = 2; i<=n; i++) { int chiso; chiso = ChiPhiMin(pa[i-1]); if(chiso==-1) break; else { pa[i] = chiso; daxet[chiso] = 1; chiphi += matran[pa[i-1]][chiso]; } } if(i!=n+1) printf("\nKhong co phuong an - Khong di qua het cac dinh"); else { if(matran[pa[n]][1]) { chiphi += matran[pa[n]][1]; InPa(); } else printf("\nKhong co phuong an - Khong quay tro lai dinh 1 duoc"); } } int main() { DocDL(); 186 pa[1] = 1; daxet[1] = 1; chiphi = 0; Greed_TSP(); getch(); } 6. Cài đặt thuật toán thiết kế theo kỹ thuật quay lui giải bài toán người bán hàng. /* Dữ liệu lưu trong tệp NguoiBanHang.txt: ghi số thành phố và ma trận chi phí*/ #include #include #include #define max 100 int n; int matran[max][max]; int daxet[max]; int pa[max]; int chiphi; int c; void DocDL() { FILE *f; f = fopen("NguoiBanHang.txt", "r"); if(!f) { puts("Loi mo tep"); getch(); exit(0); } fscanf(f, "%d", &n); int i, j; for(i=1; i<=n; i++) 187 for(j=1; j<=n; j++) fscanf(f, "%d", &matran[i][j]); fclose(f); } void InPa() { int i; puts(""); for(i=1; i<=n; i++) printf("%d -> ", pa[i]); printf("%d - %d", pa[n+1], chiphi); puts(""); } void Try(int i) { for(int j=2; j<=n; j++) if(!daxet[j] && matran[pa[i-1]][j]) { pa[i] = j; daxet[j] = 1; c += matran[pa[i-1]][j]; if(i==n) { if(matran[pa[n]][1]) { chiphi = c; chiphi += matran[pa[n]][1]; pa[n+1] = 1; InPa(); } } 188 else Try(i+1); daxet[j] = 0; c -= matran[pa[i-1]][j]; } } void inmt() { for(int i=1; i<=n; i++) { puts(""); for(int j=0; j<=n; j++) printf("%3d", matran[i][j]); } } int main() { DocDL(); daxet[1] = 1; pa[1] = 1; c = 0; Try(2); getch(); } 7. Cài đặt thuật toán thiết kế theo kỹ thuật nhánh cận giải bài toán người bán hàng. /* Dữ liệu lưu trong tệp NguoiBanHang.txt: ghi số thành phố và ma trận chi phí*/ #include #include #include #define max 100 int n; 189 int matran[max][max]; int daxet[max]; int pa[max]; int chiphi; void DocDL() { FILE *f; f = fopen("NguoiBanHang.txt", "r"); if(!f) { puts("Loi mo tep"); getch(); exit(0); } fscanf(f, "%d", &n); int i, j; for(i=1; i<=n; i++) for(j=1; j<=n; j++) fscanf(f, "%d", &matran[i][j]); fclose(f); } void InPa() { int j; for(j=1; j<=n; j++) printf("%d -> ", pa[j]); printf("%d - %d", pa[n+1], chiphi); } void Try(int i, int c) { int j; 190 int c1; for(j=2; j<=n; j++) { if(!daxet[j] && matran[pa[i-1]][j]) { c1 = c + matran[pa[i-1]][j]; if(c1 { pa[i] = j; daxet[j] = 1; c = c1; if(i==n) { if(matran[pa[n]][1] && (c+ matran[pa[n]][1]) < chiphi) { pa[n+1] = 1; chiphi = c+ matran[pa[n]][1]; } } else Try(i+1, c); daxet[j] = 0; } } } } int main() { DocDL(); pa[1] = 1; daxet[1] = 1; 191 chiphi = 1000; Try(2, 0); InPa(); getch(); } 8.Cài đặt thuật toán thiết kế theo kỹ thuật quy hoạch động giải bài toán máy trả tiền ATM. /* Dữ liệu lưu trong tệp DoiTien.txt: ghi mệnh giá n loại tiền, số tiền cần rút*/ #include #include #include #define max 100 int n, soTien; int T[max]; int pa[max]; int B[max][max]; void DocDL() { FILE *f; f = fopen("DoiTien.txt", "r"); if(!f) { puts("Loi doc tep"); getch(); exit(0); } fscanf(f, "%d", &n); fscanf(f, "%d", &soTien); int i; for(i=1; i<=n; i++) fscanf(f, "%d", &T[i]); 192 fclose(f); } int Min(int a, int b) { if(a
return a; else return b; } void QHD() { int i, j; for(j=1; j<=soTien; j++) B[0][j] = 32767; for(i=1; i<=n; i++) for(j=1; j<=soTien; j++) { if(j B[i][j] = B[i-1][j]; else B[i][j] = Min(B[i-1][j], 1+ B[i][j-T[i]]); } } void TruyVet() { int i, j; i = n; j = soTien; int k; k = 1; while(i && (j>0)) { 193 if(B[i][j] == B[i-1][j]) i--; if(B[i][j] == (1 + B[i][j-T[i]])) { j -= T[i]; pa[k] = i; k++; } } } void InPA() { int i; i = 1; while(pa[i]) { printf("%3d", T[pa[i]]); i++; } } main() { DocDL(); QHD(); TruyVet(); InPA(); getch(); } 194 [1]. Đỗ Xuân Lôi, Cấu trúc dữ liệu và giải thuật, Nhà xuất bản Đại học quốc gia Hà Nội, 2004 [2]. Nguyễn Đức Nghĩa - Nguyễn Tô Thành, Toán rời rạc, Nhà xuất bản Đại học quốc gia Hà Nội, 2003 [3]. Robert Sedgewick, Cẩm nang thuật toán, NXB Khoa học kỹ thuật, 2004. [4]. Chủ biên Ngọc Anh Thư, Nhóm dịch Nguyễn Tiến, Nguyễn Văn Hoài, Nguyễn Hữu Bình, Đặng Xuân Hường, Ngô Quốc Việt, Trương Ngọc Vân: Giáo trình Thuật toán, NXB Thống kê, 2002 [5]. Nhóm dịch Trần Đan Thư, Vũ Mạnh Tường, Dương Vũ Diệu Trà, Nguyễn Tiến Huy: Cẩm nang Thuật toán, NXB Khoa học và Kỹ thuật, 1998 [6]. Hà Huy Khoái, Nhập môn số học thuật toán, NXB Khoa học kỹ thuật, 1997. [7].Giải thuật và Lập trình, Lê Minh Hoàng, Đại học Sư phạm Hà Nội, 2002. [8]. Trần Tuấn Minh, Thiết kế và đánh giá thuật toán, Đại học Đà lạt, 2002 [9]. Đinh Mạnh Tường, Cấu trúc dữ liệu & Thuật toán, Nhà xuất bản khoa học và kĩ thuật, 2001 [10]. Nguyễn Xuân Huy, Thuật toán, Nhà xuất bản thống kê, 1988 [11]. Thomas H. Cormen:Introduction to Algorithms, Second Edition, 2001
[12]. Robert Sedgewick, Algorightms 2nd Edition, ISBN: 0201066734, Addison
Wesley, 1988. [13]. Niklaus Wirth, Algorithms and Data Structures, Prentice-Hall, 1986 [14]. Donald E. Knuth, Selected papers on analysis of algorithms, LeLand Stanford Junior University, 2000 [15]. Gregory L.Heileman, Data structures, algorithms, and object – oreinted programing, McGraw – Hill, 1996 195BÀI TẬP CHƢƠNG 1
Chƣơng 2
KỸ THUẬT CHIA ĐỂ TRỊ
Bµi tËp ch ¬ng 2
1
1 2 3 4 5 6 7
1 2 3
1 2
1 2 3 4 5 6 7 8
1 2 3 4
2 1 4 3 6 5 8 7
2 1 4 3
2 1
3 4 1 2 7 8 5 6
3 4 1 2
4 3 2 1 8 7 6 5
4 3 2 1
5 6 7 8 1 2 3 4
6 5 8 7 2 1 4 3
7 8 5 6 3 4 1 2
8 7 6 5 4 3 2 1
Ch ¬ng 3
Kü thuËt tham lam
Bµi tËp ch ¬ng 3
Chƣơng 4
KỸ THUẬT QUAY LUI
BÀI TẬP CHƢƠNG 4
Chƣơng 5
KỸ THUẬT NHÁNH VÀ CẬN
Dùng thuật toán nhánh cận (bằng cách chọn cạnh phân nhánh) tìm hành trình tối
(1,0):S=10;
T=5; g=13;
BÀI TẬP CHƢƠNG 5
Ch ¬ng 6
Kü thuËt quy ho¹ch ®éng
C =
BÀI TẬP CHƢƠNG 6
PHỤ LỤC
TÀI LIỆU THAM KHẢO