PHP7資料結構與演算法分析:第二章--演算法天平:複雜度分析

來,見證奇蹟發表於2018-01-30

enter image description here

第2章 演算法天平:複雜度分析

在上一章中我們已經瞭解了演算法的概念、特徵及設計演算法要秉持的原則!我們在最後也提示大家演算法的設計原則也可以輔助我們評判演算法的優劣!那麼本章我會盡量詳細的展示給大傢俱體的衡量演算法優劣的方法。

首先還是讓我們對本章內容做個概覽。

本章內容如下:

  • 演算法分析方法論
  • 演算法的時間複雜度
  • 常見的時間複雜度型別
  • 時間複雜度知識擴充套件
  • 演算法的空間複雜度
  • 小結

2.1 演算法分析方法論

演算法優劣的衡量即效能的分析是個很重要的課題!只有能夠正確的衡量一個演算法的好壞並且知道是什麼原因引發了演算法的效能問題,我們才能改進它,才能夠設計出更好的演算法!不斷的改進甚至挑選出更好的演算法來解決問題使我們本章的目標。

我們常用的來衡量演算法優劣的方案有兩種:事前評估和事後統計!其實這個好理解,現實中我們解決問題常用的無非也是這兩種方案。既然演算法分析本身也是一個問題,那麼解決這個問題最常用的也是演算法編寫前的分析和演算法完成後上線執行的統計。

2.1.1 事後統計方案

首先來聊聊事後統計的方案。其實事後統計無論如何是一定要做的!在演算法程式的執行階段我們一定要進行檢測和統計執行的結果,這種方案能直接告訴我們演算法真實的執行結果是否是我們想要的!但是事後統計這種方案也有它的弊端。

  • 首先,事後統計的時間點是演算法程式完成之後。這就帶來了很大的隱患。如果能達到要求還好,萬一達不倒需求的效果,那就意味著之前的人力精力財力都浪費掉了,這對專案運營者可能是致命打擊!再就是如果是上線運營階段,那造成的損失就更大。
  • 第二,計算機的硬體環境也嚴重影響著演算法的效能。在不同的硬體環境上,演算法的表現不一,這會掩蓋掉演算法本身的優勢和劣勢,使我們並不能客觀的說是演算法太好或者太壞。
  • 第三,測試資料的選擇本身也影響了演算法的執行效果!資料的規模,資料的流量,資料的分佈都會影響演算法的執行,使我們也並不能客觀的描述一個演算法的效率和優劣。

對於事後統計的方案,我們必須做,但是不能完全依賴與它!這是由於它的直觀的優點和事後統計的缺點共同決定的!所以,事前評估的重要性就不言而喻了!

2.1.2 事前評估方案

事前評估的方案是指在正式編寫演算法程式之前就對演算法通過一定的方法論進行科學的評估!

我們需要一套科學的、客觀的方法只是針對演算法本身就能夠做到比較不同演算法的優劣!其實這些方法論我們的前輩已經總結成為系統的方案,我們可以直接參考這些方案來對我們的演算法進行評估!這些方法論就是:時間複雜度和空間複雜度!

那麼我們的前輩們是基於什麼原理找到合適的方法論呢,我們這裡也有必要了解一下,因為這可以作為輔助性前提來幫我們理解我們馬上要學習的內容!

我們的前輩通過分析演算法的程式實現尤其是高階語言例如PHP編寫的程式受到的影響其實原因總結有四條:

  • 演算法本身的策略和方法。
  • 編譯產生的程式碼質量。
  • 問題的輸入規模。
  • 機器指令的執行速度。

繼續分析可知其中的第二條和第四條是依賴於底層的軟硬體環境!也就是說這個是我們不能掌控的。即實際上演算法的優劣最終歸結為演算法本身的設計和問題輸入的資料規模!所謂的資料規模指的是演算法輸入資料的數量!例如要計算10000以內的數的和,那麼10000就是資料規模。

那麼演算法本身如何衡量優劣呢,主要有兩個標準:時間複雜度和空間複雜度。

2.2 演算法的時間複雜度

演算法的時間複雜度實際上就是演算法的執行花費的時間!但是不同的機器、不同的資料規模都會造成演算法時間上的不同!所以,在這裡實際上要計算時間複雜度需要三個重要的前提!

  • 不考慮任何的軟硬體環境!
  • 假設程式每一條語句的執行時間相同,均為t;
  • 只考慮最壞情況,即資料規模足夠大,設為n。

所以,一定要注意這三個前提下,我們的分析才能正常進行也才有意義!所以我們這裡的時間複雜度實際上指的是最壞時間複雜度!

2.2.1 時間複雜度和大O表示法

這裡有一個固定的公式來表示時間複雜度:

T(n) = O(f(n))

其中n表示的是資料規模,f(n)是演算法執行的總時間,其計算公式是:

f(n) = 演算法執行語句的數量 * t

即演算法中語句的執行數量與單條語句執行時間t的積。

所以時間複雜度公式就是:

T(n) = O(演算法執行總時間)

這裡我們把演算法執行總時間放在了一個固定的格式中: O()。 我們成這種表達方式為大O表示法,大O表示法就是表達最壞的情況的一中表示形式,後面我們會學習最壞空間複雜度,也是用大O表示法來表示!而且在時間複雜度擴充套件知識中我們還會了解表達最好情況的大Ω表示法和表達平均情況的大Θ表示法!大家只要記住這種特殊的表達結構就可以了!多用幾次就會熟悉了!

2.2.2 時間複雜度計算例項

這裡給出一個實際問題作為例項場景:

計算 1+2+3+4+5.....+n 的和!

這個場景足夠簡單,可以讓我們把精力放在演算法的分析上面!

這裡我們設計多種演算法來解決上面的問題,計算不同的演算法的時間複雜度作為比較!

第一種思想:我們可以生成一個n個長度的陣列,然後把資料儲存到陣列中,最後計算陣列各項的和。

演算法的實現:

function sum( $n ){
    // 設定返回結果
    $res = 0;                                   //{1}
    // 生成陣列[1,2,3,4,5...n]
    $arr = range(1,$n);                         //{2}

    // 計算陣列中各項的和
    for($i = 0; $i < $n; ++ $i){                //{3}
        $res += $arr[$i];                       //{4}
    }

    // 返回結果
    return $res;                                //{5}
}

讓我們來計算一下上面這個演算法的時間複雜度。

第一步:計算演算法執行過程中執行的程式碼總的條數N:

分析程式碼可知,其中{1}{2}{5}所標註的程式碼語句整個過程每條都只執行1次,而{3}{4}標註的語句執行過程中每一條都要執行n次!所以總條數N:

    N = 2*n + 3

第二步:計算演算法總執行時間f(n):

前面我們已經制定了前提,以為每一條語句執行的時間實行同的,都是t,所以:

    f(n) = N * t = (2*n + 3) * t

第三步:計算時間複雜度T(n):

我們這裡的時間複雜度指的是最壞的時間複雜度,使用大O表示法表示:

    T(n) = O( f(n) ) = O( (2*n + 3) * t )

    即

    T(n) = O( (2*n + 3) * t )

第四步:簡化。

實際上到這裡我們已經成功的計算出了這個演算法的時間複雜度,但是在實際的時間複雜度的表示中,對於上面的結果我們要做一定的簡化,簡化結果是:

    T(n) = O(n)

Tips:簡化步驟

  • 1, 去掉常數t:

因為t代表的是每一條語句的執行時間,而每一條語句的執行時間都相同,所以t實際上是個常數,並且不影響分析結果

T(n) = O( (2*n + 3) )
  • 2, 用常數1取代執行時間中的所有加法常數:
T(n) = O( 2*n + 1 )
  • 3, 在修改後的執行次數函式中,只保留最高階項:
T(n) = O( 2*n + 1 )
  • 4, 如果最高階項存在且不是1,則去除與這個項相乘的常數:
T(n) = O( n + 1 )
  • 5, 如果存在資料規模n, 去掉加法常數項:
T(n) = O(n)

關於時間複雜度的計算,你會發現計算過程實際上變成了要數數整個程式一共會有多少條語句被執行的一年級數數問題!哈!開個玩笑!但,時間複雜度計算的實際結果確實也如此!

Tips

所以以後有人問你某個演算法的時間發覆雜度是什麼,如果是直接描述,你就直接告訴他是O(XX), 如果是寫表示式的話就寫我們上面演示的這種T(n)=O(XX)就可以啦!

第二種思想:直接迴圈計算陣列各項的和。

演算法的實現:

function sum( $n ){
    // 設定返回結果
    $res = 0;                                   //{1}

    // 計算陣列中各項的和
    for($i = 1; $i < $n; ++ $i){                //{2}
        $res += $i;                             //{3}
    }

    // 返回結果
    return $res;                                //{4}
}

讓我們來計算一下上面這個演算法的時間複雜度。

第一步:計算演算法執行過程中執行的程式碼總的條數N:

N = 2*n + 2

第二步:計算演算法總執行時間f(n):

f(n) = N * t = (2*n + 2) * t

第三步:計算時間複雜度T(n):

T(n) = O( f(n) ) = O( (2*n + 2) * t )

第四步:簡化。

T(n) = O(n)

你會發現我們上面這兩種演算法的時間複雜度都是O(n), 難道它們執行的效率一樣嗎?不是這樣的,這就涉及到時間複雜度到底是什麼!它描述的實際上是一種“級別”,就像手機一樣!1000以下是一個級別,1000到2000是一個級別,2000-3000是一個級別!但是每個級別裡手機有很多款!位於同一個級別裡的手機,無論是質量還是功能都是大體相似的,但並不代表完全一致!時間複雜度的代表的也是這個意思!同一演算法時間複雜度級別中的演算法的效率不會差異非常大!

第三種思想:利用高斯公式,f(n) = (1+n)*n/2。

演算法的實現:

function sum( $n ){
    // 設定返回結果
    $res = 0;                                   //{1}

    $res = ( 1 + $n ) * $n / 2;                 //{2}

    // 返回結果
    return $res;                                //{3}
}

讓我們來計算一下上面這個演算法的時間複雜度。

第一步:計算演算法執行過程中執行的程式碼總的條數N:

N = 3

第二步:計算演算法總執行時間f(n):

f(n) = N * t = 3 * t

第三步:計算時間複雜度T(n):

T(n) = O( f(n) ) = O( 3 * t )

第四步:簡化。

T(n) = O(1)

這個演算法的時間複雜度是O(1),這個時間複雜度級別是最好的級別!也是我們設計演算法的一個目標!這個級別的演算法從理論上說應該是效率最高的演算法!但是別忘了,我們還要考慮空間複雜度!

通過計算以上幾個演算法的時間複雜度,我們可以得出這樣的一個結果:

假如計算機一秒鐘可以執行一條語句,如果計算的是從1到100的和,那麼前兩種時間複雜度是O(n)級別的演算法應該在100秒完成計算(當然現實中要快的多), 而第三種演算法需要1秒鐘!如果計算的數量是從1到1000000的和,第一種和第二種O(n)級別的演算法要執行1000000秒,而第三種需要1秒!

這說明隨著資料規模的增加,O(n)級別的演算法和O(1)級別的演算法的差距越來越大!實際上從側面這也說明了時間複雜度的另外一個本質特徵:時間複雜度描述的是演算法動態漸變的狀態!通過時間複雜度能看出一個演算法隨著資料規模n的變化而變化的情況! 所以通過時間複雜度我們要明白很重要的兩點:

  • 1,同一問題的不同演算法可以通過時間複雜度來比較效率優劣!
  • 2,對於一個演算法可以通過時間複雜度來了解隨著資料規模漸變而影響演算法的效率變化情況!

ok, 下面就讓我們來看看有哪些常見的時間複雜度級別,並且讓我們類對比這些級別的時間複雜度來更深入的學習時間複雜度的本質!

2.3 常見的時間複雜度

上一小節已經學會了如何計算時間複雜度,那麼你就可以立刻去嘗試一下計算你之前接觸到的演算法,然後總結一下你會有一個驚奇的發現。我們常見的演算法的時間複雜度的種類並不是無窮的!而是非常有限的幾種!我們的前人也為我們總結出了一些常見的時間複雜度型別,那下面就讓我們一起看看常見的時間複雜度級別。這裡我給大家列出了一張表:

enter image description here

這些都是常見的時間複雜度,以後慢慢學當你遇到時,權當參考就可以了!不過如果你得到了演算法的時間複雜度要比較演算法的效率,你需要記住這些時間複雜度之間的關係。

按照遞增排列:

 O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

2.4 時間複雜度知識擴充套件

我們上面談到的時間複雜度是指“最壞時間複雜度”!這個我們在這裡再次強調一遍!“最壞時間複雜度”的確是我們通常說的預設的情況!但是有“最壞”就有“最好”吧!沒錯,不光有“最好”,還有“平均”!這些知識我想還是有必要了解一下!

2.4.1 最好情況下的演算法時間複雜度

最好情況下的時間複雜度又叫做 最好時間複雜度,使用大Ω表示法表示!

其實“最好時間複雜度”好理解,它描述了一個演算法的最好的情況下花費的時間,也可以看做是一個演算法花費時間的下限!就比如我們生活中要找一個東西,最好的情況是他就擺在你眼前,你一下就能找到它!在演算法裡呢,如果要設計一個搜尋演算法,比如要在一個包含100個元素的資料中搜尋指定目標,最好的情況就是第一次就找到了!

但是“最好時間複雜度”的實際意義並不大,除非我們要求一個演算法只要最好的狀態能滿足需求就可以使用,但是這樣的要求並不多見,一般都是要求最壞的情況下如果能接受那演算法就可以使用!所以我們主要研究的和使用的都是“最壞時間複雜度”!

2.4.2 平均情況下的演算法時間複雜度

平均情況下的時間複雜度又叫作 平均時間複雜度, 使用大Θ表示法表示!

“平均時間複雜度”也有一定的實用性,它描述了一個演算法在處理資料過程中單個資料元素花費的平均時間!但是“平均時間複雜度”有一個問題就是並不好測量,因為平均時間是在真正執行過程中統計並且計算出來的!但也更貼合實際效果!所以這種表示方法很難通過直接評估得到結果!這個更加接近事後統計的一種方案。

不過我們可以舉一個比較好統計的例子,仍然是搜尋100個排好序的資料中的某個元素!我們就使用直接遍歷對比並且找到就立刻停下的形式來搜尋!那麼最好情況下找到的第一個元素就是我們要找的,也就是1次就找到了!最壞情況呢?是第100次才找到!那麼平均呢?如果每一個元素都要找一遍的話,按照從頭到尾找並且找到就停下的演算法你可以計算一下應該是

( 1+2+3+4+...100 ) / 100 = 50.5

平均要找50.5次!

好了,以上我們瞭解了原來時間複雜度是分為3種情況的!而我們日常應用中的實際上是"最差時間複雜度"!當然我們也應該更加清晰為什麼要是用“最壞時間複雜度”而不是其它兩個!

2.5 演算法的空間複雜度

2.5.1 空間複雜度的表示

演算法的空間複雜度實際上描述的就是演算法程式執行時佔用的記憶體大小!記作:

S(n)=O( f(n) )

其中n指的是資料規模!

我們設計演算法的原則中“環保性”針對的就是演算法的空間複雜度!但是現代設計演算法和程式的時候往往忽略掉演算法的空間複雜度!因為硬體已經足夠多足夠便宜了!但是存在就有意義!在一些地方我們還是要考慮空間複雜度的!特別是儲存器很小的地方,例如物聯網晶片,印表機晶片上。

那麼是不是演算法的空間複雜度越小越好呢?並不是這樣的!因為實際是上演算法的效能主要由兩個元素制約:時間複雜度和空間複雜度!而且這兩個往往還是冤家!時間複雜度低了那麼可能就要犧牲掉空間複雜度!要降低空間複雜度,可能就要犧牲演算法的時間複雜度!

空說無憑,舉個例子!

先舉一個生活中的例子:杯子和水。

假如你們家有無數個杯子而你又有潔癖,你是不是就可以任性一些,每次喝水都可以拿過一個新的乾淨的杯子倒水喝!但現實是,你可能就只有一個杯子,如果你習慣用乾淨杯子喝水,那你是不是就每次都要先洗乾淨再倒水喝呢?這個例子中,水杯就是記憶體空間!水就是資料!如果有足夠的杯子就不用每次都洗乾淨直接用,是不是降低了時間複雜度?如果空間不夠用,水杯越少,你是不是就要更多次的先洗乾淨杯子再喝水,空間複雜度降低了但是你要花掉很多時間從而犧牲了時間複雜度!

其實在程式設計過程中我們無數次犧牲掉了空間複雜度來降低時間複雜度?只不過,你或許從不知道這些概念而已!舉個例子!

