【数据结构与算法】之深入解析“组合总和III”的求解思路与算法示例

一、题目要求

  • 找出所有相加之和为 n 的 k 个数的组合,组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
  • 说明:
    • 所有数字都是正整数。
    • 解集不能包含重复的组合。
  • 示例 1:
输入: k = 3, n = 7
输出: [[1,2,4]]
  • 示例 2:
输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]

二、求解算法

① 回溯+剪枝

  • 本题就是在 [1,2,3,4,5,6,7,8,9] 这个集合中找到和为 n 的 k 个数的组合,相对于 【数据结构与算法】之N个数中有K个数可能的组合算法 ,无非就是多了一个限制,要找到和为 n 的 k 个数的组合,而整个集合已经是固定 [1,…,9],理解了N个数中有K个数可能的组合算法之后本题就比较简单了。
  • k 相当于了树的深度,9(因为整个集合就是9个数)就是树的宽度。例如 k = 2,n = 4 的话,就是在集合[1,2,3,4,5,6,7,8,9] 中求 k(个数) = 2,n(和) = 4 的组合。
  • 选取过程如下图所示,可以看出,只有最后取到集合(1,3)和为 4 符合条件:

【数据结构与算法】之深入解析“组合总和III”的求解思路与算法示例

  • 回溯三部曲:
    • 确定递归函数参数
      • 【数据结构与算法】之N个数中有K个数可能的组合算法 一样,依然需要一维数组 path 来存放符合条件的结果,二维数组 result 来存放结果集,这里依然定义 path 和 result 为全局变量,至于为什么取名为 path?从上面树形结构中,可以看出,结果其实就是一条根节点到叶子节点的路径。
vector<vector<int>> result; // 存放结果集
vector<int> path; 			// 符合条件的结果
      • 接下来还需要如下参数:
        • targetSum(int)目标和,也就是题目中的 n;
        • k(int)就是题目中要求 k 个数的集合;
        • sum(int)为已经收集的元素的总和,也就是 path 里元素的总和;
        • startIndex(int)为下一层 for 循环搜索的起始位置。
      • 所以代码如下:
vector<vector<int>> result;
vector<int> path;
void backtracking(int targetSum, int k, int sum, int startIndex)
      • 其实这里 sum 这个参数也可以省略,每次 targetSum 减去选取的元素数值,然后判断如果 targetSum 为 0,说明收集到符合条件的结果,这里为了直观便于理解,还是加一个 sum 参数。还要强调一下,回溯法中递归函数参数很难一次性确定下来,一般先写逻辑,需要啥参数,再填什么参数。
    • 确定终止条件
      • k 其实就已经限制树的深度,因为就取 k 个元素,树再往下深了没有意义,所以如果 path.size() 和 k 相等,就终止;
      • 如果此时 path 里收集到的元素和(sum) 和 targetSum(就是题目描述的 n)相同,就用 result 收集当前的结果。
      • 所以终止代码如下:
if (path.size() == k) {
    if (sum == targetSum) result.push_back(path);
    return; // 如果path.size() == k 但sum != targetSum 直接返回
}

【数据结构与算法】之深入解析“组合总和III”的求解思路与算法示例

      • 处理过程就是 path 收集每次选取的元素,相当于树型结构里的边,sum 来统计 path 里元素的总和:
for (int i = startIndex; i <= 9; i++) {
    sum += i;
    path.push_back(i);
    backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
    sum -= i; 		 // 回溯
    path.pop_back(); // 回溯
}
    • 别忘了处理过程和回溯过程是一一对应的,处理有加,回溯就要有减,因此 C++ 示例如下:
class Solution {
private:
    vector<vector<int>> result; // 存放结果集
    vector<int> path; // 符合条件的结果
    // targetSum:目标和,也就是题目中的n。
    // k:题目中要求k个数的集合。
    // sum:已经收集的元素的总和,也就是path里元素的总和。
    // startIndex:下一层for循环搜索的起始位置。
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        if (path.size() == k) {
            if (sum == targetSum) result.push_back(path);
            return; // 如果path.size() == k 但sum != targetSum 直接返回
        }
        for (int i = startIndex; i <= 9; i++) {
            sum += i; 		   // 处理
            path.push_back(i); // 处理
            backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
            sum -= i; 		 // 回溯
            path.pop_back(); // 回溯
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear(); // 可以不加
        path.clear();   // 可以不加
        backtracking(n, k, 0, 1);
        return result;
    }
};
  • C 示例:
int *path;
int pathTop;
int **result;
int resultTop;

void backTrack(int k,int n,int sum ,int startIndex){
    if(pathTop==k){
        if(n == sum){
            int *temp = malloc(sizeof(int)*k);
            for(int i =0;i<k;i++){
                temp[i] = path[i];
            }
            result[resultTop++] = temp;
        }
        return ;
    }
    for(int j=startIndex;j<=9-(k-pathTop)+1;j++){
        sum = sum + j;
        path[pathTop++] = j;
        backTrack(k,n,sum,j+1);
        sum = sum -j;
        pathTop--;
    }
}
int** combinationSum3(int k, int n, int* returnSize, int** returnColumnSizes){
    pathTop = resultTop = 0;
    path = malloc(sizeof(int) *k);
    result = malloc(sizeof(int*) *100);
    backTrack(k,n,0,1);
    *returnSize = resultTop;
    *returnColumnSizes = malloc(sizeof(int*) * resultTop);
    for(int i=0 ; i<resultTop ; i++){
        (*returnColumnSizes)[i] = k;
    }
    return result;
}
  • 然后剪枝操作其实是很容易想到,如下图所示:

