增删改我们聊的挺多了,但是查该如何去执行呢?
搜索算法
线性搜索 (Linear Search)
最简单的查找方式就是线性搜索 (Linear Search) 了。线性搜索的方法非常简单,例如在数组里,我们就从头到尾按照index把整个数组扫一遍。找到就停下。
1 | int linearSearch(int A[], int n, int x) { |
这样的搜索方式荣获$O(n)$。好处是我们不需要对进行搜索的对象进行任何预处理。
小技巧:哨兵优化
在数组末尾放一个"哨兵"值,可以省去每次循环检查边界的开销:
1 | int linearSearchSentinel(int A[], int n, int x) { |
这个技巧在面试中偶尔会被问到。
二分搜索 (Binary Search)
那么你问到什么是预处理。在这之前请允许我先向你介绍二分搜索:
二分搜索沿用了之前做递归的"Divide and Conquer"的思路,方法是这样的:
- 定位到数据的中间,然后对比一下这个元素相比目标是更大还是更小?
- 如果要查找的目标更大,那就抛弃更小的那一半数据,在更大的那一半查找,反之亦然。
- 重复这样的过程,直到我们找到了最终结果。
这样的方法能拿到一个不错的$O(\log(n))$复杂度。但是这样的搜索方式有一个先决条件,那就是要处理的数据必须要先进行排序。这就是之前提到的预处理。
1 | int binarySearch(int A[], int n, int x) { |
⚠ 重要细节
为什么用 low + (high - low) / 2 而不是 (low + high) / 2?
因为当 low 和 high 都很大时,low + high 可能会整数溢出!这是面试和考试常问的点。
这样的要求导致我们在很多时候不会去使用二分搜索,因为对数据预先进行一步预处理可能更消耗时间。二分查找相比之下更适合用于静态数据,也就是一次写入,多次读取的情况。
lower_bound 与 upper_bound
除了查找"是否存在",二分搜索还有两个重要变体:
- lower_bound(x):返回第一个 ≥ x 的位置
- upper_bound(x):返回第一个 > x 的位置
1 | // lower_bound: 返回第一个 >= x 的位置 |
这两个函数在范围查询中非常有用!
要搜索的目标$X = 8$,请你对下面的数组执行二分搜索并追踪其过程:
$$L = [1, 3, 5, 7, 9, 11, 13, 15]$$
数组的index由0至7,因此我们一共有$n=8$个元素. 要搜索的目标是$X=8$
| 步数 | 下界 Low | 上界 High | 中位 Mid $\lfloor \frac{L+H}{2} \rfloor$ | 中位数 $L[\text{Mid}]$ | 比对 | 下一步? |
|---|---|---|---|---|---|---|
| 1 | 0 | 7 | $\lfloor 3.5 \rfloor = 3$ | 7 | $7 < 8$ (小了) | Low = Mid + 1 = 4 |
| 2 | 4 | 7 | $\lfloor 5.5 \rfloor = 5$ | 11 | $11 > 8$ (大了) | High = Mid - 1 = 4 |
| 3 | 4 | 4 | $\lfloor 4 \rfloor = 4$ | 9 | $9 > 8$ (大了) | High = Mid - 1 = 3 |
| 4 | 4 | 3 | - | - | Low > High |
没搜到,停止搜索。 |
直到Low指针位置与High发生相交,甚至相互跨越过去的时候,我们才能确定元素8根本不在数组里面。
跳转搜索 (Jump Search)
这就是那种典型的做题最爱算法。
跳转搜索 (跳转搜索) 有点像线性搜索,不过与其说像线性搜索那样一个一个往下遍历,跳转搜索会按大小为$k$的序列在目标数组里跳跃。过程大概是:
- 按照这样的规律查找值:$0, k, 2k, 3k ...$
- 当你发现跳过头的时候,回到上一次跳转的位置,然后从那里往后进行线性搜索。
$k$值的选择也是有讲究的。一般我们会选择让$k = \sqrt{n}$。
这就导致我们的时间复杂度是$O(\sqrt{n})$。
跳转搜索的执行效率没有二分搜索来的高效,但是在向后跳转成本很高的存储系统中(例如磁带),这种仅向前跳转的搜索算法还是比较有用处的。
假设我们已经有一个排好序的,大小为$n$的数组,你使用$k$作为跳转步长,那么你大概会需要完成这么多次比对来完成搜索:
$$\text{Total Comparisons} \approx \frac{n}{k} + k - 1$$
其中:
- $\frac{n}{k}$代表在最坏情况下,你找到目标属于的那个区块
- $k - 1$代表在最坏情况下,在区块内执行线性搜索的次数
“区块”就是我们每次执行跳转划分出来的区域。
已知有序数组长度$n = 100$,计算在最坏情况下,选定$k = 5$和$k = 20$作为跳转步长所需比对次数
嗯算就是了。
- $k = 5$:
$$\frac{n}{k} + k - 1 = \frac{100}{5} + 5 - 1 = 20 + 4 = 24$$
- $k = 20$:
$$\frac{n}{k} + k - 1 = \frac{100}{20} + 20 - 1 = 5 + 19 = 24$$
然后经过一波嗯算你发现他们的效率实际上相同。
为什么 $k = \sqrt{n}$ 是最优的?
我们要最小化总比较次数 $f(k) = \frac{n}{k} + k - 1$。
对 $k$ 求导:$f'(k) = -\frac{n}{k^2} + 1$
令 $f'(k) = 0$,得 $k^2 = n$,即 $k = \sqrt{n}$。
代入得最优比较次数约为 $2\sqrt{n} - 1 = O(\sqrt{n})$。
插值搜索 (Interpolation Search)
你正在你的3000名微信好友名单里查找你想要找的人聊天,而是个人都会做的事情就是想一下你好友名字的音序,然后直接跳转到那个首字母来进行查找。
例如你想找罗永浩,你就去字母L那里开始找。
插值搜索的关键是按照要搜索的元素来估算它在有序数组里的位置:
$$\text{Position} = \text{Low} + \left\lfloor \frac{(X - A[\text{Low}]) \times (\text{High} - \text{Low})}{A[\text{High}] - A[\text{Low}]} \right\rfloor$$
这个公式的思路是:假设数据均匀分布,那么目标值在值域中的相对位置,应该和它在索引中的相对位置成正比。
插值搜索的平均表现是$O(\log(\log(n)))$,速度已经很快了!
但是插值搜索在有序平均分布的数据集上表现最好,因为我们的公式就假设元素是平均分布来计算位置的。如果你的数据不是均匀分布的,那么执行搜索的最坏复杂度就是$O(n)$。
存在这样一个有序数组,其中的元素等距排列:
$$A = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]$$
请你使用插值搜索来计算目标$X=35$的第一次搜索位置
我们使用这个公式:
$$ p = \text{low} + \left\lfloor \frac{(X - A[\text{low}]) \times (\text{high} - \text{low})}{A[\text{high}] - A[\text{low}]} \right\rfloor $$
代入数值:$\text{low} = 0$,$\text{high} = 9$,$X = 35$,$A[0] = 10$,$A[9] = 100$
$$p = 0 + \left\lfloor \frac{(35 - 10) \times (9 - 0)}{100 - 10} \right\rfloor = \left\lfloor \frac{25 \times 9}{90} \right\rfloor = \left\lfloor 2.5 \right\rfloor = 2$$
所以第一次会检查位置 $p = 2$,即 $A[2] = 30$。因为 $30 < 35$,下一轮会在 $[3, 9]$ 区间继续插值搜索。
范围搜索
那么假设我们想要进行范围搜索 (Range Search),例如:找到夹在$X$和$Y$中间的所有值,你该如何制定策略?
首先我们决定不能使用哈希表来做。哈希函数的目标就是将值尽可能均匀,随机地散布在各处,所以即便你找到了一个元素,你也不能使用查找邻居的方式来快速定位范围。
有序数组 + 二分查找
如果数据是静态的(不需要频繁插入删除),最简单的方案就是:
- 用
lower_bound(X)找到第一个 $\geq X$ 的位置 $L$ - 用
upper_bound(Y)找到第一个 $> Y$ 的位置 $R$ - 区间 $[L, R)$ 内的所有元素就是答案
1 | // 范围查询:找出所有满足 X <= A[i] <= Y 的元素 |
时间复杂度:$O(\log n + k)$,其中 $k$ 是结果数量。
动态数据的范围查询
如果数据需要频繁更新,可以考虑以下数据结构:
| 数据结构 | 查询复杂度 | 更新复杂度 | 适用场景 |
|---|---|---|---|
| 有序数组 | $O(\log n + k)$ | $O(n)$ | 静态数据 |
| 平衡BST (如 AVL, 红黑树) | $O(\log n + k)$ | $O(\log n)$ | 需要动态插入删除 |
| 线段树 (Segment Tree) | $O(\log n)$ | $O(\log n)$ | 区间求和/最值 |
| 树状数组 (Fenwick Tree) | $O(\log n)$ | $O(\log n)$ | 前缀和查询 |
考试要点总结
搜索算法复杂度速查
| 算法 | 时间复杂度 | 前提条件 | 特点 |
|---|---|---|---|
| 线性搜索 | $O(n)$ | 无 | 最简单,无需预处理 |
| 二分搜索 | $O(\log n)$ | 有序 | 最常用,注意边界 |
| 跳转搜索 | $O(\sqrt{n})$ | 有序 | 只向前跳,适合磁带 |
| 插值搜索 | 平均 $O(\log\log n)$,最坏 $O(n)$ | 有序且均匀分布 | 数据不均匀时退化 |
⚠ 考试常见陷阱
- 二分搜索边界问题:
low <= highvslow < high?取决于你用的是闭区间还是半开区间 - mid 计算溢出:用
low + (high - low) / 2而不是(low + high) / 2 - 插值搜索最坏情况:数据分布极不均匀时退化为 $O(n)$
- 跳转搜索最优步长:$k = \sqrt{n}$,要会推导
- 范围查询:知道
lower_bound和upper_bound的区别和用法