GithubHelp home page GithubHelp logo

huawei2021's Introduction

Write Up

本文件是对整个比赛经历的复盘,对问题的思考,版本迭代的思路,调试时遇到的 bug,以及在整个过程中学到的经验和知识。

初赛赛题复述

初赛赛题给定了一系列型号的服务器和虚拟机,不同型号的服务器有不同的核心数和内存数,每个服务器有两个节点,核心数和内存数被平均分成两份放在两个节点中;不同型号的虚拟机也是有不同的核心数和内存数,而且分单双节点,单节点虚拟机只能部署到服务器的一个节点上,双节点虚拟机需要部署到服务器的两个节点上,每个节点分配虚拟机核心数和内存数的一半。

比如某种型号的服务器,核心数为 64,内存数为 128,那么它两个节点中每个节点都有 32 个核心,64 个内存。若某种型号的虚拟机是单节点虚拟机,有 23 个核心,62 个内存,那么它可以部署在这个服务器的某个节点上;若某种单节点虚拟机有 35 个核心,22 个内存,那么这个服务器就放不下这种虚拟机;若某种双节点虚拟机有 16 个核心,24 个内存,那么它可以部署在这个服务器上,服务器的每个节点分配 8 个核心,12 个内存。

每个服务器都有硬件成本和每天运行成本,如果某一天这个服务器从来没有用过,那么它就处于关机状态,没有运行成本。

赛题给定了训练文件,文件中存放了所有服务器的型号(大概 80 到 100 种),所有虚拟机的型号(大概 800 到 1000 种),以及 800 天到 1000 天的虚拟机请求。请求分为两种,一种是add,一种是del。所谓add,即给定虚拟机的型号,id,要求你把它放置到某个服务器中;所谓del,即给定虚拟机的 id,要求移除这个虚拟机。每天的请求数量不定,少则有几十条请求,多则有上万条请求。

我们可以进行三种操作。一种是在当天开始的时候给出一个购买服务器的方案,需要给出哪种型号的服务器买几个,可以买多种型号的服务器。第二种操作是在购买完服务器后,给出迁移方案,把虚拟机从一个服务器迁移到另一个服务器,这个迁移数量是有限制的,每天最多不能超过当前虚拟机数量的千分之三。第三种是处理当天的虚拟机请求,包括add请求和del请求,在处理add的请求时,需要按顺序处理,给出虚拟机的 id 和服务器的 id,表示把这台虚拟机放置到指定的服务器上。

服务器 id 的编号与我们每天给定的购买方案相关,它会对按购买顺序对服务器进行递增编号。比如我们的购买方案如下:

(型号 5,买 3 台)
(型号 10,买 5 台)
(型号 3,买 2 台)

那么给服务器的编号(即 id)就为:

0: 型号 5
1:型号 5
2: 型号 5
3: 型号 10
4: 型号 10
5: 型号 10
6: 型号 10
7: 型号 10
8: 型号 3
9: 型号 3

也就是说,对服务器的编号是由这个协议指定的,而不是我们自己指定的。这个协议非常重要,以至于在初赛时我差点因此放弃。

对成绩的评价是处理完所有的虚拟机请求后所需要的成本,成本越低越好。另外一个重要的限制是,处理单个数据集的时间不能超过 90 秒。线下练习与线上正赛,都是给定了两个数据集,这两个数据集的虚拟机请求分布有很大的不同,为后面程序的设计的调参提供了思路。

初赛中的算法与代码结构

最初大家都觉得这是一个多维装箱问题,即要考虑到单双节点,又要考虑到核心数和内存数,还要考虑到不同大小规格的服务器。多维装箱问题通常没有最优解,只有用贪心算法,每一步都选择当前的最优。经过搜索装箱问题,得到的有这么几种算法:first fitting,best fitting,decreasing first fitting,decreasing best fitting。

所谓的 first fitting,即来一个虚拟机请求后,我们对现有的服务器进行遍历,找到第一个能装得下它的服务器,就把这个虚拟机放置到这个服务器里。显然这个结果并不是最优结果。

best fitting 指的是来一个虚拟机请求后,我们对现有的服务器进行遍历,计算放置完这个虚拟机后,服务器剩余的资源数。然后对剩余的资源数进行排序,计算出剩余资源数最小的那个服务器,将虚拟机放置到这个服务器里。这是未知下一个虚拟机规格时,当下的最优解决方案。

decreasing first fitting 指的是,我们已知后续的多个虚拟机请求和其规格,首先对这些虚拟机进行排序,将大的排在前,小的排在后,按从大到小的顺序对虚拟机执行 first fitting 算法。显然这样的效果会比单纯的 first fitting 算法要好一些。decreasing best fitting 算法同理,只不过是把虚拟机按从大到小的顺序执行 best fitting 算法,这种方法是最费时的,但是也是效果最好的。

初赛刚开始没两天,有人开源了一个 baseline,用的就是 first fitting 算法。即按顺序处理虚拟机请求,如果能找到第一个放得下的服务器,那么就直接放置;如果找不到,就购买一个新的服务器放置这个虚拟机。

后来大家实现的都是 best fitting 算法,思路也很简单,都是直接计算放置完这个虚拟机后,服务器两个节点剩余的核心数和内存数的总和,然后选出剩余资源最小的那台服务器。如果一个都找不到,那么买台新的服务器。伪代码大概是这样的:

int find_best_fitting_serv(int vm_core, int vm_mem, int vm_node, ServList &servs)
{
    vector<pair<int, int>> res_remn;  // (remaining resources, server id)
    for (auto &serv: servs)
    {
        if (serv.can_hold_vm(vm_core, vm_mem, vm_node))
        {
            res_remn.insert(make_pair(serv.calc_remn(vm_core, vm_mem, vm_node), serv.id));
        }
    }
    
    if (!res_remn.empty())
    {
        sort(res_remn.begin(), res_remn.end());  // sort the first element of pair
        return res_remn.begin()->second;
    }

    return -1;
}

对于服务器的选型,刚开始的时候大家都是乱选,因为线下训练赛的时候数据集是公开的,所以大不了把 80 种服务器或 100 种服务器都试一遍,全程选一种即可。后来也有提出选大小和核内比都适中的服务器,或许这种效果比较好一些。

整个项目中,处理add操作的代码大概长这样:

void process_add_op(int vm_id, int vm_core, int vm_mem, int vm_node, ServList &servs)
{
    int serv_id = find_best_fitting_serv(vm_core, vm_mem, vm_node, servs);
    if (serv_id != -1)
    {
        assign_scheme.push_back(make_pair(vm_id, serv_id));
    }
    else
    {
        purchase_serv(vm_core, vm_mem, vm_node);
    }
}

迁移操作的代码大致是这样:

void migrate(int max_num_mig, ServList &servs)
{
    int num_mig = 0;

    vector<pair<int, int>> num_vm_to_serv_id;  // (number of virtual machines, server id)
    for (auto &serv: servs)
    {
        num_vm_to_serv_id.insert(make_pair(serv.vms.size(), serv.id));
    }
    sort(num_vm_to_serv_id.begin(), num_vm_to_serv_id.end());  // 先迁移剩余虚拟机少的服务器

    int serv_mig_in;
    for (auto &serv: servs)
    {
        auto vms_copy(serv.vms);  // 防止迭代器失效
        for (auto &vm: vms_copy)
        {
            serv_mig_in = find_best_fitting_serv(vm.core, vm.mem, vm.node, servs);
            if (serv_mig_in != -1)
            {
                mig_scheme.push_back(make_pair(vm.id, serv_mig_in));
                servs.mig_vm(vm.id, serv_mig_in);
                ++num_mig;
                if (num_mig >= max_num_mig)
                    return;
            }
        }
    }
}

在放置和迁移时,仅仅凭借着这个算法,进入初赛的 32 强就差不多了。这种算法的缺点是速度太慢,因为对于每个虚拟机,都要对所有的服务器进行遍历,所以效率很低很低。

这里还需要注意到一个问题,即我们在购买服务器时,总是遇到当所有服务器都放不下某种虚拟机时才购买,购买的服务器又会马上被用到,所以必须给它赋予一个临时的 id,但是这个 id 与题目给出的协定并不相符。比如我们给出的购买顺序是这样的:

temp serv id: 5, serv type: 56
temp serv id: 6, serv type: 3
temp serv id: 7, serv type: 27
temp serv id: 8, serv type: 56
temp serv id: 9, serv type: 3

我们的输出必须是这样的格式:

(服务器型号:56, 数量:2)
(服务器型号:3,数量:2)
(服务器型号:27, 数量:1)

此时服务器的 id 变为:

serv id: 5, serv type: 56
serv id: 6, serv type: 56
serv id: 7, serv type: 3
serv id: 8, serv type: 3
serv id: 9, serv type: 27

刚开始的时候没有注意到这个问题,导致程序 bug 频出,比如服务器的资源分配溢出等等。后来找到了问题所在,做了个映射就好了。

初赛的正式赛阶段也是差不多相同的思路,但是总是超时。为了节省时间,先是使用了散列表unordered_map来存储当前所有服务器的资源状态,能不能找到合适的服务器全靠运气;后来使用了红黑树map,不再对服务器进行遍历,变相实现了 best fitting 算法,速度快了很多。靠着这个版本,在初赛正赛拿了 27 名,挤入了复赛。

除了 best fitting 算法,还试了些诸如 "balance fitting" 算法,使得服务器尽量不要出现核心和内存只有其中一个满载,而另外一个不满载的情况,但是效果并不是很好。

初赛中的数据结构选择

显然我们需要用某种方式存储当前所有服务器的状态,包括 id,型号,两个节点已使用的资源,剩余的资源,以及挂载了哪些虚拟机。

一开始对 STL 中常用容器的了解并没有很多,所以写出来的结构是这样的:

struct NodeStat
{
    int cores_used;
    int cores_ream;
    int mem_used;
    int mem_ream;
};

