树结构≠递归,聊聊组装树结构时的误区

548 阅读5分钟

常见误区

在前后端开发时,如果遇到类似菜单树、部门树、人员树等业务需求,虽然有很多朋友是知道有许多种办法来实现的,但我在日常工作中碰到许多同事陷入了树结构=递归的误区里,常常被递归折磨地死去活来,一个树结构代码写了一下午才憋出来,最后性能还有可能很差。考虑到可能大家都有可能遇到这个问题,掘金里也很少提到这个话题,所以今天来简单聊聊。

一个典型的递归式树结构写法

/**
 * 递归,列表转树状结构
 *
 * @param sysMenus 所有菜单
 * @param parentId 父级id
 * @return 子菜单树集合
 */
private List<MenuVo> recurrence(List<MenuEntity> sysMenus, Long parentId) {
    return sysMenus.stream()
            .filter(i -> i.getParentId().longValue() == parentId)
            .sorted(Comparator.comparing(MenuEntity::getPriority))
            .map(menu -> {
                MenuVo menuTreeVo = new MenuVo();
                menuTreeVo.setMenuId(menu.getId());
                menuTreeVo.setName(menu.getName());
                menuTreeVo.setDescription(menu.getDescription());
                menuTreeVo.setPath(menu.getUrl());
                menuTreeVo.setIcon(menu.getIcon());
                menuTreeVo.setLeaf(menu.getLeaf());
                menuTreeVo.setChildren(recurrence(sysMenus, menu.getId()));
                return menuTreeVo;
            })
            .collect(Collectors.toList());
}

这是一段非常常见且最简单的页面菜单树的组装过程:

  • 第一步,定义或者传递过来一个最顶层的parentId
  • 第二步,进入递归,根据parentId查找直属的下级节点元素列表children
  • 第三步,对childen作循环,再找出他们的直属children,也就是childrenchildren,直到没有children为止。\

这个过程比较绕的点是:第二步和第三步我是基于代码的顺序来写的,但程序的运行过程第三步是先于第二步的
于是如果树的结构更加复杂,那递归的代码写起来会非常头大。
但其实,根本不需要用到递归。

时间复杂度问题

其实递归写法的执行效率是非常慢的, 这是因为每个节点在找寻children的时候都要遍历一遍所有节点才能找到所有children,就相当于两层完整的for循环。(对的,其实两层for循环也能实现组装树结构,但时间复杂度和递归一致,不推荐)。
因此常见递归写法的时间复杂度是O(N2N^2),N就是元素的数量,这个时间复杂度是非常不能接受的,当元素只有几百个的时候还好,如果你的元素是几万几十万个,那一个树组装下来可能得好几秒甚至几十秒。

误区产生的根源

当我们要组装一个有序链状结构的时候,比如A->B->C->D->E。写习惯了有序函数、有序业务的我们往往会陷入一个误区,那就是这个有序链状结构的组装是要按顺序的,先A->B,然后B->C,然后C->D,最后D->E。这也让我想起来很多朋友在优化递归性能的时候会把原始数据先按层级level去排个序,这样确实会快一点,但并没有解决根本问题。 但我们操作的是程序,就算我们对指针、地址的接触越来越少也应该知道这些概念,我们不需要按顺序去组装,即使是没有顺序地组装B->C 、D->E 、C->D、A->B,他们也会是A->B->C->D->E。
这其实才是很多很多开发朋友们遇到树状结构就要去递归的根源,很多人会下意识地想要一层一层地组装。 但事实是根本不需要。

推荐写法

打个比方,如果我们把待组装的节点元素想象成操场上的学生,给他们每人安排一个“领队”,然后组成一个树状方阵。
递归的玩法是:

  1. 先找出排在最前面的那批学生。
  2. 让这些“领队”在学生海里挨个问“我是你的领队吗?”来找到他们各自的直接“随从”
  3. 再让第二步的“随从”们找到他们各自的“随从”,依次类推。

太复杂了,其实只要让每个学生去找他们自己的唯一“领队”,并跟在他们后面,这不就行了嘛?
两者速率差别的本质是:领队不知道自己的随从是谁,而随从知道自己的领队是谁。
对应到绝大多数的树状结构场景里,某节点是存储着其父节点的信息,比如id的。但并不会存储其子节点的信息。
因此组装树结构正确的写法是:

  1. 假设有有一个原始entityList是所有原始节点数据的列表,需要转换成一个树状的VO对象列表。
  2. 执行单次循环,从entityList得到一个idVO对象的HashMapA。在这一步,VO对象的大部分字段已经填充完成(比如名称、图标、描述等等),只空一个childrenList为一个空列表。
  3. 在刚才第二步的时候,根据已知的parentId顺便把最上级的一个或几个VO放在一个额外的List-A里。
  4. 循环entityList,根据循环节点的parentId在HashMapA里找到对应的父节点,在其childrenList里把自己对应的VO加上
  5. 第三步的List-A就是我们要的结果。

整个过程只需要两次循环(两次,不是递归的嵌套两层),时间复杂度是O(N)。
这里多出来的一次循环是生成一个Hash字典,让某节点在找其父节点时非常迅速。
但在递归里是没法利用Hash字典的,因为递归的逻辑是父找子,而父节点是不拥有子节点的信息的,只能循环所有元素来一个个对比。
给一个示例代码:

/**
 * Hash字典,列表转树状结构
 *
 * @param sysMenus 所有菜单
 * @param parentId 父级id
 * @return 子菜单树集合
 */
private List<MenuVo> treeWithHash(List<MenuEntity> sysMenus, Long parentId) {
    // 最终返回的最上层元素
    List<MenuVo> topLevelMenuVOList = new ArrayList<>();
    // 转换为HashMap
    Map<Integer, MenuVo> voHashMap = sysMenus.stream().collect(Collectors.toMap(e -> e.getId().intValue(), menu -> {
        MenuVo menuTreeVo = new MenuVo();
        // 属性赋值
        menuTreeVo.setMenuId(menu.getId());
        menuTreeVo.setName(menu.getName());
        menuTreeVo.setDescription(menu.getDescription());
        menuTreeVo.setPath(menu.getUrl());
        menuTreeVo.setIcon(menu.getIcon());
        menuTreeVo.setLeaf(menu.getLeaf());
        menuTreeVo.setChildren(new ArrayList<>());
        // 如果是最上层元素,加入到最终返回的List中
        if (menu.getParentId().longValue() == parentId) {
            topLevelMenuVOList.add(menuTreeVo);
        }
        return menuTreeVo;
    }));
    // 遍历map,将子元素加入到父元素的children中
    for (MenuEntity menu : sysMenus) {
        if (voHashMap.containsKey(menu.getParentId())) {
            voHashMap.get(menu.getParentId()).getChildren().add(voHashMap.get(menu.getId().intValue()));
        }
    }
    return topLevelMenuVOList;
}

整段代码只有一个初始化组装的循环和一次子寻父的循环,不仅速度比递归快一个量级,编写难度也很好友好。
比如Hutool中的TreeUtil工具类里树状结构的组装代码就是使用的类似写法,而不是递归: image.png