☰
双指针法、滑动窗口与螺旋矩阵:一篇文章吃透三类面试算法题
2026/9/29 8:58:48 网站建设 项目流程

双指针法、滑动窗口、螺旋矩阵,这三个名字并列出现,几乎就是给刷算法题的人画了一张必背清单。不管你是刚开始准备面试的新人,还是已经投了不少简历的老手,只要在力扣或者代码随想录这类地方按“热门”排过题单,都绕不开它们。我见很多人刷题时对这三类的评价很极端:双指针觉得简单,滑动窗口时好时坏,螺旋矩阵干脆靠背代码。其实这三个东西底层逻辑是连着的,理解了共性,根本不用死记。

这篇文章就把双指针法、滑动窗口、螺旋矩阵放在一起拆开讲,覆盖它们各自解决什么问题、核心代码骨架长什么样、哪些细节最容易写错,以及实战里怎么排查。适合正在集中刷题的人,也适合面试前想快速过一遍重点的人。

1. 为什么这三个算法总被绑在一起聊

1.1 一个共性:把暴力解法里的重复计算去掉

先别急着逐题去背,回头看它们的共同点。双指针法、滑动窗口、螺旋矩阵,本质都是对数组或矩阵这种线性结构做遍历,只是遍历策略不同。最笨的暴力法,通常是把所有可能区间、所有可能的起点终点都试一遍,复杂度动不动就是 O(n^2) 甚至更高。这三个算法做的事,就是让遍历过程“不回头、不重复”。

双指针法,是让两个指针从不同方向或不同速度前进,通过每次移动排除一批不可能的解,把搜索空间从 O(n^2) 压到 O(n)。滑动窗口就更直观了,它本身是双指针法的一种特化形态:两个指针同向移动,中间夹出来的那段叫窗口。窗口扩张、收缩时,只在窗口边界吞吐数据,中间的数据不用重复计算。螺旋矩阵稍微特殊一点,它不属于双指针,本质是模拟题,按“上、右、下、左”的顺序转圈遍历矩阵,核心是边界控制。

打个生活化的比方。双指针像两个人从队伍两头往中间走,每走一步就排除掉一整块区域;滑动窗口像一个不断伸缩的取景框,框住需要观察的那一段,往里推一点、往外拉一点;螺旋矩阵则像沿着迷宫墙壁一直走,撞到墙就右转,直到把整面墙都摸完。它们的共同价值不是算法多高级,而是让你不要用最笨的方式把数据翻来覆去地看。

1.2 三种问题的识别信号与选型逻辑

做题第一步不是默写模板,而是判断该用哪一类。根据题目里的关键词猜方向,准确率很高。

双指针的识别信号通常是这几个:题目里出现“有序数组”“原地处理”“链表中有环”“移除元素”“两数之和”。这类题往往只要求“找到某个组合”或“原地修改数组”。核心特征是,你能从两端或者快慢两个位置出发,通过比较或条件判断来推进。

滑动窗口的识别信号是“连续”两个字。比如“连续子数组”“子串”“最长不重复子串”“长度最小的子数组”。只要问题要的是“满足某条件的连续区间的最大或最小值”,优先想滑动窗口。如果问题要求“恰好 K 个不同字符”这类约束,也是窗口收缩判断的常见场景。

螺旋矩阵的识别信号就比较简单粗暴:给一个 matrix,要求按螺旋顺序返回元素,或者反过来按螺旋填入数字。这种题没有特别深的数学原理,就是模拟。想要不稳,就得把边界的口径统一好。

选型逻辑也很朴素。先问自己:暴力做法慢在哪?如果是每个子区间都重新累加、重新统计,那就是没有利用区间之间的重叠信息,滑动窗口能复用。如果是要找一对满足条件的元素,从头到尾两层遍历会重复比较已经排除掉的组合,双指针利用有序性排除。如果只是遍历顺序特殊,那就别想捷径,老老实实把模拟边界维护好。

