引言

前段时间我写了一篇关于树操作的工具类《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》,当时主要把精力集中在分析代码的实现层面,没有从设计层面、性能层考虑,然后就被很多网友大神喷了,在此我做下深刻的反思,同时也针对大神网友提的缺点做出优化。
主要的缺点:

  1. 性能问题,因为使用递归,最坏的情况时间复杂度为 O(n^n),平均为O(nlogn),性能确实非常差
  2. 使用了peek(),peak()坑确实比较多,官方建议仅在调试和日志记录时使用,避免在 peek() 中修改元素状态
  3. 没有filter、search等方法,过滤和搜索也是树形结构中常用的方法

本文接针对这三点做了优化

一、性能优化

1.1 时间复杂度降到O(n)

使用网友的建议合成树使用Map用空间来换时间,将时间复杂度降到O(n),我们直接看优化后的代码:

public static <T, E> List<E> makeTree(List<E> menuList, Function<E, T> pId, Function<E, T> id, Predicate<E> rootCheck, BiConsumer<E, List<E>> setSubChildren) {
    //按原数组顺序构建父级数据Map,使用Optional考虑pId为null
    Map<Optional<T>, List<E>> parentMenuMap = menuList.stream().collect(Collectors.groupingBy(
            node -> Optional.ofNullable(pId.apply(node)),
            LinkedHashMap::new,
            Collectors.toList()
    ));
    List<E> result = new ArrayList<>();
    for (E node : menuList) {
        //添加到下级数据中
        setSubChildren.accept(node, parentMenuMap.get(Optional.ofNullable(id.apply(node))));
        //如里是根节点,加入结构
        if (rootCheck.test(node)) {
            result.add(node);
        }
    }
    return result;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

方法参数说明:

  1. List menuList,需要合成的集全数据
  2. Function<E, T> pId,实体中的父级ID字段,可以为null,如:MenuVo::getPId
  3. Function<E, T> id,实体中的ID字段,不能为null,如,MenuVo::getId
  4. Predicate rootCheck,判断为根节点条件,如: x->x.getPId()==-1L
  5. BiConsumer<E, List> setSubChildren,设置子节点方法,如:MenuVo::setSubMenus

使用方法:

//定义GroupVo
public class GroupVo {
    private String groupId;
    private String parentGroupId;
    private List<GroupVo> subGroups;
}
//测试合成树
GroupVo groupVo1=new GroupVo("a",null);
GroupVo groupVo2=new GroupVo("b",null);
GroupVo groupVo3=new GroupVo("c","a");
GroupVo groupVo4=new GroupVo("d","b");
List<GroupVo> groupVos= Arrays.asList(groupVo1,groupVo2,groupVo3,groupVo4);
List<GroupVo> tree=TreeUtil.makeTree(groupVos,GroupVo::getParentGroupId,GroupVo::getGroupId,x->x.getParentGroupId()==null,GroupVo::setSubGroups);
System.out.println(JsonUtils.toJson(tree));
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

输出结果:

[
    {
        "id": "a",
        "subGroup": [
            {
                "id": "c",
                "pid": "a"
            }
        ]
    },
    {
        "id": "b",
        "subGroup": [
            {
                "id": "d",
                "pid": "b"
            }
        ]
    }
]
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

1.2 性能对比测试

下图为优化后的makeTree和之前的方法分别对1万、2万、3万、4万、5万、6万节点进行耗时测试对比:

我是如何给阿里大神Tree工具类做CodeReview并优化的_List

图:Map和递归耗时对比

通过对比可以看出递归实现耗时会随着节点增长成指数增长,使用Map的耗时就小的多了,性能确实提升了好几个数量级。

1.3 Map实现原理:

使用Map空间换时间的思路其实并不复杂,主要有三步:

  1. 通过pId构建父级到下子级到倒排序索引,如图中的1下级有3和4
  2. 遍历原List,将所有节点首尾相连
  3. 返回我们需要的根节点

我是如何给阿里大神Tree工具类做CodeReview并优化的_System_02

图:使用Map合成树实现过程

图中 1-0 表示id=1,pId=0 ,右边为分组后的Map,我们看循环过程

  1. 1-0,在Map中通过1找到下级节点为3,4,然后设置到1的下级
  2. 2-0,在Map中通过2打到下级节点为5,然后设置到2的下级
  3. 3-1,在Map中没有打到下级
  4. 4-1,在Map中通过4打到下级节点为6,然后设置到4的下级
  5. 5-2,在Map中没有打到下级
  6. 6-4,在Map中没有打到下级

使用Map合成树代码虽然比较简单,但是还是有几点需要注意的:

  1. 使用Map空间换时间,将时间复杂度降为O(n),占用内存会增大
  2. 使用Optional类型支持pId为null
  3. 使用LinkedHashMap,保证合成树后子节点相对顺序不变
  4. 使用for替代peak(),避免peak的坑

1.4 大数据量测试

有网说他们有500万节点合成树的需求,在我做的所有项目中没有遇到过一次性合成500万节点树的需求,我想应该不是WEB或是移动应用,这500万节点树返回给前端,我想前端会直接卡死。不管有没有应用场景我们可以测试一下500万节点,树的深度为100的超级大树合成速度,结果只用了424ms,性能是不是炸裂了?

我是如何给阿里大神Tree工具类做CodeReview并优化的_数据结构_03

图:500万节点耗时424ms

再看一下makeTree方法内部耗时:

我是如何给阿里大神Tree工具类做CodeReview并优化的_数据结构_04

图:makeTree各方法耗时

可以看出主要耗时在是通过pId分组这里,耗时316ms。

测试代码:

//树节点数
 public static Integer size = 5000000;
 //树深度
 public static Integer deep = 100;
 private static List<MenuVo> menuVos = new ArrayList<>();
 @BeforeAll
 public static void init() {
     long currentId = 1;
     List<MenuVo> currentLevel = new ArrayList<>();
     MenuVo root = new MenuVo(currentId++, 0L, "Root");
     menuVos.add(root);
     currentLevel.add(root);
     for (int level = 1; level < deep && currentId <= size; level++) {
         List<MenuVo> nextLevel = new ArrayList<>();
         for (MenuVo parent : currentLevel) {
             for (int i = 0; i < size / Math.pow(10, level); i++) {
                 if (currentId > size) break;
                 MenuVo child = new MenuVo(currentId++, parent.getId(), String.format("关注公众号:【赵侠客】%s", currentId));
                 menuVos.add(child);
                 nextLevel.add(child);
             }
         }
         currentLevel = nextLevel;
     }
 }

 @Test
 public void testBigTree() {
     List<MenuVo> tree = TreeUtil.makeTree(menuVos, MenuVo::getPId, MenuVo::getId, x -> x.getPId() == 0, MenuVo::setSubMenus);
 }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

二、 peak()坑总结

Java Stream 的 peek() 方法是一种中间操作,通常用于在流的每个元素上执行某些操作(例如,日志记录或调试)。然而,由于其特殊的性质和某些误解,它在实际业务处理中可能会带来一些潜在的坑。以下是一些使用 peek() 时需要注意的坑:

2.1 peek() 是中间操作,不会触发流的执行

peek() 方法是一个中间操作,这意味着它不会单独触发流的执行。只有在终端操作(如 collect()、forEach()、reduce() 等)被调用时,流才会被执行。如果仅仅使用 peek() 而没有终端操作,流中的操作将不会被执行。

Stream.of(1, 2, 3)
      .peek(System.out::println); // 不会打印任何内容,因为没有终端操作
  • 1.
  • 2.

2.2 peek() 不应该用于改变元素的状态

peek() 的设计初衷是用于查看每个元素,而不是修改元素。如果在 peek() 中修改元素的状态,可能会导致意料之外的副作用,尤其是在并行流中。

List<String> list = Arrays.asList("a", "b", "c");
list.stream()
    .peek(s -> s = s.toUpperCase()) // 试图修改元素,实际上不会生效
    .forEach(System.out::println);  // 打印 "a", "b", "c" 而不是 "A", "B", "C"
  • 1.
  • 2.
  • 3.
  • 4.

2.3 在并行流中使用 peek() 可能导致线程安全问题

在并行流中,流的每个元素可能会被不同的线程处理。如果 peek() 中的操作不是线程安全的,可能会导致不可预知的结果或数据竞争。

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = Collections.synchronizedList(new ArrayList<>());
list.parallelStream()
    .peek(result::add)
    .forEach(System.out::println); //可能导致 ConcurrentModificationException 或不完整的结果
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

2.4 peek() 的操作顺序和流的执行顺序有关

peek() 中的操作顺序与流的执行顺序有关。如果流中有多个中间操作,peek() 的操作可能不会按照预期的顺序执行。

Stream.of("one", "two", "three")
      .peek(s -> System.out.println("Peek1: " + s))
      .filter(s -> s.length() > 3)
      .peek(s -> System.out.println("Peek2: " + s))
      .forEach(System.out::println);
// 可能输出:
// Peek1: one
// Peek1: two
// Peek1: three
// Peek2: three
// three
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

2.5 peak小结

虽然 peek() 方法在调试和日志记录时非常有用,但在实际业务处理中应谨慎使用。为避免潜在的问题,通常建议使用其他方法来完成需要修改元素状态或线程安全的操作,有三点建议:

  1. 仅在调试和日志记录时使用 peek()。
  2. 避免在 peek() 中修改元素状态。
  3. 确保 peek() 中的操作是线程安全的,特别是在并行流中使用时。

三、扩展方法 filter,search方法

3.1 filter()方法

filter()方法和Stream的filter()方法一样,过滤满足条件的数据节点,如里当前节点不满足其所有子节点都会过滤掉

public static <E> List<E> filter(List<E> tree, Predicate<E> predicate, Function<E, List<E>> getChildren) {
    return tree.stream().filter(item -> {
        if (predicate.test(item)) {
            List<E> children = getChildren.apply(item);
            if (children != null && !children.isEmpty()) {
                filter(children, predicate, getChildren);
            }
            return true;
        }
        return false;
    }).collect(Collectors.toList());
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

filter()使用方法:

//只保留id<5的节点
List<MenuVo> filterMenus =TreeUtil.filter(tree1,x->x.getId()<5,MenuVo::getSubMenus);
  • 1.
  • 2.

3.2 search()方法

search()方法搜索子节点,并且返回到根节点路径上的所有节点,通常用在前端多级分类搜索中,如下图:

我是如何给阿里大神Tree工具类做CodeReview并优化的_System_05

图:element UI Tree组件的搜索

z ```java public static List search(List tree, Predicate predicate, Function

search()使用方法
```java
MenuVo menu0 = new MenuVo(0L, -1L);
menu0.setName("自媒体");
MenuVo menu1 = new MenuVo(1L, 0L);
menu1.setName("公众号");
MenuVo menu2 = new MenuVo(2L, 0L);
menu2.setName("掘金");
MenuVo menu3 = new MenuVo(3L, 1L);
menu3.setName("赵侠客");
MenuVo menu4 = new MenuVo(4L, 1L);
MenuVo menu5 = new MenuVo(5L, 2L);
MenuVo menu6 = new MenuVo(6L, 2L);
menu6.setName("赵侠客");
MenuVo menu7 = new MenuVo(7L, 3L);
MenuVo menu8 = new MenuVo(8L, 3L);
MenuVo menu9 = new MenuVo(9L, 4L);
List<MenuVo> menuList = Arrays.asList(menu0,menu1, menu2,menu3,menu4,menu5,menu6,menu7,menu8,menu9);
List<MenuVo> tree1= TreeUtil.makeTree(menuList, MenuVo::getPId,MenuVo::getId,x->x.getPId()==-1L, MenuVo::setSubMenus);
System.out.println(JsonUtils.toJson(tree1));
List<MenuVo> searchRes = TreeUtil.search(tree1,x->"赵侠客".equals(x.getName()) ,MenuVo::getSubMenus);
System.out.println(JsonUtils.toJson(searchRes));
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.

输入树:

[
    {
        "id": 0,
        "name": "自媒体",
        "subMenus": [
            {
                "id": 1,
                "name": "公众号",
                "subMenus": [
                    {
                        "id": 3,
                        "name": "赵侠客",
                        "subMenus": [
                            {
                                "id": 7,
                                "pid": 3
                            },
                            {
                                "id": 8,
                                "pid": 3
                            }
                        ],
                        "pid": 1
                    },
                    {
                        "id": 4,
                        "subMenus": [
                            {
                                "id": 9,
                                "pid": 4
                            }
                        ],
                        "pid": 1
                    }
                ],
                "pid": 0
            },
            {
                "id": 2,
                "name": "掘金",
                "subMenus": [
                    {
                        "id": 5,
                        "pid": 2
                    },
                    {
                        "id": 6,
                        "name": "赵侠客",
                        "pid": 2
                    }
                ],
                "pid": 0
            }
        ],
        "pid": -1
    }
]
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.

搜索赵侠客后返回的结果:

[
    {
        "id": 0,
        "name": "自媒体",
        "subMenus": [
            {
                "id": 1,
                "name": "公众号",
                "subMenus": [
                    {
                        "id": 3,
                        "name": "赵侠客",
                        "subMenus": [],
                        "pid": 1
                    }
                ],
                "pid": 0
            },
            {
                "id": 2,
                "name": "掘金",
                "subMenus": [
                    {
                        "id": 6,
                        "name": "赵侠客",
                        "pid": 2
                    }
                ],
                "pid": 0
            }
        ],
        "pid": -1
    }
]
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.

总结

借助这次广大网友对我代码的CodeReview我总结反思了以下几点:

  1. 不要做井底之蛙,每个人的代码水平、认知水平、知识领域都不一样,有时自己觉得很好的东西,在高一层次的人看来可能就是垃圾,有时自己觉得很好的方案,在高一层的人看来就是幼稚,所以在团队开发过程技术方案评审及CodeReview还是很重要的
  2. 吾日三省吾身,要勇于接受别的观点、建议、意见和指责,千万不要和别人打嘴炮,自己的提升成长才是最重要的
  3. 做事、思考、总结,首先应该先好好做事,做完后要思考有没有更好的做法,最后要总结记录下来

完整老代码参考:解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!

完整优化后的代码:

/**
 * @Description: 树操作方法工具类
 * @Author: 公众号:赵侠客
 * @Copyright: Copyright (c) 赵侠客
 * @Date: 2024-07-22 10:42
 * @Version: 1.0
 */
public class TreeUtil {
    /**
     * 使用Map合成树
     *
     * @param menuList       需要合成树的List
     * @param pId            对象中的父ID字段,如:Menu:getPid
     * @param id             对象中的id字段 ,如:Menu:getId
     * @param rootCheck      判断E中为根节点的条件,如:x->x.getPId()==-1L , x->x.getParentId()==null,x->x.getParentMenuId()==0
     * @param setSubChildren E中设置下级数据方法,如: Menu::setSubMenus
     * @param <T>            ID字段类型
     * @param <E>            泛型实体对象
     * @return
     */
    public static <T, E> List<E> makeTree(List<E> menuList, Function<E, T> pId, Function<E, T> id, Predicate<E> rootCheck, BiConsumer<E, List<E>> setSubChildren) {
        //按原数组顺序构建父级数据Map,使用Optional考虑pId为null
        Map<Optional<T>, List<E>> parentMenuMap = menuList.stream().collect(Collectors.groupingBy(
                node -> Optional.ofNullable(pId.apply(node)),
                LinkedHashMap::new,
                Collectors.toList()
        ));
        List<E> result = new ArrayList<>();
        for (E node : menuList) {
            //添加到下级数据中
            setSubChildren.accept(node, parentMenuMap.get(Optional.ofNullable(id.apply(node))));
            //如里是根节点,加入结构
            if (rootCheck.test(node)) {
                result.add(node);
            }
        }
        return result;
    }

    /**
     * 树中过滤
     * @param tree  需要过滤的树
     * @param predicate  过滤条件
     * @param getChildren 获取下级数据方法,如:MenuVo::getSubMenus
     * @return List<E> 过滤后的树
     * @param <E> 泛型实体对象
     */
    public static <E> List<E> filter(List<E> tree, Predicate<E> predicate, Function<E, List<E>> getChildren) {
        return tree.stream().filter(item -> {
            if (predicate.test(item)) {
                List<E> children = getChildren.apply(item);
                if (children != null && !children.isEmpty()) {
                    filter(children, predicate, getChildren);
                }
                return true;
            }
            return false;
        }).collect(Collectors.toList());
    }


    /**
     * 树中搜索
     * @param tree
     * @param predicate
     * @param getSubChildren
     * @return 返回搜索到的节点及其父级到根节点
     * @param <E>
     */
    public static <E> List<E> search(List<E> tree, Predicate<E> predicate, Function<E, List<E>> getSubChildren) {
        Iterator<E> iterator = tree.iterator();
        while (iterator.hasNext()) {
            E item = iterator.next();
            List<E> childList = getSubChildren.apply(item);
            if (childList != null && !childList.isEmpty()) {
                search(childList, predicate, getSubChildren);
            }
            if(!predicate.test(item) && ( childList == null || childList.isEmpty()) ){
                iterator.remove();
            }
        }
        return tree;
    }

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.