zl程序教程

您现在的位置是:首页 >  其他

当前栏目

小白易懂的回溯算法!!!

2023-03-14 22:50:55 时间

1.什么是回溯算法?

  • 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。

回溯算法解题套路模板:

  • 1.回溯出口,当找到了一个问题的解时,存储该解。
  • 2.回溯主体,就是遍历当前的状态的所有子节点,并判断下一个状态是否是满足问题条件的,如果满足问题条件,那么进入下一个状态。
  • 3.状态返回,如果当前状态不满足条件,那么返回到前一个状态。
res = []    # 定义全局变量保存最终结果
state = []  # 定义状态变量保存当前状态
p,q,r       # 定义条件变量(一般条件变量就是题目直接给的参数)
def back(状态,条件1,条件2,……):
    if # 不满足合法条件(可以说是剪枝)
        return
    elif # 状态满足最终要求
        res.append(state)   # 加入结果
        return 
    # 主要递归过程,一般是带有 循环体 或者 条件体
    for # 满足执行条件
    if  # 满足执行条件
        back(状态,条件1,条件2,……)
back(状态,条件1,条件2,……)
return res
  • 简化版
public void backtrack(...){
	if(是一个可行解) 将结果存入集合中;
	if(无备选项或无须进一步搜索) return;
	
	for(所有的备选项){
		if(该备选项不满足约束) continue;
		生成当前节点;
		更新集合;
		子问题backtrack();
		//状态回退
		删除节点;
		回退集合;
	}
}

实战出真知,下面请看例题: 例1: 46. 全排列

  • 1.出口:当temp列表的大小和给定数组的长度一致时,说明形成了一个可行结果,需要存入ret中。同时两者数值大小一致,也说明再无备选项,搜索应该回溯到上一步。
  • 2.主体: 筛选满足约束的备选项。这里用到了一个布尔数组uesd,用来记录哪些数是已经被使用了的。显然我们应该选取那些未被使用过的数。布尔数组的技巧非常实用,应该记住。
  • 3.返回: 产生子问题并求解,求解完后恢复求解之前的状态。子问题求解前需要更新布尔数组used和暂存列表temp,子问题求解完以后,需要恢复used和temp之前的状态。
#include <iostream>
#include <vector>
using namespace std;   
class Solution {
public:
    vector<vector<int>> permute(vector<int>& nums)
    {
        vector<vector<int>> ret;
        vector<bool> used(nums.size(), false);
        backTrack(nums, ret,new vector<int>,used);
        return ret;
    }
    void backTrack(vector<int>& nums, vector<vector<int>>& ret, vector<int>* temp, vector<bool>& used)
    {
        //当temp数组存储元素个数等于size时,说明找到了一个解

        if (temp->size() == nums.size())
        {
            ret.push_back(*temp);
        }
        else
        {
           //遍历所有可能子节点
            for (int i = 0; i < nums.size(); i++)
            {
                //如果当前子节点已经被访问过,那么就跳过该子节点,访问下一个子节点
                if (used[i]== true)
                    continue;    
                //设置当前没有访问过的子节点为访问过
                used[i] = true;
                //并加入temp数组
                temp->push_back(nums[i]);
                //temp的数组个数还不等于size,无法构成一个完整的解,因此需要通过递归找到剩余解
                backTrack(nums,ret,temp, used);
                //进行回退操作,将当前子节点的访问性改成未被访问过
                used[i] = false;
                //删除temp数组尾部元素,弹出当前子节点,因为当前子节点不符合规则
                temp->pop_back();
            }
        }

    }
};
void test()
{
    Solution s;
    vector<int> nums = { 1,2,3 };
    vector<vector<int>> ret=s.permute(nums);
    for (int i = 0; i < ret.size(); i++)
    {
        for (int j = 0; j < ret[0].size(); j++)
        {
            cout << ret[i][j] << " ";
        }
        cout << endl;
    }
}
int main() 
{          
    test();
    system("pause");
    return 0;
}

官方解法:

  • 要排数组分两半,左排过,右没排
  • 通过交换,来得到不同的排列组合
class Solution {
public:
    void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len){
        // 所有数都填完了
        if (first == len) {
            res.emplace_back(output);
            return;
        }
        for (int i = first; i < len; ++i) {
            // 动态维护数组
            swap(output[i], output[first]);
            // 继续递归填下一个数
            backtrack(res, output, first + 1, len);
            // 撤销操作
            swap(output[i], output[first]);
        }
    }
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int> > res;
        backtrack(res, nums, 0, (int)nums.size());
        return res;
    }
};

例2: 47. 全排列 II

相较于上一题全排列,这题最大的难点在于如何解决重复数字的排列问题。 如果继续采用上一题的解法,在输入为[1,1,2]的情况下,[2,1,1]这种结果会出现2次,显然不满足题目要求。 这里我们不妨回顾一下排列公式和组合公式。A(3,4) = 432,而C(3,4) = 432/(321),两者的唯一区别在于,组合去除了排列的顺序,即组合不在乎所选3个数字的具体选择顺序是怎么样的,只在乎我们具体选择了哪3个数字。 进一步,若给排列问题加上数字选择的顺序约束,即只能按照编号从小到大选择数字,其实质也将变为组合问题。 回到原问题,我们能否也去除数组中这两个1的顺序性,从而避免产生重复情况。类似的,我们将固定2个1的选择顺序,只能先选择第1个1,再选择第2个1,而不存在先选择第2个1,再选择第1个1的情况。 当然在调用回溯算法以前,我们需要先对数组进行排序从而确保相同的数是相邻的。除此之外,和上一题的唯一差别仅在于选取备选项时要求固定顺序。

