Tải bản đầy đủ (.doc) (10 trang)

Một vài kỹ thuật lập trình potx

Bạn đang xem bản rút gọn của tài liệu. Xem và tải ngay bản đầy đủ của tài liệu tại đây (221.17 KB, 10 trang )

Một vài kỹ thuật lập trình
Ngày gửi bài: 19/01/2006
So luot doc: 1318
Bài viết này là lược dịch từ chương 10 ″Algorithm Design Techniques″ của quyển sách
nổi tiếng ″Data Structure and Algorithms″ của các tác giả Aho A.V., Hopcroft J.E. và
Ullman J.D. Tiêu đề do người biên soạn bài viết tự đặt.
Các bạn trẻ thân mến.
Thuật toán, kỹ thuật lập trình luôn là một điều bí ẩn và hấp dẫn đối với các bạn trẻ, các
bạn học sinh và sinh viên đang ngồi trên ghế nhà trường. Nhiều bài toán khó, đa dạng
tưởng chừng như không thể tìm được lời giải lại ẩn chứa bên trong những thuật toán đơn
giản, tuyệt đẹp đến không ngờ. Cuốn sách ″Data Structure and Algorithms″ của các tác
giả Aho A.V., Hopcroft J.E. và Ullman J.D đã ra đời cách đây 20 năm nhưng giờ đây đọc
lại vẫn thấy rất mới mẻ và chứa đựng nhiều ý tưởng hay. Đặc biệt chương ″Một vài kỹ
thuật thiết kế thuật toán″ chứa đựng nhiều ý tưởng xác định cho toàn bộ định hướng
nghiên cứu và giải quyết bài toán của ngày hôm nay. Nhân dịp số báo đặc biệt Tết Quí
Mùi, chúng tôi chân trọng giới thiệu với bạn đọc, đặc biệt là các bạn học sinh, sinh viên
chuyên tin lược dịch của chương này. Trong quá trình biên soạn, chúng tôi đã thay đổi
chút ít cho phù hợp với đối tượng của độc giả Tin học & Nhà trường. Trong khi trình bày
thuật toán các tác giả đã sử dụng khá nhiều kỹ thuật phân tích đánh giá độ phức tạp, đối
với các bạn lần đầu làm quen với những khái niệm này có thể hoàn toàn bỏ qua chúng mà
không ảnh hưởng gì đến việc đọc các phần khác của bài viết. Chúc các bạn một năm mới
với nhiều sáng tạo mới, và mong rằng những ý tưởng của bài viết này được các bạn tiếp
nhận và áp dụng sáng tạo trong công việc của mình.
1. Thuật toán Chia để Trị (Divide & Conquer)
Có lẽ thuật toán được sử dụng nhiều nhất, quan trọng nhất là kỹ thuật ″Chia để Trị″. Kỹ
thuật này sẽ chia bài toán hiện thời thành N bài toán nhỏ hơn, thực hiện lời giải cho từng
bài toán nhỏ này và từ đó xây dựng thuật toán cho bài toán lớn tổng hợp. Ví dụ cho các
thuật toán này là Sắp xếp Trộn
(1)
hoặc Tìm kiếm Nhị phân
(2)


