参与本项目,贡献其他语言版本的代码,拥抱开源,让更多学习算法的小伙伴们收益!
切割问题其实是一种组合问题!
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例: 输入: "aab" 输出: [ ["aa","b"], ["a","a","b"] ]
《代码随想录》算法视频公开课:131.分割回文串,相信结合视频再看本篇题解,更有助于大家对本题的理解。
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
相信这里不同的切割方式可以搞懵很多同学了。
这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
一些同学可能想不清楚 回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
感受出来了不?
所以切割问题,也可以抽象为一棵树形结构,如图:
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
- 递归函数参数
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (这两个参数可以放到函数参数里)
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
在回溯算法:求组合总和(二)中我们深入探讨了组合问题什么时候需要startIndex,什么时候不需要startIndex。
代码如下:
vector<vector<string>> result;
vector<string> path; // 放已经回文的子串
void backtracking (const string& s, int startIndex) {
- 递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
所以终止条件代码如下:
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
}
- 单层搜索的逻辑
来看看在递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在vector<string> path
中,path用来记录切割过的回文子串。
代码如下:
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // 是回文子串
// 获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else { // 如果不是则直接跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经填在的子串
}
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
那么判断回文的C++代码如下:
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) {
return false;
}
}
return true;
}
如果大家对双指针法有生疏了,传送门:双指针法:总结篇!
此时关键代码已经讲解完毕,整体代码如下(详细注释了)
根据Carl给出的回溯算法模板:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
不难写出如下代码:
class Solution {
private:
vector<vector<string>> result;
vector<string> path; // 放已经回文的子串
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // 是回文子串
// 获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else { // 不是回文,跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经填在的子串
}
}
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) {
return false;
}
}
return true;
}
public:
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
backtracking(s, 0);
return result;
}
};
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n^2)
上面的代码还存在一定的优化空间, 在于如何更高效的计算一个子字符串是否是回文字串。上述代码isPalindrome
函数运用双指针的方法来判定对于一个字符串s
, 给定起始下标和终止下标, 截取出的子字符串是否是回文字串。但是其中有一定的重复计算存在:
例如给定字符串"abcde"
, 在已知"bcd"
不是回文字串时, 不再需要去双指针操作"abcde"
而可以直接判定它一定不是回文字串。
具体来说, 给定一个字符串s
, 长度为n
, 它成为回文字串的充分必要条件是s[0] == s[n-1]
且s[1:n-1]
是回文字串。
大家如果熟悉动态规划这种算法的话, 我们可以高效地事先一次性计算出, 针对一个字符串s
, 它的任何子串是否是回文字串, 然后在我们的回溯函数中直接查询即可, 省去了双指针移动判定这一步骤.
具体参考代码如下:
class Solution {
private:
vector<vector<string>> result;
vector<string> path; // 放已经回文的子串
vector<vector<bool>> isPalindrome; // 放事先计算好的是否回文子串的结果
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome[startIndex][i]) { // 是回文子串
// 获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else { // 不是回文,跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经填在的子串
}
}
void computePalindrome(const string& s) {
// isPalindrome[i][j] 代表 s[i:j](双边包括)是否是回文字串
isPalindrome.resize(s.size(), vector<bool>(s.size(), false)); // 根据字符串s, 刷新布尔矩阵的大小
for (int i = s.size() - 1; i >= 0; i--) {
// 需要倒序计算, 保证在i行时, i+1行已经计算好了
for (int j = i; j < s.size(); j++) {
if (j == i) {isPalindrome[i][j] = true;}
else if (j - i == 1) {isPalindrome[i][j] = (s[i] == s[j]);}
else {isPalindrome[i][j] = (s[i] == s[j] && isPalindrome[i+1][j-1]);}
}
}
}
public:
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
computePalindrome(s);
backtracking(s, 0);
return result;
}
};
这道题目在leetcode上是中等,但可以说是hard的题目了,但是代码其实就是按照模板的样子来的。
那么难究竟难在什么地方呢?
我列出如下几个难点:
- 切割问题可以抽象为组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
我们平时在做难题的时候,总结出来难究竟难在哪里也是一种需要锻炼的能力。
一些同学可能遇到题目比较难,但是不知道题目难在哪里,反正就是很难。其实这样还是思维不够清晰,这种总结的能力需要多接触多锻炼。
本题我相信很多同学主要卡在了第一个难点上:就是不知道如何切割,甚至知道要用回溯法,也不知道如何用。也就是没有体会到按照求组合问题的套路就可以解决切割。
如果意识到这一点,算是重大突破了。接下来就可以对着模板照葫芦画瓢。
但接下来如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
关于模拟切割线,其实就是index是上一层已经确定了的分割线,i是这一层试图寻找的新分割线
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1。
所以本题应该是一道hard题目了。
可能刷过这道题目的录友都没感受到自己原来克服了这么多难点,就把这道题目AC了,这应该叫做无招胜有招,人码合一,哈哈哈。
class Solution {
List<List<String>> lists = new ArrayList<>();
Deque<String> deque = new LinkedList<>();
public List<List<String>> partition(String s) {
backTracking(s, 0);
return lists;
}
private void backTracking(String s, int startIndex) {
//如果起始位置大于s的大小,说明找到了一组分割方案
if (startIndex >= s.length()) {
lists.add(new ArrayList(deque));
return;
}
for (int i = startIndex; i < s.length(); i++) {
//如果是回文子串,则记录
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);
deque.addLast(str);
} else {
continue;
}
//起始位置后移,保证不重复
backTracking(s, i + 1);
deque.removeLast();
}
}
//判断是否是回文串
private boolean isPalindrome(String s, int startIndex, int end) {
for (int i = startIndex, j = end; i < j; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
}
回溯 基本版
class Solution:
def partition(self, s: str) -> List[List[str]]:
'''
递归用于纵向遍历
for循环用于横向遍历
当切割线迭代至字符串末尾,说明找到一种方法
类似组合问题,为了不重复切割同一位置,需要start_index来做标记下一轮递归的起始位置(切割线)
'''
result = []
self.backtracking(s, 0, [], result)
return result
def backtracking(self, s, start_index, path, result ):
# Base Case
if start_index == len(s):
result.append(path[:])
return
# 单层递归逻辑
for i in range(start_index, len(s)):
# 此次比其他组合题目多了一步判断:
# 判断被截取的这一段子串([start_index, i])是否为回文串
if self.is_palindrome(s, start_index, i):
path.append(s[start_index:i+1])
self.backtracking(s, i+1, path, result) # 递归纵向遍历:从下一处进行切割,判断其余是否仍为回文串
path.pop() # 回溯
def is_palindrome(self, s: str, start: int, end: int) -> bool:
i: int = start
j: int = end
while i < j:
if s[i] != s[j]:
return False
i += 1
j -= 1
return True
回溯+优化判定回文函数
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
self.backtracking(s, 0, [], result)
return result
def backtracking(self, s, start_index, path, result ):
# Base Case
if start_index == len(s):
result.append(path[:])
return
# 单层递归逻辑
for i in range(start_index, len(s)):
# 若反序和正序相同,意味着这是回文串
if s[start_index: i + 1] == s[start_index: i + 1][::-1]:
path.append(s[start_index:i+1])
self.backtracking(s, i+1, path, result) # 递归纵向遍历:从下一处进行切割,判断其余是否仍为回文串
path.pop() # 回溯
回溯+高效判断回文子串
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
isPalindrome = [[False] * len(s) for _ in range(len(s))] # 初始化isPalindrome矩阵
self.computePalindrome(s, isPalindrome)
self.backtracking(s, 0, [], result, isPalindrome)
return result
def backtracking(self, s, startIndex, path, result, isPalindrome):
if startIndex >= len(s):
result.append(path[:])
return
for i in range(startIndex, len(s)):
if isPalindrome[startIndex][i]: # 是回文子串
substring = s[startIndex:i + 1]
path.append(substring)
self.backtracking(s, i + 1, path, result, isPalindrome) # 寻找i+1为起始位置的子串
path.pop() # 回溯过程,弹出本次已经填在的子串
def computePalindrome(self, s, isPalindrome):
for i in range(len(s) - 1, -1, -1): # 需要倒序计算,保证在i行时,i+1行已经计算好了
for j in range(i, len(s)):
if j == i:
isPalindrome[i][j] = True
elif j - i == 1:
isPalindrome[i][j] = (s[i] == s[j])
else:
isPalindrome[i][j] = (s[i] == s[j] and isPalindrome[i+1][j-1])
回溯+使用all函数判断回文子串
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
self.partition_helper(s, 0, [], result)
return result
def partition_helper(self, s, start_index, path, result):
if start_index == len(s):
result.append(path[:])
return
for i in range(start_index + 1, len(s) + 1):
sub = s[start_index:i]
if self.isPalindrome(sub):
path.append(sub)
self.partition_helper(s, i, path, result)
path.pop()
def isPalindrome(self, s):
return all(s[i] == s[len(s) - 1 - i] for i in range(len(s) // 2))
var (
path []string // 放已经回文的子串
res [][]string
)
func partition(s string) [][]string {
path, res = make([]string, 0), make([][]string, 0)
dfs(s, 0)
return res
}
func dfs(s string, start int) {
if start == len(s) { // 如果起始位置等于s的大小,说明已经找到了一组分割方案了
tmp := make([]string, len(path))
copy(tmp, path)
res = append(res, tmp)
return
}
for i := start; i < len(s); i++ {
str := s[start : i+1]
if isPalindrome(str) { // 是回文子串
path = append(path, str)
dfs(s, i+1) // 寻找i+1为起始位置的子串
path = path[:len(path)-1] // 回溯过程,弹出本次已经填在的子串
}
}
}
func isPalindrome(s string) bool {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
if s[i] != s[j] {
return false
}
}
return true
}
/**
* @param {string} s
* @return {string[][]}
*/
const isPalindrome = (s, l, r) => {
for (let i = l, j = r; i < j; i++, j--) {
if(s[i] !== s[j]) return false;
}
return true;
}
var partition = function(s) {
const res = [], path = [], len = s.length;
backtracking(0);
return res;
function backtracking(startIndex) {
if(startIndex >= len) {
res.push(Array.from(path));
return;
}
for(let i = startIndex; i < len; i++) {
if(!isPalindrome(s, startIndex, i)) continue;
path.push(s.slice(startIndex, i + 1));
backtracking(i + 1);
path.pop();
}
}
};
function partition(s: string): string[][] {
const res: string[][] = []
const path: string[] = []
const isHuiwen = (
str: string,
startIndex: number,
endIndex: number
): boolean => {
for (; startIndex < endIndex; startIndex++, endIndex--) {
if (str[startIndex] !== str[endIndex]) {
return false
}
}
return true
}
const rec = (str: string, index: number): void => {
if (index >= str.length) {
res.push([...path])
return
}
for (let i = index; i < str.length; i++) {
if (!isHuiwen(str, index, i)) {
continue
}
path.push(str.substring(index, i + 1))
rec(str, i + 1)
path.pop()
}
}
rec(s, 0)
return res
};
char** path;
int pathTop;
char*** ans;
int ansTop = 0;
int* ansSize;
//将path中的字符串全部复制到ans中
void copy() {
//创建一个临时tempPath保存path中的字符串
char** tempPath = (char**)malloc(sizeof(char*) * pathTop);
int i;
for(i = 0; i < pathTop; i++) {
tempPath[i] = path[i];
}
//保存tempPath
ans[ansTop] = tempPath;
//将当前path的长度(pathTop)保存在ansSize中
ansSize[ansTop++] = pathTop;
}
//判断字符串是否为回文字符串
bool isPalindrome(char* str, int startIndex, int endIndex) {
//双指针法:当endIndex(右指针)的值比startIndex(左指针)大时进行遍历
while(endIndex >= startIndex) {
//若左指针和右指针指向元素不一样,返回False
if(str[endIndex--] != str[startIndex++])
return 0;
}
return 1;
}
//切割从startIndex到endIndex子字符串
char* cutString(char* str, int startIndex, int endIndex) {
//开辟字符串的空间
char* tempString = (char*)malloc(sizeof(char) * (endIndex - startIndex + 2));
int i;
int index = 0;
//复制子字符串
for(i = startIndex; i <= endIndex; i++)
tempString[index++] = str[i];
//用'\0'作为字符串结尾
tempString[index] = '\0';
return tempString;
}
void backTracking(char* str, int strLen, int startIndex) {
if(startIndex >= strLen) {
//将path拷贝到ans中
copy();
return ;
}
int i;
for(i = startIndex; i < strLen; i++) {
//若从subString到i的子串是回文字符串,将其放入path中
if(isPalindrome(str, startIndex, i)) {
path[pathTop++] = cutString(str, startIndex, i);
}
//若从startIndex到i的子串不为回文字符串,跳过这一层
else {
continue;
}
//递归判断下一层
backTracking(str, strLen, i + 1);
//回溯,将path中最后一位元素弹出
pathTop--;
}
}
char*** partition(char* s, int* returnSize, int** returnColumnSizes){
int strLen = strlen(s);
//因为path中的字符串最多为strLen个(即单个字符的回文字符串),所以开辟strLen个char*空间
path = (char**)malloc(sizeof(char*) * strLen);
//存放path中的数组结果
ans = (char***)malloc(sizeof(char**) * 40000);
//存放ans数组中每一个char**数组的长度
ansSize = (int*)malloc(sizeof(int) * 40000);
ansTop = pathTop = 0;
//回溯函数
backTracking(s, strLen, 0);
//将ansTop设置为ans数组的长度
*returnSize = ansTop;
//设置ans数组中每一个数组的长度
*returnColumnSizes = (int*)malloc(sizeof(int) * ansTop);
int i;
for(i = 0; i < ansTop; ++i) {
(*returnColumnSizes)[i] = ansSize[i];
}
return ans;
}
func partition(_ s: String) -> [[String]] {
// 把字符串转为字符数组以便于通过索引访问和取子串
let s = Array(s)
// 使用双指针法判断子串是否回文
func isPalindrome(start: Int, end: Int) -> Bool {
var start = start, end = end
while start < end {
if s[start] != s[end] { return false }
start += 1
end -= 1
}
return true
}
var result = [[String]]()
var path = [String]() // 切割方案
func backtracking(startIndex: Int) {
// 终止条件,收集结果
guard startIndex < s.count else {
result.append(path)
return
}
for i in startIndex ..< s.count {
// 回文则收集,否则跳过
guard isPalindrome(start: startIndex, end: i) else { continue }
let substring = String(s[startIndex ... i])
path.append(substring) // 处理
backtracking(startIndex: i + 1) // 寻找下一个起始位置的子串
if !path.isEmpty { path.removeLast() } // 回溯
}
}
backtracking(startIndex: 0)
return result
}
回溯+函数判断回文串
impl Solution {
pub fn partition(s: String) -> Vec<Vec<String>> {
let mut ret = vec![];
let mut path = vec![];
let sub_str: Vec<char> = s.chars().collect();
Self::backtracing(&sub_str, 0, &mut ret, &mut path);
ret
}
fn backtracing(sub_str: &Vec<char>, start: usize, ret: &mut Vec<Vec<String>>, path: &mut Vec<String>) {
//如果起始位置大于s的大小,说明找到了一组分割方案
if start >= sub_str.len() {
ret.push(path.clone());
return;
}
for i in start..sub_str.len() {
if !Self::is_palindrome(sub_str, start, i) {
continue;
}
//如果是回文子串,则记录
let s: String = sub_str[start..i+1].into_iter().collect();
path.push(s);
//起始位置后移,保证不重复
Self::backtracing(sub_str, i+1, ret, path);
path.pop();
}
}
fn is_palindrome(s: &Vec<char>, start: usize, end: usize) -> bool {
let (mut start, mut end) = (start, end);
while start < end {
if s[start] != s[end] {
return false;
}
start += 1;
end -= 1;
}
true
}
}
回溯+动态规划预处理判断回文串
impl Solution {
pub fn backtracking(is_palindrome: &Vec<Vec<bool>>, result: &mut Vec<Vec<String>>, path: &mut Vec<String>, s: &Vec<char>, start_index: usize) {
let len = s.len();
if start_index >= len {
result.push(path.to_vec());
return;
}
for i in start_index..len {
if is_palindrome[start_index][i] { path.push(s[start_index..=i].iter().collect::<String>()); } else { continue; }
Self::backtracking(is_palindrome, result, path, s, i + 1);
path.pop();
}
}
pub fn partition(s: String) -> Vec<Vec<String>> {
let mut result: Vec<Vec<String>> = Vec::new();
let mut path: Vec<String> = Vec::new();
let s = s.chars().collect::<Vec<char>>();
let len: usize = s.len();
// 使用动态规划预先打表
// 当且仅当其为空串(i>j),或其长度为1(i=j),或者首尾字符相同且(s[i+1..j−1])时为回文串
let mut is_palindrome = vec![vec![true; len]; len];
for i in (0..len).rev() {
for j in (i + 1)..len {
is_palindrome[i][j] = s[i] == s[j] && is_palindrome[i + 1][j - 1];
}
}
Self::backtracking(&is_palindrome, &mut result, &mut path, &s, 0);
result
}
}
object Solution {
import scala.collection.mutable
def partition(s: String): List[List[String]] = {
var result = mutable.ListBuffer[List[String]]()
var path = mutable.ListBuffer[String]()
// 判断字符串是否回文
def isPalindrome(start: Int, end: Int): Boolean = {
var (left, right) = (start, end)
while (left < right) {
if (s(left) != s(right)) return false
left += 1
right -= 1
}
true
}
// 回溯算法
def backtracking(startIndex: Int): Unit = {
if (startIndex >= s.size) {
result.append(path.toList)
return
}
// 添加循环守卫,如果当前分割是回文子串则进入回溯
for (i <- startIndex until s.size if isPalindrome(startIndex, i)) {
path.append(s.substring(startIndex, i + 1))
backtracking(i + 1)
path = path.take(path.size - 1)
}
}
backtracking(0)
result.toList
}
}