前言
- 本文章记录本人做LeetCode该算法题从没有思路,到超时,到10ms的过程。
- 本文章不仅仅的线索是本人的做题思路,期间有部分后来意识到是错误的,有一些虽然是错误却为后面提供了思路。因此有些思路是错误的,但会说明。
- 本篇文章基于java,并会展示部分算法代码,若有兴趣请看完全文再尝试里面的代码思路,有些思想本身未必可行。即使放了代码,那个代码也可能是错误的。
LeetCode132 题目
- 给定一个字符串 s,将 s分割成一些子串,使每个子串都是回文串。返回符合要求的最少分割次数。
例子1
- 输入:“aab”
- 输出:1
例子2
- 输入:“fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi”
- 输出:75
例子3
- 输入:“aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”
- 输出:1
穷举
尝试动态规划失败
- 由于该题出现在动态规划的标签下,因此我准备用动态规划来思考解决这道题。但这道题似乎不太常规,即使我知道某个字符以前的字符串可以多少次即切割成回文子串,但也无法推及到当前状态。
- 由于动态规划是递归+记忆化的优化版本,因此我们先通过递归穷举来寻找灵感。
穷举思路
以例子1为例,aab字符串,把字符串切割成两部分,前半部分判断是否是回文字符串,若是后半部分再通过递归的方法获取到切割多少次能成为回文字符串。
例如,切割为a和ab两部分,a若是回文(这里是),则判断ab要切割多少次才能成为回文子串,这里ab要切割1次,变成a和b即符合要求。再回溯回去,ab要切割1次,加上a和ab这一次,一共2次,即如果从a和ab切割至少要切两次。由于要寻找最小次数的切割点,因此我们通过一个for循环遍历所有切割点,最终发现aa和b之间切下,可以1刀就解决问题,即该例子的结果是1,跟例子给出的结果1致。
穷举代码
/**
* 暴力穷举
*/
class Solution2 implements Solution{
// 本身也是递归方法
public int minCut(String s) {
int n = s.length();
// 如果s长度是0
// 或者本身是回文串
// 则不需要切割,直接返回
if (n == 0 || isPalindrome(s))
return 0;
int min = Integer.MAX_VALUE; // 用来统计最小值
// 遍历所有切割点
for (int i = 1; i < n; i++) {
// 如果前面是回文串
if (isPalindrome(s.substring(0, i)))
// 递归获取后面串的最小切割数,并+1
// 与当前结果值取最小值
min = Math.min(min, minCut(s.substring(i)) + 1);
}
return min;
}
// 这个用来判断字符串s是否是回文子串
private boolean isPalindrome(String s){
char[] ss = s.toCharArray();
if (ss.length == 0)
return false;
// 通过两边向中间探测的方式判断是否回文
// 若其中一个字符不符合立刻中断
for (int i = 0, j = ss.length-1; i < j; i++, j--) {
if (ss[i] != ss[j]) return false;
}
return true;
}
}
LeetCode结果
众所周知,穷举这玩意一定会代码超时...
自行测试
我们用超时的例子,即例子2在本地测试,计算一下算法所需要消耗的时间。
测试代码
public class LeetCode132 {
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "aab";
s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution2(), 75, s); // 穷举法
}
public static void test(Solution solution, int result, Object ... o){
long time = new Date().getTime();
int r = solution.minCut(String.valueOf(o[0]));
long time2 = new Date().getTime();
System.out.println("计算答案:" + r);
System.out.println("计划答案:" + result);
String info = r == result ? "对" : "错";
System.out.println(String.format("结果:%s, 耗时:%s毫秒", info, time2-time));
}
}
测试结果
由图可知,答案是正确的,就是用了6秒多。
记忆化穷举(穷举 + 记忆化)
记忆化穷举思路
由于穷举超时了,可以做一些缓存操作,减少重复运算。因此我立刻想到可以把判断过是否是回文的字符串用HashSet缓存起来,以后要再次判断该字符串是否是回文时则可以直接获取到结果,不用频繁的遍历字符比较。(这个记忆化想法是错的, 正确的记忆化方式在后面才顿悟)
穷举代码
/**
* 穷举记忆化
*/
class Solution3 implements Solution{
private HashSet<String> palindrome = new HashSet<>();
private HashSet<String> notPalindrome = new HashSet<>();
public int minCut(String s) {
逻辑跟上面穷举法基本一样,忽略
}
// 判断是否回文
private boolean isPalindrome(String s){
if (s.length() == 0)
return false;
// 其实就这里加了一层缓存
else if (palindrome.contains(s)) {
return true;
}else if (notPalindrome.contains(s)) {
return false;
}
char[] ss = s.toCharArray();
for (int i = 0, j = ss.length-1; i < j; i++, j--) {
if (ss[i] != ss[j]){
notPalindrome.add(s);
return false;
}
}
// 添加了增加缓存的操作
palindrome.add(s);
return true;
}
}
自行测试
先自己测试一下,再放到LeetCode。
测试代码
public class LeetCode132 {
public static void main1(String[] args) {
Solution solution = new Solution8();
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution2(), 75, s);
test(new Solution3(), 75, s);
}
public static void test(Solution solution, int result, Object ... o){
...
}
}
自行测试结果
ama(zing).... 敲..还变慢了
掐指一算,应该是回文字符串缓存命中概率不高,而维护该HashMap本身也需要消耗时间,因此出现了得不偿失的情况。因此记忆化行不通。(再次强调,这里的记忆化思路是错的,我只是记录下我当时的想法,准确的记忆化思路在下面顿悟)
动态规划 第一版
动态规划思路
从穷举中,能隐约感受到递推的思路,如下:
// 状态定义
dp[i][j]; // 表示第i个字符到第j个字符最少分割次数
// 状态转移方程
// k是i到j之间的所有切割点
// 那么dp[i][j]就是在字符串第i位到第k位是回文字符串下,
// 通过第dp[k+1][j] + 1的方式递推出来
// 最终取最小的值做dp[i][j]的值。
for(int k=i; k < j; k++){
if(isPalindrome(s, i, k)){
dp[i][j] = dp[k+1][j] + 1;
}
}
上面确定了状态方程和递推方程,只需要注意一下一些初始化的值即可写出代码。
动态规划 第一版 代码
/**
* 申明:该代码无法正确运行。
*
* 动态规划 第一版
*/
class Solution4 implements Solution{
public int minCut(String s) {
int n = s.length();
char[] ss = s.toCharArray();
if (n == 0 || isPalindrome(ss, 0, n-1)) {
return 0;
}
else if (n == 2) {
return 1;
}
int[][] dp = new int[n][n];
// 用两个循环把初始状态置为理论最大值
for (int i = 0; i < n; i++) {
dp[0][i] = Integer.MAX_VALUE;
}
for (int i = 1; i < n; i++) {
System.arraycopy(dp[0],0,dp[i], 0, n);
}
// 只有一个字符的字符串都是回文串
dp[0][0] = 0;
for (int i = 1; i < n; i++) {
dp[i][i] = 0;
// 判断相邻两个字符的回文串,是回文串则不用切割
// 否则需要切割则为1
dp[i-1][i] = ss[i-1] == ss[i] ? 0 : 1;
}
// 开始递推
for (int i = 0; i < n-2; i++) {
for (int j = i+2; j < n; j++) {
// 如果dp[i][j]本身也是回文字符串,则直接返回0
if (isPalindrome(ss, i, j)){
dp[i][j] = 0;
continue;
}
// 遍历切割点
// 递推公式
for (int k = i; k < j; k++) {
if (isPalindrome(ss, i, k)){
dp[i][j] = Math.min(dp[i][j], dp[k+1][j] + 1);
}
}
}
}
return dp[0][n-1];
}
// 这里优化了回文判断,判断数组第i个到第j个是回文字符串
// 直接传入了数组,减少大量字符串占据着内存
private boolean isPalindrome(char[] ss, int l, int r){
if (r-l == 0)
return true;
for (int i = l, j = r; i < j; i++, j--) {
if (ss[i] != ss[j]) return false;
}
return true;
}
}
代码分析
这里代码没有正常执行,因为递推逻辑有问题。我们简化一下递推的逻辑:
// 递推逻辑
for (int i = 0; i < n-2; i++) {
for (int j = i+2; j < n; j++) {
// 递推方程
for (int k = i; k < j; k++) {
if (isPalindrome(ss, i, k)){
// dp[k+1][j]可能还没初始化
dp[i][j] = Math.min(dp[i][j], dp[k+1][j] + 1);
}
}
}
}
从代码不难发现,一开始只初始化了长度为2以下的字符串状态,即dp(i)(j)中,i和j的差值小于等于1,而递推方程在一开始就可能读到i和j相差大于1的状态,因此递推失败。
动态规划第二版
动态规划改进思路
上面的动态规划,由于长度的问题导致递推的失败,那么可以很容易联想到在最外面的for循环再套一层for循环,限制i和j的距离,但这就变成了4层循环,似乎没必要。
因此我决定重新设计状态定义和状态方程,其实通过长度我们可以快速联想到下面这种状态定义:
// 状态定义
dp[i][j]; // 表示从字符串长度为i的字符串,且从j字符开始算起。
// 状态转移方程
int last = i + j - 1; // 表示从j开始,长度为i,所指的位置
// k表示在长度为i,起始位置为j的位置中的切割点
for(int k=j; k < last; k++){
// 如果j到k是回文串
if(isPalindrome(s, j, k)){
则dp[i][j]是k+1起剩余字符串的状态+1
dp[i][j] = dp[last - k][k+1] + 1;
}
}
其实就是第一版的转台换一种表达的方式,那么就可以省去了最外层的长度限制循环。
为什么这样子定义就可以省略掉了一层循环?
其实这两种状态定义的方式都是二维,因此并不存在那种方式能够存取更多的信息的说法。其实仔细观察两组状态定义可以发现,其实两种定义方式基本一样,只是描述的方式不同罢了,那为什么第一种就需要多加一层循环呢?
观察第一版本的4层循环可以发现,如果限制了i和j的距离,那么只要i定死了,j也就跟着定死了,因此其实加了最外层长度限制循环,那么j那一层的循环就可以直接去掉,还是三层循环,但是相对比较绕。
其实这里说j定死并不严谨。如果限制了i和j的距离是k,那么j-i只要在小于k的范围内都是允许的,即即使加了最外层循环,且i定死,但j也可以在i+1到i+k-1的范围内活动。那为什么我说j是定死的呢?
仔细推理可知,在k长度从最小值逐渐递增下,其实j在i+1到i+k-1的值都已经在k递增的时候被地推过,而在长度k限制下,只有j等于i+k-1情况下需要被推理,因此可以且应该直接把j的循环拿掉。
动态规划 第二版 代码
class Solution5 implements Solution{
public int minCut(String s) {
int n = s.length();
char[] ss = s.toCharArray();
// 如果长度为0或者是回文串,则返回0
if (n == 0 || isPalindrome(ss, 0, n-1)) {
return 0;
}
// 来到这表示s不是回文串,如果长度为2,则直接返回1
else if (n == 2) {
return 1;
}
int[][] dp = new int[n+1][n]; // 状态定义
// 长度为0属于异常情况
dp[0][0] = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
dp[0][i] = Integer.MAX_VALUE;
}
// 初始化单字符和相邻字符的回文状态。
dp[1][1] = 0;
for (int i = 1; i < n; i++) {
dp[1][i] = 0;
dp[2][i-1] = ss[i-1] == ss[i] ? 0 : 1;
}
// 开始递推
for (int i = 3; i < n+1; i++) { // 长度从3开始
for (int j = 0; j <= n-i; j++) { // 从第0个字符开始推
// 获取到该长度和起始位置下的字符串的最后一个字符位置
int last = j + i - 1;
dp[i][j] = Integer.MAX_VALUE;
if (isPalindrome(ss, j, last)){ // 若是回文
dp[i][j] = 0;
continue;
}
// 遍历切割点,状态转移方程
for (int k = j; k < last; k++) {
if (isPalindrome(ss, j, k)){
dp[i][j] = Math.min(dp[last - k][k+1] + 1, dp[i][j]);
}
}
}
}
return dp[n][0]; // 返回长度为n,
}
private boolean isPalindrome(char[] ss, int l, int r){
...省略...
}
}
自行测试
测试代码
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution2(), 75, s); // 穷举
test(new Solution3(), 75, s); // 记忆化穷举
test(new Solution5(), 75, s); // 动态规划 第二版
}
测试结果
三次运行结果都是正确的,这里只截取展示了时间
结果:对, 耗时:5971毫秒 // 穷举
结果:对, 耗时:11660毫秒 // 记忆化穷举
结果:对, 耗时:4毫秒 // 动态规划 第二版
amazing! 这次leetcode应该稳了。
LeetCode
敲!稳个锤子...
双动态规划
加速思路
总体的框架基本定下来了,那就只能从细节上加速。
明显求回文字符串每次都用字符比较的方法比较蠢,可以换成动态规划的方式,把两个索引间是否回文串的所有可能用二维数组存下来,判断的时候就可以加速执行。
双动态规划 代码
/**
* 双动态规划
*/
class Solution6 implements Solution{
public int minCut(String s) {
... 初始参数判断 ...
boolean[][] palindrome = getPalindrome(ss); // 获取所有回文判断
int[][] dp = new int[n+1][n];
... 初始化dp状态 ..
for (int i = 3; i < n+1; i++) {
for (int j = 0; j <= n-i; j++) {
int last = j + i - 1;
dp[i][j] = Integer.MAX_VALUE;
if (palindrome[j][last]){ // 判断回文可以直接读数组即可
dp[i][j] = 0;
continue;
}
for (int k = j; k < last; k++) {
if (palindrome[j][k]){
dp[i][j] = Math.min(dp[last - k][k+1] + 1, dp[i][j]);
}
}
}
}
return dp[n][0];
}
/**
* 动态规划判断是否回文
*/
private boolean[][] getPalindrome(char[] ss){
int n = ss.length;
// 第i个字符到第j个字符是否是回文串
boolean[][] dp = new boolean[n][n];
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 从左往右遍历
for (int i = 0; i < n; i++) {
// 中间扩散
// 若达到边界前已判断出不是回文串,则短路。
for (int j = i-1, k=i+1; j >= 0 && k< n && dp[j+1][k-1]; j--, k++) {
dp[j][k] = ss[j] == ss[k];
}
// 判断相邻是否是回文串
if (i>0 && ss[i-1] == ss[i]){
dp[i-1][i] = true;
for (int j = i-2, k=i+1; j >= 0 && k< n && dp[j+1][k-1]; j--, k++) {
dp[j][k] = ss[j] == ss[k];
}
}
}
return dp;
}
private boolean isPalindrome(char[] ss, int l, int r){
... 省略代码 ...
}
}
自行测试
测试代码
由于穷举效率太低,下面将不再执行穷举的方法
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution5(), 75, s); // 动态规划 第二版
test(new Solution6(), 75, s); // 双动态规划 第二版
}
测试结果
结果:对, 耗时:9毫秒 // 动态规划 第二版
结果:对, 耗时:5毫秒 // 双动态规划 第二版
amazing! 双动态规划得到了进一步的加速。
LeetCode运行
稳...个屁,为啥我那么慢...26%就不太可以接受了。
分析
通过动态规划第二版的超时,我们发现因为长度的限制,我们不得不从短的字符串逐渐的往长的字符串推进,期间完完整整实打实的的全部推出来了,可不可以投机取巧,只推需要用到的那一部分呢?
这个时候我又想到了递归,在穷举的时候,需要用到却还不知道值就是通过递归的方式屏蔽掉下面递推的过程,从而可以直接获取到对应值。而递归的好处就是在获取到足够理想的值的情况下,可以很方便的执行短路操作,轻易的实现剪枝。
因此下个版本再次对整体框架进行改变,做到双动态规划+递归,结合之前所有加速的特性再一次尝试加速。
双动态规划 + 递归
构建思路
由于第二版的动态规划的状态定义实在过于拗口,所以改用第一版的动态规划设计。但是在即将获取未被推理过的状态值时,通过递归先推理出该状态值,再使用它。
为了区分状态值是否已被推理,我们设初始值为Integer.MAX_VALUE,若状态值不是该值,则认为该状态被推理过。
双动态规划+递归 代码
/**
* 双动态规划+递归
*/
class Solution7 implements Solution{
public int minCut(String s) {
// 初始参数判断
int n = s.length();
char[] ss = s.toCharArray();
if (n == 0 || isPalindrome(ss, 0, n-1)) {
return 0;
}
else if (n == 2) {
return 1;
}
// 获取回文数组
boolean[][] palindrome = getPalindrome(ss);
// 保存第i个到第j个字符的字符串最少要切割的刀数
int[][] dp = new int[n][n];
// 下面两个循环把所有状态都初始化为Integer.MAX_VALUE
for (int i = 0; i < n; i++) {
dp[0][i] = Integer.MAX_VALUE;
}
for (int i = 1; i < n; i++) {
System.arraycopy(dp[0],0,dp[i], 0, n);
}
// 初始化状态
dp[0][0] = 0;
for (int i = 1; i < n; i++) {
dp[i][i] = 0;
dp[i-1][i] = ss[i-1] == ss[i] ? 0 : 1;
}
// 计算第0到第n-1之间的字符串的最少刀数,即字符串s的最少刀数
dp[0][n-1] = doReversion(ss, dp, 0, n-1, palindrome);
return dp[0][n-1];
}
// 递归求状态
private int doReversion(char[] ss, int[][] dp, int left, int right, boolean[][] palindrome){
// 如果不是最大值,则表示已经求过,直接返回
if (dp[left][right] != Integer.MAX_VALUE)
return dp[left][right];
int n = right;
// 从最左开始遍历开始位置
for (int i = left; i <= n-2; i++) {
// 遍历右边索引,中间的差值为2以上,因为距离为1已被初始化过。
for (int j = i+2; j <= n; j++) {
if (palindrome[i][j]){
dp[i][j] = 0;
continue;
}
// 遍历切割点
for (int k = j-1; k >= i && dp[i][j] > 1; k--) {
if (palindrome[i][k]){
// 递归求dp[k+1][j]
dp[i][j] = Math.min(dp[i][j], doReversion(ss, dp, k+1, j, palindrome) + 1);
}
}
}
}
return dp[left][right];
}
private boolean[][] getPalindrome(char[] ss){
... 动态规划计算回文数组 ...
}
private boolean isPalindrome(char[] ss, int l, int r){
... 判断是否是回文串 ...
}
}
自行测试
测试代码
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution5(), 75, s); // 动态规划 第二版
test(new Solution6(), 75, s); // 双动态规划 第二版
test(new Solution7(), 75, s); // 双动态规划+递归
}
测试结果
结果:对, 耗时:8毫秒
结果:对, 耗时:4毫秒
结果:对, 耗时:542毫秒
ok,变慢了,原因很简单,我们用递归代替了循环,递归效率的确低于循环,因此效率降低了。
双动态规划 + 递归 加速
分析
回想我们写递归的原因,是为了更好的实现短路加速。分析可以短路的位置
-
状态dp(i)(j)若不等于0,则最小值是1,若等于1,则可以停止遍历切割点。
-
判断回文字符串的难度远小于计算状态值得难度,计算状态值的难度与长度有关,我们可以切割点可以从j向i反向推进,可以加速前期遍历的速度。若遇到了最小值1,则可以跳过后面复杂的状态值计算。
双动态规划 + 递归 加速 代码
/**
* 双动态规划 + 递归 加速
*/
class Solution8 implements Solution{
public int minCut(String s) {
.. 这里都一样 ..
}
private int doReversion(char[] ss, int[][] dp, int left, int right, boolean[][] palindrome){
if (dp[left][right] != Integer.MAX_VALUE)
return dp[left][right];
int n = right;
for (int i = left; i <= n-2; i++) {
for (int j = i+2; j <= n; j++) {
if (palindrome[i][j]){
dp[i][j] = 0;
continue;
}
// for (int k = i; k < j; k++) {
for (int k = j-1; k >= i && dp[i][j] > 1; k--) {
if (palindrome[i][k]){
dp[i][j] = Math.min(dp[i][j], doReversion(ss, dp, k+1, j, palindrome) + 1);
}
}
}
}
return dp[left][right];
}
private boolean[][] getPalindrome(char[] ss){
... 动态规划计算回文串数组 ..
}
private boolean isPalindrome(char[] ss, int l, int r){
... 判断是否是回文串 ...
}
}
自行测试
测试代码
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution5(), 75, s); // 动态规划 第二版
test(new Solution6(), 75, s); // 双动态规划 第二版
test(new Solution7(), 75, s); // 双动态规划+递归
test(new Solution8(), 75, s); // 双动态规划+递归 加速
}
测试结果
结果:对, 耗时:8毫秒 // 动态规划 第二版
结果:对, 耗时:5毫秒 // 双动态规划 第二版
结果:对, 耗时:599毫秒 // 双动态规划+递归
结果:对, 耗时:79毫秒 // 双动态规划+递归 加速
ok, not good。还是没有原来的速度快。再看看可不可以加速。
双动态规划 + 递归 Turbo加速
思路
观察三个循环发现,根据短路的思路,j循环(第二层循环)应当从最大值向最小值遍历,因为j在最大值的时候,i到j更接近s本身,即更接近答案的本身,递归本身是为了短路,从更接近答案本身计算某种程度就像投机取巧,万一一试就成功了呢?其他的东西就可以不用再去计算了。
因此我们调整以下递归的方法:
private int doReversion(char[] ss, int[][] dp, int left, int right, boolean[][] palindrome){
if (dp[left][right] != Integer.MAX_VALUE)
return dp[left][right];
int n = right;
for (int i = left; i <= n-2; i++) {
// for (int j = i+2; j <= n; j++) {
for (int j = n; j >= i + 2; j--) {
if (palindrome[i][j]){
dp[i][j] = 0;
continue;
}
for (int k = j-1; k >= i && dp[i][j] > 1; k--) {
if (palindrome[i][k]){
dp[i][j] = Math.min(dp[i][j], doReversion(ss, dp, k+1, j, palindrome) + 1);
}
}
}
}
return dp[left][right];
}
修改后我似乎感觉到不对劲,第二层循环从i+2到n的过程是为了求什么呢?
这个方法递归的初衷就是为了求dp(left)(right)这个状态,如果dp(i)(right)这个状态可以求出,那我们为什么还要求dp(i)(i+2) ~ dp(i)(right - 1)这些状态呢?
回想一下动态规划得知,我们之前的动态规划是通过长度逐渐递增,最终推出想要的值dp(i)(right),即过程是:dp(i)(i+2) -> dp(i)(i+3) -> dp(i)(i+4) -> ... -> dp(i)(right-1) -> dp(i)(right)。但是引入递归后,我们这二个机制已经作废,我们直接通过递归求得 dp(i)(right),一步到位,因此整个j循环完全可以去掉。
想到这,自然而然也会去想i存在的必要性,i当初存在也是为了推出指定长度下各种情况下的状态值,如今j永远指向了最后一个字符,起始字符的位置完全也可以交由递归控制,因此i循环也可去掉。
双动态规划 + 递归 Turbo加速 代码
/**
* 双动态规划 + 递归 Turbo加速
*/
class Solution9 implements Solution{
public int minCut(String s) {
.. 这里都一样 ..
}
private int doReversion(char[] ss, int[][] dp, int left, int right, boolean[][] palindrome){
if (dp[left][right] != Integer.MAX_VALUE)
return dp[left][right];
int n = right;
if (palindrome[left][n]){
dp[left][n] = 0;
}
for (int k = n-1; k >= left && dp[left][n] > 1; k--) {
if (palindrome[left][k]){
// 递归中left和right参数完全可以替代i循环和j循环
dp[left][right] = Math.min(dp[left][n],
doReversion(ss, dp, k+1, n, palindrome) + 1);
}
}
return dp[left][right];
}
private boolean[][] getPalindrome(char[] ss){
... 动态规划计算回文串数组 ..
}
private boolean isPalindrome(char[] ss, int l, int r){
... 判断是否是回文串 ...
}
}
自行测试
测试代码 例子2
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi";
test(new Solution5(), 75, s); // 动态规划 第二版
test(new Solution6(), 75, s); // 双动态规划 第二版
test(new Solution7(), 75, s); // 双动态规划+递归
test(new Solution8(), 75, s); // 双动态规划+递归 加速
test(new Solution9(), 75, s); // 双动态规划+递归 Turbo加速
}
测试结果 例子2
结果:对, 耗时:7毫秒 // 动态规划 第二版
结果:对, 耗时:4毫秒 // 双动态规划 第二版
结果:对, 耗时:545毫秒 // 双动态规划+递归
结果:对, 耗时:60毫秒 // 双动态规划+递归 加速
结果:对, 耗时:0毫秒 // 双动态规划+递归 Turbo加速
amazing!
测试代码 例子3
public static void main(String[] args) {
Solution solution = new Solution8();
String s = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
test(new Solution5(), 1, s); // 动态规划 第二版
test(new Solution6(), 1, s); // 双动态规划 第二版
test(new Solution7(), 1, s); // 双动态规划+递归
test(new Solution8(), 1, s); // 双动态规划+递归 加速
test(new Solution9(), 1, s); // 双动态规划+递归 Turbo加速
}
测试结果 例子3
结果:对, 耗时:43832毫秒 // 动态规划 第二版
结果:对, 耗时:778毫秒 // 双动态规划 第二版
结果:对, 耗时:83391毫秒 // 双动态规划+递归
结果:对, 耗时:1261毫秒 // 双动态规划+递归 加速
结果:对, 耗时:11毫秒 // 双动态规划+递归 Turbo加速
发现双动态规划的效果也是很明显的。
LeetCode
速度上来了,稳!
总结
- 本文章并不是一篇告诉读者LeetCode132题最优算法是什么思路的题,仅仅是为了记录我从完全没有思路到逐渐组织了思路,并逐步优化的的一个过程。一道题一下午,很漫长,但能记录下来也很值得。
- 纵观这个过程,构建框架都是最痛苦的,但改进却是相对容易的,每一次加速只要重新用心理一理逻辑,还是很容易找到可以优化的地方,有些效果大,有些效果小,很难得这一次进行一次比较系统的比较,去感受优化的力度。
- 一般我们都觉得递归的优化是循环,但这里却匪夷所思的从递归替换成循环后又变回了递归,递归有递归的代码优越性,可以更方便思考,更简洁,某种程度上我觉得递归与动态规划是一致的,只用在乎当前状态下的值,该值是怎么得来的并不是当前状态需要考虑的,我们只需要做的是相信这个值是对的,并推出目前需要计算的值。回到这个点开头,递归的确是比循环更加消耗性能,因此相信这里也可以改写成是循环的模式,从而再一次加速。
为什么记忆化穷举失败了?
从最后一个版本的递归中,很容易感受到,其实需要记忆的不仅仅是回文串,还有某个串最少切割的刀数。但由于一开始思想还没有深入到这一步,因此只是简单的想到记录回文串,从而导致该算法的失败。