mà các bạn đã đã được biết
trong chương trình Tin học Cơ bản.
Để minh họa rõ hơn cho kỹ thuật này chúng ta hãy xét một ví dụ quen thuộc đó là bài
toán ″Tháp Hà Nội″. Giả sử có 3 cọc A, B, C. Ban đầu tại A đặt một số đĩa với thứ tự trên
nhỏ dưới to như hình vẽ.
Yêu cầu của bài toán là chuyển toàn bộ số đĩa trên sang cọc B, trong quá trình chuyển
được phép sử dụng đĩa C, mỗi lần chuyển đúng 01 đĩa và luôn bảo đảm nguyên tắc đĩa
nhỏ nằm trên đĩa to trong suốt quá trình chuyển.
Bài toán
Tháp Hà Nội trên có thể giải với thuật toán ″thông minh″ sau: Giả sử ta đặt 3 cọc trên tại
các đỉnh của một tam giác đều. Tại bước với số lượt là lẻ, ta chuyển đĩa nhỏ nhất sang
cọc bên cạnh theo chiều kim đồng hồ, tại bước đi với số lượt là chẵn, ta thực hiện một
thao tác bất kỳ nhưng không đụng đến cái đĩa nhỏ nhất. Các bạn dễ dàng kiểm tra rằng đó
là một thuật toán đúng, tuy nhiên nó rất khó hiểu, khó tổng quát sang các trường hợp
khác.
Ta hãy thử vận dụng tư duy của thuật toán ″Chia để Trị″ đối với bài toán Tháp Hà Nội
này. Bài toán chuyển N đĩa từ A sang B có thể chia thành 2 bài toán nhỏ hơn với kích
thước N-1 như sau: (a) Chuyển N-1 đĩa đầu tiên từ A sang C (giữ nguyên trạng thái của
đĩa thứ N tại A). (b) Chuyển đĩa thứ N từ A sang B và chuyển N-1 đĩa từ C sang B. Chú ý
rằng khi thực hiện bài toán (b) phần chuyển N-1 đĩa từ C sang B ta có thể dùng lại hoàn
toàn thuật toán của bài (a) nhưng với vị trí thay đổi giữa A và C và tất nhiên bỏ qua sự có
mặt của đĩa thứ N trong A hay B. Với cách tư duy như vậy, việc mô phỏng thuật toán sẽ
tương đối khó do nó phải gọi đệ qui đến chính nó nhưng cách làm trên thật là dễ hiểu và
cho phép chúng ta áp dụng cho nhiều lớp bài toán khác. Chúng ta hãy xét một vài ví dụ.
Ví dụ 1: Bài toán nhân các số tự nhiên lớn
Xét bài toán nhân 2 số tự nhiên n-bit X và Y. Bài toán nhân 2 số tự nhiên n-bit (n chữ số)
đã được dạy trong nhà trường phổ thông với độ phức tạp O(n
2
)
(3)

. Bây giờ chúng ta sẽ xét
lại bài toán này với kỹ thuật Chia để Trị. Ta phân tách mỗi số X, Y thành 2 phần, mỗi
phần n/2 bit. Để cho đơn giản ta sẽ luôn xét n là lũy thừa của 2. X, Y sẽ được phân tích
thành 2 phần n/2-bit như sau:
X = A | B (X = A2
n/2
+ B)
Y = C | D
(Y = C2
n/2
+ D)
Khi đó tích XY sẽ có dạng:
XY = AC2
n
+ (AD+BC)2
n/2
+ BD (1)
Dựa trên công thức (1) ta có thể suy luận đơn giản như sau cho việc tính tích XY: chúng
ta sẽ tính 4 phép nhân với các số n/2-bit là AC, AD, BC và BD, sau đó thực hiện 3 phép
cộng với các số 2n-bit, cuối cùng là 2 phép chuyển chữ số (2 phép nhân với lũy thừa của
2) Các phép cộng và phép chuyển chữ số đều được thực hiện với thời gian O(n), do đó ta
thu được công thức tính độ phức tạp của phép toán trên T(n) là:
T(1) = 1
T(n) = 4T(n/2) + C.n (C-const)
(2) Công thức (2) cho ta T(n) = O(n
2
) và như vậy ta chưa thu được kết quả gì mới so với
phương pháp tính từ nhà trường phổ thông.
Bây giờ ta biến đổi công thức (1) dưới dạng:
XY = AC2

