二维表格转树形结构算法
原始需求是前端同学在 VUE 开发中使用 ElementUI 的树形组件时,组件需要一次性将整个树形组件的数据全部加载,这就要求后端将数据一次性返回并且必须是树形数据结构。我是理解不了 ElementUI 组件的逻辑,我自己的树形组件可以按需加载,即点击树中某一个节点时才加载这个节点下的所有数据。反抗是没有用的,老实实现需求就是了。
查询数据表的 SQL 语句如下:
SELECT id AS project_id, parent_project_id, project_name FROM qross_projects;
这个表通过字段parent_project_id
实现了树形目录中的上下级关系,从而可以实现无限级的树形数据结构。顶层目录的parent_project_id
值为0
。
要生成的树形目录数据结构应该是这样的(实际上远比这个要复杂得多):
[
{
"id": 1
"project_name": "项目 A"
"parent_project_id": 0,
"children": [
{
"id": 3
"project_name": "项目 A-1"
"parent_project_id": 1,
"children": []
},
{
"id": 4
"project_name": "项目 A-2"
"parent_project_id": 1,
"children": [
{
"id": 5
"project_name": "项目 A-2-1"
"parent_project_id": 4,
"children": []
},
{
"id": 6
"project_name": "项目 A-2-2"
"parent_project_id": 4,
"children": []
}
]
},
]
},
{
"id": 2
"project_name": "项目 B"
"parent_project_id": 0,
"children": []
}
]
使用children
属性来保存子节点的所有信息,没有子节点时那么children
则为空数组。
开始想如何实现,第一种方法是广度优先搜索,一层一层遍历节点和子节点,直到最后一层的所有节点都没有子节点;第二种方法是深度优先搜索,查找每个节点的第一个子节点是否有子节点是否有数据,如果有则继续向下查找,如果没有,则跳到下一个同胞节点向下查找,直到所有节点都没有子节点为止。记得上人工智能课时老师还留了这道题当作业,好吧,这两个方法都不可取,因为每一次查找都要访问一次数据库,即使不访问数据库,在内存中遍历也是一件不小的工程。我当时的作业每一种算法都有上百行代码,这显然作为一个有强迫症的程序员是不能容忍的。
开始思考第三种算法,一次性从数据库获取数据,且要求在内存中的查找数据的次数尽可能少,即时间复杂度尽可能低。最后尝试使用 引用类型的特性 实现,下面是的代码(Scala):
/**
* 按照指定的列做为分层依据将表格转成一个树形结构
* @param parentColumn 父级列的列名
* @param startPoint 父组列起始层的值,支持整数和字符串
* @param newColumn 新列的 id
*/
def turnToTree(primaryColumn: String, parentColumn: String, startPoint: String, newColumn: String): java.util.List[java.util.Map[String, Any]] = {
//id -> data
val relations = new mutable.HashMap[String, java.util.Map[String, Any]]()
//[data]
val result: java.util.List[util.Map[String, Any]] = new util.ArrayList[util.Map[String, Any]]()
//第一次未找到的项
val stash = new mutable.ArrayBuffer[java.util.Map[String, Any]]()
this.rows.foreach(row => {
val parent = row.getString(parentColumn)
val map = row.toJavaMap
map.put(newColumn, new util.ArrayList[Any]())
relations += row.getString(primaryColumn) -> map
if (parent == startPoint) {
//顶级
result.add(map)
}
else if (relations.contains(parent)) {
//子项
relations(parent).get(newColumn).asInstanceOf[java.util.ArrayList[Any]].add(map)
}
else {
//如果找不到,有可能其父级还在后面,先暂存
stash += map
}
})
//再次遍历
stash.foreach(map => {
val parent = map.get(parentColumn).toString
if (relations.contains(parent)) {
relations(parent).get(newColumn).asInstanceOf[java.util.ArrayList[Any]].add(map)
}
else {
//实在找不到,就变成顶级
result.add(map)
}
})
stash.clear()
relations.clear()
this.clear()
result
}
以上代码的解释如下:
- 函数参数
primaryColumn
表示主键的名字,本例中为project_id
;parentColumn
表示父级列的列名,本例中为parent_project_id
;startPoint
为查询起始点,本例中为0
,用这个值来确认是否为顶层项;newColumn
为生成新列的名称,用于保存所有子节点数据,本例中为children
。 - 这是类 DataTable 的其中的一个方法,DataTable 可以理解从数据库获取的一个二维表格,代码中的
this
即表示当前表格,this.rows
表示当前表格的所有行。 - 使用
relations
保存每一项的实体系,是一个 HashMap 结构。Key 是节点主键project_id
的值,Value 则是实体,包含节点的所有属性。 - 使用
result
保存最终要返回的结果,是一个对象数组。 - 需要两次遍历,第一次遍历,可能无法确定上下级关系,需要第一次遍历。第一次遍历时无法确认上下级关系的实体保存在
stash
中。 - 第一次遍历,初步确认下一级关系,判断是顶层还是子项,
map
变量即每一个实体。如果是顶层那么就添加到result
中,如果是子项则在relations
中找到父级实体并添加为这个实体的子项。由于引用类型的特性,result
和relations
中引用的实体是同一个,如果更新了relations
中的实体,那么result
中的保存的值也跟着更新了。 - 第二次遍历是因为数据顺序的问题,有可能主键值小的实体是主键值大的实体的子项。最好的办法是跟 SQL 语句配合,先在数据库中进行一次排序,然后再在程序中加工。例如 SQL 语句可以修改为
SELECT id AS project_id, parent_project_id, project_name FROM qross_projects ORDER BY parent_project_id ASC, id ASC;
- 最后返回值
result
则包含了所有顶层的实体,顶层实体中包含了自己的所有子项。由于引用类型的特性,虽然三个集合中项都指向相同的实体,但清空relations
和stash
并不影响reulst
的结果。