BUAA_OO_第三單元總結
OO第三單元總結
第三單元要求瞭解JML語法和語義,並根據JML給出的規格編寫程式碼,從而實現一個簡單的社交關係模擬和查詢系統,關鍵在於要準確的理解JML規格。
一、架構設計與演算法效能優化
整體圖模型建構
這一單元作業的背景是一個社交網路,層次有三層:Network
,Group
和Person
,本質就是一個圖,其中每一個person
就是一個節點,relation
就是節點之間的邊,Network
中的一系列方法都是用來維護這個圖的方法,在完成Network
中的方法時也藉助了很多資料結構時所學的關係到圖的演算法。
關於Network
中方法的實現,當然最準確和最保險的策略就是仔細閱讀JML規格,一句一句翻譯實現,只要與JML一模一樣就一定不會出錯,但是這並不是最好的策略。對於簡單的方法這樣做沒有大問題,但是像isCircle
所以在完成這一單元的作業時,我更傾向於先理解再下手,因為這次實現的就是一個社交網路,裡面的很多功能其實與現實生活中是很像的,比如傳送訊息、傳送表情包、存表情包這一系列的操作……所以我採用的方法時,首先弄明白已給出的各個類之間的關係,明白person
和relation
這些邊與節點之間的關係,然後看方法名猜功能,首先思考這個方法要幹什麼,與現實生活裡的場景(比如發微信)相聯絡,思考實現這個方法的必要性與意義。當自己對這個功能大致有一個認識之後,再讀JML進行驗證以及補充完善,這樣能大大的減少花費在閱讀JML上的時間。
容器選擇
為了使用訪問更加方便,我使用了HashMap
作為容器,如MyPerson
的acquaintance
,MyNetwork
的people
、groups
、messages
等屬性。
除此之外,Hashmap
中的key
與value
屬性也可以自行構造類來代替,這樣更有靈活性。
hw9
UML圖
路徑壓縮並查集
針對isCircle
方法,拒絕了dfs
:本身時間複雜度就高,queryBlockSum
按照規格的實現方法甚至還會調isCircle
,風險較大。
為了降低時間複雜度,所以選取了討論區助教提供的並查集方法,具體實現與助教討論曲中給出的思路一致,建立find
與merge
的方法。
但是我在進行路徑壓縮優化的時候遇到了一點問題。
我第一次實現的find
方法是這樣的,這裡面fa
是一個HashMap
型別的,key
儲存的是節點的id
,value
儲存的是節點父節點的id
:
public int find(int id) {
int father = fa.get(id);
if(father == id) {
return id;
} else {
return find(father);
}
}
由於採用了上面這種方法後,對節點的父節點的指向並沒有做出改變,僅僅是用其中一個直接指向另一個的father
而已,所以如果測試資料比較刁,構建的樹的深度就會很大,從而遍歷的深度增加,就會很慢。
所以我思考了之後,決定要採用路徑壓縮的方法,程式碼就變成了下面這樣:
public int find(int id) {
if(fa.get(id) != id) {
fa.put(id, find(fa.get(id)));
}
return fa.get(id);
}
採用以上這個方法,把所有的節點都指向根節點了,按理說會減少遞迴次數,效能應該會增強才對,但是在bug修復時卻發現這樣的效能更弱了。
後來跟助教交流過之後,分析的原因是,有可能對map
的操作變多了 ,可能是呼叫fa.get(id)
的次數過多,第一個方法裡是獲取了一個fa.get(id)
然後儲存在father
這個區域性變數裡之後再使用,但是第二個方法最壞的情況下會呼叫三次,可能是這個地方造成了常數的瓶頸,所以應該試一試把一些東西用區域性變數存起來。
也就是如果採用以上的方法,可能會在人多的時候造成棧溢位。
於是我又改成了下面這樣,先將find其父節點的返回值儲存,再替換和返回:
public int find(int id) {
if (fa.get(id) == id) {
return id;
}
int ans = find(father.get(id));
father.put(id, ans);
return ans;
}
hw10
UML圖
最小生成樹——Kruskal演算法
針對queryLeastConnection
這個方法,要求某點可達的所有點組成的自小生成樹。關於最小生成樹可選的方法有Prim
演算法和Kruskal
演算法,這裡我選擇的是Kruskal
演算法,維護了邊的序列,實現了判斷兩個點是否可以連通的方法。
根據圖的一個定理,那麼如果一個最小連通圖有n
個點,那麼他的最小生成樹一定有n-1
條邊。所以這個演算法的主要思路是,首先將所有的邊按照他們各自的權重大小進行一個排序,之後從大到小依次選擇每條邊。如果這條邊加入後沒有出現迴路那麼就加入,否則捨棄。這樣的話,需要維護幾個方法有:
- 判斷加入新的一個邊之後是否會出現迴路——維護並查集,表示與某一點連通的所有點,要求加入的邊兩端不在同一集合內。
- 按照邊的權重排序——維護邊序列,在這個序列裡邊有序排列,可以查詢合適位置進行插入。
hw11
UML圖
堆優化的Dijistra
這個主要針對的是sendIndirectMessage
這個方法,這個方法要求的是兩個點之間的最短路徑,關於最短路徑最經典的方法是Dijistra
演算法。Dijistra
演算法使用了廣度優先搜尋解決賦權有向圖或者無向圖的單源最短路徑問題,演算法最終得到一個最短路徑樹。該演算法常用於路由演算法或者作為其他圖演算法的一個子模組。
Dijkstra
演算法採用的是一種貪心的策略,宣告一個數組dis
來儲存源點到各個頂點的最短距離和一個儲存已經找到了最短路徑的頂點的集合T
;初始時,原點s
的路徑權重被賦為0
(dis[s] = 0)
。若對於頂點s
存在能直接到達的邊(s,m)
,則把dis[m]
設為w(s, m)
,同時把所有其他(s
不能直接到達的)頂點的路徑長度設為無窮大。
初始時,集合T
只有頂點s
。然後,從dis
陣列選擇最小值,則該值就是源點s
到該值對應的頂點的最短路徑,並且把該點加入到T
中,此時完成一個頂點,然後,我們需要看看新加入的頂點是否可以到達其他頂點並且看看通過該頂點到達其他點的路徑長度是否比源點直接到達短,如果是,那麼就替換這些頂點在dis
中的值。然後,又從dis
中找出最小值,重複上述動作,直到T中包含了圖的所有頂點。
二、自測策略與bug分析
Junit測試程式
本單元的指導書中建議用Juint
單元測試測試程式,我在測試時也採用了這種方法,Juint
的單元測試是利用一個相關外掛自動生成測試檔案,並且在這個測試檔案中針對要測試的方法編寫測試方法。具體是在該方法內,新建一個待測試的類,在該測試方法中先建立相關環境,再將測試方法得到的結果返回值同正確結果返回值比較。
JUnit
中有兩個基本物件:
-
TestCase
:TestCase
可以為測試提供一組方法,在建立測試程式的時候繼承TestCase的類,按照需要編寫自己的測試方法即可。後來從跟同學交流發現這個可以在IDEA的外掛幫助下快速完成,節省了大量的時間在編寫測試用例上。 -
TestSuite
:TestSuite
由幾個TestCase
或者TestSuite
組成,使用TestSuite
可以建立一個用於測試的樹形結構。
但是JUnit的測試需要基於對於JML完全理解的基礎上,並且需要提前構造好資料。
比較遺憾的是,由於自己有點偷懶的心態,過了弱測中測之後就不想再自己測試了,所以強測被幹的很慘Orz。
第三單元是我提交次數最少的一個單元,因為弱測中測實在太弱了,以至於給我了一種像交設計文件一樣只要交上去就對的錯覺,但是這種寫程式碼時的輕鬆真的是有代價的……
對拍
由於本單元不像前兩個單元,大家的輸出結果在正確的情況下一定是相同的,所以找同學對拍應該是最簡單有效的方式。
讀程式碼
因為這一單元有JML的限制,所以大家的程式碼結構都不會相差很大,有時直接閱讀程式碼也是很好的debug和互測策略。
Bug分析
我在強測和互測中出現的bug全都是tle引起的,由於一開始沒有采用並查集的演算法而是使用ArrayList
實現儲存,在第一次作業的時候效能還是不錯的,到第二次和第三次作業的時候,劣勢就顯現出來了,所以我後來改變了原有的寫法,採用了助教提供的並查集方法。在第三次作業的bug修復時,我採用了堆優化來提高效能。
三、擴充套件任務
假設出現了幾種不同的Person
- Advertiser:持續向外傳送產品廣告
- Producer:產品生產商,通過Advertiser來銷售產品
- Customer:消費者,會關注廣告並選擇和自己偏好匹配的產品來購買 -- 所謂購買,就是直接通過Advertiser給相應Producer發一個購買訊息
- Person:吃瓜群眾,不發廣告,不買東西,不賣東西
如此Network可以支援市場營銷,並能查詢某種商品的銷售額和銷售路徑等 請討論如何對Network擴充套件,給出相關介面方法,並選擇3個核心業務功能的介面方法撰寫JML規格(借鑑所總結的JML規格模式)
相關介面方法
前三種person
均可以繼承Person
類,同時增加Product
類,表示商品,每個product
都有自己的id
,並且advertiser
有一定數量的product
快取。
JML規格撰寫
setPreference
/*@ public normal_behavior
@ requires (\exists int i; 0 <= i && i < people.length;
@ (Personid == people[i].getId()) && (people[i] instanceof Customer))
@ && (\exists int i; 0 <= i && i<= products.length;
@ products[i].getId() == ProductId);
@ assignable getPerson(personId).preferences;
@ ensures getPerson(personId).prefer(ProductId);
@ ensures (\forall Product i;\old(getPerson(PersonId).prefer(i));
@ getPerson(PersonId).prefer(i));
@ public exceptional_behavior
@ signals (PersonIdNotFoundException e) !(\exists int i;
@ 0 <= i && i < people.length;people[i].getId() == id &&
@ people[i] instanceof Customer);
@ signals (ProductIdNotFoundException e) !(\exists int i;
@ 0 <= i && i < products.length;products[i].getId() == ProductId);
@ */
public void setPreference(int PersonId, int ProductId) throw PersonIdNotFoundException, ProductIdNotFoundException;
produce
/*@ public normal_behavior
@ requires (contains(id1) && (getPerson(id1) instanceof Producer));
@ requires getPerson(id1).containsProduct(id2);
@ ensures (getPerson(id1).getProduct(id2).amount =
@ \old(getPerson(id1).getProduct(id2).amount) + 1);
@ ensures (getPerson(id1).productList.length ==
@ \old(getPerson(id1).productList.length));
@ public normal_behavior
@ requires (contains(id1) && (getPerson(id1) instanceof Producer));
@ requires !getPerson(id1).containsProduct(id2);
@ ensures getPerson(id1).getProduct(id2).amount = 1;
@ ensures (getPerson(id1).productList.length ==
@ (\old(getPerson(id1).productList.length) + 1));
@*/
public void produce(int id1, int id2);
purchase
/*@ public normal_behavior
@ requires containsProduct(productId);
@ requires getPerson(id2).getProduct(productId).amount > 0;
@ requires contains(id1) && (getPerson(id1) instanceof Customer);
@ requires contains(id2) && (getPerson(id2) instanceof Producer);
@ ensures getPerson(id1).money =
@ (\old(getPerson(id1).money) - getProduct(productId).getValue);
@ ensures getPerson(id2).money =
@ (\old(getPerson(id2).money) + getProduct(productId).getValue);
@ ensures getPerson(id2).getProduct(productId).amount =
@ (\old(getPerson(id2).getProduct(productId).amount) - 1);
@*/
public void purchase(int id1, int id2, int productId);
四、學習體會
這一單元的難度明顯比前兩個單元小了很多,重點考察的也是和圖相關的一系列演算法,與之前作業不同的是,將自然語言描述換為了JML規格描述,在理解了JML規格之後,重點也就似乎回到了資料結構的相關知識上。
JML語言最大的優勢應該就是表述清晰,不會出現歧義,因此這次的作業只要按照JML嚴格執行,就不會出現除了TLE之外的錯誤,但是JML語言理解起來確實比較困難,尤其是涉及到比較複雜的函式,就會出現多層括號巢狀的JML規格,讀起來確實有點頭疼。在實驗和研討課裡也寫過JML規格,寫JML也是難度不小的一件事,因為要充分考慮各種可能出現的情況,並且要用JML正確表達想法,不然一旦JML出錯,就會造成很大的問題。
總體來說這一單元難度還是小的,但是其實學完了之後感覺收穫不是很大。相比上一個電梯單元的各種陰間錯誤,這一單元是程式碼量太大以至於給我感覺是有點重複勞動了,有點付出和收穫不成正比的感覺。尤其checkstyle限制檔案總行數不能超過500行,但是要實現的方法確實很多,為了程式碼風格拿高分,我在減少行數的過程中,做出了許多違背自己最初想法的改動,犧牲了程式碼的可讀性來滿足程式碼風格,我覺得有點本末倒置了。
JML畢竟只是一種規格語言,是否精通JML似乎沒有那麼重要,我覺得更重要的是通過這次作業,要掌握基於規格實現程式碼的思想,以及掌握程式碼寫作規範。