1. 什么是动态规划
动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。
从字面意义上来理解,就是走一步看一步,边解决问题,边对问题进行整体规划。
其实,动态规划的本质就是将问题拆分为小的子问题,对小问题的一步步解决,小问题渐渐积累为较大的问题,最终就可以解决掉整个问题。
这似乎与递归解决问题的思路很像,但是动态规划一般采用的是迭代的方法。
在解决问题的过程中,我们一般会记录下小问题的解决能带给我们的有效信息,从而利用小问题解决大问题,这样就可以避免对某些问题的重复计算。
动态规划不是什么固定的解题套路,而是提供了一种解题的思路。
动态规划的核心
动态规划的核心在于对两个概念的确定:
1. 当前状态的表示(小问题)
2. 状态之间的递推关系(小问题与较大问题之间的联系)
什么问题可以用动态规划解决
如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。
例如,求最大值/最小值,可不可行,是不是,方案个数等
2. 初次遇见动态规划
光是看上面的介绍,似乎有点晕头转向不知所云。
那么,不妨更着博主的学习路径,来看看动态规划到底是怎么回事。
兑换零钱
这是leetcode上的一道题,刷题链接:. - 力扣(LeetCode)
我便是在遇到这道题时,初次见识了动态规划。
1. 回溯解法
询问可能性,这让我想到了之前做过的N皇后的问题:C语言解决N皇后问题-CSDN博客
于是我们首先考虑试试回溯的算法。
思路
1. 每次调用函数选择一个硬币,将amount减去该硬币的值之后,传给下一次调用。
2. 依次传递下去,如果某次函数接收到的amount为0,则说明找到了一种可行的组合,此时返回1。
3. 如果某次函数接收到的amount为1,则说明该种组合不可行,此时返回0。
4. 为了避免重复,我们每次传给下一次调用的coins数组都不包括在该硬币位置之前的硬币。
例如,在示例1所给数据下,如果每次传入全部的coins,则会出现诸如2 1 1 1和1 2 1 1的重复情况。
代码
int change1(int amount, int* coins, int coinsSize)
{
if(amount == 0)
return 1;
if(amount < 0||coinsSize <= 0)
return 0;
int sum = 0;
for(int i = 0; i < coinsSize; i++)
{
sum += change1(amount - coins[i], coins + i, coinsSize - i);
}
return sum;
}
总结
这种解法,虽然极力避免了对重复情况的考虑,但是其递归的本质,还是使其在处理较大数据时会超时。
而且,相比于动态规划,这种解法似乎有点无头苍蝇的感觉,对所有情况进行无差别尝试。
回溯的解法,其运算过程像是树一样,不断展开,每一种情况对应树的一个末梢。
有了这个树状的结构,我们其实可以将每种情况是什么一并打印出来,但是这道题只要求我们求出可行方案的种数。
所以,我们似乎还是做了很多不必要的工作,我们的小问题留下的信息太多了,我们每次计算做的事也太多了。
仔细回顾这道题,我们会发现,如果将我们的思路总结成递归的基本思路,就会是如下这样:
1. 要知道coins组合成amount有多少种方法(),我们首先要知道组合成amount-coins[i]有多少种方法()。
2. 那么。
但是,我们在解决的过程中,不仅把 记录了下来,还将得到其的路径也记录了下来。
而且,在不同的深度中amount-coins[i]的大小可能相同,这也就导致我们还是不可避免地做了很多重复的工作。
于是我在官方的解答中,首次见识了动态规划是如何解决问题的。
2. 动态规划解法
思路
1. 与前面回溯解法总结后的思路相同,我们也要先求得,但是我们只记录在解决这个问题得到的信息中,我们需要的,也就是其值。
2. 状态的表示:我们用一个一维数组来记录每种金额的组合数,元素下标就代表金额。这样,对于每种可能出现的金额(amount-coins[i]),我们就仅会做一次计算。
3. 状态之间的关系:也就是。只要知道较小金额的组合种数,就可以依次递推得到较大金额的组合种数。
4. 结果:我们要求的,其实也就是下标为amount的元素的大小。
5. 下标为0的元素值赋为1,因为得到总金额为0的组合有且仅有一枚硬币都没有的组合。
6. 数组默认初始化为0,这样一来,如果不存在可以组成amount-coins[i]的组合,那么对的贡献就为0。
代码
int change2(int amount, int* coins, int coinsSize)
{
int dp[amount + 1];
memset(dp, 0, sizeof(dp));
dp[0] = 1;
for(int i = 0; i < coinsSize; i++)
{
for(int j = coins[i]; j <= amount; j++)
{
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
总结
动态规划的算法通常采用迭代来实现,这不仅解决了递归,回溯的缺陷,而且真正意义上做到了杜绝重复子问题的运算。
并且,在解决问题的过程中,充分利用了子问题留下的有效信息来解决当前问题。
递归时,只能利用自己所在分支的信息,而动态规划可以对全局的信息进行利用。
但是,动态规划的不足就在于其实在不好想到,且没有固定的套路,是考验个人能力的好手段。
3. 尝试自己解决动态规划问题
这是牛客网上的一道题,刷题链接:公共子串计算_牛客题霸_牛客网
思路
1. 这道题是要我们找两个字符串的公共子串,这使得我会想起之前研究过的kmp算法(从一无所有的角度出发,带你一步步实现kmp算法-CSDN博客)(对这道题不是很重要,不了解也可),这么一想,似乎得到next数组的思想就像是动态规划。
2. 我们依然想用一个数组来存储有效信息,这个数组的下标表示当前的状态(我正在解决哪个问题),其对应的值就是我们要留下的有效信息。
3. 我们用i和j分别指向两个字符串,当所指两个字符匹配成功时,当前公共子串的长度就加1。所以,要想知道当前公共子串的长度,我们就要知道在这两个字符匹配成功之前公共子串的长度。
4. 那么,这道题用一维数组似乎无法很好地表示当前的状态,因为我们要知道匹配之前公共子串的长度,那么其对应的i和j就都需要能表示出来,我们才能找到它。
5. 状态的表示:于是我们采用二维数组(dp[][])来定义当前状态,元素的下标分别为i和j,表示当前公共子串的末尾在两个字符串中分别在第i个和第j个位置上。
6. 状态之间的关系:如果当前两个字符配对成功则dp[i][j] = dp[i-1][j-1] + 1;如果匹配失败,则dp[i][j] = 0。
7. 结果:我们定义一个变量maxLen来记录目前最长的公共子串长度,如果某位置上dp[i][j] > maxLen,则maxLen = dp[i][j]。将每个位置的公共子串都检查完之后maxLen即是要求结果。
代码
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[151] = {0};
char arr2[151] = {0};
scanf("%s", arr1);
scanf("%s", arr2);
int len1 = strlen(arr1);
int len2 = strlen(arr2);
int dp[len1+1][len2+1];
int maxLen = 0;
memset(dp, 0, sizeof(dp));
for(int i = 1; i <= len1; i++)
{
for(int j = 1; j <= len2; j++)
{
dp[i][j] = arr1[i-1] == arr2[j-1] ? dp[i-1][j-1] + 1 : 0;
maxLen = maxLen > dp[i][j] ? maxLen : dp[i][j];
}
}
printf("%d\n", maxLen);
return 0;
}
总结
动态规划是很值得学习的东西,也是需要费力攻克的难关,绝不是看一两篇文章就能完全掌握的。
本文章只是带领读者初步了解动态规划这种解题思想,能够入门,方便进行更加深入的学习。
博主也是刚刚接触到这类题型,顿时感到未来的路还很长,希望在了解更多之后还有机会分享动态规划学习的文章。
接下来,可以通过背包问题,进行更加深入地学习。