【算法】回溯算法+进阶题——全排列Ⅱ、组合总和Ⅱ、解数独

本文深入探讨了回溯算法的原理及其在组合、分割、子集和排列等问题中的应用。通过详细解析组合总和Ⅱ、全排列Ⅱ和解数独的例题,阐述了回溯算法的思路、代码框架以及去重策略。同时,文章强调了排序在回溯算法中的重要性,以及如何根据问题特点选择合适的去重方法。
摘要由CSDN通过智能技术生成

目录

一、前言

• 什么是回溯算法

• 用回溯能解决那些题型

二、总体思路 

         1. 通过树逐层解剖

         2. 代码框架 

         3. 是否需要去重

三、例题

1. 组合总和Ⅱ

2. 全排列Ⅱ

3. 解数独

一、前言

本篇文章是本人通过代码随想录学习之后写下的,所以有部分内容与代码随想录写的是相似的,这里也通过这篇文章感谢代码随想录🙏🙏🙏

• 什么是回溯算法

1)百度百科:回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。

2)回溯算法实际上就是递归,相比与嵌套for循环这种暴力的方法,回溯则是一种“刚中带柔”的方法,代码实现起来也相对简洁,但他的效率其实并不是很高,同样是一种暴力的解法。

• 用回溯能解决那些题型

⬇️⬇️⬇️对应力扣上的题⬇️⬇️⬇️

这里有leetcode的题目链接,大家可以去练练手,看看你是否有思路? 

1)组合组合组合总和
2)分割分割回文串复原IP地址
3)子集子集子集Ⅱ
4)排列全排列全排列Ⅱ
5)棋盘N皇后解数独

不难发现,这些题差不多都是要我们搜索符合条件的子情况,用回溯这样的暴力搜索就能将所有情况都搜索出来,然后再筛选符合条件的情况;

所以以后遇到类似的问题大家都可以考虑一下能否用回溯来解决。

二、总体思路 

1. 通过树逐层解剖

2. 代码框架 

void backTracking()
{
    if  (终止条件)
    {
        存放符合条件的子集;
        return;
    }
    
    for  (int i = 本层递归的开始位置; i < 集合大小; i++)
    {
        存放该结点;
        bcakTracking();   //递归
        回溯,将结点从子集中删除;
    }

}

3. 是否需要去重

有些题给出的集合中有重复的数字,那么就要考虑得到的子集有重复的情况了;

例如给出一个集合{1,1,2},找出总和为3的所有不重复的子集,加入先取第一个1,那么后面一个1在同一树层上就不能取了,但是在同一树枝上可以取;

具体去重操作我们在下面例题里具体说明⬇️

三、例题

1. 组合总和Ⅱ

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8

输出:[ [1,1,6] , [1,2,5] , [1,7] , [2,6] ]

传送➡️https://leetcode.cn/problems/combination-sum-ii/

这里我们以题中例题的序列为例:

1)排序

首先我们先将这个序列进行排序(插入排序为例,之前文章有讲解,便于之后的去重操作(有些题改变了顺序不能求出答案,可以尝试其他方法去重)

2)去重

 if (i > startIndex && candidates[i] == candidates[i - 1])  
            continue;

 配合树来看,上述中提到的去重操作为什么写成这样呢?

 • 设想如果我们第一层选了1,那么下一层是可以选下一个1的,这两个1没有在位置上重复

(i== startIndex && candidates[i] == candidates[i - 1])

• 但是在第一层选了1之后回溯到第二个1的时候,如果再选了它,那么与他组成的子集必定会与第一个1组成的子集重复,所以我们要将第二个1跳过

(i > startIndex && candidates[i] == candidates[i - 1])

 3)树:

 4)回溯框架

树的横向是该函数栈帧中的for循环,那么如何控制for循环的开始位置(及从序列的第几个数开始遍历)就非常重要了:

因为组合是不讲究顺序的,如果每层for循环从序列的第一个数开始,那么肯定有重复的组合,例如一次层选1,第二层选2和第一层选2,第一层选1,他们构成的组合都是[ 1, 2 ];

所以每层for循环要从该数开始(startIndex),往后遍历。

纵向是递归,递归函数的条件是什么呢?

上述说到每层for的起始位置是下一个数,那么进入下一层树我们就该将本层遍历到第 i 个数的下一个数传下去

void backTracking(int* candidates, int candidatesSize, int target, int sum, int startIndex)
{
    if (sum > target)
        return;
    if (sum == target)   //将本次符合条件的子集复制,再存放到结果集里
    {
        int* tmppath = (int*)malloc(sizeof(int) * pathTop);
        for (int i = 0; i < pathTop; i++)
        {
            tmppath[i] = path[i];
        }
        lenth[retTop] = pathTop;
        ret[retTop++] = tmppath;
    }

    for (int i = startIndex; i < candidatesSize; i++)
    {
        if (i > startIndex && candidates[i] == candidates[i - 1])  //去重
            continue;

        sum += candidates[i];
        path[pathTop++] = candidates[i];
        backTracking(candidates, candidatesSize, target, sum, i + 1);   //i+1及下一层树的startIndex

        sum -= candidates[i];  //回溯
        pathTop--;
    }
}

