[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!在这次解题过程中我们可以看到通过一步步的优化,实现同样目的的程序性能可以有数百倍的提高。