【数据结构与算法】之深入解析“组合总和III”的求解思路与算法示例

  • 已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。那么剪枝的地方一定是在递归终止的地方剪,剪枝代码如下:
if (sum > targetSum) { // 剪枝操作
    return;
}
  • 最后的 C++示例如下:
class Solution {
private:
    vector<vector<int>> result; // 存放结果集
    vector<int> path; // 符合条件的结果
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        if (sum > targetSum) { // 剪枝操作
            return; // 如果path.size() == k 但sum != targetSum 直接返回
        }
        if (path.size() == k) {
            if (sum == targetSum) result.push_back(path);
            return;
        }
        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
            sum += i; // 处理
            path.push_back(i); // 处理
            backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
            sum -= i; // 回溯
            path.pop_back(); // 回溯
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear(); // 可以不加
        path.clear();   // 可以不加
        backtracking(n, k, 0, 1);
        return result;
    }
};
  • Java 示例:
class Solution {
	List<List<Integer>> result = new ArrayList<>();
	LinkedList<Integer> path = new LinkedList<>();

	public List<List<Integer>> combinationSum3(int k, int n) {
		backTracking(n, k, 1, 0);
		return result;
	}

	private void backTracking(int targetSum, int k, int startIndex, int sum) {
		// 减枝
		if (sum > targetSum) {
			return;
		}

		if (path.size() == k) {
			if (sum == targetSum) result.add(new ArrayList<>(path));
			return;
		}
		
		// 减枝 9 - (k - path.size()) + 1
		for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
			path.add(i);
			sum += i;
			backTracking(targetSum, k, i + 1, sum);
			// 回溯
			path.removeLast();
			// 回溯
			sum -= i;
		}
	}
}

② 二进制(子集)枚举

  • 组合中只允许含有 1−9 的正整数,并且每种组合中不存在重复的数字,意味着这个组合中最多包含
    9 个数字,我们可以把原问题转化成集合 S={1,2,3,4,5,6,7,8,9},要找出 S 的当中满足如下条件的子集:
    • 大小为 k;
    • 集合中元素的和为 n。
  • 因此可以用子集枚举的方法来解决本题,即原序列中有 9 个数,每个数都有两种状态,「被选择到子集中」和「不被选择到子集中」,所以状态的总数为 29。用一个 9 位二进制数 mask 来记录当前所有位置的状态,从第到高第 i 位为 0 表示 i 不被选择到子集中,为 1 表示 i 被选择到子集中。当按顺序枚举 [0, 29−1] 中的所有整数的时候,就可以不重不漏地把每个状态枚举到,对于一个状态 mask,可以用位运算的方法得到对应的子集序列,然后再判断是否满足上面的两个条件,如果满足,就记录答案。
  • 如何通过位运算来得到 mask 各个位置的信息?对于第 i 个位置,可以判断 (1 << i) & mask 是否为 0,如果不为 0 则说明 i 在子集当中。当然,这里要注意的是,一个 9 位二进制数 i 的范围是 [0,8],而可选择的数字是 [1,9],所以需要做一个映射,最简单的办法就是当我们知道 i 位置不为 0 的时候将 i+1 加入子集。
  • 当然,子集枚举也可以用递归实现。
  • C++ 示例:
class Solution {
public:
    vector<int> temp;
    vector<vector<int>> ans;

    bool check(int mask, int k, int n) {
        temp.clear();
        for (int i = 0; i < 9; ++i) {
            if ((1 << i) & mask) {
                temp.push_back(i + 1);
            }
        }
        return temp.size() == k && accumulate(temp.begin(), temp.end(), 0) == n; 
    }

    vector<vector<int>> combinationSum3(int k, int n) {
        for (int mask = 0; mask < (1 << 9); ++mask) {
            if (check(mask, k, n)) {
                ans.emplace_back(temp);
            }
        }
        return ans;
    }
};
  • Java 示例:
class Solution {
    List<Integer> temp = new ArrayList<Integer>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();

    public List<List<Integer>> combinationSum3(int k, int n) {
        for (int mask = 0; mask < (1 << 9); ++mask) {
            if (check(mask, k, n)) {
                ans.add(new ArrayList<Integer>(temp));
            }
        }
        return ans;
    }

    public boolean check(int mask, int k, int n) {
        temp.clear();
        for (int i = 0; i < 9; ++i) {
            if (((1 << i) & mask) != 0) {
                temp.add(i + 1);
            }
        }
        if (temp.size() != k) {
            return false;
        }
        int sum = 0;
        for (int num : temp) {
            sum += num;
        }
        return sum == n;
    }
}
  • 复杂度分析:
    • 时间复杂度:O(M×2M),其中 M 为集合的大小,本题中 M 固定为 9,一共有 2M 个状态,每个状态需要 O(M+k)=O(M) 的判断 (k≤M),故时间复杂度为 O(M×2M);
    • 空间复杂度:O(M),即 temp 的空间代价。
上一篇:uCOS-III 学习记录(6)——优先级表


下一篇:​LeetCode刷题实战162:寻找峰值