模擬退火算法c++


轉載. 為方便理解, 在原博客的基礎上加部分注釋, 原博客地址:http://www.cnblogs.com/CsOH/p/6049117.html

 

今天終於用模擬退火過了一道題:CodeVS: P1344。

有 N ( <=20 ) 台 PC 放在機房內,現在要求由你選定一台 PC,用共 N-1 條網線從這台機器開始一台接一台地依次連接他們,最后接到哪個以及連接的順序也是由你選定的,為了節省材料,網線都拉直。求最少需要一次性購買多長的網線。(說白了,就是找出 N 的一個排列 P1 P2 P3 ..PN 然后 P1 -> P2 -> P3 -> ... -> PN 找出 |P1P2|+|P2P3|+...+|PN-1PN| 長度的最小值)

  這種問題被稱為最優組合問題。傳統的動態規划算法O(n22n)在n = 20的情況下空間、時間、精度都不能滿足了。這時應該使用比較另類的算法。隨機化算法在n比較小的最優化問題表現較好,我們嘗試使用隨機化算法。

#include<cstdio>
#include<cstdlib>
#include<ctime>
#include<cmath>
#include<algorithm>

const int maxn = 21;
double x[maxn], y[maxn];
double dist[maxn][maxn];
int path[maxn];
int n;
double path_dist(){
    double ans = 0;
    for(int i = 1; i < n; i++) {
        ans += dist[path[i - 1]][path[i]];
    }
    return ans;
}
int main(){
    srand(19260817U);                            // 使用確定的種子初始化隨機函數是不錯的選擇 
    scanf("%d", &n);
    for(int i = 0; i < n; i++) scanf("%lf%lf", x + i, y + i);
    for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) dist[i][j] = dist[j][i] = hypot(x[i] - x[j], y[i] - y[j]);
    
    for(int i = 0; i < n; i++) path[i] = i;        // 獲取初始排列 
    double ans = path_dist();                    // 初始答案 
    int T = 30000000 / n;                         // 單次計算的復雜度是O(n),這里的30000000是試出來的 
    while(T--){
        std::random_shuffle(path, path + n);    // 隨機打亂排列 
        ans = std::min(ans, path_dist());        // 更新最小值 
    }
    printf("%.2lf", ans);
}
View Code

可惜的是,這個算法只能拿50分。使用O(n!)枚舉排列和使用上述算法沒有太大的不同。從解的角度分析,假如某一次計算嘗試出了一個比較好的路徑,那么最優的路徑很可能可以在原基礎上作一兩次改動就可以得到,這時候完全打亂整個序列不是一個很好的選擇。

  另一個方法:根據原序列生成一個新的序列,然后交換新序列的任意兩個數。假如說新生成的序列更優,則使用新序列繼續計算,否則序列不變。

  這個算法就是局部搜索法(爬山法)。可惜,這個算法不正確。這個算法只顧眼前,忽略了大局,只要更優便走,這樣可能會造成“盯着眼前的小山包,忽略遠處的最高峰”,找到的值往往只是“局部最優值”。當然——這個方法也並不是完全不正確。我們可以多次計算使用上述方法計算,取最值。這里不再贅述。

  下面介紹退火算法(SA,Simulated Annealing)。

  首先拿爬山做例子:我們要找到山脈的最高峰,但是我(計算機)只能看到我的腳下哪邊是上升的,哪邊是下降的,看不到遠處是否上升。每次移動,我們隨機選擇一個方向。如果這個方向是上升的的(更優),那么就決定往那個方向走;如果這個方向是下降的(更差),那么“隨機地接受”這個方向,接受就走,不接受就再隨機一次——這個隨機是關鍵,要考慮很多因素。比如,一個陡的下坡的接受率要比一個緩的下坡要小(因為陡的下坡后是答案的概率小);同樣的下降坡度,接受的概率隨時間降低(逐漸降低才能趨向穩定)。

  為什么要接受一個更差的解呢?如下圖所示:

 

  如果堅決不接受一個更差的解,那么就會卡在上面的“當前位置”上了。倘若接受多幾次更差的解,讓他移動到山谷那里,則可以突破局部最優解,得到全局最優解。

既然這個隨機這么重要,那么我們就將它寫為一個函數:

