0
点赞
收藏
分享

微信扫一扫

LeetCode 旋转数组系列 153. 154. 33. 81



文章目录

  • ​​题目描述​​
  • ​​题解​​
  • ​​153​​
  • ​​33​​
  • ​​154​​
  • ​​81​​

题目描述

这几道都是旋转数组的题目,区别只是题目的条件限制不太一样。

旋转数组是这样的一种数组:将一个按升序排列好的数组,往右侧循环移位,使得整个数组形成左右两个有序区间,且左区间的数都比右区间的数大。比如 ​​[5,6,7,8,1,2,3,4]​​。

153和33这两道题给定的条件都是,数组中的元素互不相同。153是找最小值,33是找给定值。

154和81这两道题给定的条件都是,数组中的元素可能重复。154是找最小值,81是找给定值。

题解

解题思路都是二分,因为是有序序列(准确的说是因为区间具有二段性,二段性怎么理解呢?即,可以将区间划分为2段,使得左边1段都满足条件​​a​​​,右边1段都不满足条件​​a​​​。此时可以根据条件​​a​​进行二分,从而找到左右两端的分界点。)

最小值,相比找给定值来说,要简单一些。

153

先说找最小值,从最简单的,数组中不存在重复元素的153题,开始。

借用之前文章里的一张图

LeetCode 旋转数组系列 153. 154. 33. 81_算法

数组经过旋转后,可能会形成上面这样,左大右小的两段有序区间。注意,只是可能。当旋转的次数恰好等于数组长度时,此时相当于没旋转,是个整体有序的序列。 这个在后面写代码时需要特别注意。

那么,二分时,我们只需要判断当前的中点​​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种情况

  1. 数组旋转后形成左右两个有序区间,且​​mid​​位于右侧区间
  2. 数组旋转后不变

两种情况都需要往左走,需要更新​​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]​​这个判断,其实包含了两种情形

  1. 数组旋转后整体有序
  2. 数组旋转后形成左右两个区间,且​​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)

LeetCode 旋转数组系列 153. 154. 33. 81_leetcode_02

我们仍然用二分,仍然需要每次判断,二分的中点​​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​​是位于左侧区间,还是右侧区间的判断。以及如何合理的使用对应的条件,来达到目的(兼容不同情形以减少代码量,或精确的确认某种情形)。



举报

相关推荐

0 条评论