struct ServerStat
{
    vector<int> types;
    vector<int> ids;
    vector<int> cores_used;
    vector<int> cores_ream;
    vector<int> mem_used;
    vector<int> mem_ream;
    vector<NodeStat*> node_a;
    vector<NodeStat*> node_b;
};

struct VMStat
{
    vector<int> types;
    vector<int> vm_ids;
    vector<int> server_ids;
    vector<int> nodes;  // 0 for node A, 1 for node B, 2 for both
};

对于服务器的状态,只存储了型号,id,以及两个节点的使用情况,并没有存储挂载了哪些虚拟机;而对于虚拟机的状态,只存储了型号,id,挂载的服务器的 id,以及挂载在哪个节点上。

这样的数据结构有几个问题:

  1. vector容器优势在于随机访问,但是缺点也很明显:当有频繁的删除操作时,需要在内存中不断地移动数据,使其保持线性顺序,效率很低很低。服务器只增不减,用vector或许还说得过去,但是虚拟机一直处于频繁的增删过程,再使用vector就是欠缺考虑了。

  2. 服务器状态和虚拟机状态分别存储,表面上看上去互不干扰,但是当我们已知一台服务器的 id,想知道它挂载了哪些虚拟机时,会是个非常耗时的问题,因为需要对所有虚拟机进行遍历。

  3. 当我们增添一台服务器或修改某个服务器的状态时,也变得非常复杂:我们需要对各个vector分别进行修改。这样的代码写起来既不雅观,也容易出错。

  4. 因为服务器的 id 有前面提到的映射问题,所以实际上vector中存储的服务器的 id 并不是递增的,而是根据输出协议重新排过序的。这样一来,vector的优点尽失,我们再也无法利用随机访问的特性了。每次找一个服务器都需要对服务器进行遍历,这样的速度是我们无法接受的。

正因为存在诸多的问题,所以我们不如直接面向对象编程,并且直接放弃vector,转而使用mapunordered_map,这样一来大大提升了效率:

struct Node
{
    int core_rema;
    int mem_rema;

    Node(int core_rema, int mem_rema)
    : core_rema(core_rema), mem_rema(mem_rema) {}

    void _change_node_stat(int op, int core, int mem);
};

struct VM
{
    int vm_type;
    int serv_node;

    VM(int vm_type, int serv_node): vm_type(vm_type), serv_node(serv_node) {}
};

struct Serv
{
    int id;
    int serv_type;
    Node nodes[2];
    unordered_map<int, VM> vms;  // vm_id -> vm
    
    Serv(int id, int type);
    Serv(const Serv &obj);
    void operator= (Serv const &obj);
    bool can_hold_vm(int vm_type, int serv_node);
    void add_vm(int vm_id, int vm_type, int serv_node);
    void del_vm(int vm_id);
    
    void _change_serv_stat(int op, int serv_node, int core, int mem);
};


struct ServList
{
    unordered_map<int, Serv> servs;  // id -> xxx

    Serv& operator[] (int id);
    Serv& get_serv_by_vm_id(int vm_id);
    void add_serv(int id, int type);
    void mig_vm(int vm_id, int serv_id, int serv_node);
};

这样的结构看起来显然是合理的,但是并没有解决实现 best fitting 算法时需要遍历的问题。

为了防止每次都对所有服务器遍历,引入了一个全局的散列表(unordered_map<int, int>)来保存当前所有服务器的状态(剩余的核心数和内存数):

extern unordered_map<int, vector<pair<int, int>>> ream;  // res -> (serv_id, serv_node),服务器中单节点的状态(剩余的核心数加剩余的内存数)
extern unordered_map<int, vector<int>> dream;  // res -> (serv_id),整个服务器的剩余资源数

当我们接到一个虚拟机请求,或者想迁移一个虚拟机时,直接从表的对应位置开始查,直到能查到能放得下这个虚拟机的服务器为止。由于散列表并不是有序的,而且我们不知道散列表的上界和下界,所以能不能查到全靠运气。即使这样,也节省出来了大量的时间。

为了解决有序性和有界性这两个问题,将unordered_map改为了map

extern map<int, vector<pair<int, int>>> ream;  // res -> (serv_id, serv_node),服务器中单节点的状态(剩余的核心数加剩余的内存数)
extern map<int, vector<int>> dream;  // res -> (serv_id),整个服务器的剩余资源数

因为map是由红黑树实现的,所以其第一个元素是按从小到大的顺序排列的。我们可以用ream.begin()->first拿到它的下界,用ream.rbegin()->first拿到它的上界 。这样一来,就解决了有序性和有界性的问题。通过保存服务器的状态,我们变相地实现了 best fitting 算法,并且速度比遍历快了许多。也是自此,在复赛时走上了使用map无限套娃的不归路。

复赛赛题复述

初赛时整个训练数据文件可以全部读取,复赛时要求先读取k天的虚拟机请求,然后输出第 1 天的方案,之后每读取一天的请求,给出一天的方案,直到读取完所有的请求后,直接输出剩余所有的方案。

这个改动对整个算法的影响不大。

复赛的另外一个改动为增大了迁移次数。初赛时的最大迁移次数为当日所有虚拟机数的千分之三下向取整,复赛时更改为百分之三向下取整。

复赛中的算法与代码结构

初赛正赛刚结束,第二天就开始尝试单纯形法和分支定界实现的整数规划了。当时想使用整数规划解决两个问题:

  1. 对于接下来一天请求的虚拟机列表,我们能否从中找到几个,使得某种型号的服务器尽量得满?如果填充得还可以,余量很小,那么我们就买这种服务器。

  2. 在迁移时,若某种服务器还有很多空余,我们能否从其他的服务器中抽调几个虚拟机,使得这个空余的服务器恰好装满?

在第 13 版,花了一天多时间写了个单纯形法的线性规划求解器,以及一个使用分支定界法实现的整数优化器,虽然很好用,但是速度实在慢至难以忍受。后来就把它放弃了。也是从这一版开始,每天服务器的状态都被保存成文件打印下来,并且用 python 分析了数据集中的 add 请求和 del 请求的比例,以及服务器型号、虚拟机型号、add 请求中虚拟机实例的核心、内存数的比例与分布状况。

经过一些分析,确实看出些端倪。比如两个数据集中,有一个数据集几乎全是 add 请求,另外一个数据集 add 请求和 del 请求持平;一个数据集的 add 请求中虚拟机的核内比约为 0.8,另外一个约为 1。有时候会出现迁移次数达不到当天的最大值的情况,看了看 dump 下来的服务器状态,基本都是因为有的服务器核心数被装满,但内存还远未满;有的服务器内存被装潢,但核心远未满。

对于复赛时排名能前进如此之多,全靠通过计算 add 与 del 的比例,以及核心内存的比例,对迁移策略和选型策略进行动态调整。

在后续的几个版本中,除了实现更快的 best fitting 算法,还实现了使服务器“满载”作为优先选择的 full load 算法。即制定一个阈值,比如服务器剩余的核心数小于等于 4,内存数小于等于 7,那么就认为这台服务器“满载(full load)”。对于一个虚拟机,若能找到一个服务器,将虚拟机放入后,使它满载,那么就不用再找 best fitting 了。这些操作都是基于查表进行的,不再对服务器进行遍历,所以速度快了很多。

在选型时,参考了别人的算法,但是发现了一个神奇的参数。当调大它时,第二个数据集成本降得很快;当调小它时,第一个数据集成本降得很快。结合之前发现的数据分布问题,猜到也许是当天请求中的核心数和内存数的比例与这个参数相关。于是果断把这个比例整合到了这个参数中,马上出现了两个数据集成本同时降低的场面,排名一下前进了不少。

后来又发现,迁移有时候次数迁不满,主要是因为有核内比差异过大的虚拟机迁不出去,导致这台服务器怎么都不能平衡。于是在选型时统计了当天所有服务器剩余资源的状态,将剩余资源的核心和内存数的比例添加到了选型方案中,也能降低一些成本。

在迁移时,实现了两种思路,一种是传统的 best fitting,从虚拟机最少的服务器开始清空,对于每个虚拟机,先找到它能使其 full load 的服务器,若找不到,再找 best fitting 的服务器;另一种是从剩余资源数最大的服务器开始,若这个服务器既不满载,也不空,那么判断这个服务器是否处于不平衡状态,若不平衡,则迁出去一台和这个服务器核内比相差最大的虚拟机,再迁进来一个能使其满载的虚拟机。若找不到,或者服务器并没有处于不平衡状态,则同样使用 best fitting,找到一台虚拟机使这个服务器剩余的资源变小一点。

这两种思路同样对两个数据集的表现截然相反,传统的 best fitting 对数据集一表现很好,但对数据集二表现不佳;消除不平衡的迁移算法对数据集二表现很好,对数据集一表现不好。我猜了猜,也许是当天的 add / del 的比例有影响。将两种迁移策略按这个比例分配后,成本果然又降了许多,在两个数据集上表现俱佳。

这些策略一直到复赛正赛都没有变过。

复赛中的数据结构

复赛中为了让查询操作更快,精心维护了几个全局的状态表:

extern map<int, map<int, set<pair<int, int>>>> nnode_core_mem_map;  // node core -> (node mem, (serv_id, node)), 非空非满载服务器节点的状态
extern map<int, map<int, set<int>>> nserv_core_mem_map;  // min core -> (min mem, (serv_id)), 非空非满载服务器节点最小资源的状态
extern map<int, map<int, map<int, set<int>>, greater<int>>, greater<int>> vm_stat; // vm core -> (vm mem, (vm_node -> (vm_id))
extern map<int, int> vm_id_to_serv_id;

其中nnode_core_mem_map专门用于匹配单节点的虚拟机,nserv_core_mem_map专门用来匹配双节点的虚拟机。以nnode_core_mem_map为例,其实现 best fitting 的思路是这样的:

假如它当前存储的状态为

cpu: 1
    mem: 4
        (187234234, 0), (323529875, 1), (23582385, 0)
    mem: 5
        (347525232, 1), (2485294524, 0), (234213564, 0), (8297346548, 1)
cpu: 2
    mem: 3
        (235285825, 1)
