简单算法:回溯法 例题:Leetcode 17 Leetcode 39

本文深入探讨回溯算法的基本概念及其应用场景,并通过两个典型例题——电话号码字母组合及组合总和问题,展示了如何运用回溯算法解决实际问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

回溯算法 以及Leetcode例题

    回溯算法,正如其名所述,前进到某个地方可以折返到交叉路再选择另一条路继续前进。类似我们数据结构中的树。大部分回溯问题都离不开几个元素:递归、深度优先以及全排列。以下是笔者作为小萌新,觉得比较不错的可以用来理解回溯的思想的例题,特此摘记。

题目如下:

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

键盘图

示例:

input: "2,3"

output:[“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”]

思路:

    拿到题目的时候,有些比较明显的特征在提示笔者此题应该是要用回溯的:

  • 题目的输出近似是一种将满足条件所有排列都枚举出来
  • 每次固定一个位置改变其他位置

    与其他的回溯问题有些不同之处的是,如果使用的是局部字符串变量保存解集元素的话,此题在递归结束的时候可以不需要回溯。(详细见Code)

public class LetteCombination {
    /**
     * 用来存储各个按键对应的字母
     */
    private List<String> letters = new ArrayList<String>();

    /**
     * 将按键对应的字母保存进letters
     */
    public void prepareForList(){
        letters.add("abc");
        letters.add("def");
        letters.add("ghi");
        letters.add("jkl");
        letters.add("mno");
        letters.add("pqrs");
        letters.add("tuv");
        letters.add("wxyz");
    }

    /**
     * 入口函数
     * @param digits 输入字符串
     * @return
     */
    public List<String> letterCombinations(String digits) {
        if(0==digits.length()||digits==null){
            return new ArrayList<String>();
        }
        prepareForList();// 记得进行数据的初始化
        List<String> res = new ArrayList<>();
        letterCombinations(digits,0,"",res);
        return res;
    }

    /**
     * 选取这些参数的是为了人为控制每次递归的值传递,digits 和 res 是必须的
     * @param digits 输入的按键
     * @param index 指向当前遍历到的digits的元素
     * @param tmp 存放当前字母组合的结果
     * @param res 存放所有组合的结果
     */
    public void letterCombinations(String digits,int index,String tmp,List<String> res){
        // tmp达到预期长度就将tmp保存进res中
        if(tmp.length()==digits.length()){
            res.add(tmp);
            return;
        }
        // 读取当前digits遍历到的元素 对应的可选字符集
        String choose = letters.get(digits.charAt(index)-'2');
        for(int i=0;i<choose.length();i++){// 遍历可选字符集,每次固定当前位置的字符,再进行下一位的字符选择
            // 笔者认为最核心的一句,相比于其他的回溯需要进行还原操作,这里使用局部变量的方式就不需要还原了
            String curr = tmp+choose.charAt(i);// 这里遇到坑了,还原原始字符串,不比数组,字符串没有remove方法。必须利用物理栈进行变量的个阶段保存,即还原
            letterCombinations(digits,index+1,curr,res);// 递归
        }
    }

    public static void main(String[] args){
        String input = "23";
        LetteCombination lc = new LetteCombination();
        List<String> l = lc.letterCombinations(input);
        for(String s:l){
            System.out.print(l+"\t");
        }
    }
}

题目2如下:

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的数字可以无限制重复被选取。

说明:

所有数字(包括 target)都是正整数。 解集不能包含重复的组合。

示例 1:

输入: candidates = [2,3,6,7],target = 7
所求解集为:
[
  [7],
  [2,2,3]
]

思路:

    与上面的题目特征相似,都是获取可枚举的排列中满足特定条件的值(此处是需要获取加载之List中的元素的和等于target的这些List)。因此几乎可以说是直接照搬上面的回溯部分,稍作修改即可使用了。

    唯一需要注意的地方就是,为了去重(如:不去重的话得到的结果集可能是:[2,2,3],[2,3,2]这样的),我们需要引入一个index作为每次遍历的起点,这样保证每一个解集元素List都是一个递增的List,也就不会出现重复的现象了。

private List<List<Integer>> result = new ArrayList<List<Integer>>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        myMethod(candidates,target,new ArrayList<>(),0,0);
        return result;
    }

    /**
     *
     * @param candidates 可选元素集
     * @param target 目标大小
     * @param curr 解集
     * @param sum 记录当前curr中所有元素之和
     * @param index 记录上一次递归candidates使用的元素下标,例如:curr:2,3 说明index应该到了1
     */
    public void myMethod(int[] candidates,int target,List<Integer> curr,int sum,int index){
        // 达到目标大小则将curr加入结果List中
        if(sum == target){
            result.add(new ArrayList<>(curr));
            return;
        }
        // 大于目标大小就没必要接着计算了直接返回
        else if(sum>target)
            return;
        // 起点的选择笔者认为是此题的核心,因为笔者刚开始i从0开始,会出现重复的例如:2,2,3 和 2,3,2,
        // 若 i 从 index 开始则会维持输入元素的一个递增的序列,进而避开了重复的部分(因为无法选到小于index的坐标的值)
        for(int i=index;i<candidates.length;i++){
            curr.add(candidates[i]);// 加入解集中
            myMethod(candidates,target,curr,sum+candidates[i],i);// 递归调用
            curr.remove(curr.size()-1);// 解集回溯
        }
    }

    public static void main(String[] args){
        CombinationSum cs = new CombinationSum();
        List<List<Integer>> lr = cs.combinationSum(new int[]{2,3,6,7},7);
        for(List<Integer> l:lr){
            System.out.println(l);
        }
    }

OK,就先记录到这!

转载于:https://my.oschina.net/u/3744313/blog/1924122

<think>好的,我现在要解决用户的问题:如何学好回溯法。用户可能是一个刚开始学习算法的新手,或者有一定基础但想加强回溯法部分的学习。回溯法是一个比较重要的算法思想,广泛应用于解决组合、排列、子集等问题。用户可能在学习过程中遇到了困难,比如不知道如何正确实现回溯,或者在剪枝优化上感到困惑。需要系统地整理学习回溯法的步骤和注意事项,帮助用户建立正确的学习路径。 首先,我需要回顾回溯法的基本概念。回溯法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来排除该解,即“回溯”并尝试其他可能的解。这种思想通常用递归来实现,但也可以用迭代加栈的方式。 接下来,用户可能需要了解回溯法的核心要素。比如,问题通常可以分解为多个阶段,每个阶段有多个选择,需要记录当前路径,并在无法继续时撤销选择(回溯)。这时候,我应该举一些经典例子,比如全排列、N皇后问题、组合总和等,帮助用户理解回溯法的应用场景。 然后,用户可能对如何具体实现回溯法有疑问。需要分步骤讲解:确定递归函数参数、终止条件、遍历选择列表、做出选择、递归调用、撤销选择。这部分可能需要用具体的代码示例来说明,比如全排列问题的Java或Python实现,但用户要求用中文回答,可能不需要代码,但需要用自然语言描述清楚过程。 另外,剪枝优化是提高回溯效率的关键,用户可能不知道如何有效剪枝。这里需要解释剪枝的概念,比如在遍历过程中提前排除不可能的解,减少递归次数。例如,在组合总和中,如果当前和已经超过目标,就可以提前终止该路径的递归。同时,可能需要提到排序预处理、去重处理等方法。 用户还可能对回溯法的时间复杂度感到困惑,因为回溯法通常是指数级的时间复杂度,但实际应用中通过剪枝可以大幅优化。需要解释这一点,并提醒用户注意问题规模,合理评估是否适用回溯法。 另外,常见的错误和调试方法也是用户关心的内容。比如,忘记撤销选择导致状态污染,或者递归终止条件不正确导致无限递归。这时候,应该给出一些调试建议,例如打印递归路径,使用IDE的调试工具,或者简化问题来测试。 最后,实践是学习的关键,用户需要多做相关题目,逐步积累经验。应该推荐一些经典题目,并建议用户按照由易到难的顺序练习,同时总结每道题的解题思路和优化点。 在整理这些思路时,需要注意结构清晰,分点讲解,避免技术术语过于晦涩,用通俗易懂的语言让用户逐步理解。同时,确保LaTeX公式的正确使用,比如行内公式用$...$,独立公式用$$...$$,并且正确书写数学表达式,如$O(2^n)$等。 现在需要检查是否有遗漏的要点,比如回溯法DFS的关系,是否需要区分回溯、递归、DFS之间的区别?可能用户会有这样的疑问,所以应该简要说明:回溯法通常用DFS的方式实现,但DFS更侧重于遍历图或树的结构,而回溯法则更强调通过剪枝和状态管理来解决问题。 总结下来,回答的结构应该是:基本概念、核心步骤、经典例题、剪枝优化、常见错误及调试、学习建议,最后总结。确保每个部分都有清晰的解释和例子,帮助用户逐步掌握回溯法的学习和应用。</think>学习回溯法需要系统性地掌握其核心思想、解题框架和优化技巧。以下分步骤指导你高效掌握回溯法: --- ### 一、理解回溯法的本质 **核心思想**:通过递归遍历所有可能的候选解,**尝试-撤销-回溯**,找到满足条件的所有解。 - **关键特征**:类似DFS(深度优先搜索),但需要显式地管理状态(做出选择→递归→撤销选择)。 - **适用场景**:组合、排列、子集、分割、棋盘类问题(如N皇后)等。 --- ### 二、掌握回溯法通用解题框架 所有回溯问题都遵循以下模板: ```python def backtrack(路径, 选择列表): if 满足终止条件: 记录结果 return for 选择 in 选择列表: if 剪枝条件跳过: # 优化点 continue 做出选择(修改路径和状态) backtrack(新路径, 新选择列表) 撤销选择(恢复路径和状态) ``` #### 📌 关键步骤拆解 1. **确定递归参数**:路径(当前已选元素)、选择列表(剩余可选元素)。 2. **终止条件**:通常为路径长度达到目标(如全排列的`len(path) == n`)。 3. **遍历选择列表**:逐个尝试所有可能的选择。 4. **剪枝优化**:提前排除无效分支(如跳过重复元素)。 5. **状态管理**:通过递归前后的“选择-撤销”保证不同路径状态隔离。 --- ### 三、通过经典例题深化理解 #### 例1:全排列问题(LeetCode 46) - **问题**:给定无重复数字的数组,返回所有可能的排列。 - **代码关键点**: - 路径:已选的数字组合 - 选择列表:未选的数字(用`used`数组标记是否已选) - 终止条件:路径长度等于数组长度 #### 例2:组合总和(LeetCode 39) - **问题**:给定无重复元素的数组,找出所有和为target的组合(可重复选元素)。 - **优化技巧**: - 排序数组后剪枝:若当前和 + 候选元素 > target,终止后续分支 - 控制递归起始索引避免重复组合 --- ### 四、剪枝优化技巧精讲 #### 1. 去重剪枝 - **场景**:候选列表包含重复元素(如全排列II) - **方法**:排序后跳过相同元素(需配合`used`数组判断是否为同一层重复) #### 2. 可行性剪枝 - **场景**:组合总和问题 - **方法**:提前计算剩余元素是否可能达到目标,若不可能则终止分支 #### 3. 搜索顺序优化 - **场景**:N皇后问题 - **方法**:按行放置皇后,每行只需检查列和对角线冲突 --- ### 五、常见错误调试方法 1. **状态未正确撤销** - **现象**:结果中出现重复元素或错误组合 - **解决**:确保递归后恢复所有修改过的状态变量 2. **剪枝条件错误** - **调试**:打印当前路径和选择列表,观察剪枝是否合理 3. **终止条件遗漏** - **示例**:分割回文串问题中忘记判断最后一个子串 --- ### 六、高效学习路径建议 1. **基础阶段**(1-2周) - 刷经典模板题:全排列、子集、组合总和 - 重点:理解框架,手写代码调试 2. **进阶阶段**(2-3周) - 挑战复杂问题:N皇后、解数独、分割回文串 - 重点:设计剪枝策略,分析时间复杂度 3. **总结阶段**(1周) - 整理常见问题类型,归纳剪枝套路(如排序去重、索引控制) - 对比动态规划,理解回溯法的适用边界 --- ### 七、学习资源推荐 - **书籍**:《算法导论》(第14章)、《代码随想录》 - **刷题平台**:LeetCode标签“回溯”下前50题 - **可视化工具**:Algo-Animation(观察递归树生成过程) --- ### 总结 回溯法的核心在于**暴力穷举+剪枝优化**。通过大量练习掌握以下能力: 1. 将问题抽象为树形结构 2. 定义路径、选择列表和终止条件 3. 设计有效的剪枝策略 坚持每天解决1-2道回溯问题,2个月内可显著提升算法思维!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值