OO第二次作业总结

第二次总结性作业

一、第一次作业

(一)UML类图和协作图

从类图不难看出,由主线程生成了读入线程和电梯(电梯控制器)线程,读入线程和控制器线程通过托盘线程(PeopleSet)进行交互,协作图如下:

其中,改变电梯状态的逻辑又可以具体细化为下图:

  • 1、其中,look是电梯调度算法的一种,是 scan 算法的升级,scan简单来说就是 1-20,20-1的循环,当路径上有和前进方向相同的乘客就捎带上。而look在scan的基础上进一步做了优化,加入了转向这样一个逻辑:当我前进方向上有乘客,或者电梯内有人时,继续前进,否则转向,这样就能大大减小扫描的范围,提高扫描的效率,极其类似于我们生活中的电梯,这种算法在题设的 Random 和 Night 模式下都有极好的表现,但是在 Morning 下存在性能缺陷(Morning 理论上等满员或者读入结束再运行电梯较优,但由于具体实现过程中出现 Bug,后续便没有采用),最后96.3的性能分还是比较可观。

  • 2、此次作业的结束标记即为读入结束的标记,由读入线程在读入结束时设置并 notify 所有线程,让线程能够正常结束,避免出现死等的情况。

(二)调度器设置

​ 第一次作业的调度器(PeopleSet)设置较为简单,基本遵循 FCFS (FIRST COME FIRST SERVE) 的原则,按读入顺序将请求放在自身队列中,在电梯控制器尝试取时每次给出最老的一个请求。当然,电梯具体怎么调度取决于电梯本身的 look 算法,与调度器无关,调度器只负责请求的分发,这种思路在后面两次作业中都得到了保留。

(三)同步块设置和锁的选择

​ 从上面的描述、类图和协作图中我们不难看出,本次作业共享的对象仅限于托盘,并且托盘中只有一个队列作为所有线程的共享对象,因此采取了直接对整个对象上锁的模式来设置临界区,具体如下:

public void run() { //这是Reader线程的run方法,负责将请求放入peopleSet
    while (true) {
        PersonRequest pr = elevatorInput.nextPersonRequest();
        if (pr == null) { break; }
        synchronized (peopleSet) {
            peopleSet.push(pr);
            peopleSet.notifyAll();
        }
    }
    synchronized (peopleSet) {
        peopleSet.setEnd();
        peopleSet.notifyAll();
    }
}
private boolean readSet() {
    while (true) { //将当前的信息读入
        synchronized (peopleSet) { //第一次没有使用单例模式,因此这里的peopleSet其实传的都是引用
            PersonRequest pr = peopleSet.pop();
            if (pr == null) {
                if (checkEnd()) { return false; }
                if (checkWait()) {
                    try {
                        peopleSet.wait();
                        continue;
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                break;
            } else { waitList.add(pr); }
        }
    }
    return true;
}

二、第二次作业

(一)UML类图和协作图

对类图做简单分析我们不难得出:在第一次作业的基础上,我们的架构没有大的改变,线程依然是 Reader 和 EleController,之间的交互依然是通过托盘 PeopleSet 进行,并且 Reader 线程承担了新建电梯的任务,较大的变化主要在新增的People、Printer 和 Counter 类上,其中 People 是把读入的请求信息提取出来的类,这次作业和请求类没有大的区别,Printer 类主要是为了解决多个电梯输出线程安全的问题,对官方包做的封装类,Counter 类主要是为了支持吐出请求机制,给第三次作业做准备的机制(然而并没有达到预期效果,在第三次作业中作了进一步修改),详细的协作图如下:

可以看出,整体的关系没有大的改变,明显的变化在于电梯状态中对线程结束的判断:

其中,waitNum 是我们的 Counter 类用线程安全的方式实现的计数器。而此时的结束标记已经不是读入结束的标记(因为一个线程随时可能接受从别人那里吐出的请求而被唤醒),而是在读入结束且所有线程 wait 的情况下的一个标记,为此我们新开了一个类 Counter 来对计数的 int 做线程安全的处理(这种做法有缺陷,在后面会细讲)

(二)调度器设置

从第二次开始,按照我们的设计,调度器的设计就是决定性能的重要环节了(电梯的逻辑不会改变了,那么怎么把任务分的近,压力分配的均匀,就是我们首要考虑的事情)

private int getWeight(EleController eleC, People people) {
    String id = eleC.getEle().getId();
    int weight = eleC.getEleSize() +
                 eleC.getWaitSize() + eleMap.get(id).size() +
                 Math.abs(eleC.getEle().getFloor() - people.getFromFloor());
    boolean dir = Integer.compare(people.getFromFloor(), eleC.getEle().getFloor())
                  == eleC.getEle().getDirection();
    return dir ? weight : weight * 2;
}

一个基本的思路,就是设定一个值,这个值代表电梯运送当前请求所需的代价(或者说是惩罚),而我们总是把任务派给惩罚值最小的电梯,我们的惩罚函数考虑了这样几个参量:

  • 1、电梯内的人数

  • 2、电梯等待队列的人数

  • 3、电梯分配队列的人数

  • 4、电梯接到请求所需的移动距离

  • 5、当前请求所在方向与电梯前进方向是否相同

其中,第五点是我根据 look 的特性所规定的一个参量,显然,在 look 算法的逻辑下,一个电梯更容易去携带处理一个在自己前进方向上的请求。

(Ps : 参数的考虑一定要全面,第二次作业本人就因为少考虑了一个参量导致任务频繁派给同一个电梯荣获 rtle,具体系数就见仁见智,全靠直觉了,当然要是会炼丹也可以

(三)同步块的设置和锁的选择

这一次作业中,我们采取了单例模式和观察者模式,并且实现了吐出请求的功能,复杂了结束条件,因此锁的选择和结束的判断都有了一定的改变。

1、观察者模式

每个电梯实际上只观察自己对应的那个请求队列,只给自己的请求队列上锁,以此来增加分配的效率,减少锁的占用。

public void push(People people) {
    int min = 0x7fffffff;
    String eleId = "";
    synchronized (eleList) {
        for (EleController eleC : eleList) {
            int weight = getWeight(eleC, people);
            if (weight < min) {
                min = weight;
                eleId = eleC.getEle().getId();
            }
        }
    }
    ArrayList<People> arrayList = eleMap.get(eleId);
    synchronized (arrayList) {
        arrayList.add(people);
        arrayList.notifyAll();
    }
}

同时也因为如此,我们在唤醒所有线程时,需要遍历存放请求的容器。

public void notifyList() {
    for (String item : eleMap.keySet()) {
        ArrayList<People> arrayList = eleMap.get(item);
        synchronized (arrayList) {
            arrayList.notifyAll();
        }
    }
}

2、单例模式

结合上面所述的观察者模式,我们用饿汉式实现了单例模式托盘,并将锁改为对每个单独请求队列的锁。

public class PeopleSet { // 单例模式托盘
    private Vector<EleController> eleList;
    private HashMap<String, ArrayList<People>> eleMap;
    private HashMap<String, Boolean> popTag;
    private Boolean end;
    private boolean finish;
    private Counter waitNum;
    private static PeopleSet set = new PeopleSet();
    
    public static PeopleSet getSet() { return set; }
    ...
}

3、吐出请求的实现

为了实现吐出请求的功能,在新加入电梯后,能够对未处理的请求进行合理的分类,我们需要一种更加合理的结束方式:如果采取之前的方式,即线程发现读入结束并且满足wait条件时,便把线程杀掉,就会出现一个线程被杀掉之后,又接受到另外一个线程所吐出的请求,从而出现请求无法处理的情况。在这种情况下,比较合理的一个思路就是:当所有线程都 wait 并且读入结束时,我们设定一个标记,并且唤醒所有线程结束。因此我们有了 Counter 类(注意直接对 Integer 类加锁是错误的,值每次变化锁都会改变)。

public class Counter {
    private int cnt;

    Counter() {
        cnt = 0;
    }

    public void plus() {
        ++cnt;
    }

    public void minus() {
        --cnt;
    }

    public int getCount() {
        return cnt;
    }
}

但是这种设计存在缺陷,因为 notify 时我们不知道线程是否在 wait,因此这两个 set 方法都只能由线程自己调用,偶尔就会出现线程被唤醒后还没有 setWake,另一个线程就 setWait() 设置了结束标记,这显然不是我们希望看见的(第二次作业只要有 pop 标记便不让线程 wait 就能不影响正确性,至于为什么这里就不作赘述)。

三、第三次作业 -- 枚举换乘实现

(一)UML类图和协作图

对类图做简单分析我们不难发现,第三次作业类和类之间的关系并没有变复杂,我们采取了别的判断结束的方式反而减少了类的数量,唯一比较大的变化在于我们的 EleCountroller 可以将还没有到达目的地的请求再次送回 PeopleSet。

可以发现,整个流程和第二次作业没什么区别,区别主要在于实现了将没有完成的请求放回托盘继续处理的功能(和第二次作业实现的吐出请求功能高度相同)

电梯控制器自己的调度逻辑也没有大的变化,只是增加了对未完成请求的处理,并且用 arrayList 的形式实现了对结束条件的判断。

(二)调度器设置

在讲调度器之前,我们先看下 People 类的变化

public class People {
    private int fromFloor;
    private int toFloor;
    private int personId;
    private People nextRequest;

    People(int personId, int fromFloor, int toFloor, People nextRequest) {
        this.personId = personId;
        this.fromFloor = fromFloor;
        this.toFloor = toFloor;
        this.nextRequest = nextRequest;
    }

    public int getFromFloor() {
        return fromFloor;
    }

    public int getToFloor() {
        return toFloor;
    }

    public int getPersonId() {
        return personId;
    }

    public int getDirection() {
        return Integer.compare(getToFloor(), getFromFloor());
    }

    public People getNextRequest() {
        return nextRequest;
    }
}

我们在 People 类中新增了 nextRequest 并提供了得到其的方法,像链表一样将请求串在了一起,在上一个请求输出 out 之后,将下一个请求再次 push 进托盘(NULL 当然就不推了)

之后问题就变为了:我们拆不拆请求?怎么拆请求?请求拆了给谁?

面向对象的思想告诉我们,拆请求和处理请求,应该是相互独立的两个部分。

这样来看的话,处理请求这一部分,除了要 push 下一阶段的请求之外,基本就和第二次没什么区别。

而分离请求这部分,正如我们的标题,由于课程组在电梯类型和数据上的放水 完全可以通过枚举解决。而决定分不分的工作,就和之前一样,完全交给我们的惩罚函数。

惩罚函数如下:

private int getWeight(EleController eleC, People people) {
    Ele ele = eleC.getEle();
    String id = eleC.getEle().getId();
    if (!ele.getMoveTag(people.getFromFloor()) ||
        !ele.getMoveTag(people.getToFloor())) {
        return INF;
    }
    int moveTime = ele.getMoveTime() / 200;
    moveTime = moveTime * moveTime;
    int weight = eleC.getEleSize() +
                 eleC.getWaitSize() + eleMap.get(id).size() + 1;
    weight = weight * moveTime *
            (Math.abs(ele.getFloor() - people.getFromFloor()) / 2 +
             Math.abs(people.getFromFloor() - people.getToFloor()));
    boolean dir = Integer.compare(people.getFromFloor(), ele.getFloor()) == ele.getDirection();
    return dir ? weight : weight * 3 / 2;
}

惩罚函数中考虑了速度和移动的楼层,因此多个电梯换乘时惩罚函数可以直接相加,所以,枚举生成请求的方式就比较简单。

public void analysePush(People people) { //不用质疑,这段长爆了的暴力代码刚好60行(
    int id = people.getPersonId();
    int from = people.getFromFloor();
    int to = people.getToFloor();
    int min = INF;
    People ans = null;
    Vector<EleController> analyseList = new Vector<>();
    synchronized (eleList) { analyseList.addAll(eleList); }
    for (EleController eleCA : analyseList) {
        Ele eleA = eleCA.getEle();
        int weight = getWeight(eleCA, people);
        if (weight < min) {
            ans = people;
            min = weight;
        }
        for (EleController eleCB : analyseList) {
            Ele eleB = eleCB.getEle();
            if (eleA.getId().equals(eleB.getId())) { continue; }
            for (int i = 1; i <= 20; ++i) { //枚举中间楼层
                if (i == from || i == to) { continue; }
                People second = new People(id, i, to, null);
                People first = new People(id, from, i, second);
                int weightA = getWeight(eleCA, first);
                int weightB = getWeight(eleCB, second);
                if (weightA == INF || weightB == INF) { continue; }
                weight = (weightA + weightB) * 5 / 4;
                if (weight < min) {
                    ans = first;
                    min = weight;
                }
            }
            for (EleController eleCC : analyseList) {
                Ele eleC = eleCC.getEle();
                if (eleB.getId().equals(eleC.getId())) { continue; }
                for (int i = 1; i <= 20; ++i) {
                    for (int j = 1; j <= 20; ++j) {
                        if (i == j ||
                            i == from || i == to ||
                            j == from || j == to) { continue; }
                        People third = new People(id, j, to, null);
                        People second = new People(id, i, j, third);
                        People first = new People(id, from, i, second);
                        int weightA = getWeight(eleCA, first);
                        int weightB = getWeight(eleCB, second);
                        int weightC = getWeight(eleCC, third);
                        if (weightA == INF || weightB == INF || weightC == INF) { continue; }
                        weight = (weightA + weightB + weightC) * 2;
                        if (weight < min) {
                            ans = first;
                            min = weight;
                        }
                    }
                }
            }
        }
    }
    push(ans);
}

请求生成大概如图所示,增加换乘次数就增加枚举循环就可以了。同时通过大量的随机数据我们可以发现,少换乘或者是不换乘会得到更好的时间效率,因此我们给换乘的惩罚函数乘上一个系数,即实现这种功能,避免极限情况,并尽量减少这种机制的使用。

(三)同步块的设置和锁的选择

从设计上我们不难看出,第三次作业同步块的设置和锁的选择和第二次作业并没有什么较大的差别。主要的差别在于对于结束的判断,我们采取了新的逻辑。

private boolean setWait(String id) {
    synchronized (waitEleList) {
        waitEleList.add(id);
        if (end && waitEleList.size() == eleList.size()) {
            return finish = true;
        }
    }
    return false;
}
public void push(People people) {
    int min = INF;
    String eleId = "";
    synchronized (eleList) {
        for (EleController eleC : eleList) {
            int weight = getWeight(eleC, people);
            if (weight < min) {
                min = weight;
                eleId = eleC.getEle().getId();
            }
        }
    }
    ArrayList<People> arrayList = eleMap.get(eleId);
    synchronized (arrayList) {
        arrayList.add(people);
        arrayList.notifyAll();
        synchronized (waitEleList) {
            waitEleList.remove(eleId);
        }
    }
}

用 arrayList 来管理结束的好处在于,我们可以用 push 请求的线程来管理当前等待电梯的队列,由于 arrayList remove 方法没有所移除元素不会报错的优秀特性,我们不必特判,同时也因为这种由唤醒的进程来管理等待队列的特性(唤醒进程一定不 wait),我们不会误判所有电梯都 wait 的情况,困扰我们的结束问题,就简单的解决了。

四、第三次作业 -- 最短路实现

我们先把第三次作业扩展性和性能往后放一放,我们先来讲一下(在我看来)最具扩展性,最符合题目意义的一种实现:最短路。

(一)UML类图和协作图

可以看出,不论是哪种实现方式,核心的思路没有大的改变,只不过多出了方便跑最短路算法的一个类。

协作图没有变化,唯一变化的是我们的 analysePush 函数。

(二)调度器设置

调度器是我们最短路实现的重要部分,在交流过程中也有很多同学反应说最短路没有写出来,这里我们就来详细看看怎么实现。

1、Distance类的定义

Distance类是我们实现最短路中重要的辅助类,秉承 “不要自己造轮子” 的原则,为了使用系统的各种容器,我们需要重载 hashCode、equals 、compareTo 方法,这都会在我们后面的实现中给我们带来极大的方便。而 last 的作用我们后面在讲,读者可以好好想想它到底是干啥的。

public class Distance implements Comparable {
    private int dis;
    private int floor;
    private EleController eleC;
    private Distance last;

    Distance(int dis, int floor, EleController eleC, Distance last) {
        this.dis = dis;
        this.floor = floor;
        this.eleC = eleC;
        this.last = last;
    }

    @Override
    public int hashCode() {
        return getEleId().hashCode() * floor;
    }

    @Override
    public boolean equals(Object obj) { //楼层电梯相同即为相同
        return floor == ((Distance) obj).floor &&
               getEleId().equals(((Distance) obj).getEleId());
    }

    @Override
    public int compareTo(Object o) { //注意java的堆默认是小根堆,如果要实现大根堆,我们return值加个负号就行了
        return Integer.compare(dis, ((Distance) o).dis);
    }

    public String getEleId() {
        return eleC.getEle().getId();
    }

    public int getDistance() {
        return dis;
    }

    public int getFloor() {
        return floor;
    }

    public EleController getEleController() {
        return eleC;
    }

    public Distance getLast() {
        return last;
    }
}

2、最短路算法的初始化

这次的最短路由于有多个电梯,是一种典型的分层图最短路问题,因此我们把一个电梯看作一层,而初始点最开始可以选择登上任意一个电梯,因此我们要将所有的电梯的情况都放进我们的堆中,方便后面的处理(当然边的权还是之前那样取不准确的值大概估计)

public void analysePush(People people) {
    int id = people.getPersonId();
    int from = people.getFromFloor();
    int to = people.getToFloor();
    analyseList = new Vector<>();
    synchronized (eleList) { //拷贝防止长时间占用锁运算降低效率
        analyseList.addAll(eleList);
    }
    disMap = new HashMap<>();
    pq = new PriorityQueue<>();
    for (EleController eleC : analyseList) {
        Ele ele = eleC.getEle();
        int eleWeight = getEleWeight(eleC);
        for (int i = 1; i <= 20; ++i) {
            if (i != from || !ele.getMoveTag(from)) {
                disMap.put(new Distance(INF, i, eleC, null), INF);
            } else {
                int weight = eleWeight * Math.abs(ele.getFloor() - from) / 2;
                Distance dis = new Distance(weight, i, eleC, null);
                disMap.put(dis, weight);
                pq.add(dis);
            }
        }
    }
    Distance end = Dijkstra(to);
    splitPush(end, id);
}

这里给出 getEleWeight 的参数,当然参数见仁见智,仅供参考。

private int getEleWeight(EleController eleC) {
    Ele ele = eleC.getEle();
    String id = eleC.getEle().getId();
    int moveTime = ele.getMoveTime() / 200;
    moveTime = moveTime * moveTime;
    int weight = eleC.getEleSize() +
        eleC.getWaitSize() + eleMap.get(id).size() + 1;
    return moveTime * weight;
}

很明显可以看到,我们没有把距离参数加在里面,只是单独考虑了负载,剩余参数得由我们最短路运行中得到的距离决定。

3、最短路算法的运行

下面我们简单来提一提最短路算法的运行过程。

private Distance Dijkstra(int to) {
    Distance ans = null;
    vstSet = new HashSet<>();
    while (!pq.isEmpty()) {
        Distance top = pq.poll(); // 取出堆顶
        if (top.getFloor() == to) {
            ans = top;
            break;
        }
        if (vstSet.contains(top)) { //这个点已经更新过了,跳过
            continue;
        }
        vstSet.add(top);
        EleController topEleC = top.getEleController();
        Ele topEle = topEleC.getEle();
        int topEleWeight = getEleWeight(topEleC);
        for (int i = 1; i <= 20; ++i) { //同一个电梯上下移动
            if (!topEle.getMoveTag(i) || i == top.getFloor()) {
                continue;
            }
            int weight = top.getDistance() +
                      topEleWeight * Math.abs(i - top.getFloor());
            Distance dis = new Distance(weight, i, topEleC, top);
            int before = disMap.get(dis);
            if (weight < before) {
                disMap.put(dis, weight);
                pq.add(dis);
            }
        }
        for (EleController eleC : analyseList) { // 尝试电梯换乘。
            Ele ele = eleC.getEle();
            if (!ele.getMoveTag(top.getFloor())) {
                continue;
            }
            int eleWeight = getEleWeight(eleC);
            int weight = top.getDistance() +
                         eleWeight * Math.abs(ele.getFloor() - top.getFloor()) / 2;
            weight = weight * 3 / 2;
            Distance dis = new Distance(weight, top.getFloor(), eleC, top);
            int before = disMap.get(dis);
            if (weight < before) {
                disMap.put(dis, weight);
                pq.add(dis);
            }
        }
    }
    return ans;
}

光这样看代码应该很难看明白,接下来我们结合文字和图片解释一下这个过程。

最短路算法大概分为了这样几步:

  • 1、取出当前堆中距离最小的点。
  • 2、判断这个点在不在已经被取出的点集中,如果在,那么跳过当前点。(因为一定有相同位置距离更短的点在这个点之前更新过了,这时做任何操作都没有意义)
  • 3、从这个点尝试更新别的点(可能是换乘电梯,可能是就在这个电梯中移动,都有对应的代价)的最短路径,如果更新成功,被更新的点用 last 记录自己是被哪个点更新的(结合我们要返回的 end,你应该对我们求到最短路之后要做什么有感觉了吧?)
  • 4、回到 1 重复,直到堆为空。

我们可以对这个过程做个简单的图示:

显然我们看出,对于同一个电梯的各种楼层,是一个完全图,而电梯和电梯间,只有同楼层之间可以相互连接(也就是分层图不同层之间的连接),也就是我们通过对 equals 方法的更改,把 distance 用成了一个二维数组,这两维分别是电梯名和楼层,值是我们想优化的最小代价。

4、最短路信息的提取

private void splitPush(Distance end, int id) {
    int to = end.getFloor();
    int from;
    People last = null;
    Distance now = end;
    while (true) {
        if (now.getLast() == null) { //没有更早的请求了,来到了起点
            from = now.getFloor();
            People ans = new People(id, from, to, last);
            push(ans);
            return;
        }
        Distance before = now.getLast();
        if (before.getEleId().equals(now.getEleId())) { //电梯的id相同,没有换乘
            now = before;
            continue;
        }
        //是换乘请求,提取出前一段请求
        from = now.getFloor();
        People trans = new People(id, from, to, last);
        last = trans;
        to = from;
        now = before;
    }
}

在这一部分中,就体现出了我们之前辛辛苦苦维护的 last 的作用了,他像一个链表,把我们的整个处理过程倒着串了起来,并且我们的 Distance 中存储了电梯的 id,一旦我们发现了电梯的 id 不同,我们就可以定位一次换乘,并把请求串起来。

至此,我们成功的通过玄学的权值函数和最短路完成了对请求的拆解, 之后只要再根据我们的权值函数分配请求就能以更差的效率解决问题了!!!

(三)同步块的设置和锁的选择

由于我们的 analysePush 始终只能由读入线程来调用,因此在这个部分没有变化。

五、第三次作业架构可扩展性分析

(一)枚举实现复杂度分析

Method CogC ev(G) iv(G) v(G)
Ele.Ele(String,String) 0 1 1 1
Ele.changDirection() 0 1 1 1
Ele.closeDoor() 0 1 1 1
Ele.getDirection() 0 1 1 1
Ele.getFloor() 0 1 1 1
Ele.getId() 0 1 1 1
Ele.getLower() 0 1 1 1
Ele.getMoveTag(int) 0 1 1 1
Ele.getMoveTime() 0 1 1 1
Ele.getUpper() 0 1 1 1
Ele.getVolume() 0 1 1 1
Ele.init(String) 10 1 1 9
Ele.moveFloor() 0 1 1 1
Ele.openDoor() 0 1 1 1
EleController.EleController(String,String) 0 1 1 1
EleController.checkEnd() 1 1 3 3
EleController.checkWait() 1 1 3 3
EleController.getEle() 0 1 1 1
EleController.getEleSize() 0 1 1 1
EleController.getWaitList() 0 1 1 1
EleController.getWaitSize() 0 1 1 1
EleController.look() 0 1 1 1
EleController.lookDirChange() 8 5 5 8
EleController.lookMove() 6 4 2 5
EleController.move() 1 1 2 2
EleController.printIn() 1 1 2 2
EleController.printInOut() 3 2 3 4
EleController.printOut() 3 1 3 3
EleController.run() 7 4 2 5
EleController.tryIn() 5 1 6 6
EleController.tryOut() 4 1 4 4
Main.getArrivePattern() 0 1 1 1
Main.main(String[]) 0 1 1 1
People.People(int,int,int,People) 0 1 1 1
People.getDirection() 0 1 1 1
People.getFromFloor() 0 1 1 1
People.getNextRequest() 0 1 1 1
People.getPersonId() 0 1 1 1
People.getToFloor() 0 1 1 1
PeopleSet.PeopleSet() 0 1 1 1
PeopleSet.analysePush(People) 61 13 7 24
PeopleSet.getSet() 0 1 1 1
PeopleSet.getWeight(EleController,People) 3 2 2 4
PeopleSet.isEnd() 0 1 1 1
PeopleSet.notifyList() 1 1 2 2
PeopleSet.push(EleController) 1 1 2 2
PeopleSet.push(People) 3 1 3 3
PeopleSet.readSet(EleController) 22 7 6 8
PeopleSet.setEnd() 1 1 2 2
PeopleSet.setWait(String) 2 2 2 3
PeopleSet.tryPop(EleController) 3 2 3 4
Printer.getPrinter() 0 1 1 1
Printer.println(String) 0 1 1 1
Reader.Reader(ElevatorInput) 0 1 1 1
Reader.run() 6 3 4 5
Class OCavg OCmax WMC
Ele 1.64 10 23
EleController 2.35 6 40
Main 1 1 2
People 1 1 6
PeopleSet 3.67 16 44
Printer 1 1 2
Reader 3 5 6

可以看出,枚举这种实现方式,平均的复杂度还是可观的,但在暴力枚举的部分,由于枚举的情况过多,导致了圈复杂度的爆炸,并且在问题变的复杂时,枚举的实现难度这个好像写个函数就行?和复杂度的上升也是不可控的。

(二)最短路实现复杂度分析

Method CogC ev(G) iv(G) v(G)
Distance.Distance(int,int,EleController,Distance) 0 1 1 1
Distance.compareTo(Object) 0 1 1 1
Distance.equals(Object) 1 1 2 2
Distance.getDistance() 0 1 1 1
Distance.getEleController() 0 1 1 1
Distance.getEleId() 0 1 1 1
Distance.getFloor() 0 1 1 1
Distance.getLast() 0 1 1 1
Distance.hashCode() 0 1 1 1
Ele.Ele(String,String) 0 1 1 1
Ele.changDirection() 0 1 1 1
Ele.closeDoor() 0 1 1 1
Ele.getDirection() 0 1 1 1
Ele.getFloor() 0 1 1 1
Ele.getId() 0 1 1 1
Ele.getLower() 0 1 1 1
Ele.getMoveTag(int) 0 1 1 1
Ele.getMoveTime() 0 1 1 1
Ele.getUpper() 0 1 1 1
Ele.getVolume() 0 1 1 1
Ele.init(String) 10 1 1 9
Ele.moveFloor() 0 1 1 1
Ele.openDoor() 0 1 1 1
EleController.EleController(String,String) 0 1 1 1
EleController.checkEnd() 1 1 3 3
EleController.checkWait() 1 1 3 3
EleController.getEle() 0 1 1 1
EleController.getEleSize() 0 1 1 1
EleController.getWaitList() 0 1 1 1
EleController.getWaitSize() 0 1 1 1
EleController.look() 0 1 1 1
EleController.lookDirChange() 8 5 5 8
EleController.lookMove() 6 4 2 5
EleController.move() 1 1 2 2
EleController.printIn() 1 1 2 2
EleController.printInOut() 3 2 3 4
EleController.printOut() 3 1 3 3
EleController.run() 7 4 2 5
EleController.tryIn() 5 1 6 6
EleController.tryOut() 4 1 4 4
Main.getArrivePattern() 0 1 1 1
Main.main(String[]) 0 1 1 1
People.People(int,int,int,People) 0 1 1 1
People.getDirection() 0 1 1 1
People.getFromFloor() 0 1 1 1
People.getNextRequest() 0 1 1 1
People.getPersonId() 0 1 1 1
People.getToFloor() 0 1 1 1
PeopleSet.Dijkstra(int) 22 8 7 11
PeopleSet.PeopleSet() 0 1 1 1
PeopleSet.analysePush(People) 8 1 5 5
PeopleSet.getEleWeight(EleController) 0 1 1 1
PeopleSet.getSet() 0 1 1 1
PeopleSet.getWeight(EleController,People) 3 2 2 4
PeopleSet.isEnd() 0 1 1 1
PeopleSet.notifyList() 1 1 2 2
PeopleSet.push(EleController) 1 1 2 2
PeopleSet.push(People) 3 1 3 3
PeopleSet.readSet(EleController) 22 7 6 8
PeopleSet.setEnd() 1 1 2 2
PeopleSet.setWait(String) 2 2 2 3
PeopleSet.splitPush(Distance,int) 5 4 3 4
PeopleSet.tryPop(EleController) 3 2 3 4
Printer.getPrinter() 0 1 1 1
Printer.println(String) 0 1 1 1
Reader.Reader(ElevatorInput) 0 1 1 1
Reader.run() 6 3 4 5
Class OCavg OCmax WMC
Distance 1 1 9
Ele 1.64 10 23
EleController 2.35 6 40
Main 1 1 2
People 1 1 6
PeopleSet 3.13 10 47
Printer 1 1 2
Reader 3 5 6

接下来我们来看一下最短路算法,可以明显看到,圈复杂度相比于我们之前的枚举算法,被分到了三个函数当中去虽然也好不到哪里去,同时最短路算法具有更好的可拓展性,可以应对各种不合理的电梯移动请求(比如单向运输、规定到达楼层之类的),并且计算的复杂度为 O(电梯数 * 楼层数 log (电梯数 * 楼层数)) ,计算十分迅速,在请求变得复杂时复杂度不会有大量的上升。

(三)两种算法的性能比较

这里不对许多人所推崇的不转运单电梯运输或者是只转运一次做出评价,就算在强测中得到了较好的性能分,这种方法依然是一种在互测中可以被 hack,并且扩展性较差的方法不信的话可以试试加两个B电梯然后全跑4层到16层的请求50条

测试点编号 枚举结束时间 最短路结束时间
1888 88.4668 90.7817
1889 78.2304 85.8793
1890 80.32 88.3767
1891 79.8795 84.9034
1892 73.5313 69.6549
1893 85.9865 88.0132
1894 83.6363 74.1568
1895 90.2181 83.3487
1896 70.4685 70.9772
1897 64.4188 67.515
1898 68.2775 70.4141
1899 73.0497 75.9307
1900 37.6448 37.0251
1901 84.9882 83.4166
1902 79.0214 76.2125
1903 61.6692 58.55
1904 44.1801 47.9992
1905 61.2179 52.2287
1906 34.5098 30.3766
1907 49.4287 32.1921

可以看出,相同的参数下,随机数据枚举的表现更好Night模式好像最短路表现惊人?。在写各种方法的过程中,我们可以发现一个有趣的现象:换乘的机制做得越完善,随机数据的效率就会越差,比如说,我换乘只枚举两个电梯,在随机数据下就会明显比枚举三个电梯要快一些。但是我们基本可以相信,只要能够使用合适的参数,尽量减少一般情况下的换乘,我们依然能够获得不俗的性能,并且获得对极限情况的处理能力。对这个题的换乘,我认为,我们应该做到的是实现这个机制,并尽量去避免大量的使用到这个机制导致降低效率,而不是说利用评测数据的随机性而去减少机制的实现。

除此之外,比较纠结于性能的同学可以思考这样一个问题,在换乘情况下,A电梯是否可以不使用 LOOK 而使用 SSTF(可以看作总是以最近请求为主请求的ALS算法) 算法来做更多的短距离运输呢?多种算法结合是否会带来效率的提升呢?这些问题就留给读者自己思考了。

六、分析自己程序的Bug

除了第二次作业少加了参数导致了 rtle,这三次作业中没有出现别的bug,这里就不作赘述。

其实从每次代码的迭代,比如从两次循环到三次循环,判断方式从一个 Counter 类到一个 arrayList 来维护的改变,都是在课下和同学互相测试,比较极限情况做出的改动,都是写者在实践过程中踩得坑,读者可以好好体会每次改动的意义和作用虽然前面好像都写了

七、分析自己发现别人的 Bug 所采用的策略

分析别人Bug 主要是通过评测机和大量随机数据进行的,这里就不作赘述。

这里就对碰到的几种典型bug进行一个简单的总结:

  • 线程输出不安全,没有仔细阅读官方包的实现,导致输出出现时间倒流的情况。
  • 逻辑不清晰,在换乘时没有先输出出电梯的请求就急于将请求分给下一个电梯。
  • 在继承前几次作业的代码时,没有意识到继承的部分是线程不安全的而让多个线程调用。
  • 在对 Map 等容器,特别是“线程安全”的 vector 进行枚举时,没有上锁导致容器内部信息发生变化导致线程异常。
  • 线程不知道如何结束,没有理清 wait 的逻辑,在OS极端调度的情况(可以参考第二次作业的结束逻辑)出现死等的现象(这个比较常见,最后一次作业房间内 5 人出现这种情况,不过 3 人难以复现)

八、心得体会

本次作业,让我更深入地理解了线程安全和层次化的设计,也看到了自身在面向对象、迭代开发上面的进步(没有重构,嘻嘻)。从线程安全的角度,获得了许多有益的经验:比如不知道是否线程安全的类应该封装、单例模式、观察者模式、生产者-消费者模式等许多开发过程中总结的有益方法,同时也了解了加锁的方法以及各种防止死锁的技巧(比如给锁加编号之类的)。在层次化设计上,也更加理解了老师在课上所说的 “make sense” 的含义,对所需要做的工作进行细化和分配,开始从多个线程的角度去思考对同一个共享对象的操作,也逐渐开始理解了什么情况下我们要单独做个对象,什么情况下我们应该使用多态。

posted @ 2021-04-23 22:52  L_RUA  阅读(398)  评论(0编辑  收藏  举报