5)整体代码

//定义为全局变量,以便减少递归传参的数量

int* path;   //符合条件的单个子集
int** ret;   //结果集
int* lenth;  //每个子集的长度

int retTop;  //结果的个数
int pathTop;  //记录子集的长度

void backTracking(int* candidates, int candidatesSize, int target, int sum, int startIndex)
{
    if (sum > target)
        return;
    if (sum == target)
    {
        int* tmppath = (int*)malloc(sizeof(int) * pathTop);
        for (int i = 0; i < pathTop; i++)
        {
            tmppath[i] = path[i];
        }
        lenth[retTop] = pathTop;
        ret[retTop++] = tmppath;
    }

    for (int i = startIndex; i < candidatesSize; i++)
    {
        if (i > startIndex && candidates[i] == candidates[i - 1])
            continue;

        sum += candidates[i];
        path[pathTop++] = candidates[i];

        backTracking(candidates, candidatesSize, target, sum, i + 1);

        sum -= candidates[i];
        pathTop--;
    }
}

void Sort(int* candidates, int candidatesSize)    //插入排序
{
    for (int i = 0; i < candidatesSize - 1; i++)
    {
        int end = i;
        int tmp = candidates[i + 1];
        while (end >= 0)
        {
            if (candidates[end] > tmp)
            {
                candidates[end + 1] = candidates[end];
                end--;
            }
            else
                break;
        }
        candidates[end + 1] = tmp;
    }
}

int** combinationSum2(int* candidates, int candidatesSize, int target, int* returnSize, int** returnColumnSizes)
{
    path = (int*)malloc(sizeof(int) * 100);   //开辟空间
    ret = (int**)malloc(sizeof(int*) * 100);
    lenth = (int*)malloc(sizeof(int*) * 100);
    retTop = pathTop = 0;

    Sort(candidates, candidatesSize);   //排序

    backTracking(candidates, candidatesSize, target, 0, 0);   //回溯

    //返回结果
    *returnSize = retTop;

    *returnColumnSizes = (int*)malloc(sizeof(int) * retTop);
    for (int i = 0; i < retTop; i++)
    {
        (*returnColumnSizes)[i] = lenth[i];
    }

    return ret;
}

2. 全排列Ⅱ

给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

示例 1:

输入:nums = [1,1,2]
输出:
[[1,1,2] , [1,2,1] , [2,1,1]]

传送➡️ https://leetcode.cn/problems/permutations-ii/

1)排序

还是先对序列排序,方便之后去重

 2)树

3)回溯框架

树的横向是该函数栈帧中的for循环:与组合不同[1,1,2] 与 [1,2,1]是同一组合不同排列,所以每次for循环的其实位置都是序列的第一个元素

纵向是递归:每次for循环的条件不变,所以递归传到下一层树的条件是一样的,不需要对传入的参数进行调整;

void backTracking(int* nums, int numsSize, int* used)
{
    if (pathTop == numsSize)
    {
        int* tmppath = (int*)malloc(sizeof(int) * numsSize);
        for (int i = 0; i < numsSize; i++)
        {
            tmppath[i] = path[i];
        }
        ret[retTop++] = tmppath;
    }

    for (int i = 0; i < numsSize; i++)
    {
        if (used[i] == 1 || (i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]))
            continue;

        path[pathTop++] = nums[i];
        used[i] = 1;
        backTracking(nums, numsSize, used);
        used[i] = 0;
        pathTop--;
    }
}

 4)去重

if (used[i] == 1 || (i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]))
            continue;

这道题的难点就是去重这步操作,如何想出这一系列去重条件呢?

• 因为每层树及每次for循环都是从第一个数开始,而同一个位置的数不能重复使用,所以我们可以定义一个与集合大小一样的数组used,用来记录该位置的数是否被用过(用过置为1,没用过置为0

used[ i ] == 1 

• 之前说过将集合排序,相同的数就挨在一起了,举例当同一个for循环中二个1与第一个1相等,那么这时在选取第二个1得到的子集与选取第一个1的子集肯定有重复;

如何判断是否在同一层for循环中的?我们知道一个树枝结束后要回溯,及将该位置的used数组重置为0,所以在同一层遇到第二个1时,只去要看前一个位置的used是否为0( ued[i-1] ),是1则表示不在同一层for循环而是在上一层递归,是0则表示在同一层for循环。

i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]

5)整体代码

//定义为全局变量,以便减少递归传参的数量

int** ret;   //结果集
int* path;   //记录当前排列
int retTop;   //记录排列的个数
int pathTop;   //记录放入排列的数量

void Sort(int* nums, int numsSize)   //插入排序
{
    for (int i = 0; i < numsSize - 1; i++)
    {
        int end = i;
        int tmp = nums[end + 1];
        while (end >= 0)
        {
            if (nums[end] > tmp)
            {
                nums[end + 1] = nums[end];
                end--;
            }

            else
                break;
        }
        nums[end + 1] = tmp;
    }
}

void backTracking(int* nums, int numsSize, int* used)
{
    if (pathTop == numsSize)
    {
        int* tmppath = (int*)malloc(sizeof(int) * numsSize);
        for (int i = 0; i < numsSize; i++)
        {
            tmppath[i] = path[i];
        }
        ret[retTop++] = tmppath;
    }

    for (int i = 0; i < numsSize; i++)
    {
        if (used[i] == 1 || (i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]))
            continue;

        path[pathTop++] = nums[i];
        used[i] = 1;

        backTracking(nums, numsSize, used);

        used[i] = 0;
        pathTop--;
    }
}

int** permuteUnique(int* nums, int numsSize, int* returnSize, int** returnColumnSizes) 
{
    path = (int*)malloc(sizeof(int) * numsSize);    //开辟空间
    ret = (int**)malloc(sizeof(int*) * 1000);
    retTop = pathTop = 0;

    int* used[numsSize];
    memset(used, 0, sizeof(used));   //初始化used数组

    Sort(nums, numsSize);
    backTracking(nums, numsSize, used);

    //返回结果
    *returnSize = retTop;
    *returnColumnSizes = (int*)malloc(sizeof(int) * retTop);
    for (int i = 0; i < retTop; i++)
    {
        (*returnColumnSizes)[i] = numsSize;
    }
    return ret;
}

3. 解数独

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。

数独部分空格内已填入了数字,空白格用 '.' 表示。

示例 1:

 传送➡️https://leetcode.cn/problems/sudoku-solver/

1)两层for循环遍历棋盘 

• 之前做的题都是一维的,只需要在一层for循环里套递归回溯,而解数独是个二维数组,需要在两层for循环套递归回溯,用两层for循环遍历整个棋盘;

2)for + 递归 + 回溯

• 题中 ‘ . ’ 表示没有填数字,所以我们紧接下来用for循环依次判断1到9的数填入本次空是否合法,如果遇到合法的数就将其填入并进入下次递归;

如果1到9都没有合法的则说明之前填入的数需要撤回,以便之后的数可以合法填入。这时候就需要在整个for循环结束后return false 在递归下面紧接这回溯的操作

if(board[i][j] == '.')
{
    for (int k = '1'; k <= '9'; k++)
    {
        if (isvalid(board, i, j, k))   //判断k是否填入合法
        {
            board[i][j] = k;

            bool result = solveSudoku(board, boardSize, boardColSize);   //递归棋盘

            if (result == true)   //改步骤看总代码分析
                return true;

            board[i][j] = '.';   //回溯
        }
    }
    return false;    //整个for循环走完也没有合法的数,则返回false
}

3)那什么时候才算完成数独呢?

当两个for循环走完(及整个棋盘遍历完)也没有遇到 ‘ . ’ (及所有棋盘都填满数字),则返回 true,这时在之前递归步骤之下的 return true会引发连锁反应,逐渐返回到第一层递归,最后返回函数。

4) 整体代码

bool isvaild(char** board, int x, int y, char k)  //判断是否合法
{
    int startRow = (x / 3) * 3;
    int startCol = (y / 3) * 3;

    for (int i = startRow; i < startRow + 3; i++)
    {
        for (int j = startCol; j < startCol + 3; j++)
        {
            if (board[i][j] == k)
            {
                return false;
            }
        }
    }

    for (int i = 0; i < 9; i++)
    {
        if (board[i][y] == k)
            return false;
    }

    for (int j = 0; j < 9; j++)
    {
        if (board[x][j] == k)
            return false;
    }

    return true;

}
bool solveSudoku(char** board, int boardSize, int* boardColSize)
{
    for (int i = 0; i < boardSize; i++)
    {
        for (int j = 0; j < boardSize; j++)
        {
            if (board[i][j] == '.')
            {
                for (int k = '1'; k <= '9'; k++)
                {
                    if (isvaild(board, i, j, k))
                    {
                        board[i][j] = k;
                        bool result = solveSudoku(board, boardSize, boardColSize);
                        if (result == true)   
                            return true;
                        board[i][j] = '.';
                    }
                }
                return false;
            }
        }
    }
    return true;    //整个棋盘填满之后返回true
}

到这里回溯算法的解法与例题就结束了,希望大家有所收获,文章有错误或者有疑问都可以评论联系我,我们一起学习🙏

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Dusong_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值