PHP面試:常見查找算法一篇說透

預警

在本篇文章中,將爲各位老鐵介紹不一樣的搜索算法以及它們的複雜度。由於力求通俗易懂,因此篇幅可能較長,大夥能夠先Mark下來,天天抽時間看一點理解一點。本文配套的Github Repo,歡迎各位老鐵star,會一直更新的。php

開篇

和排序相似,搜索或者叫作查找,也是平時咱們使用最多的算法之一。不管咱們搜索數據庫仍是文件,實際上都在使用某種搜索算法來定位想要查找的數據。node

線性查找

執行搜索的最多見的方法是將每一個項目與咱們正在尋找的數據進行比較,這就是線性搜索或順序搜索。它是執行搜索的最基本的方式。若是列表中有n項。在最壞的狀況下。咱們必須搜索n個項目才能找到一個特定的項目。下面遍歷一個數組來查找一個項目。git

function linearSearch(array $arr, int $needle) {
    for ($i = 0, $count = count($arr); $i < $count; $i++) {
        if ($needle === $arr[$i]) {
            return true;
        }
    }

    return false;
}
複製代碼

線性查找的複雜度

best time complexity O(1)
worst time complexity O(n)
Average time complexity O(n)
Space time complexity O(1)

二分搜索

線性搜索的平均時間複雜度或最壞時間複雜度是O(n),這不會隨着待搜索數組的順序改變而改變。因此若是數組中的項按特定順序排序,咱們沒必要進行線性搜索。咱們能夠經過執行選擇性搜索而能夠得到更好的結果。最流行也是最著名的搜索算法是「二分搜索」。雖然有點像咱們以前說的二叉搜索樹,但咱們不用構造二叉搜索樹就可使用這個算法。github

function binarySearch(array $arr, int $needle) {
    $low = 0;
    $high = count($arr) - 1;

    while ($low <= $high) {
        $middle = (int)(($high + $low) / 2);

        if ($arr[$middle] < $needle) {
            $low = $middle + 1;
        } elseif ($arr[$middle] > $needle) {
            $high = $middle - 1;
        } else {
            return true;
        }
    }

    return false;
}
複製代碼

在二分搜索算法中,咱們從數據的中間開始,檢查中間的項是否比咱們要尋找的項小或大,並決定走哪條路。這樣,咱們把列表分紅兩半,一半徹底丟棄,像下面的圖像同樣。算法

clipboard.png

遞歸版本:數據庫

function binarySearchRecursion(array $arr, int $needle, int $low, int $high) {
    if ($high < $low) return false;

    $middle = (int)(($high + $low) / 2);

    if ($arr[$middle] < $needle) {
        return binarySearchRecursion($arr, $needle, $middle + 1, $high);
    } elseif ($arr[$middle] > $needle) {
        return binarySearchRecursion($arr, $needle, $low, $middle - 1);
    } else {
        return true;
    }
}
複製代碼

二分搜索複雜度分析

對於每一次迭代,咱們將數據劃分爲兩半,丟棄一半,另外一半用於搜索。在分別進行了1,2次和3次迭代以後,咱們的列表長度逐漸減小到n/2,n/4,n/8...。所以,咱們能夠發現,k次迭代後,將只會留下n/2^k項。最後的結果就是 n/2^k = 1,而後咱們兩邊分別取對數 獲得 k = log(n),這就是二分搜索算法的最壞運行時間複雜度。編程

best time complexity O(1)
worst time complexity O(log n)
Average time complexity O(log n)
Space time complexity O(1)

重複二分查找

有這樣一個場景,假如咱們有一個含有重複數據的數組,若是咱們想從數組中找到2的第一次出現的位置,使用以前的算法將會返回第5個元素。然而,從下面的圖像中咱們能夠清楚地看到,正確的結果告訴咱們它不是第5個元素,而是第2個元素。所以,上述二分搜索算法須要進行修改,將它修改爲一個重複的搜索,搜索直到元素第一次出現的位置才中止。數組

clipboard.png

function repetitiveBinarySearch(array $data, int $needle) {
    $low = 0;
    $high = count($data);
    $firstIndex = -1;

    while ($low <= $high) {
        $middle = ($low + $high) >> 1;

        if ($data[$middle] === $needle) {
            $firstIndex = $middle;
            $high = $middle - 1;
        } elseif ($data[$middle] > $needle) {
            $high = $middle - 1;
        } else {
            $low = $middle + 1;
        }
    }

    return $firstIndex;
}
複製代碼

首先咱們檢查mid所對應的值是不是咱們正在尋找的值。 若是是,那麼咱們將中間索引指定爲第一次出現的index,咱們繼續檢查中間元素左側的元素,看看有沒有再次出現咱們尋找的值。 而後繼續迭代,直到low >high。 若是沒有再次找到這個值,那麼第一次出現的位置就是該項的第一個索引的值。 若是沒有,像往常同樣返回-1。咱們運行一個測試來看代碼是否正確:bash

public function testRepetitiveBinarySearch() {
    $arr = [1,1,1,2,3,4,5,5,5,5,5,6,7,8,9,10];

    $firstIndex = repetitiveBinarySearch($arr, 6);

    $this->assertEquals(11, $firstIndex);
}
複製代碼

發現結果正確。數據結構

clipboard.png

到目前爲止,咱們能夠得出結論,二分搜索確定比線性搜索更快。可是,這一切的先決條件是數組已經排序。在未排序的數組中應用二分搜索會致使錯誤的結果。 那可能存在一種狀況,就是對於某個數組,咱們不肯定它是否已排序。如今有一個問題就是,是否應該首先對數組進行排序而後應用二分查找算法嗎?仍是繼續使用線性搜索算法?

小思考

對於一個包含n個項目的數組,而且它們沒有排序。因爲咱們知道二分搜索更快,咱們決定先對其進行排序,而後使用二分搜索。可是,咱們清楚最好的排序算法,其最差的時間複雜度是O(nlogn),而對於二分搜索,最壞狀況複雜度是O(logn)。因此,若是咱們排序後應用二分搜索,複雜度將是O(nlogn)。

可是,咱們也知道,對於任何線性或順序搜索(排序或未排序),最差的時間複雜度是O(n),顯然好於上述方案。

考慮另外一種狀況,即咱們須要屢次搜索給定數組。咱們將k表示爲咱們想要搜索數組的次數。若是k爲1,那麼咱們能夠很容易地應用以前的線性搜索方法。若是k的值比數組的大小更小,暫且使用n表示數組的大小。若是k的值更接近或大於n,那麼咱們在應用線性方法時會遇到一些問題。假設k = n,線性搜索將具備O(n2)的複雜度。如今,若是咱們進行排序而後再進行搜索,那麼即便k更大,一次排序也只會花費O(nlogn)時間復。而後,每次搜索的複雜度是O(logn),n次搜索的複雜度是O(nlogn)。若是咱們在這裏採起最壞的運行狀況,排序後而後搜索k次總的的複雜度是O(nlogn),顯然這比順序搜索更好。

咱們能夠得出結論,若是一些搜索操做的次數比數組的長度小,最好不要對數組進行排序,直接執行順序搜索便可。可是,若是搜索操做的次數與數組的大小相比更大,那麼最好先對數組進行排序,而後使用二分搜索。

二分搜索算法有不少不一樣的版本。咱們不是每次都選擇中間索引,咱們能夠經過計算做出決策來選擇接下來要使用的索引。咱們如今來看二分搜索算法的兩種變形:插值搜索和指數搜索。

插值搜索

在二分搜索算法中,老是從數組的中間開始搜索過程。 若是一個數組是均勻分佈的,而且咱們正在尋找的數據可能接近數組的末尾,那麼從中間搜索可能不是一個好選擇。 在這種狀況下,插值搜索可能很是有用。插值搜索是對二分搜索算法的改進,插值搜索能夠基於搜索的值選擇到達不一樣的位置。例如,若是咱們正在搜索靠近數組開頭的值,它將直接定位到到數組的第一部分而不是中間。使用公式計算位置,以下所示

clipboard.png

能夠發現,咱們將從通用的mid =(low * high)/2 轉變爲更復雜的等式。若是搜索的值更接近arr[high],則此公式將返回更高的索引,若是值更接近arr[low],則此公式將返回更低的索引。

