Introduction
This article introduces the idea of DP on DP and demonstrates how it applies to concrete problems through two examples.
Idea
The term “DP on DP” refers to abstracting the process of solving a subproblem (usually another DP) as an automaton (DFA) during dynamic programming, and then designing a new layer of DP on top of that automaton.
This technique is mainly used for sequence counting, probability, and expectation problems. A typical problem has the following structure:
- Given an alphabet and a set of “valid sequences” of length , denoted by , over it. Depending on the alphabet, the sequences may be binary strings, digit strings, state sequences, and so on.
- For each particular sequence , dynamic programming can determine whether it is valid (that is, ), calculate its weight, or find a related value.
- Finally, we want to count all sequences in , their total weight, their expected value, and so on.
At this point, enumerating all sequences is infeasible. We therefore abstract the process of “determining whether a sequence is valid” (the inner DP) as a deterministic finite automaton (DFA). In general, for a fixed sequence , the state function of the inner DP can be written as : the value of some quantity after processing the prefix of of length , with the other state components equal to . The corresponding state transition equation of the inner DP is
In other words, the function is uniquely determined by the previous function and the current character . If we regard the function as a state of an automaton, the state transition equation of the inner DP gives a transition of that automaton. Thus, the automaton corresponding to the inner DP has the following structure:
- The state set consists of every possible and , together with their corresponding functions .
- The transition function is in the state transition equation of the inner DP.
- The initial state is usually obvious: it is the initial state of the inner DP.
- The accepting state set corresponds to all valid sequences .
The function itself may be quite complex. Therefore, when solving a concrete problem, it is usually necessary to compress the state space using state compression or DFA minimization. This is also the main reason why DP on DP can significantly reduce the time and space complexity compared with brute-force DP.
After abstracting the inner DP as a DFA, we can design a new DP on this DFA to solve the original problem; this is the outer DP. For ease of exposition, consider a simple counting problem. Define the state function of the outer DP as , the number of prefixes of length that reach state in the DFA. Its state transition equation is
The initial state is of course , and the final answer can usually be obtained by a simple calculation from . The outer DP is actually a special case of DP on a DAG.
Examples
The following two examples explain the general approach of DP on DP in detail.
Example 1
Hero meet devil
Given a string over the alphabet ACGT, denoted by , with . For every , determine how many strings of length over the alphabet ACGT, denoted by , have a longest common subsequence with of length .
Solution
The first DP that comes to mind is to let be the number of length- strings whose longest common subsequence with has length . However, this state cannot be transitioned: the main problem is that we do not know which characters constitute this longest common subsequence.
Consider the naive process for finding the longest common subsequence. Let be the length of the LCS of 's first characters and 's first characters. Then
For a fixed , recording each value of is enough to maintain the current state of and the first characters of —specifically, the first characters. Since has length only , this idea is feasible.
We therefore redefine the state as the number of length- strings whose DP array with (namely ) is in state . This DP seems to have many states, but , so we can maintain the difference array of ; the number of states is .
Now consider how to transition. If we know the array and also know , we can use the naive LCS transition (the DP equation above) to obtain . Thus, the naive LCS becomes the inner DP that helps transition .
We enumerate , take a state and calculate its transitioned state , then update with to complete the outer-DP transition. Finally, let be the answer for LCS length . For each state , increase by .
Reference Code
#include <cstring>
#include <iostream>
#include <string>
using namespace std;
const int MOD = 1e9 + 7;
const int MAXN = 100;
int T, a[MAXN + 1], h[MAXN + 1];
int g[MAXN + 1], m, n;
int nxt[1 << 16]
[4]; // State transition table: nx[mask][ch] is the new state after appending ch to state mask
int f[1001]
[1 << 16]; // DP array: f[i][mask] is the number of ways of length i in state mask
int ans[MAXN +
1]; // Final answer: ans[i] is the number of length-m strings with i matching positions
void add(int &x, int y) { x = (x + y) % MOD; }
int calc(int mask, int x) { // Given the current mask state and character
// x (0~3), return the new state
int res = 0;
for (int i = 0; i < n; ++i) g[i + 1] = g[i] + ((mask >> i) & 1);
for (int i = 1; i <= n; ++i) {
if (a[i] == x)
h[i] = g[i - 1] + 1;
else
h[i] = max(h[i - 1], g[i]);
}
for (int i = 1; i <= n; ++i)
if (h[i] > h[i - 1]) res |= (1 << (i - 1));
return res;
}
int popcount(int x) { // Count the number of 1s in mask (that is, the number of matched positions)
int res = 0;
while (x) {
res += x & 1;
x >>= 1;
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> T;
while (T--) {
string S;
cin >> S >> m;
n = S.length();
memset(f, 0, sizeof(f));
memset(ans, 0, sizeof(ans));
memset(a, 0, sizeof(a));
for (int i = 0; i < n; ++i) {
if (S[i] == 'A') a[i + 1] = 0;
if (S[i] == 'C') a[i + 1] = 1;
if (S[i] == 'G') a[i + 1] = 2;
if (S[i] == 'T') a[i + 1] = 3;
}
int lim = (1 << n);
for (int i = 0; i < lim; ++i)
for (int j = 0; j < 4; ++j) nxt[i][j] = calc(i, j);
f[0][0] = 1;
for (int i = 0; i < m; ++i)
for (int j = 0; j < lim; ++j)
if (f[i][j])
for (int k = 0; k < 4; ++k) add(f[i + 1][nxt[j][k]], f[i][j]);
for (int i = 0; i < lim; ++i) add(ans[popcount(i)], f[m][i]);
for (int i = 0; i <= n; ++i) cout << ans[i] << '\n';
}
return 0;
}
Example 2
[ZJOI2019] Mahjong
Suppose there are tile ranks in Mahjong, with tiles of each rank. A meld is either three consecutive tiles of ranks (a sequence) or three tiles of the same rank (a triplet), and a pair is two tiles of the same rank . A Mahjong hand is winning if and only if, viewed as a multiset, it can be divided into four melds and one pair, or into seven distinct pairs. Given Mahjong tiles, find the expected number of additional draws needed for the hand to contain a winning subsequence.
Solution
First, for a hand, we only need to consider the count of each tile rank, not their order. Thus, every prefix of any tile sequence can be converted into a length- sequence whose entries are in . Initially, if the count of rank is , the -th number in the converted sequence is restricted to an integer in . Note that the converted sequence does not consider the order of draws, whereas the Mahjong sequence in the problem does.
Let be the minimum number of draws needed to win. Directly calculating the expectation is difficult, so consider the following transformation. Let be the number of sequences that are not winning after tiles have been drawn. In the corresponding Mahjong sequence, these tiles must appear before the remaining tiles, but the order among those tiles and the remaining tiles is arbitrary. Therefore, the number of Mahjong sequences in which drawing only the first tiles does not produce a win is
The total number of Mahjong sequences is , so the probability that drawing the first tiles does not produce a win is
Using the tail-sum formula, we obtain the desired expectation:
The problem has now been reduced to computing . We solve it using DP on DP.
First consider the inner DP, which uses dynamic programming to determine whether a converted sequence represents a winning hand. The seven-pairs case is relatively easy, so we focus on the first winning form. Let denote the maximum number of melds after processing the first ranks, with groups of and tiles of rank remaining, and with a pair present/absent (that is, ). If we run the DP on a sequence and the final contains a number greater than or equal to , then the sequence is winning.
The state transition of this DP is fairly complicated. We discuss it in two steps. First, consider the transition of . This means that after adding tiles of rank to the current hand, without forming a new pair, we determine how the number of melds changes. If, after adding tiles of rank , we want to obtain sequences, groups of , and standalone tiles of rank , then we should transition from (this choice minimizes waste as much as possible), and use the remaining tiles to form as many triplets as possible. Enumerating all possibilities gives the following transition equation:
Second, consider the case where a pair must be formed. When adding tiles of rank , there are three transitions:
- Transition with added tiles to .
- Transition with added tiles to .
- If , transition with added tiles to .
These are all the transitions from after adding tiles, yielding .
Once the state transitions of the inner DP are solved, we can construct the winning-hand automaton. The automaton transitions are exactly the transitions of the inner DP above; we must also decide how to represent each automaton state. Each state corresponds to one possible value of . Its three dimensions are . The dimensions indexed by and retain groups and tiles, respectively, for forming sequences later. Since three identical sequences can always be rearranged into three triplets, we only need to consider the need to form at most identical sequences, so each tile type needs to retain at most : . Thus, can be represented by a array. In addition, to maintain the seven-pairs winning form, each state needs a counter representing the maximum number of pairs that can currently be formed.
Each element of the array may take a value in . However, since a meld count of at least is already winning, every element can be capped at . Since adding any tile to a winning sequence still yields a winning sequence, DFA minimization lets us compress all winning states into a single state. Therefore, for non-winning states, each position only needs to consider . In the implementation, is represented by .
Even so, there are still many possible states: . It is not realistic to enumerate them all. In fact, most of these possibilities never occur in a winning-hand automaton. To avoid considering states that do not actually exist, use the idea of BFS: start from the initial state and expand states one step at a time, stopping when a winning state is reached. The resulting automaton has states.
Finally, consider how to run a DP on the winning-hand automaton (the outer DP). Let be the number of sequences that have processed the first ranks, have drawn tiles in total, and have reached state of the winning-hand automaton. During the transition, enumerate the number of drawn tiles , where is the number of the initial tiles that are of rank . Multiply the previous sequence count by the number of ways to choose from the remaining tiles a total of tiles, namely , and accumulate. Formally,
Here, is the state obtained by adding tiles to automaton state , specifically tiles. After the outer DP ends, we can calculate the number of sequences that have drawn tiles and are still not winning:
Substituting this into the expression above gives the desired expectation.
Reference Code
#include <algorithm>
#include <cstdio>
#include <iostream>
#include <map>
#include <unordered_map>
using namespace std;
const int N = 100, M = 400, mod = 998244353;
int n, m, a[N + 5], fact[M + 5], inv[M + 5];
inline int C(int x, int y) {
return 1LL * fact[x] * inv[y] % mod * inv[x - y] % mod;
}
class HuAutomation { // Winning-hand automaton
private:
class Mat {
private:
int f[3][3];
public:
Mat() {
for (int i = 0; i <= 2; i++)
for (int j = 0; j <= 2; j++) f[i][j] = -1;
}
int* operator[](const int& x) { return f[x]; }
inline bool operator==(Mat x) const {
for (int i = 0; i <= 2; i++)
for (int j = 0; j <= 2; j++)
if (f[i][j] != x[i][j]) return 0;
return 1;
}
inline bool operator<(Mat x) const {
for (int i = 0; i <= 2; i++)
for (int j = 0; j <= 2; j++)
if (f[i][j] != x[i][j]) return f[i][j] < x[i][j];
return 0;
}
inline bool Check() {
for (int i = 0; i <= 2; i++)
for (int j = 0; j <= 2; j++)
if (f[i][j] > 3) return 1;
return 0;
}
inline void Upd(Mat x, int t) {
// Update the current matrix with the values of existing matrix x, simulating the state after adding t current tiles
for (int i = 0; i <= 2; i++)
for (int j = 0; j <= 2; j++)
if (x[i][j] != -1)
for (int k = 0; k < 3 && i + j + k <= t; k++)
f[j][k] = max(f[j][k], min(i + x[i][j] + (t - i - j - k) / 3, 4));
// i,j,k,(t-i-j-k)
// Enumerate the numbers of tiles used to form melds, retained as (i-1,i), retained as i, and used to form melds directly, respectively
// Limit the update to at most 4 pairs
}
};
struct node {
int t, state[5];
Mat F[2];
node() {
for (int i = 0; i <= 4; i++) state[i] = 0;
t = 0;
for (int i = 0; i <= 1; i++) F[i] = Mat();
}
inline bool Check() { return t == -1 || t >= 7 || F[1].Check(); }
node Hu() {
node x;
x.t = -1;
return x;
}
bool operator<(const node& x) const {
if (t == x.t) {
if (F[0] == x.F[0]) return F[1] < x.F[1];
return F[0] < x.F[0];
}
return t < x.t;
}
node operator+(int x) { // Add x new tiles
if (Check()) return Hu(); // Return immediately if already winning
node res;
res.F[0].Upd(F[0], x), res.F[1].Upd(F[1], x); // Update
res.t = t;
if (x > 1) res.F[1].Upd(F[0], x - 2), ++res.t;
if (res.Check()) res = Hu(); // Check whether the hand is winning
return res;
}
} A[2100];
map<node, int> mp;
inline int Get(node x) {
if (mp.count(x)) return mp[x];
mp[x] = ++tot;
A[tot] = x;
return tot;
}
inline void Expand(int x) {
// Build the state transition graph:
// For the current state A[x], try adding 0~4 tiles of the same rank to obtain 5 new states
for (int i = 0; i <= 4; i++) A[x].state[i] = Get(A[x] + i);
}
inline node Hu() {
node x;
x.t = -1;
return x;
}
inline node Emp() {
node x;
x.F[0][0][0] = 0;
return x;
}
public:
int tot, f[N + 5][M + 5][2100];
inline void Build() {
A[1] = Emp(), A[2] = Hu(); // State 1 is the initial valid state, and state 2 is the winning state
mp[A[1]] = 1, mp[A[2]] = 2;
tot = 2;
Expand(1);
for (int i = 3; i <= tot; i++)
Expand(i); // Enumerate all reachable states and build the state graph (since 2
// is the winning state, it need not be expanded)
}
void DP() {
f[0][0][1] = 1;
for (int i = 1; i <= n; i++)
for (int j = m; j >= 0; j--)
for (int k = 1; k <= tot; k++)
if (f[i - 1][j][k])
for (int t = 0; t <= 4 - a[i]; t++)
// Enumerate how many tiles can be added (at most 4 in total, with a[i] already present) and perform the combinatorial transition
(f[i][j + t][A[k].state[a[i] + t]] +=
1LL * f[i - 1][j][k] * C(4 - a[i], t) % mod) %= mod;
}
} Hfu;
inline long long qpow(long long x, int y) {
long long res = 1;
while (y) {
if (y & 1) res = res * x % mod;
x = x * x % mod;
y >>= 1;
}
return res;
}
void init(int n) {
fact[0] = 1;
for (int i = 1; i <= n; i++) fact[i] = 1LL * fact[i - 1] * i % mod;
inv[n] = qpow(fact[n], mod - 2);
for (int i = n - 1; i >= 0; i--) inv[i] = 1LL * inv[i + 1] * (i + 1) % mod;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int ans = 0;
Hfu.Build(); // Build the state graph
cin >> n;
for (int i = 1; i <= 13; i++) {
int x, y;
cin >> x;
cin >> y;
++a[x];
}
m = (n << 2) - 13;
init(m);
Hfu.DP();
for (int i = 1; i <= m; i++) {
(ans += 1LL * Hfu.f[n][i][1] * fact[i] % mod * fact[m - i] % mod) %= mod;
for (int j = 3; j <= Hfu.tot; j++) // Do not count node 2 because it is the winning node
(ans += 1LL * Hfu.f[n][i][j] * fact[i] % mod * fact[m - i] % mod) %= mod;
// f[i][j][k]: the number of ways after processing the first i ranks, drawing j tiles in total, and reaching k
// -th node; multiply each way by i! (permutations of these i tiles) × (m - i)!
// (permutations of the remaining tiles), then sum modulo mod
}
cout << (1LL * ans * inv[m] + 1) % mod;
// Multiplying the final answer by inv[m] divides by m!, removing the total number of permutations; the final +1 comes from the DP definition that after drawing i
// tiles the hand is still not winning
return 0;
}