1. 程式人生 > >(填坑計劃)全排列及其各種變體——遞迴+回溯

(填坑計劃)全排列及其各種變體——遞迴+回溯

填一下大一沒好好學習的遺留坑。

其實全排列問題是個老生常談的問題了,大一接觸到的時候就知道是用遞迴實現,但是由於沒好好學習,對其理解一直不深刻。能看出來程式碼是正確的,但是不理解為什麼,程式碼也沒辦法自己寫出來。(也是因為之前第二課堂學長給的程式碼意義不明)

在看了一名學長的部落格之後,現在回過頭來終於是弄明白了全排列的原理,所以現在寫全排列也算是清晰了很多。

首先是全排列的思路,總的來說就是遞迴+回溯的過程:

1、先確定好當前的元素,然後分析後面的元素

2、對於後面的元素,我們也是先確定好當前的元素,再分析後面的元素。很顯然這就是1的步驟,所以這就是遞迴了。

3、遞迴結束後需要回溯,由於當前元素已經確定的所有情況都已經處理完了,所以我們把當前元素的確定狀態解除。在其餘元素中選擇當前元素,上一個元素放到後面去。

這就是全排列的大體思路。在實際寫的過程中基本遇到下面兩種,一是對於字典序要求不嚴的,這裡我們的程式碼大致如下:

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

void f(char *a, int k)
{
	int i;
	char tmp;

	//當一次排列的過程結束時,輸出當前字串,並且回溯
	if(k ==  strlen(a))
	{
		puts(a);
		return;
	}

	for(i = k; i < strlen(a); i++)                  //k是處理到第幾個字元,該字元與後面的交換
	{
	        //1.將當前元素之後的所有元素(包括其自己)交換位置
		tmp = a[k]; a[k] = a[i]; a[i] = tmp;		//swap(a[k],a[i])
		//2.上一個元素已經確定好位置了,我們對下一位及以後的元素進行同樣處理
		f(a, k+1);
		//3.回溯到上一輪,然後交換回到1之前的狀態,然後與1中交換的下一個元素進行交換
		tmp = a[k]; a[k] = a[i]; a[i] = tmp;        //swap(a[k],a[i])
	}
}

int main()
{
	char a[101];
        scanf("%s",a);
	f(a, 0);
	return 0;
}

但是這種處理方式不能處理按照字典序的排序輸出,如果我們輸入1234,會得到如下輸出:

1234
1243
1324
1342
1432
1423
2134
2143
2314
2341
2431
2413
3214
3241
3124
3142
3412
3421
4231
4213
4321
4312
4132
4123

我們看到1432在1423之前,這是因為這是2與4交換位置後的情況,所以程式會先輸出1432。

如果我們要按照字典序輸出,就需要通過類似DFS的思路:

1、首先確定當前元素,然後壓入棧中。這裡我們用到的是一個類似於棧的資料結構,每次選擇好元素後將其壓入棧中。不過最後我們會遍歷陣列輸出,而且省略了出棧。嚴格說來並不完全與棧相同。同時標記該元素已經出現過。然後確定下一個元素。

2、在未出現過的元素中確定一個元素壓入棧中,這裡也是遞迴的思想。

3、回溯到1之前的情況,即釋放當前元素,將其標記未未使用過。然後從後面的元素中確定當前元素。程式碼如下:

#include<stdio.h>
#define MAX 10
char stack[MAX+1]={0};					//以字串的形式存放生成的全排列數
int used[MAX]={0};
int N;
void allrank(int m,int n);				//全排列數生成函式

int main()
{
	printf("Input a number N(1<=N<=10):");
	scanf("%d",&N);
	allrank(0,N);
	return 0;
}

void allrank(int m,int n)
{
	int i=0;

        //n是剩餘的沒被標記(使用)的數,如果n=0,意味著數字被用完了,輸出並且回溯
	if(n==0)					
	{
		stack[N+1]='\0';
		puts(stack);
		return;
	}
	for(i=1;i<=N;i++)
	{
		if(used[i]==0)
		{
			used[i]=1;			//數字i被標記為已使用
			stack[m]='0'+i;
			allrank(m+1,n-1);		//進入了另一個函式
			used[i]=0;			//數字i被釋放 (回溯)
		}
	}
}

首先我們把1號元素確定為1,然後從234中進行全排列。

我們把2號元素設定為2,然後從34中進行全排列。

我們把3號元素設定為3,從4中進行全排列。得到1234.

