DP 優化

becaido

講師介紹

  • 建國中學 陳柏凱
  • OJ handle:becaido
  • 因為想學習 DP 優化所以當 DP 優化講師

單調隊列優化

給一個陣列 \(a_1\sim a_n\),問每個長度為 \(k\) 的區間最大值 (\(a_i\sim a_{i+k-1}\) 的最大值)?

我會線段樹!
\(O(n\log(n))\)

\(n=10^7\),被卡常

\(O(n)\) 的做法

維護一個 deque,代表一個長度為 \(k\) 的區間

最前段為區間最大值,後面的元素遞減

當最前面元素與目前位置距離 \(\geq k\),代表它不會被考慮到了

加入的元素可以把 deque 後端的元素淘汰掉

Code:

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

const int N = 1e7 + 5;

int n, k;
int a[N];
deque<int> dq;

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) {
        if (dq.size() && i - dq.front() >= k) dq.pop_front();
        while (dq.size() && a[i] >= a[dq.back()]) dq.pop_back();
        dq.push_back(i);
        if (i >= k) cout << a[dq.front()] << ' ';
    }
}

用單調隊列優化多重背包問題

\(n\) 種物品,每種重量 \(w_i\),價值 \(v_i\),個數 \(a_i\),背包重量限制 \(W\)

用二進制做 01 背包
\(O(W\sum\log(a_i))\)

dp 式:

\(dp_{i,j}\):前 \(i\) 個物品重量為 \(j\) 以下能拿到的最大值

\(\rightarrow dp_{i,j}=\max\limits_{0\leq x\leq a_i}(dp_{i-1,j-x\times w_i}+x\times v_i)\)

令 \(k=j-x\times w_i\)
\(dp_{i,j}=\max\limits_{0\leq x\leq a_i}(dp_{i-1,k}+\frac{j-k}{w_i}\times v_i)\\=\max\limits_{0\leq x\leq a_i}(dp_{i-1,k}-\lfloor\frac{k}{w_i}\rfloor\times v_i)+\lfloor\frac{j}{w_i}\rfloor\times v_i\)

對 \(j\text{ mod }w_i\) 相同的做一遍單調隊列優化!
\(O(n\times W)\)

斜率優化

給 \(n,x,s_1\sim s_n(s_i\leq s_{i+1}),f_1\sim f_n(x\geq f_i\geq f_{i+1})\)
 

殺第 \(i\) 隻怪獸要花 \(s\times f\) 的時間,\(s\) 是現在這隻怪獸的 \(s_i\),\(f\) 是你目前的 \(f\) (初始值 \(f_0=x\)),殺了第 \(i\) 隻怪獸,\(f\) 會變 \(f_i\),可以選擇要殺或不殺這隻怪獸,問最少要花多少時間才能殺掉第 \(n\) 隻怪獸?

列出 DP 式,令 \(dp_i\) 是殺掉第 \(i\) 隻怪獸最少要花的時間,最後要求 \(dp_n\)

\(dp_i = \min\limits_{0\leq j<i}(dp_j + s_i\times f_j)\)

直接 \(O(n^2)\) 算

TLE

\(dp_i = \min\limits_{0\leq j<i}(dp_j + s_i\times f_j)\)

轉換一下式子

\(dp_i = \min\limits_{0\leq j<i}(f_j\times s_i+dp_j)\)

是不是很像一個東西?

\(dp_i = \min\limits_{0\leq j<i}(M_j\times x+K_j)\)

一條直線的樣子!

把第 \(j\) 隻怪獸用 \(L_j:y=M_j\times x+K_j\) 表示

這題還有什麼性質?

當 \(j\) 變大,\(M\) (斜率) 會遞減,\(x\) (查詢) 會遞增!

查詢的是很多直線在某個 \(x\) 的 \(\min\)

維護一個上凸包!

(實線代表目前 \(x\) 的 \(\min\) 在哪一條)

可以用 deque 維護凸包的直線

當要詢問某個 \(x\) 的最小值,可以比較最前面的兩條線

因為 \(x\) 會遞增,所以 \(L_1\) 在之後一定比 \(L_2\) 差,直接把 \(L_1\) pop 掉

繼續比,發現 \(L_2\) 比 \(L_3\) 好,
所以目前 \(x\) 值最好的直線是 \(L_2\)

現在我們算出 \(dp_i\) 了,代表我們有 \(L_i\) 這條直線了,要放入這個凸包

情況一

直接放在 deque 後面

情況二

\(L_3\) 好像不管怎樣都不會是最好的...
(\(L_3\) 不在凸包上 \(\Leftrightarrow\) \(L_3,L_i\) 交點的 \(x\) \(\leq\) \(L_3,L_2\) 交點的 \(x\))

QQ

這個時候 \(L_2\) 跟 \(L_i\) 會合力把 \(L_3\) 殺掉

算交點可以用數學處理(國中數學)

Code:

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

using ll = long long;

const int N = 2e5 + 5;

int n;
int s[N], f[N];
int M[N];
ll K[N], dp[N];
deque<int> dq;

ll cal_y(int i, int x) {
    return 1ll * M[i] * x + K[i];
}
bool can_del(int i1, int i2, int i) {
    /*
        y = m1*x + k1, y = m2*x + k2
    ==> x = (k2-k1) / (m1 - m2)

        x_{i1,i2} >= x_{i,i2}
    ==> (k[i2]-k[i1]) / (m[i1]-m[i2]) >= (k[i2]-k[i]) / (m[i]-m[i2])
    ==> (k[i2]-k[i1]) * (m[i]-m[i2]) <= (k[i2]-k[i]) * (m[i1]-m[i2])
    */
    return (K[i2] - K[i1]) * (M[i] - M[i2]) <= (K[i2] - K[i]) * (M[i1] - M[i2]);
}

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n >> f[0];
    for (int i = 1; i <= n; i++) cin >> s[i];
    for (int i = 1; i <= n; i++) cin >> f[i];

    M[0] = f[0];
    dq.push_back(0);
    for (int i = 1; i <= n; i++) {
        int x = s[i];
        while (dq.size() >= 2 && cal_y(dq[0], x) >= cal_y(dq[1], x)) dq.pop_front();
        dp[i] = cal_y(dq[0], x);
        M[i] = f[i], K[i] = dp[i];
        while (dq.size() >= 2 && can_del(dq.end()[-2], dq.end()[-1], i)) dq.pop_back();
        dq.push_back(i);
    }
    cout << dp[n] << '\n';
}

