3

精读《算法 - 滑动窗口》

 2 years ago
source link: https://zhuanlan.zhihu.com/p/380707273
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

精读《算法 - 滑动窗口》

前端开发话题下的优秀答主

滑动窗口算法是较为入门题目的算法,一般是一些有规律数组问题的最优解,也就是说,如果一个数组问题可以用动态规划解,但又可以使用滑动窗口解决,那么往往滑动窗口的效率更高。

双指针也并不局限在数组问题,像链表场景的 “快慢指针” 也属于双指针的场景,其快慢指针滑动过程中本身就会产生一个窗口,比如当窗口收缩到某种程度,可以得到一些结论。

因此掌握滑动窗口非常基础且重要,接下来按照我的经验给大家介绍这个算法。

精读

滑动窗口使用双指针解决问题,所以一般也叫双指针算法,因为两个指针间形成一个窗口。

什么情况适合用双指针呢?一般双指针是暴力算法的优化版,所以:

  1. 如果题目较为简单,且是数组或链表问题,往往可以尝试双指针是否可解。
  2. 如果数组存在规律,可以尝试双指针。
  3. 如果链表问题限制较多,比如要求 O(1) 空间复杂度解决,也许只有双指针可解。

也就是说,当一个问题比较有规律,或者较为简单,或较为巧妙时,可以尝试双指针(滑动窗口)解法。

我们还是拿例子说明,首先是两数之和。

两数之和

两数之和是一道简单题,实际上和滑动窗口没什么关系,但为了引出三数之和,还是先讲这道题。题目如下:

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。

暴力解法就是穷举所有两数之和,发现和为 target 结束,显然这种做法有点慢,我们换一种思路。

由于可以用空间换时间,又只有两个数,我们可以对题目进行转化,即通过一次遍历,将 nums 每一项都减去 target,然后找到后面任意一项值为前面的结果,即表示它们和为 target

可以用哈希表 map 加速查询,即将每一项 target - num 作为 key,如果后面任何一个 num 作为 key 可以在 map 中找到,则得解,且上一个数的原始值可以存在 map 的 value 中。这要仅需遍历一次,时间复杂度为 O(n)。

之所以说这道题,是因为这道题是单指针,即只有一个指针在数组中移动,并配合哈希表快速求解。对于稍微复杂的问题,单指针就不够了,需要用双指针解决(一般来说不会用到三或以上指针),那复杂点的题目就是三数之和了。

三数之和

三数之和是一道中等题,别以为只是两数之和的加强版,其思路完全不同。题目如下:

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 abc ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。

由于超过了两个数,所以不能像双指针一样求解了,因为即便用了哈希表存储,也会在遍历时遇到 “两数之和” 的问题,而哈希表方案无法继续嵌套使用,即无法进一步降低复杂度。

为了降低时间复杂度,我们希望只遍历一次数组,这就需要数组满足一定条件我们才能用滑动窗口,所以我们对数组进行排序,使用快排的时间复杂度为 O(nlogn),时间复杂度已超出两数之和,不过因为题目复杂,这个牺牲是无法避免的。

假设从小到大排序,那我们就拿到一个递增数组了,此时经典滑动窗口方法就可用了!怎么滑动呢?首先创建两个指针,分别叫 leftright,通过不断修改 leftright,让它们在数组间滑动,这个窗口大小就是符合题目要求的,当滑动完毕时,返回所有满足条件的窗口即可,记录其实很简单,只要在滑动过程中记录一下就行。

首先排除异常值,即数组长度过小,然后对于常规情况,我们拿一个全局变量存储当前窗口数的和,这样 right + 1 只要累加 nums[right+1]left + 1 只要减去 nums[left] 即可快速拿到求和。

由于需要考虑所有情况,所以需要一次数组遍历,对于每次遍历的起始点 i,如果 nums[i] > 0 则直接跳过,因为数组排序后是递增的,后面的和只会永远大于 0;否则进行窗口滑动,先形成三个点 [i, i+1, n-1],这样保持 i 不动,不断包夹后两个数字即可,只要它们的和大于 0,就将第三个点左移(数字会变小),否则将第二个点右移(数字会变大),其实第二个和第三个数就是滑动窗口。

这样的话时间复杂度是 O(n²),因为存在两次遍历,忽略快排较小的时间复杂度。

