个人项目作业
软件工程个人项目作业
-
在文章开头给出教学班级和可克隆的 Github 项目地址。
项目 内容 课程链接 2020春季计算机学院软件工程(罗杰 任健) 作业要求 个人项目作业 教学班级 006 项目地址 https://github.com/17373432/Project1 -
解题思路描述。即刚开始拿到题目后,如何思考,如何找资料的过程。
看到题目第一反应就是要按斜率将直线分类,假设有m组斜率,分别为\(k_1,k_2,...,k_m\),每组斜率的直线分别有\(a_1,a_2,...,a_n\)个,最终答案即为\(\dfrac{\sum\limits_{i=1}^m (a_i×\sum\limits_{j=1,j≠i}^m a_j)}{2}\)。但是这么做并没有考虑公共点的情况,为了处理公共点问题,必须要把交点计算出来,而上述方法不涉及交点计算,所以只好放弃。
暴力计算交点的复杂度是\(O(n^2)\),题目设置了运行时间限制,所以肯定不能使用暴力算法,我采取了如下两种剪枝方法:
- 平行直线不相交,依然可以按照上述思路,只计算斜率不同的直线的交点。
- 如果直线a与直线b相交于一个已知的公共点p,则可以不用计算点p上其他直线与直线a、直线b的交点。
但即便这么优化,最坏情况时间复杂度依然是\(O(n^2)\),目前没能想到更好的方法。
关于找资料,我尝试了在网上搜索求直线交点个数问题的解法,但是都是不带解析式不考虑三线共点的情况进行理论上最大值的求解,也有人理论分析并没有快于\(O(n^2)\)的算法,所以并没有什么实际收获。
-
设计实现过程。设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何?
-
主类:将各个类联系起来,完成基本功能。
- 属性:map<斜率,set<对应斜率的直线>>,set<直线>,set<点>,set<圆>。
- 方法:将直线按斜率储存,以及求交点的一些方法。
-
Line类:采用一般式\(Ax+By+C=0\),避免了斜率为正无穷的讨论。
- 属性:基本的A,B,C的值,以及其他简化计算的属性。
- 方法:求与直线的交点,以及构造函数,返回属性的值,重载小于运算符等方法。
-
Point类:
- 属性:横坐标x,纵坐标y,set<交于此点的直线>。
- 方法:构造函数,返回属性的值,添加经过这个点的直线,重载小于运算符等方法。
-
Circle类:
- 属性:圆心(使用Point),半径,以及其他简化计算的属性。
- 方法:求与直线的交点,求与圆的交点,以及构造函数,返回属性的值,重载小于运算符等方法。
其他函数:浮点数相等比较(设置的浮点误差为\(10^{-12}\))。
关键函数是否需要画出流程图?
这次由于没画流程图,导致复杂的函数如求直线与直线的交点的函数写了很久。因为涉及的容器过多、循环层数多,每次查找的时候都得花很多时间来理清思路。
单元测试是怎么设计的?
- 涉及了两两之间的每一种情况:直线与直线平行、相交,直线与圆相交、相切、相离,圆与圆内含、内切、相交、外切、相离。
- 涉及了直线斜率为0,斜率不存在的情况。
- 测试了浮点误差可能导致的相交视为平行的情况。
- 进行了压力测试(见test\*.txt文件)。
-
-
记录在改进程序性能上所花费的时间,描述你改进的思路,并展示一张性能分析图(由 VS 2019 的性能分析工具自动生成),并展示你程序中消耗最大的函数。
这是1000个数据运行后的分析:
其中的std::_Tree那一项是set和map的.insert()与.find()函数,花费了大量的时间,仔细分析代码后发现我的容器全部使用的是set,在每次合并的时候都会比较是否与其他元素相同,所以每进行一次set的合并,这个元素与其他元素的比较就会多一次。改进方法即为只将最外一层容器使用set,中间运算过程的容器使用vector,这样保证每个元素到最后都只会与其他元素比较一次。改进之后确实提升了一些性能。
不过后来才发现运行时间这么长其实是因为开的Debug模式进行分析,所以这张图片上的数字的意义其实不大,但是相对大小关系还是可以参考的。这是7000个数据运行后的分析:
其中大部分时间仍然是set的.find()和.insert(),查找资料后得知c++中的map与set是用红黑树实现的,查找和插入的时间复杂度是\(O(logn)\),所以我就找了时间复杂度为\(O(1)\)的unordered_set来代替set,同样的7000个数据结果如下:
可以看到,图中std::_Hash这一项CPU总计比之前的std::_Tree少了接近40000,反应到最终的结果从1分30秒的执行时间减少到了35秒。果然数据多了之后\(O(logn)\)和\(O(1)\)算法的时间差异就体现了出来,可见容器的选择十分重要。再附上具体代码比较的图:
同一段代码,上图为使用set的具体代码分析,下图为使用unordered_set
-
代码说明。展示出项目关键代码,并解释思路与注释说明。
类图:
-
首先,最关键的还是浮点数相等比较,浮点数有误差,计算中很难做到完全相等,所以要设定一个精度来进行等于判断。
#define eps 0.00000000001 inline bool dEqual(double x, double y) { double temp = 0; bool result = false; temp = abs(x - y); if (temp < eps) { result = true; } return result; }
这里的eps我设置的是\(10^{-12}\),这个宁可多开几位小数也不能少开,不然很可能在相切、平行这种地方出问题。
-
直线预处理:将直线按斜率排序。
void Proc::preProcLine(Line line) { map<double, vector<Line>>::iterator iter; iter = preMap.find(line.getK()); // 如果存在斜率k,则加入k对应的直线组中 if (iter != preMap.end()) { vector<Line> s = iter->second; s.push_back(line); preMap[line.getK()] = s; } // 如果不存在斜率k,则新建一个键对 else { vector<Line> s; s.push_back(line); preMap[line.getK()] = s; } }
这里使用Map<double, vector<Line>>,做到从斜率映射到直线组,简化后续计算。因为在之后的分析中发现这个Map的查找和插入执行总时间很短,所以并没有将其改成unordered_map。
-
计算两条直线的交点
Point Line::withLine(Line l) { const double a2 = l.getA(); double b2 = l.getB(); double c2 = l.getC(); //a2*b!=a*b2 double deno = (a2 * b - a * b2); double y = (a * c2 - a2 * c) / deno; double x = (b2 * c - b * c2) / deno; Point p(x, y); return p; }
套公式即可。注意这里的deno是分母,不能等于0,所以要提前判断
a2*b!=a*b2
,即直线不平行,不过下面我已经保证了不会计算相同斜率的直线的交点,所以没必要再进行判断。(为了保持各个类之间的独立性和代码的稳定性,在这里加一个出错处理会更好,不过时间不是很充裕,所以没有加) -
计算一个圆和一条直线的交点
vector<Point> Circle::withLine(Line l) { vector<Point> s; const double x0 = o.getX(); const double y0 = o.getY(); const double delta = r2 - pow(l.getA() * x0 + l.getB() * y0 + l.getC(), 2) / l.geta2Ab2(); if (delta >= 0) { const double sqrtDelta = sqrt(delta); const double leftX = (l.getb2() * x0 - l.getab() * y0 - l.getac()) / l.geta2Ab2(); const double rightX = sqrtDelta * l.getCos(); const double leftY = (l.geta2() * y0 - l.getab() * x0 - l.getbc()) / l.geta2Ab2(); const double rightY = sqrtDelta * l.getSin(); // 注意rightX与rightY的符号不同 Point p1(leftX - rightX, leftY + rightY); Point p2(leftX + rightX, leftY - rightY); s.push_back(p1); s.push_back(p2); } return s; }
套公式即可。判别式delta大于0有2个交点;等于0有一个焦点;小于0无交点。注意算交点坐标时横坐标和纵坐标右半部分符号是相反的(一开始就是这个地方的bug,不过很容易发现和修复)
-
计算两个圆的交点
vector<Point> Circle::withCircle(Circle that) { vector<Point> s; const double max = r + that.r; const double min = abs(r - that.r); const double d = sqrt(pow(o.getX() - that.o.getX(), 2) + pow(o.getY() - that.o.getY(), 2)); if (min <= d && d <= max) { const double A = that.xx - xx; const double B = that.yy - yy; const double C = x2Ay2Sr2 - that.x2Ay2Sr2; const Line l(A, B, C); s = withLine(l); } return s; }
套公式即可。这里先求的是两圆公共弦的表达式,然后利用公共弦再与任意一个圆相交求出交点。注意圆心距d要在大于等于两圆半径之差与小于等于半径之和这个范围中求出来的公共弦才有意义。(小于两半径之差求出来的是虚弦也可能与圆有交点导致最终求出来交点变多)
-
计算所有直线与直线产生的交点
void Proc::lineAndLine() { map<double, vector<Line>>::iterator iterM; // 遍历斜率k for (iterM = preMap.begin(); iterM != preMap.end(); iterM++) { vector<Line>::iterator iterS; vector<Line> tempSet = iterM->second; // 遍历斜率为k的直线 for (iterS = tempSet.begin(); iterS != tempSet.end(); iterS++) { vector<Line>::iterator iterL; Line l1 = *iterS; set<int> inteId; // 遍历已添加的直线 for (iterL = lineSet.begin(); iterL != lineSet.end(); iterL++) { Line l2 = *iterL; set<int>::iterator iterI = inteId.find(l2.getId()); // 如果l2与l1的交点已经被算出来了,就跳过 if (iterI != inteId.end()) { continue; } Point p = l1.withLine(l2); unordered_set<Point, hashPoint>::iterator iterP = pointSet.find(p); // 如果这个点在点集中,就把在这个点上的其他直线的id加入inteId,减少计算 if (iterP != pointSet.end()) { p = *iterP; vector<int> temp = p.getLines(); inteId.insert(temp.begin(), temp.end()); // l1也在这个点上 p.addLine(l1.getId()); } // 如果不在,就把l1,l2加入在这个点上的集合中,将这个点加入点集 else { p.addLine(l1.getId()); p.addLine(l2.getId()); pointSet.insert(p); } } } // 将斜率为k的直线全部加入已添加的集合 lineSet.insert(lineSet.end(), tempSet.begin(), tempSet.end()); } }
l1为某个斜率下的某条直线,inteId为记录l1已经和哪些直线相交(后续无需再计算l1和这些直线的交点),储存的是这些直线的交点,lineSet记录的是已经遍历过的直线。
具体过程:第一层循环,遍历各个斜率。第二层循环,遍历这个斜率的直线,记为l1,并用inteId记录该直线与哪些直线已经相交。第三层循环,遍历之前已经遍历过的直线,记为l2,如果l2在inteId中则跳过计算,进行下一次循环;否则计算l1与l2的交点p。查找p是否已经出现过,如果已经出现过,则将p上的直线全部加入到inteId中,使得l1在后续碰到这些直线可以直接跳过不用计算,将l1计入交于p上的直线中;否则将l1和l2计入交于p上的直线中,将p加入最终的点集。在第二层循环结束后(即有一组k的直线已经被遍历完),将这组直线全部加入lineSet中(这样整体加入的好处是保证了相同斜率的直线不会进行计算)。
-
计算所有直线与圆的交点
void Proc::lineAndCircle(Circle circle) { vector<Point> result; map<double, vector<Line>>::iterator iterM; // 遍历斜率k for (iterM = preMap.begin(); iterM != preMap.end(); iterM++) { vector<Line>::iterator iterS; vector<Line> tempSet = iterM->second; // 遍历斜率为k的直线 for (iterS = tempSet.begin(); iterS != tempSet.end(); iterS++) { Line l = *iterS; vector<Point> s = circle.withLine(l); vector<Point>::iterator iterS; for (iterS = s.begin(); iterS != s.end(); iterS++) { pointSet.insert(*iterS); } } } }
与计算所有直线和直线交点类似,外两层循环遍历直线,记为l,然后求l与圆的交点。
-
计算所有圆产生的交点
void Proc::calcCircle() { vector<Circle>::iterator iter1; // 遍历所有的圆 for (iter1 = circleSet.begin(); iter1 != circleSet.end(); iter1++) { vector<Circle>::iterator iter2; Circle circle1 = *iter1; // 计算该圆与所有直线的交点 lineAndCircle(circle1); // 计算该圆与已添加的圆的交点 for (iter2 = circleSet.begin(); iter2 != iter1; iter2++) { Circle circle2 = *iter2; vector<Point> s = circle1.withCircle(circle2); vector<Point>::iterator iterS; for (iterS = s.begin(); iterS != s.end(); iterS++) { pointSet.insert(*iterS); } } } }
第一层循环,遍历每个圆,记为circle1,计算circle1与所有直线的交点。第二层循环,遍历已遍历过的圆,记为circle2,计算circle1与circle2的交点。
-
-
代码警告消除
-
在你实现完程序之后,在下述 PSP 表格记录下你在程序的各个模块上实际花费的时间。
PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟) Planning 计划 · Estimate · 估计这个任务需要多少时间 10 10 Development 开发 · Analysis · 需求分析 (包括学习新技术) 120 120 · Design Spec · 生成设计文档 30 30 · Design Review · 设计复审 (和同事审核设计文档) 10 10 · Coding Standard · 代码规范 (为目前的开发制定合适的规范) 10 10 · Design · 具体设计 60 120 · Coding · 具体编码 360 300 · Code Review · 代码复审 60 240 · Test · 测试(自我测试,修改代码,提交修改) 240 420 Reporting 报告 · Test Report · 测试报告 30 30 · Size Measurement · 计算工作量 10 10 · Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 30 30 合计 970 1270 这次作业乍一看不是很难,所以具体设计、代码复审、测试上的时间估算的比较少,越写就越感觉自己编程能力不足,之前并没有用c++写过类,所以这次光是学习怎么写类、怎么用unordered_set都花了很多时间,在测试方面研究为什么性能降不下来,一直盯着代码分析数据看,摆弄了好久才发现是分析报告里面消耗最大的std::_Tree指的是红黑树,才有所进展。关于浮点精度取得不够小导致的最终结果少一个的bug也是找了好久才发现。
-
个人总结
一开始以为这次作业在计算上的时间开销会比较大,所以拼命推公式化简,尽可能多的找到可以预先存储的值,拿空间换时间,但是写了之后才发现,计算的时间只是鸡毛蒜皮,容器的查找和插入的时间就能占到95%以上,容器选的不好,做再多其他方面的优化也是白搭,相反容器选的好纯暴力算法应该都能在规定时间内运行完。所以这次作业设立最大运行时间只是为了让我们意识到容器的重要性么?
PS:这次作业时间真的是有点紧,周五才上完课把热身博客写完,周六要写其他作业,周一周二又有课,有效的工作时间就只有周日和周一晚上,这次由于时间问题导致很多地方都是草草了事,没有加出错处理,unorder_set也只是瞎用,为了用这个容器还把Point类的属性给public了,很多地方都没按标准来。总之,我感觉这次的ddl对于我来说设置的太尴尬了,周二就只有晚上有时间,周三一天没有课,可截止日期偏偏是周二下午,这个时间对周五上课的同学来说太不友好了,由衷希望下次ddl能往后推一推。