LeetCode 旋转数组系列 153.154.33.81

329 阅读6分钟

题目描述

这几道都是旋转数组的题目,区别只是题目的条件限制不太一样。 旋转数组是这样的一种数组:将一个按升序排列好的数组,往右侧循环移位,使得整个数组形成左右两个有序区间,且左区间的数都比右区间的数大。比如 [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 + 1mid位于左侧区间,答案一定在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,则答案落右侧区间内,midr之间,此时仍然往右侧查找
      • 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) 我们仍然用二分,仍然需要每次判断,二分的中点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是位于左侧区间,还是右侧区间的判断。以及如何合理的使用对应的条件,来达到目的(兼容不同情形以减少代码量,或精确的确认某种情形)。