function interpolationSearch(array $arr, int $needle) {
    $low = 0;
    $high = count($arr) - 1;

    while ($arr[$low] != $arr[$high] && $needle >= $arr[$low] && $needle <= $arr[$high]) {
        $middle = intval($low + ($needle - $arr[$low]) * ($high - $low) / ($arr[$high] - $arr[$low]));

        if ($arr[$middle] < $needle) {
            $low = $middle + 1;
        } elseif ($arr[$middle] > $needle) {
            $high = $middle - 1;
        } else {
            return $middle;
        }
    }

    if ($needle == $arr[$low]) {
    	return $low;
    } 
    
    return -1;
    
}
複製代碼

插值搜索須要更多的計算步驟,可是若是數據是均勻分佈的,這個算法的平均複雜度是O(log(log n)),這比二分搜索的複雜度O(logn)要好得多。 此外,若是值的分佈不均勻,咱們必需要當心。 在這種狀況下,插值搜索的性能能夠須要從新評估。下面咱們將探索另外一種稱爲指數搜索的二分搜索變體。

指數搜索

在二分搜索中,咱們在整個列表中搜索給定的數據。指數搜索經過決定搜索的下界和上界來改進二分搜索,這樣咱們就不會搜索整個列表。它減小了咱們在搜索過程當中比較元素的數量。指數搜索是在如下兩個步驟中完成的:

1.咱們經過查找第一個指數k來肯定邊界大小,其中值2^k的值大於搜索項。 如今,2^k和2^(k-1)分別成爲上限和下限。 2.使用以上的邊界來進行二分搜索。

下面咱們來看下PHP實現的代碼

function exponentialSearch(array $arr, int $needle): int {
    $length = count($arr);
    if ($length == 0) return -1;

    $bound = 1;

    while ($bound < $length && $arr[$bound] < $needle) {
        $bound *= 2;
    }

    return binarySearchRecursion($arr, $needle, $bound >> 1, min($bound, $length));
}
複製代碼

咱們把$needle出現的位置記位i,那麼咱們第一步花費的時間複雜度就是O(logi)。表示爲了找到上邊界,咱們的while循環須要執行O(logi)次。由於下一步應用一個二分搜索,時間複雜度也是O(logi)。咱們假設j是咱們上一個while循環執行的次數,那麼本次二分搜索咱們須要搜索的範圍就是2^j-1 至 2^j,而j=logi,即

clipboard.png

那咱們的二分搜索時間複雜度須要對這個範圍求log2,即

clipboard.png

那麼整個指數搜索的時間複雜度就是2 O(logi),省略掉常數就是O(logi)。

best time complexity O(1)
worst time complexity O(log i)
Average time complexity O(log i)
Space time complexity O(1)

哈希查找

在搜索操做方面,哈希表能夠是很是有效的數據結構。在哈希表中,每一個數據都有一個與之關聯的惟一索引。若是咱們知道要查看哪一個索引,咱們就能夠很是輕鬆地找到對應的值。一般,在其餘編程語言中,咱們必須使用單獨的哈希函數來計算存儲值的哈希索引。散列函數旨在爲同一個值生成相同的索引,並避免衝突。

PHP底層C實現中數組自己就是一個哈希表,因爲數組是動態的,沒必要擔憂數組溢出。咱們能夠將值存儲在關聯數組中,以便咱們能夠將值與鍵相關聯。

function hashSearch(array $arr, int $needle) {
    return isset($arr[$needle]) ? true : false;
}
複製代碼

樹搜索

搜索分層數據的最佳方案之一是建立搜索樹。在第理解和實現樹中,咱們瞭解瞭如何構建二叉搜索樹並提升搜索效率,而且介紹了遍歷樹的不一樣方法。 如今,繼續介紹兩種最經常使用的搜索樹的方法,一般稱爲廣度優先搜索(BFS)和深度優先搜索(DFS)。

廣度優先搜索(BFS)

在樹結構中,根鏈接到其子節點,每一個子節點還能夠繼續表示爲樹。 在廣度優先搜索中,咱們從節點(主要是根節點)開始,而且在訪問其餘鄰居節點以前首先訪問全部相鄰節點。 換句話說,咱們在使用BFS時必須逐級移動。

clipboard.png

使用BFS,會獲得如下的序列。

clipboard.png

僞代碼以下:

procedure BFS(Node root)
    Q := empty queue
    Q.enqueue(root)
    
    while(Q != empty) 
        u := Q.dequeue()
        for each node w that is childnode of u
            Q.enqueue(w)
        end for each
    end while
end procedure        
複製代碼

下面是PHP代碼。

class TreeNode {
    public $data = null;
    public $children = [];

    public function __construct(string $data = null) {
        $this->data = $data;
    }

    public function addChildren(TreeNode $treeNode) {
        $this->children[] = $treeNode;
    }
}

class Tree {
    public $root = null;

    public function __construct(TreeNode $treeNode) {
        $this->root = $treeNode;
    }

    public function BFS(TreeNode $node): SplQueue {
        $queue = new SplQueue();
        $visited = new SplQueue();

        $queue->enqueue($node);

        while (!$queue->isEmpty()) {
            $current = $queue->dequeue();
            $visited->enqueue($current);

            foreach ($current->children as $children) {
                $queue->enqueue($children);
            }
        }

        return $visited;
    }
}
複製代碼

完整的例子和測試,你能夠點擊這裏查看

若是想要查找節點是否存在,能夠爲當前節點值添加簡單的條件判斷便可。BFS最差的時間複雜度是O(|V| + |E|),其中V是頂點或節點的數量,E則是邊或者節點之間的鏈接數,最壞的狀況空間複雜度是O(|V|)。

圖的BFS和上面的相似,但略有不一樣。 因爲圖是能夠循環的(能夠建立循環),須要確保咱們不會重複訪問同一節點以建立無限循環。 爲了不從新訪問圖節點,必須跟蹤已經訪問過的節點。可使用隊列,也可使用圖着色算法來解決。

深度優先搜索(DFS)

深度優先搜索(DFS)指的是從一個節點開始搜索,並從目標節點經過分支儘量深地到達節點。 DFS與BFS不一樣,簡單來講,就是DFS是深刻挖掘而不是先擴散。DFS在到達分支末尾時而後向上回溯,並移動到下一個可用的相鄰節點,直到搜索結束。仍是上面的樹

clipboard.png

此次咱們會得到不通的遍歷順序:

clipboard.png

從根開始,而後訪問第一個孩子,即3。而後,到達3的子節點,並反覆執行此操做,直到咱們到達分支的底部。在DFS中,咱們將採用遞歸方法來實現。

procedure DFS(Node current)
    for each node v that is childnode of current
       DFS(v)
    end for each
end procedure

複製代碼
public function DFS(TreeNode $node): SplQueue {
    $this->visited->enqueue($node);

    if ($node->children) {
        foreach ($node->children as $child) {
            $this->DFS($child);
        }
    }

    return $this->visited;
}
複製代碼

若是須要使用迭代實現,必須記住使用棧而不是隊列來跟蹤要訪問的下一個節點。下面使用迭代方法的實現

public function DFS(TreeNode $node): SplQueue {
    $stack = new SplStack();
    $visited = new SplQueue();

    $stack->push($node);

    while (!$stack->isEmpty()) {
        $current = $stack->pop();
        $visited->enqueue($current);

        foreach ($current->children as $child) {
            $stack->push($child);
        }
    }

    return $visited;
}
複製代碼

這看起來與BFS算法很是類似。主要區別在於使用棧而不是隊列來存儲被訪問節點。它會對結果產生影響。上面的代碼將輸出8 10 14 13 3 6 7 4 1。這與咱們使用迭代的算法輸出不一樣,但其實這個結果沒有毛病。

由於使用棧來存儲特定節點的子節點。對於值爲8的根節點,第一個值是3的子節點首先入棧,而後,10入棧。因爲10後來入棧,它遵循LIFO。因此,若是咱們使用棧實現DFS,則輸出老是從最後一個分支開始到第一個分支。能夠在DFS代碼中進行一些小調整來達到想要的效果。

public function DFS(TreeNode $node): SplQueue {
    $stack = new SplStack();
    $visited = new SplQueue();

    $stack->push($node);

    while (!$stack->isEmpty()) {
        $current = $stack->pop();
        $visited->enqueue($current);

        $current->children = array_reverse($current->children);
        foreach ($current->children as $child) {
            $stack->push($child);
        }
    }

    return $visited;
}
複製代碼

因爲棧遵循Last-in,First-out(LIFO),經過反轉,能夠確保先訪問第一個節點,由於顛倒了順序,棧實際上就做爲隊列在工做。要是咱們搜索的是二叉樹,就不須要任何反轉,由於咱們能夠選擇先將右孩子入棧,而後左子節點首先出棧。

DFS的時間複雜度相似於BFS。

相關文章
相關標籤/搜索