基本思想
對於優化問題,要記錄一個到目前已經取得的最優可行解及對應的目標函數值,這個記錄要根據最優的原則更新。無論采用隊列式還是優先隊列式搜索,常常用目標函數的一個動態界(函數)來剪掉不必要搜索的分枝。
對於最大值優化問題,經常會估計一個(動態)上界,如果當前節點的估計(動態)上界\(CUB\)小於當前取得的目標值,就直接剪掉該節點的子樹。
對於最小值優化問題,經常會估計一個(動態)下界,如果當前節點的估計(動態)下界\(CLB\)大於當前取得的目標值,就直接剪掉該節點的子樹。
對於可行解問題,經常會估計一個(動態)下界,如果當前節點的估計(動態)下界\(CLB\)大於當前取得的目標值,就直接剪掉該節點的子樹。
上面的動態上下界就叫做剪枝函數,可以有效地減少活節點數,降低復雜度。
旅行商問題
狀態空間樹:
隊列分支限界法:
優先隊列分支限界法:
0/1背包的分支限界法
分別解釋節點各個字段的含義:
- Parent:表示節點X的父親節點;
- Level:表示節點X在狀態空間樹中的深度;
- Tag:表示每個物品的選擇與否;
- CC:記錄背包在節點X處的可用容量;
- CV:記錄在節點X處的物品價值;
- CUB:存放節點X的動態上界Pvu;
各個變量的含義:
- Pvu(X):表示節點X處可行解所能達到的一個上界;
- Pvl(X):表示節點X處可行解所能達到的一個下界;
- prev:表示目前能得到的最大值。
剪枝方案:
如果Pvu<prev,那么直接剪掉節點X的子樹,不將X放入活節點表,或者說不生成X的子節點。只用這個策略其實就可以完成這個任務,但是我們想要盡可能多的降低復雜度,接着看。
如果Pvu=prev,因為prev可能是一個方案的結果,那么這是從X繼續搜索下去不會得到更好的解,可以剪掉;但是如果prev不是一個答案節點,而是一個中間結果,那么問題就復雜了。
我們先來看prev的更新過程,對於一個節點X,\(prev=max(prev, Pvl(X))\),我們發現,prev有一定的“前瞻性”,就是說如果在估計Pvl的時候恰好就是答案節點的解,那么prev將會在到達答案節點前就獲知當前路徑的結果,那么這個時候我們如果認為prev=Pvu的時候就直接剪掉的話,就會遺失可能的最優解。
那么自然地我們就想要破除prev的前瞻性,也就是說讓prev只在答案節點處得到最后的結果,所以我們用一個足夠小的常量e,把prev的更新過程改為\(prev=max(prev, Pvl(X)-e)\),這樣就規避掉了prev的前瞻性。那么e到底應該多小,只要不影響兩個節點之間的優先級順序就可以,即\(Pvl(Z)<Pvl(Y)=>Pvl(Z)<Pvl(Y)-e\)。
那么經過上述處理,最終的剪枝策略是:當\(Pvu \le prev\)時剪掉節點X。同時Pvu(X)可以作為優先級函數。
代碼如下,在AcWing上提交通過,需要注意的細節都寫在注釋里了,還是很多的:
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
const int MAX = 1010;
// e的值也不能太小,要在double精度內
const double e = 0.0001;
int n, M;
int res = 0;
int W[MAX];
int P[MAX];
double PW[MAX];
struct node
{
struct node *parent, *lchild, *rchild;
int level, tag, cc;
double cub, cv;
node(int _level, int _tag, int _cc, double _cv, double _cub, node* _left, node* _right, node* _parent)
{
level = _level;
tag = _tag;
cc = _cc;
cv = _cv;
cub = _cub;
lchild = _left;
rchild = _right;
parent = _parent;
}
};
typedef struct node Node;
struct Nodeless
{
bool operator()(const struct node *_left, const struct node *_right)
{
return _left->cub < _right->cub;
}
};
void LUBound(int cap, double cv, int clevel, double &Pvl, double &Pvu)
{
// 物品需要按照單位價值非遞減的方式排列
// 使用完全背包問題的貪心算法估計上下界
Pvl = cv;
int rv = cap;
for (int i = clevel + 1; i <= n; i++)
{
// 至少一個物品無法裝入
if (rv < W[i])
{
Pvu = Pvl + rv * 1.0 * P[i] / W[i];
for (int j = i + 1; j <= n; j++)
{
if (rv >= W[j])
{
rv -= W[j];
Pvl += P[j];
}
}
// 此時Pvu >= Pvl,因為物品按照單價從高到低排列
return;
}
rv -= W[i];
Pvl += P[i];
}
// 表示都能裝進去
Pvu = Pvl;
return;
}
void Finish(Node* res)
{
int v = 0;
for (int i = n; i > 0; i--)
{
if (res->tag == 1)
{
v += P[i];
}
res = res->parent;
}
cout << v << endl;
}
void LFKNAP()
{
priority_queue<Node*, vector<Node*>, Nodeless> livenodes;
double Pvu, Pvl, prev;
LUBound(M, 0, 0, Pvl, Pvu);
Node* root = new Node(0, 0, M, 0, Pvu, nullptr, nullptr, nullptr);
Node* ans = root;
prev = Pvl - e;
while (root->cub > prev)
{
int i = root->level + 1;
int cap = root->cc;
// 因為cv要和prev比較大小,雖然C++會自動將int升為double,但是寫的清楚些總沒壞處hhh
double cv = root->cv;
if (i == n + 1)
{
if (cv > prev)
{
prev = cv;
ans = root;
}
}
else
{
if (cap >= W[i])
{
// 為什么不調用LUBound(cap - W[i], cv + P[i], i, Pvl, Pvu)?
// 因為左孩子可行時,Pvu的值等於root->cub,Pvl的值等於之前節點的Pvl,不必再次計算。
// 為什么左孩子里沒有更新prev?
// 因為prev=max(prev, Pvl-ee),prev就是各節點Pvl-ee的最大值,這里算出的Pvl一定等於之前節點的Pvl。所以不必計算。
Node* left = new Node(i, 1, cap - W[i], cv + P[i], root->cub, nullptr, nullptr, root);
// 如果只求結果,這里root->lchild=left操作以及下面的root->rchild=right實際上可以省略
root->lchild = left;
livenodes.push(left);
}
LUBound(cap, cv, i, Pvl, Pvu);
if (Pvu > prev)
{
// 這里是個大坑!!!課件上給的是Pvl,應該是Pvu,可在https://www.acwing.com/problem/content/2/測試
Node* right = new Node(i, 0, cap, cv, Pvu, nullptr, nullptr, root);
root->rchild = right;
prev = max(prev, Pvl - e);
livenodes.push(right);
}
}
if (livenodes.empty()) break;
root = livenodes.top();
livenodes.pop();
}
Finish(ans);
}
void quicksort(int start, int end)
{
if (start > end) return;
int i = start, j = end + 1;
double pivot = PW[start];
while (true)
{
while (PW[++i] > pivot && i < end);
while (PW[--j] < pivot && j > start);
if (i < j)
{
swap(W[i], W[j]);
swap(P[i], P[j]);
swap(PW[i], PW[j]);
}
else break;
}
swap(W[j], W[start]);
swap(P[j], P[start]);
swap(PW[j], PW[start]);
quicksort(start, j - 1);
quicksort(j + 1, end);
}
int main()
{
cin >> n >> M;
for (int i = 1; i <= n; i++)
{
cin >> W[i] >> P[i];
PW[i] = 1.0 * P[i] / W[i];
}
quicksort(1, n);
LFKNAP();
return 0;
}
使用隊列式的分支限界法只需要LFKNAP方法中while(root->cub > prev)
改為while(true)
,然后改用普通的queue就可以了。
這個算法的時間復雜度暫時還不會分析QAQ,我覺得最壞是\(O(2^n)\),但是經過剪枝策略后平均的時間復雜度應該要更小。
有耐心的朋友可以把搜索樹打出來看看,我是沒耐心了QAQ,這個課件的打印錯誤沒把我命要了。。。
還是順手補上了這個搜索樹的代碼,用的是BFS的思路:
void layerOrder(Node* root)
{
cout << "Search Tree: " << endl;
queue<Node*> q;
q.push(root);
int layer = 0;
int cnt = 0;
while(!q.empty())
{
cout << "layer: " << layer;
if(layer > 0)
cout << " weight: " << W[layer] << " price: " << P[layer] << endl;
else cout << endl;
int layer_len = q.size();
cnt += layer_len;
for(int i = 0 ; i < layer_len ; i++)
{
Node* cur = q.front();
q.pop();
cout << "node: " << cur << " parent: " << cur->parent;
cout << " tag: " << cur->tag << endl;
if(cur->lchild) q.push(cur->lchild);
if(cur->rchild) q.push(cur->rchild);
}
layer++;
}
cout << "Size of Tree Nodes: " << cnt << endl;
}
既然做了這部分修改,那就再來比較一下隊列式和優先隊列式的搜索節點數量:
對於用例
隊列式的搜索樹長這樣:
優先隊列的搜索樹長這樣:
可見隊列式的節點數是10,優先隊列的節點數是8,而且是在輸入用例規模這么小的情況下,所以我們可以說優先隊列可以更好的降低分支限界法的搜索復雜度。
電路板布線問題
找最短路徑就從目標節點開始,每次找長度減一的節點進入即可,直到找到開始節點,這個策略是一定可以找到開始節點的,就是說在回溯過程中不會走到錯誤的路徑上。
如果不存在最短路徑,那么活節點表會變空,所以直接輸出無解即可。
由於每個活節點最多進入活節點隊列一次,最多需要處理mn個節點,擴展一個活節點需要\(O(1)\)的時間,所以共耗時\(O(mn)\)。構造最短路需要\(O(L)\)的時間,L表示最短路徑的長度。
優先級的確定以及LC-檢索
我們知道,節點優先級的選擇和計算直接影響搜索空間樹的復雜程度,進而直接影響算法性能,我們希望具有如下特征的活節點稱為當前擴展節點:
- 以X為根的子樹中含有問題答案的答案節點;
- 在所有滿足1的節點中,X距離答案節點最近。
我們希望我們定義的優先級可以盡快找到具有上述特征的節點,我們自然希望付出盡可能小的優先級計算成本。那么對於任意節點,搜索成本可以使用兩種標准來衡量:
- 在生成一個答案節點之前,子樹X需要生成的節點數,我們希望子樹X快速生成答案節點;
- 以X為根的子樹中,距離X最近的那個答案節點到X的路徑長度。
那么我們用\(c()\)表示最小搜索成本函數,遞歸地定義如下:
其實上述的偽代碼只要掌握了0/1背包的分支限界法就很好理解了。需要注意的地方在上面的0/1背包問題都提到了。
旅行商問題