如果查詢是遞減不是遞增怎麼辦?

改用 stack!

Monster Game Ex

與原本問題相同,只是每 \(k\) 個怪獸必須要有一個被殺。

\(\Rightarrow\) 只能從 \(\max(0,i-k)\leq j<i\) 轉移

原本 \(L_2,L_i\) 會殺掉 \(L_3\),但是有可能 \(L_2\) 在被用到之前就過期了

原本 \(x_{L_3,L_i}\leq x_{L_3,L_2}\) 可以把 \(L_3\) pop 掉變成

\(x_{L_3,L_i}\leq \min(x_{L_3,L_2},\)過期點\()\)

練習題:TIOJ 1676

Monster Game ExEx

每個怪獸都有上一個轉移範圍 \(l_i,r_i\)

區間詢問...

可以線段樹嗎?

可以!

對每個線段樹節點開一個 deque

每次加入 \(L_i\),加入區間有包含 \(i\) 的 deque

每次查詢,可以對 \(O(\log(n))\) 個小區間做一次,最後取 \(\min\)

與原題相同,但 \(s_i,f_i\) 沒有遞增或遞減了

沒有遞增(減)性就不能好好維護凸包了...

有三個方法!

方法一:分治

沒有遞增(減)性,我們強制讓它有!

\(\text{divide}(l,r)\) 可以處理好在 \([l,r]\) 的所有轉移

呼叫 \(\text{divide}(l,mid)\) 把左邊的處理好,接著我們要處理從 \([l,mid]\) 轉移到 \([mid+1,r]\)

對 \([l,mid]\) 的斜率 \(\text{sort}\),對 \([mid+1,r]\) 的查詢 \(\text{sort}\),就可以直接 \(O(r-l+1)\) 做斜率優化了,之後呼叫 \(\text{divide}(mid+1,r)\) 處理右邊的

\(\text{sort}\) 的部分可以用 \(\text{merge sort}\) 的方法,時間、空間複雜度 \(O(n\times\log(n))\)

方法二:李超線段樹

對值域開一個動態開點線段樹

區間 \([L,R]\) 這個節點會存一條直線 \(line\),
代表 \(mid=\frac{L+R}{2}\) 在 \(line\) 及祖先節點的 \(line\) 中會有最小值

現在要插入一個線段,有兩種情況

第一種:此節點還沒有放任何線段

\(\Rightarrow\) 直接放

第二種:遇到此節點的 \(line\) 了

代入 \(x=mid\) 比較大小

把此節點的 \(line\) 設成較好的,繼續往下插入較不好的

要往左還是往右?

比斜率!

1. \(m\geq line.m\)

插左邊

2. \(m<line.m\)

插右邊

每次空間至多只會多 \(O(1)\)

空間複雜度 \(O(n)\),時間複雜度 \(O(n\times\log(C))\)

Code:

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

using ll = long long;

const int N = 2e5 + 5;
const int MAX = 1e6;

int n;
int s[N], f[N];
ll dp[N];

struct Line {
    int m;
    ll k;
    Line() {}
    Line(int m, ll k) : m(m), k(k) {}
    ll operator () (const int &x) const {
        return 1ll * m * x + k;
    }
};

struct Node {
    Line line;
    int ls = 0, rs = 0;
    Node() {}
    Node(Line line) : line(line) {}
} node[N];

int root, sz;
int newnode(Line line) {
    node[++sz] = Node(line);
    return sz;
}

void ins(int &pos, int l, int r, Line line) {
    if (pos == 0) {
        pos = newnode(line);
        return;
    }
    Node &nd = node[pos];
    if (l == r) {
        if (line(l) < nd.line(l)) nd.line = line;
        return;
    }
    int mid = (l + r) / 2;
    if (line(mid) < nd.line(mid)) swap(line, nd.line);
    if (line.m >= nd.line.m) ins(nd.ls, l, mid, line);
    else ins(nd.rs, mid + 1, r, line);
}
ll que(int pos, int l, int r, int x) {
    if (pos == 0) return LLONG_MAX;
    Node &nd = node[pos];
    if (l == r) return nd.line(x);
    int mid = (l + r) / 2;
    return min(nd.line(x), x <= mid ? que(nd.ls, l, mid, x) : que(nd.rs, mid + 1, r, x));
}

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n >> f[0];
    for (int i = 1; i <= n; i++) cin >> s[i];
    for (int i = 1; i <= n; i++) cin >> f[i];
    ins(root, 0, MAX, Line(f[0], 0));
    for (int i = 1; i <= n; i++) {
        dp[i] = que(root, 0, MAX, s[i]);
        ins(root, 0, MAX, Line(f[i], dp[i]));
    }
    cout << dp[n] << '\n';
}

方法三:動態凸包

一樣是空間 \(O(n)\),時間 \(O(n\times\log(n))\)

但是講師不會 OAO
會的可以上來講

練習題:

TIOJ 1921

ZJ f994

TIOJ 2213

有轉移範圍的不單調斜率優化...

線段樹!

每個線段樹節點都開一棵李超線段樹

\(O(n\times\log(n)\times\log(C))\)

練習題:

ZJ f995:疊疊杯子蛋糕 - Extreme (\(n\leq 3\times 10^5\))

對於轉移範圍任意的,似乎就只能這樣了

那如果大小固定是 \(k\) 呢?

原本以為只能做到 \(O(n\times\log(n)\times\log(C))\)

直到...

TIOJ 2285:花花世界

