[動態規劃] 區間 dp

PassName發表於2024-05-18

區間 dp

石子合併

將區間長度 \(len\) 作為 \(dp\) 的階段

\(f[l][r]\) 表示把最初的第 \(l\) 堆到第 \(r\) 堆石子合併成一堆,需要消耗的最少體力。

合併代價就是這兩堆石子的質量和,這裡可以用字首和直接計算,設 \(s[i]\) 表示前 \(i\) 堆石子的質量和。

狀態轉移方程:

\[f[l][r] = \min_{l≤k<r}^{}\{f[l][k]+f[k+1][r]+(s[r]-s[l-1])\} \]

計算 max 同理

注意,由於需要破環為鏈,不能直接將答案返回 \(f[1][n]\) ,要在計算過程中更新答案。

int f[N][N];
int n;
int a[N], s[N];

int cost(int l, int r){return s[r] - s[l - 1];}

int dp_min()
{
    int minn = inf;
    for (rint len = 1; len < n; len++)
    {
    	for (rint l = 1; l <= 2 * n - len; l++)
    	{
    		int r = l + len;
    		f[l][r] = inf;
    		for (rint k = l; k < r; k++) f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + cost(l, r));
			if (len + 1 == n) minn = min(minn, f[l][r]);
		}
	}
	return minn;
}

int dp_max()
{
	int maxx = 0;
	for (rint len = 1; len < n; len++)
	{
		for (rint l = 1; l <= 2 * n - len; l++)
		{
			int r = l + len;
			f[l][r] = 0;
			for (rint k = l; k < r; k++) f[l][r] = max(f[l][r], f[l][k] + f[k + 1][r] + cost(l, r));
			if (len + 1 == n) maxx = max(maxx, f[l][r]);
		}
	}
	return maxx;
}

signed main()
{
    cin >> n;
    for (rint i = 1; i <= n * 2; i++)
    {
    	if (i <= n)
		{
			cin >> a[i];
			a[i + n] = a[i];
		}
    	s[i] = s[i - 1] + a[i];
	}
	cout << dp_min() << endl << dp_max() << endl;
	return 0;
}

P4342 [IOI1998] Polygon

本題要求的是區間合併的最大得分,很明顯是區間 \(dp\) 問題。

把被刪除的邊逆時針方向的頂點稱為 "第 \(1\) 個頂點",依此類推。然後用區間 \(dp\) 常見的狀態表示。

\(f[l][r]\) 表示把第 \(l\)\(r\) 個頂點合成的最大得分。

狀態表示沒有什麼問題,但是狀態轉移出現了問題,我們並不能透過 \(f[l][k]\)\(f[k + 1][r]\) 來得到 \(f[l][r]\)

有可能 \(l\) ~ \(k\) 的某種得分是負數 \(a\)\(k + 1\) ~ \(r\) 的某種得分也是負數 \(b\),但是 \(a * b\) 可能比 \(f[l][k] * f[k + 1][r]\) 更大。

由此得出狀態轉移的錯誤性,那麼我們應該如何得出 \(f[l][r]\) 呢,首先分析 \(l ~ r\) 的最大得分有幾種可能得到:

  1. max(l ~ k) + max(k + 1 ~ r)
  2. max(l ~ k) * max(k + 1 ~ r)
  3. min(l ~ k) * min(k + 1 ~ r) (負負得正)

透過分析可以發現,要想得出 \(f[l][r]\) 需要知道子狀態的最大值和最小值。

\(f[l][r][0]\) 表示把第 \(l\)\(r\) 個頂點合成的最大得分,\(f[l][r][1]\) 表示把第 \(l\)\(r\) 個頂點合成的最小得分

那麼我們每次轉移都需要轉移最大值和最小值,上面已經分析最大值的轉移,再分析一下最小值的轉移:

  1. min(l ~ k) + min(k + 1 ~ r)
  2. min(l ~ k) * min(k + 1 ~ r)
  3. max(l ~ k) * max(k + 1 ~ r)
  4. max(l ~ k) * min(k + 1 ~ r)
  5. min(l ~ k) * max(k + 1 ~ r)

起始狀態為 \(f[i][i][0] = f[i][i][1] = a[i]\),結束狀態為 \(f[1][n][0]\)

以上就是全部的狀態轉移,另外還需要考慮第一步要刪除哪條邊。這裡採用拆環成鏈的技巧,例如,將 - a - b - c - d - 拆成 a - b - c - d - a - b - c - d 這時列舉所有以前一半為端點的鏈,就能找出所有的情況。

a - b - c - dd - a

b - c - d - aa - b

c - d - a - bb - c

d - a - b - cc - d

最終列舉所有的鏈從中取最大值即可。

int n;
int a[N]; 
//原序列
char op[N]; 
//操作序列
//f[l][r][0] 表示把第 l 到 r 個頂點合成的最大得分
//f[l][r][1] 表示把第 l 到 r 個頂點合成的最小得分
int f[N][N][2];

