BUAA OO-Course 2022 Unit3 Summary
BUAA OO-Course 2022 Unit3 Summary
设计策略
由于代码主框架由JML规定好了,在写代码的时候就要考虑以什么顺序来进行。在本单元的三次作业中,我均采用自底向上的方式来理解和完成作业。即,首先完成异常类,之后按照Person
→Group
→Network
的逻辑顺序编写,这样更方便自己理解代码。
第一次作业(hw9)
代码(方法)解读与容器选择
整体代码
本次作业的代码主要实现一个社交网络(network
),每个人(Person
)所属于不同的群体(Group
),他们之间有各种各样的关系。我们很容易可以将社交网络抽象为带权无向图,人抽象为节点,人与人的关系中的value
属性即为边的权值。(第一次作业中暂时看不出Group
和relation
的逻辑关系)
容器选择
/* MyPerson.java */
private final HashMap<Integer, Person> acquaintances = new HashMap<>();
private final HashMap<Integer, Integer> values = new HashMap<>();
/* MyGroup.java */
private final HashMap<Integer, Person> peoples = new HashMap<>();
/* MyNetwork.java */
private final HashMap<Integer, Person> peoples = new HashMap<>();
private final HashMap<Integer, Group> groups = new HashMap<>();
private final DisjointSet<Integer> peoplesIdDisjointSet = new DisjointSet<>();
可以看到,几乎所有与查找、索引有关的容器都选择了HashMap
。但最后在MyNetwork.java
中我采用了一个自定义的类型DisjointSet
,具体见后。
方法解读
注:该部分为我当时写代码过程中的一些标注,主要便于自己理解代码在做什么
怕时间久了就忘了
MyPerson.java
public boolean isLinked(Person person) {
// 判断节点之间是否有边
// 即判断两个人之间是否是熟人(acquaintance)
// 如果传入的person为自己则直接返回true | 但自己的id并不在上面定义的熟人容器acquaintances中
}
public int queryValue(Person person) {
// 访问某条边,返回该边的权值
// 即返回自己与person之间的value
// 如果传入的person为自己则直接返回0
}
public void addAcquaintance(Person person, int value) {
// 该方法不是重载方法
// 添加某条带权边
// 即将person加入熟人acquaintances中,并记录"权值"value
}
MyGroup.java
public int getValueSum() {
// 求本group中所有人之间(如果有的话)边的权值之和,再乘2
}
public int getAgeMean() {
// 返回本group中所有人的平均年龄
}
public int getAgeVar() {
// 返回本group中所有人年龄的方差
}
MyNetwork.java
public void addRelation(int id1, int id2, int value) throws ... {
// 在两节点之间加带权边
// 即在人id1和人id2之间添加双向关系,并设置关系的value
}
public int queryValue(int id1, int id2) throws ... {
// 访问连接id1和id2的边的权值
// 通过调用MyPerson类的queryValue具体实现
}
public boolean isCircle(int id1, int id2) throws ... {
// 判断id1到id2是否连通
// 如果id1 == id2,直接返回true (根据jml描述得到)
}
public int queryBlockSum() {
// 求连通分量的个数,有几个极大连通子图
}
算法使用与优化
-
在
MyGroup
类的getValueSum()
方法中,要求一个顶点集中所有边的权值之和再乘2,我用了两重for循环遍历。但在遍历过程中,不重复地访问两个顶点之间的关系,使遍历次数减少一半(好吧其实复杂度没降),代码如下所示:@Override public int getValueSum() { // return (the sum weight of all edges) * 2 int ret = 0; List<Person> persons = new ArrayList<>(peoples.values()); for (int i = 0; i < persons.size() - 1; i++) { Person pi = persons.get(i); for (int j = i + 1; j < persons.size(); j++) { Person pj = persons.get(j); /* no need to call pi.isLinked(pj) here, * since 0 will return if !pi.isLinked(pj) */ ret += pi.queryValue(pj); } } return ret << 1; // ret * 2 }
当然,这样处理
qvs
指令的复杂度仍然是o(n^2),这导致我在第二次作业的互测中出现了CTLE
的错误。要避免这个问题,在第二层循环时不应该对persons
这个较大的容器进行遍历,而应该对第一层循环中得到的人pi的熟人进行遍历,如果过pi的熟人pj在persons中,则加上pi.queryValue(pj)
。代码应改成这样:public int getValueSum() { // return (the sum weight of all edges) * 2 int ret = 0; List<Person> persons = new ArrayList<>(peoples.values()); for (int i = 0, j = 0; i < persons.size(); i++) { Person pi = persons.get(i); for (Person pk : ((MyPerson) pi).getAcquaitances()) { Person pj = persons.get(j); /* no need to call pi.isLinked(pj) here, * since 0 will return if !pi.isLinked(pj) */ ret += (peoples.containsKey(pk.getId()) ? pi.queryValue(pk) : 0); } } return ret; }
-
在
MyGroup
类计算年龄相关的方法中,通过在每次增加人或减少人时及时修改总年龄sumAge
和总年龄的平方和sumAgePow
,减少遍历开销。计算平均年龄只需要一行代码:return (int) (sumAge / peoples.size());
。在计算年龄方差时,一开始我直接运用公式:
同样实现了O(1)的时间复杂度。但在方差计算式,需要将中间结果的保存为double,防止出现精度问题。代码如下所示:@Override public int getAgeVar() { /* variance: Var(x) = E(x^2) - (Ex)^2; * E(x) = sumAge / peoples.size(); * E(x^2) = sumAgePow / people.size() */ if (peoples.size() == 0) { return 0; } final double Ex = (double) sumAge / (double) peoples.size(); final double Ex2 = (double) sumAgePow / (double) peoples.size(); return (int) (Ex2 - Ex * Ex); }
但后面发现这样与规格有所不同。
/* 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));
规格中在求和时每一项都进行了取整操作,最终结果不一定上面化简结果相同。因此,应在规格所给的取整条件下进行化简,可以得到如下公式:
代码也就能改写如下:public int getAgeVar() { if (peoples.size() == 0) { return 0; } return (int) ((sumAgePow - 2 * (sumAge / peoples.size()) * sumAge + peoples.size() * (sumAge / peoples.size()) * (sumAge / peoples.size())) / peoples.size()); }
-
在
MyNetwork
类中涉及算法的两个方法主要为isCircle()
和queryBlockSum()
。前者求两个节点之间是否连通,后者计算连通分量的个数。对于上述两个问题,我采用了并查集进行实现。首先,我实现了一个支持泛型的并查集类
DisjointSet<T>
,代码如下:public class DisjointSet<T> { /* T SHOULD BE INVARIANT OBJECT!!! */ // value: the father of key private final HashMap<T, T> father = new HashMap<>(); private int blockNum = 0; // the number of different set (different ancestor) private long recentFindDepth; public void add(T t) { // add a new element t, now t is the father of itself father.put(t, t); blockNum++; } public T find(T t) { /* non-recursive path compression */ T ancestor = t; recentFindDepth = 0; while (ancestor != father.get(ancestor)) { ancestor = father.get(ancestor); recentFindDepth++; } T now = t; T temp; while (now != ancestor) { temp = father.get(t); father.put(t, ancestor); now = temp; } return ancestor; } public void merge(T t1, T t2) { if (t1 == t2) { // no need to merge return; } T ancestor1 = find(t1); long findDepth1 = recentFindDepth; T ancestor2 = find(t2); long findDepth2 = recentFindDepth; if (ancestor1 == ancestor2) { // no need to merge as well return; } if (findDepth1 > findDepth2) { father.put(ancestor2, ancestor1); } else { father.put(ancestor1, ancestor2); } blockNum--; } public boolean havaSameAncestor(T t1, T t2) { if (t1 == t2) { return true; } T ancestor1 = find(t1); T ancestor2 = find(t2); return ancestor1 == ancestor2; } public int getBlockNum() { return blockNum; } }
注意到我在并查集中设置了
blockNum
和recentFindDepth
两个属性,分别用于记录集合个数(即不同的祖先的个数)和在调用find()
方法时循环次数。其中,blockNum
维护较简单,只需要在增加节点和合并节点时改变其值。而recentFindDepth
的作用则与并查集中按秩合并的效果类似。上面代码中
find()
方法没采用递归的方式,同时在搜索过程中进行了路径压缩,以减小空间复杂度。而合并方法merge()
就需要用到前面定义的recentFindDepth
,分别记录两个节点到他们祖先节点的"距离",然后将距离短的合并到距离长的祖先那,从而降低了均摊复杂度。回到
MyNetwork
的这两个方法来。在queryBlockSum()
方法中,由于在增加和合并节点时有对blocknum
进行记录,因此可以直接得到连通子图个数。而isCircle()
方法,只需要判断两个节点是否有共同的祖先节点即可。
第二次作业(hw10)
代码(方法)解读与容器选择
整体代码
进一步完善第一次作业中实现的社交网络,新增了MyMessage
类,在MyPerson
类中添加一些属性和简单的getter
和setter
,主要修改在MyNetwork
中几个重要的方法,具体见方法解读部分。
完成作业时可以在这个链接比较两次作业JML的差异,从而快速定位和解决新增需求
容器选择
/* MyPerson.java */
private final List<Message> messages = new ArrayList<>();
/* MyNetwork */
private final TreeSet<Edge<Integer>> edges = new TreeSet<>();
几个根据id得到对象的Hashmap
就不在这里写了,上面主要列出两个实现稍微有些不同的容器。一个是MyPerson
类中用于存储消息的容器。这里我直接用了ArrayList
。阅读MyPerson
类中关于消息的操作后就可以发现,规格中要求实现的getReceivedMessages()
方法需要返回最新加入的4条消息,并不需要根据消息的id索引消息,因此用栈来实现应该是最佳的。但是java官方包中的Stack
类是Vector
的子类,其在操作上的效率实际上不如ArrayList
,因此我直接采用ArrayList
。
第二个容器TreeSet<Edge<Integer>> edges
,是用来存储每一对人与人之间的value
(即每一条无向边的权值)。其中Edge
是自定义的一个边的类,保存两个节点(人)以及边的权值(value),并实现了Comparable
接口。TreeSet
是有序的结合,其内部由红黑树实现,保证查找和插入均摊复杂度较低。值得注意的是,TreeSet
不能包含相同元素,因此在实现Edge
的compareTo
方法时需要保证一定不能返回0,否则可能出现相同权值的边只能保存一个的后果:
@Override
public int compareTo(Edge<T> e) {
if (value != e.value) {
return value - e.value;
}
return 1; // 直接返回1
}
该容器在queryLeastConnection(int id)
方法中用到。
方法解读
这里只列出两个JML相对难理解的方法,均在MyNetwork
中。
public int queryLeastConnection(int id) throws PersonIdNotFoundException {
// 求出id所在连通分量的最小生成树,并返回总权值
}
public void sendMessage(int id) throws
RelationNotFoundException, MessageIdNotFoundException, PersonIdNotFoundException {
// 该方法的JML在homework10中算是最长的了
// 在排除几种异常情况外,首先根据id对消息类型进行分类:
// 类型0:两人socialValue均增加
// 类型1:group的所有人socialValue增加
// 最后记得删掉信息
}
算法使用与优化
本次作业主要涉及的算法就是queryLeastConnection
中的最小生成树算法。由于第一次作业实现了并查集,我采用并查集优化的Kruskal算法。而Kruskal算法是按照权值从小到大一条条把边放到集合中,所以我在MyNetwork
类中维护了一个缓存变量TreeSet<Edge<Integer>> edges
用于有序地存储所有带权边。同时为了获得连通分量的所有节点,我在并查集DisjointSet
中新增了HashMap<T, HashSet<T>> components
属性,以祖先节点为键,该节点所在连通分量的所有节点集合为值。代码如下:
public class DisjointSet<T> { /* T SHOULD BE INVARIANT OBJECT!!! */
// components: 所有连通分量
// key: ancestor node; value: all nodes in this component
// ......
private final HashMap<T, HashSet<T>> components;
private final boolean useComponents;
public void add(T t) {
// add a new element t, now t is the father of itself
father.put(t, t);
if (useComponents) {
components.put(t, new HashSet<T>() {
{
add(t);
}
});
}
blockNum++;
}
//......
public void merge(T t1, T t2) {
// ......
if (findDepth1 > findDepth2) { // ancestor1 be the ancestor
father.put(ancestor2, ancestor1);
if (useComponents) {
components.get(ancestor1).addAll(components.get(ancestor2));
components.remove(ancestor2);
}
} else { // ancestor2 be the ancestor
father.put(ancestor1, ancestor2);
if (useComponents) {
components.get(ancestor2).addAll(components.get(ancestor1));
components.remove(ancestor1);
}
}
blockNum--;
}
// ......
public HashSet<T> getComponent(T t) {
// return the component that node t is in
// pre_condition: useComponents == true
return components.get(find(t));
}
}
这里的useComponents
属性用于判断是否需要维护components
这个变量。在MyNetwork
类的属性中需要维护以得到连通分量的所有节点集合,而在kruskal算法中用到的并查集则不需要维护,减小时空开销。
算法实现如下:
public int queryLeastConnection(int id) throws PersonIdNotFoundException {
// exception handle
HashSet<Integer> nodeSet = peoplesIdDisjointSet.getComponent(id); // 得到id所在连通分量的所有节点
if (nodeSet.size() <= 1) {
return 0;
}
DisjointSet<Integer> nodeDisjointSet = new DisjointSet<>(nodeSet); // 新建一个所有节点孤立的并查集用于kruskal算法
int values = 0;
for (Edge<Integer> e : edges) {
Integer node1 = e.getNode1();
Integer node2 = e.getNode2();
if (nodeSet.contains(node1) && nodeSet.contains(node2)) {
if (!nodeDisjointSet.havaSameAncestor(node1, node2)) {
nodeDisjointSet.merge(node1, node2);
values += e.getValue();
if (nodeDisjointSet.getBlockNum() <= 1) {
break;
}
}
}
}
return values;
}
第三次作业(hw11)
代码(方法)解读与容器选择
整体代码
进一步完善社交网络。实现Message
的几个子类,同时在Network
增加几个主要操作。
容器选择
本次作业基本上没加什么新容器,值得一提的是Network
接口中有如下两个JML规格:
@ public instance model non_null int[] emojiIdList;
@ public instance model non_null int[] emojiHeatList;
两个数据规格变量并不意味着要用两个容器来存储,我选用了Hashmap
来保存。这体现了JML规格与实现的分离。
方法解读
public int sendIndirectMessage(int id) throws MessageIdNotFoundException{
// id: message id
// person1 = idToMessage.get(id).getPerson1()
// person2 = idToMessage.get(id).getPerson2()
// 求出从**给定起点**person1到终点person2的最短路径
}
算法使用与优化
本次作业涉及到算法的方法就是上面方法解读中提到的sendIndirectMessage
。我选择用优先队列优化的Dijkstra算法。Dijkstra算法基本思路是:从给定节点出发,依次找到与已选择的所有节点距离最短的未选择的结点,直到选中终点(本题给定终点的情况下可直接返回)。而优先队列的主要作用在于每次以较低复杂度选出距离最短的结点。代码实现如下:
// called in method sendIndirectMessage()
private int dijkstra(Person p1, Person p2) {
Set<Integer> component = peoplesIdDisjointSet.getComponent(p1.getId());
Set<Integer> selectedNodes = new HashSet<>();
HashMap<Integer, Integer> idToDistance = new HashMap<>();
idToDistance.put(p1.getId(), 0);
PriorityQueue<Node> queue = new PriorityQueue<>();
queue.add(new Node(p1, 0)); // starting node
while (!queue.isEmpty()) {
Node node = queue.poll();
if (node.equals(p2)) {
return node.getDistance();
}
if (selectedNodes.contains(node.getNodeId())) {
continue;
}
selectedNodes.add(node.getNodeId());
int currentDistance = node.getDistance();
node.getAdjacenctNodes().forEach((personId, weight) -> {
int newDistance = currentDistance + weight;
if (component.contains(node.getNodeId())
&& (!selectedNodes.contains(personId)) &&
newDistance < idToDistance.getOrDefault(personId, Integer.MAX_VALUE)) {
queue.add(new Node(peoples.get(personId), newDistance));
idToDistance.put(personId, newDistance);
}
});
}
return -1; // error case
}
关于测试
在JML规格方面,我试着用了junit4的测试框架,发现就本单元来说意义不大。测试方法中的逻辑其实也是自己编写的,如果自己对于规格的理解有误,有可能在测试中使用assertTrue
断言时传入的也是不符合规格的结果,导致测不出bug。其实在规格方面,预期做测试,不如在写代码时认真读规格,去掉JML规格的注释,根据括号高亮匹配逐字阅读规格,保证自己的实现符合规格要求,这样在代码写完后留下的bug就比较少。对于JML中的also
可以使用不同条件的输入数据,观察输出是否对应。
本单元的测试主要采用大量随机数据、边缘数据生成的黑箱测试与对拍,通过不断重复测试发现bug。
bug与性能问题
其实本单元的bug几乎都是性能问题造成的CTLE
。
第一次作业自己没发现bug,在互测时通过大量qbs
指令(queryBlockSum
)成功hack到别人;
第二次作业在gvs
指令(getValueSum
)中因为两重循环t了(参见第一次作业算法使用与优化部分);
第三次作业未出现bug,也没hack到别人。
一些其他收获
removeIf与forEach参数区别
removeIf
(in Map.entrySet())的方法声明如下:
// in interface **Collection**
// Map.entrySet() returns a Set<Map.Entry<K,V>> object and Set interface extends Collections
default boolean removeIf(Predicate<? super E> filter);
使用该方法需要传入一个Predicate
对象。Predicate
是一个函数式接口,一般用于lambda表达式。如:在一个HashMap
对象中调用removeIf
方法:
// emojiIdToHeat: HashMap<Integer, Integer>
emojiIdToHeat.entrySet().removeIf(entry -> entry.getValue() < limit);
而下面这种使用方法则是错误的,编译器会报Operator '<' cannot be applied to '<lambda parameter>', 'int'的错误。这是因为传入的参数(emojiId, heat)
不是一个Map.Entry<Integer, Integer>
对象。
emojiIdToHeat.entrySet().removeIf((emojiId, heat) -> heat < limit);
而forEach
则不同(以Hashmap为例),其方法声明如下:
public void forEach(BiConsumer<? super K, ? super V> action);
从参数定义可以看到,这里就需要传两个分别为HashMap
中键和值的类型的参数,如下所示:
// node.getAdjacenctNodes(): return a instance of HashMap<Integer, Integer>
node.getAdjacenctNodes().forEach((personId, weight) -> {
// do something
});
如何在forEach方法中"break"
这里仍然以HashMap
的forEach
方法为例。forEach
是不支持在里面的lambda表达式中使用break或return的。java8的官方文档有如下描述:
default void forEach(Consumer<? super T> action)
Performs the given action for each element of the Iterable until all elements have been processed or the action throws an exception.
因此可以使用抛出一个运行时异常(继承RuntimeException
)来中断执行并返回,代码如下所示:
// map: HashMap<Integer, Integer>
try {
map.forEach((key, value) -> {
// do something
if (/*...*/) {
throw new RuntimeException();
}
});
} catch (RuntimeException e) {
return;
}
如果需要返回lambda表达式中计算的中间结果,可以将结果保存到一个自定义异常中,在捕捉异常后返回其值,代码如下所示:
// MyException.java
public class MyException extends RuntimeException {
private final String msg;
public MyException(String msg) {
this.msg = msg;
}
public String getMsg() {
return msg;
}
}
// another class
try {
map.forEach((key, value) -> {
// do something
if (/*...*/) {
throw new MyException("xxx");
}
});
} catch (MyException e) {
return e.getMsg();
}
当然,这种做法还存在些许争议。在《Effective Java 2nd Edition》一书中有如下评价:
'Use exceptions only for exceptional conditions';
'Use runtime exceptions to indicate programming errors';
尽管如此,其仍然是一个有效的方法。
任务:对Network的扩展
类结构
|- Advertiser: 发广告
|- Producer: 生产产品
|- Customer: 购买产品
|- Commodity: 商品封装类
public class Network {
public void sendAdvertisement(int advertisementId, Commodity commodity);
public int querySalesValue(Commodity commodity);
public Customer getCustomer(Commodity commodity);
public int getAdvertisementIdByCommodity(Commodity commodity);
public Producer getProducer(Commodity commodity);
}
public class Advertiser {
public void displayAdvertisement(Commodity commodity);
}
public class Commodity {
/* 商品类 */
}
public class Customer {
public void readAdvertisement(int advertisementId);
public void buy(int Advertisement, Commodity commodity);
}
public class Producer {
public void produce(Commodity commodity);
public Message getUnsendedAdervertisement(Commodity commodity);
}
规格编写
由于本例中销售只能由生产者到消费者,销售路径实际上涉及生产者、刊登的广告、消费者,因此我提供了下面三种方法更加产品来查询相应对象:
public Customer getCustomer(Commodity commodity);
public int getAdvertisementIdByCommodity(Commodity commodity);
public Producer getProducer(Commodity commodity);
随机选取三个方法编写规格:
// 不包含异常处理
/*@ public normal_behavior
@ ensures \result == salesValue;
@*/
public /*@ pure @*/ int querySalesValue(Commodity commodity);
/*@ public normal_behavior
@ requires !\old(advertisementsOnDisplay.contains(advertisementId));
@ requires !\old(commdity2Customer.containsKey(commodity));
@ assignable advertisementsOnDisplay;
@ assignable commdity2Customer;
@ ensures advertisementsOnDisplay.contains(advertisementId);
@ ensures (\forall int id; \old(advertisementsOnDisplay.contains(id)); advertisementsOnDisplay.contains(id));
@ ensures \old(advertisementsOnDisplay.length) == advertisementsOnDisplay.length - 1;
@*/
public void sendAdvertisement(int advertisementId, Commodity commodity);
/*@ public normal_behavior
@ ensures \result == commdity2Customer.get(commodity);
@*/
public /*@ pure @*/ Customer getCustomer(Commodity commodity);