73
社区成员
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 30 | 30 |
· Estimate | · 估计这个任务需要多少时间 | 30 | 30 |
Development | 开发 | 1590 | 1880 |
· Analysis | · 需求分析 (包括学习新技术) | 120 | 180 |
· Design Spec | · 生成设计文档 | 60 | 60 |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 30 |
· Design | · 具体设计 | 120 | 180 |
· Coding | · 具体编码 | 600 | 800 |
· Code Review | · 代码复审 | 300 | 200 |
· Test | · 测试(自我测试,修改代码,提交修改) | 300 | 400 |
Reporting | 报告 | 300 | 270 |
· Test Report | · 测试报告 | 120 | 180 |
· Size Measurement | · 计算工作量 | 60 | 30 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 120 | 60 |
合计 | 1920 | 2180 |
Information Hiding就是面向对象编程中的封装,通过将类的部分属性私有化,并对外提供具有一定规则的可以被外界访问属性的方法,如此外部只能通过设定好的规则对对象的属性进行操作。可以减少耦合、保护对象数据的安全。
在本次的设计中,core模块中的WordChain类中采用private关键字将属性words、headMap、tailMap隐藏,并对外提供append()方法来向words中添加单词同时将修改headMap和tailMap,contains(char c)方法来判断单词链中是否已经出现以c开头或结束的单词。这部分的属性是程序运行的关键部分,其中属性不能由调用者修改,而是通过内部的规则来修改headMap和tailMap,防止外部修改导致程序出错。
class WordChain {
private:
vector<Word> words;
bool headMap[26];
bool tailMap[26];
int letterNum = 0;
public:
WordChain();
WordChain(const vector<string>& words);
void insertHead(Word word);
void append(Word word);
void append(WordChain wordChain);
vector<Word> getWords();
WordChain *add(Word word);
char getHead();
char getTail();
bool contains(char c);
int getWordNum();
int getLetterNum() const;
string toString();
};
本次的设计中core.dll模块的API接口采用本来是如下的三个接口,但由于项目开始时只考虑到自己书写方便,没有考虑到与其他小组的交换问题,导致接口的普适性非常低,无法与其他小组进行交换活动。
std::vector<std::string> getAllChains(const std::set<std::string>& words);
WordChain getMaxWordChain(const std::set<std::string>& words, char h, char t, char j, bool allowCircle);
WordChain getMaxLetterChain(const std::set<std::string>& words, char h, char t, char j, bool allowCircle);
后来,为了与其他小组进行更好的交换行动,又在上面的三个接口外面封装了一层,变成了课程组推荐的接口。
int gen_chains_all(char* words[], int len, char* result[]);
int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop);
int gen_chain_char(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop);
等到真正与其他小组进行交换的时候才意识到一个好的接口带来的模块易用性的改变。对于原来的接口而言,需要引入vector.h、string.h和WordChain.h三个头文件,其中WordChain.h还是自己定义的。这样在调用core.dll的时候不仅需要core.h也需要WordChain.h,造成使用上的不便。
“高内聚、松耦合”是一个非常重要的通用的设计思想,以有效提高代码的可读性和可维护性。 在调用方和被调用方约定好接口和数据交互之后,双方在遵守接口的协议的条件下,可以修改内部的实现方法而不需要改变接口和数据的交互。
在本次的设计中,通过将核心计算模块与其他模块解耦,将核心计算模块分离出来。其他模块负责参数处理与单词读入处理,然后调用核心计算模块core.dll的接口调用对应的功能,同时不需要关注核心计算模块的实现细节。核心模块只需要负责计算并按照接口约定将结果返回。
core.dll模块接口约定如下:
int gen_chains_all(char* words[], int len, char* result[]);
int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop);
int gen_chain_char(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop);
core
接口模块:用于封装调用Compute
类计算方法的接口。为了便于与其他组交换,我们使用了课程组建议的3个接口。
int gen_chains_all(char* words[], int len, char* result[])
:用于找出words
中len
个单词所能构成的所有单词链,保存在result
里,返回找到的单词链数量;int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop)
:用于找出words
中len
个单词所能构成的单词最多的单词链,保存在result
里,返回单词数量;int gen_chain_char(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop)
:用于找出words
中len
个单词所能构成的字符最多的单词链,保存在result
里,返回单词数量;Compute
类:核心计算模块,是一个以字母为节点,单词Word
为边的图,包含若干SCComponent
。用于检测单词环、查找最长单词链等核心功能。
bool hasCircle()
:检查是否有单词链成环;vector<string> getAllChains()
:获得所有单词链(每个单词链一个string);WordChain getDiameterWithCircle(int lengthType)
:获得最长的单词链,参数可选择单词数最多或长度最大;WordChain getLongestPathWithCirCleTo(char tail, int lengthType)
:获得最长的单词链,指定结尾字母为tail
,参数可选择单词数最多或长度最大;WordChain getLongestPathWithCirCleFrom(char head, int lengthType)
:获得最长的单词链,指定开头字母为head
,参数可选择单词数最多或长度最大;WordChain getLongestPathWithCirCleBetween(char head, char tail, int lengthType)
:获得最长的单词链,指定开头字母为head
,结尾单词为tail
,参数可选择单词数最多或长度最大;SCComponent
类:字母强连通分量类。用于计算强连通分量中任意两字母间最长单词序列。
void addNode(int node)
:添加一个节点(字母)进入SCC;void addEdge(Word word)
:添加一个边(单词)进入SCC;void searchPaths()
:计算任意两节点间的最长路径;vector<int> getNodes()
:获取所有节点;WordChain getMostPathBetween(int from, int to)
:获取from
和to
之间的单词最多的单词链;WordChain getLongestPathBetween(int from, int to)
:获取from
和to
之间的 字符最多的单词链;Word
与WordChain
类:单词和单词序列,用以封装数据结构。注意单词序列并非单词链,只有长度大于1的单词序列才是单词链。
模块的实现关键在于Compute
类与SCComponent
类。
Compute
类对于hasCircle()
与getAllChain()
,我们使用了简单的DFS算法实现。
对于寻找最长单词链的函数实现,我们的优化经历了3个阶段:
使用DFS搜索符合条件的最长单词链:这样做时间复杂度过高,被舍弃;
设计了无环的动态规划算法与有环的动态规划算法:
对于无环的情况:首先将所有字母拓扑排序,然后依次按以下方式更新以字母$i$结尾的最长单词链长度
$$
dp[i]\leftarrow \max_j{dp[j]+e_{ji}}
$$
对于有环的情况首先使用Tarjan
算法找出所有强连通分量。接下来,我们先在强连通分量内部使用DFS搜索任意两点间的最长路径。然后对所有强连通分量进行拓扑排序,按照顺序对每个强连通分量中的节点$i$按以下规则进行动态规划:
首先,更新以字母$i$结尾,并且不包含$i$所在强连通分量其它字母的最长单词序列长度$dp_{in}[i]$,表示从其它SCC进入此SCC的最长单词序列:
$$
dp_{in}[i]\leftarrow \max_j{dp_{out}[j]+e_{ji}}(scc[i]\neq scc[j])
$$
然后,更新以字母$i$结尾,并且包含$i$所在强连通分量其它字母的最长单词序列长度$dp_{out}[i]$,表示从此SCC离开时的最长单词序列:
$$
dp_{out}[i]\leftarrow \max_j{dp_{in}[j]+\max{p_{ji}}}(scc[i]= scc[j])
$$
对应的$rec_{in}$与 $rec_{out}$以相同的逻辑进行更新。
统一使用有环的动态规划算法:由于仅包含一个单词的自环,如alpha,不算成环的单词链,然而从算法的角度来讲,这种情况需要使用有环的算法,所以我们对有无环的情况都统一使用了支持有环的算法。值得注意的是,为了防止在寻找字母最多的单词链时,wooooooooooooorld
这样的单词覆盖了war road
这样的单词链,每次试图更新$dp$与$rec$数组时,也检查新产生的链是否合法且比当前最优结果要好,如果是,即使不更新$dp$与$rec$数组,也更新最优结果。
SCComponent
类SCCompoennt
类主要负责给出其中两节点的最长路径,使用DFS从所有节点依次开始遍历SCC,记录沿途的路径。时间复杂度为$O(|V_{scc}|\cdot|E_{scc}|)$。
我们认为我们代码的独到之处在于使用的函数能同时处理带环和不带环的情况,对于长度为1的自环也是如此,从而减少了对特殊情况的判断,使得代码更易于维护。并且,从效率的角度来讲,该算法在无环时时间复杂度会退化为$O(|E|)$,与直接使用特判自环的无环动态规划算法相同。
本次设计中使用Visual Studio 2022自带的性能分析工具进行性能分析。
分析结果如下图,由图可见,核心计算模块Compute::getDiameterCircle占用了程序运行的大部分时间,约80%。
该计算模块的功能是寻找允许成环时的最长的单词链。优化前我们使用了DFS搜索了所有合法的单词链,并选出了最长的一个单词链。这种方法的时间复杂度是$O(|V|\cdot|E|)$。我们通过抽取强连通分量的方法,将若干互相可以通过若干单词连接的字母合并为一个强连通分量。接下来,我们先在强连通分量内部使用DFS搜索任意两点间的最长路径,再对所有强连通分量进行拓扑排序。按照排序结果,对每个强连通分量,对其中字母$i$按以下规则进行动态规划:
首先,更新以字母$i$结尾,并且不包含$i$所在强连通分量其它字母的最长单词序列长度$dp_{in}[i]$,表示从其它SCC进入此SCC的最长单词序列:
$$
dp_{in}[i]\leftarrow \max_j{dp_{out}[j]+e_{ji}}(scc[i]\neq scc[j])
$$
然后,更新以字母$i$结尾,并且包含$i$所在强连通分量其它字母的最长单词序列长度$dp_{out}[i]$,表示从此SCC离开时的最长单词序列:
$$
dp_{out}[i]\leftarrow \max_j{dp_{in}[j]+\max{p_{ji}}}(scc[i]= scc[j])
$$
对应的$rec_{in}$与 $rec_{out}$以相同的逻辑进行更新。优化后,最优的情况是无环时,时间复杂度为$O(|E|)$;最坏的情况是只有一个SCC,时间复杂度为$O(|V|\cdot|E|)$,退化为原来的情况。
经过优化后的分析结果如下图,图中显示核心计算模块Compute::getDiameterCircle
占用时间已经下降到了70%。
Design by Contract是契约式编程,是一种软件设计方法。契约式编程要求软件设计者为软件组件定义正式的,精确的并且可验证的接口。契约式编程强调三个概念:前置条件、后置条件和不变式。
优点:调用者必须提供正确的参数,被调用者必须保证正确的结果和调用者要求的不变性。双方都有必须履行的义务,也有使用的权利,这样就保证了双方代码的质量,提高了软件工程的效率和质量。
缺点:对于程序语言有一定的要求,契约式编程需要一种机制来验证契约的成立与否。此外,契约式编程并未被标准化,因此项目之间的定义和修改各不一样,给代码造成很大混乱。
在本次设计中,我们约定了core.dll的四个API接口的前置条件、后置条件和不变式。不过,为了交流的方便,我们并没有采取形式化的语言表述,只是口头约定。
参数 | 意义 |
---|---|
words | 传入单词,每一个元素为char*类型 |
len | 传入单词长度 |
results | 保存计算结果,调用者需要开辟20000个char*类型的指针,同时需要释放返回值数量的空间 |
head | 传入单词链首字母,head = 0为不指定 |
tail | 传入单词链尾字母,tail = 0为不指定 |
reject | 传入单词链不允许出现的首字母,reject = 0为不指定 |
enable_loop | 是否允许存在环 |
返回值 | result数组长度 |
异常 | 1.输入单词存在非法环 2.输入单词数量len <= 0 3.输入的head、tail、reject字母不合规 |
Code Contract是微软推出的一款代码契约插件,插件采用前置条件、后置条件和对象不变量的形式,对 .NET 编程提供运行时检查、静态检查和文档生成。
Code Contract是Design by Contract具体实现的一款插件,优缺点同Design by Contract。
本次设计中,并未采用这款插件。
我们使用Google的gtest作为单元测试的模板,其中一个测试样例代码如下:
//-w -h h -t g -j a -r无
TEST(core_test, gen_chain_word4) {
int len = 11;
const char *words[] = {"apple", "alph", "dog", "hand", "group", "element", "than", "apple", "eir", "nop", "moon"};
int res_num = 2;
const char *result[] = {"hand", "dog"};
char **_result = new char *[SIZE];
int _num = gen_chain_word((char **)words, len, _result, 'h', 'g', 'a', false);
EXPECT_EQ(res_num, _num);
for (int i = 0; i < res_num; i++) {
EXPECT_STREQ(result[i], _result[i]);
free(_result[i]);
}
free(_result);
}
我们针对代码设计了24个测试样例,下图为测试全部通过的截图:
覆盖率结果如图:
当输入的单词中存在非法环时,会在经过判断后抛出如下异常:
// allowCircle 为题目中的-r参数
if (!allowCircle && compute.hasCircle()) {
throw logic_error("The words contain a circle.");
}
其中对单词链中是否存在环的判断是在compute中的hasCircle()中进行的,采用dfs的方法,判断代码如下:
bool Compute::hasCircle() {
for (int i = 0; i < 26; i++) {
for (Word &word : this->wordsMap.at(i)) {
if (!word.checkCircleVisited) {
int headLetter[26] = {0};
if (hasCircle(headLetter, word)) {
return true;
}
}
}
}
return false;
}
bool Compute::hasCircle(int headLetter[], Word &word) {
headLetter[word.getHeadLetter() - 'a'] += 1;
for (Word &w : this->wordsMap.at(word.getTailLetter() - 'a')) {
if (word.str == w.str || word.checkCircleVisited) {
continue;
}
if (headLetter[w.getTailLetter() - 'a'] != 0) {
return true;
}
if (hasCircle(headLetter, w)) {
return true;
}
}
headLetter[word.getHeadLetter() - 'a'] -= 1;
word.checkCircleVisited = true;
return false;
}
针对性的测试样例如下:
TEST(core_test, exception2) {
int len = 4;
const char *words[] = {"ab", "bc", "cd", "da"};
char **_result = new char * [SIZE];
try {
gen_chain_word((char **) words, len, _result, 0, 0, 0, false);
} catch (const std::exception &e) {
EXPECT_STREQ(e.what(), "The words contain a circle.");
}
}
当调用者传入的单词数量len <= 0时,会抛出如下异常:
if (len <= 0) {
throw logic_error("Please ensure the words length is greater than zero.");
}
针对性的测试样例如下:
TEST(core_test, exception6) {
int len = 0;
const char **words = nullptr;
char **_result = new char * [SIZE];
try {
gen_chain_char((char **) words, len, _result, 0, 0, 0, true);
} catch (const std::exception &e) {
EXPECT_STREQ(e.what(), "Please ensure the words length is greater than zero.");
}
}
传入的head、tail、reject字母的值必须是0或a-z或A-Z,当出现非以上情况时,抛出异常:
void checkLetter(char head, char tail, char reject) {
if (!isalpha(head) && head != 0) {
throw logic_error("The head letter need to be a-z or A-Z.");
}
if (!isalpha(tail) && tail != 0) {
throw logic_error("The tail letter need to be a-z or A-Z.");
}
if (!isalpha(reject) && reject != 0) {
throw logic_error("The reject letter need to be a-z or A-Z.");
}
}
针对性的测试样例如下:
TEST(core_test, exception3) {
int len = 4;
const char *words[] = {"ab", "bc", "cd", "da"};
char **_result = new char * [SIZE];
try {
gen_chain_word((char **) words, len, _result, 12, 0, 0, false);
} catch (const std::exception &e) {
EXPECT_STREQ(e.what(), "The head letter need to be a-z or A-Z.");
}
}
界面模块采用QT5为框架,通过Desinger可视化布置界面。界面模块主要被划分为两个部分,分别是是参数区和输入输出区。
参数区分为功能型参数选择和附加型参数选择。其中功能型参数选择采用的是互斥的radioButton进行选择,保证同时只有一个功能型参数可以被选中。附加型参数区采用的是非互斥的radioButton,可以选择任意数量的附加型参数,其中-h、-t、-j参数后采用comboBox下拉菜单选择对应的字母。
输入区和输出区具有相同的设计,分别包含一个textEdit文本编辑框,和一个pushButton文件IO。pushButton与事件采用管道进行绑定,信号为clicked()。点击按钮后,进行文件选择,从文件输入或输出到文件。从文件输入的结果好显示在输入区的textEdit,计算的结果显示在输出区的textEdit。
connect(ui->pushButton_2, SIGNAL(clicked()), this, SLOT(loadFromFile()));
connect(ui->pushButton_3, SIGNAL(clicked()), this, SLOT(saveToFile()));
最终界面如下图:
GUI通过LoadLibraryA("core.dll")导入core.dll库,将获取到的参数通过约定的API接口调用core.dll。需要注意计算结束的时候释放导入的库和core.dll中申请的空间。
HMODULE core = LoadLibraryA("core.dll");
int ret;
char **result = new char * [1000];
try {
if (n) {
ret = gen_chains_all(words, words_len, result);
} else if (w) {
ret = gen_chain_word(words, words_len, result, h_char, t_char, j_char, r);
} else if (c) {
ret = gen_chain_char(words, words_len, result, h_char, t_char, j_char, r);
}
} catch (exception &e) {
QMessageBox::warning(this, "Warning", e.what());
return;
}
结对编程采用线上+线下的方式,线上采用的是腾讯会议,线下通常去的地点是H座10楼的wings。
考虑到任务量,仅核心模块是采用结对的方式实现的,其他部分是约定分工个人完成,然后提交pr给另一人审核。
优点:
缺点:
实际花费时间见2中PSP表格,表格记录了预估花费时间和实际花费时间。
我们组与另一小组进行了GUI、测试模块与核心模块的互换工作。
另一小组成员:
这次的交换活动主要遇到两个问题。