資料結構與演算法-複雜度分享&大 O 演算法

AllenBug發表於2019-08-11

一. 大 O 表示法,分析時關注原則:

  • 只關注迴圈次數最多的一段程式碼
  • 加法原則:總複雜度等於量級最大那段程式碼複雜度
  • 乘法原則:巢狀程式碼的複雜度等於巢狀程式碼內外複雜度的乘積
    常見覆雜度

二.線性表結構

線性結構: 就是排成一條線的結構,只有前後兩個方向,非線性結構包括樹,圖等。從儲存角度來說,一個是順序儲存,一個是鏈式儲存,各有利弊,陣列需要預先申請連續記憶體,超出限制會溢位,但是對明確知道規模的小型資料集合而言,使用陣列會更加高效,隨機訪問的特性也更加方便陣列讀取,但插入和刪除效能要差一些;連結串列的話沒有空間限制,但需要額外的空間儲存指標,插入,刪除效率很高,但不支援隨機訪問。雖說 PHP 中很少接觸到這些,但是你學習使用 C 語言的話這些基礎還是需要了解的。

陣列

   陣列(Array)是一種線性表資料結構,它用一組連續的記憶體空間,來儲存一組具有相同型別的資料。如果你學習過 C 語言,應該對這段定義很熟悉,但是在 PHP 這種動態語言中,因為陣列底層是通過雜湊表(後面我們會介紹這個資料結構)實現的,所以功能異常強大,這段常規的陣列定義在 PHP 中並不成立,PHP 的陣列可以儲存任何型別資料,如果與 Java 對比的話,PHP 陣列整合了 Java 的陣列、List、Set、Map 於一身,所以寫程式碼的效率比 Java 高了幾個量級
   特性:拋開 PHP 或 JavaScript 這種動態語言,對於傳統的陣列,比如:C 語言和 Java 中的陣列,在使用之前都需要宣告陣列儲存資料的型別和陣列的大小,陣列的優點是可以通過下標值隨機訪問陣列內的任何元素,演算法複雜度是 O(1) ,非常高效,但是缺點是刪除/插入元素比較費勁。以刪除為例,需要在刪除某個元素後,將後續元素都往前移一位,如果是插入,則需要將插入位置後的元素都往後移,所以對陣列的插入/刪除而言,演算法複雜度是 O(n),當然了,這個針對 C/Java 這種語言而言,PHP 不受此約束,因為它不是傳統一樣的陣列。

連結串列

  與陣列不同,連結串列並不需要一塊連續的記憶體空間,它通過“指標”將一組零散的記憶體塊串聯起來使用,如圖:

  單向連結串列:有兩個節點比較特殊,分別是第一個節點和最後一個節點。我們通常把第一個節點叫做頭節點,把最後一個節點叫做尾節點。其中,頭節點用來記錄連結串列的基地址,有了它,可以遍歷得到整條連結串列。而尾節點特殊的地方是:指標不是指向下一個節點,而是指向 NULL,表示這是連結串列的最後一個節點。對單連結串列而言,理論上來說,插入和刪除節點的時間複雜是O(1),查詢節點的時間複雜度是 O(n)。如圖:

  迴圈連結串列:迴圈連結串列和單連結串列的區別是尾節點執行的頭節點,從而頭尾相連,有點像貪吃蛇,可用於解決 [約瑟夫環] 問題,如圖:

  雙向連結串列: 與單連結串列的區別是雙向連結串列除了有一個指向下一個節點的指標外,還有一個用於指向上一個節點的指標,從而實現通過 O(1)複雜度找到上一個節點。正是因為這個節點,是的雙向連結串列的插入,刪除節點時比單連結串列更高效,雖然我們前面已經提到單連結串列插入,刪除時間複雜 O(1)了,但是這沒有考慮還只是針對插入,刪除本身而言。已刪除為例,刪除某個節點後,需要將其前驅節點的指標指向被刪除節點的下一個節點,這樣,我們還需要將獲取前驅節點,在單連結串列中獲取前驅節點的時間複雜度是O(n),所以綜合來看單連結串列的刪除,插入操作時間複雜度也是O(n),而雙向連結串列則不然,它有一個指標指向上一個節點,所以其插入和刪除時間複雜度才是正真的 O(1)。

   迴圈雙向連結串列:

堆疊應用場景:瀏覽器前進,倒退功能,編輯器中的撤銷,取消功能,程式程式碼中的函式呼叫,遞迴,四則運算等等。都是基於堆疊這種資料結構來實現的,就連著名的 stackoverflow 網站也是取[棧溢位] ,需要求教之意。

   棧又叫堆疊,是限定只能在一端進行插入和刪除操作的線性表,並且滿足先進後出(FIFO)的特點。我們把允許插入和刪除的一端叫做棧頂,另一個端叫棧底,不含任何資料的叫空棧。
  棧支援通過陣列/連結串列實現,通過陣列實現的叫做順序棧,通過連結串列實現的叫做鏈棧。

// * 通過 PHP 陣列實現簡單的順序棧
<?php
class SimpleStack
{
    private $_stack = [];
    private $_size = 0;

    //初始化 stack 大小

    public  function __construct($size)
    {
        $this->_size = $size;
    }

    private  function  check()
    {
        return count($this->_stack) >= $this->_size;
    }

    private function  isEmpty()
    {
        return current($this->_stack) == false ;
    }
    //push 資料
    public  function push($value)
    {
        if($this->check())
        {
            return "我已經存滿了,請先 pop 資料 ";
        }
        array_push($this->_stack, $value);
    }
    //pop 彈出資料
    public  function pop()
    {
        if($this->isEmpty()){
            return "我沒資料給你了,請 push 資料";
        }
        return array_pop($this->_stack);
    }
}
$stack = new SimpleStack(2);
$stack->push('Allen');
$stack->push('hello');
echo $stack->pop();
echo $stack->pop();

佇列

  和棧一樣,佇列也是一種特殊的線性表結構,只不過佇列是在一端插入,另一端刪除,就根我們平常排隊一樣的道理,從隊尾入隊,在隊頭出去,所以佇列的特性是先入先出(FIFO),允許插入的一端叫隊尾,允許刪除的一端叫隊頭。榮國陣列實現的叫順序佇列,通過連結串列實現的叫做鏈式佇列,棧只需要一個棧頂指標就可以了,因為只允許在棧頂插入刪除,但是佇列需要兩個指標,一個指向隊頭,一個指向隊尾。

   通過陣列實現的佇列有一個問題,隨著佇列元素的插入和刪除,隊尾指標和隊頭指標不斷後移,而導致隊尾指標指向末尾無法插入資料,這時候有可能佇列頭部還有剩餘空間,如圖:

  以上問題,我們可以通過資料搬移的方式把所有的佇列資料往前移,但這會增加額外的時間複雜度,如果頻繁運算元據量很大的佇列,顯然對效能有嚴重損耗,對此問題的解決方案是迴圈佇列,即把佇列頭尾連起來:

 <?php
/**
 *  PHP 陣列模擬佇列
 */
class SimpleQueue
{
    private  $_queue = [];
    private  $_size = 0;
    public  function __construct($size)
    {
        $this->_size = $size;
    }
    /*
     * 確認佇列是否滿
     *
     */
    private function  check()
    {
        return count($this->_queue) >= $this->_size;
    }
    private  function isEmpty()
    {
        return current($this->_queue) === false;
    }
    /*
     * 入隊
     */
    public  function  push($value)
    {
        if($this->check())
        {
            return '佇列已滿,請執行出隊。';
        }
        array_push($this->_queue, $value);
    }
    public  function pop()
    {
        if($this->isEmpty()){
            return '佇列已清空,請新增佇列';
        }
        return array_shift($this->_queue);
    }
}
$queue = new SimpleQueue(5);
$queue->push('Allen task1');
$queue->push('Allen task2');
echo $queue->pop();
echo $queue->pop();

遞迴(算不上任何資料結構和演算法),比較重要的程式設計技巧

遞迴,簡單來講就是在函式定義中呼叫函式自身,從我們從前學習教學解體經驗來講,就是講一個大問題拆分成多個小問題,逐一擊破後最後歸併結果。我們判斷一個問題是否可以通過遞迴來解決,主要看它是否滿足三個條件:

  1. 一個問題可以分解為幾個子問題的解
  2. 這個問題與分解後的子問題,除了資料規模不同,求解思路完全一樣
  3. 存在遞迴終止條件 遞迴一定要有終止條件,否則會導致函式被無限呼叫最終致使記憶體溢位

   通過以上分析,我們可以整理遞迴的編寫思路:寫出遞迴公式,找到終止條件。有句話叫做「人理解迭代,神理解遞迴」,說的就是遞迴程式碼可讀性不好,理論上看,遞迴程式碼都是可以轉化為迭代實現的。但是遞迴程式碼更簡潔,更顯逼格,我們在通過遞迴實現程式碼的時候,切忌試圖通過人腦去分解每個步驟,那樣會把自己搞暈,這種重複迭代的事情交給計算機去做,我們要做的是抽象規律,寫遞迴公式,找終止條件,再把它們轉換為遞迴程式碼,就完事了。

編寫遞迴程式碼時有兩個注意事項:

  1. 警惕堆疊溢位,為此要設定好終止條件和合理的遞迴層數
  2. 防止重複計算

   案例:斐波納數列:0,1,1,2,3,5,8,13,21....(求第 n 個數列值)
抽象出規律:F0=0;F1=1;....F(n) = F(n-1)+F(n-2)

<?php
function fibonacci($n)
{
    if($n == 0)
    {
        return 0;
    }
    if($n == 1)
    {
        return 1;
    }
    return fibonacci($n - 2) + fibonacci($n - 1);
}
print fibonacci(6);

相關文章