题目描述
这几道都是旋转数组的题目,区别只是题目的条件限制不太一样。
旋转数组是这样的一种数组:将一个按升序排列好的数组,往右侧循环移位,使得整个数组形成左右两个有序区间,且左区间的数都比右区间的数大。比如 [5,6,7,8,1,2,3,4]
。
153和33这两道题给定的条件都是,数组中的元素互不相同。153是找最小值,33是找给定值。 154和81这两道题给定的条件都是,数组中的元素可能重复。154是找最小值,81是找给定值。
题解
解题思路都是二分,因为是有序序列(准确的说是因为区间具有二段性,二段性怎么理解呢?即,可以将区间划分为2段,使得左边1段都满足条件a
,右边1段都不满足条件a
。此时可以根据条件a
进行二分,从而找到左右两端的分界点。)
找最小值,相比找给定值来说,要简单一些。
153
先说找最小值,从最简单的,数组中不存在重复元素的153题,开始。
借用之前文章里的一张图 数组经过旋转后,可能会形成上面这样,左大,右小的两段有序区间。注意,只是可能。当旋转的次数恰好等于数组长度时,此时相当于没旋转,是个整体有序的序列。 这个在后面写代码时需要特别注意。
那么,二分时,我们只需要判断当前的中点mid
,是位于左侧这个更大的区间,还是右侧这个更小的区间。
如何判断呢?我们每次二分时,会知道3个位置的值:左端点l
,右端点r
,和中点mid
。我们可以借助这三个位置的值,之间的大小关系,来判断mid
处于哪个区间。
由于不存在重复元素,容易知道
- 当
mid
位于右侧区间时,有arr[mid] <= arr[r]
,或者arr[mid] < arr[l]
,此时答案在左侧,需要往左侧走,需要更新右边界r = mid
(注意mid
本身可能是答案,因为我们要求的就是右侧区间的第一个元素,所以不能更新为r = mid - 1
) - 当
mid
位于左侧区间时,有arr[mid] >= arr[l]
,或者arr[mid] > arr[r]
,此时要往右侧走,需要更新l = mid + 1
(mid
位于左侧区间,答案一定在mid
右侧,取不到mid
本身)
根据上面的分析,每次只需要判断mid
位于左侧还是右侧,然后决定二分搜索是往左走还是往右走即可。
判断mid
是否位于右侧区间,我们有2个条件可以用
arr[mid] <= arr[r]
arr[mid] < arr[l]
在满足上面的条件时,需要更新r = mid
那么选用哪一个条件呢?这里要注意。因为前面提到的,整个数组可能旋转后仍然保持原样。如果我们选用 arr[mid] < arr[l]
。那么我们的代码是 if (arr[mid] < arr[l]) r = mid;
只有当数组旋转后确实形成了左右2个有序区间,且mid
确实位于右侧区间时,上面这个条件才会为true
。
而如果我们选用arr[mid] <= arr[r]
,则在数组整体有序(旋转后保持原样)时,条件仍然为true
,而在数组整体有序时,此时也恰好应该往左走。
也就是说,当条件arr[mid] <= arr[r]
满足时,包含2种情况
- 数组旋转后形成左右两个有序区间,且
mid
位于右侧区间 - 数组旋转后不变
两种情况都需要往左走,需要更新r = mid
而如果选择arr[mid] < arr[l]
,则这个条件满足时,只会包含第1种情况。所以我们选择判断条件为arr[mid] <= arr[r]
,可以减少需要编写的代码量。
那么除了上面的情况,剩余情况就只有1种了,即
- 数组旋转后形成左右两个有序区间,且
mid
位于左侧区间
由于只剩这一种情况,直接用else
处理即可,最终代码如下。
class Solution {
public int findMin(int[] arr) {
int l = 0, r = arr.length - 1;
while (l < r) {
int mid = l + r >> 1;
if (arr[mid] < arr[r]) r = mid;
else l = mid + 1;
}
return arr[l];
}
}
上面的代码可以将条件写成arr[mid] < arr[r]
,而不用加等号。为什么呢?因为mid
永远取不到r
。因为我们求中点时,用的是mid = l + r >> 1
,即 (l + r) / 2
,而r
一定要大于l
才能进入循环,即r
最小也是l + 1
,而此时mid = l
。
33
再来说查找给定值,33题的条件也是数组中不存在重复元素。题目的基本情形和上面分析的一致,就不赘述。区别只是查找的不再是最值,而是某个指定值。
我们先把经典的二分框架写出来,再看看是否需要进行一些微调或者特判。
假定数组整体有序,则查找某个元素的二分算法框架如下
class Solution {
public int search(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l < r) {
int mid = l + r >> 1;
if (nums[mid] < target) {
l = mid + 1;
} else if (nums[mid] > target) {
r = mid - 1;
} else return mid;
}
return nums[l] == target ? l : -1;
}
}
接下来进行一些微调。
先看第一个条件,当满足 nums[mid] < target
时,正常情况是要往右侧查找的。
若数组整体有序,且mid
位置小于目标值,要往右侧查找,需要更新左边界l = mid + 1
。
然后,我们再看看,在旋转数组的情况下,有没有什么不同。也就是说,有什么时候,是需要往左侧查找的吗?(更新右边界) 下方需要往左侧查找的情形用(√)示意。
- 当数组旋转后保持不变(×)
- 当数组旋转后形成左右两个有序区间
mid
落在左侧区间(×) 因为nums[mid] < target
,而左侧区间已经是较大的区间,答案只能在左侧区间的mid
的右侧mid
落在右侧区间nums[r] >= target
(×) 右侧是较小区间,但右侧区间的最大值大于target
,则答案落右侧区间内,mid
和r
之间,此时仍然往右侧查找nums[r] < target
(√) 右侧是较小区间,且右侧区间的最大值仍然小于target
,那么答案一定落在左侧的区间,需要往左查找(左侧区间整体比右侧区间更大)
综上所述,当nums[mid] < target
时,只有当mid
落在右侧较小的区间,且右侧区间最大值都还小于target
,即nums[r] < target
时,才需要往左侧查找,其余情况按正常的往右侧查找即可。
所以我们修改nums[mid] < target
这一部分的代码,如下
if (nums[mid] < target) {
if (???) r = mid - 1; // mid落在右侧区间, 且nums[r] < target时
else l = mid + 1; // 其余情况按正常往右侧查找
}
代码中???
处的条件,就是 mid
落在右侧区间,且nums[r] < target
特别注意,关于mid
落在右侧区间。根据153这道题的分析,可以有2个条件用来判断
nums[mid] <= nums[r]
nums[mid] < nums[l]
因为此时我们需要确保mid
是落在右侧区间的,所以我们只能选择nums[mid] < nums[l]
。
为什么呢?因为当数组整体有序时,nums[mid] <= nums[r]
这个条件也会为true
。所以用nums[mid] <= nums[r]
并不能保证mid
一定落在右侧区间。
结合153题,我们进行一下总结。
nums[mid] <= nums[r]
这个判断,其实包含了两种情形
- 数组旋转后整体有序
- 数组旋转后形成左右两个区间,且
mid
位于右侧区间
而nums[mid] < nums[l]
这个判断,只对应第2种情形(因为数组整体有序时,mid
位置的元素是不可能大于更左侧的元素的)。
在153题中我们选择nums[mid] <= nums[r]
这个条件,是为了兼容数组整体有序的情况,减少代码量。
而33题这里,是必须确保mid
位于右侧区间,所以只能选择nums[mid] < nums[l]
。
于是,对于nums[mid] < target
这部分代码,就完整了:
if (nums[mid] < target) {
if (nums[mid] < nums[l] && nums[r] < target) r = mid - 1;
else l = mid + 1;
}
再来看第二个条件,当满足nums[mid] > target
时,正常来说是要往左侧查找的。那么同样的,在旋转数组的情形下,什么时候需要往右侧查找呢?分析和上面的类似,不赘述,这里直接给出结论:
只有当mid
位于左侧区间,且左侧区间的最小值,仍然大于target
时,此时说明答案在右侧更小的区间当中,需要往右查找。
判断mid
位于左侧区间,同样有2个条件可以选用:
nums[mid] >= nums[l]
nums[mid] > nums[r]
与上面类似的,nums[mid] >= nums[l]
额外包含了数组整体有序的情况,所以这里只能选用nums[mid] > nums[r]
。
补全这部分代码如下
else if (nums[mid] > target) {
if (nums[mid] > nums[r] && nums[l] > target) l = mid + 1;
else r = mid - 1;
}
于是,我们的代码就完整了:
class Solution {
public int search(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l < r) {
int mid = l + r >> 1;
if (nums[mid] < target) {
if (nums[mid] < nums[l] && nums[r] < target) r = mid - 1;
else l = mid + 1;
} else if (nums[mid] > target) {
if (nums[mid] > nums[r] && nums[l] > target) l = mid + 1;
else r = mid - 1;
} else return mid;
}
return nums[l] == target ? l : -1;
}
}
154
接下来看进阶版,数组中存在重复元素。
先看154这道题,找最值。基本情形和上面的类似,但有一点不同,因为可能存在重复元素。如果数组旋转后没有保持原样,则形成的状态如下图所示(来源LeetCode)
我们仍然用二分,仍然需要每次判断,二分的中点mid
,是位于右侧区间还是左侧区间。
当mid
位于右侧区间时,会满足如下两个条件
nums[mid] <= nums[r]
nums[mid] <= nums[l]
同样的,nums[mid] <= nums[r]
包含了数组旋转后保持原样的情况。
但是,我们无法用这两个条件来判断mid
位于右侧区间。这两个条件只是mid
位于右侧区间时的必要条件,而不是充分条件。(mid
位于右侧区间时,这两个条件一定满足;但满足这两个条件时,mid
不一定位于右侧区间)
原因就是存在重复元素,使得上面的条件能取到等号。
既然这样,那我们尝试把条件中的等号去掉:
nums[mid] < nums[r]
nums[mid] < nums[l]
与153类似的,nums[mid] < nums[r]
包含了数组整体有序,以及mid
位于右侧区间,两种情形,两种情形都需要往左侧查找。(mid
本身可能是答案,所以要更新r = mid
)
所以,在满足nums[mid] < nums[r]
时,能够确定答案在左侧。
if (nums[mid] < nums[r]) r = mid;
类似的,当mid
位于左侧区间时,会满足
nums[mid] >= nums[l]
nums[mid] >= nums[r]
同样,为了能够使用这两个条件,我们先把等号去掉
nums[mid] > nums[l]
nums[mid] > nums[r]
同样,nums[mid] > nums[l]
包含了数组整体有序和mid
位于左侧区间两种情形。但我们此时要往右侧查找,所以只能选择nums[mid] > nums[r]
。
最后,当nums[mid] == nums[r]
时,此时我们不能确定最小值究竟在左侧还是右侧,但是由于mid
位置和r
位置的数相同,并且mid
不会等于r
。所以我们可以至少先移除r
这个位置上的数(先排除一个数),而不会漏掉最小值。所以此时r--
即可。
综上,代码如下
class Solution {
public int findMin(int[] nums) {
int l = 0, r = nums.length - 1;
while (l < r) {
int mid = l + r >> 1;
if (nums[mid] < nums[r]) r = mid;
else if (nums[mid] > nums[r]) l = mid + 1;
else r--;
}
return nums[l];
}
}
81
最后来看,数组有重复元素,查找指定值。
同样,先写出经典二分框架
class Solution {
public boolean search(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l < r) {
int mid = l + r >> 1;
if (nums[mid] < target) {
if (???) r = mid - 1; //TODO
else l = mid + 1
} else if (nums[mid] > target) {
if (???) l = mid + 1; //TODO
else r = mid - 1;
} else return true;
}
return nums[l] == target;
}
}
与33类似的,在nums[mid] < target
时,通常是要往右查找的。什么时候要往左查找呢?只有mid
位于右侧区间,且右侧区间最大值仍然小于target
时。
关键就在于对mid
位于右侧区间的判断,与33不一样了。
先来看看mid
位于右侧区间时,会满足什么条件?
nums[mid] <= nums[r]
nums[mid] <= nums[l]
同样先去掉等号
nums[mid] < nums[r]
nums[mid] < nums[l]
我们要确保mid
位于右侧区间,所以选择nums[mid] < nums[l]
上面第一个???
的条件其实和33题一样了
if (nums[mid] < nums[l] && nums[r] < target) r = mid - 1;
但是当nums[mid] == nums[l]
时,我们无法确定答案在哪一侧。但是我们至少可以排除掉l
这一个位置。
同理,第二个???
的条件也是类似的,不再赘述。
直接给出完整代码如下
class Solution {
public boolean search(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l < r) {
int mid = l + r >> 1;
if (nums[mid] < target) {
if (nums[mid] < nums[l] && nums[r] < target) r = mid - 1;
else if (nums[mid] == nums[l]) l++;
else l = mid + 1;
} else if (nums[mid] > target) {
if (nums[mid] > nums[r] && nums[l] > target) l = mid + 1;
else if (nums[mid] == nums[r]) r--;
else r = mid - 1;
} else return true;
}
return nums[l] == target;
}
}
至此,旋转数组系列完结。总结一下,比较关键的地方在于,对于二分中点mid
是位于左侧区间,还是右侧区间的判断。以及如何合理的使用对应的条件,来达到目的(兼容不同情形以减少代码量,或精确的确认某种情形)。