【递归、搜索与回溯算法】(穷举vs暴搜vs深搜vs回溯vs剪枝:一文讲清概念与用法)


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在学习算法的过程中,很多人都会反复碰到这些词:穷举、暴力搜索、深度优先搜索、回溯、剪枝.它们看起来彼此相关,实际用起来也常常混在一起,于是初学者很容易产生疑惑:穷举和暴搜到底是不是一回事?DFS只是搜索顺序,还是一种算法思想?回溯和DFS有什么本质区别?剪枝又是在什么时候发挥作用?这些概念之所以容易混淆,是因为它们常常出现在同一类问题中:需要尝试、需要选择、需要一步步向前探索,并在不满足条件时退回来重新决策.从排列组合,到子集划分,再到路径搜索、数独求解、N 皇后问题,这些经典题目的背后,几乎都离不开"递归 + 搜索"的核心框架.但如果只是机械地背模板,往往只能"会写题",却很难真正理解算法的运行逻辑.事实上,穷举强调的是"把所有可能都试一遍",暴搜更偏向"不加优化地直接搜索",深搜描述的是"一条路走到底"的搜索方式,回溯则是在搜索过程中"试错并撤销选择"的方法,而剪枝则是在搜索途中尽早排除无效分支、提高效率的重要优化手段.它们彼此联系、层层递进,共同构成了许多递归搜索问题的解题基础.本文将围绕"递归、搜索与回溯算法"这一主线,系统梳理穷举 vs 暴搜 vs 深搜 vs 回溯 vs 剪枝这些高频概念,讲清它们分别是什么、有什么区别、在什么场景下使用,以及它们之间是如何衔接起来的.希望读完这篇文章后,你不仅能分清这些名词,更能真正建立起一套清晰的递归搜索思维框架.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!

目录

1.穷举vs暴搜vs深搜 vs回溯vs剪枝:到底有什么区别?

在学习递归与搜索算法时,很多人都会接触到几个高频词:穷举、暴搜、深搜、回溯、剪枝.这些概念经常一起出现,甚至常常在同一道题里同时出现,于是很容易让人产生一种感觉:它们好像差不多,但又总觉得哪里不一样.这种"似懂非懂"的状态非常普遍.原因就在于:这些词虽然都和"搜索"有关,但它们其实并不处在同一个层面上.有的是解题思想,有的是搜索策略,有的是具体方法,还有的是优化手段.如果不把层次理清,后面学排列组合、N 皇后、数独、路径搜索这类题时,就会一直混淆.我们就把这几个概念彻底拆开,讲清它们分别是什么、区别在哪里,以及它们之间到底是怎样一步步衔接起来的.
1.穷举:把所有可能都试一遍

先说最基础的:穷举.

所谓穷举,本质上就是一种非常朴素的解题思想:既然不知道哪种情况是答案,那就把所有可能情况全部列出来,一个一个试.

比如:

• 想找数组里是否有两个数之和等于10,那就把所有二元组都枚举一遍;

• 想求一个集合的所有子集,那就把每个元素"选或不选"的所有情况都考虑到;

• 想得到一个字符串的所有排列,那就把所有顺序全部生成出来.

你会发现,穷举强调的是一种**"不遗漏"**的思维方式.它并不关心你具体怎么写代码,也不关心你是递归写、循环写,还是用什么数据结构,它只关心一件事:所有可能性,是否都被考虑到了.

所以,穷举首先是一种思想.它的优点是简单直接、不容易漏解;缺点也很明显:如果可能情况太多,效率就会很差.

2.暴搜:把穷举直接写成程序

如果说穷举是一种思想,那么暴搜就是把这种思想"硬落地"的一种写法.

暴搜,全称通常可以理解为"暴力搜索".它强调的是:不做太多优化,直接把所有可能状态搜出来.

这里的"搜",往往不是手动一项一项列,而是把问题组织成一棵搜索树,然后让程序一层层去探索.

比如一个选择问题,每一步都有若干选项:

• 第一步选 A、B、C

• 第二步再从剩余情况里继续选

• 第三步再继续......

程序就会从起点出发,把所有分支一路搜到底.如果这个过程中没有提前排除、没有优化、没有减少重复,那基本就是暴搜.

所以可以这样理解:

• 穷举:我要把所有可能都试一遍

• 暴搜:我真的写了个程序,把所有可能都搜了一遍,而且基本没优化

两者关系很近,但不完全一样.穷举偏思想,暴搜偏实现.

3.深搜(DFS):一条路走到底的搜索顺序

接下来是最常见的一个词:深搜,也就是DFS(Depth First Search).

DFS 的核心不是"要不要搜所有情况",而是:搜索的时候,先沿着一条路径一直走到底,走不通了再退回来.这是一种搜索顺序,也是一种搜索策略.假设你站在一棵树的根节点上,面前有好几个分支.DFS 的做法不是每个分支都先看一眼,而是先选一个分支一直往下走,直到走不下去了,再退回来尝试别的分支.

所以,DFS回答的问题是:"按什么顺序搜?"它强调的是纵向优先.

这点很重要,因为很多人会把 DFS 和回溯混为一谈。其实 DFS 只是搜索框架,它本身并不一定带有"试错"和"撤销"的意味。比如树的前序遍历、本质上就是 DFS;图的遍历、连通块搜索,本质上也是 DFS.这些场景里,你只是按深度优先的顺序访问节点,不一定涉及复杂的状态恢复.因此,DFS 更准确地说,是一种搜索策略/遍历方式.

4.回溯:带"撤销动作"的 DFS

如果说DFS只是规定了"先一路走到底再回来",那么回溯则是在这个框架上,更进一步地加入了"试错"和"撤销选择"的过程.

回溯最典型的过程可以概括成三步:

  1. 做一个选择

  2. 递归进入下一层

  3. 撤销这个选择

这三步反复进行,就形成了回溯算法的基本结构.

比如全排列问题。

假设当前要生成 [1,2,3] 的所有排列:

• 第一个位置先选 1

• 第二个位置再选 2

• 第三个位置再选 3,得到一个结果

• 然后返回上一层,把第三个位置的选择撤销

• 再试别的数

• 然后继续往回退,把第二个位置的选择也撤销

• 再换新的方案

这个过程本质上就是:一边向下搜索,一边在返回时恢复现场.

这就是回溯.所以,回溯和 DFS 的关系通常可以概括为:回溯 = 基于 DFS 的试错搜索

或者更直白一点:回溯就是"带状态恢复"的DFS.

这也是为什么排列、组合、子集、N 皇后、数独等题目,通常都被归类为回溯题.因为这类问题不只是"访问节点",而是在每一步都要做选择,而这些选择一旦走错,就需要退回来重选.

5.剪枝:提前砍掉没必要搜索的分支

当你已经会用回溯或DFS搜索所有可能时,马上就会遇到一个现实问题:很多分支根本没有必要继续搜.

这时候就需要引入一个非常关键的优化手段:剪枝.所谓剪枝,就是在搜索过程中,提前判断某个分支已经不可能得到有效答案,于是直接停止搜索这一支.形象地说,整个搜索过程像是在遍历一棵大树,而剪枝就是:发现某根枝条后面肯定没结果,干脆直接剪掉,不再往下走.

例如在 N 皇后问题中,如果某一行放下皇后后,已经和前面的皇后冲突了,那么后面就不必再继续放了,因为这条路已经不可能产生合法解.这就是典型的剪枝.再比如组合求和问题里,如果当前和已经大于目标值,而后面又都是正数,那么继续搜下去也不可能成功,也可以立刻停止.

所以,剪枝并不是一种新的搜索方式,它更像是:对搜索过程的优化.

它的作用不是改变答案,而是减少无意义的搜索量,让原本可能超时的暴搜或回溯变得可行.

它们最大的区别:不在同一个层面理解这几个概念最关键的一点,就是要意识到它们不是同一维度的术语.

可以简单分层:

• 穷举:解题思想

• 暴搜:朴素实现

• DFS:搜索策略

• 回溯:具体方法

• 剪枝:优化手段

也就是说,它们不是互相排斥的关系,而是经常会叠加出现/

比如一道典型回溯题,常常是这样的:

• 从思路上看,它是在穷举所有可能;

• 从实现上看,它一开始可能是暴搜;

• 从搜索方式上看,它通常用的是DFS;

• 从过程特征上看,它属于回溯;

• 从性能优化上看,它会加上剪枝.

所以,这些词不是"谁替代谁",而是分别描述了同一个算法过程的不同侧面.

总的来说,穷举、暴搜、深搜、回溯、剪枝并不是彼此割裂的几个名词,而是同一类问题在不同层面上的描述:

• 穷举说的是思路:所有可能都试一遍;

• 暴搜说的是实现:不加优化地直接搜;

• 深搜说的是顺序:一条路走到底再回头;

• 回溯说的是方法:边试边退,撤销选择;

• 剪枝说的是优化:提前砍掉无效分支.


2.回溯的深入介绍

2.1什么是回溯算法?

回溯算法是一种经典的递归算法,通常用于解决组合问题、排列问题和搜索问题等.

回溯算法的基本思想:从一个初始状态开始,按照一定的规则向前搜索,当搜索到某个状态无法前进时,回退到前一个状态,再按照其他的规则搜索.回溯算法在搜索过程中维护一个状态树,通过遍历状态树来实现对所有可能解的搜索.

回溯算法的核心思想:"试错",即在搜索过程中不断地做出选择,如果选择正确,则继续向前搜索;否则,回退到上一个状态,重新做出选择.回溯算法通常用于解决具有多个解,且每个解都需要搜索才能找到的问题.


2.2回溯算法的模板

cpp 复制代码
void backtrack(vector<int>& path, vector<int>& choices, ...) {
    // 满足结束条件
    if (/* 满足结束条件 */) {
        // 将路径添加到结果集中
        res.push_back(path);
        return;
    }

    // 遍历所有选择
    for (int i = 0; i < choices.size(); i++) {
        // 做出选择
        path.push_back(choices[i]);
        // 做出当前选择后继续搜索
        backtrack(path, choices);
        // 撤销选择
        path.pop_back();
    }
}

其中,path 表示当前已经做出的选择,choices 表示当前可以做的选择.在回溯算法中,我们需要做出选择,然后递归地调用回溯函数.如果满足结束条件,则将当前路径添加到结果集中;否则,我们需要撤销选择,回到上一个状态,然后继续搜索其他的选择.

回溯算法的时间复杂度通常较高,因为它需要遍历所有可能的解.但是,回溯算法的空间复杂度较低,因为它只需要维护一个状态树.在实际应用中,回溯算法通常需要通过剪枝等方法进行优化,以减少搜索的次数,从而提高算法的效率.


2.3回溯算法的应用

  1. 组合问题
    组合问题是指从给定的一组数(不重复)中选取出所有可能的 k 个数的组合.
    例如,给定数集 [1,2,3],要求选取 k=2 个数的所有组合,结果为:
  • [1,2]
  • [1,3]
  • [2,3]
  1. 排列问题
    排列问题是指从给定的一组数(不重复)中选取出所有可能的 k 个数的排列.
    例如,给定数集 [1,2,3],要求选取 k=2 个数的所有排列,结果为:
  • [1,2]
  • [2,1]
  • [1,3]
  • [3,1]
  • [2,3]
  • [3,2]
  1. 子集问题
    子集问题是指从给定的一组数中选取出所有可能的子集,其中每个子集中的元素可以按照任意顺序排列.
    例如,给定数集 [1,2,3],要求选取所有可能的子集,结果为:
  • []
  • [1]
  • [2]
  • [3]
  • [1,2]
  • [1,3]
  • [2,3]
  • [1,2,3]

2.4总结

回溯算法是一种非常重要的算法,可以解决许多组合问题、排列问题和搜索问题等.回溯算法的核心思想是搜索状态树,通过遍历状态树来实现对所有可能解的搜索.回溯算法的模板非常简单,但是实现起来需要注意一些细节,比如如何做出选择、如何撤销选择等.


3.全排列(OJ题)


算法思路

典型的回溯题目,我们需要在每一个位置上考虑所有的可能情况并且不能出现重复.通过深度优先搜索的方式,不断地枚举每个数在当前位置的可能性,并回溯到上一个状态,直到枚举完所有可能性,得到正确的结果.

每个数是否可以放入当前位置,只需要判断这个数在之前是否出现即可。具体地,在这道题目中,我们可以通过一个递归函数 backtrack 和标记数组 visited 来实现全排列。
递归函数设计:
void backtrack(vector<vector<int>>& res, vector<int>& nums, vector<bool>& visited, vector<int>& ans, int step, int len)

  • 参数:step(当前需要填入的位置),len(数组长度);
  • 返回值:无
  • 函数作用:查找所有合理的排列并存储在答案列表中.

递归流程如下:

  1. 首先定义一个二维数组 res 用来存放所有可能的排列,一个一维数组 ans 用来存放每个状态的排列,一个一维数组 visited 标记元素,然后从第一个位置开始进行递归;
  2. 在每个递归的状态中,我们维护一个步数 step,表示当前已经处理了几个数字;
  3. 递归结束条件:当 step 等于 nums 数组的长度时,说明我们已经处理完了所有数字,将当前数组存入结果中;
  4. 在每个递归状态中,枚举所有下标 i,若这个下标未被标记,则使用 nums 数组中当前下标的元素:
    a. 将 visited[i] 标记为 1;
    b. ans 数组中第 step 个元素被 nums[i] 覆盖;
    c. 对第 step+1 个位置进行递归;
    d. 将 visited[i] 重新赋值为 0,表示回溯;
  5. 最后,返回 res.

特别地,我们可以不使用标记数组,直接遍历 step 之后的元素(未被使用),然后将其与需要递归的位置进行交换即可.

核心代码

cpp 复制代码
class Solution
{
    //成员变量(整个递归过程中共享)
    vector<vector<int>> ret;   //存储最终所有的全排列结果(二维数组)
    vector<int> path;          //存储当前正在拼接的单条排列路径
    bool check[7];              //标记数组:标记nums中的元素是否被使用过
                                //题目限制nums长度≤6,所以开7个空间足够用

public:
    //主函数:入口,接收输入数组,返回所有排列
    vector<vector<int>> permute(vector<int>& nums)
    {
        dfs(nums);  //调用深度优先搜索(回溯核心)
        return ret; //返回最终结果
    }

    //回溯递归函数
    void dfs(vector<int>& nums)
    {
        //1.递归终止条件:当前路径长度 = 数组长度 → 找到一个完整排列
        if(path.size() == nums.size())
        {
            ret.push_back(path); //把当前排列加入结果集
            return;              //终止当前递归,回溯
        }

        //2.遍历所有可选元素
        for(int i = 0; i < nums.size(); i++)
        {
            //如果当前元素没有被使用过(check[i]为false)
            if(!check[i])
            {
                //【第一步:做选择】
                path.push_back(nums[i]);  //把元素加入当前路径
                check[i] = true;          //标记该元素已使用

                //【第二步:递归】继续向下搜索下一个位置的数字
                dfs(nums);
                //【第三步:回溯(撤销选择)】
                path.pop_back();          //移除最后加入的元素
                check[i] = false;         //取消标记,恢复初始状态
            }
        }
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

class Solution
{
    vector<vector<int>> ret;
    vector<int> path;
    vector<bool> check;

public:
    vector<vector<int>> permute(vector<int>& nums)
    {
        ret.clear();
        path.clear();
        check = vector<bool>(nums.size(), false);

        dfs(nums);
        return ret;
    }

    void dfs(vector<int>& nums)
    {
        if (path.size() == nums.size())
        {
            ret.push_back(path);
            return;
        }

        for (int i = 0; i < nums.size(); i++)
        {
            if (!check[i])
            {
                path.push_back(nums[i]);
                check[i] = true;

                dfs(nums);

                // 回溯:恢复现场
                path.pop_back();
                check[i] = false;
            }
        }
    }
};

int main()
{
    Solution s;
    vector<int> nums = {1, 2, 3};

    vector<vector<int>> ans = s.permute(nums);

    cout << "全排列结果如下:" << endl;
    for (const auto& vec : ans)
    {
        for (int x : vec)
        {
            cout << x << " ";
        }
        cout << endl;
    }

    return 0;
}

4.子集(OJ题)


算法思路:

为了获得 nums 数组的所有子集,我们需要对数组中的每个元素进行选择或不选择的操作,即 nums 数组一定存在 2 数组长度 2^{\text{数组长度}} 2数组长度 个子集.对于查找子集,具体可以定义一个数组,来记录当前的状态,并对其进行递归.

对于每个元素有两种选择:

  1. 不进行任何操作;
  2. 将其添加至当前状态的集合.

在递归时我们需要保证递归结束时当前的状态与进行递归操作前的状态不变,而当我们在选择进行步骤 2 进行递归时,当前状态会发生变化,因此我们需要在递归结束时撤回添加操作,即进行回溯.

递归函数设计:
void dfs(vector<vector<int>>& res, vector<int>& ans, vector<int>& nums, int step)

  • 参数:step(当前需要处理的元素下标);
  • 返回值:无;
  • 函数作用:查找集合的所有子集并存储在答案列表中.

递归流程如下:

  1. 递归结束条件:如果当前需要处理的元素下标越界,则记录当前状态并直接返回;
  2. 在递归过程中,对于每个元素,我们有两种选择:
    • 不选择当前元素,直接递归到下一个元素;
    • 选择当前元素,将其添加到数组末尾后递归到下一个元素,然后在递归结束时撤回添加操作;
  3. 所有符合条件的状态都被记录下来,返回即可.

核心代码

cpp 复制代码
//解法一
class Solution
{
    //成员变量:整个递归过程中共享
    vector<vector<int>> ret;   //存储最终所有子集(二维数组,结果集)
    vector<int> path;          //存储当前正在拼接的「单个子集」(临时路径)

public:
    //主函数:入口,接收输入数组,返回所有子集
    vector<vector<int>> subsets(vector<int>& nums)
    {
        dfs(nums, 0);  //调用递归函数,从下标0开始遍历
        return ret;    //返回最终所有子集
    }

    //回溯递归函数
    //pos:当前遍历到的数组下标(决定我们要处理第几个元素)
    void dfs(vector<int>& nums, int pos)
    {
        //1.递归终止条件:遍历完数组所有元素
        if(pos == nums.size())
        {
            ret.push_back(path); //把当前拼接好的子集加入结果集
            return;              //终止当前递归,回溯
        }

        //分支1:选择当前元素 nums[pos] 
        path.push_back(nums[pos]);  //把当前元素加入子集
        dfs(nums, pos + 1);         //递归处理下一个元素
        path.pop_back();            //回溯:撤销选择,恢复path的原始状态

        //分支2:不选择当前元素 nums[pos] 
        dfs(nums, pos + 1);         //直接递归处理下一个元素
    }
};


//解法二
class Solution
{
    //全局共享的成员变量
    vector<vector<int>> ret;   //结果集:存储所有子集
    vector<int> path;          //临时路径:存储当前正在拼接的子集

public:
    //主函数:入口,接收数组,返回所有子集
    vector<vector<int>> subsets(vector<int>& nums)
    {
        dfs(nums, 0);  //从数组下标 0 开始递归搜索
        return ret;    //返回最终所有子集
    }

    //回溯核心函数
    //pos:当前可以选择的**起始下标**(保证不重复选元素、不生成重复子集)
    void dfs(vector<int>& nums, int pos)
    {
        //1.关键:每次进入递归,先把当前的 path 加入结果集
        //因为子集包含:空集、单元素、多元素... 所有中间状态都是合法子集
        ret.push_back(path);

        //2.枚举:从 pos 开始,遍历所有可选的元素
        for(int i = pos; i < nums.size(); i++)
        {
            //选择当前元素:加入临时子集
            path.push_back(nums[i]);
            //递归:下一层只能从 i+1 开始选(不能回头选,避免重复子集)
            dfs(nums, i + 1);
            //回溯:撤销选择,恢复path,尝试下一个元素
            path.pop_back(); 
        }
    }
};

完整测试代码

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

class Solution
{
    vector<vector<int>> ret;
    vector<int> path;

public:
    vector<vector<int>> subsets(vector<int>& nums)
    {
        ret.clear();
        path.clear();
        dfs(nums, 0);
        return ret;
    }

    void dfs(vector<int>& nums, int pos)
    {
        if (pos == nums.size())
        {
            ret.push_back(path);
            return;
        }

        //选 nums[pos]
        path.push_back(nums[pos]);
        dfs(nums, pos + 1);
        path.pop_back(); //恢复现场

        //不选 nums[pos]
        dfs(nums, pos + 1);
    }
};

int main()
{
    Solution s;
    vector<int> nums = {1, 2, 3};

    vector<vector<int>> ans = s.subsets(nums);

    cout << "子集结果如下:" << endl;
    for (const auto& subset : ans)
    {
        cout << "{ ";
        for (int x : subset)
        {
            cout << x << " ";
        }
        cout << "}" << endl;
    }

    return 0;
}


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容:【递归、搜索与回溯算法】(综合练习:一网打尽常见题型分类总结与方法归纳)


每日心灵鸡汤:圈子真的很重要
你靠近什么样的人,就会走什么样的路.跟着苍蝇找厕所,跟着蜜蜂找花朵,跟着千万赚百万,跟着乞丐学要饭.圈子决定人生,遇贵人先择业,遇良人先成家.目标放中间,杂事放两边.其实限制你发展的,往往不是智商和学历,而是你所处的生活圈和工作圈.人最大的运气不是捡钱,而是某天你遇到一个人,他打破了你原来的思维,提高了你的认知,提升了你的境界,带你走上更高的境界.好的圈子,不是人人都有钱,而是人人都上进.记住,提升自己比仰望别人更有意义!

相关推荐
承渊政道2 小时前
【递归、搜索与回溯算法】(综合练习:一网打尽常见题型分类总结与方法归纳)
c++·算法·决策树·分类·深度优先·哈希算法·宽度优先
我不是懒洋洋2 小时前
【数据结构】栈和链表基本方法的实现
c语言·开发语言·数据结构·c++·链表·青少年编程·ecmascript
邪修king2 小时前
C++ vector 超全攻略:核心知识点、STL 生态联系与避坑指南
c语言·c++·面试
小江的记录本2 小时前
【网络安全】《网络安全与数据安全核心知识体系》(包括数据脱敏、数据加密、隐私合规、等保2.0)
java·网络·后端·python·算法·安全·web安全
SimpleLearingAI2 小时前
ROPE:大模型必学操作
人工智能·算法
zore_c2 小时前
【C++】C++类和对象实现日期类项目——时间计算器!!!
java·c语言·数据库·c++·笔记·算法·排序算法
草莓熊Lotso2 小时前
Linux 线程同步与互斥(二):线程同步从条件变量到生产者消费者模型全解,原理 + 源码彻底吃透
linux·运维·服务器·c语言·开发语言·数据库·c++
人道领域2 小时前
【LeetCode刷题日记】:344,541-字符串反转字符串反转技巧:双指针原地交换法
算法·leetcode·面试
Crazy________2 小时前
4.13docker仓库registry
mysql·算法·云原生·eureka