n
+ ((A-B)(D-C) + AC + BD)2
n/2
+ BD
(2) Công thức (2) mặc dù phức tạp hơn (1) nhưng chúng có thể được tính bởi:
- 3 phép nhân n/2-bit: AC, BD và (A-B)(D-C).
- 6 phép +,- các số n/2-bit.
- 2 phép chuyển chữ số (nhân với lũy thừa của 2).
Do vậy với cách tính trên ta có công thức sau tính độ phức tạp của thuật toán này:
T(1) = 1
T(n) = 3T(n/2) + C.n (C-const)
(3) Công thức (3) cho ta
Như vậy ta đã thu được một kết quả mới cho việc thực hiện phép nhân 2 số tự nhiên n-bit
với thuật toán mạnh hơn phép nhân bình thường đã học trong nhà trường (
4
).
Ví dụ 2: Bài toán tạo lịch thi đấu Tennis
Giả sử cần lập một lịch thi đấu Tennis cho n = 2
k
vận động viên (VĐV). Mỗi vận động
viên phải thi đấu với lần lượt n-1 vận động viên khác, mỗi ngày thi đấu 1 trận. Như vậy
n-1 là số ngày thi đấu tối thiểu phải có. Chúng ta cần lập lịch thi đấu bằng cách thiết lập
ma trận có n hàng, n-1 cột. Giá trị số tại vị trí (i,j) (hàng i, cột j) chỉ ra vận động viên cần
thi đấu với vận động viên i trong ngày thứ j.
Sử dụng kỹ thuật Chia để Trị, chúng ta hãy lập lịch thi đấu cho nửa (n/2) số vận động
viên đầu tiên. Bằng việc sử dụng lời gọi đệ qui chúng ta đưa bài toán về trường hợp chỉ
có 2 VĐV.
Chúng ta minh họa bằng trường hợp n=8. Lịch thi đấu cho 4 người đầu tiên của danh
sách chiếm nửa trái trên của ma trận (4 hàng, 3 cột). Phần nửa trái dưới (4 hàng, 3 cột)
của ma trận là lịch thi đấu của 4 VĐV còn lại (từ 5 đến 8). Phần này thu được từ nửa trái

trên bằng cách cộng 4 vào mỗi phần tử tương ứng của ma trận. Để điền nốt các phần còn
lại của ma trận chúng ta chỉ cần xác định lịch thi đấu giữa các VĐV với số thấp (≤n/2)
với các VĐV với số cao (≥n/2). Để làm việc này chúng ta xếp các VĐV từ 1 đến n/2 đấu
lần lượt với các VĐV số cao vào ngày 4. Các ngày còn lại thu được từ ngày 4 bằng cách
hoán vị vòng quanh các VĐV với số thứ tự cao. Quá trình điền số được mô tả trong hình
2. Các bạn có thể tổng quát quá trình này cho trường hợp tổng quát n=2
k
bất kỳ.
2.Thuật toán Qui Hoạch Động (Dynamic Programming)
Trong phần trên chúng ta đã thấy sức mạnh của kỹ thuật Chia để Trị bằng cách chia nhỏ
bài toán cần làm. Tuy nhiên không phải bao giờ cũng có thể chia nhỏ bài toán thành các
bài toán con và từ đó tìm ra lời giải của bài toán lớn. Trong các trường hợp như vậy, mặc
dù chúng ta vẫn có thể chia nhỏ bài toán thành nhiều bài toán con, nhưng thời gian thu
được sẽ tăng theo số mũ và thuật toán trở nên vô giá trị.
Trên thực tế, việc chia thành các bài toán con thường chỉ chiếm thời gian là đa thức.
Trong trường hợp này một bài toán con sẽ được lặp lại nhiều lần trong quá trình tìm kiếm
lời giải. Để khỏi mất thời gian mỗi khi giải quyết các bài toán con, các bạn sẽ lưu trữ các
lời giải này để tra cứu về sau mỗi khi cần đến. Công việc này sẽ đòi hỏi độ phức tạp thuật
toán là đa thức.
Có một cách làm còn đơn giản hơn cách đã nêu trên. Chúng ta sẽ lưu giữ tất cả các lời
giải của các bài toán con lại không cần biết rằng chúng có được dùng lại nhiều lần về sau
hay không, không quan tâm đến việc các lời giải này có cần thiết cho lời giải của bài toán
chính của chúng ta hay không. Cách làm như vậy có tên gọi là Qui hoạch động. Bản thân
từ qui hoạch động được lấy từ lý thuyết điều khiển.
Cách cài đặt thực tế của thuật toán qui hoạch động không thống nhất nhưng điều chung
nhất ở chúng là có một cái bảng và chúng ta cần lần lượt điền các thông số vào cái bảng
này. Để minh họa chúng ta hãy xét một vài ví dụ.
Ví dụ 3: Trò chơi Tán thủ
(5)
Giả sử có hai tán thủ A, B cần đấu trực diện với nhau, qui định chung là người thắng

