本篇题目
双指针基础概念
什么是双指针
双指针是指用两个变量分别指向数组中的不同位置,通过它们的相对移动来解决问题。for 循环本质上也是一种指针,只是隐式地自动 +1,和手动维护指针完全等价。
# 手动双指针写法l, r = 0, 1while r < len(nums): # ... r += 1 # 手动移动
# for 循环写法(等价)for r in range(1, len(nums)): # r 自动 +1双指针通用模板
模板① 快慢指针(原地修改数组)
适用:删除重复元素、移动零、原地过滤
l = 0 # 慢指针,指向下一个"合法位置"for r in range(len(nums)): # 快指针,负责遍历探测 if <满足条件>: nums[l] = nums[r] # 把合法元素写入 l 的位置 l += 1 # l 右移,准备接下一个合法元素# 最终 l 就是合法元素的数量模板② 对撞指针(有序数组找目标)
适用:两数之和、三数之和
l, r = 0, len(nums) - 1while l < r: if nums[l] + nums[r] > target: r -= 1 # 和太大,右指针左移 elif nums[l] + nums[r] < target: l += 1 # 和太小,左指针右移 else: return [l, r] # 找到答案LeetCode 26 · 删除有序数组中的重复项
思路
用快慢双指针,l 记录下一个不重复元素该放的位置,r(for 循环)遍历数组。遇到新元素就放到 l 的位置,然后 l 右移。
代码
class Solution: def removeDuplicates(self, nums: List[int]) -> int: l = 1 # 第一个元素永远保留,从第二个位置开始填
for r in range(1, len(nums)): if nums[r] != nums[r - 1]: # 遇到新元素 nums[l] = nums[r] # 放到 l 的位置 l += 1 return l # l 本身就是不重复元素的数量关键细节
l 从 1 开始:第一个元素永远不需要去重,所以 l=1 表示从第二个位置开始填,最终 l 直接等于唯一元素的数量,不需要 return l + 1。
l 的含义:l 不是索引,而是”已处理的不重复元素个数”,每次找到新元素就先赋值再 +1,所以最终值就是答案。
总结
- 核心技巧:快慢双指针,
l慢指针记录写入位置,r(for)快指针遍历 - 复杂度:时间 O(n),空间 O(1)
LeetCode 283 · 移动零
思路
快慢双指针,r 遍历数组,遇到非零元素就和 l 交换,l 右移。非零数字自然移到前面,零就沉到后面了。
代码
class Solution: def moveZeroes(self, nums: List[int]) -> None: l, r = 0, 0
while r < len(nums): if nums[r] != 0: nums[l], nums[r] = nums[r], nums[l] # 交换 l += 1 # l 右移,等待下一个非零数字 r += 1 # r 每轮都右移探测关键细节
和第 26 题的区别:这题用交换而不是覆盖,因为要保留零,只是把它们移到末尾。
总结
- 核心技巧:快慢双指针,遇到非零就交换
- 复杂度:时间 O(n),空间 O(1)
LeetCode 167 · 两数之和 II
思路
数组已升序排列,用对撞指针。两数之和大于 target 就让右指针左移(变小),小于 target 就让左指针右移(变大),等于就返回。
代码
class Solution: def twoSum(self, numbers: List[int], target: int) -> List[int]: left = 0 right = len(numbers) - 1
while left < right: if numbers[left] + numbers[right] > target: right -= 1 elif numbers[left] + numbers[right] < target: left += 1 else: return [left + 1, right + 1] # 下标从 1 开始,所以 +1
return [] # 题目保证有解,这行可写可不写关键细节
为什么第二个条件必须用 elif 不能用 if:
elif 保证每次循环只移动一个指针。如果改成 if,第一个条件成立执行 right -= 1 后,会继续判断第二个 if,可能在同一轮循环里同时移动两个指针,导致跳过答案甚至死循环。
总结
- 核心技巧:对撞指针,利用有序性有方向地移动
- 复杂度:时间 O(n),空间 O(1)
LeetCode 15 · 三数之和
思路
把三数之和降维成两数之和。
for 循环固定第一个数 a,问题变成:在 a 右边找两个数之和等于 -a,这就是上一题的双指针做法。额外要处理去重。
记忆方式:排序 → for 循环固定 a → 剩下的用 twoSum 双指针 → 两处去重
代码
class Solution: def threeSum(self, nums: list[int]) -> list[list[int]]: # 先升序排序,让双指针可以有方向地移动 res = [] nums.sort()
# enumerate 同时拿到锚点的索引和值 for i, number in enumerate(nums):
# 锚点去重:如果当前锚点和上一个相同,跳过 # 因为结果会完全重复,没有意义 if i > 0 and number == nums[i - 1]: continue
# 初始化左右指针,在锚点右边的范围内找两数之和 l = i + 1 r = len(nums) - 1
# 只要左右指针没相遇就继续 while l < r: temp_three_sum = number + nums[l] + nums[r] # 和太大,右指针左移(让和变小) if temp_three_sum > 0: r -= 1 # 和太小,左指针右移(让和变大) elif temp_three_sum < 0: l += 1 else: # 找到和为 0 的三个数,加入结果列表 res.append([number, nums[l], nums[r]]) l += 1 # 左指针去重:跳过和上一个相同的数字 # 注意 l < r 要放前面,防止越界后再访问 nums[l] while l < r and nums[l] == nums[l - 1]: l += 1 return res关键细节
两处去重:
- 锚点
a去重:if i > 0 and number == nums[i - 1]: continue - 找到答案后
l去重:while l < r and nums[l] == nums[l - 1]
条件顺序:while l < r and nums[l] == nums[l - 1] 中 l < r 必须放前面,利用短路求值,防止 l 越界时访问 nums[l] 报错。
总结
- 核心技巧:for 循环固定锚点 + twoSum 双指针,两处去重
- 复杂度:时间 O(n²),空间 O(1)
LeetCode 300 · 最长递增子序列
思路
这题换成动态规划,不是双指针。
LIS[i] 表示从 nums[i] 开始的最长递增子序列长度,初始都为 1(每个元素自己就是长度为 1 的子序列)。
从右往左遍历:对于每个 i,看右边所有比它大的 j,用 1 + LIS[j] 来更新 LIS[i]。为什么从右往左?因为 LIS[i] 依赖右边的结果,右边必须先算好。
代码
class Solution: def lengthOfLIS(self, nums: List[int]) -> int: # 初始化 LIS 列表,每个数字的 LIS 初始值为 1(自己本身) LIS = [1] * len(nums)
for i in range(len(nums) - 1, -1, -1): # 从右往左遍历(锚点) for j in range(i + 1, len(nums)): # j 从 i 右边一位遍历到末尾 # 只有右边数字更大时,才能形成递增子序列,才更新 LIS[i] if nums[j] > nums[i]: LIS[i] = max(LIS[i], 1 + LIS[j])
# LIS 的起点可以是任意一个数字,所以返回 LIS 中的最大值 return max(LIS)走一遍例子
nums = [1, 2, 4, 3]LIS = [1, 1, 1, 1] # 初始
i=3(3): 右边没有数,LIS 不变i=2(4): 右边没有比 4 大的,LIS 不变i=1(2): nums[2]=4>2,LIS[1]=max(1,1+1)=2 nums[3]=3>2,LIS[1]=max(2,1+1)=2 LIS=[1,2,1,1]i=0(1): nums[1]=2>1,LIS[0]=max(1,1+2)=3 LIS=[3,2,1,1]
返回 max(LIS) = 3 ✓ (子序列是 [1,2,4] 或 [1,2,3])总结
- 核心技巧:动态规划,从右往左保证依赖已计算
- 复杂度:时间 O(n²),空间 O(n)
动态规划的心法与通用模板
什么是动态规划(DP)?
动态规划 = 定义状态 + 找递推关系 + 遍历顺序
与递归的本质区别:用数组记录中间结果,避免重复计算
动态规划的三步曲
步骤1️⃣:定义状态(最关键!)
用一个数组或字典定义”问题的子问题”,每个位置代表一种情况的答案。
# 模板:# dp[i] = 某个答案 / 数值# 含义:从 i 开始(或到 i 为止)的最大/最小值、路径数等
# 300题例子:# dp[i] = 从 nums[i] 开始的最长递增子序列长度定义状态的秘诀:
- 问自己:“我要存储什么中间结果?”
- 不要定义模糊的状态,要具体到某个位置/某种选择
- 状态定义好了,递推关系就水到渠成
步骤2️⃣:找递推关系(推导过程)
用前面已算出的状态,推导当前状态。
# 模板:# dp[i] = f(dp[i-1], dp[i-2], ...) 或者# dp[i] = f(dp[i+1], dp[i+2], ...)
# 300题例子:# dp[i] = max(1, max(1 + dp[j] for j in range(i+1, n) if nums[j] > nums[i]))# ^^^^^^ 当前值本身是1 ^^^^^^^^^^^^^ 如果右边有更大的数,延伸过去找递推关系的秘诀:
- 问自己:“当前状态怎样从之前的状态算出来?”
- 分情况讨论:选择做什么 vs. 不做什么、符合条件 vs. 不符合等
- 用
max()/min()/+组合已知状态
步骤3️⃣:遍历顺序(至关重要!)
这决定了依赖关系是否满足。
# 从左往右:dp[i] 依赖 dp[i-1] 等左边的值for i in range(len(arr)): dp[i] = f(dp[i-1], ...)
# 从右往左:dp[i] 依赖 dp[i+1] 等右边的值for i in range(len(arr) - 1, -1, -1): dp[i] = f(dp[i+1], ...)
# 300题:从右往左,因为 dp[i] = max(...dp[j] for j > i)遍历顺序的秘诀:
- 先算被依赖的,再算依赖它的
- 如果
dp[i]需要dp[j],那么dp[j]必须先算好 - 错的遍历顺序 = 用未初始化的值 = 结果全错
动态规划通用模板
class Solution: def solve(self, arr): n = len(arr)
# 第一步:定义状态并初始化 dp = [初始值] * n # 或者 dp = {状态: 值}
# 可能需要 base case dp[边界位置] = 边界值
# 第二步:确定遍历顺序和递推关系 # 方案A:从左往右(依赖左边) for i in range(1, n): # 根据 dp[i-1] 等推导 dp[i] dp[i] = f(dp[i-1], arr[i])
# 方案B:从右往左(依赖右边) for i in range(n-2, -1, -1): # 根据 dp[i+1] 等推导 dp[i] dp[i] = f(dp[i+1], arr[i])
# 第三步:返回答案 return max(dp) / min(dp) / dp[n-1] / ...常见的递推关系套路
| 题型 | 递推关系 | 例题 |
|---|---|---|
| 最长/最短序列 | dp[i] = max(dp[i], 1 + dp[...]) 找条件符合的最优子状态 | 300-LIS |
| 路径计数 | dp[i] = sum(dp[j]) 汇总所有合法前驱状态 | 爬楼梯 |
| 能否到达 | dp[i] = dp[i] or dp[j] 只要有一条路径可达就算可达 | 跳跃游戏 |
| 数字和类 | dp[i] = min(dp[i], cost + dp[...]) 选最小成本方案 | 硬币找零 |
动态规划 vs. 贪心 vs. 递归
| 特性 | 动态规划 | 贪心 | 递归(不记忆化) |
|---|---|---|---|
| 最优子结构 | ✅ 有 | ✅ 有 | ✅ 有 |
| 无后效性 | ✅ 有 | ✅ 有 | ✅ 有 |
| 子问题重叠 | ✅ 有 | ❌ 无 | ✅ 有 |
| 需要记忆化 | ✅ 是 | ❌ 不需要 | ✅ 是 |
| 复杂度 | 中等 | 最优 | 指数(不优化) |
记住这个口诀
定义状态 + 列递推 + 选遍历 = 动态规划
状态定义好,递推自然来;遍历顺序对,结果不会坏。
五题横向对比
| 题目 | 类型 | 核心技巧 | 特殊处理 |
|---|---|---|---|
| 26 删除重复项 | 快慢指针 | l 记录写入位置,遇到新元素就填入 | l 从 1 开始,返回 l 即为答案 |
| 283 移动零 | 快慢指针 | 遇到非零就和 l 交换 | 用交换而非覆盖 |
| 167 两数之和 II | 对撞指针 | 有序数组,大了缩右,小了扩左 | 第二个条件必须用 elif |
| 15 三数之和 | 对撞指针 | for 固定锚点 + twoSum 双指针 | 两处去重,注意条件顺序防越界 |
| 300 最长递增子序列 | 动态规划 | LIS[i] 从右往左更新 | 从右往左保证依赖已计算 |