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

Giải thuật đệ quy và những bài toán kinh điển trong đệ quy ( Phần 1 )

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 (310.37 KB, 20 trang )

Giải thuật : Đệ Quy
1

Cách
1

2

Tại
4

3

Phương
6

4


12

đệ
sao

quy
đệ

hoạt
quy

pháp


dụ

về

một

động

hoạt

động

truy
số

suất

hàm

đệ

quy

4.1

Tìm phần tử lớn nhất của mảng................................................................12

4.2

Nhận diện số nguyên không dấu ( Unsigned)...........................................13


4.3

Ước chung lớn nhất...................................................................................13

5

Tuần
14

tự

trong

đệ

quy

5.1

Xử lí đầu vào của mảng............................................................................14

5.2

Toà tháp Hà Nội.........................................................................................15

Bài viết này mô tả việc sử dụng đệ quy trong lâp trình.
Đệ quy tương đương với sức mạnh của phương pháp
lặp ­ do đó, một ngôn ngữ lập trình không cần cả hai,
nhưng đệ  quy làm nhiều tác vụ  dễ  dàng hơn để  lập

trình và các ngôn ngữ chủ  yếu dựa vào các vòng lặp
cũng  luôn  cung  cấp   đệ  quy. Sức mạnh  đến từ  khả
năng giải quyết vấn đề  đệ  quy sẽ  thay đổi cách bạn
tiếp cận nhiều vấn đề của lập trình.


Đệ quy là kỹ thuật mô tả và giải quyết các vấn đề bằng cách đưa ra các vấn đề nhỏ
hơn tương tự. Phương pháp đệ quy là những phương pháp có thể tự gọi, trực tiếp
hoặc gián tiếp. Đệ quy là một phương pháp thay thế cho phép lặp; một ngôn ngữ lập
trình với các vòng lặp không mạnh mẽ hơn bằng cách thêm các phương thức đệ
quy, tương tự thêm các vòng lặp vào một ngôn ngữ có đệ quy không làm cho nó
mạnh hơn. Nhưng các kỹ thuật giải quyết vấn đề đệ quy thường khá khác so với
các kỹ thuật lặp và thường đơn giản và rõ ràng hơn nhiều, và do đó dễ lập trình
hơn.

1 Cách đệ quy hoạt động
Để hiểu cách các hàm đệ quy hoạt động, cần phải hiểu các định nghĩa đệ quy và chúng
ta sẽ bắt đầu với các định nghĩa đệ quy của các hàm toán học. Hàm factorial (luỹ thừa)
thường được biểu thị bằng dấu chấm than "!", Có thể được định nghĩa đại khái như sau:
0! == 1
n! == n*(n­1)*(n­2)*...*3*2*1 với số nguyên n > 0.

Luỹ thừa chỉ được xác định cho các số nguyên lớn hơn hoặc bằng 0 và tạo ra các số
nguyên lớn hơn 0.
Do đó,
0! == 1
1! == 1
2! == 2*1 == 2
3! == 3*2*1 == 6
4! == 4*3*2*1 == 24

5! == 5*4*3*2*1 == 120
...
10! == 10*9*8*7*6*5*4*3*2*1 == 3,628,800

Định nghĩa trên là sự lặp lại theo quy luật và chúng ta có thể sử dụng một vòng lặp để
viết cách tính giá trị của n!.
// Factorial: iterative
public static int fact(int n)
{
// Precondition
Assert.pre(n >= 0,"argument must be >= 0");
// Postcondition: returned value == n!
int factValue = 1;
for (int i = 1; i <= n; i++)
factValue = factValue * i;
return fact;
}


Hàm giai thừa có thể được định nghĩa dễ dàng với định nghĩa đệ quy:
0! == 1
n! == n*(n­1)! Với số nguyên n > 0.

Định nghĩa này bao gồm hai mệnh đề hay là hai phương trình. Mệnh đề thứ nhất, được
gọi là mệnh đề cơ sở, đưa ra kết quả trực tiếp, trong trường hợp này, cho đối số 0.
Phương trình thứ hai, được gọi là mệnh đề quy nạp hoặc đệ quy, đánh dấu hay thông
báo rằng định nghĩa là định nghĩa đệ quy vì đối tượng được định nghĩa, (hàm giai thừa
"!"), xuất hiện ở bên phải của phương trình cũng như bên trái. Phương thức hàm đệ quy
để tính giai thừa như sau:
// Factorial: recursive

