Day27~77. 组合

1,083 阅读6分钟

摘要

本文主要介绍了回溯算法的理论基础和回溯法模版,以及LeetCode中77.组合问题的解题思路和代码。

1、回溯算法理论基础

1.1 概念

回溯算法是一种用于解决组合问题、排列问题和搜索问题的常用算法。它基于深度优先搜索的思想,通过不断地尝试各种可能的选择,然后回退到上一步,直到找到问题的解或穷尽所有可能性。

回溯算法的核心思想是构建一棵决策树,每个节点表示在问题中的一个决策点,从根节点出发,逐步向下扩展树,直到找到解或无法继续扩展。如果遇到无法继续扩展的情况,就回退到上一层节点,尝试其他分支。

以下是回溯算法的基本框架和关键要点:

  1. 路径(Path): 在算法执行过程中,需要维护一个路径,用于记录当前的选择或决策。
  2. 选择列表(Choices): 每一步都有一个或多个选择,算法需要生成当前状态下的所有候选选择。
  3. 结束条件(Termination Condition): 定义何时达到问题的解,当满足结束条件时,算法结束。
  4. 回溯过程(Backtracking): 如果当前路径无法达到解或满足条件,算法需要回退到上一个状态,尝试其他选择。

下面是一个简单的 Java 示例,用于解决组合问题(找到数组中所有可能的组合):

import java.util.ArrayList;
import java.util.List;
​
public class BacktrackingExample {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> result = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        backtrack(result, path, n, k, 1);
        return result;
    }
​
    private void backtrack(List<List<Integer>> result, List<Integer> path, int n, int k, int start) {
        // 结束条件:已选择的元素数量达到 k
        if (path.size() == k) {
            result.add(new ArrayList<>(path));
            return;
        }
​
        // 选择列表:从 start 到 n 中选择一个数加入当前组合
        for (int i = start; i <= n; i++) {
            path.add(i);
            // 递归进入下一层决策树
            backtrack(result, path, n, k, i + 1);
            // 撤销选择
            path.remove(path.size() - 1);
        }
    }
​
    public static void main(String[] args) {
        BacktrackingExample solution = new BacktrackingExample();
        int n = 4;
        int k = 2;
        List<List<Integer>> result = solution.combine(n, k);
        for (List<Integer> combination : result) {
            System.out.println(combination);
        }
    }
}

在上述示例中,combine 函数用于生成组合,backtrack 函数用于递归搜索所有可能的组合。回溯算法的关键在于如何维护路径、选择列表、结束条件和回溯过程。

这个示例展示了回溯算法在 Java 中的基本实现方式,你可以根据具体问题的要求来定制路径、选择列表和结束条件,以解决各种类型的组合、排列和搜索问题。

1.2 回溯法模版

回溯法是一种常用于解决组合、排列、搜索等问题的算法。以下是回溯法的通用模板,使用 Java 编程语言表示:

public class BacktrackingTemplate {
    public void backtrack(/* 入参 */) {
        // 判断是否满足结束条件
        if (满足结束条件) {
            // 处理结束条件,例如将结果存入结果集
            return;
        }
​
        // 遍历选择列表
        for (选择 : 选择列表) {
            // 做出选择
            做出选择;
​
            // 进入下一层决策树
            backtrack(/* 下一层参数 */);
​
            // 撤销选择
            撤销选择;
        }
    }
}

在这个模板中,你需要根据具体问题的特性来填充模板中的各个部分:

  1. 满足结束条件: 这部分代码用于判断是否达到问题的解,如果满足结束条件,通常需要将当前的选择存入结果集或进行其他操作。
  2. 选择列表: 这是问题中的选择集合,需要根据问题的不同进行设置。例如,如果是组合问题,选择列表是从数组中选择元素;如果是排列问题,选择列表是数组中的所有元素;如果是搜索问题,选择列表是所有可能的状态或路径。
  3. 做出选择和撤销选择: 在算法的每一层递归中,需要做出一个选择,并进入下一层递归。在进入下一层递归之前,需要做出选择(例如,在路径中添加一个元素),然后在递归完成后撤销选择,以便回到上一层状态。

