再开一个新坑,以后会不定期总结一下对于一些基本算法题的解法思路和实现。作为一个AI领域的从业者,不仅对于AI本身的技术要有掌握,对于计算机科学中的基本算法思想也要扎实的基础,这样才能做一个合格的算法工程,本人在这方面的能力还不够成熟,因此开这个坑,希望通过这种方式,真正去理解算法的本质。
本文要总结的题目是找最长回文子串,对应leetcode中的题目如下:
Longest Palindromic Substring
Given a string
s , find the longest palindromic substring in s . You may assume that the maximum length of s is 1000. Example 1: Input: "babad" Output: "bab" Note: "aba" is also a valid answer. Example 2: Input: "cbbd" Output: "bb"
本文主要集中在如何一步一步使用动态规划的方法来解决这个问题并进行优化的过程,有其他更好的解决该问题的方法不在本文中讨论。
最简单思想
首先,这个题目最简单的思想就是暴力遍历所有可能结果,假设当前字符串序列为
具体做法是遍历所有可能长度的子串,判断其是不是回文子串,然后记录其长度,最后返回那个长度最长的子串即可。
暴力搜索的问题
暴力搜索的时间效率肯定是最低的,那么低在哪里呢?我们以
为例,列举暴力搜索的前几个步骤,很明显就能看出来问题:
- 子串长度为1时,遍历所有单个字符 ,此时所有单个字符都是回文。
- 子串长度为2时,遍历所有二元字符子串 ,然后判断每个元素是否为回文,判断依据是
- 子串长度为3时,遍历所有三元字符串 ,这里判断每个元素是否为回文。
- 子串长度为4时,遍历所有四元字符串 ,然后判断每个元素是否为回文。
- 重复上述步骤,直到遍历到 这个长度为n的字符串为止。
进一步分析,其实步骤3时,其实会用到1的结果,因为每个元素中间项
通过1已经知道是回文了,那么只要判断
是否成立就行了。然而步骤3是重复处理了。
同样的,步骤4同样用到了步骤2的结果,对于
,如果知道
是回文,那么只要分析
就可以了。因此步骤4也重复处理。
引入动态规划
通过上面的分析可以知道,暴力搜索时,并不是每一个元素都需要去专门遍历并判断是否为回文,有些元素可以通过历史的结果来帮助判断一个元素是否为回文。同时我们也可以总结一些规律:
假设
是我们要求解的最长的回文子串(其中j>=i),那么它必然符合两个条件:
a.
b.
(j>=i+2)必然也是一个回文子串,否则上述假设不成立。
基于上述规律,我们可以将这个问题进行拆分,分成最基本的优化目标问题单元:即只要到一个回文子串,然后以该子串为中心,左右两边的字符若相等,则找到一个更长的新的回文子串。即从一元字符开始,以该字符为中心向两边扩展的方式,逐渐找到更长的回文子串,有点像穿衣服一样,一层一层往上套。这个就是动态规划的思想,当然我只是用口语化的方式描述了一下,理论化的动态规划肯定更加严谨公式化,但是我就不在这里讨论了,有兴趣的可以自行查阅。
那么如何实现上述的思想呢?前面说了,我要知道
是否为回文,那么主要是需要知道
是否为回文。这里可以将其视作一个填表问题。我们设计一个bool型的n*n的二维矩阵A,如果
是一个回文串,那么
位置就填上true,否则为false。填表的顺序则是从长度为1的字符串开始,逐级向长度更长的字符串填,间而言之就是一种周期性的斜向上方向,具体如图:
其实上图也说明了一个事情,就是我们并不需要把所有表格都填上,而是只需要填满上三角的表格就能得到最终结果。因此,在代码实现的时候,我们可以初始化一个上三角形状的二维数组,这样可以节省一半的空间。(代码实现的时候,我还是初始化了整矩阵,为了与后续优化区分)。
我们要做的,就是当遇到回文串时,不仅在上述表格相应位置填上值,而且需要记录当前子串的起始位置和长度,用于后续的返回结果。
代码实现1
下面给出我写的初步动态规划解法,只列出关键逻辑,完整代码后续会放到github上,到时会贴出来。代码不是很优美,求大佬轻喷。
int s_size = s.size();
vector<vector<bool> > result(s_size, vector<bool> (s_size,false));
初始化一个n*n的bool型矩阵,n为字符串长度。
for(int i=0;i<s_size;i++)
{
result[i][i] = true;
if(i < s_size-1)
{
if (s[i]==s[i+1])
{
result[i][i+1] = true;
cur_max = 2;
start = i;
}
}
}
这里是先填充对角线,以及
上斜线的位置,对角线对应一元字符,这个很好理解,都是回文,而上斜线的位置也很好处理,只要判断
就可以了。同时要记录当前最长回文的起始位置start以及长度cur_max。
for (int distance = 2;distance < s_size;distance++)
{
// cout<<distance;
for (int i = 0;i+distance<s_size;i++)
{
result[i][i+distance] = (result[i+1][i+distance-1]&&(s[i]==s[i+distance]));
if(result[i][i+distance])
{
if(distance+1>cur_max)
{
cur_max = distance+1;
start = i;
end = i+distance;
}
}
}
}
这里最外层的循环表示字符串的长度遍历,里层遍历则是对字符串起始位置的遍历。循环内部的逻辑在上面章节已经介绍过,即根据
是否为回文以及
来判断
是否为回文。同时记录回文子串的起始位置和长度。
进一步优化
上述代码在leetcode运行后的时间和空间成本如下:
可以看到无论是时间成本还是空间成本都是很高的,优化的空间很大呀!
回头看我们的上述解法过程,我们将一个矩阵中一个斜线上的所有位置填表称为一个epoch操作。每轮epoch其实都隐含得使用了前一个epoch的信息,更加具体的说是前一个epoch的某一个位置上的值。我们是否需要一整个二维矩阵表格来填某个epoch的信息呢?答案是不需要!具体原因看下图图示:
具体来说,每次填表格,其实只需要其右下角的表格的值就可以了,而初始的对角线和上斜线位置的判断也是很简单就能实现也不用记录,因此实际上我们不用一个二维矩阵,只需要几个临时变量记录右下角的表格值就可以了。
另外,我们需要变更填表的顺序,之前我们的顺序都是每个epoch斜上方向45度填。这里我们改换成如下的填表顺序:
其中箭头上标的数字代表循环的epoch轮数。即每次我向左斜向上方填表,填到边界时,跳到下一轮的其实位置,接着填。
优化代码实现
代码的话其实注意几个点就行,一个是每一轮遍历的顺序是往左斜向上的,另一个就是每一轮要记录当前轮起始的位置,因为下一轮的起始位置是与前一轮的起始位置和当前轮数相关的,如图:
主要代码逻辑如下:
bool pre_flag = false;
int epoch_start_i = 0;
int epoch_start_j = 0;
这里直接初始化一个临时变量存放右下角的值,然后初始化两个位置变量来存每一个epoch的起始位置。
对于对角线和上斜线的处理就不贴出来了,这里贴一下其他位置的填法:
//根据右下角的表格填当前表格
pre_flag = (pre_flag && s[i]==s[j]);
//如果是回文,且长度最长,则记录起始位置
if(pre_flag && j-i+1>cur_max)
{
cur_max = j-i+1;
start = i;
}
//到边界位置,需要换到下一轮的起始位置
if(i-1<0 || j+1 >=s_size)
{
//本轮起始位置为对角线上
if (epoch_start_i == epoch_start_j)
{
i =epoch_start_i;
j = epoch_start_j+1;
}else
{
//本轮起始位置在上斜线上
i =epoch_start_i+1;
j = epoch_start_j;
}
continue;
}else
{
//向左斜上方向处理
i--;
j++;
continue;
}
最后,上述代码在leetcode上运行的花费如下:
可以看到,无论是时间复杂度,还是空间复杂度,都有了很大的优化。