【Python】二分查找

**第一章:减半的艺术——二分查找的哲学核心与算法基石 **

1.1 秩序的绝对前提:为何二分查找离不开“有序”

二分查找的惊人效率,并非凭空而来。它建立在一个简单、牢固、但绝不容妥协的基石之上:待查找的序列必须是有序的。这个前提,是二分查找算法的“灵魂契约”,一旦违背,整个算法的逻辑将瞬间崩塌。

为了理解这一点,让我们想象一个游戏:“猜数字”。

  • 无序场景: 我从1到100之间随便想一个数字,不告诉你任何规律。你来猜。

    • 你猜50。我告诉你:“不对”。你从这个回答中得到了什么信息?几乎什么都没有。这个数字可能在[1, 49]之间,也可能在[51, 100]之间。你无法排除任何一半的可能性。你唯一能做的,就是继续一个一个地猜,或者随机乱猜。这就是顺序查找的困境。
  • 有序场景: 我还是从1到100之间想一个数字,但我告诉你:“你每猜一个数,我都会告诉你,我心中的数比你猜的‘大’还是‘小’。”

    • 这个“大”或“小”的反馈,就是秩序的体现。它为整个搜索空间赋予了方向性。
    • 你猜50。我告诉你:“小了”。
    • 瞬间,你获得了巨大的信息量!你知道了目标数字不可能在[50, 100]这个区间内。你一下子就排除了一半的可能性。你的“搜索空间”从100个数字,骤然缩减到了49个 ([1, 49])。
    • 接下来,你在新的搜索空间[1, 49]的中间再猜,比如25。我告诉你:“大了”。
    • 你再次排除了[1, 25]这个区间,搜索空间进一步缩减到[26, 49]

这个“猜数字”游戏,完美地诠释了有序性的价值。有序性提供了一种可预测的单调关系。正是因为我们知道array[i]一定小于或等于array[i+1],我们才能在将目标值target与中间值array[mid]比较后,做出一个确定性的、能排除一半错误答案的决策:

  • 如果 target < array[mid],那么target不可能存在于mid的右侧(包括mid本身)。
  • 如果 target > array[mid],那么target不可能存在于mid的左侧(包括mid本身)。
  • 如果 target == array[mid],我们就找到了目标。

没有有序性,就没有这种决策能力,二分查找的“减半”魔法便无从施展。因此,在应用二分查找之前,如果数据是无序的,我们必须先对其进行排序。这通常意味着一笔O(n log n)的初始开销。这笔开销在“一次排序,多次查找”的场景中,是极其划算的。

1.2 算法的微观之舞:left, right, mid 与搜索空间

二分查找的执行过程,可以看作是三个指针 leftrightmid 在一个有序数组上进行的优雅舞蹈。这三个指针共同定义和维护着一个核心概念——搜索空间(Search Space)

  • left 指针: 搜索空间左边界的索引。
  • right 指针: 搜索空间右边界的索引。
  • 搜索空间: 我们当前认为目标值可能存在的闭区间 [left, right]。算法的每一次迭代,目标都是无情地、确定地收缩这个区间。
  • mid 指针: 搜索空间的中点,是我们每一轮进行“试探”的“棋子”。它的计算方式是 mid = left + (right - left) // 2

舞蹈的流程(以最基础的模板为例):

  1. 初始化:

    • left 指向数组的第一个元素,left = 0
    • right 指向数组的最后一个元素,right = len(array) - 1
    • 此时,整个数组 [0, len-1] 就是我们的初始搜索空间。
  2. 循环条件:

    • 只要搜索空间还存在(即 left <= right),舞蹈就继续。如果left > right,说明搜索空间已经为空,目标不存在。
  3. 每一次迭代 (一小步舞步):
    a. 计算中点: mid = left + (right - left) // 2。我们找到了试探点。
    b. 比较: 将目标值 targetarray[mid] 进行比较。这是舞蹈的核心动作。
    c. 收缩空间(变奏): 根据比较结果,移动leftright指针,以抛弃一半无效的搜索空间。
    * 如果 target == array[mid]: 找到了!舞蹈结束,返回mid
    * 如果 target < array[mid]: 目标值在mid的左侧。我们知道从midright的所有元素(包括mid本身)都是“错误答案”。因此,我们必须将右边界收缩到mid的左边一位。即 right = mid - 1。新的搜索空间变为 [left, mid - 1]
    * 如果 target > array[mid]: 目标值在mid的右侧。我们知道从leftmid的所有元素(包括mid本身)都是“错误答案”。因此,我们必须将左边界收缩到mid的右边一位。即 left = mid + 1。新的搜索空间变为 [mid + 1, right]

  4. 循环终止:

    • 如果循环是因为找到目标而结束,则成功。
    • 如果循环是因为 left > right 而结束,说明搜索空间被压缩到空,依然没有找到目标。这意味着目标值在数组中不存在。

可视化追踪:
让我们追踪在 A = [2, 5, 7, 8, 11, 12] 中查找 target = 11 的过程。

轮次 left right 搜索空间 [left, right] mid 计算 array[mid] 比较结果 动作
1 0 5 [0, 5] 0+(5-0)//2 = 2 7 11 > 7 left = 2 + 1 = 3
2 3 5 [3, 5] 3+(5-3)//2 = 4 11 11 == 11 找到!返回 mid=4

