2870 字
14 分钟
4月4日刷题笔记--数组双指针与动态规划入门

本篇题目#

#26删除有序数组中的重复项
Easy
#283移动零
Easy
#167两数之和 II - 输入有序数组
Medium
#15三数之和
Medium
#300最长递增子序列
Medium

双指针基础概念#

什么是双指针#

双指针是指用两个变量分别指向数组中的不同位置,通过它们的相对移动来解决问题。for 循环本质上也是一种指针,只是隐式地自动 +1,和手动维护指针完全等价。

# 手动双指针写法
l, r = 0, 1
while 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) - 1
while 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 记录下一个不重复元素该放的位置,rfor 循环)遍历数组。遇到新元素就放到 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 慢指针记录写入位置,rfor)快指针遍历
  • 复杂度:时间 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] 从右往左更新从右往左保证依赖已计算
4月4日刷题笔记--数组双指针与动态规划入门
https://www.yyylegend.com/posts/4月4日刷题笔记数组双指针与动态规划入门/
作者
YYYLEGEND
发布于
2026-04-04
许可协议
CC BY-NC-SA 4.0