然後回溯,把3號元素設定為4,得到1243。

再回溯,就需要把2號元素改為3,然後對24全排列……

最終得到這樣的

1234
1243
1324
1342
1423
1432
2134
2143
2314
2341
2413
2431
3124
3142
3214
3241
3412
3421
4123
4132
4213
4231
4312
4321

結果,這正是我們想要的字典序。因為每次確定元素時,我們總是最小的元素優先安排上這個位置,把所有排列完成後,然後再把後面的元素安排上這個位置。

總的來說,這就是遞迴+回溯的全過程。

如果你已經理解了,那麼來看看其他的全排列吧。

重複數全排列

【問題描述】
輸入一個字串,字串由字母、數字組成,可能包含重複的字元。生成這些字元的不重複的全排列,並將結果列印到標準輸出上。
【輸入形式】
從標準輸入上讀入一個由字母、數字組成的字串,字串的長度小於100,其中包含重複的字元。
【輸出形式】
向標準輸出印結果。 每個排列佔1行,各字元之間無空格分隔,每行由換行符結束。各行之間不必排序,但同一個排列不得重複輸出。
【輸入樣例】
AABB
【輸出樣例】
AABB 
ABAB 
ABBA 
BABA 
BAAB 
BBAA
【時間限制】
20s
【空間限制】
65536KB

這題對於排列順序沒有要求,而且是對於字串的排列,所以是使用第一種的思路。然而出現了重複數,所以大致思路是有的情況下不能繼續遞迴呼叫了。

我一開始的思路是每次實現一個排列後記錄下來。之後如果一次交換後出現了已出現過的序列,那麼就不必再遞迴,直接跳過這次交換。然而20s的執行時間還是超時了= =所以去網上搜了思路

我的思路之所以超時,還是因為僅僅做到排除相同情況的序列,而不是想辦法規避。我們分析如下一種情況,當前序列為

AB132768B90。當A首先與第一個B交換時,我們得到序列BA132768B90。然後我們對於後面的部分A132768B90進行全排列。當回溯後繼續交換,如果A與第二個B交換,會得到BB132768A90,而後面的序列全排列之後與A132768B90的全排列完全相同。所以這次交換完全可以跳過。

也就是說,在判斷時,如果一個字元在之前出現過,就不需要進行這次交換,更不需要遞迴呼叫了。用程式語言描述:第i個字元與第j個字元交換時,如果[i,j)中沒有與第j個數相等的字元則需要交換,如果有則不需要交換。

程式碼如下,我們使用isSwap來判斷是否需要交換:

#include<stdio.h>
#include<string.h>
#include<stdbool.h>
#define MAXNUM 101

void fullsort(char s[], int k);
bool isSwap(char s[], int begin, int end);

int main()
{
	char s[MAXNUM];
	scanf("%s", s);
	fullsort(s, 0);
	return 0;
}

void fullsort(char s[], int k)
{
	int i, temp;

	if (k == strlen(s))
        {
		puts(s);
		return;
        }

	for (i = k; i < strlen(s); i++)
	{
		if(isSwap(s, k, i))
                {
                    temp = s[k]; s[k] = s[i]; s[i] = temp;
                    fullsort(s, k + 1);
                    temp = s[k]; s[k] = s[i]; s[i] = temp;
                }
	}
}

bool isSwap(char s[], int begin, int end)
{
    int i;
    for(i = begin; i < end; i++)
        if(s[i] == s[end])
            return false;
    return true;
}

這個思路是在交換前就判斷需不需要交換,因此避免了多餘的遞迴與回溯,大大節省了執行時間。經檢驗執行時間都沒有超過1s的。(為什麼我想不出來這麼好的方法呢)

選排列

【問題描述】
求從n個自然數(1-n)中選取m個數的所有的排列形式,即求P(n,m)的所有的排列形式,且按升序排列。
【輸入形式】
標準輸入。輸入只有一行,包括兩個整數n和m,其中0<n,m<=9,二者之間以一個空白符分隔。
【輸出形式】
在標準輸出上輸出有若干行,每一行都是符合題意的一種排列形式,每個元素間用一個空格分隔,並按升序排列。
【輸入樣例】
3 2
【輸出樣例】
1 2 
1 3 
2 1 
2 3 
3 1 
3 2
【時間限制】
2s
【空間限制】
65536KB

全排列的變體。(不如說全排列是選排列的特殊情況)

不難,也是DFS,全排列是全部使用(即棧中元素到達n個)才輸出,而選排列則是棧中元素到了m個就需要輸出並回溯了。

#include<stdio.h>
#define MAXNUM 10

int used[MAXNUM] = { 0 };
int stack[MAXNUM];
int m, n;

void permutation(int a);

int main()
{
	scanf("%d%d", &n, &m);
	permutation(0);
	return 0;
}

void permutation(int a)
{
    int i;

	if (a == m)
	{
		for (i = 0; i < m; i++)
			printf("%d ", stack[i]);
		printf("\n");
		return;
	}

	for (i = 1; i <= n; i++)
	{
		if (used[i] == 0)
		{
			stack[a] = i;
			used[i] = 1;
			permutation(a + 1);
			used[i] = 0;
		}
	}
}

程式碼裡的a記錄的基本就是棧中元素的個數。

組合

【問題描述】
求n個自然數(1-n)的所有m-組合,即C(n,m)的所有不可重複的組合形式。
【輸入形式】
標準輸入。輸入只有一行,包括兩個整數n和m,其中0<n<=20,0<m<=n,二者之間以一個空白符分隔。 輸入內容可以保證在演算法得當情況下,規定時間內可以完成。
【輸出形式】
在標準輸出上輸出有若干行,每一行都是符合題意的一種排列形式,每個元素間用一個空格分隔,並按升序排列。
【輸入樣例】
3 2
【輸出樣例】
1 2 
1 3 
2 3
【時間限制】
2s
【空間限制】
65536KB

與選排列類似,不過要避免重複選擇。思路也很簡單。每次出現一個元素被選擇後,確定下面的元素就得從該元素後面尋找選擇。所以用一個start來表示從哪個元素開始遍歷。

#include<stdio.h>
#define MAXNUM 22

int used[MAXNUM] = { 0 };
int stack[MAXNUM];
int m, n;

void choose(int a, int start);

int main()
{
	scanf("%d%d", &n, &m);
	choose(0, 1);
	return 0;
}

void choose(int a, int start)
{
    int i;

	if (a == m)
	{
		for (i = 0; i < m; i++)
			printf("%d ", stack[i]);
		printf("\n");
		return;
	}

	for (i = start; i <= n; i++)
	{
		if (used[i] == 0)
		{
			stack[a] = i;
			used[i] = 1;
			choose(a + 1, i + 1);
			used[i] = 0;
		}
	}
}

錯位全排列

【題目描述】
輸出第 k 大的 1∼n 的全錯位排列。
全錯位排列:對於數字 1∼n 的一個排列 p1,p2,…,pn,如果對 ∀i∈[1,n] ,都有 pi≠i,那麼稱它為全錯位排列。
第 k 大:把全錯位排列按照字典序從大到小排序之後的第 k 個。
【輸入】
第一個數為資料組數 T(1≤T≤20) 。
接下來 T 行,每行兩個空格隔開的正整數 n,k(2≤n≤9)。保證 k 合法。
【輸出】
對於每組資料,輸出一行,n 個空格隔開的整數。表示第 k 大的全錯位排列
【輸入樣例】
2
2 1
3 1
【輸出樣例】
2 1
3 1 2
【題目來源】:BUAAOJ 1079 ruaaaaa

麻煩一點的可能是處理第k大,首先我們用一個index來表示這是第幾大的。其次由於是字典序大的在前面,所以我們從後往前遍歷,這樣每次都是字典序大的在前面出現。

除此之外,全排列的判斷僅僅是多了一個p[i] != i而已,在遞迴時判斷一下就好了。

#include<stdio.h>
#define MAXNUM 10

int used[MAXNUM] = {0};
int stack[MAXNUM];
int m, n, index;

void allrank(int number)
{
    int i;

    if(number == m)
    {
        index ++;
        if(index == n)
        {
            for(i = 0; i < m; i++)
                printf("%d ", stack[i]);
            putchar('\n');
        }
    }

    for(i = m; i >= 1; i--)
    {
        if(used[i] == 0 && number + 1 != i)
        {
            used[i] = 1;
            stack[number] = i;
            allrank(number + 1);
            used[i] = 0;
        }
    }
}

int main()
{
    int T;
    scanf("%d",&T);
    while(T--)
    {
        index = 0;
        scanf("%d%d",&m,&n);
        allrank(0);
    }
    return 0;
}

說白了,只要能理解開始的兩種全排列基本情況,後面的變體那都不是事。