trước n ván sẽ là người thắng cuộc. Trên thực tế thường giá trị n = 4. Giả sử hai tán thủ
A, B là mạnh ngang nhau và do đó sác xuất thắng, thua trong mỗi ván là 50/50. Giả sử
P(i,j) là sác xuất sao cho A cần thắng thêm i ván nữa , B cần thắng thêm j ván nữa thì A
sẽ chắc chắn thắng chung cuộc. Chúng ta cần tính những giá trị P(i,j) này với i, j bất kỳ.
Nếu i=0, j>0, tức là A đã thắng rồi và do đó P(0,j)=1. Nếu i>0, j=0, tức là B đã thắng và
A đã thua rồi, do đó P(i,0)=0. Với i, j > 0 ta có nhận xét sau: sác xuất để A thắng chung
cuộc dựa vào ván tiếp theo A thắng hay thua. Nếu ván tiếp theo A thắng, khi đó sác xuất
để A thắng sẽ là P(i-1,j), còn nếu A thua ở ván tiếp theo thì sác xuất để A vẫn thắng
chung cuộc sẽ là P(i,j-1). Vì ván tiếp theo khả năng A thắng thua là 50/50 nên ta có công
thức P(i,j) = (P(i-1,j)+P(i,j-1))/2. Tóm lại ta có công thức truy hồi sau để tính P(i,j).
Từ công thức (4) với i+j=n ta dễ dàng tính được công thức truy hồi của độ phức tạp tính
toán T(n) như sau:
T(1) = C(C-const)
T(n) = 2T(n-1) + D (D-const)
(5) Ta tính được T(n) = O(2
n
). Như vậy việc tính toán các hệ số P(i,j) sẽ có độ phức tạp
tăng theo số mũ của n nếu tính toán bằng kỹ thuật đệ qui và đây là một kết quả rất lớn.
Tuy nhiên công thức trên chỉ cho ta giới hạn trên của tính toán, để hiểu rõ hơn sự″tồi tệ″
thực sự của việc sử dụng đệ qui tính toán theo công thức(4) chúng ta sẽ thử tính toán giới
hạn dưới của công việc tính toán này. (Giới hạn dưới của độ phức tạp được ký hiệu là
big-omega: W).
Để tính được giá trị này chúng ta sẽ tính số lần gọi hàm P khi thực hiện đệ qui cách tính
P(i,j) theo công thức (4). Công thức (4) với i+j=n nếu xem xét kỹ sẽ gợi ý cho chúng ta
về một đẳng thức tương tự của hệ số tổ hợp là
(tổ hợp chập i từ n phần tử, số cách chọn ra i phần tử từ tập hợp ban đầu n phần tử). Từ
nhận xét trên dễ dàng suy ra rằng số lần gọi hàm P trong lời gọi P(i,j) sẽ ít nhất là
.
Với i=j=n/2 dễ thấy giá trị này sẽ bằng
.

