1. 程式人生 > >動態 DP 學習筆記

動態 DP 學習筆記

ont class 編號 例題 for clas 理解 ble end

動態 DP 學習筆記

\(\texttt{by Tweetuzki}\)

不得不承認,去年提高組 D2T3 對動態 DP 起到了良好的普及效果。

動態 DP 主要用於解決一類問題。這類問題一般原本都是較為簡單的樹上 DP 問題,但是被套上了喪心病狂的修改點權的操作。舉個例子,我們來看一道例題。

【模板】動態 DP

給定一棵 \(n\) 個點的樹。\(i\) 號點的點權為 \(a_i\)。有 \(m\) 次操作,每次操作給定 \(u, w\),表示修改點 \(u\) 的權值為 \(w\)。你需要在每次操作之後求出這棵樹的最大權獨立集的權值大小。

我們首先考慮沒有修改的情況下怎麽做。首先先選取 \(1\)

號點作為全樹的根。然後我們設 \(f_{i, 0}\) 表示不選擇 \(i\) 號點時,以 \(i\) 號點為根的子樹的最大權獨立集;\(f_{i, 1}\) 表示選擇 \(i\) 號點時,以 \(i\) 號點為根的子樹的最大權獨立集。我們可以很容易地寫出如下的方程:

\[ f_{i, 0} = \sum_{j} \max(f_{j, 0}, f_{j, 1}) \f_{i, 1} = \sum_{j} f_{j, 0} + a_i \]

這裏 \(j\) 表示 \(i\) 號點的所有兒子。特殊地,若點 \(i\) 為葉子節點,\(f_{i, 0} = 0, f_{i, 1} = a_i\)

最後的答案就是 \(\max(f_{1, 0}, f_{1, 1})\)


接下來帶上修改。

首先根據動態規劃的轉移方程可以發現,我們修改了一個點的點權,只會更改從這個點到根這條路徑上節點的 DP 值,其他值是不會發生更改的。這時候如果我們要對整棵樹重新求一遍最大權獨立集,未免太過浪費。所以我們希望能夠更改這條鏈上的 DP 值。

由於樹可能會退化成一條鏈,這樣每次更新就是 \(\mathcal{O(n)}\) 的,顯然不可接受。我們希望這條鏈只更新 \(\log n\) 次……

點分治!抱歉博主太弱了,不會那個被稱作“全局平衡二叉樹”的厲害做法。

這時候我們請出解決樹上問題的神器——重鏈剖分。

重鏈剖分有一些性質,這些性質正是它在動態 DP 中能夠發揮作用的重要保障。

  1. 每個點到根的路徑上,最多經過 \(\log n\) 條輕邊。也就是說,重鏈的條數最多也只有 \(\log n\) 條。這為動態 DP 的時間復雜度做了保障。
  2. 每條重鏈的鏈尾都是葉子節點,且只有葉子節點沒有重兒子。這為動態規劃的初始狀態和轉移方式做了保障。
  3. 重鏈剖分中,一條重鏈所在的區間在剖出的 DFS 序上,是連續的一段區間。這為可以使用數據結構維護區間信息,達到快速轉移做了保障。

那麽在宏觀上,我們相當於在更新時,對於這些重鏈暴力地互相轉移更新。接下來我們考慮一些微觀問題:在一條鏈裏,怎麽支持快速修改和查詢這條鏈的 DP 值。

我們保持 \(f\) 數組的定義不變。為了迎合重鏈剖分劃分出了輕重兒子,我們形式化地定義 \(g\) 數組:\(g_{i, 1}\) 表示 \(i\) 號點的所有輕兒子,都不取的最大權獨立集;\(g_{i, 0}\) 表示 \(i\) 號點的所有輕兒子,可取可不取形成的最大權獨立集。這樣就可以把上述的 DP 式子大大簡化了(至少沒有了那個 \(\Sigma\))。

\[ f_{i, 0} = g_{i, 0} + \max(f_{j, 0}, f_{j, 1}) \f_{i, 1} = g_{i, 1} + a_i + f_{j, 0} \]

這裏的 \(j\) 表示 \(i\) 號點的重兒子。特殊地,對於葉子節點,\(g_{i, 0} = g_{i, 1} = 0\)

但是感覺這玩意兒好像不大優美?第二個轉移式子中,\(g_{i, 1}\)\(a_i\) 都只和 \(i\) 有關,那麽我們不妨把它們合並起來。我們重新定義 \(g_{i, 1}\):表示 \(i\) 號點只考慮輕兒子的取自己的最大權獨立集。那麽這時候,第二個方程就可以變為 \(f_{i, 1} = g_{i, 1} + f_{j, 0}\)

