【軟工】個人項目作業——個人軟件流程(PSP)


【軟工】個人項目作業——個人軟件流程(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的幾何對象組合,計算交點,使用哈希集合維護去重。

點的維護

三種交點有着三種不同的公式。首先將它們的通式推導出來。具體的推導和公式可以參照:

其中,線線交點可以寫成如下形式:

\[(x,y)=(\frac{x_1}{x_2},\frac{y_1}{y_2}) \]

其中\(x_1,x_2,y_1,y_2\)均為整數表達式的運算結果。於是,設計一個有理數類存儲線-線交點的坐標(不使用double的理由見上節)以便於哈希和比較。

然而,線-圓交點和圓-圓交點的形式為:

\[(x,y)=(\frac{x_1+x_2\sqrt{\Delta}}{x_3},\frac{y_1+y_2\sqrt{\Delta}}{y_3}) \]

其中\(x_\bullet,y_\bullet, \Delta\)均為整數表達式的運算結果。當\(\Delta\)為完全平方數時,該式化簡為有理數形式;否則,該式為無理數。

考慮到有理數不可能等於無理數,因此首先檢查\(\Delta\)是否能開根,若可以則使用有理數類,否則直接求值使用double存儲(此處可以使用double的理由見上節)。

求交點:四種二元關系

假設有類Line和類Circle存儲兩類幾何對象。然而求交點需要(Line, Line), (Line, Circle), (Circle, Line), (Circle, Circle)四種組合。

一開始,我傾向於使用父類和子類維護不同的幾何對象,但發現即使使用重載和重寫,代碼效率和可讀性並沒有明顯的提高。

后來通過查找資料,我在 這篇問答帖子 中找到了最佳的解決方案:使用std::variantstd::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來輔助調試和獲取正確答案:

cc inter

性能改進

筆者使用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的取值范圍相當,因此筆者明確知道此處的寫法是安全的且符合程序員本意的,因此有意忽略。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM