在這個題目的基礎上,我瞭解了一下這幾個“編程寫法”,並對循環、遞歸、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,構建每個元素的冪分支解空間樹。也就是像類似這樣展開(圖題無關)
對於這個題來說,就是,沒有到全部裝完的時候,就嘗試不同的硬幣(顯然深搜比起分治也更好理解)。直到產生一系列樹。
如果從分治角度上說,就是可以有幾種方法(硬幣)回到前一種子情形。然後再對子情形進行遞歸求解
遞歸題解
這其實並不是一個講深搜的好例子,它的最終輸出行爲是比較複雜的。
如果沒有完整方案,要輸出剩下錢數最少的找零方案中的最少硬幣數。
沒有搜到的可能性和重要性被放大了。這是一個使用分治策略的深搜(這個表述就很悖論,仍然說明二者是互通的)。
記憶化的分治,記錄已經搜索過的部分,是通過離散來填充連續空間
按規模分治。這個方法來自網絡,由於使用了兩次遞歸,所以慢的出奇。
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;
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問題,使用遞歸極其緩慢,所以就用動規好了。總體上來說,遞歸思路很好說明。但是面對這樣的問題,深搜只能撂挑子了。