2. 双指针法:两种核心骨架一次吃透

2.1 左右夹逼:有序数组的常规武器

双指针法里最经典的就是左右指针从两端往中间走。最典型的题是“有序数组两数之和”:

bool twoSumSorted(vector<int>& nums, int target) { int l = 0, r = nums.size() - 1; while (l < r) { int sum = nums[l] + nums[r]; if (sum == target) { return true; } else if (sum < target) { ++l; } else { --r; } } return false; }

这段代码看起来简单,但每一步移动都有讲究。为什么 sum < target 时一定移动左指针?因为数组有序,此时 nums[l] + nums[r] 已经太小了,右指针已经指向当前区间最大的元素,再往左移动只会更小,所以只能让左指针向右走,增大加数。反过来,sum > target 时,左指针已经指向当前区间最小的元素,只能右指针向左走缩小加数。每次移动都排除掉一整行或一整列的可能性,搜索空间从 n^2 降到了 n。

我举个具体例子推演一遍。数组是 [1, 2, 4, 6, 8, 10],target 是 12。初始 l=0 指向 1,r=5 指向 10,sum=11,小于 12,于是 l 移到 1,指向 2;sum=12,命中。如果目标值是 14,那过程就是 sum=11 太小,l++; sum=12 还是太小,l++; sum=14,命中。你可以发现,整个过程没有任何一对数字被重复比较,因为一旦左指针越过某个位置,说明它和所有右侧元素的组合都已经被排除过了。

这里有个细节很多新人在实战里容易漏掉:如果题目要求返回所有不重复的组合,而不是只返回是否存在,那在找到一组解后,l 和 r 都要跳过重复元素,避免结果里出现相同组合。比如数组有 [1,1,2,7] 这种重复值,找到 1+7=8 后,如果左指针只加一,还是会指向另一个 1,就会产生重复解。正确做法是移动后继续 while 跳过相邻相同值。

2.2 快慢指针:链表的巡边神器

快慢指针是双指针法的第二个重要分支。最常见的应用是两个:链表成环判断、原地去重。

链表成环判断的核心代码长这样:

bool hasCycle(ListNode* head) { ListNode *slow = head, *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) return true; } return false; }

快指针每次走两步,慢指针每次走一步。如果链表有环,快指针最终会在环里追上慢指针;如果没环,快指针会先走到 nullptr。为什么在环里一定能追上?快指针比慢指针每轮多走一步,相对速度是 1。假设慢指针刚进环时,快指针在环内离它 k 步,那每走一轮,这个距离就减少 1,k 轮之后必然会碰上。这个过程跟环的长度无关,所以不用额外算数学,但面试官很爱追问一句“为什么一定会相遇”,能答出相对速度这个概念,印象分会好很多。

原地去重题则是快慢指针的数组版:

int removeDuplicates(vector<int>& nums) { int n = nums.size(); if (n == 0) return 0; int slow = 0; for (int fast = 1; fast < n; ++fast) { if (nums[fast] != nums[slow]) { ++slow; nums[slow] = nums[fast]; } } return slow + 1; }

这里的两个指针职责完全不同:slow 维护的是“已经处理好的不重复序列”的末端,fast 是往前探索的侦察兵。每当 fast 发现一个新值,就把它搬到 slow 的后面。你可以把 slow 当成一个写指针,fast 当成一个读指针。这个思路还能推广到“移除指定元素”“移动零”这类题,本质上都是同一套:读指针负责扫描,写指针负责记录有效信息,扫描到有用数据就往前推进写指针。

2.3 循环终止条件为什么必须 l < r

双指针最容易翻车的细节就是循环条件。左右夹逼型的题,条件几乎都是 while (l < r),不是 while (l <= r)。原因是如果允许 l == r,此时两个指针指向同一个元素,它的左右两侧区域都已经排除过了,继续处理这个中间元素没有意义,还可能造成重复访问甚至死循环。比如找三数之和这类题,外层循环已经把 i 固定住,内层 l 和 r 如果越过彼此,就会出现组合重复。

