手撕算法必备技巧(壹) —— 二分搜索

3,024 阅读3分钟

文章介绍了二分搜索最常见的几个场景的使用:寻找一个数寻找左侧边界以及寻找右侧边界。文章内容既适合完全不知道二分搜索是什么的新同学,也适合老同学复习拓展。

考察过该题目的公司有:拼多多、美团、腾讯、阿里巴巴、百度、华为等大厂。

我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。

寻找一个数

题目描述

在有序数组中搜索一个数,如果存在,返回其索引,否则返回 -1。

思路

二分查找的思路非常简单,利用了数组的有序性

每次取数组中点的元素值与目标值比大小,每次共有三种情况:

  1. 如果找到了就直接返回索引;
  1. 如果中点值比目标值小,说明中点值之前的值都比目标值小,因此目标值在右半区间;
  1. 如果中点值比目标值大,说明中点值之后的值都比目标值大,因此目标值一定在左半区间。

如果遍历完整个数组后都没找到目标值,那就说明没找到,返回 -1。 二分搜索-寻找一个数

参考代码

int binarySearch(int[] nums, int target) {
    // 搜索区间 [left, right]
    int left = 0, right = nums.length - 1;
    while (left <= right) { // !!!
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            return mid; // !!!
        } else if (nums[mid] < target) { // 搜索区间变为 [mid+1, right]
            left = mid + 1; // !!!
        } else { // 搜索区间变为 [left, mid-1]
            right = mid - 1; // !!!
        }
    }
    return -1; // !!!
}

深入分析

计算 mid 时需要防止溢出,left + ((right - left) >> 1)(left + right) / 2 的结果相同(>> 1 相当于 / 2),但是有效防止了 leftright 太大,直接相加导致溢出的情况。

  • 搜索区间: [left, right],因为 right 初始化时是 nums.length - 1,即最后一个元素的索引。

  • 停止搜索: nums[mid] == target,找到目标值即停止。如果没有找到,while 循环终止,并返回 -1。

  • 循环终止: 搜索区间为空的时候,循环终止。while (left <= right) 的循环终止条件是当 left 的值为 right + 1 时,写成区间形式就是 [right + 1, right],此时搜索区间为空。此时,while 循环终止是正确的,直接返回 -1 即可。

  • 区间移动: 在搜索区间为 [left, right] 时,若索引 mid 上的元素不是要找的 target 时,要去 [left, mid - 1][mid + 1, right] 区间上搜索,因为 mid 已经被搜索过了,应当从搜索区间中删除

  • 代码缺陷: 无法找到升序数组中存在多个目标值的左右边界索引情况。假设有升序数组 nums = [1, 2, 3, 3, 3, 3, 3],target 为 3,该算法返回的索引是 3。如果此时,我想得到 target 的左侧边界索引,即 2,或者想得到 target 的右侧边界,即 6,上述代码是无法处理的。

时间复杂度

O(log2n)O(log_2n)

对长度为 n 的数组进行二分,最坏情况就是取 2 的对数。

空间复杂度

O(1)O(1)

常数级变量,无额外辅助空间。

为了解决上述的代码缺陷,我们来学习如何使用二分搜索找到目标值的左右边界。

寻找左侧边界的二分搜索

题目描述

在一个有序数组中,存在多个大小为 target 值的元素,请找到目标值在数组中的开始位置,即左侧边界。

思路

这道题整体的思路与二分搜索寻找一个数基本是一致的,只有几处不同,且看我娓娓道来。

我们以输入数组为 [1, 3, 4, 5, 5, 5, 5, 5, 9],目标值为 5 为例进行讲解。

一开始,我们的中点值就是目标值(nums[mid] == target),我们可以直接返回索引 4 吗?当然不可以啦,我们要找的是第一个 5 出现的索引 3,所以,我们应该继续缩小搜索区间,左移右指针(right = mid - 1right 此时为 3)。 此时,新的 mid 值为 1,该位置元素值小于目标值(nums[mid] < target),需要右移 left 指针(left = mid + 1left 为 2)。

新的 mid 值为 2,该位置元素值小于目标值,继续右移 left 指针(left 为 3)。

新的 mid 值为 3,该位置元素值与目标值相等,左移 right 指针(right 为 2),跳出循环,返回 left

完整的过程如下: 二分搜索寻找左侧边界 所以,不同的地方就是,与目标值相等时,不能直接返回索引,而是收缩右边界,也就是左移右指针(right = mid - 1),最后返回左指针即可

参考代码

int searchLeftBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            right = mid - 1; // 收缩右边界
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    // 越界检查,不存在检查
    if (left >= nums.length || nums[left] != target) {
        return -1;
    }
    return left;
}

深入分析

1. 为什么能够搜索左侧边界?

关键点在于 nums[mid] == target 时的处理。

if (nums[mid] == target) {
    right = mid - 1; // 收缩右边界
}

当找到 target 时,不立即返回,而是缩小搜索区间的右边界 right,在区间 [left, mid - 1] 中继续搜索,也就是不断向左靠拢,达到锁定左侧边界的目的。

2. 为什么最终返回的是 left 而不是 right

while (left <= right) 的循环终止条件是 left == right + 1 ,因此左边界的索引值一定是在 left == right 时出现的。然而此时,循环无法停止,right 还要继续收缩,因此只能返回左边界的索引值 left

3. 为什么越界检查只检查左边界 left

我们最终返回的是左边界索引 left,因此只需校验左边界 left 最终是否合法即可,另一方面,由于 nums[mid] == target 时,right = mid - 1; 这样 right 在很多情况下都会越界(比如,左边界的索引为 0 时),校验其是否合法没有意义,还会导致返回错误。

时间复杂度

O(log2n)O(log_2n)

空间复杂度

O(1)O(1)

常数级变量,无额外辅助空间。

寻找右侧边界的二分查找

题目描述

在一个有序数组中,存在多个大小为 target 值的元素,请找到目标值在数组中的结束位置,即右侧边界。

思路

和寻找左侧边界相反,找到目标值的时候收缩左边界,也就是右移左指针。最终返回右指针索引。

两者的不同如下。

寻找左侧边界寻找右侧边界
nums[mid] == targetmid = right - 1mid = left + 1
返回值leftright
越界判断left >= nums.length || nums[left] != targetright < 0 || nums[right] != target

参考代码

int searchRightBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            left = mid + 1; // 收缩左边界
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (right < 0 || nums[right] != target) {
        return -1;
    }
    return right;
}

深入分析

1. 为什么能够搜索右侧边界?

关键点在于 nums[mid] == target 时的处理。

if (nums[mid] == target) {
    left = mid + 1; // 收缩左边界
}

当找到 target 时,不立即返回,而是增大搜索区间的左边界 left,在区间 [mid + 1, right] 中继续搜索,也就是不断向右靠拢,达到锁定右侧边界的目的。

2. 为什么最终返回的是 right 而不是 left

while (left <= right) 的循环终止条件是 left == right + 1 ,而右边界的索引值一定是在 left == right 时出现的。然而此时,循环无法停止,left 还要继续增大,因此只能返回右边界的索引值 right

3. 为什么越界检查只检查右边界 right

我们最终返回的是右边界索引 right,因此只需校验右边界 right 最终是否合法即可,另一方面,由于 nums[mid] == target 时,left = mid + 1; 这样 left 在很多情况下都会越界(比如,右边界的索引为 0 时),校验其是否合法没有意义,还会导致返回错误。

总结

首先,我们先来梳理汇总一下上面的内容。

1. 搜索区间:[left, right],即搜索区间左右皆闭合。

2. 循环条件:left <= right,因此,终止条件为 left == right + 1

3. 收缩区间: 寻找左边界,找到目标值后,收缩 right,即 right = mid - 1;寻找右边界,找到目标值后,扩大 left,即 left = mid + 1

4. 越界校验: 寻找左边界,跳出循环后,校验 left;寻找右边界,跳出循环后,校验 right

5. 返回值: 寻找左边界,返回 left;寻找右边界,返回 right

寻找一个数寻找左侧边界寻找右侧边界
搜索区间[left, right][left, right][left, right]
循环条件left <= rightleft <= rightleft <= right
循环终止条件left == right + 1left == right + 1left == right + 1
nums[mid] == target时,收缩区间return -1;right = mid - 1;left = mid + 1;
越界检验检验 left校验 right
返回值返回 mid返回 left返回 right

再次回顾之前的代码:

寻找一个数

int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1; // !!!
    while (left <= right) { // !!!
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            return mid; // !!!
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1; // !!!
}

寻找左边界

int searchLeftBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1; // !!!
    while (left <= right) { // !!!
        int mid = left + ((right - left) >> 1); 
        if (nums[mid] == target) {
            right = mid - 1; // !!!
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (left >= nums.length || nums[left] != target) { // !!!
        return -1;
    }
    return left; // !!!
}

寻找右边界

int searchRightBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1; // !!!
    while (left <= right) { // !!!
        int mid = left + ((right - left) >> 1); 
        if (nums[mid] == target) { 
            left = mid + 1; // !!!
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (right < 0 || nums[right] != target) { // !!!
        return -1;
    }
    return right; // !!!
}

写在最后,二分搜索最有价值的思想在于,通过已知信息尽可能多地收缩(折半)搜索空间,从而提高穷举效率,快速找到目标。

实战一下

二分查找(力扣704)

题目描述:

力扣-704-二分查找

参考代码:

public int search(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target)
            return mid;
        else if (nums[mid] < target)
            left = mid + 1;
        else
            right = mid - 1;
    }
    return -1;
}

在排序数组中查找元素的第一个和最后一个位置(力扣34)

题目描述:

力扣-34-在排序数组中查找元素的第一个和最后一个位置

参考代码:

public int[] searchRange(int[] nums, int target) {
    return new int[] {searchLeftBound(nums, target), searchRightBound(nums, target)};
}  

int searchLeftBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            right = mid - 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (left >= nums.length || nums[left] != target) {
        return -1;
    }
    return left;
} 

int searchRightBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            left = mid + 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (right < 0 || nums[right] != target) {
        return -1;
    }
    return right;
}

在排序数组中查找数字I(剑指Offer53-I)

题目描述:

剑指 Offer 53 - I. 在排序数组中查找数字 I

参考代码:

public int search(int[] nums, int target) {
    int L = searchLeftBound(nums, target);
    int R = searchRightBound(nums, target);
    if ( L == -1 || R == -1) {
        return 0;
    } else {
        return R - L + 1;
    }
}

int searchLeftBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            right = mid - 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (left >= nums.length || nums[left] != target)
        return -1;
    return left;
}

int searchRightBound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            left = mid + 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (right < 0 || nums[right] != target)
        return -1;
    return right;
}

参考资料

  1. 程序基地 - github
  2. 牛客网
  3. labuladong 的算法小抄
  4. 算法(4th)
  5. 力扣网