public static int fact(int n)
{
// Precondition
Assert.pre(n >= 0,"argument must be >= 0");
// Postcondition: returned value == n!
if (n == 0)
return 1;
else
return n * fact(n­1);
}

Định nghĩa này có thể được nhận dạng là đệ quy vì định nghĩa của hàm chứa lệnh gọi
hàm được định nghĩa.Có thể hơi trừu tượng và khó hiểu nhưng sau khi làm một số bài
tập và đọc lại bạn sẽ hiểu. Định nghĩa tự nó cho thấy rất ít sự phức tạp hoặc tính toán;
điều phức tạp nhất xảy ra là tham số n được nhân với một giá trị được trả về bởi một
phương thức. Tại sao nó hoạt động? Nhờ vào các pre và postcondition! Nếu phương
thức này được truyền giá trị 0, nó trả về 1. Nếu nó được truyền giá trị lớn hơn, giả sử 5,
thì nó nhân gấp 5 lần giá trị được trả về bởi thực tế (4). Do đó, kết quả là chính xác nếu
là: thực tế (4) trả về 4!. Nếu muốn, chúng ta có thể thấy thực tế (5) trả về là 5 * 4 * 3 *
2 * 1. Nhưng có một kỹ thuật mạnh mẽ hơn nhiều, và chúng ta sẽ tìm hiểu ngay.
Phương pháp đệ quy thường thể hiện hai tính chất. Đầu tiên, đối với một số tập hợp đối
số, phương thức tính toán câu trả lời là trực tiếp, không cần gọi đệ quy. Những trường
hợp này tương ứng với các mệnh đề cơ bản của định nghĩa. Thông thường một số vấn
đề 'nhỏ' được giải quyết trực tiếp bằng các mệnh đề cơ bản; trong trường hợp giai thừa,
giá trị của hàm cho đối số nhỏ nhất có thể, 0, được chỉ định rõ ràng trong hàm. Thuộc
tính thứ hai của các phương thức đệ quy là nếu một đối số không được xử lý bởi mệnh
đề cơ sở, thì phương thức sẽ tính kết quả bằng cách gọi đệ quy có đối số "gần với mệnh
đề cơ sở" (thường là nhỏ hơn) so với đối số cho lần gọi ban đầu . Trong trường hợp giai
thừa, nếu đối số n không bằng 0, thì một lần gọi đệ quy được thực hiện cho factorial,
nhưng đối số được truyền là n-1.

Các vấn đề nhỏ được giải quyết trực tiếp bằng định nghĩa hàm đệ quy có thể nhiều hơn
một lần và số lượng các lần gọi đệ quy được thực hiện trong một hàm cũng có thể nhiều


hơn một. Một hàm toán học đệ quy kinh điển khác là hàm Fibonacci; như với Factorial (
giai thừa) , nó chỉ được định nghĩa cho các số nguyên không âm.
F(0) = 0 
F(1) = 1 
F(n) = F(n­1) + F(n­2) vớ n>=2

Chuỗi Fibonacci là chuỗi các số nguyên được tạo bởi dãy: 0, 1, 1, 2, 3, 5, 8, 13, 21...
Hai giá trị đầu tiên được tạo mặc định; mỗi phần tử khác là tổng của hai số trước. Việc
thực hiện tính toán các số tiếp theo trong dãy theo phương pháp đệ quy được xây dựng
trực tiếp từ định nghĩa toán học:
// Fibonacci
public static int fibonacci(int n)
{
// Precondition
Assert.pre(n >= 0,"argument must be >= 0");
// Postcondition: returned value is F(n)
if (n == 0 || n == 1) 
return n;
else
return fibonacci(n­1)+fibonacci(n­2);
}

Định nghĩa này giải quyết trực tiếp hai trường hợp, cho các đối số 0 và 1 và có hai lệnh
gọi đệ quy để tính F (n), cả hai đều truyền các đối số nhỏ hơn n.
Sử dụng đệ quy không đảm bảo thành công 100%. Một định nghĩa đệ quy có thể không
chính xác theo nhiều cách khác nhau và hàm đệ quy có thể được gọi không chính xác.

