permute C语言如何实现排列组合算法?

99ANYc3cd6
预计阅读时长 19 分钟
位置: 首页 C语言 正文

排列是指从一组元素中按照一定顺序选取所有可能的组合,对于数组 [1, 2, 3],它的全排列是: [1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]

permute C语言
(图片来源网络,侵删)

在 C 语言中,实现排列最经典、最常用的方法是递归回溯法


核心思想:递归回溯

想象一下,我们要生成所有排列,这个过程可以分解为以下步骤:

  1. 选择:从当前可选的数字中,选择一个。
  2. 探索:将这个选中的数字固定在当前位置,然后对剩下的数字重复这个过程(递归)。
  3. 撤销:探索完成后,撤销这个选择,以便尝试下一个可选的数字(回溯)。

为了实现“选择”和“撤销”,我们需要一个机制来标记哪些数字已经被使用过,以及一个地方来存储当前的排列结果。


实现步骤

  1. 输入:一个包含 n 个元素的数组。
  2. 状态变量
    • used[]: 一个布尔数组,用来标记哪些元素已经被选中并放在了当前排列的某个位置。
    • path[]: 一个数组,用来存储当前正在构建的排列。
    • depth: 或 index,一个整数,表示当前正在构建排列的第 depth 个位置。
  3. 递归函数:我们将核心逻辑放在一个递归函数中,backtrack()
  4. 终止条件:当 depth 等于数组的长度 n 时,说明我们已经构建了一个完整的排列,将 path 数组的内容打印或保存下来。
  5. 递归过程
    • 在函数内部,遍历输入数组的所有元素。
    • 如果一个元素 nums[i] 还没有被使用(!used[i]),就执行以下操作: a. 选择:将 nums[i] 标记为已使用 (used[i] = true),并将其加入到 path 的当前位置 (path[depth] = nums[i])。 b. 探索:递归调用 backtrack(),并让 depth + 1,去填充下一个位置。 c. 回溯:从递归调用返回后,撤销刚才的选择,将 nums[i] 标记为未使用 (used[i] = false),以便它可以被用于后续其他位置的排列。

C 语言代码示例

下面是一个完整的 C 语言示例,它会打印出一个整数数组的所有全排列。

permute C语言
(图片来源网络,侵删)
#include <stdio.h>
#include <stdbool.h> // 使用 bool, true, false
// nums: 原始数组
// n: 数组长度
// path: 当前正在构建的排列路径
// used: 标记元素是否已被使用的数组
// depth: 当前要填充的路径位置(从0开始)
void backtrack(int* nums, int n, int* path, bool* used, int depth) {
    // 1. 终止条件
    // depth 等于 n,说明 path 已经被填满,得到了一个完整的排列
    if (depth == n) {
        // 打印当前排列
        for (int i = 0; i < n; i++) {
            printf("%d ", path[i]);
        }
        printf("\n");
        return; // 返回上一层递归
    }
    // 2. 递归过程
    // 遍历原始数组,为当前 depth 位置选择一个数字
    for (int i = 0; i < n; i++) {
        // 3. 选择
        // nums[i] 还没有被使用,则可以选择它
        if (!used[i]) {
            // 标记为已使用
            used[i] = true;
            // 将 nums[i] 放入路径的当前位置
            path[depth] = nums[i];
            // 4. 探索
            // 递归调用,去填充下一个位置 (depth + 1)
            backtrack(nums, n, path, used, depth + 1);
            // 5. 回溯
            // 从上面的递归调用返回后,撤销选择
            // 以便可以尝试其他数字
            used[i] = false;
            // path[depth] 的值会在下一次循环中被覆盖,所以这里不需要手动清空
        }
    }
}
// 封装函数,方便调用
void permute(int* nums, int numsSize) {
    // 分配辅助空间
    int* path = (int*)malloc(numsSize * sizeof(int));
    bool* used = (bool*)malloc(numsSize * sizeof(bool));
    // 初始化 used 数组为 false,表示所有元素都未被使用
    for (int i = 0; i < numsSize; i++) {
        used[i] = false;
    }
    printf("All permutations of the array are:\n");
    // 开始递归
    backtrack(nums, numsSize, path, used, 0);
    // 释放内存
    free(path);
    free(used);
}
int main() {
    int nums[] = {1, 2, 3};
    int n = sizeof(nums) / sizeof(nums[0]);
    permute(nums, n);
    return 0;
}

代码输出:

All permutations of the array are:
1 2 3 
1 3 2 
2 1 3 
2 3 1 
3 1 2 
3 2 1 

代码逐行解析

  1. #include <stdbool.h>: 引入这个头文件,我们可以使用 bool 类型,让代码更清晰。
  2. backtrack 函数:
    • 参数: 它接收原始数组、长度、当前路径、使用标记和当前深度,这些是构建和回溯状态所必需的全部信息。
    • if (depth == n): 这是递归的“出口”,当我们要填充的位置深度已经等于数组长度时,说明一个排列已经完成。
    • for (int i = 0; i < n; i++): 这个循环是“选择”的关键,它让我们遍历所有可能性。
    • if (!used[i]): 这是剪枝操作,如果某个数字已经被用过,就跳过它,避免重复。
    • used[i] = true;: 做出选择,标记这个数字为“已占用”。
    • path[depth] = nums[i];: 将选中的数字放入结果路径的当前位置。
    • backtrack(..., depth + 1): 递归调用是核心,我们“委托”给一个更小的子问题:去填充下一个位置。
    • used[i] = false;: 回溯,当从递归调用返回后,我们必须撤销刚才的选择,把状态恢复到调用之前的样子,这样才能尝试下一个数字,这就是“回溯”的精髓。
  3. permute 函数:
    • 这是一个辅助函数,用于初始化环境并启动递归。
    • 它负责分配 pathused 数组的内存。
    • 初始化 used 数组。
    • 调用 backtrack 开始整个过程。
    • 最后释放内存,防止内存泄漏。
  4. main 函数:
    • 定义一个测试数组。
    • 调用 permute 函数来执行排列操作。

进阶与变种

处理重复元素

如果输入数组包含重复元素([1, 1, 2]),上面的代码会产生重复的排列(例如两个 [1, 1, 2]),为了避免这种情况,我们需要在 for 循环中增加一个去重逻辑。

修改点:在 for 循环中,nums[i]nums[i-1] 相同,nums[i-1] 在之前已经被使用过,nums[i] 就不应该被再次选择。

// 在 backtrack 函数的 for 循环内部修改
for (int i = 0; i < n; i++) {
    // 去重逻辑
    if (used[i] || (i > 0 && nums[i] == nums[i-1] && !used[i-1])) {
        continue;
    }
    // ... 剩下的选择、探索、回溯逻辑不变 ...
}

注意!used[i-1] 的条件保证了我们只使用重复元素中“第一个”未被使用的那个,从而保证了排列的唯一性,这个条件比较微妙,是处理排列去重的标准写法。

返回所有排列的列表(LeetCode 风格)

有时候我们不想直接打印,而是想把所有排列的结果存储起来,返回一个二维数组,这需要动态内存管理。

permute C语言
(图片来源网络,侵删)
// ... ( backtrack 函数内部打印部分需要修改 )
// 定义一个结果列表结构
int** result;
int result_size;
int* result_col_sizes;
// 修改 backtrack 的终止条件
if (depth == n) {
    // 分配一个一维数组来存储当前排列
    int* current_permutation = (int*)malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) {
        current_permutation[i] = path[i];
    }
    // 将当前排列添加到结果列表中
    result[result_size] = current_permutation;
    result_col_sizes[result_size] = n;
    result_size++;
    return;
}
// ... (其余逻辑不变)
// 在 permute 函数中,你需要预先分配足够大的 result 数组
// 这部分比较复杂,需要先计算总数(n!)或者动态扩容
// 实际实现中,通常先收集到临时列表,最后再转换成 C 的数组形式

这种实现方式在 C 中比较繁琐,因为 C 没有内置的动态数组或列表结构,通常需要预先计算排列总数 n! 来分配足够大的空间,或者使用更复杂的动态内存管理。

方法 优点 缺点 适用场景
递归回溯 逻辑清晰,易于理解和实现,对于中小规模数据效率足够。 递归深度大时可能导致栈溢出,需要额外的 usedpath 数组空间。 算法学习、面试、中小规模数据的排列生成。
库函数 (如 next_permutation) 代码量极少,效率高,且能很好地处理重复元素。 需要排序作为前置步骤,只能按字典序生成下一个排列,无法直接获取所有排列。 需要按顺序迭代所有排列的场景。

对于大多数学习和应用场景,掌握递归回溯法是理解和实现排列功能的关键,它不仅是解决排列问题的利器,也是解决许多其他组合问题(如子集、组合、棋盘问题等)的通用思想。

-- 展开阅读全文 --
头像
dede仿站高级教程,如何快速高效完成仿站?
« 上一篇 04-12
dede友情链接怎么改?直接替换还是需修改代码?
下一篇 » 04-12

相关文章

取消
微信二维码
支付宝二维码

目录[+]