动态规划简介
动态规划(Dynamic Programming)是一种解决多阶段决策问题的数学优化方法。它将一个复杂的问题拆分成若干个子问题,并通过求解子问题的最优解来推导出原问题的最优解。
动态规划的核心思想是利用子问题的最优解来构建原问题的最优解。它通常适用于具有重叠子问题和最优子结构性质的问题。重叠子问题是指在问题的求解过程中,同一个子问题会被多次重复计算,而最优子结构性质是指一个问题的最优解可以通过相关子问题的最优解来表示。
动态规划算法一般包括以下步骤:
- 定义状态:确定问题需要求解的状态,将问题转化为具有状态的子问题。
- 定义状态转移方程:根据问题的最优子结构性质,建立子问题之间的递推关系,将原问题的最优解与子问题的最优解联系起来。
- 确定初始条件:确定最简单的子问题的解,作为递推的起点。
- 递推求解:按照递推关系,从简单的子问题开始,逐步求解更复杂的子问题,直到求解出原问题的最优解。
- 构造最优解:根据求解过程中记录的信息,构造出原问题的最优解。
动态规划算法的优点是可以避免重复计算,通过存储子问题的解来提高效率。它在求解诸如最短路径、背包问题、序列比对、图论等优化问题时具有广泛的应用。
示例1
有一座高度是10级台阶的楼梯,从下往上走,每跨一步只能向上1级或者2级台阶。要求用程序来求出一共有多少种走法。
解法一:
可以使用递归的方式解决这个问题。递归的思路是将当前问题拆解成更小规模的子问题,然后通过递归调用求解子问题的解,并将子问题的解累加得到原问题的解。
下面是用递归方式解决的代码示例:
func countWaysOfClimbingStairs(_ n: Int) -> Int {
if n <= 2 {
return n
}
return countWaysOfClimbingStairs(n-1) + countWaysOfClimbingStairs(n-2)
}
let numberOfWays = countWaysOfClimbingStairs(10)
print("一共有 \(numberOfWays) 种走法")
在上述代码中,countWaysOfClimbingStairs函数接受一个整数n作为参数,表示台阶的级数。如果n小于等于2,直接返回n。否则,通过递归调用countWaysOfClimbingStairs函数来计算到达第n级台阶的走法总数,该总数等于到达第n-1级台阶的走法总数加上到达第n-2级台阶的走法总数。最终递归会在n=1和n=2时终止,返回对应的值,得到最终结果。
需要注意的是,递归方式可能会导致重复计算,因此在实际应用中,为了提高效率,可以使用记忆化技术(Memoization)来避免重复计算。例如,可以使用一个字典来保存已经计算过的结果,每次计算前先检查字典中是否已经存在该结果,如果存在则直接返回,避免重复计算。
解法二:
要避免递归的重复计算,可以使用记忆化技术(Memoization)。记忆化技术通过在递归过程中保存已经计算过的结果,避免重复计算相同的子问题,从而提高效率。
下面是使用记忆化技术的代码示例:
var memo: [Int: Int] = [:]
func countWaysOfClimbingStairs(_ n: Int) -> Int {
if let result = memo[n] {
return result
}
if n <= 2 {
return n
}
let result = countWaysOfClimbingStairs(n-1) + countWaysOfClimbingStairs(n-2)
memo[n] = result
return result
}
let numberOfWays = countWaysOfClimbingStairs(10)
print("一共有 \(numberOfWays) 种走法")
在上述代码中,我们定义了一个memo字典来保存已计算过的结果。在递归函数countWaysOfClimbingStairs内部,首先检查字典中是否已经存在了n对应的结果,如果存在直接返回该结果,避免重复计算。如果结果不存在,则按照正常的递归方式计算,并将结果存入memo字典中。这样,在后续的递归调用中,如果再次遇到相同的子问题,就可以直接从memo字典中获取结果,而不需要重复计算。
使用记忆化技术可以有效避免递归的重复计算,提高程序的执行效率。
解法三:
这个问题可以使用动态规划来解决。我们可以将问题转化为求解到达第n级台阶的走法总数,其中n为台阶的级数。
定义一个数组dp,dp[i]表示到达第i级台阶的走法总数。根据题目要求,第一级台阶只有一种走法(直接跨一级),第二级台阶有两种走法(跨一级、一次跨两级),因此可以初始化dp[1]=1和dp[2]=2。
对于第i级台阶,可以从第i-1级台阶跨一级到达,也可以从第i-2级台阶跨两级到达,所以到达第i级台阶的走法总数为dp[i-1]+dp[i-2]。
根据以上递推关系,我们可以使用循环从第3级开始计算所有级别的走法总数,直到计算到第n级为止。最终dp[n]即为所求的结果。
下面是用Swift语言实现的代码示例:
func countWaysOfClimbingStairs(_ n: Int) -> Int {
if n <= 2 {
return n
}
var dp = Array(repeating: 0, count: n + 1)
dp[1] = 1
dp[2] = 2
for i in 3...n {
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
}
let numberOfWays = countWaysOfClimbingStairs(10)
print("一共有 \(numberOfWays) 种走法")
运行上述代码,将输出结果为:"一共有 89 种走法",即到达10级台阶的走法总数为89种。
示例2
国王和金矿问题
国王和金矿问题(King and Gold Mine Problem)是一个经典的组合优化问题,通常用来演示动态规划的应用。
问题描述如下:国王带着一些工人来到一座金矿开采金子。每座金矿的开采所需的工人数和产出的金子数量都不同。国王拥有一定数量的工人,需要决定如何分配这些工人来最大化金矿的总产出。
假设有n座金矿,编号从1到n,第i座金矿开采所需的工人数为P[i],产出的金子数量为G[i]。国王拥有的工人总数为W。
要解决这个问题,可以使用动态规划的思想。我们定义一个二维数组dp,dp[i][j]表示在前i座金矿中,拥有j个工人时的最大金子产出量。
根据问题的特点,我们可以得到以下推导关系:
- 当j < P[i](即当前的工人数量小于第i座金矿所需的工人数)时,无法开采第i座金矿,因此dp[i][j] = dp[i-1][j],即与前i-1座金矿的最优解相同。
- 当j >= P[i]时,可以选择开采第i座金矿或者不开采。如果选择开采第i座金矿,那么当前的最大金子产出量为dp[i-1][j-P[i]] + G[i]。如果选择不开采,那么当前的最大金子产出量为dp[i-1][j]。因此,dp[i][j] = max(dp[i-1][j-P[i]] + G[i], dp[i-1][j])。
根据以上推导关系,我们可以使用循环来计算dp数组的值,最终dp[n][W]即为所求的最大金子产出量。
解法一:
下面是使用Swift编写的国王和金矿问题的动态规划解法的代码示例,代码中包含了详细的注释说明:
func getMaxGold(_ n: Int, _ w: Int, _ p: [Int], _ g: [Int]) -> Int {
// 创建一个二维数组来存储最大金子产出量
var dp = Array(repeating: Array(repeating: 0, count: w+1), count: n+1)
// 逐个填充dp数组
for i in 1...n {
for j in 1...w {
if j < p[i-1] {
// 当前工人数不足以开采第i座金矿,与前i-1座金矿的最优解相同
dp[i][j] = dp[i-1][j]
} else {
// 可以选择开采第i座金矿或者不开采,取两者的最大值
dp[i][j] = max(dp[i-1][j-p[i-1]] + g[i-1], dp[i-1][j])
}
}
}
// 返回最大金子产出量
return dp[n][w]
}
// 测试示例
let numberOfMines = 5
let numberOfWorkers = 10
let requiredWorkers = [2, 3, 4, 5, 6]
let goldOutput = [4, 5, 6, 7, 8]
let maxGold = getMaxGold(numberOfMines, numberOfWorkers, requiredWorkers, goldOutput)
print("最大金子产出量为:\(maxGold)")
在上述代码中,我们定义了一个getMaxGold函数来求解最大金子产出量。函数接受四个参数:n表示金矿的数量,w表示工人的数量,p是一个包含每座金矿所需工人数的数组,g是一个包含每座金矿产出金子数量的数组。
函数内部首先创建一个二维数组dp,用于存储最大金子产出量。然后通过两层循环逐个填充dp数组,根据推导关系计算每个位置的值。最后返回dp[n][w],即最大金子产出量。
在测试示例中,我们使用了5座金矿,10个工人,requiredWorkers数组表示每座金矿所需的工人数,goldOutput数组表示每座金矿的金子产出量。运行代码后,将输出最大金子产出量。
这段代码利用了动态规划的思想,通过填充二维数组来解决国王和金矿问题,时间复杂度为O(nw),其中n是金矿数量,w是工人数量。
解法二:
使用递归的方式解决国王和金矿问题,可以通过定义一个递归函数来实现。递归函数的参数包括金矿的数量、工人的数量以及金矿所需的工人数和产出的金子数量。函数的返回值是最大金子产出量。
递归解法的思路是,对于第i座金矿,可以选择开采或者不开采。如果选择开采第i座金矿,那么剩余的工人数量为w - p[i],金子产出量为g[i],需要递归调用函数计算剩余金矿的最大产出量。如果选择不开采第i座金矿,那么直接递归调用函数计算剩余金矿的最大产出量。最终,取两种选择中的较大值作为当前情况下的最大金子产出量。
下面是使用递归方式解决国王和金矿问题的代码示例,代码中包含了详细的注释说明:
func getMaxGold(_ n: Int, _ w: Int, _ p: [Int], _ g: [Int]) -> Int {
// 递归函数,参数包括金矿的数量i和剩余的工人数量w
func recursiveMaxGold(_ i: Int, _ w: Int) -> Int {
// 递归终止条件
if i == 0 || w == 0 {
return 0
}
// 当前金矿所需工人数超过剩余工人数,无法开采当前金矿
if p[i-1] > w {
return recursiveMaxGold(i-1, w)
}
// 选择开采当前金矿和不开采当前金矿的两种情况,取较大值
let case1 = g[i-1] + recursiveMaxGold(i-1, w - p[i-1]) // 选择开采当前金矿
let case2 = recursiveMaxGold(i-1, w) // 不开采当前金矿
return max(case1, case2)
}
// 调用递归函数,返回最大金子产出量
return recursiveMaxGold(n, w)
}
// 测试示例
let numberOfMines = 5
let numberOfWorkers = 10
let requiredWorkers = [2, 3, 4, 5, 6]
let goldOutput = [4, 5, 6, 7, 8]
let maxGold = getMaxGold(numberOfMines, numberOfWorkers, requiredWorkers, goldOutput)
print("最大金子产出量为:\(maxGold)")
在上述代码中,我们定义了一个递归函数recursiveMaxGold,该函数接受两个参数:金矿的数量i和剩余的工人数量w。在递归函数内部,首先判断递归终止条件,即当金矿的数量为0或者剩余的工人数量为0时,返回0。然后根据当前金矿所需工人数和剩余工人数的比较,选择开采当前金矿或者不开采,并递归调用函数计算剩余金矿的最大产出量。最后,取两种选择中的较大值作为当前情况下的最大金子产出量。
在测试示例中,我们使用了5座金矿,10个工人,requiredWorkers数组表示每座金矿所需的工人数,goldOutput数组表示每座金矿的金子产出量。运行代码后,将输出最大金子产出量。
需要注意的是,递归解法在面对较大的问题规模时,可能会产生大量的重复计算,导致效率较低。因此,在实际应用中,在处理大规模问题时,使用动态规划的迭代解法通常更为高效。
为了优化递归解法的效率,可以采用记忆化技术,将已经计算过的结果保存起来,避免重复计算。我们可以利用一个二维数组来存储已计算的结果,在每次递归调用前先检查是否已经计算过,如果已经计算过,则直接返回保存的结果。
下面是使用记忆化技术优化递归解法的代码示例,代码中包含了详细的注释说明:
func getMaxGold(_ n: Int, _ w: Int, _ p: [Int], _ g: [Int]) -> Int {
// 创建一个二维数组来存储已计算的结果
var memo = Array(repeating: Array(repeating: -1, count: w+1), count: n+1)
// 递归函数,参数包括金矿的数量i和剩余的工人数量w
func recursiveMaxGold(_ i: Int, _ w: Int) -> Int {
// 检查是否已经计算过
if memo[i][w] != -1 {
return memo[i][w]
}
// 递归终止条件
if i == 0 || w == 0 {
return 0
}
// 当前金矿所需工人数超过剩余工人数,无法开采当前金矿
if p[i-1] > w {
memo[i][w] = recursiveMaxGold(i-1, w)
return memo[i][w]
}
// 选择开采当前金矿和不开采当前金矿的两种情况,取较大值
let case1 = g[i-1] + recursiveMaxGold(i-1, w - p[i-1]) // 选择开采当前金矿
let case2 = recursiveMaxGold(i-1, w) // 不开采当前金矿
// 保存计算结果
memo[i][w] = max(case1, case2)
return memo[i][w]
}
// 调用递归函数,返回最大金子产出量
return recursiveMaxGold(n, w)
}
// 测试示例
let numberOfMines = 5
let numberOfWorkers = 10
let requiredWorkers = [2, 3, 4, 5, 6]
let goldOutput = [4, 5, 6, 7, 8]
let maxGold = getMaxGold(numberOfMines, numberOfWorkers, requiredWorkers, goldOutput)
print("最大金子产出量为:\(maxGold)")
在上述代码中,创建了一个二维数组memo用于存储已计算的结果。在递归函数recursiveMaxGold中,首先检查当前状态是否已经计算过,如果已经计算过,则直接返回保存的结果。如果没有计算过,则继续进行递归计算,计算完毕后将结果保存在memo数组中。通过这种方式,可以避免重复计算,提高了递归解法的效率。
记忆化技术的优化能够有效减少递归解法的重复计算,提高算法效率,特别在面对较大的问题规模时,能够显著减少计算时间。