Một số lỗi này sẽ khiến một lần gọi kéo dài mãi mãi không kết thúc. Các lần gọi đệ quy
không kết thúc tương tự như các vòng lặp không kết thúc. Về lý thuyết, hàm Java hoàn
toàn OK, sẽ không bao giờ chấm dứt bởi vì mỗi lần gọi hàm đều dẫn đến một lần gọi
khác - không có trường hợp cơ bản nào để dừng việc tính toán. Trên thực tế, nó sẽ chấm
dứt khi không gian cần thiết cho thời gian chạy stack vượt quá khả năng của máy tính
hoặc khi n-1 quá nhỏ.
public static int byebye(int n)
{
return byebye(n­1) + 1;
}


Hàm sau có trường hợp cơ bản, nhưng sẽ chỉ dừng lại đối với các đối số chẵn dương một đối số là số nguyên lẻ hoặc số âm sẽ dẫn đến một chuỗi các lệnh gọi hàm không bao
giờ kết thúc.
public static int evenOnly(int n) 
{
if (n == 0) // Truong hop co ban
return 0; 
else 
return soChan(n­2) + 2;
}

Các hàm giai thừa và hàm Fibonaccinchỉ được xác định với các số nguyên dương, như
đã nêu các điều kiện trên việc gọi một trong hai đối số âm sẽ dẫn đến việc tính toán
không thể kết thúc.

2 Tại sao phương pháp đệ quy hoạt động
Trong phần trước, chúng ta đã thấy làm thế nào để suy luận về tính chính xác của code
có chứa phép gán, phép lặp. Nhưng làm thế nào để chúng ta thuyết phục bản thân rằng
phương pháp đệ quy hoạt động? Câu trả lời đơn giản, dựa trên nguyên tắc toán học,

hoặc nguyên tắc tổng quát hơn, đệ quy. Trong phần này, mình giới thiệu ngắn gọn về
phương pháp quy nạp trong toán học và sau đó cho thấy ứng dụng của nó đối với
phương pháp đệ quy.
Ví dụ: Giả sử ta muốn chỉ ra rằng với mọi số nguyên không âm n (0, 1, 2, ...) thì biểu
thức (n5-n) là bội số nguyên của 5. Có một số cách thức chứng minh. Ví dụ: chúng ta có
thể chứng minh trực tiếp bằng cách hiển thị (kiểm tra 10 trường hợp khác nhau) rằng
với mọi số tự nhiên n thì n5 và n có cùng một chữ số đơn vị. Do đó n 5-n luôn có kết quả
là chữ số 0 ở vị trí đơn vị, và do đó chia hết cho 10.
Giải thuật thay thế sử dụng bằng cách chứng minh quy nạp. Ở đây chúng ta bắt đầu với
định nghĩa quy nạp của tập hợp và sau đó sử dụng định nghĩa này làm cơ sở chứng
minh. Tập hợp các số nguyên không âm N có thể được mô tả đệ quy như sau:
1. 0 thuộc N.
2. Nếu n thuộc N, thì n+1 cũng thuộc N.
3. Chỉ các số như điều 1 và 2 thì mới thuộc N
Quy tắc đầu tiên thiết lập : tập N không trống; nó có thể nhiều hơn một phần tử. Đây là
quy tắc cơ bản của định nghĩa một tập hợp. Quy tắc thứ hai là quy tắc quy nạp; nó luôn
có dạng "Nếu các phần tử này nằm trong tập hợp, thì phần tử khác này cũng nằm trong
tập hợp." Quy tắc cuối cùng thường không được nêu ra, nhưng nó đóng vai trò loại trừ
phần tử bất kỳ khỏi tập hợp ngoại trừ các phần tử được gộp là hệ quả của các quy tắc
cơ bản và quy nạp.
Mình đã đưa ra một VD quy nạp của tập N, ta thường có thể sử dụng chứng minh quy
nạp để chứng minh một định lý có dạng: Với mọi n thuộc N, P(n); mọi phần tử của n


đều có tính chất của P. Để hiển thị bằng quy nạp rằng tính chất của P(n) là chứa tất cả
các số nguyên không âm, đối với tất cả các phần tử của N, chúng ta phải biểu diễn 2
điều:
1. P(0) là đúng. Nghĩa là P giữ trường hợp cơ sở.
2. Với mọi n thuộc N,P(n) => P(n+1). mọi n có thuộc tính của P thì n+1 cũng có
thuộc tính của P.

