二分查找:
1.Leetcode 704 二分查找(题解)
难度:⭐️
这道题目的前提是数组为有序数组,同时题目还强调数组中无重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这些都是使用二分法的前提条件,当大家看到题目描述满足如上条件的时候,可要想一想是不是可以用二分法了。
小Tips:
1.while循环内的条件为区间(左闭右闭/左闭右开)成立的条件,前者为left<=right,后者为left<right
2.防止两个int相加越界,改进写法:int middle=left+(right-left)/2;
2.Leetcode 35 搜索插入位置(题解)
难度:⭐️⭐️
这道题目,要在数组中插入目标值,无非是这四种情况。
- 目标值在数组所有元素之前
- 目标值等于数组中某一个元素
- 目标值插入数组中的某个中间位置(目标值不在原数组中)
- 目标值在数组所有元素之后
这四种情况确认清楚了,就可以尝试解题了。
看题解之前,我尝试自己为这四种方式分别写出判定条件,可惜最后被系统判定超时。
题解代码最妙的地方在于,仅在上一题的基础上,把循环外的返回值return -1改为了return right+1(返回子区间最后索引的下一个索引),就同时解决了这四个问题。针对上述四种情况,分别解释如下:
- 此时最后二分的middle必为0,此时nums[middle]>target,故right=middle-1=-1,所以新区间为[0,-1],退出循环,返回right+1=0,正确
- 此为上道题目(704)循环内的第三种情况(nums{middle]==target)
- 最复杂的一种情况,经过若干次二分之后,最后必定会出现target位于子数组[left, right]的一侧(往往此时子数组只剩下一个元素,即left=right),那么此时就等同于第一种或第四种情况
- 此时最后二分的middle必为nums.size()-1,由于nums[middle]<target,那么left=middle+1=nums.size(),新区间为[nums.size(), nums.size()-1],退出循环,返回right+1=nums.size(),正确
概括一下:找到了,直接插;没找到,插在right+1(right为最终跳出while循环之后的值,针对左闭右闭区间)。
//还有另一种简单的理解方式:1)如果target在数组中,当nums[middle]=target(找到)时,直接返回middle即插入位置 2)如果target不在数组中,那最后一次循环无非是left=middle=right,那么target和nums[middle]只有两种关系:>或者<,对于前者(>),left=middle+1,right=middle,应插入在middle+1;对于后者(<),right=middle-1,left=middle,应插在middle。综上,最后while循环结束时,如果没找到target,那只需返回left或者right+1即可。
3.Leetcode 34 在排序数组中查找元素的第一个和最后一个位置(题解)
难度:⭐️⭐️⭐️⭐️
这道题在上一题的基础上继续扩展,增加了多个重复值的情况。
首先还是和上一题的思路一样,目标值可能有四种情况,接下来,就是去寻找左边界,和右边界了。
采用二分法来去寻找左右边界,为了让代码清晰,我分别写两个二分来寻找左边界和右边界。
刚刚接触二分搜索的同学不建议上来就想用一个二分来查找左右边界,很容易把自己绕进去,建议扎扎实实的写两个二分分别找左边界和右边界
一开始我看题解时,觉得给出的方案似乎不是最优解,比如其中有一些边界检测,以及上述四种情况的判定,可以在代码实现的时候进行合并判定。
我的思路是,先用一次二分法,判断是否能找到nums[middle]==target,如果不能,直接返回[-1,-1](这样一次就解决了四种情况中的134);如果能找到(情况3),那么记录下这个middle,继续二分到最后,找到其中一个边界值,然后回到刚才middle的地方重新二分,找到另外一个边界值。这样不用进行两次完整二分,时间复杂度比O(2logn)稍微低一点。
当然这样写的缺点也是有的,没有像题解一样将边界值封装为两个子函数,不利于拓展。
有趣的是,我把自己的代码改成封装调用两个函数后,反而超时了。在找原因的同时,我仔细研读了一遍题解的代码,发现其中有一些地方构造十分精妙。
- 题解代码中的leftBound和rightBound不是最终结果的上下界,而是再往外一个位置的索引,即第一个比target还小的值的索引(甚至可能索引已经越界,如0或nums.size())
- 这样写的好处,是为了区分是否在数组区间内找到了target,如果target没找到且夹在数组两个值之间,那rB-lB恒为1(如[2,3,5] 4,lB=1, rB=2)而如果target找到了,那rB-lB一定大于1(如[1,2] 1,lB=-1,rB=1)所以在主函数中,可以通过rB-lB的值,确定最终返回的结果是[lb+1,rB-1],还是[-1,-1]
- 另外题解将lB和rB的初值设为-2,也是考虑到上面一种情况索引越界后可能为-1,这样只要target位于数组两端之外,那么肯定有一个边界没被修改过,因此主函数中判断lB和rB的值,便可以发现这种情况,从而指定返回值为[-1,-1]
附:带详细注释版题解代码
双指针:
1.Leetcode 27 移除元素(题解)
难度:⭐️
这道题目我一开始自己用暴力解法(两层for循环)实现了,但复杂度为O(n2)
这个题目暴力的解法就是两层for循环,一个for循环遍历数组元素 ,第二个for循环更新数组。
删除过程如下:
看了题解之后发现可以用双指针的思想,实现O(n)的复杂度。
小Tips:
1. 使用vector.erase()的复杂度为O(n),本质上也是删除后将每个后面的数组元素往前移动一位。
2. 什么时候可以直接使用库函数?如果某题的核心算法不是该库函数(只是其中的一步),且已经了解该库函数的复杂度,则可以直接调用,否则建议手写。
3.数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
双指针法(快慢指针法): 通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
定义快慢指针
- 快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
- 慢指针:指向更新 新数组下标的位置
删除过程如下:
双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。
这种方式得到的结果,新数组中的元素仍然保持相对顺序。
题解中还给出了另外一种双指针的方法(两头移动),可以实现最少移动次数,设计上也十分精妙。唯一缺点是新数组元素是乱序。
2.Leetcode 26 移动排序数组中的重复项
难度:⭐️
这道题如果使用暴力解法,需要新建一个数组来存放无重复元素,时间复杂度为O(n),空间复杂度为O(n)。用双指针的解法,空间复杂度降为O(1)。
这道题的难点在于判断重复项的条件。我第一次做的时候,也是想了很久,经历一次、二次失败提交后,最后想出了一个合理的解法。
- 首先第一个数必定不重复,于是让fastIndex=1
- 判断重复项时,既要保证当前fast和slow所指的值不重复(此时将fast的值给slow),也得保证slow和slow-1不重复。所以直接比较fast和slow-1的值是否相等即可
- (举个例子:[0,1,1,2,2,2,3,3],如果一开始判断的是fast=1和slow=1,那结果是[0,1,2,3,3],如果是fast=1和slow=0,则正确)
- 一开始,先判断fastIndex=1和slowIndex=0,如果不等,则将当前fast的值赋给slow-1下一个位置(slow)
- 其实我们比较fast和slow-1的时候,并不需要关注slow的值是什么(是否和slow重复),因为一旦fast不等于slow-1,将fast赋给slow之后,slow肯定也不等于slow-1
3.Leetcode 977 有序数组的平方(题解)
难度:⭐️
这道题的难点是如何对有序数组按平方值大小排序。暴力解法是先计算出每个数的平方,然后用sort函数排序,时间复杂度O(logn),空间复杂度O(1)。
最优解是双指针,考虑到平方最大值只会出现在数组两端,因此分别在两端创建一个指针,不断朝中间收缩即可。
如动画所示:
滑动窗口:
1.Leetcode 209 长度最小的子数组(题解)
难度:⭐️⭐️
这道题要求找到一个长度最短的连续子数组,使得其中每个元素之和大于target。暴力解法是从第一个元素开始,向后遍历所有的元素并累积求和,直到大于target位置停止,保存子数组长度。接着从第二个元素开始遍历,以此类推,最后返回最小的子数组长度。此方法的时间复杂度为O(n2),空间复杂度为O(1)。运行时会提示超时。
接下来就开始介绍数组操作中另一个重要的方法:滑动窗口。
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
那么滑动窗口如何用一个for循环来完成这个操作呢?
如果只用一个for循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?此时难免再次陷入 暴力解法的怪圈。
所以 只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么问题来了, 滑动窗口的起始位置如何移动呢?
这里还是以题目中的示例来举例,s=7, 数组是 2,3,1,2,4,3,来看一下查找的过程:
最后找到 4,3 是最短距离。
其实从动画中可以发现滑动窗口也可以理解为双指针法的一种!只不过这种解法更像是一个窗口的移动,所以叫做滑动窗口更适合一些。
在本题中实现滑动窗口,主要确定如下三点:
- 窗口内是什么?
- 如何移动窗口的起始位置?
- 如何移动窗口的结束位置?
窗口就是 满足其和 ≥ s 的长度最小的 连续 子数组。
窗口的起始位置如何移动:如果当前窗口的值大于s了,窗口就要向前移动了(也就是该缩小了)。
窗口的结束位置如何移动:窗口的结束位置就是遍历数组的指针,也就是for循环里的索引。
解题的关键在于 窗口的起始位置如何移动,如图所示:
可以发现滑动窗口的精妙之处在于满足当前子序列和大小的情况,不断调节子序列的起始位置,通过不断滑动窗体,找到子数组的长度最小值,从而将O(n^2)暴力解法降为O(n)。
- 时间复杂度:O(n)
- 空间复杂度:O(1)
一些人可能会疑惑为什么时间复杂度是O(n)。
不要以为for里放一个while就以为是O(n^2)啊, 主要是看每一个元素被操作的次数,每个元素在滑动窗后进来操作一次,出去操作一次,每个元素都是被操作两次,所以时间复杂度是 2 × n 也就是O(n)。
这道题目还可以使用前缀和+二分法来做,时间复杂度为O(nlogn),空间复杂度为O(n),我自己写的代码会提示超时。不过使用题解的代码,可以通过。(题解直接调用了lower_bound函数来计算二分)
2.Leetcode904 水果成篮
难度:⭐️⭐️⭐️⭐️
这道题的意思,相当于求最多只包含两种元素的连续子数组的最大长度。
这道题如果用暴力解法,依次从每个起始位置遍历,直到遇到第三个新元素结束,记录并比较最大数组长度,时间复杂度为O(n2),空间复杂度为O(1)。运行会提示超时。
最佳解法是滑动窗口,不过相比上一道题用双指针l, r分别记录窗体的起始和结束位置,还需要再额外用一个指针n记录窗体起始位置更新后的新位置(即将被砍掉水果最后出现位置的下一个位置)。
思路:用滑动窗口遍历fruits,当有新种类的水果进入窗口时,做以下判断
- 如果窗口中只有一种水果(进来的是第二种水果),将这种水果加入basket数组
- 如果窗口中已经有两种水果(进来的是第三种水果),更新窗口的左边界l=n(砍掉第一种水果),更新basket中水果的种类(用留下来的、第三种水果,替换原来第一、第二种,即basket[0]=fruits[n], basket[1]=fruits[r])
- 如果进来的水果类型,和之前记录的最后出现过的水果类型不同(fruits[r]!= fruits[n]) 更新前一种水果结束位置的下一个位置(n=r),这样可以保证下次砍掉的元素,是窗体中最后出现时间最远的那个元素(砍完后,窗体中仅剩一种元素,也就是最新出现过的那种元素)。
- 计算当前窗体大小(r-l+1),并判断是否更新最大窗体值
本题的精髓和难点在于第三个指针n,重点是理解它的意义。当l移动到n之后,窗体中现有的两种元素中,最久没有新出现过的那种元素便会全部砍掉。因此只需要用fruits[n]记录一下最新出现的水果种类,之后每当最新出现一种新水果时,更新一下n的位置到该新水果即可。特别的,当两种已知水果类型轮流出现时,会不断更新n的位置(谁最后出现,另一种下次被砍)
举例:[1,2,2,2,1,2,2,3] ,一开始n=0,当r=1时,fruits[r]!=fruits[n](2!=1),更新n=r(n=1),此时n代表即将被砍掉元素的最后出现位置到下一个位置(元素1将被砍)。之后r继续遍历到4,fruits[r]!=fruits[n](1!=2),这时更新n=r(n=4),下次砍掉时,元素2将被砍。同理,更新n=5(元素1将被砍)。当r=7时,出现第三种元素,l=n(l=5),元素1已被砍,更新n=7(下次元素2将被砍)
具体实现代码(详细注释)。
3. Leetcode76 最小覆盖子串
难度:⭐️⭐️⭐️⭐️⭐️
我们可以用滑动窗口的思想解决这个问题。在滑动窗口类型的问题中都会有两个指针,一个用于「延伸」现有窗口的 r指针,和一个用于「收缩」窗口的 l指针。在任意时刻,只有一个指针运动,而另一个保持静止。我们在 s 上滑动窗口,通过移动 r 指针不断扩张窗口。当窗口包含 t 全部所需的字符后,如果能收缩,我们就收缩窗口直到得到最小窗口。
如何判断当前的窗口包含所有 t 所需的字符呢?我们可以用一个哈希表表示 t 中所有的字符以及它们的个数,用一个哈希表动态维护窗口中所有的字符以及它们的个数,如果这个动态表中包含
t 的哈希表中的所有字符,并且对应的个数都不小于 t 的哈希表中各个字符的个数,那么当前的窗口是「可行」的。
- 首先建立两个哈希表(unordered_map) ori, des,分别记录t,以及滑动窗口中的s的所有字符的个数
- 然后开始滑动窗口,每次通过比较函数check()确定是否ori中所有字符的个数小于des中对应字符(auto i:ori; i.second<des[i]),如果满足的话,更新一下窗口大小res,并记录此时窗口最左边的位置tmp_l。
- 遍历完成后,如果res未被更新过,返回string()空字符串,否则返回子串return s.substr(tmp_l, res)
具体实现代码(详细注释)。
复杂度分析
时间复杂度:最坏情况下左右指针对 s 的每个元素各遍历一遍,哈希表中对 s 中的每个元素各插入、删除一次,对 t 中的元素各插入一次。每次检查是否可行会遍历整个 t 的哈希表,哈希表的大小与字符集的大小有关,设t中字符集大小为 C,则渐进时间复杂度为 O(C⋅∣s∣+∣t∣)。
空间复杂度:这里用了两张哈希表作为辅助空间,每张哈希表最多不会存放超过字符集大小的键值对,我们设字符集大小为 C ,则渐进空间复杂度为 O(C)。
题解中还提到了三点优化,可以再扩展研究一下具体的实现。
其中一个改进方式,是将check()替换为int count,每当s中新出现的字符位于t中,且个数没超过t时,count++,当count=t.size()时,s包含t中全部字符。使用这种方法,时间复杂度为O(|s|+|t|),空间复杂度为O(|D|+|C|),其中D为s中字符集的大小。
(因为在遍历s时,代码将无用字符的判定条件ori.find(s[r])!=ori.end()改为了ori[s[r]]!=0,这样会将s中(包括t没有的)所有字符加入ori中,导致空间复杂度从O(|C|)提高到O(|D|)),但时间复杂度从O(|C|)降低为O(1)。同理,check()==true改为count==t.size(),将时间复杂度从O(|C|)降低为O(1)。)
螺旋矩阵:
1.Leetcode 59 螺旋矩阵II(题解)
难度:⭐️⭐️⭐️
模拟行为的题目在数组中很常见,不涉及到什么算法,就是单纯的模拟,十分考察大家对代码的掌控能力。
这道题的难点在于如何判断每次循环的开始和结束条件,这里需要用到循环不变量原则。
- 一共循环n/2圈,如果n为奇数,最后单独赋中心位置值。
- 每圈分为右、下、左、上四条边,保持左闭右开的循环不变量原则。
- 每圈循环左上角起始位置(startx=0, starty=0),结束后+1。
- 每圈设置一个偏移量offset,用于判断边的结束条件,初值为1,每圈结束后+1。
按照左闭右开的原则,来画一圈,大家看一下:
这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。
这也是坚持了每条边左闭右开的原则。
一些同学做这道题目之所以一直写不好,代码越写越乱。
就是因为在画每一条边的时候,一会左开右闭,一会左闭右闭,一会又来左闭右开,岂能不乱。
具体实现代码。
2.Leetcode 54 旋转矩阵
难度:⭐️⭐️⭐️⭐️
这道题在上题的基础上,增加了长方形矩阵的情况,更加复杂一些,但大体思路是一样的。
- startx, starty, offset的定义与上题不变
- 循环次数改为loop=min(m,n)/2
- 最后循环后的处理,分m<=n, m>n两种情况,分别从(startx, starty)处,朝右/下遍历至j=n-offset/m-offset。(注意这里是等号,不是小于号)
具体实现代码。
总结
文中部分内容参考自:代码随想录