当前位置: 首页 > news >正文

面试算法高频08-动态规划-03

练习题

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组 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))。

思路
定义两个变量 firstsecond,分别表示偷到前前一间房屋和前一间房屋的最大金额。对于当前房屋,有两种选择:

  • 偷当前房屋:则总金额为 first + 当前房屋金额
  • 不偷当前房屋:则总金额为 second
    取两者最大值更新状态,逐步迭代。

代码

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 为前两间房屋的较大值。
  • 从第三间房屋开始迭代,每次更新 firstsecondfirst 代表前前一间的最大值,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) ) 计算最小值。

代码

import mathdef numSquares(n):dp = [float('inf')] * (n + 1)dp[0] = 0for 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]

解释

  1. 初始化 ( dp ) 数组,dp[0] = 0 表示 ( 0 ) 不需要任何完全平方数。
  2. 遍历 ( i ) 从 ( 1 ) 到 ( n ),对于每个 ( i ),遍历 ( j )(( j^2 \leq i ))。
  3. 通过状态转移方程更新 ( dp[i] ),取最小值。
  4. 最终 ( dp[n] ) 即为组成 ( n ) 的最少完全平方数个数。

时间复杂度:( O(n\sqrt{n}) ),遍历 ( n ) 次,每次遍历 ( \sqrt{n} ) 次。
空间复杂度:( O(n) ),存储 ( dp ) 数组。

此方法通过动态规划高效地计算出最少完全平方数个数,确保了算法的正确性和效率。

题目描述

给你两个单词 word1word2,请返回将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

最优解(Python)

from typing import Listclass 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 ) 分别是 word1word2 的长度,需要遍历一个 ( (m + 1) \times (n + 1) ) 的二维数组。
    • 空间复杂度:( O(m \times n) ),使用了一个 ( (m + 1) \times (n + 1) ) 的二维数组来存储中间状态。

编辑距离(最少操作数转换单词)

题目描述

给定两个单词 word1word2,计算将 word1 转换成 word2 所需的最少操作数。允许的操作有三种:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符
最优解:动态规划(Python实现)
from typing import Listclass 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(当前操作):
      1. 删除:删除 word1 的第 i 个字符,操作数为 dp[i-1][j] + 1
      2. 插入:在 word1 中插入 word2 的第 j 个字符,操作数为 dp[i][j-1] + 1
      3. 替换:将 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) ),其中 mn 分别为两个单词的长度。需要遍历一个 ( (m+1) \times (n+1) ) 的二维数组。
  • 空间复杂度:( O(m \times n) ),使用二维数组存储中间状态。若优化空间,可压缩为一维数组(每次仅保留前一行的状态),但此处采用直观的二维数组解法,便于理解。
4. 示例推导

word1 = "horse", word2 = "ros" 为例:

  • 初始化边界:第一行和第一列分别为 0,1,2,30,1,2,3,4,5(对应插入或删除操作)。
  • 填充过程中,当字符相同时(如 h vs r 不同,o vs o 相同),根据状态转移方程逐步计算每个 dp[i][j]
  • 最终 dp[5][3] 即为结果 3(实际最少操作:horserorse(替换 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代码
from typing import Listclass 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 Trueelse:# 当前位置超出可到达范围,无法继续跳跃break# 遍历结束后,若最远位置仍未到达最后一个下标,返回Falsereturn max_reach >= n - 1

最优解分析

思路解析
  1. 贪心策略
    每次遍历到位置 i 时,若 i 在当前最远可达位置 max_reach 内,则更新 max_reachi + nums[i](即从 i 出发能到达的最远位置)和当前 max_reach 中的较大值。

    • max_reach 在遍历过程中已覆盖最后一个下标(n-1),则直接返回 True,无需继续遍历。
    • 若某一位置 i 超出 max_reach,说明后续位置无法到达,直接跳出循环并返回 False
  2. 边界处理

    • 当数组长度为 01 时,直接返回 True(空数组题目保证输入合法,长度为 1 时无需跳跃即可到达)。
    • 遍历过程中,若 i 超过 max_reach,说明中间存在无法跨越的“断层”,直接终止遍历。
复杂度分析
  • 时间复杂度:( O(n) ),其中 ( n ) 是数组 nums 的长度。仅需遍历数组一次,每个元素处理时间为常数。
  • 空间复杂度:( O(1) ),仅使用常数级额外空间(max_reach 和循环变量)。
示例推导

以示例 2 nums = [3,2,1,0,4] 为例:

  • 初始 max_reach = 0
  • i=00 <= 0,更新 max_reach = max(0, 0+3=3),此时 max_reach=3,未达最后下标(4)。
  • i=11 <= 3,更新 max_reach = max(3, 1+2=3),仍为 3。
  • i=22 <= 3,更新 max_reach = max(3, 2+1=3),仍为 3。
  • i=33 <= 3,更新 max_reach = max(3, 3+0=3),仍为 3。
  • i=44 > 3,跳出循环,返回 False,符合预期。
关键点
  • 贪心的核心:每次尽可能扩展最远可达范围,避免重复计算中间状态。
  • 提前终止:一旦最远可达范围覆盖最后一个下标,立即返回 True,优化最坏情况下的性能。

该解法通过线性遍历和常数空间,高效解决了跳跃游戏问题,是此类问题的经典贪心解法。

题目描述

给定一个非负整数数组 nums,你最初位于数组的 第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。

最优解(贪心算法)

通过维护当前能到达的最远位置,遍历数组时动态更新该位置,若在遍历过程中最远位置覆盖最后一个下标,则直接返回 True;若当前位置超出最远可达范围,说明无法继续跳跃,返回 False

Python代码
from typing import Listclass 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 Trueelse:# 当前位置不可达,后续位置也无法到达break# 遍历结束后,检查最远可达位置是否覆盖终点return max_reach >= n - 1

最优解分析

核心思路:贪心策略
  1. 维护最远可达位置
    max_reach 表示从起点出发,经过一系列跳跃后能到达的最远下标。初始时 max_reach = 0(起点位置)。

    • 遍历每个下标 i,若 imax_reach 范围内(即 i <= max_reach),说明可以从前面的某个位置跳跃到 i,此时更新 max_reachi + nums[i](从 i 出发能跳到的最远位置)和当前 max_reach 中的较大值。
    • i 超出 max_reach(即 i > max_reach),说明无法到达 i,后续下标也无法到达,直接终止遍历。
  2. 提前终止条件
    一旦 max_reach 覆盖最后一个下标(n - 1),立即返回 True,无需遍历剩余元素,优化最坏情况下的时间复杂度。

复杂度分析
  • 时间复杂度:( O(n) ),其中 ( n ) 是数组长度。每个元素仅遍历一次,每次操作均为常数时间。
  • 空间复杂度:( O(1) ),仅使用常数级额外空间(max_reach 和循环变量)。
示例推导
  • 示例 1:nums = [2, 3, 1, 1, 4]

    • i=00 <= 0max_reach = max(0, 0+2=2)2(未达终点,继续)。
    • i=11 <= 2max_reach = max(2, 1+3=4)4(已达终点 4,返回 True)。
  • 示例 2:nums = [3, 2, 1, 0, 4]

    • i=00 <= 0max_reach = 3
    • i=11 <= 3max_reach = 31+2=3)。
    • i=22 <= 3max_reach = 32+1=3)。
    • i=33 <= 3max_reach = 33+0=3)。
    • i=44 > 3,跳出循环,返回 False
关键点
  • 贪心的本质:每次尽可能扩展可达范围,避免回溯或重复计算,确保线性时间复杂度。
  • 边界处理:当数组长度为 1 时,直接返回 True(无需跳跃即可到达终点);当某位置不可达时,后续位置必然不可达,提前终止遍历。

该解法通过线性扫描和常数空间,高效解决了跳跃游戏问题,是此类问题的最优解法。

题目:跳跃游戏 II

给定一个非负整数数组 nums,你最初位于数组的第一个位置,数组中的每个元素代表你在该位置可以跳跃的最大长度。目标是使用最少的跳跃次数到达最后一个位置。

解法:贪心算法

通过维护当前跳跃的最远可达位置 max_reach 和当前跳跃的终点 end,遍历数组时更新这些变量。当到达当前跳跃的终点时,跳跃次数加 1,并将终点更新为新的最远可达位置。若最远可达位置已覆盖最后一个位置,提前结束遍历。

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代码
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]  
优化空间(一维数组)
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) ),优化后仅用一行数组存储状态。

通过动态规划,利用状态转移方程高效计算路径数,确保每个位置的路径数由相邻位置推导而来,最终得到右下角的路径总数。

相关文章:

  • 新环境注册为Jupyter 内核
  • Uniapp:vite.config.js全局配置
  • 可解释人工智能(XAI):让机器决策透明化
  • AI - LangChain - 介绍(1)
  • 成员方法的详细说明(结合Oracle官方文档)
  • 9.5/Q1,GBD数据库最新高分文章解读
  • Cursor
  • JVM 内存分配策略
  • spring cloud 服务注册与发现(Service registration and discovery)
  • 常见算法的总结与实现思路
  • Flutter 学习之旅 之 flutter 作为 module ,在 Android 的界面中嵌入Flutter界面功能的简单整理
  • 研究:大模型输出一致性:确定性与随机性的场景化平衡
  • 【Spark入门】Spark架构解析:组件与运行机制深度剖析
  • IP SSL证书常见问题:快速实现HTTPS加密
  • 【前端】【面试】如何实现图片渐进式加载?有几种方法
  • 根据模板语法生成和导出Word文档的工具类
  • 【优选算法 | 二分查找】二分查找算法解析:如何通过二段性优化搜索效率
  • TensorRT详解
  • 练习普通话,说话更有节奏
  • Matplotlib可视化基础
  • 在岸、离岸人民币对美元汇率双双升破7.26关口
  • 路边“僵尸车”被人以1450元卖了,嫌疑人被刑拘
  • 君亭酒店:2024年营业收入约6.76亿元, “酒店行业传统增长模式面临巨大挑战”
  • 俄乌战火不熄,特朗普在梵蒂冈与泽连斯基会晤后口风突变
  • 商务部:4月份以来的出口总体延续平稳增长态势
  • 人民日报头版:上海纵深推进浦东高水平改革开放