如何将扁平表转换为树形结构的最高效/最优雅的方法是什么?

574
假设您有一张扁平的表格,用于存储一个有序的树形层次结构:
Id   Name         ParentId   Order
 1   'Node 1'            0      10
 2   'Node 1.1'          1      10
 3   'Node 2'            0      20
 4   'Node 1.1.1'        2      10
 5   'Node 2.1'          3      10
 6   'Node 1.2'          1      20

这是一个图表,其中我们有[id] Name。根节点0是虚构的。
                       [0] ROOT
                          /    \ 
              [1] Node 1          [3] Node 2
              /       \                   \
    [2] Node 1.1     [6] Node 1.2      [5] Node 2.1
          /          
 [4] Node 1.1.1
你会使用什么最简主义方法将其输出到HTML(或文本),作为正确排序的正确缩进的树?
进一步假设您只有基本数据结构(数组和哈希映射),没有具有父/子引用的花哨对象,没有ORM,没有框架,只有您的双手。该表格表示为结果集,可以随机访问。
伪代码或纯英语都可以,这只是一个概念性问题。
奖励问题:在RDBMS中存储此类树形结构是否有根本上更好的方法?

编辑和添加

回答一个评论者(Mark Bessey)的问题:根节点并不是必需的,因为它永远不会被显示出来。ParentId = 0 是表示“这些是顶层”的约定。Order列定义了具有相同父级的节点如何排序。

我所说的“结果集”可以被想象成一个哈希映射数组(为了保持这个术语)。对于我的例子来说,它已经存在了。有些答案会走额外的路线先构建它,但那也没关系。

树可以任意深入。每个节点可以有N个孩子。虽然我没有想到一个“数百万条目”的树。

不要把我的节点命名选择(“节点1.1.1”)误认为是可以依赖的东西。节点也可以叫做“Frank”或“Bob”,没有暗示任何命名结构,这只是为了使其可读性更强。

我已经发布了自己的解决方案,这样你们就可以对它进行分析了。


2
“不使用带有父/子引用的花哨对象” - 为什么?创建一个基本的Node对象,使用.addChild()和.getParent()方法,可以很好地建模节点关系。 - matt b
2
这是一个普通的树(其中n个孩子,n可以大于2),还是二叉树(节点可以有0、1或2个孩子)? - BKimmel
由于您可以使用哈希表实现适当的节点数据结构,因此在这里没有实际限制,只需要更多的工作。 - Svante
...这正是你所做的。 - Svante
1
@dreftymac,从技术上讲,传递闭包表是非规范化的。避免数据异常比传统的邻接列表设计更困难。但是,像非规范化设计一样,它使某些类型的查询更快。 - Bill Karwin
显示剩余5条评论
15个回答

1

使用邻接表示法进行即时路径枚举的预订遍历

来自以下嵌套集:

这是我看到的唯一有效的遍历方式,代价是更新速度较慢。这可能是大多数人想要的预订方式。

https://dev59.com/BnVC5IYBdhLWcg3ww0Hr#192462中的闭包表很有趣,但我不知道如何在其中强制执行预订:MySQL Closure Table hierarchical database - How to pull information out in the correct order

仅供娱乐,这里提供了一种基于父ID/子索引表示法,在最后计算1.3.2.5.前缀并按其排序的递归方法。

优点:
  • 更新只需要更新每个兄弟节点的索引
缺点:
  • 对于超深的树,最坏情况下需要n^2的内存使用。这可能非常严重,这就是为什么我说这种方法可能主要是为了好玩而已。但也许有一些超高更新情况下的应用场景呢?谁知道呢。
  • 递归查询,因此读取效率比嵌套集更低

创建并填充表:

CREATE TABLE "ParentIndexTree" (
  "id" INTEGER PRIMARY KEY,
  "parentId" INTEGER,
  "childIndex" INTEGER NOT NULL,
  "value" INTEGER NOT NULL,
  "name" TEXT NOT NULL,
  FOREIGN KEY ("parentId") REFERENCES "ParentIndexTree"(id)
)
;
INSERT INTO "ParentIndexTree" VALUES
  (0, NULL, 0, 1, 'one'  ),
  (1, 0,    0, 2, 'two'  ),
  (2, 0,    1, 3, 'three'),
  (3, 1,    0, 4, 'four' ),
  (4, 1,    1, 5, 'five' )
;

表示树:
    1
   / \
  2   3
 / \
4   5

那么对于像PostgreSQL(https://www.postgresql.org/docs/14/arrays.html)这样具有数组的数据库管理系统:
WITH RECURSIVE "TreeSearch" (
  "id",
  "parentId",
  "childIndex",
  "value",
  "name",
  "prefix"
) AS (
  SELECT
    "id",
    "parentId",
    "childIndex",
    "value",
    "name",
    array[0]
  FROM "ParentIndexTree"
  WHERE "parentId" IS NULL

  UNION ALL

  SELECT
    "child"."id",
    "child"."parentId",
    "child"."childIndex",
    "child"."value",
    "child"."name",
    array_append("parent"."prefix", "child"."childIndex")
  FROM "ParentIndexTree" AS "child"
  JOIN "TreeSearch" AS "parent"
    ON "child"."parentId" = "parent"."id"
)
SELECT * FROM "TreeSearch"
ORDER BY "prefix"
;

这将动态创建形如前缀的格式:
1 -> 0
2 -> 0, 0
3 -> 0, 1
4 -> 0, 0, 0
5 -> 0, 0, 1

然后,PostgreSQL按照数组的字母顺序排序如下:
1 -> 0
2 -> 0, 0
4 -> 0, 0, 0
5 -> 0, 0, 1
3 -> 0, 1

这段文字的翻译如下:

这是我们想要的预订结果。

对于像SQLite这样没有数组的DBMS,您可以通过使用一串固定宽度整数编码前缀来进行黑客攻击。二进制是理想的,但我找不到方法,因此十六进制可以工作。当然,这意味着您必须选择一个最大深度,以适合所选字节数,例如下面我选择6,允许每个节点最多有16^6个子节点。

WITH RECURSIVE "TreeSearch" (
  "id",
  "parentId",
  "childIndex",
  "value",
  "name",
  "prefix"
) AS (
  SELECT
    "id",
    "parentId",
    "childIndex",
    "value",
    "name",
    '000000'
  FROM "ParentIndexTree"
  WHERE "parentId" IS NULL

  UNION ALL

  SELECT
    "child"."id",
    "child"."parentId",
    "child"."childIndex",
    "child"."value",
    "child"."name",
    "parent"."prefix" || printf('%06x', "child"."childIndex")
  FROM "ParentIndexTree" AS "child"
  JOIN "TreeSearch" AS "parent"
    ON "child"."parentId" = "parent"."id"
)
SELECT * FROM "TreeSearch"
ORDER BY "prefix"
;

一些嵌套集笔记
以下是我在查看其他嵌套集答案后有些困惑的几点。
Jonny Buchanan 展示了他的嵌套集设置为:
__________________________________________________________________________
|  Root 1                                                                  |
|   ________________________________    ________________________________   |
|  |  Child 1.1                     |  |  Child 1.2                     |  |
|  |   ___________    ___________   |  |   ___________    ___________   |  |
|  |  |  C 1.1.1  |  |  C 1.1.2  |  |  |  |  C 1.2.1  |  |  C 1.2.2  |  |  |
1  2  3___________4  5___________6  7  8  9___________10 11__________12 13 14
|  |________________________________|  |________________________________|  |
|__________________________________________________________________________|

这让我想知道为什么不直接使用看起来更简单的方式:
__________________________________________________________________________
|  Root 1                                                                 |
|   ________________________________    _______________________________   |
|  |  Child 1.1                     |  |  Child 1.2                    |  |
|  |   ___________    ___________   |  |   ___________   ___________   |  |
|  |  |  C 1.1.1  |  |  C 1.1.2  |  |  |  |  C 1.2.1  | |  C 1.2.2  |  |  |
1  2  3___________|  4___________|  |  5  6___________| 7___________|  |  | 
|  |________________________________|  |_______________________________|  |
|_________________________________________________________________________|

“没有为每个端点添加额外数字。”
但是当我尝试实际实现它时,我发现这样的更新查询很难/不可能实现,除非我有像Konchog使用的父级信息。问题在于,在树被移动时,很难/不可能区分兄弟和父母,我需要这个来决定在关闭间隙时是否要减少右侧。
左/大小与左/右:您可以将其存储在数据库中的任何一种方式,但我认为左/右可以更有效,因为您可以使用多列索引(左、右)对DB进行索引,然后用于加速祖先查询,这些查询的类型为:
left < curLeft AND right > curLeft

在Ubuntu 22.04、PostgreSQL 14.5和SQLite 3.34.0上进行了测试。

1
如果可以创建嵌套的哈希映射或数组,那么我可以从开头简单地遍历整个表格,并将每个项目添加到嵌套数组中。为了知道要插入到嵌套数组的哪个级别中,我必须追踪每一行到根节点。我可以使用记忆化,这样我就不需要一遍又一遍地查找相同的父节点。
编辑:我会先将整个表格读入一个数组中,这样它就不会重复查询数据库。当然,如果您的表格非常大,这种方法就不实用了。
在构建完结构之后,我必须通过深度优先遍历来打印出 HTML。
没有更好的基本方法来使用一个表格存储这些信息(虽然我可能错了;),并且很想看到更好的解决方案)。但是,如果您创建了一个方案来使用动态创建的数据库表,那么您就打开了一个全新的世界,以简单性为代价,以及 SQL 地狱的风险;)。

1
我宁愿不更改数据库布局,只是因为需要新的子节点级别。 :-) - Tomalak

1

要扩展比尔的SQL解决方案,您可以基本上使用平面阵列来完成相同的操作。此外,如果您的字符串长度都相同,并且您的最大子节点数已知(例如在二叉树中),则可以使用单个字符串(字符数组)进行操作。如果您具有任意数量的子项,则会稍微复杂一些......我必须检查我的旧笔记以查看可以做些什么。

然后,为了牺牲一点内存,特别是如果您的树是稀疏和/或不平衡的,只需通过一些索引数学方法,将您的树宽度优先地存储在阵列中,就可以随机访问所有字符串,如下所示(对于二叉树):

String[] nodeArray = [L0root, L1child1, L1child2, L2Child1, L2Child2, L2Child3, L2Child4] ...

如果你知道字符串的长度,那么你就知道了它。

我现在正在工作,所以不能花太多时间在这上面,但是如果有兴趣,我可以找一些代码来做这个。

我们曾经用它来搜索由DNA密码子构成的二叉树,一个过程建立了树,然后我们将其压缩以搜索文本模式,当找到时,通过索引数学(从上面反转)我们得到节点返回...非常快速和高效,尽管我们的树很少有空节点,但我们可以在瞬间搜索数千兆字节的数据。


0

考虑使用像neo4j这样的nosql工具来处理层次结构。 例如,像领英这样的网络应用程序使用couchbase(另一种nosql解决方案)

但是仅将nosql用于数据集市级别的查询,不要用于存储/维护交易


阅读了SQL和“非表”结构的复杂性和性能后,我的第一反应也是NoSQL。当然,导出等问题有很多。此外,OP仅提到了表格。哦,好吧。显然我不是DB专家。 - Josef.B

0
如果元素按照树的顺序排列,就像你的示例一样,你可以使用类似以下 Python 示例的方法:
delimiter = '.'
stack = []
for item in items:
  while stack and not item.startswith(stack[-1]+delimiter):
    print "</div>"
    stack.pop()
  print "<div>"
  print item
  stack.append(item)

这个功能维护了一个栈,表示树中当前位置。对于表中的每个元素,它会弹出堆栈元素(关闭匹配的div),直到找到当前项目的父级为止。然后输出该节点的开始并将其推送到堆栈。

如果您想使用缩进而不是嵌套元素输出树,只需跳过打印div的print语句,并在每个项目之前打印与堆栈大小的某个倍数相等的空格数。例如,在Python中:

print "  " * len(stack)

您也可以轻松使用此方法构建一组嵌套的列表或字典。

编辑:从您的澄清中我看到,这些名称并不是节点路径的意图。这表明了另一种方法:

idx = {}
idx[0] = []
for node in results:
  child_list = []
  idx[node.Id] = child_list
  idx[node.ParentId].append((node, child_list))

这构建了一个元组数组的树(!)。idx [0]表示树的根节点。数组中的每个元素都是一个2元组,包括节点本身和其所有子节点的列表。构建完成后,您可以保留idx [0]并丢弃idx,除非您想通过它们的ID访问节点。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接