那么四数之和,五数之和呢?

四数之和

该题和三数之和完全一样,除了要求变成四个数。

首先还是排序,然后双重递归,即确定前两个数不变,不断包夹后两个数,后两个数就是 i+1n-1,算法和三数之和一样,所以最终时间复杂度为 O(n³)。

那么 N 数之和(N > 2)都可以采用这个思路解决。

为什么没有更优的方法呢?我想可能因为:

  1. 无论几数之和,快排一次时间复杂度都是固定的,所以沿用三数之和的方案其实占了排序算法便宜。
  2. 滑动窗口只能用两个指针进行移动,而没有三指针但又保持时间复杂度不变的窗口滑动算法存在。

所以对于 N 数之和,通过排序付出了 O(nlogn) 时间复杂度之后,可以用滑动窗口,将 2 个数时间复杂度优化为 O(n),所以整体时间复杂度就是 O(N - 2 + 1 个 n),即 O(N-1 个 n),而最小的时间复杂度 O(n²) 比 O(nlogn) 大,所以总是忽略快排的时间复杂度,所以三数之和时间复杂度是 O(n²),四数之和时间复杂度为 O(n³),依此类推。

可以看到,我们从最简单的两数之和,到三数之和、四数之和,跨入了滑动窗口的门槛,本质上是利用排序后数组有序的特性,让我们在不用遍历数组的前提下,可以对窗口进行滑动,这是滑动窗口算法的核心思想。

为了加强这个理解,再看一道类似的题目,无重复字符的最长子串。

无重复字符的最长子串

无重复字符的最长子串是一道中等题,题目如下:

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

由于最长子串是连续的,所以显然可以考虑滑动窗口解法。其实确定了滑动窗口解法后,问题很简单,只要设定 leftright,并用一个哈希 Set 记录哪些元素存在过,在过程中记录最大长度,并尝试 right 右移,如果右移过程中发现出现重复字符,则 left 右移,直到消除这个重复字符为止。

解法并不难,但问题是,我们要想清楚,为什么用滑动窗口遍历一次就可以做到 不重不漏?即这道题时间复杂度只有 O(n) 呢?

只要想明白两个问题:

  1. 由于子串是连续的,既然不存在跳跃的情况,只要一次滑动窗口内能包含所有解,就涵盖了所有情况。
  2. 一次滑动窗口内不包含什么?由于我们只将 right 右移,且出现重复后尝试将 left 右移到不重复后,right 再继续右移,这忽略了出现重复后, right 左移的情况。

我们重点看二个问题,显然,如果 abcd 这四个连续的字符不重复,那么 left 右移后,bcd 也显然不重复,所以如果此时就可以将 right 右移形成 bcda 的窗口继续找下去,而不需要尝试 bc 这种情况,因为这种情况虽然不重复,但一定不是最优解。

好了,通过这个例子我们看到,滑动窗口如何缩小窗口范围其实不难,但更要注重的是,背后对于为什么可以用滑动窗口的思考,滑动窗口有没有做到不重不漏,如果没有想清楚,可能整个思路都错了。

那么滑动窗口的应用已经说透了?其实没有,我们上面只说了缩小窗口这种比较单一的脑回路,其实双指针构成的滑动窗口不一定都是那么正常滑的,一种有意思的场景是快慢指针,即是以相对速度决定窗口如何滑动。

关于快慢指针,经典的题目有环形链表、删除有序数组中的重复项。

环形链表

环形链表是一道简单题,题目如下:

给定一个链表,判断链表中是否有环。

如果不是进阶要求空间复杂度 O(1),我们可以在遍历时稍稍 “污染” 一下原始链表,这样总能发现是否走了回头路。

但要求空间开销必须是常数,我们不得不考虑快慢指针。说实话第一次看到这道题时,如果能想到快慢指针的解法,绝对是相当聪明的,因为必须要有知识迁移的能力。怎么迁移呢?想象学校在开运动会,相信每次都有一个跑的最慢的同学,慢到被最快的同学追了一圈。

等等,操场不就是环形链表吗?只要有人跑得慢,就会被跑得快的追上,追上不就是相遇了吗? 所以快慢指针分别跑,只要相遇则判定为环形链表,否则不是环形链表,且一定有一个指针先走完。

那么细枝末节就是优化效率了,慢指针到底慢多少呢?

