個人項目作業\(\cdot\)求交點個數
一、作業要求簡介
本次作業是北航計算機學院軟件工程課程的個人項目作業,個人開發能力對於軟件開發團隊是至關重要的,本項目旨在通過一個求幾何圖形的交點的需求來使學生學會個人開發的常用技巧,如PSP方法,需求分析,設計文檔,編碼實現,測試,性能評價等等。
| 項目 | 內容 |
|---|---|
| 本作業屬於北航軟件工程課程 | 博客園班級博客 |
| 作業要求請點擊鏈接查看 | 個人項目作業 |
| 班級:006 | Sample |
| GitHub地址 | IntersectProject |
| 我在這門課程的目標是 | 獲得成為一名軟件工程師的能力 |
| 這個作業在哪個具體方面幫助我實現目標 | 總結過去、規划未來 |
二、PSP表格
| PSP2.1 | Personal Software Process Stages | 預估耗時(分鍾) | 實際耗時(分鍾) |
|---|---|---|---|
| Planning | 計划 | 90 | 83 |
| · Estimate | · 估計這個任務需要多少時間 | 90 | 83 |
| Development | 開發 | 830 | 1320 |
| · Analysis | · 需求分析 (包括學習新技術) | 30 | 60 |
| · Design Spec | · 生成設計文檔 | 60 | 40 |
| · Design Review | · 設計復審 (和同事審核設計文檔) | 60 | 60 |
| · Coding Standard | · 代碼規范 (為目前的開發制定合適的規范) | 20 | 20 |
| · Design | · 具體設計 | 60 | 120 |
| · Coding | · 具體編碼 | 240 | 480 |
| · Code Review | · 代碼復審 | 0 | 0 |
| · Test | · 測試(自我測試,修改代碼,提交修改) | 360 | 540 |
| Reporting | 報告 | 180 | 240 |
| · Test Report | · 測試報告 | 30 | 180 |
| · Size Measurement | · 計算工作量 | 30 | 30 |
| · Postmortem & Process Improvement Plan | · 事后總結, 並提出過程改進計划 | 120 | 30 |
| 合計 | 1100 | 1560 |
三、解題思路描述
題目需求簡述
- 題目需求為,給定若干直線,求其交點個數
- 直線條數
1000 <= N <= 500000 - 交點個數
0 <= h <= 5000000 - 運行時長60s
解題思路
拿到題目首先想到暴力求解,兩兩計算交點,然后去重。但是這樣就是純\(O(n^2)\)的復雜度,必然TLE的。思來想去呢也沒有想到本質上改變最壞復雜度\(O(n^2)\)的算法。於是便在網上查了一些資料,發現網上的題目都有一個重要的限定,不存在三線共點。但是我們這個題目的需求是允許三線共點的,所以並沒有什么幫助。
之后看到了交點個數0 <= h <= 5000000的限制,感覺也許最壞復雜度\(O(n^2)\)的算法並不是不可能解的,因為如果有N = 500000條直線不存在三線共點和平行的話,確實會有\(N(N-1)/2\)個交點,但是之所以交點個數有限制 h <= 5000000,就說明存在大量的多線共點和平行。
沿着這個思路想下去,便可以在暴力的\(O(n^2)\)算法基礎上考慮將多線共點和平行的情況剪枝掉,剪枝后的具體的時間復雜度比較復雜我沒有計算,不過應該是可以滿足時間條件的,后文中將對其進行壓力測試。
四、設計文檔
(一)PipeLine

PreProcess
- ReadShape:讀取文件接收全部輸入的直線和圓
- Shape construct:根據輸入構建形狀對象,計算直線斜率。
- Classified by Slope:按斜率將直線分組存起來。
CalcIntersect
- CalcLines:計算所有直線之間的交點:
- 依次考慮每個平行組,按每條線遍歷計算交點。平行組內的線不用計算交點。
- 查交點表,如果存在,就可以不求同一交點的其他線了。
交點表:Map<點,Set<線>>
維護交點表:新增的交點加入交點表,線加入表中對應的線集
- CalcCircles:所有線算完后,再一個個遍歷圓。暴力求其與之前圖形的全部交點。
- 計算圓與直線的交點時,可以按如下方法剪枝:
考慮圓與一族平行線的交點,將平行線族的截距排序為b1,b2,b3 \(\cdots\)
若bi開始與圓相離,則大於i的線一定相離,反正小於的情況亦然。
(二)類間關系圖UML

