演算法——回溯法(子集、全排列、皇后問題)
1、定義
回溯演算法也叫試探法,它是一種系統地搜尋問題的解的方法。
回溯演算法的基本思想是:從一條路往前走,能進則進,不能進則退回來,換一條路再試。
回溯演算法解決問題的一般步驟為:
1、定義一個解空間,它包含問題的解。
2、利用適於搜尋的方法組織解空間。
3、利用深度優先法搜尋解空間。
4、利用限界函式避免移動到不可能產生解的子空間。
問題的解空間通常是在搜尋問題的解的過程中動態產生的,這是回溯演算法的一個重要特性。
確定瞭解空間的組織結構後,回溯法就從開始結點(根結點)出發,以深度優先的方式搜尋整個解空間。這個開始結點就成為一個活結點,同時也成為當前的擴充套件結點。在當前的擴充套件結點處,搜尋向縱深方向移至一個新結點。這個新結點就成為一個新的活結點,併成為當前擴充套件結點。如果在當前的擴充套件結點處不能再向縱深方向移動,則當前擴充套件結點就成為死結點。此時,應往回移動(回溯)至最近的一個活結點處,並使這個活結點成為當前的擴充套件結點。
回溯法即以這種工作方式遞迴地在解空間中搜索,直至找到所要求的解或解空間中已沒有活結點時為止。
/*
對於其中的函式和變數,解釋如下:
a[]表示當前獲得的部分解;
k表示搜尋深度;
input表示用於傳遞的更多的引數;
is_a_solution(a,k,input)判斷當前的部分解向量a[1...k]是否是一個符合條件的解
construct_candidates(a,k,input,c,ncandidates)根據目前狀態,構造這一步可能的選擇,存入c[]陣列,其長度存入ncandidates
process_solution(a,k,input)對於符合條件的解進行處理,通常是輸出、計數等
make_move(a,k,input)和unmake_move(a,k,input)前者將採取的選擇更新到原始資料結構上,後者把這一行為撤銷。
*/
bool finished = FALSE ; /* 是否獲得全部解? */
backtrack(int a[], int k, data input)
{
int c[MAXCANDIDATES]; /*這次搜尋的候選 */
int ncandidates; /* 候選數目 */
int i; /* counter */
if (is_a_solution(a,k,input))
process_solution(a,k,input);
else
{
k = k+1;
construct_candidates(a,k,input,c,&ncandidates);
for (i=0; i<ncandidates; i++)
{
a[k] = c[i];
make_move(a,k,input);
backtrack(a,k,input);
unmake_move(a,k,input);
if (finished)
return; /* 如果符合終止條件就提前退出 */
}
}
}
2、給出陣列,求全排列
《演算法競賽入門經典》 P116
給出一個數組,將其按字典序形成全排列。
例如給出:a[] = {3, 1, 2};
我們可以先將其排序,a[] = {1,2,3};然後再全排列。
1 2 3
1 3 2
2 1 3
2 3 1
3 2 1
3 1 2
這裡有對全排列的一個綜合的概括:http://blog.csdn.net/morewindows/article/details/7370155/
1.全排列就是從第一個數字起每個數分別與它後面的數字交換。
2.去重的全排列就是從第一個數字起每個數分別與它後面非重複出現的數字交換。
3.全排列的非遞迴就是由後向前找替換數和替換點,然後由後向前找第一個比替換數大的數與替換數交換,最後顛倒替換點後的所有資料。
1:遞迴方法
分別將每個位置交換到最前面位,之後全排列剩下的位。
【例】遞迴全排列 1 2 3 4 5
1,for迴圈將每個位置的資料交換到第一位
swap(1,1~5)
2,按相同的方式全排列剩餘的位
/// 遞迴方式生成全排列的方法
//fromIndex:全排列的起始位置
//endIndex:全排列的終止位置
void PermutationList(int fromIndex, int endIndex)
{
if (fromIndex > endIndex)
Output(); //列印當前排列
else
{
for (int index = fromIndex; index <= endIndex; ++index)
{
// 此處排序主要是為了生成字典序全排列,否則遞迴會打亂字典序
Sort(fromIndex, endIndex);
Swap(fromIndex, index);
PermutationList(fromIndex + 1, endIndex);
Swap(fromIndex, index);
}
}
}
新增排序函式
int comp2(const void*a,const void*b)
{
return *(int*)a - *(int*)b;
}
int list[] = {4, 3, 1, 2, 5};
qsort(list,5,sizeof(int),comp2);
2、非遞迴方法(字典序法):
這種演算法被用在了C++的STL庫中。
對給定的字符集中的字元規定了一個先後關係,在此基礎上規定兩個全排列的先後是從左到右逐個比較對應的字元的先後。
[例]字符集{1,2,3},較小的數字較先,這樣按字典序生成的全排列是:
123,132,213,231,312,321
※ 一個全排列可看做一個字串,字串可有字首、字尾。
生成給定全排列的下一個排列.所謂一個的下一個就是這一個與下一個之間沒有其他的。這就要求這一個與下一個有儘可能長的共同字首,也即變化限制在儘可能短的字尾上。
[例]839647521是1–9的排列。1—9的排列最前面的是123456789,最後面的987654321,從右向左掃描若都是增的,就到了987654321,也就沒有下一個了。否則找出第一次出現下降的位置。
【例】 一般而言,設P是[1,n]的一個全排列。
P=P1P2…Pn=P1P2…Pj-1PjPj+1…Pk-1PkPk+1…Pn
find: j=max{i|Pi
void PermutationList(int *list, int n)
{
int i,j,k,diff;
int all_increase = 0;
qsort(list,n,sizeof(int),comp2);
for(i=0;i<n;i++)
printf("%d ",list[i]);
printf("\n");
while(1)
{
//從尾部往前找第一個P(i-1) < P(i)的位置
for(i=n-1; i>0; i--)
{
if(list[i-1] < list[i])
break;
}
if(i == 0) //從尾部開始全部為增序時,全排列結束
break;
//從i位置往後找到最後一個大於i-1位置的數
//即其差最小
diff = list[i] - list[i-1];
k = i;
for(j=i; j<n; j++)
{
if(list[j] > list[i-1] && diff > list[j]-list[i-1])
{
diff = list[j] - list[i-1];
k = j;
}
}
//交換位置i-1和k的值
swap2(list+i-1,list+k);
//倒序i後的數
for(j=i,k=n-1; j< k; j++,k--)
{
swap2(list+j,list+k);
}
for(i=0;i<n;i++)
printf("%d ",list[i]);
printf("\n");
}
}
3、給出n,求1~n的全排列
《演算法競賽入門經典》 P116
輸入一個整數,例如3,生成1、2、3的全排列。
輸出為:
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
示意圖:
程式碼:
void print_permutation(int *a, int n, int cur)
{
int i,j;
if(cur == n)
{
for(i=0;i<n;i++)
printf("%d ",a[i]);
printf("\n");
}
else
{
int exist = 0;
for(i=1;i<=n;i++) //嘗試在a[cur]中填入1~n的數
{
exist = 0;
for(j=0;j<cur;j++)
{
if(i == a[j])
exist = 1;
}
if(!exist)
{
a[cur] = i;
print_permutation(a,n,cur+1);
}
}
}
}
4、子集生成
《演算法競賽入門經典》 P120
給定一個n,列舉0~n的所有子集。
例如:n=4,列舉0,1,2,3,4的子集。
子集:
0
0 1
0 1 2
0 1 2 3
0 1 3
0 2
0 2 3
0 3
1
1 2
1 2 3
1 3
2
2 3
3
1、增量構造法
/*遞迴輸出0~n所有子集,其中cur為當前下標,cur-1表示上一層最後一個元素下標,初始值0*/
void print_subset(int *a, int n, int cur)
{
int i,min;
//for(i=0;i<cur;i++)
for(i=0;i<=cur-1;i++) //輸出上一層子集0~cur-1的位置。
printf("%d ",a[i]);
printf("\n");
//找到當前子集首個值,因為按字典順序輸出,所以每次找到最小的元素
min = cur ? a[cur-1]+1 : 0; //每次獲得當前集的最小元素,要麼是0,要麼是上一層最後一個元素+1
/*************************************************************/
/* 以上為每次遞迴都會執行的兩個操作,1、輸出上一子集 2、找到當前子集的首個值 */
for(i=min;i<n;i++) //迴圈處理:當前集的最小元素min~n-1的元素
{
a[cur] = i; //將當前子集的最小值,也就是第一個元素給a[cur]
print_subset(a,n,cur+1); //遞迴,擴大當前子集的範圍cur+1,在遞迴中輸出遞迴前的前一個子集的集合
}
}
2、二進位制法
/*輸出子集s*/
/*
* 當s=151=1001 0111 時,輸出為0 1 2 4 7(子集)
* n表示為總集合
* 當n=8
* s= 0000 0001 時,輸出為0
*/
void print_subsetn(int n, int s)
{
int i;
for(i=0; i<n; i++)
{
if(s & (1<<i)) //如果s中的i位為1
printf("%d ",i); //列印s中位數為1的位數號i
}
printf("\n");
}
/*輸出0~n所有子集*/
void print_subset(int n)
{
int i;
for(i=0; i<(1<<n); i++) //i表示子集,0~2^n-1從空集到全集的範圍,全集{0,1,...,n-1}二進位制為n個1,即2^n-1
print_subsetn(n,i);
}
/*
* 當n=2時,二進位制法的二進位制長度就為2
* 那麼空集=0 全集=3
* i=0=00 => 輸出空集
* i=1=01 => 輸出{0}
* i=2=10 => 輸出{1}
* i=3=11 => 輸出{0,1}
*/
5、從n個數中選m個數的組合
1、n個數存放在陣列b中。
【思路】
從後往前選,先確定一個數,例如在b[4] = {1,2,3,4}中,先確定4,然後再在剩下的1,2,3中選m-1個數,這是一個遞迴的形式。
例如:在1 2 3 4 5中選3個數
先確定5,然後在1 2 3 4 中選2個數
5完成後,確定4,再在1 2 3 中選2個數,如此遞迴。
//在b陣列中選m個數
//n: b陣列的個數 j為全域性陣列a的指標。
void choose(int *b,int n, int m, int j)
{
int i,k;
if(m == 0)
{
for(i=0;i<j;i++)
printf("%d",a[i]);
printf("\n");
return;
}
for(i=n; i>0;i--) //從後往前選
{
a[j] = b[i-1];
//choose(b,n-1,m-1,j+1); //在1~n-1中選取m-1個數
choose(b,i-1,m-1,j+1); //將n-1改為i-1解決了出現重複數字的情況,例如:43 42 41 33 32 31 22 21 11
}
}
2、從1~n中選m個數的組合,不利用陣列b
//在1~n中選m個數
void choose(int n, int m, int j)
{
int i,k;
if(m == 0)
{
for(i=0;i<j;i++)
printf("%d",a[i]);
printf("\n");
return;
}
for(i=n; i>0;i--) //從後往前選
{
a[j] = i;
choose(i-1,m-1,j+1);
}
}
6、在1-n中選取m個字元進行全排列
int vis[11];
void chos(int n, int m, int j)
{
int i;
//if(m == 0)
if(m == j)
{
for(i=0;i<j;i++)
printf("%d ",a[i]);
printf("\n");
}
for(i=1; i<=n; i++)
{
if(!vis[i])
{
a[j] = i;
vis[i] = 1;
//chos(n-1,m-1,j+1);
chos(n,m,j+1); //因為有vis陣列判斷某個數是否已經加入到輸出的集合了,所有不需要n-1
vis[i] = 0;
}
}
return;
}
八皇后問題
《演算法競賽入門經典》 P125
在8* 8的棋盤上擺放8個皇后,使其不能互相攻擊,即任意的兩個皇后不能處在同意行,同一列,或同一斜線上(不能在一條左斜線上,當然也不能在一條右斜線上)。可以把八皇后問題拓展為n皇后問題,即在n*n的棋盤上擺放n個皇后,使其任意兩個皇后都不能處於同一行、同一列或同一斜線上。
//一個N皇后問題的處理
void Queen(int j, int (*Q)[N])
{
if(j == N) //j從0開始一步一步往右邊逼近,當到達N時,前面的都已放好。
{
//得到一個解,輸出陣列Q
return;
}
//對j列的每一行進行探測,看是否能夠放置皇后
for(int i=0; i<N; ++i) //i表示行,j表示列
{
if(isCorrect(i,j,Q)) //如果可以在i行,j列中放置皇后(判斷同行同列斜線上是否已有皇后)
{
Q[i][j] = 1; //放置皇后
Queen(j+1,Q); //深度遞迴,繼續放下一列
Q[i][j] = 0; //回溯
}
}
}
/*
* n皇后處理
* cur表示行,col[cur]表示第cur行皇后的列編號,tot表示解的個數
* 演算法的過程:起始列按0~n-1,每次按行放置(cur初始值為0),迴圈尋找從第0列~第n-1列能放置皇后的位置,找到後進行下一
* 行的放置。
* 當所有行都放置成功後,解的個數加一。
*/
int tot = 0;
int col[50];
void search(int n, int cur)
{
int i,j,ok;
if(cur == n) //行數達到最大
{
tot++;
}
else
{
for(i=0; i<n; i++) //列
{
ok = 1; //放置標誌位
col[cur] = i; //嘗試將第cur行的皇后放在第i列
for(j=0; j<cur; j++) //從第0行開始到cur-1行,檢查是否和前面的皇后有衝突
{
if(col[cur] == col[j] || //在同一列
cur-col[cur] == j-col[j] || //主對角線
cur+col[cur] == j+col[j] ) //副對角線
{
ok = 0;
break; //當前i列存在衝突,換下一列
}
}
if(ok) //如果當前行cur找到放置點後,繼續下一行的放置
{
search(n,cur+1);
}
}
}
}
int main()
{
int n;
scanf("%d",&n);
search(n,0);
printf("%d\n",tot);
}
八皇后問題就是回溯演算法的典型,第一步按照順序放一個皇后,然後第二步符合要求放第2個皇后,如果沒有位置符合要求,那麼就要改變第一個皇后的位置,重新放第2個皇后的位置,直到找到符合條件的位置就可以了。
int vis[3][]; //記錄當前嘗試的皇后所在的列|正斜線|負斜線是否已有其他皇后
void search_ex(int n, int cur)
{
int i,j,ok;
if(cur == n) //行數達到最大
{
tot++;
}
else
{
for(i=0; i<n; i++) //列
{
if(!vis[0][i] && !vis[1][cur+i] && !vis[2][cur-i+n]) //可放置
{
col[cur] = i; //嘗試將第cur行的皇后放在第i列
vis[0][i] = vis[1][cur+i] = vis[2][cur-i+n] = 1;
search_ex(n,cur+1);
vis[0][i] = vis[1][cur+i] = vis[2][cur-i+n] = 0;
}
}
}
}