Bước đầu tiên được gọi là bước cơ sở chứng minh. Bước cơ sở xác định thuộc tính giữ
tất cả các phần tử được đưa ra trong bước cơ sở định nghĩa của tập N. Bước thứ hai là
bước quy nạp; bất cứ thứ gì có thể được xây dựng bằng cách sử dụng bước quy nạp của
định nghĩa N đều có thuộc tính P. Lưu ý rằng một khi ta đã chỉ ra các bước cơ sở và quy
nạp, ta có một công thức để chỉ ra rằng P(n) giữ bất kỳ n cụ thể nào. Vì P(0) giữ (cơ sở)
, do đó là P(1) (P(n+1)); vì P(1) giữ, nên P(2); vì P(2) giữ, nên P(3) cũng vậy, v.v. Do đó,
chúng ta có thể sử dụng công thức này để hiển thị trực tiếp nhưng rất dài dòng, có khi
đến P(3625). Nhưng nếu chúng ta có thể chỉ ra phép tất suy, quy tắc suy luận toán học
được gọi là Nguyên lý quy nạp toán học cho phép chúng ta kết luận.
Với mọi n trong N, P (n).
Để sử dụng quy nạp cho bài toán (n5­n) ta quan sát như sau. Bước cơ sở là đúng bởi sự
hiển nhiên:
Với mọi n thuộc N, P(n).
Thì:

05­0 == 0

và 0 chia hết cho 5.
Bước quy nạp: Cách phổ biến nhất trình bày lập luận:
Với mọi n, P(n) => Q(n)
Lưu ý rằng phép tất suy này là đúng nếu P(n) là sai, do đó sự suy ra là đúng nếu chúng
ta chỉ đơn giản chỉ ra rằng bất cứ khi nào P(n) là đúng, thì Q(n) là đúng. Trong trường
hợp này, chúng tôi muốn chỉ ra rằng nếu n5 ­ n chia hết cho 5, thì (n+1)5 ­ (n+1) cũng
chia hết cho 5. Để chứng minh điều này, trước tiên ta đánh giá (n+1)5 - (n+1) và được:
(n5 + 5n4 + 10n3 + 10n2 + 5n +1) ­ (n + 1)
Biến đổi ta được:
(n5 -n ) + 5(n4 + 2n3 + 2n2 + n)
Biểu thức này là tổng của 2 biểu thức, biểu thức thứ 2 ta thấy hiển nhiên chia hết cho 5.
Và nếu biểu thức bên trái đúng, thì biểu thức ban đầu chia hết cho 5 là đúng. Điều này
chứng minh sự suy ra ở trên — nếu n5 - n chia hết cho 5, thì (n+1)5 - (n+1) cũng chia

hết cho 5. Điều đó chứng minh cho suy luận: P(n) => P(n+1), đó chính xác là điều ta
muốn chứng minh ở bước quy nạp, do đó ta có thể kết luận P( n) đúng với mọi n thuộc
N. Đó là P(n)= n5 - n chia hết cho 5 với mọi số nguyên n không âm.


Nếu ta nắm được định nghĩa và cách thức hoạt động của đệ quy thì phương thức chứng
minh bằng quy nạp vô cùng đơn giản. Để chứng minh hàm f đúng với mọi số nguyên n,
đầu tiên ta chỉ ra rằng hàm f đúng với mỗi trường hợp cơ sở. Bước này đơn giản. Sau
đó ta chỉ ra: nếu f đúng (nghĩa là, nếu nó thoả mãn điều kiện trước và sau) với mọi giá
trị nhỏ hơn n, thì phương pháp cũng đúng với n. Do đó để chỉ ra rằng hàm f(n) luôn tính
ra giá trị đúng, ta phải chỉ ra rằng nó cũng đúng với trường hợp cơ sở, và sau đó ta phải
chỉ ra rằng giá trị được trả về bởi việc gọi hàm f(n) đúng nếu tất cả giá trị được trả về
bởi việc gọi đến hàm f(k) là đúng, với điều kiện k < n.
Ví dụ, để thấy được cách hàm factorial (hàm luỹ thừa) tính toán, đầu tiên ta phải chỉ ra
kết quả được trả về bởi việc tính toán fact(0) là đúng, đó là, 0!.
// Factorial: recursive
public static int fact(int n)
{
// Precondition: Dieu kien truoc
Assert.pre(n >= 0,"argument must be >=0");
// Postcondition: returned value == n!   Dieu kien sau
if (n == 0)
return 1;
else
return n * fact(n­1);
}

