算法分析
算法(algorithm)是爲求解一個問題需要遵循的、被清楚地指定的簡單指令的集合。
數學基礎
定義: 如果存在正常數和使得當時,則記爲。
定義: 如果存在正常數和使得當時,則記爲。
定義:當且僅當且。
定義: 如果且,則。
這些定義的目的是要在函數之間建立一種相對的級別。給定兩個函數,通常存在一些點,在這些點上一個函數的值小於另一個函數的值,因此,像這樣的聲明是沒有什麼意義的。於是,我們比較它們的相對增長率(relative rate of growth)。
如果用傳統的不等式來計算增長率,那麼第一個定義是說的正常率小於等於的增長率。第二個定義(念成“Omega”)是說的增長率大於等於的增長率。第三個定義(念成“Theta”)是說的增長率等於增長率。最後一個定義(念成“小o”)說的則是的增長率小於的增長率。它不同於大O,因爲大O包含增長率相同這種可能性。
當我們說時,我們是在保證函數是在不快於的速度增長;因此是的一個上界(upper bound)。與此同時,意味着是的一個下界(lower bound)。
作爲一個例子,的增長比快,因此我們說或。和以相同的速率增長時,從而和都是正確的。直觀地說,如果,那麼從技術上都是成立的,但最後一個選擇是最好的答案。
法則1
如果且,那麼
- ,
法則2
如果是一個次多項式,則。
法則3
對任意常數。它告訴我們對數增長得非常緩慢。
函數 | 名稱 |
---|---|
常數 | |
對數級 | |
對數平方根 | |
線性級 | |
平方級 | |
立方級 | |
指數級 |
我們總能通過計算極限來確定兩個函數和的相對增長率,必要的時候可以使用洛必達法則。該極限可以有四種可能的值:
- 極限是:這意味着
- 極限是:這意味着
- 極限是:這意味着
- 極限擺動:二者無關
運行時間計算
一個簡單的例子
這裏是計算的一個簡單的程序片段:
int Sum(int N) {
int i, PartialSum;
/* 1*/ PartialSum = 0;
/* 2*/ for (i = 1; i <= N; i++)
/* 3*/ PartialSum += i * i * i;
/* 4*/ return PartialSum;
}
對這個程序的分析很簡單。聲明不計時間,第1行和第4行各佔一個時間單元。第3行每執行一次佔用四個時間單元(兩次乘法、一次加法和一次賦值),每執行次共佔用個時間單元。第二行在初始化、測試和對的自增運算中隱含着開銷。所有這些的總開銷是初始化1個時間單元,所有的測試個時間單元,以及所有的自增運算個時間單元,共。我們忽略調用函數和返回值的開銷,得到總量是。因此,我們說該函數是。
如果我們每次分析一個程序都要演示所有這些工作,那麼這項任務很快就會變成不可行的工作。幸運的是,由於我們有了大的結果,因此就存在許多可以採取的捷徑並且不影響最後的結果。這使得我們得到若干一般法則。
一般法則
法則1——for循環
一次for循環的運行時間至多是該for循環內語句(包括測試)的運行時間乘以迭代的次數。
法則2——嵌套的for循環
從裏向外分析這些循環。在一組嵌套循環內部的一條語句總的運行時間爲該語句的運行時間乘以該組所有的for循環的大小的乘積。
作爲一個例子,下列程序片段爲:
for (i=0; i<N; i++)
for(j=0; j<N; i++)
k++;
法則3——順序語句
將各個語句的運行時間求和即可(這意味着,其中的最大值就是所得的運行時間)。
作爲一個例子,下面程序片段先用去,再花費,總的開銷也是:
for(i=0; i<N; i++)
A[i] = 0;
for(i=0; i<N; i++)
for(j=0; j<N; j++)
A[i] += A[j] + i + j;
法則4——if/else語句
對於程序片段:
if(Condition)
S1
else
S2
一個if/else語句的運行時間從不超過判斷再加上S1和S2中運行時間長者的總的運行時間。
其他的法則都是顯然的,但是,分析的基本策略是從內部(或最深層部分)向外展開的。如果有函數調用,那麼這些調用要首先分析。如果有遞歸過程,那麼存在幾種選擇。若遞歸只是被薄面紗遮住的for循環,則分析通常是很簡單的。例如,下面的函數實際上就是一個簡單的循環,從而其運行時間爲:
long int Factorial(int N) {
if (N <= 1)
return 1;
else
return N * Factorial(N - 1);
}
這個例子中對遞歸的使用實際上並不好。當遞歸被正常使用時,將其轉換成一個簡單的循環結構是相當困難的。在這種情況下,分析將涉及求解一個遞推過程。爲了觀察到這種可能發生的情形,考慮下列程序,實際上它對遞歸使用的效率低得令人詫異。
long int Fib(int N) {
/* 1*/ if (N <= 1)
/* 2*/ return 1;
else
/* 3*/ return Fib(N - 1) + Fib(N - 2);
}
令爲函數Fib(N)
的運行時間。如果或,則運行時間是某個常數值,即第一行上做判斷以及返回所用的時間。若,則執行該函數時間是第一行上的常數工作加上第三行上的工作。第三行由一次加法和兩次函數調用組成。由於函數調用不是簡單的運算,必須通過它們本身來分析。第一次函數調用是Fib(N - 1)
,從而按照的定義,它需要個時間單元。類似的論證指出,第二次函數調用需要個時間單元。此時總的時間需求爲,其中"2"指的是第一行上的工作加上第三行上的加法。於是對於我們有下列關於Fib(N)
的運行時間公式:
但是,因此由歸納法容易證明。而,類似的計算可以證明(對於),可見,這個程序的運行時間以指數的速度增長。
最大子序列和問題的解
給定整數(可能有負數),求的最大值(爲方便起見,如果所有整數均爲負數,則最大子序列和爲0)。
我們將要敘述四個算法來求解最大子序列和問題。
第一個算法爲:
int MaxSunSequenceSum(coonst int A[], int N) {
int ThisSum, MaxSum, i, j, k;
/* 1*/ MaxSum = 0;
/* 2*/ for(i=0; i<N; i++)
/* 3*/ for(j=i; j<N; j++) {
/* 4*/ ThisSum = 0;
/* 5*/ for(k=i; k<=j; j++)
/* 6*/ ThisSum += A[k];
/* 7*/ if(ThisSum > MaxSum)
/* 8*/ MaxSum = ThisSum;
}
/* 9*/ return MaxSum;
}
它只是窮舉式地嘗試所有的可能。再有,本算法不計算實際的子序列;實際的計算還要添加一些額外的程序。
該算法肯定會正確運行。運行時間爲,這完全取決於第5行和第6行,第6行由一個含於三重嵌套for循環中的語句組成。第2行上的循環大小爲。
第2個循環大小爲,它可能要小,但也可能是。我們必須假設最壞的情況,而這可能會使得最終的界有些大。第3個循環的大小爲,我們也要假設它的大小爲。因此總數爲。語句1總共的開銷只是,而語句7和8總共開銷也只不過,因爲它們只是兩層循環內部的簡單表達式。
事實上,考慮到這些循環的實際大小,更精確的分析指出答案是,而我們上面的估計高出個因子6(不過這並無大礙,因爲常數不影響數量級)。一般來說,在這類問題中上述結論是正確的。精確的分析由得到,該和指出程序的第6行被執行的次數。經過計算我們得到。
下面指出一種改進的算法,該算法顯然是:
int MaxSunSequenceSum(const int A[], int N) {
int ThisSum, MaxSum, i, j;
/* 1*/ MaxSum = 0;
/* 2*/ for(i=0; i<N; i++) {
/* 3*/ ThisSum = 0;
/* 4*/ for (j=i; j<N; j++) {
/* 5*/ ThisSum += A[j];
/* 7*/ if(ThisSum > MaxSum)
/* 8*/ MaxSum = ThisSum;
}
}
return MaxSum;
}
對於這個問題有一個遞歸和相對複雜的解法,要是真的沒出現(線性的)解法,這個算法就會是體現遞歸威力的極好的範例了。
該算法採用一種“分治(divide-and-conquer)”策略。其想法就是把問題分成兩個大致相等的子問題,然後遞歸地對它們求解,這是“分”部分。“治”階段將兩個子問題的解合併到一起並可能再做些少量的附加工作,最後得到整個問題的解。
在這個問題中,最大子序列和可能在三處出現。或者整個出現在輸入數據的左半部,或者整個出現在右半部,或者跨越輸入數據的中部從而佔據左右兩半部分。前兩種情況可以遞歸求解。第三種情況的最大和可以通過求出前半部分的最大和(包含前半部分的最後一個元素)以及後半部分的最大和(包含後半部分的第一個元素)而得到。然後將這兩個和加在一起。作爲一個例子,考慮下列輸入:
前半部分 | 後半部分
4 -3 5 -2 -1 2 6 -2
其中前半部分的最大子序列和爲6而後半部分的最大子序列和爲8。
前半部分包含其最後一個元素的最大和是4,而後半部分包含其第一個元素的最大和是7。因此,橫跨這兩部分且通過中間的最大和爲。
下面提出了實現這種策略的一種實現手段。
int Max3(int a, int b, int c)
{
return a > b ? a > c ? a : c : b > c ? b : c;
}
int MaxSubSum(const int A[], int Left, int Right)
{
int MaxLeftSum, MaxRightSum;
int MaxLeftBorderSum, MaxRightBorderSum;
int LeftBorderSum, RightBorderSum;
int Center, i;
/* 1*/ if (Left == Right) /* Base Case */
/* 2*/ if (A[Left] > 0)
/* 3*/ return A[Left];
else
/* 4*/ return 0;
/* 5*/ Center = (Left + Right) / 2;
/* 6*/ MaxLeftSum = MaxSubSum(A, Left, Center);
/* 7*/ MaxRightSum = MaxSubSum(A, Center + 1, Right);
/* 8*/ MaxLeftBorderSum = 0; LeftBorderSum = 0;
/* 9*/ for (i = Center; i >= Left; i--)
{
/* 10*/ LeftBorderSum += A[i];
/* 11*/ if (LeftBorderSum > MaxLeftBorderSum)
/* 12*/ MaxLeftBorderSum = LeftBorderSum;
}
/* 13*/ MaxRightBorderSum = 0; RightBorderSum = 0;
/* 14*/ for (i = Center + 1; i <= Right; i++)
{
/* 15*/ RightBorderSum += A[i];
/* 16*/ if (RightBorderSum > MaxRightBorderSum)
/* 17*/ MaxRightBorderSum = RightBorderSum;
}
/* 18*/ return Max3(MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum);
}
int MaxSubSequnceSum3(const int A[], int N)
{
return MaxSubSum(A, 0, N - 1);
}
第1行至第4行處理基準情況。如果Left == Right
,那麼只有一個元素,並且當該元素非負時它就是最大子序列和。Left > Right
的情況是不可能出現的,除非是負數。第6行和第7行執行兩次遞歸調用。我們可以看到,遞歸調用總是小於原問題的問題進行。第8行至12行以及第13行至第17行計算達到中間分界處的兩個最大和的和數。這兩個最大和的和爲擴展到左右兩邊的最大和。
令是求解大小爲的最大子序列和問題所花費的時間。如果,則算法3執行程序第1行到第4行花費某個時間常量,我們稱之爲一個時間單元。於是,。否則,程序必須運行兩次遞歸調用,即在第9行和第17行之間的兩個for循環,還需某個小的簿記量,如在第5行和第18行。這兩個for循環總共接觸到到的每一個元素,而在循環內部的工作量是常量,因此,在第9到17行花費時間爲。第1行到第5行,第8、13和18行上的程序的工作量都是常量,從而與相比可以忽略。其餘就是第6、7行上運行的工作。這兩行求解大小爲的子序列問題(假設是偶數)。因此 ,這兩行每行花費個時間單元,共花費個時間單元。算法3花費的總的時間爲。我們得到方程組:
爲了簡化計算,可以用代替上面方程中的項;由於最終還是要用大表示的,因此這麼做並不影響答案。如果,且,那麼,以及。其形式是顯然並且可以得到,即若,則。
下面是第4種算法的代碼:
int MaxSubSequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, j;
/* 1*/ ThisSum = MaxSum = 0;
/* 2*/ for (j = 0; j < N; j++)
{
/* 3*/ ThisSum += A[j];
/* 4*/ if (ThisSum > MaxSum)
/* 5*/ MaxSum = ThisSum;
/* 6*/ else if (ThisSum < 0)
/* 7*/ ThisSum = 0;
}
/* 8*/ return MaxSum;
}
該算法的一個附帶優點是,它只對數據進行一次掃描,一旦A[i]
被讀入並被處理,它就不再需要被記憶。因此,如果數組在磁盤或磁帶上,它就可以被順序讀入,在主存中不必存儲數組的任何部分。不僅如此,在任意時刻,算法都能對它已經讀入的數據給出子序列問題的正確答案(其他算法不具有這個特性)。具有這種特性的算法叫做聯機算法(online algorithm)。僅需要常量空間並以線性時間運行的聯機算法幾乎是完美的算法。
運行時間中的對數
分析算法最混亂的方面大概集中在對數上面。我們已經看到,某些分治算法將以時間運行。除分治算法外,可將對數最常出現的規律概括爲下列一般法則:如果一個算法用常數時間()將問題的大小削減爲其一部分(通常是),那麼該算法就是。另一方面,如果使用常數時間只是把問題減少一個常數(如將問題減少1),那麼這種算法就是的。
顯然,只有一些特殊種類的問題才能夠呈現除型。例如,若輸入個數,則一個算法只是把這些數讀入就必須耗費的時間量。因此,當我們談到這類問題的算法時,通常是假設輸入數據已經提前讀入。我們提供具有對數特點的三個例子。
對分查找(binary search)
給定一個整數和整數,後者已經預先排序並在內存中,求使得的下標,如果不再數據中,則返回*
下面給出代碼實現:
int BinarySearch(const ElementType A[], ElementType X, int N)
{
int Low, Mid, High;
/* 1*/ Low = 0; High = N - 1;
/* 2*/ while (Low <= High)
{
/* 3*/ Mid = (Low + High) / 2;
/* 4*/ if (A[Mid] < X)
/* 5*/ Low = Mid + 1;
/* 6*/ else if (A[Mid] > X)
/* 7*/ High = Mid - 1;
else
/* 8*/ return Mid; /* Found */
}
/* 9*/ return NotFound; /* NotFound is defined as -1 */
}
顯然,每次迭代在循環內的所有工作花費爲,因此分析需要確定循環的次數。循環從開始並在結束。每次循環後的值至少將該次循環前的值折半;於是,循環的次數最多爲。(例如,若,則在各次迭代後的最大值是64,32,16,8,4,2,1,0,-1。)因此,運行時間是。
歐幾里得算法
計算最大公因數的歐幾里得算法。兩個整數的最大公因數()是同時整數二者的最大整數。於是,。下面代碼實現這個算法,假設。(如果,則循環的第一次迭代將它們互相交換)。
unsigned int Gcd(unsigned int M, unsigned int N)
{
unsigned int Rem;
/* 1*/ while (N > 0)
{
/* 2*/ Rem = M % N;
/* 3*/ M = N;
/* 4*/ N = Rem;
}
return M;
}
算法通過連續計算餘數直到餘數是-爲止,最後的非零餘數就是最大公因數。因此,如果和,則餘數序列是399,393,6,3,0。從而,。
在兩次迭代以後,餘數最多是原始值的一半。這就證明了,迭代次數至多是從而得到運行時間。
定理2.1
如果,則。
歐幾里得算法在平均情況下的性能需要大量篇幅的高度複雜的數學分析,其迭代的平均次數約爲。
冪運算
將用乘法的次數作爲運行時間的度量。
計算的常見的算法是使用次乘法自乘。下面的 遞歸算法更好:
long int Pow(long int X, unsigned int N)
{
/* 1*/ if (N == 0)
/* 2*/ return 1;
/* 3*/ if (N == 1)
/* 4*/ return X;
/* 5*/ if (IsEven(N))
/* 6*/ return Pow(X * X, N / 2);
else
/* 7*/ return Pow(X * X, N / 2) * X;
}
第1行至第4行處理基準情形。如果是偶數,我們有,如果是奇數,則。
例如,爲了計算,算法將如下進行,它只用到9次乘法:
顯然,所需要的乘法次數最多是,因爲把問題分半最多需要兩次乘法(如果是奇數)。
第3行到第4行實際上不是必須的,因爲如果是1,那麼第7行將做同樣的事情。第7行還可以寫成
/* 7*/ return Pow(X, N - 1) * X;
而不影響程序的正確性。事實上,程序仍將以運行,因爲乘法的序列同以前一樣。
檢驗你的分析
一旦分析進行過後,則需要看一看答案是否正確,是否儘可能地好。一種實現方法是編程並比較實際觀察到的運行時間與通過分析所描述的運行時間是否相匹配。當擴大一倍時,則線性程序的運行時間乘以因子,二次程序的運行時間乘以因子,而三次程序則乘因子。以對數時間運行的程序當增加一倍時其運行時間只是多加一個常數,而以運行的程序則花費比在相同環境下運行時間的兩倍稍多一些的時間。如果低階項的係數相對地大,並且又不是足夠地大,那麼運行時間的變化量很難觀察清楚。
檢驗一個程序是否時的另一個常用的技巧是對的某個範圍(通常用2的倍數隔開)計算比值,其中是憑經驗觀察到的運行時間。如果是運行時間的理想近似,那麼所算出的值收斂於一個正常數。如果估計過大,則算出的值收斂於。如果估計過低從而程序不是的,那麼算出的值發散。