练习题
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组 nums
,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1 :
输入:nums = [1,2,3,1]
输出:4
解释:偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3),偷窃到的最高金额 = 1 + 3 = 4 。
示例 2 :
输入:nums = [2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋(金额 = 2),偷窃 3 号房屋(金额 = 9),接着偷窃 5 号房屋(金额 = 1),偷窃到的最高金额 = 2 + 9 + 1 = 12 。
最优解(动态规划 + 空间优化)
利用动态规划思想,通过滚动变量优化空间复杂度至 (O(1))。
思路 :
定义两个变量 first
和 second
,分别表示偷到前前一间房屋和前一间房屋的最大金额。对于当前房屋,有两种选择:
- 偷当前房屋:则总金额为
first + 当前房屋金额
。 - 不偷当前房屋:则总金额为
second
。
取两者最大值更新状态,逐步迭代。
代码:
python
class Solution:
def rob(self, nums: List[int]) -> int:
if not nums:
return 0
if len(nums) == 1:
return nums[0]
first, second = nums[0], max(nums[0], nums[1])
for i in range(2, len(nums)):
first, second = second, max(second, first + nums[i])
return second
复杂度分析:
- 时间复杂度:(O(n)),其中 (n) 是房屋数量,需遍历数组一次。
- 空间复杂度:(O(1)),仅用两个变量存储状态。
答案解析:
- 初始化
first
为第一间房屋金额,second
为前两间房屋的较大值。 - 从第三间房屋开始迭代,每次更新
first
和second
,first
代表前前一间的最大值,second
代表前一间的最大值。 - 最终
second
即为偷到最后一间房屋时的最大金额,返回该值。
题目:完全平方数
给定正整数 ( n ),找到若干个完全平方数(比如 ( 1, 4, 9, 16, \dots ))使得它们的和等于 ( n ),要求组成和的完全平方数的个数最少。
解法:动态规划
思路 :
定义 ( dp[i] ) 表示组成 ( i ) 的最少完全平方数个数。对于每个 ( i ),遍历小于 ( i ) 的完全平方数 ( j^2 ),通过状态转移方程 ( dp[i] = \min(dp[i], dp[i - j^2] + 1) ) 计算最小值。
代码:
python
import math
def numSquares(n):
dp = [float('inf')] * (n + 1)
dp[0] = 0
for i in range(1, n + 1):
max_j = int(math.sqrt(i))
for j in range(1, max_j + 1):
if i >= j * j:
dp[i] = min(dp[i], dp[i - j * j] + 1)
return dp[n]
解释:
- 初始化 ( dp ) 数组,
dp[0] = 0
表示 ( 0 ) 不需要任何完全平方数。 - 遍历 ( i ) 从 ( 1 ) 到 ( n ),对于每个 ( i ),遍历 ( j )(( j^2 \leq i ))。
- 通过状态转移方程更新 ( dp[i] ),取最小值。
- 最终 ( dp[n] ) 即为组成 ( n ) 的最少完全平方数个数。
时间复杂度 :( O(n\sqrt{n}) ),遍历 ( n ) 次,每次遍历 ( \sqrt{n} ) 次。
空间复杂度:( O(n) ),存储 ( dp ) 数组。
此方法通过动态规划高效地计算出最少完全平方数个数,确保了算法的正确性和效率。
题目描述
给你两个单词 word1
和 word2
,请返回将 word1
转换成 word2
所使用的最少操作数。你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
最优解(Python)
python
from typing import List
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# 创建二维数组,dp[i][j]表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符的最少操作数
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 初始化边界条件
for i in range(m + 1):
dp[i][0] = i # word1 前 i 个字符转换为空字符串,需要删除 i 个字符
for j in range(n + 1):
dp[0][j] = j # 空字符串转换为 word2 前 j 个字符,需要插入 j 个字符
# 填充 dp 数组
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:
# 取插入、删除、替换三种操作的最小值,然后加 1(因为进行了一次操作)
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
return dp[m][n]
最优解分析
-
动态规划思路:
- 定义
dp[i][j]
表示将word1
的前i
个字符转换为word2
的前j
个字符的最少操作数。 - 边界条件 :
- 若
word1
为空字符串(i = 0
),则需要插入j
个字符才能转换为word2
的前j
个字符,即dp[0][j] = j
。 - 若
word2
为空字符串(j = 0
),则需要删除i
个字符才能将word1
的前i
个字符转换为空字符串,即dp[i][0] = i
。
- 若
- 状态转移方程 :
- 当
word1[i - 1] == word2[j - 1]
时,当前字符相同,不需要进行插入、删除或替换操作,所以dp[i][j] = dp[i - 1][j - 1]
。 - 当
word1[i - 1] != word2[j - 1]
时,有三种操作选择:- 插入:将
word1
的前i
个字符转换为word2
的前j - 1
个字符(dp[i][j - 1]
),再插入一个字符,操作数为dp[i][j - 1] + 1
。 - 删除:将
word1
的前i - 1
个字符转换为word2
的前j
个字符(dp[i - 1][j]
),再删除一个字符,操作数为dp[i - 1][j] + 1
。 - 替换:将
word1
的前i - 1
个字符转换为word2
的前j - 1
个字符(dp[i - 1][j - 1]
),再替换一个字符,操作数为dp[i - 1][j - 1] + 1
。
取这三种操作数的最小值,即dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
。
- 插入:将
- 当
- 定义
-
复杂度分析:
- 时间复杂度 :( O(m \times n) ),其中 ( m ) 和 ( n ) 分别是
word1
和word2
的长度,需要遍历一个 ( (m + 1) \times (n + 1) ) 的二维数组。 - 空间复杂度:( O(m \times n) ),使用了一个 ( (m + 1) \times (n + 1) ) 的二维数组来存储中间状态。
- 时间复杂度 :( O(m \times n) ),其中 ( m ) 和 ( n ) 分别是
编辑距离(最少操作数转换单词)
题目描述
给定两个单词 word1
和 word2
,计算将 word1
转换成 word2
所需的最少操作数。允许的操作有三种:
- 插入一个字符
- 删除一个字符
- 替换一个字符
最优解:动态规划(Python实现)
python
from typing import List
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# 创建二维数组 dp,其中 dp[i][j] 表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符的最少操作数
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 初始化边界条件:当其中一个单词为空时的操作数
for i in range(m + 1):
dp[i][0] = i # 删除 word1 前 i 个字符(全部删除,操作数为 i)
for j in range(n + 1):
dp[0][j] = j # 插入 word2 前 j 个字符(全部插入,操作数为 j)
# 填充 dp 数组
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:
# 当前字符不同,选择三种操作中的最小值并加 1(当前操作)
dp[i][j] = 1 + min(
dp[i-1][j], # 删除 word1 的第 i 个字符(对应 word1 前 i-1 转换为 word2 前 j)
dp[i][j-1], # 插入 word2 的第 j 个字符(对应 word1 前 i 转换为 word2 前 j-1)
dp[i-1][j-1] # 替换 word1 的第 i 个字符为 word2 的第 j 个字符(对应前 i-1 和 j-1 转换)
)
return dp[m][n] # 返回最终结果,即两个完整单词的最少操作数
最优解分析
1. 动态规划思路
编辑距离问题是典型的动态规划问题,核心是通过子问题的解推导原问题的解。
- 状态定义 :
设dp[i][j]
表示将word1
的前i
个字符转换为word2
的前j
个字符所需的最少操作数。 - 状态转移 :
- 若当前字符相同(
word1[i-1] == word2[j-1]
),则无需操作,直接继承左上角的状态dp[i-1][j-1]
。 - 若当前字符不同,则有三种操作选择,取其中最小值并加 1(当前操作):
- 删除 :删除
word1
的第i
个字符,操作数为dp[i-1][j] + 1
。 - 插入 :在
word1
中插入word2
的第j
个字符,操作数为dp[i][j-1] + 1
。 - 替换 :将
word1
的第i
个字符替换为word2
的第j
个字符,操作数为dp[i-1][j-1] + 1
。
- 删除 :删除
- 若当前字符相同(
2. 边界条件
- 当
word1
为空(i=0
)时,需要插入word2
的前j
个字符,操作数为j
。 - 当
word2
为空(j=0
)时,需要删除word1
的前i
个字符,操作数为i
。
3. 复杂度分析
- 时间复杂度 :( O(m \times n) ),其中
m
和n
分别为两个单词的长度。需要遍历一个 ( (m+1) \times (n+1) ) 的二维数组。 - 空间复杂度:( O(m \times n) ),使用二维数组存储中间状态。若优化空间,可压缩为一维数组(每次仅保留前一行的状态),但此处采用直观的二维数组解法,便于理解。
4. 示例推导
以 word1 = "horse", word2 = "ros"
为例:
- 初始化边界:第一行和第一列分别为
0,1,2,3
和0,1,2,3,4,5
(对应插入或删除操作)。 - 填充过程中,当字符相同时(如
h
vsr
不同,o
vso
相同),根据状态转移方程逐步计算每个dp[i][j]
。 - 最终
dp[5][3]
即为结果3
(实际最少操作:horse
→rorse
(替换 h→r)→rose
(删除 r)→ros
(删除 e),共 3 步)。
总结
编辑距离问题通过动态规划将复杂的字符串转换问题分解为子问题,利用状态转移方程高效求解。关键在于正确定义状态和转移逻辑,边界条件的处理也至关重要。该解法是此类问题的经典解法,时间和空间复杂度均为多项式级别,适用于大多数实际场景。
题目描述
给定一个非负整数数组 nums
,你最初位于数组的 第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
示例 1 :
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步到下标 1,然后跳 3 步到达最后一个下标。
示例 2 :
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置(值为 0),无法继续跳跃到最后一个下标。
最优解(贪心算法)
通过维护当前能到达的最远位置,遍历数组时不断更新该位置,若在遍历过程中最远位置覆盖最后一个下标,则返回 True
;若当前位置超过最远位置,说明无法继续跳跃,返回 False
。
Python代码
python
from typing import List
class Solution:
def canJump(self, nums: List[int]) -> bool:
n = len(nums)
max_reach = 0 # 初始化当前能到达的最远位置
for i in range(n):
# 如果当前位置在可到达范围内,则尝试更新最远位置
if i <= max_reach:
max_reach = max(max_reach, i + nums[i])
# 提前判断是否已到达或超过最后一个下标,优化性能
if max_reach >= n - 1:
return True
else:
# 当前位置超出可到达范围,无法继续跳跃
break
# 遍历结束后,若最远位置仍未到达最后一个下标,返回False
return max_reach >= n - 1
最优解分析
思路解析
-
贪心策略 :
每次遍历到位置
i
时,若i
在当前最远可达位置max_reach
内,则更新max_reach
为i + nums[i]
(即从i
出发能到达的最远位置)和当前max_reach
中的较大值。- 若
max_reach
在遍历过程中已覆盖最后一个下标(n-1
),则直接返回True
,无需继续遍历。 - 若某一位置
i
超出max_reach
,说明后续位置无法到达,直接跳出循环并返回False
。
- 若
-
边界处理:
- 当数组长度为
0
或1
时,直接返回True
(空数组题目保证输入合法,长度为 1 时无需跳跃即可到达)。 - 遍历过程中,若
i
超过max_reach
,说明中间存在无法跨越的"断层",直接终止遍历。
- 当数组长度为
复杂度分析
- 时间复杂度 :( O(n) ),其中 ( n ) 是数组
nums
的长度。仅需遍历数组一次,每个元素处理时间为常数。 - 空间复杂度 :( O(1) ),仅使用常数级额外空间(
max_reach
和循环变量)。
示例推导
以示例 2 nums = [3,2,1,0,4]
为例:
- 初始
max_reach = 0
。 i=0
:0 <= 0
,更新max_reach = max(0, 0+3=3)
,此时max_reach=3
,未达最后下标(4)。i=1
:1 <= 3
,更新max_reach = max(3, 1+2=3)
,仍为 3。i=2
:2 <= 3
,更新max_reach = max(3, 2+1=3)
,仍为 3。i=3
:3 <= 3
,更新max_reach = max(3, 3+0=3)
,仍为 3。i=4
:4 > 3
,跳出循环,返回False
,符合预期。
关键点
- 贪心的核心:每次尽可能扩展最远可达范围,避免重复计算中间状态。
- 提前终止 :一旦最远可达范围覆盖最后一个下标,立即返回
True
,优化最坏情况下的性能。
该解法通过线性遍历和常数空间,高效解决了跳跃游戏问题,是此类问题的经典贪心解法。
题目描述
给定一个非负整数数组 nums
,你最初位于数组的 第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
最优解(贪心算法)
通过维护当前能到达的最远位置,遍历数组时动态更新该位置,若在遍历过程中最远位置覆盖最后一个下标,则直接返回 True
;若当前位置超出最远可达范围,说明无法继续跳跃,返回 False
。
Python代码
python
from typing import List
class Solution:
def canJump(self, nums: List[int]) -> bool:
n = len(nums)
max_reach = 0 # 当前能到达的最远下标
for i in range(n):
# 如果当前位置在可达范围内,则尝试更新最远可达位置
if i <= max_reach:
max_reach = max(max_reach, i + nums[i])
# 提前判断是否已到达终点,优化性能
if max_reach >= n - 1:
return True
else:
# 当前位置不可达,后续位置也无法到达
break
# 遍历结束后,检查最远可达位置是否覆盖终点
return max_reach >= n - 1
最优解分析
核心思路:贪心策略
-
维护最远可达位置 :
用
max_reach
表示从起点出发,经过一系列跳跃后能到达的最远下标。初始时max_reach = 0
(起点位置)。- 遍历每个下标
i
,若i
在max_reach
范围内(即i <= max_reach
),说明可以从前面的某个位置跳跃到i
,此时更新max_reach
为i + nums[i]
(从i
出发能跳到的最远位置)和当前max_reach
中的较大值。 - 若
i
超出max_reach
(即i > max_reach
),说明无法到达i
,后续下标也无法到达,直接终止遍历。
- 遍历每个下标
-
提前终止条件 :
一旦
max_reach
覆盖最后一个下标(n - 1
),立即返回True
,无需遍历剩余元素,优化最坏情况下的时间复杂度。
复杂度分析
- 时间复杂度:( O(n) ),其中 ( n ) 是数组长度。每个元素仅遍历一次,每次操作均为常数时间。
- 空间复杂度 :( O(1) ),仅使用常数级额外空间(
max_reach
和循环变量)。
示例推导
-
示例 1:
nums = [2, 3, 1, 1, 4]
i=0
:0 <= 0
,max_reach = max(0, 0+2=2)
→2
(未达终点,继续)。i=1
:1 <= 2
,max_reach = max(2, 1+3=4)
→4
(已达终点4
,返回True
)。
-
示例 2:
nums = [3, 2, 1, 0, 4]
i=0
:0 <= 0
,max_reach = 3
。i=1
:1 <= 3
,max_reach = 3
(1+2=3
)。i=2
:2 <= 3
,max_reach = 3
(2+1=3
)。i=3
:3 <= 3
,max_reach = 3
(3+0=3
)。i=4
:4 > 3
,跳出循环,返回False
。
关键点
- 贪心的本质:每次尽可能扩展可达范围,避免回溯或重复计算,确保线性时间复杂度。
- 边界处理 :当数组长度为
1
时,直接返回True
(无需跳跃即可到达终点);当某位置不可达时,后续位置必然不可达,提前终止遍历。
该解法通过线性扫描和常数空间,高效解决了跳跃游戏问题,是此类问题的最优解法。
题目:跳跃游戏 II
给定一个非负整数数组 nums
,你最初位于数组的第一个位置,数组中的每个元素代表你在该位置可以跳跃的最大长度。目标是使用最少的跳跃次数到达最后一个位置。
解法:贪心算法
通过维护当前跳跃的最远可达位置 max_reach
和当前跳跃的终点 end
,遍历数组时更新这些变量。当到达当前跳跃的终点时,跳跃次数加 1
,并将终点更新为新的最远可达位置。若最远可达位置已覆盖最后一个位置,提前结束遍历。
Python 代码
python
from typing import List
class Solution:
def jump(self, nums: List[int]) -> int:
n = len(nums)
steps = 0 # 跳跃次数
end = 0 # 当前跳跃的终点
max_reach = 0 # 目前能到达的最远位置
for i in range(n - 1):
max_reach = max(max_reach, i + nums[i])
if i == end:
steps += 1
end = max_reach
if end >= n - 1:
break # 已到达或超过最后一个位置,提前结束
return steps
算法分析
- 时间复杂度:( O(n) ),遍历数组一次,每个元素处理时间为常数。
- 空间复杂度:( O(1) ),仅使用常数级额外空间。
该算法通过贪心策略,每次在可跳跃范围内找到最远可达位置,确保跳跃次数最少,高效解决问题。
题目:不同路径
一个机器人位于一个 m x n
网格的左上角,每次只能向下或者向右移动一步,试图达到网格的右下角。求总共有多少条不同的路径?
解法:动态规划
定义 dp[i][j]
表示到达网格 (i, j)
位置的不同路径数。
- 初始条件 :
- 第一行
dp[0][j] = 1
(只能一直向右移动)。 - 第一列
dp[i][0] = 1
(只能一直向下移动)。
- 第一行
- 状态转移方程 :
dp[i][j] = dp[i-1][j] + dp[i][j-1]
(从上方或左方到达)。
Python代码
python
from typing import List
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[1] * n for _ in range(m)]
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]
优化空间(一维数组)
python
from typing import List
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [1] * n
for i in range(1, m):
for j in range(1, n):
dp[j] += dp[j-1]
return dp[n-1]
算法分析
- 时间复杂度:( O(m \times n) ),两层循环遍历网格。
- 空间复杂度 :
- 二维数组:( O(m \times n) )。
- 一维数组:( O(n) ),优化后仅用一行数组存储状态。
通过动态规划,利用状态转移方程高效计算路径数,确保每个位置的路径数由相邻位置推导而来,最终得到右下角的路径总数。