給你 \(a_0\sim a_n,\ b_1\sim b_n,\ m_1\sim m_n\),
\(F_i = \max\limits_{\max(0, b_i - k + 1)\leq j \leq b_i}(F_j + (a_i - a_j + 1)\times m_i)\),請你輸出 \(\sum\limits_{i = 0}^n F_i\ (\text{mod }1020050909)\)

(校內賽寫線段樹套李超線段樹被卡...)

要想辦法優化到 \(O(n\times\log(C))\)

移一下式子,變成
\(F_i = (-a_j)\times m_i + F_j + (a_i + 1)\times m_i\)

每 \(k\) 個分一塊,那每個區間會蓋住恰好一個分界點

分界點往左跟往右的直線可以分開算

每個點記錄它在分界點的左(右)邊會轉移到哪一些點

練習題

ZJ i963:疊疊杯子蛋糕 ꝏ Extreme (\(n\leq 10^6\))

基本上是看完花花世界出的

矩陣優化

費式數列 \(f_0=0,f_1=1,f_i=f_{i-1}+f_{i-2}\)

求 \(f_n\ (\text{mod }M)\)

直接算

\(O(n)\)

\(n\leq 10^{18}\) 怎麼辦?

把轉移寫成矩陣乘法

令 \(A=\)

\(\times A\times A\times\dots\)

可以寫成 \(\times A^n\)

\(\Rightarrow\) 可以快速冪!

\(O(\log(n))\)

矩陣快速冪的時機

DP 轉移可以用矩陣表達

矩陣快速冪的優化

費式數列可以 \(O(1)\)

#include "moon.h"



\(f_1\sim f_k=1\) (\(k\leq 15\))
\(f_i=\sum\limits_{j=1}^k f_{i-j}\)

\(q\leq 10^5\) 次詢問,每次給 \(n\leq 10^{18}\),請求出 \(f_n\ (\text{mod }M)\)

費式數列 Ex

現在要考慮矩陣大小了,兩個 \(k\times k\) 矩陣相乘的複雜度是 \(O(k^3)\) (或是你可以用 \(O(k^{2.37188})\))

那 \(q\) 次詢問的總時間複雜度是 \(O(q\times k^3\times\log(C))\)

TLE 了

可以預處理

先算出 \(A^1,A^2,A^4,A^8\dots\),快速冪時可以直接用

把 \(k\times k\) 的矩陣改成 \(1\times k\)

複雜度 \(O(k^4\times\log(C)+q\times k^2\times\log(C))\)

給 \(f_0\sim f_{k-1}\),\(f_i=\sum\limits_{j=1}^k a_j\times f_{i-j}\) (\(k\leq 2000\))

求 \(f_n\) (\(n\leq 10^{18}\))

費式數列 ExEx

\(O(k^3\times\log(C))\) 太慢

似乎有一個多項式的做法

做法 (From OI-wiki)

令 \(F(\sum c_ix^i)=\sum c_if_i\) (把多項式的第 \(i\) 項乘 \(f_i\))

想求 \(F(x^n)=f_n\)

\(F(x^n)=F(\sum\limits_{i=1}^ka_ix^{n-i})\)

\(\Rightarrow\ F(x^n-\sum\limits_{i=1}^ka_ix^{n-i})=F(x^{n-k}(x^k-\sum\limits_{i=0}^{k-1}a_{k-i}x^i))=0\)

我們現在知道 \(F(x^k-\sum\limits_{i=0}^{k-1}a_{k-i}x^i)=0\)

\(\text{mod}\) 一次多項式要 \(O(k^2)\),加上快速冪,複雜度 \(O(k^2\times\log(C))\)

令 \(G(x)=x^k-\sum\limits_{i=0}^{k-1}a_{k-i}x^i\)

那求 \(F(x^n)\) 相當於求 \(F(x^n\ \text{mod }G(x))\)

費式數列 ExExEx

給 \(f_0\sim f_{k-1}\),\(f_i=\sum\limits_{j=1}^k a_j\times f_{i-j}\) (\(k\leq 3\times 10^4\))

求 \(f_n\) (\(n\leq 10^{18}\))

\(O(k^2\times\log(C))\) 也 TLE 了,要怎麼優化啊?

多項式...

NTT!

把 \(\text{mod }G(x)\) 從 \(O(k^2)\) 優化到 \(O(k\times\log(k))\) 了

複雜度 \(O(k\times\log(k)\times\log(C))\)

ZJ f013:\(k\leq 30,n\leq 2^{50},q\leq 10^4\),測資爛,\(O(k^3\times\log(C))\) 可過

ZJ f996:\(k\leq 15,n\leq 2^{60},q\leq 10^5\),\(O(k^4\times\log(C)+q\times k^2\times\log(C))\)

ZJ i001:\(k\leq 2000,n\leq 10^9\),\(O(k^2\times\log(n))\) 可過

ZJ i002:\(k\leq 3\times 10^4,n\leq 10^9\),\(O(k\times\log(k)\times\log(n))\)

ZJ j777:\(k\leq 2\times 10^5,n\leq 2^{24}\),有點卡常

一連串的費式數列題

練習題:

luogu P4723

luogu P5808 (挑戰題!(我也不會))

Library Checker - Kth term of Linearly Recurrent Sequence (看別人寫的超快 code)

ZJ c490 (任意模數(好難...))

轉移點單調優化 / 分治優化

\(\text{val}(l,i)\) 代表從 \(l\) 轉移到 \(i\) 的數值,
並且可以直接算(不需要仰賴之前的結果)

\(\text{last}(i)\) 代表 \(i\) 最好的轉移點(極值會是 \(\text{val}(\text{last}(i),i)\))

現在如果 \(\text{last}(1)\leq\text{last}(2)\leq\dots\leq\text{last}(n)\)

要算出 \(\text{last}(1)\sim\text{last}(n)\),有沒有什麼快速的方法?

假設算一次 \(\text{val}(l,i)\) 要 \(O(f(n))\)
現在先令它是 \(O(1)\) (計算方便)

