Thứ Hai, 25 tháng 11, 2013

[Thuật toán]Cách tính độ phức tạp thuật toán – Algorithm complexity(Phần 2)

IV. CÁCH TÍNH ĐỘ PHỨC TẠP GIẢI THUẬT
Cách tính độ phức tạp của một giải thuật bất kỳ là một vấn đề không đơn giản. Tuy nhiên ta có thể tuân theo một số nguyên tắc sau:
Quy tắc bỏ hằng số
Nếu đoạn chương trình P có thời gian thực hiện T(n) = O(c1.f(n)) với c1 là một hằng số dương
thì có thể coi đoạn chương trình đó có độ phức tạp tính toán là O(f(n)).


Quy tắc cộng – lấy max
Nếu T1(n) và T2(n) là thời gian thực hiện của hai đoạn chương trình P1 và P2; và T1(n)=O(f(n)), T2(n)=O(g(n)) thì thời gian thực hiện của đoạn hai chương trình đó nối tiếp nhau là T(n)=O(max(f(n),g(n)))
VD:
Lệnh gán x:=15 tốn một hằng thời gian hay O(1), Lệnh đọc dữ liệu readln(x) tốn một hằng thời gian hay O(1).
Vậy thời gian thực hiện cả hai lệnh trên nối tiếp nhau là O(max(1,1))=O(1)
Quy tắc nhân
Nếu T1(n) và T2(n) là thời gian thực hiện của hai đoạn chương trình P1và P2 và T1(n) = O(f(n)), T2(n) = O(g(n)) thì thời gian thực hiện của đoạn hai đoạn chương trình đó lồng nhau là T(n) = O(f(n).g(n))
Quy tắc tổng quát để phân tích một chương trình
- Thời gian thực hiện của mỗi lệnh gán, READ, WRITE là O(1).
– Thời gian thực hiện của một chuỗi tuần tự các lệnh được xác định bằng qui tắc cộng. Như vậy thời gian này là thời gian thi hành một lệnh nào đó lâu nhất trong chuỗi lệnh.
– Thời gian thực hiện cấu trúc IF là thời gian lớn nhất thực hiện lệnh sau THEN hoặc sau ELSE và thời gian kiểm tra điều kiện. Thường thời gian kiểm tra điều kiện là O(1).
– Thời gian thực hiện vòng lặp là tổng (trên tất cả các lần lặp) thời gian thực hiện thân vòng lặp. Nếu thời gian thực hiện thân vòng lặp không đổi thì thời gian thực hiện vòng lặp là tích của số lần lặp với thời gian thực hiện thân vòng lặp.
VD: Tính thời gian thực hiện của thủ tục sắp xếp “nổi bọt”
1
2
3
4
5
6
7
8
9
10
11
12
procedure Bubble (var a: array[1..n] of integer);
var i,j,temp: integer;
begin
    for i:=1 to n-1 do                  {1}
        for j:=n downto i+1 do              {2}
            if a[j-1]>a[j] then          {3}
            begin
                temp:=a[j-1];           {4}
                a[j-1]:=a[j];           {5}
                a[j]:=temp;             {6}
            end;
end;
Về giải thuật sắp xếp nổi bọt, chúng ta sẽ bàn kĩ hơn trong chương 2. Ở đây, chúng ta chỉ quan tâm đến độ phức tạp của giải thuật.
Ta thấy toàn bộ chương trình chỉ gồm một lệnh lặp {1}, lồng trong lệnh {1} là lệnh {2}, lồng trong lệnh {2} là lệnh {3} và lồng trong lệnh {3} là 3 lệnh nối tiếp nhau {4}, {5} và {6}. Chúng ta sẽ tiến hành tính độ phức tạp theo thứ tự từ trong ra.
Trước hết, cả ba lệnh gán {4}, {5} và {6} đều tốn O(1) thời gian, việc so sánh a[j-1] > a[j] cũng tốn O(1) thời gian, do đó lệnh {3} tốn O(1) thời gian. Vòng lặp {2} thực hiện (n-i) lần, mỗi lần O(1) do đó vòng lặp {2} tốn O((n-i).1) = O(n-i).Vòng lặp {1} lặp có i chạy từ 1 đến n-1 nên thời gian thực hiện của vòng lặp {1} và cũng là độ phức tạp của giải thuật là:
T(n) = \sum_{i=1}^{n-1}(n-i) = \frac{n.(n-1)}{2} = O(n^2)
Chú ý: Trong trường hợp vòng lặp không xác định được số lần lặp thì chúng ta phải lấy số lần lặp trong trường hợp xấu nhất.
Tìm kiếm tuần tự. Hàm tìm kiếm Search nhận vào một mảng a có n số nguyên và một số nguyên x, hàm sẽ trả về giá trị logic TRUE nếu tồn tại một phần tử a[i] = x, ngược lại hàm trả về FALSE.
Giải thuật tìm kiếm tuần tự là lần lượt so sánh x với các phần tử của mảng a, bắt đầu từ a[1], nếu tồn tại a[i] = x thì dừng và trả về TRUE, ngược lại nếu tất cả các phần tử của a đều khác X thì trả về FALSE.
1
2
3
4
5
6
7
8
9
10
FUNCTION Search(a:ARRAY[1..n] OF Integer;x:Integer):Boolean;
VAR i:Integer;
    Found:Boolean;