这个过程清晰地展示了搜索空间是如何在log n次迭代内迅速收敛到目标的。

1.3 mid的计算:一个经典的“溢出”幽灵

mid的计算看似简单,但mid = (left + right) // 2这个我们直觉上最先想到的公式,在某些编程语言中(如Java, C++, C#)隐藏着一个著名的陷阱:整数溢出(Integer Overflow)

  • 问题所在: 在这些语言中,整数类型(如int)有一个固定的最大值(例如 2^31 - 1)。如果leftright都是非常大的正整数,它们的和 left + right 可能会超出这个最大值,导致溢出。溢出后的结果会变成一个负数或一个意想不到的小正数,使得后续的除法和整个算法逻辑完全错误。

  • Python的豁免: 在Python中,我们是“幸运”的。Python的int类型支持任意精度,它会自动分配足够的内存来存储任何大小的整数,所以 left + right 永远不会溢出。因此,在纯Python环境中,mid = (left + right) // 2安全的。

  • 为何我们仍需关注?:

    1. 算法的普适性: 理解这个问题,能让你在任何语言中都能写出健壮的二分查找。
    2. 面试考察: 这是一个经典的、考察候选人对细节和边界情况关注度的面试问题。
    3. 工程严谨性: 采用更安全的形式,是一种良好的编程习惯,体现了代码的专业性和防御性。

更安全的mid计算公式

mid = left + (right - left) // 2

为什么这个公式是安全的?

  1. right - left:由于right >= left,这个差值永远是非负的,并且其大小不会超过原始right的值,所以它永远不会溢出。
  2. (right - left) // 2:计算出leftright之间距离的一半。
  3. left + ...:将这个“一半的距离”加到左边界left上,得到的就是中点。这个加法的结果,其值域被限制在[left, right]之间,自然也不会溢出。

这两个公式在数学上是等价的:
left + (right - left) / 2 = (2*left + right - left) / 2 = (left + right) / 2

从现在开始,在我们的所有实现中,都将统一使用 mid = left + (right - left) // 2 这种更健壮、更专业的写法。

1.4 基础实现:迭代与递归的双重奏

我们可以用两种主要的方式来实现二分查找:迭代(使用循环)和递归。

1. 迭代版本 (Iterative Version)
迭代版本使用while循环来控制搜索过程,它通常是更受推荐的实现方式。

  • 优点:
    • 空间效率高: 空间复杂度是O(1),因为它只使用了固定数量的额外变量(left, right, mid)。
    • 无堆栈溢出风险: 对于极大的数组,递归可能会因为调用栈过深而导致堆栈溢出(Stack Overflow),迭代则没有这个问题。
    • 逻辑更直观: 对于很多人来说,循环的逻辑比递归的函数调用更容易追踪和理解。
def binary_search_iterative(arr, target):
    """
    使用迭代方式实现的基础二分查找。
    在一个有序数组中查找一个确切的值。

    参数:
        arr (list): 一个已升序排列的列表。
        target: 我们要查找的目标值。

    返回:
        如果找到target,返回其索引;否则,返回-1。
    """
    if not arr: # 检查数组是否为空
        return -1 # 如果为空,直接返回-1

    left, right = 0, len(arr) - 1 # 初始化左右边界,构成闭区间 [left, right]

    # 循环条件:只要搜索空间不为空 (left <= right)
    while left <= right:
        # 使用健壮的方式计算中点,防止在其他语言中溢出
        mid = left + (right - left) // 2

        # --- 核心比较逻辑 ---
        if arr[mid] == target: # 情况一:找到了目标
            return mid # 直接返回中点索引
        elif target < arr[mid]: # 情况二:目标在左半部分
            # 目标值不可能在 mid 或其右侧,所以我们将右边界移动到 mid 的左边一位
            right = mid - 1
        else: # 情况三:目标在右半部分 (target > arr[mid])
            # 目标值不可能在 mid 或其左侧,所以我们将左边界移动到 mid 的右边一位
            left = mid + 1
            
    # 如果循环结束仍未找到,说明目标值不存在于数组中
    return -1

# --- 迭代版本测试 ---
test_array = [2, 3, 4, 10, 40, 55, 61, 78, 99]
target_found = 10
target_not_found = 11

print("--- 迭代二分查找测试 ---")
index1 = binary_search_iterative(test_array, target_found)
print(f"查找 {
     target_found}: {
     '找到,索引为 ' + str(index1) if index1 != -1 else '未找到'}") # 预期: 找到,索引为 3

index2 = binary_search_iterative(test_array, target_not_found)
print(f"查找 {
     target_not_found}: {
     '找到,索引为 ' + str(index2) if index2 != -1 else '未找到'}") # 预期: 未找到

index3 = binary_search_iterative(test_array, 2) # 测试边界:第一个元素
print(f"查找 {
     2}: {
     '找到,索引为 ' + str(index3) if index3 != -1 else '未找到'}") # 预期: 找到,索引为 0

index4 = binary_search_iterative(test_array, 99) # 测试边界:最后一个元素
print(f"查找 {
     99}: {
     '找到,索引为 ' + str(index4) if index4 != -1 else '未找到'}") # 预期: 找到,索引为 8

2. 递归版本 (Recursive Version)
递归版本将查找过程定义为一个函数,该函数在自身内部调用自己,但处理的是一个更小的子数组(通过传递新的leftright索引)。

  • 优点:
    • 代码简洁: 对于某些人来说,递归的定义——“在一个子问题上解决同样的问题”——在逻辑上可能更清晰、更贴近算法的数学定义。
  • 缺点:
    • 空间开销: 每次函数调用都会在调用栈上创建一个新的栈帧,空间复杂度是O(log n),用于存储递归调用的上下文。
    • 性能略低: 函数调用的开销通常比循环迭代要高。
    • 堆栈溢出风险: 如前所述,在处理大规模数据时有风险。
def binary_search_recursive_helper(arr, target, left, right):
    """
    二分查找的递归辅助函数,真正执行递归逻辑。

    参数:
        arr (list): 原始数组。
        target: 目标值。
        left (int): 当前搜索空间的左边界。
        right (int): 当前搜索空间的右边界。

    返回:
        索引或-1。
    """
    # 递归的基线条件 (Base Case): 搜索空间为空
    if left > right:
        return -1 # 找不到,结束递归

    # 计算中点
    mid = left + (right - left) // 2

    # --- 核心比较与递归调用 ---
    if arr[mid] == target: # 找到了
        return mid
    elif target < arr[mid]: # 目标在左半部分
        # 在新的、更小的搜索空间 [left, mid - 1] 上,再次调用自己
        return binary_search_recursive_helper(arr, target, left, mid - 1)
    else: # 目标在右半部分
        # 在新的、更小的搜索空间 [mid + 1, right] 上,再次调用自己
        return binary_search_recursive_helper(arr, target, mid + 1, right)

def binary_search_recursive(arr, target):
    """
    二分查找的递归版本入口函数。
    它负责初始化并调用递归辅助函数。
    """
    if not arr:
        return -1
    # 启动递归,初始搜索空间是整个数组
    return binary_search_recursive_helper(arr, target, 0, len(arr) - 1)


# --- 递归版本测试 ---
print("\n--- 递归二分查找测试 ---")
index5 = binary_search_recursive(test_array, target_found)
print(f"查找 {
     target_found}: {
     '找到,索引为 ' + str(index5) if index5 != -1 else '未找到'}") # 预期: 找到,索引为 3

index6 = binary_search_recursive(test_array, target_not_found)
print(f"查找 {
     target_not_found}: {
     '找到,索引为 ' + str(index6) if index6 != -1 else '未找到'}") # 预期: 未找到

虽然递归版本在代码结构上有一种独特的美感,但在绝大多数的工程实践和算法竞赛中,迭代版本因其空间效率和稳健性而成为首选。本章后续引入的模板,也将主要基于迭代版本进行构建。

1.5 二分查找的三大模板:应对万变的“万能钥匙”

基础的二分查找只能解决“找到或找不到某个确切值”的问题。然而,真实世界的二分查找问题,充满了各种各样的变体:

  • 找到第一个等于target的元素。
  • 找到最后一个等于target的元素。
  • 找到第一个大于target的元素。
  • 找到最后一个小于target的元素。
  • 在一个单调递减的函数上寻找一个特定的输入值。

为了系统性地解决这些问题,社区总结出了三种核心的二分查找模板。它们之间的细微差别,主要体现在循环条件边界更新上,这些差别赋予了它们解决不同问题的能力。掌握这三个模板,就像拥有了三把“万能钥匙”,足以打开绝大多数二分查找问题的大门。

模板一:while left <= right (基础查找模板)

  • 循环条件: while left <= right
  • 搜索空间: 闭区间 [left, right]
  • 收缩逻辑: right = mid - 1left = mid + 1。在找到mid不等于target时,mid本身也被排除。
  • 特点: 这是我们已经实现的、最基础的模板。它的搜索空间包含leftright。当left == right时,循环还会再执行一次,检查最后一个元素。循环结束时,left会恰好比right大1 (left = right + 1)。
  • 适用场景: 查找一个确切的值。因为它试图在循环内部找到目标并提前返回,所以逻辑最简单直接。

模板二:while left < right (左边界查找模板)

  • 循环条件: while left < right
  • 搜索空间: 左闭右开区间 [left, right)(这是一种理解方式,另一种是认为最终答案在[left, right]中,循环在left==right时终止)。
  • 收缩逻辑: 通常是right = midleft = mid + 1。注意,当arr[mid]可能是答案时,我们不能直接排除它,所以是right = mid,而不是mid - 1
  • 特点: 循环在leftright相遇时 (left == right) 终止。这意味着循环结束后,left(或right)指向的就是那个“潜在的”答案。这个模板的设计,天然地适合寻找满足某个条件的最左侧边界
  • 适用场景: 寻找第一个等于target的元素,或寻找第一个大于等于target的元素(即bisect_left)。

模板三:while left + 1 < right (鲁棒邻居模板)

  • 循环条件: while left + 1 < right
  • 搜索空间: 开区间 (left, right)
  • 收缩逻辑: right = midleft = mid
  • 特点: 这是最鲁棒、最不容易写出死循环的模板。它保证了循环结束时,leftright指针是相邻的。搜索空间最终被压缩到只剩下leftright两个元素。因此,它把最终的决策判断,移出了循环,在循环结束后,再对arr[left]arr[right]进行检查。
  • 适用场景: 适用于所有场景,特别是当边界更新逻辑比较复杂,容易在left < right模板中造成死循环时。它用循环后的几次额外判断,换来了循环体内逻辑的绝对安全。

第二章:边界的艺术——在重复元素中寻找第一个与最后一个 (The Art of Boundaries: Finding the First and Last Occurrence in Arrays with Duplicates)

标准二分查找的魅力在于其O(log n)的效率,但其局限性也同样明显。当面对一个像 [5, 7, 7, 8, 8, 8, 10] 这样的数组时,如果我们查找目标8,标准算法可能会返回索引345中的任何一个,其结果是“正确但不够精确”的。在许多现实场景中,我们需要的信息远不止于此:

  • 这个商品第一次上架销售是哪一天?(寻找第一个时间戳)
  • 统计某个错误码在日志文件中出现的次数。(需要找到第一次出现和最后一次出现的索引,相减即可)
  • 在一个版本控制系统中,找到引入某个bug的第一次提交。

这些问题,都指向了一个共同的需求:在重复数据中,精确地定位一个值的起始边界终止边界

2.1 模板二的精髓:向左看齐,寻找“下界” (The Essence of Template II: Aligning Left, Searching for the “Lower Bound”)

为了找到一个值的“最左侧”边界,我们需要一种能够不断向左“挤压”搜索空间的查找模板。这正是模板二 (while left < right) 的用武之地。

模板二 (while left < right) 的逻辑深潜

  • 循环条件: while left < right。这个条件意味着,当leftright指向同一个位置时,循环就终止了。搜索过程在它们“相遇”的那一刻结束。
  • 搜索空间: 可以理解为左闭右开区间[left, right),或者理解为答案最终落在leftright指向的那个位置。
  • 核心更新规则:
    • right = mid: 这是模板二的灵魂。当我们检查arr[mid],发现它有可能是我们要找的那个最左侧的答案时(例如,arr[mid] >= target),我们不能mid排除掉。我们只能肯定地说,答案在mid或者mid的左侧。因此,我们将右边界收缩到mid,新的搜索空间是 [left, mid]
    • left = mid + 1: 当我们检查arr[mid],发现它绝不可能是我们要找的答案时(例如,arr[mid] < target),我们可以放心地将mid以及mid左侧的所有元素全部排除。因此,我们将左边界移动到mid的右边一位,新的搜索空间是 [mid + 1, right]
  • 循环终止: 循环结束后,leftright会指向同一个位置。这个位置,就是我们经过层层筛选后剩下的“唯一候选人”。我们只需在循环外,对这个候选人进行最后的验证即可。

实战一:寻找第一个等于 target 的元素

这个问题等价于:寻找一个最小的索引i,使得arr[i] == target

逻辑分析:
我们要找的是一个“分界点”。这个点的左边,所有元素都 < target;从这个点开始(包括这个点),元素都 >= target。所以,我们的问题转化为了:寻找第一个大于或等于target的元素

应用模板二:

  • check(mid) 条件: arr[mid] >= target
  • 如果 arr[mid] >= target: mid可能是答案,或者答案在mid左边。收缩右边界:right = mid
  • 如果 arr[mid] < target: mid太小了,答案一定在mid右边。收缩左边界:left = mid + 1

代码实现:find_first_occurrence

def find_first_occurrence(arr, target):
    """
    使用模板二,在一个可能包含重复元素的有序数组中,查找target第一次出现的位置。

    参数:
        arr (list): 一个已升序排列的列表。
        target: 我们要查找的目标值。

    返回:
        如果找到target,返回其第一次出现的索引;否则,返回-1。
    """
    if not arr: # 检查数组是否为空
        return -1

    left, right = 0, len(arr) - 1 # 初始化左右边界

    # 使用模板二的循环条件: left < right
    while left < right:
        mid = left + (right - left) // 2 # 计算中点

        if arr[mid] >= target:
            # 如果 mid 的值大于或等于目标值,
            # 说明第一个出现的位置可能就是 mid,或者在 mid 的左边。
            # 所以我们收缩右边界到 mid,继续在 [left, mid] 区间查找。
            right = mid
        else: # arr[mid] < target
            # 如果 mid 的值小于目标值,
            # 说明第一个出现的位置肯定在 mid 的右边。
            # 所以我们收缩左边界到 mid + 1,继续在 [mid + 1, right] 区间查找。
            left = mid + 1
            
    # 循环结束后,left 和 right 相遇,指向了唯一的“候选位置”。
    # 我们需要验证这个候选位置上的值是否真的是 target。
    if arr[left] == target:
        return left # 验证成功,返回该索引
    else:
        return -1 # 候选位置上的值不是target,说明target不存在

# --- 寻找第一个出现位置的测试 ---
test_array_duplicates = [1, 2, 3, 3, 3, 5, 5, 8, 8, 8, 8, 10]
target1 = 3
target2 = 8
target3 = 5
target4 = 11 # 一个不存在的值

print("--- 模板二:寻找第一个出现位置 ---")
idx1 = find_first_occurrence(test_array_duplicates, target1)
print(f"查找第一个 {
     target1}: {
     '索引 ' + str(idx1) if idx1 != -1 else '未找到'}") # 预期: 索引 2

idx2 = find_first_occurrence(test_array_duplicates, target2)
print(f"查找第一个 {
     target2}: {
     '索引 ' + str(idx2) if idx2 != -1 else '未找到'}") # 预期: 索引 7

idx3 = find_first_occurrence(test_array_duplicates, target3)
print(f"查找第一个 {
     target3}: {
     '索引 ' + str(idx3) if idx3 != -1 else '未找到'}") # 预期: 索引 5

idx4 = find_first_occurrence(test_array_duplicates, target4)
print(f"查找第一个 {
     target4}: {
     '索引 ' + str(idx4) if idx4 != -1 else '未找到'}") # 预期: 未找到

实战二:寻找第一个大于等于 target 的元素 (Lower Bound)

这个问题是上一个问题的泛化,也是Python bisect_left的底层实现逻辑。

逻辑分析:
这与“寻找第一个等于target的元素”的搜索逻辑是完全一样的。我们都是在寻找一个“分界点”,这个点是第一个满足 arr[i] >= target 的位置。唯一的区别在于最后的验证步骤。

代码实现:find_lower_bound

def find_lower_bound(arr, target):
    """
    使用模板二,寻找第一个大于或等于target的元素的位置。
    这等同于 bisect_left 的功能。

    参数:
        arr (list): 一个已升序排列的列表。
        target: 目标值。

    返回:
        第一个大于或等于target的元素的索引。如果所有元素都小于target,则返回 len(arr)。
    """
    if not arr: # 空数组情况
        return 0

    left, right = 0, len(arr) # 注意:right 初始化为 len(arr)
    
    # 这里的搜索空间是 [left, right),因为插入点可能在数组末尾
    while left < right:
        mid = left + (right - left) // 2

        if arr[mid] >= target:
            # mid 的值大于或等于目标,它可能是下界,或者下界在它左边
            # 继续在 [left, mid) 中查找
            right = mid
        else: # arr[mid] < target
            # mid 的值小于目标,下界肯定在它右边
            # 继续在 [mid + 1, right) 中查找
            left = mid + 1
            
    # 循环结束后,left (或 right) 就是第一个大于或等于 target 的位置
    return left

# --- 寻找下界的测试 ---
test_array_lb = [1, 3, 5, 7, 9]
print("\n--- 模板二:寻找下界 (Lower Bound) ---")
# 寻找存在的值
print(f"在 {
     test_array_lb} 中寻找 >= 5 的下界: {
     find_lower_bound(test_array_lb, 5)}") # 预期: 2
# 寻找不存在但在范围内的值
print(f"在 {
     test_array_lb} 中寻找 >= 4 的下界: {
     find_lower_bound(test_array_lb, 4)}") # 预期: 2
# 寻找小于所有值的值
print(f"在 {
     test_array_lb} 中寻找 >= 0 的下界: {
     find_lower_bound(test_array_lb, 0)}") # 预期: 0
# 寻找大于所有值的值
print(f"在 {
     test_array_lb} 中寻找 >= 10 的下界: {
     find_lower_bound(test_array_lb, 10)}")# 预期: 5 (即 len(arr))

通过这两个实战,我们可以看到模板二在处理“寻找左边界”或“下界”这类问题上的优雅与高效。它的核心在于right = mid这一不对称的收缩策略,它像一个只向左挤压的活塞,不断地将搜索空间推向问题的最终解。

2.2 模板的变奏:向右看齐,寻找“上界” (A Variation on the Template: Aligning Right, Searching for the “Upper Bound”)

现在,我们面临一个镜像问题:如何找到target最后一次出现的位置?

方法一:利用已有的“下界”查找 (间接法)
这是一个非常聪明的思路:

  1. 我们不直接找target的最后一个位置。
  2. 我们去找第一个大于target的元素的位置。假设这个位置是idx
  3. 那么,idx - 1这个位置,就是target最后一次出现的位置。

逻辑分析:
寻找“第一个大于target的元素”的位置,和寻找“第一个大于等于target的元素”非常相似。我们只需要微调一下check(mid)的条件。

  • check(mid) 条件: arr[mid] > target
  • 如果 arr[mid] > target: mid可能是答案,或者答案在mid左边。收缩右边界:right = mid
  • 如果 arr[mid] <= target: mid太小或等于目标,答案一定在mid右边。收缩左边界:left = mid + 1

代码实现:find_upper_boundfind_last_occurrence

def find_upper_bound(arr, target):
    """
    使用模板二的变体,寻找第一个严格大于target的元素的位置。
    这等同于 bisect_right 的功能。

    参数:
        arr (list): 一个已升序排列的列表。
        target: 目标值。

    返回:
        第一个严格大于target的元素的索引。如果所有元素都小于等于target,则返回 len(arr)。
    """
    if not arr:
        return 0

    left, right = 0, len(arr) # 搜索空间 [left, right)

    while left < right:
        mid = left + (right - left) // 2

        if arr[mid] > target:
            # 如果 mid 的值严格大于目标,它可能是上界,或上界在它左边。
            # 继续在 [left, mid) 中查找。
            right = mid
        else: # arr[mid] <= target
            # 如果 mid 的值小于或等于目标,上界肯定在它右边。
            # 继续在 [mid + 1, right) 中查找。
            left = mid + 1
            
    # 循环结束后,left (或 right) 就是第一个严格大于 target 的位置
    return left

def find_last_occurrence(arr, target):
    """
    在一个可能包含重复元素的有序数组中,查找target最后一次出现的位置。

    参数:
        arr (list): 一个已升序排列的列表。
        target: 我们要查找的目标值。

    返回:
        如果找到target,返回其最后一次出现的索引;否则,返回-1。
    """
    if not arr:
        return -1
    
    # 步骤1: 找到第一个严格大于target的元素的索引(即上界)
    upper_bound_index = find_upper_bound(arr, target)
    
    # 步骤2: 候选位置是上界索引减1
    candidate_index = upper_bound_index - 1
    
    # 步骤3: 验证候选位置
    # 必须检查候选索引是否有效 (>=0) 并且该位置的值是否确实是target
    if candidate_index >= 0 and arr[candidate_index] == target:
        return candidate_index
    else:
        return -1

# --- 寻找最后一个出现位置的测试 ---
print("\n--- 模板二变奏:寻找最后一个出现位置 ---")
idx5 = find_last_occurrence(test_array_duplicates, target1) # target1 = 3
print(f"查找最后一个 {
     target1}: {
     '索引 ' + str(idx5) if idx5 != -1 else '未找到'}") # 预期: 索引 4

idx6 = find_last_occurrence(test_array_duplicates, target2) # target2 = 8
print(f"查找最后一个 {
     target2}: {
     '索引 ' + str(idx6) if idx6 != -1 else '未找到'}") # 预期: 索引 10

idx7 = find_last_occurrence(test_array_duplicates, target3) # target3 = 5
print(f"查找最后一个 {
     target3}: {
     '索引 ' + str(idx7) if idx7 != -1 else '未找到'}") # 预期: 索引 6

idx8 = find_last_occurrence(test_array_duplicates, target4) # target4 = 11
print(f"查找最后一个 {
     target4}: {
     '索引 ' + str(idx8) if idx8 != -1 else '未找到'}") # 预期: 未找到

方法二:专门为“右边界”设计的二分查找 (直接法)

我们也可以设计一个专门寻找右边界的循环,避免间接计算。其关键在于调整mid的计算,以防止在left=mid时陷入死循环。
当搜索空间只剩下两个元素[left, right]时,mid = left + (right - left) // 2会等于left。如果此时的更新规则是left = midleft指针将不再移动,导致无限循环。
为了解决这个问题,在寻找右边界时,我们计算mid时需要“向上取整”:mid = left + (right - left + 1) // 2

  • check(mid) 条件: arr[mid] <= target
  • 如果 arr[mid] <= target: mid可能是答案,或者答案在mid右边。收缩左边界:left = mid
  • 如果 arr[mid] > target: mid太大了,答案一定在mid左边。收缩右边界:right = mid - 1

这种方法虽然可行,但引入了对mid计算的修改,增加了认知负担和出错的可能性。相比之下,通过寻找“第一个大于target的数”来间接定位右边界的方法,逻辑更统一,因为它始终复用“寻找左边界”这一核心模式,更推荐掌握。

终极合体:search_range
现在我们可以将寻找第一个和最后一个位置的逻辑,封装成一个函数,一次性返回目标的起止范围。

def search_range(arr, target):
    """
    在一个包含重复元素的有序数组中,查找给定目标的起始和终止位置。

    参数:
        arr (list): 已排序的列表。
        target: 目标值。

    返回:
        一个包含两个元素的列表 [起始索引, 终止索引]。如果目标不存在,则返回 [-1, -1]。
    """
    first = find_first_occurrence(arr, target) # 调用我们之前实现的函数找第一个位置

    # 如果连第一个位置都找不到,那目标肯定不存在
    if first == -1:
        return [-1, -1]
        
    # 我们知道目标肯定存在,所以可以直接找第一个大于 target 的位置
    # 这个位置减 1 就是最后一个 target 的位置
    # 注意:我们这里复用 upper_bound 逻辑,而不是 last_occurrence,因为后者包含验证步骤
    upper_bound = find_upper_bound(arr, target)
    last = upper_bound - 1
    
    return [first, last]

# --- 查找范围测试 ---
print("\n--- 终极合体:查找目标的起止范围 ---")
range1 = search_range(test_array_duplicates, 3)
print(f"查找 {
     3} 的范围: {
     range1}") # 预期: [2, 4]

range2 = search_range(test_array_duplicates, 8)
print(f"查找 {
     8} 的范围: {
     range2}") # 预期: [7, 10]

range3 = search_range(test_array_duplicates, 6) # 不存在的值
print(f"查找 {
     6} 的范围: {
     range3}") # 预期: [-1, -1]

这个search_range函数是二分查找边界问题的一个经典应用案例,它完美地展示了如何通过组合两个边界查找,来解决一个更复杂的区间问题。

2.3 模板三的哲学:鲁棒性与“邻居”的力量 (The Philosophy of Template III: Robustness and the Power of “Neighbors”)

模板三 (while left + 1 < right) 提供了一种截然不同的思考方式,它被许多经验丰富的程序员认为是最安全、最不容易出错的模板,尤其是在处理复杂的、非典型的二分查找问题时。

模板三 (while left + 1 < right) 的逻辑深潜

  • 循环条件: while left + 1 < right。这个条件保证了leftright之间始终至少隔着一个元素。循环在leftright成为“邻居”时终止。
  • 搜索空间: 可以理解为开区间(left, right),我们总是在leftright“之间”的元素中寻找。
  • 核心更新规则: left = midright = mid。因为循环结束时leftright不会重合,mid也永远不会等于leftright,所以我们可以安全地将边界移动到mid,而不需要+1-1。这大大降低了在循环中出现“差一错误(Off-by-One Error)”的风险。
  • 循环终止: 循环结束后,leftright是相邻的。真正的答案,只可能在arr[left]arr[right]这两个“幸存者”之中。所有的决策逻辑,都被推迟到循环结束之后,使得循环体本身变得异常简洁和安全。

用模板三再次解决“寻找第一个等于target的元素”

逻辑分析:

  • 我们的目标是找到第一个>= target的位置。
  • check(mid) 条件: arr[mid] >= target
  • 如果arr[mid] >= target: mid可能是答案,或者答案在它左边。mid成为了新的右边界候选人。所以 right = mid
  • 如果arr[mid] < target: mid太小了,答案一定在它右边(但不包括mid)。mid成为了新的左边界候选人。所以 left = mid

代码实现:find_first_occurrence_template3

def find_first_occurrence_template3(arr, target):
    """
    使用模板三,以最鲁棒的方式查找target第一次出现的位置。

    参数:
        arr (list): 一个已升序排列的列表。
        target: 目标值。

    返回:
        如果找到target,返回其第一次出现的索引;否则,返回-1。
    """
    if not arr:
        return -1

    # 初始化左右边界。为了让 (left, right) 覆盖整个数组,left设为-1, right设为len(arr)
    left, right = -1, len(arr)

    # 循环条件:left 和 right 不相邻
    while left + 1 < right:
        mid = left + (right - left) // 2

        if arr[mid] >= target:
            # 如果 mid 的值大于或等于目标,答案可能在 mid 或其左侧。
            # 我们将右边界收缩到 mid,因为 mid 本身是一个潜在的答案。
            right = mid
        else: # arr[mid] < target
            # 如果 mid 的值小于目标,答案肯定在 mid 的右侧。
            # 我们将左边界移动到 mid,因为 mid 及其左侧都不可能是答案。
            left = mid

    # --- 循环后决策 ---
    # 循环结束时,right 指向的就是第一个 >= target 的位置。
    # 我们需要检查 right 是否越界,以及 arr[right] 是否真的等于 target。
    if right < len(arr) and arr[right] == target:
        return right
    else:
        return -1

# --- 模板三测试 ---
print("\n--- 模板三:鲁棒的边界查找 ---")
idx9 = find_first_occurrence_template3(test_array_duplicates, 3)
print(f"查找第一个 {
     3}: {
     '索引 ' + str(idx9) if idx9 != -1 else '未找到'}") # 预期: 索引 2

idx10 = find_first_occurrence_template3(test_array_duplicates, 8)
print(f"查找第一个 {
     8}: {
     '索引 ' + str(idx10) if idx10 != -1 else '未找到'}") # 预期: 索引 7

模板二 vs. 模板三:风格之争

  • 模板二 (left < right): 更紧凑。循环结束时直接得到答案候选人。但right = midleft = mid + 1的不对称性需要仔细思考,以避免死循环。
  • 模板三 (left + 1 < right): 更“防御性”。循环体内的left = midright = mid非常对称和安全。它将决策的复杂性从循环内部转移到了循环外部,思路更清晰,尤其适合处理更复杂的判断条件。

对于初学者,或者在面对一个全新的、不熟悉的二分查找变体时,从模板三开始思考,往往是更安全、更不容易出错的选择

2.4 Python bisect 模块:站在巨人的肩膀上 (The bisect Module: Standing on the Shoulders of Giants)

Python深知边界查找的重要性,因此在标准库中直接提供了一个用C语言编写的、经过高度优化的bisect模块,专门用于处理这类问题。学习和使用bisect模块,是在Python中进行高效边界查找的最佳实践。

bisect.bisect_left(a, x)

  • 功能: 查找在有序列表a中插入元素x后,x应该被放置的索引,以保持列表的有序性。如果x已经存在,则返回第一个x的索引。
  • 等价于: 我们手动实现的 find_lower_boundfind_first_occurrence(在找到后进行验证)。

bisect.bisect_right(a, x)bisect.bisect(a, x)

  • 功能: 与bisect_left类似,但如果x已经存在,它返回的是最后一个x之后的那个插入点索引。
  • 等价于: 我们手动实现的 find_upper_bound

使用bisect模块重写我们的边界查找函数

import bisect

def find_first_with_bisect(arr, target):
    """使用 bisect_left 查找第一个出现的位置。"""
    # 找到target的插入点
    index = bisect.bisect_left(arr, target)
    # 验证该位置是否有效且值是否正确
    if index < len(arr) and arr[index] == target:
        return index
    return -1

def find_last_with_bisect(arr, target):
    """使用 bisect_right 查找最后一个出现的位置。"""
    # 找到target右侧的插入点
    index = bisect.bisect_right(arr, target)
    # 候选位置是它左边一位
    candidate_index = index - 1
    # 验证
    if candidate_index >= 0 and arr[candidate_index] == target:
        return candidate_index
    return -1

def search_range_with_bisect(arr, target):
    """使用 bisect 模块实现 search_range。"""
    left_index = find_first_with_bisect(arr, target)
    if left_index == -1:
        return [-1, -1]
    right_index = find_last_with_bisect(arr, target)
    return [left_index, right_index]

# --- bisect 模块测试 ---
print("\n--- 使用 bisect 模块进行边界查找 ---")
range_bisect = search_range_with_bisect(test_array_duplicates, 8)
print(f"使用 bisect 查找 {
     8} 的范围: {
     range_bisect}") # 预期: [7, 10]

**第三章:思想的飞跃——答案二分法与隐式空间搜索 **

3.1 答案二分法的核心逻辑:从“求解”到“判定” (The Core Logic of Answer Binary Search: From “Solving” to “Checking”)

许多算法问题,本质上是求解一个满足某些条件的最优值(最大值、最小值、第一个满足条件的值等)。

  • 求一个数的平方根。
  • 在满足载重限制下,船能运输的最小运力是多少?
  • D天内吃完所有香蕉,最慢的吃香蕉速度是多少?

这些问题的共同点是:

  1. 我们要求解一个单一的答案
  2. 这个答案存在于一个可确定范围的解空间内。例如,一个正数的平方根,一定在[0, number]之间。
  3. 我们可以定义一个check(x)函数,对于任何一个猜测的答案x,这个函数可以高效地验证x是否满足题目的约束条件,或者判断出x相对于真实答案是“偏大”还是“偏小”。这个check函数的结果,必须具有单调性

单调性是施展答案二分法的“灵魂契约”。这意味着,如果一个猜测的答案x是可行的,那么所有比x“更好”或“更宽松”的答案(例如,对于求最小值问题,所有比x大的值)也都是可行的。反之,如果x不可行,那么所有比x“更差”或“更严格”的答案也都不可行。这种单调性,将整个解空间清晰地划分为了两部分:一个“可行解”区间和一个“不可行解”区间。

答案二分法的通用模板

  1. 确定解空间: 找到答案可能存在的范围 [left, right]。这个范围通常由问题的约束条件决定。
  2. 定义check(x)函数: 实现一个判定函数。输入是一个猜测的答案x,返回True(表示x可行或偏小)或False(表示x不可行或偏大)。
  3. 套用二分查找模板: 在[left, right]这个答案的值域上,执行二分查找。
    • mid = left + (right - left) // 2 (或根据问题调整)
    • 调用 check(mid)
    • 根据check(mid)的返回结果,收缩leftright,不断逼近“可行”与“不可行”的那个临界点。这个临界点,就是我们要求的最终答案。

通过这种方式,我们将一个复杂的“求解”问题,转化成了一个(通常)更简单的、可以反复调用的“判定”问题。只要check函数能在多项式时间内完成,我们就能在O(log(Range) * CheckTime)的复杂度内找到最终解。

3.2 实战演练一:求解平方根 (x 的平方根)

这是一个经典的数值计算问题,也是答案二分法最直观的应用。问题:给定一个非负整数x,计算并返回x的算术平方根的整数部分。

分析

  1. 求解目标: 我们要求解一个整数ans,使得 ans * ans <= x,并且(ans+1) * (ans+1) > x
  2. 确定解空间: 对于任何非负整数x,它的平方根的整数部分,一定不会超过x本身(除了0和1,其他都小于x)。因此,答案存在的范围是 [0, x]
  3. 定义check(mid)函数:
    • 我们的目标是找到满足ans*ans <= x的那个最大的ans
    • 我们可以这样定义check函数:对于一个猜测的答案mid,检查 mid * midx 的关系。
    • 如果mid * mid <= x: 这说明mid是一个可行的解,但可能不是最大的。真正的答案可能就是mid,或者在mid的右侧。
    • 如果mid * mid > x: 这说明mid太大了,是一个不可行的解。真正的答案一定在mid的左侧。
  4. 套用模板: 这个check函数的行为,完美地符合“寻找右边界”的模式。我们想找到最后一个满足 mid*mid <= xmid

代码实现:my_sqrt

def my_sqrt(x: int) -> int:
    """
    使用答案二分法,计算一个非负整数x的平方根的整数部分。

    参数:
        x (int): 一个非负整数。

    返回:
        x的算术平方根的整数部分。
    """
    if x < 0: # 处理无效输入
        raise ValueError("输入必须为非负整数")
    i
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

宅男很神经

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值