ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

双指针、滑动窗口与螺旋矩阵:遍历算法核心逻辑与边界控制详解

双指针、滑动窗口与螺旋矩阵:遍历算法核心逻辑与边界控制详解 刷算法题的朋友应该都有一种感觉刷到一定阶段会发现很多题目背后的“骨架”其实是共通的。双指针法、滑动窗口、螺旋矩阵这三个名字在各类面试题库中反复出现尤其在一些高频题单里它们几乎是同一批常客。如果你正准备面试或者刚开始系统刷题弄清楚这三类题目的底层逻辑会比零散地背几十道题高效得多。这篇文章我想和你聊的不是我“又刷了多少道题”而是这三个方法各自到底在解决什么本质问题写的时候有哪些容易翻车的细节以及我实际调试过程中踩过的坑。内容会包含可直接套用的代码模板、边界条件的分析还有一些网上教程里很少讲清楚但面试中很常见的追问点。不管你是刚接触算法的新手还是已经刷了一段时间想查漏补缺这篇文章都值得花十分钟读完。1. 三个名字一种思维整体思路拆解先说结论双指针法、滑动窗口、螺旋矩阵本质上都指向同一件事——通过控制遍历顺序和遍历窗口把暴力枚举的复杂度降下来。暴力的思路很简单要找一个区间、一对元素就全部扫一遍。问题是很多场景下全扫一遍是 O(n^2) 甚至 O(n^3)数据规模稍大就撑不住。而这三类方法都利用了数据本身的某种“顺序性”让指针在恰当的时机前进或后退从而避免无效计算。1.1 双指针法解决的是什么问题双指针法主要解决两类问题对撞型和快慢型。对撞型场景里数据通常是有序的或者可以排序。一个指针放在头部一个指针放在尾部根据当前两个指针指向元素的和、差、大小关系决定是移动左指针还是右指针。经典的“两数之和 II”“三数之和”“盛最多水的容器”都属于这一类。它把二重循环中的一重循环“压平”让遍历次数从 O(n^2) 变成 O(n)。快慢型场景里两个指针从同一个起点出发一个走一步一个走两步利用速度差来解决问题。链表判环、找链表中点、移除有序数组中的重复元素都是快慢指针的经典应用。这种类型的核心逻辑是让两个指针之间形成“距离”通过这个距离感知结构特征。1.2 滑动窗口和双指针的关系滑动窗口本质上是双指针的一种特化形态但它关注的不是“两个指针指向的元素之间的关系”而是“两个指针夹住的区间”。大部分滑动窗口题都有一个共同特征求解的对象是连续子数组或子串而且窗口内的元素具备某种统计性质比如和、乘积、种类、频率。如果暴力枚举所有子区间复杂度是 O(n^2)而滑动窗口通过“右指针负责扩张左指针负责收缩”的方式让每个元素最多被处理两次整体复杂度降到 O(n)。你不需要每次都重新计算窗口内的内容只需要在边界变化时增量更新统计信息。这就是它能省时间的根本原因。1.3 螺旋矩阵为什么能归到同一类思维里螺旋矩阵看起来和双指针不太像毕竟它处理的是二维数组。但剥开表面它用的还是同一个核心思想维护一组边界按固定方向推进遍历。用四个变量top、bottom、left、right分别表示矩阵四条边的当前范围每次从一条边遍历到另一条边遍历完就把对应的边界向内收缩直到边界交叉。你会发现这就是二维世界里的“指针”——四个指针控制遍历范围每一轮循环都只处理当前的边界层。理解了这一点螺旋矩阵就不再是一道需要死记硬背的题目而是一套逻辑自洽的模拟流程。2. 双指针法从对撞到快慢的实操要点双指针法最容易出问题的地方不是“想不到用双指针”而是指针移动的条件写错。我见过不少人在left right和left right之间反复纠结其实这个选择完全取决于你要处理的两个指针指向的元素是否可能重叠。2.1 对撞指针的正确写法与移动逻辑以最经典的有序数组两数之和为例。假设数组是升序的目标值是 target。left指向最小值right指向最大值。如果nums[left] nums[right]小于 target说明两个数的和偏小应该让left右移换一个更大的数如果和大于 target则应该让right左移。当两个指针相遇时说明不存在符合条件的数对。def two_sum_sorted(nums, target): left, right 0, len(nums) - 1 while left right: current_sum nums[left] nums[right] if current_sum target: return [left, right] elif current_sum target: left 1 else: right - 1 return [-1, -1]这里用while left right而不是因为在两数之和的场景中两个指针指向同一个元素时没有意义——你不能把一个元素用两次。但在判断回文串的场景中情况又不一样了。回文串校验里两个指针同样是一左一右向中间靠拢但你要比较的是s[left]和s[right]是否相等。这个场景下用left right也是对的因为当left right时剩下的是中间一个字符不需要和谁比较。真正需要用到left right的是那些“必须处理中间元素”的题目比如在数组中反转一段区间。注意双指针题目里判断条件不是死记硬背出来的要问自己“当两个指针重合时这个元素还需要处理吗”。想清楚了边界条件自然不会错。2.2 快慢指针的两种经典应用快慢指针里最容易理解的场景是判断链表是否有环。slow每次走一步fast每次走两步如果链表无环fast会先走到None如果有环两个指针最终会在环内相遇。这个相遇不是巧合而是相对速度造成的必然结果——fast比slow快一步在环形轨道上每一轮循环都逼近一个单位的距离。def has_cycle(head): slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: return True return False另一个常见用途是数组去重。一个指针slow维护“已处理区域的末尾”另一个指针fast扫描整个数组。每当fast发现一个和slow所在位置不同的新值就把slow先向前移动一格再把这个新值写进去。最终slow 1就是去重后数组的长度。def remove_duplicates(nums): if not nums: return 0 slow 0 for fast in range(1, len(nums)): if nums[fast] ! nums[slow]: slow 1 nums[slow] nums[fast] return slow 1这个写法的精妙之处在于它同时做到了“原地修改”和“不破坏前面元素的相对顺序”。slow指向的是最后一个保留元素它既充当了写入位置又充当了比较基准。我刚开始刷这道题时总想着用slow记录“当前该填哪个位置”结果和比较基准混在了一起代码越写越乱。后来才意识到在这个场景里slow同时承担两个职责是算法的核心设计不要试图拆开它们。2.3 双指针指向多个指针的进阶三数之和双指针不止是两个指针也可以是“固定一个移动两个”。三数之和就是典型例子排序后固定一个数nums[i]然后在[i 1, len(nums) - 1]区间上用对撞指针找两个数让它们的和等于-nums[i]。这里有三个细节值得注意。第一外层i必须跳过重复值否则结果集里会出现相同的三元组。第二内层双指针找到一组答案后left和right都要跳过后续重复的元素。第三当前序数组是有序的但如果nums[i]本身就大于 target即 0可以直接结束循环因为后面的数都更大不可能凑出更小的和。def three_sum(nums): nums.sort() res [] n len(nums) for i in range(n - 2): if nums[i] 0: break if i 0 and nums[i] nums[i - 1]: continue left, right i 1, n - 1 while left right: total nums[i] nums[left] nums[right] if total 0: res.append([nums[i], nums[left], nums[right]]) while left right and nums[left] nums[left 1]: left 1 while left right and nums[right] nums[right - 1]: right - 1 left 1 right - 1 elif total 0: left 1 else: right - 1 return res跳过重复值的逻辑一定要放在“找到一组答案之后”而不是每次移动指针时都判断。因为如果直接放在移动逻辑里容易把“跳过重复值”和“向中间靠拢”两个动作混在一起导致指针移动次数不对结果出现重复三元组。这不是代码风格问题是逻辑顺序问题。3. 滑动窗口的实现细节与高频陷阱滑动窗口看起来模板化很强网上已经有很多“万能模板”但真正写起来还是有几个容易翻车的地方。我觉得最有必要展开聊的是窗口的统计结构、收缩时机和答案更新位置这三个问题。3.1 固定窗口与可变窗口怎么选固定窗口的题目特征非常明显题目直接告诉你窗口长度 k。比如“滑动窗口最大值”“字符串的排列”。这种题只需要用right遍历数组当窗口长度超过 k 时把left对应的元素移出窗口left再往前挪一格。可变窗口则是那些“求最长/最短满足某种条件的子数组/子串”的题目。这类题没有明确告诉窗口大小窗口大小本身就是我们要优化的目标。比如“无重复字符的最长子串”“最小覆盖子串”“长度最小的子数组”。可变窗口的模板比固定窗口稍微复杂一点因为你要决定什么时候收缩、收缩到哪里。判断用哪种模型最简单的办法是看题干里有没有“精确窗口大小”或“至多/至少”这类词。有精确窗口大小就用固定窗口有“不超过某个限制条件”就用可变窗口。3.2 可变窗口的增量更新与收缩逻辑先看一个最简单也最典型的例子求长度最小的连续子数组使得子数组的和大于等于 target。暴力法是枚举所有子数组滑动窗口的做法是right不断向右扩张同时累加窗口内的和当和满足条件时记录当前窗口长度然后收缩left直到和再次不满足条件。这个过程本质上是在“保持窗口满足条件的前提下尽量压缩窗口长度”。def min_sub_array_len(target, nums): left 0 window_sum 0 ans float(inf) for right in range(len(nums)): window_sum nums[right] while window_sum target: ans min(ans, right - left 1) window_sum - nums[left] left 1 return 0 if ans float(inf) else ansdef min_sub_array_len(target, nums): left 0 window_sum 0 ans float(inf) for right in range(len(nums)): window_sum nums[right] while window_sum target: ans min(ans, right - left 1) window_sum - nums[left] left 1 return 0 if ans float(inf) else ans这段代码里最关键的一行是window_sum - nums[left]; left 1。它体现了滑动窗口的核心思想当窗口已经满足条件时尝试把左边界往右拉看能不能用更短的窗口满足条件。注意收缩是“持续的”用while而不是if因为可能收缩一次之后窗口依然满足条件要继续收缩。答案更新的位置也要想清楚。这段代码是在收缩过程中更新答案因为只有当窗口满足条件时长度才有意义。如果把更新放在收缩之后的外层就可能在窗口不满足条件时记录一个无效长度。这个问题遇到“最小覆盖子串”时会更明显。3.3 高频题滑动窗口最大值单调队列为什么必须用热词里反复出现“滑动窗口最大值”“滑动窗口的最小值”这两道题本质上是一样的每个长度为 k 的窗口求最大值。最直接的思路是每个窗口都扫描一次复杂度 O(nk)用堆处理能到 O(n log k)但最优解法是用单调队列做到 O(n)。单调队列的思路是队列里保存的是数组下标但下标对应的元素值保持单调递减。每次新元素入队前先从队尾把所有比它小的元素弹出再把它的下标入队。队头永远是当前窗口的最大值。同时如果队头下标已经滑出窗口范围就把它从队头弹出。from collections import deque def max_sliding_window(nums, k): q deque() res [] for i, num in enumerate(nums): while q and nums[q[-1]] num: q.pop() q.append(i) if q[0] i - k: q.popleft() if i k - 1: res.append(nums[q[0]]) return res为什么用而不是时弹出队尾这里说明一下如果两个元素值相等保留更靠右的下标更有优势因为它能在窗口里存活更久。所以遇到相等元素时旧元素可以放心弹出。这是一处很容易被忽略但实测很有用的细节。注意这道题的核心不是“维护窗口内所有元素”而是“快速淘汰不可能成为答案的元素”。一旦理解了这个目的单调队列的代码就容易写对了。热词里还有一个“滑动窗口中位数”这题的难度比最大值大不少因为中位数不满足单调性需要同时维护两个堆或者用有序容器。我个人的建议是先掌握最大值再考虑中位数。3.4 窗口统计结构的选型窗口内统计的种类越多数据结构的选择就越重要。如果只是统计窗口内数值的和一个累加变量就够了。如果是统计字符出现频率通常用一个字典或数组。如果还要快速判断两个窗口是否“字符组成相同”那可以用数组记录频率配合一个变量统计“当前有多少类型已经满足条件”。以“最小覆盖子串”为例它需要记录 t 中每个字符在窗口内出现的次数是否不少于要求的次数。很多初学者会在每次移动窗口时重新数一遍字符频率这样复杂度又回到 O(nk) 了。正确做法是维护一个required计数器当某字符的窗口内频率达到目标频率时required就减 1。当required 0时说明窗口已经覆盖了 t。def min_window(s, t): from collections import defaultdict need defaultdict(int) for ch in t: need[ch] 1 left 0 required len(need) ans_start, ans_len 0, float(inf) window defaultdict(int) for right, ch in enumerate(s): if ch in need: window[ch] 1 if window[ch] need[ch]: required - 1 while required 0: if right - left 1 ans_len: ans_start, ans_len left, right - left 1 left_char s[left] if left_char in need: if window[left_char] need[left_char]: required 1 window[left_char] - 1 left 1 return s[ans_start:ans_start ans_len] if ans_len ! float(inf) else 这个代码很长但核心就两个状态变化required变成 0 时说明窗口达标required从 0 变成 1 时说明窗口右缩过头了。只要盯住这个计数器整段代码的逻辑就清楚多了。还有一个延伸点热词里有“滑动窗口的思路js版本模板”。JS 写法和 Python 类似但要注意 JS 的数组和对象在频繁增删时的性能表现尤其shift()操作是 O(n) 的不建议在滑动窗口题里用来维护队列。要维护动态窗口的边界值JS 里用双向链表或者直接维护指针会更稳妥。4. 螺旋矩阵的边界控制与实现如果说双指针和滑动窗口的难点在逻辑那螺旋矩阵的难点就纯粹在边界控制。它本身没有太多技巧就是老老实实按方向遍历但边界条件一旦写错很容易出现重复遍历、死循环、越界访问这三种典型事故。4.1 为什么需要四个边界变量螺旋遍历的顺序是上边、右边、下边、左边然后进入内层继续。每一次遍历完一条完整边之后对应边界向内收缩一格。top控制上边界遍历完上边后top 1right控制右边界遍历完右边后right - 1以此类推。def spiral_order(matrix): if not matrix or not matrix[0]: return [] top, bottom 0, len(matrix) - 1 left, right 0, len(matrix[0]) - 1 res [] while top bottom and left right: for j in range(left, right 1): res.append(matrix[top][j]) top 1 for i in range(top, bottom 1): res.append(matrix[i][right]) right - 1 if top bottom or left right: break for j in range(right, left - 1, -1): res.append(matrix[bottom][j]) bottom - 1 for i in range(bottom, top - 1, -1): res.append(matrix[i][left]) left 1 return res这段代码里有一处经常被忽略却极其关键的行if top bottom or left right: break。它的存在是因为在遍历完上边和右边之后可能已经完成了整层处理此时矩阵可能只剩一行或一列。如果不检查就继续执行下方的“下边”和“左边”遍历会造成重复访问甚至越界。我举个例子一个 3 行 1 列的矩阵。第一次循环中上边遍历完成后top变成 1右边遍历完成后right变成 0此时top1, bottom2, left0, right0还有数据需要处理。但如果没有 break 判断紧接着就会执行“从右到左遍历下边”也就是遍历matrix[2][0]这个元素并没有被访问过还算安全。但如果矩阵是 1 行 1 列呢上边遍历完成top1右边遍历完成right0此时条件top bottom为真如果不 break下边遍历会访问matrix[0][-1]也就是把最后一列又输出了一遍。这种问题非常隐蔽不构造特殊用例很难发现。提示螺旋矩阵在任何一步收缩之后都可能出现“某一维边界已经交叉”的情况。安全起见可以在每轮循环末尾统一判断一次或者干脆在每轮四个方向遍历结束后使用完整的if检查。不要偷懒省略。4.2 螺旋矩阵 II填充版与遍历版的不同点LeetCode 上还有一道螺旋矩阵 II要求是给定正整数 n按螺旋顺序生成一个 n x n 的矩阵。遍历版用“读”填充版用“写”但骨架几乎一样只是把res.append(matrix[...])换成了matrix[...] num; num 1。def generate_matrix(n): matrix [[0] * n for _ in range(n)] top, bottom, left, right 0, n - 1, 0, n - 1 num 1 while top bottom and left right: for j in range(left, right 1): matrix[top][j] num num 1 top 1 for i in range(top, bottom 1): matrix[i][right] num num 1 right - 1 if top bottom or left right: break for j in range(right, left - 1, -1): matrix[bottom][j] num num 1 bottom - 1 for i in range(bottom, top - 1, -1): matrix[i][left] num num 1 left 1 return matrix这个版本相对不容易错因为你填充到矩阵里的数字是单调递增的即使某个位置被重复赋值只要最终结果看起来正确就不容易发现问题。但我建议你还是用 1x1、2x2、3x3 的小矩阵手动跑一遍流程确认每个数字都只被赋值一次。填充版和遍历版在面试中出现的频率差不多掌握一个另一个基本上就通了。4.3 变体问题的应对思路螺旋矩阵的变体主要围绕两个方向起始点不同和遍历方向不同。比如从矩阵中心开始螺旋向外遍历或者按逆时针方向输出。这类题其实没有太多新东西核心还是四个边界变量加四个方向的遍历。从中心开始的情况下你需要把起点设定在最内层然后按相反方向扩张边界。逆时针遍历则只需要调整四个方向循环的先后顺序。还有一类“蛇形遍历”的题比如 Z 字形打印矩阵它本质上就是“按斜线方向交替遍历”。这种题不建议用螺旋矩阵的模板硬套而是应该单独理解行号和列号的奇偶性决定了遍历方向。虽然名字里都有“矩阵遍历”但蛇形遍历和螺旋矩阵的边界控制思路差别很大不要混淆。5. 常见问题排查与调试实录我发现很多人在刷题初期不是思路想不出来而是代码跑不通之后不知道怎么排查。下面几个问题是我在实际调试中遇到频率最高的也基本覆盖了这三类题的典型 bug。5.1 为什么 while 循环会死循环或越界双指针题里最常见的死循环原因是某个分支里忘记移动指针。比如对撞指针中如果current_sum ! target你必须在每个分支里都让left或right前进一步。我曾见过有人把left 1和right - 1写在循环末尾的统一位置结果在sum target时指针不再移动而循环条件又没退出于是死循环。滑动窗口的死循环一般出现在收缩逻辑里。如果你在收缩时只移动了left却没有及时更新窗口统计信息那while条件可能永远为真。比如“无重复字符的最长子串”中正确做法是在window[s[left]] - 1之后再left 1两个动作缺一不可。螺旋矩阵的越界则通常出现在我前面说的“缺少 break 判断”或“边界变量更新顺序错误”。一个通用排查技巧是找一个极小的矩阵1 行 1 列、1 行 5 列、5 行 1 列手动跟踪每一轮循环中top/bottom/left/right的变化画在纸上。这个习惯能帮你迅速定位问题。5.2 什么时候用 left right什么时候用 left right这个问题我在前面提过几次这里系统总结一下。如果循环里访问了nums[left]和nums[right]并且逻辑要求这两个必须不是同一个元素用。典型场景两数之和、三数之和内层双指针、盛最多水的容器。如果循环里要访问的元素允许两个指针重合并且重合时还要处理一次那么用。典型场景二分查找、反转数组、回文串验证虽然回文串在重合时其实没什么要处理的用也没问题但用也不会错。滑离开窗这里则不一样。left right在滑动窗口里通常不是循环条件而是判断窗口是否合法的一个状态。比如“固定长度的至少为 k 的连续子数组”里你需要等到right - left 1达到某个阈值才开始有答案。这个阈值判断本质上就是在控制窗口内的元素数量。搞清楚“循环边界”和“窗口条件”是两个概念就不会混了。5.3 如何构造测试用例来验证边界我强烈建议每写完一个滑动窗口或螺旋矩阵代码不要直接提交而是先跑下面几组用例。对双指针题空数组、单元素数组、两个相同元素数组、所有元素都相等的数组、已经有序和逆序的数组。重点观察指针移动时是否访问了不存在的下标。对滑动窗口题空字符串、窗口长度等于数组长度的极端情况、窗口长度等于 1 的情况、所有元素都相同的字符串。重点观察窗口统计信息是否正确更新。对螺旋矩阵题空矩阵、只有一行、只有一列、一行一列、奇数和偶数行数列数组合。重点观察是否有重复输出或漏掉中间元素。我用这些用例已经救回了很多次“感觉逻辑没错但提交报错”的情况。特别是滑动窗口题一旦窗口长度为 1很多 bug 就会暴露出来。5.4 面试中如何讲解你的思路最后聊一个题外话。代码写对只是第一步面试时要把思路表达清楚才是关键。我的习惯是先说明“这道题可以看成是一段连续区间的问题所以用滑动窗口”再讲“我让右指针负责扩展左指针负责收缩同时用一个变量维护窗口内的和”最后说明“答案在收缩过程中更新因为收缩后的窗口才是满足条件的最短区间”。螺旋矩阵则可以这样讲“我维护四个边界变量每走完一条边就收缩对应的边界循环条件是上下边界不交叉且左右边界不交叉。唯一需要注意的是在遍历下边和左边之前要做一次边界检查防止只剩单行或单列时重复遍历。”面试官会通过你的表达判断你是真的理解了这道题还是只是背了模板。所以我在上面这些讲解中特别强调了“为什么这么收缩”“为什么在这个位置更新答案”——这些点才是代码之外的真正得分点。我的切身体会是双指针法、滑动窗口、螺旋矩阵这三类题刷一遍并不难但要达到“随手能默写、改了能适应变体”的程度还是需要把边界条件烂熟于心。建议你按这样的顺序练习先做两数之和 II 和移除重复元素掌握双指针的基本移动逻辑再做无重复字符的最长子串理解滑动窗口的收缩与更新最后做螺旋矩阵把二维边界控制练扎实。这三关过了很多看起来毫无头绪的题你会发现它们其实都长着一张熟悉的脸。
返回列表