第一次个人项目作业
1. 在文章开头给出教学班级和可克隆 的 Github 项目地址(例子如下)。(1')
项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2020春季计算机学院软件工程(罗杰 任健) (北京航空航天大学 - 计算机学院) |
这个作业的要求在哪里 | 个人项目作业 |
我的教学班级 | 005 |
这个项目的GitHub地址 | https://github.com/Kennnnnnji/Intersect |
2. 在开始实现程序之前,在下述 PSP 表格记录下你估计将在程序的各个模块的开发上耗费的时间。(0.5')
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务需要多少时间 | 10 | 5 |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 20 | 21 |
· Design Spec | · 生成设计文档 | 15 | 16 |
· Design Review | · 设计复审 (和同事审核设计文档) | 30 | 35 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 10 | 8 |
· Design | · 具体设计 | 10 | 9 |
· Coding | · 具体编码 | 300 | 320 |
· Code Review | · 代码复审 | 60 | 50 |
· Test | · 测试(自我测试,修改代码,提交修改) | 120 | 100 |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 60 | 90 |
· Size Measurement | · 计算工作量 | 10 | 10 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 10 | 10 |
合计 | 655 | 674 |
3. 解题思路描述。即刚开始拿到题目后,如何思考,如何找资料的过程。(3')
首先看到基本需求:给定 N 条直线,询问平面中有多少个点在至少 2 条给定的直线上。题目输入保证答案只有有限个。
数据要求:所有直线参数均为整数,范围为 (-100000, 100000)。
代码评分规则:
- 正确性评分 — 20 分。正确性测试中输入的几何对象个数满足 1 <= N <= 1000,要求程序在 60 秒内给出结果,超时则认定运行结果无效。
- 性能评分 — 10 分。性能测试中输入的几何对象个数满足 1000 <= N <= 500000,交点个数 h 满足 0 <= h <= 5000000,要求程序在 60 秒内给出结果。性能评分将采取档级评分制度,助教将根据同学们的程序跑同一数据耗费的时间长度将程序分为若干档,第 n 档的同学得到的分数为 10 / n。注:当程序的正确性评分等于 20 分时才可以参与性能评分环节,所以请各位同学务必保证自己程序的正确性。
分析:
题目的实际需求就是求解多条直线的交点。除了正确性的要求之外,还要尽可能提高性能。那么,首先考虑如何求解:
-
若已知两条直线方程 (L_1: y = k_1x + b_1, L_2: y = k_2x + b_2),且(k_1 eq k_2),可直接联立得到解。
-
若有一条直线是垂直的,(L_1: x = x_0, L_2: y = k_2x + b_2),可带入求解。
-
若两条直线平行,没有交点。
直线方程的公式有以下几种形式:
斜截式:
[egin{equation}y=kx+bend{equation} ]
截距式:
$$egin{equation}x/a+y/b=1end{equation}$$
两点式:
$$egin{equation}(x-x1)/(x2-x1)=(y-y1)/(y2-y1)end{equation}$$
一般式:
$$egin{equation}ax+by+c=0end{equation}$$ (可以表达任意直线)
在这里采用斜截式,只需要建立直线的时候计算(k, b)(或垂直(x)轴),空间复杂度小,在之后的计算中也非常简洁。
以上为基本求解方法。
那么,如何对所有直线求解呢?
最直观的想法是对所有直线分别求交点,时间复杂度为(O(n^2))。但是,这样粗暴的解法的性能无疑是很低的。在网络上查找资料,想到平行线可以作为一个集合,集合内不用求交点。但是,要求平行线集合仍然要遍历它们的斜率,和直接求交点的时间复杂度并无太大区别,因为在求交点的时候,若斜率相同,那么可以直接跳过。
另外,由于存在多条直线交于同一点的情况,实际上对于所有可能的交点,都要求出具体的交点坐标。那么,平行线集合的优势也被进一步削弱了。所以总体上,这一类做法的复杂度都是(O(n^2))。
在这一基础上,可以做一些初步的优化,即已经求过交点的两条直线不再求解。如:有直线(L_1, L_2),在求解(L1)对(L_2)的交点之后,不需要反过来求交点。如此可以节省一半的时间。
其次,在确定算法之后,考虑数据的精度问题。在题目直线参数为(-100000, 100000)的情况下,float
精度是达不到要求的。只有使用double
才能满足精度约束。
4. 设计实现过程。设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?单元测试是怎么设计的?(4')
类的设计
类图:
类包括:Shape, Point, Line, Circle
.
其中,Line, Circle
是Shape
的子类,采用面向对象的方法来维护可拓展性、可维护性良好的程序结构。
Shape:
二维几何形的类。具有基本的共有属性和方法。Point:
点类。
具有属性:坐标x, y
,脏位 valid
。
具有方法:
Line:
直线类。
具有属性:斜率k
,截距b
,垂直布尔值vertical
,垂直截距vertical_x
。
具有方法:
show():
显示对象属性。
getCross(Line* l2):
获得与另一条直线的交点。
setX(double x):
获得直线上横坐标为x
的点。
Circle:
圆类。
具有属性:圆心P
,半径r
。
具有方法:
bool isCross(Line l):
判断圆是否与直线相交。
bool isCross(Circle c2):
判断两圆是否相交。
pointPair intersections(Circle* c):
获取两圆的交点。
pointPair getCrossPoints(Circle* cir, Line* l):
获取圆和直线的交点。
数据的组织
使用STL中的vector
来管理Line
。为了不使点重复,使用set
管理获得的点。但是set
的性能较慢,需要进行排序,而这一点在本次作业中没有要求。改为使用unordered_set
来管理Point
。
在这一过程中,发现单纯使用double
来储存/计算点的坐标,有时候导致相同的点判断为不同,推测是计算中精度问题导致出错。为了验证这一点,采用float
保存坐标。测试边界条件:如
3
L 1 1 99999 0
L 1 1 99998 0
L 0 0 0 1
没有产生问题。但是在大规模测试(如1000条直线)发了正确性的错误。为了改进这一点,自行修改了set
中点==
操作,仅取特定double
精度(这里考虑到题目的要求,采用小数点后11位),解决了这一bug。经过一些测试,边界条件全部通过。但是,是否在极端情况下发生这样的精度问题,仍然无法保证。这一问题仍待解决。
单元测试设计
在项目中,编写了多个单元测试。
单元测试覆盖率:
这里由于一部分代码只有debug时才使用,所以没有覆盖。实际功能性覆盖在85%~90%以上。
在单元测试中,有基本的功能性测试:
TEST_METHOD(TestCircleVerticalLine) {
Line *newLine = new Line(3, 1, 3, 0);
Circle* newC = new Circle(Point(1, 0), 2);
auto pts = getCrossPoints(newC, newLine);
Assert::AreEqual(pts.first.x, 3.);
Assert::AreEqual(pts.first.y, 0.);
Assert::AreEqual(pts.second.x, 3.);
Assert::AreEqual(pts.second.y, 0.);
}
也有边界测试:
TEST_METHOD(TestMargin) {
vector<Line*> lineVec;
vector<Circle*> circleVec;
lineVec.push_back(new Line(1, 1, 99999, 0));
lineVec.push_back(new Line(1, 1, 99998, 0));
lineVec.push_back(new Line(0, 0, 0, 1));
unordered_set<Point, myHash>* rest = getCrossPoints(lineVec, circleVec, false, false);
Assert::AreEqual((int)rest->size(), 3);
}
对所有程序分支进行了比较全面的覆盖,减少出错的概率。
5. 记录在改进程序性能上所花费的时间,描述你改进的思路,并展示一张性能分析图(由 VS 2019 的性能分析工具自动生成),并展示你程序中消耗最大的函数。(3')
本项目做出的优化:
- 采用
unordered_set
保存数据,避免排序带来的性能消耗。 - 跳过已经计算过的几何形,避免重复计算。
构思优化消耗时间20min,实现优化消耗30min左右。
关于为什么没有做平行线的优化:
考虑平行线之间不用互相计算交点,节省了时间。但是得到平行线集合需要消耗不小的时间,而且多条线交于同一点要求所有几何形之间都要计算出准确的交点位置。平行线优化并没有展现出优势,但是会加大程序的复杂度。
性能分析:
所有分析采用随机生成的 (N = 1000) 组数据。
使用CPU分析工具,发现主要CPU时间花在getCrossPoints()
中的Hash
函数上。
我的Hash
函数:
重写了对象的哈希函数,这一步的时间难以优化。
分析运行内存:
主要是储存Point
消耗内存空间。
6. 代码说明。展示出项目关键代码,并解释思路与注释说明。(3')
main.cpp:
// 重写 Point类的相等操作
bool operator ==(const Point& obj, const Point& obj2) {
return double_equal(obj.x, obj2.x) && double_equal(obj.y, obj2.y);
}
// 主要逻辑,获取交点集合
unordered_set<Point, myHash>* getCrossPoints(vector<Line*> lineVec, vector<Circle*> circleVec) {
unordered_set<Point, myHash>* pointSet = new unordered_set<Point, myHash>(); // 保存Point的容器,使用unordered_set防止重复,同时无需排序
// 求线与线的交点
for (vector<Line*>::iterator iter = lineVec.begin(); iter != lineVec.end(); iter++) {
// 从当前位置的下一个形状开始,避免重复计算
for (auto iter2 = iter + 1; iter2 != lineVec.end(); iter2++) {
Point p = ((Line*)(*iter))->getCross((Line*)*iter2);
if (p.valid) {
pointSet->insert(p);
}
}
}
// 求线与圆的交点
for (vector<Line*>::iterator iter = lineVec.begin(); iter != lineVec.end(); iter++) {
for (auto iter2 = circleVec.begin(); iter2 != circleVec.end(); iter2++) {
// 计算线与圆的交点
pointPair pp = getCrossPoints((Circle*)(*iter2), (Line*)(*iter));
// 返回的是2个点的pair,分别加入点集
if (pp.first.valid) {
pointSet->insert(pp.first);
}
if (pp.second.valid) {
pointSet->insert(pp.second);
}
}
}
for (auto iter = circleVec.begin(); iter != circleVec.end(); iter++) {
for (auto iter2 = iter + 1; iter2 != circleVec.end(); iter2++) {
pointPair pp = ((Circle*)(*iter))->intersections((Circle*)(*iter2));
if (pp.first.valid) { // 只有当点有效时才加入
pointSet->insert(pp.first);
}
if (pp.second.valid) {
pointSet->insert(pp.second);
}
}
}
return pointSet;
}
point.h
struct Point {
...
Point(double x, double y) {
// 使用自定义的精度来储存点,防止因为计算导致的问题
valid = true;
const long long plus = 100000000000;
long long a2 = (long long)(x * plus + 0.5);
this->x = a2 / (double)plus;
long long b2 = (long long)(y * plus + 0.5);
this->y = b2 / (double)plus;
}
}
circle.h:
static pointPair getCrossPoints(Circle* cir, Line* l) {
// 当圆与直线不相交时,返回空的点对
if (!cir->isCross(*l)) {
return pointPair(Point(), Point());
}
double k = l->k, b = l->b;
double cx = cir->P.x, cy = cir->P.y, r = cir->r;
// 对于直线垂直的特殊情况,要特殊处理
if (l->vertical) {
double delta = r * r - (l->vertical_x - cx) * (l->vertical_x - cx);
delta = sqrt(delta);
double x1 = l->vertical_x;
return pointPair(Point(x1, b + delta), Point(x1, b - delta));
}
// 计算线与圆的交点
double x1, y1, x2, y2;
double P = cx * cx + (b - cy) * (b - cy) - r * r;
double a = (1 + k * k);
double b = (2 * cx - 2 * k * (b - cy));
double c = sqrt(b * b - 4 * a * P);
x1 = (b + c) / (2 * a);
y1 = k * x1 + b;
x2 = (b - c) / (2 * a);
y2 = k * x2 + b;
// 返回2个点的点对
return pointPair(Point(x1, y1), Point(x2, y2));
}
7. 在你实现完程序之后,在下述 PSP 表格记录下你在程序的各个模块上实际花费的时间。(0.5')
(见问题1)
8. Code Quality Analysis 分析
使用VisualStudio代码分析工具进行代码质量分析:
进行改进,去除警告:
9. 运行说明
输入格式
- 第 1 行:一个自然数 N >= 1,表示输入的直线的数目。注:具体的 N 的限制参见评分规则。
- 第 2 行至第 N + 1 行:每行描述一条直线。具体格式如下:
- 直线:
L
,表示通过点 (x1, y1) 和 (x2, y2) 的直线。输入保证给定两点不重合。
- 直线:
所有直线参数均为整数,范围为 (-100000, 100000)。
输出格式
共 1 行,输出平面中满足需求的点的数目。
运行方法
使用命令行运行:
参数 | 参数意义 | 用法示例 |
---|---|---|
-i |
带一个参数,表示输入文件的路径(绝对或相对路径) | Intersect.exe -i input.txt -o output.txt |
-o |
带一个参数,表示输出文件的路径(绝对或相对路径) | Intersect.exe -i input.txt -o output.txt |