有人会说,运动会上,跑步慢的人如果想被快的人追上,最好就不要跑。对,但环形链表问题中,链表不是操场,可能只有某一段是环,也就是跑步慢的人至少要跑到环里,才可能与跑得快人的相遇,但跑得慢的人又不知道哪里开始成环,这就是难点。

你有没有想过,为什么快排用二分法,而不是三分法?为什么每次中间来一刀,可以最快排完?原因是二分可以用最小的 “深度” 将数组切割为最小粒度。那么同理,快慢指针中,慢指针要想被尽快追上,速度可能最好是快指针的一半。那从逻辑上分析,为什么呢?

直观来看,如果慢指针太慢,可能大部分时间都在进入环形之前的位置转悠,快指针虽然快,但永远在环里跑,所以总是无法遇到慢指针,这给我们的启示是,慢指针不能太慢;如果慢指针太快,几乎速度和快指针一样,就像两个运动员都互不相让的争夺第一一样,他们真的想相遇,估计得连续跑几个小时吧,所以慢指针也不能过快。所以这样分析下来,慢指针只能取折中的一半速度。

但用一半的慢速真的能最快相遇吗?不一定,举一个例子,假设链表是完美环形,一共有 [1,6] 共 6 个节点,那么慢指针一次走 1 步,快指针一次走 2 步,那么一共是 2,3 3,5 4,1 5,3 6,5 1,1 共走 6 步,但如果快指针一次走 3 步呢?一共是 2,4 3,1 4,4 3 步。这么说一般速度不一定最优?其实不是的,计算机在链表寻址时,节点访问的消耗也要考虑进去,后者虽然看上去更快,但其实访问链表 next 的次数更多,对计算机来说,还不如第一种来得快。

所以准确来说,不是快指针比慢指针快一倍速度,而是慢指针一次走一步,快指针一次走两步最优,因为相遇时,总移动步数最少。

再说一个简单问题,即用快慢指针判断链表中倒数第k个节点或者链表中点。

判断链表中点

快指针是慢指针速度 2 倍,当快指针到达尾部,慢指针的位置就是链表中点。

链表中倒数第k个节点

链表中倒数第k个节点是一道简单题,题目如下:

输入一个链表,输出该链表中倒数第 k 个节点。为了符合大多数人的习惯,本题从 1 开始计数,即链表的尾节点是倒数第 1 个节点。

这道题就是判断链表中点的变种,只要让慢指针比快指针慢 k 个节点,当快指针到达末尾时,慢指针就指向倒数第 k+1 个节点了。这道题注意一下数数别数错了即可。

接下来终于说道快慢指针的另一种经典用法题型,删除有序数组中的重复项了。

删除有序数组中的重复项

删除有序数组中的重复项是一道简单题,题目如下:

给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。

这道题,要原地删除重复元素,并返回长度,所以只能用快慢指针。但怎么用呢?快多少慢多少?

其实这道题快多少慢多少并不像前面题目一样预设好了,而是根据遇到的实际数字来判断。

我们假设慢指针是 slow 快指针是 fast,注意变量命名也有意思,同样是双指针问题,有的是 slow right,有的是 slow fast,重点在于用何种方法移动指针。

我们只要让 fast 扫描完全表,把所有不重复的挪到一起就好了,这样时间复杂度是 O(n),具体做法是:

  1. slowfast 初始都指向 index 0。
  2. 由于是 有序数组,所以就算有重复也一定连在一起,所以可以让 fast 直接往后扫描,只有遇到和 slow 不同的值,才把其和 slow+1 交换,然后 slow 自增,继续递归,直到 fast 走到数组尾部结束。

做完这套操作后,slow 的下标值就是答案。

可以看到,这道题对于慢指针要如何慢,其实是根据值来判断的,如果 fast 的值与 slow 一样,那么 slow 就一直等着,因为相同的值要被忽略掉,让 fast 走就是在跳过重复值。

说完了常见的双指针用法,我们再来看一些比较难啃的特殊问题,这里主要讲两个,分别是 盛最多水的容器接雨水

盛最多水的容器

盛最多水的容器是一道中等题,题目如下:

