計數 dp 做題記錄(日更)

Nekopedia發表於2024-08-28

前言

因為本人太弱,急需鍛鍊思維,固從現在起開始著手寫計數題,並寫下題解分析思路的欠缺。另外本文將長時間更新,所以我準備把它置頂,儘量日更

P3643 [APIO2016] 划艇 2024.8.28

簡要題意

現在有 \(n\) 個區間,每個區間範圍為 \([l_i,r_i]\)。現在有 \(n\) 個元素需要賦值,每個元素的值要麼為零,要麼在給定的區間內。對於一個值非零的元素 \(a_i\),需要滿足它的數值嚴格大於所有標號比它小的元素,即 \(a_i\ge\max_{1\le j<i}\{a_j\}\)。求方案數。

資料範圍:\(n\le500,1\le l_i\le r_i\le10^9\)

題解

首先去想題目性質,然後很高興地發現根本沒有什麼性質。然後先考慮樸素 dp,我們令 \(f_{i,j}\) 表示第 \(i\) 個元素值為 \(j\) 的方案數,最後答案為 \(\sum_{i=1}^{i\le n}\sum_{j}f_{i,j}\)

然後考慮轉移,其實轉移也很暴力我就直接放式子了:

\[f_{i,j}=\begin{cases} \sum_{k=0}^{i-1}\sum_{l=1}^{j-1}f_{k,l} & j\in[l_i,r_i]\\ 0 & otherwise \end{cases} \]

為方便轉移,初始 \(f_{0,1}=1\)

然後不用多說這個肯定爆了。第二維值域是 \(10^9\) 所以能夠想到將區間離散化,然後第二維改成區間。這樣轉移就需要小小的改變一下,因為涉及到了區間選若干點,所以需要加一個係數。那麼係數我們怎麼求呢?

假設當前區間長度為 \(len\),元素為第 \(i\) 個。列舉到前 \(j\) 個元素時,現在有 \(i-j\) 個元素將會放在當前區間,我們就把問題抽象成有 \(1\)\(len\) 一共 \(len\) 個數,我們需要從中選出最多 \(m\) 個,選擇的方案數就是轉化時乘上的係數。考慮對於一次選擇,我可以選或不選,若我選就會從中選取一個數就正常做;但如果我不選呢?就把它看成我選了零。於是我們就可以往序列中加入 \(m\) 個零,現在有一共 \(len+m\) 個數,我要從中選出 \(m\) 個,方案為 \({len+m}\choose m\)。然而因為 dp 狀態欽定第 \(i\) 個數必選,所以我們實際往序列中加入的零的個數應該會比上述操作少一個(因為保證至少有一個數也就是 \(i\) 不為零)。於是最後的 dp 轉移就變成了下面的:

\[f_{i,j}=\begin{cases} \sum_{k=0}^{i-1}\sum_{l=1}^{j-1}f_{k,l}{Len_j+cnt-1\choose cnt} & j\in[l_i,r_i]\\ 0 & otherwise \end{cases} \]

但是現在總複雜度還是 \(O(n^4)\) 的,還需要一個小最佳化,然後考慮哪些狀態可以一起考慮。我們可以發現,對於當前的狀態 \(f_{i,j}\),我只要滿足一個狀態 \(f_{k,l}\) 的第二維小於 \(j\) 也就是 \(l<j\),就可以將所有的 \(f_{k,l},l<j\) 累加然後整體乘上一個組合數,所以記一個 \(g_{i,j}=\sum_{k=1}^{j-1}f_{i,k}\),然後就得到了最後的轉移式:

\[f_{i,j}=\sum_{k=0}^{i-1}g_{k,j}{Len_j+cnt-1\choose cnt} \]

時間複雜度 \(O(n^3)\) 不做過多解釋。

程式碼

int n, l[N], r[N], z[N << 1], tot;
ll f[N], c[N], inv[N], ans;
ll add(ll x, ll y){
    x += y; return x >= p ? x - p : x;
}

signed main(){
    // fileio(fil);
    n = rd();
    for(int i = 1; i <= n; ++i){
        z[i - 1 << 1 | 1] = l[i] = rd(), z[i << 1] = r[i] = rd() + 1;
    }
    sort(z + 1, z + 1 + (n << 1));
    tot = unique(z + 1, z + 1 + (n << 1)) - z - 1;
    for(int i = 1; i <= n; ++i){
        l[i] = lower_bound(z + 1, z + 1 + tot, l[i]) - z;
        r[i] = lower_bound(z + 1, z + 1 + tot, r[i]) - z;
    }
    inv[1] = f[0] = c[0] = 1;
    for(int i = 2; i <= n; ++i)inv[i] = 1ll * (p - p / i) * inv[p % i] % p;
    for(int i = 1; i < tot; ++i){
        int len = z[i + 1] - z[i];
        for(int j = 1; j <= n; ++j)c[j] = c[j - 1] * (len + j - 1) % p * inv[j] % p;
        for(int j = n; j; --j)if(l[j] <= i and i + 1 <= r[j]){
            ll s = 0; int cnt = 1;
            for(int k = j - 1; ~ k; --k){
                s = add(s, c[cnt] * f[k] % p);
                cnt += l[k] <= i and i + 1 <= r[k];
            }
            f[j] = add(f[j], s);
        }
    }
    for(int i = 1; i <= n; ++i)ans += f[i];
    printf("%lld", ans % p);
    return 0;
}

小結

其實做完這道題時感覺完全不夠紫題,但是在看題解之前怎麼都切不了。其實暴力 dp 我肯定會,離散化我想到了,後面的組合數也很基礎,最後的字首和相對於其他最佳化也簡單得多。但是,為什麼我就是做不出來呢?因為我不熟悉知識間的組合與銜接,不肯從暴力入手,老是想怎麼直接出正解,而真正的正解需要前面大量的鋪墊。它或許是 OIer 做題時的妙手偶得,但更是大量的經驗與積累!

而對於我來說,我拿到一道題應該去做什麼?我首先要去分析題目的性質,然後根據性質看看能不能得出進一步結論。有了以上的東西,我就可以去根據已有的東西思考如何得出答案,這一期間可以先將時間複雜度暫放。最後再來慢慢最佳化求解的過程,方法。還有不要忘了驗證正確性