DP 詳解

George0915發表於2024-10-27

DP 概述

DP(Dynamic programming,全稱動態規劃),是一種基於分治,將原問題分解為簡單子問題求解複雜問題的方法。

動態規劃的耗時往往遠少於樸素(爆搜)解法。

動態規劃 and 遞迴

之前說過,動態規劃也是分治思路,而遞迴更是傳統的分治思路,但時間複雜度卻大相徑庭,為什麼呢?

動態規劃是 自頂向上 思想,而遞迴是 自頂向下 解法。

自頂向上 and 自頂向下?

自頂向上

意思很簡單,從下往上推導:\(f(1) \rightarrow f(2) \rightarrow \dots \rightarrow f(n - 1) \rightarrow f(n)\)

這也是為什麼 動態規劃演算法 脫離了 遞迴 的函式,改用迴圈迭代推到的原因。

自頂向下

反過來,自頂向下就是從上往下推,觸底後在將結果返回回來。

\(f(n) \rightarrow f(n - 1) \rightarrow \dots \rightarrow f(2) \rightarrow f(1) \rightarrow f(2) \rightarrow f(3) \rightarrow \dots \rightarrow f(n - 1) \rightarrow f(n)\)

這也是為什麼遞迴比動態規劃時間複雜度高的多的原因。

我們可以看出,動態規劃更像是遞推演算法的 plus 版。

狀態轉移方程

狀態轉移方程,就是如何將子問題轉移至父親問題的公式。

在簡單 DP 中,轉移方程可以直接套用至 dfs, bfs 等爆搜演算法。

DP 最難的部分就是列出狀態轉移方程,如果沒有狀態轉移方程,一切都白搭。

例:設 \(f_i\) 為數列第 \(i\) 為的數,斐波那契數列的狀態轉移方程為 \(f_i = f_{i - 1} + f_{i - 2}\)

DP 如下:

f[1] = 1;
f[2] = 1;
for (int i = 3; i <= n; i++)
    f[i] = f[i - 1] + f[i - 2]; // 轉移方程
cout << f[n];

同樣的,我們可以將轉移方程套用在遞迴暴力上:

int f(int n)
{
    if (n == 1 || n == 2)
        return 1;
    return f(n - 1) + f(n - 2); // 轉移方程
}

動態規劃要素

  1. 最優子結構:問題的最優解 包含 子問題最優解。即為:區域性最優解 = 全域性最優解。

  2. 無後效性

    • 在推導後面狀態時,僅在意前面狀態數值,不在意是如何推匯出來的。

    • 某狀態確定後,不會因為後面的決策而改變前面的決策。

  3. 重疊子問題:不同的決策到達相同的狀態時可能產生重複的狀態,為了避免不必要的計算,我們通常使用 記憶化搜尋(在計算出新狀態時將它儲存起來一遍下次使用)來解決,這也是最經典的 空間換時間

不滿足這三點你還想 DP?想 peach 呢?

狀態的定義

前言:空間換時間

很簡單的名字,即為使用空間的代價來確保不會超時。

狀態?

狀態,通俗來講就是你 \(f_{xxx}\) 代表的是什麼。比如斐波那契數列中 \(f_i\) 代表的就是第 \(i\) 為是什麼。

對於狀態:

  1. 狀態越多,表示的資訊越多,空間越大

  2. 反之,狀態越少,表示的資訊越少,空間越小

在我們狀態定義時,可能有這些情況:

\(部分情況 \begin{cases} 狀態太少?\begin{cases} 資訊量太少 & 無解 \\\\ 資訊量太少 & 不滿足動態規劃要素 \end{cases} \\\\ 狀態太多? \begin{cases} 空間太大 & MLE \\\\ 需要太多時間更新狀態 & TLE \end{cases} \end{cases}\)

所以,狀態 and 狀態轉移方程時整個動態規劃中最最最難的部分,想清楚這兩點,這題也就解出來了。

參考資料

https://zh.wikipedia.org/wiki/動態規劃

五大基本演算法之動態規劃演算法 DP dynamic programming

動態規劃解題套路框架 | labuladong 的演算法筆記

1.最優子結構 | 資料結構與演算法之美

例題

例題一思路

純 DP

點我檢視題目

沒看資料:好一個 dfs!

注:兩種情況

  1. 拿本物品

    • 3 倍獎金?

    • 1 倍獎金?

  2. 不拿本物品

ll dfs(int i, int now, ll cnt)
{
    if (i == n + 1)
        return cnt;
    if (!((now + 1) % 3) && ((now + 1) >= 3))
        return max(dfs(i + 1, now + 1, cnt + (a[i] * 3)), dfs(i + 1, now, cnt));
    else
        return max(dfs(i + 1, now + 1, cnt + a[i]), dfs(i + 1, now, cnt));
}

