算法题3:指针类型题目,这个标题看起来简单,但"指针类型"这四个字背后藏着一整类高频面试题。最近好几个读者问我,说LeetCode刷到双指针、快慢指针、滑动窗口,总感觉每个题都会一点,但题目一变就懵。这篇文章就把指针类题目的底层逻辑拆开讲透,从使用场景、解题模板、边界条件到实战演变,一次讲明白。1. 指针类型题目到底在考什么指针类型题目,表面上是考你指针怎么移动,本质上考的是你对数据组织方式的理解。我面试别人的时候常说:指针题是最诚实的题型——会不会,一写边界条件就露馅。1.1 从"暴力解"到"指针解"的思维转变数组上的暴力解法,大多数是两层循环。外层循环固定一个起点,内层循环从起点扫到结尾。这种思路的问题在于时间复杂度是O(n²),数据规模一大就超时。指针解法改变的是扫描策略:不再是"每个起点扫一遍",而是"两个指针各扫一遍"。整个过程两个指针加起来最多走2n步,复杂度直接降到O(n)。这中间的思维跨越,是很多初学者卡住的地方。我来举个例子。判断一个字符串是不是回文串:暴力思路是反转字符串再比较,额外用O(n)空间。双指针思路是:一个指针从最左边往右走,一个指针从最右边往左走,逐个比较两个指针指向的字符是否相等。不相等就不是回文,相等就继续走。def isPalindrome(s: str) -> bool: left, right = 0, len(s) - 1 while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True这就是最基本的双指针。两个指针从两端向中间"夹逼",每次比较只涉及两个位置的访问,没有任何额外空间。1.2 双指针的本质:用两个游标替代枚举双指针的核心思想是:枚举的时候,很多组合是无效的,直接用两个游标的相对关系跳过这些无效组合。举个例子,有序数组里找两个数,让它们的和等于目标值target。暴力法是固定第一个数,遍历找第二个数,O(n²)。双指针法是:left指向数组开头,right指向数组结尾。如果left+right的和大于target,说明right太大了,right往左挪;如果小于target,说明left太小了,left往右挪。那有人会问:为什么这样移动不会漏掉答案?假设正确答案是nums[i]和nums[j],i < j。在整个过程中,left最多只会"超过"i,不会"跳过"i。为什么?因为left往右移动的唯一原因,是当前和小于target。如果当前left正好站在i的位置,right在j的右边,此时当前和一定大于等于nums[i]+nums[j],因为right指向的数是更大的数。一旦大于target,right就往左挪,最终一定会挪到j。所以答案不会被跳过。这就是双指针的正确性证明思路。代码上很简单,但要给别人讲清楚"为什么敢这么移动",大多数人讲不出来。2. 双指针的四种滑动形态:什么时候用哪一个双指针看似都叫"双指针",实际上内部还有细分。我习惯分成四种形态,面试和刷题时心里先定形态,再套模板,速度会快很多。2.1 夹逼型:两端向中间收窄两个指针分别从数组两端出发,根据条件决定哪边移动。典型场景是:有序数组找两数之和、回文判断、接雨水。用"接雨水"来展示这个形态的实际价值。题目给一个非负整数数组,每个数代表一个柱子的高度,求下雨后能接多少水。暴力法对每个位置往左右看最大值,O(n²)。双指针夹逼法是:left和right分别从两端走,维持leftMax和rightMax两个变量,代表左边和右边遇到过的最大高度。接雨水的关键,是当前位置能接的水量取决于min(leftMax, rightMax)。如果leftMax小于rightMax,说明右边一定有柱子兜底,此时left位置的水量只取决于leftMax。判断完之后,left往右走,否则right往左走。def trap(height: List[int]) -> int: if not height: return 0 left, right = 0, len(height) - 1 leftMax, rightMax = 0, 0 ans = 0 while left < right: if height[left] < height[right]: leftMax = max(leftMax, height[left]) ans += leftMax - height[left] left += 1 else: rightMax = max(rightMax, height[right]) ans += rightMax - height[right] right -= 1 return ans这个题的难点在于理解"短板原理":最低的柱子决定了水面能有多高。双指针夹逼时,每次都移动"当前较矮柱子"那一侧,因为较矮一侧的水量已经确定,移动指针不会影响后面的判断。2.2 快慢型:同一个方向,不同速度快慢指针也叫龟兔赛跑算法。两个指针都从起点出发,一个每次走两步,一个每次走一步。主要用在链表上:判断链表是否有环、找链表的中间节点、找链表的倒数第k个节点。链表的环检测是最经典的场景。为什么快指针每次走两步而不是任意步?因为快指针比慢指针每次多走一步,在环上,每走一次它们的距离就缩短1,所以一定能相遇。如果快指针走三步或更多,可能会出现永远跨过慢指针的情况,导致无法相遇。def hasCycle(head: Optional[ListNode]) -> bool: slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False有条件限制时,快慢指针也有用。比如"找链表倒数第k个节点":fast先走k步,然后slow和fast一起走,fast到底时slow正好在倒数第k个位置。这种方法只需要一次遍历,不需要知道链表长度。类似的边界处理问题都值得好好练习。找两个链表的交点也能用这个思路:先走一遍得出两条链表的长度差,然后让长链表的指针先走差值,再两个指针同步走,相遇的地方就是交点。这类题目本质上都是"用指针的相对距离差做文章"。2.3 滑窗型:维护一个动态区间滑动窗口其实是双指针的一种变形:一个指针作为窗口的左边界left,一个指针作为右边界right,right不断向右扩展窗口,left在满足条件时向右收缩窗口。典型题目是"最长无重复字符子串"。窗口右侧每扩展一个字符,就检查窗口内是否有重复。为了O(1)时间判断重复,配合哈希表记录每个字符最后一次出现的位置。如果当前字符之前出现过且在窗口内,就把left跳到上一次出现位置的下一个。def lengthOfLongestSubstring(s: str) -> int: last_seen = {} left = 0 ans = 0 for right, ch in enumerate(s): if ch in last_seen and last_seen[ch] >= left: left = last_seen[ch] + 1 last_seen[ch] = right ans = max(ans, right - left + 1) return ans滑动窗口的难点不在编码,而在窗口收缩的条件。很多时候"什么时候缩窗口、缩到哪一步停"是需要推导的,做题时不要上来就写代码,先用例子模拟一遍窗口的移动过程。滑窗另一个高频变形是"定长窗口 + 维护统计信息"。比如找数组中连续k个元素的某种极值,就用一个Hash表或者计数数组维护窗口内的统计信息,right扩展一个,left丢一个,每次更新统计再更新答案。这类题型的操作模式非常固定,本质就是"流水线式的窗口滑动"。2.4 合并型:两个序列的游标归并合并型双指针常用于两个有序数组的合并操作。两个指针分别指向两个数组的当前元素,谁小就选谁,把选出来的元素放入结果集,对应指针向前移动。经典题"合并两个有序数组"。给定两个非递减数组,把第二个数组的数值合并进第一个数组,第一个数组假设有自己的额外尾空间,要求原地操作。如果从左往右合并,会面临覆盖问题——nums1中的元素还没被比较就可能被覆盖了。常规做法是从后往前填充:p1指向nums1有效元素的末尾,p2指向nums2的末尾,cur指向nums1的末尾,从大到小逐个放置。这样即使覆盖也没事,因为被覆盖的位置已经被"消费"过了。def merge(nums1: List[int], m: int, nums2: List[int], n: int) -> None: p1, p2 = m - 1, n - 1 cur = m + n - 1 while p2 >= 0: if p1 >= 0 and nums1[p1] > nums2[p2]: nums1[cur] = nums1[p1] p1 -= 1 else: nums1[cur] = nums2[p2] p2 -= 1 cur -= 1这个题的启发性在于:指针移动方向是可以根据场景定制的。很多题目正着走会覆盖数据,倒着走反而干净利落。做题时如果发现正着处理特别别扭,试试从反面想。3. 指针题的边界条件:一个条件写错,整题前功尽弃指针题最大的特征就是边界条件极其敏感。数组访问越界、死循环、漏处理空数组,是三类最常见的错误。这一章我专门讲讲排查思路。3.1 循环条件怎么定:left <= right 还是 left < right这是区分菜鸟和老手的一个关键细节。两类循环写法对应的场景完全不同。如果循环体逻辑需要同时访问left和right位置的值,比如回文判断,那么用while left < right,两个指针至少要有间隔,否则都是同一个位置,比较没有意义。如果是二分查找类的逻辑,区间左右都是闭区间,需要访问的mid可能等于left或right,所以用while left <= right,否则中间元素会被漏掉。我自己的习惯是写代码前先想一句:指针重叠时,我还要不要处理这个位置?要处理就写成<=,不处理就写成<。再看一个典型例子,二分查找的两种写法:左闭右闭[left, right]用while left <= right;左闭右开[left, right)用while left < right。混用这两套写法,是很多人二分查找反复出错的根源。建议做题时固定一套习惯,不要每次凭感觉。3.2 空数组和单元素数组:最容易忽略的测试点指针题的共同尴尬是:逻辑跑标准样例没问题,一上来就空指针或者是长度为1的数组就崩。我在刷题时给自己定了一个规矩:每题提交前,强制在脑子里跑三个测试用例:空输入、单元素输入、大规模随机输入。空输入的套路是统一在函数开头加一次性判断。单元素输入的关键是像"反转链表"这类题里,头节点本身就是尾节点,循环条件往往就不满足,直接返回原链表即可,逻辑上非常清晰。大规模随机输入主要用来测性能,防止思考不周引入了O(n²)。这些习惯如果养成,能减少很多无谓的提交失败。3.3 链表场景下的空指针防护:先判空再解引用链表题里经常出现这样的错误:先写了node.next,忘了node可能是null。尤其在快慢指针、合并链表场景,链表的长度判空必须放在访问之前。# 错误的写法 if node.next: # 如果node本身就是None,这里就崩了 node = node.next # 正确的写法 if node and node.next: node = node.next建议统一养成"用node和node.next同时判断"的防御式写法。C++遇到野指针的情况很常见,Python和Java里虽然不太会遇到底层崩溃,但也会抛空指针异常,代码评审时这种问题最容易被揪出来。3.4 双指针对撞为什么不需要哈希表:空间换时间的权衡回到算法面试的一个核心问题:双指针和哈希表到底怎么选?比如两数之和,哈希表能O(n)时间解决,双指针需要先排序再扫,也是O(n log n)。为什么双指针也有价值?因为哈希表需要额外空间O(n),而双指针在有序数组上可以做到原地操作、零额外空间。在嵌入式、内存受限的场景,这是很大优势。此外"三数之和""四数之和"这类题,如果用哈希表去重逻辑非常复杂,但排序 + 双指针天然自带去重,代码反而容易些。这两者的取舍没有绝对答案,关键看清题目对时间空间的要求。很多候选人两数之和只会哈希表写法,遇到变种就卡住,说明练习时做得太表面了。4. 从"会做题"到"会挑题":指针解法的适用范围分析对着一道新题想不出解法,大概率不是不会写代码,而是没有判断出这道题"属于哪个门派"。分析指针的适用范围,这个能力比背诵代码模板重要得多。4.1 有序 + 枚举组合:双指针的第一反应场景双指针最典型的出场条件有两个:输入是数组或链表,单调有序或部分有序问题本质是需要从数据中找某种"组合"关系这两个条件同时满足时,双指针基本是第一个要想到的解。经典案例是三数之和:排序之后,遍历数组固定一个数left,然后对剩余区间用双指针夹逼找两数之和。这里的套路值得记下来:**"固定一个,双指针找两个"**是处理组合类题目的黄金模板。def threeSum(nums: List[int]) -> List[List[int]]: nums.sort() n = len(nums) res = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue # 跳过重复的基准元素 lo, hi = i + 1, n - 1 while lo < hi: s = nums[i] + nums[lo] + nums[hi] if s < 0: lo += 1 elif s > 0: hi -= 1 else: res.append([nums[i], nums[lo], nums[hi]]) while lo < hi and nums[lo] == nums[lo + 1]: lo += 1 while lo < hi and nums[hi] == nums[hi - 1]: hi -= 1 lo += 1 hi -= 1 return res这个题的坑主要在两个去重逻辑:外层固定值的去重、内层双指针收缩时的去重。漏掉任何一个,答案就会出现重复。想清楚"去重发生在何时",比硬背这段代码重要得多。4.2 链表问题:快慢指针的专属主场链表和数组最大的区别是"不知道长度、无法随机访问",所以链表题非常依赖指针的相对位置关系。这时候快慢指针就成了首选。判断回文链表、找链表中点、给环形链表找环入口,这几类问题都能用快慢指针解决。找环入口还需要进一步推理:快慢指针在环上相遇后,从链表头部和相遇点同时同速走,两个指针再次相遇的位置就是环入口。这里的推导基础是:链头到环入口的距离等于相遇点顺时针到环入口的距离。建议配合画图自己推一遍,不要只记结论。还有一个更进阶的场景:"删除链表倒数第n个节点"。快指针先走n步,慢指针再跟着走。这样等快指针走到链表末尾,慢指针恰好指向倒数第n个节点的前一个节点,直接跳过即完成删除。这类技巧的厉害之处在于只用一次遍历就完成了原本需要两次遍历的事情。4.3 什么时候不适用:必须提醒的场景约束指针解法也有明确的失效场景,我平时做技术评审时会特别注意这几点:数据无序且预处理成本高:如果数组完全无序,排序后又需要保持原始秩序,双指针可能不适用,最好换成哈希表或前缀和处理空间要求苛刻但需要额外标记:滑动窗口如果需要维护Hash计数,严格来说用了O(k)额外空间,特殊空间受限环境要斟酌指针语义不清晰的高维场景:处理矩阵或三维数据时,多指针策略很容易混淆,陷入维护索引的泥潭,建议优先考虑迭代器、坐标或状态压缩DP这些边界判断,体现了对算法"适合什么、不适合什么"的理解深度。平时刷题时除了把AC当目标,一定要顺手做这个判断题。5. 实战中的避坑记录:三次真实血泪教训讲完方法论,分享几个自己以前实际写代码时踩过的大坑。这些案例都比较典型,对正在准备面试的朋友特别有参考价值。5.1 合并数组时的覆盖问题:从错误推导出"从后往前"的写法有一次在处理合并两个有序数组的题目时,我一开始正序写。p1和p2都从0开始,一旦nums2中的元素比nums1中的小,就需要把nums1中后面所有元素整体往后挪一位。这么写代码是能过的,效率却很差,最差情况下每次插入都触发O(n)的搬移,整体复杂度退化成O(n²)。那时我停下来重新推导。nums1的末尾预留了空间,既然从前往后会覆盖未处理的元素,那从后往前填就不会了。每次比较两个数组末尾的最大值,更大的那个放到nums1真正的末尾。这个思路一换,代码立刻变得又短又稳。这告诉我的道理是:正向有冲突时,反向往往能绕开冲突的核心。指针方向不是一个僵化的选择,而应该是推导出来的结果。5.2 滑动窗口的最小值问题:left指针竟然往回走了还有一次做连续子数组题目,我错误地写了left的回退逻辑。当时的场景是窗口需要寻找满足某个和条件的最短子数组,我每次发现窗口不满足条件就想让left回退到更早的位置,结果死循环了。排查后发现问题的根源在于:滑动窗口的left指针只应该向右移动,不应该向左回退。窗口右边界增长时,如果当前子数组不满足条件,要么right继续向前,要么left向右收缩,绝不存在"left往后退"的合法性。左指针一旦回退,窗口状态就乱了,统计信息也完全不可信。那次调试让我记住了一个简洁可靠的原则:滑动窗口的两个指针在同一方向前进时可以随时各自移动,但任何指针都不能向反方向走。写代码时如果发现自己在移动指针时move back,要么思路错了,要么窗口维护方式有问题。5.3 数组和链表指针语义的混淆:index越界与node.next失效还有一次教训是混淆了数组和链表两种指针的底层语义。写数组代码时用索引习惯,自然而然地写了个伪代码",脑子里盘旋着"node = node.next",却忘了当前节点可能是None。在遍历链表的循环里,先判断while node and node.next,这句保护被我省了,结果一条长链表越到后面越容易出问题。数组的指针本质是索引下标,逻辑上是"当前位置",甚至可以回退搜索;链表指针则必须顺着next走,不存在回头路。两者之间每一种都有不同的边界规则,不能生搬硬套。后来我给自己立了一个规矩:写链表题时先画示意图,写数组题时先检查循环边界。宁可多花两分钟在草稿纸,不要用半小时在Debugger里追问题。这几次教训让我意识到,指针题的坑大多不是算法理念上的,而是细节执行上的。面试官看得最多的恰恰是这些细节——边界处理是否严谨、特殊用例是否考虑过、指针移动的状态是否正确。6. 如何系统练习指针题:我的训练路线这部分给出适合自己的训练路径。路径规划的逻辑是"先熟悉核心场景,再统一应对变式",不建议一上来就乱刷两百题。6.1 第一阶段:把基础场景刷成条件反射建议把以下题型按顺序练熟,每一类至少手写三遍,直到不需要想就能默写:有序数组的两数之和与三数之和最长无重复字符子串判断链表环与找环入口合并两个有序数组回文串判断这五个题型分别对应双指针的前四种基本形态加一个夹逼变形。练熟之后,大部分指针题都能从这些骨架上生长出来。6.2 第二阶段:针对变式做"刻意练习"基础题刷完,开始做变式的专项训练。列举一些典型的变式:三数之和变体:最接近target的三数之和,双指针内根据差值更新答案接雨水变体:前后双向维护最大高度,双向指针夹逼链表奇偶重排:快慢指针一次遍历完成拆分和重接找到字符串中所有字母异位词:滑动窗口 + 频次数组,定长窗口推着走删除有序数组中的重复项:双指针在同一方向上维护不同区间6.3 第三阶段:参加模拟面试,暴露真实表现刷题练习和面试表达是完全不同的能力。我在训练后期每天安排一场模拟面试,接收别人的追问,同时锻炼自己的口头推导能力。面试官会问三道指针题和一道算法设计题,结束后复盘自己当时的代码质量、边界分析、用时和提问。这样做的效果很明显。三次模拟之后,我发现自己在面试中的节奏问题:总是急着写代码,没有先把边界条件和指针移动逻辑讲清楚。为了改善,我给自己定了"先讲思路后动手、先跑用例再提交"的模板。到了真正的面试中,这个流程有效稳定了输出质量。练到这里,指针类型的题目基本可以覆盖90%以上的面试场景。这套方法对基础扎实的人事半功倍,对零基础的人也是一个少走弯路的学习路径。最后说一个我在实际使用中发现的小经验:遇到指针题卡住时先别急着看题解,拿笔在纸上把数组或链表画出来,从左到右手动走一遍指针的移动过程。手跟不上脑子的时候,往往是思路本身的bug所在;一旦指针移动的路径清晰了,代码就只是把思路翻译成语言的苦力活而已。
算法题3:指针类型题目,这个标题看起来简单,但"指针类型"这四个字背后藏着一整类高频面试题。最近好几个读者问我,说LeetCode刷到双指针、快慢指针、滑动窗口,总感觉每个题都会一点,但题目一变就懵。这篇文章就把指针类题目的底层逻辑拆开讲透,从使用场景、解题模板、边界条件到实战演变,一次讲明白。1. 指针类型题目到底在考什么指针类型题目,表面上是考你指针怎么移动,本质上考的是你对数据组织方式的理解。我面试别人的时候常说:指针题是最诚实的题型——会不会,一写边界条件就露馅。1.1 从"暴力解"到"指针解"的思维转变数组上的暴力解法,大多数是两层循环。外层循环固定一个起点,内层循环从起点扫到结尾。这种思路的问题在于时间复杂度是O(n²),数据规模一大就超时。指针解法改变的是扫描策略:不再是"每个起点扫一遍",而是"两个指针各扫一遍"。整个过程两个指针加起来最多走2n步,复杂度直接降到O(n)。这中间的思维跨越,是很多初学者卡住的地方。我来举个例子。判断一个字符串是不是回文串:暴力思路是反转字符串再比较,额外用O(n)空间。双指针思路是:一个指针从最左边往右走,一个指针从最右边往左走,逐个比较两个指针指向的字符是否相等。不相等就不是回文,相等就继续走。def isPalindrome(s: str) -> bool: left, right = 0, len(s) - 1 while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True这就是最基本的双指针。两个指针从两端向中间"夹逼",每次比较只涉及两个位置的访问,没有任何额外空间。1.2 双指针的本质:用两个游标替代枚举双指针的核心思想是:枚举的时候,很多组合是无效的,直接用两个游标的相对关系跳过这些无效组合。举个例子,有序数组里找两个数,让它们的和等于目标值target。暴力法是固定第一个数,遍历找第二个数,O(n²)。双指针法是:left指向数组开头,right指向数组结尾。如果left+right的和大于target,说明right太大了,right往左挪;如果小于target,说明left太小了,left往右挪。那有人会问:为什么这样移动不会漏掉答案?假设正确答案是nums[i]和nums[j],i < j。在整个过程中,left最多只会"超过"i,不会"跳过"i。为什么?因为left往右移动的唯一原因,是当前和小于target。如果当前left正好站在i的位置,right在j的右边,此时当前和一定大于等于nums[i]+nums[j],因为right指向的数是更大的数。一旦大于target,right就往左挪,最终一定会挪到j。所以答案不会被跳过。这就是双指针的正确性证明思路。代码上很简单,但要给别人讲清楚"为什么敢这么移动",大多数人讲不出来。2. 双指针的四种滑动形态:什么时候用哪一个双指针看似都叫"双指针",实际上内部还有细分。我习惯分成四种形态,面试和刷题时心里先定形态,再套模板,速度会快很多。2.1 夹逼型:两端向中间收窄两个指针分别从数组两端出发,根据条件决定哪边移动。典型场景是:有序数组找两数之和、回文判断、接雨水。用"接雨水"来展示这个形态的实际价值。题目给一个非负整数数组,每个数代表一个柱子的高度,求下雨后能接多少水。暴力法对每个位置往左右看最大值,O(n²)。双指针夹逼法是:left和right分别从两端走,维持leftMax和rightMax两个变量,代表左边和右边遇到过的最大高度。接雨水的关键,是当前位置能接的水量取决于min(leftMax, rightMax)。如果leftMax小于rightMax,说明右边一定有柱子兜底,此时left位置的水量只取决于leftMax。判断完之后,left往右走,否则right往左走。def trap(height: List[int]) -> int: if not height: return 0 left, right = 0, len(height) - 1 leftMax, rightMax = 0, 0 ans = 0 while left < right: if height[left] < height[right]: leftMax = max(leftMax, height[left]) ans += leftMax - height[left] left += 1 else: rightMax = max(rightMax, height[right]) ans += rightMax - height[right] right -= 1 return ans这个题的难点在于理解"短板原理":最低的柱子决定了水面能有多高。双指针夹逼时,每次都移动"当前较矮柱子"那一侧,因为较矮一侧的水量已经确定,移动指针不会影响后面的判断。2.2 快慢型:同一个方向,不同速度快慢指针也叫龟兔赛跑算法。两个指针都从起点出发,一个每次走两步,一个每次走一步。主要用在链表上:判断链表是否有环、找链表的中间节点、找链表的倒数第k个节点。链表的环检测是最经典的场景。为什么快指针每次走两步而不是任意步?因为快指针比慢指针每次多走一步,在环上,每走一次它们的距离就缩短1,所以一定能相遇。如果快指针走三步或更多,可能会出现永远跨过慢指针的情况,导致无法相遇。def hasCycle(head: Optional[ListNode]) -> bool: slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False有条件限制时,快慢指针也有用。比如"找链表倒数第k个节点":fast先走k步,然后slow和fast一起走,fast到底时slow正好在倒数第k个位置。这种方法只需要一次遍历,不需要知道链表长度。类似的边界处理问题都值得好好练习。找两个链表的交点也能用这个思路:先走一遍得出两条链表的长度差,然后让长链表的指针先走差值,再两个指针同步走,相遇的地方就是交点。这类题目本质上都是"用指针的相对距离差做文章"。2.3 滑窗型:维护一个动态区间滑动窗口其实是双指针的一种变形:一个指针作为窗口的左边界left,一个指针作为右边界right,right不断向右扩展窗口,left在满足条件时向右收缩窗口。典型题目是"最长无重复字符子串"。窗口右侧每扩展一个字符,就检查窗口内是否有重复。为了O(1)时间判断重复,配合哈希表记录每个字符最后一次出现的位置。如果当前字符之前出现过且在窗口内,就把left跳到上一次出现位置的下一个。def lengthOfLongestSubstring(s: str) -> int: last_seen = {} left = 0 ans = 0 for right, ch in enumerate(s): if ch in last_seen and last_seen[ch] >= left: left = last_seen[ch] + 1 last_seen[ch] = right ans = max(ans, right - left + 1) return ans滑动窗口的难点不在编码,而在窗口收缩的条件。很多时候"什么时候缩窗口、缩到哪一步停"是需要推导的,做题时不要上来就写代码,先用例子模拟一遍窗口的移动过程。滑窗另一个高频变形是"定长窗口 + 维护统计信息"。比如找数组中连续k个元素的某种极值,就用一个Hash表或者计数数组维护窗口内的统计信息,right扩展一个,left丢一个,每次更新统计再更新答案。这类题型的操作模式非常固定,本质就是"流水线式的窗口滑动"。2.4 合并型:两个序列的游标归并合并型双指针常用于两个有序数组的合并操作。两个指针分别指向两个数组的当前元素,谁小就选谁,把选出来的元素放入结果集,对应指针向前移动。经典题"合并两个有序数组"。给定两个非递减数组,把第二个数组的数值合并进第一个数组,第一个数组假设有自己的额外尾空间,要求原地操作。如果从左往右合并,会面临覆盖问题——nums1中的元素还没被比较就可能被覆盖了。常规做法是从后往前填充:p1指向nums1有效元素的末尾,p2指向nums2的末尾,cur指向nums1的末尾,从大到小逐个放置。这样即使覆盖也没事,因为被覆盖的位置已经被"消费"过了。def merge(nums1: List[int], m: int, nums2: List[int], n: int) -> None: p1, p2 = m - 1, n - 1 cur = m + n - 1 while p2 >= 0: if p1 >= 0 and nums1[p1] > nums2[p2]: nums1[cur] = nums1[p1] p1 -= 1 else: nums1[cur] = nums2[p2] p2 -= 1 cur -= 1这个题的启发性在于:指针移动方向是可以根据场景定制的。很多题目正着走会覆盖数据,倒着走反而干净利落。做题时如果发现正着处理特别别扭,试试从反面想。3. 指针题的边界条件:一个条件写错,整题前功尽弃指针题最大的特征就是边界条件极其敏感。数组访问越界、死循环、漏处理空数组,是三类最常见的错误。这一章我专门讲讲排查思路。3.1 循环条件怎么定:left <= right 还是 left < right这是区分菜鸟和老手的一个关键细节。两类循环写法对应的场景完全不同。如果循环体逻辑需要同时访问left和right位置的值,比如回文判断,那么用while left < right,两个指针至少要有间隔,否则都是同一个位置,比较没有意义。如果是二分查找类的逻辑,区间左右都是闭区间,需要访问的mid可能等于left或right,所以用while left <= right,否则中间元素会被漏掉。我自己的习惯是写代码前先想一句:指针重叠时,我还要不要处理这个位置?要处理就写成<=,不处理就写成<。再看一个典型例子,二分查找的两种写法:左闭右闭[left, right]用while left <= right;左闭右开[left, right)用while left < right。混用这两套写法,是很多人二分查找反复出错的根源。建议做题时固定一套习惯,不要每次凭感觉。3.2 空数组和单元素数组:最容易忽略的测试点指针题的共同尴尬是:逻辑跑标准样例没问题,一上来就空指针或者是长度为1的数组就崩。我在刷题时给自己定了一个规矩:每题提交前,强制在脑子里跑三个测试用例:空输入、单元素输入、大规模随机输入。空输入的套路是统一在函数开头加一次性判断。单元素输入的关键是像"反转链表"这类题里,头节点本身就是尾节点,循环条件往往就不满足,直接返回原链表即可,逻辑上非常清晰。大规模随机输入主要用来测性能,防止思考不周引入了O(n²)。这些习惯如果养成,能减少很多无谓的提交失败。3.3 链表场景下的空指针防护:先判空再解引用链表题里经常出现这样的错误:先写了node.next,忘了node可能是null。尤其在快慢指针、合并链表场景,链表的长度判空必须放在访问之前。# 错误的写法 if node.next: # 如果node本身就是None,这里就崩了 node = node.next # 正确的写法 if node and node.next: node = node.next建议统一养成"用node和node.next同时判断"的防御式写法。C++遇到野指针的情况很常见,Python和Java里虽然不太会遇到底层崩溃,但也会抛空指针异常,代码评审时这种问题最容易被揪出来。3.4 双指针对撞为什么不需要哈希表:空间换时间的权衡回到算法面试的一个核心问题:双指针和哈希表到底怎么选?比如两数之和,哈希表能O(n)时间解决,双指针需要先排序再扫,也是O(n log n)。为什么双指针也有价值?因为哈希表需要额外空间O(n),而双指针在有序数组上可以做到原地操作、零额外空间。在嵌入式、内存受限的场景,这是很大优势。此外"三数之和""四数之和"这类题,如果用哈希表去重逻辑非常复杂,但排序 + 双指针天然自带去重,代码反而容易些。这两者的取舍没有绝对答案,关键看清题目对时间空间的要求。很多候选人两数之和只会哈希表写法,遇到变种就卡住,说明练习时做得太表面了。4. 从"会做题"到"会挑题":指针解法的适用范围分析对着一道新题想不出解法,大概率不是不会写代码,而是没有判断出这道题"属于哪个门派"。分析指针的适用范围,这个能力比背诵代码模板重要得多。4.1 有序 + 枚举组合:双指针的第一反应场景双指针最典型的出场条件有两个:输入是数组或链表,单调有序或部分有序问题本质是需要从数据中找某种"组合"关系这两个条件同时满足时,双指针基本是第一个要想到的解。经典案例是三数之和:排序之后,遍历数组固定一个数left,然后对剩余区间用双指针夹逼找两数之和。这里的套路值得记下来:**"固定一个,双指针找两个"**是处理组合类题目的黄金模板。def threeSum(nums: List[int]) -> List[List[int]]: nums.sort() n = len(nums) res = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue # 跳过重复的基准元素 lo, hi = i + 1, n - 1 while lo < hi: s = nums[i] + nums[lo] + nums[hi] if s < 0: lo += 1 elif s > 0: hi -= 1 else: res.append([nums[i], nums[lo], nums[hi]]) while lo < hi and nums[lo] == nums[lo + 1]: lo += 1 while lo < hi and nums[hi] == nums[hi - 1]: hi -= 1 lo += 1 hi -= 1 return res这个题的坑主要在两个去重逻辑:外层固定值的去重、内层双指针收缩时的去重。漏掉任何一个,答案就会出现重复。想清楚"去重发生在何时",比硬背这段代码重要得多。4.2 链表问题:快慢指针的专属主场链表和数组最大的区别是"不知道长度、无法随机访问",所以链表题非常依赖指针的相对位置关系。这时候快慢指针就成了首选。判断回文链表、找链表中点、给环形链表找环入口,这几类问题都能用快慢指针解决。找环入口还需要进一步推理:快慢指针在环上相遇后,从链表头部和相遇点同时同速走,两个指针再次相遇的位置就是环入口。这里的推导基础是:链头到环入口的距离等于相遇点顺时针到环入口的距离。建议配合画图自己推一遍,不要只记结论。还有一个更进阶的场景:"删除链表倒数第n个节点"。快指针先走n步,慢指针再跟着走。这样等快指针走到链表末尾,慢指针恰好指向倒数第n个节点的前一个节点,直接跳过即完成删除。这类技巧的厉害之处在于只用一次遍历就完成了原本需要两次遍历的事情。4.3 什么时候不适用:必须提醒的场景约束指针解法也有明确的失效场景,我平时做技术评审时会特别注意这几点:数据无序且预处理成本高:如果数组完全无序,排序后又需要保持原始秩序,双指针可能不适用,最好换成哈希表或前缀和处理空间要求苛刻但需要额外标记:滑动窗口如果需要维护Hash计数,严格来说用了O(k)额外空间,特殊空间受限环境要斟酌指针语义不清晰的高维场景:处理矩阵或三维数据时,多指针策略很容易混淆,陷入维护索引的泥潭,建议优先考虑迭代器、坐标或状态压缩DP这些边界判断,体现了对算法"适合什么、不适合什么"的理解深度。平时刷题时除了把AC当目标,一定要顺手做这个判断题。5. 实战中的避坑记录:三次真实血泪教训讲完方法论,分享几个自己以前实际写代码时踩过的大坑。这些案例都比较典型,对正在准备面试的朋友特别有参考价值。5.1 合并数组时的覆盖问题:从错误推导出"从后往前"的写法有一次在处理合并两个有序数组的题目时,我一开始正序写。p1和p2都从0开始,一旦nums2中的元素比nums1中的小,就需要把nums1中后面所有元素整体往后挪一位。这么写代码是能过的,效率却很差,最差情况下每次插入都触发O(n)的搬移,整体复杂度退化成O(n²)。那时我停下来重新推导。nums1的末尾预留了空间,既然从前往后会覆盖未处理的元素,那从后往前填就不会了。每次比较两个数组末尾的最大值,更大的那个放到nums1真正的末尾。这个思路一换,代码立刻变得又短又稳。这告诉我的道理是:正向有冲突时,反向往往能绕开冲突的核心。指针方向不是一个僵化的选择,而应该是推导出来的结果。5.2 滑动窗口的最小值问题:left指针竟然往回走了还有一次做连续子数组题目,我错误地写了left的回退逻辑。当时的场景是窗口需要寻找满足某个和条件的最短子数组,我每次发现窗口不满足条件就想让left回退到更早的位置,结果死循环了。排查后发现问题的根源在于:滑动窗口的left指针只应该向右移动,不应该向左回退。窗口右边界增长时,如果当前子数组不满足条件,要么right继续向前,要么left向右收缩,绝不存在"left往后退"的合法性。左指针一旦回退,窗口状态就乱了,统计信息也完全不可信。那次调试让我记住了一个简洁可靠的原则:滑动窗口的两个指针在同一方向前进时可以随时各自移动,但任何指针都不能向反方向走。写代码时如果发现自己在移动指针时move back,要么思路错了,要么窗口维护方式有问题。5.3 数组和链表指针语义的混淆:index越界与node.next失效还有一次教训是混淆了数组和链表两种指针的底层语义。写数组代码时用索引习惯,自然而然地写了个伪代码",脑子里盘旋着"node = node.next",却忘了当前节点可能是None。在遍历链表的循环里,先判断while node and node.next,这句保护被我省了,结果一条长链表越到后面越容易出问题。数组的指针本质是索引下标,逻辑上是"当前位置",甚至可以回退搜索;链表指针则必须顺着next走,不存在回头路。两者之间每一种都有不同的边界规则,不能生搬硬套。后来我给自己立了一个规矩:写链表题时先画示意图,写数组题时先检查循环边界。宁可多花两分钟在草稿纸,不要用半小时在Debugger里追问题。这几次教训让我意识到,指针题的坑大多不是算法理念上的,而是细节执行上的。面试官看得最多的恰恰是这些细节——边界处理是否严谨、特殊用例是否考虑过、指针移动的状态是否正确。6. 如何系统练习指针题:我的训练路线这部分给出适合自己的训练路径。路径规划的逻辑是"先熟悉核心场景,再统一应对变式",不建议一上来就乱刷两百题。6.1 第一阶段:把基础场景刷成条件反射建议把以下题型按顺序练熟,每一类至少手写三遍,直到不需要想就能默写:有序数组的两数之和与三数之和最长无重复字符子串判断链表环与找环入口合并两个有序数组回文串判断这五个题型分别对应双指针的前四种基本形态加一个夹逼变形。练熟之后,大部分指针题都能从这些骨架上生长出来。6.2 第二阶段:针对变式做"刻意练习"基础题刷完,开始做变式的专项训练。列举一些典型的变式:三数之和变体:最接近target的三数之和,双指针内根据差值更新答案接雨水变体:前后双向维护最大高度,双向指针夹逼链表奇偶重排:快慢指针一次遍历完成拆分和重接找到字符串中所有字母异位词:滑动窗口 + 频次数组,定长窗口推着走删除有序数组中的重复项:双指针在同一方向上维护不同区间6.3 第三阶段:参加模拟面试,暴露真实表现刷题练习和面试表达是完全不同的能力。我在训练后期每天安排一场模拟面试,接收别人的追问,同时锻炼自己的口头推导能力。面试官会问三道指针题和一道算法设计题,结束后复盘自己当时的代码质量、边界分析、用时和提问。这样做的效果很明显。三次模拟之后,我发现自己在面试中的节奏问题:总是急着写代码,没有先把边界条件和指针移动逻辑讲清楚。为了改善,我给自己定了"先讲思路后动手、先跑用例再提交"的模板。到了真正的面试中,这个流程有效稳定了输出质量。练到这里,指针类型的题目基本可以覆盖90%以上的面试场景。这套方法对基础扎实的人事半功倍,对零基础的人也是一个少走弯路的学习路径。最后说一个我在实际使用中发现的小经验:遇到指针题卡住时先别急着看题解,拿笔在纸上把数组或链表画出来,从左到右手动走一遍指针的移动过程。手跟不上脑子的时候,往往是思路本身的bug所在;一旦指针移动的路径清晰了,代码就只是把思路翻译成语言的苦力活而已。