【Day32】704. 二分查找 34. 在排序数组中查找元素的第一个和最后一个位置

文章目录

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]」来定义搜索范围

  1. 初始化指针:left = 0(数组起始下标),right = len(nums)-1(数组末尾下标);
  2. 循环搜索 :只要 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;
  3. 循环结束未找到:返回 -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)复杂度,因此不能遍历数组,必须用二分:

  1. 找左边界:找到第一个等于target的下标(即使有重复,也要最左边的);
  2. 找右边界:找到最后一个等于target的下标(即使有重复,也要最右边的);
  3. 若左边界不存在(返回-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,-1],避免数组越界;
  2. 溢出优化 :mid计算用left + (right-left)/2,体现细节严谨性;
  3. 逻辑清晰:拆分「找左边界」和「找右边界」两个函数,代码可读性高。

总结

  1. 这道题的核心是「两次二分查找」,分别找左/右边界,区别仅在于「找到目标值后是否继续收缩边界」;
  2. 找左边界:找到target后,right=mid-1(往左找);
  3. 找右边界:找到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 下标;

核心是「找到后不终止,继续向目标方向收缩边界」,这也是二分找左右边界和普通二分的核心区别。


相关推荐
骑驴看星星a2 小时前
Golang学习之time包与net/http 包
学习·http·golang
lars_lhuan2 小时前
Go 方法
开发语言·后端·golang
灰色小旋风2 小时前
力扣 12 整数转罗马数字 C++
开发语言·c++·leetcode
8Qi82 小时前
环形链表刷题笔记(LeetCode热题100--141、142)
c语言·数据结构·c++·算法·leetcode·链表
滴滴答滴答答2 小时前
机考刷题之 13 LeetCode 1004 最大连续1的个数 III
java·算法·leetcode
一叶落4382 小时前
139. 单词拆分(Word Break)
c语言·数据结构·算法·leetcode·深度优先·图论
逆境不可逃2 小时前
【从零入门23种设计模式17】行为型之中介者模式
java·leetcode·microsoft·设计模式·职场和发展·中介者模式
小二·2 小时前
Go 语言系统编程与云原生开发实战(第37篇)
java·云原生·golang
小二·3 小时前
Go 语言系统编程与云原生开发实战(第40篇 · 终章)
开发语言·云原生·golang