使用这个通用模板,你可以根据具体问题的需求来填充相关的逻辑。回溯算法的核心思想是通过递归实现决策树的遍历,在每个节点上做出选择并继续搜索,直到找到解或穷尽所有可能性。

2、77.组合

2.1 思路

  1. 递归函数的参数: 在递归函数中,我们需要传递以下参数:

    • start:表示当前考虑的数字的起始位置。
    • k:表示还需要选择几个数字。
    • n:表示可选数字的范围。
  2. 满足结束条件: 在递归函数的开头,首先判断是否满足结束条件。结束条件有两个:

    • k 等于 0 时,表示已经选择了 k 个数字,将当前的组合加入结果集。
    • i > n 时,表示已经考虑完了所有可选数字,直接返回。
  3. 进行选择和递归: 在递归函数的主体部分,我们需要考虑两种情况:

    • 选择当前数字 i,将其添加到当前组合中,然后递归调用函数,传递参数 i+ 1k - 1
    • 不选择当前数字 i,直接递归调用函数,传递参数 i+ 1k
  4. 撤销选择: 在每一次递归返回后,需要撤销选择,即将当前数字从组合中移除,以便考虑下一个数字的选择。

  5. 回溯搜索: 通过不断地进行选择和递归,回溯算法会搜索所有可能的组合。

1、可以剪枝优化吗?

例如,对于给定的参数 n = 4, k = 2,当遍历选择列表时,需要考虑 k 的值来确定循环的范围。具体来说,当 k = 2 时,如果 n = 3,我们可以继续遍历,但如果 n = 4,则不应该继续遍历。因此,我们可以调整循环变量 i 的取值范围为 [start, (n - k + 1) + linked.size()]

这个调整确保了在当前状态下,仅考虑那些可以生成有效组合的数字。这是一个很好的优化,以减少不必要的遍历,加快算法的执行速度。

77.组合4

2.2 代码

未剪枝优化

    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> list = new ArrayList<>();
        LinkedList<Integer> linked = new LinkedList<>();
        combine(list, linked, n, k, 1);
        return list;
    }
​
    public void combine(List<List<Integer>> list, LinkedList<Integer> linked, int n, int k, int start) {
        if(linked.size() == k) {
            list.add(new ArrayList<>(linked));
            return;
        }
​
        for(int i=start; i<=n; i++) {
            linked.add(i);
​
            combine(list, linked, n, k, i+1);
​
            linked.removeLast();
        }
    }

ChatGPT的代码

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> result = new ArrayList<>();
        List<Integer> current = new ArrayList<>();
        backtrack(result, current, 1, n, k);
        return result;
    }
​
    private void backtrack(List<List<Integer>> result, List<Integer> current, int start, int n, int k) {
        if (k == 0) {
            result.add(new ArrayList<>(current));
            return;
        }
        
        for (int i = start; i <= n; i++) {
            current.add(i);
            backtrack(result, current, i + 1, n, k - 1);
            current.remove(current.size() - 1);
        }
    }
}

剪枝优化

    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> list = new ArrayList<>();
        LinkedList<Integer> linked = new LinkedList<>();
        combine(list, linked, n, k, 1);
        return list;
    }
​
    public void combine(List<List<Integer>> list, LinkedList<Integer> linked, int n, int k, int start) {
        if(linked.size() == k) {
            list.add(new ArrayList<>(linked));
            return;
        }
​
        for(int i=start; i<=(n-k+1)+linked.size(); i++) {
            linked.add(i);
​
            combine(list, linked, n, k, i+1);
​
            linked.removeLast();
        }
    }

参考资料

代码随想录-回溯算法理论基础

代码随想录-组合问题