查看原文
其他

回文问题终极篇:最小代价构造回文串

labuladong labuladong 2021-10-12

学算法认准 labuladong

东哥带你手把手撕力扣😏

点击下方卡片即可搜索👇

读完本文,你可以去力扣完成第 1312 题「让字符串成为回文串的最少插入次数」,难度 Hard

回文串就是正着读反着读都一样的字符串,面试笔试中经常出现回文相关的题目,我们之前有好几篇讲解回文问题的文章,是判断回文串或者寻找最长回文串/子序列的:

经典面试题:最长回文子串

子序列解题模板:最长回文子序列

如何高效判断回文单链表?

本文就来研究一道「构造回文串的最小插入次数」的问题,然后所有回文相关的问题你都可以搞定了,如果再遇到回文算法题,就偷着乐吧~

这次的题目比较困难,让字符串成为回文串的最少插入次数:

函数签名如下:

int minInsertions(string s);

比如说输入s = "abcea",算法返回 2,因为可以给s插入 2 个字符变成回文串"abeceba"或者"aebcbea"。如果输入s = "aba",则算法返回 0,因为s已经是回文串,不用插入任何字符。

思路解析

首先,要找最少的插入次数,那肯定得穷举喽,如果我们用暴力算法穷举出所有插入方法,时间复杂度是多少?

每次都可以在两个字符的中间插入任意一个字符,外加判断字符串是否为回文字符串,这时间复杂度肯定爆炸,是指数级。

那么无疑,这个问题需要使用动态规划技巧来解决。之前的文章说过,回文问题一般都是从字符串的中间向两端扩散,构造回文串也是类似的。

我们定义一个二维的dp数组,dp[i][j]的定义如下:对字符串s[i..j],最少需要进行dp[i][j]次插入才能变成回文串

我们想求整个s的最少插入次数,根据这个定义,也就是想求dp[0][n-1]的大小(ns的长度)。

同时,base case 也很容易想到,当i == jdp[i][j] = 0,因为当i == js[i..j]就是一个字符,本身就是回文串,所以不需要进行任何插入操作。

接下来就是动态规划的重头戏了,利用数学归纳法思考状态转移方程。

状态转移方程

状态转移就是从小规模问题的答案推导更大规模问题的答案,从 base case 向其他状态推导嘛。如果我们现在想计算dp[i][j]的值,而且假设我们已经计算出了子问题dp[i+1][j-1]的值了,你能不能想办法推出dp[i][j]的值呢

既然已经算出dp[i+1][j-1],即知道了s[i+1..j-1]成为回文串的最小插入次数,那么也就可以认为s[i+1..j-1]已经是一个回文串了,所以通过dp[i+1][j-1]推导dp[i][j]的关键就在于s[i]s[j]这两个字符

这个得分情况讨论,如果s[i] == s[j]的话,我们不需要进行任何插入,只要知道如何把s[i+1..j-1]变成回文串即可:

翻译成代码就是这样:

if (s[i] == s[j]) {
    dp[i][j] = dp[i + 1][j - 1];
}

如果s[i] != s[j]的话,就比较麻烦了,比如下面这种情况:

最简单的想法就是,先把s[j]插到s[i]右边,同时把s[i]插到s[j]右边,这样构造出来的字符串一定是回文串:

PS:当然,把s[j]插到s[i]左边,然后把s[i]插到s[j]左边也是一样的,后面会分析。

但是,这是不是就意味着代码可以直接这样写呢?

if (s[i] != s[j]) {
    // 把 s[j] 插到 s[i] 右边,把 s[i] 插到 s[j] 右边
    dp[i][j] = dp[i + 1][j - 1] + 2;
}

不对,比如说如下这两种情况,只需要插入一个字符即可使得s[i..j]变成回文:

所以说,当s[i] != s[j]时,无脑插入两次肯定是可以让s[i..j]变成回文串,但是不一定是插入次数最少的,最优的插入方案应该被拆解成如下流程:

步骤一,做选择,先将s[i..j-1]或者s[i+1..j]变成回文串。怎么做选择呢?谁变成回文串的插入次数少,就选谁呗。

