Kỹ thuật khử đệ quy
Trần Đức Thiện
Đệ quy là quả tim trong các nghiên cứu lý thuyết cũng như thực hành tính toán, đã thể hiện
rất nhiều sức mạnh và có ưu điểm trong nhiều bài toán. Tuy nhiên bài này tôi lại đi ngược với
công việc chúng ta thường làm: khử đệ quy, đó là vấn đề cũng có nhiều thú vị và đáng để
chúng ta xem xét.
Khử đệ quy ở đây là biến một thủ tục đệ quy thành một thủ tục chỉ chứa vòng lặp mà không
ảnh hưởng gì đến các yếu tố khác, chứ không phải là thay đổi thuật toán. Ví dụ như trong các
hàm đệ quy tính n! và số Fibonaci F(n) ta có thể thay bằng một vòng lặp để tính; Đó không
phải là phương pháp khử đệ quy mà tôi muốn nói. Trong trường hợp tổng quát, khử đệ quy là
một việc làm khá phức tạp và khó khăn. ở hàm n! hay F(n) ta có thể dùng một thuật toán
không đệ quy, nhưng trong một số bài toán, đệ quy là bắt buộc. Bạn có thể nói rằng, vậy thì
cứ sử dụng đệ quy, vừa ngắn gọn dễ hiểu, vừa dễ cài đặt. Nhưng có đôi khi, sự hạn hẹp của
bộ nhớ dành cho chương trình con không cho phép chúng ta làm điều đó; hoặc chúng ta biết
rằng, ngôn ngữ máy không có đệ quy, vì vậy các trình biên dịch đều phải có nhiệm vụ khử đệ
quy. Và bạn có thể thực sự gặp rắc rối với thủ tục đệ quy của mình khi trong một môi trường
lập trình mà không cung cấp khả năng gọi đệ quy. Khử đệ quy giúp bạn vẫn giữ được nguyên
bản thuật toán đệ quy của mình mà không hề có lời gọi đệ quy, và như thế chương trình có thể
chạy được trong bất kỳ môi trường lập trình nào.
Khử đệ quy thực chất là chúng ta phải làm công việc của một trình biên dịch đối với một thủ
tục, đó là: Đặt tất cả các giá trị của các biến cục bộ và địa chỉ của chỉ thị kế tiếp vào ngăn xếp
(Stack), quy định các giá trị tham số cho thủ tục và chuyển tới vị trí bắt đầu thủ tục, thực hiện
lần lượt từng câu lệnh. Sau khi thủ tục hoàn tất thì nó phải lấy ra khỏi ngăn xếp địa chỉ trả về
và các giá trị của các biến cục bộ, khôi phục các biến và chuyển tới địa chỉ trả về.
Để dễ theo dõi chúng ta lấy ví dụ với bài toán cụ thể là bài toán duyệt cây. Giả sử có một cây
nhị phân lưu trữ trong biến động t được định nghĩa:
type pnode = ^node;
node = record
inf : variable; { truong luu tru thong tin }
l,r : pnode;
end;
var t : pnode;
Xuất phát từ nút gốc t, cần duyệt qua hết cây theo thứ tự từ trái qua phải. Chương trình con đệ
quy sẽ như sau:
procedure Try(t : pnode);
begin
if t <> nil then
begin
visit(t);
Try(t^.l);
Try(t^.r);
end;
end;
Trước hết có thể thấy rằng lệnh gọi đệ quy thứ hai có thể được khử dễ dàng bởi không có mã
lệnh theo sau nó. Khi lệnh này thực hiện thì thủ tục Try( ) được gọi với tham số t^.r và khi lệnh
gọi này kết thúc thì thủ tục Try hiện hành cũng kết thúc. Chương trình được viết lại như sau
dùng goto:
procedure try(t : pnode);
label 0;
begin
0 : if t = nil then exit;
visit(t);
try(t^.l);
t := t^.r;
goto 0;
end;
Đó là kỹ thuật rất nổi tiếng được gọi là khử đệ quy phần cuối. Việc khử lần gọi đệ quy còn lại
đòi hỏi phải làm nhiều việc hơn. Giống như một trình biên dịch chúng ta phải tổ chức một ngăn
xếp (Stack) để lưu trữ các biến cục bộ, các tham số, và sử dụng các thủ tục:
Push(t): Đặt biến t vào đỉnh Stack;
Hàm pop: lấy 1 giá trị ở đỉnh stack.
Hàm stackempty: Báo hiệu Stack đã rỗng.
ở đây không có giá trị trả về và chỉ có một biến cục bộ là t nên chúng ta sẽ nạp nó vào stack
nếu chưa được xử lý và ở mỗi bước chúng ta lấy biến ở đỉnh stack ra để xử lý nó và các nút
con tiếp theo của nó. Chương trình khử cả lời gọi đệ quy thứ hai sẽ như sau:
procedure try(t : pnode);
label 0,1,2;
begin
0: if t = nil then goto 1;
visit(t);
push(t);
t := t^.l;
goto 0;
2 : t := t^.r; goto 0;
1 : if stackempty then exit;
t := pop;
goto 2;
end;
Thủ tục trên chỉ là diễn giải thô của ý tưởng để các bạn dễ hiểu, vì vậy các chỉ thị goto còn
rườm ra, chúng ta sẽ viết lại một cách có cấu trúc hơn như sau:
procedure try(t : pnode);
label 0;
begin
0: while t <> nil do
begin
visit(t);
push(t^.r);
t := t^.l
end;
if stackempty then exit;
t := pop;
goto 0;
end;
Bây giờ, loại bỏ hoàn toàn các chỉ thị goto và tránh trường hợp nạp các nút rỗng vào stack ta
có thủ tục duyệt cây không đệ quy chuẩn như sau, các bạn sẽ thấy rằng về bản chất nó không
khác thủ tục đệ quy là mấy:
procedure try(t : pnode);
begin
push(t);
repeat
t := pop;
visit(t);
if t^.l <> nil then push(t^.l);
if t^.r <> nil then push(t^.r);
until stackempty;
end;
Để minh hoạ cụ thể hơn cho kỹ thuật này, tôi xin trình bày với các bạn chương trình sắp xếp
nhanh(QuickSort) khử đệ quy:
Program Quick_sort_Khu_de_quy_Th;
const
inp = 'FileName.inp';
out = 'FileName.out';
maxstack = 1000;
maxn = 1000;
type
it = longint;
var
a : array[1..maxn] of it;
sl,sr : array[1..maxstack] of word;
n,top : it;
f : text;
procedure push(l,r : word);
begin
inc(top);
sl[top] := l;
sr[top] := r;
end;
procedure pop(var l,r : word);
begin
l := sl[top];
r := sr[top];
dec(top);
end;
function stackEmpty : boolean;
begin
stackempty := top = 0;
end;
procedure init;
begin
top := 0;
end;
procedure nhap;
var
i : it;
begin
assign(f,inp);
reset(f);
readln(f,n);
for i := 1 to n do read(f,a[i]);
close(f);
end;
procedure sort(l1,r1 : word);
var
l,r,i,j : word;
t,tg : it;
begin
push(l1,r1);
repeat
pop(l,r);
i := l;
j := r;
t := a[(l+r) div 2];
repeat
while a[i] < t do inc(i);
while t < a[j] do dec(j);
if i <= j then
begin
tg := a[i];
a[i] := a[j];
a[j] := tg;
inc(i);
dec(j);
end;
until i > j;
if i < r then push(i,r);
if l < j then push(l,j);
until stackEmpty;
end;
procedure xuat;
var
i : it;
begin
assign(f,out);
rewrite(f);
for i := 1 to n do write(f,a[i],' ');
close(f);
end;
BEGIN
nhap;
init;
sort(1,n);
xuat;
END.
Trong một lúc nào đó chúng ta có thể khảo sát tính hiệu quả của việc khử đệ quy. Còn bây giờ
bạn vẫn có thể trung thành với thủ tục Try đệ quy của mình, nó thực sự ngắn gọn, dễ hiểu và
dễ cài đặt. Dù có thể không dùng đến nhưng nghiên cứu thêm là một việc không hề thừa. Biết
đâu sau này bạn trở thành một người viết chương trình dịch thì sao, thế thì nó − bài viết này
rất bổ ích cho bạn rồi đấy. Chào thân ái và hẹn gặp lại.