软工1816 · 第二次作业 - 个人项目

第二次软工作业

Github提交链接


PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 15 45
· Estimate · 估计这个任务需要多少时间 10 0
Development 开发 120 120
· Analysis · 需求分析 (包括学习新技术) 180 240
· Design Spec · 生成设计文档 30 20
· Design Review · 设计复审 30 20
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 30 10
· Design · 具体设计 60 30
· Coding · 具体编码 90 60
· Code Review · 代码复审 60 120
· Test · 测试(自我测试,修改代码,提交修改) 200 240
Reporting 报告 10 10
· Test Repor · 测试报告 10 10
· Size Measurement · 计算工作量 20 10
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 45 60
|       | 	合计  |910 |995

表格内容分的很细,而自己实际在做的时候脑袋都糊了,可能真正写代码的时间没那么长,但是边写边找资料就把几个时间都混在一起了。大部分的时间都在查博客找资料,虽然头都要炸了,但是也确实了解到了很多新东西。


功能实现思路历程

  • 需求分析:
    给定一个文件input.txt,要求:
  • 统计文件单词总数,并输出词频前10的单词及其频率
  • 统计文件字符数、行数
  • 输出到result.txt文件中
  • 设计实现思路
  • 在之前的学期写过从控制台输入一段英文统计词频,要求大同小异,针对这种字符型和实型的存储要求,首先想到可以用map容器实现,而且其实现方式为红黑二叉树,存储效率不低。针对排序要求想到的是sort函数进行排序,nlogn的效率也不低了。中间的问题在并不能对map进行直接的排序,这里还需要使用vector容器,它能够像容器一样存放各种类型的对象,简单地说,vector是一个能够存放任意类型的动态数组,能够增加和压缩数据。
  • 思考完使用的大致方法后就开始搭框架,这次作业并没有使用到自己写的类,都是通过几个不同的函数实现,分为:
int CountLine(char *filename)//计算行数
int CountWords(char *filename)//计算总的单词数
int CountChars(char *filename)//计算总字符数
void CountWF(char *filename)//计算排序输出词频前十的词
  • 在具体实现的过程发现:map默认按照key进行升序排序,和输入的顺序无关。如果是int/double等数值型为key,那么就按照大小排列;如果是string类型,那么就按照字符串的字典序进行排列,hhh操心的字典序问题就解决了。这时候也发现一个问题:如果百万级的文本都是不同的单词,nlogn也是一个庞大的数字。再看看需求只需要输出前十,那就使用堆排序吧,建立一个小根堆,遍历map容器若value值小于堆顶则入堆,保证堆里的10个value都是最大的,在维护堆的时候只需要维护10个存储单元即log10(堆排序大法好)。基于以上思路写出了第一个版本。

性能分析

  • 测试输出结果:
  • 测试文档字符数为400w+,在VS菜单->分析->性能探查器直接对程序进行分析,对于初代版本的性能分析图如下:

    其中main函数占用百分99的时间,点击详情可以看到具体是什么代码花费了具体多少时间,单位为毫秒

  • 居然只是一句代码就耗费了整个程序一半的时间
eassy[s]++;

这句代码做的事情可不少。首先eassy是一个map容器,字符串s为key值,当执行这句代码时会在红黑二叉树中查找是否有key值为s的结点,若有其value++,若无开辟一个新结点key值为s,value=1。这样脑阔就疼了,因为只有一句话,根本不知道怎么优化,map内部运行具体机制也不知道,就去网上查“如何增快map的速度”。然后发现了unordered_map。

  • 内部实现机理
  • map: map内部实现了一个红黑树,该结构具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素,因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了map的效率。
  • unordered_map: unordered_map内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的
  • 优缺点以及适用处
  • map
    • 优点:
      有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作
      红黑树,内部实现一个红黑书使得map的很多操作在lgnlgn的时间复杂度下就可以实现,因此效率非常的高
    • 缺点:
      空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点,孩子节点以及红/黑性质,使得每一个节点都占用大量的空间
      适用处,对于那些有顺序要求的问题,用map会更高效一些
  • unordered_map
    • 优点:
      因为内部实现了哈希表,因此其查找速度非常的快
    • 缺点:
      哈希表的建立比较耗费时间
      适用处,对于查找问题,unordered_map会更加高效一些,因此遇到查找问题,常会考虑一下用unordered_map
  • 于是将map 改为unordered_map,再进行分析,结果如下:

    进入main函数查看具体消耗:
  • 速度直接提升了1.5倍,hash_map诚不欺我。其他地方的优化就没有细做下去了,实在是想不到能够怎么优化。不过这里抛出一个问题:为什么判断是否符合需求中单词的样子中,判断az字符所用的时间比起AZ字符所用的时间多用了将近4倍。
  • 算法复杂度分析:整体只需遍历文件一编,复杂度为O(n),排序的复杂度为O(nlog10), unordered_map 数据结构为无序哈希表,存储时间复杂度仅为O(1)时间,故这个程序总的时间复杂度为O(n)+O(nlogn)

功能模块的封装

  • 代码都各有特色,如果现在我们要把这个功能放到不同的环境中去(例如,命令行,Windows图形界面程序,网页程序,手机App),就会碰到困难:代码散落在各个函数中,很难剥离出来作为一个独立的模块运行以满足不同的需求。于是将代码中的函数剥离出来写入.h文件中,但是问了助教这样做还不够,要封装成.dll文件(咸鱼突刺)。有以下几个功能:
  1. 统计字符数
  2. 统计单词数
  3. 统计最多的10个单词及其词频
  4. 统计总行数
  • 说干就干真抓实干,在vs2017上建立新的windows桌面动态链接库(dll)程序,分为dll1.h,dll1.cpp文件,部分代码为:
\\dll1.h

 __declspec(dllexport) int  CountLines(char *filename);//统计行数
 
 __declspec(dllexport) int CountChars(char *filename);//统计字符数
 
 __declspec(dllexport) bool cmp(int a, int b);//sort函数cmp
 __declspec(dllexport) void adjustDown(vector<unordered_map<string, int>::iterator> &top, int i);//调整堆
 __declspec(dllexport) void topK(unordered_map<string, int> &essay, vector<unordered_map<string, int>::iterator> &top);//计算排序词频前十单词
 __declspec(dllexport) void CountWF(char *filename);//实现输入文本输出词频前十单词
 
 __declspec(dllexport) int CountWords(char *filename);//统计单词数
  • dll1.cpp文件则把具体函数的实现方式写上,并去掉__declspec(dllexport) 其它与dll1.h无二。代码打好后生成解决方案就会在debug目录下生产.dll和.lib两个文件。要调用时将这两个文件+dll1.h文件拷贝至要使用的项目目录下就好了。运行结果如下:
  • 与之前的结果相同,封装成功:)

扎zn的单元测试

  • 一开始跟着邹欣老师的博客边学边做,但总是和预想中的不一样,一会儿找不到.h文件,一会儿无法打开文件,甚至会出现根本不知道是什么东西的错误(googleTest...balabala名字太长忘了,甚至忘记截图),然后想要不就不做了吧,就这个东西问题应该不是很大。。直到博客写到这里,想好歹把错误的截图截下来把,然后重新建项目,重新照着步骤一步步做。惊喜的是居然弄出来了,测试代码很简单hhh,由于我把三个功能的函数封装在一个.lib文件里,所以在单元测试时只有一个测试,不过通过控制变法一个个测过去了,结局很美,都通过了,最后如下图:

  • 单元测试(模块测试)是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。直到这个词是做什么的以来一直对它有误解

    • 做单元测试太烦了,直接做出问题又不是找不到错误
    • 做单元测试浪费时间,特别是赶进度的时候,完全没有意义
    • 它仅仅用来证明代码做了什么
  • 即便是现在,我也没能完全转变观念,但其实单元测试的效果是特别显著的,在代码最基础的时候检测好过集成后找bug万倍。单元测试是构筑产品质量的基石,我们不要因为节约单元测试的时间不做单元测试或随便做而让我们在后期浪费太多的不值得的时间,我们也不愿意因为由于节约那些时间导致开发出来的整个产品失败或重来!部分摘自博客(若侵则删)
  • 之后会在实践中逐渐转变观念,努力做好单元测试(flag高高挂起)

总结感悟

  • 这次作业做了很久很久,单博客就写了3个多小时,写的时候不停在回忆这次作业我获得了什么东西,细想还是很多的,最重要的是观念上的转变,以前C++ 也是这样的模式,但一直没有很认真对待,碰到难关或者和教程上写的不一样一开始还会去钻研几个小时,但是逐渐失去耐心干脆就不去做了,实际上这样什么都没有学到,比如大一下用命令行编译C++的事情,觉得有编译器了就拖着没有去做,如果没这次作业可能就一直不会下去了。这样的例子还有很多就不一一赘述了。第二个呢对map、vector容器有了更深的认识,真的是一个很好用的工具,之后一定拜读STL。这是软件工程的第一个实战作业,才知道VS原来有辣么多的功能。
  • 再一个让我感触比较深的是封装dll文件,之前上C++ 课的时候就听过这个词,但一直没有去了解,很多人说没有必要封装,甚至会浪费时间,降低效率。自己做完之后的理解是我封装好一个dll文件之后,我就可以将对应dll文件和API文件给别人,别人只要知道这个函数名和参数以及作用就能够使用我写的函数,并且无法也不需要看到我的函数的源码,这种保护机制我觉得意义是重大的。个人理解不知道对不对还请大佬们指正。
  • Github提交记录
  • 代码在github上前前后后也交了很多次,每次代码的变化都能通过github看出来,也能做到版本回退。感觉不用在桌面上建好多个文件夹放各个版本的代码是最大的好处hhh。作业做完,想起柯逍(da)老(mo)师(wang)说的不逼自己一把永远不知道自己有多么优秀,虽然我做了很久也没有很优秀,但比起不逼自己碰到困难就pass好得多了吧。

补充:发现bug

  1. 代码中数据读入时使用数组接收,最大为1000字符,原本想正常的文档应该不会超过1000字符/行,但是我错了TAT,文档的输入应该是没有限制的。所以改用string类型接受字符,最大长度根据内存决定。应改动添加代码如下:
fstream in ;
string ss;
int lineNum=0;
while(getline(in,ss))
{    lineNum++;
    if(ss.max_size()==ss.lenth())//若长度等于最大空间则作为异常情况处理
    {
        cout<<"第"<<lineNum<<"行长度过长"<<endl;
    }
}
  1. 由于定义堆时,使用了数值为10的常变量,当不同的单词数小于10时程序就会崩溃,原代码中将单词数小于10的情况直接当成异常处理(当时为了偷懒,现在良心过意不去)。对于堆大小K的值作出改动,代码如下 :
//原代码中以及完成了map的操作
int K = 0;
if(essay.size()<=10)//用于计算<Key,Value>对的数量,即不同单词的数量
{
    K = essay.size();
}
else 
{
    K = 10;
}

最后用一句话结束这篇博客:

不管前方的路有多苦,只要走的方向正确,不管多么崎岖不平,都比站在原地更接近幸福。

posted @ 2018-09-11 01:13  乐乐kami  阅读(260)  评论(4编辑  收藏  举报