「力扣」第 1095 题:山脉数组中查找目标值

大家好,这里是力扣视频题解。

今天要和大家分享的是第 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、找不到的话,就后面的有序数组里查找。


那么如何查找山顶呢。

二分查找的基本思路是「逐渐缩小待搜索的区间」,也可以理解为是不断地排除一定不存在目标元素的区间,进而「逐渐缩小 待搜索的区间」。

应用在查找山脉数组的峰顶元素也是这样。

我们仔细看一下:转折点或者说山顶元素有什么样的特征呢?

它的左边一定比它严格小,右边也一定比它严格小。

怎么样利用这个性质定位山顶的位置呢。

逐渐缩小搜索区间的思路是:先尝试找到一个元素,一般来说是区间里,中间位置的元素,如果它满足这个性质,我们就找到了目标元素。

但是只要仔细思考,就会发现,同时满足严格大于左边和严格大于右边这两个条件太强。不满足的话,我们不知道下一轮该往哪个方向继续查找。

其实我们可以将这个判别条件放松一点,只用其中一个条件试一下。

我们用 ii + 1 表示相邻的两个位置,比较 ii + 1 位置元素的大小关系,其实就可以看出当前我们处在这个山脉数组的哪一段。

我们假设当前位置的下标是 i

  • 如果当前位置的元素严格小于它右边位置的元素,我们就知道了现在我们的位置是在山脉数组的前半部分,有序并且是升序部分的区间里;

当前位置 i 一定不是峰顶元素,因为左边的元素肯定比当前位置小,它的左边也一定不存在峰顶元素,

  • 而这一条的反面是当前位置的元素严格大于它右边位置的元素,我们就知道了现在我们的位置是在山脉数组的后半部分,有序并且是逆序部分的区间里;

当前位置 i 可能是峰顶元素,因为右边的元素肯定比当前位置小,所以当前位置 i 的右边也一定不存在峰顶元素。

我们来看这两个条件,一种情况是可以排除当前位置的左边,反面的情况是可以排除当前位置的右边。就在这样的过程当中,搜索的区间越来越小,直至我们找到了峰顶元素的位置。


如何设计判别函数,是二分查找算法的一个重点,但是绝大多数情况下没有那么难,需要根据题目的特点来设计。但是总体的思路是,通过左右边界逐渐逼近的方式,逐渐缩小搜索的范围。

设计判别函数,通常需要靠猜测、尝试和过往的经验。

通常的经验是:先考虑当前位置的元素 [mid] 满足什么条件的时候,它不是目标元素,进而考虑当前位置左右区间元素的性质。

在绝大多数情况下,是比较好想,并且不容易出错的。这只是一个经验,并不绝对。需要根据具体的问题具体分析。


通过刚才的分析,我们就知道了:「二分查找」算法不一定只能应用在「有序数组」的查找元素中。

它可以应用于在旋转有序数组、山脉数组中,查找目标元素。

并且我们还可以用二分查找算法确定一个有范围的整数。

还可以应用在我们要找一个整数,这个整数满足一定的性质,并且这种性质在某种意义上具有单调性。这些问题和简单的二分查找问题的区别就只在于判别函数的设计上,希望大家能够完成这里列出的问题,以加深对二分查找的应用场景的体会。


下面我们对二分查找算法做一个简单的介绍:

我们讨论的前提是:在数组的 [left, right] 左闭右闭区间里查找目标元素。

第 1 种二分查找的思路:在代码层面上的一个显著特征是 while (left <= right)。在循环体内部,围绕中间元素 nums[mid] 与目标元素 target 的大小关系展开讨论:
1、如果等于,就直接返回这个元素的下标;
2、如果严格大于,就需要确定下一轮搜索区间,进而确定边界的设置;
3、如果严格小于,同样需要确定下一轮搜索区间,进而确定边界的设置。

这样的思路在区间只剩下一个元素的时候,还会执行一遍循环体。
如果依然找不到目标元素,退出循环以后,返回一个没有找到的标识。

这种思路的特点是:

1、(重点,慢)在循环体内部就找目标元素,找到了就直接返回;

2、而这样的写法,在循环体内部通常有 3 个的分支,因为我们需要单独拿出一个分支或者跳出循环,或者直接返回,另外两个分支用于边界的收缩,或者是左边界 left 向右边靠,或者是右边 right 向左边靠。

3、退出循环以后,leftright 不在一个位置, leftright 的右边, [right, left]

对比之下,我们来看二分查找的第 2 种二分查找的思路:

在代码层面上的一个显著特征是 while (left < right)。在循环体里只有 2 个分支:

1、每个分支做的事情就只有边界收缩;
2、当退出循环以后,区间里只剩下一个元素,这个时候,我们需要根据情况单独判断这个元素是否是目标元素。

为什么说是根据情况呢?在一些情况下,如果我们能够确定目标元素一定存在,那么剩下的这个元素就一定是我们要找的目标元素。

优点:退出循环的时候一定有 left == right,在一些问题上我们就不用去思考应该返回哪个下标。

但是这个思路有一个小的注意事项。


原因就在取中间数的这个表达式上:

int mid = left + (right - left) / 2

当区间里只剩下两个元素,一旦判别函数将 mid 分到右边,也就是边界收缩的代码是 left = mid 的时候,搜索的范围不会减少,因此下一轮还会执行到这个分支,进而产生死循环。

解决的办法就是在这种情况下:我们取中间数的时候,都调整为上取整。这样在循环体最后一遍执行的时候,就能够将区间分开,进而退出循环。

这一点细节我们就不再展开了,如果不是特别清楚的话,没有关系,编写代码的过程中,遇到问题的时候,可以把 leftmidright 打印出来看一下,相信就不难理解这个现象。

需要和大家强调的一点是:/ 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] 是否存在目标元素。

猜你喜欢

转载自blog.csdn.net/lw_power/article/details/105738552
今日推荐