给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai)(i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

建议先仔细读一读题目再继续,这道题相对比较复杂。

好了,为什么说这是一道双指针题目呢?因为我们看怎么计算容纳水的体积?其实这道题就简化为长乘宽。

长度就是选取的两个柱子的间距,宽就是其中最短柱子的高度。问题就是,虽然柱子间距越远,长度越大,但宽度不一定最大,一眼是没法看出来最优解的。

所以还是得多次尝试,那怎么样可以用最少的尝试次数,但又不重不漏呢?定义 left right 两个指针,分别指向 0n-1 即首尾两个位置,此时长度是最大的(柱子间距离是最远的),接下来尝试一下别的柱子,试哪个呢?

  • 较长的那个?如果新的比较短的更短,那么宽度更短了;如果新的比较短的更长,也没用,因为较短的决定了水位。
  • 较短的那个?如果新的较长,那么才有机会整体体积更大。

所以我们移动较短的那个,并每次计算一下体积,最后当两根柱子相遇时结束,过程中最大体积就是全局最大体积。

这道题双指针的移动规则比较巧妙,与上面普通题目不一样,重点不是在是否会运用滑动窗口算法,而是能否找到移动指针的规则。

当然你可能会说,为什么两个指针要定义在最两端,而非别的地方?因为这样就无法控制变量了。

如果指针选在中间位置,那么指针外移时,柱子的间距与柱子长度同时变化,就很难找到一条完美路线。比如我们移动较短的柱子,是因为较短的柱子确定了最低水位,改变它,可能让最低水位变高,但问题是两根柱子的间距也在变大,这样移动较短还是较长的柱子哪个更优就说不准了。

说实话这种方法不太容易想到,需要多找几种选择尝试才能发现。当然,算法如果按照固定套路就能推导出来,也就没有难度了,所以要接受这种思维跳跃。

接下来我们看一道更特殊的滑动窗口问题,接雨水,它甚至分为多段滑动窗口。

接雨水

接雨水是一道困难题,题目如下:

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

与盛雨水不同,这道接雨水看的是整体,我们要算出能接的所有水的数量。

其实相比上一道题,这道题还算比较好切入,因为我们从左到右计算即可。思考发现,只有产生了 “凹槽” 才能接到雨水,而凹槽由它两边最高的柱子决定,那什么范围算一段凹槽呢?

显然凹槽是可以明确分组的,一个凹槽也无法被分割为多个凹槽,就像你看水坑一样,无论有多少,多深的坑在一起,总能一个一个数清楚,所以我们就从左到右开始数。

怎么数凹槽呢?用滑动窗口办法,每个窗口就是一个凹槽,那么窗口的起点 left 就是左边第一根柱子,有以下情况:

  • 如果直接相邻的右边柱子更高(或一样高),那从它开始向右看,根本无法接雨水,所以直接抛弃,left++
  • 如果直接相邻的右边柱子更矮,那就有产生凹槽的机会。
    • 那么继续往右看,如果右边一直都更矮,那也接不到雨水。
    • 如果右边出现一个高一些的,就可以接到雨水,那问题是怎么算能接多少,以及找到哪结束呢?
      • 只要记录最左边柱子高度,右边柱子的结束判断条件是 “遇到一个与最左边一样高的柱子”,因为一个凹槽能接多少水,取决于最短的柱子。当然,如果右边没有柱子了,虽然比最左边低一点,但只要比最深的高,也算一个结束点。

这道题,一旦遇到凹槽结束点,left 就会更新,开始新的一轮凹槽计算,所以存在多个滑动窗口。从这道题可以看出,滑动窗口题型相当灵活,不仅判断条件因题而异,窗口数量可能也有多个。

总结

滑动窗口本质是双指针的玩法,不同题目有不同的套路,从最简单的按照规律包夹,到快慢指针,再到无固定套路的因题而异的特殊算法。

其实按照规律包夹的套路属于碰撞指针范畴,一般对于排序好的数组,可以一步一步判断,或者用二分法判断,总之不用根据整体遍历来判断,效率自然高。

快慢指针也有套路可循,但具体快多少,或者慢多少,可能具体场景要具体看。

对于无固定套路的滑动窗口,就要根据题目仔细品味啦,如果所有套路都能总结出来,算法也少了乐趣。

讨论地址是:精读《算法 - 滑动窗口》· Issue #328 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

v2-d46b608443dd2afa3dea21b96236fa06_720w.jpg

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK