1. 程式人生 > 其它 >BUAA-OO-第三單元總結

BUAA-OO-第三單元總結

北航計算機學院面向物件第三單元總結

由於本單元三次作業非常相似,都是基於上一次的功能和指令進行迭代擴充套件,因此以下總結都以第三次作業為例。

一、 利用JML規格生成資料並測試

本單元作業是契約式程式設計,不需要在架構設計上花太多心思,作業中容易出現Bug的情況是對官方給定的JML理解有誤,以及設計的演算法時間複雜度過高。因此本單元的自動測試基本是為了驗證程式的功能正確性,而壓力測試主要靠針對實現程式碼使用的演算法手動構造資料,比如對並查集、最短路徑的壓力測試等。生成資料後的自動測試比較簡單,與第二單元的多執行緒並行不同,本單元程式的執行結果是固定、可預測的,因此只需要找幾個小夥伴對拍、逐行對比輸出即可。(但要小心大家是不是同一個Bug

生成的資料分別對Message的相關操作、其餘的操作進行測試,主要是因為Message的相關操作相對於其餘的操作而言,結構和功能上都相對獨立一些。

對Message的測試資料

# 首先初始化圖以及表情列表
# add person
for i in range(30):
    instrlist += "ap"
        ……    
# add relation
for i in range(300):
    instrlist += "ar"
        ……
# 增加兩個組
instrlist += "ag"
        ……
for i in range(25):
    
# 25個人在組裡,5個人不在組裡以驗證異常 instrlist += "atg" …… for i in range(15): # 初始15個表請 instrlist += "sei " …… # 對Message的相關指令進行隨機性功能檢測 for i in range(950): instr = random() if instr < 0.4 and len(msgId) != 0: instrlist += choice(["sm","sim"]) …… elif instr < 0.8: cnt
+= 1 if cnt == 5: # 每五個人增加一個msgId缺失,保證對異常丟擲的檢測 msgId.append(randint(1001, 5000)) cnt = 0 type = randint(0, 1) instrlist += choice(['arem','am','anm','aem']) …… elif instr<0.9: instrlist += "qm" …… elif instr<0.95: instrlist += "qp " …… elif instr < 0.97: instrlist += "qrm " …… elif instr<0.98: instrlist+="dce " …… else: instrlist += "cn" ……

對其餘操作的測試資料

除開Message以外指令關聯性較強,因此選擇在同一張圖上進行隨機測試

# 初始化圖
for i in range(people_num):
    instrlist += "ap"
    ……
for i in range(group_num):
    instrlist += "ag"
    ……
# 隨機列舉指令,進行資料生成
for i in range(LENGTH - people_num):
    instr = choice(instrs)
    ……
    if instr == "qci":
      qci_num += 1
    if instr == "qlc":
      qlc_num += 1
    if instr == 'ar':
      Instrlist += ' '
      ……
    elif instr == 'atg' or instr == 'dfg':
      ……
    elif instr == 'qv' or instr == 'qci':
      ……
    ……

而針對時間的壓力測試,本單元只針對qbs指令(是否使用並查集、並查集是否路徑壓縮),qlc指令(是否對最小生成樹樸素演算法進行優化)構造了零圖和完全圖。(第三次互測摸了

二、 架構設計及維護策略

UML類圖

考慮到簡潔性,上述UML類圖沒有包含異常類

架構設計

本單元的架構以及圖的模型,基本上由官方包搭建好了,只要閱讀並正確理解JML描述,大家的架構應該相差不大。閱讀官方包不難發現,本單元作業主要是維護一個社交網路,以Person為圖的節點,以Relation為圖的無向邊。針對這個圖進行諸如:查詢連通、最小生成樹、最短路徑等一系列操作。而我們要重點關注的,應該是使用怎樣的資料結構、演算法、容器來對圖的一些屬性進行維護,以便能夠簡潔、迅速地完成指令對圖的修改和查詢。

維護策略

路徑壓縮的並查集

通過閱讀JML規格,不難發現本單元作業中的qci指令即為查詢圖中兩點是否連通,qbs指令為查詢圖中連通分支數量,qlc指令為查詢包含某點的最小生成樹。這三個查詢都能夠藉助並查集實現。相信並查集的相關實現大家都不陌生,在本單元作業中具體而言就是,在add person時向並查集中新增節點並將其設為一個源點(也就是它所在集合裡的boss),在add relation時檢查是否需要合併兩個集合,更新源點。使用路徑壓縮的寫法能夠防止圖退化成一條鏈時,多次查詢首尾兩個點連通關係可能會導致超時。同時以下給出的寫法採用的是非遞迴的形式,這能避免上面描述的鏈結構時可能出現的爆棧情況。

public int find(int x) {
    int cur;
    int tmp;
    int root;
    root = x;
    while (root != origin.get(root)) {
        root = origin.get(root);//查詢根節點
    }
    cur = x;
    while (cur != root)             //非遞迴路徑壓縮操作
    {
        tmp = origin.get(cur);        //用tmp暫存parent[cur]的父節點
        origin.replace(cur,root);        //parent[x]指向根節點
        cur = tmp;                    //cur指向父節點
    }
    return root;         //返回根節點的值
}

基於基數排序優化的克魯斯卡爾演算法

第二次作業中的qlc指令實際上是查詢包含某節點的最小生成樹,最小生成樹演算法一般為prim和kruskal演算法,但兩演算法演算法的樸素演算法時間複雜度都是O(n²)/O(e²),在本次作業中1e4量級的資料會超時。考慮到作業中稀疏圖出現的概率應該是大於稠密圖的,因此最終選擇使用了kruskal演算法。而針對該演算法的優化就是對遍歷的邊按權值進行排序。其實使用堆排序優化就可以,java還有現成的優先佇列用,但自己想嘗試寫寫看基數排序效果怎麼樣,結果發現不如同學的堆排序(小丑竟是我自己。

具體寫法其實和樸素演算法幾乎一樣,不同的是需要在遍歷前對邊進行排序,而找邊就不需要每次通過遍歷尋找最小邊,直接從當前索引下標開始,尋找能連通兩個非連通分支的邊即可。

public int getValue() {
        // 獲取連通分支中的所有無向邊
        for (Person person1:people) {
            ......
            ArrayList<Person> persons = ((MyPerson) person1).getAcquaintance();
            for (int i = 0;i < persons.size();i++) {
                ......
                edges.addEdge(new Edge(value.get(i),id1,id2));
            }
        }
     // 對邊進行升序排序 edges.radixSort(); ......
while (cnt < sum && index < sumOfEdge) { Edge edge = edges.getEdge(index); // 選擇能將兩個非連通分支連通的最短邊 while (isCircle(edge)) { index++; edge = edges.getEdge(index); } rst += edge.getValue(); ...... //更新並查集
       searchSet.merge(searchSet.find(edge.getFrom()),searchSet.find(edge.getTo()));
...... } return rst; }

堆優化的迪傑斯特拉演算法

第三次作業中的sim指令實際上是查詢兩個連通點之間的最短路徑,最短路徑的演算法有弗洛伊德演算法(時間複雜度O(n³),pass),迪傑斯特拉演算法(樸素寫法時間複雜度O(n²),pass;堆優化後O((m+n)logn),可以考慮)以及SPFA演算法(平均O(m),最壞O(mn)),由於對於網路流的演算法不是很熟悉,同時在網上查閱資料發現有很多前輩指出SPFA演算法的時間複雜度很玄學,因此最後選擇使用了堆優化的迪傑斯特拉演算法。

三、 分析程式碼中的效能問題

比較容易發現的效能問題在維護策略中已經給出,但除此之外這次作業中還有不少可能因為效能問題而超時的地方。

說到底JML僅僅是通過規格化的語言描述了方法和類的功能、執行條件、過程。規格化語言是死板的,但寫程式的人是靈活的,單純依照JML的描述編寫的程式碼很有可能是效能最差的(尤其是比較複雜的方法,涉及到多重迴圈時)。在閱讀JML編寫程式時,不應該不加以思考,盲目地跟從JML的描述coding,而是要充分理解其描述的功能,思考某些迴圈是否真的有必要?某些巢狀迴圈是否能通過別的方法去掉?筆者認為只要在編寫過程中對每個描述持有懷疑態度,就基本不會出現效能問題。

除開維護策略中提到的以外,JML給出的寫法可以優化的地方大概有以下幾點:

  • 善用容器

本單元作業出現的各個類的物件都有其唯一的id,善用HashMapHashSet等容器,可以去除JML中出現的很多迴圈。比如Group類中的hasPerson()方法,JML描述為

ensures \result == (\exists int i; 0 <= i && i < people.length; people[i].equals(person));

看起來是一個O(n)的方法,實際上使用HashMap作為儲存Person的容器,利用HashMap.containsKey()方法,就能優化至O(1)的複雜度。諸如此類的優化本單元作業裡還有很多,這裡就不再贅述。

  • 小心巢狀呼叫

有的函式從表面上看只是O(n)的時間複雜度,然後其中卻暗藏殺機,它在每次迴圈的時候呼叫了某個方法,而當該方法也是0(n)的複雜度時,實際上最後執行下來就會是O(n²)的複雜度,存在TLE的風險。比如Group類中的getAgeVar()方法,JML描述為

ensures \result == (people.length == 0? 0 : ((\sum int i; 0 <= i && i < people.length;(people[i].getAge() - getAgeMean()) * (people[i].getAge() - getAgeMean())) / people.length));

這個描述看上去時間複雜度只有O(n),但它每次迴圈都呼叫了getAgeMean()方法,該方法的JL描述同樣為O(n)的時間複雜度,這樣巢狀呼叫最終就導致了getAgeVar()方法的複雜度變成了O(n²)。實際上第二次作業互測同組就有同學對於qgvs指令沒有考慮函式巢狀調用出現了tle。

四、 Network擴充套件

任務分析

  • 對於Advertiser、Producer、Customer可以分別繼承抽象類Person,共有id、money等屬性,sendMessage()等方法。
  • 對於消費者,可以新增一個commodityType[] commodityPrefer屬性記錄偏好,可根據偏好搜尋商品。
  • 對於Advertiser,可以使用單例的模式,將其視為一個“平臺”會更加方便。
  • 對於advertise、produce、purchase可以分別繼承類Message,將這三個行為視為一個Person物件傳送的資訊。

方法JML

sendAdvertisement()
/*@ public normal_behavior
  @ requires containsMessage(id) && (getMessage(id) instanceof Advertisement);
  @ assignable messages;
  @ assignable people[*].messages;
  @ ensures !containsMessage(id) && messages.length == \old(messages.length) - 1 &&
  @         (\forall int i; 0 <= i && i < \old(messages.length) && \old(messages[i].getId()) != id;
  @         (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))));
  @ ensures (\forall int i; 0 <= i && i < people.length; person[i].likeType(\old(((Advertisement)getMessage(id))).getType) ==> 
  @         ((\forall int j; 0 <= j && j < \old(person[i].getMessages().size());
  @          person[i].getMessages().get(j+1) == \old(person[i].getMessages().get(j))) &&
  @         (person[i].getMessages().get(0).equals(\old(getMessage(id)))) &&
  @         (person[i].getMessages().size() == \old(person[i].getMessages().size()) + 1)));
  @ also
  @ public exceptional_behavior
  @ signals (MessageIdNotFoundException e) !containsMessage(id);
  @ signals (MessageNotAdverException e) containsMessage(id) && !(getMessage(id) instanceof Advertisement);
  @*/
public void sendAdvertisement(int id) throws MessageIdNotFoundException, MessageNotAdverException;
sendPurchase()
/*@ public normal_behavior
  @ requires containsMessage(id) && (getMessage(id) instanceof Purchase);
  @ requires (getMessage(id).getPerson1() instanceof Customer) && (getMessage(id).getPerson2() instanceof Producer);
  @ assignable messages;
  @ assignable getMessage(id).getPerson1().money;
  @ assignable getMessage(id).getPerson2().messages, getMessage(id).getPerson2().money;
  @ ensures !containsMessage(id) && messages.length == \old(messages.length) - 1 &&
  @         (\forall int i; 0 <= i && i < \old(messages.length) && \old(messages[i].getId()) != id;
  @         (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i]))));
  @ ensures (\old(getMessage(id)).getPerson1().getMoney() ==
  @         \old(getMessage(id).getPerson1().getMoney()) - ((Purchase)\old(getMessage(id))).getMoney() &&
  @         \old(getMessage(id)).getPerson2().getMoney() ==
  @         \old(getMessage(id).getPerson2().getMoney()) + ((Purchase)\old(getMessage(id))).getMoney());
  @ ensures (\forall int i; 0 <= i && i < \old(getMessage(id).getPerson2().getMessages().size());
  @          \old(getMessage(id)).getPerson2().getMessages().get(i+1) == \old(getMessage(id).getPerson2().getMessages().get(i)));
  @ ensures \old(getMessage(id)).getPerson2().getMessages().get(0).equals(\old(getMessage(id)));
  @ ensures \old(getMessage(id)).getPerson2().getMessages().size() == \old(getMessage(id).getPerson2().getMessages().size()) + 1;
  @ ensures getProduct(\old((Purchase)getMessage(id)).getProductId()).getNum() == \old(getProduct(\old((Purchase)getMessage(id)).getProductId()).getNum()) + 1;
  @ also
  @ public exceptional_behavior
  @ signals (MessageIdNotFoundException e) !containsMessage(id);
  @ signals (MessageNotPurchaseException e) containsMessage(id) && !(getMessage(id) instanceof Purchase);
  @ signals (PersonNotCustomerException e) containsMessage(id) && (getMessage(id) instanceof Purchase) && 
  @         !(getMessage(id).getPerson1() instanceof Customer);
  @ signals (PersonNotAdvertiserException e) containsMessage(id) && (getMessage(id) instanceof Purchase) && 
  @         (getMessage(id).getPerson1() instanceof Customer) && !(getMessage(id).getPerson2() instanceof Producer);
  @*/
public void sendPurchase(int id) throws
        MessageIdNotFoundException, MessageNotPurchaseException, PersonNotCustomerException, PersonNotAdvertiserException;
querySale()
/*@ public normal_behavior
  @ requires containsProduct(id);
  @ ensures \result == getProduct(id).getNum();
  @ also
  @ public exceptional_behavior
  @ signals (ProductIdNotFoundException e) !containsProduct(id);
  @*/
public /*@ pure @*/ int querySale(int id) throws ProductIdNotFoundException;

五、 學習體會

  • 切實體會到JML語言的嚴謹性帶來的力量,能夠零歧義地描述一個模組的功能和執行流程,我認為是一個很了不起的作品。
  • 同時JML語言一定程度上也存在侷限性和缺點,在面對較為複雜的方法時,JML的閱讀和書寫都會變得非常複雜。以qlc方法為例,剛開始寫第二次作業時沒有注意到指導書上提示的“最小生成樹”演算法,初見qlc方法的JML描述不得不讓我望而生畏。層層剖析它的括號,用紙筆一點點推導,二十多分鐘後才終於恍然大悟,這xx不就是最小生成樹嗎?如果用數學語言甚至一行公式就能描述清楚。。。