oo第三单元总结。。

吴佳峻-22373141 学生 2024-05-16 10:03:17

第三单元

第九周总结

架构总结

只多实现了一个counter类和Map类,前者用于异常计数,后者用于计算BlockSumTripleSum。如下:

img

为什么这样做呢

使用独立的 Map 类来管理连通块数量和三元闭包数量,而不是直接在 MyNetwork 类中实现这些功能:

1. 专业化与分工

  • 单一职责原则:按照设计模式的单一职责原则,一个类应该只负责一件事情。MyNetwork 类可能主要聚焦于管理人物间的网络关系,例如添加、删除人物和维护人际关系等。引入 Map 类可以使得 MyNetwork 不需要处理图的连通性和复杂的图算法,如连通分量和闭包计算,使代码更加模块化和易于管理。
  • 功能专业化Map 类可以专门优化连通块和闭包的计算,包括数据结构的选择和算法的实现,而不必考虑网络的其他方面,如个体属性管理和人际关系的动态变化。

2. 性能优化

  • 使用id: 全部使用id进行查找会快很多!!!在图中仅仅使用人物id来进行查询,添加等,在经过测试后会比使用每次对person整体进行查询要快,同时使用了依赖注入,实现了解耦。
  • 效率提高Map 类可以使用专门的数据结构和算法(如并查集和邻接表),专注于图的效率和性能优化。例如,使用并查集可以非常快速地处理大量的连通性查询和合并操作。
  • 分离计算:在不同场景下可能只需要更新连通性而不是整个网络结构,使用 Map 可以独立地更新和计算,避免对整个网络数据的重复计算,特别是在大型数据集上操作时。

3. 可扩展性与维护性

  • 易于扩展:将连通块和闭包功能封装在 Map 类中,使得未来对图算法的任何改进或优化都可以局部修改,而不影响整体网络逻辑。这对于添加新的功能或调整现有算法特别有用。
  • 代码清晰:分离的类结构使得每个类的功能更明确,代码更加清晰,从而降低了维护和升级系统的复杂度。

4. 测试与验证

  • 便于测试:独立的 Map 类可以单独进行单元测试,专注于图的连通性和闭包功能的正确性验证。这样可以更精确地控制测试覆盖范围,确保关键功能的稳定性和可靠性。

5. 逻辑清晰与解耦

  • 解耦合:使用 Map 类可以减少 MyNetwork 与图的直接依赖,实现业务逻辑与数据处理逻辑的分离。这在处理复杂系统或多种类型的网络模型时尤其重要。

综上所述,独立的 Map 类为系统的架构提供了更多的灵活性,同时提升了系统的可维护性和性能。这种方法允许开发者更有效地管理代码和优化特定功能,尤其是在大规模和高复杂性的应用场景中。

具体算法

使用并查集来查询是否连通以及动态维护连通块blockCnt个数,使用一张图来动态维护三元闭包tripleCnt个数。几乎所有数据结构都用HashMap。

并查集

路径压缩
public int find(int x) {
    ...
    parent.put(x, find(parent.get(x))); // 路径压缩
    ...

递归地调用 find 方法,直到找到根节点(当一个节点指向自身时,即为根节点)。在递归调用的过程中,每个节点的父节点都被更新为根节点,从而实现路径压缩。

按秩合并

按秩合并是一种优化并查集合并操作的方法。在这种策略中,每个元素都有一个“秩”(rank),通常可以理解为树的高度或深度。合并时,总是将秩较低的树的根指向秩较高的树的根。

public void union(int x, int y) {
    ...
    if (rootX != rootY) {
        if (rank.get(rootX) > rank.get(rootY)) {
            parent.put(rootY, rootX);
        } 
        ...
        else {
            parent.put(rootY, rootX);
            rank.put(rootX, rank.get(rootX) + 1); // 当两棵树的秩相等时,合并后的秩增加1
        }
    }
}

这段代码首先找到两个节点的根节点。如果它们不在同一个连通分量中,比较它们的秩,将秩较小的根节点的父节点更新为秩较大的根节点。如果秩相同,任选一个作为根,并增加其秩。

维护BlockCnt

add节点的时候++,union的时候--即可

如何删除

唯一需要注意的点就是如何在并查集中删除一个关系呢,朴素的全部重建我觉得也不会超时,并且在互测数据范围内不会被卡超时。当然可以选择只重建那两个节点的相关部分,方法如下:

两个相连的点A,B,对A进行dfs,把所有跟A有关系的点的根设置为A,同时秩为1。

for (int node : visited) {
      parent.put(node, startNode);
      rank.put(node, 1);
}

如果这其中没有B,就把B新建为一个根节点。

(zy学姐的博客中貌似说使用这种方法不能使用按秩合并,为什么,不懂)

dfs

private void dfs(int node, Set<Integer> visited) {
    visited.add(node);
    for (int neighbor : graph.get(node)) {
        if (!visited.contains(neighbor)) {
            dfs(neighbor, visited);
        }
    }
}

维护tripleCnt

因为在Map类中额外维护了一个关系图,在unionremoveEdge的时候分别去addTriple(x, y);,delTriple(x, y);

朴素的遍历,唯一的小优化是先比较x和y谁的有关系节点少,就用他的查

private void addTriple(int x, int y) {
        for (int neighbor : graph.get(x)) {
            if (graph.get(neighbor).contains(y)) {
                tripleCnt++;
            }
        }
    }

其他注意点

在上面这个操作中

private void addTriple(int x, int y) {
        for (int neighbor : graph.get(x)) {
            if (graph.get(neighbor).contains(y)) {
                tripleCnt++;
            }
        }
    }

这样写

if (graph.get(y).contains(neighbor)) 

在我的架构中是可以的,因为使用HashMap。但如果是在MyPerson中进行三元闭包的添加,就要注意这个顺序了,就是数组按行遍历和按列遍历,实测速度会相差10倍。

或许可以使用动态树Link/Cut Trees?支持边的动态添加和删除。Link/Cut Trees 结构使用 Splay Trees 作为其核心的数据结构来处理动态树的问题。由于 Splay Trees 的操作通常涉及到旋转,以使任意节点成为树的根,Link/Cut Trees 的性能取决于 Splay Tree 的操作效率。时间复杂度均为 **O(log n)**。

相较于并查集,Link/Cut Trees 提供了更多的灵活性,尤其是在需要处理节点间动态连接和断开的场景下。并查集在处理只有合并操作的连通性问题时非常高效,尤其是通过路径压缩和按秩合并优化后,其几乎能达到接近常数时间的效率。

压力测试

使用150个节点构建成一个完全图(11175个边),然后再删除所有的边,嗯不超时。

img

junit

注意以下pure条件就好

第十次总结

这次作业要实现的内容有以下几个:

增加tag,并求总和和方差

找value最大的熟人,并且计算couplesum

求两点间的最短路径(不带权重)

tag有关

首先$10000 * 200 ^ 2 = 4*10^8 < int:2,147,483,6472,147,483,647$

也就是说方差不会爆int

然后就是在tag中维护两个属性

private int ageSum; 
private int ageSquaredSum;

addPerson,delPerson的时候维护一下就好

couplesum

首先是如何去求一个的bestperson,以下用bp代称

这里朴素实现的话就是遍历,但是我们可以用一个带有顺序的数据结构去存acquaintance,我使用的是treemap,前面的键值就是value,同时按照value从大到小排序

private final TreeMap<Integer, Set<Person>> acquaintancesByValue = new TreeMap<>(Collections.reverseOrder());

这样的话理论上来讲,add操作由常数级变为Olgn,查找bp操作由On变为了常数级(取第一个值就好)

但是实际测试的话与正常实现的时间相差不大。

注意一下加人的时候还要按照id从小到大加进去

acquaintancesByValue.putIfAbsent(value, new TreeSet<>(Comparator.comparingInt(Person::getId)));
acquaintancesByValue.get(value).add(person);

这里同样使用treeSet来实现排序

在您的当前实现中,确实,使用 TreeMap<Integer, Set<Integer>> 嵌套 TreeSet<Integer> 的结构,每次添加和删除操作的时间复杂度都是log2n,其中 (n) 是元素的数量。这种结构确保了亲密值可以被排序,并且每个亲密值下的熟人 ID 也是有序的。

改进方案

如果您想避免使用两层的排序结构而又想维持排序的特性,可以考虑使用以下几种方法之一:

  1. 使用单个数据结构,如 TreeMap,但以不同的方式:

    • 可以考虑将 TreeMap 的 key 设计为一个复合键,这个键可以同时包含亲密值和熟人 ID,但这需要实现自定义的比较器。这种方式的缺点是处理复杂性高,且可能难以直接使用标准的 TreeMap
  2. 使用 PriorityQueueSortedSet 的变种:

    • 可以使用如 PriorityQueueSortedSet(如 TreeSet),这些结构内部也是用树实现的,但可以通过封装一个包含亲密值和 ID 的对象来管理排序。
  3. 自定义数据结构:

    • 实现一个自定义的数据结构,这个结构内部管理两个映射:一个映射按照亲密值到熟人 ID 的集合,另一个直接映射从熟人 ID 到亲密值。这样可以在更新和查询时更加高效。

使用自定义比较器

如何使用 TreeMap 但通过自定义比较器来避免嵌套操作:

this.acquaintances = new TreeMap<>(Comparator
                .comparingInt(AcquaintanceKey::getValue)
                .thenComparingInt(AcquaintanceKey::getPersonId));

private static class AcquaintanceKey {
        private final int value;
        private final int personId;
}

1

public class MyPerson implements Person {
    private final int id;
    private final String name;
    private final int age;
    private final TreeMap<AcquaintanceKey, Integer> acquaintances = new TreeMap<>();
    private final HashMap<Integer, Tag> tags;

    public MyPerson(int id, String name, int age) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.tags = new HashMap<>();
    }

    private static class AcquaintanceKey {
        int value;
        int personId;

        AcquaintanceKey(int value, int personId) {
            this.value = value;
            this.personId = personId;
        }

        public int getValue() {
            return value;
        }

        public int getPersonId() {
            return personId;
        }
    }

    private Comparator<AcquaintanceKey> comparator = Comparator
        .comparingInt(AcquaintanceKey::getValue)
        .thenComparingInt(AcquaintanceKey::getPersonId);

    public void addAcquaintance(Person person, int value) {
        AcquaintanceKey key = new AcquaintanceKey(value, person.getId());
        acquaintances.put(key, person.getId());
    }

    // 其他方法同理进行修改
}

这种方法通过将亲密值和熟人 ID 封装成一个 AcquaintanceKey 对象,然后在 TreeMap 中使用自定义的比较器来排序。这样可以避免嵌套的集合结构,同时保持数据的有序性和快速访问性。

之后就是求couplesum

我在这里实现了一个脏位,因为能够使得couplesum改变的情况是可以被包含在下面两种情况中的:

  1. 修改1的2关系,此时2是1的bp
  2. 修改了1的某个关系,1的bp前后发生了改变

modifyRelation中增加对这两种情况的检查,成立就设置脏位为true

queryCoupleSum

    if (!coupleDirty) {
            return coupleCnt;
        }
        coupleCnt = 0;
        coupleDirty = false;

这样就实现了尽可能少的全部遍历着查找couplesum

同时,还可以用一个set来存已经从处理过的person,当遍历到这个person的时候,continue就好,如下,这样这个操作就是On级别的了

for (Person p1 : persons.values()) {
            获取p1的最佳熟人ID
                如果p1已处理过,则跳过
                获取p1的最佳熟人
                    获取p2的最佳熟人ID
                    如果p2的最佳熟人也是p1
                        计数
                        标记p1p2为已处理
                    }
                }
        }

求最短路径

上次作业中我在Mymap类中预存了一个关系图,这时候就用上了,可以基于此来实现对最小路径的查询。

queryShortestPath(int id1, int id2) 是在不带权重的图中寻找路径。使用BFS比较合理

广度优先搜索(BFS)

BFS 是一种用于图的遍历或搜索的算法,它从根节点开始,一层层向外扩展,直到找到目标节点为止,因此它自然地适用于在无权图中找到最短路径(即边的数量最少的路径)。

在此基础上,我实现了双向BFS,如下:

双向BFS

