1. 程式人生 > ><資料結構與演算法分析>讀書筆記--執行時間中的對數及其分析結果的準確性

<資料結構與演算法分析>讀書筆記--執行時間中的對數及其分析結果的準確性

分析演算法最混亂的方面大概集中在對數上面。我們已經看到,某些分治演算法將以O(N log N)時間執行。此外,對數最常出現的規律可概括為下列一般法則:

如果一個演算法用常數時間(O(1))將問題的大小削減為其一部分(通常是1/2),那麼該演算法就是O(logN)。另一方面,如果使用常數時間只是把問題減少一個常數的數量(如將問題減少1),那麼這種演算法就是O(N)的。

下面,提供了具有對數特點的三個例子,分別為如下:

1.折半查詢;

2.歐幾里得演算法;

3.冪運算;

一、折半查詢

第一個例子通常叫做折半查詢。

折半查詢:給定一個整數X和整數A0,A1,.....,AN-1,後者已經預先排序並在記憶體中,求下標i使得Ai

=X,如果X不在資料中,則返回i=-1。

明顯的解法是從左到右掃描資料,其執行花費線性時間。然而,這個演算法沒有用到該表已經排序的事實,這就使得演算法很可能不是最好的。一個好的策略是驗證X是否是居中的元素。

如果是,則答案就找到了。如果X小於居中元素,那麼我們可以應用同樣的策略於居中元素左邊已排序的子序列;同理,如果X大於居中元素,那麼我們檢查資料的右半部分。(同樣,也存在可能會終止的情況)。

示例一(反映了Java語言資料下標從0開始的慣例):

public static <AnyType extends Comparable<? super AnyType>> int
binarySearch(AnyType[] a,AnyType x) { int low = 0,high = a.length-1; while(low <=high) { int mid = (low+high)/2; if(a[mid].compareTo(x) < 0) low = mid + 1; else if (a[mid].compareTo(x) > 0) high
= mid - 1; else return mid; } return -1;//NOT_FOUND }

 

顯然,每次迭代在迴圈內的所有工作花費O(1),因此分析需要確定迴圈的次數。迴圈從high-low=N-1開始,並保持high-low >= -1。

每次迴圈後high-low的值至少將該次迴圈前的值折半;於是,迴圈的次數最多為[log(N-1)]+2。(例如,若high-low=128,則在各次迭代後high-low的最大值是64,32,16,8,4,2,1,0,-1。)因此,執行時間是O(logN)。與此等價,我們也可以寫出執行時間的遞推公式,不過,當我們理解實際在做什麼以及為什麼的原理時,這種強行寫公式的做法通常沒有必要。

折半查詢可以看作是我們的第一個資料結構實現方法,它提供了在O(logN)時間內的contains操作,但是所有其他操作(特別是insert操作)均需要O(N)時間。在資料是穩定(即不允許插入操作和刪除操作)的應用中,這種操作可能是非常有用的。此時輸入資料需要一次排序,但是此後的訪問會很快。有個例子是一個程式,它需要保留(產生於化學和物理領域的)元素週期表的資訊。這個表是相對穩定的,因為很少會加進新的元素。元素名可以始終是排序的。由於只有大約110種元素,因此找出一個元素最多需要訪問8次。要是執行順序查詢就會需要多得多的訪問次數。

 

二、歐幾里得演算法

第二個例子是計算最大公因數的歐幾里得演算法。兩個整數的最大公因數(gcd)是同時整除二者的最大整數。於是,gcd(50,15)=5。

示例二(所示的演算法計算gcd(M,N),假設M>=N(如果N>M,則迴圈的第一次迭代將它們互相交換):

    public static long gcd(long m,long n) {
        while( n != 0) {
            
            long rem = m % n;
            m = n;
            n = rem;
        }
        
        return m;
    }

 

演算法連續計算餘數直到餘數是0為止,最後的非零餘數就是最大公因數。因此,如果M=1989和N=1590,則餘數序列是399,393,6,3,0。從而,gcd(1989,1590)=3。正如例子所表明的,這是一個快速演算法。

如前所述,估計演算法的整個執行時間依賴於確定餘數序列究竟有多長。雖然logN看似像理想中的答案,但是根本看不出餘數的值按照常數因子遞減的必然性,因為我們看到,例中的餘數從399僅僅降到393事實上,在一次迭代中餘數並不按照一個常數因子遞減。然而,我們可以證明,在兩次迭代以後,餘數最多是原始值的一半。這就證明了,迭代次數至多2 log N = O(logN)從而得到執行時間。這個證明並不難,因此我們將它放在這裡,可從下列定理直接推出它。

定理2.1 

如果M>N,則M mod N < M/2。

證明:

存在兩種情形。如果N<=M/2,則由於餘數小於N,故定理在這種情形下成立。另一種情形是N>M/2。但是此時M僅含有一個N從而餘數為M-N < M/2,定理得證。

從上面的例子來看,2 log N 大約為20,而我們僅進行了7次運算,因此有人會懷疑這是不是可能的最好的界。事實上,這個常數在最壞的情況下還可以稍微改進1.44 log N(如M和N是兩個相鄰的斐波那契數時就是這種情況)。歐幾里得演算法在平均情況下的效能需要大量篇幅的高度複雜的數學分析,其迭代的平均次數約為(12 ln 2 lnN)/π2 + 1.47。

 

三、冪運算

最後一個例子是處理一個整數的冪(它還是一個整數)。由取冪運算得到的數一般都是相當大的,因此,我們只能在假設有一臺機器能夠儲存這樣一些大整數(或有一個編譯程式能夠模擬它)的情況下進行我們的分析。我們將用乘法的次數作為執行時間的度量。

計算XN 的明顯的演算法是使用N-1次乘法自乘。有一種遞迴演算法效果較好。N<=1是這種遞迴的基準情形。否則,若N是偶數,我們有XN = XN/2 . XN/2 ,如果N是奇數,則XN = X(N-1)/2 .

X(N-1)/2 .X。

例如,為了計算X62,演算法將如下進行,它只用到9次乘法:

X3 = (X3)X ,X7 = (X3)2X,X15 = (X7)2X,X31 = (X152X,X62 = (X31)2

顯然,所需要的乘法次數最多是2logN,因為把問題分半最多需要兩次乘法(如果N是奇數)。

這裡,我們又可以寫出一個遞推公式並將其解出。簡單的直覺避免了盲目的強行處理。

示例三:

public static long pow(long x , int n) {
        
        if( n == 0)
            return 1;
        if( n == 1)
            return x;
        if( isEven(n))
            return pow(x * x,n/2);
        else
            return pow(x * x,n/2) * x;
    }
    
    public static boolean isEven(int n) {
        
        return (n % 2 == 0);
    }

 

關於分析結果的準確性:

根據經驗,有時分析會估計過大。如果這種情況發生,那麼或者需要進一步細化分析(一般通過機敏的觀察),或者可能是平均執行時間顯著小於最壞情形的執行時間,不可能對所得的界再加以改進。對於許多複雜的演算法,最壞的界通過某個壞的輸入是可以達到的,但在實踐中情形下仍然懸而未決),而最壞情形的界儘管過分地悲觀,但卻是最好的已知解析結果。

 

示例原始碼地址為:https://github.com/youcong1996/The-Data-structures-and-algorithms/tree/master/algorithm_analysis