【軟工】個人項目作業——個人軟件流程(PSP)
| 項目 | 內容 |
|---|---|
| 班級:北航2020春軟件工程 006班(羅傑、任健 周五) | 博客園班級博客 |
| 作業:設計程序求幾何對象的交點集合 | 個人項目作業 |
| 個人課程目標 | 系統學習軟件工程,訓練軟件開發能力 |
| 這個作業在哪個具體方面幫助我實現目標 | 實踐個人軟件開發流程(PSP) |
| 項目地址 | GitHub: clone/http |
個人軟件流程(PSP)
| PSP2.1 | 預估耗時(分鍾) | 實際耗時(分鍾) |
|---|---|---|
| Planning | 20 | 20 |
| · Estimate | 20 | 20 |
| Development | 310 | 530 |
| · Analysis | 30 | 90 |
| · Design Spec | 10 | 30 |
| · Design Review | 10 | 10 |
| · Coding Standard | 10 | 10 |
| · Design | 40 | 90 |
| · Coding | 120 | 120 |
| · Code Review | 30 | 30 |
| · Test | 60 | 150 |
| Reporting | 50 | 50 |
| · Test Report | 20 | 20 |
| · Size Measurement | 10 | 10 |
| · Postmortem & Process Improvement Plan | 20 | 20 |
| In Total | 380 | 600 |
最終完成整個項目的時間遠遠超出了我的預計,其中與預期嚴重不符的項包括:分析需求、設計和測試。其中,分析需求和設計超時的原因是對題目要求功能的本質思考不清晰,思路和設計經過了以下的反復迭代和更改:
-
首先對參數在\((-10^5,10^5)\)范圍時的交點取值范圍進行了數學上的分析,認為線-線交點可能坐標高達\(4\times10^{10}\),精度要求可能高於\(10^{-5}\)。
-
於是認為使用
double維護點坐標精度不夠,於是決定自行構造一個有理數類\(\frac{P}{Q}\),分子分母均為long long型 -
后來發現附加題里涉及到圓,線-圓交點的形式為\(\frac{A+B\sqrt{C}}{D}\),於是決定擴展有理數類到支持帶系數的根式。再思考如何標准化該式以進行兩坐標之間的比較(哈希和判等),涉及到了質因數分解等。
-
再仔細分析,認為線-線交點范圍可能達到\(4\times 10^{10}\),再由於double類型的有效數字僅為15位左右,即小數點后5位左右,因此認為應當使用有理數類存儲線線交點以避免精度問題。然而對線-圓交點和圓-圓交點而言,交點范圍必在\(\pm 2\times 10^{5}\)以內,因此使用double存儲可到小數點后近10位,因此涉及到圓的交點可以使用double,精度足夠。在比較時認為有理數≠double小數。
-
又發現線線交點可能和圓交點重合,於是必須檢查涉及到圓的交點坐標是否為有理數。是有理數則使用有理數類,否則使用double。這要求將坐標的公式寫出,檢查根號內的整數是否為完全平方數。若是完全平方數則可以化為有理數類,否則直接求值。
可以看出,如果一開始就較為清晰地將各個需求羅列出來,再一一分析,分析之后再進行統一設計,可能很快就可以想出有理數/無理數的分類,而不是將所謂設計的有理數類反復拓展以支持新需求。
如果是一邊像這樣設計一邊寫代碼,浪費的時間就更是災難性的,代碼將會反復修改,思路也會頻繁被打斷。
因此PSP看似麻煩復雜的流程不是沒有道理的,以后應當記住這個教訓。
解題思路
此題使用哈希表的暴力解法時間復雜度為\(O(n^2)\)。容易考慮到有兩種優化條件,分別為 “平行線” 和 “多線共點”。對於前者可以按斜率進行等價類划分,在類間進行兩兩求交;對於后者需要額外計算判斷是否共點,也會帶來常數的提升。
因此筆者仍然選擇暴力解法,枚舉每pair的幾何對象組合,計算交點,使用哈希集合維護去重。
點的維護
三種交點有着三種不同的公式。首先將它們的通式推導出來。具體的推導和公式可以參照:
- 線-線交點: Wikipedia
- 線-圓交點: Wolfram MathWorld
- 圓-圓交點: Stack Overflow
其中,線線交點可以寫成如下形式:
其中\(x_1,x_2,y_1,y_2\)均為整數表達式的運算結果。於是,設計一個有理數類存儲線-線交點的坐標(不使用double的理由見上節)以便於哈希和比較。
然而,線-圓交點和圓-圓交點的形式為:
其中\(x_\bullet,y_\bullet, \Delta\)均為整數表達式的運算結果。當\(\Delta\)為完全平方數時,該式化簡為有理數形式;否則,該式為無理數。
考慮到有理數不可能等於無理數,因此首先檢查\(\Delta\)是否能開根,若可以則使用有理數類,否則直接求值使用double存儲(此處可以使用double的理由見上節)。
求交點:四種二元關系
假設有類Line和類Circle存儲兩類幾何對象。然而求交點需要(Line, Line), (Line, Circle), (Circle, Line), (Circle, Circle)四種組合。
一開始,我傾向於使用父類和子類維護不同的幾何對象,但發現即使使用重載和重寫,代碼效率和可讀性並沒有明顯的提高。
后來通過查找資料,我在 這篇問答帖子 中找到了最佳的解決方案:使用std::variant和std::visit來優美地實現“多態二元函數”。
std::variant相當於一種升級版的union類型,可以安全地存儲不同類型的對象,可以通過index()方法取得某對象的類型,也可以通過std::get<type>(x)取得variant對象x的值。不僅如此,它還支持使用std::visit(visitor, vars)去自動處理各種類型為參數的函數調用(可以參考cppReference.com),正好和該問題的需求相匹配!其中,visitor是一個封裝了callable函數的結構體,能支持每個參數的每種類型組合,vars是待傳入的variant參數列表。
具體實現請見“代碼說明”。
交點集合的維護(去重)
C++中的set基於BST實現,在此我們並不需要對點進行排序和有序組織,因此考慮使用unordered_set來維護點,相當於Java中的HashSet。要使用unordered_set,必須提供哈希函數和判等函數。
對於點來說,有x和y兩個坐標,在哈希時只需將兩個坐標獲取哈希值再進行組合即可,在判等時需要注意先驗條件“有理數不等於無理數”以保證正確性!
而坐標有整數數對(有理數)和浮點數(double)兩種形式,在判等時應當注意判斷等號兩端坐標分別點類型。
注意到在哈希和判等前,坐標必須進行化簡(\(\frac{8}{6}=\frac{4}{3}\))和標准化(\(\frac{-0}{8}=\frac{0}{1}\)),因此使用輾轉相除法求最大公約數,再消去該因子。
由於這里分子和分母有可能較大,因此普通的輾轉相除法可能效率較低。一個優化的輾轉相除法可以參照《編程之美》2.7節《最大公約數問題》。該算法檢查兩數的奇偶性,當至少有一個數為偶數的情況下,數值的規模將會直接減半。當兩個數為奇數時,算法避免了較慢的除法和取模運算,而是使用輾轉相減,使得再次出現偶數。因此,該算法的最壞時間復雜度為\(O(\log_2(\max(x,y))\),十分理想。
具體實現請見“代碼說明”。
設計
類與數據結構
如上文所說,基礎的數據結構是坐標,支持兩種形式的數,構造時化簡和標准化。支持hashCode。
class Coordinate {
// Case 1: Rational Number = A / B (long-long / long-long)
// Case 2: Float Number = C (double)
private:
void simplifyRational();
void simplifySqrt(ll add, ll coeff, ll insqrt, ll btm);
public:
bool isRational, isNan;
ll top, bottom;
double value;
Coordinate(ll tp, ll btm); // tp / btm
Coordinate(ll a, ll b, ll c, ll btm); // ( a + b * sqrt(c) ) / btm -----> (1) A / B or (2) double value
std::size_t hashCode() const ;
};
坐標組成點,點可以求哈希值和判等:
class Point {
public:
Coordinate x, y;
Point(Coordinate xx, Coordinate yy);
};
struct hashCode_Point {
std::size_t operator()(const Point &point) const ;
};
struct equals_Point {
bool operator()(const Point &lhs, const Point &rhs) const ;
};
幾何對象有直線和點,它們之間支持兩兩求交點:
class Line {
public:
Line(int x1, int y1, int x2, int y2);
int p1_x, p1_y;
int p2_x, p2_y;
};
class Circle {
public:
Circle(int x, int y, int r);
int center_x, center_y;
int radius;
};
std::vector<Point> intersection(Line x, Circle y);
std::vector<Point> intersection(Circle x, Line y);
std::vector<Point> intersection(Line x, Line y);
std::vector<Point> intersection(Circle x, Circle y);
最后使用基於哈希的unordered_map維護點集:
std::unordered_set<Point, hashCode_Point, equals_Point> container;
代碼說明
坐標與交點
優化的最大公約數算法,為化簡作准備:
ll fastGcd(ll x, ll y) {
if (x < y)
return fastGcd(y, x);
if (!y)
return x;
// 使用位運算以避免較慢的除法和取模
if ((x >> 1u) << 1u == x) {
// 兩個偶數 或 一奇一偶
if ((y >> 1u) << 1u == y) return (fastGcd(x >> 1u, y >> 1u) << 1u);
else return fastGcd(x >> 1u, y);
} else {
// 一奇一偶 或 兩個奇數
if ((y >> 1u) << 1u == y) return fastGcd(x, y >> 1u);
else return fastGcd(y, x - y);
}
}
標准化有理數,檢查是否能開根號將“無理數”化為有理數:
void Coordinate::simplifyRational() {
// 化簡成分母為正數、分子符號不定的最簡分數
assert(isRational);
// now bottom != 0
// 6 / -4 --> -3 / 2
if (bottom < 0) {
top = -top;
bottom = -bottom;
}
// now bottom > 0 ---> gcd != 0
ll gcd = fastGcd(abs(bottom), abs(top));
top /= gcd;
bottom /= gcd;
}
void Coordinate::simplifySqrt(ll add, ll coeff, ll insqrt, ll btm) {
// 檢查是否可開根號成有理數
ll tryRoot = sqrt(insqrt);
if (tryRoot * tryRoot == insqrt) { // actually a RATIONAL !
isRational = true;
top = add + coeff * tryRoot;
bottom = btm;
simplifyRational();
}
}
坐標數值的哈希函數與點的哈希函數:
std::size_t Coordinate::hashCode() const {
if (isRational) {
std::size_t h1 = std::hash<long long>{}(top);
std::size_t h2 = std::hash<long long>{}(bottom);
// 參考標准庫的寫法,將兩子成員的哈希值合並
return ((h1 ^ (h2 << 1u)) << 1u) | 1u;
} else {
std::size_t h = std::hash<double>{}(value);
// 有先驗知識:有理數≠無理數
// 有理數的哈希值末尾為1,無理數的哈希值末尾為0
return (h << 1u) | 0u;
}
}
struct hashCode_Point {
std::size_t operator()(const Point &point) const {
std::size_t h1 = point.x.hashCode();
std::size_t h2 = point.y.hashCode();
// 參考標准庫的寫法,將兩子成員的哈希值合並
return h1 ^ (h2 << 1u);
}
};
點的判等函數:
struct equals_Point {
bool operator()(const Point &lhs, const Point &rhs) const {
// 按成員比較。注意有先驗知識:有理數≠無理數
bool x_eq = false, y_eq = false;
if (lhs.x.isRational) {
x_eq = (rhs.x.isRational & (lhs.x.top == rhs.x.top) & (lhs.x.bottom == rhs.x.bottom));
} else {
// 無理數取小數點八位進行比較
x_eq = ((!rhs.x.isRational) & ((long long)(lhs.x.value * 1e8) == (long long)(rhs.x.value * 1e8)));
}
if (lhs.y.isRational) {
y_eq = (rhs.y.isRational & (lhs.y.top == rhs.y.top) & (lhs.y.bottom == rhs.y.bottom));
} else {
y_eq = ((!rhs.y.isRational) & ((long long)(lhs.y.value * 1e8) == (long long)(rhs.y.value * 1e8)));
}
return x_eq & y_eq;
}
};
直線與圓求交點
兩兩求交點,共四種組合的自動匹配:
// 使用 std::variant 和 std::visit 來實現“多態二元函數” !
// 重載四種組合
std::vector<Point> intersection(Line x, Circle y);
std::vector<Point> intersection(Circle x, Line y);
std::vector<Point> intersection(Line x, Line y);
std::vector<Point> intersection(Circle x, Circle y);
// 類型的定義,相當於 union
using Geometry = std::variant<Line, Circle>;
// 重載()運算符以實現類型匹配
struct interset_visitor {
template<typename Shape1, typename Shape2>
std::vector<Point> operator()(const Shape1 &lhs, const Shape2 &rhs) const {
return intersection(lhs, rhs);
}
};
// 定義hashSet,傳入哈希函數和判等函數
std::unordered_set<Point, hashCode_Point, equals_Point> container;
for (int i = 0; i < objCount; ++i) {
for (int j = i + 1; j < objCount; ++j) {
// 使用 std::visit 重定向四種重載的參數組合
std::vector<Point> intersections = std::visit(interset_visitor{}, (*objs)[i], (*objs)[j]);
for (Point p: intersections)
container.insert(p);
}
}
單元測試
為使程序跨平台且具有較好的可拓展性,筆者沒有采用VS自帶的單元測試框架,而是使用了其支持的 GoogleTest。
筆者針對坐標&點、幾何&求交這兩個主要功能和數據單元進行了數十項單元測試,測試點主要功能點如下所示:
- 坐標和點的構造與化簡 GoogleTest Code
- 有理數的構造
- 無理數的構造和求浮點值
- 有理數的化簡
- 復雜式化簡成有理數
- 復雜式無法化簡成有理數
- 分子分母各個位置上的負數、0、正數、極小值、極大值
- 非法坐標(交點在無窮遠)
- 隨機參數對象
- 兩個幾何對象求交點 GoogleTest Code
- 平行於坐標軸的直線
- 非平凡的直線
- 交點為有理數的直線
- 交點為有理數的線-圓和圓-圓
- 交點為無理數的線-圓和圓-圓
- 線-圓相交、相切、相離
- 圓-圓相交、內外切、內外離
- 隨機參數對象
運行結果為:

筆者使用Wolfram Alpha來輔助調試和獲取正確答案:

性能改進
筆者使用VS 2019 Community進行了效能分析測試,第一次測試結果如下:

可以看到,operator <<占了很多的時間,導致判等函數占用很多時間,同時程序運行超時。
這是因為為了簡單起見,在哈希表的判等中,筆者使用單元測試時驗證過的輸出函數將對象轉換成字符串,再進行字符串的比較。這樣時間主要浪費在了構造字符流、構造字符串和比較字符串上。
因此,筆者對其進行了改進,將使用輸出到字符串再比較替換成了按邏輯比較成員變量:
struct equals_Point {
bool operator()(const Point &lhs, const Point &rhs) const {
/* TIME-COSTING !!!
std::ostringstream outstream1, outstream2;
outstream1 << lhs;
outstream2 << rhs;
return outstream1.str() == outstream2.str();
*/
bool x_eq = false, y_eq = false;
if (lhs.x.isRational)
x_eq = ...;
else
x_eq = ...;
if (lhs.y.isRational) ...
return x_eq & y_eq;
}
};
第二次測試的結果如下:

可以看出,現在程序的主要運行時間花費分布十分合理,主要在求交點、構造交點、化簡交點、求最大公約數這一條調用鏈上。程序的運行時間也從100s縮短到了19s。
代碼風格與質量
筆者使用VS 2019 Community進行了代碼質量分析(Microsoft建議的分析),改正代碼后的結果如下:

其中關於freopen和scanf的警告,在此次作業確保調用方式正確、輸入數據正確的情況下,筆者為了效率和性能,沒有將其替換成freopen_s、scanf_s等,也沒有增加相應的代碼處理它們的返回值。
最后一條警告的對象是下面一條語句:
ll insideSqrt;
...
ll possibleRoot = sqrt(insideSqrt);
工具警告我們將double轉成long long可能丟失數據,但我們明確知道sqrt()內部的值是long long型的整數,且取值范圍在\(\pm4\times 10^{10}\)內,開根號后取值范圍必定不會變大到與double的取值范圍相當,因此筆者明確知道此處的寫法是安全的且符合程序員本意的,因此有意忽略。