双向广度优先搜索(Bi-directional BFS)是一种用于搜索最短路径的算法,特别适用于在两个已知节点之间寻找最短路径的场景。这种方法从两个节点(通常是起点和终点)同时开始搜索,一层一层地向外扩展,直到两个搜索从不同方向相遇。相比于单向的 BFS,双向 BFS 可以显著减少搜索空间,因此在很多情况下能更快地找到最短路径。

如何工作?

双向 BFS 同时从两个节点开始搜索:一个从起点开始,另一个从终点开始。每次迭代中,算法交替扩展这两个方向的边界,即先扩展从起点开始的一层,再扩展从终点开始的一层。这种方法的关键在于,只需要扩展到两个搜索相遇的地方,而不是完全探索整个图。

算法步骤

  1. 初始化:为起点和终点各自创建一个队列和一个访问集合,用于存储每一层的节点和已访问的节点。
  2. 迭代搜索:在每一轮中,对起点的队列进行扩展,然后对终点的队列进行扩展。
  3. 检查相遇:在每次扩展后,检查当前扩展的节点是否被对方的搜索已访问过。

dijkstra比较

  • 双向 BFS 同时从起点和终点开始搜索,并在两者相遇时停止。这意味着搜索空间通常是从单一起点或终点开始时搜索空间的平方根。相比之下,Dijkstra 算法通常从单一起点出发,可能需要遍历图中更多的节点。
  • 在节点间的平均距离较短的图中,双向 BFS 可以非常迅速地找到路径,因为两边搜索很快就会相遇。Dijkstra 算法则可能在找到最短路径之前遍历更多的节点。
  • 因为我们只需要提供长度,也不需要知道具体路径,这一点也符合BFS

优化

我们可不可以把查找过程中的点之间的距离都存下来呢,下次找的时候就可以直接给出了。这里同样需要设置脏位。但是对于单向BFS来说这个操作很好实现,那么双向BFS怎么办呢。

双向 BFS 和单向 BFS 加缓存是两种有效的最短路径搜索策略,它们各有优势和局限。下面是对这两种方法的分析:

选择哪种方法取决于具体应用的需求:

  • 如果查询多样化且图较大,或者需要频繁从不同的源点进行搜索,双向 BFS 可能更合适。
  • 如果查询经常重复或者通常是从同一或少数几个源点到不同目标点,单向 BFS 加缓存可能更优。

在考虑100个节点的稠密图和稀疏图进行最短路径搜索时,我们可以分析双向 BFS 和单向 BFS(加缓存)的效率。对于这样的图规模,我们可以详细比较这两种方法在不同类型的图结构下的表现。

对于稠密图

  • 双向 BFS 在理论上更快找到路径,但处理开销大。
  • 单向 BFS 加缓存适合重复查询,但初次填充缓存成本高。

对于稀疏图

  • 双向 BFS 可能不如稠密图中高效,但对于某些配置可能仍然是一个不错的选择。
  • 单向 BFS 加缓存在稀疏图中更为高效,特别是当查询集中在少数几个源点时。

综上,可以找一个折中的方法:就是在双向BFS的时候左边存一半,右边存一半经过点的距离。

private int visitLevel() {
            得到当前层的节点数
            for (int i = 0; i < currentSize; i++) {
               得到当前距离
                for (int neighbor : graph.get(currentNode)) {
                    if (!visited.containsKey(neighbor)) {
                        存储距离时考虑中间节点数量
                        检查是否与对方搜索相遇
                            updateCache(sourceId, neighbor, totalDistance);
                            检查是否与对方搜索相遇 {
                            // 返回总距离
                            updateCache(sourceId, neighbor, totalDistance); 
                            return totalDistance;
                        }
                        }
                    }
                }
            }
        }
        return -1; // 这一层未找到交汇点
    }

我的nb帅气室友叶佩霖提出了:

当删边前后的tripleSum改变了,就说明并查集去掉这俩点的连接后,有另外一个点跟他们是连着的!!!!!因此并查集不需要重建,这个剪枝操作实测能让删除操作的耗时降低一个数量级。

第十一次分析

private final List<Message> messages = new LinkedList<>();// 使用 LinkedList 存储消息,因为要取前五个

1

bug分析

惨淡出现两个bug

第一个就是queryValueSum爆掉了,设置了脏位

第二个就是

person1.addMoney(-moneyToDistribute * tag.getSize());

这里直接写的

person1.addMoney(((RedEnvelopeMessage) message).getMoney());

money可能不会整除,寄

第九周总结

架构总结

只多实现了一个counter类和Map类,前者用于异常计数,后者用于计算BlockSumTripleSum。如下:

image-20240425100227246

为什么这样做呢

使用独立的 Map 类来管理连通块数量和三元闭包数量,而不是直接在 MyNetwork 类中实现这些功能:

1. 专业化与分工

  • 单一职责原则:按照设计模式的单一职责原则,一个类应该只负责一件事情。MyNetwork 类可能主要聚焦于管理人物间的网络关系,例如添加、删除人物和维护人际关系等。引入 Map 类可以使得 MyNetwork 不需要处理图的连通性和复杂的图算法,如连通分量和闭包计算,使代码更加模块化和易于管理。
  • 功能专业化Map 类可以专门优化连通块和闭包的计算,包括数据结构的选择和算法的实现,而不必考虑网络的其他方面,如个体属性管理和人际关系的动态变化。

2. 性能优化

  • 使用id: 全部使用id进行查找会快很多!!!在图中仅仅使用人物id来进行查询,添加等,在经过测试后会比使用每次对person整体进行查询要快,同时使用了依赖注入,实现了解耦。
  • 效率提高Map 类可以使用专门的数据结构和算法(如并查集和邻接表),专注于图的效率和性能优化。例如,使用并查集可以非常快速地处理大量的连通性查询和合并操作。
  • 分离计算:在不同场景下可能只需要更新连通性而不是整个网络结构,使用 Map 可以独立地更新和计算,避免对整个网络数据的重复计算,特别是在大型数据集上操作时。

3. 可扩展性与维护性

  • 易于扩展:将连通块和闭包功能封装在 Map 类中,使得未来对图算法的任何改进或优化都可以局部修改,而不影响整体网络逻辑。这对于添加新的功能或调整现有算法特别有用。
  • 代码清晰:分离的类结构使得每个类的功能更明确,代码更加清晰,从而降低了维护和升级系统的复杂度。

4. 测试与验证

  • 便于测试:独立的 Map 类可以单独进行单元测试,专注于图的连通性和闭包功能的正确性验证。这样可以更精确地控制测试覆盖范围,确保关键功能的稳定性和可靠性。

5. 逻辑清晰与解耦

  • 解耦合:使用 Map 类可以减少 MyNetwork 与图的直接依赖,实现业务逻辑与数据处理逻辑的分离。这在处理复杂系统或多种类型的网络模型时尤其重要。

综上所述,独立的 Map 类为系统的架构提供了更多的灵活性,同时提升了系统的可维护性和性能。这种方法允许开发者更有效地管理代码和优化特定功能,尤其是在大规模和高复杂性的应用场景中。

具体算法

使用并查集来查询是否连通以及动态维护连通块blockCnt个数,使用一张图来动态维护三元闭包tripleCnt个数。几乎所有数据结构都用HashMap。

并查集

路径压缩
public int find(int x) {
    ...
    parent.put(x, find(parent.get(x))); // 路径压缩
    ...

递归地调用 find 方法,直到找到根节点(当一个节点指向自身时,即为根节点)。在递归调用的过程中,每个节点的父节点都被更新为根节点,从而实现路径压缩。

按秩合并

按秩合并是一种优化并查集合并操作的方法。在这种策略中,每个元素都有一个“秩”(rank),通常可以理解为树的高度或深度。合并时,总是将秩较低的树的根指向秩较高的树的根。

public void union(int x, int y) {
    ...
    if (rootX != rootY) {
        if (rank.get(rootX) > rank.get(rootY)) {
            parent.put(rootY, rootX);
        } 
        ...
        else {
            parent.put(rootY, rootX);
            rank.put(rootX, rank.get(rootX) + 1); // 当两棵树的秩相等时,合并后的秩增加1
        }
    }
}

这段代码首先找到两个节点的根节点。如果它们不在同一个连通分量中,比较它们的秩,将秩较小的根节点的父节点更新为秩较大的根节点。如果秩相同,任选一个作为根,并增加其秩。

维护BlockCnt

add节点的时候++,union的时候--即可

如何删除

唯一需要注意的点就是如何在并查集中删除一个关系呢,朴素的全部重建我觉得也不会超时,并且在互测数据范围内不会被卡超时。当然可以选择只重建那两个节点的相关部分,方法如下:

两个相连的点A,B,对A进行dfs,把所有跟A有关系的点的根设置为A,同时秩为1。

for (int node : visited) {
      parent.put(node, startNode);
      rank.put(node, 1);
}

如果这其中没有B,就把B新建为一个根节点。

(zy学姐的博客中貌似说使用这种方法不能使用按秩合并,为什么,不懂)

dfs

private void dfs(int node, Set<Integer> visited) {
    visited.add(node);
    for (int neighbor : graph.get(node)) {
        if (!visited.contains(neighbor)) {
            dfs(neighbor, visited);
        }
    }
}

维护tripleCnt

因为在Map类中额外维护了一个关系图,在unionremoveEdge的时候分别去addTriple(x, y);,delTriple(x, y);

朴素的遍历,唯一的小优化是先比较x和y谁的有关系节点少,就用他的查

private void addTriple(int x, int y) {
        for (int neighbor : graph.get(x)) {
            if (graph.get(neighbor).contains(y)) {
                tripleCnt++;
            }
        }
    }

其他注意点

在上面这个操作中

private void addTriple(int x, int y) {
        for (int neighbor : graph.get(x)) {
            if (graph.get(neighbor).contains(y)) {
                tripleCnt++;
            }
        }
    }

这样写

if (graph.get(y).contains(neighbor)) 

在我的架构中是可以的,因为使用HashMap。但如果是在MyPerson中进行三元闭包的添加,就要注意这个顺序了,就是数组按行遍历和按列遍历,实测速度会相差10倍。

或许可以使用动态树Link/Cut Trees?支持边的动态添加和删除。Link/Cut Trees 结构使用 Splay Trees 作为其核心的数据结构来处理动态树的问题。由于 Splay Trees 的操作通常涉及到旋转,以使任意节点成为树的根,Link/Cut Trees 的性能取决于 Splay Tree 的操作效率。时间复杂度均为 **O(log n)**。

相较于并查集,Link/Cut Trees 提供了更多的灵活性,尤其是在需要处理节点间动态连接和断开的场景下。并查集在处理只有合并操作的连通性问题时非常高效,尤其是通过路径压缩和按秩合并优化后,其几乎能达到接近常数时间的效率。

压力测试

使用150个节点构建成一个完全图(11175个边),然后再删除所有的边,嗯不超时。

image-20240425104014610

junit

注意以下pure条件就好

第十次总结

这次作业要实现的内容有以下几个:

增加tag,并求总和和方差

找value最大的熟人,并且计算couplesum

求两点间的最短路径(不带权重)

tag有关

首先$10000 * 200 ^ 2 = 4*10^8 < int:2,147,483,6472,147,483,647$

也就是说方差不会爆int

然后就是在tag中维护两个属性

private int ageSum; 
private int ageSquaredSum;

addPerson,delPerson的时候维护一下就好

couplesum

首先是如何去求一个的bestperson,以下用bp代称

这里朴素实现的话就是遍历,但是我们可以用一个带有顺序的数据结构去存acquaintance,我使用的是treemap,前面的键值就是value,同时按照value从大到小排序

private final TreeMap<Integer, Set<Person>> acquaintancesByValue = new TreeMap<>(Collections.reverseOrder());

这样的话理论上来讲,add操作由常数级变为Olgn,查找bp操作由On变为了常数级(取第一个值就好)

但是实际测试的话与正常实现的时间相差不大。

注意一下加人的时候还要按照id从小到大加进去

acquaintancesByValue.putIfAbsent(value, new TreeSet<>(Comparator.comparingInt(Person::getId)));
acquaintancesByValue.get(value).add(person);

这里同样使用treeSet来实现排序

在您的当前实现中,确实,使用 TreeMap<Integer, Set<Integer>> 嵌套 TreeSet<Integer> 的结构,每次添加和删除操作的时间复杂度都是log2n,其中 (n) 是元素的数量。这种结构确保了亲密值可以被排序,并且每个亲密值下的熟人 ID 也是有序的。

改进方案

如果您想避免使用两层的排序结构而又想维持排序的特性,可以考虑使用以下几种方法之一:

  1. 使用单个数据结构,如 TreeMap,但以不同的方式:

    • 可以考虑将 TreeMap 的 key 设计为一个复合键,这个键可以同时包含亲密值和熟人 ID,但这需要实现自定义的比较器。这种方式的缺点是处理复杂性高,且可能难以直接使用标准的 TreeMap
  2. 使用 PriorityQueueSortedSet 的变种:

    • 可以使用如 PriorityQueueSortedSet(如 TreeSet),这些结构内部也是用树实现的,但可以通过封装一个包含亲密值和 ID 的对象来管理排序。
  3. 自定义数据结构:

    • 实现一个自定义的数据结构,这个结构内部管理两个映射:一个映射按照亲密值到熟人 ID 的集合,另一个直接映射从熟人 ID 到亲密值。这样可以在更新和查询时更加高效。

使用自定义比较器

如何使用 TreeMap 但通过自定义比较器来避免嵌套操作:

this.acquaintances = new TreeMap<>(Comparator
                .comparingInt(AcquaintanceKey::getValue)
                .thenComparingInt(AcquaintanceKey::getPersonId));

private static class AcquaintanceKey {
        private final int value;
        private final int personId;
}

1

public class MyPerson implements Person {
    private final int id;
    private final String name;
    private final int age;
    private final TreeMap<AcquaintanceKey, Integer> acquaintances = new TreeMap<>();
    private final HashMap<Integer, Tag> tags;

    public MyPerson(int id, String name, int age) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.tags = new HashMap<>();
    }

    private static class AcquaintanceKey {
        int value;
        int personId;

        AcquaintanceKey(int value, int personId) {
            this.value = value;
            this.personId = personId;
        }

        public int getValue() {
            return value;
        }

        public int getPersonId() {
            return personId;
        }
    }

    private Comparator<AcquaintanceKey> comparator = Comparator
        .comparingInt(AcquaintanceKey::getValue)
        .thenComparingInt(AcquaintanceKey::getPersonId);

    public void addAcquaintance(Person person, int value) {
        AcquaintanceKey key = new AcquaintanceKey(value, person.getId());
        acquaintances.put(key, person.getId());
    }

    // 其他方法同理进行修改
}

