数据结构与算法分析(十八)--- 如何使用动态规划高效寻找最优解?

本文深入探讨了动态规划在解决多阶段决策最优解问题中的应用,通过零钱兑换问题详细阐述了动态规划、回溯、贪心算法的异同。文章首先分析了回溯算法的不足,然后介绍了贪心算法的局限性,并展示了如何通过动态规划结合状态转移方程和备忘录策略高效地解决零钱兑换问题。同时,文章还讨论了动态规划的特征,包括最优子结构、无后效性和重叠子问题,以及如何从递归决策树中找出状态转移方程。最后,通过编辑距离问题进一步展示了动态规划在拼写纠错中的应用,以及如何利用动态规划优化语音识别的复杂度。
摘要由CSDN通过智能技术生成

前言:

前文提到,当我们遇到一个比较复杂的问题时,通常会将其分解为多个比较简单的等价子问题,分别求得等价子问题的解就可以得到原复杂问题的解。分解复杂问题通常也有两个思考维度:一个是从空间上将原大规模问题分割为多个相互独立且结构相似的小规模等价子问题,再将多个小规模子问题的解合并为原问题的解,这就是分治算法;另一个是从时序上将原多阶段决策复杂问题拆解为多个前后相继的单阶段决策子问题,从初始状态开始按照DFS 顺序依次求得每个单阶段决策子问题的解,所有满足约束条件的决策路径就构成了原问题的解,这就是回溯算法

分治算法将大规模问题分割为多个小规模子问题的过程,实际上也大幅降低了其时间复杂度,比如排序算法从插入排序时间复杂度O(n2) 降低到归并排序时间复杂度O(n*logn)。而且分治算法分割出的小规模子问题是相互独立的,这就方便使用并行计算,进一步提高效率。

回溯算法将多阶段决策问题拆解为多个单阶段决策子问题,虽然可以按照DFS 规律无遗漏的穷举所有可能的解,但是该算法的时间复杂度太高,比如全排列的时间复杂度为O(nn)、全组合的时间复杂度为O(2n),而且多个单阶段决策子问题是有前后相继关系的,并不方便直接使用并行计算提高效率。如果不能降低回溯算法的时间复杂度,该算法就不适合用来处理大规模数据,适用场景或范围比较受限,有没有什么办法降低回溯算法的时间复杂度呢?

要想降低回溯算法的时间复杂度,让计算机少做无用功,就需要提供更多的信息。从信息论的角度,提供越多的信息,信息熵越低,不确定性越小,计算机求解去消除不确定性需要做的工作就越少。所以,要想提高回溯算法的时间复杂度,就需要找到更多的信息提前消除更多的不确定性(也可以理解为提前大幅剪枝)。当然,也不是多阶段决策问题都能找到更多更有效的信息,比如全排列中的N 皇后问题就很难再降低其时间复杂度。

我们在解决现实问题时,通常并不需要求出所有可能的解,而是更关注最优解。从前篇博文介绍的决策树可以看出,若只求最优解,可能只需要遍历其中的某一条或某几条决策路径,这将大幅降低多阶段决策问题的时间复杂度。理想情况下,如果每个阶段选择局部最优解(只跟当前状态有关),最后可以得到全局最优解(也即符合贪心选择性),只需要遍历多阶段决策树的其中一条选择路径(从初始根结点到某个最优解叶子结点),时间复杂度降低到O(n),这就是贪心算法。但现实中符合这类情况的问题并不多,通常当前阶段的局部最优解简单串联并不能得到全局最优解,我们该如何高效求解这类比较普遍的多阶段决策最优解问题呢?

一、从回溯剪枝到动态规划

如何求解最优解问题呢?如果你学过线性代数,很自然会想到线性规划(在线性约束条件形成的可行域内,找到线性目标函数的最优值),它可以用来解决现实中很多资源受限情况下进行最优化配置的问题。线性规划是运筹学的一个分支,我们关注的多阶段决策最优解问题在运筹学中也有研究,那就是动态规划(主要求解以时间划分阶段的动态过程的决策最优化问题)。

动态规划中的动态强调将原多阶段决策问题按时间划分为一系列前后相继的单阶段决策问题,这一点跟回溯算法划分阶段是类似的,也与线性规划这类与时间无关的静态规划形成对比(可将线性规划人为引入时间因素划分阶段,然后使用动态规划方法求解)。动态规划相比回溯算法高效求解最优决策序列的关键是,找到跟最优决策相关的最少路径(也即最优子结构),剪除跟最优决策无关的路径(通常还需要避免重叠子问题的重复求解),大幅提高计算效率。

动态规划中有一类比较简单且经典的问题 — 背包问题,下面以完全背包问题中的零钱兑换问题为例,看看回溯算法、贪心算法、动态规划算法求解该问题的思路有何区别?

[Leetcode - 322] 零钱兑换:给定不同面额的硬币 coins(可以认为每种硬币的数量是无限的,也即完全背包问题)和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

1.1 回溯剪枝求解零钱兑换问题

零钱兑换问题实际上是一个组合总和问题,不同面额的硬币coins 实际上就是构成组合的无重复数字序列(每种面额数量不限),目标总和为amount,可以使用前文介绍的回溯算法求解组合总和问题的思路穷举所有满足约束条件的组合。最后,在满足目标总和为amount 的所有组合中,找到元素个数最少的组合(也即硬币个数最少)即可得到答案。按照上述逻辑,使用组合总和思路编写零钱兑换问题的实现代码如下:

#include<vector>
#include<iostream>
using namespace std;

void dfs_coins(vector<int>& coins, int amount, int idxCoin, int& minCoins)
{
   
	// 使用static 变量用于记录当前选择的组合
    static vector<int> path;
    // 遍历每种可能的组合,若满足目标总和则更新更少元素组合并返回
    if(amount == 0){
   
    	// 判断是否为更少的元素组合,遍历完所有组合后minCoins 保存的就是满足组合总和的最少元素数量
        if(path.size() < minCoins)
            minCoins = path.size();
        return;
    }
	// 每个结点状态逐个遍历可能的选择分支,由于每个元素数量不限,循环内递归调用参数idxCoin 不增加,以便选择相同的元素
    for(int i = idxCoin; i < coins.size(); ++i){
   
        if(amount - coins[i] >= 0){
   
            path.push_back(coins[i]);
            dfs_coins(coins, amount - coins[i], i, minCoins);
            path.pop_back();
        }      
    }
}

int main(void)
{
   
    vector<int> coins = {
   1, 2, 5};
    int amount = 11;
    int minCoins = INT_MAX;
    
    dfs_coins(coins, amount, 0, minCoins);
	minCoins = minCoins < INT_MAX ? minCoins : -1;
	cout << minCoins << endl;

    return 0;
}

组合总和的时间复杂度在前文已经分析过了,由于组合总和实际上是遍历全组合,全组合的时间复杂度为O(2N),空间复杂度为O(N),其中N 为构成组合的元素个数。在本例中,N 就是硬币数量,但本题硬币数量不确定(同一面额硬币数量不限),约束硬币总数量的条件是目标总和 S(也即amount,下文为表述方便简称为S)和n 种不同面额(c1、c2…cn),可以得到硬币数量最多为S 除以最小面额,硬币面额为常数,则平均硬币数量可以表示为S/n,用S/n 替换上面的N,得到时间复杂度O(2S/n) 和空间复杂度O(S/n)。

使用回溯算法求解零钱兑换问题的复杂度比较高,我们在leetcode 上提交代码测试结果如下:

Time Limit Exceeded
57/182 cases passed (N/A)

看来本题不能简单使用回溯算法穷举所有的组合,需要找到更高效的解法。

1.2 贪心算法求解零钱兑换问题

零钱兑换问题是要求得满足目标和的最少硬币数量,实际上是一个最优解问题。回溯算法复杂度高的原因是穷举了所有可能的组合,要想降低复杂度,可以只沿着某个最优决策路径遍历。

在零钱兑换问题中,使用最少硬币数量的组合总和问题,实际上就是达到目标总和需要的最少阶段数(每个阶段选择一枚硬币)。我们容易想到,要想使用最少的硬币数量达到目标总和,优先选择面额最大的硬币能更快接近目标总和,这正是贪心算法的思想。

贪心算法假设每个阶段选择最优决策(也即最快逼近目标的选择分支),最后可以得到全局最优解(也即符合贪心选择性)。比如硬币面额有{1,2,5} 三种,目标总和为11,按照贪心算法我们先选择最大面额5,剩余目标总和为6, 继续选择最大面额5,剩余目标总和1,此时继续选择最大面额将超出目标总和,选择次大面额也超出目标总和,直到选择面额1 正好符合目标总和,于是找零面额分别为{5,5,1}。

我们按照贪心算法的思路编写求解零钱兑换问题的实现代码如下:

#include<algorithm>
......
int greedy_coins(vector<int>& coins, int amount)
{
   
    if(amount < 0 || (coins.empty() && amount > 0))
        return -1;
    if(amount == 0)
        return 0;
    // 对各种面额按递减顺序排序,方便依次取最大面额、次大面额硬币
    sort(coins.begin(), coins.end(), greater<int>());
    int minCoins = 0;	// 最少硬币数量
    for (int i = 0; i < coins.size(); i++) {
   
        if(amount - coins[i] >= 0){
   
            minCoins += amount / coins[i];	// 取尽可能多数量当前能取的最大面额coins[i]
            amount = amount % coins[i];		// 选择amount / coins[i] 个数量的硬币后,更新剩余的目标总和
        }
    }
    // 若最后正好凑够目标总和,且选择了至少一枚硬币,则返回最少硬币数minCoin,否则返回-1
    if(amount != 0 || minCoins == 0)
        return -1;
    else
        return minCoins;
}

