拓扑排序在处理树形关系结构中的应用

Preface


偶然在QQ上的一个交流群中看到了一位群友的棘手需求。互联网开发中,数据的落盘存储通常在MySQL中。MySQL是一种关系型数据库,以“行”为基本的存储单元,然后通过外键等建立数据实体模型之间的联系。

但有些数据的存储,在MySQL上并没有那么友好。比如一个存在等级与隶属关系的部门表。每个部门会有一个所直接隶属的上级部门,一个部门又可能会有多个直属的下级部门。在MySQL中存储时,每一个部门都要有一个parent列,来指明自己的直属上级部门。这样一来,就会得到一张实际为树形关系的部门结构表。

实体数据之间的关系,是一张树形结构图

话题回到群友的需求上来,群友的需求也很简单,通过数据库查询与业务层的处理,将所有部门的完整隶属关系整合成一个列表,如下所示:

0: 北京分公司 -> 人事部 -> 档案组
1: 北京分公司 -> 人事部 -> 绩效组
2: 北京分公司 -> 科技部 -> 前台组
             ...

处理思路


看到了这样的需求后,第一个闪念在脑海中的想法是——拓扑排序。我们知道树结构是图结构的一种特例,树结构是一种无环图,因此只要数据库记录层面不出问题,将查询得到的数据按照树结构组织起来,那么该结构的拓扑序列是一定存在的。

在实际拓扑排序中,只要将每条数据的parent域作为一条指向其他结点的边来看待即可。从入度为零的结点出发,按照parent指针进行深度优先遍历,将路径上的结点收集起来,即可完成上述需求。

如果想要了解拓扑排序的过程或者算法原理,可以查看数据结构书籍中关于章节的讲述,也可查看下面链接中的解释。

拓扑排序百度百科:https://baike.baidu.com/item/%E6%8B%93%E6%89%91%E6%8E%92%E5%BA%8F

拓扑排序维基百科:https://zh.wikipedia.org/wiki/%E6%8B%93%E6%92%B2%E6%8E%92%E5%BA%8F

设计与实现


算法性能预估

在拓扑排序之前,我们需要考虑一下算法的性能问题。首先拓扑排序的时间复杂度在 O ( N 2 ) O(N^2) O(N2),因此理论上该算法的性能并不好。但是时间复杂度只是一个理论指标,实际我们还需要考虑问题的规模。通常部门的架构表的数据量不会很大,对于某些场景下,该表的数据量也就在几千到几万条之间。

同时,部门架构表经常面对的是读多写少的场景,因此一定程度上,我们可以将一次业务处理的数据进行缓存。而且在业务处理中,面对如此小量的数据,通常我们可以从磁盘中一次性将其读入到内存,然后供业务层处理,这样可以减少磁盘IO的次数。

算法实现

准备承载数据库查询结果的POJO类

@Data  
@NoArgsConstructor  
@AllArgsConstructor  
static class Node {  
    private Integer id;            // 部门ID  
    private String name;           // 部门名称  
    private Integer parent;        // 上级部门的ID  
    private Integer inDegree = 0;  // 该部门的入度(该部门子部门的数量)  
}

模拟数据库查询得到整张部门表数据

final List<Node> dbQuerySet = Arrays.asList(  
        new Node(1, "北京分公司", null, 0),  
        new Node(2, "人事部", 1, 0),  
        new Node(3, "科技部", 1, 0),  
        new Node(4, "售后部", 1, 0),  
        new Node(5, "档案组", 2, 0),  
        new Node(6, "绩效组", 2, 0),  
        new Node(7, "前台组", 3, 0),  
        new Node(8, "中台组", 3, 0),  
        new Node(9, "后台组", 3, 0),  
        new Node(10, "回访组", 4, 0),  
        new Node(11, "话务组", 4, 0),  
        new Node(12, "客服中心", 4, 0),  
        new Node(13, "纪律检查小组", null, 0),  
        new Node(14, "总公司驻派审计小组", null, 0),  
        new Node(15, "作风违纪监察委员会", 13, 0),  
        new Node(16, "财务违纪监察委员会", 13, 0),  
        new Node(19, "制度研判委员会", 13, 0)  
);

将查询到的数据组织为树结构,并计算每个部门的入度

