动态规划 回文篇

LeetCode刷题最好按照标签归类来刷,本文主要对leetcode上的回文串问题做一个梳理。
Leetcode上回文串经典题如下:

  1. LC5. 最长回文子串
  2. LC9. 回文数
  3. LC131.分割回文串i
  4. LC132.分割回文串ii

回文串核心问题

什么是回文串

回文字符串指的是字符串从左到右 和从右到左是一样的。

  1. 单个字符肯定是回文串
  2. 两个字符如果相等是回文串
  3. 多个字符,如果首末位相等,中间是回文,那么整体是回文

概括三种情况:
对于一个字符串s,从下标i到j的子串,判断其回文。flag[i][j]表示i~j是否为回文。

s[i]==s[j] &&(flag[i+1][j-1] || j-i<=1)

依赖关系
由此可见flag[i][j]依赖于flag[i+1][j-1],我们要计算出flag[i][j]必须先求出flag[i+1][j-1],而这个位置是在(i,j)的下方,因此我们填充flag这个表格数组的时候遵从的是从左到右从下到上的生成方式。

LC5 最长回文子串

使用动态规划 dp[i] 表示从i到len-1位置的最长回文子串长度。
图1 最长回文子串演示
求出一个dp数组,保存[i,j]是否为回文的信息,对于一个从i到j的子串,如何判断它是否为回文串。
如图1,当固定i=3时,j可以从3到7进行遍历,每次j遍历的过程中会计算出i到j是否是回文,这样再下次计算的时候,直接使用,不用重复计算。
如当i=3,j=5时,s[i]=s[j],且flag[4][4]是true,那么dp[i]此时就可以进行更新,dp[i]=5-3+1=3;当j不断向后继续遍历,dp[i]的值就会不断更新。

	/**
	**为什么不能从i=0到i=n-1 而是要从i=n-1到i=0因
    **flag[i][j]依赖于 flag[i+1][j-1]因此 要先计算出i+1行 ,j-1列才能推算		出i,j
   **因此方向为从下到上,从左到右
   **/
    public String longestPalindrome(String s) {
        if(s==null || s.length()==0){
            return "";
        }
        int n=s.length();
        boolean[][] flag=new boolean[n][n];
        int[] dp=new int[n];
        for(int i=0;i<n;i++){
            dp[i]=1;
        }
        char[]t =s.toCharArray();
        //记录长度
        int res=0;
        //记录起始位置
        int index=-1;
        for(int i=n-1;i>=0;i--){
            for(int j=i;j<n;j++){
                //[i,j]是回文,dp[i]表示[i,len-1]上的最长回文串,dp[i]有一个初始值
                //现在找到了一个 i~j 长度为j-i+1,  j一直变化,能够找到以i开头的最大长度
                if(t[i]==t[j]&& (j-i<=1||flag[i+1][j-1])){
                    flag[i][j]=true;
                    //从i到j是回文,那么[i,len-1]的最长回文 至少有j-i+1;
                    dp[i]=Math.max(dp[i],j-i+1);
                }
            }
            if(res<dp[i]){
                res=dp[i];
                index=i;
            }
        }
        return s.substring(index,index+res);
    }

LC9 回文数

我们可以将数字转成字符串,然后利用首尾指针进行判断,一旦不等说明不是回文,但是这样要耗费额外的空间。
我们可以从末尾构建反转后的数字,再与原来的数进行比较,如果是相等,那就是回文。负数肯定不是回文。

    class Solution {
        public boolean isPalindrome(int x) {
            if (x < 0) {
                return false;
            }
            int tmp = x;
            //存储反转后的数字,可能溢出,用long
            long val = 0;
            while (x > 0) {
                // 末尾数字放在前头
                val = val * 10 + x % 10;
                x = x / 10;
            }
            return tmp == val;
        }
    }

LC131 分割回文串i

如果要求出所有的分割情况,而不要求最小的,则应该使用递归回溯。
实际上我们是在一个隐式树结构上进行搜索,可以使用深度优先搜索。
考虑以下例子,对于一个字符串 s=“aaaba”,我们可以做如下选择:

  • 第一次从第0个字符开始
    可以选择一个字符组成a,也可以选择两个字符aa,直到选择最后一个字符,不能再进行选择了,退出,当然在每次选择完以后,都要判断当前的字符串是不是回文,是回文的话,才从已经选择完的后面一个位置,进行下一轮选择。
  • 第二次选择从第一个字符开始
    那么也可以在此基础上向后扩展选择第1个,第2个,,直到选择的字符到达字符串末尾。按照第一次的方式再进行计算。

    编码的话,实际就是把上面的描述用程序语言翻译下。

