天天看點

回溯法——N皇後問題與TSP問題回溯法——N皇後問題與TSP問題

回溯法——N皇後問題與TSP問題

對于一般的問題而言,很多時候無法找到其最優子結構性質或者其不是一個最優化問題,這些問題都是NP的。我們無法采用像動态規劃一樣的技巧簡化求出某個具體解的過程,唯一能做的就是蠻力周遊所有可能的解。在蠻力法的基礎上,假如把求每個解的過程組織成一個樹狀結構,從根節點到葉節點的每次周遊就是一個可能解,那麼就可以采用深搜+剪枝的方式來簡化計算,在計算之前就知道某些解是不可行的,是以可以說回溯法是特殊情況下對蠻力法的優化。

問題:

N後問題:在N×N的棋盤中放置N個皇後,使得任何兩個皇後之間不能互相攻擊(不在同一行,同一列,同一對角線),試給出所有的放置方法。

旅行售貨員(貨郎擔TSP)問題:某售貨員要到若幹城市去推銷商品,已知各城市間的路程耗費(代價),如何標明一條從駐地出發,經過每個城市一遍,最後回到駐地的路線,使得總路程耗費最小。

分析:

N皇後問題,不是最優化問題,僅僅是可行解問題,可行解也不唯一。即使将可行解視為最優解,子問題求得了一個可行解,該可行解也不一定構成父問題的可行解。

TSP問題,沒有最優子結構,即使求得了一個最優路徑,其子問題也不一定是最優的,因為有經過所有節點的要求。

二者都是蠻力求解。

嘗試建立樹狀解空間,用回溯法求解。解空間的表示分為四個步驟:

  1. 問題解的表示:将問題的解表示成一個n元式 (x1, x2, …, xn) 的形式。
  2. 顯示限制:對分量xi的取值限定。
  3. 隐示限制:為滿足問題的解而對不同分量之間施加的限制。
  4. 解空間:解向量滿足限制條件的所有多元組,構成了問題的一個解空間

是以N皇後問題:

  1. 問題解表示為(x1, x2, …, xn)
  2. 顯示限制:xi in {1, 2, 3, … ,n}
  3. 隐示限制:xi和前面的所有x不同行不同列不同斜線,即xi != xj, |i - j| != |xi - xj|
  4. 解空間為滿足顯示限制和隐示限制的所有(x1, x2, …, xn)

TSP問題:

  1. 問題解表示為(x1, x2, …, xn)
  2. 顯示限制:xi in {1, 2, 3, … ,n}
  3. 隐示限制:(xi, xi+1 ) in E和任意i, k in{1,2, 3, … ,n}, xi != xk, (xn, x1) in E
  4. 解空間為滿足顯示限制和隐示限制的所有(x1, x2, …, xn)

解空間的組織形式一般可分為兩類,子集樹和排列樹。當所給的問題是從n個元素,每個元素有m種不同選擇的集合S中找出滿足某種性質的子集時,相應的解空間樹稱為子集樹,子集樹通常有mn個葉結點。當所給的問題是确定n個元素滿足某種性質的排列時,相應的解空間樹稱為排列樹,排列樹通常有n!個葉結點。

其中,已經被完全通路過的子樹的根節點稱為黑節點,未被完全通路過的子樹的根節點稱為灰節點,完全尚未被通路的子樹的根節點稱為白節點。其中白節點和灰節點統稱為活結點,黑節點為死節點。

對于N皇後問題而言,目的是求可行解,而按可行解構造樹比較難以實作,是以實際上常常會放寬搜尋的範圍。易于實作的解空間樹就是子集樹和排列樹,N皇後問題兩種樹均可使用,其實就是判不判列的問題。

對于TSP問題而言,其可行解比較容易構造,就是排列樹,其目的是求可行解中的一個最優解,是以可以用排列樹求解。

由此剛好引出了兩種剪枝方式。對于N皇後問題而言,假如目前灰節點已經不符合限制了,那麼以其為根節點的所有子樹都可以舍棄,這是限制函數剪枝;對于TSP問題而言,假如目前灰節點求得的值已經使得後面無論取什麼值,得到的可行解都比目前記錄的最優解小或者大了,那麼以其為根節點的所有子樹都可以舍棄,這是限界函數剪枝。

針對限界剪枝,回溯法必須滿足多米諾性質,就是限界剪枝一定是正确的:

設P(x1, x2, …, xi)是關于向量<x1, x2, …, xi>的某個性質,那麼P(x1, x2, …, xi+1)真蘊含P(x1, x2, …, xi)為真,即

P(x1, x2, …, xi+1) → P(x1, x2, …, xi) (0< i < n) (n為向量維數)

翻譯過來就是子問題不對了,父問題也不能對(逆否命題)。

代碼:

代碼有挺多細節的,還是背一下模闆比較好

#include<bits/stdc++.h>

using namespace std;

