**第一章:减半的艺术——二分查找的哲学核心与算法基石 **
1.1 秩序的绝对前提:为何二分查找离不开“有序”
二分查找的惊人效率,并非凭空而来。它建立在一个简单、牢固、但绝不容妥协的基石之上:待查找的序列必须是有序的。这个前提,是二分查找算法的“灵魂契约”,一旦违背,整个算法的逻辑将瞬间崩塌。
为了理解这一点,让我们想象一个游戏:“猜数字”。
-
无序场景: 我从1到100之间随便想一个数字,不告诉你任何规律。你来猜。
- 你猜50。我告诉你:“不对”。你从这个回答中得到了什么信息?几乎什么都没有。这个数字可能在
[1, 49]
之间,也可能在[51, 100]
之间。你无法排除任何一半的可能性。你唯一能做的,就是继续一个一个地猜,或者随机乱猜。这就是顺序查找的困境。
- 你猜50。我告诉你:“不对”。你从这个回答中得到了什么信息?几乎什么都没有。这个数字可能在
-
有序场景: 我还是从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
与搜索空间
二分查找的执行过程,可以看作是三个指针 left
、right
和 mid
在一个有序数组上进行的优雅舞蹈。这三个指针共同定义和维护着一个核心概念——搜索空间(Search Space)。
left
指针: 搜索空间左边界的索引。right
指针: 搜索空间右边界的索引。- 搜索空间: 我们当前认为目标值可能存在的闭区间
[left, right]
。算法的每一次迭代,目标都是无情地、确定地收缩这个区间。 mid
指针: 搜索空间的中点,是我们每一轮进行“试探”的“棋子”。它的计算方式是mid = left + (right - left) // 2
。
舞蹈的流程(以最基础的模板为例):
-
初始化:
left
指向数组的第一个元素,left = 0
。right
指向数组的最后一个元素,right = len(array) - 1
。- 此时,整个数组
[0, len-1]
就是我们的初始搜索空间。
-
循环条件:
- 只要搜索空间还存在(即
left <= right
),舞蹈就继续。如果left > right
,说明搜索空间已经为空,目标不存在。
- 只要搜索空间还存在(即
-
每一次迭代 (一小步舞步):
a. 计算中点:mid = left + (right - left) // 2
。我们找到了试探点。
b. 比较: 将目标值target
与array[mid]
进行比较。这是舞蹈的核心动作。
c. 收缩空间(变奏): 根据比较结果,移动left
或right
指针,以抛弃一半无效的搜索空间。
* 如果target == array[mid]
: 找到了!舞蹈结束,返回mid
。
* 如果target < array[mid]
: 目标值在mid
的左侧。我们知道从mid
到right
的所有元素(包括mid
本身)都是“错误答案”。因此,我们必须将右边界收缩到mid
的左边一位。即right = mid - 1
。新的搜索空间变为[left, mid - 1]
。
* 如果target > array[mid]
: 目标值在mid
的右侧。我们知道从left
到mid
的所有元素(包括mid
本身)都是“错误答案”。因此,我们必须将左边界收缩到mid
的右边一位。即left = mid + 1
。新的搜索空间变为[mid + 1, right]
。 -
循环终止:
- 如果循环是因为找到目标而结束,则成功。
- 如果循环是因为
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
)。如果left
和right
都是非常大的正整数,它们的和left + right
可能会超出这个最大值,导致溢出。溢出后的结果会变成一个负数或一个意想不到的小正数,使得后续的除法和整个算法逻辑完全错误。 -
Python的豁免: 在Python中,我们是“幸运”的。Python的
int
类型支持任意精度,它会自动分配足够的内存来存储任何大小的整数,所以left + right
永远不会溢出。因此,在纯Python环境中,mid = (left + right) // 2
是安全的。 -
为何我们仍需关注?:
- 算法的普适性: 理解这个问题,能让你在任何语言中都能写出健壮的二分查找。
- 面试考察: 这是一个经典的、考察候选人对细节和边界情况关注度的面试问题。
- 工程严谨性: 采用更安全的形式,是一种良好的编程习惯,体现了代码的专业性和防御性。
更安全的mid
计算公式
mid = left + (right - left) // 2
为什么这个公式是安全的?
right - left
:由于right >= left
,这个差值永远是非负的,并且其大小不会超过原始right
的值,所以它永远不会溢出。(right - left) // 2
:计算出left
和right
之间距离的一半。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)
递归版本将查找过程定义为一个函数,该函数在自身内部调用自己,但处理的是一个更小的子数组(通过传递新的left
和right
索引)。
- 优点:
- 代码简洁: 对于某些人来说,递归的定义——“在一个子问题上解决同样的问题”——在逻辑上可能更清晰、更贴近算法的数学定义。
- 缺点:
- 空间开销: 每次函数调用都会在调用栈上创建一个新的栈帧,空间复杂度是
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 - 1
或left = mid + 1
。在找到mid
不等于target
时,mid
本身也被排除。 - 特点: 这是我们已经实现的、最基础的模板。它的搜索空间包含
left
和right
。当left == right
时,循环还会再执行一次,检查最后一个元素。循环结束时,left
会恰好比right
大1 (left = right + 1
)。 - 适用场景: 查找一个确切的值。因为它试图在循环内部找到目标并提前返回,所以逻辑最简单直接。
模板二:while left < right
(左边界查找模板)
- 循环条件:
while left < right
- 搜索空间: 左闭右开区间
[left, right)
(这是一种理解方式,另一种是认为最终答案在[left, right]
中,循环在left==right
时终止)。 - 收缩逻辑: 通常是
right = mid
或left = mid + 1
。注意,当arr[mid]
可能是答案时,我们不能直接排除它,所以是right = mid
,而不是mid - 1
。 - 特点: 循环在
left
和right
相遇时 (left == right
) 终止。这意味着循环结束后,left
(或right
)指向的就是那个“潜在的”答案。这个模板的设计,天然地适合寻找满足某个条件的最左侧边界。 - 适用场景: 寻找第一个等于
target
的元素,或寻找第一个大于等于target
的元素(即bisect_left
)。
模板三:while left + 1 < right
(鲁棒邻居模板)
- 循环条件:
while left + 1 < right
- 搜索空间: 开区间
(left, right)
。 - 收缩逻辑:
right = mid
或left = mid
。 - 特点: 这是最鲁棒、最不容易写出死循环的模板。它保证了循环结束时,
left
和right
指针是相邻的。搜索空间最终被压缩到只剩下left
和right
两个元素。因此,它把最终的决策判断,移出了循环,在循环结束后,再对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
,标准算法可能会返回索引3
、4
或5
中的任何一个,其结果是“正确但不够精确”的。在许多现实场景中,我们需要的信息远不止于此:
- 这个商品第一次上架销售是哪一天?(寻找第一个时间戳)
- 统计某个错误码在日志文件中出现的次数。(需要找到第一次出现和最后一次出现的索引,相减即可)
- 在一个版本控制系统中,找到引入某个bug的第一次提交。
这些问题,都指向了一个共同的需求:在重复数据中,精确地定位一个值的起始边界和终止边界。
2.1 模板二的精髓:向左看齐,寻找“下界” (The Essence of Template II: Aligning Left, Searching for the “Lower Bound”)
为了找到一个值的“最左侧”边界,我们需要一种能够不断向左“挤压”搜索空间的查找模板。这正是模板二 (while left < right
) 的用武之地。
模板二 (while left < right
) 的逻辑深潜
- 循环条件:
while left < right
。这个条件意味着,当left
和right
指向同一个位置时,循环就终止了。搜索过程在它们“相遇”的那一刻结束。 - 搜索空间: 可以理解为左闭右开区间
[left, right)
,或者理解为答案最终落在left
和right
指向的那个位置。 - 核心更新规则:
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]
。
- 循环终止: 循环结束后,
left
和right
会指向同一个位置。这个位置,就是我们经过层层筛选后剩下的“唯一候选人”。我们只需在循环外,对这个候选人进行最后的验证即可。
实战一:寻找第一个等于 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
最后一次出现的位置?
方法一:利用已有的“下界”查找 (间接法)
这是一个非常聪明的思路:
- 我们不直接找
target
的最后一个位置。 - 我们去找第一个大于
target
的元素的位置。假设这个位置是idx
。 - 那么,
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_bound
与 find_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 = mid
,left
指针将不再移动,导致无限循环。
为了解决这个问题,在寻找右边界时,我们计算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
。这个条件保证了left
和right
之间始终至少隔着一个元素。循环在left
和right
成为“邻居”时终止。 - 搜索空间: 可以理解为开区间
(left, right)
,我们总是在left
和right
“之间”的元素中寻找。 - 核心更新规则:
left = mid
或right = mid
。因为循环结束时left
和right
不会重合,mid
也永远不会等于left
或right
,所以我们可以安全地将边界移动到mid
,而不需要+1
或-1
。这大大降低了在循环中出现“差一错误(Off-by-One Error)”的风险。 - 循环终止: 循环结束后,
left
和right
是相邻的。真正的答案,只可能在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 = mid
和left = mid + 1
的不对称性需要仔细思考,以避免死循环。 - 模板三 (
left + 1 < right
): 更“防御性”。循环体内的left = mid
和right = 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_bound
或find_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
天内吃完所有香蕉,最慢的吃香蕉速度是多少?
这些问题的共同点是:
- 我们要求解一个单一的答案。
- 这个答案存在于一个可确定范围的解空间内。例如,一个正数的平方根,一定在
[0, number]
之间。 - 我们可以定义一个
check(x)
函数,对于任何一个猜测的答案x
,这个函数可以高效地验证x
是否满足题目的约束条件,或者判断出x
相对于真实答案是“偏大”还是“偏小”。这个check
函数的结果,必须具有单调性。
单调性是施展答案二分法的“灵魂契约”。这意味着,如果一个猜测的答案x
是可行的,那么所有比x
“更好”或“更宽松”的答案(例如,对于求最小值问题,所有比x
大的值)也都是可行的。反之,如果x
不可行,那么所有比x
“更差”或“更严格”的答案也都不可行。这种单调性,将整个解空间清晰地划分为了两部分:一个“可行解”区间和一个“不可行解”区间。
答案二分法的通用模板
- 确定解空间: 找到答案可能存在的范围
[left, right]
。这个范围通常由问题的约束条件决定。 - 定义
check(x)
函数: 实现一个判定函数。输入是一个猜测的答案x
,返回True
(表示x
可行或偏小)或False
(表示x
不可行或偏大)。 - 套用二分查找模板: 在
[left, right]
这个答案的值域上,执行二分查找。mid = left + (right - left) // 2
(或根据问题调整)- 调用
check(mid)
。 - 根据
check(mid)
的返回结果,收缩left
或right
,不断逼近“可行”与“不可行”的那个临界点。这个临界点,就是我们要求的最终答案。
通过这种方式,我们将一个复杂的“求解”问题,转化成了一个(通常)更简单的、可以反复调用的“判定”问题。只要check
函数能在多项式时间内完成,我们就能在O(log(Range) * CheckTime)
的复杂度内找到最终解。
3.2 实战演练一:求解平方根 (x 的平方根)
这是一个经典的数值计算问题,也是答案二分法最直观的应用。问题:给定一个非负整数x
,计算并返回x
的算术平方根的整数部分。
分析
- 求解目标: 我们要求解一个整数
ans
,使得ans * ans <= x
,并且(ans+1) * (ans+1) > x
。 - 确定解空间: 对于任何非负整数
x
,它的平方根的整数部分,一定不会超过x
本身(除了0和1,其他都小于x)。因此,答案存在的范围是[0, x]
。 - 定义
check(mid)
函数:- 我们的目标是找到满足
ans*ans <= x
的那个最大的ans
。 - 我们可以这样定义
check
函数:对于一个猜测的答案mid
,检查mid * mid
与x
的关系。 - 如果
mid * mid <= x
: 这说明mid
是一个可行的解,但可能不是最大的。真正的答案可能就是mid
,或者在mid
的右侧。 - 如果
mid * mid > x
: 这说明mid
太大了,是一个不可行的解。真正的答案一定在mid
的左侧。
- 我们的目标是找到满足
- 套用模板: 这个
check
函数的行为,完美地符合“寻找右边界”的模式。我们想找到最后一个满足mid*mid <= x
的mid
。
代码实现:my_sqrt
def my_sqrt(x: int) -> int:
"""
使用答案二分法,计算一个非负整数x的平方根的整数部分。
参数:
x (int): 一个非负整数。
返回:
x的算术平方根的整数部分。
"""
if x < 0: # 处理无效输入
raise ValueError("输入必须为非负整数")
i