文章目录
704.二分查找
题目:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果 target 存在返回下标,否则返回 -1。
你必须编写一个具有 O(log n) 时间复杂度的算法。
示例 1:
输入 : nums = [-1,0,3,5,9,12], target = 9
输出 : 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:
输入 : nums = [-1,0,3,5,9,12], target = 2
输出 : -1
解释: 2 不存在 nums 中因此返回 -1
提示 :
你可以假设 nums 中的所有元素是不重复的。
n 将在 [1, 10000]之间。
nums 的每个元素都将在 [-9999, 9999]之间。
思路:
二分查找的前提是数组有序且无重复(题目已满足),核心是「每次把搜索范围缩小一半」,时间复杂度O(log n)。
用「左闭右闭区间 [left, right]」来定义搜索范围
- 初始化指针:left = 0(数组起始下标),right = len(nums)-1(数组末尾下标);
- 循环搜索 :只要 left ≤ right(区间有效),就执行:
- 计算中间下标:mid = left + (right - left)/2(避免 left+right 溢出);
- 比较 nums[mid] 和 target:
- 若 nums[mid] == target → 找到目标,返回 mid;
- 若 nums[mid] < target → 目标在右半区,更新 left = mid + 1;
- 若 nums[mid] > target → 目标在左半区,更新 right = mid - 1;
- 循环结束未找到:返回 -1。
-
为什么用
mid = left + (right-left)/2而不是(left+right)/2?→ 避免 left+right 超出int范围(比如left和right都是很大的数时,相加会溢出)。
left + (right - left)/2 = (2*left + right - left)/2 = (left + right)/2
用 mid = left + (right-left)/2 而不是 (left+right)/2,是为了避免整数溢出 :因为如果 left 和 right 都是接近 int 最大值的大数,两者相加会超出 int 的取值范围,导致数值溢出变成负数,计算出错误的 mid;而 left + (right-left)/2 先做减法再做加法,不会触发溢出,且数学结果和前者完全一致。
-
闭区间 [left, right] 意味着:
→ 当 nums[mid] < target 时,mid 肯定不是目标,所以 left 要跳到 mid+1;
→ 当 nums[mid] > target 时,right 要跳到 mid-1。
代码实现(Go):
go
package main
import (
"fmt"
)
// search 二分查找核心函数(闭区间 [left, right] 写法)
// 参数:
// nums: 升序无重复的整型数组
// target: 要查找的目标值
// 返回值:
// 找到则返回目标值下标,否则返回-1
func search(nums []int, target int) int {
// 1. 初始化左右指针,定义闭区间 [left, right]
left := 0
right := len(nums) - 1
// 2. 循环:只要区间有效(left <= right)就继续搜索
for left <= right {
// 计算中间下标,避免 left+right 溢出
mid := left + (right - left)/2
// 3. 比较中间值和目标值
if nums[mid] == target {
return mid // 找到目标,直接返回下标
} else if nums[mid] < target {
// 目标在右半区,缩小左边界到 mid+1(mid已排除)
left = mid + 1
} else {
// 目标在左半区,缩小右边界到 mid-1(mid已排除)
right = mid - 1
}
}
// 4. 循环结束未找到,返回-1
return -1
}
func main() {
// 示例1输入
nums1 := []int{-1, 0, 3, 5, 9, 12}
target1 := 9
// 示例2输入
nums2 := []int{-1, 0, 3, 5, 9, 12}
target2 := 2
// 调用函数并输出结果
fmt.Printf("示例1:nums=%v, target=%d → 输出:%d\n", nums1, target1, search(nums1, target1)) // 输出4
fmt.Printf("示例2:nums=%v, target=%d → 输出:%d\n", nums2, target2, search(nums2, target2)) // 输出-1
}
- 时间复杂度 :O(log n),每次循环搜索范围缩小一半,最多循环 log₂n 次(比如n=10000时,仅需约14次循环);
- 空间复杂度 :O(1),仅使用left、right、mid三个变量,无额外空间开销。
34.在排序数组中查找元素的第一个和最后一个位置
题目:
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
示例 1 :
输入 :nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
示例 2 :
输入 :nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
示例 3 :
输入 :nums = [], target = 0
输出:[-1,-1]
提示 :
0 <= nums.length <= 10^5
-10^9 <= nums[i] <= 10^9
nums 是一个非递减数组
-10^9 <= target <= 10^9
思路:
两次二分找边界
题目要求O(log n)复杂度,因此不能遍历数组,必须用二分:
- 找左边界:找到第一个等于target的下标(即使有重复,也要最左边的);
- 找右边界:找到最后一个等于target的下标(即使有重复,也要最右边的);
- 若左边界不存在(返回-1),则直接返回[-1,-1];否则返回[左边界, 右边界]。
关键:如何用二分找左/右边界?
依然用「闭区间 [left, right]」写法,仅调整边界更新规则:
1. 找左边界的规则
- 当
nums[mid] == target:不直接返回,而是缩小右边界(right = mid - 1),继续往左找更早的目标值; - 当
nums[mid] < target:左边界右移(left = mid + 1); - 当
nums[mid] > target:右边界左移(right = mid - 1); - 循环结束后,需验证
left是否越界,且nums[left] == target(避免目标值不存在)。
2. 找右边界的规则
- 当
nums[mid] == target:不直接返回,而是扩大左边界(left = mid + 1),继续往右找更晚的目标值; - 当
nums[mid] < target:左边界右移(left = mid + 1); - 当
nums[mid] > target:右边界左移(right = mid - 1); - 循环结束后,需验证
right是否越界,且nums[right] == target(避免目标值不存在)。
验证 left/right 是否越界,主要是两个目的:
避免数组下标越界报错 (比如 left 超过数组长度、right 为负数);
确认目标值真的存在于数组中 (比如 left 在范围内,但 nums [left] 不是目标值,说明目标值不存在);这一步是二分找边界的关键,不验证的话,代码会在目标值不存在时崩溃或返回错误结果。
- 边界处理:数组为空时直接返回[-1,-1],避免数组越界;
- 溢出优化 :mid计算用
left + (right-left)/2,体现细节严谨性; - 逻辑清晰:拆分「找左边界」和「找右边界」两个函数,代码可读性高。
总结
- 这道题的核心是「两次二分查找」,分别找左/右边界,区别仅在于「找到目标值后是否继续收缩边界」;
- 找左边界:找到target后,
right=mid-1(往左找); - 找右边界:找到target后,
left=mid+1(往右找);
代码实现(Go):
go
package main
import (
"fmt"
)
// searchRange 查找目标值在非递减数组中的起始和结束位置
// 核心思路:
// 1. 先找左边界(第一个等于target的下标),若左边界不存在,直接返回[-1,-1],因为非递减,右边界肯定不存在
// 2. 左边界存在则找右边界(最后一个等于target的下标),利用非递减特性,右边界必存在
func searchRange(nums []int, target int) []int {
// 第一步:找左边界(第一个等于target的元素下标)
leftIdx := findLeftBound(nums, target)
// 关键判断:左边界为-1 → 数组中无target,直接返回[-1,-1]
// 依据:非递减数组中,等于target的元素必然连续,左边界不存在则整体不存在
if leftIdx == -1 {
return []int{-1, -1}
}
// 第二步:找右边界(最后一个等于target的元素下标)
// 此时左边界存在,非递减特性保证右边界必存在,无需额外判断
rightIdx := findRightBound(nums, target) // 只有leftIdx≠-1时才会执行,而 leftIdx != -1 意味着:数组中至少有一个元素等于 target
return []int{leftIdx, rightIdx}
}
// findLeftBound 二分查找目标值的左边界(第一个等于target的下标)
// 参数:
//
// nums: 非递减排列的整数数组
// target: 要查找的目标值
//
// 返回值:
//
// 找到则返回第一个等于target的下标,否则返回-1
func findLeftBound(nums []int, target int) int {
left := 0
right := len(nums) - 1
leftBound := -1 // 左边界默认值:-1(表示不存在)
for left <= right {
// 计算中间下标:用left + (right-left)/2 而非 (left+right)/2,避免整数溢出
mid := left + (right-left)/2
if nums[mid] == target {
// 找到target:先记录当前下标(可能是左边界),继续往左收缩找更早的元素
leftBound = mid
right = mid - 1 // 核心操作:缩小右边界,向左探索更早的target
} else if nums[mid] < target {
// target在右半区:左指针右移,缩小搜索范围到[mid+1, right]
left = mid + 1
} else {
// target在左半区:右指针左移,缩小搜索范围到[left, mid-1]
right = mid - 1
}
}
return leftBound // 未找到则返回初始值-1
}
// findRightBound 二分查找目标值的右边界(最后一个等于target的下标)
// 参数:
//
// nums: 非递减排列的整数数组
// target: 要查找的目标值
//
// 返回值:
//
// 找到则返回最后一个等于target的下标,否则返回-1
func findRightBound(nums []int, target int) int {
left := 0
right := len(nums) - 1
rightBound := -1 // 右边界默认值:-1(表示不存在)
// 闭区间[left, right]循环查找,直到区间无效(left > right)
for left <= right {
// 计算中间下标:避免整数溢出的标准写法
mid := left + (right-left)/2
if nums[mid] == target {
// 找到target:先记录当前下标(可能是右边界),继续往右收缩找更晚的元素
rightBound = mid
left = mid + 1 // 核心操作:扩大左边界,向右探索更晚的target(与左边界唯一区别)
} else if nums[mid] < target {
// target在右半区:左指针右移,缩小搜索范围到[mid+1, right]
left = mid + 1
} else {
// target在左半区:右指针左移,缩小搜索范围到[left, mid-1]
right = mid - 1
}
}
return rightBound // 未找到则返回初始值-1
}
func main() {
// 示例1:target存在且有多个重复
nums1 := []int{5, 7, 7, 8, 8, 10}
target1 := 8
// 示例2:target不存在
nums2 := []int{5, 7, 7, 8, 8, 10}
target2 := 6
// 示例3:空数组
nums3 := []int{}
target3 := 0
// 输出结果验证
fmt.Printf("示例1:nums=%v, target=%d → 输出:%v\n", nums1, target1, searchRange(nums1, target1)) // 预期[3,4]
fmt.Printf("示例2:nums=%v, target=%d → 输出:%v\n", nums2, target2, searchRange(nums2, target2)) // 预期[-1,-1]
fmt.Printf("示例3:nums=%v, target=%d → 输出:%v\n", nums3, target3, searchRange(nums3, target3)) // 预期[-1,-1]
}
// fmt.Println(searchRange(nums3, target3)) // 输出:[-1 -1]
- 时间复杂度 :O(log n)
两次二分查找,每次都是O(log n),总复杂度仍为O(log n); - 空间复杂度 :O(1)
仅使用常数个变量,无额外空间开销。
找左边界时,每次找到 target 后,不直接返回,而是将右边界收缩到 mid-1,继续向左探索更早的 target ,直到循环结束,最后记录的 leftBound 就是第一个(最左)的 target 下标;
找右边界时,每次找到 target 后,将左边界扩大到 mid+1,继续向右探索更晚的 target ,循环结束后记录的 rightBound 就是最后一个(最右)的 target 下标;
核心是「找到后不终止,继续向目标方向收缩边界」,这也是二分找左右边界和普通二分的核心区别。