// 判斷pos層選擇的位置是否和前面已選的沖突
bool check(int res[],int pos){
    for(int i = 1;i < pos;i++){
        if((abs(i - pos) == abs(res[i] - res[pos])) || (res[pos] == res[i])){
            return false;
        }
    }
    return true;
}

// 子集樹求解N皇後問題,遞歸寫法
// 輸入:初始層号pos,皇後數量n,結果記錄數組res[]
void queenBacktrace(int pos,int n,int res[]){
    // 假如求得了一個可行解,那就輸出這個解
    // res最後的結果不是最後一個可行解,這一點特别注意
    if(pos>n){
        for(int i = 1;i <= n;i++)
            cout<<res[i]<<" ";
        cout<<endl;
    }else{
        // 周遊目前節點的所有擴充節點
        for(int i = 1;i <= n;i++){
            // 假如該擴充節點滿足要求
            res[pos] = i;
            if(check(res, pos)){
                res[pos] = i;
                queenBacktrace(pos+1, n, res);
            }
        }
    }
}

// 子集樹求解N皇後問題,疊代寫法
// 輸入:皇後數量n,結果記錄數組res[]
void queenIterate(int n, int res[]){
    // 初始從第一行開始
    int pos = 1;
    // 需要手動記錄每一層的中斷傳回位置
    int f[9];
    // memset(f, 1, sizeof(f));
    // memset按照位元組指派,隻能指派0或-1
    for(int i = 0;i <= n;i++)
        f[i] = 1;
    // 難點在于何時退出while循環,觀察遞歸可以發現,遞歸最後的動作是完成了第一行的所有擴充節點
    // 隻有當某個節點的所有擴充節點都周遊完時,才會退回這個節點的上一層
    // 是以最後将會退回到第0層,遞歸終止
    while(pos>0){
        // 假如還有沒周遊的擴充節點
        if(f[pos] <= n){
            // 周遊目前節點的所有未檢查的擴充節點
            // 與遞歸的差別,沒有遞歸自然的入棧出棧和記錄斷點了
            for(;f[pos] <= n;f[pos]++){
                res[pos] = f[pos];
                // 假如該節點滿足要求
                if(check(res, pos)){
                    // 假如求得了一個可行解,那麼就輸出這個解
                  	// 不能像遞歸一樣pos++然後統一到下一層處理,因為數組下标會越界
                    if(pos==n){
                        for(int i = 1;i <= n;i++)
                            cout<<res[i]<<" ";
                        cout<<endl;
                    }else{
                        pos++;
                        // 易錯點,每一層有多個獨立的子樹,這些子樹公用一個f[]
                        // 假如新來到一棵子樹,那必須要重置這顆子樹的f[]
                        // 否則f[]還是上一棵子樹的值
                        f[pos] = 0;// 最坑的地方,這個循環出去的時候還有一次++,是以初始化為0
                    }
                }
            }
        }else{// 也可以在每一個子樹周遊完之後清除f[]
            // 所有的擴充節點都周遊完了,那就回退一層
            pos--;
            f[pos]++;//易錯點
        }
    }
}

int main(){
    int n = 8;
    int res[9];
    memset(res, 0, sizeof(res));

    queenIterate(n, res);
    cout<<endl;
    queenBacktrace(1, n, res);
}
           
#include<bits/stdc++.h>

using namespace std;

#define MAXN 105
#define INF 0x3f3f3f

// 鄰接矩陣存儲
int m[MAXN][MAXN];
// 結果記錄數組,下标為第幾個點,對應的是選的哪個點
int res[MAXN];
// 記錄最優解
int bestRes[MAXN];
// 最優解的大小
int minRes = INF;


// 檢查目前這一步選出的結果是否滿足限制
// 目前共選了pos個點,一共有n個待選點
bool check(int pos,int n){
    // 就一個點,肯定是可行解,一般也不會用到,因為第一次就是從第二個點開始算的
    if(pos < 2) return true;
    // pos加入後,若目前的解還是處于中間過程中,檢查新加入的點和上一個加入的點之間有沒有連通
    if(pos<n && m[res[pos-1]][res[pos]]!=INF) return true;
    // pos加入後,若目前的解就是最終的解,檢查新加入的點和上一個加入的點之間有沒有連通,還要檢查是否能構成一個閉合的回路
    if(pos==n && m[res[pos-1]][res[pos]]!=INF && m[res[pos]][res[1]]!=INF) return true;
    
    return false;
}

// 計算目前部分解的大小
int sum(int pos,int n){
    int nowRes = 0;
    if(pos < n){
        for(int i = 2;i <= pos;i++){
            nowRes += m[res[i]][res[i-1]];
        }
        return nowRes;
    }else{
        for(int i = 2;i <= n;i++){
            nowRes += m[res[i]][res[i-1]];
        }
        nowRes += m[res[n]][res[1]];
        return nowRes;
    }
}

// 限界函數
bool bound(int pos,int n){
    if(pos < 2) return true;
    if(pos <= n && sum(pos, n) <= minRes) return true;
    return false;
}

