众所周知,二分法能非常高效地从有序数组中找出特定的元素,时间复杂度为 O(logN)。
一般而言,遇到要求 O(logN) 时间复杂度的搜索问题,十有八九会用到二分查找。
注意:求二分法的中点时注意防止溢出,两个 32 位正整数相加可能会溢出。
下面整理下二分法常见的套路:
套路一:搜索数组中等于某个元素的位置
这种问题基本就是套二分法的模版,二分法模版整理如下:
public int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1;
int mid;
while (left <= right) {
mid = ((right - left) >> 1) + left; // 这里不用 (left + right) / 2 是为了防止溢出
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // 没找到
}
这类型的题举例如下:
LeetCode 74 题:搜索二维矩阵
题目: 编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性: 每行中的整数从左到右按升序排列。 每行的第一个整数大于前一行的最后一个整数。
解析: 由题目可以看出,每一行的第一个数大于上一行最后一个数,如果将二维数组看成一个大的一维数组,那么整个数组是严格升序的,可以直接套用模板,唯一的问题是要将一维数组的索引,转化为二维数组的索引,这个转化是很简单的:假设一维数组的索引是 i,二维数组的大小是 m x n,则 i 在二维数组中的位置是:i / n, i % n。
答案:
public boolean searchMatrix(int[][] matrix, int target) {
int m = matrix.length, n = matrix[0].length;
int left = 0, right = m * n - 1;
int mid;
while (left <= right) {
mid = ((right - left) >> 1) + left;
if (matrix[mid/n][mid%n] == target) {
return true;
}
if (matrix[mid/n][mid%n] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return false;
}
套路二:搜索数组中大于等于某个元素的最小位置
这也是一个常见套路,元素存在时便返回其下标,不存在时则返回其应该被插入在数组中的下标,整理模版如下:
public int binarySearchInsert(int[] nums, int target) {
int left = 0, right = nums.length - 1, ans = nums.length;
int mid;
while (left <= right) {
mid = ((right - left) >> 1) + left; // 这里不用 (left + right) / 2 是为了防止溢出
if (nums[mid] >= target) { // 当中点值大于等于要查找的值时,记录下位置,然后在左边搜索
ans = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return ans;
}
这种类型的题目举例如下:
LeetCode 34 题:在排序数组中查找元素的第一个和最后一个位置
题目: 给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。 如果数组中不存在目标值 target,返回 [-1, -1]。
思路: 起始位置就是大于等于这个元素的第一个位置,结束位置就是大于这个元素的位置的上一个位置,两者都可以套用上面的模版。
答案:
private int binarySearch(int[] nums, int target, boolean lower) {
int left = 0, right = nums.length - 1, ans = nums.length;
while (left <= right) {
int mid = ((right - left) >> 1) + left;
// 这里根据是否包含元素有不同的条件
if (nums[mid] > target || (lower && nums[mid] == target)) {
right = mid - 1;
ans = mid;
} else {
left = mid + 1;
}
}
return ans;
}
public int[] searchRange(int[] nums, int target) {
int leftIndex = binarySearch(nums, target, true);
int rightIndex = binarySearch(nums, target, false) - 1;
if (leftIndex < nums.length && nums[leftIndex] == target && rightIndex < nums.length){
return new int[]{leftIndex, rightIndex};
}
return new int[]{-1, -1};
}
套路三:部分有序数组中搜索某个元素
二分法的精髓在于在有序数组中快速的减少搜索范围。但是很多时候给的条件并非是在一个完全有序的数组中进行搜索,这个时候就需要充分利用有序的部分减少搜索范围,灵活运用上面两个模版,快速写出高性能的代码。
LeetCode 33 题:搜索旋转排序数组
题目: 整数数组 nums 按升序排列,数组中的值互不相同。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2]。
给你 旋转后 的数组 nums 和一个整数 target,如果 nums 中存在这个目标值 target,则返回它的下标,否则返回 -1。
思路: 旋转后的数组中元素大小呈锯齿形,从下标 0 到某个位置,是升序的,然后从下一个位置到结尾也是升序的,第一段的数都大于第二段的数。
假设我们用二分法的套路来搜索,取中间元素,若中间元素落在第一段,则起始点到中间元素这一段是有序的;若中间元素落在第二段,则中间元素到结尾这一段是有序的。
也就是说无论如何,总有一段元素是有序的,那么对这一段有序的元素可以判断出目标元素到底在哪一部份。
答案:
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = ((right - left) >> 1) + left;
if (nums[mid] == target) {
return mid;
}
if (nums[left] <= nums[mid]) { // 这个条件说明前半段是有序的
if (target >= nums[left] && target < nums[mid]) { // target 介于前半段元素中,则搜索范围在前半段
right = mid - 1;
} else {
left = mid + 1;
}
} else { // 到这里说明后半段有序
if(target <= nums[right] && target > nums[mid]){ // target 介于后半段元素中,则搜索范围在后半段
left = mid + 1;
} else {
right = mid- 1;
}
}
}
return -1;
}
LeetCode 81 题:搜索旋转排序数组 II
题目: 已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4]。
给你 旋转后 的数组 nums 和一个整数 target,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target,则返回 true,否则返回 false。
思路: 这题跟上一题基本一样,主要的区别点在于数组中的元素可能重复。思路也跟上一题一样,但是要注意有个特别的点:这里由于元素相同,旋转之后可能出现最左边的元素等于中间的元素等于最右边元素,这时我们无法判断到底哪一块是有序的。这时就只能把左右边界分别往中间移动一位。
答案:
public boolean search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = ((right - left) >> 1) + left;
if (nums[mid] == target) {
return true;
}
if (nums[mid] == nums[left] && nums[mid] == nums[right]) {
left++;
right--;
continue;
}
if (nums[left] <= nums[mid]) {
if (target < nums[mid] && target >= nums[left]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else {
if (target <= nums[right] && target > nums[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return false;
}
LeetCode 4 题:寻找两个正序数组中位数
题目: 给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数。
思路: 对于总共奇数个 ( 2 * k + 1) 元素的两个数组, 寻找的就是第 k + 1 个元素, 对于总共偶数个元素 ( 2 * k ) 的数组,寻找的就是第 k 个和第 k + 1 个元素。 所以问题变成了如何在两个有序数组中找到第 k 个元素。 这里我们的思路是从第一个数组中找出第 k / 2 个元素, 第二个数组中也找出第 k / 2 个元素, 第 k 个元素肯定不可能在较小的 k / 2 元素中。这样就将搜索的范围减小了一半。 另外这里需要注意很多边界问题,不能下标越界。
答案:
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int n = nums1.length + nums2.length;
if (n % 2 == 1) {
return findKth(nums1, nums2, n / 2 + 1) * 1.0;
} else {
return (findKth(nums1, nums2, n / 2) + findKth(nums1, nums2, n / 2 + 1)) / 2.0;
}
}
private int findKth(int[] nums1, int[] nums2, int k) {
int index1 = 0, index2 = 0;
int newIndex1, newIndex2;
while (true) {
if (index1 == nums1.length) {
return nums2[index2 + k - 1];
}
if (index2 == nums2.length) {
return nums1[index1 + k - 1];
}
if (k == 1) {
return Math.min(nums1[index1], nums2[index2]);
}
newIndex1 = Math.min(index1 + k / 2, nums1.length) - 1; // 防止越界
newIndex2 = Math.min(index2 + k / 2, nums2.length) - 1;
if (nums1[newIndex1] < nums2[newIndex2]) {
k -= (newIndex1 - index1 + 1);
index1 = newIndex1 + 1;
} else {
k -= (newIndex2 - index2 + 1);
index2 = newIndex2 + 1;
}
}
}