回溯算法有“通用的解题法“之称,但是往往在学习的过程中会有这样几个困惑:
1、无法理解其精髓之要
2、理解之后不能写出代码
3、能写出代码之后又不能推广
本篇文章将详细讲解回溯的思想以及组合、排列、装载等三个经典且具有代表性的问题。
本文章涉及基础:
1、Java集合理解应用
2、对数据结构中树的了解
3、递归的概念以及应用(回溯的核心任然是递归)
有了以上知识点会更利于阅读此帖子。
一、回溯法简介
一种优选的搜索法,又称试探法。按选优条件向前搜索,已达到目标。但当搜索到某一步时,发现原选择并不优或者达不到目标,就退一步重新选择。这种走不通就退回再走的技术称为回溯法。
其实很多人对于单独的某一个回溯的算法能理解,但是真正自己面对到问题后没有头绪。这里介绍一个回溯算法的固定模板:
private static void backtracking(List<List<Object>> list, Arraylist<Integer> tempList, int[] a,....)
{
//终止条件,也就是一次结果或者不符合条件
if(false)//false代表条件不符合
return false;
if(true)//当符合需要的结果
list.add(new ArrayList(tempList))//注意这里要重新创建,因为tempList是一个对象,改变的话,会改变结果值。所以重新创建
//对每个值进行回溯
for(int i = start; i < a.length; i++)
{
if(true)//存在某个限定条件的,比如出现重复值,跳过(根据条件限定而存在与否)
continue;
mask(used(i));//将i标记为已使用(根据条件限定而存在与否)
backtracking(list, tempList, a, i+1)//此处的i+1也可以根据实际情况判断题目中的数字是否可以重复使用
unmask(used(i));//回溯完要记得取消掉
tempList.remove(tempList.size() - 1);//回溯回父节点.寻找下一个节点
}
}
好,接下来我们直接在实战中慢慢了解:
题目一:给定一个没有重复数字的序列,返回它的全排列:
例:input=[1,2,3]
ouput:[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
观察这张图,根节点中包含1、2、3,然后我们每一次取一个,框中为剩余的未选择的数字,注意一下我们这道题目中说的是排列,而不是组合,在接下来的一题中会有组合,这张图就是老师或者书本上提及到的“解空间”,可以这么理解。然后我们可以观察,这棵树的每一次深度遍历都是题目对应的一个解,所以我们将所有的深度遍历结果表示出来即可。好,看代码:
import org.junit.Test;
import java.util.*;
/**
* 给定一个没有重复数字的序列,返回它的全排列
* @author wanhailin
* @creat 2022-03-23-21:18
*/
public class test1 {
List<List<Integer>> res = new ArrayList<>();//装所有结果的集合
List<Integer> tmp = new ArrayList<>();//装一条路径的结果
public List<List<Integer>> permute(int[] nums) {
if (nums.length == 0) {
return res;
}
backtracking(nums);
return res;
}
private void backtracking(int[] nums) {
if (tmp.size() == nums.length) {//当单条路径的长度达到数组长度的时候,说明该路径已经完成
res.add(new ArrayList<>(tmp));//将该结果集合加入到总结果集合中
return;
}
for (int i = 0; i < nums.length; i++) {
if (tmp.contains(nums[i]))//遇到使用过的数字就跳过
continue;
tmp.add(nums[i]);//将数组的数字加入到单条结果集合中
backtracking(nums);//递归
tmp.remove(tmp.size() - 1);//回溯到上一层
}
}
@Test
public void test() {
int[] arr1=new int[]{1,2,3};
List<List<Integer>> list=permute(arr1);
System.out.println(list);
}
}
重点在于以下几点:
1、理解递归的这个过程,建议去详细了解递归时候内存创建的过程。
2、理解结果集合产生的过程,首先是每一条路径用List<>存储,然后所有的路径也存储在List<>中,所以最终结果集合是List<List<>>的形式。
3、结合递归然后理解 (tmp.remove(tmp.size() - 1);//回溯到上一层),之所以能够进行回溯,也是由于递归的机制,真实的每一条路径都已经记录,它只是通过这样回退再进入另一条路径。。。以这样的方式遍历完所有的路径。、
题目二:给定一个数组,返回所有的两个数字组合的情况、
例:输入:[1,2,3,4]
输出:[[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
观察这张图,这也就是我们所说的解空间,这里存在的问题是有顺序问题,每一层就相当于选择了一个数字。这里给大家推荐一个哔哩哔哩讲的比较好的视频,视频比文字来的更加直观:带你学透回溯算法-组合问题(对应力扣题目:77.组合)| 回溯法精讲!_哔哩哔哩_bilibili
好,我们来看代码:
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
/**
* 给定一个数组,返回所有两个数字组合的情况
*/
public class test2 {
List<List<Integer>> res = new ArrayList<>();//装总结果的集合
List<Integer> temp = new ArrayList<>();//装单条结果的集合
public List<List<Integer>> f(int[] nums) {
backtracking(4, 2, 1);
return res;
}
public void backtracking(int n, int k, int start_index) {
//start_index为每次递归搜索的起始位置,k是组合的大小,n为传入数组长度
if (temp.size() == k) {
res.add(new ArrayList<>(temp));
return;
}
for (int i = start_index; i <= n; i++) {
temp.add(i);
backtracking(n, k, i + 1);
temp.remove(temp.size() - 1);
}
}
@Test
public void test() {
int[] arr1 = new int[]{1, 2, 3, 4};
List<List<Integer>> lists = f(arr1);
System.out.println(lists);
}
}
重点与问题一相同。
题目三:经典的装载问题,有n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且∑wi <= c1 + c2。
问是否有一个合理的装载方案,可将这n个集装箱装上这2艘轮船。如果有,找出一种装载方案。
问题分析:
如果一个给定装载问题有解,则采用下面的策略可得到最优装载方案。
(1)首先将第一艘轮船尽可能装满;
(2)将剩余的集装箱装上第二艘轮船。
将第一艘轮船尽可能装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近c1。由此可知,装载问题等价于以下特殊的0-1背包问题:
max∑ wi * xi && ∑ wi * xi <= c1, xi ∈ {0, 1}, 1 <= i <= n;
例:
输入:
int[] weight = new int[]{0, 20, 30, 60, 40, 40}; int first_load_weight = 100; int second_load_weight = 100;
输出:
第一艘船的载重量:90
第二艘船的载重量:100
第0件货物装入第一艘船
第1件货物装入第一艘船
第2件货物装入第一艘船
第4件货物装入第一艘船
第3件货物装入第二艘船
第5件货物装入第二艘船
很明显应该用深搜去做这道题,来看一下代码:
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
/**
* 装载问题
*/
public class test3 {
static int n;//货物数量
static int[] weight;//货箱重量数组
static int first_load_weight;//第一艘船的可载重量
static int[] best_way;//已产生的最佳方案
static int best_way_weight = 0;//最佳方案重量
static int[] current_way;//当前装载方案
static int current_way_eight = 0;//当前已装载重量
static int rest;//货物剩余重量
public static int f(int[] w, int f_l_w) {//货物重量数组,第一艘船的重量
//初始化成员变量
n = w.length - 1;//通过货物重量数组获取货物数量
first_load_weight = f_l_w;//初始化第一艘船的载重量
weight = w;//初始化货箱重量数组
current_way_eight = 0;//初始化当前装载重量为0
best_way_weight = 0;//初始化当前最优装载重量为0
current_way = new int[n + 1];//初始化当前装载方案
best_way = new int[n + 1];//初始化最优装载方案
for (int i = 0; i <= n; i++) {
rest += weight[i];//初始化剩余最大量
}
backtracking(1);
return best_way_weight;
}
public static void backtracking(int t) {//遍历的位置
//遍历到叶子节点就结束当前路径
if (t > n) {
if (current_way_eight > best_way_weight) {//判断是否最优
for (int i = 0; i <= n; i++) {
best_way[i] = current_way[i];//替换最优方案重量数组
}
best_way_weight = current_way_eight;//替换最优方案总重量
}
return;
}
rest -= weight[t - 1];//计算剩余货物重量
if (current_way_eight + weight[t - 1] < first_load_weight) {//遍历左子树
current_way[t - 1] = 1;
current_way_eight += weight[t - 1];//增加当前载重量
backtracking(t+1);//递归
current_way_eight -= weight[t - 1];//回溯
}
if (current_way_eight + weight[t - 1] > first_load_weight) {//遍历右子树
current_way[t - 1] = 0;
backtracking(t+1);//递归
}
rest += weight[t - 1];//恢复递归后的剩余量
}
@Test
public void test() {
int[] weight = new int[]{0, 20, 30, 60, 40, 40};
int first_load_weight = 100;
int second_load_weight = 100;
int n = weight.length-1;
f(weight, first_load_weight);
int weight_of_second = 0;
for (int i = 0; i <= n; i++) {
weight_of_second += weight[i] * (1 - best_way[i]);
}
if (weight_of_second > second_load_weight) {
System.out.println("无解");
} else {
System.out.println("第一艘船的载重量:" + best_way_weight);
System.out.println("第二艘船的载重量:" + weight_of_second);
for (int i = 0; i <= n; i++) {
if (best_way[i] == 1) {
System.out.println("第" + i + "件货物装入第一艘船");
}
}
for (int i = 0; i <= n; i++) {
if (best_way[i] == 0) {
System.out.println("第" + i + "件货物装入第二艘船");
}
}
}
}
}
通过对这三道题真正的了解后,大家应该对回溯法有了进一步的了解,俗话说的好,真正的掌握需要多多的实践,所以从理解到掌握还需要大量的刷题与总结。文章较长,谢谢阅读,有错误之处还望指正,谢谢!