回溯算法实际上也是一种暴力算法,利用树型结构的回溯与剪枝从而解决问题
解题步骤主要分三步:1.确立回溯函数的参数 2.确立终止条件 3.确立单层遍历逻辑
组合问题
77. 组合
这道题目就是经典的组合问题
如果我们使用for循环来进行暴力求解,那么当n和k越来越多,我们嵌套的for循环也就会越来越多
所以我们使用回溯算法来解决,我们先画出一个树状图
那么解决回溯算法的核心就在于使用for循环来进行横向遍历,使用递归回溯来完成纵向遍历
画图举例
使用递归完成纵向遍历
使用for循环完成横向遍历
1.确立回溯函数的参数:
要传入数组,为了让函数知道下一次遍历应该从哪开始,所以也需要传入起始坐标
public void backtracking(int[] nums,int startIndex)
2.确立终止条件:当我们的path的长度到达2时就将path添加到result里面去
3.确立单层遍历逻辑: 设置for循环使得数组横向遍历
for(int i = startIndex;i < nums.length;i++){
path.add(nums[i]);
backtracing(nums,i+1);
path.removeLast();
}
最终代码如下
class Solution {
List<List<Integer>> result= new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n,k,1);
return result;
}
public void backtracking(int n,int k,int startIndex){
if (path.size() == k){
result.add(new ArrayList<>(path));
return;
}
for (int i =startIndex;i<=n;i++){
path.add(i);
backtracking(n,k,i+1);
path.removeLast();
}
}
}
216. 组合总和 III
这题的解题思路实际上和上一题是一样的,虽然题目中写道:每个数字 最多使用一次,但实际上我们使用横向遍历的时候每一次遍历的数字都是不同的,所以我们本题在上一题的基础上只需要改变 2.确立终止条件 即可
2.确立终止条件:
if (path.size() == k) {
if (sum == targetSum) result.add(new ArrayList<>(path));
return;
}
其余思路不变,具体代码如下:
class Solution {
List<List<Integer>> result = new ArrayList();
LinkedList<Integer> path = new LinkedList();
public List<List<Integer>> combinationSum3(int k, int n) {
combinationSum3Helper(k,n,1);
return result;
}
public void combinationSum3Helper(int k,int n,int startIndex){
if(path.size() == k){
int sum = 0;
for(int i : path){
sum += i;
}
if(sum == n){
result.add(new ArrayList(path));
}
return;
}
for(int i = startIndex;i <= 9;i++){
path.add(i);
combinationSum3Helper(k,n,i+1);
path.removeLast();
}
}
}
17. 电话号码的字母组合
首先画出本题的树状图
1.确立回溯函数的参数:
首先要知道每个按键所对应的字母组合所以要传入一个String数组;
当我们选择了 2 这个按键,我们还需要知道下一个按键的位置,所以要传入按键位置以及题目所给定的数字组合;
当然我们还需要下标来确认遍历的位置在哪所以也需要startIndex,但是本题的startIndex实际上就是按键位置,所以代码如下:
public void letterCombinationsHelper(String digits,String[] numsString,int num)
2.确立终止条件
当我们的按键数字与我们的数字组合长度相同时那么说明此时就到叶子节点需要进行返回了
所以代码如下:
if(num == digits.length()){
result.add(stringBuffer.toString());
return;
}
3.确立单层遍历逻辑
我们首先要有一个String来接收我们的字母组合,然后再利用for循环的横向遍历来手机不同的字母组合
所以代码如下:
String str = numsString[digits.charAt(num) - '0'];
for(int i = 0;i < str.length();i++){
stringBuffer.append(str.charAt(i));
letterCombinationsHelper(digits,numsString,num + 1);
stringBuffer.deleteCharAt(stringBuffer.length() - 1);
}
最后完整代码如下:
class Solution {
List<String> result = new ArrayList();
public List<String> letterCombinations(String digits) {
if(digits == null || digits.length() == 0){
return result;
}
String[] numsString = {" "," ","abc","def","ghi","jkl","mno","pqrs","tuv","wxzy"};
letterCombinationsHelper(digits,numsString,0);
return result;
}
StringBuffer stringBuffer = new StringBuffer();
public void letterCombinationsHelper(String digits,String[] numsString,int num){
if(num == digits.length()){
result.add(stringBuffer.toString());
return;
}
String str = numsString[digits.charAt(num) - '0'];
for(int i = 0;i < str.length();i++){
stringBuffer.append(str.charAt(i));
letterCombinationsHelper(digits,numsString,num + 1);
stringBuffer.deleteCharAt(stringBuffer.length() - 1);
}
}
}
组合总和问题
39. 组合总和
这题的解题思路实际上还是用到回溯算法,但是我们在纵向遍历的时候需要时注意的是传递到下一层递归的起始位置与传递的起始位置相同,原因在于题目中要求的是不同的组合,也就是说加入我们从3开始进行纵向遍历,那么下一层递归的起始位置也是3
首先画出树状图:
1.确立回溯函数的参数:首先要传递数组以及题目要求的目标值,然后传递起始位置,然后还需要知道每一层的总和是多少(这里传不传其实都可以)
public void combinationSumHelper(int[] candidates,int sum,int target,int index)
2.确立终止条件:当总和达到目标值时就添加到ans中并且返回
if(sum == target){
result.add(new ArrayList(path));
return;
}
3.确立单层遍历逻辑:
我们在确定单层遍历逻辑的时候需要进行剪枝操作陷入死循环,也就是说,当总和以及超过目标值时就应该直接返回
for(int i = index;i < candidates.length;i++){
if(sum + candidates[i] > target){
break;
}
sum += candidates[i];
path.add(candidates[i]);
combinationSumHelper(candidates,sum,target,i);
path.removeLast();
sum -= candidates[i];
}
最后完整代码如下
class Solution {
List<List<Integer>> result = new ArrayList();
LinkedList<Integer> path = new LinkedList();
int sum = 0;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
if(candidates.length == 0){
return result;
}
Arrays.sort(candidates);
combinationSumHelper(candidates,target,0);
return result;
}
public void combinationSumHelper(int[] candidates,int target,int index){
if(sum == target){
result.add(new ArrayList(path));
return;
}
for(int i = index;i < candidates.length;i++){
if(sum + candidates[i] > target){
break;
}
sum += candidates[i];
path.add(candidates[i]);
combinationSumHelper(candidates,target,i);
path.removeLast();
sum -= candidates[i];
}
}
}
40. 组合总和 II
这题实际上和上面那题大体思路上是一样的,但是这题的要求是每个组合中不能出现重复的数字
这里的重复的数字实际上指的是数组中重复的下标所对应的数字
这也解释了为什么能出现 [1,1,6] 这种组合
那么为了解决不出现重复的下标所对应的数字,我们使用startIndex来标记每次开始的下标位置哪,从而避免重复出现重复的下标所对应的数字
同时还要求解集不能包含重复的组合,那么我们可以将数组进行排序,当遇到相同的数字时就跳过知道遇到不同的数字,这样就可以不包含重复的组合,因为当两个数字相同的时候,那么他们两个对应的组合一定会有重复的。
1.确立回溯函数的参数:
这题我们只需要知道每层递归的起始位置在哪即可
public void combinationSum2Helper(int[] candidates,int target,int index)
2.确立终止条件
当我们的总和达到了目标值,那么就将结果添加到ans中并且返回
if(sum == target){
result.add(new ArrayList(path));
}
3.确立单层遍历逻辑
同样的,为了避免死循环,当总和超过目标值就直接跳出循环直接返回了
if(sum + candidates[i] > target){
break;
}
当我们在单层遍历的过程中需要确定该下标是否被使用过
if ( i > start && candidates[i] == candidates[i - 1] ) {
continue;
}
这里为什么 i 是 i > start 而不是 i > 0 呢?如果是 i > 0的话那么就一定会省略掉 [1,1,6] 这种组合
当我们设置成 i > start 以后既能够保证在同一层里面不出现重复的组合又能保证不会省略掉像[1,1,6] 这种组合
for ( int i = start; i < candidates.length; i++ ) {
if(sum + candidates[i] <= target){
break;
}
if (i > start && candidates[i] == candidates[i - 1] ) {
continue;
}
sum += candidates[i];
path.add( candidates[i] );
// i+1 代表当前组内元素只选取一次
backTracking( candidates, target, i + 1 );
sum -= candidates[i];
path.removeLast();
}
全部代码如下:
class Solution {
List<List<Integer>> result = new ArrayList();
LinkedList<Integer> path = new LinkedList();
int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
use = new boolean[candidates.length];
Arrays.fill(use,false);
Arrays.sort(candidates);
combinationSum2Helper(candidates,target,0);
return result;
}
public void combinationSum2Helper(int[] candidates,int target,int index){
if(sum == target){
result.add(new ArrayList(path));
}
for(int i = index;i < candidates.length;i++){
if(sum + candidates[i] > target){
break;
}
if(i > 0 && candidates[i] == candidates[i-1] && !use[i-1]){
continue;
}
sum += candidates[i];
path.add(candidates[i]);
combinationSum2Helper(candidates,target,i+1);
sum -= candidates[i];
path.removeLast();
}
}
}
组合分割问题
组合分割问题的核心在于分割,我们通常使用 startIndex 下标来作为分割线,从而达到分割的效果,通常这类题目的解法分为三步,
1.使用for循环完成横向遍历 使用回溯完成纵向遍历(基本的回溯操作)
2.在每一层内使用函数判断分割后的字符串是否合法
3.达到终止条件以后添加到结果内
131. 分割回文串
这道题的核心问题在于如何分割,我们使用startIndex来完成分割,通过告诉下一层的起始位置在哪从而完成分割字符串
首先我们画出树状图
可以看到我们通过不停的使用startIndex就可以完成对字符串的分割,将字符串分割成不同的子串,再将子串放进判断是否合法的函数内进行判断,如果合法就放进收集结果的链表内,如果 不合法那么就将移动分割线到 下一位
for(int i = startIndex;i < s.length();i++){
if(isPartition(s,startIndex,i)){
String str = s.substring(startIndex,i+1);
dq.addLast(str);
}else{
continue;
}
partitionHelper(s,i+1);
dq.removeLast();
}
对于是否合法的函数需要根据题意来编写代码
public boolean isPartition(String s,int startIndex,int endIndex){
int left = startIndex;
int right = endIndex;
while(left <= right){
if(s.charAt(left) != s.charAt(right)){
return false;
}
left++;
right--;
}
return true;
}
当我们的分割线超过了字符串的长度时,此时就已经分割完毕了所以我们要将收集到的结果放进结果集内
if(startIndex >= s.length()){
ans.add(new ArrayList(dq));
return;
}
最后贴上完整代码
class Solution {
List<List<String>> ans = new ArrayList();
Deque<String> dq = new LinkedList();
public List<List<String>> partition(String s) {
partitionHelper(s,0);
return ans;
}
public void partitionHelper(String s,int startIndex){
if(startIndex >= s.length()){
ans.add(new ArrayList(dq));
return;
}
for(int i = startIndex;i < s.length();i++){
if(isPartition(s,startIndex,i)){
String str = s.substring(startIndex,i+1);
dq.addLast(str);
}else{
continue;
}
partitionHelper(s,i+1);
dq.removeLast();
}
}
public boolean isPartition(String s,int startIndex,int endIndex){
int left = startIndex;
int right = endIndex;
while(left <= right){
if(s.charAt(left) != s.charAt(right)){
return false;
}
left++;
right--;
}
return true;
}
}
93. 复原 IP 地址
这道题目实际上还是分割问题,我们根据刚才的思路将树状图画出来(关键的分支)
本题的关键在于合法IP地址的判断函数,题目要求 每个整数位于 0
到 255
之间组成,且不能含有前导 0,当前导为0时要求该整数只能为0
所以我们合法IP地址判断应该这么写
public boolean isVaild(StringBuffer stringBuffer,int start,int end){
String s = stringBuffer.toString();
if(start > end){
return false;
}
char[] str = s.toCharArray();
if(start != end && str[start] == '0'){
return false;
}
int num = 0;
for(int i = start;i <= end;i++){
if(str[i] > '9' || str[i] < '0'){
return false;
}
int number = str[i] - '0';
num = num * 10 + number;
if(num > 255){
return false;
}
}
return true;
}
我们的回溯函数里面for循环的代码编写同之前的解法是一样的,首先判断该子串是否合法,如果合法就进入到下一层递归进行分割,如果不合法那么就直接跳出递归,回溯到上一层
for(int i = startIndex;i < str.length();i++){
if(isVaild(str,startIndex,i)){
str.insert(i+1,".");
restoreIpAddressesHelper(str,i+2,pointNums+1);
str.deleteCharAt(i+1);
}else{
break;
}
}
我们的终止条件是当字符串中出现了三个分割点时就添加到结果集中,这里要注意的是由于我们判断子串是否合法的时候判断的是分割点前面的子串而不是分割点后面的子串所以很有可能会出现下面这种情况
所以为了防止这种情况的发生,我们需要在终止条件的地方再次进行判断
if(pointNums == 3){
if(isVaild(str,startIndex,str.length()-1)){
ans.add(str.toString());
}
return;
}
总体代码如下
class Solution {
List<String> ans = new ArrayList();
public List<String> restoreIpAddresses(String s) {
StringBuffer str = new StringBuffer(s);
restoreIpAddressesHelper(str,0,0);
return ans;
}
public void restoreIpAddressesHelper(StringBuffer str,int startIndex,int pointNums){
//如果有三个点那么就直接返回了
if(pointNums == 3){
if(isVaild(str,startIndex,str.length()-1)){
ans.add(str.toString());
}
return;
}
for(int i = startIndex;i < str.length();i++){
if(isVaild(str,startIndex,i)){
str.insert(i+1,".");
restoreIpAddressesHelper(str,i+2,pointNums+1);
str.deleteCharAt(i+1);
}else{
break;
}
}
}
public boolean isVaild(StringBuffer stringBuffer,int start,int end){
String s = stringBuffer.toString();
if(start > end){
return false;
}
char[] str = s.toCharArray();
if(start = end && str[start] == '0'){
return false;
}
int num = 0;
for(int i = start;i <= end;i++){
if(str[i] > '9' || str[i] < '0'){
return false;
}
int number = str[i] - '0';
num = num * 10 + number;
if(num > 255){
return false;
}
}
return true;
}
}