剑指Offer_#11_旋转数组的最小数字
Contents
题目
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如,数组 [3,4,5,1,2] 为 [1,2,3,4,5] 的一个旋转,该数组的最小值为1。
示例 1:
输入:[3,4,5,1,2]
输出:1
示例 2:
输入:[2,2,2,0,1]
输出:0
思路分析
旋转操作将原本的一个有序数组分割为两个有序数组,分别称之为左有序数组和右有序数组。
所谓的最小数字其实就是旋转点的数字,或者说右有序数组的第一个数字,其下标用pivot表示。
整体的思路是二分查找,使用两个指针,通过比较,不断地缩小范围,最后两个指针相遇,指向的就是要找的最小数字。
二分查找
初始化左右指针为数组的开头(i)和结尾(j),然后取中间点(m),判断中间点是位于左有序数组,还是右有序数组,判断条件是:
numbers[m] > numbers[j]
- 说明中间点m位于左有序数组。要找的pivot在当前m的右边,所以把左边界右移,即执行
i = m + 1
。这里加1是因为,number[m]
大于某个数字(number[m]
或者number[j]
),所以m绝不可能是最小数字,直接排除掉。
- 说明中间点m位于左有序数组。要找的pivot在当前m的右边,所以把左边界右移,即执行
numbers[m] < numbers[j]
- 说明中间点m位于右有序数组。要找的pivot在当前m的左边,所以把右边界左移,即执行
j = m
。这里没有像上边一样减一是因为,pivot是一定属于右有序数组的,如果减一,可能就错过pivot数字了。
- 说明中间点m位于右有序数组。要找的pivot在当前m的左边,所以把右边界左移,即执行
- 特殊情况:
numbers[m] == numbers[j]
- 不好判断中间点m到底是位于左有序数组还是右有序数组,这里就可以直接左移右边界一位,即执行
j--
。为什么可以这么做,是因为这样做并不会错过pivot,将pivot排除在外。具体论证如下。
- 不好判断中间点m到底是位于左有序数组还是右有序数组,这里就可以直接左移右边界一位,即执行
分情况讨论: m位于左有序数组,m位于右有序数组。
- 若m位于左有序数组,那么pivot在m的右边。继续分类讨论:j就是pivot,j不是pivot.
a) j就是pivot,这时j--
就把pivot错过了,直接将范围缩小到左有序数组。举例说明:在[1,1,1,2,1]当中,numbers[m] == numbers[j]
,pivot与j重合。这隐含了一个信息,也就是numbers[j]<=numbers[i]<=numbers[m]
(将pivot还原到数组开头,得到的数组是有序数组),又有numbers[m]==numbers[j]
,则说明numbers[i]==numbers[i+1]==...==numbers[m]==numbers[j]
。那么之后一定是将j
逐渐左移,进入到[i,m]
范围内,最终定位到的值也一定与pivot相等,不会错过pivot。
b) j不是pivot,pivot又在m右边,那执行j--
正好是在缩小范围,也不会错过pivot. - 若m位于右有序数组,那么pivot在m的左边。应该左移右边界
j
,直接j--
是不会错过pivot的。之后继续进行二分搜索,必然是把范围逐渐左移。
为什么选择numbers[j]
作为判断标准? 原因是numbers[j]
一定位于右有序数组中,但numbers[i]
不一定位于左有序数组中,例如数组[1,2,3,4,5]只有右有续数组,pivot==0
。
以下举两种不同测试用例进行分析。
- 不存在相等数字
这时,左有序数组的任何一个数大于右边界数(即numbers[j]
)。
右有序数组的任何一个数小于右边界数。
- 存在相等数字
这时,左有序数组任何一个数大于等于右边界数。
右有续数组任何一个数小于等于右边界数。
综上,在中间数不等于numbers[j]
的时候,可以以numbers[j]
作为判断标准。
解答
- class Solution {
- public int minArray(int[] numbers) {
- int i = 0,j = numbers.length - 1;
- while(i < j){
- int m = (i+j) / 2;
- if (numbers[m] > numbers[j]) i = m + 1;
- else if (numbers[m] < numbers[j]) j = m;
- else j--;
- }
- return numbers[j];
- }
- }
复杂度分析
时间复杂度是O(logn)
,空间复杂度是O(1)
这是讨论区大佬的写法,好处是代码很短,其实在处理特殊情况numbers[m] == numbers[j]
时,使用j--
的方式,非常不直观,需要比较复杂的分类讨论来证明它的正确性。
书中的解法思路会更加直观明了,也就是当遇到有相等数字的时候,就放弃二分查找,转而逐个遍历进行查找。
解答2
- class Solution {
- public int minArray(int[] numbers) {
- int i = 0;
- int j = numbers.length - 1;
- while(i != j){
- int m = i + (j - i) / 2;
- if(numbers[m] > numbers[j]) i = m + 1;
- else if(numbers[m] < numbers[j]) j = m;
- else return findMin(numbers,i,j);
- }
- return numbers[i];
- }
- public int findMin(int[] numbers,int i,int j){
- int result = numbers[i];
- for(int m = i;i <= j;i++){
- if (numbers[i] < result) result = numbers[i];
- }
- return result;
- }
- }
复杂度其实跟上面的方法没有区别,因为本质上,上面的解法在遇到相等的情况时,执行j--
,其实也是一个个的去遍历。
解答3
上面的两种方法,在二分查找时,都是以numbers[j]
作为比较对象,实际上也可以以numbers[i]
作为比较对象。
上面分析过了,为什么二分查找时,比较的对象是numbers[j]
,主要就是应对pivot==0
,也就是旋转位置是0,整个数组就是有序数组的情况。
所以就需要针对这种特殊情况增加一句特殊判断。即第6行的判断语句,如果判断[i,j]
范围内是完全有序的数组,直接返回numbers[i]
。
复杂度依然不变。
- class Solution {
- public int minArray(int[] numbers) {
- int i = 0;
- int j = numbers.length - 1;
- while(i != j){
- if(numbers[i] < numbers[j]) return numbers[i];
- int m = i + (j - i) / 2;
- if(numbers[m] > numbers[i]) i = m + 1;
- else if(numbers[m] < numbers[j]) j = m;
- else return findMin(numbers,i,j);
- }
- return numbers[i];
- }
- public int findMin(int[] numbers,int i,int j){
- int result = numbers[i];
- for(int m = i;i <= j;i++){
- if (numbers[i] < result) result = numbers[i];
- }
- return result;
- }
- }