bool accept(double delta, double temper){
    if(delta <= 0) return true;
    return rand() <= exp((-delta) / temper) * RAND_MAX;
} 

  其中delta是新答案的變化量,temper是當前的“溫度”。溫度是模擬退火算法的一個重要概念,它隨時間的推移緩慢減小。我們來分析一下這個代碼:

if(delta <= 0) return true;

  由於答案越小越優,因此當溫度的變化量小於零(新答案減小)時,新解比舊解優,因此返回“接受”

return rand() <= exp((-delta) / temper) * RAND_MAX;

  RAND_MAX是rand()的最大值。為了保證跨平台、跨編譯器甚至跨版本時的正常運作,我們不對其作出任何假定。

  我們把它移項:return (double)rand() / RAND_MAX <= exp((-delta) / temper)。在右邊,temper是正數,delta是正數(delta是負數的已經return出去了),因此exp()中間的參數是負數。我們知道,指數函數在參數是負數時返回(0, 1)——這就是接受的概率。我們在左邊隨機一個實數,如果它比概率小,就接受,否則就不接受。

  然后將RAND_MAX移到右邊,以省下昂貴的除法成本和避免浮點數的各種陷阱。

  有了接受函數,就可以寫計算過程了:

復制代碼
double solve(){
    const double max_temper = 10000;
    double temp = max_temper;
    double dec = 0.999;
    Path p;
    while(temp > 0.1){
        Path p2(p);
        if(accept(p2.dist() - p.dist(), temp)) p = p2;
        temp *= dec;
    }
    return p.dist();
}
復制代碼

  其中Path是路徑,它有一個構造函數是接受另一個Path類型的對象,然后交換其中兩個點的順序。

復制代碼
 1 struct Path{
 2     City path[maxn];
 3     
 4     Path(){
 5         F(i, n) path[i] = citys[i];
 6     }
 7     
 8     Path(const Path& p):path(p.path){
 9         swap(path[rand() % n], path[rand() % n]);
10     }
11     
12     void shuffle(){
13         random_shuffle(path, path + n);
14     } 
15     
16     double dist(){
17         double ans = 0;
18         for(int i = 1; i < n; i++){
19             ans += path[i - 1].distTo(path[i]);
20         }
21         return ans;
22     }
23 };
復制代碼

  上文的City是路徑一個點。而void shuffle()是隨機打亂整個序列(在本題沒有用上)。

  下面是City的定義:

復制代碼
 1 struct City{
 2     double x, y;
 3     City(){}
 4     City(double x, double y):x(x), y(y){}
 5     double distTo(const City& rhs) const {
 6         return hypot(x - rhs.x, y - rhs.y);
 7     }
 8     friend istream& operator >> (istream& in, City& c){
 9         return in >> c.x >> c.y;
10     }
11 }citys[maxn];
復制代碼

  最后是程序的主框架:

復制代碼
 1 int main(){
 2     srand(19260817U);
 3     ios::sync_with_stdio(false);
 4     cin >> n;
 5     F(i, n) cin >> citys[i];
 6     double ans = 1./0;
 7     int T = 15;
 8     while(T--){
 9         ans = min(ans, solve());
10     }
11     printf("%.2lf", ans);
12 } 
復制代碼

  完整代碼如下:

#define F(i, n) for(int i = 0; i < n; i++)
#define F1(i,n) for(int i = 1; i <=n; i++)
#include<cmath>
#include<algorithm>
#include<iostream>
#include<cstdio>
using namespace std;

const int maxn = 21;        // 機器的數目
int n;
struct City{
    double x, y;
    City(){}
    City(double x, double y):x(x), y(y){}
    double distTo(const City& rhs) const {
        return hypot(x - rhs.x, y - rhs.y);
    }
    friend istream& operator >> (istream& in, City& c){
        return in >> c.x >> c.y;
    }
}citys[maxn];

struct Path{
    City path[maxn];

    Path(){
        F(i, n) path[i] = citys[i];
    }

    Path(const Path& p):path(p.path){       // 生成新的path解時用,交換兩個位置的數據
        swap(path[rand() % n], path[rand() % n]);
    }

    void shuffle(){
        random_shuffle(path, path + n);
    }

    double dist(){          // 求解總的距離
        double ans = 0;
        for(int i = 1; i < n; i++){
            ans += path[i - 1].distTo(path[i]);
        }
        return ans;
    }
};



bool accept(double delta, double temper){
    if(delta <= 0) return true;
    return rand() <= exp((-delta) / temper) * RAND_MAX;
}

