1.概述
在平时Java开发业务系统中,处理树形结构数据是常见的需求,比如菜单系统、组织架构、分类体系等。我们经常需要在扁平化的List和层级化的Tree之间进行转换。本文将分别从新手小白、高阶老鸟不同视角全面探讨多种实现方案,包括常规方法、函数式编程、抽象封装工具类等,并分析各自的优缺点和性能优化策略。
业务系统中树形数据结构的应用场景是通过ID关联上下级关系的对象,然后将这些对象组织成一棵树。主要有以下常用应用场景:
- 系统菜单 菜单是一个系统必不可少的功能,通过菜单分层树形结构可以方便的控制菜单展示和上下级。
- 组织架构 组织架构往往有多个层级,如公司下有机构、机构下有部门、部门下有小组等等,这些上下级就是一颗树的关系
- 商品分类 这在电商系统中很常见,比如数码类下有相机、耳机等,相机下面又分为单反、微单等等
- 文件目录 不用过多解释,文件目录结构层级就是天然的一棵树
- 地址区域 地址信息包含省、市、区、县、镇、村等层级关系
2.List与Tree相互转换实现
上面这些场景在一个系统中可能都同时存在,今天这里我们就以最常见的系统菜单对象为例进行总结叙述。
kotlin
@Data
public static class Menu {
private Long id;
private Long parentId;
private String name;
private List<Menu> children = new ArrayList<>();
public Menu(Long id, Long parentId, String name) {
this.id = id;
this.parentId = parentId;
this.name = name;
}
}
基于该场景,下面我们就来看看实现方案吧。
2.1 常规递归写法
递归是数据结构和算法里面常用的一个技巧写法,也是一个新手小白应该掌握的,这里就是通过递归查找每个节点的子节点来构建树,话不多说直接上代码:
scss
public static List<Menu> listToTree(List<Menu> dataList) {
List<Menu> roots = new ArrayList<>();
// 找出所有根节点 parentId=0的是根节点
for (Menu menu : dataList) {
if (menu.getParentId() == 0L) {
roots.add(menu);
}
}
// 为每个根节点构建子树
for (Menu root : roots) {
buildChildren(root, dataList);
}
return roots;
}
private static void buildChildren(Menu parent, List<Menu> dataList) {
//遍历所有数据,获取当前节点的子节点
for (Menu menu : dataList) {
if (Objects.equals(parent.getId(), menu.getParentId())) {
buildChildren(menu, dataList);
//将是当前节点的子节点添加到当前节点的children中
parent.getChildren().add(menu);
}
}
}
这个代码结构清晰,逻辑直观,可以说是简单明了,想必大家都能写出来,当然了上面的查找根节点root和子树可以利用Stream和Lambda表达式、foreach等实现,大家感兴趣的话可以自行实现一波。
根据如下代码简单测试下,完美实现list转成tree
java
public static void main(String[] args) {
List<Menu> menuList = List.of(
new Menu(1L, 10L, "m1"),
new Menu(2L, 9L, "m2"),
new Menu(3L, 1L, "m3"),
new Menu(4L, 1L, "m4"),
new Menu(5L, 2L, "m5"),
new Menu(6L, 2L, "m6"),
new Menu(7L, 3L, "m7"),
new Menu(8L, 3L, "m8"),
new Menu(9L, 0L, "m9"),
new Menu(10L, 0L, "m10")
);
System.out.println(tree);
}
注意我这里使用的是JDK17才能直接通过List.of()
构造测试数据,如果你是JDK8请使用guava或者自己实现构造数据哦。书归正传,这种递归写法其实在平时业务系统开发代码中挺常见的,你要非说它有啥问题嘛,只能说一般情况不会有问题,非要有问题那就是这两种情况:
- 递归深度过大可能导致栈溢出
- 每次查找子节点都需要遍历整个列表,时间复杂度O(n²)
其实上面两个问题点归根结底就是树的层次过深,会导致构建tree的性能下降,当然了作为新手菜鸟,能实现上面代码我个人认为已经无可厚非啦,没人能说你写得有问题,但是作为高阶老鸟就得思考了,虽然一般业务系统的树结构的层级不会过高,不会超过5级,如菜单、组织架构等场景层级都比较低,但是文件目录层级就不一定了,因为这个功能是支持用户随便新增层级的,新增一个文件夹超过10个层级是很有可能的,这时候就得考虑性能问题了,
2.2 基于空间换时间思路实现
简单来说就是如果for循环很多次,嵌套循环很深等等都可以考虑基于这种方式优化性能
scss
public static List<Menu> listToTreeWithMap(List<Menu> dataList) {
// 空间换时间的体现
Map<Long, Menu> nodeMap = dataList.stream()
.collect(Collectors.toMap(Menu::getId, Function.identity()));
List<Menu> result = new ArrayList<>();
// 一次遍历即可,时间复杂度O(n)
dataList.forEach(menu -> {
if (menu.getParentId() == 0L) {
result.add(menu);
} else {
Menu parent = nodeMap.get(menu.getParentId());
if (parent != null) {
parent.getChildren().add(menu);
}
}
});
return result;
}
数据结构和算法写得好不好的两大衡量维度:时间复杂度和空间复杂度,大部分的算法题目如果限制了执行时间长度,大概率就是使用这种空间换时间思路实现啦。这也算是解决性能问题一种技巧。
2.3 封装一个通用的工具类
其实能考虑到树层级过深这种极端场景基于空间换时间思路实现已经很不错了,但是不知道你有没有注意到我上面强调过一个系统存在多种场景需要构建树,上面提到的5个场景可能同时存在,那且不是要为每个场景单独写一个工具类,重复写5遍一样结构的代码。作为老鸟这肯定是不能忍受的,这时候肯定想封装抽象一个适合所有场景的工具类供大家使用,针对不同类型的对象转tree会立刻想到泛型,毕竟泛型就是干这个事情的,但是使用泛型之后,怎么查找根节点和构建子树呢?那就得使用函数式接口编程了
关于泛型+函数式编程解决重复编码的知识点可以看看之前我们总结的文章:将泛型和函数式编程结合,只是为了让代码更加优雅!
思路如下:
- 使用泛型,解决适配不同场景对象,如菜单、文件、地址等
- 函数式接口编程解决查找根节点和构建子树这种骨架结构性代码
基于上面的递归实现写法对照封装抽象如下:
swift
/**
*
* @param dataList 数据
* @param isRoot 判断根节点断言
* @param isChild 判断子节点断言
* @param setChildren 设置子树
* @return tree
* @param <E> 类型
*/
public static <E> List<E> listToTree(List<E> dataList,
Predicate<E> isRoot,
BiPredicate<E, E> isChild,
BiConsumer<E, List<E>> setChildren) {
return dataList.stream().
// 查找根节点
filter(isRoot)
// 给根节点构建子树
.peek(root -> setChildren.accept(root, buildChildren(root, dataList, isChild, setChildren)))
.collect(Collectors.toList());
}
private static <E> List<E> buildChildren(E parent,
List<E> dataList,
BiPredicate<E, E> isChild,
BiConsumer<E, List<E>> setChildren) {
return dataList.stream()
// 获取父节点下的子节点
.filter(child -> isChild.test(parent, child))
// 递归调用,构建子节点的子树
.peek(child -> setChildren.accept(child, buildChildren(child, dataList, isChild, setChildren)))
.collect(Collectors.toList());
}
基于上面的测试数据测试如下:
less
List<Menu> tree = listToTree(menuList,
menu -> menu.getParentId() == 0L,
(parent, child) -> Objects.equals(parent.getId(), child.getParentId()),
Menu::setChildren);
可以看到基于泛型和函数式编程,将可变的代码作为方法参数传入,动态灵活。
怎么判断是根节点,怎么是子节点,怎么构建子树这些动态参数由调用方实现,有些系统因为设计不规范,可能是parentId = null
或者parentId=0
或者parentId=-1
为根节点,这种不确定性正好可以用函数式编程解决。
基于空间换时间对应封装抽象如下:
swift
/**
* 基于空间换时间
*/
public static <T, R> List<T> listToTreeWhitMap(
List<T> dataList,
Predicate<T> isRoot,
Function<T, R> getId,
Function<T, R> getParentId,
BiConsumer<T, List<T>> setChildren) {
// 按父节点分组
Map<R, List<T>> parentIdToChildren = dataList.stream()
.filter(node -> !isRoot.test(node))
.collect(Collectors.groupingBy(getParentId));
// 设置每个节点的子节点
dataList.forEach(node -> setChildren.accept(
node,
Optional.ofNullable(parentIdToChildren.get(getId.apply(node)))
.orElse(Collections.emptyList())));
// 返回根节点列表
return dataList.stream()
.filter(isRoot)
.collect(Collectors.toList());
}
2.4 tree转list
tree转list其实遍历整棵树,怎么遍历都行,一般分为深度优先(DFS)和广度优先(BFS),代码如下:
swift
/**
* 将Tree转换为List(广度优先)
* @param tree 树形结构
* @param getChildren 获取子节点的函数
* @return 扁平化列表
*/
public static <E> List<E> treeToListBfs(
List<E> tree,
Function<E, List<E>> getChildren) {
List<E> result = new ArrayList<>();
Queue<E> queue = new LinkedList<>(tree);
while (!queue.isEmpty()) {
E node = queue.poll();
result.add(node);
queue.addAll(getChildren.apply(node));
}
return result;
}
/**
* 将Tree转换为List(深度优先)
* @param tree 树形结构
* @param getChildren 获取子节点的函数
* @return 扁平化列表
*/
public static <E> List<E> treeToListDfs(
List<E> tree,
Function<E, List<E>> getChildren) {
List<E> result = new ArrayList<>();
tree.forEach(node -> traverseDfs(node, getChildren, result));
return result;
}
private static <E> void traverseDfs(
E node,
Function<E, List<E>> getChildren,
List<E> result) {
result.add(node);
getChildren.apply(node).forEach(child ->
traverseDfs(child, getChildren, result));
}
3.总结
方案 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|
常规递归 | O(n²) | O(h) h为树高 | 直观易懂 | 递归深度大时可能栈溢出 | 小规模数据 |
Map辅助 | O(n) | O(n) | 高效,无递归 | 需要额外空间 | 中大规模数据 |
泛型+函数式编程 | O(n) | O(n) | 代码简洁、复用 | 可读性稍差 | 熟悉函数式编程 |
本文详细介绍了Java中List与Tree相互转换的多种实现方案,从传统的递归方法到函数式编程实现,再到针对性能问题的优化策略。在实际开发中,应根据数据规模、团队技术栈和性能要求选择合适的实现方式。对于大多数场景,Map辅助的非递归方法是较好的选择,它平衡了性能和代码可读性。对于函数式编程熟悉的团队,函数式实现可以提供更简洁、更动态灵活的可复用代码。