DFS——组合与排列

这篇博客探讨了深度优先搜索(DFS)在解决排列和组合问题中的应用。介绍了如何生成n维向量,全排列,以及如何利用next_permutation函数。同时,详细讨论了在有重复元素时如何避免重复的全排列,并讲解了位向量法、增量法和二进制法在枚举组合中的实现,展示了DFS在组合问题上的灵活运用。

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

引子

1.关于深搜:深度优先搜索是一种解决问题的算法策略。通常,首先它把问题解决过程分解成若干个阶段,然后递归地搜索(枚举)每个阶段所有可能的选项,得到组合式的解,到达边界后,检验解的合法性。
2.学习了那么久的深搜,再回头看一下,就是一串格子,按照题目的要求去填空,其本质就是求组合与排列
3.算法框架:

void dfs(int i)
{
    if(满足边界条件)
    {
        输出解
        return;
    }
    for(可选择的选择j)
        if(没有访问过j&&其它条件)
        {
            标记j已经访问过
            保存
            dfs(i+1);
            取消标记//回溯
        }
}

正题

排列

生成n维向量vector

n维向量是有n个元素的序对,每个元素的取值范围从1到k。例如3的5维向量为{1,1,1,1,1},{1,1,1,1,2},….,{3,3,3,3,3}。输入k和n,输出所有k的n维向量。  、
限制条件 :1<= k <=10, 1<=n<=6

分析

简单的一个搜索,直接用框架解决,而且元素可以重复,不用标记

#include<cstdio>
#define MAXN 6
int n,k,a[MAXN+5];
void dfs(int i)
{
    if(i>n)
    {
        for(int j=1;j<n;j++)
            printf("%d ",a[j]);
        printf("%d\n",a[n]);
        return ;
    }
    for(int j=1;j<=k;j++)
    {
        a[i]=j;
        dfs(i+1);
    }
}
int main()
{
    scanf("%d %d",&n,&k);
    dfs(1);
}
思考
  1. 这道题如果要输出序号,可以增加一个变量tot,like this:
#include<cstdio>
#define MAXN 6
int n,k,a[MAXN+5],tot;
void dfs(int i)
{
    if(i>n)
    {
        tot++;
        for(int j=1;j<n;j++)
            printf("%d:%d ",tot,a[j]);
        printf("%d\n",a[n]);
        return ;
    }
    for(int j=1;j<=k;j++)
    {
        a[i]=j;
        dfs(i+1);
    }
}
int main()
{
    scanf("%d %d",&n,&k);
    dfs(1);
}

2.k的n维向量的总方案数是多少?

对于每一个位置i,都有k个选择,一共n个位置,所以方案数应是k^n

全排列

输入n,输出数字1..n的所有排列。这里不是要计算排列有多少种,而是枚举所 有的排列,以字典顺序枚举。 
限制条件 1<=n<=10

分析

与第一题类似,要标记判重
也可以在存储的答案中查找一遍有无使用选项j,但此方法明显慢得多

#include<cstdio>
#define MAXN 10
int ans[MAXN+5],n;
bool vis[MAXN+5];
void dfs(int x)
{
    if(x>n)
    {
        for(int i=1;i<n;i++)
            printf("%d ",ans[i]);
        printf("%d\n",ans[n]);
        return;
    }
    for(int i=1;i<=n;i++)
        if(!vis[i])
        {
            vis[i]=1;
            ans[x]=i;
            dfs(x+1);
            vis[i]=0;
        }
}

int main()
{
    scanf("%d",&n);
    dfs(1);
}

还有一个方法:交换法
初始:将ans数组赋成1,2,…,n
递归参数x:每次将i从x枚举到n
交换ans[x]和ans[i]
递归x+1
换回ans[x]和ans[i]

#include<cstdio>
#include<iostream>
using namespace std;
# define MAXN 100
int ans[MAXN+5];
int n;
void dfs(int x)
{
    if(x==n)
    {
        for(int i=0;i<n;i++)
            printf("%d ",ans[i]);
        puts(" ");
        return ;
    }
    for(int i=x;i<n;i++)
{
        swap(ans[i],ans[x]);
        dfs(x+1);
        swap(ans[i],ans[x]);
    }
}
int main()
{
    scanf("%d",&n);
    for(int i=0;i<n;i++)
        ans[i]=i+1;
    dfs(0);
    return 0;
}
生成下一个排列:next_permutation

STL 的next_permutation()提供了便捷的枚举排列的方法。它从字典序最小的排 列开始,调用一次,产生下一个排列。 
遵从STL算法库的惯例,next_permutation(begin, end)接受两个迭代器参数,
输入和结果均在迭代器所指容器(通常是vector或数组)。 
当能够产生一个按字典序的新排列时,next_permutation()返回true,否则返
回false。可以利用返回值,在一个循环中,生成所有排列。 
调用一次next_permutation()的时间复杂度为:O(n),大约是从当前排列到下 一个排列需要调用交换函数swap()的次数。 
另一个成对的函数是prev_permutation(),它生成上一个排列。

举个栗子:

生成可重集的全排列

输入一个包含n个整数的数组,元素可以重复。按字典序输出所有全排列,方案不重复。 
例如{1,2,2} 所有的排列就是{1,2, 2}、{2, 1, 2} 、 {2, 2, 1} 。 
限制条件 1<=n<=10 

分析

如果还像之前那样进行标记的话,由于有重复的元素,所以可能会造成重复(标记下标)或缺少元素(标记值),所以要进行去重。那我们就要思考在什么情况下是重复的。如果当前数字与上一次这个位置的数字的值是相同的,那么排列看起来没有区别,所以我们可以用一个变量last来记录上一次这个位置出现的值,进行判断。在做这个方法时要注意先排序,其目的是把相同元素排在一起,否则last会失去作用,因为last仅仅记录的是上一次的值。

#include<cstdio>
#define MAXN 20
using namespace std;
int a[MAXN+5],ans[MAXN+5],n,last;
bool vis[MAXN+5];
void dfs(int i)
{
    if(i>n)
    {
        for(int j=1;j<n;j++)
            printf("%d ",ans[j]);
        printf("%d\n",ans[n]);
    }
    last=-1;
    for(int j=1;j<=n;j++)
        if(!vis[j]&&a[j]!=last)
        {
            ans[i]=a[j];
            vis[j]=1;
            last=a[j];
            dfs(i+1);
            vis[j]=0;
        }
}
int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
        scanf("%d",&a[i]);
    dfs(1);
}

第二种方法是改进一下vis[],用一个cnt数组来记录这个数字有多少个,用去一个就–,如果cnt[i]为0,表示i已经用完了。

#include<cstdio>
#define MAXN 10
#define MAXVAL 30
int n;
int ans[MAXN+5];
int cnt[MAXVAL+5];
void dfs(int i)
{
    if(i>n)
    {
        for(int j=1;j<n;j++)
            printf("%d ",ans[j]); 
        printf("%d\n",ans[n]);
        return;
    }
    for(int j=1;j<=MAXVAL;j++)
        if(cnt[j])
        {
            cnt[j]--;
            ans[i]=j;
            dfs(i+1);
            cnt[j]++;
        }
}
int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
    {
        int t;
        scanf("%d",&t);
        cnt[t]++;
    }
    dfs(1);
}

第三种方法,理解为交换法中如果交换的两个数字是相同的,则没有区别

Part:组合
枚举组合Combination

枚举组合就是生成n个元素的各种组合方式。本质上说,就是枚举子集。
例如{1,2,3} 所有的组合就是{} 、 {1} 、 {2} 、 {3} 、 {1,2} 、 {1,3} 、 {2,3} 、 {1,2, 3},一共有8 个组合

位向量法

计算组合个数的方法
1 可取可不取,有两种情形、 2 可取可不取,有两种情形、 3 可取可不取,有两种情形。根据 乘法原理,总共2×2×2 = 2^3 种情形。
用程序实现时,模拟这个过程。设立标记数组vis[],vis[i]=true,表示集合中包含第i个元素。在 DFS中依次考虑每个元素,取还是不取,把决策信息记录在vis[]中。到达边界后,扫描vis[],输 出一组解。
算法思想是:依序枚举每个位置。针对每个位置,试着填入取或不取

实现

#include MAXN 10
bool vis[MAXN+5];
int A[MAXN+5];
int n;
void dfs(int i)
{
    if(i>=n)
    {
        for(int j=0;j<n;j++)
            if(vis[j]) printf("%d ",A[j]);
        puts("");
        return ;
    }
    vis[i]=0;
    dfs(i+1);

    vis[i]=1;
    dfs(i+1);
    vis[i]=0;
}
增量法(能实现字典序)