// 0. 将数据组织为树结构  
final Map<Integer, Node> map = new HashMap<>();  
// 1. 将所有的查询结果放入Map中缓存,以部门ID为查询键  
dbQuerySet.forEach(it -> map.put(it.getId(), it));  
// 2. 建立每个结点的入度  
dbQuerySet.forEach(it -> {  
    final Integer p = it.getParent();  
    if (p == null) return;  
    map.compute(p, (id, node) -> { node.setInDegree(node.getInDegree() + 1); return node; });  
});

按照拓扑排序的思想,实现上文中的要求

// 3. 开始构建  
final List<String> deptTopologicalGraph = new ArrayList<>(); // 存储最终的结果  
final Deque<String> path = new ArrayDeque<>(); // 存储每次深度优先搜索路径上的结点  
for (;;) {  
    if (map.isEmpty()) break;  
    Node t = null;  
    //  3.1 找到入度为0的结点  
    for (final Map.Entry<Integer, Node> e : map.entrySet()) {  
        if (Objects.equals(e.getValue().getInDegree(), 0)) {  
            t = map.remove(e.getKey());  
            break;  
        }    
    }    
    
    Objects.requireNonNull(t, "部门架构中存在环路");  
    if (t.getParent() == null) continue; // 排除汇点  
  
    // 3.2 按照parent域不断向上寻找  
    for (;;) {  
        path.push(t.getName());  
        final Integer pId = t.getParent(); // 获取父结点的ID  
        if (pId == null) break;  
        final Node pn = map.get(pId); // 获取父结点  
        Objects.requireNonNull(pn, "父结点不能为空");  
  
        if (Objects.equals(t.getInDegree(), 0)) {  
            // 若当前结点的入度为零,则将该结点从Map中删除  
            //  同时将当前结点的父结点入度值减1  
            pn.setInDegree(pn.getInDegree()-1);  
            map.remove(t.getId());  
        }        
        t = pn;    
    }    
    deptTopologicalGraph.add(String.join(" -> ", path)); // 生成结果  
    path.clear(); // 清空path  
}  
deptTopologicalGraph.forEach(System.out::println);

算法执行结果

北京分公司 -> 人事部 -> 档案组
北京分公司 -> 人事部 -> 绩效组
北京分公司 -> 科技部 -> 前台组
北京分公司 -> 科技部 -> 中台组
北京分公司 -> 科技部 -> 后台组
北京分公司 -> 售后部 -> 回访组
北京分公司 -> 售后部 -> 话务组
北京分公司 -> 售后部 -> 客服中心
纪律检查小组 -> 作风违纪监察委员会
纪律检查小组 -> 财务违纪监察委员会
纪律检查小组 -> 制度研判委员会

算法存在的问题与解决方案

从上述算法执行结果上来看,部门中存在多个一级部门(没有上级的部门)时,算法也可以正常工作。但是若一个一级部门没有其下属部门时,最终的结果并不会将其收纳进来。因此在算法开始之前,可以先将这些作为一级部门但没有下属部门的特例单独过滤出来处理。

// 3.0 预处理一些特例情况  
final Map<Integer, Node> newMap = map.entrySet().stream()  
        .filter(it -> {  
            final Node n = it.getValue();  
            if (n.getParent() == null && n.getInDegree() == 0) {  
                deptTopologicalGraph.add(n.getName());  
                return false;  
            } else {  
                return true;  
            }        
        })        
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

正确的输出结果

总公司驻派审计小组
北京分公司 -> 人事部 -> 档案组
北京分公司 -> 人事部 -> 绩效组
北京分公司 -> 科技部 -> 前台组
北京分公司 -> 科技部 -> 中台组
北京分公司 -> 科技部 -> 后台组
北京分公司 -> 售后部 -> 回访组
北京分公司 -> 售后部 -> 话务组
北京分公司 -> 售后部 -> 客服中心
纪律检查小组 -> 作风违纪监察委员会
纪律检查小组 -> 财务违纪监察委员会
纪律检查小组 -> 制度研判委员会

可优化的点

可以在数据库存储时,为每一行数据新增一个入度(in_degree)列,这样在查询时,可以将某些特例单独查询处理,同时也减少了业务层面关于入度的一些计算工作。

-- 查询特例
select * from dept where parent = NULL and in_degree = 0;
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值