cpu: 3
    mem: 1
        (2452523236, 0), (6825823532, 1), (476583746, 1)
    mem: 4
        (2351834513, 0)

假如某个单节点虚拟机的核心数为 1,内存数为 2,那么它首先找到(cpu 1, mem 4)这个组合,然后找到(cpu 2, mem 3),最后找到(cpu 3, mem 4),每个组合里只要找到一个服务器就可以了。然后比较这三个组合的剩余资源数:1 + 4 - (1 + 2) = 22 + 3 - (1 + 2) = 23 + 4 - (1 + 2) = 4,此时剩余资源数最小的是前两个组合,我们任取一个即可。这样一来,本应对 12 个节点进行遍历,但是我们只查询了 3 次便找到了 best fitting 值。

这中间还有个小技巧:如果节点处剩余的核心数减去虚拟机的核心数,大于等于当前的最小剩余资源数,那么就不用再往下找了。因为剩余资源数res = (node_core - vm_core) + (node_mem - vm_mem),如果仅仅第一项都比最小的res还大了,那么第一项和第二项加起来也一定大于res。仍以上面的数据为例:找完了cpu 1cpu 2后,最小的剩余资源数为 2,对于cpu 33 - 1 = 2 >= 2,因此cpu 3及更大的 cpu 都可以放弃不用再找了。这样一来效率又提升了不少。

对于nserv_core_mem_map,里面存储的是服务器两个节点中,两种剩余资源更小的那个。比如某个服务器剩余资源数是这样的:(node 0: (cpu: 3, mem: 4), node 1: (cpu: 2, mem: 3)),那么存储它时 cpu 取最小值 2,mem 取最小值 3。这样做的好处是,匹配双节点虚拟机时,不需要判断一个节点满足放置条件时,另一个节点是否也放置条件,因为存储的是最小值,若连最小值都能满足旋转要求,那么另外的节点也一定满足放置要求。

对于虚拟机,同样也维护了一个状态表,只不过里面存储的顺序是由大到小,这样当某个服务器需要找一个合适的虚拟机时,可以自然而然地找到 best fitting 的虚拟机。

这样的表除了可以找 best fitting,在找满足使服务器满载条件的虚拟机时速度也是非常快。

但是这样的数据结构也是存在问题的:对于迁移操作,如果某个服务器既非空,也没有满载,并且对它尝试清空时失败,那么它也将会留在这个状态表中,在清空其它服务器中的虚拟机时,仍有可能找到这台服务器,并向其中添加虚拟机。但是如果我们创建一个集合,存储已经处理过的服务器,并且当在查找到某个服务器时,发现这个服务器处于集合中,那么跳到下个服务器。这样的操作会非常耗时,因为满足要求的服务器大部分都是之前已经处理过的。后来索性把这个集合设置为空集,即使可能会出现循环迁移的问题,只要成本没有提升得很明显,就不再管了。

复赛正赛赛题复述

复赛正赛时,可以有一天不限制迁移次数。

不知道为什么,我们队根本没有注意到需求变更任务书,败得很不甘心。

Problem Shooting

这里记录遇到的问题以及 bug 调试过程。

  1. 资源分配溢出,输出格式不正确等

    1. 没有做服务器 id 的映射,或者没有按题目规定的输出协议输出。
  2. 编译出错的几个原因

    1. 在 windows 平台上,可以直接使用INT_MAX宏,而在 linux 平台上,这样写是通不过编译的,只能写成INT32_MAX。但是对于小数,两个平台上都可以用FLT_MAX以及DBL_MAX宏,但是似乎需要包含#include <cfloat>

    2. 打包压缩包时,未包含build.shbuild_and_run.shCodeCraft_zip.sh三个脚本。这个情况只在复赛正赛时遇到过。

  3. 爆内存

    1. 使用了嵌套的vector

      vector要求其存储的内容必须是连续的,因此如果有嵌套的vector,会占用大量的内存以保证其连续性:

      struct A
      {
          vector<int> val_1;
          vector<int> val_2;
          vector<int> val_3;
      };
      
      vector<A> vec;

      解决方案是在外部的vector中只存储指针:vector<A*> vec;

    2. 程序陷入了死循环,购买了无穷多的服务器。

  4. Rb tree 相关,vector 相关,queue 相关

    1. while循环的有个分支没有写continue,导致后面出现了数组越界。(线下的 debug 环境中程序正常,但是单独运行程序时出错,线上也会出错)

    2. 在销毁queue对象时出现某个vector对象释放内存错误,某个对象被delete了两次。这个 bug 其实是在其它函数中数组越界造成的,内存早就被写坏了。当时把所有的[]都换成了at()才排查出来。

    3. 出现红黑树相关库的错误:要么是迭代器失效,要么是创建迭代器时find()返回了end(),但是仍然使用了这个迭代器。

  5. 线上的成本比线下多了许多

    1. int型在 windows 和 linux 上长度不一致导致的回绕

    2. 有一个变量忘了加static修饰,从而使用了未初始化的变量,使得线上和线下的结果不同

学到的知识