但是這玩意兒咋區間維護嘞?回想一下當初學習斐波那契的時候,我們碰到過這樣的 DP 方程:

\[f_i = f_{i - 1} + f_{i - 2}\]

這個方程涉及上一步的貢獻,沒法滿足結合率,不太舒服。於是我們定義了一個矩陣,化加為乘,於是我們愉快地用快速冪 AC 了。

這道題我們也給它套個矩陣。對於每個點,都表示一個狀態,這個狀態共有兩個值,於是我們考慮維護一個 \(1 \times 2\) 的矩陣。

\[ \begin{vmatrix} f_{i, 0} & f_{i, 1} \end{vmatrix} \]

現在我們要從一個點的重兒子 \(j\) 轉移到 \(i\) 上,也就是說我們需要構造出一個轉移矩陣使得 \(\begin{vmatrix} f_{j, 0} & f_{j, 1} \end{vmatrix}\) 能夠轉移到 \(\begin{vmatrix} f_{i, 0} & f_{i, 1} \end{vmatrix}\)。但是我們回顧一下這個轉移方程(已更改 \(g_{i, 1}\) 的定義):

\[ f_{i, 0} = g_{i, 0} + \max(f_{j, 0}, f_{j, 1}) \f_{i, 1} = g_{i, 1} + f_{j, 0} \]

它一點也不滿足矩陣乘法的形式啊!

別慌……我們大膽地重定義矩陣乘法!

我們定義一個新的運算符 \(*\),對於矩陣 \(\mathrm{A}, \mathrm{B}\),定義 \(\mathrm{A} * \mathrm{B}\) 的結果 \(\mathrm{C}\),滿足:

\[ \mathrm{C}_{i, j} = \max_{k}(\mathrm{A}_{i, k} + \mathrm{B}_{k, j}) \]

實現到代碼上,就是

struct Matrix {
  int mat[MaxN][MaxN];
}

inline Matrix operator * (Matrix a, Matrix b) {
  Matrix c;

  for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
      for (int k = 0; k < n; ++k)
        c.mat[i][j] = max(c.mat[i][j], a.mat[i][k] + b.mat[k][j]);

  return c;
}

但是這個東西為什麽具有結合率呢?

  • 一種感性的理解:由於 \(\max\) 操作和加法操作都是滿足結合率的,所以這個運算滿足結合率。
  • 一種理性但不太嚴謹的證明:讀者不妨拿出之筆,計算幾組 \((\mathrm{A} * \mathrm{B}) * \mathrm{C}\)\(\mathrm{A} * (\mathrm{B} * \mathrm{C})\) 的值(如果您計算比較厲害,帶上參數算當然更好)。一般情況下,證明了三個滿足條件,對於所有情況都是能滿足條件的。

於是我們口胡完了結合率的證明。那麽我們就可以用了。接下來我們要構造一個轉移矩陣,這個是相對難的一個內容。我就介紹一下我個人構造轉移矩陣的拙劣方法吧。

在構造一個轉移矩陣之前,我們先想辦法把這玩意兒變形,變得和運算 \(*\) 差不多。

\[ f_{i, 0} = \max(f_{j, 0} + g_{i, 0}, f_{j, 1} + g_{i, 0}) \f_{i, 1} = \max(g_{i, 1} + f_{j, 0}, -\infty) \]

接著我們把已知的狀態和要轉移到的狀態寫在一起,把未知的轉移矩陣用 \(\mathrm{U}\) 表示。

\[ \begin{vmatrix} f_{j, 0} & f_{j, 1} \end{vmatrix} * \mathrm{U} = \begin{vmatrix} f_{i, 0} & f_{i, 1} \end{vmatrix} \]

我們原來是一個 \(1 \times 2\) 的矩陣,要形成一個 \(1 \times 2\) 的矩陣,那麽 \(\mathrm{U}\) 應當是一個 \(2 \times 2\) 的矩陣。那麽我們設矩陣左上、右上、左下、右下四個位置分別為 \(u_1, u_2, u_3, u_4\)。接下來把每個位置對應上去。

\(f_{i, 0}\) 的值應該為 \(\max(f_{j, 0} + u_1, f_{j, 1} + u_3)\)。對應轉移方程,我們發現 \(u_1\) 應該就是 \(g_{i, 0}\)\(u_3\) 也是 \(g_{i, 0}\)。同樣的,\(f_{i, 1}\) 的值應該為 \(\max(f_{j, 0} + u_2, f_{j, 1} + u_4)\)。對應轉移方程,我們發現 \(u_2\) 應該是 \(g_{i, 1}\),而不存在 \(f_{j, 1}\) 項,就將 \(u_4\) 賦為 \(-\infty\)。最後寫出來,檢查一遍:

