树形结构遍历,实战递归是何循环遍最直接的写法,但也是历树最容易翻车的地方。我见过太多项目里一个getAllChildren方法把栈撑爆,形结尤其是实战组织架构树、菜单权限树这种数据,何循环遍深度动辄七八层,历树节点数量上万。形结递归代码看起来优雅,实战实际跑起来StackOverflowError直接教你做人。何循环遍
先看一个典型的历树递归实现:
public void traverse(TreeNode node) { if (node == null) return; System.out.println(node.getName()); List<TreeNode> children = node.getChildren(); if (children != null && !children.isEmpty()) { for (TreeNode child : children) { traverse(child); } } } 这段代码本身没问题,问题出在调用它的形结场景。如果树的实战深度超过JVM默认栈深度——通常几千层就崩——而你恰好碰上了一个脏数据导致成环,那就是何循环遍无限递归。我遇到过一次线上事故,历树某部门树的parentId指向了自己的子节点,递归直接打满CPU,线程堆栈溢出后整个服务不可用。
换成迭代写法,用栈或队列来模拟递归过程,安全性高出一截。深度优先用Deque当栈使:
public void traverseDFS(TreeNode root) { if (root == null) return; Deque<TreeNode> stack = new ArrayDeque<>(); stack.push(root); while (!stack.isEmpty()) { TreeNode node = stack.pop(); System.out.println(node.getName()); List<TreeNode> children = node.getChildren(); if (children != null) { // 倒序压栈,保证输出顺序和递归一致 for (int i = children.size() - 1; i >= 0; i--) { stack.push(children.get(i)); } } } } 注意那个倒序压栈的细节。很多人写完发现遍历顺序反了,就是因为没处理这个。广度优先就把Stack换成Queue,push换成offer,pop换成poll,其余逻辑不变。
但迭代有个代价:代码量上去了,可读性下降。团队里新人看到一坨栈操作容易懵。我的建议是,如果树深度可控(比如明确不超过50层),递归完全够用,别为了炫技把代码写复杂。真正需要警惕的是数据来源不可控的场景——用户自定义分类、第三方接口返回的树——这种时候迭代是保命手段。
还有一种偷懒做法,用JSON序列化库的@JsonIgnore或者Hibernate的懒加载来避免手动遍历。这招在只需要一层子节点时管用,深层遍历照样得自己写。而且它把问题藏起来了,哪天需要统计所有节点数量,还是得老老实实遍历。
实际项目里我更喜欢给树节点加一个level字段,遍历时顺手计算层级。这样前端拿到数据可以直接渲染缩进,不用再递归算深度。代码大概长这样:
public void traverseWithLevel(TreeNode root) { Deque<TreeNode> stack = new ArrayDeque<>(); Deque<Integer> levelStack = new ArrayDeque<>(); stack.push(root); levelStack.push(0); while (!stack.isEmpty()) { TreeNode node = stack.pop(); int level = levelStack.pop(); node.setLevel(level); List<TreeNode> children = node.getChildren(); if (children != null) { for (int i = children.size() - 1; i >= 0; i--) { stack.push(children.get(i)); levelStack.push(level + 1); } } } } 两个栈同步操作,看着有点笨,但比在节点里塞临时变量干净。用record包装一下也行,Java 14之后可以写成record NodeWithLevel(TreeNode node, int level) { },然后一个栈搞定。
最后说个坑:如果树节点有equals和hashCode的自定义实现,用Set去重时可能误删。我见过有人用HashSet存已访问节点来防环,结果两个不同节点因为id相同被当成同一个,遍历直接漏掉一半数据。防环用IdentityHashMap或者干脆在节点上加个visited标志位,别依赖业务字段的equals。
遍历本身不复杂,复杂的是数据。写之前先问一句:这棵树有多深?数据干净吗?答案不同,代码写法就不同。