我們看題面,一眼看出的狀態為:\(f_i\) 表示前 \(i\) 個物品獲得的最大獎金。

但是,我們發現不滿足無後效性。

根據上述方法,我們嘗試使用空間的代價來最佳化。

將狀態改為:\(f_{i, j}\) 表示前 \(i\) 個物品,當前物品數取餘 \(3\)\(j\) 時獲得的最大獎金。

\(f{i, j} = \begin{cases} j = 0 \begin{cases} i \ge 3 \begin{cases} f_{i - 1, 0} & 不拿 \\\\ f_{i - 1, 2} + a_i \times 3 & 拿 \end{cases} \\\\ f_{i - 1, 0} & 沒有到 3 個,不存在這種情況。 \end{cases} \\\\ j = 1 \begin{cases} f_{i - 1, 1} & 不拿 \\\\ f_{i - 1, 0} + a[i] & 拿 \end{cases} \\\\ j = 2 \begin{cases} i \ge 2 \begin{cases} f_{i - 1, 2} & 不拿 \\\\ f_{i - 1, 1} + a_i & 拿 \end{cases} \\\\ f_{i - 1, 2} & 沒有至少 2 個物品,沒有這種情況。 \end{cases} \end{cases}\)

完整程式碼為:

#include <bits/stdc++.h>
using namespace std;

#define ll long long
int n;
ll a[100005];

/*
            20PTS
ll dfs(int i, int now, ll cnt)
{
    if (i == n + 1)
        return cnt;
    if (!((now + 1) % 3) && ((now + 1) >= 3))
        return max(dfs(i + 1, now + 1, cnt + (a[i] * 3)), dfs(i + 1, now, cnt));
    else
        return max(dfs(i + 1, now + 1, cnt + a[i]), dfs(i + 1, now, cnt));
}
*/

ll f[100005][3];
ll ans;

int main()
{
    cin >> n;
    for (int i = 1; i <= n; i++)
        cin >> a[i];
    // cout << dfs(1, 0, 0) << "\n";

    for (int i = 1; i <= n; i++)
    {
        f[i][0] = f[i - 1][0];
        f[i][1] = f[i - 1][1];
        f[i][2] = f[i - 1][2];
        if (i >= 3)
            f[i][0] = max(f[i][0], f[i - 1][2] + (a[i] * 3));
        f[i][1] = max(f[i][1], f[i - 1][0] + a[i]);
        if (i >= 2)
            f[i][2] = max(f[i][2], f[i - 1][1] + a[i]);
        ans = max(ans, f[i][0]);
        ans = max(ans, f[i][1]);
        ans = max(ans, f[i][2]);
    }
    cout << ans << "\n";
    return 0;
}

點我檢視題目

首先,我們欣賞一下原出題人的提示。

例題二前言:分類討論

在看了許多不當人的講解後,我濃縮出:分類討論就是分類 --> 討論!分類討論就是將問題透過不同的結果 / 形式 / 不同點分成幾類逐個解決。

例題二思路

既然說到分類討論我們先來分個類。

\(\max(\sum_{i = 1}^{N} A_i) = \begin{cases} C > 0 & \max(\sum_{i = L}^{R} A_i) \times C \\\\ C < 0 & \min(\sum_{i = L}^{R} A_i) \times C \end{cases}\)

最大最小怎麼使用 \(O(N)\) 求?Bingo!最大 / 最小 子段和即可。

最後比一下就好了。

完整 Code:

#include <bits/stdc++.h> 
using namespace std;

#define ll long long
int n;
ll c;
ll a[100005];
ll solve()
{
    ll original_sum = 0;
    for (int i = 1; i <= n; ++i)
        original_sum += a[i];

    ll dp_max[100005], dp_min[100005];
    dp_max[1] = a[1];
    dp_min[1] = a[1];

    ll maxx = dp_max[1];
    ll minn = dp_min[1];

    for (int i = 2; i <= n; i++)
    {
        dp_max[i] = max(a[i], dp_max[i - 1] + a[i]);
        dp_min[i] = min(a[i], dp_min[i - 1] + a[i]);
        maxx = max(maxx, dp_max[i]);
        minn = min(minn, dp_min[i]);
    }

    ll res = max((c - 1) * maxx, (c - 1) * minn);
    ll ans = original_sum + res;
    return ans;
}

int main()
{
    cin >> n >> c;
    for (int i = 1; i <= n; ++i)
        cin >> a[i];
    cout << solve() << endl;
    return 0;
}