2022面向物件設計與構造第三單元總結
一、測試策略
本單元作業我基本的測試策略就是單元測試 + 情況覆蓋
- 單元測試:使用JUnit工具,對每一個類建立一個測試類,然後再在測試類內編寫各方法對應的測試方法。
- 情況覆蓋:由於本單元需要實現的介面都由JML描述,所以在編寫測試方法時,只需要覆蓋各方法的JML中寫明的各種情況即可。比如對於Network介面中的storeEmojiId方法,它的JML描述為:
/*@ public normal_behavior @ requires !(\exists int i; 0 <= i && i < emojiIdList.length; emojiIdList[i] == id); @ assignable emojiIdList, emojiHeatList; @ ensures (\exists int i; 0 <= i && i < emojiIdList.length; emojiIdList[i] == id && emojiHeatList[i] == 0); @ ensures emojiIdList.length == \old(emojiIdList.length) + 1 && @ emojiHeatList.length == \old(emojiHeatList.length) + 1; @ ensures (\forall int i; 0 <= i && i < \old(emojiIdList.length); @ (\exists int j; 0 <= j && j < emojiIdList.length; emojiIdList[j] == \old(emojiIdList[i]) && @ emojiHeatList[j] == \old(emojiHeatList[i]))); @ also @ public exceptional_behavior @ signals (EqualEmojiIdException e) (\exists int i; 0 <= i && i < emojiIdList.length; @ emojiIdList[i] == id); @*/ public void storeEmojiId(int id) throws EqualEmojiIdException;
由JML可知,這個方法有兩種執行的可能,一種是emojiIdList不存在輸入的id,此時將id加入emojiIdList,並將對應的emojiHeatList置零。另一種是emojiIdList存在輸入的id,此時則會丟擲EqualEmojiIdException異常。基於上面的複習,我編寫了以下的測試方法:
@org.junit.jupiter.api.Test void storeEmojiIdTest() throws Exception { network.storeEmojiId(1); System.out.println(network.containsEmojiId(1)); network.storeEmojiId(1); }
前兩行是為了測試第一種情況,第三行是為了測試第二種情況。若方法實現無誤,則測試方法執行之後應該會先輸出一個true,然後再丟擲一個EqualEmojiIdException異常。
當然,這個測試方法並不完備,它並沒有全面覆蓋JML中的每一個ensure語句,我只是以它舉例說明測試的基本思路。
二、架構設計
由於本單元作業的基礎架構已由官方包決定,所以下面主要分析高時間複雜度方法的優化策略。
2.1 第一次作業
2.1.1 MyNetwork類isCircle()方法的優化
- 優化前:
- 演算法:深度優先搜尋。
- 時間複雜度:O(n!)
- 優化策略:
使用並查集,用樹來組織並查集,在MyPerson類中新增一個father屬性,表示該物件在並查集樹中的父節點,初始值為自身。當呼叫addRelation方法時,首先判斷該relation兩端的person在不在同一個並查集樹中。若在,則直接新增,否則合併兩個樹。合併的原則是:將較高的那棵樹的根節點作為新樹的根節點,另一棵樹的根節點作為新樹根節點的子節點。 - 優化後時間複雜度:
addRelation時間複雜度不變,isCircle時間複雜度降為O(log2n),整體時間複雜度大大降低。
2.1.2 MyNetwork類queryBlockSum()方法的優化
- 優化前:
- 演算法:二重迴圈 + isCircle
- 時間複雜度:O(n^2*log2n)
- 優化策略:
為MyPerson類設定一個minIndex屬性,表示該MyPerson物件所在的連通分量中所有MyPerson物件在people陣列中的最小下標值。物件剛加入Network時,minIndex設為此時該物件的下標。當不同連通分量合併時,新的根節點的minIndex值設為原連通分量兩個根節點的最小minIndex值。而查詢一個MyPerson物件的block時,只需要找到該物件所在連通分量的根節點,查詢根節點的minIndex值,將起與此物件的下標值相比較,若兩者相等,則說明該物件之前的所有MyPerson物件都不與其連通,block為真。 - 優化後時間複雜度:
addRelation時間複雜度不變,queryBlockSum時間複雜度降為O(log2n),整體時間複雜度大大降低。
2.2 第二次作業
2.2.1 MyNetwork類queryLeastConnection()方法的優化
- 優化前:
- 演算法:普通的Kruskal演算法
- 時間複雜度:O(mn)
- 優化策略:
建立一個Relation類,用來儲存Network中的關係(即圖的邊),再在MyNetwork中增加一個connectGroups屬性,用來儲存各連通分量根節點和分量邊集合的一一對映。當addRelation時,首先判斷該relation兩端的person在不在同一個連通分量中。若在,則直接將新的relation加進對應的connectGroups項,否則合併兩個根節點對應的relation集合,再把新的relation加進去。這樣,使用Kruskal演算法時就可以先直接得到該點所處的連通分量的所有邊,然後使用該邊集進行快排。此外,同樣可以將並查集應用到Kruskal演算法連通分量的檢驗中,具體實現不再贅述。 - 優化後時間複雜度:
addRelation時間複雜度不變,queryLeastConnection的時間複雜度降為O(mlog2n),整體時間複雜度明顯降低。
2.2.2 MyGroup類getValueSum()方法的優化
- 優化前:
- 演算法:二重迴圈 + isLinked
- 時間複雜度:O(n^3)
- 優化策略:
為MyPerson類設定一個inGroups屬性,它是一個列表,表示該物件所在的所有群組。同時為MyGroup設定一個valueSum屬性。當向群組加人時,先把該群組的id加進該person的inGroups列表,然後遍歷該person的所有熟人,如果熟人也在該群組裡,則將valueSum加上二倍的權值,從群組刪除人時同理。特別地,當addrelation時,如果兩端的person存在公共群組,則將這些群組的valueSum都加上二倍權值。同時,為了儘可能地減小時間複雜度,我將能使用Hashset/hashmap的地方都做了替換。 - 優化後的時間複雜度:
考慮到指導書給出的限制:一個person所在是群組最多為20個,所以可以認為addRelation時間複雜度不變,addToGroup和delFromGroup的時間複雜度增加到O(n),getValueSum的時間複雜度降為O(1),整體時間複雜度大大降低。
2.3 第三次作業
2.3.1 MyNetwork類sendIndirectMessage()方法的優化
- 優化前:
- 演算法:普通的單源點最短路徑演算法(Dijkstra演算法)
- 時間複雜度:O(n^2 + m)
- 優化策略:
使用小根堆來維護Dest陣列,降低尋找最小值操作的時間複雜度。 - 優化後時間複雜度:
sendIndirectMessage的時間複雜度降為O((m + n) * log2n),考慮到點和邊的實際新增方式,所以時間複雜度實際上是從O(n^2)降為了O(nlog2n),整體時間複雜度明顯降低。
三、程式碼效能問題
本單元我僅在第一次作業出現過一次效能問題,就是前文已述的queryBlockSum()方法,此處不再贅述。
四、Network擴充套件
4.1 基本擴充套件思路
新建MyAdvertiser、MyProducer、MyCustomer類,分別用來表示Advertiser、Producer、Customer,並在MyNetwork中建立advertisers、producers、customers屬性。為了實現這些新增人物的特殊屬性,MyAdvertiser應該有一個儲存當前正在推送廣告的產品id屬性和僱主id屬性,以及customers和producers的一個副本;MyProducer的屬性應該包含產品的id、價格、銷售量,以及customers的一個副本;MyCustomer的屬性應該有自己收到的所有廣告組成的列表。同時實現這三個類內部以及MyNetwork的相關方法。
4.2 相關介面方法
- MyAdvertiser
- getter-setter方法
- sendAdvertise方法:向消費者傳送廣告(產品id)
- sendBuyingMessage方法:消費者通過它向生產商傳送購買需求
- MyProducer
- getter-setter方法
- MyCustomer
- getter-setter方法
- buy方法:向廣告商傳送購買需求
- MyNetwork
- setAdvertiser方法:為生產商繫結一個廣告商
- advertise方法:讓廣告商向所有消費者傳送一輪廣告
- buyProduct方法:讓消費者購買一定數量的某產品
- queryProductSales方法:查詢產品銷售額
- queryProductPath方法:查詢產品銷售路徑
- 其它的get*、contains*查詢方法,add*增添方法
4.3 核心業務介面JML
選擇MyNetwork的前三個核心業務功能介面
- setAdvertiser方法:
/*@ public normal_behavior
@ requires (containsProducer(producerId) && containsAdvertiser(advertiserId));
@ assignable getAdvertiser(advertiserId).productId, getAdvertiser(advertiserId).producerId;
@ ensures getAdvertiser(advertiserId).productId == getProducer(producerId).productId;
@ ensures getAdvertiser(advertiserId).producerId == producerId;
@ also
@ public exceptional_behavior
@ signals (ProducerIdNotFoundException e) !containsProducer(producerId)
@ signals (AdvertiserIdNotFoundException e) (containsProducer(producerId) && !containsAdvertiser(advertiserId));
@*/
public void setAdvertiser(int producerId, int advertiserId);
- advertise方法:
/*@ public normal_behavior
@ requires containsAdvertiser(advertiserId);
@ assignable customers[*].receivedAdvertise;
@ ensures (\forall int i; i >= 0 && i < customers.length;
@ (\forall int j; j >= 0 && j < \old(customers[i].receivedAdvertise.length);
@ (\exist int k; k >= 0 && k < customers[i].receivedAdvertise.length; \old(customers[i].receivedAdvertise)[j] == customers[i].receivedAdvertise[k])));
@ ensures (\forall int i; i >= 0 && i < customers.length;
@ (\exist int j; j >= 0 && j < customers[i].receivedAdvertise.length; customers[i].receivedAdvertise[j] == getAdvertiser(advertiserId).productId));
@ ensures (\forall int i; i >= 0 && i < customers.length;
@ customers[i].receivedAdvertise.length = \old(customers[i].receivedAdvertise).length + 1);
@ also
@ public exceptional_behavior
@ signals (AdvertiserIdNotFoundException e) !containsAdvertiser(advertiserId));
@*/
public void advertise(int advertiserId);
- buyProduct方法:
/*@ public normal_behavior
@ requires (containsCustomer(customerId) && getCustomer(customerId).contains(productId) && amount >= 0);
@ assignable getProducer(productId).sales;
@ ensures getProducer(productId).sales == \old(getProducer(productId).sales) + getProducer(productId).price * amount;
@ also
@ public exceptional_behavior
@ signals (CustomerIdNotFoundException e) !containsCustomer(customerId));
@ signals (ProductIdNotFoundException e) (containsCustomer(customerId) && !getCustomer(customerId).contains(productId));
@ signals (AmountException e) (containsCustomer(customerId) && getCustomer(customerId).contains(productId) && amount < 0);
@*/
public void buyProduct(int customerId, int productId, int amount);
五、學習體會
本單元的學習給我帶來了兩大體會:
- 一是考慮問題一定要全面。JML是一個嚴謹性很高的語言,能囊括一個方法的各種情況,但凡具體實現與JML有絲毫偏差,都會產生意想不到的錯誤。因此,在學習JML的過程中,我的考慮問題的思維也不免變得更加嚴謹、全面。
- 而是效能與功能同樣重要。在過去的程式碼實踐中,我一直都只關注自己程式碼的功能是否符合要求,很少去思考演算法是否最優、時間複雜度能否減小、是否存在冗餘操作等問題。本單元的學習讓我意識到了效能的重要性,也為我培養了一些降低時間複雜度的基本思維。