在这个题目的基础上,我了解了一下这几个“编程写法”,并对循环、递归、dp有了新的想法。从原理上,这几个想法都是大事化小、小事化了。只不过方向不同罢了。
根据The Algorithm Design Manual,解决这类存在顺序解决关系的问题,我们的通用的想法是
因为递归实际上是一种更自然的思路,从已知到实现只需迈出一步。这一步也抽象了无数步的实现过程。往往更加清晰
实际上我按照这套mindset来考虑dp问题之后,会发现对于并不熟悉的dp题目,明显构思起来要比直接考虑“有什么状态?怎么扩展状态”这套顺手,因为递归是很自然的一种思维方式。当然,已经比较熟练直接就能看出来状态和转移方程的题目,以及一些状态实在太显眼比考虑递归还简单的题目,就不需要用这套了。
PS:有一些优化,使用记忆化搜索方式实现DP做不了,有的题目会被这个卡死,比如必须用滚动数组等方式压缩状态否则MLE的那种。有一些优化,使用刷表法做不了,比如实际访问的状态很稀疏又不能整齐控制顺序,你一建表就MLE或者TLE那种。所以两种DP主流实现方式都要掌握。
作者:sin1080
链接:https://www.zhihu.com/question/323076638/answer/673995021
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
滚动数组绝对是DP的优秀特征,极大概率省去绝大部分的内存,同时通过刷表,将时间复杂度也降到多项式量级;递归的离散搜索也是连续的dp所不能取代的(当然可以用离散化技巧)
递归的通解思路
递归有两种主要的形式:搜索-回溯,与分治。
深度优先搜索与回溯法,代表了递归的主要用法之一。传的是层数,适合于排列解空间树的问题
如果我们硬要分别的话,另外还有分治。分治传的是问题规模。分治策略容易估计递归的阶数
其实本质上都是一样的。以后就不必区分了。
void dfs(data x[], int n, int i)
{
data a[];
if (i>n) {
if (x[1~n]是解) {
输出或保存;
}
} else {
设定子节点a[]: x[i]=a[t];
for (int t = 0; t <子节点数目; t++) {
if (legal(x, i, a[t])) {
设置第i步现场;
dfs(x, n, i+1);
恢复第i步现场;
}
}
}
}
当这个题不是分治法思路的时候,我们就应该考虑使用dfs,构建每个元素的幂分支解空间树。也就是像类似这样展开(图题无关)
对于这个题来说,就是,没有到全部装完的时候,就尝试不同的硬币(显然深搜比起分治也更好理解)。直到产生一系列
a
n
a^n
an树。
如果从分治角度上说,就是可以有几种方法(硬币)回到前一种子情形。然后再对子情形进行递归求解
递归题解
这其实并不是一个讲深搜的好例子,它的最终输出行为是比较复杂的。
如果没有完整方案,要输出剩下钱数最少的找零方案中的最少硬币数。
没有搜到的可能性和重要性被放大了。这是一个使用分治策略的深搜(这个表述就很悖论,仍然说明二者是互通的)。
记忆化的分治,记录已经搜索过的部分,是通过离散来填充连续空间
按规模分治。这个方法来自网络,由于使用了两次递归,所以慢的出奇。
int型的穷尽枚举,便于我们大量剪枝。但是实际上,仍然要全部算完才直到某个点处和终点的距离,所以并没有起到良好的剪枝效果。这说明了NPC问题的难解性。
/*
Problem: NYOJ(南阳理工OJ)
Author :2486
Memory: 1012 KB Time: 192 MS
Language: C/C++ Result: Accepted
*/
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int maxn=100000+5;
const int INF=0x3f3f3f3f;
int n,t,a[maxn],vis[maxn],Min;
int dfs(int s) {
if(s<0)return INF;//非典型的出口:没有找到
if(vis[s]!=-1)return vis[s]; //必须进行剪枝,不过在标准想法当中,并不是主要步骤
Min=min(s,Min);//最优情况通过‘问题规模’决定,所以只需传递这个问题规模,然后对作为最优解的全局变量进行更新即可。
int ans=INF;
for(int i=0; i<n; i++) {
ans=min(dfs(s-a[i])+1,ans);//对当层进行枚举,其中利用先前结果的想法和完全背包是一样的
}//得到最少硬币数
if(ans!=INF)vis[s]=ans;
return ans;
}
int main() {
while(~scanf("%d%d",&n,&t),n&&t) {//仅当输出结束时退出
for(int i=0; i<n; i++) {
scanf("%d",&a[i]);
}
memset(vis,-1,sizeof(vis));
vis[0]=0;
Min=INF;
dfs(t);
if(vis[t]==-1) {//是否可以找零,如果不能,就对刚刚已经搜索到的最小差额的情形进行再次递归,这次必将搜索到//这一步是很巧妙的
dfs(t-Min);
printf("%d\n",vis[t-Min]);
} else
printf("%d\n",vis[t]);
}
return 0;
}
按层深搜。
#include <iostream>
#include <climits>
using namespace std;
int num, t, a[55], ans = INT_MAX, m = INT_MAX;
// m is the closest record of all slns.
void change(int n, int c) //n is the number of coins, c is the scale of the problem
{
if (c == m) //at least not worse
ans = ans < n ? ans : n;
if (c < m) // a better sln
ans = n, m = c;
for (int i = 1; i <= num; i++)
if (c >= a[i])
change(n+1, c-a[i]);
}
int main()
{
cin.sync_with_stdio(false);
cin >> num >> t;
for (int i = 1; i <= num; i++)
cin >> a[i];
change(0, t);
cout << ans;
}
动态规划的思考
从dp角度,深搜对应完全背包,j >= w[i]
也就对应着剪枝。
不过这二者对维度的枚举顺序并不相同。深搜是在容量的层面上枚举内容,动规是盯着内容单元枚举容量。
这是由算法的不同特征导致的:
- 动规借助循环实现,所以相对连续,适合于枚举相对连续的容量,这种枚举量大的特点也注定了不能使用递归(当然,记忆化搜索可以解决这个问题)。
- 而递归本来就可以用来进行相对离散的递推,所以通过枚举内容的单元,可以减少递归调用的发生,这对效率是很有利的。
由于完全背包问题的无限性,可以多次利用先前结果;所以在动规的递推过程当中,是从小到大依次进行的,这和0-1背包显然不同。
这个完全背包的过程可以利用线性数组。
#include <iostream>
#include <cstring>
#define MAXN 100005
using namespace std;
int n, t, a[55], dp[MAXN];
int main()
{
cin >> n >> t;
for (int i = 0; i < n; i++)
cin >> a[i];
memset(dp, 0x3f, sizeof(dp)); dp[0] = 0;//这个问题中是求最小值,所以应该初始化为较大的3f
for (int i = 0; i < n; i++)
for (int j = a[i]; j <= t; j++)
dp[j] = min(dp[j], dp[j-a[i]] + 1);//可以把输出和动规结合起来。没有太大差异啦
for (int i = t; i >= 1; i--)
if (dp[i] != 0)
{
cout << dp[i];
break;
}
}
这个题目属于典型的NPC问题,使用递归极其缓慢,所以就用动规好了。总体上来说,递归思路很好说明。但是面对这样的问题,深搜只能撂挑子了。