Nhìn vào code thấy nếu n == 0 thì hàm trả về 1, giá trị đúng cho 0!. Để chỉ ra vế suy ra,
ta chỉ cần chỉ ra nếu hàm factorial đúng với mọi giá trị cho đến n, thì nó cũng đúng với
n+1. Ta thấy tiếp rằng nếu fact(n+1) là để gọi fact(n) thì n+1 phải lớn hơn 0. Do đó

lệnh gọi đệ quy đến hàm fact(n)thì điều kiện đầu của hàm giữ. Hàm sau đó trả về tích
của n+1 và kết quả của việc tính fact(n). Nếu fact(n) trả về n!, thì fact(n+1) trả về
(n+1)!, đúng theo như định nghĩa quy nạp của hàm luỹ thừa.
Ta sẽ phải cẩn thận trong việc nêu điều kiện trước và sau cho hàm đệ quy cũng như ta
nêu cho các vòng lặp.

3 Truy nguyên phương pháp đệ quy
Kỹ thuật truy nguyên chồng chéo là phương pháp tốt để theo dõi cách thức đệ quy hoạt
động. Có một mẹo là bạn cứ nghĩ mỗi lần gọi đệ quy là một phương thức riêng biệt, do
đó cần nhiều bản sao của phương thức đệ quy. Ví dụ, chương trình chính gọi
fact(4).Tuần tự tiếp theo đó là truy nguyên ( lần theo lần gọi trước). Trong trường hợp
này ta cần 5 bản sao của hàm factorial. Các tham số thực tế đã được thay thế cho các
tham số chính thức trong hàm.


// Main

// Main

public int fact(int 4)
Call to
fact(4)

x = fact(4);

x = 

{
   if (4==0) return 1;
   else return 4*fact(3);

}

// Main

x = 

public int fact(int 4)
{
   i
   e
}

public int fact(int 3)
{
   if (3==0) return 1;
   else return 3*fact(2);

Call to
fact(2)

}

// Main
public int fact(int 4)
x = 

{
   i
   e
}


public int fact(int 3)
{
   i
   e
}

public int fact(int 2)
{
   if (2==0) return 1;
   else return 2*fact(1);
}

Call to
fact(1)

Call to
fact(3)


// Main
public int fact(int 4)
x =

{
   i
   e
}

public int fact(int 3)

{
   i
   e
}

public int fact(int 2)
{
   i
   e
}

public int fact(int 1)
{
   if (1==0) return 1;
   else return 1*fact(0);

Call to
fact(0)

}

// Main
public int fact(int 4)
x = 

{
   i
   e
}


public int fact(int 3)
{
   i
   e
}

public int fact(int 2)
{
   i
   e
}

public int fact(int 1)
{
   i
   e
}

public int fact(int 0)
{
   if (0==0) return 1;
   else return 0*fact(0);
}

Returns 1


// Main
public int fact(int 4)
x = 


{
   i
   e
}

public int fact(int 3)
{
   i
   e
}

public int fact(int 2)
{
   i
   e
}

public int fact(int 1)
{
   if (1==0) return 1;
   else return 1*1;

Returns 1*1==1

}

// Main
public int fact(int 4)
x = 


{
   i
   e
}

public int fact(int 3)
{
   i
   e
}

public int fact(int 2)
{
   if (2==0) return 1;
   else return 2*1;
}

Returns 2*1==2


// Main
public int fact(int 4)
x = 

{
   i
   e
}


public int fact(int 3)
{
   if (3==0) return 1;
Returns 3*2==6

   else return 3*2;
}

// Main

x = 

public int fact(int 4)
{
   if (4==0) return 1;
   else return 4*6;

Returns 4*6==24

}

// Main

x = 24;

x is assigned 24


4 Ví dụ về hàm đệ quy
Cấu trúc dữ liệu thường được định nghĩa theo cách đệ quy; ví dụ, một định nghĩa kinh

điển của phép liệt kê là đệ quy.
1. Một liệt kê rỗng chính là một liệt kê.
2. Nếu a là một giá trị, và x là một liệt kê, thì a:x chính là một liệt kê.
3. Không có liệt kê nào khác.
Tập hữu hạn được định nghĩa theo cách tương tự:
1. Tập rỗng là tập hữu hạn.
2. Nếu S là tập hữu hạn, và x là một phần tử, phép hợp S  {x} là một tập hữu
hạn.
3. Ngoài ra không còn tập hữu hạn nào khác.
Ta thường sẽ không đưa ra các định nghĩa đệ quy rõ ràng về các tập hợp và hàm bên
dưới các phương thức đệ quy của chúng, nhưng điều này là cần thiết và khó khăn trong
việc xây dựng các điều kiện trước và sau của hàm đệ quy thường phản ánh sự thiếu
hiểu biết về các định nghĩa cơ bản. Trong phần này, mình sẽ đưa ra một số định nghĩa về
các hàm đệ quy đơn giản được xác định trên các tập hợp con. Trong một số trường hợp,
bước cơ sở của định nghĩa được khai báo cho các mảng con và trong các trường hợp
khác, nó được định nghĩa trên các mảng con mà có một phần tử duy nhất. Đệ quy không
thực sự phù hợp với hầu hết các ví dụ chúng tôi đưa ra trong phần này vì các vấn đề
được giải quyết dễ dàng lặp đi lặp lại, nhưng việc sử dụng đệ quy làm cho cả chương
trình và thuật toán cơ bản trở nên dễ hiểu.

4.1

Tìm số lớn nhất của mảng số nguyên nhập từ bàn phím.

Hàm maxEntry trả về phần tử lớn nhất của mảng b[lo...hi]  được định nghĩa theo
cách đệ quy. Trường hợp cơ sở là tập hợp các mảng con với một lần nhập đầu vào ;
trường hợp quy nạp là tập hợp các mảng con với hai hay nhiều hơn số lần nhập đầu vào.


// Find the largest entry of an integer subarray.

