1 项目信息
- 教学班级:周五班
- 项目地址:https://github.com/GrapeLemonade/two-thirds-of-icpc.git
2 预计耗时
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务需要多少时间 | 200 | |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 1200 | |
· Design Spec | · 生成设计文档 | 120 | |
· Design Review | · 设计复审 (和同事审核设计文档) | 20 | |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 5 | |
· Design | · 具体设计 | 150 | |
· Coding | · 具体编码 | 1200 | |
· Code Review | · 代码复审 | 20 | |
· Test | · 测试(自我测试,修改代码,提交修改) | 600 | |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 90 | |
· Size Measurement | · 计算工作量 | 60 | |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 20 | |
合计 | 3685 |
3 方法论的运用
Information Hiding
我们在头文件中向外提供符号时,特地只包含了我们需要向外提供的函数。
由于我们采用了面向过程的设计风格,我们并没有运用 private
等访问控制方法做进一步的信息隐藏。
Interface Design
由于多样化的需求,我们一共提供了三套接口:
- 课程组约定的接口:
EXPOSED_FUNCTION int gen_chain_word(const char* words[], int len, char* result[], char head, char tail, bool enable_loop); EXPOSED_FUNCTION int gen_chains_all(const char* words[], int len, char* result[]); EXPOSED_FUNCTION int gen_chain_word_unique(const char* words[], int len, char* result[]); EXPOSED_FUNCTION int gen_chain_char(const char* words[], int len, char* result[], char head, char tail, bool enable_loop);
- 面向 CLI 的接口:
EXPOSED_FUNCTION int engine( const char* words[], int len, char* result[], char head, char tail, bool count, bool weighted, bool enable_self_loop, bool enable_ring);
- 面向 GUI 的接口:
EXPOSED_FUNCTION const char* gui_engine( const char* input, int type, char head, char tail, bool weighted);
Loose Coupling
- 课程组提供的接口这里我们提供了原样的实现,由于并不是我们设计的接口,在此不评价其耦合度。
- 面向 CLI 的接口脱胎于课程组接口,其初衷是用一个函数管理四种模式,并方便命令行程序调用(直接传入各 flag)。由于其面向性较强,耦合度不可避免地相对较高。
- 由于 GUI 模块与 Core 模块所属生态差异较大,因此必须做到尽量松的解耦。从接口的设计上体现这一点的是单词文本的输入方法和结果的返回方法。我们将输入、输出格式统一成了单一的字符串,方便在不同生态之间进行对接、交互。对于异常情况,此接口也要求在计算模块内部捕获异常,以文本形式返回信息,以尽可能地降低耦合度。
4 接口设计与实现
首先对所有单词去重,因为题目中要求同一个单词只能使用一次。
接着建立一个 26 26 26 个节点的有向图,每个节点代表一个字母。对于每个单词,设其首字母和尾字母分别为 h h h 和 t t t,添加一条有向边 s → t s \to t s→t。该有向图的每个长度大于 2 2 2 的 Trail (不能经过同一条边的路径) 均对应一个单词链。
我们定义 exDAG 为这样的有向图:每个点至多有一条指向自己的边,且去除所有自环后,图是 DAG。
对于七种参数,大致可以将其分为如下四种类型:
-n
,仅能单独使用,计算所有 Trail,有向图必须是 exDAG。-m
,仅能单独使用,不考虑所有自环计算最长 Trail,每条边边权为 1 1 1,有向图必须是 exDAG。-w
,计算最长 Trail,每条边边权为 1 1 1,可以搭配如下三种参数使用:-h a
,要求对应 Trail 的起点必须是字母a
。-t b
,要求对应 Trail 的终点必须是字母b
。-r
,若没有该参数则有向图必须是 exDAG。
-c
,计算最长 Trail,每条边边权为单词长度,同样可以搭配上述三种参数使用。
我们实现了一个函数 int engine(const char* words[], int len, char* result[], char head, char tail, int type, bool weighted)
用于处理上述四种类型:
words
是传入每个单词的指针数组。len
是传入单词的数量。result
是存放所求结果的指针数组,同时该函数的返回值即为result
对应数组的长度。head
是对首字母的限制,若无限制为0
。tail
是对尾字母的限制,若无限制为0
。type
表示是上述哪种类型。weighted
表示每条边的权值是否为单词长度。
该函数首先会调用 void init_words(const char* words[], int len)
,该函数将每个单词转化为对应的边。
接着会调用 void get_SCC()
,该函数利用 Tarjan 算法求出有向图的每个强连通分量。
然后,如果要求有向图是 exDAG,会调用 void check_loop()
,该函数先判断是否有一个点存在多于一个的自环,再判断是否存在一个强连通分量有多于两个点。若是,则抛出异常,该异常也会给出一个存在的单词环。
最后,根据要求类型的不同调用不同函数:
-
若要求输出所有的单词链,则调用
int get_all(char* result[])
,该函数通过枚举起点,DFS 找到所有单词链,若单词链数量超过 20000 20000 20000,则会抛出异常。 -
若有向图是 exDAG,则调用
int get_max_DAG(char* result[], char head, char tail, bool enable_self_loop, bool weighted)
。该函数的本质是在 DAG 上 dp 求最长路,这是一个很经典的问题,设 f i f_i fi 是以节点为 i i i 起点的最长路长度,对于一条边起点为 i i i、终点为 j j j、权值为 w w w 的边 e e e,通过逆序枚举拓扑序 (已经通过 Tarjan 算法求出),转移方程为:
f i ← f j + w f_i \gets f_{j} + w fi←fj+w
但该函数具体实现仍有许多改动的细节,列举如下:- 如果限定了终点 i i i,对于所有 j ≠ i j \ne i j=i, f j f_j fj 初始化为 − ∞ -\infty −∞, f i f_i fi 初始化为 0 0 0;否则均初始化为 0 0 0。
- 计算最大值的同时需要记录方案。
- 如果
enable_self_loop
为真,算完每个点 f i f_i fi 后要将其权值加到上面,同样需要改动方案的输出。 - 因为最终要求路径长度大于 2 2 2,最终统计答案时需要枚举第一条边 ( i → j i \to j i→j),且如果限制了起点需要保证其合法性,接着将这条边的权值 + f j +f_j +fj 作为答案,此外如果枚举的第一条边是自环还要进行特判。
-
若有向图不要求为 exDAG,则调用
int get_max(char* result[], char head, char tail, bool weighted)
。该函数的本质是状压 DP 求解一般有向图的最长 Trail。众所周知,一般有向图的最长 Trail 是一个 NP 问题,为保证正确性,我们没有采取近似算法。具体来说,设 f S , i f_{S,i} fS,i 是已使用的边集为 S S S,所在点为 i i i,以此为起始状态能经过的最长路长度,对于一条边起点为 i i i、终点为 j j j、权值为 w w w 的边 e e e,转移为:
f S , i ← f S ∪ e , j + w f_{S,i} \gets f_{S \cup e,j}+w fS,i←fS∪e,j+w
具体实现上,因为最多只有 100 100 100 条边,可以使用__int128
存储 S S S,但是我没有找到在 Visual Studio 中使用__int128
的方法,因只需进行位运算,使用pair<long long, long long>
代替。接着使用map<pair<pair<long, long>, int>, int>
存储每个 f S , i f_{S,i} fS,i,进行记忆化搜索。该函数具体实现仍有许多改动的细节,均类似于函数
get_max_DAG
,在此不进行赘述。
engine
函数并不对外暴露,为方便和 CLI 与 GUI 交互,还对其进行了封装。
为和另一组的 GUI 进行交互,我们封装了四个函数,它们的内部逻辑均为调用 engine
函数:
int gen_chain_word(const char* words[], int len, char* result[], char head, char tail, bool enable_loop)
int gen_chains_all(const char* words[], int len, char* result[])
int gen_chain_word_unique(const char* words[], int len, char* result[])
int gen_chain_char(const char* words[], int len, char* result[], char head, char tail, bool enable_loop)
为和我们的 CLI 进行交互,同时方便 CLI 读取选项,对 engine
函数重载为 int engine(const char* words[], int len, char* result[], char head, char tail, bool count, bool weighted, bool enable_self_loop, bool enable_ring)
,其内部逻辑也为调用原始 engine
函数。
为和我们的 GUI 进行交互,将 engine
函数封装为 const char* gui_engine(const char* input, int type, char head, char tail, bool weighted)
,其内部逻辑同样为调用原始 engine
函数。
这六个函数都被封装到 core.dll
。
5 计算模块 UML
由于我们的设计是面向过程的,这里展示的是我们计算模块的调用图。
6 性能改进
DAG 部分已经是线性的复杂度了,无法进一步优化复杂度。
非 DAG 部分,作为 NP 问题,在保证正确性的前提下,仅能做对一些特殊数据进行优化:
-
如果当前点存在自环,优先走完全部自环。
-
如果存在重边,只走权值最大的那条。
-
S S S 中仅保留和 i i i 属于同一个强连通分量的边,具体实现为当 i i i 和 j j j 不在同一个强连通分量时,转移变为:
f S , i ← f ∅ , j + w i , j f_{S,i} \gets f_{\varnothing,j}+w_{i,j} fS,i←f∅,j+wi,j
这些优化并不会改变该算法的时间复杂度,例如一个 10 10 10 个点的完全有向图,就可以令该算法达到指数级时间复杂度。
对于非构造样例,该算法的效率还是不错的,时间复杂度的瓶颈存在于每个强连通分量的内部,如果单个强连通分量内部的合法路径数量不是很多,可以很快计算出答案。
这里并没有额外花费时间进行更改,因为编码比较晚,所以编码之前已经全部想好。
以 5 5 5 个点的完全有向图为例,性能分析如下:
时间主要花费在 dfs_max
、以及记忆化搜索过程中通过 map
查找上,按照我们的设计,这也是必然的。
7 合约编程
合约编程是形式化地描述接口以指导编程的一类编程方法。
其好处是可以精准地指定接口要求,无二义性地指导协作开发。
其缺点是目前技术限制下泛用性一般,且其在团队中将蕴含不菲的培训成本。
我们为节省开发和维护成本,主要采用口头约定的方法约定接口行为,其性价比较高。例如:
8 单元测试结果
计算模块部分单元测试主要有两部分组成:手工样例测试和对拍测试。
手工样例测试
主要是对题目中的样例、以及一些容易出错的小样例进行测试,下面以对 test_gen_chain_word
这个接口的测试为例。
首先通过函数 test
调用对应的接口,并和答案进行比对:
void test(const char* words[], int len, const char* ans[], int ans_len, char head, char tail, bool enable_loop){
char** result = (char**)malloc(10000);
int out_len = gen_chain_word(words, len, result