\[ \begin{vmatrix} f_{j, 0} & f_{j, 1} \end{vmatrix} * \begin{vmatrix} g_{i, 0} & g_{i, 1} \\ g_{i, 0} & -\infty \end{vmatrix} = \begin{vmatrix} f_{i, 0} & f_{i, 1} \end{vmatrix} \]

嗯……好像沒問題?

這樣子,我們對於一條重鏈,我們的葉子節點就存儲了最初始的值,鏈上每個節點都對應著一個轉移矩陣。我們發現這個轉移矩陣和重鏈信息是沒有任何關系的,且因為這個矩陣滿足結合率,對於一條重鏈,我們可以之間線段樹維護區間乘積(或者叫……“\(*\) 積”?)。然後到了一條重鏈鏈頭,因為這個點是它父親的輕兒子,我們需要更新它父親節點所在的點的轉移矩陣。這樣子一直跳到根節點就可以了。貌似……大功告成?

重鏈剖分剖出的 DFS 序,由於先訪問了鏈頭,所以這個區間中,鏈頭在區間左端,鏈尾在區間右端。我們存儲的初始信息在葉子節點(也就是鏈尾)上,因此我們的矩陣 \(*\) 法應當是轉移矩陣在前,要維護的值矩陣在後。我們要把這個矩陣前後換個順序,再轉個個兒,加上一些推算,可以變形成:

\[ \begin{vmatrix} g_{i, 0} & g_{i, 0} \\ g_{i, 1} & -\infty \end{vmatrix} * \begin{vmatrix} f_{j, 0} \\ f_{j, 1} \end{vmatrix} = \begin{vmatrix} f_{i, 0} \\ f_{i, 1} \end{vmatrix} \]

這樣就真的做完了。最後我寫一些關於代碼實現的小細節:

  1. 對於一個點查其 dp 值,需要從這個點一直查到區間鏈尾。因此,樹剖時我們需要多維護一個 \(\texttt{End[i]}\)(這裏的 \(i\) 是一條重鏈的鏈頭),表示以 \(i\) 為鏈頭的這條鏈,鏈尾(葉子)節點在 DFS 序上的位置。
  2. 更新線段樹上某個點的轉移矩陣時,傳入的如果是矩陣,遞歸下去常數太大。一個解決方法是,在線段樹外,維護一個矩陣組 \(\texttt{Val[i]}\),表示每個節點對應的轉移矩陣。這樣在線段樹更新找到對應位置時,直接賦值進來即可。

最後貼上代碼。

解釋一下變量名:

\(\texttt{Id[i]}\) 表示 \(i\) 號點在 DFS 序中的位置,\(\texttt{Dfn[i]}\) 表示在 DFS 序中下標 \(i\) 的位置對應的是什麽點(與 \(\texttt{Id[i]}\) 相反),\(\texttt{Fa[i]}\) 是父親節點,\(\texttt{Siz[i]}\) 是子樹大小,\(\texttt{Dep[i]}\) 是該節點深度(好像沒什麽用),\(\texttt{Wson[i]}\)\(i\) 號節點的重兒子,\(\texttt{Top[i]}\) 表示 \(i\) 號點所在重鏈鏈頂編號。

#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;

const int MaxN = 100000 + 5, MaxM = 200000 + 5;
const int MaxV = 400000 + 5;
const int INF = 0x7F7F7F7F;

struct Matrix {
  int mat[2][2];

  Matrix() {
    memset(mat, -0x3F, sizeof mat);
  }

  inline Matrix operator * (Matrix b) {
    Matrix c;

    for (int i = 0; i < 2; ++i)
      for (int j = 0; j < 2; ++j)
        for (int k = 0; k < 2; ++k)
          c.mat[i][j] = max(c.mat[i][j], mat[i][k] + b.mat[k][j]);

    return c;
  }
};

int N, M; int cntv, cnte;
int A[MaxN];
int Fa[MaxN], Siz[MaxN], Dep[MaxN], Wson[MaxN];
int Top[MaxN], Id[MaxN], Dfn[MaxN], End[MaxN];
int F[MaxN][2];
int Head[MaxN], To[MaxM], Next[MaxM];
Matrix Val[MaxN];

struct SegTree {
  int L[MaxV], R[MaxV];
  Matrix M[MaxV];

  inline void Push_up(int i) {
    M[i] = M[i << 1] * M[i << 1 | 1];
  }

  void Build_Tree(int left, int right, int i) {
    L[i] = left, R[i] = right;
    if (L[i] == R[i]) {
      M[i] = Val[Dfn[L[i]]];
      return;
    }

    int mid = (L[i] + R[i]) >> 1;
    Build_Tree(L[i], mid, i << 1);
    Build_Tree(mid + 1, R[i], i << 1 | 1);
    Push_up(i);
  }

