滑动窗口算法是较为入门题目的算法,通常是一些有规律数组问题的最优解,也就是说,若是一个数组问题能够用动态规划解,但又可使用滑动窗口解决,那么每每滑动窗口的效率更高。前端
双指针也并不局限在数组问题,像链表场景的 “快慢指针” 也属于双指针的场景,其快慢指针滑动过程当中自己就会产生一个窗口,好比当窗口收缩到某种程度,能够获得一些结论。git
所以掌握滑动窗口很是基础且重要,接下来按照个人经验给你们介绍这个算法。github
滑动窗口使用双指针解决问题,因此通常也叫双指针算法,由于两个指针间造成一个窗口。算法
什么状况适合用双指针呢?通常双指针是暴力算法的优化版,因此:数组
也就是说,当一个问题比较有规律,或者较为简单,或较为巧妙时,能够尝试双指针(滑动窗口)解法。微信
咱们仍是拿例子说明,首先是两数之和。优化
两数之和是一道简单题,实际上和滑动窗口没什么关系,但为了引出三数之和,仍是先讲这道题。题目以下:指针
给定一个整数数组
nums
和一个整数目标值target
,请你在该数组中找出 和为目标值target
的那 两个 整数,并返回它们的数组下标。code你能够假设每种输入只会对应一个答案。可是,数组中同一个元素在答案里不能重复出现。cdn
暴力解法就是穷举全部两数之和,发现和为 target
结束,显然这种作法有点慢,咱们换一种思路。
因为能够用空间换时间,又只有两个数,咱们能够对题目进行转化,即经过一次遍历,将 nums
每一项都减去 target
,而后找到后面任意一项值为前面的结果,即表示它们和为 target
。
能够用哈希表 map
加速查询,即将每一项 target - num
做为 key,若是后面任何一个 num
做为 key 能够在 map
中找到,则得解,且上一个数的原始值能够存在 map
的 value 中。这要仅需遍历一次,时间复杂度为 O(n)。
之因此说这道题,是由于这道题是单指针,即只有一个指针在数组中移动,并配合哈希表快速求解。对于稍微复杂的问题,单指针就不够了,须要用双指针解决(通常来讲不会用到三或以上指针),那复杂点的题目就是三数之和了。
三数之和是一道中等题,别觉得只是两数之和的增强版,其思路彻底不一样。题目以下:
给你一个包含n
个整数的数组nums
,判断nums
中是否存在三个元素a
,b
,c
,使得a + b + c = 0
?请你找出全部和为0
且不重复的三元组。
因为超过了两个数,因此不能像双指针同样求解了,由于即使用了哈希表存储,也会在遍历时遇到 “两数之和” 的问题,而哈希表方案没法继续嵌套使用,即没法进一步下降复杂度。
为了下降时间复杂度,咱们但愿只遍历一次数组,这就须要数组知足必定条件咱们才能用滑动窗口,因此咱们对数组进行排序,使用快排的时间复杂度为 O(nlogn),时间复杂度已超出两数之和,不过由于题目复杂,这个牺牲是没法避免的。
假设从小到大排序,那咱们就拿到一个递增数组了,此时经典滑动窗口方法就可用了!怎么滑动呢?首先建立两个指针,分别叫 left
与 right
,经过不断修改 left
与 right
,让它们在数组间滑动,这个窗口大小就是符合题目要求的,当滑动完毕时,返回全部知足条件的窗口便可,记录其实很简单,只要在滑动过程当中记录一下就行。
首先排除异常值,即数组长度太小,而后对于常规状况,咱们拿一个全局变量存储当前窗口数的和,这样 right + 1
只要累加 nums[right+1]
,left + 1
只要减去 nums[left]
便可快速拿到求和。
因为须要考虑全部状况,因此须要一次数组遍历,对于每次遍历的起始点 i
,若是 nums[i] > 0
则直接跳过,由于数组排序后是递增的,后面的和只会永远大于 0;不然进行窗口滑动,先造成三个点 [i, i+1, n-1]
,这样保持 i
不动,不断包夹后两个数字便可,只要它们的和大于 0,就将第三个点左移(数字会变小),不然将第二个点右移(数字会变大),其实第二个和第三个数就是滑动窗口。
这样的话时间复杂度是 O(n²),由于存在两次遍历,忽略快排较小的时间复杂度。
那么四数之和,五数之和呢?
该题和三数之和彻底同样,除了要求变成四个数。
首先仍是排序,而后双重递归,即肯定前两个数不变,不断包夹后两个数,后两个数就是 i+1
和 n-1
,算法和三数之和同样,因此最终时间复杂度为 O(n³)。
那么 N 数之和(N > 2)均可以采用这个思路解决。
为何没有更优的方法呢?我想可能由于:
因此对于 N 数之和,经过排序付出了 O(nlogn) 时间复杂度以后,能够用滑动窗口,将 2 个数时间复杂度优化为 O(n),因此总体时间复杂度就是 O(N - 2 + 1 个 n),即 O(N-1 个 n),而最小的时间复杂度 O(n²) 比 O(nlogn) 大,因此老是忽略快排的时间复杂度,因此三数之和时间复杂度是 O(n²),四数之和时间复杂度为 O(n³),依此类推。
能够看到,咱们从最简单的两数之和,到三数之和、四数之和,跨入了滑动窗口的门槛,本质上是利用排序后数组有序的特性,让咱们在不用遍历数组的前提下,能够对窗口进行滑动,这是滑动窗口算法的核心思想。
为了增强这个理解,再看一道相似的题目,无重复字符的最长子串。
无重复字符的最长子串是一道中等题,题目以下:
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。
因为最长子串是连续的,因此显然能够考虑滑动窗口解法。其实肯定了滑动窗口解法后,问题很简单,只要设定 left
和 right
,并用一个哈希 Set 记录哪些元素存在过,在过程当中记录最大长度,并尝试 right
右移,若是右移过程当中发现出现重复字符,则 left
右移,直到消除这个重复字符为止。
解法并不难,但问题是,咱们要想清楚,为何用滑动窗口遍历一次就能够作到 不重不漏?即这道题时间复杂度只有 O(n) 呢?
只要想明白两个问题:
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
个节点。为了符合大多数人的习惯,本题从1
开始计数,即链表的尾节点是倒数第1
个节点。
这道题就是判断链表中点的变种,只要让慢指针比快指针慢 k
个节点,当快指针到达末尾时,慢指针就指向倒数第 k+1
个节点了。这道题注意一下数数别数错了便可。
接下来终于说道快慢指针的另外一种经典用法题型,删除有序数组中的重复项了。
删除有序数组中的重复项是一道简单题,题目以下:
给你一个有序数组
nums
,请你
原地 删除重复出现的元素,使每一个元素 只出现一次 ,返回删除后数组的新长度。
这道题,要原地删除重复元素,并返回长度,因此只能用快慢指针。但怎么用呢?快多少慢多少?
其实这道题快多少慢多少并不像前面题目同样预设好了,而是根据遇到的实际数字来判断。
咱们假设慢指针是 slow
快指针是 fast
,注意变量命名也有意思,一样是双指针问题,有的是 slow right
,有的是 slow fast
,重点在于用何种方法移动指针。
咱们只要让 fast
扫描彻底表,把全部不重复的挪到一块儿就行了,这样时间复杂度是 O(n),具体作法是:
slow
和 fast
初始都指向 index 0。fast
直接日后扫描,只有遇到和 slow
不一样的值,才把其和 slow+1
交换,而后 slow
自增,继续递归,直到 fast
走到数组尾部结束。作完这套操做后,slow
的下标值就是答案。
能够看到,这道题对于慢指针要如何慢,实际上是根据值来判断的,若是 fast
的值与 slow
同样,那么 slow
就一直等着,由于相同的值要被忽略掉,让 fast
走就是在跳太重复值。
说完了常见的双指针用法,咱们再来看一些比较难啃的特殊问题,这里主要讲两个,分别是 盛最多水的容器 与 接雨水。
盛最多水的容器是一道中等题,题目以下:
给你n
个非负整数a1,a2,...,an
,每一个数表明坐标中的一个点(i, ai)
。在坐标内画n
条垂直线,垂直线i
的两个端点分别为(i, ai)
和(i, 0)
。找出其中的两条线,使得它们与x
轴共同构成的容器能够容纳最多的水。
<img width=400 src="https://z3.ax1x.com/2021/06/12/25WZZt.png">
建议先仔细读一读题目再继续,这道题相对比较复杂。
好了,为何说这是一道双指针题目呢?由于咱们看怎么计算容纳水的体积?其实这道题就简化为长乘宽。
长度就是选取的两个柱子的间距,宽就是其中最短柱子的高度。问题就是,虽然柱子间距越远,长度越大,但宽度不必定最大,一眼是无法看出来最优解的。
因此仍是得屡次尝试,那怎么样能够用最少的尝试次数,但又不重不漏呢?定义 left
right
两个指针,分别指向 0
与 n-1
即首尾两个位置,此时长度是最大的(柱子间距离是最远的),接下来尝试一下别的柱子,试哪一个呢?
因此咱们移动较短的那个,并每次计算一下体积,最后当两根柱子相遇时结束,过程当中最大致积就是全局最大致积。
这道题双指针的移动规则比较巧妙,与上面普通题目不同,重点不是在是否会运用滑动窗口算法,而是可否找到移动指针的规则。
固然你可能会说,为何两个指针要定义在最两端,而非别的地方?由于这样就没法控制变量了。
若是指针选在中间位置,那么指针外移时,柱子的间距与柱子长度同时变化,就很难找到一条完美路线。好比咱们移动较短的柱子,是由于较短的柱子肯定了最低水位,改变它,可能让最低水位变高,但问题是两根柱子的间距也在变大,这样移动较短仍是较长的柱子哪一个更优就说不许了。
说实话这种方法不太容易想到,须要多找几种选择尝试才能发现。固然,算法若是按照固定套路就能推导出来,也就没有难度了,因此要接受这种思惟跳跃。
接下来咱们看一道更特殊的滑动窗口问题,接雨水,它甚至分为多段滑动窗口。
接雨水是一道困难题,题目以下:
给定n
个非负整数表示每一个宽度为1
的柱子的高度图,计算按此排列的柱子,下雨以后能接多少雨水。
<img width=400 src="https://z3.ax1x.com/2021/06/12/25OejP.png">
与盛雨水不一样,这道接雨水看的是总体,咱们要算出能接的全部水的数量。
其实相比上一道题,这道题还算比较好切入,由于咱们从左到右计算便可。思考发现,只有产生了 “凹槽” 才能接到雨水,而凹槽由它两边最高的柱子决定,那什么范围算一段凹槽呢?
显然凹槽是能够明确分组的,一个凹槽也没法被分割为多个凹槽,就像你看水坑同样,不管有多少,多深的坑在一块儿,总能一个一个数清楚,因此咱们就从左到右开始数。
怎么数凹槽呢?用滑动窗口办法,每一个窗口就是一个凹槽,那么窗口的起点 left
就是左边第一根柱子,有如下状况:
left++
。若是直接相邻的右边柱子更矮,那就有产生凹槽的机会。
若是右边出现一个高一些的,就能够接到雨水,那问题是怎么算能接多少,以及找到哪结束呢?
这道题,一旦遇到凹槽结束点,left
就会更新,开始新的一轮凹槽计算,因此存在多个滑动窗口。从这道题能够看出,滑动窗口题型至关灵活,不只判断条件因题而异,窗口数量可能也有多个。
滑动窗口本质是双指针的玩法,不一样题目有不一样的套路,从最简单的按照规律包夹,到快慢指针,再到无固定套路的因题而异的特殊算法。
其实按照规律包夹的套路属于碰撞指针范畴,通常对于排序好的数组,能够一步一步判断,或者用二分法判断,总之不用根据总体遍从来判断,效率天然高。
快慢指针也有套路可循,但具体快多少,或者慢多少,可能具体场景要具体看。
对于无固定套路的滑动窗口,就要根据题目仔细品味啦,若是全部套路都能总结出来,算法也少了乐趣。
讨论地址是: 精读《算法 - 滑动窗口》· Issue #328 · dt-fe/weekly
若是你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版权声明:自由转载-非商用-非衍生-保持署名( 创意共享 3.0 许可证)