# DP进阶训练:区间dp + 数位dp + 状压dp

DP进阶训练:区间dp + 数位dp + 状压dp

vj题单


A. Multiplication Puzzle (区间dp) POJ 1651

题意:

  • 首先这道题题意大概是:n个数字,每次你能拿走一个数字(除了两边的),贡献是这个数字和两边两个数字的成绩。最后题目要求你按任意顺序拿走n-2个数字,使得贡献和最小。

分析:

  • 顺序: 首先能想到是个dp题,因为在思考时候就是在考虑最优的处理顺序是什么。注意,这里提到了一个很关键的词:顺序,这表示一种策略,显然不能想到合适的贪心思路,那就往dp方向想。
  • 性质: 我在最初思考时候,一般按照yxc dalao的闫式dp分析法,首先先认真分析这道题中存在的性质。(个人的一点看法:每个题不同的性质是解这道题的关键,如果你感觉没有思路,可能是没有抓住或挖掘出关键性质)。本题我认为关键的性质是:三个相邻的数要一同考虑,贡献是a[i - 1] * a[i] * a[i + 1]。三个数考虑的结果是a[i]从数组中删除。一个数组不断删除,会从长数组变成短数组,即若短数组最优解计算出来后,可以反着求出长数组的最优解。这个性质就已经指向了区间dp,因为前面的性质是在区间间转移。
  • 表示: 通过前面分析,你得到了解题思路是dp, 关键性质是区间间的转移方式。接着就应该去思考如何通过dp数组来表示一个局面,或者说dp数组如何定义。误区,再以前做dp题时候我有个误区,就是没有特别看重题目性质分析这一步,这导致我经常在嗯想dp表示和转移。还有个误区就是我总是希望dp数组能表示一个局面,然后再不同的局面间进行dp转移。但事实上具体的局面很难表示这也是我做不出dp题的原因之一。如何去思考dp表示呢:一个很关键的词就是————抽象。试想一下动态规划问题就是在考虑一大堆子状态局面间的转移问题,但是状态表示和状态转移都十分不好想。但是我们有时候看题解会发现他们中状态转移非常丝滑,原因就是状态表示的好,使得两个状态间的转移非常清晰。所以要练习自己抽象的能力,因为好的状态表示它不是表示出一个具体的局面,比如本题中具体局面就是一种剩余数字的结果,如[10, 1, 50 ,5] 这种,我们会发现这怎么表示呀?? 所以好的表示是把这道题涉及的子状态局面划分成多个抽象的集合,每个集合是许多小局面的组合,而这些集合间能有清晰的转移。具体见下图。本题相对简单,因为区间dp一般就是dp[l][r],本题中表示l到r这个区间处理结束的最优解。

  • 转移: 有了好的表示转移是不难的,更多时候表示和转移是放在一块考虑的,单考虑一个可能会让另一个的工作变得难做。本题中是dp[l][r]表示l到r之间处理结束, 那子状态如何贡献给dp[l][r]呢?这里有个关键词:完全覆盖 意思就是在考虑状态转移时要把所有能更新当前状态的子局面都考虑到。所以本题考虑l,r之间的k节点是当前[l,r]数组最后一个更新的,dp[l][r] = min{dp[l][k] + dp[k][r] + a[l] * a[r] * a[k]} dp[l][k] + dp[k][r] 就表示l到k间数字和k到r间数字都删除完的最优结果。我再一开始考虑时犯了个错误,我想的是dp[l][r] = min{dp[l][k] + dp[k + 1][r] + 更新最优值} 相当于我认为先把[l, k] 区间删到最后两个值,再把[k+1,r]删到最后两个值。这里其实犯的错误就是没有完全覆盖,因为我这么想相当于我把k+1留在了最后再考虑是否删除,但是对于[l,r]数组k+1它不是边界数字, 你甚至可以上来就把k+1这个数字删除了, 但我这样想相当于给加了一条限制(就是k+1这个数字留到最后删除),使得原本更新[l,r]的子状态一部分状态因为不满足这个新加的限制,没有用于更新[l,r]状态,这就是没有完全覆盖。
  • 整理思路::dp[i][j]表示数组[i,j]之间删除到只剩两边数字时的最优解,转移方程dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + a[i] * a[k] * a[j]) 表示[i,k],[k,j]两个数组都删除到只剩i,k和k,j的最优解的和,然后在[i,k,j]三个数的数组中删除k进行合并,贡献为a[i] * a[k] * a[j]。 初始化:[i,j]长度小于3的初始化为0, 等于3的初始化为 a[i] * a[i + 1] * a[i + 2], 大于3的初始化为INF(因为后面转移时取min)。 结果: dp[0][n - 1]

