引言
前段时间我写了一篇关于树操作的工具类《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》,当时主要把精力集中在分析代码的实现层面,没有从设计层面、性能层考虑,然后就被很多网友大神喷了,在此我做下深刻的反思,同时也针对大神网友提的缺点做出优化。
主要的缺点:
- 性能问题,因为使用递归,最坏的情况时间复杂度为 O(n^n),平均为O(nlogn),性能确实非常差
- 使用了peek(),peak()坑确实比较多,官方建议仅在调试和日志记录时使用,避免在 peek() 中修改元素状态
- 没有filter、search等方法,过滤和搜索也是树形结构中常用的方法
本文接针对这三点做了优化
一、性能优化
1.1 时间复杂度降到O(n)
使用网友的建议合成树使用Map用空间来换时间,将时间复杂度降到O(n),我们直接看优化后的代码:
方法参数说明:
- List menuList,需要合成的集全数据
- Function<E, T> pId,实体中的父级ID字段,可以为null,如:MenuVo::getPId
- Function<E, T> id,实体中的ID字段,不能为null,如,MenuVo::getId
- Predicate rootCheck,判断为根节点条件,如: x->x.getPId()==-1L
- BiConsumer<E, List> setSubChildren,设置子节点方法,如:MenuVo::setSubMenus
使用方法:
输出结果:
1.2 性能对比测试
下图为优化后的makeTree和之前的方法分别对1万、2万、3万、4万、5万、6万节点进行耗时测试对比:
图:Map和递归耗时对比
通过对比可以看出递归实现耗时会随着节点增长成指数增长,使用Map的耗时就小的多了,性能确实提升了好几个数量级。
1.3 Map实现原理:
使用Map空间换时间的思路其实并不复杂,主要有三步:
- 通过pId构建父级到下子级到倒排序索引,如图中的1下级有3和4
- 遍历原List,将所有节点首尾相连
- 返回我们需要的根节点
图:使用Map合成树实现过程
图中 1-0 表示id=1,pId=0 ,右边为分组后的Map,我们看循环过程
- 1-0,在Map中通过1找到下级节点为3,4,然后设置到1的下级
- 2-0,在Map中通过2打到下级节点为5,然后设置到2的下级
- 3-1,在Map中没有打到下级
- 4-1,在Map中通过4打到下级节点为6,然后设置到4的下级
- 5-2,在Map中没有打到下级
- 6-4,在Map中没有打到下级
使用Map合成树代码虽然比较简单,但是还是有几点需要注意的:
- 使用Map空间换时间,将时间复杂度降为O(n),占用内存会增大
- 使用Optional类型支持pId为null
- 使用LinkedHashMap,保证合成树后子节点相对顺序不变
- 使用for替代peak(),避免peak的坑
1.4 大数据量测试
有网说他们有500万节点合成树的需求,在我做的所有项目中没有遇到过一次性合成500万节点树的需求,我想应该不是WEB或是移动应用,这500万节点树返回给前端,我想前端会直接卡死。不管有没有应用场景我们可以测试一下500万节点,树的深度为100的超级大树合成速度,结果只用了424ms,性能是不是炸裂了?
图:500万节点耗时424ms
再看一下makeTree方法内部耗时:
图:makeTree各方法耗时
可以看出主要耗时在是通过pId分组这里,耗时316ms。
测试代码:
二、 peak()坑总结
Java Stream 的 peek() 方法是一种中间操作,通常用于在流的每个元素上执行某些操作(例如,日志记录或调试)。然而,由于其特殊的性质和某些误解,它在实际业务处理中可能会带来一些潜在的坑。以下是一些使用 peek() 时需要注意的坑:
2.1 peek() 是中间操作,不会触发流的执行
peek() 方法是一个中间操作,这意味着它不会单独触发流的执行。只有在终端操作(如 collect()、forEach()、reduce() 等)被调用时,流才会被执行。如果仅仅使用 peek() 而没有终端操作,流中的操作将不会被执行。
2.2 peek() 不应该用于改变元素的状态
peek() 的设计初衷是用于查看每个元素,而不是修改元素。如果在 peek() 中修改元素的状态,可能会导致意料之外的副作用,尤其是在并行流中。
2.3 在并行流中使用 peek() 可能导致线程安全问题
在并行流中,流的每个元素可能会被不同的线程处理。如果 peek() 中的操作不是线程安全的,可能会导致不可预知的结果或数据竞争。
2.4 peek() 的操作顺序和流的执行顺序有关
peek() 中的操作顺序与流的执行顺序有关。如果流中有多个中间操作,peek() 的操作可能不会按照预期的顺序执行。
2.5 peak小结
虽然 peek() 方法在调试和日志记录时非常有用,但在实际业务处理中应谨慎使用。为避免潜在的问题,通常建议使用其他方法来完成需要修改元素状态或线程安全的操作,有三点建议:
- 仅在调试和日志记录时使用 peek()。
- 避免在 peek() 中修改元素状态。
- 确保 peek() 中的操作是线程安全的,特别是在并行流中使用时。
三、扩展方法 filter,search方法
3.1 filter()方法
filter()方法和Stream的filter()方法一样,过滤满足条件的数据节点,如里当前节点不满足其所有子节点都会过滤掉
filter()使用方法:
3.2 search()方法
search()方法搜索子节点,并且返回到根节点路径上的所有节点,通常用在前端多级分类搜索中,如下图:
图:element UI Tree组件的搜索
z ```java public static List search(List tree, Predicate predicate, Function
输入树:
搜索赵侠客后返回的结果:
总结
借助这次广大网友对我代码的CodeReview我总结反思了以下几点:
- 不要做井底之蛙,每个人的代码水平、认知水平、知识领域都不一样,有时自己觉得很好的东西,在高一层次的人看来可能就是垃圾,有时自己觉得很好的方案,在高一层的人看来就是幼稚,所以在团队开发过程技术方案评审及CodeReview还是很重要的
- 吾日三省吾身,要勇于接受别的观点、建议、意见和指责,千万不要和别人打嘴炮,自己的提升成长才是最重要的
- 做事、思考、总结,首先应该先好好做事,做完后要思考有没有更好的做法,最后要总结记录下来
完整老代码参考:解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!
完整优化后的代码: