硬幣找零——揹包問題,以及循環、遞歸、動規共通性

在這個題目的基礎上,我瞭解了一下這幾個“編程寫法”,並對循環、遞歸、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,構建每個元素的冪分支解空間樹。也就是像類似這樣展開(圖題無關)
在這裏插入圖片描述
對於這個題來說,就是,沒有到全部裝完的時候,就嘗試不同的硬幣(顯然深搜比起分治也更好理解)。直到產生一系列ana^n樹。

如果從分治角度上說,就是可以有幾種方法(硬幣)回到前一種子情形。然後再對子情形進行遞歸求解

遞歸題解

這其實並不是一個講深搜的好例子,它的最終輸出行爲是比較複雜的。

如果沒有完整方案,要輸出剩下錢數最少的找零方案中的最少硬幣數
沒有搜到的可能性和重要性被放大了。這是一個使用分治策略的深搜(這個表述就很悖論,仍然說明二者是互通的)。

記憶化的分治,記錄已經搜索過的部分,是通過離散來填充連續空間

按規模分治。這個方法來自網絡,由於使用了兩次遞歸,所以慢的出奇。

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問題,使用遞歸極其緩慢,所以就用動規好了。總體上來說,遞歸思路很好說明。但是面對這樣的問題,深搜只能撂挑子了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章