《二分法之旋转有序数组》
二分查找实际上就是根据有序得条件进行边界收缩从而到 O(logn) 复杂度的搜索算法。二分法说简单也并不简单,Knuth 大佬(KMP 算法发明者)说过:Although the basic idea of binary search is comparatively straightforward,the details can be surprisingly tricky…,思路很简单,但是细节是魔鬼。所以对于旋转数组搜索问题更能够体现你对二分法的魔鬼细节了解多少。本文给出二分法的三种模板,寻中,寻左,寻右,以及对应LeetCode上五道题目的解法。本文给出的解法统一了:l <= r,先判断左区间是否为升序。
Key Words:二分法、旋转数组
Beijing, 2021.01
作者:RaySue
标准二分法的三种形式
在有序数组中查找一个特定元素 target 的过程如下:
-
若 target == nums[mid],直接返回
-
若 target < nums[mid],则 target 位于左侧区间 [left,mid) 中。令 right = mid - 1,在左侧区间查找
-
若 target > nums[mid],则 target 位于右侧区间 (mid,right] 中。令 left = mid + 1,在右侧区间查找
如果寻找的有序数组中有重复元素,可能有多个 target ,那么二分法应该取哪个值呢?通过对二分法的简单调整就可以实现这个功能,下面的代码为了方便理解,所以在寻左和寻右的时候就没有简写,实际用的时候可以将其中的两个分支合并即可。
C++ 代码模板
寻中
搜索所有target的中间元素位置
int l = 0, r = nums.size() - 1, mid;
while(l <= r)
{
mid = l + (r - l >> 1);
if (nums[mid] < target)
{
l = mid + 1;
} else if (nums[mid] > target)
{
r = mid - 1;
} else {
return mid;
}
}
return -1;
寻左
寻找所有target位于最左边的元素位置,注意要判断结果的左边界是否右溢出
int l = 0, r = nums.size() - 1, mid;
while(l <= r)
{
mid = l + (r - l >> 1);
if (nums[mid] < target)
{
l = mid + 1;
} else if (nums[mid] > target)
{
r = mid - 1;
} else {
r = mid - 1;
}
}
if (l >= nums.size() || nums[l] != target)
return -1;
寻右
寻找所有target位于最右边的元素位置,注意要判断结果的右边界是否左溢出
int l = 0, r = nums.size() - 1, mid;
while(l <= r)
{
mid = l + (r - l >> 1);
if (nums[mid] < target)
{
l = mid + 1;
} else if (nums[mid] > target)
{
r = mid - 1;
} else {
l = mid + 1;
}
}
if (r < 0 || nums[r] != target)
return -1;
旋转排序数组
前面回顾了二分法一般情况的模板,我们的主角旋转排序数组登场了,这里提到的旋转排序数组就是对一个排序数组,进行了一次旋转即在某个位置进行了断开,然后把后部分放到前面,如下:
-
旋转前:[1,2,3,4,5,6]
-
旋转后:[4,5,1,2,3,4]
-
标准的排序数组使用二分法搜索target示意图,绿色是左边界,红色是右边界,橙色是mid,星星是 target:
- 旋转排序数组使用二分法搜索target示意图,左右两部分都是升序的,且左半部分大于右半部分:
旋转排序数组二分查找有四种:
-
搜索特定值
- 不包含重复元素的旋转排序数组
- 包含重复元素的旋转排序数组
-
搜索最小值
- 不包含重复元素的旋转排序数组
- 包含重复元素的旋转排序数组
旋转排序数组二分搜索 target
旋转排序数组二分搜索 target 的大致思路:
-
若 target == nums[mid],直接返回
-
若 nums[left] <= nums[mid],说明左侧区间 [left,mid]「连续递增」。此时:若 nums[left] <= target < nums[mid],说明 target 位于左侧。令 right = mid-1,在左侧区间查找,否则,令 left = mid+1,在右侧区间查找
-
否则,说明右侧区间 [mid,right]「连续递增」。此时:若 nums[mid] < target <= nums[right],说明 target 位于右侧区间。令 left = mid+1,在右侧区间查找,否则,令 right = mid-1,在左侧区间查找
注意:区间收缩时不包含 mid,也就是说,实际收缩后的区间是 [left,mid) 或者 (mid,right],因为mid在最初已经判断过了,每次你更新左右边界是否用 mid 加减 1的时候只需考虑一下 mid 是否被判断过,不要漏掉情况。
- 不包含重复元素的旋转排序数组 搜索特定值
int search(vector<int> &nums, int target)
{
int l = 0, r = nums.size() - 1, mid;
while(l <= r)
{
mid = l + (r - l >> 1);
if (nums[mid] == target) return mid;
else if (nums[l] <= nums[mid])
{
// 这里一定是 <= 否则就会漏掉情况
if (nums[l] <= target && target < nums[mid])
{
r = mid - 1;
} else {
l = mid + 1;
}
}
else
{
// 这里也一定是 <= 否则漏情况
if (nums[mid] < target && target <= nums[r])
{
l = mid + 1;
} else {
r = mid - 1;
}
}
}
return -1;
}
- 包含重复元素的旋转排序数组 搜索特定值
对于包含重复的情况,我们就不能考虑[left,mid] 这个区间了,因为当 nums[left] == nums[mid] 的时候,我们是不知道要收缩左边界还是右边界的,这时候就退化为了线性查找,这在有重复元素nums[left] <= nums[mid] 拆开为 nums[left] < nums[mid] 即 nums[left] == nums[mid] 分开处理就行了。
bool search(vector<int> &nums, int target)
{
int l = 0, r = nums.size() - 1, mid;
while(l <= r)
{
mid = l + (r - l >> 1);
if (nums[mid] == target) return true;
else if (nums[l] < nums[mid])
{
if (nums[l] <= target && target < nums[mid])
{
r = mid - 1;
} else {
l = mid + 1;
}
} else if (nums[l] > nums[mid])
{
if (nums[mid] < target && target <= nums[r])
{
l = mid + 1;
} else {
r = mid - 1;
}
} else
{
// 这里提供线性查找的三种方式
// 方式一
nums[l] == nums[mid] ? l++: r--;
// 方式二 判断 [l,mid] 区间
//for (int i = l; i <= mid; ++i)
//{
// if (nums[i] == target) return true;
//}
//l = mid + 1;
// 方式三 判断 [mid,r] 区间
//for (int i = mid; i <= r; ++i)
//{
// if (nums[i] == target) return true;
//}
//r = mid - 1;
// 方式四 直接判断 [l, r] 区间
}
}
return false;
}
旋转排序数组 搜索最小值
- 不包含重复元素的旋转排序数组 搜索最小值
旋转排序数组二分搜索 最小值 的大致思路:
-
如果 nums[l] <= nums[r],说明nums的[l, r]区间是单调递增的,返回nums[l]即可,考虑如果给定的nums压根就没旋转,那么结果肯定是对的。
-
如果 nums[l] <= nums[mid],说明nums的[l, r]区间是单调递增的,所以最小值肯定在 mid 右边,而且mid必然不是最小值,所以 l = mid + 1;
-
如果 nums[l] > nums[mid],说明 mid 在右半部分,即[mid, r]区间是单调递增的,所以最小值肯定在 mid 或 mid 左边,注意,此时 mid 可能就是最小值,所以 r = mid;
int findMin(vector<int> &nums)
{
int l = 0, r = nums.size() - 1, mid;
while(l <= r)
{
mid = l + (r - l >> 1);
if (nums[l] <= nums[r]) return nums[l];
else if (nums[l] <= nums[mid])
{
l = mid + 1;
} else {
r = mid;
}
}
return -1;
}
- 包含重复元素的旋转排序数组 搜索最小值
对于包含重复的情况,我们就无法通过nums[l] <= nums[r]就认为nums[l]就是最小值, 举个例子[2,2,1,2,2,2],显然此时的 nums[l] 就不是最小值。我的做法是在各个分支都计算更新一下最小值,比较好理解。
int findMin(vector<int> &nums)
{
if (nums.empty()) return -1;
if (nums.size() == 1) return nums.back();
int minVal = nums[0];
int l = 0, r = nums.size() - 1, mid;
while (l <= r)
{
mid = l + (r - l >> 1);
if (nums[l] < nums[mid])
{
minVal = min(minVal, nums[l]);
l = mid + 1;
} else if (nums[l] > nums[mid])
{
minVal = min(minVal, nums[mid]);
r = mid - 1;
} else
{
minVal = min(minVal, nums[mid]);
nums[l] == nums[mid]? l++ : r--;
}
}
return minVal;
}
参考
[2] https://labuladong.gitbook.io/algo/di-ling-zhang-bi-du-xi-lie/er-fen-cha-zhao-xiang-jie
PS: 读完本文你可以立马去LeetCode解决一下五个问题:
- 33.搜索旋转排序数组
- 81.搜索旋转排序数组II
- 153.寻找旋转排序数组中的最小值
- 153.寻找旋转排序数组中的最小值II
- 剑指 Offer 11.旋转数组的最小数字