class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
      vector<vector<int>> ret;
        vector<bool> used(nums.size(), false);
        vector<int> path;
        sort(nums.begin(), nums.end());
        backTrack(nums, ret,path,used);
        return ret;
    }
    void backTrack(vector<int>& nums, vector<vector<int>>& ret, vector<int>& path, vector<bool>& used)
    {
        if (path.size() == nums.size())
        {
            ret.push_back(path);
            return;
        }
        else
        {
            for (int i = 0; i < nums.size(); i++)
            {
                if (used[i] || (i>0&&nums[i] == nums[i-1] && !used[i-1]))
                    continue;
                used[i] = true;
                path.push_back(nums[i]);
                backTrack(nums, ret, path, used);
                used[i] = false;
                path.pop_back();
            }
        }
    }
};

例3: 39. 组合总和

  • 1.出口:当所选取数字和为目标值时
  • 2.主体:每一次都从数组首元素开始选择一个元素,如果最后发现累加发现无法构成目标值,就回退从第二个元素试水,依次类推
  • 3.返回:构不成目标值或者构成了目标值还需要继续寻找其他解,都需要回退剪 枝来寻找其他可能解

注意:

这题同样存在如何避免重复结果的问题。类似的,为了变排列问题为组合问题,我们对数组的全部数字固定了选择顺序——下标小的数字必须在下标大的数字之前被选择。这里不再用到布尔数组used,原因是当我们固定了选择顺序后,在每一步,我们都容易知道,下标大于当前数字的是未被选择过的,而下标小于当前数字的是已经被选择过的。 不能再次选择之前选择过的数字,当选过第二个数字后,就不能选择第一个数字了

class Solution {
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target)
    {
        vector<vector<int>> ret;
        vector<int> path;
        backTrack(ret, target, path, candidates,0);
        return ret;
    }
    //这里left作用:不能再次选择之前选择过的数字
    void backTrack(vector<vector<int>>& ret, int target, vector<int>& path, vector<int>& cand, int left)
    {
        if (accumulate(path.begin(), path.end(), 0) == target)
        {
            ret.push_back(path);
            return;
        }
        else if (accumulate(path.begin(), path.end(), 0) > target)
        {
            return;
        }
        else
        {
            for (int i = left; i < cand.size(); i++)
            {
                path.push_back(cand[i]);
                backTrack(ret, target, path, cand, i);
                path.pop_back();

            }
        }
    }
};

例4:走迷宫

//迷宫 1为墙 0为通路
int Graph[][6] =
{
		{1, 1, 1, 1, 1, 1},
		{1, 0, 0, 0, 1, 1},
		{1, 0, 1, 0, 0, 1},
		{1, 0, 0, 0, 1 ,1},
		{1, 1, 0, 0, 0, 1},
		{1, 1, 1, 1, 1, 1}
};
  • 打印输出所有通路

出口:找到了迷宫出口 主体:从起点开始,不断往四周深入探索,直到遇到最终出口 返回:如果找到了一条通路或者进入思路,那么需要往后退一步,寻找其他可能解

#include<iostream>
using namespace std;
#include<vector>
#include<algorithm>
//迷宫 1为墙 0为通路
int Graph[][6] =
{
		{1, 1, 1, 1, 1, 1},
		{1, 0, 0, 0, 1, 1},
		{1, 0, 1, 0, 0, 1},
		{1, 0, 0, 0, 1 ,1},
		{1, 1, 0, 0, 0, 1},
		{1, 1, 1, 1, 1, 1}
};
//迷宫 起点 终点
void findPath(int(*Graph)[6], int x1, int y1, int x2, int y2)
{
	//存放迷宫路径
	static vector<pair<int,int>> path;
	//找到出口
	if (x1 == x2 && y1 == y2)
	{
		cout << "迷宫路径如下:" << endl;
		path.push_back({ x1,y1 });
		Graph[x1][y1] = -1;
		for_each(path.begin(), path.end(), [](pair<int, int> path) {cout << "{" << path.first << "," << path.second << "}" << endl; });
		return;
	}
	else
	{
		if (Graph[x1][y1]==0)//当前要走的方向为通路
		{
			int di = 0;//方向--右 下 左 上
			int x, y;//记录下一次要走的方向
			for (; di < 4; di++)
			{
				switch (di)
				{
				case 0://右---列加一
				{
					x = x1;
					y = y1 + 1;
					break;
				}
				case 1://下---行加一
				{
					x = x1 + 1;
					y = y1;
					break;
				}
				case 2://左---列减一
				{
					x = x1;
					y = y1 - 1;
					break;
				}
				case 3://上---行减一
				{
					x = x1 - 1;
					y = y1;
					break;
				}
				}
				path.push_back({ x1,y1 });
				Graph[x1][y1] = -1;
			 findPath(Graph, x, y, x2, y2);//更新寻找的起点
			 //回溯加剪枝
			 Graph[x1][y1] = 0;
			 path.pop_back();
			}
		}
	}
}
int main()
{
	findPath(Graph, 1, 1, 4, 4);
	system("pause");
	return 0;
}

总结:

  • 现在我们对回溯算法进行总结。回溯算法的本质是一种深搜递归遍历。通过上面的例题,我们可以知道回溯算法可以对各类组合、排列问题进行较好的求解,所以当遇到可以抽象建模为排列或组合的问题,回溯算法都可以作为一种求解手段。