請問你對下面的==程式碼片段1==有什麼想法?

//程式碼片段1

$arr = [1,2,3,4,5];

for( $i=0; $i < count( $arr ); $i ++ ){
    echo $arr[ $i ];
}

相信你從便開始程式設計很多人就告訴你如上的程式碼效率低,要優化,優化方法如下面的==程式碼片段二==:

//程式碼片段2

$arr = [1,2,3,4,5];
$len = count( $arr );

for( $i=0; $i < $len; $i ++ ){
    echo $arr[ $i ];
}

相信大家這個可以看明白,我只是把計算陣列長度的程式碼放在了for迴圈的外面並且新增加了一個變數 ==$len== 來接受陣列的長度!這個操作相信你一定做過!結果就是我們使用了一個新的變數從而省去了for迴圈內每次都要進行陣列長度運算的時間!程式如何佔用記憶體的,實際上一個變數就對應著一個空間!所以我們使用了空間複雜度換取了時間複雜度!我們通過計算能得到明確的結果!

2.5.2 空間複雜度的計算

要計算演算法中的空間複雜度實際上就是計算演算法佔用記憶體的大小!要計算佔用記憶體空間的大小,我們就要知道演算法中有多少變數,每種變數對應了多少記憶體空間!雖然PHP是弱型別的語言,但不代表PHP沒有型別!所以,PHP每種資料型別佔用的實際記憶體大小也不相同,不過我們在計算空間複雜度時,這些具體的值都會被簡化掉!要研究PHP資料型別的大小需要閱讀PHP核心程式碼,這裡不是我們的重點,相關的內容我們製作專門的文章來說明。在這裡就那我們最好理解的形式來計算,按照標準C語言的型別大小就好了。

我們就以上面的兩個程式碼片段為例打造一個遍歷陣列元素的演算法來計算空間複雜度。

由於記憶體空間會受到所在機器及計算機系統影響,這裡假設是在64位下的linux系統環境下的資料。

程式碼片段一的空間複雜度的計算

function arrIterator( $arr ){                   {1}

    for( $i=0; $i < count( $arr ); $i ++ ){     {2}
        echo $arr[ $i ];                        {3}
    }

}

第一步:統計演算法中變數的個數,型別和對應的記憶體數量:

分析程式碼可知,其中用到的變數如下:
    $arr    陣列    儲存有n個整型元素  
    $i      整型

第二步:計算演算法總記憶體空間f(n):

    f(n) = n * 8 + 8 = 8*n + 8

第三步:計算時間複雜度T(n):

    S(n) = O( f(n) ) = O( 8*n + 8 )

    即

    S(n) = O( 8*n + 8 )

第四步:簡化。

簡化方法和時間複雜度相同:

    S(n) = O(n)

Tips

其實演算法的空間複雜度計算結果你會發現對應資料型別的空間大小都被簡化沒了,變成了數一數演算法中變數個數的遊戲!

程式碼片段二的空間複雜度的計算

function arrIterator( $arr ){                   {1}

    $len = count( $arr );                       {2}

    for( $i=0; $i < $len; $i ++ ){              {3}
        echo $arr[ $i ];                        {4}
    }

}

第一步:統計演算法中變數的個數,型別和對應的記憶體數量:

分析程式碼可知,其中用到的變數如下:
    $arr    陣列    儲存有n個整型元素
    $len    整型
    $i      整型

第二步:計算演算法總記憶體空間f(n):

    f(n) = n * 8 + 8 + 8 = 8*n + 16

第三步:計算時間複雜度T(n):

    S(n) = O( f(n) ) = O( 8*n + 16 )

    即

    S(n) = O( 8*n + 16 )

第四步:簡化。

    S(n) = O(n)

通過比較兩種實現方式的空間複雜度都是O(n)級別的,但是第二種方案使用了程式碼片段二優化之後反而空間複雜度比使用第一個程式碼片段增加了整型資料的大小,但是我們公認為第二種更快了!而其中原因就是用空間換取了時間!

2.6 小結

本章我們學習了演算法效能的分析方法,主要學習了時間複雜度的意義和計算,這是重點!我們也學習了空間複雜度的意義和計算!我們要明白時間複雜度和空間複雜度的關係!對於演算法而言是要更注重效率還是空間的節約,這個要在目標的基礎上達到一個平衡!

相關文章