快慢指针的循环条件则是 while (fast && fast->next)。这个顺序不能反,必须先判断 fast 本身不是空指针,再判断 fast->next 不是空指针,否则在 fast 已经走到链表尾时,访问 fast->next 会直接抛空指针异常。我见过不止一个人面试时在这里卡住,写完代码后自己干瞪眼半天。

还有个大家一起踩过的坑是 int 溢出。有序数组两数之和类题目,如果数字范围很大,nums[l] + nums[r] 有可能爆 int,尤其是力扣的题目经常塞大数边界。稳妥的做法是提前转成 long long 再相加,或者用减法比较,比如判断 nums[r] == target - nums[l],避开加法溢出。这个点很多人不写出来,但真跑大数据用例时会挂。

3. 滑动窗口:一份模板应对全部子串子数组问题

3.1 窗口的伸缩逻辑与模板框架

滑动窗口能解决的问题集中在“连续子序列”这个范畴。暴力做法是枚举所有起点和终点,再对每个区间单独计算,复杂度 O(n^2) 甚至 O(n^3)。滑动窗口的思路完全不同:既然区间是连续的,那我从 [l, r] 挪到 [l, r+1] 时,只需要把新元素加入状态;要把左边界挪到 l+1 时,只需要移除 nums[l]。中间那些元素的状态完全复用,不需要重新计算。

先看一份通用模板,以下是无重复字符的最长子串的 C++ 实现:

int lengthOfLongestSubstring(string s) { unordered_set<char> window; int l = 0, ans = 0; for (int r = 0; r < s.size(); ++r) { while (window.count(s[r])) { window.erase(s[l]); ++l; } window.insert(s[r]); ans = max(ans, r - l + 1); } return ans; }

这个模板拆开就是三件事:右指针扩张、左指针收缩、更新答案。每个字符在循环中最多被加入一次、移除一次,所以整体复杂度 O(n)。

为什么先扩张再收缩?因为 r 指针每走一步,窗口就多了一个新元素,这个新元素可能打破窗口的合法性(比如出现了重复字符)。所以进入循环后第一件事是把 s[r] 尝试放进窗口;如果它和窗口已有的内容冲突,就不断从左边移除,直到窗口重新合法。只要任意时刻窗口都是合法状态,那在循环末尾用 r - l + 1 更新答案就一定是对的,不用额外判断条件。

如果看到这里觉得有点绕,可以想象一个不断伸缩的取景框。右边界每前进一步,就有一个新物体进入画面,如果画面里出现了重复的违禁品,左边界就往右缩,把违禁品挤出去。直到画面里每个元素都是合法的,这个时候画面长度就是一个候选答案。

3.2 可变窗口和固定窗口的切换

窗口有两种常见形态:可变窗口和固定窗口。上面模板属于可变窗口,左边界什么时候收缩由条件决定。另一类题是固定窗口大小,比如“大小为 K 且平均值最大的连续子数组”,这种题不需要 while 收缩,只需要当窗口长度超过 K 时把左边界往前推一步:

double findMaxAverage(vector<int>& nums, int k) { double sum = 0; for (int i = 0; i < k; ++i) sum += nums[i]; double ans = sum; for (int r = k; r < nums.size(); ++r) { sum += nums[r] - nums[r - k]; ans = max(ans, sum); } return ans / k; }

固定窗口的更新逻辑是:新元素进来,旧元素出去,窗口始终保持 K 大小。这里的窗口更像是“滑动的纱窗”,每次平移一步,不伸缩。往深了说,如果要求的是“滑动窗口的最大值/最小值”,那就不是简单双指针能解决的了,需要用单调队列来维护窗口内极值,因为只记录一个 max 的话,最大值被移出窗口时你不知道次大值是谁。这个扩展方向经常和滑动窗口一起考,值得单独找几道题练。

