数据库数据转树形结构的两种方式
通常数据库存储树形数据一般采取这种形式:
我们会创建一个对应的实体类
package cn.kanyun.build_tree; import java.util.List; /** * 节点类 * 部分字段添加transient关键字是为了,在Json序列化时不序列化该字段 * * @author KANYUN * */ public class Node { private Long id; private Long parentId; private String name; private transient String parentName; private transient boolean isDir; private transient String path; private List<Node> children; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Long getParentId() { return parentId; } public void setParentId(Long parentId) { this.parentId = parentId; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getParentName() { return parentName; } public void setParentName(String parentName) { this.parentName = parentName; } public boolean isDir() { return isDir; } public void setDir(boolean isDir) { this.isDir = isDir; } public String getPath() { return path; } public void setPath(String path) { this.path = path; } public List<Node> getChildren() { return children; } public void setChildren(List<Node> children) { this.children = children; } @Override public String toString() { return "Node [id=" + id + ", parentId=" + parentId + ", name=" + name + "]"; } }
第一种处理方式:递归
package cn.kanyun.build_tree; import java.sql.SQLException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import com.google.gson.Gson; import cn.hutool.db.Db; import cn.hutool.db.Entity; import cn.hutool.db.sql.Condition; /** * 递归构建树 深度优先遍历(DFS) * * @author KANYUN * */ public class Recursion2Tree { /** * 定义根节点 */ static Node root = new Node(); /** * 所有的节点数据 */ static List<Node> nodeList = new ArrayList(); public static void main(String[] args) throws Exception { // TODO Auto-generated method stub long startTime = System.currentTimeMillis(); Recursion2Tree tree = new Recursion2Tree(); // 从数据库中获取数据,并进行类型转换开始 List<Entity> result = Db.use().query("SELECT * FROM daasfolder_copy1"); for (Entity entity : result) { Node node = new Node(); node.setId(entity.getLong("id")); node.setParentId(entity.getLong("parentid")); node.setPath(entity.getStr("path")); node.setName(entity.getStr("name")); nodeList.add(node); } // 从数据库中获取数据,并进行类型转换结束 // 初始化根节点的children root.setChildren(new ArrayList<Node>()); // 构建根节点 tree.buildRoot(nodeList); // 递归子节点 tree.buildChildren(); // 完成打印 Gson gson = new Gson(); System.out.println(gson.toJson(root.getChildren())); System.out.println("耗时:" + (System.currentTimeMillis() - startTime)); } /** * 构建顶级树,即找到根节点下的数据 * * @param nodeList */ private void buildRoot(List<Node> nodeList) { Iterator<Node> iterator = nodeList.iterator(); while (iterator.hasNext()) { Node node = iterator.next(); if (node.getParentId() == 0) { // 找到根节点下的数据,将其添加到root下,并将该节点从所有的节点列表中移除 root.getChildren().add(node); iterator.remove(); } } } /** * @return void * @throws Exception * @Author 赵迎旭 * @Description 构建子节点 * @Date 14:48 2020/9/18 * @Param [] **/ private void buildChildren() throws Exception { // 如果元数据没有被删除完,说明还有数据没有挂到相应的节点上,则继续循环 while (nodeList.size() > 0) { Iterator<Node> iterator = nodeList.iterator(); build: while (iterator.hasNext()) { Node node = iterator.next(); // 是否找到父节点,(注意这里使用的是原子类型,因为原子类型是引用类型) AtomicBoolean isFind = new AtomicBoolean(false); // 从根节点下的所有一级子节点开始递归遍历DFS for (Node pNode : root.getChildren()) { recursion(node, pNode, iterator, isFind); if (isFind.get()) { continue build; } } // 如果该node在上面的递归中没有找到父节点 // 出现这种问题一般是两个原因: // 1.就是数据的顺序是乱的,即当前遍历的节点的父节点还没有挂到树上 处理方法:跳过该Node继续遍历 // 2.当前节点的父节点,不存在(除非当前节点是根节点下的节点) 处理方法:抛出异常 if (!isFind.get()) { // 则看剩下的Node集合中是否存在该node的父节点 for (Node pNode : nodeList) { if (pNode.getId().equals(node.getParentId())) { // 如果存在则继续外层遍历循环 continue build; } } // 否则抛出异常 throw new Exception("当前Node节点找不到父节点:" + node.toString()); } } } } /** * @return boolean * @Description 递归添加 * @Date 14:49 2020/9/18 * @Param [bean, beanList] **/ private void recursion(Node node, Node pNode, Iterator<Node> iterator, AtomicBoolean isFind) { Long id = pNode.getId(); Long parent_id = node.getParentId(); if (parent_id.equals(id)) { if (pNode.getChildren() == null) { List<Node> children = new ArrayList<>(); pNode.setChildren(children); } pNode.getChildren().add(node); iterator.remove(); isFind.set(true); ; return; } if (pNode.getChildren() != null) { for (Node currentPNode : pNode.getChildren()) { recursion(node, currentPNode, iterator, isFind); } } } }
可见递归构造树形数据分两步:
1.构建根节点下的所有一级子节点
2.未挂载的节点开始循环遍历递归 尝试挂载到根节点下的一级子节点下
第二种方式:
我们尝试更改一下数据库的结构,增加每个节点的路径,如图所示:
那么我们就可以得到另一种处理树形结构的方法:
package cn.kanyun.build_tree; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import com.google.gson.Gson; import cn.hutool.db.Db; import cn.hutool.db.Entity; /** * 循环构建树 广度优先遍历(BFS) * * @author KANYUN * */ public class FlatPath2Tree { /** * 同一层级的数据放在Map中,层数为key。需要注意的是这里的层数从 0 开始 不断地 自增 中间是不会出现断序的,即 key 一定 是 1,2,3,4 * 而不是 1,2,4 如果出现了断续,则说明数据是存在问题,即脏数据问题 */ static Map<Integer, List<Node>> levelMap = new HashMap<Integer, List<Node>>(); /** * 定义根节点 */ static Node root = new Node(); public static void main(String[] args) throws Exception { long startTime = System.currentTimeMillis(); FlatPath2Tree tree = new FlatPath2Tree(); // 从数据库中获取数据,并进行类型转换开始 List<Entity> result = Db.use().query("SELECT * FROM daasfolder_copy1"); List<Node> nodeList = new ArrayList(); for (Entity entity : result) { Node node = new Node(); node.setId(entity.getLong("id")); node.setParentId(entity.getLong("parentid")); node.setPath(entity.getStr("path")); nodeList.add(node); } // 从数据库中获取数据,并进行类型转换结束 // 数据预处理 tree.preNodeHandler(nodeList); // 构建树 tree.buildTree(); // 完成打印 Gson gson = new Gson(); System.out.println(gson.toJson(root.getChildren())); System.out.println("耗时:" + (System.currentTimeMillis() - startTime)); } /** * 数据预处理,分析Node节点的层数,判断是否是目录(其实这个判断不一定要像程序中写的那么复杂,有时候数据库里会有相应的字段标识是否是目录) * 得到父节点的名字 * * @param nodes */ private void preNodeHandler(List<Node> nodes) { for (Node node : nodes) { // 这里使用了split的一个重载方法,因为 "test/".split("/") 默认返回的数组长度是1,省略了最后的空值,详情查阅split的重载方法 String[] pathInfoList = node.getPath().split("/", -1); // 判断是否是目录,split的结果返回是数组,其数组长度肯定大于等于1的,直接判断数组的最后一个元素是否为空即可 boolean isDir = pathInfoList[pathInfoList.length - 1].equals(""); // 如果是目录标题为length - 2,否则目录标题为length - 1 String title = isDir ? pathInfoList[pathInfoList.length - 2] : pathInfoList[pathInfoList.length - 1]; // 判断有几级目录,如果是目录 -2 ,非 目录 -1 int level = isDir ? pathInfoList.length - 2 : pathInfoList.length - 1; // 获取父目录,先判断level是否为0,如果为0 说明父目录是根目录,接着再判断路径是否是目录 String parentName = level == 0 ? "/" : isDir ? pathInfoList[pathInfoList.length - 3] : pathInfoList[pathInfoList.length - 2]; // System.out.println("当前遍历目录的层级为:" + level); node.setName(title); node.setDir(isDir); node.setParentName(parentName); if (isDir) { // 如果是目录初始化children List<Node> children = new ArrayList(); node.setChildren(children); } // 将该Node放到Map中去 List<Node> nodeLevel = levelMap.get(level); if (nodeLevel == null) { nodeLevel = new ArrayList<>(); levelMap.put(level, nodeLevel); } nodeLevel.add(node); } } /** * 最终处理树,即处理层级,封装数据 * * @throws Exception */ public void buildTree() throws Exception { root.setChildren(new ArrayList<Node>()); int maxLevel = levelMap.size(); System.out.println("maxLevel:" + maxLevel); // Set<Integer> keys = levelMap.keySet(); // for (Integer level : keys) { // System.out.println(level); // } // 需要注意的是,这里是顺序遍历,即首先得到操作的肯定是根节点下的数据,BFS 广度优先遍历,对树一层一层的扫 for (int level = 0; level < maxLevel; level++) { List<Node> nodeLevel = levelMap.get(level); for (Node node : nodeLevel) { // 得到当前节点的兄弟节点列表 List<Node> siblingNodes = this.getSiblingNodes(node, level, root); // 将当前节点加入到该列表中 siblingNodes.add(node); } } } /** * 得到当前节点的兄弟节点列表 * * @param node * @param level * @param root * @return * @throws Exception */ private List<Node> getSiblingNodes(Node node, int level, Node root) throws Exception { String patName = node.getParentName(); List<Node> cutNode = new ArrayList(); if (level == 0) { // 当层级为0时,说明是根节点的数据 cutNode = root.getChildren(); } else { // 当层级不为0时,说明有父目录.此时先找到父目录,从levelMap中找到父目录列表,再遍历到底哪个是父目录 List<Node> parentNodeList = levelMap.get(level - 1); for (Node parentNode : parentNodeList) { // 需要注意的是这里是进行的字符串的判断,name的判断,那么会不会存在name重复的问题呢?其实是有一定概率重复的,如下面的例子 // 北京市->丰台区->长辛店镇->朱家坟 // 郑州市->金水区->长辛店镇->朱家坟 // 长辛店镇是不会挂错节点的,因为还有一个父节点的名字做保证,但是到朱家坟就不一样的了,他们的父节点名称是一样的,那么很有可能会挂错 // 如果能保证名称不会出现这个问题,那么这代码是可用的,如果不能保证,还会需要进行适量的更改,主要是从Node类的path属性入手,将其改为ID进行组装 // 如果解决这个问题?就是 Node类中的path属性使用节点id进行拼接,id是不会重复的,所以就不会出现这个问题了 if (parentNode.isDir() && parentNode.getName().equals(patName)) { return parentNode.getChildren(); } } throw new Exception("当前Node节点找不到父节点:" + node.toString()); } return cutNode; } }
可以看到这种方式处理树形结构分4步:
1.计算每个节点的所在层级和该节点对应的父节点:如 /a/b/c 那么c就在第三层 c的父节点是b
2.将层数相同的节点放在同一个结合中,存储在Map集合中,层数作为key,节点结合做为value
3.此时Map中的key 为 1,2,3... 按key的大小取出Map中的数据 ,那么你就知道了,第一次取出第一层的数据,也就是根节点的第一级数据
4.取出数据之后怎么照他的父级节点呢?其实很简单,比如当前节点的层数是3,那么他的父级节点一定是2,所以我们从Map中找2层的数据,然后对比当前节点的父名称,父级的名称是否一致得到了到底要挂载到哪个父节点下
对比:
我们看到第一种处理方式它是如何构建数据的呢?假如有一个数据想要加入树,那么就需要从根节点遍历,然后再找根节点下的子节点,依次递归下去,这种形式叫深度优先(DFS),它的对比方式依赖于id和pid
而第二种方式则不同,它先收集当前树的层级,并保存每个层级的数据到集合中去,假如有一个数据想要加入树,先看当前数据的层级,然后直接找到它父节点所在的层级,再对比找到对应的父节点,这种形式在广度优先(BFS),它的对比方式可以依赖于id和pid也可以单纯依赖path的数据
所以可以很明显的看出BFS的效率是更高的,因为它避免了许多无谓的递归判断,而DFS由于每次都需要从根节点开始判断,因此注定效率不会太高,但是DFS的优点是什么呢?DFS的代码简单而容易理解。而BFS则需要计算每个节点的层级,这一块逻辑稍显复杂。
因此当已有递归在面对大量且层级较深的数据时效率低下时,可以尝试使用第二种方式来处理树形结构。
同样如果你得到的 数据 是诸如: /a/b1/c1 , /a/b2/c2 , /a/b3/c3 这样的非结构化的数据时,同样也可以使用第二种方式。