这种方法通过将亲密值和熟人 ID 封装成一个 AcquaintanceKey 对象,然后在 TreeMap 中使用自定义的比较器来排序。这样可以避免嵌套的集合结构,同时保持数据的有序性和快速访问性。

之后就是求couplesum

我在这里实现了一个脏位,因为能够使得couplesum改变的情况是可以被包含在下面两种情况中的:

  1. 修改1的2关系,此时2是1的bp
  2. 修改了1的某个关系,1的bp前后发生了改变

modifyRelation中增加对这两种情况的检查,成立就设置脏位为true

queryCoupleSum

    if (!coupleDirty) {
            return coupleCnt;
        }
        coupleCnt = 0;
        coupleDirty = false;

这样就实现了尽可能少的全部遍历着查找couplesum

同时,还可以用一个set来存已经从处理过的person,当遍历到这个person的时候,continue就好,如下,这样这个操作就是On级别的了

for (Person p1 : persons.values()) {
            获取p1的最佳熟人ID
                如果p1已处理过,则跳过
                获取p1的最佳熟人
                    获取p2的最佳熟人ID
                    如果p2的最佳熟人也是p1
                        计数
                        标记p1p2为已处理
                    }
                }
        }

求最短路径

上次作业中我在Mymap类中预存了一个关系图,这时候就用上了,可以基于此来实现对最小路径的查询。

queryShortestPath(int id1, int id2) 是在不带权重的图中寻找路径。使用BFS比较合理

广度优先搜索(BFS)

BFS 是一种用于图的遍历或搜索的算法,它从根节点开始,一层层向外扩展,直到找到目标节点为止,因此它自然地适用于在无权图中找到最短路径(即边的数量最少的路径)。

在此基础上,我实现了双向BFS,如下:

双向BFS

双向广度优先搜索(Bi-directional BFS)是一种用于搜索最短路径的算法,特别适用于在两个已知节点之间寻找最短路径的场景。这种方法从两个节点(通常是起点和终点)同时开始搜索,一层一层地向外扩展,直到两个搜索从不同方向相遇。相比于单向的 BFS,双向 BFS 可以显著减少搜索空间,因此在很多情况下能更快地找到最短路径。

如何工作?

双向 BFS 同时从两个节点开始搜索:一个从起点开始,另一个从终点开始。每次迭代中,算法交替扩展这两个方向的边界,即先扩展从起点开始的一层,再扩展从终点开始的一层。这种方法的关键在于,只需要扩展到两个搜索相遇的地方,而不是完全探索整个图。

算法步骤

  1. 初始化:为起点和终点各自创建一个队列和一个访问集合,用于存储每一层的节点和已访问的节点。
  2. 迭代搜索:在每一轮中,对起点的队列进行扩展,然后对终点的队列进行扩展。
  3. 检查相遇:在每次扩展后,检查当前扩展的节点是否被对方的搜索已访问过。

dijkstra比较

  • 双向 BFS 同时从起点和终点开始搜索,并在两者相遇时停止。这意味着搜索空间通常是从单一起点或终点开始时搜索空间的平方根。相比之下,Dijkstra 算法通常从单一起点出发,可能需要遍历图中更多的节点。
  • 在节点间的平均距离较短的图中,双向 BFS 可以非常迅速地找到路径,因为两边搜索很快就会相遇。Dijkstra 算法则可能在找到最短路径之前遍历更多的节点。
  • 因为我们只需要提供长度,也不需要知道具体路径,这一点也符合BFS

优化

我们可不可以把查找过程中的点之间的距离都存下来呢,下次找的时候就可以直接给出了。这里同样需要设置脏位。但是对于单向BFS来说这个操作很好实现,那么双向BFS怎么办呢。