代码

# 省略头文件
int main() {
    // freopen("../temp.in", "r", stdin);
    // freopen("../temp.out", "w", stdout);
    int n; scanf("%d",&n);
    vector<int> a(n, 0);
    for(int i = 0; i < n; ++ i) {
        cin >> a[i];
    }
    vector<vector<int> > dp(n, vector<int>(n, 0x3f3f3f3f));

    for(int len = 1; len <= n; ++ len) {
        for(int i = 0; i + len - 1 < n; ++ i) {
            int j = i + len - 1;
            if(len < 3) {
                dp[i][j] = 0;
                continue;
            }
            if(len == 3) {
                dp[i][j] = a[i] * a[i + 1] * a[j];
                continue;
            }
            for(int k = i + 1; k < j; ++ k) {
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + a[i] * a[k] * a[j]);
            }
        }
    }
    cout << dp[0][n - 1] << endl;
    return 0;
}

B. Cheapest Palindrome (区间dp) POJ 3280

题意:

  • 长度为m的字符串,你能进行操作:给n个字符,在任意位置添加字符或者删除任意字符。并给出了n个字符的添加和删除费用。问把s串变成回文串的最少花费。

分析:

  • 顺序: 有一种先把一部分变成回文,然后更新其他部分的感觉,还有贪心的感觉,但是贪心做不了,考虑dp

  • 性质: 我在分析性质时候还是太浮于表面,分析性质不能只是停留在归纳题目已知点上,要深入一些。比如本题在性质这里最关键的是:两种操作,变成回文串。

  • 表示: dp[l][r]表示[l,r]部分变成回文串

  • 转移: 在想转移的时候,不要想复杂了,更不要把所有子问题都考虑进去。你不是要解决dp[l][r]的问题,你只是进行一步转移,不要把问题深入进去去考虑子问题怎么做,那就弄复杂了。本题如果仅考虑转移,只需要比较s[i]和s[j]就行,因为子问题都已经处理完了,相当于已经是回文的了。你只需要考虑变化s[i]或者s[j]使得[i,j]变成回文就行。s[i]==s[j],dp[i][j] = dp[i + 1][j - 1]s[i] != s[j], dp[i][j] = min(dp[i + 1][j] + 处理s[i]最小花费, dp[i][j - 1] + 处理s[j]的最小花费)。这个处理最小花费可以预处理出来,cost[op] = min(x,y)。试想一下,如果[i,j]阶段就去考虑如何复杂的实现,那就没法做了。同时记住不要越级更新,比如[i+2,j]假如也可以通过更新使得[i,j]更新成为回文串,但是实际上在[i+1,j]中会把这个算进去,我们抽象地划分集合进行转移就是为了让集合间转移更清晰,如果去越级去考虑,虽然不影响完全覆盖,但是会让转移问题变得十分复杂,背离了我们抽象操作的初心。

  • 整理思路: 区间dp, dp[i][j] 表示[i,j]更新为回文串的最小花费。预处理出cost[op-'a'] = min(x,y)。转移方程:s[i]==s[j]: dp[i][j] = dp[i+1][j-1]s[i] !=s[j]: dp[i][j] = min(dp[i+1][j]+cost[s[i]-'a'], dp[i][j-1]+cost[s[j]-'a'])。结果dp[0][n]

代码:

// 省略头文件
int main() {
    // freopen("../temp.in", "r", stdin);
    // freopen("../temp.out", "w", stdout);
    
    int n, m; cin >> n >> m;
    string s; cin >> s;
    vector<int> cost(26, 0);
    for(int i = 0; i < n; ++ i) {
        char op; cin >> op;
        int x, y; cin >> x >> y;
        cost[op - 'a'] = min(x, y);
    }

    vector<vector<int> > dp(m, vector<int>(m, 0));
    for(int len = 2; len <= m; ++ len) {
        for(int i = 0; i + len - 1 < m; ++ i) {
            int j = i + len - 1;
            if(s[i] == s[j]) dp[i][j] = dp[i + 1][j - 1];
            else dp[i][j] = min(dp[i + 1][j] + cost[s[i] - 'a'], dp[i][j - 1] + cost[s[j] - 'a']);
        }
    }

    cout << dp[0][m - 1] << endl;
    return 0;
}

C. Brackets (区间dp) POJ 2955

题意:

  • 找s子序列的最长常规括号序列的长度

分析:

  • 这里区间dp转移写法不同, s[i] == s[j]时dp[i][j] = dp[i + 1][j - 1] + 2;
  • s[i] != s[j] 时,遍历断点k, dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
  • 重要:可以看到dp[i][k] 和dp[k+1][j], 还是dp[i][k],dp[k][j]的写法主要还是看dp[i][j]所表示的含义,如果dp[i][j]表示剩下(i,j)中的任意一个值那就是前者,如果dp[i][j]表示剩下(i,j)中的i和j那就用后者。

代码:

//省略头文件
int dp[105][105];

void solve(string s) {
    memset(dp, 0, sizeof(dp));
    int n = s.size();
    
    for(int len = 1; len <= n; ++ len) {
        for(int i = 0; i + len - 1 < n; ++ i) {
            int j = i + len - 1;
            if((s[i] == '(' && s[j] == ')') || (s[i] == '[' && s[j] == ']')) 
                dp[i][j] = dp[i + 1][j - 1] + 2;
            for(int k = i; k < j; ++ k) {
                dp[i][j] = max(dp[i][j], dp[i][k] + dp[k + 1][j]);
            }
        }
    }
    printf("%d\n",dp[0][n - 1]);
}

int main() {
    string s;
    while(cin >> s) {
        if(s == "end") break;
        solve(s);
    }
    return 0;
}

D. Dire Wolf (区间dp) HDU 5115

题意:

  • n个狼,每个狼有a,b两个属性,分别表示攻击力和攻击加成。每个狼的攻击力是它自身攻击力加上两边狼的加成攻击力组成。当杀掉一只狼后,你会收到它的攻击伤害,然后它会死掉从队列中去掉,意味着它两边的狼会相邻。问你要杀掉所有狼受到的最小伤害。

分析:

  • 因为还是策略顺序问题,所以考虑dp。这道题很明显的区间dp,但是没有想到要如何转移。
  • 参考了一下题解,发现a[i]无论如何都会被计算在答案中,所以可以先加上所有a[i], 然后dp过程只去考虑b[i]就行。
  • 下面这个操作要记熟: dp[i][j] 表示(i,j) 之间都被消灭掉只剩下i和j。 那么dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + b[i] + b[j])。 这样就解决了如何合并区间的问题。
  • 上面四道区间dp题,这道题和A题合并方式类似。

代码:

// 省略头文件和参数
ll a[300], b[300];
ll dp[300][300];

int main() {
    // freopen("../temp.in", "r", stdin);
    // freopen("../temp.out", "w", stdout);
    int T; scanf("%d",&T);
    int tcase = 0;
    while(T --) {
        ll n; scanf("%lld",&n);
        ll res = 0;
        for(int i = 1; i <= n; ++ i) {
            scanf("%lld",&a[i]);
            res += a[i];
        }
        for(int i = 1; i <= n; ++ i) {
            scanf("%lld",&b[i]);
        }
        n ++;
        b[n] = 0;
        memset(dp, 0x3f3f3f3f, sizeof(dp));
        for(int i = 0; i <= n; ++ i) {
            dp[i][i + 1] = 0; dp[i][i] = 0;
        }
        for(int len = 2; len <= n; ++ len) {
            for(int i = 0; i + len <= n; ++ i) {
                int j = i + len;
                for(int k = i; k <= j; ++ k) {
                    dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + b[i] + b[j]);
                }
            }
        }
        printf("Case #%d: %lld\n", ++tcase, res + dp[0][n]);
    }
    return 0;
}

E. XHXJ's LIS (数位dp + 状压 + LIS) HDU 4352

题意:

  • 输入L,R,K, 计算在L <= x <= R 范围内,满足LIS(x) == k的数量。
  • LIS(x) 表示x按数位考虑的最长上升子序列,如数字 14253 按数位考虑的最长上升子序列是 (1, 2, 3)(不唯一), 长度为3。

分析:

  • 首先分享一下 灵神的模板, 我的实现就是在模板上改的
  • 数位dp个人感觉,主要考虑如何在构造数中引入题目题目限制。就像本题如何对一个构造的数如何判断它LIS == k? 至于构造过程其他的部分都是套模板。
  • 这里我也不清楚怎么解释,我也学习别人做法解决的。通过对构造过程中数位使用情况进行状压,用10位来表示数位数字使用情况。如0100000000表示前面构造的数位填入了1。
  • 还是以14235为例(看不明白可以先看解释QwQ, 解释在下面):
    • 初始时,sta=0000000000
    • 引入1,sta=0100000000
    • 引入4,sta=0100100000
    • 引入2,sta=0110000000
    • 引入5,sta=0110010000
    • 引入3,sta=0111000000
  • 你会发现最后sta中1的个数就是这个数的LIS。可以把sta理解成一个有序桶,每次加入一个数我们就在桶中找到当前数合适的位置,你会发现当引入2时用2换掉了前面的4,引入3时用3换掉了前面的5。事实上我们当引入一个新数x,就在桶中删掉大于或等于x的第一位数,然后加入x,没有比x大的就直接加入x。这样的做法在LIS问题上肯定是思路不对的,但是在我们状压下,这样做最后sta中1的个数就是这个数LIS结果。
  • 分析下为什么:当加入x时,前面的比x大的数肯定都被x筛掉了,但是我们为了让sta中1个数等于LIS结果,我们不能把前面的1都删掉。这时候就可以考虑我们能把x代替前面哪一位呢?比如我们前面2代替了4,对2加入以前的结果仍然是2(1,2(4)),对2加入后的结果也能满足,即(1,2)。再举个例子1,3,5,7,4。在加入4前sta=01010101, 加入4后,用4替换比4大的第一个数5,那么sta=01011001。 你会发现更新后的这个sta即保留了前面的结果,又引入了最新的构造数位。
  • 【重要 -- 对解法的最新理解】:比如1,2,9999,这三位LIS是3,那我后面加入一个3的时候,我就用3替换9999,因为1,2,9999和1,2,3 LIS一样替换后不会影响最优解。而且3比较小,后面加入的数也更容易比3大,所以维护3记录比维护9999更有长远的价值。

代码:

// 省略头文件等信息
int m;
ll dp[25][1 << 10][11];
string s;
int k;
int sol[25];

//引入数位d时,更新sta (找一个>=d的位置更换,没有就直接加入)
int change_sta(int sta, int d) {
    for(int i = d; i < 10; ++ i) {
        if((sta >> i) & 1) {
            return (sta ^ (1 << i)) | (1 << d);
        }
    }
    return sta | (1 << d);
}

// 判断x中1的个数,用来计算sta中1的个数
int check(int x) {
    int ans = 0;
    while(x) {
        if(x & 1) ans ++;
        x >>= 1;
    }
    return ans;
}

// 基于数位dp模板,来做本题:
ll f(int i, int sta, bool is_limit, bool is_num) {
    if(i == -1) return is_num && check(sta) == k;
    if(!is_limit && is_num && dp[i][sta][k] != -1) return dp[i][sta][k];
    ll res = 0;
    if(!is_num) res = f(i - 1, sta, false, false);
    int up = is_limit ? sol[i] : 9;
    for(int d = 1 - is_num; d <= up; ++ d) {
        res += f(i - 1, change_sta(sta, d), is_limit && d == up, true);
    }
    if(!is_limit && is_num) dp[i][sta][k] = res;
    return res;
}

// 数位dp计算小于等于x的满足题目LIS==k限制的数字数量
ll solve(ll x) {
    int m = 0;
    while(x) {
        sol[m ++] = x % 10;
        x /= 10;
    }
    return f(m - 1, 0, true, false);
}

int main() {
    memset(dp, -1, sizeof(dp));
    int T; scanf("%d",&T);
    int num = 1;
    while(T --) {
        ll l, r; scanf("%lld%lld%d",&l,&r,&k);
        printf("Case #%d: %lld\n", num ++, solve(r) - solve(l - 1));
    }
    return 0;
}

F. 不要62 (数位dp) HDU 2089

题意:

  • 输入L,R,计算L <= x <= R中满足x中没有 4 和 62 的数字个数。

分析:

  • 还是去考虑怎么引入限制。这题不难就是pre表示前一位数字,如果前一位是6,当前为不能构造2。并且任何时候当前位都不能构造4。剩下的套模板就行。

代码:

//省略头文件和一些设置
int dp[10][10];
int m;
int sol[25];

int f(int i, int pre, bool is_limit, bool is_num) {
    if(i == -1) return is_num;
    if(!is_limit && is_num && dp[i][pre] != -1) return dp[i][pre];
    int res = 0;
    if(!is_num) res = f(i - 1, pre, false, false);
    int up = is_limit ? sol[i] : 9;
    for(int d = 1 - is_num; d <= up; ++ d) {
        if(d == 4) continue;
        if(pre == 6 && d == 2) continue;
        res += f(i - 1, d, is_limit && d == up, true); 
    }
    if(!is_limit && is_num) dp[i][pre] = res;
    return res;
}

int solve(int x) {
    m = 0;
    while(x) {
        sol[m ++] = x % 10;
        x /= 10;
    }
    return f(m - 1, 0, true, false);
}

int main() {
    int l, r;
    memset(dp, -1, sizeof(dp));
    while(cin >> l >> r) {
        if(!l && !r) break;    
        printf("%d\n", solve(r) - solve(l - 1));
    }
    return 0;
}

G. Bomb (数位dp) HDU 3555

题意:

  • 输入n,计算1 <= x <= n中满足x数位中有49的数字个数。

分析:

  • 比上一道题还简单,就是 n - [1,n]中没有49的数字个数

代码:

//省略头文件和一些参数
int m;
vector<ll> sol(65);
ll dp[66][10];

ll f(int i, ll pre, bool is_limit, bool is_num) {
    if(i == -1) return is_num;
    if(!is_limit && is_num && dp[i][pre] != -1) return dp[i][pre];
    ll res = 0;
    if(!is_num) res = f(i - 1, pre, false, false);
    ll up = is_limit ? sol[i] : 9;
    for(ll d = 1 - is_num; d <= up; ++ d) {
        if(pre == 4 && d == 9) continue;
        res += f(i - 1, d, is_limit && d == up, true);
    }
    if(!is_limit && is_num) dp[i][pre] = res;
    return res;
}

ll solve(ll x) {
    m = 0;
    while(x) {
        sol[m ++] = x % 10;
        x /= 10;
    }
    return f(m - 1, 0, true, false);
}

int main() {
    memset(dp, -1, sizeof(dp));
    int T; scanf("%d",&T);
    while(T --) {
        ll n; scanf("%lld",&n);
        printf("%lld\n", n - solve(n));
    }
    return 0;
}

H. Balanced Number (数位dp) HDU 3709

题意:

  • 计算平衡数个数,平衡数指一个数字在数位上能找到一个分界,两边力矩和相等。例如4139, 以3为中心,左边 4 * 2 + 1 * 1 = 9, 右边 9 * 1 = 9,两边相等,这个数是平衡数。

