本文介绍 LeetCode 题集中,使用回溯法(递归)解决字符串相关的问题。
LeetCode 其他有关回溯法的问题:
LeetCode 题集:回溯法和递归(一)数组相关问题
LeetCode 题集:回溯法和递归(三)矩阵相关问题
131. Palindrome Partitioning(分割回文串)
问题描述
思路与代码
本题思路与数组类问题相似,根据回溯法的思想,从起始位置开始,遍历下一个回文子串的结束位置,然后更新起始位置,递归完成即可。
代码如下:
class Solution:
def partition(self, s: str) -> List[List[str]]:
list_partition = []
partition = []
def is_palindrome(i: int, j: int):
"""
判断子串是否为回文串
:param i: 子串起点
:param j: 子串终点
:return: 是否为回文串
"""
while i < j:
if s[i] != s[j]:
return False
i, j = i + 1, j - 1
return True
def backtrack(pos: int):
"""
回溯法的递归函数
:param pos: 当前起始位置
:return: 无
"""
if pos >= len(s):
list_partition.append(partition.copy())
return
# 从当前位置遍历下一个回文子串
for i in range(pos, len(s)):
if is_palindrome(i=pos, j=i):
partition.append(s[pos: i + 1])
backtrack(pos=i + 1)
partition.pop()
backtrack(pos=0)
return list_partition
运行效果:
算法可以进一步优化,即使用动态规划进行预处理,将每个子串是否为回文串提前计算出来,提高整体效率。
LeetCode 131 官方题解 动态规划预处理
优化后的代码如下:
class Solution:
def partition(self, s: str) -> List[List[str]]:
list_partition = []
partition = []
# 动态规划预处理
n = len(s)
mat_is_pld = [[True for _ in range(n)] for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i + 1, n):
mat_is_pld[i][j] = (s[i] == s[j]) and mat_is_pld[i + 1][j - 1]
def backtrack(pos: int):
"""
回溯法的递归函数
:param pos: 当前起始位置
:return: 无
"""
if pos >= len(s):
list_partition.append(partition.copy())
return
# 从当前位置遍历下一个回文子串
for i in range(pos, len(s)):
if mat_is_pld[pos][i]:
partition.append(s[pos: i + 1])
backtrack(pos=i + 1)
partition.pop()
backtrack(pos=0)
return list_partition
运行效果:
显而易见的是,事先存储回文子串信息,会增加内存(空间)消耗。
132. Palindrome Partitioning II(分割回文串 II)
问题描述
思路与代码
本着 Palindrome Partitioning II 是 Palindrome Partitioning 的变体的思维方式,笔者在第一时间选择修改前一题的代码以求解本题,并且去掉了一些变体问题中不必要的存储:
class Solution:
def minCut(self, s: str) -> int:
if len(s) == 1:
return 0
# 动态规划预处理
n = len(s)
mat_is_pld = [[True for _ in range(n)] for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i + 1, n):
mat_is_pld[i][j] = (s[i] == s[j]) and mat_is_pld[i + 1][j - 1]
def backtrack(pos: int, num_sub: int, min_num_sub: int) -> int:
"""
回溯法的递归函数
:param pos: 当前起始位置
:param num_sub: 当前已切割的子串数
:param min_num_sub: 当前的最小切割子串数
:return: min_num_sub: 当前的最小切割子串数
"""
if pos >= len(s):
min_num_sub = min(num_sub, min_num_sub)
return min_num_sub
# 从当前位置遍历下一个回文子串
# for i in range(pos, len(s)):
for i in range(len(s) - 1, pos - 1, -1):
if mat_is_pld[pos][i]:
if num_sub + 1 >= min_num_sub: # 若当前已切割的子串数超过历史最小值,则直接剪枝
break
num_sub += 1
min_num_sub = backtrack(pos=i + 1, num_sub=num_sub, min_num_sub=min_num_sub)
num_sub -= 1
return min_num_sub
min_cut = backtrack(pos=0, num_sub=0, min_num_sub=n) - 1
return min_cut
提交运行后,发现超时:
于是很乖巧地去请教官方题解,恍然大悟,因为本题不是求所有切割方式,而是最小切割次数,即不是要找到全部解集,而是只要一个最优解,因此动态规划才是更优的方法。事实证明,思维定势要不得呀!
代码如下:
class Solution:
def minCut(self, s: str) -> int:
# 动态规划预处理:字串是否为回文串
n = len(s)
mat_is_pld = [[True for _ in range(n)] for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i + 1, n):
mat_is_pld[i][j] = (s[i] == s[j]) and mat_is_pld[i + 1][j - 1]
# 动态规划求解:最少切割次数
list_min_cut = [i for i in range(n)] # 前 i 个字符组成的子串的最少切割次数
for i in range(n):
if mat_is_pld[0][i]:
list_min_cut[i] = 0
else:
for j in range(i):
if mat_is_pld[j + 1][i]:
list_min_cut[i] = min(list_min_cut[i], list_min_cut[j] + 1)
return list_min_cut[n - 1]
运行效果:
在官网的所有提交记录中,还有更优的方法,该方法也采用动态规划,不同点在于:
- 对两种特殊情况直接输出结果:无需切割(整体为回文串);只需切割 1 次
- 将动态规划预处理的部分与求解部分合二为一
- 在循环过程中,对奇数长度和偶数长度两种情况的回文子串分别处理,通过由中心点向两侧扩展的方式高效切割回文串,以更新动态规划的中间结果列表
代码如下:
class Solution:
def minCut(self, s: str) -> int:
# special case 1: 无需切割
if s == s[:: -1]:
return 0
# special case 2: 只需切割 1 次
for i in range(1, len(s)):
if s[i:] == s[: i - 1: -1] and s[: i] == s[i - 1:: -1]:
return 1
dp = [i for i in range(-1, len(s))] # dp[i] 表示到第 i 位之前的子串的最小切割次数
for i in range(len(s)):
t = 0
while i - t >= 0 and i + t < len(s) and s[i - t] == s[i + t]: # 奇数长度的回文串
dp[i + t + 1] = min(dp[i + t + 1], dp[i - t] + 1)
t = t + 1
t = 0
while i - t >= 0 and i + t + 1 < len(s) and s[i - t] == s[i + t + 1]: # 偶数长度的回文串
dp[i + t + 2] = min(dp[i + t + 2], dp[i - t] + 1)
t = t + 1
return dp[-1]
运行效果:
22. Generate Parentheses(括号生成)
问题描述
思路与代码
本题的目的是穷举出所有可行组合,因此很容易想到回溯法,要点在于右括号不能出现在左括号的左边,即每当加入一个右括号时,其左侧的左括号数量需要多于右括号。
具体代码如下:
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
list_group = []
def backtrack(pos: int, num_left: int, num_right: int, group: str):
if pos == 2 * n:
list_group.append(group)
return
if num_left < n: # 左括号的数量未用完,还可以继续添加左括号
group += '('
backtrack(pos=pos + 1, num_left=num_left + 1, num_right=num_right, group=group)
group = group[: -1]
if num_left > num_right: # 已使用的左括号数量多于右括号,则可以添加右括号
group += ')'
backtrack(pos=pos + 1, num_left=num_left, num_right=num_right + 1, group=group)
group = group[: -1]
backtrack(pos=0, num_left=0, num_right=0, group='')
return list_group
运行效果还不错:
此外,笔者在题解中看到了一种动态规划的方法,也比较有启发:
代码如下:
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
if n == 0:
return []
total_list = list()
total_list.append([None]) # 0 组括号时记为 None
total_list.append(["()"]) # 1 组括号只有一种情况
for i in range(2, n+1): # 开始计算 i 组括号时的括号组合
list_tmp = []
for j in range(i): # 开始遍历 p q ,其中 p + q = i - 1 , j 作为索引
now_list1 = total_list[j] # p = j 时的括号组合情况
now_list2 = total_list[i-1-j] # q = (i - 1) - j 时的括号组合情况
for k1 in now_list1:
for k2 in now_list2:
if not k1:
k1 = ""
if not k2:
k2 = ""
el = "(" + k1 + ")" + k2
list_tmp.append(el) # 把所有可能的情况添加到 list_tmp 中
total_list.append(list_tmp) # list_tmp 就是 i 组括号的所有情况,添加到 total_list 中,继续求解 i = i + 1 的情况
return total_list[n]
运行效果: