zoukankan      html  css  js  c++  java
  • [LeetCode 132] 回文分割II(Palindrome Partitioning II)

    前言

    在软件开发行业中实际工程做得久了,大多数人会发现很少有机会接触到各种算法。正如Reddit上有人评论到,当初进公司的时候通过了n轮算法面试,实际工作却很可能是不断的解“null pointer exception”的bug。但是算法作为软件开发的基础的重要性确是不容置疑的,由此我最近突然想要练习练习算法题,补充一下工作中接触不到的知识。在探索过程中发现了LeetCode这个网站,其中Online Judge部分有不少不错的练习题,遂打算在博客中分享解题经验。我google了一下目前分享LeetCode的博客,大多全篇代码或比较简单的解题报告。对于经过ACM训练的人可能看到要点就能领悟了,但是对于我这种非ACM人士来说经常看得一头雾水。所以我希望以一个程序员而不是ACM选手的角度分享解题过程,而不是ACM高手那样的精炼提示:“用DP就能搞定”,“是NP问题”。最后,所有的问题的解决方案将会用C++代码实现。

    问题

    给定一个字符串s,切割s使该切割结果中每一个子串都是一个回文。

    返回需要切割次数最少的回文集。

    例如,如果有s = "aab",

    返回1,因为分割集["aa","b"]可以由s切割一次产生

    初始思路

    不管用什么方法,一个判断字符串是不是回文的函数肯定是少不了的,这个比较容易实现。我们可以同时从字符串的头部和尾部向中间移动,只要字符有不等的情况发生,那么改字符串肯定不是回文。代码如下:

    bool IsPalindrome(const std::string& s, size_t start, size_t end)
    {
        bool result = true;
        
        while(start < end)
        {
            if(s[start] != s[end])
            {
                result = false;
                break;
            }
            
            ++start;
            --end;
        }
        
        return result;
    }

    要找出切割次数最少的回文集,那么我们可以找出所有可能的回文集,然后找出切割次数最少的那个。要怎么找出所有的回文集呢?让我们用较短的aab作为例子人力暴力拆解看看:

    a,a,b

    aa,b

    我们从aab的起点开始,先选长度为1的子串,发现它是回文,这样问题分解为求a和[ab的所有回文集合]的组合。然后选取长度为2的子串,也是回文,问题分解为aa和[b的所有回文集合]的组合。最后选取长度问3的子串,发现它不是回文。

    用代码模拟一下,大概是这样:

    void FindMinPartition(const std::string& s, size_t start)
    {
        size_t pos = start;
                
        while(pos < s.size())
        {
            if(IsPalindrome(s, start, pos))
            {
                FindMinPartition(s, pos + 1);
            }
            ++pos;
        }
    }

    可以看到函数里面出现了递归,既然是递归就要有递归结束条件。我们可以看看前面模拟的aa和[b的所有回文集合]的情况,判断b的所有回文集时发现b也是回文,但是我们不需要再往后找了,因为再往后就超出了字符串的范围,由此我们可以得到递归的结束条件应该是start >= s.size()(别忘了下标是从0开始的,下标为s.size()的时候已经越界了)。

    现在怎么递归清楚了,但是切割次数还没统计呢。不难看出每判断出一个回文,就是一次切割。方便起见,我们可以用一个独立于改函数的全局或成员变量来保存,每次判断出回文后对其加1。且慢,如果光是加1这个切割次数就变成了整个过程的切割次数总和,这个数字肯定不对。让我们在用aab的例子来看看,当我们解决完a和[ab的所有回文集合]的组合的问题后取长度为2的字符串aa时,切割次数应该是1,因为切完a后我们又从头开始切aa,相当于对a的那次切割已经被取消了。从而可以得出每次递归解决问题后切割次数应该减1。

    好了,那么现在最后的问题就是算最小切割次数了。每次递归结束时,我们都会得到一个回文集及切割的次数,将其与一个专门存放最小次数的变量比较,如果当前切割次数小于该变量就更新之即可。这里要注意的是我们对最后的一个子串也“切了一刀”,所以和最小次数比较及赋值时要减去1。

    最后得出方案:

    class Solution32_v1 {
    public:
        int minCut(const std::string& s) {
            
            minCut_ = -1;
            currentCut_ = 0;
            
            FindMinPartition(s, 0);
            
            return minCut_;
        }
        
    private: 
        void FindMinPartition(const std::string& s, size_t start)
        {
            if(start < s.size())
            {
                size_t pos = start;
                
                while(pos < s.size())
                {
                    if(IsPalindrome(s, start, pos))
                    {
                        ++currentCut_;    
                        FindMinPartition(s, pos + 1);
                        --currentCut_;
                    }
                    ++pos;
                }
            }
            else
            {
                if(currentCut_ - 1 < minCut_ || minCut_ == -1)
                {
                    minCut_ = currentCut_ - 1;
                }
            }
        }
        
        int minCut_;
        int currentCut_;
    };

     运行“Judge small”,顺利通过测试!很好,下面让我们运行“Judge Large”。什么,竟然提交失败了,超时!

    优化

    在本机运行超时的用例:

    fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi

    需要6秒才能返回结果。看来LeetCode不允许这么长的执行时间。要怎么优化呢?

    经过观察,可以发现我们在递归的过程中有很多重复运算。以上面的字符串为例,我们会计算[f],[i],[f]与[gbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi的所有回文集合]。然后又会计算[fif]与[gbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi的所有回文集合]。在有很多递归调用的情况下,这种重复会浪费很多时间。所以我们需要想一个办法重复利用某些计算结果。

    首先,要保存已近计算过的分割次数,很自然的想到map。于是我们定义一个std::map<std::string, int>类型的类成员变量来保存某个子串的最小分割次数,该字符串本身作为key。

    其次,由于要保存各子串的分割次数,使用一个成员变量不能满足要求,我们需要通过返回值告诉自己的上层函数,并由上层函数计算出最小分割次数。

    int partMin = -1;
    int partCut = 0;
    
    循环start
    
    partCut = FindMinPartition(s, pos + 1);
    
    if(partCut < partMin || partMin == -1)
    {
         partMin = partCut;
    }
    
    循环end
    
    return partMin;

    最后,我们需要得到的是某个子串的分割次数而不是当前的分割次数。需要通过partCut - currentCut_计算出来。因为currentCut_为进一步分割子串前的分割次数,而partCut为分割子串后的分割次数,两者的差即为该子串贡献的分割次数。

    这样在每次尝试递归求解之前,我们都可以判断一下该子串以前有没有被计算过,如果有,可以直接使用计算结果而避免递归。把所有功能结合起来之后:

    class Solution132_v2
    {
    public:
        Solution132_v2() : currentCut_(0)
        {     
        }
        int FindMinPartition(const std::string& s, size_t start)
        {
            if(start < s.size())
            {        
                int pos = start;
                int partMin = -1;
                int partCut = 0;
                
                while(pos < s.size())
                {
                    if(IsPalindrome(s, start, pos))
                    {                 
                        std::string rest = s.substr(pos + 1, s.size() - pos - 1);
                        
                        ++currentCut_;
                        
                        if(rest != "")
                        {
                            std::map<std::string, int>::const_iterator iter = infoMap_.find(rest);
                            
                            if(iter != infoMap_.end())
                            {
                                if(currentCut_ + iter->second < partMin || partMin == -1)
                                {
                                    partMin = currentCut_ + iter->second;
                                }
                                ++pos;
    --currentCut_;
    continue; } } partCut = FindMinPartition(s, pos + 1); if(partCut < partMin || partMin == -1) { partMin = partCut; } if(rest != "") { infoMap_[rest] = partCut - currentCut_; } --currentCut_; } ++pos; } return partMin; } else { return currentCut_ - 1; } } private: int currentCut_; std::map<std::string, int> infoMap_; };

    现在在本机处理fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi只需要1ms了。提交,运行!这回大多数字符串通过了,但是又挂在了

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aabbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

    这样一个字符串上。

    再次优化

    在本机再次运行处理这个超长字符串,用时3.6秒。还能怎么优化呢?由于我们要找的是最小分割次数,是不是能终止一些已经没可能产生最小分割的循环?尝试在判断回文成功后加入下列代码:

    int estimate = 0;
    if(pos == s.size() - 1)
    {
        estimate = currentCut_;
    }
    else
    {
        estimate = currentCut_ + 1;
    }
                        
    if(estimate >= partMin && partMin != -1)
    {
        break;
    }

    这里注意当当前回文是字符串后半部分时,分割次数是不会增加的(我们在后面返回时会减掉)。而如果不是最后,至少会增加1。如果估计的分割数将会大于等于当前的最小分割数,那么已经没有必要再找下去了。

    再次尝试本机运行,用时基本没有变化。这是怎么回事?重新审视我们的分割方法,每次取子串都是从最短的取起,如abba会首先得到[a],[b],[b],[a]的3次分割子集。这就导致了滤条件基本没用,因为首先放进去的都是分割次数最大的。由于子串越长,分割次数肯定更少,我们应该采用先从最长子串取起的方法。还是拿最简单的aab做例子。我们先取长度为3的aab,发现不是回文。再取长度为2的aa,发现是回文,进而求解b的回文集。最后去长度为1的a,进而求解ab的回文集。这样从最长取起的方法就可以有效的利用我们的终止循环条件了。调整后的代码如下:

    class Solution132
    {
    public:
        Solution132() : currentCut_(0)
        {
        }
        int FindMinPartition(const std::string& s, size_t start)
        {
            if(start < s.size())
            {            
                int pos = s.size() - 1;
                int partMin = -1;
                int partCut = 0;
    
                while(pos >= (int)start)
                {
                    if(IsPalindrome(s, start, pos))
                    {
                        int estimate = 0;
                        if(pos == s.size() - 1)
                        {
                            estimate = currentCut_;
                        }
                        else
                        {
                            estimate = currentCut_ + 1;
                        }
                        
                        if(estimate >= partMin && partMin != -1)
                        {
                            break;
                        }                    
                        
                        std::string rest = s.substr(pos + 1, s.size() - pos- 1);
                        
                        ++currentCut_;
                        
                        if(rest != "")
                        {
                            std::map<std::string, int>::const_iterator iter = infoMap_.find(rest);
                            
                            if(iter != infoMap_.end())
                            {
                                //std::cout << "String: " << rest << "-Count: " << iter->second << std::endl;
                                if(currentCut_ + iter->second < partMin || partMin == -1)
                                {
                                    partMin = currentCut_ + iter->second;
                                }
                                --pos;
                                --currentCut_;
                                continue;
                            }
                        }
                        
                        partCut = FindMinPartition(s, pos + 1);
                        if(partCut < partMin || partMin == -1)
                        {
                            partMin = partCut;
                        }
                        
                        if(rest != "")
                        {
                            infoMap_[rest] = partCut - currentCut_;
                        }
                        
                        --currentCut_;
                    }
                    --pos;
                }
                
                return partMin;
            }
            else
            {
                return currentCut_ - 1;
            }
        }
        
    private:
        int currentCut_;
        std::map<std::string, int> infoMap_;
    };

    这回在本机处理那个超长字符串只要7ms了,应该没问题了。提交并运行:

     终于成功通过 Judge Large!在这次解题过程中我们可以看到通过一步步的优化,实现同样目的的程序性能可以有数百倍的提高。

  • 相关阅读:
    洛谷 P2023 BZOJ 1798 [AHOI2009]维护序列
    洛谷 P1640 BZOJ 1854 [SCOI2010]连续攻击游戏
    洛谷 P4300 BZOJ 1266 [AHOI2006]上学路线route
    洛谷 P1886 滑动窗口
    洛谷 P1063 能量项链
    洛谷 P1156 垃圾陷阱
    洛谷 P1854 花店橱窗布置
    洛谷 P3800 Power收集
    洛谷 P2285 BZOJ 1207 [HNOI2004]打鼹鼠
    day31
  • 原文地址:https://www.cnblogs.com/shawnhue/p/leetcode_132.html
Copyright © 2011-2022 走看看