分析:

  • 前面也分析过,数位dp构造什么的都是套模板,真正需要考虑的是如何引入题目限制。本题的限制就是左右力矩和相等。
  • 如上图分析得到,只要引入sum和sum2两个参数,就能在最后判断当前构造是否满足要求。具体可以看代码,其他部分都是套模板相对好理解。
  • 做本题时候突然有个感觉就是是不是我们要引入限制最后当构造完成后,可以用来判断构造数字是否合适。

代码:

//省略头文件及一些模板
ll dp[20][182][1900];
ll sol[20];
ll m;

ll f(int i, ll sum, ll sum2, bool is_limit, bool is_num) {
    if(i == -1) return is_num && sum > 0 && (sum2 == 0 || sum2 % sum == 0);
    if(!is_limit && is_num && dp[i][sum][sum2] != -1) return dp[i][sum][sum2];
    ll res = 0;
    if(!is_num) res = f(i - 1, sum, sum2, false, false);
    ll up = is_limit ? sol[i] : 9;
    for(ll d = 1 - is_num; d <= up; ++ d) {
        res += f(i - 1, sum + d, sum2 + d * (m - 1 - i), is_limit && d == up, true);
    }
    if(!is_limit && is_num) dp[i][sum][sum2] = res;
    return res;
}

ll solve(ll x) {
    if(x < 0) return 0;
    memset(dp, -1, sizeof(dp));
    m = 0;
    while(x) {
        sol[m++] = x % 10;
        x /= 10;
    }
    return f(m - 1, 0, 0, true, false) + 1ll;
}

int main() {
    //freopen("../temp.in", "r", stdin);
    //freopen("../temp.out", "w", stdout);
    int T; scanf("%d",&T);
    while(T --) {
        ll x, y; scanf("%lld%lld",&x, &y);
        printf("%lld\n", solve(y) - solve(x - 1));
    }
    return 0;
}

I. B-number (数位dp) HDU 3652

题意:

  • 计算区间 [1,n] 中含有 '13' 且模 13 为 0 的数字有多少个。

分析:

  • 假设A表示[1,n]中含'13'数,B表示[1,n]中模13为0的数,A∩B = B - 非A∩B。 B=n/13, 下面通过数位dp计算非A∩B就行,就是[1,n]不含'13'但模13为0的个数。比较简单套模板就行。
  • 引入限制,我们通过pre限制'13',通过val来限制模13为0,但是我们发现这样val值很大没法开这么大的数组,我们分析发现实际只需要判断val%13这个值就行,所以val在取值时之间模13就行。具体见代码。

代码:

//省略头文件
int dp[10][10][14];
int sol[10];
int m;

int f(int i, int pre, int val, bool is_limit, bool is_num) {
    if(i == -1) return is_num && val % 13 == 0;
    if(!is_limit && is_num && dp[i][pre][val] != -1) return dp[i][pre][val];
    int res = 0;
    if(!is_num) res = f(i - 1, pre, val, false, false);
    int up = is_limit ? sol[i] : 9;
    for(int d = 1 - is_num; d <= up; ++ d) {
        if(pre == 1 && d == 3) continue;
        res += f(i - 1, d, (val * 10 % 13 + d) % 13, is_limit && d == up, true);
    }
    if(!is_limit && is_num) dp[i][pre][val] = res;
    return res;
}

int solve(int x) {
    memset(dp, -1, sizeof(dp));
    m = 0;
    while(x) {
        sol[m ++] = x % 10;
        x /= 10;
    }
    return f(m - 1, 0, 0, true, false);
}

int main() {
    //freopen("../temp.in", "r", stdin);
    //freopen("../temp.out", "w", stdout);
    int n;
    while(cin >> n) {
        printf("%d\n", n / 13 - solve(n));
    }
    return 0;
}
posted @ 2023-06-03 17:30  A_sc  阅读(35)  评论(0编辑  收藏  举报