字符串
1.131.分割回文串
思路
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
切割问题,也可以抽象为一棵树形结构,如图:
回溯三部曲
- 递归函数参数
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (这两个参数可以放到函数参数里)
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
在回溯算法:求组合总和(二) (opens new window)中我们深入探讨了组合问题什么时候需要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;
}
}
个人理解,这里其实也可以把树形结构看成对分割线位置的选择,startIndex就是下一个可选的分割线位置的起始位置。startIndex至多为 s.size() - 1;
那么这里分割问题实际上可以类比到前面的组合问题,使用 “选择”思想的套路解决。
- 单层搜索的逻辑
来看看在递归循环中如何截取子串呢?
在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。
判断回文子串
双指针法
public boolean isPanlindrome(String s, int left , int right){
while(left < right){
if(s.charAt(left++) != s.charAt(right--)) return false;
}
return true;
}
class Solution {
List<List<String>> res = new ArrayList<>();
List<String> path = new ArrayList<>();
public List<List<String>> partition(String s) {
dfs(s,0);
return res;
}
public void dfs(String s, int startIndex){
if(startIndex >= s.length()) {
res.add(new ArrayList<>(path));
return ;
}
for(int i=startIndex ; i < s.length() ; i++){
//左闭右闭
if(isPanlindrome(s,startIndex,i)){
path.add(new String(s.substring(startIndex,i + 1))); //substring参数左闭右开
}else{
continue;
}
dfs(s,i+1);
path.remove(path.size()-1);
}
}
public boolean isPanlindrome(String s, int left , int right){
while(left < right){
if(s.charAt(left++) != s.charAt(right--)) return false;
}
return true;
}
}
优化
上面的代码还存在一定的优化空间, 在于如何更高效的计算一个子字符串是否是回文字串。上述代码isPalindrome
函数运用双指针的方法来判定对于一个字符串s
, 给定起始下标和终止下标, 截取出的子字符串是否是回文字串。但是其中有一定的重复计算存在:
例如给定字符串"abcde"
, 在已知"bcd"
不是回文字串时, 不再需要去双指针操作"abcde"
而可以直接判定它一定不是回文字串。
具体来说, 给定一个字符串s
, 长度为n
, 它成为回文字串的充分必要条件是s[0] == s[n-1]
且s[1:n-1]
是回文字串。
如果熟悉动态规划这种算法的话, 我们可以高效地事先一次性计算出, 针对一个字符串s
, 它的任何子串是否是回文字串, 然后在我们的回溯函数中直接查询即可, 省去了双指针移动判定这一步骤.
public void isPalindrome(char[] str) {
for (int i = 0; i <= str.length; ++i) {
dp[i][i] = true;
}
for (int i = 1; i < str.length; ++i) {
for (int j = i; j >= 0; --j) {
if (str[j] == str[i]) {
if (i - j <= 1) {
dp[j][i] = true;
} else if (dp[j + 1][i - 1]) {
dp[j][i] = true;
}
}
}
}
}
(待动态规划篇解释)
2.93. 复原 IP 地址
1.参数 : startIndex 表示可以选择的分割线的起始位置
2.返回条件
startIndex >= s.length
if path.size() == 4 , res.add(new ArrayList<>(path)) 返回
else 返回
3.单层逻辑处理
遍历从 从startIndex 到 path.size() - 1 的所有分割位置,如果 s.substring(startIndex, i+1)符合ip规范, path.add(s.substring(startIndex, i+1)) ,递归调用下一层。不合法就break结束本层循环。
这道题和上面的分割字符串的问题实际上是同一类型的问题,都可以看做使用回溯对分割位置进行选择,不同的是上面判断的是回文串,这道题要判断的是是否是合法ip。
判断合法ip相对麻烦,要考虑数的大小、是否右前缀0(包括前缀为0值为0,前缀为0值不为0的情况),而且大数处理也比较麻烦。
编写代码如下:
class Solution {
List<String> res = new ArrayList<>();
List<String> path = new ArrayList<>();
String s;
public List<String> restoreIpAddresses(String s) {
this.s = s;
if(s.length() > 12) return res;
dfs(0);
return res;
}
public void dfs(int startIndex){
if(startIndex == s.length()){
if(path.size() == 4){
StringBuilder sb = new StringBuilder();
for(int i=0 ; i < 3 ; i++){
sb.append(path.get(i)).append(".");
}
sb.append(path.get(3));
res.add(new String(sb));
}
return ;
}
if(startIndex > s.length()) return ;
for(int i = startIndex ; i < s.length() ; i++){
if(isIP(startIndex,i)){
path.add(new String(s.substring(startIndex,i+1)));
dfs(i+1);
path.remove(path.size()-1);
}else{
break;
}
}
}
public boolean isIP(int startIndex , int endIndex){
//传参是左闭右闭
Long value = Long.valueOf(s.substring(startIndex,endIndex+1));//api设计是左闭右开
if(s.charAt(startIndex)=='0'&& value != 0) return false;
if(value == 0 && endIndex!=startIndex) return false;
if(value >= 0 && value <= 255) return true;
return false;
}
}
但这里对isIp的判断不是很好,最好的办法还是在字符串上通过startIndex 和 endIndex进行操作,需要考虑:
- 段位以0为开头的数字不合法
- 段位里有非正整数字符不合法
- 段位如果大于255了不合法
对应代码如下:
// 判断字符串s在左闭又闭区间[start, end]所组成的数字是否合法
bool isValid(const string& s, int start, int end) {
if (start > end) {
return false;
}
if (s[start] == '0' && start != end) { // 0开头的数字不合法
return false;
}
int num = 0;
for (int i = start; i <= end; i++) {
if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法
return false;
}
num = num * 10 + (s[i] - '0');
if (num > 255) { // 如果大于255了不合法
return false;
}
}
return true;
}
子集问题
1.78.子集
子集也是一种组合问题,因为它的集合是无序的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始。
把子集问题的抽象树看做是“选谁”构成的,那么结果应当是树的所有结点。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
回溯三部曲
- 递归函数参数
全局变量数组path为子集收集元素,二维数组result存放子集组合。(也可以放到递归函数参数里)
递归函数参数在上面讲到了,需要startIndex。
代码如下:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIndex) {
- 递归终止条件
从图中可以看出:
剩余集合为空的时候,就是叶子节点。
那么什么时候剩余集合为空呢?
就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了,代码如下:
if (startIndex >= nums.size()) {
return;
}
其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了。
- 单层搜索逻辑
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
那么单层递归逻辑代码如下:
for (int i = startIndex; i < nums.size(); i++) {
path.push_back(nums[i]); // 子集收集元素
backtracking(nums, i + 1); // 注意从i+1开始,元素不重复取
path.pop_back(); // 回溯
}
完整代码如下:
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] nums;
public List<List<Integer>> subsets(int[] nums) {
this.nums = nums;
dfs(0);
return res;
}
public void dfs(int startIndex){
res.add(new ArrayList<>(path));
if(startIndex == nums.length) return ; //条件可以不加,由下面for循环控制
//i == nums.length()时会不进入for循环返回
for(int i=startIndex ; i < nums.length ; i++){
path.add(nums[i]);
dfs(i+1);
path.remove(path.size()-1);
}
}
}
总结
这是一道标准的模板题.
要清楚子集问题和组合问题、分割问题的的区别,子集是收集树形结构中树的所有节点的结果。
而组合问题、分割问题是收集树形结构中叶子节点的结果。
(前提是把抽象树看做是有“选谁”构成的)
2.90.子集II
本题用“取谁”的思想构造回溯树,取每一个结点存到答案里,但是要注意对树层去重。
由于要对树层去重,数组首先要排序。
回溯三部曲:
(1)参数 :startIndex
(2)返回逻辑:每一层先 res.add(new ArrayList<>(path)) , 因为要把每一个节点代表的答案存储到res里,然后当 startIndex >= nums.length return , 也可以这里不return,由for循环控制。
(3)每一层的逻辑:
要注意去重
for(int i = startIndex ; i < nums.length ; i++){
if(i != startIndex && nums[i] == nums[i-1]) continue;
path.add(nums[i]);
dfs(i+1);
path.remove(path.size()-1);
}
完整代码如下:
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] nums;
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
this.nums = nums;
dfs(0);
return res;
}
public void dfs(int startIndex){
res.add(new ArrayList<>(path));
for(int i = startIndex ; i < nums.length ; i++){
if(i != startIndex && nums[i] == nums[i-1]) continue;
path.add(nums[i]);
dfs(i+1);
path.remove(path.size()-1);
}
}
}
3.491.非递减子序列
个人解法
答案的范围是有条件的树的节点
1.参数 startIndex
2.返回条件 由startIndex 的范围 以及 非递减 的条件决定,可以由 for循环负责 , continue 或者 i >= nums.length 时返回
3.单层逻辑
先判断是否把path加入,因为要去重,先加入set,最后遍历set加入res
for循环遍历所有可能,符合递增就dfs,不符合就 return (continue)(相当于剪枝)
代码如下:
class Solution {
public List<List<Integer>> res = new ArrayList<>();
public List<Integer> path = new ArrayList<>();
public int[] nums;
Set<List<Integer>> set = new HashSet<>();
public List<List<Integer>> findSubsequences(int[] nums) {
this.nums = nums;
dfs(0);
for(List<Integer> item : set){
res.add(new ArrayList<>(item));
}
return res;
}
public void dfs(int startIndex){
if(path.size()>=2){
set.add(new ArrayList<>(path));
}
for(int i = startIndex ; i < nums.length ; i++){
if(path.isEmpty() || nums[i] >= path.get(path.size()-1)){
path.add(nums[i]);
dfs(i+1);
path.remove(path.size()-1);
}else{
continue;
}
}
}
}
代码随想录题解:
本题要求返回所有该数组中不同的递增子序列
不能对原数组进行排序的,排完序的数组都是自增子序列了。
所以不能使用之前的去重逻辑!
回溯三部曲
- 递归函数参数
本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置。
- 终止条件
本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以和回溯算法:求子集问题! (opens new window)一样,可以不加终止条件,startIndex每次都会加1,并不会无限递归。
但本题收集结果有所不同,题目要求递增子序列大小至少为2,所以代码如下:
if (path.size() > 1) {
result.push_back(path);
// 注意这里不要加return,因为要取树上的所有节点
}
(个人认为存放答案这个操作是放在递归函数一开始要写的,但是这属于单层处理逻辑,可以放在单层处理里)
- 单层搜索逻辑
在图中可以看出,同一父节点下的同层上使用过的元素就不能再使用了。
解释一下这里仍然从树层上去重,并且方法是同一父节点下的同层上使用过的元素就不能再使用了的原因:(1)从图中可以看出,选择该层其那面使用过的元素,必然会造成答案重复 (2)选择该元素后续所可能组成的所有答案,都可以由前面那个使用过的元素的子树节点所对应的答案得到。
那么单层搜索代码如下:
unordered_set<int> uset; // 使用set来对本层元素进行去重
for (int i = startIndex; i < nums.size(); i++) {
if ((!path.empty() && nums[i] < path.back())
|| uset.find(nums[i]) != uset.end()) {
continue;
}
uset.insert(nums[i]); // 记录这个元素在本层用过了,本层后面不能再用了
path.push_back(nums[i]);
backtracking(nums, i + 1);
path.pop_back();
}
对于已经习惯写回溯的同学,看到递归函数上面的uset.insert(nums[i]);
,下面却没有对应的pop之类的操作,应该很不习惯吧
这也是需要注意的点,unordered_set<int> uset;
是记录本层元素是否重复使用,新的一层uset都会重新定义(清空),所以要知道uset只负责本层!
题目中说"-100 <= nums[i] <= 100"
, 所以也可以用数组做哈希。
所以正如在哈希表:总结篇!(每逢总结必经典) (opens new window)中说的那样,数组,set,map都可以做哈希表,而且数组干的活,map和set都能干,但如果数值范围小的话能用数组尽量用数组。
完整代码如下:
class Solution {
public List<List<Integer>> res = new ArrayList<>();
public List<Integer> path = new ArrayList<>();
public int[] nums;
public List<List<Integer>> findSubsequences(int[] nums) {
this.nums = nums;
dfs(0);
return res;
}
public void dfs(int startIndex){
if(path.size()>=2){
res.add(new ArrayList<>(path));
}
Set<Integer> set = new HashSet<>();
for(int i = startIndex ; i < nums.length ; i++){
if((path.isEmpty() || nums[i] >= path.get(path.size()-1)) && !set.contains(nums[i])){
set.add(nums[i]);
path.add(nums[i]);
dfs(i+1);
path.remove(path.size()-1);
}else{
continue;
}
}
}
}
排列问题
1.46.全排列
- 递归函数参数
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
但排列问题需要一个used数组,标记已经选择的元素,如图橘黄色部分所示:
- 递归终止条件
叶子节点就是收割结果的地方。
当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
- 单层搜索的逻辑
这里和77.组合问题 、131.切割问题 和78.子集问题最大的不同就是for循环里不用startIndex了。
因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。
而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。这里就是防止树枝出现重复元素。
这里used不做参数,做全局变量也可以,做好现场恢复即可。
代码如下:
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] nums;
Set<Integer> set = new HashSet<>();
public List<List<Integer>> permute(int[] nums) {
this.nums = nums;
dfs();
return res;
}
public void dfs(){
if(path.size() == nums.length){
res.add(new ArrayList<>(path));
return ;
}
for(int i = 0 ; i < nums.length ; i++){
if(!set.contains(nums[i])){
set.add(nums[i]);
path.add(nums[i]);
dfs();
path.remove(path.size()-1);
set.remove(nums[i]);
}else{
continue;
}
}
}
}
2.47.全排列 II
使用上一题的解题思路,并结合Set去除,代码如下:
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] nums;
int[] used;
Set<List<Integer>> resultSet = new HashSet<>();
public List<List<Integer>> permuteUnique(int[] nums) {
this.nums = nums;
used = new int[21];// used[i+10] 为 i 的 可用次数
for(int i : nums){
used[i+10]++;
}
dfs();
res.addAll(resultSet);
return res;
}
public void dfs(){
if(path.size() == nums.length){
resultSet.add(new ArrayList<>(path));
return ;
}
for(int i = 0 ; i < nums.length ; i++){
if(used[nums[i]+10]>0){
used[nums[i]+10]--;
path.add(nums[i]);
dfs();
path.remove(path.size()-1);
used[nums[i]+10]++;
}else{
continue;
}
}
}
}
需要注意的是,这里不能够再次使用set来判断树枝上是否使用过该元素来排除结果了,因为题目中给出的数字包括重复数字,应当用哈希思想记录每个元素的可用次数,在用过之后--,回溯回来以后恢复现场++。
也可以先排序后去重:
(注意这里必须要用used数组,因为这是全排列,每次都是从0开始的,不是从startIndex开始的)
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] nums;
boolean[] used;
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums);
this.nums = nums;
used = new boolean[nums.length];
dfs();
return res;
}
public void dfs(){
if(path.size() == nums.length){
res.add(new ArrayList<>(path));
return ;
}
for(int i = 0 ; i < nums.length ; i++){
if( i != 0 && nums[i]==nums[i-1] && used[i-1] == false) continue;
//同时还不能重复选择自己
if(used[i] == false){
used[i] = true;
path.add(nums[i]);
dfs();
path.remove(path.size()-1);
used[i] = false;
}
}
}
这种解法的思想是:先排序,再利用used数组再每个树层上去重,采集每个节点上的结果。同时还因为是全排列,还需要在每个树枝上防止重复使用已经用过的元素。
性能分析
之前并没有分析各个问题的时间复杂度和空间复杂度,这次来说一说。
这块网上的资料鱼龙混杂,一些所谓的经典面试书籍根本不讲回溯算法,算法书籍对这块也避而不谈,感觉就像是算法里模糊的边界。
所以这块就说一说我个人理解,对内容持开放态度,集思广益,欢迎大家来讨论!
子集问题分析:
- 时间复杂度:$O(n × 2^n)$,因为每一个元素的状态无外乎取与不取,所以时间复杂度为$O(2^n)$,构造每一组子集都需要填进数组,又有需要$O(n)$,最终时间复杂度:$O(n × 2^n)$。
- 空间复杂度:$O(n)$,递归深度为n,所以系统栈所用空间为$O(n)$,每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为$O(n)$。
排列问题分析:
- 时间复杂度:$O(n!)$,这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * ..... 1 = n!。每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:
result.push_back(path)
),该操作的复杂度为$O(n)$。所以,最终时间复杂度为:n * n!,简化为$O(n!)$。 - 空间复杂度:$O(n)$,和子集问题同理。
组合问题分析:
- 时间复杂度:$O(n × 2^n)$,组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:$O(n)$,和子集问题同理。
去重总结参考:回溯算法去重问题的另一种写法