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

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

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

## 精读

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

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

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` 中是否存在三个元素 `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）都可以采用这个思路解决。

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

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³)，依此类推。

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

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

### 无重复字符的最长子串

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

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

由于最长子串是连续的，所以显然可以考虑滑动窗口解法。其实确定了滑动窗口解法后，问题很简单，只要设定 `left` 和 `right`，并用一个哈希 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. 让 `slow` 和 `fast` 初始都指向 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` 轴共同构成的容器可以容纳最多的水。

<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](https://github.com/dt-fe/weekly/issues/328)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