双向 BFS 和单向 BFS 加缓存是两种有效的最短路径搜索策略,它们各有优势和局限。下面是对这两种方法的分析:

选择哪种方法取决于具体应用的需求:

  • 如果查询多样化且图较大,或者需要频繁从不同的源点进行搜索,双向 BFS 可能更合适。
  • 如果查询经常重复或者通常是从同一或少数几个源点到不同目标点,单向 BFS 加缓存可能更优。

在考虑100个节点的稠密图和稀疏图进行最短路径搜索时,我们可以分析双向 BFS 和单向 BFS(加缓存)的效率。对于这样的图规模,我们可以详细比较这两种方法在不同类型的图结构下的表现。

对于稠密图

  • 双向 BFS 在理论上更快找到路径,但处理开销大。
  • 单向 BFS 加缓存适合重复查询,但初次填充缓存成本高。

对于稀疏图

  • 双向 BFS 可能不如稠密图中高效,但对于某些配置可能仍然是一个不错的选择。
  • 单向 BFS 加缓存在稀疏图中更为高效,特别是当查询集中在少数几个源点时。

综上,可以找一个折中的方法:就是在双向BFS的时候左边存一半,右边存一半经过点的距离。

private int visitLevel() {
            得到当前层的节点数
            for (int i = 0; i < currentSize; i++) {
               得到当前距离
                for (int neighbor : graph.get(currentNode)) {
                    if (!visited.containsKey(neighbor)) {
                        存储距离时考虑中间节点数量
                        检查是否与对方搜索相遇
                            updateCache(sourceId, neighbor, totalDistance);
                            检查是否与对方搜索相遇 {
                            // 返回总距离
                            updateCache(sourceId, neighbor, totalDistance); 
                            return totalDistance;
                        }
                        }
                    }
                }
            }
        }
        return -1; // 这一层未找到交汇点
    }

我的nb帅气室友叶佩霖提出了:

当删边前后的tripleSum改变了,就说明并查集去掉这俩点的连接后,有另外一个点跟他们是连着的!!!!!因此并查集不需要重建,这个剪枝操作实测能让删除操作的耗时降低一个数量级。

第十一次分析

private final List<Message> messages = new LinkedList<>();// 使用 LinkedList 存储消息,因为要取前五个

测试分析

测试过程分析

本单元的测试过程包括以下几个步骤:

  1. 数据生成

    • 使用不同的策略生成各种测试数据,包括正常数据、扩展数据、全部随机数据等。
    • 数据生成函数可以包括随机数据以及手动捏造的数据等,根据不同的输入参数和策略生成相应的指令列表。
  2. 指令执行

    • 每个生成的数据集包含一系列指令,这些指令将被执行以测试系统的不同功能。
    • 指令类型包括添加人(ap)、添加关系(ar)、查询(qvqci等)、发送消息(amaremanm等)等。
  3. 结果验证

    • 根据生成的数据集和预期的结果,对系统输出进行验证。
    • 通过比较预期的输出和实际的输出,判断系统是否按照预期工作。

黑箱测试与白箱测试的理解

  • 黑箱测试

    • 定义:不考虑程序内部结构和实现的测试方法,只关注输入和输出。
    • 优点:无需了解系统内部实现,测试用例的设计更侧重于功能和需求,能够发现接口、功能等方面的问题。
    • 缺点:无法覆盖程序的所有逻辑分支,可能会遗漏一些内部的逻辑错误。
  • 白箱测试

    • 定义:了解程序内部结构和实现的测试方法,通过代码的逻辑结构设计测试用例。
    • 优点:能够覆盖程序的所有逻辑分支,发现隐藏的逻辑错误。
    • 缺点:需要对系统内部实现有深入了解,测试用例设计复杂,可能会忽略系统外部接口和功能问题。

单元测试、功能测试、集成测试、压力测试、回归测试的理解

  • 单元测试

    • 定义:对软件系统中的最小可测试单元进行测试,一般是函数或类的方法。
    • 目标:确保每个单元按预期独立工作。
    • 策略:使用模拟对象和桩来隔离单元进行测试。
  • 功能测试

    • 定义:验证系统的每个功能模块是否按照需求文档的规定正确工作。
    • 目标:确保系统功能满足用户需求。
    • 策略:基于用例的测试,检查功能输入和输出。
  • 集成测试

    • 定义:在单元测试的基础上,将多个单元组合起来进行测试,验证它们之间的接口和交互。
    • 目标:发现单元之间接口和集成的问题。
    • 策略:自底向上或自顶向下逐步集成,使用驱动程序和桩进行测试。
  • 压力测试

    • 定义:通过对系统施加超出正常工作负载的压力,测试系统的稳定性和性能。
    • 目标:确保系统在高负载条件下能够稳定运行。
    • 策略:逐步增加负载,观察系统性能指标和行为。
  • 回归测试

    • 定义:在软件发生修改后重新运行测试,确保修改没有引入新的错误,并验证修复的错误。
    • 目标:确保新代码的修改不会影响现有功能。
    • 策略:重新执行已有的测试用例,并增加新的测试用例覆盖修改部分。

代码的数据构造分析

人员生成

  • 列表中随机选取一个未添加的人员,如果列表为空,则返回一个新建的人员对象。
  • 列表中随机选取一个已添加的人员,如果列表为空,则调用1方法。
  • 随机返回一个未添加的人员,有一定概率(60%)返回一个新添加的人员。
  • 随机返回一个人员,有很小的概率(1%)返回一个已添加的人员。

指令生成

根据不同的需求和规则生成相应的指令字符串。

数据构造策略

  1. 随机性

    • 通过Random类生成随机数和字符串,确保测试数据的多样性和不可预测性。
    • 随机选择人员和关系,模拟真实的随机操作场景。
  2. 列表管理

    • 使用不同列表分别管理未添加、已添加和未添加的人员,确保指令生成的合理性和逻辑性。
  3. 指令多样性

    • 提供多种指令生成方法,涵盖系统的不同功能,如添加人员、添加关系、查询、发送消息等,确保全面测试系统的各个方面。
  4. 数据关联

    • 在生成指令时,利用数据池中的信息(如信息tags)和已生成的消息ID、表情ID,确保生成的数据具有一定的关联性和逻辑性。
  5. 组合测试

    组合测试是将多种指令组合起来进行测试,验证系统在不同操作组合下的表现

  6. 大数据量测试

    大数据量测试是测试系统在处理大量数据时的表现。这有助于发现系统的性能瓶颈和资源管理问题。

  7. . 特殊值测试

    特殊值测试包括零值、负值和特定的特殊值。此策略有助于发现处理特殊值时的潜在问题。

