算法分析课程期末项目

算法分析课程期末项目


Capacitated Facility Location Problems

Suppose there are n facilities and m customers. We wish to choose:

  • (1) which of the n facilities to open
  • (2) the assignment of customers to facilities
  • The objective is to minimize the sum of the opening cost and the assignment cost.
  • The total demand assigned to a facility must not exceed its capacity.

Preparation

有关 CFLP 的详细内容可以参考以下文件

首先,我们需要从 Instances 中读取数据。

我们使用如图所示的容器保存每个实例的相关数据,并用如下代码读取数据。

bool Solver::load() {
    try {
        string path = "Instances/p" + to_string(this->sn);
        ifstream fin(path);

        fin >> this->nfacility >> this->ncustomer;

        this->b = this->f = vector<int>(this->nfacility);
        for (int i = 0; i < this->nfacility; ++i) {
            fin >> this->b[i] >> this->f[i];
        }
        this->a = vector<double>(this->ncustomer);
        for(int i = 0; i < this->ncustomer; ++i) {
            fin >> this->a[i];
        }
        this->c = vector<vector<double>>(this->nfacility, vector<double>(this->ncustomer));
        for (int i = 0; i < this->nfacility; ++i) {
            for (int j = 0; j < this->ncustomer; ++j) {
                fin >> this->c[i][j];
            }
        }
        this->y = vector<int>(this->nfacility, 1);
    } catch(exception e) {
        cerr << "Failed to load the target" << endl;
        return false;
    }

    return true;
}


Solution 1 (Greedy Algorithm)

对于这种问题,最容易想到的方法就是贪心算法。我们使用如下策略进行贪心。

  • 当 y[i] 等于 1 时,代表设施 i 是可选择的设施;当 y[i] 等于 0 时,代表设施 i 是不可选择的设施。
  • 把一个需求分配到一个设施时,待分配设施的容量必须大于等于当前需求。
  • 顺序扫描所有的客户需求,使用贪心的策略把当前的需求分配到一个可选择的设施,分配时忽略开设设施的开销,把需求分配到拥有最小分配开销的设施。

在观察分析了 Instances 中的各个实例数据后,我发现单个分配开销的值与单个开设开销的值多数比较接近。考虑到需求的数目远比设施的数目要多,我们可以在贪心的时候忽略开设设施的开销,仅在算法最后加上开设设施的开销。这种贪心的策略往往只能找到局部最优解,在某些情况下,甚至会出现无法求出解的情况。当我们贪心地去分配需求时,如果我们没办法分配完所有的需求,我们就返回 cost == -1,通过返回一个无效值表示无法通过贪心算法求解。

pair<double, double> Solver::greedy(bool init) {
    if (init && !Solver::load()) {
        return make_pair(-1.0, -1.0);
    }

    struct timeb t0, t1;
    ftime(&t0);
    double cost = 0.0;
    vector<int> capacity = this->b;
    this->x = vector<vector<int>>(this->nfacility);
    for (int j = 0; j < this->ncustomer; ++j) {
        int best_facility = -1;
        double best_cost = DBL_MAX;
        for (int i = 0; i < this->nfacility; ++i) {
            if (this->y[i] && capacity[i] >= this->a[j] && this->c[i][j] < best_cost) {
                best_facility = i;
                best_cost = this->c[i][j];
            }
        }
        if (best_facility == -1) {
            if (init) {
                cerr << "Failed to solve with greedy" << endl;
            }
            return make_pair(-1.0, -1.0);
        } else {
            capacity[best_facility] -= this->a[j];
            cost += best_cost;
            x[best_facility].push_back(j);
        }
    }
    ftime(&t1);

    string status = "";
    for (int i = 0; i < this->nfacility; ++i) {
        if (this->x[i].empty()) {
            status += "0 ";
        } else {
            status += "1 ";
            cost += this->f[i];
        }
    }
    if (init) {
        string path = "Results/p" + to_string(this->sn);
        ofstream fout(path);
        fout << fixed << setprecision(1) << cost << endl;
        fout << status << endl;
        for (int i = 0; i < this->nfacility; ++i) {
            fout << x[i] << endl;
        }
        fout.close();
    }

    return make_pair(cost, (t1.time-t0.time)+(t1.millitm-t0.millitm)*1e-3);
}


我们把贪心算法得到的分配方案以邻接链表的形式保存在 x 中,即使用 x[i] 来保存设施 i 服务的客户。在得到 x 后,我们扫描 x,如果 x[i] 不为空的话,代表我们使用了这座设施,则我们需要令总开销加上 f[i],即开设设施 i 的开销。在应用贪心算法时,我把所有的设施都设成了可选择,这个操作能加大成功求出解的概率,但是这种选择往往不能获得比较好的分配方案。

实验结果保存在以下文件夹中

简略的结果表保存在以下文件中

贪心算法时无法获得比较好的方案的,对于这种 NP-hard 问题,我们往往使用启发式算法来获得更好的结果。通常,我们会使用拉格朗日启发式算法、退火算法、禁忌搜索等算法解决这类 Single Source Capacitated Facility Location Problem。在此,我们在贪心算法的基础上应用禁忌搜索来优化我们的算法。

同样地,我们用 make_pair(-1.0, -1.0) 来表示无解的情况。我们先使用默认的贪心算法来获得一组初始解,然后我们把全局最优开销 – global_cost、全局最优 y – global_y、全局最优 x – global_x 设置成使用贪心算法获得的初始解。接下来,我们定义禁忌表 ts 来保存访问过的状态,防止陷入循环中。通常,我们是需要给禁忌表设置一个最大长度的,但由于我把最大迭代次数设成了 100,而实际上的可能性数目十分庞大,我们即便不设置禁忌表的最大长度也不会有问题。我们通过计算最优的 local_cost 来搜索出较优解。在每一次迭代中,我们先把 local_cost 初始化成一个无效值,这里使用的是 DBL_MAX。接下来,我们把禁忌算法的领域定义为改变一个设施的可选择状态,即每次只改变 y 的其中一个值。我们基于邻居 y’ 使用贪心算法算出一组邻居解,然后我们比较这组解的 cost 与 local_cost 的值,当 cost < local_cost 且邻居 y’ 不在禁忌表中时,我们更新 local_cost 的值。而禁忌搜索还存在特赦规则,即满足某种条件时,即便 y’ 在禁忌表中时,我们也更新 local_cost 的值,此时,我们选择的特赦条件为 cost < global_cost。当我们遍历完 y 的所有邻居时,我们 local_cost 的值进行判定:如果 local_cost == DBL_MAX,那么代表当前 y 的所有邻居都无法通过贪心算法求出解,此时我们不需进行回溯,而是直接跳出循环;local_cost < global_cost 时,我们更新 global_cost、global_x、global_y 的值;否则,不进行任何操作。完成判定后,我们把当前 y 更新成局部最优 y,即 local_y,并把 local_y 编码加入到 ts 中。在算法中,我们使用 unordered_set <string> 来保存禁忌条目,通过独热编码的形式来表示不同的禁忌。

pair<double, double> Solver::tabu() {
    if (!Solver::load()) {
        return make_pair(-1.0, -1.0);
    }

    int T = 100;
    struct timeb t0, t1;
    ftime(&t0);
    double global_cost = Solver::greedy(false).first;
    if (global_cost < 0) {
        cerr << "Failed to solve with greedy" << endl;
        return make_pair(-1.0, -1.0);
    }
    vector<int> global_y = this->y;
    vector<vector<int>> global_x = this->x;
    while (T-- != 0) {
        double local_cost = DBL_MAX;
        vector<int> local_y = this->y;
        vector<vector<int>> local_x = this->x;
        for (int i = 0; i < this->nfacility; ++i) {
            this->y[i] ^= 1;
            pair<double, double> result = Solver::greedy(false);
            if (result.first >= 0 && ((result.first < local_cost && ts.find(Solver::encode(this->y)) == ts.end()) || (result.first < global_cost))) {
                local_cost = result.first;
                local_y = this->y;
                local_x = this->x;
            }
            this->y[i] ^= 1;
        }
        if (local_cost == DBL_MAX) {
            break;
        } else if (local_cost < global_cost) {
            global_cost = local_cost;
            global_y = local_y;
            global_x = local_x;
        }
        this->y = local_y;
        this->x = local_x;
        this->ts.insert(Solver::encode(local_y));
    }
    ftime(&t1);

    string status = "";
    for (int i = 0; i < this->nfacility; ++i) {
        if (global_x[i].empty()) {
            status += "0 ";
        } else {
            status += "1 ";
        }
    }
    string path = "Results/p" + to_string(this->sn);
    ofstream fout(path);
    fout << fixed << setprecision(1) << global_cost << endl;
    fout << status << endl;
    for (int i = 0; i < this->nfacility; ++i) {
        fout << global_x[i] << endl;
    }
    fout.close();

    return make_pair(global_cost, (t1.time-t0.time)+(t1.millitm-t0.millitm)*1e-3);
}


实验结果保存在以下文件夹中

简略的结果表保存在以下文件中

Comparison

通过比较 Greedy 和 Tabu 的结果,我们能发现 Tabu 在每个实例上都能取得比 Greedy 好的数据,且时间开销平均仅不到 Greedy 的一千倍(每个实例不超过 0.5s)。在某些实例上,Tabu 得到方案的开销仅为 Greedy 的一半左右,则证明使用 Tabu 要远优于使用 Greedy。最后,项目的全部文件保存在以下文件夹中,更详细的结果可在 github 上查看。