算法复习-分支限界法

基本思想

对于优化问题,要记录一个到目前已经取得的最优可行解及对应的目标函数值,这个记录要根据最优的原则更新。无论采用队列式还是优先队列式搜索,常常用目标函数的一个动态界(函数)来剪掉不必要搜索的分枝。

对于最大值优化问题,经常会估计一个(动态)上界,如果当前节点的估计(动态)上界\(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-检索

我们知道,节点优先级的选择和计算直接影响搜索空间树的复杂程度,进而直接影响算法性能,我们希望具有如下特征的活节点称为当前扩展节点:

  1. 以X为根的子树中含有问题答案的答案节点;
  2. 在所有满足1的节点中,X距离答案节点最近。

我们希望我们定义的优先级可以尽快找到具有上述特征的节点,我们自然希望付出尽可能小的优先级计算成本。那么对于任意节点,搜索成本可以使用两种标准来衡量:

  1. 在生成一个答案节点之前,子树X需要生成的节点数,我们希望子树X快速生成答案节点;
  2. 以X为根的子树中,距离X最近的那个答案节点到X的路径长度。

那么我们用\(c()\)表示最小搜索成本函数,递归地定义如下:


其实上述的伪代码只要掌握了0/1背包的分支限界法就很好理解了。需要注意的地方在上面的0/1背包问题都提到了。

旅行商问题






posted @ 2020-08-08 10:36  xinze  阅读(1257)  评论(0编辑  收藏  举报