天天看點

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

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

項目 内容
班級:北航2020春軟體工程 006班(羅傑、任健 周五) 部落格園班級部落格
作業:設計程式求幾何對象的交點集合 個人項目作業
個人課程目标 系統學習軟體工程,訓練軟體開發能力
這個作業在哪個具體方面幫助我實作目标 實踐個人軟體開發流程(PSP)
項目位址 GitHub: clone/http

個人軟體流程(PSP)

PSP2.1 預估耗時(分鐘) 實際耗時(分鐘)
Planning 20
· Estimate
Development 310 530
· Analysis 30 90
· Design Spec 10
· Design Review
· Coding Standard
· Design 40
· Coding 120
· Code Review
· Test 60 150
Reporting 50
· Test Report
· Size Measurement
· Postmortem & Process Improvement Plan
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,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::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
    • 平行于坐标軸的直線
    • 非平凡的直線
    • 交點為有理數的直線
    • 交點為有理數的線-圓和圓-圓
    • 交點為無理數的線-圓和圓-圓
    • 線-圓相交、相切、相離
    • 圓-圓相交、内外切、内外離

運作結果為:

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

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

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

性能改進

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

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

可以看到,

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;
    }
};
           

第二次測試的結果如下:

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

可以看出,現在程式的主要運作時間花費分布十分合理,主要在求交點、構造交點、化簡交點、求最大公約數這一條調用鍊上。程式的運作時間也從100s縮短到了19s。

代碼風格與品質

筆者使用VS 2019 Community進行了代碼品質分析(Microsoft建議的分析),改正代碼後的結果如下:

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

其中關于freopen和scanf的警告,在此次作業確定調用方式正确、輸入資料正确的情況下,筆者為了效率和性能,沒有将其替換成freopen_s、scanf_s等,也沒有增加相應的代碼處理它們的傳回值。

最後一條警告的對象是下面一條語句:

ll insideSqrt;
...
ll possibleRoot = sqrt(insideSqrt);
           

工具警告我們将double轉成long long可能丢失資料,但我們明确知道

sqrt()

内部的值是long long型的整數,且取值範圍在\(\pm4\times 10^{10}\)内,開根号後取值範圍必定不會變大到與double的取值範圍相當,是以筆者明确知道此處的寫法是安全的且符合程式員本意的,是以有意忽略。