double solve(){
    const double max_temper = 10000;        // 初始溫度
    double temp = max_temper;
    double dec = 0.999;
    Path p;
    while(temp > 0.1){
        Path p2(p);         // p2是新的解, 通過Path p2(p)構造時, 會隨意交換p中兩個位置的數據生成p2
        if(accept(p2.dist() - p.dist(), temp)) p = p2;
        temp *= dec;
    }
    return p.dist();
}

int main(){
    srand(19260817U);
    ios::sync_with_stdio(false);
    cin >> n;
    F(i, n) cin >> citys[i];
    double ans = 1./0;      // +inf大於任何數,https://www.cnblogs.com/dosrun/p/3908617.html
    //cout << "ans" << ans << endl;
    int T = 155;
    while(T--){
        ans = min(ans, solve());
    }
    printf("%.2lf", ans);
}
View Code

 

其實本代碼在很多地方寫復雜了,比如累贅的City類。在比賽中,我們不會寫得如此復雜。下面對其簡化:

#include<cstring>
#include<cmath>
#include<algorithm>
#include<iostream>
#include<cstdio>
using namespace std;

const int maxn = 21;
int n;
double x[maxn], y[maxn];
double dist[maxn][maxn];

struct Path{
    int path[maxn];
    
    Path(){
        for(int i = 0; i < n; i++) path[i] = i;
    }
    
    Path(const Path& p){
        memcpy(path, p.path, sizeof path);
        swap(path[rand() % n], path[rand() % n]);
    }
    
    double dist(){
        double ans = 0;
        for(int i = 1; i < n; i++){
            ans += ::dist[path[i - 1]][path[i]];
        }
        return ans;
    }
};



bool accept(double delta, double temper){
    if(delta <= 0) return true;
    return rand() <= exp((-delta) / temper) * RAND_MAX;
} 

double solve(){
    const double max_temper = 10000;
    const double dec = 0.999;
    double temp = max_temper;
    Path p;
    while(temp > 0.01){
        Path p2(p);
        if(accept(p2.dist() - p.dist(), temp)) p = p2;
        temp *= dec;
    }
    return p.dist();
}

int main(){
    srand(19260817U);
    cin >> n;
    for(int i = 0; i < n; i++) {
        scanf("%lf%lf", x + i, y + i);
    }
    for(int i = 0; i < n; i++){
        dist[i][i] = 0;
        for(int j = i + 1; j < n; j++){
            dist[i][j] = dist[j][i] = hypot(x[i] - x[j], y[i] - y[j]);
        }
    }
    double ans = 1./0;
    int T = 155;
    while(T--){
        ans = min(ans, solve());
    }
    printf("%.2lf", ans);
}
View Code

交上去就可以AC了。

  由於隨機化算法有一定不穩定性,這里要多次調用計算過程取最小值。T=155就是外循環次數。

  值得注意的是,T=15就可以過80%的數據,T=42可以過完全部數據,此時最大數據運行時間為86ms。這里T取155是保險起見,畢竟時間足夠。

  上面的代碼仍有改進的余地。比如,在solve()函數中,應當把最優解記下來,在返回解時返回記下的那個最優解,免得跳到了某些差解后返回差解。

  下面是一張表供大家估算運行時間,左邊是“降溫系數”,上方是初溫與末溫的比值,表格內容是大致的迭代次數。

  從上表可以看出,增加十倍的初溫與末溫比值只會增加約25%的迭代次數,而往0.9…99的后面加個9會增加十倍的運行時間。

  除了記憶上表外,我們還可以通過記錄退火次數(將tot初始化為零,每次產生新解時tot++,計算完后看看tot)或者使用計算器計算退火次數。計算后選擇一個合適的外循環次數。

  除此之外,我們還要根據數據規模,靈活地調整初溫、末溫與降溫系數。一般來說,初溫不宜太大,否則會讓前幾次迭代接受了很差的解,浪費時間;降溫系數不宜過大,否則會讓算法過早穩定,不能找到最優值;同樣,降溫系數也不宜太高(更不能大於1,不然溫度越來越高),否則可能會超時。

  在正式使用中還有些技巧,如每次降溫后,做足夠多次計算后才再次降溫(內循環),這對算法准確性沒有太大影響。

  除了模擬退火外,還有不少隨機化算法。比如遺傳算法、蟻群算法,這些算法被稱為“元啟發算法”,有興趣的讀者可以查閱相關資料。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM