Find First and Last Position of Element in Sorted Array
给定一个按非递减顺序排序的整数列表 nums,因此某个值可以重复出现,以及一个整数 target。返回一个包含两个索引的列表,索引从 0 开始:target 第一次和最后一次出现的位置。如果列表中没有 target,则返回 [-1, -1]。时间复杂度尽量达到 O(log n)。
函数
- numsinteger-array
- 有序的整数列表,其中的值可能会重复
- targetinteger
- 要查找的值
- 返回值integer-array
- target 的第一个和最后一个索引,如果不存在则为 [-1, -1]
约束条件
1 ≤ nums.length ≤ 104-104 ≤ nums[i], target ≤ 104nums按非递减顺序排列。
示例
- 输入
- nums = [3, 5, 5, 5, 8, 9, 9, 12]target = 5
- 输出
- [1, 3]
- 解释
- 3 个 5 分别位于索引 1、2 和 3,因此这一段从索引 1 开始,到索引 3 结束。索引 0 上是 3,索引 4 上是 8,因此这一段无法延伸得更远。
- 输入
- nums = [3, 5, 5, 5, 8, 9, 9, 12]target = 7
- 输出
- [-1, -1]
- 解释
- 7 会位于索引 3 处的 5 和索引 4 处的 8 之间,但它们都不是 7。列表中没有副本,所以答案是
[-1, -1]。
进阶
如果同一个列表要针对不同目标进行数千次查询,能不能提前遍历一次列表,让每次查询都比 O(log n) 更快?
提示
逐个打开提示,每个都会多透露一点。
由于
nums已排序,所有target都会集中在一起。你需要找到这一组的两个边界。当连续区间很长时,找到一个副本再向外遍历,可能需要
O(n)时间。你可以改为让二分查找在找到匹配项后继续向左查找:这样它就会停在第一个值大于或等于目标数的索引处。编写一个辅助函数,返回第一个满足
nums[i] ≥ value的索引。用target调用它,获取这段连续元素的起始位置,检查该位置的值是否为target,然后用target + 1调用它:这段连续元素在其前一个索引处结束。
题解
普通的二分查找会在遇到的任意一个 target 副本处停止,而这个副本可能位于连续区间中的任意位置。改为查找下界:值大于或等于给定数字的第一个索引。target 和 target + 1 的下界可以在 O(log n) 时间内确定这个连续区间的两端。
扫描整个列表
思路
遍历列表一次。第一个包含 target 的索引是起始位置,最后一个是结束位置。如果 target 从未出现过,两者都保持为 -1。
这种方法适用于任何列表,无论是否有序,而这正是它的缺点:它会读取全部 n 个值。对于包含 10^4 个值的列表,需要进行 10,000 次比较,而大约 28 次就够了。
算法
- 设置
first = last = -1。 - 对于每个索引
i,如果nums[i]等于target,并且first仍为-1,就设置first = i,并设置last = i。 - 返回
[first, last]。
def searchRange(nums, target):
first = last = -1
for i, value in enumerate(nums):
if value == target:
if first == -1:
first = i # the first match fixes the start
last = i # every later match moves the end
return [first, last]找到一个副本,然后展开
思路
用二分查找找到任意一个值为 target 的索引。列表已经排序,因此所有相同的值都会连续地出现在该索引周围:只要相邻值仍是 target,就向左和向右移动。
查找的开销为 O(log n),每个副本向外移动一步,总共移动 k 步。如果列表中的大多数值都是 target,比如有 10^4 个副本,那么移动过程会读取每个值,最终又回到了线性扫描。
算法
- 使用二分查找,找到任意满足
nums[found] == target的索引found。 - 如果没有这样的索引,返回
[-1, -1]。 - 从
found开始向左移动first,直到它前面的值不等于target。 - 从
found开始向右移动last,直到它后面的值不等于target。 - 返回
[first, last]。
def searchRange(nums, target):
# An ordinary binary search finds some copy of target, if there is one.
lo, hi = 0, len(nums) - 1
found = -1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
found = mid
break
if nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
if found == -1:
return [-1, -1]
# The copies sit next to each other, so walk out to both ends of the run.
first = last = found
while first > 0 and nums[first - 1] == target:
first -= 1
while last < len(nums) - 1 and nums[last + 1] == target:
last += 1
return [first, last]两个下界二分查找
思路
换个问题来问:第一个值至少为 value 的索引是什么?这个索引就是下界,如果没有符合条件的值,则下界为 n。保留一个始终包含该索引的左闭右开区间 [lo, hi)。如果 nums[mid] < value,那么 mid 及其之前的所有值都太小。否则,mid 符合条件,但更早的索引也可能符合条件,所以将 mid 保留在区间内,并将其设为新的 hi。循环不会在找到匹配项时提前结束。
target 的下界就是它连续出现的起始位置。由于这些值都是整数,第一个大于 target 的值位于 target + 1 的下界处,而连续区间在它之前一个索引处结束。在第一个示例中,5 的下界是 1,6 的下界是 4,此处的值为 8:连续区间是 [1, 3]。无论连续区间的长度是多少,两次查找处理 10^4 个值都大约需要 28 步。
算法
- 编写
lowerBound(value):先将lo = 0、hi = n;当lo < hi时,如果nums[mid] < value,将lo = mid + 1,否则将hi = mid;返回lo。 - 将
first = lowerBound(target)。 - 如果
first == n或nums[first] != target,返回[-1, -1]。 - 将
last = lowerBound(target + 1) - 1,并返回[first, last]。
def lowerBound(nums, value):
# The first index whose number is at least value, or len(nums) if none is.
lo, hi = 0, len(nums)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < value:
lo = mid + 1 # nums[mid] and everything before it is too small
else:
hi = mid # nums[mid] qualifies, but an earlier index might too
return lo
def searchRange(nums, target):
first = lowerBound(nums, target)
if first == len(nums) or nums[first] != target:
return [-1, -1]
# The run of target ends right before the first number above target.
last = lowerBound(nums, target + 1) - 1
return [first, last]
易错点和边界情况
- 在
nums[mid] == target时立即返回。该索引可能位于这段连续重复值中的任何位置:在[3, 5, 5, 5, 8]中,第一个中间位置是索引 2,它不在两端。 - 在下界搜索中,当
nums[mid] ≥ value时写成hi = mid - 1。这会丢弃mid,而它可能就是答案。 - 读取
nums[first]前没有检查first < n。当target大于所有值时,例如示例中的 13,下界就是n,也就是末尾之后的位置。 - 忘记在 Lua 和 R 中进行位置偏移。它们的列表从 1 开始,因此两个位置都要减去 1。
常见问题3
查找第一个和最后一个位置的时间复杂度是多少?
使用两次二分查找,时间复杂度为 O(log n),空间复杂度为 O(1)。找到匹配项后,两次查找都不会停止,因此开销不取决于目标值有多少个副本:对于 10^4 个值,每次查找约需 14 步。
二分查找中的下界是什么?
某个值的下界是第一个元素大于或等于该值的索引,如果没有这样的元素,则下界是列表的长度。将该值插入这个位置,可以保持列表有序。上界是第一个元素严格大于该值的索引。
一个二分查找函数能同时找到第一个和最后一个索引吗?
可以。给搜索过程设置一个标记:匹配时记录索引,并继续向左搜索以找到第一个副本,或向右搜索以找到最后一个副本。对 target 和 target + 1 调用下界搜索也可以。两者的运行时间都是 O(log n)。
相似题目
运用相同思路的题目。做两三道,才能真正掌握一种模式。
def searchRange(nums, target):
# 在这里编写代码测试 #1
测试 #2
参数
nums = [3, 5, 5, 5, 8, 9, 9, 12] target = 5
预期输出
[1, 3]