思路是往子集里不断放入新元素。每次递归进入后,当前子集都是一个合法解, 先输出解。再考虑试着往子集里新增一个元素。子集里的元素应该升序生成,避免{1,2},{2,1} 这种重复,故设立变量i指示新增元素的最小值。
增量法生成的组合是按字典序排列的。
实现

#define MAXN 10
int S[MAXN+5];
int n;
void dfs(int i,int sz)
//i:下一次放入子集的最小值  sz:当前子集的大小
{
    for(int j=0;j<sz;j++)
        printf("%d ",S[j]);
    puts("");
    for(int j=i;j<=n;j++)
    {
        S[sz]=j;
        dfs(j+1,sz+1);
    }
}

思考
把枚举子集中的元素看成是下标,就可以输出元素值为任意类型的组合。
输入任意类型的元素,存放在数组A中。先排序。
再把输出子集的语句修改成输出特定元素:

for(int j = 0; j < sz; j++) 
      printf("%d ", A[S[j]]);
二进制(位运算)法

把十进制数0~15写成二进制形式:
0000
0001
0010
0011
0100
0101
0110
0111
1000
1001
1010
1011
1100
1101
1110
1111
把数位从右往左分别看成是第0,1,2,3个元素,二进制数该位为0,表示该元素不在子集中; 为1,表示在子集中。例如,0110表示第1,2号元素在子集中,0,3号元素不在子集中。
从0到15正好有16个数,而包含4个元素的所有组合的个数也是16,每一个数就 对应了一个子集,该整数中1的位置就指示了属于子集的元素。
因此一个循环就可以枚举出n个元素的所有组合:

 up = 1 << n;     //up -1的二进制形式恰好有n个1 
 for(int s = 0; s < up; s++)   

要检验一个整数所代表的子集中有哪些元素,需要用到位运算:

1<<i //表示把1左移i位  
s & (1<<i)//表示检验s的右起第i位是否为1,为1则表示第i号元素在子集中  
for(int i = 0; i < n; i++)  
    if( s & (1 << i)) 
         printf(“%d “, A[i]); //输出第i号元素

实现

#include<cstdio>
#define MAXN 10
int A[MAXN+5];
int n;
int main()
{
    scanf("%d",&n);
    for(int i=0;i<n;i++)
        scanf("%d",&A[i]);
    int up=1<<n;
    for(int s=0;s<up;s++)
    {
        for(int i=0;i<n;i++)
            if(s&(1<<i))
                printf("%d",A[i]);
        puts("");
    }
    return 0;
}

思考
二进制法没有用到递归。
联想集合的二进制整数表示


Tip:内容相照应《算法竞赛入门经典》中第七章

### DFS 组合问题回溯算法的关系 在处理组合问题时,虽然深度优先搜索DFS)和回溯算法看似相似,但实际上两者存在显著区别。DFS 主要关注于尽可能深地探索图结构中的路径[^1],而回溯法则更侧重于通过试探性的前进,在遇到无法满足条件的情况时退回到上一状态并尝试其他可能性[^3]。 对于某些特定类型的组合问题而言,直接应用标准形式下的回溯机制并非最优选择: #### 不适用原因分析 - **效率考量** 当面对的是较为简单的排列组合类题目而非一般意义上的迷宫或棋盘等问题时,采用纯粹基于栈帧调用的递归方式实现DFS往往能获得更高的执行速度。这是因为每次函数调用都会消耗额外资源建立新的环境上下文;相比之下,利用循环控制变量调整候选列表的方法可以减少不必要的开销[^2]。 - **逻辑清晰度** 对于一些仅需穷尽所有可能选项的任务来说,维持一个显式的待选集合并通过不断缩小范围直至为空即可完成任务。此时如果引入完整的回溯框架反而会使程序变得臃肿难以理解——毕竟后者通常伴随着更多辅助数据结构的支持以便灵活应对中途折返的需求[^4]。 ```python def dfs_combinations(elements, k): result = [] def helper(start=0, current=[]): if len(current) == k: result.append(list(current)) return for i in range(start, len(elements)): current.append(elements[i]) helper(i + 1, current) current.pop() # 这里体现了简单版的“回溯”,但不是传统意义上带有大量判断逻辑的那种 helper() return result ``` 此代码片段展示了如何在一个简化版本中运用类似于回溯的思想来解决问题,但它并没有依赖复杂的决策树构建过程而是专注于快速生成符合条件的结果集。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值