这里记录学到的 C++ 基础知识。

  1. 字符串与 IO

    1. C++ 的输入输出流通常是将内容放入某个string对象中:

      string strbuf;
      getline(cin, strbuf);

      这样可以从标准输入读取一行的内容,不包含换行符。

      从文件中读取一行内容:

      #include <fstream>
      #include <string>
      using namespace std;
      
      int main()
      {
          ifstream ifs;
          ifs.open("./text.txt", ios::in);
      
          string strbuf;
          getline(ifs, strbuf);
      
          // do something ...
      
          ifs.close();
          
          return 0;
      }
    2. 字符串与数字之间的转换

      string对象转换成数字:

      int num_int = stoi(strbuf);
    3. 重定向

      File *f = freopen(path, "r", cin);可以将文件重定向到标准输入,这样就不用再对读文件写一套代码,对标准输入写一套代码了。

      同理freopen(path, "w", cout);可以将文件重定向到标准输出,这样我们把内容写入到标准输出,就会自动写入文件。

    4. 据说scanf()pritnf()cincout要快,但是没有实验过。

    5. scanf()遇到空格或换行就会返回。因此不能这样读数据:scanf("%d %d %d", &var_1, &var_2, &var_3);,只能分成三次读。

  2. 全局变量的使用

    在某个头文件(比如test.h)中声明全局变量:

    #include <vector>
    using namespace std;
    
    extern int global_var;
    extern vector<int> vec;

    然后在这个头文件所对应的 cpp 文件(比如test.cpp)中定义全局变量:

    #include "test.h"
    
    int global_var = 0;
    vector<int> vec;

    最后在需要用到这个全局变量的地方包含头文件就可以了:

    // main.cpp
    #include "test.h"
    #include <iostream>
    using namespace std;
    
    int main()
    {
        ++global_var;
        vec.push_back(3);
        vec.push_back(4);
    
        cout << global_var << endl;
        cout << vec[0] << endl;
    
        return 0;
    }
    1. 有关拷贝构造函数

      VMTypeNode(const VMTypeNode &obj) {type = obj.type; node = obj.node;}
      void operator= (VMTypeNode const &obj) {type = obj.type; node = obj.node;}  // 似乎必须要加上 const 才可以
    2. 如果类中的成员有vector之类的容器,或嵌套的vector容器,当我们拷贝一个对象时,会对这些容器自动赋值吗?如果我们自己写了拷贝构造函数,但只实现了一部分,编译器会对剩下的部分自动赋值吗?(似乎不会)

  3. STL 容器与算法

    1. 如果容器存储的是对象,那么容器在clear()erase()时会自动调用对象的析构函数。如果窗口中存储的是指针,那么容器在clear()erase()时并不会自动delete指针。

    2. 可以用引用来初始化容器(比如vectorunordered_map)中的元素。此时会调用拷贝构造函数。

    3. 如果自己实现了拷贝构造函数,那么每个成员变量都需要指定怎样初始化。对于未指定的成员变量,编译器不会自动赋值。

    4. mapset使用greater()作为比较大小的方法

      int main()
      {
          map<int, int, greater<int>()) m;
          set<pair<int, int>, greater<pair<int, int>>()) s;
          return 0;
      }

      此时s.begin()取得的是最大值的迭代器,s.rbegin()取得的是最小值的迭代器。

    5. lower_boundupper_bound

      1. c++ 不能保证 lower bound 一定比 upper bound 小。所以还需要自己手动比较一下值。

      2. lower_bound(n)返回的迭代器一定大于等于nupper_bound(n)返回的迭代器一定小于n。若拿greater()作为模板参数,那么lower_bound(n)返回的迭代器是小于等于n的,而upper_bound(n)返回的迭代器是大于n的。

    6. 有关映射的问题。

      比如我想将一个容器中的元素{3, 5, 8, 4}映射为{5, 8, 4, 3},这个操作不能在一个循环中完成,因为有可能出现循环映射的情况。例如将 3 映射为 5 后,变成{5, 5, 8, 4},接下来再将 5 映射为 8:{8, 8, 8, 4},这样就出错了。正确的实现有两种方法,要么是创建一个空容器副本,然后按映射关系依次填入;要么先在一个循环里将需要映射的旧元素删除,然后再在另一个循环里将需要映射的新元素写入。

    7. set是靠a < ba > b来判断两个元素是否相等的,因此如果我们自定义比较方法,即使两个元素的实际值不一样,但在我们的比较函数中得出两个元素一样的结论,set仍然会删除一个元素。或许可以用multiset解决这个问题,但是它需要查找特定元素,很慢很慢。

    8. emplace()emplace_back()无法做类型检查,只有在编译时才能发现错误。insert()push_back()可以做类型检查。

    9. set()中可以插入pair,默认按first的大小排序。

    10. multiset加嵌套的pair是个神奇的组合,可以解决很多问题。对于不需要按键查找,只需要插入和删除的情况很适用。

    11. 如果对一个容器又是删除,又是添加,比如修改一个set中的元素,但是set中的元素都是const类型,所以只能先删除,再添加,那么最好不要在一个循环中完成,而是创建一个set的 copy,然后把set清空,最后根据 copy,选择性地往set中添加内容。

    12. 如果要想自定义有序容器中元素的比较,有两种方法

      1. 使用函数

        bool comp(int v1, int v2)
        {
            if (v1 > v2)
                return true;
            else
                return false;
        }
        
        
        int main()
        {
            set<int, bool (*)(int, int)> s({5, 3, 2, 1, 4}, comp);
        
            for (auto iter = s.begin(); iter != s.end(); ++iter)
            {
                cout << *iter << endl;
            }
            return 0;
        }
      2. 使用类的()运算符重载

        struct comp
        {
            bool operator()(int v1, int v2)
            {
                if (v1 > v2)
                    return true;
                else
                    return false;
            }
        };
        
        
        int main()
        {
            set<int, comp> s({5, 3, 2, 1, 4});
        
            for (auto iter = s.begin(); iter != s.end(); ++iter)
            {
                cout << *iter << endl;
            }
            return 0;
        }

        输出:

        5
        4
        3
        2
        1
        
  4. 迭代器

    1. 迭代器失效的问题

      在容器中删除一个元素时,迭代器会失效。如果是for循环,解决办法是这样:

      set<int> s({4, 2, 3, 5, 1});
      
      for (auto &iter = s.begin(); iter != s.end(); )
      {
          if (condition)  // need delete an element
              del(iter++);  // 传入一个迭代器的副本,然后让本迭代器递增
          else
              ++iter;
      }

      在这种方法中,失效的是迭代器的副本,而本迭代器不会失效。

      一些erase()函数会返回下一个有效的迭代器:

      set<int> s({4, 2, 3, 5, 1});
      
      auto iter = s.begin();
      while (iter != s.end())
      {
          if (*iter % 2 == 0)
              iter = s.erase(iter);
          else
              ++iter;
      }

      可惜这两种方法都只适用于只删不增的操作。需要注意的是,有时迭代器失效并不是发生在本函数中,而是发生在本函数调用的其它函数中,这种隐蔽的错误很难察觉。

      如果我们既想删,又想增,那么最好构建一个容器的副本,对副本进行删除迭代,将原容器清空后,进行插入操作。

      range-for 隐藏的迭代器失效更危险,因为没法手动iter++

    2. next(iter, n)会返回一个iter递增n次后的副本advance(iter, n)会对iter本身递增ndistance(iter1, iter2)可以返回两个迭代器之间的步数。

    3. 关于反向迭代器

      有关反向迭代器,base()以及正向迭代器的关系,可以参考这个链接:

      int main()
      {
          set<int> s({5, 3, 2, 1, 4});
          set<int>::reverse_iterator rit(++s.find(4));
          for (auto iter = rit; iter != s.rend(); ++iter)
          {
              cout << *iter << endl;
          }
          return 0;
      }

      输出:

      4
      3
      2
      1
      
  5. 匿名对象不能作为实参传递给接收引用的函数。