signed main()
{
    cin >> n;

    //接收原序列、操作序列
    for (rint i = 1; i <= 2 * n; i++)
        if (!(i % 2)) cin >> a[i / 2];
        else scanf("%s", &op[(i + 1) / 2]);

    //初始化
    for (rint l = 1; l <= 2 * n; l++)
        for (rint r = 1; r <= 2 * n; r++)
            f[l][r][0] = -inf, f[l][r][1] = inf;

    //拆環成鏈
    for (rint i = 1; i <= n; i++)
    {
        a[i + n] = a[i];
        op[i + n] = op[i];
        f[i][i][0] = f[i + n][i + n][0] = f[i][i][1] = f[i + n][i + n][1] = a[i]; 
    }

    for (rint len = 1; len < n; len++) 
    {
        for (rint l = 1; l + len <= 2 * n; l++)
        {
            int r = l + len; 
            for (rint k = l; k < r; k++) 
                if (op[k + 1] == 't') //加法
                {
                    f[l][r][0] = max(f[l][r][0], f[l][k][0] + f[k + 1][r][0]);
                    f[l][r][1] = min(f[l][r][1], f[l][k][1] + f[k + 1][r][1]);
                }
                else //乘法
                {
                    f[l][r][0] = max(f[l][r][0], f[l][k][0] * f[k + 1][r][0]);
                    f[l][r][0] = max(f[l][r][0], f[l][k][1] * f[k + 1][r][1]);
                    f[l][r][1] = min(f[l][r][1], f[l][k][1] * f[k + 1][r][1]);
                    f[l][r][1] = min(f[l][r][1], f[l][k][1] * f[k + 1][r][0]);
                    f[l][r][1] = min(f[l][r][1], f[l][k][0] * f[k + 1][r][1]);
                    f[l][r][1] = min(f[l][r][1], f[l][k][0] * f[k + 1][r][0]);
                }
        }		
	}

    //列舉所有鏈取最大值
    int res = -inf;
    for (rint i = 1; i <= n; i++) res = max(res, f[i][i + n - 1][0]);
    cout << res << endl;

    //找出所有能取到最大值的頂點
    for (rint i = 1; i <= n; i++)
        if (res == f[i][i + n - 1][0])
            cout << i << " ";

    return 0;
}

AcWing 284. 金字塔

\(f[l][r]\) 表示子串 \(s[l\) ~ \(r]\) 對應多少種可能的樹形結構。

然後考慮對區間的劃分,根據區間的劃分不同,也可能得出不同的樹形結構。

\(s[l\) ~ \(r]\) 對應一棵子樹,那麼 \(s[l]\)\(s[r]\) 就應該是樹根,\(s[l + 1]\)\(s[r - 1]\) 就是進入和離開子樹時的節點。
除此之外,\([l, r]\) 包含的每棵更深的子樹都對應一個子問題,會產生 \([l, r]\) 中的一段,相鄰兩段之間還有途經樹根產生
的一個字元。由於 \([l, r]\) 包含的子樹個數可能不止兩個,如果我們樸素的列舉劃分點的數量和所有劃分點的位置,那麼
時間複雜度會高得離譜。

因此我們可以換種思路,嘗試把 $s[l $ ~ $ r]$ 分成兩部分,每部分可由若干棵子樹組成,不過這樣可能會產生重複計數。

如果每段都可以由多棵子樹構成,如 "ABABABA",劃分成 "A|BAB|A|B|A" 和 "A|B|A|BAB|A",其中 "BAB" 都能產生 "B|A|B"
兩棵子樹,那麼這兩種劃分方式最終就會變成同一種結果。

為了解決不重不漏,我們可以只考慮子串 \([l\) ~ \(r]\) 的第一棵子樹時由哪一段構成的,列舉劃分點$ k$,令子串 \(s[l + 1\) ~ \(k - 1]\)
構成 \([l, r]\) 的第一棵子樹,\(s[k\) ~ \(r]\) 構成 \([l, r]\) 的剩餘部分(其他子樹)。

如果 \(k\) 不相同,那麼子串 \(s[l + 1\) ~ \(k - 1]\) 代表的子樹的大小也不相同,就不可能產生重複計算的結構。

由此得出狀態轉移方程,當 \(s[l]≠s[r]\)

\[f[l][r]=0 \]

\(s[l]=s[r]\)

\[f[l][r]=f[l+1][r-1]+\sum_{l+2≤k≤r-2,s[l]=s[k]}{f[l+1][k-1]*f[k][r]} \]

起始狀態為 \(f[i][i] = 1\),目標狀態為 \(f[1][n]\)

char str[N];
int f[N][N]; 
//表示子串 s[l ~ r] 對應多少種可能的樹形結構

int dfs(int l, int r)
{
    if (l > r) return 0; //不合法的狀態方案數為 0
    if (l == r) return 1; //一個節點無法劃分,方案數為 1
    if (f[l][r] != -1) return f[l][r]; //如果當前區間已經計算過,直接返回結果
    if (str[l] != str[r]) return 0; //不是一棵完整的子樹,方案數為 0
    //到這說明當前區間沒計算過
    f[l][r] = 0; //最開始方案數為 0
    for (rint k = l + 2; k <= r; k++) //列舉劃分點
        f[l][r] = (f[l][r] + dfs(l + 1, k - 1) * dfs(k, r)) % mod; //累加方案數
    return f[l][r]; //將 [l ~ r] 的方案數往回傳
}

signed main()
{
    scanf("%s", str + 1);
    memset(f, -1, sizeof f); //記憶化搜尋初始化,沒有計算過的狀態預設為 -1
    cout << dfs(1, strlen(str + 1)) << endl; //遞迴計算整個區間
    return 0;
}

相關文章