- CIntersect類:實現控制流,方法包含輸入、計算兩圖形交點、計算交點總數
- CShape類:圖形類基類,為每個圖形實例創建唯一id
- CLine類和CCircle類:繼承圖形類基類,作用為表示形狀代數方程參數。
- 直線方程兩種表示方法
- 一般方程:\(Ax + By +C = 0\)
- 斜截方程:\(y = kx + b\)
- 圓方程兩種表示
- 一般方程: \(x^2 + y^2 + Dx + Ey +F = 0\)
- 標准方程: \((x-x_0)^2 + (y-y_0)^2 = r^2\)
- CSlope類和CBias類:為解決斜率無窮大設計,isInf和isNan為true時表示直線的斜率為無窮,此時k和b的具體值無效。由於要按斜率分組,CSlope要實現小於運算符。
- CPoint類:表示交點,作為map的key,需要實現小於運算符。
(三)關鍵函數
- inputShapes: 處理輸入函數,直線按斜率分組,放到
map<double, set<CLine>>的_k2lines中
圓直接放到set<CCircle>的_circles里。 - calcShapeInsPoint:求兩個圖形交點的函數,分三種情況,返回點的vector。
- 直線與直線
- 直線與圓
- 圓與圓
- cntTotalInsPoint: 求所有焦點的函數,按先直線后圓的順序依次遍歷求焦點。已經遍歷到的圖形加入一個
over集中。- 直線兩個剪枝方法:
- 砍平行:依次加入每個平行組,不需計算組內直線交點,只需遍歷
over集中其它不平行直線。 - 砍共點:假若ABC共點,按ABC的順序遍歷,先計算了AB,交點為P;之后計算AC時發現交點也是P,則無需計算BC交點。方法為維護
_insp2shapes這個map<CPoint, set<CShape>>數據結構,為交點到經過它的線集的映射。
- 砍平行:依次加入每個平行組,不需計算組內直線交點,只需遍歷
- 再依次遍歷圓,暴力求焦點。加到
_insp2shapes里 - 函數返回
_insp2shapes.size()即為交點個數。
- 直線兩個剪枝方法:
(四)測試設計
按照代碼實現的計划,先后實現三部分功能,實現完即測試,測試通過即提交。測試粒度為pipeline中的函數。測試數據和代碼均已上傳github。
-
test_input: 構造了4個測試數據,測試輸入函數inputShapes的功能,下面為其中一個測試樣例,解釋見注釋:
測試覆蓋單線、常規、共點、平行
TEST_METHOD(TestMethod4) { // paralile 數據為兩組平行線 // 4 // L 0 0 0 1 // L 0 0 1 1 // L 1 0 1 2 // L 1 0 2 1 //直線一般方程ABC答案集 vector<CLine> ans; ans.push_back(CLine(1, -1, 0)); ans.push_back(CLine(1, -1, -1)); ans.push_back(CLine(1, 0, 0)); ans.push_back(CLine(2, 0, -2)); //直線斜率答案集 vector<CSlope> ans_slope; ans_slope.push_back(CSlope(1.0)); ans_slope.push_back(CSlope(true)); ifstream fin("../test/test4.txt");//讀測試輸入文件 if (!fin) {//確認讀入正確 Assert::AreEqual(132, 0); } //測試開始 CIntersect ins; ins.inputShapes(fin); //獲取測試目標數據結構 map<CSlope, set<CLine> > k2lines = ins.getK2Lines(); //對比答案 Assert::AreEqual((int)k2lines.size(), 2); int i = 0; int j = 0; for (map<CSlope, set<CLine> >::iterator mit = k2lines.begin(); mit != k2lines.end(); ++mit, ++i) { Assert::AreEqual(true, mit->first == ans_slope[i]); Assert::AreEqual((int)(mit->second.size()), 2); set<CLine> lines = mit->second; for (set<CLine>::iterator sit = lines.begin(); sit != lines.end(); ++sit, ++j) { Assert::AreEqual(true, ans[j] == *sit); } } } -
test_line_intersect: 構造4個測試樣例,測試兩線交點函數
calcShapeInsPoint,代碼略測試覆蓋單線、常規、共點、平行
-
test_cnt_intersect: 構造11個測試樣例,測試總數函數
cntTotalInsPoint,代碼示例測試覆蓋單線、常規、共點、平行、浮點精度、內外切、三線切於一點、壓力測試
TEST_METHOD(TestMethod9) { // 相切測試,含內切、外切、直線兩圓三線切於一點 // 6 // C 0 0 10 // C 4 3 5 // C - 5 0 5 // L 2 14 14 - 2 // L 0 0 0 1 // L - 10 0 - 10 1 ifstream fin("../test/test9.txt"); if (!fin) { Assert::AreEqual(132, 0); } CIntersect ins; ins.inputShapes(fin); int cnt = ins.cntTotalInsPoint(); Assert::AreEqual(9, cnt); // 總數為9 }
五、性能改進與消除所有告警
(一) 性能改進
運行VS2017的性能探測器,查看自己代碼的性能瓶頸。


可見運行總耗時38s,最耗時的函數為cntTotalInsPoint, 下面仔細分析此函數,找出性能瓶頸。


分析:
可見性能瓶頸在map<CPoint, set<CShape>>這個_insp2shapes變量的插入和查找上,通過仔細分析發現,此變量可以優化:
-
由於此變量的作用是通過給定交點,找到通過此交點的線,由於我可以通過id來唯一確定一個CShape,所以直接存int就可以了,
set<CShape>可以改成set<int> -
其次,這個set是不需要查找的,只需要添加,以及整體copy,所以不需要用set,可以改成vector。set在插入前是需要遍歷紅黑樹的,耗時耗內存。於是原來的
map<CPoint, set<CShape>>改成了map<CPoint, vector<int>>。 -
類似的,這個
map<CSlope, set<CLine>>也可以改成map<CSlope, vector<CLine>>。
修改后的性能分析

可以看出,運行總時間由38減少到了27,性能大幅度提升。


之前具體的代碼被采樣到的次數也有所降低,可見修改產生了性能提升。
(二) 消除告警
消除告警前:

消除告警后:

六、代碼說明
(一)浮點數比較處理
眾所周知計算機中的浮點數是不能直接比較相等的,常見的浮點數相等的比較方法為
#define EPS 1e-6
double x;
double y;
if (abs(x-y) < EPS) {
cout << "x == y" << endl;
}
這種方式保證了在一定的浮點誤差內,兩個浮點數認為相等。
在本需求中,涉及到若干浮點數相關類需要重載 < 運算符。其代碼需要考慮浮點誤差問題。例如CPoint類的小於運算符代碼如下:
bool CPoint::operator < (const CPoint & rhs) const
{ // 要求僅當 _x < rhs._x - EPS 或 _x < rhs._x + EPS && _y < rhs._y - EPS 時返回true
if (_x < rhs._x - EPS || _x < rhs._x + EPS && _y < rhs._y - EPS) {
return true;
}
return false;
}
(二)求兩線交點:直線與直線 or 直線與圓 or 圓與圓
// calculate all intersect points of s1 and s2
// return the points as vector
// need: s1, s2 should be CLine or CCircle.
// special need: if s1, s2 are CLine. They cannot be parallel.
std::vector<CPoint> CIntersect::calcShapeInsPoint(const CShape& s1, const CShape& s2) const
{
if (s1.type() == "Line" && s2.type() == "Line") { // 直線交點公式,輸入要求兩線不平行
double x = (s2.C()*s1.B() - s1.C()*s2.B()) / (s1.A()*s2.B() - s2.A()*s1.B());
double y = (s2.C()*s1.A() - s1.C()*s2.A()) / (s1.B()*s2.A() - s2.B()*s1.A());
vector<CPoint> ret;
ret.push_back(CPoint(x, y));
return ret;
}
else {
if (s1.type() == "Circle" && s2.type() == "Line") {
return calcInsCircLine(s1, s2);
}
else if (s1.type() == "Line" && s2.type() == "Circle") {
return calcInsCircLine(s2, s1);
}
else { // 兩個圓的交點轉化為一個圓與公共弦直線的交點
CLine line(s1.D() - s2.D(), s1.E() - s2.E(), s1.F() - s2.F());
return calcInsCircLine(s1, line);
}
}
}
// calculate Intersections of one circ and one line
// need: para1 is CCirc, para2 is CLine
// return a vector of intersections. size can be 0,1,2.
std::vector<CPoint> calcInsCircLine(const CShape& circ, const CShape& line)
{
if (line.k().isInf()) { // 斜率無窮,略
...
}
else if (abs(line.k().val() - 0.0) < EPS) { //斜率為0,略
...
}
else {
vector<CPoint> ret;
double k = line.k().val();
double x0 = circ.x0();
double y0 = circ.y0();
double b1 = line.b().val();
double d_2 = (k * x0 - y0 + b1) * (k * x0 - y0 + b1) / (1 + k * k);
double d = sqrt(d_2); // 圓心到直線距離
double n; // 半弦長
if (d - circ.r() > EPS) { // not intersect
return ret;
}
else if (circ.r() - d < EPS){ // tangent
n = 0.0;
}
else { // intersect
n = sqrt(circ.r() * circ.r() - d_2);
}
double b2 = x0 / k + y0;
double xc = (b2 - b1) / (k + 1 / k); // 弦中點x坐標
double yc = (k * b2 + b1 / k) / (k + 1 / k); // 弦中點y坐標
// 交點坐標
double x1 = xc + n / sqrt(1 + k * k);
double x2 = xc - n / sqrt(1 + k * k);
double y1 = yc + n * k / sqrt(1 + k * k);
double y2 = yc - n * k / sqrt(1 + k * k);
ret.push_back(CPoint(x1, y1));
ret.push_back(CPoint(x2, y2));
return ret;
}
}
(三)平行分組和公共交點剪枝
// the main pipeline: loop the inputs and fill in _insp2shapes or _insPoints
// return the total count of intersect points
// need: _k2lines and _circles have been filled
int CIntersect::cntTotalInsPoint()
{
// lines first
vector<CLine> over;
for (auto mit = _k2lines.begin(); mit != _k2lines.end(); ++mit) { // 遍歷平行組
vector<CLine>& s = mit->second;
for (auto sit = s.begin(); sit != s.end(); ++sit) { //遍歷組內直線
// trick: If the cross point already exists,
// we can cut calculation with other lines crossing this point.
set<int> can_skip_id; // use this to record which line do not need calculate.
for (auto oit = over.begin(); oit != over.end(); ++oit) { // 遍歷over集
if (can_skip_id.find(oit->id()) == can_skip_id.end()) { // cannot skip
CPoint point = calcShapeInsPoint(*sit, *oit)[0]; // must intersect // 能保證不平行
if (_insp2shapesId.find(point) == _insp2shapesId.end()) { // 全新交點
_insp2shapesId[point].push_back(sit->id());
_insp2shapesId[point].push_back(oit->id());
}
else { // cross point already exists 交點已存在
vector<int>& sl = _insp2shapesId[point];
can_skip_id.insert(sl.begin(), sl.end()); // 下次遇到可以跳過不算
_insp2shapesId[point].push_back(sit->id());
}
}
}
}
over.insert(over.end(), s.begin(), s.end());// 整個平行組加入over集
}
// 后面算圓略
...
}
七、思考
-
c++不允許將父類強轉為子類,如何更優雅地解決calcShapeInsPoint函數中接收參數是父類類型,但是需要根據不同子類類型使用不同方法的問呢?
std::vector<CPoint> calcInsCircLine(const CShape& circ, const CShape& line)我希望通過這一個函數,封裝全部三類的相交問題,所以在接收的參數上必須采用基類的類型,但函數內部計算時需要使用子類的方法,如何實現呢?
- 通過傳指針能做到,把參數設為父類的指針,然后強轉為子類的指針,但是不太方便,也不太優雅。
- 通過傳引用也能實現,利用虛函數的多態特性動態調用對應的子類的函數。但需要在基類里寫完全用不到的方法,失去了封裝性。如在CShape類里寫getA()(目前采用的實現方式)
-
本次使用了c++STL的map和set,底層都是用紅黑樹實現的,復雜度為O(n)。在討論交流中發現,c++11標准也新增了類似java中HashSet和HashMap的STL函數,即unordered_map和unordered_set。這個復雜度在好的情況下是O(1)的。下次要記得使用。