BEGIN
    i:=1;                                           {1}
    Found:=FALSE;                                   {2}
    WHILE(i<=n) AND (not Found) DO                   {3}
        IF A[i]=X THEN Found:=TRUE ELSE i:=i+1;     {4}
    Search:=Found;                                  {5}
END;
Ta thấy các lệnh {1}, {2}, {3} và {5} nối tiếp nhau, do đó độ phức tạp của hàm Search chính là độ phức tạp lớn nhất trong 4 lệnh này. Dễ dàng thấy rằng ba lệnh {1}, {2} và {5} đều có độ phức tạp O(1) do đó độ phức tạp của hàm Search chính là độ phức tạp của lệnh {3}. Lồng trong lệnh {3} là lệnh {4}. Lệnh {4} có độ phức tạp O(1). Trong trường hợp xấu nhất (tất cả các phần tử của mảng a đều khác x) thì vòng lặp {3} thực hiện n lần, vậy ta có T(n) = O(n).
Ðộ phức tạp của chương trình có gọi chương trình con không đệ quy
Nếu chúng ta có một chương trình với các chương trình con không đệ quy, để tính thời gian thực hiện của chương trình, trước hết chúng ta tính thời gian thực hiện của các chương trình con không gọi các chương trình con khác. Sau đó chúng ta tính thời gian thực hiện của các chương trình con chỉ gọi các chương trình con mà thời gian thực hiện của chúng đã được tính. Chúng ta tiếp tục quá trình đánh giá thời gian thực hiện của mỗi chương trình con sau khi thời gian thực hiện của tất cả các chương trình con mà nó gọi đã được đánh giá. Cuối cùng ta tính thời gian cho chương trình chính.
Giả sử ta có một hệ thống các chương trình gọi nhau theo sơ đồ sau:
Chương trình A gọi hai chương trình con là B và C, chương trình B gọi hai chương trình con là B1 và B2, chương trình B1 gọi hai chương trình con là B11 và B12.
Ðể tính thời gian thực hiện của A, ta tính theo các bước sau:
1. Tính thời gian thực hiện của C, B2, B11 và B12. Vì các chương trình con này không gọi chương trình con nào cả.
2. Tính thời gian thực hiện của B1. Vì B1 gọi B11 và B12 mà thời gian thực hiện của B11 và B12 đã được tính ở bước 1.
3. Tính thời gian thực hiện của B. Vì B gọi B1 và B2 mà thời gian thực hiện của B1 đã được tính ở bước 2 và thời gian thực hiện của B2 đã được tính ở bước 1.
4. Tính thời gian thực hiện của A. Vì A gọi B và C mà thời gian thực hiện của B đã được tính ở bước 3 và thời gian thực hiện của C đã được tính ở bước 1.
Ta có thể viết lại chương trình sắp xếp bubble như sau: Trước hết chúng ta viết thủ tục Swap để thực hiện việc hoàn đổi hai phần tử cho nhau, sau đó trong thủ tục Bubble, khi cần ta sẽ gọi đến thủ tục Swap này.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
procedure Swap (var x, y: integer);
var temp: integer;
begin
    temp := x;
    x := y;
    y := temp;
end;
procedure Bubble (var a: array[1..n] of integer);
var i,j :integer;
begin
    for i:=1 to n-1 do                              {1}
        for j:=n downto i+1 do                      {2}
            if a[j-1]>a[j] then
                Swap(a[j-1], a[j]);                 {3}
