81、70爬楼梯(动态规划,简单)
思路:就是类似斐波那契数列
def climbStairs(self, n: int) -> int:
if n == 1:
return 1
a, b = 1, 1
res = 0
for i in range(2, n + 1):
res = a + b
a, b = res, a
return res
82、118杨辉三角(动态规划,简单)
思路:
外层循环
i表示当前行号(从 0 开始)。每行先创建临时列表并放入开头的
1。内层循环
for j in range(1, i):处理中间元素(当i >= 2时才执行),利用上一行res[i-1]的相邻两数求和。最后,如果
i >= 1(即不是第一行),补上末尾的1。
def generate(self, numRows: int) -> List[List[int]]:
res = []
for i in range(numRows):
tmp = []
tmp.append(1)
for j in range(1, i):
tmp.append(res[i - 1][j - 1] + res[i - 1][j])
if i >= 1:
tmp.append(1)
res.append(tmp)
return res
83、198打家劫舍(动态规划,中等)
定义二维状态:dpi0 = 前 i 间房屋,且 不偷第 i 间 的最高金额
dpi1 = 前 i 间房屋,且 偷第 i 间 的最高金额
在第 i 间房屋前,思考"最后一步"的两种可能:
不偷第 i 间(状态 0)
既然不偷 i,那 i-1 可以偷也可以不偷,取最大值即可:
dp[i][0] = max(dp[i-1][0], dp[i-1][1])偷第 i 间(状态 1)
如果偷 i,则 i-1 一定不能偷 ,所以只能从 i-1 的不偷状态转移,并加上当前房屋的金额:
dp[i][1] = dp[i-1][0] + nums[i]该代码空间 O(n),但 dpi 只依赖 dpi-1,所以可以用两个变量滚动更新,降为 O(1)
def rob(self, nums: List[int]) -> int:
n = len(nums)
dp = [[0 for _ in range(2)] for _ in range(n)]
dp[0][0] = 0
dp[0][1] = nums[0]
for i in range(1, n):
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1])
dp[i][1] = dp[i - 1][0] + nums[i]
return max(dp[n - 1][0], dp[n - 1][1])
84、279完全平方数(动态规划,中等)
思路:完全背包问题
dp[i]表示凑出整数i的最少平方数个;对于当前的i,dpi = min( dpi - j² + 1 ) ,对所有 k² ≤ i可以枚举这些数,假设当前枚举到 j,那么还需要取若干数的平方,构成 i−j2。此时发现该子问题和原问题类似,只是规模变小了
def numSquares(self, n: int) -> int:
dp = [inf] * (n + 1)
dp[1] = 1
dp[0] = 0
x = math.isqrt(n)
count = [0] * (x)
for i in range(1, x + 1):
count[i - 1] = i**2
for i in range(2, n + 1):
for k in count:
if k <= i:
dp[i] = min(dp[i - k] + 1, dp[i])
return dp[n]
85、322零钱兑换(动态规划,中等)
思路:这道题和"完全平方数"完全同模型------都是无限物品求最小数量。
dp[i]表示凑出金额i所需的最少硬币数。考虑最后一步用了哪一枚硬币
coin:
dp[i] = min(dp[i], dp[i - coin] + 1),对所有coin <= i。其他 dp[i] 初始化为一个足够大的值 (如 float('inf') 或 amount+1),表示暂时不可达。用 inf 的好处是:min 操作永远安全;检查最终答案时,若 dp[amount] 还是 inf,说明无法凑出,返回 -1.
我写时候的错误逻辑漏洞:
if j<=i:
if dpi==-1 and dpi-j!=-1:
dpi=dpi-j+1
else:
dpi=min(dpi, dpi-j+1)
如果dpi不是-1,而dpi-j是-1,那么更新dpi时可能用min(dpi, dpi-j+1),但dpi-j是-1,dpi-j+1 = 0,这会导致错误地将dpi改成0。
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [-1] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for j in coins:
if j <= i and dp[i - j] != -1:
if dp[i] == -1:
dp[i] = dp[i - j] + 1
else:
dp[i] = min(dp[i], dp[i - j] + 1)
return dp[amount]
86、139单词拆分(动态规划,中等)
思路:
状态 :
dp[i]表示s的前i个字符能否被拆分(用 0/1 表示)。初始化 :
dp[0] = 1,空字符串可拼。转移 :对每个
i,寻找一个切分点j,使得前缀dp[j]可拼且s[j:i]在字典里。
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
dp = [0] * (len(s) + 1)
dp[0] = 1
for i in range(1, len(s) + 1):
for j in range(0, i):
if dp[j] == 1 and s[j:i] in wordDict:
dp[i] = 1
return True if dp[len(s)] == 1 else False
87、300最长递增子序列(动态规划,中等)
思路:注意不要求连续
dpi 应该表示 以第 i 个元素结尾的最长递增子序列长度(必须包含 numsi)
要想得到
dp[i],需要看它之前所有元素j < i:
只要
nums[j] < nums[i],nums[i]就可以接在以nums[j]结尾的子序列后面所以
dp[i] = max( dp[j] + 1 ),对所有满足j < i且nums[j] < nums[i]的 j如果没有任何 j 满足,
dp[i] = 1(自己单独成为一个子序列)
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
dp = [0] * n
dp[0] = 1
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
if dp[i] == 0:
dp[i] = 1
return max(dp)
88、152乘积最大子数组(动态规划,中等)
思路:乘积问题因为负数反转符号,所以需要双状态。类似地:如果遇到"乘积最大子序列(不要求连续)",可能也得同时记录最大积和最小积。如果是求和,就不需要,因为加法不存在符号反转问题。
dpmax[i]:以nums[i]结尾的连续子数组的最大乘积
dpmin[i]:以nums[i]结尾的连续子数组的最小乘积我最初用了一堆if else来判断区分
nums[i]的正负,其实完全没有必要,直接统一,max和min会自动处理好符号反转、遇零归零、单独取当前数等所有情况。因为第
i步只依赖第i-1步,空间可以优化到 O(1)
def maxProduct(self, nums: List[int]) -> int:
n = len(nums)
dpmax, dpmin = [0] * n, [0] * n
dpmax[0] = dpmin[0] = nums[0]
for i in range(1, n):
dpmax[i] = max(nums[i] * dpmax[i - 1], nums[i] * dpmin[i - 1], nums[i])
dpmin[i] = min(nums[i] * dpmax[i - 1], nums[i] * dpmin[i - 1], nums[i])
return max(dpmax)
def maxProduct(self, nums: List[int]) -> int:
n = len(nums)
res = pre_max = pre_min = nums[0]
for i in range(1, n):
cur_max, cur_min = pre_max, pre_min
pre_max = max(nums[i] * cur_max, nums[i] * cur_min, nums[i])
pre_min = min(nums[i] * cur_max, nums[i] * cur_min, nums[i])
res = max(res, pre_max)
return res
89、416分割等和子集(动态规划,中等)
思路:总和必须能被 2 整除,和为奇数直接返回false;
每个子集的和 =
sum(nums) / 2,设这个值为target→ 题目变成:能否从
nums中选出一部分数,使它们的和正好为target?0/1 背包的特征:每个物品只有选/不选两种可能,且最多选一次。
dp[i][j],其中i表示前i个数,j表示某个和,前 i 个数能否凑出和 j。
不选第
i个数(值为num):dp[i][j]取决于dp[i-1][j]选第
i个数:dp[i][j]取决于dp[i-1][j - num](前提是j >= num)两者满足其一即可,所以用"逻辑或"连接。
一维dp:可以发现上面的二维dp任何一行的计算,只依赖于上一行同一列和更左侧的某列。
原理总结:
当我们计算
dp[j]时,需要的是dp[i-1][j-num],也就是"上一行"更左侧的值。在一维滚动数组中,这个"上一行"的值就存储在当前的
dp[j-num]中。如果 先更新小索引,我们会把上一行的值提前污染成这一行的值,导致同一个数被重复使用(完全背包效果)。
如果 先更新大索引,当我们更新
dp[j]时,dp[j-num]还保留着上一行的状态,因为j-num < j,还没被这一轮更新覆盖。这就完美模拟了 0/1 背包:每个数只用一次。对于 0/1 背包,所有二维 DP 只要满足"仅依赖上一行",都能压缩成一维
def canPartition(self, nums: List[int]) -> bool:
if sum(nums)%2!=0:return False
target=int(sum(nums)/2)
n=len(nums)
dp=[[0 for _ in range(target+1)] for _ in range(n+1)]
dp[0][0]=1
for i in range(1,n+1):
dp[i][0]=1
for j in range(1,target+1):
if dp[i-1][j] or (j>=nums[i-1] and dp[i-1][j-nums[i-1]]):
dp[i][j]=1
else:
dp[i][j]=0
return dp[n][target]==1
if sum(nums) % 2 != 0:
return False
target = int(sum(nums) / 2)
n = len(nums)
dp = [0] * (target + 1)
dp[0] = 1
for num in nums:
for j in range(target, -1, -1):
if j >= num:
dp[j] = dp[j - num] or dp[j]
return dp[target] == 1
90、32最长有效括号(动态规划,困难)
思路:
状态定义:
dp[i]= 以s[i]结尾的最长有效括号子串长度
如果
s[i]=='(',长度只能是 0(continue维持初始化的 0)。如果
s[i]==')',分两种情况:情况 A :
s[i-1]=='('→
...()结构,dp[i] = 2 + dp[i-2]情况 B :
s[i-1]==')'→ 先找到与当前
)配对的候选位置pre = i - dp[i-1] - 1
若
pre < 0或s[pre] != '(',匹配失败,dp[i]=0若
s[pre] == '(',匹配成功,有效长度 =dp[i-1] + 2+ 前面可能接上的有效长度dp[pre-1](如果pre-1 >= 0)
def longestValidParentheses(self, s: str) -> int:
n = len(s)
if n == 1 or n == 0:
return 0
dp = [0] * (n)
dp[0] = 0
dp[1] = 2 if s[1] == ")" and s[0] == "(" else 0
for i in range(2, n):
if s[i] == "(":
continue
else:
if s[i - 1] == "(":
dp[i] = 2 + dp[i - 2]
else:
if i - dp[i - 1] - 1 >= 0:
tmp = s[i - dp[i - 1] - 1]
if tmp == ")":
continue
else:
if i - dp[i - 1] - 2 >= 0:
dp[i] = dp[i - dp[i - 1] - 2] + dp[i - 1] + 2
else:
dp[i] = i + 1
return max(dp)
91、62不同路径(多维动态规划,中等)
思路:最后一步要么从上边
(i-1, j)下来,要么从左边(i, j-1)过来。→
dp[i][j] = dp[i-1][j] + dp[i][j-1]优化:计算第
i行时,只用到了第i-1行(上一行)以及本行左边的格子 。因此可以只维护一行数组
dp[j],代表"上一行"的状态,然后一边计算一边更新。
dp[j] = dp[j] + dp[j-1] # dp[j] 是上一行的值,dp[j-1] 是本行刚算出的左边值
def uniquePaths(self, m: int, n: int) -> int:
dp = [[0] * n for _ in range(m)]
for i in range(0, m):
dp[i][0] = 1
for i in range(0, n):
dp[0][i] = 1
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]
92、64最小路径和(多维动态规划,中等)
思路:
dp[j]:在当前正在处理的行中,从左上角(0,0)走到(当前行, j)的最小路径和。
def minPathSum(self, grid: List[List[int]]) -> int:
m = len(grid)
n = len(grid[0])
dp = [0] * n
dp[0] = grid[0][0]
for i in range(1, n):
dp[i] = dp[i - 1] + grid[0][i]
for i in range(1, m):
dp[0] = dp[0] + grid[i][0]
for j in range(1, n):
dp[j] = min(dp[j], dp[j - 1]) + grid[i][j]
return dp[n - 1]
93、5最长回文子串(多维动态规划,中等)
思路:暴力解法: 时间复杂度O(n³);以
i结尾时,你从j = i - dp[i-1] - 1(即"以 i-1 结尾的最长回文"的左边界再往左一位)开始,一直检查到j = i,对每个候选的j,切片s[j:i+1],反转对比,如果是回文就更新dp[i]区间DP :时间复杂度O(n2),空间复杂度O(n2)。
dp[i][j]:用 0/1 表示s[i..j]是否是回文(1为真),初始化长度为 1 的所有子串为回文;外层枚举长度,内层枚举左端点。dp[i][j]依赖dp[i+1][j-1],在表格上就是左下角的那个格子,所以需要按照长度枚举。**中心扩展法:**时间复杂度O(n2),空间复杂度O(1)。回文不是由端点定义的,而是由对称中心定义的。一个回文子串,本质上是从某个中心向两边对称展开的结果。
如果 sleft == sright,说明这一层是对称的;left 左移一位,right 右移一位。
遍历所有可能的中心:奇数中心:每个字符都是一个中心,共 n 个;偶数中心:每两个相邻字符之间的缝隙,共 n-1 个;总共
2n - 1个中心对每个中心,分别尝试扩展:奇数扩展:
expand(s, i, i);偶数扩展:expand(s, i, i+1)
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
dp = [1] * n
for i in range(1, n):
start = i - dp[i - 1] - 1
if start < 0:
start = 0
for j in range(start, i + 1):
tmp = s[j : i + 1]
if tmp == tmp[::-1]:
dp[i] = max(dp[i], len(tmp))
maxlen = 0
maxi = 0
for i in range(0, n):
if dp[i] > maxlen:
maxlen = dp[i]
maxi = i
return s[maxi - maxlen + 1 : maxi + 1]
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
if n == 1:
return s
dp = [[0] * n for _ in range(n)]
maxlen = 1
resj = 0
for i in range(0, n):
dp[i][i] = 1
for i in range(2, n + 1):
for j in range(0, n - i + 1):
if (dp[j + 1][j + i - 2] == 1 or i == 2) and s[j] == s[j + i - 1]:
dp[j][j + i - 1] = 1
if i > maxlen:
maxlen = i
resj = j
return s[resj : resj + maxlen]
class Solution:
def expand(self, s, l, r):
while l >= 0 and r < len(s) and s[l] == s[r]:
l -= 1
r += 1
return l + 1, r - 1
def longestPalindrome(self, s: str) -> str:
n = len(s)
maxlen, start = 1, 0
for i in range(0, n):
l1, r1 = self.expand(s, i, i)
if i < n - 1 and s[i] == s[i + 1]:
l2, r2 = self.expand(s, i, i + 1)
else:
l2, r2 = 0, 0
if r1 - l1 + 1 > maxlen:
maxlen = r1 - l1 + 1
start = l1
if r2 - l2 + 1 > maxlen:
maxlen = r2 - l2 + 1
start = l2
return s[start : start + maxlen]
94、1143最长公共子序列(多维动态规划,中等)
思路:
状态必须同时描述两个字符串的进度:
dp[i][j]表示text1的前 i 个字符 和text2的前 j 个字符之间的最长公共子序列长度。考虑最后两个字符的关系:
情况 A:
text1[i-1] == text2[j-1]这两个字符相等,它们一定可以 出现在公共子序列里,而且是作为最后一位。
那剩下的 LCS 就是
text1的前 i-1 个和text2的前 j-1 个的 LCS 长度,再加上 1。情况 B:
text1[i-1] != text2[j-1]这两个字符不相等,它们不可能同时 出现在公共子序列的最后。
只能舍弃其中一个:
舍弃
text1的最后字符:看dp[i-1][j]舍弃
text2的最后字符:看dp[i][j-1]取两者中的较大值。
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m = len(text1)
n = len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]
95、72编辑距离(多维动态规划,中等)
思路:
dp[i][j]= 将word1[0..i-1]转换成word2[0..j-1]所需的最少操作数;情况 A:
word1[i-1] == word2[j-1]这两个字符已经相同,不需要任何操作→
dp[i][j] = dp[i-1][j-1]情况 B:
word1[i-1] != word2[j-1]必须做一次操作,有三种选择:
替换 :把
word1[i-1]替换成word2[j-1],然后问题退化成dp[i-1][j-1]。操作数 +1。删除 :删除
word1[i-1],问题退化成dp[i-1][j]。操作数 +1。插入 :在
word1的末尾插入一个word2[j-1],这样新字符和word2[j-1]匹配,问题退化成dp[i][j-1]。操作数 +1。取这三种操作的最小值:
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m = len(word1)
n = len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, n + 1):
dp[0][i] = i
for i in range(1, m + 1):
dp[i][0] = i
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(
dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1, dp[i - 1][j] + 1
)
return dp[m][n]
96、136只出现一次的数字(位运算,简单)
思路:
位运算 就是直接对整数在内存中的二进制位 进行操作。
+、-、*是把一个数当作整体来算,而位运算是把数拆成一个个 0/1 位来独立运算。常见的位运算有:
与
&:两位都为 1 才得 1或
|:只要有一位为 1 就得 1异或
^:两位不同就得 1,相同得 0
def singleNumber(self, nums: List[int]) -> int:
res = 0
for num in nums:
res ^= num
return res
97、169多数元素(数组,简单)
思路:Boyer-Moore 投票算法(O(n) 时间,O(1) 空间)
不同则抵消。多数元素出现次数 > ⌊n/2⌋,如果把它和所有其他元素两两配对"消灭",最后活下来的一定是它。
class Solution:
def majorityElement(self, nums: List[int]) -> int:
candidate = None
count = 0
for num in nums:
if count == 0:
candidate = num
count = 1
else:
if num == candidate:
count += 1
else:
count -= 1
return candidate
98、75颜色分类(排序,中等)
思路:原地排序,也叫荷兰国旗问题。
把数组分成 三个区域,从左到右:0 的区间 1 的区间 待探查区间 2 的区间
维护三个指针:
low:下一个 0 应该放的位置(也是 1 区间的左边界)
mid:当前正在检查的元素位置(待探查区间的左端)
high:下一个 2 应该放的位置(2 区间的左边界)核心逻辑 :
mid从左向右移动,遇到什么值就做什么操作:
nums[mid] == 0:把它和
low位置交换,这样 0 就去了前面的区域,然后low++,mid++(因为换过来的只可能是 0 或 1,不可能有 2)
nums[mid] == 1:已经在正确区域,不用动,
mid++
nums[mid] == 2:把它和
high位置交换,这样 2 就去了后面的区域,然后high--注意 :
mid不增加 ,因为从high换过来的值还没检查过,可能是 0 或 1,需要下次再判断。
class Solution:
def sortColors(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
n=len(nums)
low,mid,high=0,0,n-1
while mid<=high:
if nums[mid]==2:
nums[high],nums[mid]=nums[mid],nums[high]
high-=1
elif nums[mid]==0:
nums[low],nums[mid]=nums[mid],nums[low]
low+=1
mid+=1
else:
mid+=1
99、31下一个排列(数组,中等)
思路:首先解释一下这个题目:把它看成一个多位数字 (每一位就是数组的一个元素)。所有由同样数字构成的排列中,找到一个"刚刚好比当前数字大一点 "的下一个数。如果已经是最大的那个数,就回到最小的那个数。要求:原地修改,只用常数额外空间。
想法:
为了让下一个数"刚好大一点",应该:尽可能保留高位不变 (就像加 1 时,优先动个位数,不动千位数);在尽可能靠右 的位置做一个微小的"增大";增大之后,把后面的所有数字重新排成最小排列(升序),这样才能让整体"尽量小"。
从右向左找第一个"下降点"
pivot:希望找到可以变大的最靠右的位置。如果一个位置右侧的数字都是降序的,说明右侧部分已经是最大排列了,没法再变大;在pivot右侧找"刚好比它大一点"的数,因为从右向左是递增的,所以我们只需从右向左找第一个大于nums[pivot]的数 ,它就是那个"最小的大于它的数"。交换之后,pivot右边的子数组仍然是降序 ,降序是最大的排列。为了使整体增幅最小,需要把后面这部分变成最小排列,也就是升序。性质 1:为什么
pivot右边是降序?因为
pivot是从右向左第一个 满足nums[i] < nums[i+1]的位置。这意味着在
pivot右边的所有位置k,都满足nums[k] >= nums[k+1](否则会在更右的位置找到另一个i)。性质 2:为什么"刚好大于"就是"从右向左第一个大于"?
右侧降序 → 从右向左看是升序。
在升序序列中,第一个遇到的满足
> pivot值的元素,必然就是所有> pivot值的元素中最小的那个。性质 3:为什么交换后,右侧仍然保持降序?
交换前:
nums[pivot] = a,nums[successor] = b,满足b > a在
successor右边的所有元素都≤ a(否则successor就不是第一个大于a的数)交换后:
- 新
pivot位置变成b,新successor位置变成a检查相邻关系:
successor左边邻居和a的关系:原左边元素≥ b > a,所以降序保持。
a和右边邻居的关系:右边邻居≤ a,所以降序保持。其余相邻关系不变。
因此整个右侧仍是降序,可以直接反转。
class Solution:
def nextPermutation(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
n = len(nums)
pivot = -1
for i in range(n - 1, 0, -1):
if nums[i] > nums[i - 1]:
pivot = i - 1
break
if pivot == -1:
nums.reverse()
return
aa = 0
for j in range(n - 1, pivot, -1):
if nums[j] > nums[pivot]:
aa = j
break
nums[pivot], nums[aa] = nums[aa], nums[pivot]
l = pivot + 1
r = n - 1
while l <= r:
nums[l], nums[r] = nums[r], nums[l]
l += 1
r -= 1
100、287寻找重复数(数组,中等)
思路:时间复杂度 O(n),空间复杂度 O(1),不修改原数组。
题目有两个关键条件:数字都在
[1, n]范围内,数组长度为n + 1这恰好建立一种映射:把数组的索引看作链表节点,把索引的值看作
next指针 。也就是从i到nums[i]连一条有向边。每个节点的入度,就是这个节点在 nums 中的出现次数。重复元素的入度大于 1。Floyd 快慢指针判圈算法:
让快慢指针同时从起点出发,因为有环,快指针最终会套圈慢指针,两者在环内某点相遇。相遇后,把慢指针(或另一个新指针,这里用
head)重置到起点。然后两个指针每次都只走一步,它们再次相遇的那个节点,就是环的入口,也就是要找的重复数字。
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
slow = fast = 0
while True:
slow = nums[slow]
fast = nums[nums[fast]]
if slow == fast:
break
head = 0
while slow != head:
slow = nums[slow]
head = nums[head]
return slow