// 排列樹求解TSP問題,遞歸
// 輸入:起始城市是第pos個,預設從2開始,一共有n個城市
void TSPbacktrace(int pos,int n){
    // 假如找到了一個可行解
    if(pos>n){
        // 假如這個可行解比目前記錄的最小可行解還要小
        if(sum(pos, n) < minRes){
            minRes = sum(pos, n);
            // 記錄可行解為目前最優解
            for(int i = 0;i <= n;i++){
                bestRes[i] = res[i];
            }
        }
    }else{
        // 周遊所有的擴充節點
        // 由于是排列樹,是以每次要選取沒有選過的那個點
        // 為了友善,可以把已經選過的點放在一起,每次從沒選過的那個點開始選就可以了
        // 為此需要做兩點,第一點,不能單純的按序号從小到大的順序來周遊所有點了,因為選過的點可能是亂序的,是以要将res初始化為序号從小到大
        // 第二點,每次選了某個點後,将該點與目前res所對應的那個序号的點交換,這樣就保證了選過的點放在一起
        for(int i = pos;i <= n;i++){
            swap(res[pos], res[i]);
            // 假如該點滿足條件,限制函數+限界函數
            if(check(pos, n) && bound(pos, n)){
                TSPbacktrace(pos+1, n);
            }
            // 注意恢複現場,已經選過這個點了
            swap(res[pos], res[i]);
        }
    }
}

// 排列樹求解TSP問題,疊代
// 輸入:起始城市是第pos個,預設從2開始,一共有n個城市
void TSPiterate(int pos,int n){
    // 記錄每一層通路到了哪個擴充節點
    int f[n+1];
    // 排列樹和子集樹的初始化不同
    for(int i = 0;i <= n;i++){
        f[i] = i;
    }
    
    while (pos >= 2) {
        for(;f[pos] <= n;f[pos]++){
            swap(res[pos], res[f[pos]]);
            if(check(pos, n) && bound(pos, n)){
                // 找到了可行解
                if(pos == n){
                    if(sum(pos, n) < minRes){
                        minRes = sum(pos, n);
                        // 記錄可行解為目前最優解
                        for(int i = 0;i <= n;i++){
                            bestRes[i] = res[i];
                        }
                    }
                    // 理論分析應該有三種恢複現場的swap
                    // 2. 選了這個擴充節點,這個擴充節點還是葉節點,那麼将不會向更深一層進發,會選下一個擴充節點,和第一種沒選的情況同理
                    //(這裡已經是最後一個位置了實際上沒有交換)
                    //swap(res[pos], res[f[pos]]);
                }else{
                    pos++;
                    f[pos] = pos-1;// 排列樹和子集樹的初始化不同
                }
            }else{
                // 1.沒選這個擴充節點,在check完之後就應該換回來
                swap(res[pos], res[f[pos]]);
            }
        }
        // 目前子樹被周遊完了,也可以不寫在for循環完成的地方而是寫成八皇後代碼裡的那種單獨判斷
        pos--;
        // 3. 選了這個擴充節點,這個擴充節點是内節點,在其子樹通路完成後,需要回複現場,否則肯定出錯
        swap(res[pos], res[f[pos]]);
        f[pos]++;
    }
}

int main(){
    int n = 4;
    // 初始化操作
    for(int i = 0;i <= n;i++){
        res[i] = i;
        bestRes[i] = 0;
        for(int j = 0;j <= n;j++){
            m[i][j] = INF;
        }
    }
    
    m[1][2] = 10;
    m[1][3] = 6;
    m[1][4] = 4;
    m[2][3] = 5;
    m[2][4] = 10;
    m[3][4] = 20;
    m[2][1] = 10;
    m[3][1] = 6;
    m[4][1] = 4;
    m[3][2] = 5;
    m[4][2] = 10;
    m[4][3] = 20;
    
    // pos從第二個城市開始,因為第一個城市已經預設選擇了第一個編号的城市
    TSPbacktrace(2, n);
    //TSPiterate(2, n);
    
    for(int i = 1;i <= n;i++)
        cout<<bestRes[i]<<" ";
    
    cout<<endl<<minRes;
}
           

複雜度分析:

時間複雜度下界視為解空間樹葉節點的個數,是以

N皇後問題:Ω(nn)

TSP問題:Ω((n-1)!) = Ω(n!)

時間複雜度的上界比較難以分析,需要考慮其限制函數和限界函數,在這篇部落格和後面的幾篇回溯部落格中,限制函數和限界函數的寫法其實包含了重複的計算,但是分析時間複雜度的時候是按照最簡的計算方式來分析的。

N皇後問題最差情況下每個節點要與其他N個節點比較,這是O(n),節點數量級為O(nn)級,是以時間複雜度為

O(nn)

TSP問題限制函數和限制函數都是O(1),是以時間複雜度為

O(n!)

空間複雜度都是O(n2)

P.S.回溯法寫起來真的很難想,寫非遞歸的時候全程如下表情:

回溯法——N皇後問題與TSP問題回溯法——N皇後問題與TSP問題

繼續閱讀