大家好,这里是力扣视频题解。
今天要和大家分享的是第 1095 号问题:山脉数组中查找目标值。
这道题虽然是标注为 hard 的一道问题,但是思路并不难想到。并且,如果大家做过第 852 号问题:山脉数组的峰顶索引,相信解决这道问题就不在话下。
我们来看一下问题的描述:
1、首先这是一个:交互式问题。所谓交互式问题,就是我们可以调用一个题目给出的接口。
我们要相信这个接口返回的数据永远是正确的。并且通过调用接口,完成题目要求的任务。
比较麻烦的是在测试上,如果在本地测试的话就需要实现接口。为此,在编码上,就需要保证的逻辑是非常清晰、且完全正确的。
2、这道题让我们找出在山脉数组中等于目标元素的最小的下标值。
什么是山脉数组呢?题目有一个定义。
-
首先这个数组的长度大于等于 3 3 3。它其实是第 2 点的前提:
-
在这个数组的有效范围内,存在这样的一个关系式:
- 前增后减,
A[i]
是山脉数组中的最大值。 - 注意:这里给出的下标,从 0 0 0 开始到数组的长度减 1 1 1,并且不等符号都是严格符号(不存在等于的情况)。
- 前增后减,
根据题目的描述,我们可以很清楚地在脑海里呈现出这样的山脉的形状。
只有 1 个峰顶元素,是这两条提示告诉我们的,这个信息在解决这道问题的过程中非常重要。
题目还说我们 不能直接访问该山脉数组,必须通过一个接口 MountainArray
接口来获取数据。
接口的两个方法意思是非常明确的,通过下标获得一个元素的值,以及山脉数组的长度。
注意:对接口的 get
函数发起超过 100 100 100 次调用的提交将被视为错误答案。
也就是说这个接口是 访问敏感 的,或者说访问成本比较高。题目希望我们能够通过尽量少的调用接口,来找到目标元素出现的最小下标。
所以「遍历整个数组」这个思路是行不通的。
我们来看两个示例:
第 1 个示例强调了,如果在山脉数组中,存在和目标元素相等的元素,我们返回最早出现的下标;
而第 2 个示例,告诉我们如果山脉数组中不存在目标元素,返回 − 1 -1 −1。
最后我们来看提示:
大家在做题的时候千万不要忽视,题目中对于 输入数据 范围的相关提示。
输入数据的范围 很多时候 就决定了我们可以写什么样的算法。在输入数据的范围很小的时候,最直接、简单的做法就是最好的解法。
这里:山脉数组的长度为 3 到 10000。
而之前题目又限制了我们对山脉数组的访问的次数,这个次数的上限我们刚刚看到了为 100 100 100。
我们想一想 100 100 100 和 10000 10000 10000 的关系,很显然题目要求我们实现的算法的时间复杂度为 O ( log N ) O(\log N) O(logN) 这个级别。而符合这个复杂度的算法其实很容易想到,就是二分查找。
目标元素的值和数组中的元素的值都在 10^9
,这是在整型的范围内。
题目读完了,算法的思路就锁定在了二分查找。
- 如果这个数组是有序数组的话,使用二分查找就会变得简单很多;
- 但是山脉数组已经非常接近有序了。只不过它有一个转折点。如果我们能找到这个转折点的话,其实就可以转化成在两个有序数组里查找目标元素。
那么是否可以通过二分查找找到这个转折点呢?当然可以,我们待会分析。现在我们先叙述算法的流程。
1、先找山顶的位置,如果山顶位置的元素就是目标元素,又由于 只有一个山顶,这个算法就可以直接返回;
2、否则先在前半段里查找目标元素,这是因为题目要求我们找的是最早出现的元素的下标;
3、找不到的话,就后面的有序数组里查找。
那么如何查找山顶呢。
二分查找的基本思路是「逐渐缩小待搜索的区间」,也可以理解为是不断地排除一定不存在目标元素的区间,进而「逐渐缩小 待搜索的区间」。
应用在查找山脉数组的峰顶元素也是这样。
我们仔细看一下:转折点或者说山顶元素有什么样的特征呢?
它的左边一定比它严格小,右边也一定比它严格小。
怎么样利用这个性质定位山顶的位置呢。
逐渐缩小搜索区间的思路是:先尝试找到一个元素,一般来说是区间里,中间位置的元素,如果它满足这个性质,我们就找到了目标元素。
但是只要仔细思考,就会发现,同时满足严格大于左边和严格大于右边这两个条件太强。不满足的话,我们不知道下一轮该往哪个方向继续查找。
其实我们可以将这个判别条件放松一点,只用其中一个条件试一下。
我们用 i
和 i + 1
表示相邻的两个位置,比较 i
和 i + 1
位置元素的大小关系,其实就可以看出当前我们处在这个山脉数组的哪一段。
我们假设当前位置的下标是 i
。
- 如果当前位置的元素严格小于它右边位置的元素,我们就知道了现在我们的位置是在山脉数组的前半部分,有序并且是升序部分的区间里;
当前位置 i
一定不是峰顶元素,因为左边的元素肯定比当前位置小,它的左边也一定不存在峰顶元素,
- 而这一条的反面是当前位置的元素严格大于它右边位置的元素,我们就知道了现在我们的位置是在山脉数组的后半部分,有序并且是逆序部分的区间里;
当前位置 i
可能是峰顶元素,因为右边的元素肯定比当前位置小,所以当前位置 i
的右边也一定不存在峰顶元素。
我们来看这两个条件,一种情况是可以排除当前位置的左边,反面的情况是可以排除当前位置的右边。就在这样的过程当中,搜索的区间越来越小,直至我们找到了峰顶元素的位置。
如何设计判别函数,是二分查找算法的一个重点,但是绝大多数情况下没有那么难,需要根据题目的特点来设计。但是总体的思路是,通过左右边界逐渐逼近的方式,逐渐缩小搜索的范围。
设计判别函数,通常需要靠猜测、尝试和过往的经验。
通常的经验是:先考虑当前位置的元素 [mid]
满足什么条件的时候,它不是目标元素,进而考虑当前位置左右区间元素的性质。
在绝大多数情况下,是比较好想,并且不容易出错的。这只是一个经验,并不绝对。需要根据具体的问题具体分析。
通过刚才的分析,我们就知道了:「二分查找」算法不一定只能应用在「有序数组」的查找元素中。
它可以应用于在旋转有序数组、山脉数组中,查找目标元素。
并且我们还可以用二分查找算法确定一个有范围的整数。
还可以应用在我们要找一个整数,这个整数满足一定的性质,并且这种性质在某种意义上具有单调性。这些问题和简单的二分查找问题的区别就只在于判别函数的设计上,希望大家能够完成这里列出的问题,以加深对二分查找的应用场景的体会。
下面我们对二分查找算法做一个简单的介绍:
我们讨论的前提是:在数组的 [left, right]
左闭右闭区间里查找目标元素。
第 1 种二分查找的思路:在代码层面上的一个显著特征是 while (left <= right)
。在循环体内部,围绕中间元素 nums[mid]
与目标元素 target
的大小关系展开讨论:
1、如果等于,就直接返回这个元素的下标;
2、如果严格大于,就需要确定下一轮搜索区间,进而确定边界的设置;
3、如果严格小于,同样需要确定下一轮搜索区间,进而确定边界的设置。
这样的思路在区间只剩下一个元素的时候,还会执行一遍循环体。
如果依然找不到目标元素,退出循环以后,返回一个没有找到的标识。
这种思路的特点是:
1、(重点,慢)在循环体内部就找目标元素,找到了就直接返回;
2、而这样的写法,在循环体内部通常有 3 个的分支,因为我们需要单独拿出一个分支或者跳出循环,或者直接返回,另外两个分支用于边界的收缩,或者是左边界 left 向右边靠,或者是右边 right 向左边靠。
3、退出循环以后,left
和 right
不在一个位置, left
在 right
的右边, [right, left]
。
对比之下,我们来看二分查找的第 2 种二分查找的思路:
在代码层面上的一个显著特征是 while (left < right)
。在循环体里只有 2 个分支:
1、每个分支做的事情就只有边界收缩;
2、当退出循环以后,区间里只剩下一个元素,这个时候,我们需要根据情况单独判断这个元素是否是目标元素。
为什么说是根据情况呢?在一些情况下,如果我们能够确定目标元素一定存在,那么剩下的这个元素就一定是我们要找的目标元素。
优点:退出循环的时候一定有 left == right
,在一些问题上我们就不用去思考应该返回哪个下标。
但是这个思路有一个小的注意事项。
原因就在取中间数的这个表达式上:
int mid = left + (right - left) / 2
当区间里只剩下两个元素,一旦判别函数将 mid
分到右边,也就是边界收缩的代码是 left = mid
的时候,搜索的范围不会减少,因此下一轮还会执行到这个分支,进而产生死循环。
解决的办法就是在这种情况下:我们取中间数的时候,都调整为上取整。这样在循环体最后一遍执行的时候,就能够将区间分开,进而退出循环。
这一点细节我们就不再展开了,如果不是特别清楚的话,没有关系,编写代码的过程中,遇到问题的时候,可以把 left
、mid
和 right
打印出来看一下,相信就不难理解这个现象。
需要和大家强调的一点是:/ 2
默认的取整行为是下取整,在区间里只剩下两个元素时候,取中间数只能取到较小的那个元素。
要使得 mid
取到较大的元素,需要改成上取整。
是否需要调整为上取整,只和算法里边界收缩的行为有关,看到 left = mid
的时候,需要上取整。
总结一下:
1、在编写二分查找代码的过程中,一定要非常清楚,每一个变量的定义、和每一行代码的作用,尽量不要跳步,就能够保证代码写对;
2、在一些特别细节、容易出错的地方,可以做一个简单的注释;
3、在程序出问题的时候,一定要耐心的调试,就把变量打印出来看一下,相信就不难找到解决的办法。
4、第 2 种思路,也就是我们在这张幻灯片里展示的思路,在解决一些复杂的时候比较有用,可以帮助我们少考虑很多细节的问题。
我们在等会写代码的时候,就会一直使用这个思路。
最后我们来说一下:二分查找的基本思想,减治思想。
这个思想是非常朴素、直观的:每次都将问题的规模减少,由于问题的规模是有限的,因此可以达到大而化小,小而化了的目的;
- 它是「分治思想」的特例,只不过没有将两个子问题合并的步骤;
- 在「双指针问题」、「选择排序算法」等问题中,都有这种思想的体现,其实二分查找也可以认为是一个特殊的「双指针问题」;
- 这个思想其实是潜移默化地体现在我们日常解决问题的过程中,就是我们常说的排除法。
最后我们来看一下这个问题的时间复杂度。
三次二分查找,时间复杂度是 3 倍 O log N,忽略常数的倍数,因此时间复杂度是记为 O(log N)。
由于我们算法的执行过程中,只适用了常数个变量,与问题的规模无关,因此,空间复杂度是 O(1)。
这就是这道题的视频题解,感谢大家的收看。
public class Solution {
public int findInMountainArray(int target, MountainArray mountainArr) {
// 首先我们将山脉数组的长度保存下来,因为我们后面还会再用到
int len = mountainArr.length();
int peakIndex = findMountainTop(mountainArr, 0, len - 1);
int res = findFromSortedArray(mountainArr, 0, peakIndex, target);
if (res != -1) {
return res;
}
return findFromReverseArray(mountainArr, peakIndex + 1, len - 1, target);
}
}
我们用代码的方式和大家总结一下:
// 在 [left, right] 区间里查找目标元素
if (A[i] < A[i + 1]) {
// 下一轮搜索的区间 [i + 1, right]
} else {
// A[i] > A[i + 1]
// 下一轮搜索的区间 [left, i]
}
设计判别函数的思路:
通常将数组分成三个区间考虑。
- 考虑搜索区间的中间位置的元素
[mid]
是否目标元素; - 进而考虑区间的中间位置的元素的左边
[left, mid - 1]
是否存在目标元素; - 进而考虑区间的中间位置的元素的右边
[mid + 1, right]
是否存在目标元素。