直接暴力算

\(O(n^2)\),TLE

嘗試優化

每次暴力的時候從 \(\text{last}(i-1)\) 開始枚舉 \(\dots\)

還是 \(O(n^2)\)

worst case:\(\text{last}(1)=\text{last}(2)= \dots =\text{last}(n)=1\)

要找到好的枚舉順序 \(\dots\)

每次對半切!

分治!

定義函數 \(\text{divide}(x,y,l,r)\)

要算 \(\text{last}(l)\sim\text{last}(r)\)

並且只能從 \([x,y]\) 這個區間轉移

\(mid=\lfloor\frac{l+r}{2}\rfloor\)(\([l,r]\) 的中點)

算出 \(mid\) 最好的轉移點 \(best=\text{last}(mid)\)

然後呼叫 \(\text{divide}(x,best,l,mid-1),\text{divide}(best,y,mid+1,r)\)

複雜度:

最多只會遞迴 \(O(\log(n))\) 層

每層最多只會跑 \(O(n)\)

\(O(n\log(n))\)!

有一個頂點會往右、往下的框框,問你裡面面積最大的矩形。

看成把紅點轉移到藍點

直接對每個藍點看從哪個紅點轉移比較好

\(O(n^2)\)

看看有沒有轉移點單調

如果從 \(r_2\) 轉移到 \(b_1\) 比
從 \(r_1\) 轉移好 (紅色面積 > 藍色面積)

那 \(\dots\)

從 \(r_2\) 轉移到 \(b_2\) 也會從 \(r_1\) 轉移好!

Code:

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

using ll = long long;

const int N = 1e5 + 5;

int n, m;
ll ans;
pair<ll, ll> a[N], b[N];

ll cal(int i, int j) {
    if (a[i].first < b[j].first || a[i].second > b[j].second) return -1;
    return (a[i].first - b[j].first) * (b[j].second - a[i].second);
}

void divide(int x, int y, int l, int r) {
    if (l > r) return;
    int mid = (l + r) / 2, best;
    ll mx = -1;
    for (int i = x; i <= y; i++) {
        ll val = cal(mid, i);
        if (val > mx) {
            mx = val;
            best = i;
        }
    }
    ans = max(ans, mx);
    divide(x, best, l, mid - 1);
    divide(best, y, mid + 1, r);
}

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n;
    n /= 2;
    ll x = 0, y = 0;
    for (int i = 1; i <= n; i++) {
        int dx, dy;
        cin >> dx >> dy;
        x += dx;
        a[i] = {x, y};
        y += dy;
    }

    cin >> m;
    m /= 2;
    x = y = 0;
    for (int i = 1; i <= m; i++) {
        int dx, dy;
        cin >> dy >> dx;
        y += dy;
        b[i] = {x, y};
        x += dx;
    }

    divide(1, m, 1, n);
    cout << ans << '\n';
}

練習題:

TIOJ 1449

CSES 2087

TIOJ 1842 (IOI)

TIOJ 1899 (IOI)

轉移點單調 Ex

\(\text{val}(l,i)\) 不能直接算了,要算出 \(\text{val}(i)\) 要仰賴 \(\text{val}(l)\)

強制讓他可以直接算!

\(\text{solve}(l,r)\) 要算出 \(\text{val}(l)\sim \text{val}(r)\)

\(mid=\lfloor\frac{l+r}{2}\rfloor\)

先呼叫 \(\text{solve}(l,mid)\) 算出左半部分

從左半部分轉移到右半部分的可以呼叫 \(\text{divide}(l,mid,mid+1,r)\)

最後呼叫 \(\text{solve}(mid+1,r)\) 處理右半部分的轉移

大致上來說就是套兩層分治

複雜度 \(O(n\log^2(n))\)

有沒有更快的方法啊?

四邊形優化

xD / yD 指的是狀態有 \(O(n^x)\),轉移有 \(O(n^y)\) 的 DP

\(dp_i=\min\limits_{0\leq l<i}(dp_l+\text{val}(l,i))\)

有 \(O(n)\) 個狀態,有 \(O(n)\) 個轉移,
所以是 1D / 1D

凸性優化

\(l_1<l_2\),

當從 \(l_2\) 轉移到 \(i\) 比從 \(l_1\) 轉移到 \(i\) 好,

那從 \(l_2\) 轉移到 \(i+1\) 會比從 \(l_1\) 轉移到 \(i+1\) 好

以上性質會有
\(\text{last}(1)\leq\text{last}(2)\leq\dots\leq\text{last}(n)\)

但反過來不一定

可以用一個 deque 維護每一個轉移點轉移到哪些範圍

詢問某個點 \(i\) 時,如果 deque 最前面的轉移點沒有包含它,那就 pop 掉

要加入 \(i\) 可以轉移到的範圍

比較 deque 後面線段的左端點 \(L\)

比較 \(dp_i+\text{val}(i,L)\) 與 \(dp_{p_4}+\text{val}(p_4,L)\) 的大小

如果從 \(i\) 轉移比較好,可以把 \(p_4\) 轉移到的範圍 pop 掉

再來看 \(p_3\) 的範圍,如果轉移到 \(p_3\) 的左端點 \(p_3\) 比 \(i\) 好

那 \(i\) 比 \(p_3\) 好的點會在 \(p_3\) 的線段中間

可以二分搜!!!

搜到 \(pos\) 後可以把 \(i\) 轉移到的範圍 push 在後面

Code:

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

const int N = 1e5 + 5;

int n;
int dp[N];

struct seg {
    int p, L, R;
};
deque<seg> DQ;

int cal(int l, int i) {
    // do something
}

int main() {
    // init

    DQ.push_back({0, 1, n});
    for (int i = 1; i <= n; i++) {
        while (DQ.size() && DQ[0].R < i) DQ.pop_front();
        dp[i] = cal(DQ[0].p, i);

        while (DQ.size() && cal(i, DQ.back().L) <= cal(DQ.back().p, DQ.back().L)) DQ.pop_back();

        if (DQ.size() == 0) DQ.push_back({i, i + 1, n});
        else {
            auto [p, L, R] = DQ.back();
            while (L < R) {
                int mid = (L + R) / 2 + 1;
                if (cal(p, mid) < cal(i, mid)) R = mid - 1;
                else L = mid;
            }
            DQ.back().R = R;
            if (R != n) DQ.push_back({i, R + 1, n});
        }
    }
}

有一位偉人曾經說過:「有優超性就可以用李超線段樹耶!」

於是也可以用線段樹來做 1D / 1D 優化

Code (凸性):

/* adapted from PCC's code */

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

const int N = 1e5 + 5;

int n;
int dp[N];

int cal(int l, int i) {
    // do something
}

int node[4 * N];
void ins(int pos, int l, int r, int p) {
    if (l == r) {
        if (cal(p, l) <= cal(node[pos], l)) node[pos] = p;
        return;
    }
    if (p == node[pos]) return;
    int mid = (l + r) / 2;
    if (p >= mid) {
        ins(pos << 1 | 1, mid + 1, r, p);
        return;
    }
    if (cal(p, mid) <= cal(node[pos], mid)) swap(node[pos], p);
    if (p < node[pos]) ins(pos << 1, l, mid, p);
    else ins(pos << 1 | 1, mid + 1, r, p);
}
int que(int pos, int l, int r, int p) {
    if (l == r) return cal(node[pos], p);
    int mid = (l + r) / 2;
    return min(cal(node[pos], p), p <= mid ? que(pos << 1, l, mid, p) : que(pos << 1 | 1, mid + 1, r, p));
}

int main() {
    // init

    for (int i = 1; i <= n; i++) {
        dp[i] = que(1, 1, n, i);
        ins(1, 1, n, i);
    }
}

給 \(N,K,P\),有 \(N\) 本書,每本書有寬度 \(A_i\)

要把書放在很多層書架,每層都是連續的書組成,如果 \(i\) 跟 \(i+1\) 放在同一層,中間要放 寬度 \(L_i\) 的隔板

一層總寬度 \(M\) 書架的代價是 \(|M-K|^P\)

問最小代價

令 \(\text{pre}_i=\sum\limits_{i=1}^n(A_i+L_i)\)

列出 DP 式:

\(dp_i=\min\limits_{0\leq l<i}(dp_l+|\text{pre}_i-\text{pre}_l-L_i-K|^P)\)

有凸單調性嗎?

\(\text{pre}_i-\text{pre}_{l_1}-L_i\):

\(\text{pre}_i-\text{pre}_{l_2}-L_i\):

想像有一條正在往右移的紅線

代價如果是(藍線左端點與紅線的距離)\(^P\)

那在紅線到某個點時從 \(l_2\) 轉移會比較好,

在這個點往右都會是 \(l_2\) 比較好

(初始減 \(K\) 是紅線往左 \(K\),從轉移到 \(i\) 變轉移到 \(i+1\) 是紅線往右)

用 deque 維護

數字可能很大,比較時可能會需要用 double

\(O(n\log(n)\log(P))\)

凹性優化

\(l_1<l_2\),

當從 \(l_1\) 轉移到 \(i\) 比從 \(l_2\) 轉移到 \(i\) 好,

那從 \(l_1\) 轉移到 \(i+1\) 會比從 \(l_2\) 轉移到 \(i+1\) 好

原本維護 deque

現在最好的轉移點與插入時都是從同一個地方看

\(\Rightarrow\) 用 stack 維護!

stack 頂端

一樣可以二分搜

Code:

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

const int N = 1e5 + 5;

int n;
int dp[N];

struct seg {
    int p, L, R;
};
vector<seg> ST;

int cal(int l, int i) {
    // do something
}

int main() {
    // init

    ST.push_back({0, 1, n});
    for (int i = 1; i <= n; i++) {
        while (ST.size() && ST.back().R < i) ST.pop_back();
        dp[i] = cal(ST.back().p, i);

        while (ST.size() && cal(i, ST.back().R) <= cal(ST.back().p, ST.back().R)) ST.pop_back();

        if (ST.size() == 0) ST.push_back({i, i + 1, n});
        else {
            auto [p, L, R] = ST.back();
            while (L < R) {
                int mid = (L + R) / 2;
                if (cal(p, mid) < cal(i, mid)) R = mid;
                else L = mid + 1;
            }
            ST.back().L = L;
            if (i + 1 <= L - 1) ST.push_back({i, i + 1, L - 1});
        }
    }
}
#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 5;

int n;
int dp[N];

int cal(int l, int i) {
    // do something
}

int node[4 * N];
void ins(int pos, int l, int r, int p) {
    if (l == r) {
        if (cal(p, l) <= cal(node[pos], l)) node[pos] = p;
        return;
    }
    if (p == node[pos]) return;
    int mid = (l + r) / 2;
    if (p >= mid) {
        ins(pos << 1 | 1, mid + 1, r, p);
        return;
    }
    if (cal(p, mid) <= cal(node[pos], mid)) swap(node[pos], p);
    if (p < node[pos]) ins(pos << 1 | 1, mid + 1, r, p);
    else ins(pos << 1, l, mid, p);
}
int que(int pos, int l, int r, int p) {
    if (l == r) return cal(node[pos], p);
    int mid = (l + r) / 2;
    return min(cal(node[pos], p), p <= mid ? que(pos << 1, l, mid, p) : que(pos << 1 | 1, mid + 1, r, p));
}

int main() {
    // init

    for (int i = 1; i <= n; i++) {
        dp[i] = que(1, 1, n, i);
        ins(1, 1, n, i);
    }
}

線段樹(只跟凸性差一個地方)

選擇轉移點的唬爛方法

選一個數字 \(\text{magic}\)

只在 \([\max(0,i-\text{magic}),i-1]\) 找轉移點

複雜度:\(O(n\times\text{magic})\)

正確性:???

竟然有 IOI 題可以用這個過

練習題:

TIOJ 1886 (IOI)

USACO 2019 Feb p3

Monge Condition

一個 \(n\times m\) 的矩陣 \(A\)

對所有 \(1\leq i_1<i_2\leq n,1\leq j_1<j_2\leq m\)

都有 \(A_{i_1,j_1}+A_{i_2,j_2}\leq(\geq)A_{i_1,j_2}+A_{i_2,j_1}\)

與對所有 \(1\leq i<n,1\leq j<m\)

\(A_{i,j}+A_{i+1,j+1}\leq(\geq)A_{i,j+1}+A_{i+1,j}\) 等價

紅色總和 \(\leq(\geq)\) 藍色總和

Knuth's Optimization

2D / 1D 的 DP 轉移

\(dp_{l,r}=\min\limits_{l\leq k<r}(dp_{l,k}+dp_{k+1,r}+\text{val}(l,r))\)

當 \(\text{val}\) 滿足以下條件:

區間包含單調:\(\text{val}(l_2,r_1)\leq\text{val}(l_1,r_2)\)

對任意 \(1\leq l_1\leq l_2\leq r_1\leq r_2\leq n\)

四邊形不等式:\(\text{val}(l_1,r_1)+\text{val}(l_2,r_2)\leq\text{val}(l_1,r_2)+\text{val}(l_2,r_1)\)

(跟 Monge Condition 的樣子長好像)

會有一個性質

令 \(\text{best}(l,r)\) 為 \(dp_{l,r}\) 最好的轉移點

也就是一個 \(k\) 使得從 \(\text{best}(l,r)=k\) 轉移會使 \(dp_{l,r}\) 有最小值

則 \(\text{best}(l,r-1)\leq\text{best}(l,r)\leq\text{best}(l+1,r)\)

證明:

The proof is left as an exercise for the reader.

由於證明有好多數字,講師不會數學,有興趣的人可以去 CP-Algorithm 看

每次要算 \(dp_{l,r}\) 的時候

原本轉移範圍是 \([l,r]\)

現在只要從 \([\text{best}(l,r-1),\text{best}(l+1,r)]\)

複雜度原本是 \(O(n^3)\),現在變成 \(\dots\)

\(O(n^2)\)!

\(\text{best}(1,1)\leq\text{best}(2,2)\leq\text{best}(3,3)\leq\dots\)

\(\text{best}(1,2)\leq\text{best}(2,3)\leq\text{best}(3,4)\leq\dots\)

\(\text{best}(1,3)\leq\text{best}(2,4)\leq\text{best}(3,5)\leq\dots\)

每一層 \(O(n)\),總共 \(n\) 層

有一個大小為 \(n\) 的陣列 \(x\)

剛開始只有一個大陣列 \(x_1\sim x_n\)

每次要把大陣列分成左右兩個小陣列

花費是陣列數字的總和

問把陣列分 \(n\) 個大小為 \(1\) 陣列的總花費?

設 \(dp_{l,r}\) 是把 \(x_l\sim x_r\) 分完後的最小花費

\(pre_i=\sum\limits_{j=1}^i x_j\)

\(dp_{l,r}=\min\limits_{l\leq k<r}(dp_{l,k}+dp_{k+1,r})+\text{pre}_r-\text{pre}_{l-1}\)

區間包含單調:\(\text{val}(l_2,r_1)\leq\text{val}(l_1,r_2)\)

四邊形不等式:\(\text{val}(l_1,r_1)+\text{val}(l_2,r_2)\leq\text{val}(l_1,r_2)+\text{val}(l_2,r_1)\)

發現 \(\text{val}(l,r)=\text{pre}_r-\text{pre}_{l-1}\)

可以優化!

Code:

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

using ll = long long;

const int N = 5005;

int n;
int best[N][N];
ll pre[N], dp[N][N];

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> pre[i];
        pre[i] += pre[i - 1];
        best[i][i] = i;
    }
    for (int len = 2; len <= n; len++) {
        for (int l = 1, r = len; r <= n; l++, r++) {
            ll mn = LLONG_MAX;
            for (int k = best[l][r - 1]; k <= min(r - 1, best[l + 1][r]); k++) {
                if (dp[l][k] + dp[k + 1][r] < mn) {
                    mn = dp[l][k] + dp[k + 1][r];
                    best[l][r] = k;
                }
            }
            dp[l][r] = mn + pre[r] - pre[l - 1];
        }
    }
    cout << dp[1][n] << '\n';
}

練習題:

TIOJ 1639

CF 1101 F

題外話:

剛剛的例題有一個 \(O(n\log(n))\) 的算法

可以解 TIOJ 1714

據說是用 Garsia–Wachs 或是 Treap

轉移點單調

凸單調性

凸 Monge Condition

Slope trick

有一個長這樣的函數

由很多個直線組成

紅色點是斜率改變的地方

除了把一段一段的直線跟分界點存起來

有沒有其他存法啊

觀察每一條線的斜率

發現只要存最右邊那條線跟所有斜率改變 \(1\) 的點就好了!

以這個函數為例,假設最右邊的直線是 \(y=2x+5\)

那我們就只要存 \(y=2x+5\) 與 \(\{p_1,p_1,p_2,p_3,p_4\}\) 就好了

注意到如果在同一點斜率改變超過 \(1\),那要放入這個點很多次

性質:

可以把兩個這樣子的函數合起來

假設 \(h(x)=f(x)+g(x)\)

那 \(h\) 最右邊的直線會是 \(f\) 最右邊的 \(+\ g\) 最右邊的

\(h\) 斜率改變 \(1\) 的點會是 \(f\) 與 \(g\) 的聯集!

應用

有 \(n\) 個整數 \(a_1\sim a_n\),每次可以花費 \(1\) 讓 \(a_i\) 減 \(1\) 或加 \(1\)

問最少要花費多少讓 \(a\) 非嚴格遞增?

\(O(nC)\) 的做法:

令 \(dp_{i,x}\) 是把 \(a_i\) 變成 \(x\) 所需最少的花費