总结

测试过程涵盖了从数据生成到结果验证的完整流程,采用了多种数据生成策略来覆盖不同的测试场景和条件。通过黑箱测试和白箱测试结合的方式,可以确保系统在各种情况下的稳定性和正确性。单元测试、功能测试、集成测试、压力测试和回归测试等不同类型的测试共同作用,全面保证系统的质量。

架构设计

架构设计概述

本单元的架构设计包括以下几个核心组件:

  1. MyPerson:表示图中的节点(个人),包含个人信息及其朋友列表。
  2. MyNetwork:管理所有的MyPerson对象,并处理节点和节点之间关系的增删改查。
  3. MyMap:提供更高层次的接口,封装MyNetwork的操作。
  4. 消息类:包括MyMessageMyNoticeMessageMyEmojiMessageMyRedEnvelopeMessage,处理不同类型的消息。
  5. 标签类MyTag表示标签,用于组织和管理用户的标签信息。

这些组件共同构成了一个社交网络图模型,支持用户、关系、消息和标签的管理。

图模型的构建策略

图模型的构建策略包括以下几个方面:

  1. 节点的创建

    • 每个用户在系统中表示为一个MyPerson对象,包含用户的基本信息(如ID、名字、年龄)和朋友关系。
    • 通过MyNetworkaddPerson方法来创建和添加新的节点。
  2. 边的创建

    • 图中的边表示用户之间的朋友关系,通过MyNetworkaddRelation方法来创建。
    • 每次添加关系时,更新两个用户的朋友列表,保证图结构的连通性。
  3. 消息的处理

    • 系统支持多种消息类型,包括普通消息、通知消息、表情消息和红包消息,每种消息由对应的类表示。
    • 通过MyNetworkMyMap的方法来发送和处理消息,确保消息在用户之间正确传递。
  4. 标签的管理

    • MyTag类表示标签信息,通过标签将用户组织起来。
    • 通过MyNetworkMyMap的方法来添加和管理标签,支持用户对标签的操作。

图模型的维护策略

图模型的维护策略主要包括以下几个方面:

  1. 节点的维护

    • 添加节点:使用MyNetworkaddPerson方法添加新节点。
    • 删除节点:使用MyNetworkremovePerson方法,同时需要从所有节点的朋友列表中移除该节点,保持图的完整性。
    • 更新节点:通过MyPerson对象的方法来更新节点信息,如修改年龄或名字。
  2. 边的维护

    • 添加边:使用MyNetworkaddRelation方法,确保双方的朋友列表都被更新。
    • 删除边:使用MyNetworkremoveRelation方法,同样需要更新双方的朋友列表。
  3. 消息的维护

    • 系统支持多种消息类型,维护消息的发送、接收和处理逻辑。
    • 通过相应的消息类(如MyMessageMyNoticeMessage)来管理不同类型的消息,确保消息的正确传递和处理。
  4. 标签的维护

    • 使用MyTag类来管理标签信息,支持标签的创建、删除和更新。
    • 通过MyNetworkMyMap的方法来处理标签相关的操作,确保标签信息的准确性。

具体实现细节见上面的作业总结

bug分析

出现了两个bug

1.queryValueSum被卡到了,设置脏位解决

2.

person1.addMoney(-moneyToDistribute * tag.getSize());

写成了

person1.addMoney(-(RedEnvelopeMessage) message).getMoney());

可能不被整除,没好好读JML导致的。

规格与实现分离的理解

规格与实现分离是一种软件工程中的重要原则,它强调在开发过程中将软件的行为描述(规格)与其具体实现分开。这样做有几个主要优点:

  1. 清晰性
    • 规格明确描述了软件的预期行为,使开发者和用户都能清楚地理解系统应该做什么,而不需要了解其内部实现细节。
  2. 可维护性
    • 当需求发生变化时,可以首先修改规格,然后再调整实现,这样可以确保系统行为的变更是有依据的,不会引入意外的错误。
  3. 可替换性
    • 如果规格与实现严格分离,可以在不影响外部行为的情况下替换实现。这对于性能优化或技术更新非常有用。
  4. 测试性
    • 根据规格进行测试,可以更容易地验证系统是否满足预期需求。通过规格定义的测试用例,可以独立于实现进行测试。

JML与规格与实现分离的关系

JML(Java Modeling Language)作为一种行为接口规格语言,与规格与实现分离的原则密切相关。

1. 明确行为规范

JML允许开发者在Java代码中嵌入行为规范,包括前置条件、后置条件、类不变式等。这些规范明确地描述了方法的预期行为和类的属性,而不涉及具体的实现细节。例如,前置条件定义了在调用方法之前必须满足的条件,后置条件定义了方法执行后应满足的条件。这些规范使得开发者和用户可以清晰地理解系统的预期行为,而无需了解其内部实现。

2. 增强可维护性

通过使用JML,开发者可以将规格与实现分开,这大大提高了代码的可维护性。当需求发生变化时,开发者可以首先修改JML规范,然后再调整实现代码。JML规范提供了一个明确的行为标准,确保实现的修改是有依据的,并且不会引入意外的错误。

3. 支持可替换性

如果规格与实现严格分离,可以在不影响外部行为的情况下替换实现。JML提供了一个详细的行为描述,使得不同的实现可以基于相同的规格进行替换。这样,如果需要优化性能或进行技术更新,只需确保新实现满足JML规范即可,而不需要担心改变外部行为。

4. 改进测试性

JML规范可以用于生成测试用例,根据规格进行测试可以验证系统是否满足预期需求。由于JML明确了方法的行为规范,测试人员可以根据这些规范设计测试用例,独立于实现进行测试。这确保了测试的全面性和准确性,能够有效发现实现中的问题。

5. 提升文档化效果

JML规范作为代码的一部分,提供了自文档化的效果。通过阅读JML注释,开发者可以直接理解类和方法的行为规范,这对于代码审查、团队协作和后续维护非常有帮助。

总结

JML与规格与实现分离的关系密切,通过提供形式化的行为规范,JML实现了规格与实现的明确分离。这种分离带来了诸多好处,包括明确行为、增强可维护性、支持可替换性、改进测试性和提升文档化效果,使得软件系统更加健壮、易于理解和维护。

Junit测试

利用规格信息更好地设计实现JUnit测试

结合JML规格信息,我们可以更好地设计JUnit测试,并有效检验代码实现与规格的一致性。以下是如何利用规格信息优化JUnit测试设计的分析:

1. 明确测试目标

规格信息提供了详细的行为描述,包括前置条件、后置条件和类不变式。利用这些信息,可以明确测试的目标和预期结果。

  • 前置条件:确定方法在什么情况下可以被调用,以及在这些条件下应该如何测试。
  • 后置条件:确定方法调用后的预期结果,用于验证方法的正确性。
  • 类不变式:确保类在方法调用前后保持一致性。

2. 设计全面的测试用例

利用JML规格信息,可以设计出全面的测试用例,覆盖各种可能的输入和场景。

  • 正常情况:测试方法在满足前置条件时的行为,验证其是否满足后置条件。
  • 边界情况:测试方法在接近边界值时的行为,验证其是否正确处理这些特殊情况。
  • 异常情况:测试方法在不满足前置条件时的行为,验证其是否能够正确处理并抛出适当的异常。

3. 自动生成测试用例

一些工具可以根据JML规格自动生成测试用例,这大大简化了测试的设计过程,并确保测试用例与规格的一致性。

4. 增强测试的可读性

通过结合JML规格信息,JUnit测试用例可以更清晰地表达测试目的和预期结果,提高测试代码的可读性和可维护性。

利用JUnit测试检验代码实现与规格的一致性

1. 验证前置条件

在JUnit测试中,可以首先验证前置条件是否满足,确保在适当的情况下调用方法。

2. 验证后置条件

在方法调用后,使用JUnit断言验证后置条件是否满足。

3. 验证类不变式

通过在方法调用前后验证类不变式,确保类在操作过程中始终保持一致性。

整合JML与JUnit的好处

1. 提高测试覆盖率

通过利用规格信息,可以设计出全面的测试用例,覆盖更多的场景和边界情况,从而提高测试覆盖率。

2. 确保规格一致性

JUnit测试可以验证实现是否满足JML规格,通过自动化测试不断验证代码的正确性和一致性,确保实现与规格一致。

3. 简化测试设计

规格信息提供了明确的测试目标和预期结果,简化了测试用例的设计过程,使测试更具针对性和有效性。

4. 增强代码可靠性

通过全面的JUnit测试,验证代码实现是否满足规格,可以有效发现和修复潜在的错误,增强代码的可靠性和稳定性。

总结

结合JML规格信息,利用JUnit测试可以更好地设计和实现测试用例,全面验证代码实现与规格的一致性。这种方法不仅提高了测试覆盖率和代码可靠性,还简化了测试设计过程,使得测试更加高效和有针对性。通过这种方式,可以有效确保软件系统的行为符合预期,提升软件质量。

学习体会

对于JML的体会

在本单元学习过程中,JML(Java Modeling Language)给我留下了深刻的印象。作为一种行为接口规格语言,JML在描述Java程序行为方面提供了强大的工具,使得规格与实现分离的原则得以有效应用。以下是我对JML的几点体会:

1. 增强代码清晰性

JML的主要作用是通过形式化的方式描述程序的行为,包括方法的前置条件、后置条件和类不变式等。这种明确的行为描述大大增强了代码的清晰性,使得开发者能够准确理解和维护代码。

2. 提高代码可维护性

JML使得代码维护更加容易。当需求变化或代码需要重构时,可以首先修改JML规范,然后根据新的规范调整代码实现。这样做有助于确保修改后的代码仍然符合预期行为,减少引入错误的风险。此外,JML规范还为代码的文档化提供了极大的帮助,使得代码更加可读和易于理解。

3. 支持自动化验证和测试

JML不仅仅是文档化工具,它还可以与工具链结合,进行自动化验证和测试。通过JML,可以自动生成测试用例,并在运行时验证程序是否满足规格要求。例如:

  • 使用工具进行静态分析,检查代码是否违反了JML规范。
  • 在单元测试中,自动验证方法的前置条件和后置条件,确保代码的正确性。

这种自动化验证和测试极大地提高了开发效率和代码质量。

4. 促进可靠的软件设计

JML促使开发者在编写代码前首先思考并定义行为规范,这有助于设计出更加健壮和可靠的软件系统。通过详细定义每个类和方法的行为,可以提前发现和解决潜在的问题,减少在实现阶段出现的错误。

对本单元的理解

本单元强调了规格与实现分离的重要性,并通过JML提供了实现这一原则的具体工具。通过在代码中嵌入JML规范,我们可以清晰地定义和理解系统的行为,而不依赖于具体的实现细节。这种方法提高了代码的可读性和可维护性,使得代码修改和扩展更加容易。

在实际开发中,JUnit测试是确保代码质量的常用工具。本单元通过结合JML和JUnit测试,展示了如何利用规格信息设计和实现更为全面和有效的测试。通过JML规范明确测试目标,JUnit测试用例可以覆盖更多的场景和边界情况,从而提高测试覆盖率和代码可靠性。

在实践过程中,编写和维护JML规范需要一定的时间和精力,尤其是在复杂系统中。然而,这种投入是值得的,因为它能显著提高代码质量,减少后续维护的成本和风险。此外,通过结合JML和JUnit测试,可以形成良好的开发和测试习惯,提升整体开发效率和软件质量。

通过本单元的学习,我深刻体会到JML在增强代码清晰性、提高可维护性、支持自动化验证和测试以及促进可靠的软件设计方面的巨大价值。同时,结合JML和JUnit测试,实现了规格与实现分离的原则,使得代码开发更加规范和高效。

...全文
57 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