int main(void)
{
   
    ......
    int minCoins = greedy_coins(coins, amount);
	......
}

上述算法的时间复杂度为O(n),空间复杂度为O(1),其中n 为硬币的面额数量。贪心算法的效率还是很高的,但是能解决我们的问题吗?我们将代码提交到leetcode 测试结果如下:

Wrong Answer
46/182 cases passed (N/A)
Testcase
[186,419,83,408]
6249
Answer
-1
Expected Answer
20

第46 个case Fail 了,我们按照贪心算法的思路手动验证下6249 - 14 * 419 - 2 * 186 = 11,最小面额83 大于11,所以按照贪心算法的思路确实无法得到满足目标总和的组合。也可以举个更简单的例子,硬币面额{1,5,11},目标总和15,按照贪心算法的思路,计算过程15 - 11 - 4 * 1 = 0,最少硬币数为 1 + 4 = 5 个,但我们可以找到更少的硬币组合 5 * 3 = 15,也即使用3 枚五元的硬币即可,可见贪心算法的适用场景有限。

使用贪心算法需要事前证明这个多阶段决策问题确实符合贪心选择性(也即每次选择最快逼近目标值的决策分支,最后可以得到全局最优解),如果在不满足贪心选择性的场合使用了贪心算法,将可能得不到解或者得到的不是最优解(如果不要求最优解,倒也可以使用贪心算法高效获得一个近似次优解)。

我国的币值{1,5,10,20,50,100} 和美元币值{0.01,0.05,0.1,0.25,0.5,1,2,5,10,20,50,100} 经证明都是符合贪心选择性的,可以使用贪心算法快速求解相应的零钱兑换问题,但其它币值是否符合贪心选择性就要在使用前证明下了。

1.3 动态规划求解零钱兑换问题

求解零钱兑换问题,回溯算法效率太低,贪心算法适用范围受限,该如何求解呢?前面也提到运筹学中的一个分支 — 动态规划,是求解多阶段决策最优解的一种策略,我们尝试使用动态规划算法来解决该问题。

动态规划求解多阶段决策最优解问题,首先需要找到最优子结构(也即可以通过子问题的最优解推导出原问题的最优解),这有点像博文递归与递推中介绍的抢数字游戏面试题,通过递归这种逆向思维比较好分析。

我们求解线性规划问题需要先找到约束条件和目标函数,求解动态规划问题也类似。零钱兑换中的目标函数假设为f(S) ,表示目标总和为S 时需要的最少硬币数量,假设最后选择的硬币面值为ci,则从前一阶段推进到下一阶段的递推公式为f(S) = f(S - ci) + 1, 但我们不知道最后一枚硬币的确切面值ci,只知道它是所有可供选择面值之一。假设硬币面额分别为{c[0],c[1],…, c[n-1]},那么目标函数从前一状态往下一阶段推进决策的递推公式为f(S) = min{f(S - c[0]), f(S - c[1]),…, f(S - c[n-1])} + 1,也即前一阶段所有可选分支中的最小值加一。这个递推公式在动态规划中就叫做状态转移方程,动态规划中的每次决策可选择的分支即为一个状态,每个决策过程即为一个阶段,状态转移方程指的是从前一个状态推进决策到下一个状态,实际上就是递归中的递推公式。

举个例子,假如硬币面额{1,2,5},目标总和11,需要最少的硬币数f(11) = min{f(11 - 1), f(11 - 2), f(11 - 5)} + 1,也即f(11) 的值是f(10)、f(9)、f(6) 这三个值中的最小值加一。除了目标函数f(S) 和递推公式或状态转移方程,还需要边界约束条件,我们的目标是正好凑够目标总和S,从后往前逆向递归到f(0) 即为边界约束条件,要想凑够0 元不需要任何硬币,所以f(0) = 0。零钱兑换问题就转换为到达f(0) 的最短路径(也即需要选择硬币的次数或阶段最少),该例中做选择的递归决策树如下:
零钱兑换决策树示例

递归中的边界约束条件和递推公式共同构成了动态规划中的状态转移方程,整理上面分析的零钱兑换问题中的状态转移方程如下:
f ( S ) = { − 1 , S < 0 0 , S = 0 m i n { f ( S − c [ 0 ] ) , f ( S − c [ 1 ] ) , ⋯ , f ( S − c [ n − 1 ] ) } + 1 , S ≥ c [ i ] f(S)= \begin{cases} -1, & S < 0 \\ 0, & S = 0 \\ min\begin{Bmatrix} f(S - c[0]), &f(S - c[1]), &\cdots &,f(S - c[n-1]) \end{Bmatrix} + 1, & S \geq c[i] \end{cases} f(S)=

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

流云IoT

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值