至于“滑动窗口的中位数”这类题,维护的数据结构会更复杂,通常需要有序集合或大小堆来平衡,但它依然遵守同一个大原则:增删数据时只在窗口两端操作,中间部分全部复用。先把基础窗口模板吃透,再去碰这些变体就不会慌。

3.3 别把算法窗口和信号处理的滑动窗口滤波搞混

这里额外提一句,因为有些同学搜资料时会搜到“滑动窗口滤波”“滑动窗口滤波 FPGA 实现”这类内容。信号处理领域里的滑动窗口,是取最近 N 个采样点的均值、中位数或加权值作为输出,硬件上用移位寄存器就能做,窗口大小固定、每个时刻都平移一格。算法题里的滑动窗口,是为了在约束条件下找最优子区间,窗口大小可能动态伸缩,且面向的是“最长/最短/恰好满足条件”这类优化目标。二者名字一样,底层都有“维护一个最近的连续集合,增删只发生在窗口两端”的思想,但目标完全不同。如果是为了面试刷题,就专注算法题这个版本;如果是为了做 DSP 或者 Verilog 设计,那套滤波模型的数学特性和实现方式又是另一门学问,千万别混为一谈。

4. 螺旋矩阵:边界控制与循环不变量的实战

4.1 四边收缩的按层模拟

螺旋矩阵面试中出现频率极高,因为它既要考察模拟能力,又要考察边界控制。最容易错的地方不在于思路难,而是写着写着就把边界变量弄乱了。

推荐的解法是按层模拟,每轮处理一圈,从外往内。先定义四个边界:top、bottom、left、right。然后用四个 for 循环,分别遍历上边、右边、下边、左边:

vector<int> spiralOrder(vector<vector<int>>& matrix) { vector<int> res; int top = 0, bottom = matrix.size() - 1; int left = 0, right = matrix[0].size() - 1; while (top <= bottom && left <= right) { for (int j = left; j <= right; ++j) res.push_back(matrix[top][j]); ++top; for (int i = top; i <= bottom; ++i) res.push_back(matrix[i][right]); --right; if (top <= bottom) { for (int j = right; j >= left; --j) res.push_back(matrix[bottom][j]); --bottom; } if (left <= right) { for (int i = bottom; i >= top; --i) res.push_back(matrix[i][left]); ++left; } } return res; }

这个写法有个核心原则:每条边遍历完后,立刻收缩对应的边界变量。上边遍历完 top 加一,右边遍历完 right 减一,下边遍历完 bottom 减一,左边遍历完 left 加一。这样保证下一轮处理的是内层那一圈,不会重复访问外层元素。

为什么上下左右四个循环都不一样?因为四条边的走向不同,上边从左到右,右边从上到下,下边从右到左,左边从下到上。四个循环的上下限也必须严格对应收缩后的边界。比如上边遍历完后 top 已经变了,右边循环的起点就不能再用原来的 top,而是新的 top。

4.2 奇数阶矩阵为何容易重复与漏解

很多人写到下行和左行时就出错,原因出在矩阵行列数为奇数或只有一行一列的情况。比如 3x3 矩阵,最内圈只剩一个元素。处理完上边和右边后,如果下边循环和左边循环还无条件执行,就会把中心元素重复遍历,或者访问已经越界的空区间。

所以第二个和第三个 for 循环前面要额外加 if 判断。跑 3x3 的例子:第一轮处理完上边 [0][0]、[0][1]、[0][2],top 变成 1;右边处理 [1][2]、[2][2],right 变成 1;此时 top=1、bottom=1、left=0、right=1,满足 top <= bottom 所以下边处理 [2][1]、[2][0],bottom 变成 0;此时 left=0、right=1,满足 left <= right 所以左边处理 [1][0],left 变成 1。第二轮 top=1、bottom=0、left=1、right=1,外层 while 条件 top <= bottom 不成立,退出。中心元素 [1][1] 在第二轮上边循环被加入,第一轮下边循环因为 top 已经递增越过了它,不会重复。加不加那两个 if 的差别,就是外圈收完只剩一行或一列时,多余的那趟循环会重复读数据。