那 \(dp_{i,x}=\min\limits_{x'\leq x}(dp_{i-1,x'})+|x-a_i|\)

這樣不夠快

雖然可以注意到 \(a_i\) 一定會變成原陣列的某個數

可以優化到 \(O(n^2)\)

但終究不是我們要的複雜度

我們讓 \(dp\) 變成一個函數

\(dp_{i,x}\Rightarrow dp_i(x)\)

同時我們設一個叫 \(mn_i(x)\) 的函數

這個函數是對 \(dp_i(x)\) 取前綴 \(\min\)

也就是 \(mn_i(x)=\min\limits_{x'\leq x}(dp_i(x'))\)

轉移式從原本的 \(dp_{i,x}=\min\limits_{x'\leq x}(dp_{i-1,x'})+|x-a_i|\)

 變成 \(dp_i(x)=mn_{i-1}(x)+|x-a_i|\)

注意到 \(y=|x-a_i|\) 這個函數可以轉換成之前提到的形式

可以用數學歸納法證明 \(dp_i(x)\) 跟 \(mn_i(x)\)

也可以轉換成這種形式

\(mn_{i-1}(x)\) 會長這樣

最右邊的直線會是 \(y=K\) 的形式 (斜率 \(=0\))

\(dp_i(x)=mn_{i-1}(x)+|x-a_i|\) 會長這樣

最右邊的函數
會是 \(y=x-a_i+K\)

相當於在 \(mn_{i-1}(x)\) 裡加了 \(\{a_i,a_i\}\)

將 \(y=K\) 加上了 \(y=x-a_i\)

\(mn_i(x)\) 是對 \(dp_i(x)\) 取前綴 \(\min\)

\(dp_i(x)\)

其實就是把最右邊的紅點 \(R\) 移除

然後把最右邊那條線變成 \(y=R-a_i+K\)

\(mn_i(x)\)

最後想要求 \(dp_n(x)\) 的最小值

也就是 \(mn_n(x)\) 最右邊的地方

會發現答案是 \(y=K\) 的 \(K\)

實作上斜率改變點可以用 pq 維護!

Code:

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

using ll = long long;

const int N = 2e5 + 5;

int n;
ll ans;
int a[N];
priority_queue<int> pq;

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) {
        pq.push(a[i]);
        pq.push(a[i]);
        int R = pq.top();
        ans += R - a[i];
        pq.pop();
    }
    cout << ans << '\n';
}

以下是一些可以用 slope trick 或必須用 slope trick 的題目:

CF 713 C

ZJ h862

CF 866 D

ABC 250 G

ARC 123 D

CF 372 C

USACO 2016 Open Contest p3

CF 802 O

CF 1534 G

Aliens 優化

又叫做 wqs 二分搜或帶權二分

因為某一年 IOI 需要用到這個技巧而變有名

這是一個凹函數

\(dp(x)\)

這個函數的最大值可以用 \(dp\) 算

並且我們可以知道最大值的位置發生在哪裡

\((p_{mx},dp(p_{mx}))\)

\(dp(x)\)

如果把 \(dp(x)-\text{pen}\times x\) 會發生什麼事?

(pen = penalty = 罰款)

\((p_{mx},dp(p_{mx}))\)

\(-\text{pen}\times x\) 也是一個凹函數

兩個凹函數相加還是一個凹函數

\(dp(x)\)

\(-\text{pen}\times x\)

