BUAA OO-Course 2022 Unit3 Summary

BUAA OO-Course 2022 Unit3 Summary

设计策略

由于代码主框架由JML规定好了,在写代码的时候就要考虑以什么顺序来进行。在本单元的三次作业中,我均采用自底向上的方式来理解和完成作业。即,首先完成异常类,之后按照PersonGroupNetwork的逻辑顺序编写,这样更方便自己理解代码。

第一次作业(hw9)

代码(方法)解读与容器选择

整体代码

本次作业的代码主要实现一个社交网络(network),每个人(Person)所属于不同的群体(Group),他们之间有各种各样的关系。我们很容易可以将社交网络抽象为带权无向图,人抽象为节点,人与人的关系中的value属性即为边的权值。(第一次作业中暂时看不出Grouprelation的逻辑关系)

容器选择

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

    注意到我在并查集中设置了blockNumrecentFindDepth两个属性,分别用于记录集合个数(即不同的祖先的个数)和在调用find()方法时循环次数。其中,blockNum维护较简单,只需要在增加节点和合并节点时改变其值。而recentFindDepth的作用则与并查集中按秩合并的效果类似。

    上面代码中find()方法没采用递归的方式,同时在搜索过程中进行了路径压缩,以减小空间复杂度。而合并方法merge()就需要用到前面定义的recentFindDepth,分别记录两个节点到他们祖先节点的"距离",然后将距离短的合并到距离长的祖先那,从而降低了均摊复杂度。

    回到MyNetwork的这两个方法来。在queryBlockSum()方法中,由于在增加和合并节点时有对blocknum进行记录,因此可以直接得到连通子图个数。而isCircle()方法,只需要判断两个节点是否有共同的祖先节点即可。

第二次作业(hw10)

代码(方法)解读与容器选择

整体代码

进一步完善第一次作业中实现的社交网络,新增了MyMessage类,在MyPerson类中添加一些属性和简单的gettersetter,主要修改在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不能包含相同元素,因此在实现EdgecompareTo方法时需要保证一定不能返回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"

这里仍然以HashMapforEach方法为例。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);
posted @ 2022-05-25 15:29  NormalLLer  阅读(94)  评论(3编辑  收藏  举报