我遇到很多人在面试时死背螺旋矩阵代码,一被问“为什么这里要加 if”就答不上来。我的建议是,至少用一个手写的 3x3 或 1xN 的例子把代码走一遍,理解了这个 if 的保护作用,以后就不用靠背了。

4.3 方向法:撞墙转向的备选实现

按层模拟之外,还有一种方向法,思路是维护一个方向数组,比如右、下、左、上。每次移动一步,如果下一个位置越界或者已经访问过,就换方向。实现时需要额外的 visited 二维数组记录哪些格子已经填过。

方向法代码更直观,不容易搞混四条边的上下边界,代价是多占一份 O(n^2) 的布尔数组。对于面试场景,两种思路都能过,但按层模拟在实际面试中往往更加加分,因为它展现了你对循环不变量和边界收缩的理解,不用额外空间。

如果你偏好方向法,要特别注意“下一次位置”的判定必须发生在换方向之前:先试探下一步是否合法,不合法就转向,转向后重新计算下一步。这个逻辑如果写成“先走再判断”,就会走进死路。

5. 踩坑实录:三类题最容易翻车的细节

5.1 问题速查表

我把三类问题里常见的错误列成一张表,刷题或者面试复盘时直接对着看就行:

问题类型典型错误正确姿势
双指针求和int 溢出用 long long 或比较差值 target - nums[l]
双指针循环while (l <= r) 导致重复处理中间元素统一用 while (l < r)
快慢指针while 条件写成 fast->next && fast先判 fast,再判 fast->next,防止空指针
滑动窗口收缩窗口后忘更新状态收缩左边界时同步移除对应元素,保持窗口合法性
滑动窗口先更新答案再收缩窗口对“合法窗口”类题目,收缩完再更新答案
固定窗口窗口平移时新旧元素没同步加减每次平移只处理移入和移出一个元素
螺旋矩阵下边和左边循环无条件执行加 top <= bottom 和 left <= right 判断
螺旋矩阵for 循环用 < 而边界却用闭区间统一使用左闭右闭的 <=,避免行列奇偶问题

5.2 排查的通用套路

代码写出来后如果结果不对,先别急着从头读一遍。我自己的习惯是找几个特别小的测试用例,用手把过程走一遍。双指针和滑动窗口类的题目,最有效的调试是打印每一轮 l、r 的值和当前窗口状态,看看是哪一步更新出了问题。绝大多数坑都集中在更新边界变量和更新答案的顺序上。

举几个具体例子。滑动窗口无重复字符的题,如果 window 的删除操作放在 insert 之前,会造成字符还没加入窗口就先被删掉的诡异情况。螺旋矩阵如果 for 循环条件全是 <,在某些矩形里会漏掉 edge 上的元素。这些错误靠眼睛看很难看出来,但一跑测试立刻现形。

面试手写代码时,还有一个我很推荐的加分习惯:写完后主动报一下边界条件。比如双指针题可以说“循环条件是 l < r,因为碰到自己也意味着搜索空间耗尽”;滑动窗口题可以说“任何时刻窗口内都是合法的,答案只在窗口合法时更新”;螺旋矩阵题可以说“两个 if 是防止最后只剩一行或一列时重复遍历”。这几句话一说出口,面试官就知道你是真懂,而不是背了模板。

我自己现在刷题的习惯,是拿到题目先用三个问题过一遍:数据是有序的吗?要找的是连续区间吗?是矩阵里的遍历顺序题吗?三个问题分别对应双指针法、滑动窗口、螺旋矩阵三条路线。如果一条路线走不通,再往“排序后处理”“哈希表”“单调队列”这些方向扩展。这套判断顺序帮我省掉了大量无效思考,也希望对你有点用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询