Vậy ta vừa chứng minh được rằng cận
dưới độ phức tạp tính toán P(i,j) là là một giá trị rất lớn (tuy có nhỏ hơn 2
n
) và
hầu như không thể áp dụng tính toán trên thực tế.
Cách tính P(i,j) tốt nhất là vừa tính vừa điền số vào bảng như mô tả trong hình 3 dưới
đây.
Bảng hệ số P(i,j) được điền tuần tự như sau: Trước tiên để ý rằng hàng dưới cùng của
bảng là toàn 0 và hàng đầu tiên bên phải sẽ là toàn 1. Xuất phát từ góc phải dưới chúng ta
lần lượt điền số vào bảng theo hướng Tây-Bắc dọc theo đường chéo ngược với i+j không
thay đổi. Thuật toán điền số P(i,j) vào bảng được mô tả như sau:
Function Ođs(i,j: integer):real;
(7)
var s,k:integer;
Begin
for s:=1 to i+j do
begin P[0,s]:=1;
P[s,0]:=0;
for k:=1 to s-1 do
P[k,s-k]:=(P[k-1,s-k]+P[k,s-k-1])/2;
end;
Ođs:=(P[i,j]);
End; {Ođs}
Ta hãy thử phân tích thuật toán trên. Vòng lặp bên trong là O(s) thời gian, hai lệnh gán 0
và 1 chỉ là O(1) thời gian, như vậy tổng số thời gian tính từ vòng lặp ngoài sẽ là
với n=i+j. Chắc các bạn đã thấy sự kỳ diệu của phương pháp điền bảng
số so sánh với việc gọi đệ qui, và đó là tư tưởng của thuật toán qui hoạch động.
Ví dụ 4: Bài toán Phân hoạch Tam giác
Ta sẽ xét thêm một ví dụ nữa minh họa cho kỹ thuật qui hoạch động, đó là bài toán tam
giác hóa đa giác. Giả sử có một đa giác trên mặt phẳng với các đỉnh cho trước. Yêu cầu

nối các ″cung″ nối giữa hai đỉnh bất kỳ của đa giác để chia đa giác thành các tam giác
nhỏ hơn (phân hoạch tam giác) sao cho tổng các dây cung nối là nhỏ nhất. Một cách chọn
dây cung như vậy được gọi là một Phân hoạch tam giác tối thiểu.
Hình 4 mô tả một đa giác 7 cạnh với một phân hoạch tam giác. Từ dữ liệu trên hình vẽ ta
tính được tổng chiều dài của phân hoạch này là
tuy nhiên phân hoạch này không
là tối ưu.
Bây giờ chúng ta sẽ dùng kỹ thuật qui hoạch động để giải bài toán phân hoạch tam giác
này. Để tiện cho việc theo rõi, chúng ta sẽ ký hiệu các đỉnh của đa giác là V
0
, V
1
, , V
n-1

theo chiều kim đồng hồ. Tổng chiều dài các dây cung của một phân hoạch sẽ được gọi là
Giá trị của phân hoạch này. Trước tiên chúng ta có một số nhận xét sau đây:
Bổ đề 1. Trong mọi phân hoạch tam giác, hai đỉnh kề nhau bất kỳ của đa giác bao giờ
cũng có tối thiểu một đỉnh được nối với một dây cung.
Giả sử V
i
,V
i-1
là hai đỉnh kề mà không được nối với bất cứ dây dung nào của phân hoạch
tam giác. Khi đó vùng phân hoạch chứa cạnh V
i
V
i+1
sẽ phải chứa thêm hai cạnh nữa là V
i-

1
V
i
và V
i+1
V
i+2
và do đó vùng phân hoạch này không là tam giác.
Bổ đề 2. Giả sử (V
i
,V
j
) là một dây cung của phân hoạch tam giác, khi đó phải tồn tại một
đỉnh V
k
sao cho (V
i
,V
k
) và (V
k
,V
j
) sẽ là cạnh của đa giác hoặc dây cung của phân hoạch.
Thật vậy, cạnh (V
i
,V
j
) phải là cạnh của một tam giác của phân hoạch. Đỉnh thứ 3 chính là
V