一些思考

  1. 对于固定的输入输出格式,请不要再造一套通用的字符串处理函数。若已知字符串的最大长度,请尽量使用 buffer,不要使用 string。

    对于特定的问题,我们只要写出来一个特定解就好,这样的效率最高,也容易写出来。对特定的问题写一个通用的解法,显然是吃力不讨好的行为。

  2. 不要光想着代码和思路的优美,不要对付假想的问题。要实际分析数据,提出问题,做出假设,实施方案,最后检查假设的正确性。这样才是解决实际问题的思路。

  3. 何时使用vector

    1. 需要按下标位置对数据进行索引。

    2. 出现重复的元素。

    3. 不需要删除和查询。

  4. 在不引发歧义的情况下,变量名越短越好。这样敲代码的速度才能变快。

    比如之前定义了单个服务器的状态为ServerStat类型,所有服务器的状态为ServerStatList类型,后来发现,不如定义为ServServs更方便。但是也懒得改了。

  5. 不要一上来就写class,先写数据 + 函数的形式,觉得差不多该把这些数据和函数封装到一起了,再写classstruct的话,需要的时候就写,需要什么功能了再往里面添加成员方法。

  6. C 语言的标准库似乎只能获得精确到秒的时间,C++ 的标准库可以获得毫秒、微秒级的时间,但是写起来挺复杂难用。如果不要求绝对时间的话,使用clock()获取一个 CPU 计数,简单分析一下就可以。

  7. 如果一些状态或查询表在整个程序的生命周期中都存在,并且要在各处都用到,那么就可以把它作为一个全局变量。这样一来,函数的参数就会少写很多。

  8. 请首先观察现象,然后分析问题,最后再解决问题。不要尝试解决停留在想象中的“虚拟”问题。

  9. 不要尝试一次性给出完美的解决方案,如果现有的方案是可以改进的,那么就是值得感激的。

  10. 首先要复述题目,明白我们要干什么,然后再行动。我们常常因为对自己的解题方法过于自信而放弃读题,自以为是。但是无论解决什么问题,都是从阅读题目开始的。

  11. unordered_mapmap要慎用[],多用find()。在维护状态表时,要定点对状态表进行复查。不要对自己的逻辑过于自信。

  12. 数据结构非常重要。单个数据结构 map,set,vector 等都很简单,好理解。但是它们的组合,以及它们存放的内容,会有多种效果,千变万化。在选择数据结构之前,要想好自己是需要查找功能,还是需要排序和查找,还是需要删除和添加,还是只需要遍历就好。键、值是否唯一,是否需要自动创建数据结构,还是只用 pair 就好,这些也很重要。

