跳到主要内容
预计阅读 23 分钟

大海捞针的艺术 —— 查找算法

在100万条数据中找到目标,最笨的方法需要100万次比较,最聪明的方法只需要20次。查找算法教你如何聪明地”缩小范围”。

📋 开篇自测:你已经知道多少?

  1. 为什么二分查找要求数据必须是有序的?如果数据无序,有没有办法加速查找?
  2. 二分查找看似简单,但”在一个有序数组中查找第一个等于目标值的元素”你能bug-free地写出来吗?
  3. 搜索引擎在几十亿网页中找到你要的内容,用的是什么查找技术?

一、线性查找——最朴素的起点

最简单的查找方法:从头到尾逐个检查,直到找到目标或遍历完毕。

def linear_search(arr, target):
    """线性查找:逐个比较"""
    for i, val in enumerate(arr):
        if val == target:
            return i
    return -1

data = [4, 2, 7, 1, 9, 3, 5]
print(linear_search(data, 9))   # 4
print(linear_search(data, 6))   # -1

时间复杂度: O(n)

线性查找不要求数据有序,实现简单,在小数据量时完全够用。但当数据量达到百万甚至上亿时,O(n)就不够看了。

这就像你在一个没有任何标记的图书馆里找一本书——只能从第一个书架开始,一本一本地翻过去看书名。


二、二分查找——每次排除一半

核心思想

如果数据是有序的,我们可以每次用中间元素和目标值比较,从而排除一半的数据。

这就像猜数字游戏:“我心里想了一个1到100的数,你来猜,每次我告诉你猜大了还是猜小了。“最优策略是什么?先猜50。如果被告知”大了”,就在1-49里猜;如果”小了”,就在51-100里猜。每次范围缩小一半,最多7次就能猜中。

def binary_search(arr, target):
    """二分查找:在有序数组中查找目标值"""
    left, right = 0, len(arr) - 1
    while left <= right:
        mid = left + (right - left) // 2  # Python中整数无溢出问题,但在Java/C++中这样写可防止溢出,是跨语言的好习惯
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1

data = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
print(binary_search(data, 7))    # 3
print(binary_search(data, 8))    # -1

时间复杂度: O(log n) 空间复杂度: O(1)

来感受一下O(log n)的威力:

数据量线性查找最大次数二分查找最大次数
1001007
10,00010,00014
1,000,0001,000,00020
10亿10亿30

10亿条数据,只需要30次比较!这就是对数增长的魅力。

二分查找的前提条件

  1. 数据必须有序 —— 这是二分查找的前提。如果数据无序,要么先排序(O(n log n)),要么用哈希表。
  2. 数据必须支持随机访问 —— 数组可以,链表不行(链表无法O(1)访问中间元素)。

🤔 想一想 二分查找的时间复杂度是O(log n),排序的时间复杂度是O(n log n)。如果一个无序数组只需要查找一次,先排序再二分查找值得吗?如果需要查找很多次呢?


三、二分查找的四种变体——面试官最爱考的

基本的二分查找容易理解,但面试中经常考的是它的变体。这些变体处理的是”有重复元素的有序数组”中的精准定位问题。

变体1:查找第一个等于目标值的元素

def find_first(arr, target):
    """找到第一个等于target的元素的索引"""
    left, right = 0, len(arr) - 1
    result = -1
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            result = mid
            right = mid - 1  # 关键:找到了不停下,继续往左找
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return result

data = [1, 3, 5, 5, 5, 7, 9]
print(find_first(data, 5))  # 2(第一个5的位置)

变体2:查找最后一个等于目标值的元素

def find_last(arr, target):
    """找到最后一个等于target的元素的索引"""
    left, right = 0, len(arr) - 1
    result = -1
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            result = mid
            left = mid + 1  # 关键:找到了不停下,继续往右找
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return result

data = [1, 3, 5, 5, 5, 7, 9]
print(find_last(data, 5))  # 4(最后一个5的位置)

变体3:查找第一个大于等于目标值的元素

def find_first_ge(arr, target):
    """找到第一个大于等于target的元素的索引(下界)"""
    left, right = 0, len(arr) - 1
    result = len(arr)  # 如果所有元素都小于target
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] >= target:
            result = mid
            right = mid - 1
        else:
            left = mid + 1
    return result

data = [1, 3, 5, 7, 9, 11]
print(find_first_ge(data, 6))   # 3(第一个>=6的是7,索引3)
print(find_first_ge(data, 5))   # 2(第一个>=5的是5,索引2)

变体4:查找最后一个小于等于目标值的元素

def find_last_le(arr, target):
    """找到最后一个小于等于target的元素的索引(上界)"""
    left, right = 0, len(arr) - 1
    result = -1
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] <= target:
            result = mid
            left = mid + 1
        else:
            right = mid - 1
    return result

data = [1, 3, 5, 7, 9, 11]
print(find_last_le(data, 6))   # 2(最后一个<=6的是5,索引2)
print(find_last_le(data, 7))   # 3(最后一个<=7的是7,索引3)

这四种变体的关键区别在于:找到目标后是继续往左找还是往右找。 掌握了这个核心差异,就能应对各种二分变体题。


四、二分查找的扩展应用

应用1:求平方根

求一个数的平方根(精确到小数点后6位),这其实也是一个二分查找问题。

def sqrt_binary(n, precision=1e-6):
    """用二分法求平方根"""
    if n < 0:
        raise ValueError("负数没有实数平方根")
    if n < 1:
        left, right = 0, 1
    else:
        left, right = 0, n

    while right - left > precision:
        mid = (left + right) / 2
        if mid * mid < n:
            left = mid
        else:
            right = mid
    return round((left + right) / 2, 6)

print(sqrt_binary(2))     # 1.414214
print(sqrt_binary(9))     # 3.0
print(sqrt_binary(0.25))  # 0.5

应用2:查找旋转排序数组

一个有序数组在某个位置被”旋转”了,比如 [4,5,6,7,0,1,2](原来是 [0,1,2,4,5,6,7])。如何在这样的数组中查找目标值?

def search_rotated(arr, target):
    """在旋转排序数组中查找"""
    left, right = 0, len(arr) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            return mid
        # 判断哪半边是有序的
        if arr[left] <= arr[mid]:  # 左半边有序
            if arr[left] <= target < arr[mid]:
                right = mid - 1
            else:
                left = mid + 1
        else:  # 右半边有序
            if arr[mid] < target <= arr[right]:
                left = mid + 1
            else:
                right = mid - 1
    return -1

data = [4, 5, 6, 7, 0, 1, 2]
print(search_rotated(data, 0))   # 4
print(search_rotated(data, 3))   # -1

应用3:查找峰值元素

在一个数组中,找到任意一个比左右邻居都大的”峰值”元素。

def find_peak(arr):
    """二分查找峰值元素"""
    left, right = 0, len(arr) - 1
    while left < right:
        mid = left + (right - left) // 2
        if arr[mid] < arr[mid + 1]:
            left = mid + 1  # 峰值在右边
        else:
            right = mid     # 峰值在左边(包括mid)
    return left

data = [1, 3, 5, 4, 2]
print(find_peak(data))  # 2(arr[2]=5是峰值)

🤔 想一想 上面的峰值查找算法为什么能用二分?因为如果mid位置的值比右边小,说明从mid到右边一定存在一个”先升后降”的趋势,峰值必然在右半边。这和有序数组不同,但二分的本质——每次能排除一半——仍然成立。


五、插值查找与指数查找

插值查找——更聪明的猜测

二分查找每次都猜中间位置。但如果数据分布均匀,我们可以根据目标值在范围中的相对位置来猜一个更可能的位置。

这就像你查字典找”python”——你不会翻到正中间,而是会翻到大约3/4的位置,因为p在字母表的后半段。

def interpolation_search(arr, target):
    """插值查找:根据值的分布来估计位置"""
    left, right = 0, len(arr) - 1
    while left <= right and arr[left] <= target <= arr[right]:
        if arr[left] == arr[right]:
            if arr[left] == target:
                return left
            break
        # 关键公式:按比例估算目标位置
        pos = left + int((target - arr[left]) * (right - left)
                        / (arr[right] - arr[left]))
        if arr[pos] == target:
            return pos
        elif arr[pos] < target:
            left = pos + 1
        else:
            right = pos - 1
    return -1

# 对均匀分布的数据效果最好
data = list(range(0, 1000, 2))  # [0, 2, 4, 6, ..., 998]
print(interpolation_search(data, 678))  # 339

时间复杂度: 数据均匀分布时O(log log n),最坏O(n)

指数查找——先确定范围再二分

当你面对的数据量未知或者非常大时,指数查找先以指数级增长步长确定一个范围,然后在该范围内做二分查找。

def exponential_search(arr, target):
    """指数查找"""
    if arr[0] == target:
        return 0

    # 第一步:找到一个范围
    bound = 1
    while bound < len(arr) and arr[bound] <= target:
        bound *= 2

    # 第二步:在范围内二分查找
    left = bound // 2
    right = min(bound, len(arr) - 1)
    while left <= right:
        mid = left + (right - left) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1

data = list(range(100))
print(exponential_search(data, 67))  # 67

六、实际工程中的查找

在真实项目中,查找往往不是简单地在一个内存数组里找数据。

数据库查询: 数据库使用B+树索引。B+树是一种多路搜索树,每个节点可以有几百个子节点,树的高度通常只有3-4层。这意味着在几十亿条数据中,只需要3-4次磁盘读取就能定位到目标数据。

搜索引擎: 使用倒排索引。为每个关键词建立一个列表,记录包含这个词的所有网页。搜索时,先查索引找到候选网页,再排序返回结果。

Python中的查找:

# 使用bisect模块进行二分查找
import bisect

sorted_data = [1, 3, 5, 7, 9, 11, 13, 15]

# 查找插入位置
pos = bisect.bisect_left(sorted_data, 7)
print(pos)  # 3

# 等价于"查找第一个>=target的位置"
pos = bisect.bisect_left(sorted_data, 6)
print(pos)  # 3(6应该插在7前面)

# bisect_right返回的是最后一个<=target的后一个位置
pos = bisect.bisect_right(sorted_data, 7)
print(pos)  # 4

⚠️ 常见误区

  • 误区1:“二分查找很简单” —— 基本版确实简单,但变体版(查找第一个/最后一个/边界值)很容易写出死循环或返回错误结果。Knuth曾说过:虽然二分查找的思路在1946年就被提出了,但第一个没有bug的实现要到1962年才出现。
  • 误区2:“查找之前一定要先排序” —— 如果只查找一次,排序(O(n log n))再二分(O(log n))不如直接线性查找(O(n))。只有需要多次查找时,先排序才划算。
  • 误区3:“二分查找只能用在有序数组上” —— 二分查找的本质是每次能排除一半的搜索空间。只要满足这个条件,就能用二分。旋转数组、峰值查找、数学函数求零点都是例子。

📝 掌握度自测

  1. 概念题: 为什么说 mid = left + (right - left) // 2mid = (left + right) // 2 更好?
  2. 编码题: 在有序数组 [1,2,2,2,3,4,5] 中,使用二分查找统计数字2出现了多少次。
  3. 分析题: 如果一个有序数组有10亿个元素,每个元素4字节,总共约4GB。二分查找时,每次访问中间元素可能导致缓存未命中。和线性查找(顺序访问,对缓存友好)相比,二分查找一定更快吗?
  4. 编码题: 实现一个函数,在旋转排序数组中找到最小值。比如 [4,5,6,7,0,1,2] 的最小值是0。
  5. 思考题: 假设你需要在一个极大的有序文件(无法完全加载到内存)中查找一个值,你会怎么做?

💡 自我评估

  • 全部答对:二分查找已经是你的拿手好戏了。
  • 答对3-4题:理解到位,把变体版多练几遍就完美了。
  • 答对1-2题:建议在纸上模拟二分查找的每一步,特别注意left和right的更新逻辑。
  • 全部答错:不用担心,二分查找是公认的”看着简单写起来难”。建议从最基本的版本开始,逐步过渡到变体。

前面学的数据结构——数组、链表、栈、队列、哈希表、树——每个元素之间的关系都有某种限制。接下来要登场的”图”则是最自由的数据结构:任何节点之间都可以有连接,没有任何限制。社交网络、地铁线路、互联网的拓扑——都是图。下一章,我们进入图的世界。

购买课程解锁全部内容

刷题到通关:数据结构与算法面试实战

¥29.90