public static int maxEntry(int[] b, int lo, int hi)
{
// Precondition
Assert.pre(0<=lo && lo<=hi && hi// Postcondition: returned value is the largest value in 
b[lo...hi]
if (lo == hi) 
return b[lo]; // single element subarray.
else
return Integer.max(b[lo],maxEntry(b, lo+1, hi));
}

4.2

Nhận dạng số nguyên Unsigned

Hàm isValid xác định xem một chuỗi có phải là số nguyên không dấu hợp lệ hay không,
nghĩa là nó chỉ có chứa các chữ số hay không. Chuỗi rỗng đóng vai trò là trường hợp cơ
sở nên thỏa mãn hàm.


//  Xác định xem chuỗi chỉ chứa chữ số hay không
public static boolean isValid(String s)
{
// Precondition
Assert.pre(true,"");
// Postcondition: returned value ==
//            (Ai: 0 <= i < s.length(); s.charAt(i) is a digit)
if (s.length() == 0)
return true;

// Empty string is valid.
else
if ('0' <= s.charAt(0) && s.charAt(0)<='9') // First character
                                               // is a digit.
return isValid(s.substring(1));
else
return false;
}

//  Xác định xem chuỗi chỉ chứa chữ số hay không .
public static boolean isValid(String s)
{
// Precondition
Assert.pre(true,"");
// Postcondition: returned value ==
//            (Ai: 0 <= i < s.length(); s.charAt(i) is a digit)
if (s.length() == 0)
return true;
// Empty string is valid.
else
return ('0' <= s.charAt(0) && 
              s.charAt(0)<='9'   &&
              isValid(s.substring(1)) 
}

4.3

Ước chung lớn nhất

Hàm ước chung lớn nhất (gcd) (the greatest common divisor) được xác định với bất kỳ

hai đối số nguyên nào mà không phải cả 2 số bằng 0. Ước chung lớn nhất của hai số
nguyên x và y được xác định là số nguyên z lớn nhất sao cho x% z == 0 và y% z == 0.
Euclid tìm thấy thuật toán hiệu quả để tìm gcd của hai số nguyên bằng cách để ý rằng
số nguyên nào chia hết cho x và y cũng phải chia hết cho x% y (và y% x). Một phiên
bản đệ quy của thuật toán Euclid được trình bày như sau:


// Tìm UCLN của 2 số nguyên
public static int gcd(int x, int y)
{
// Precondition 
Assert.pre(x != 0 || y != 0,"it nhat 1 trong 2 so nguyen khac 0");
// Postcondition: returned value is gcd(x,y).
if (y == 0)
return Math.abs(x);
else
return gcd(Math.abs(y), Math.abs(x) % Math.abs(y));
}

Với y == 0. Nếu trong bất kỳ lần gọi nào y không lớn hơn x, hệ thức này được duy trì
trong mỗi lần gọi đệ quy liên tiếp sau đó- thực tế, trong tất cả các lần gọi liên tiếp, độ
lớn của y nhỏ hơn x. Do đó nếu độ lớn của y ban đầu lớn hơn x; trong trường hợp này,
tác dụng của lệnh gọi là thay thế x bằng độ lớn của y và y bằng độ lớn của x - các giá
trị không âm và giá trị lớn hơn được đặt cho x. Do vậy, độ lớn của y luôn nhỏ hơn x.

5 Một số ví dụ phương pháp đệ quy
Đối với hàm đệ quy, các vấn đề liên quan đệ quy phải được giải quyết 2 vấn đề:
Vấn đề thứ nhất có thể giải quyết trực tiếp
Vấn đề thứ 2 giải quyết bằng cách gọi đệ quy
Trong đó vấn đề thứ 2, giải quyết bằng đệ quy: Lệnh gọi đệ quy phải được tính toán sao

cho vấn đề cuối cùng được giải quyết trực tiếp để không tạo ra vòng lặp vô hạn.
5.1

Xử lí đầu vào của mảng

Ví dụ dưới đây thay đổi giá trị của b[i] thành b[i]%i cho mỗi đầu vào của mảng từ lo
đến hi. Lệnh gọi đến lo > hi trả về chương trình mà không thực hiện lệnh code nào.
// Change the value of b[i] to b[i] mod i for all 
// entries in b[lo...hi]
public static void modi(int[] b, int lo, int hi)
{
// Precondition
Assert.pre(0<=lo && hi// Postcondition: (Ai : lo <= i <= hi : b[i] = old_b[i] % i)
if (lo <= hi)
{
b[lo] = b[lo] % i;
modi(b, lo+1, hi);
}
}
 

Để thay đổi tất cả đầu vào của mảng c, ta thực hiện lệnh:


modi(c,0,c.length­1)

Đây có thể không phải là cách tối nhất để triển khai thuật toán này nhưng được thực
hiện một cách có cấu trúc để ta thấy được thuật toán đệ quy ngắn gọn ra sao.
5.2


Toà tháp Hà Nội

“Toà tháp Hà Nội” là bài toán kinh điển hay về sức mạnh và sự đồng nhất của phương
thức đệ quy.
Truyền thuyết kể rằng trong Đền Benares ở Hà Nội có 64 đĩa vàng trên ba chốt kim
cương. Mỗi đĩa có kích thước khác nhau để khi xếp đĩa chồng lên cái chốt thứ nhất,
chúng tạo thành một kim tự tháp. Các nhà sư của ngôi đền muốn di chuyển các đĩa từ
chốt thứ nhất đến chốt thứ ba; khi họ hoàn thành cũng là lúc thế giới kết thúc. Có các
quy tắc để di chuyển đĩa: Thứ nhất, chỉ có một đĩa có thể được di chuyển một lúc. Và
thứ hai, một đĩa lớn hơn không thể được đặt trên một đĩa nhỏ hơn.
Bài toán được tạo ra bởi Edouard Lucas và được làm thành đồ chơi để bán ở Pháp năm
1893.
Giải pháp cho bài toán là một chuỗi các lần di chuyển từ cột 1 sang cột 3 tuấn theo 2
quy tắc. Ta có thể từ từ hiểu nguyên tắc hoạt động của cách di chuyển bằng cách bắt
đầu từ các giá trị nhỏ của n ( số đĩa ). Đặt tên cho các cột 1,2,3 thứ tự là A,B,C. Với
n==1:
move 1 disk from peg A to C

A

B

Với n==2 phức tạp hơn 1 chút:

C

A

B


C


move 1 disk from A to B
move 1 disk from A to C
move 1 disk form B to C

A

B

C

A

B

C

A

B

A

C

B


C

Cú pháp cho mỗi lần chuyển đĩa là : Move 1 disk from ...(1).. to ..(2)..
(1) Là vị trí cột lấy đĩa
(2) Là vị trí cột mà đĩa được chuyển tới
Với n==3:
move 1 disk from A to C.
move 1 disk from A to B.
move 1 disk from C to B.    Now disks 1 and 2 are on B
move 1 disk from A to C.
move 1 disk from B to A.
move 1 disk from B to C.
move 1 disk from A to C.

Có 7 lần di chuyển:

A

B

A

B

C

C

A


B

A

C

B

C


A

A

B

B

C

C

A

B

A

C


B

C

Để ý hai điều trong ví dụ này. Đầu tiên, chúng ta thấy phải mất 2 n-1 bước để di chuyển
n đĩa. Và thứ hai, di chuyển 3 đĩa từ A đến C, trước tiên chúng ta di chuyển 2 đĩa từ A
sang B (3 bước đầu tiên), sau đó di chuyển một đĩa (thứ n) từ A sang C (bước 4), sau đó
di chuyển 2 đĩa từ B đến C (bước 5-7). Nói chung, để di chuyển n đĩa từ A sang C, trước
tiên chúng ta di chuyển n-1 đĩa từ A sang B (‘trung gian’), sau đó di chuyển một đĩa từ
A sang C, và cuối cùng di chuyển n-1 đĩa từ B sang C . Ta có các thủ tục đệ quy sau. Ta
sẽ sử dụng các ký tự 'A', 'B' và 'C' để chỉ định ba chốt. Giải pháp sau đây sử dụng
trường hợp cơ sở là: không di chuyển đĩa nào, nghĩa là không cần làm gì.


// Cac buoc di chuyen n dia tu A sang C ( source ­> dest )
// Dung B lam cot trung gian, giu tam cac dia (workspace)
public static void hanoi(int n, char source, char workspace, char 
dest)
{
// Precondition:  n dia nho nhat nam o cot source.
// Postcondition: n dia tren cung tu cot source (A) duoc
   //                chuyen sang cot dest (C) tuan theo quy 
   //                luat: 
//
  Chi mot dia duoc di chuyen 1 luc.
//                  Dia to khong duoc nam tren dia nho.
if (n > 0)  // Truong hop co so, khong di chuyen dia nao.
{
hanoi(n­1, source, dest, workspace);

// n­1 dia duoc di chuyen sang trung gian B
// Dia n nam tren cot source
System.out.println("Chuyen 1 dia tu "+source+" sang 
"+dest+".");
// n­1 dia tren cung da nam o cot trung gian B.
// Dia n chuyen sang cot dest (C).
hanoi(n­1, workspace, source, dest);
// n dia tren cung duoc chuyen sang cot cuoi cung.
}
}

Để hiện thị 63 bước ta thêm câu lệnh:
hanoi(6,'A','B','C');

Đây là giải pháp vi diệu mà lúc đầu tưởng chừng như vô cùng phức tạp. Việc giải quyết
bài toán này bằng vòng lặp đòi hỏi thuật toán dài hơn nhiều và khó hiểu hơn.
Thường có thể hình dung ra một giải pháp đệ quy bằng cách khái quát hóa từ một giải
pháp dùng vòng lặp. Theo cách hiểu này, người ta có thể nói rằng đệ quy có thể cho
phép bạn giải quyết vấn đề mà không thực sự hiểu cách bạn đã làm nó! Trong bài toán
Tháp Hà Nội, các ràng buộc của bài toán cho thấy rõ rằng mọi giải pháp di chuyển đĩa
dưới cùng một cách trực tiếp từ chốt A sang chốt C đều phải qua trạng thái trung gian
đó là các đĩa n-1 trên chốt B. Trong trường hợp có ba đĩa, trạng thái này là như sau:

A

B

C

Nếu sau đó người ta thấy rằng việc di chuyển n-1 đĩa sang chốt B là vấn đề tương tự

như việc di chuyển n đĩa sang chốt C, thì giải pháp chung của đệ quy


Move n­1 disks to from A to B.
Move the bottom disk to from A to C.
Move n­1 disks from B to C.



×