题目来源于力扣 33. 搜索旋转排序数组。以此题谈一谈二分思想。
问题描述
升序排列的整数数组 arr 在预先未知的某个点上进行了旋转(例如, [1,2,3,4,5,6,7] 经旋转后可能变为 [3,4,5,6,7,1,2] )。
请你在数组中搜索 key,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
输入输出
输入:arr[ ] = [3,4,5,6,7,1,2] key = 5
输出:2
输入:arr[ ] = [3,4,5,6,7,1,2] key = 8
输出:-1
提示信息(一定要注意)
1 <= n <= 5000 // 数组长度的要求
-10^4 <= arr[i] <= 10^4 // 数组中元素的取值范围
arr[ ] 中的每个值都 独一无二 // 解题范围
arr[ ] 肯定会在某个点上旋转 // 必定是旋转数组
-10^4 <= key <= 10^4 // 查找关键字 key 的取值范围
题目解析
这道题目最直观的解法是从头到尾遍历数组一次,我们就能找出要查找的元素 key。这种思路的时间复杂度显然是 。第一反应是这种思路的一定是 "语文阅读理解" 能力欠佳,没有抓住问题描述中的 搜索(查找) 、升序排序 及 旋转数组 三个关键字,一个抓不住面试官提问信息的面试者,必然是得不到面试官认可的。
首先自问何为升序排序的旋转数组?不懂就主动向面试官询问,景禹的读者可以看 图解:旋转数组(Rotate Array) 。
套路:查找有序数组(包括旋转数组)中的元素优先考虑二分查找算法
二分查找法查找有序数组中元素的时间复杂度为 。但是题目中的数组为升序排序的旋转数组,并不严格满足二分查找的有序性条件。比如原有序数组为 arr[] = {1,2,3,4,5,6,7}
,升序排序的旋转数组为 rotateArray[] = {4,5,6,7,1,2,3}
, rotateArray
整体不再满足有序性。
但是,我们注意到 rotateArray
实际上可以划分为两个排序的子数组,{4,5,6,7}
和 {1,2,3}
。我们还注意到这两个子数组的分界线两侧的元素一定满足 rotateArray[pivot] > rotateArray[pivot+1]
,其中 pivot
表示旋转数组中最大元素的下标, pivot+1
表示旋转数组中最小元素的下标。
对旋转数组 rotateArray[] = {4,5,6,7,1,2,3}
,pivot = 3
,rotateArray[3] = 7
即旋转数组中的最大元素,rotateArray[4] = 1
为最小元素,如图 1-1 所示。
图 1-1
我们的思路也就有了,利用二分查找找到旋转数组 rotateArray
中的 pivot ,然后将旋转数组分成两个有序的子数组 [0, pivot]
和 [pivot+1, n-1]
, 最后在两个有序的子数组中分别使用二分查找法查找输入的关键字 key 即可。
以有序旋转数组 rotateArray[] = {3,4,5,6,7,1,2}
为例,说明二分查找 pivot 的过程。
设置一个 low
指针指向旋转数组的第 0 个元素 3 ,high
指针指向旋转数组的第 6 个元素 2 ,计算 low
和 high
的中点 mid = (low + high) / 2 = 3
如图 1-2 所示:
图 1-2
其中 pivot 与 mid 之间会存在两种情况:
- 判断条件
mid < high && arr[mid] > arr[mid+1]
是否满足,该条件的意义是找到的 pivot 即为mid
。此时mid = 3
、high = 6
,arr[mid] = 6
,arr[mid+1]=7
,显然并不满足。
- 判断条件
mid > low && arr[mid-1] > arr[mid]
是否满足,该条件的意义时找到的 pivot 为mid - 1
。
二分法的核心在于,根据 mid 将搜索空间减半:
判断条件 arr[low] >= arr[mid]
,如果满足,则表明旋转数组在区间 [low, mid]
内非有序数组,要查找的 pivot 位于 [low,mid-1]
之间,递归进行处理即可。反之 arr[low] < arr[mid]
,则表示旋转数组的子区间 [low, mid]
为有序数组,要查找的元素 pivot 位于 [mid+1, high]
非升序排列的区间,递归的进行处理。
如图 1-2 所示,low = 0
, mid=3
,arr[low]=arr[0] < arr[mid]=arr[3]
,则递归处理 [mid+1, high]
,即 [4, 6]
。
此时 low = 4
,high = 6
,计算 mid = (4 + 6) / 2 = 5
,满足条件mid > low && arr[mid-1] > arr[mid]
,即 5 > 4 && arr[4] > arr[5] ,则 pivot 的下标为 mid-1 ,如图 1-3 所示。
图 1-3
理解上面思路,也不难写出查找 pivot 的代码:
// 查找旋转数组arr中的pivot
int findPivot(int arr[], int low, int high){
if (high return -1;
if (low == high)
return low;
int mid = (low + high) / 2;
if(mid arr[mid+1]) // pivot == mid
return mid;
if(mid > low && arr[mid-1] > arr[mid]) // pivot == mid-1
return mid-1;
if(arr[low] >= arr[mid]) // pivot in [low, mid-1]
return findPivot(arr, low, mid-1);
return findPivot(arr, mid+1, high); // pivot in [mid+1, high]
}
然后根据 pivot 将数组分割成两个子数组 [0,pivot-1]
和 [pivot+1,n-1]
。首先判断 arr[pivot] == key
是否满足,满足也就没有必要在两个子数组中使用二分查找 key 。然后比较 arr[0] <= key
,若满足,则表示要查找的关键字 key 位于子数组 [0,pivot-1]
中,否则位于 [pivot+1,n-1]
中,使用二分查找即可。
例如旋转数组 rotateArray[] = {3,4,5,6,7,1,2}
的 pivot 为 4,要查找的元素为 key = 5 ,7 = arr[4] != key
;比较 arr[0] = 3 < key
,则使用二分查找在区间 [0,3]
内查找关键字 key。
在上面完整思路的基础上,也不难写出如下的代码:
#include
int findPivot(int[], int, int);
int binarySearch(int[], int, int, int);
// 查找旋转数组 arr 中的关键字 key
int pivotedBinarySearch(int arr[], int n, int key) {
int pivot = findPivot(arr, 0, n - 1);
// 如果pivot不存在,则说明数组不是旋转数组,使用二分查找即可
if (pivot == -1)
return binarySearch(arr, 0, n - 1, key);
// pivot 存在,先比较arr[pivot]
if (arr[pivot] == key)
return pivot;
if (arr[0] <= key) // 确定 key 位于那个区间内
return binarySearch(arr, 0, pivot - 1, key);
return binarySearch(arr, pivot + 1, n - 1, key);
}
// 查找旋转数组中的pivot,如[3,4,5,6,7,1,2]中的pivot为4,即7的下标
int findPivot(int arr[], int low, int high) {
if (high return -1;
if (high == low)
return low;
int mid = (low + high) / 2; /*low + (high - low)/2;*/
if (mid arr[mid + 1])
return mid;
if (mid > low && arr[mid] 1])
return (mid - 1);
if (arr[low] >= arr[mid])
return findPivot(arr, low, mid - 1);
return findPivot(arr, mid + 1, high);
}
// 标准的二分查找
int binarySearch(int arr[], int low, int high, int key) {
if (high return -1;
int mid = (low + high) / 2;
if (key == arr[mid])
return mid;
if (key > arr[mid])
return binarySearch(arr, mid + 1, high, key);
return binarySearch(arr, low, mid - 1, key);
}
int main() {
int rotateArr[] = { 3, 4, 5, 6, 7, 1, 2 };
int n = sizeof(rotateArr) / sizeof(rotateArr[0]);
int key = 3;
printf("key 的下标为 : %d", pivotedBinarySearch(rotateArr, n, key));
return 0;
}
复杂度分析
- 时间复杂度:
- 空间复杂度:
但上面的代码是否就完美了呢?面试官会告诉我们其实不然。他会提示我们再仔细考虑一下,是否可以将多次调用二分查找的方式优化一下,仅需要一次就能在旋转数组中查找到关键字 key。
以旋转数组 arr[] = {3,4,5,6,7,1,2}、key = 5
为例。
设置一个指向旋转数组第 0 个元素的指针 low
,指向旋转数组第 6 个元素的指针 high
,计算两者的中点 mid = (low + high) / 2 = 3
。
比较 arr[low]
和 arr[mid]
,从而判断当前数组以 mid 为分割点得到的子数组 [low, mid]
是否有序,当 **arr[low] <= arr[mid]
** 时,表明子数组 arr[low,...,mid]
为有序数组;否则,子数组 arr[mid+1,...,high]
一定为有序子数组。
arr[low] = 3 < arr[mid] = 6
,则表明子数组 arr[0,...,3]
为有序数组;
子数组 arr[low,...,mid]
为有序数组,判断关键字 key 是否在子数组的范围内,即判断 arr[low] <= key <= arr[mid]
是否满足。若满足则递归地处理子数组 [low,mid-1]
,否则,递归处理子数组 [mid+1, high]
。
子数组 arr[0,...,3]
为有序数组,且 3 < 5 < 6
,也就是 key 值可能位于子数组 [0,3]
之内,递归处理子数组 arr[0,...,2]
:
新的 low = 0、high = 2 ,则 mid = (low+high)/2 = 1 ,arr[0] <= arr[1] ,子数组 arr[0,1]
为有序子数组,但是 arr[0]
和 arr[1]
均小于要查找的 key = 5 ,所以递归处理子数组 [2,2]
:
此时的 low = 2、high = 2 , mid = 2 ,arr[mid] = 5
,直接返回 key 值在数组中的下标 2 即可。
查找旋转数组 arr[] = {3,4,5,6,7,1,2}
中的关键字 key = 1
的过程如图 1-5 所示。
图 1-5 查找旋转数组中的关键字 1
实现代码
#include
int keyBinarySearch(int arr[], int low, int high, int key) {
if (low > high)
return -1;
int mid = (low + high) / 2;
if (arr[mid] == key){
return mid;
}
if (arr[low] <= arr[mid]) {
if (arr[low] <= key && key <= arr[mid]) {
return keyBinarySearch(arr, low, mid-1, key);
}
return keyBinarySearch(arr, mid+1, high, key);
}
if (arr[mid] <= key && key <= arr[high]) {
return keyBinarySearch(arr, mid+1, high, key);
}
return keyBinarySearch(arr, low, mid - 1, key);
}
int main() {
int arr[] = { 3,4,5,6,7,1,2};
int n = sizeof(arr) / sizeof(arr[0]);
int key = 1;
int i = keyBinarySearch(arr, 0, n - 1, key);
if (i != -1) {
printf("%d\n", i);
}
else{
printf("key 不存在");
}
return 0;
}
复杂度分析
- 时间复杂度:
- 空间复杂度:
那么上面这段代码的是否就完美了呢?如果数组中有重复元素的话又该如何处理,该算法能否处理呢?
比如,对于数组 arr[ ] = [1,1,1,1,1,0,1,1]
or [1,1,0,1,1,1,1,1,1]
,就无法用上面的两种思路在 的时间查找到元素 0 。如图 3-1 所示,arr[low] = arr[mid] = arr[high] = 1
,我们就无法确定要查找的关键字是在 [low, mid-1]
左半部分还是在 [mid+1, high]
右半部分。解决办法就是退而求其次,使用 的时间进行查找。
图 3-1 有序旋转数组存在重复元素
所以,果断回答,无法用 时间查找有重复元素的有序旋转数组中的元素。正如题目要求的提示信息一样,旋转数组arr[] 中的每个值都是独一无二的 ,并且 arr[] 肯定会在某个点上旋转 。
总结
二分查找是一个超越了 查找 的 二分 思想。2 这个数字在计算中意义远远超过其本身的意义。我们甚至可以用下面一种抽象艺术来形象的表示二分思想:
推荐阅读:
一文横扫数组基础知识
作者:景禹,一个追求极致的共享主义者,想带你一起拥有更美好的生活,化作你的一把伞。