Java回溯详解(组合、排列、装载问题)

回溯算法有“通用的解题法“之称,但是往往在学习的过程中会有这样几个困惑:

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 + "件货物装入第二艘船");
                }
            }
        }
    }
}

 通过对这三道题真正的了解后,大家应该对回溯法有了进一步的了解,俗话说的好,真正的掌握需要多多的实践,所以从理解到掌握还需要大量的刷题与总结。文章较长,谢谢阅读,有错误之处还望指正,谢谢!

 

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值