感想

我们的队伍是just try 一下。从玉兰花开到杨花落尽,不知不觉参加这个比赛已经一个多月了,最终拿到了复赛的第 6 名,虽然没有进入决赛略有遗憾,但是整个成长的过程让我获益匪浅,所有的经历我都终生难忘。

看着最初的几个版本,代码简陋得像幅简笔画,感慨良多。在后续的迭代过程中,糟粕的观念和代码片段逐渐被抛弃,先进的数据结构和算法逐渐被引入,代码效率逐渐高起来,结构逐渐健壮起来,就好像一个跌跌撞撞不会走路的小孩,逐渐长成了健壮有力的青年。

在迭代到第 4 版的时候,选择使用vector存储服务器状态和虚拟机状态的弊端逐渐暴露无遗,积重难返,而且还有服务器 id 顺序不对的问题,怎么都映射不过来,第 5 版无论如何都写不出来,当时真的想放弃了。经过一晚上调试都失败了后,给队友说了这件事,队友说问题不大,辛苦了,好好休息。然后我就把这件事放了下来,在床上躺了几天。

离初赛正式赛还剩两三天的时候,突然就失眠了一个晚上,怎么想都觉得不甘心。第二天早上一大早就到了实验室,疯狂地敲键盘,把整个底层结构又重写了一遍。在迭代版本中可以看到,第 4 版的代码还到处都是vector,第 5 版已经重写了一部分了,大体的框架初具雏形,第 6 版基本上已经是脱胎换骨,眼神也变得坚定起来。从第 6 版开始,整个底层结构变成了以服务器为主导,挂载虚拟机的形式,再也没有vector低效率的问题,映射服务器的 id 也变得非常容易了。第 6 版的框架直到最后一个迭代版本都在继续使用。

第 9 版已经非常明朗了,其实这就是遍历 + best fitting 的一个最终实现。凭借它可以实现还可以的效果。但是因为有遍历的过程,所以速度无论如何都提不上来,初赛正式赛时总会超时。为了解决超时这个问题,在第 10 版中引入了unordered_map,第 11 版引入了map,才勉强拿到了初赛的第 27 名,挤入了复赛。

在复赛的准备期间,尝试了线性规划,尝试了递归搜索,尝试了精确匹配,尝试了诸多算法,但是效果都不好。直至后来开始分析数据集,分析每天服务器的状态,才找到问题所在:我根本就没有在解决实际遇到的问题,我只是在解决自己想象出来的虚拟的问题。我总是在尝试如何给出完美、精确的解决方案,但是这种想法从一开始就是错的,实际上如果一个问题是可以改进的,那么就是值得感激的,即使改进后的解决方案并不完美。后来根据分析数据得到的灵感,对选型算法和迁移算法进行了改进,效果终于好了起来。

复赛正式赛的时候,第一次提交我们直接拿到了排行榜的第二,没想到改进的算法效果如此之好。但是后来排名逐渐掉了下去,我们努力地调参,但实际上所有的参数本来就已经是最优的了,效果越调越差。直到最后都没有突破我们第一次提交的最好成绩。

后来第一名的队伍上台总结经验的时候,说到需求变更的问题时,才猛然想起,我们队根本没有看复赛正赛的任务需求变更书——上面写了可以有一天不限制迁移次数。我一下就愣住了,好像被闪电击中。没想到并没有败在算法上,而是败在了没读题上。后来榜单再次放出,排名超过我们的,基本都是把迁移次数加上去的。队友苦笑道:我命由天不由我。

在比赛那天的早上,我忽然想到,其实服务器当前剩余资源的状态是动态变化的。之前是每天统计一次,如果把改变成实时变化的,会不会在选型时效果更好一点?怀着这样的想法,以为在正式赛时 3 个小时可以调试出来,但是刚开始就遇到了编译错误的问题,调了半个小时,发现是提交时少了几个脚本。之后就有点慌了,也没有完全把这个实现出来。

现在在复盘时,完善了这个动态变化的参数,增加了某天的迁移次数,估算着大概也就 1553017630 左右的成本,这样的成绩也只能从第 6 名前进到第 5 名,可能这就是当前我们队的最高水平了吧,即使没有进入前 4,心中也没有什么遗憾了。

整个比赛过程中,把 STL 里的容器和算法几乎用了个遍,现在对各种容器的选择有了更深刻的理解。所谓的程序 = 算法 + 数据结构,贯穿了整个迭代过程。

随着这份总结的尾声,差不多能放下这个比赛了。感谢队友的鼓励,感谢自己的努力。希望以后能做得更好。

just try 一下

2021.4.15

huawei2021's People

Contributors

b5paper avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.