PHP面試:常見查詢演算法一篇說透
在本篇文章中,將為各位老鐵介紹不同的搜尋演算法以及它們的複雜度。因為力求通俗易懂,所以篇幅可能較長,大夥可以先Mark下來,每天抽時間看一點理解一點。本文配套的 ofollow,noindex">Github Repo ,歡迎各位老鐵star,會一直更新的。
開篇
和排序類似,搜尋或者叫做查詢,也是平時我們使用最多的演算法之一。無論我們搜尋資料庫還是檔案,實際上都在使用某種搜尋演算法來定位想要查詢的資料。
線性查詢
執行搜尋的最常見的方法是將每個專案與我們正在尋找的資料進行比較,這就是線性搜尋或順序搜尋。它是執行搜尋的最基本的方式。如果列表中有n項。在最壞的情況下。我們必須搜尋n個專案才能找到一個特定的專案。下面遍歷一個數組來查詢一個專案。
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),這不會隨著待搜尋陣列的順序改變而改變。所以如果陣列中的項按特定順序排序,我們不必進行線性搜尋。我們可以通過執行選擇性搜尋而可以獲得更好的結果。最流行也是最著名的搜尋演算法是“二分搜尋”。雖然有點像我們之前說的 二叉搜尋樹 ,但我們不用構造二叉搜尋樹就可以使用這個演算法。
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; } 複製程式碼
在二分搜尋演算法中,我們從資料的中間開始,檢查中間的項是否比我們要尋找的項小或大,並決定走哪條路。這樣,我們把列表分成兩半,一半完全丟棄,像下面的影象一樣。

遞迴版本:
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個元素。因此,上述二分搜尋演算法需要進行修改,將它修改成一個重複的搜尋,搜尋直到元素第一次出現的位置才停止。

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,我們繼續檢查中間元素左側的元素,看看有沒有再次出現我們尋找的值。 然後繼續迭代,直到 high。 如果沒有再次找到這個值,那麼第一次出現的位置就是該項的第一個索引的值。 如果沒有,像往常一樣返回-1。我們執行一個測試來看程式碼是否正確:
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); } 複製程式碼
發現結果正確。

到目前為止,我們可以得出結論,二分搜尋肯定比線性搜尋更快。但是,這一切的先決條件是陣列已經排序。在未排序的陣列中應用二分搜尋會導致錯誤的結果。 那可能存在一種情況,就是對於某個陣列,我們不確定它是否已排序。現在有一個問題就是,是否應該首先對陣列進行排序然後應用二分查詢演算法嗎?還是繼續使用線性搜尋演算法?
小思考
對於一個包含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),顯然這比順序搜尋更好。
我們可以得出結論,如果一些搜尋操作的次數比陣列的長度小,最好不要對陣列進行排序,直接執行順序搜尋即可。但是,如果搜尋操作的次數與陣列的大小相比更大,那麼最好先對陣列進行排序,然後使用二分搜尋。
二分搜尋演算法有很多不同的版本。我們不是每次都選擇中間索引,我們可以通過計算作出決策來選擇接下來要使用的索引。我們現在來看二分搜尋演算法的兩種變形:插值搜尋和指數搜尋。
插值搜尋
在二分搜尋演算法中,總是從陣列的中間開始搜尋過程。 如果一個數組是均勻分佈的,並且我們正在尋找的資料可能接近陣列的末尾,那麼從中間搜尋可能不是一個好選擇。 在這種情況下,插值搜尋可能非常有用。插值搜尋是對二分搜尋演算法的改進,插值搜尋可以基於搜尋的值選擇到達不同的位置。例如,如果我們正在搜尋靠近陣列開頭的值,它將直接定位到到陣列的第一部分而不是中間。使用公式計算位置,如下所示

可以發現,我們將從通用的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,即

那我們的二分搜尋時間複雜度需要對這個範圍求log2,即

那麼整個指數搜尋的時間複雜度就是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時必須逐級移動。

使用BFS,會得到以下的序列。

虛擬碼如下:
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在到達分支末尾時然後向上回溯,並移動到下一個可用的相鄰節點,直到搜尋結束。還是上面的樹

這次我們會獲得不通的遍歷順序:

從根開始,然後訪問第一個孩子,即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。