比如图二的情况,将s[i+1..j]变成回文串的代价小,因为它本身就是回文串,根本不需要插入;同理,对于图三,将s[i..j-1]变成回文串的代价更小。

然而,如果 s[i+1..j]s[i..j-1]都不是回文串,都至少需要插入一个字符才能变成回文,所以选择哪一个都一样:

那我怎么知道s[i+1..j]s[i..j-1]谁变成回文串的代价更小呢?

回头看看dp数组的定义是什么,dp[i+1][j]dp[i][j-1]不就是它们变成回文串的代价么?

步骤二,根据步骤一的选择,将s[i..j]变成回文

如果你在步骤一中选择把s[i+1..j]变成回文串,那么在s[i+1..j]右边插入一个字符s[i]一定可以将s[i..j]变成回文;同理,如果在步骤一中选择把s[i..j-1]变成回文串,在s[i..j-1]左边插入一个字符s[j]一定可以将s[i..j]变成回文。

那么根据刚才对dp数组的定义以及以上的分析,s[i] != s[j]时的代码逻辑如下:

if (s[i] != s[j]) {
    // 步骤一选择代价较小的
    // 步骤二必然要进行一次插入
    dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}

综合起来,状态转移方程如下:

if (s[i] == s[j]) {
    dp[i][j] = dp[i + 1][j - 1];
else {
    dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}

这就是动态规划算法核心,我们可以直接写出解法代码了。

代码实现

首先想想 base case 是什么,当i == jdp[i][j] = 0,因为这时候s[i..j]就是单个字符,本身就是回文串,不需要任何插入;最终的答案是dp[0][n-1]n是字符串s的长度)。那么 dp table 长这样:

又因为状态转移方程中dp[i][j]dp[i+1][j]dp[i]-1]dp[i+1][j-1]三个状态有关,为了保证每次计算dp[i][j]时,这三个状态都已经被计算,我们一般选择从下向上,从左到右遍历dp数组:

完整代码如下:

int minInsertions(string s) {
    int n = s.size();
    // 定义:对 s[i..j],最少需要插入 dp[i][j] 次才能变成回文
    vector<vector<int>> dp(n, vector<int>(n, 0));
    // base case:i == j 时 dp[i][j] = 0,单个字符本身就是回文
    // dp 数组已经全部初始化为 0,base case 已初始化

    // 从下向上遍历
    for (int i = n - 2; i >= 0; i--) {
        // 从左向右遍历
        for (int j = i + 1; j < n; j++) {
            // 根据 s[i] 和 s[j] 进行状态转移
            if (s[i] == s[j]) {
                dp[i][j] = dp[i + 1][j - 1];
            } else {
                dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
            }
        }
    }
    // 根据 dp 数组的定义,题目要求的答案
    return dp[0][n - 1];
}

现在这道题就解决了,时间和空间复杂度都是 O(N^2)。还有一个小优化,注意到dp数组的状态之和它相邻的状态有关,所以dp数组是可以压缩成一维的:

int minInsertions(string s) {
    int n = s.size();
    vector<int> dp(n, 0);

    int temp = 0;
    for (int i = n - 2; i >= 0; i--) {
        // 记录 dp[i+1][j-1]
        int pre = 0;
        for (int j = i + 1; j < n; j++) {
            temp = dp[j];

            if (s[i] == s[j]) {
                // dp[i][j] = dp[i+1][j-1];
                dp[j] = pre;
            } else {
                // dp[i][j] = min(dp[i+1][j], dp[i][j-1]) + 1;
                dp[j] = =min(dp[j], dp[j - 1]) + 1;
            }

            pre = temp;
        }
    }

    return dp[n - 1];
}

至于这个状态压缩是怎么做的,我们前文 状态压缩技巧:动态规划的降维打击 详细介绍过,这里就不展开了。

往期推荐 🔗

二叉树的题,就那几个框架,枯燥至极🤔

状态压缩技巧:动态规划的降维打击

一个函数秒杀 2Sum 3Sum 4Sum 问题

回溯算法和动态规划,到底谁是谁爹

东哥手写正则通配符算法,结构清晰,包教包会!

_____________

学好算法全靠套路,认准 labuladong 就够了。


知乎、B站搜索:labuladong


《labuladong的算抄》「pdf」「进群」

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存