递归回溯

    class Solution {
        List<List<String>> res = new ArrayList<>();
        public List<List<String>> partition(String s) {
            int len = s.length();
            dfs(s, len, 0, new ArrayList<>());
            return res;
        }
 
        void dfs(String s,int len, int index, List<String> list) {
            if (index >= len) {
                if (index == len) {
                    res.add(new ArrayList<>(list));
                }
                return;
            }
            //从index位置开始,可以选择的候选人有 ,index+1,index+2,直到index+i=len
            for (int i = 1; index + i <= len; i++) {
            	//substring 是左闭右开
                String tmp = s.substring(index, index + i);
                if (isValid(tmp)) {
                	//tmp有效,将其先加入列表
                    list.add(tmp);
                    //如果当前选择的字符串是回文,那么下一次就从index+i处开始选择
                    dfs(s,len, index + i, list);
                    //回溯 tmp有效,重新进行选择,可能选择tmp后一个字符的位置,需要把之前的结果撤销
                    list.remove(list.size() - 1);
                }
            }
        }

		//校验是否是回文
        public boolean isValid(String s) {
            int left = 0;
            int right = s.length()-1;
            while (left < right){
                if(s.charAt(left)==s.charAt(right)){
                    left++;
                    right--;
                }else{
                    return false;
                }
            }
            return true;
        }
    }

耗时

记忆化搜索

上面我们在计算从i到j是否是回文的时候,有重复计算,实际上可以存储i-j的回文结果,用记忆化搜索来实现。具体如下:

    class Solution {
        List<List<String>> res = new ArrayList<>();
        public List<List<String>> partition(String s) {
            int len = s.length();
            boolean[][] flag = new boolean[len][len];
            dfs(s, len, 0, new ArrayList<>(),flag);
            return res;
        }

        void dfs(String s,int len, int index, List<String> list,boolean[][] flag) {
            if (index >= len) {
                if (index == len) {
                    res.add(new ArrayList<>(list));
                }
                return;
            }
            for (int i = 1; index + i <= len; i++) {
                String tmp = s.substring(index, index + i);
                //使用flag数组来记录从i到j是否是回文,避免重复计算,能够快速剪枝
                if (flag[index][index+i-1] || isValid(tmp)) {
                    flag[index][index+i-1] = true;
                    list.add(tmp);
                    dfs(s,len, index + i, list,flag);
                    list.remove(list.size() - 1);
                }
            }
        }

        public boolean isValid(String s) {
            int left = 0;
            int right = s.length()-1;
            while (left < right){
                if(s.charAt(left)==s.charAt(right)){
                    left++;
                    right--;
                }else{
                    return false;
                }
            }
            return true;
        }
    }

耗时
能看到处理时间上提高了。

递归回溯2

也可以换另外一种写法,我觉得第一种写法可能更好理解一些,其实可以发现很多回溯问题,都是这种套路(比如ip问题),后续会更新回溯专题。

	public static List<List<String>> partition(String s) {
        List<List<String>> res=new ArrayList<>();
        List<String> cur=new ArrayList<>();
        dfs(res,cur,s);
        return res;
    }
    /**
     *
     * @param res 存储每一趟遍历的符合要求的记过
     * @param cur cur记录一趟遍历,存储的结果
     * @param s
     */
    public static void dfs(List<List<String>>res,List<String> cur,String s){

        if(s.length()==0){
            res.add(new ArrayList<>(cur));
        }
        int len=s.length();
        for(int i=1;i<=len;i++){
            String s1=s.substring(0,i);
            if(isPalindrome(s1)){
                cur.add(s1);
                String s2=s.substring(i);
                dfs(res,cur,s2);
                cur.remove(cur.size()-1);
            }
        }
    }

    public static boolean isPalindrome(String s){
        if(s==null||s.length()==0){
            return false;
        }
        int j=s.length()-1;
        int i=0;
        while(i<j){
            if(s.charAt(i)==s.charAt(j)){
                i++;
                j--;
            }else{
                return false;
            }
        }
        return true;
    }

LC132 分割回文串ii

求最小的切分,使得分割后每个子串都是回文的。
状态 dp[i] 表示从i到n-1的最小回文分割数目,
那么对于任意的一个j,j属于[i,n-1],如果[i,j]是回文的话,则
dp[i]=min(dp[j+1]+1) j从i到n-1,且当j=n-1时,dp[n]=-1;
因此只要遍历一遍从i到n-1的j,求取最小的dp[i]即可。
如果i-j是回文,那么dp[i]= 1+dp[j+1]
如果回文的范围扩大,比如i到j+2都是回文,那么dp[i]=1+dp[j+3]
对于一个位置i,它的最小切分为
min(dp[j] )+1 其中 j=i…len-1;

	//和最长递增子序列很相似
    public int minCut(String s) {
        int len=s.length();
        boolean[][] flag=new boolean[len][len];
        //dp[i]代表[i,len-1]的切成回文的最小分割数
        int[] dp=new int[len+1];
//        getdp(s,flag,len);
        //从右往左进行计算
        dp[len]=-1;
        for(int i=len-1;i>=0;i--){
            dp[i]=Integer.MAX_VALUE;
            for(int j=i;j<len;j++){
                //[i,j]是回文
                if(s.charAt(j)==s.charAt(i) && (j-i<=1 || flag[i+1][j-1])){
                    flag[i][j]=true;
                    dp[i]=Math.min(dp[j+1]+1,dp[i]);
                }
            }
        }
        return dp[0];
    }

版权声明:本文为yjpeng125原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。