k
cần tìm.
Để bắt đầu tìm kiếm phân hoạch tam giác tối ưu, chúng ta chọn 2 đỉnh kề bất kỳ, chẳng
hạn V
0
và V
1
. Khi đó phải tồn tại đỉnh Vk sao cho V
0
V
k
và V
1
V
k
là cạnh hoặc dây cung
của phân hoạch. Với mỗi cách chọn k như vậy ta đưa bài toán tìm phân hoạch về 2 bài
toán con, tương ứng với 2 đa giác con xác định bởi dây cung vừa tìm được phân chia đa
giác ban đầu thành 2 phần. Với ví dụ đa giác 7 cạnh đã nêu trên giả sử ta chọn đỉnh V
3

với cung V
0
V
3
, khi đó sẽ đưa về 2 bài toán con ứng với hai đa giác sau đây:
Tiếp theo chúng ta cần giải quyết bài toán phân hoạch tam giác cho hai trường hợp tương
ứng với hình 5 trên. Ví dụ với Bài toán con 2, nếu chúng ta bắt đầu từ cung V
3
V

5
thì sẽ
chia thành 2 bài toán con nữa tương ứng với hai đa giác con là (V
3
,V
4
,V
5
) và
(V
0
,V
3
,V
5
,V
6
). Cách tiếp cận như vậy sẽ có thời gian tăng số mũ với n.
Chú thích của người viết.
(1) Sắp xếp trộn. Thuật toán sắp xếp tách dãy ban đầu thành 2 dãy và sau đó tiến hành
″trộn″ hai dãy này lại với nhau theo hình thức xét lần lượt từng phần tử đầu của các dãy
này.
(2) Tìm kiếm nhị phân. Thuật toán tìm kiếm trên một dãy đã sắp thứ tự dựa trên ý tưởng
luôn chia dãy đã cho thành 2 phần bằng nhau và đưa việc tìm trên dãy lớn về bài toán tìm
kiếm trên các dãy con này.
(3) Phương pháp tính nhân trong nhà trường phổ thông. Chia nhỏ việc nhân 2 số n-chữ số
XY thành n bài toán con, mỗi bài toán con nhân X với một số có một chữ số. Với mỗi bài
toán con như vậy việc nhân mất O(n) thời gian, do vậy tổng số độ phức tạp là O(n
2
).

(4) Tới đây sẽ có bạn thắc mắc rằng thế thì tại sao không dạy cách nhân mới này cho học
sinh ngay từ trên ghế nhà trường. Tuy nhiên thuật toán mô tả ở đây hoàn toàn không tốt
hơn cách nhân thông thường ở trường phổ thông với n<5000. Ma(.t kha'c no' la.i qua'
phu+'c ta.p va` kho^ng sa'ng su?a nhu+ thua^.t toa'n ho.c sinh ddang su+? du.ng hie^.n
nay.
(5) Nguyên tác là cụm từ World Series Ođs, chúng tôi sửa lại cho thích hợp hơn với thực
tế của Việt Nam.
(6) Cách tính như sau:
Với i=j=n/2 ta cần phải chứng minh:
Ta chứng minh bất đẳng thức này qui nạp theo n (n-chẵn). Với n=2, bất đẳng thức là hiển
nhiên. Giả sử nó đã đúng với n, ta sẽ chứng minh nó cũng đúng với n+2.
Thật vậy khi đó ta có:
.
theo qui nạp giá trị này sẽ lớn hơn hoặc bằng
.
bất đẳng thúc cuối cùng xảy ra vì
.
Đó là đpcm.
(7) Tôi dùng tên hàm từ nguyên bản tiếng Anh.
Bùi Việt Hà

×