end;
Trong cách viết trên, chương trình Bubble gọi chương trình con Swap, do đó để tính thời gian thực hiện của Bubble, trước hết ta cần tính thời gian thực hiện của Swap. Dễ thấy thời gian thực hiện của Swap là O(1) vì nó chỉ bao gồm 3 lệnh gán. Trong Bubble, lệnh {3} gọi Swap nên chỉ tốn O(1), lệnh {2} thực hiện n-i lần, mỗi lần tốn O(1) nên tốn O(n-i). Lệnh {1} thực hiện n-1 lần nên:
T(n) = \sum_{i=1}^{n-1}(n-i) = \frac{n.(n-1)}{2} = O(n^2)
Phân tích các chương trình Ðệ quy
Với các chương trình có gọi các chương trình con đệ quy, ta không thể áp dụng cách tính như vừa trình bày bởi vì một chương trình đệ quy sẽ gọi chính bản thân nó và nếu theo phương pháp đó thì, để tính thời gian thực hiên của chương trình A, ta phải tính thời gian thực hiện của chương trình A và cái vòng luẩn quẩn ấy không thể kết thúc được.
Với các chương trình đệ quy, trước hết ta cần thành lập các phương trình đệ quy, sau đó giải phương trình đệ quy, nghiệm của phương trình đệ quy sẽ là thời gian thực hiện của chương trình đệ quy.
Thành lập phương trình đệ quy
Phương trình đệ quy là một phương trình biểu diễn mối liên hệ giữa T(n) và T(k), trong đó T(n) là thời gian thực hiện chương trình với kích thước dữ liệu nhập là n, T(k) thời gian thực hiện chương trình với kích thước dữ liệu nhập là k, với k < n. Ðể thành lập được phương trình đệ quy, ta phải căn cứ vào chương trình đệ quy. Thông thường một chương trình đệ quy để giải bài toán kích thước n, phải có ít nhất một trường hợp dừng ứng với một n cụ thể và lời gọi đệ quy để giải bài toán kích thước k (k0 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 Giai_thua. Thời gian để thực hiện phép nhân và phép gán là một hằng C2. Vậy ta có
T(n) = \begin{cases}  C1 & n=0 \\  F(T(n-1)) + C2 & n>0  \end{cases}
Ðây là phương trình đệ quy để tính thời gian thực hiện của chương trình đệ quy Giai_thua.
Chúng ta xét thủ tục MergeSort một cách phác thảo như sau:
1
2
3
4
5
6
7
8
9
10
FUNCTION MergeSort (L:List; n:Integer):List;
VAR L1,L2:List;
BEGIN
    IF n=1 THEN RETURN(L)
    ELSE
    BEGIN
        Chia đôi L thành L1 và L2, với độ dài n/2;
        RETURN(Merge(MergeSort(L1,n/2),MergeSort(L2,n/2)));
    END;
END;
Chẳng hạn để sắp xếp danh sách L gồm 8 phần tử 7, 4, 8, 9, 3, 1, 6, 2 ta có mô hình minh họa của MergeSort như sau:

Hàm MergeSort nhận một danh sách có độ dài n và trả về một danh sách đã được sắp xếp. Thủ tục Merge nhận hai danh sách đã được sắp L1 và L2 mỗi danh sách có độ dài n/2 trộn chúng lại với nhau để được một danh sách gồm n phần tử có thứ tự. Giải thuật chi tiết của Merge ta sẽ bàn sau, chúng ta chỉ để ý rằng thời gian để Merge các danh sách có độ dài n/2 là O(n). Gọi T(n) là thời gian thực hiện MergeSort một danh sách n phần tử thì T(n/2) là thời gian thực hiện MergeSort một danh sách n/2 phần tử. Khi L có độ dài 1 (n = 1) thì chương trình chỉ làm một việc duy nhất là return(L), việc này tốn O(1) = C1 thời gian. Trong trường hợp n > 1, chương trình phải thực hiện gọi đệ quy MerSort hai lần cho L1 và L2 với độ dài n/2 do đó thời gian để gọi hai lần đệ quy này là 2T(n/2). Ngoài ra còn phải tốn thời gian cho việc chia danh sách L thành hai nửa bằng nhau và trộn hai danh sách kết quả (Merge). Người ta xác đinh được thời gian để chia danh sách và Merge là O(n) = C2n. Vậy ta có phương trình đệ quy như sau:
T(n) = \begin{cases}  C1 & n=1 \\  2T(\frac{n}{2}) + C2.n & n>1  \end{cases}

Xem tiếp:
Phần 3