  void Update_Tree(int x, int i) {
    if (L[i] == R[i]) {
      // 直接賦值,減小常數
      M[i] = Val[Dfn[x]];
      return;
    }

    int mid = (L[i] + R[i]) >> 1;
    if (x <= mid) Update_Tree(x, i << 1);
    else Update_Tree(x, i << 1 | 1);
    Push_up(i);
  }

  // 查詢一個點的 DP 值,相當於查詢這條重鏈上鏈尾矩陣和鏈中轉移矩陣的 ‘*‘ 積
  Matrix Query_Tree(int left, int right, int i) {
    if (L[i] == left && R[i] == right) return M[i];

    int mid = (L[i] + R[i]) >> 1;
    if (right <= mid)
      return Query_Tree(left, right, i << 1);
    else if (left > mid)
      return Query_Tree(left, right, i << 1 | 1);
    else
      return Query_Tree(left, mid, i << 1) * Query_Tree(mid + 1, right, i << 1 | 1);
  }
} T;

inline void add_edge(int from, int to) {
  cnte++; To[cnte] = to;
  Next[cnte] = Head[from]; Head[from] = cnte;
}

void readin() {
  scanf("%d %d", &N, &M);
  for (int i = 1; i <= N; ++i)
    scanf("%d", &A[i]);
  for (int i = 1; i < N; ++i) {
    int u, v;
    scanf("%d %d", &u, &v);
    add_edge(u, v); add_edge(v, u);
  }
}

void dfs1(int u) {
  Siz[u] = 1;

  for (int i = Head[u]; i; i = Next[i]) {
    int v = To[i];
    if (v == Fa[u]) continue;

    Fa[v] = u; Dep[v] = Dep[u] + 1;
    dfs1(v);

    Siz[u] += Siz[v];
    if (Siz[v] > Siz[Wson[u]]) Wson[u] = v;
  }
}

void dfs2(int u, int chain) {
  cntv++;
  Id[u] = cntv; Dfn[cntv] = u;
  Top[u] = chain;
  End[chain] = max(End[chain], cntv);

  // 第二次樹剖時直接更新 F, G 數組(這裏直接將 G 放入矩陣更新)
  F[u][0] = 0, F[u][1] = A[u];
  Val[u].mat[0][0] = Val[u].mat[0][1] = 0;
  Val[u].mat[1][0] = A[u];
  if (Wson[u] != 0) {
    dfs2(Wson[u], chain);
    // 依照定義,重兒子不應計入 G 數組
    F[u][0] += max(F[Wson[u]][0], F[Wson[u]][1]);
    F[u][1] += F[Wson[u]][0];
  }

  for (int i = Head[u]; i; i = Next[i]) {
    int v = To[i];
    if (v == Fa[u] || v == Wson[u]) continue;
    dfs2(v, v);

    F[u][0] += max(F[v][0], F[v][1]);
    F[u][1] += F[v][0];
    Val[u].mat[0][0] += max(F[v][0], F[v][1]);
    Val[u].mat[0][1] = Val[u].mat[0][0];
    Val[u].mat[1][0] += F[v][0];
  }
}

void init() {
  readin();
  dfs1(1); dfs2(1, 1);
}

void update_path(int u, int w) {
  Val[u].mat[1][0] += w - A[u];
  A[u] = w;

  Matrix bef, aft;
  while (u != 0) {
    // 計算貢獻時,應當用一個 bef 矩陣還原出少掉這個輕兒子的情況,再將 aft 加入更新
    bef = T.Query_Tree(Id[Top[u]], End[Top[u]], 1);
    T.Update_Tree(Id[u], 1);
    aft = T.Query_Tree(Id[Top[u]], End[Top[u]], 1);
    u = Fa[Top[u]];

    Val[u].mat[0][0] += max(aft.mat[0][0], aft.mat[1][0]) - max(bef.mat[0][0], bef.mat[1][0]);
    Val[u].mat[0][1] = Val[u].mat[0][0];
    Val[u].mat[1][0] += aft.mat[0][0] - bef.mat[0][0];
  }
}

void solve() {
  T.Build_Tree(1, N, 1);

  for (int i = 1; i <= M; ++i) {
    int u, w;
    scanf("%d %d", &u, &w);
    update_path(u, w);
    Matrix Ans = T.Query_Tree(Id[1], End[1], 1);
    printf("%d\n", max(Ans.mat[0][0], Ans.mat[1][0]));
  }
}

int main() {
  init();
  solve();
  return 0;
}

動態 DP 學習筆記