\(dp'(x)=dp(x)-\text{pen}\times x\)

\((p_{mx}',dp'(p_{mx}'))\)

最大值位置會改變!

得到 \(dp'(p_{mx}')\) 後,

可以加上 \(\text{pen}\times p_{mx}'\) 得到原本的 \(dp(p_{mx}')\)!

Aliens 優化的核心思想就是利用最大值可以快速求的特性,加或減 penalty 改變凹(凸)函數最大(小)值發生的位置,求出不容易求的值

可以二分搜 \(\text{pen}\) 來控制最大值發生的位置!

但有的時候 \(dp'(K-1)=dp'(K)=dp'(K+1)\)

沒辦法直接搜到 \(K\)

搜到的如果是 \(t\)

\(dp'(t)=dp(t)-\text{pen}\times t=dp(K)-\text{pen}\times K\)

\(\Rightarrow dp(K)=dp'(t)+\text{pen}\times K\)

所以不管搜到哪個點加上 \(\text{pen}\times K\) 就可以了

然後有的時候你很難證明題目的函數是凹(凸)函數

二分搜的時候要注意搜的方向

方向錯誤很可能就導致答案錯誤

有一種證法是把題目轉成 mincost flow 模型

流量每增加 \(1\),增加量會遞增

大部分的時候可以靠感覺 (〃∀〃)

有一個商品在 \(n\) 個時間點的價格是 \(a_1\sim a_n\)

當身上沒有商品時可以花 \(a_i\) 買

當身上有商品時可以以 \(a_i\) 賣掉

問最多買賣 \(k\) 次能賺到最多的錢

\(O(nk)\) 做法:

\(dp_{0,i,j},\ dp_{1,i,j}\) 代表第 \(i\) 個時間點,身上(沒)有東西,買賣 \(j\) 次最多能賺到的錢

\(dp_{0,i,j}=\max(dp_{0,i-1,j},dp_{1,i-1,j-1}+a_i)\)

\(dp_{1,i,j}=\max(dp_{1,i-1,j},dp_{0,i-1,j}-a_i)\)

觀察到它是凹函數

\(\Rightarrow\) Aliens 優化

用 pair 存 dp 值,存最大獲利與買賣次數

\(O(n\log(C))\)

Code:

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

using ll = long long;

const int N = 2e6 + 5;

int n, k;
int a[N];

pair<ll, int> better(pair<ll, int> a, pair<ll, int> b) {
    if (a.first != b.first) return a.first > b.first ? a : b;
    return a.second < b.second ? a : b;
}

pair<ll, int> cal(int pen) {
    pair<ll, int> dp0, dp1;
    dp0 = {0, 0};
    dp1 = {-1e18, 0};
    for (int i = 1; i <= n; i++) {
        pair<ll, int> tmp = dp0;
        dp0 = better(dp0, {dp1.first + a[i] - pen, dp1.second + 1});
        dp1 = better(dp1, {tmp.first - a[i], tmp.second});
    }
    return dp0;
}

int main() {
    ios::sync_with_stdio (false), cin.tie (0);
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];
    int l = 0, r = 1e9;
    while (l < r) {
        int mid = (l + r) / 2;
        if (cal(mid).second > k) l = mid + 1;
        else r = mid;
    }
    cout << cal(l).first + 1ll * l * k << '\n';
}

有 \(N\) 個關卡,與 \(N\) 個正整數 \(t_1\sim t_N\),要分成 \(K\) 個連續的區段,想要一次一個區段去做練習,假設現在的區段為 \([l,r]\),已經練好了 \([l,i)\),這次想練關卡 \(i\),那練到關卡 \(i\) 的機率是 \(\frac{t_i}{\sum_{j = l}^{\ i}\ t_j}\),否則會練到已經練過的關卡,想要以最好的分法使總共練習次數的期望值最小。

每個區段可以分開算,先來看一個區段期望要練幾次

假設現在區段的 \(t\) 值為 \(t_l\sim t_r\),那輪到 \(t_i\) 的時候,設 \(x = \frac{\sum_{j = l}^{\ i - 1}\ t_j}{\sum_{j = l}^{\ i}\ t_j}\),\(y = \frac{t_i}{\sum_{j = l}^{\ i}\ t_j}\),那練一次的機率為 \(y\),練兩次的機率為 \(xy\),練三次的機率為 \(x^2y\)。

依此類推,那麼期望值就會是

\(y + 2xy + 3x^2y +\dots= y(1 + 2x + 3x^2+\dots) = \frac{y}{(1-x)^2}\),又 \(1 - x = y\),所以最後是 \(\frac{1}{y}\)。

令 \(\text{pre}_i = \sum\limits_{j = 1}^{i} t_j\),\(\text{p1}_i = \sum\limits_{j = 1}^{i} \frac{1}{t_j}\),\(\text{p2}_i = \sum\limits_{j = 1}^{i} \frac{\text{pre}_{j - 1}}{t_j}\)

\(\frac{1}{y}\) 可以寫成 \(\frac{\text{pre}_{i - 1} - \text{pre}_{l-1} + t_i}{t_i} = 1 + \frac{\text{pre}_{i-1}}{t_i} - \text{pre}_{l-1}\times \frac{1}{t_i}\),
那把 \(l\sim r\) 的 \(\frac{1}{y}\) 都加起來後會得到

\((r - l + 1) + (\text{p2}_r - \text{p2}_{l - 1}) - \text{pre}_{l-1}\times (\text{p1}_r - \text{p1}_{l-1})\)

如果要 \(dp\) 可以假設上一個區段結束的點為 \(l\),現在在 \(r\),那 \(dp_r = dp_l + (r-l) + (\text{p2}_r-\text{p2}_l) - \text{pre}_l\times (\text{p1}_r-\text{p1}_l) \\ = \max(-\text{pre}_l\times \text{p1}_r + dp_l - l - \text{p2}_l + \text{pre}_l\times \text{p1}_l) + r + \text{p2}_r \\= \max (m_l\times x_r + k_l) + C_r\)

可以使用一個斜率遞減,查詢遞增的 \(O(N)\) 斜率優化

現在已經知道要怎麼 \(dp\) 了,只差怎麼分 \(K\) 段

如果你的數學夠強,或是你的信仰夠深

那你可以相信這是一個凸函數

於是可以 \(O(N\log(C))\) Aliens 優化套斜率優化!

數線上有 \(n\) 個座標,要在其中 \(k\) 個設郵局,求每個座標到自己最近之郵局座標距離和最小值。

發揮信仰之力

相信它是凸函數

假設我們現在的 \(\text{pen}\) 是固定的

要來推 \(dp\) 式

假設 \(dp_i\) 是從 \(dp_j\) 轉移過來的

在 \([j+1,i]\) 的座標都跑到同一家郵局

那在 \([j+1,i]\) 的中位數設郵局是最好的

\(dp_i=\min\limits_{0\leq j<i}(dp_j+\text{dis}(j+1,i)+\text{pen})\)

\(\text{dis}(j+1,i)\) 代表 \([j+1,i]\) 到中位數的總距離,可以用前綴和算

目前推出的 \(dp\) 式是 \(O(n^2)\) 的

繼續觀察會發現它有凸單調性

\(\Rightarrow\) 可以凸四邊形優化

做一次要 \(O(n\log(n))\)

總共 \(O(n\log(n)\log(C))\)

有 \(n\) 個寶可夢,有 \(a\) 顆紅球跟 \(b\) 顆藍球,丟紅球抓到第 \(i\) 隻寶可夢的機率是 \(p_i\),丟藍球抓到第 \(i\) 隻寶可夢的機率是 \(u_i\),同一種球對一隻寶可夢最多只能丟一顆,也可以同時丟紅球跟藍球,這樣抓到的機率是 \(1-(1-p_i)(1-u_i)\)。

問抓到寶可夢數量最大的期望值。

\(dp_{i,x,y}\) 是到了第 \(i\) 隻寶可夢,共丟了 \(x\) 顆紅球、\(y\) 顆藍球能抓到的期望值

有四種情況:不丟、丟紅球、丟藍球、丟紅球跟藍球

網路上的解法幾乎都是說套兩層 Aliens

給紅球 \(\text{pen}_1\),給藍球 \(\text{pen}_2\)

複雜度 \(O(n\log^2(C))\)

但是好像這種寫法都有辦法 hack

在這篇 blog 的做法是三分搜

做 \(k\) 維的 Aliens 的複雜度是 \(O(n2^k\log^k(C))\)

類似題:ZJ f998

謝謝大家

DP 優化

By becaido