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。

相关文章
相关标签/搜索