1. 问题背景与核心挑战
在排序数组中查找特定元素的边界位置,是算法面试中的经典问题。给定一个按照升序排列的整数数组nums和一个目标值target,我们需要找到target在数组中首次出现和最后一次出现的位置(下标)。如果数组中不存在该目标值,则返回[-1, -1]。
这个问题的难点在于:
- 必须设计时间复杂度为O(log n)的算法
- 需要处理数组中存在重复元素的情况
- 要考虑数组为空或目标值不存在的边界条件
2. 暴力解法与二分查找对比
2.1 线性扫描的局限性
最直观的解法是从头到尾遍历数组,记录第一次和最后一次遇到target的位置。这种方法虽然简单,但时间复杂度是O(n),无法满足面试中对高效算法的要求。
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
int first = -1, last = -1;
for(int i = 0; i < nums.size(); i++) {
if(nums[i] == target) {
if(first == -1) first = i;
last = i;
}
}
return {first, last};
}
2.2 二分查找的优势
对于已排序的数组,二分查找可以将时间复杂度降至O(log n)。我们需要对标准二分查找进行改造,使其能够定位元素的边界位置。
3. 二分查找的边界定位实现
3.1 查找左边界
查找左边界时,我们需要找到第一个等于target的元素。关键在于当nums[mid] == target时,继续在左半部分搜索。
cpp复制int findLeft(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int res = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] >= target) {
right = mid - 1;
if(nums[mid] == target) res = mid;
} else {
left = mid + 1;
}
}
return res;
}
3.2 查找右边界
查找右边界时,我们需要找到最后一个等于target的元素。当nums[mid] == target时,继续在右半部分搜索。
cpp复制int findRight(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int res = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] <= target) {
left = mid + 1;
if(nums[mid] == target) res = mid;
} else {
right = mid - 1;
}
}
return res;
}
4. 完整解决方案与优化
4.1 组合左右边界查找
将上述两个函数组合起来,形成完整的解决方案:
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
return {findLeft(nums, target), findRight(nums, target)};
}
4.2 代码优化技巧
- 使用统一的二分查找框架,减少代码重复
- 提前终止条件:当左边界已经为-1时,可以直接返回[-1, -1]
- 边界检查:处理空数组或目标值超出数组范围的情况
优化后的实现:
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
if(nums.empty() || target < nums[0] || target > nums.back())
return {-1, -1};
int left = 0, right = nums.size() - 1;
int first = -1, last = -1;
// 查找左边界
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] >= target) {
right = mid - 1;
if(nums[mid] == target) first = mid;
} else {
left = mid + 1;
}
}
if(first == -1) return {-1, -1};
// 查找右边界
left = first; // 优化:从已知的左边界开始
right = nums.size() - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] <= target) {
left = mid + 1;
if(nums[mid] == target) last = mid;
} else {
right = mid - 1;
}
}
return {first, last};
}
5. 复杂度分析与边界条件
5.1 时间复杂度分析
- 两次二分查找,每次时间复杂度为O(log n)
- 总体时间复杂度保持为O(log n)
5.2 空间复杂度分析
- 只使用了常数级别的额外空间
- 空间复杂度为O(1)
5.3 边界条件处理
需要特别注意以下边界情况:
- 空数组:直接返回[-1, -1]
- 目标值小于数组最小值或大于最大值:提前返回[-1, -1]
- 数组中只有一个目标值:first和last相同
- 数组中所有元素都是目标值:first=0, last=n-1
- 目标值在数组中不存在:返回[-1, -1]
6. 测试用例设计
完整的测试应该包含以下情况:
cpp复制void test() {
vector<int> nums1 = {5,7,7,8,8,10};
assert(searchRange(nums1, 8) == vector<int>({3,4}));
vector<int> nums2 = {5,7,7,8,8,10};
assert(searchRange(nums2, 6) == vector<int>({-1,-1}));
vector<int> nums3 = {};
assert(searchRange(nums3, 0) == vector<int>({-1,-1}));
vector<int> nums4 = {1};
assert(searchRange(nums4, 1) == vector<int>({0,0}));
vector<int> nums5 = {2,2};
assert(searchRange(nums5, 2) == vector<int>({0,1}));
vector<int> nums6 = {1,3,3,5,5,5,8,9};
assert(searchRange(nums6, 5) == vector<int>({3,5}));
cout << "All test cases passed!" << endl;
}
7. 常见错误与调试技巧
7.1 典型错误模式
- 无限循环:由于边界条件处理不当导致
- 漏掉边界元素:在更新left或right时错误地跳过可能解
- 混淆大于和大于等于:导致无法正确处理重复元素
7.2 调试建议
- 打印中间变量:在循环中输出left、right和mid的值
- 使用小规模测试用例:更容易发现问题
- 检查边界更新逻辑:确保不会跳过可能的解
7.3 二分查找模板选择
推荐使用左闭右闭区间模板(left <= right),因为它:
- 终止条件明确:left > right时循环结束
- 边界更新直观:left = mid + 1或right = mid - 1
- 适用于大多数二分查找变种问题
8. 扩展思考与变种问题
8.1 相关变种问题
- 查找第一个大于target的元素位置
- 查找最后一个小于target的元素位置
- 在旋转排序数组中查找目标值
- 在二维矩阵中查找目标值
8.2 实际应用场景
- 数据库索引范围查询
- 日志系统中按时间戳筛选记录
- 统计分析中的分位数计算
- 游戏中的排行榜查询
8.3 算法选择考量
当遇到排序数组中的查找问题时,应该:
- 首先考虑二分查找的可能性
- 明确查找的具体要求(精确匹配、范围、边界等)
- 根据需求调整二分查找的条件判断
- 特别注意重复元素的处理
在实际编码面试中,建议先和面试官确认输入数据的特性和期望的输出格式,再选择最合适的算法实现。对于这个问题,掌握二分查找的边界处理技巧是关键,这也是面试官考察的重点之一。
