18. 字符串的排列与组合:全排列的递归实现,字符串的所有子集

好,咱们今天聊点硬核的——字符串的排列与组合。这两个问题,说白了就是「把字符重新排序」和「挑出一些字符来」。听起来简单?嗯,但递归实现里藏着不少坑。我当年第一次写全排列时,调试到怀疑人生。

18.1 全排列的递归实现

全排列,就是把字符串里的所有字符,按所有可能的顺序排一遍。比如 "abc",排列出来就是 abc、acb、bac、bca、cab、cba,一共 6 种。

递归的思路其实很朴素:固定第一个字符,然后对剩下的字符做全排列。你想想看,这不就是「分而治之」吗?

核心思想: 把问题拆成「选一个字符放首位」+「剩下字符的全排列」。

我习惯用交换法来实现。什么意思呢?就是让每个字符都有机会站到第一个位置,然后递归处理后面的部分。

#include <stdio.h>
#include <string.h>

// 交换两个字符
void swap(char *a, char *b) {
    char temp = *a;
    *a = *b;
    *b = temp;
}

// 全排列递归函数
void permute(char *str, int left, int right) {
    if (left == right) {
        // 已经排到最后一个字符,输出结果
        printf("%s\n", str);
        return;
    }

    for (int i = left; i <= right; i++) {
        // 固定第 left 个位置为 str[i]
        swap(&str[left], &str[i]);
        // 递归处理剩下的字符
        permute(str, left + 1, right);
        // 回溯:恢复原来的顺序
        swap(&str[left], &str[i]);
    }
}

int main() {
    char str[] = "abc";
    int n = strlen(str);
    printf("字符串 \"%s\" 的全排列:\n", str);
    permute(str, 0, n - 1);
    return 0;
}

运行结果:

abc
acb
bac
bca
cba
cab

注意那个回溯的 swap。我曾经漏掉这一步,结果递归回来时字符串顺序全乱了,输出一堆重复的排列。嗯,回溯是递归的灵魂,忘了它你就等着哭吧。

小技巧: 如果字符串里有重复字符,比如 "aab",上面的代码会输出重复排列。解决办法是在交换前加一个判断:如果 str[i] 已经在 left 到 i-1 之间出现过,就跳过。这叫「剪枝」。

18.2 字符串的所有子集

子集问题,说白了就是「选或不选」。每个字符有两种状态:在子集里,或者不在。所以 n 个字符的字符串,一共有 2^n 个子集(包括空集)。

递归实现也很直观:从第一个字符开始,要么选它,要么不选它,然后继续处理下一个字符。

#include <stdio.h>
#include <string.h>

// 辅助数组,记录当前子集
char subset[100];

// 生成所有子集
void subsets(char *str, int index, int len, int pos) {
    // 输出当前子集
    printf("{");
    for (int i = 0; i < pos; i++) {
        printf("%c", subset[i]);
    }
    printf("}\n");

    // 从 index 开始,依次选择字符
    for (int i = index; i < len; i++) {
        // 选当前字符
        subset[pos] = str[i];
        // 递归处理后面的字符
        subsets(str, i + 1, len, pos + 1);
        // 回溯:其实这里不需要显式删除,因为下次会覆盖
    }
}

int main() {
    char str[] = "abc";
    int n = strlen(str);
    printf("字符串 \"%s\" 的所有子集:\n", str);
    subsets(str, 0, n, 0);
    return 0;
}

运行结果:

{}
{a}
{ab}
{abc}
{ac}
{b}
{bc}
{c}

注意看,这个顺序不是字典序,而是按「选字符的先后顺序」来的。如果你想要字典序,可以先对字符串排序,或者用另一种方法——位掩码法。

位掩码法: 用一个整数的二进制位来表示每个字符选或不选。比如 3 位二进制数,从 000 到 111,正好对应 8 个子集。
void subsets_bitmask(char *str) {
    int n = strlen(str);
    int total = 1 << n;  // 2^n

    for (int mask = 0; mask < total; mask++) {
        printf("{");
        for (int i = 0; i < n; i++) {
            if (mask & (1 << i)) {
                printf("%c", str[i]);
            }
        }
        printf("}\n");
    }
}

我个人更喜欢位掩码法,代码简洁,而且不用递归,性能也好。但递归法更容易理解「选或不选」这个核心思想。

18.3 知识体系图

下面这张图帮你理清本章的知识脉络:

字符串的排列与组合 全排列 所有子集 递归思想:固定首位 + 剩余全排列 实现方式:交换法(swap + 回溯) 去重技巧:剪枝(跳过重复字符) 递归法:选或不选 + 回溯 位掩码法:二进制位表示选择状态 子集总数:2^n(含空集) 核心:递归 + 回溯 = 暴力枚举的优雅实现

18.4 避坑指南

我这些年写递归,踩过的坑比走过的路还多。给你列几个最常见的:

  • 忘记回溯——交换法里,递归完一定要换回来。我曾经漏了这一步,结果排列结果全是错的。
  • 递归终止条件写错——全排列是 left == right 时输出,子集是处理完所有字符时输出。别搞混了。
  • 字符串长度别算错——strlen 返回的是字符个数,不包括 '\0'。但你的字符数组要留够空间。
  • 重复字符没处理——如果字符串里有重复字符,不剪枝的话会输出大量重复排列。面试官看到会皱眉头的。
注意: 递归深度等于字符串长度。如果字符串很长(比如超过 1000 个字符),递归会爆栈。这时候考虑用迭代法或者位掩码法。

18.5 实战经验

我在做密码破解工具时,遇到过需要生成所有可能的密码组合。嗯,就是全排列的变种——每个位置可以选多个字符,而且可以重复。那叫「可重复排列」,用递归实现时,每次递归都从 0 开始遍历字符集,而不是从 left+1 开始。

还有一次,我在做数据脱敏工具,需要生成某个字段的所有可能取值子集。用位掩码法,一个 int 就能搞定 32 个字符的所有子集,性能杠杠的。

说白了,排列组合问题在面试里出现频率极高。不是因为实际工作中天天用,而是因为它能考察你对递归和回溯的理解深度。你想想看,能把递归写明白的人,写其他代码也不会差到哪去。

我的建议: 先用手在纸上画递归树,把每一步的调用关系理清楚。代码写不出来?没关系,先把递归树画对,代码自然就出来了。

公众号:蓝海资料掘金营,微信deep3321