防止递归 CTE 多次访问节点

Posted

技术标签:

【中文标题】防止递归 CTE 多次访问节点【英文标题】:Prevent recursive CTE visiting nodes multiple times 【发布时间】:2010-10-24 04:35:29 【问题描述】:

考虑以下简单的 DAG:

  1->2->3->4

还有一个表格,#bar,描述这个(我使用的是 SQL Server 2005):

parent_id   child_id
1           2
2           3
3           4
//... other edges, not connected to the subgraph above

现在假设我有一些其他任意标准来选择第一条和最后一条边,即 1->2 和 3->4。我想用这些来找到我的图表的其余部分。

我可以如下编写递归 CTE(我使用来自 MSDN 的术语):

with foo(parent_id,child_id) as (
// anchor member that happens to select first and last edges:
select parent_id,child_id from #bar where parent_id in (1,3)
union all
// recursive member:
select #bar.* from #bar
join foo on #bar.parent_id = foo.child_id
)
select parent_id,child_id from foo

但是,这会导致边 3->4 被选中两次:

parent_id  child_id
1          2
3          4
2          3
3          4    // 2nd appearance!

如何防止查询递归到已经描述的子图中?如果在查询的“递归成员”部分中,我可以引用 到目前为止由递归 CTE 检索到的所有数据(并在递归成员中提供一个谓词,不包括已经访问过的节点)。但是,我认为我只能访问由递归成员的最后一次迭代返回的数据。

当有很多这样的重复时,这将无法很好地扩展。有没有办法防止这种不必要的额外递归?

请注意,我可以在语句的最后一行使用“select distinct”来获得所需的结果,但这似乎是在所有(重复)递归完成后应用的,所以我不要认为这是一个理想的解决方案。

编辑 - hainstech 建议通过添加谓词来停止递归,以排除在起始集中显式递归的向下路径,即仅递归 where foo.child_id not in (1,3)。这仅适用于上述情况,因为它很简单——所有重复的部分都从锚节点集中开始。它不能解决它们可能不是的一般情况。例如,考虑将边 1->4 和 4->5 添加到上述集合中。边缘 4->5 将被捕获两次,即使使用建议的谓词。 :(

【问题讨论】:

【参考方案1】:

CTE 是递归的。

当您的CTE 有多个初始条件时,这意味着它们也有不同的递归堆栈,并且无法在另一个堆栈中使用来自一个堆栈的信息。

在您的示例中,递归堆栈将如下所示:

(1) - first IN condition
(1, 2)
(1, 2, 3)
(1, 2, 3, 4)
(1, 2, 3) - no more children
(1, 2) - no more children
(1) - no more children, going to second IN condition

(3) - second condition
(3, 4)
(3) - no more children, returning

如您所见,这些递归堆栈不相交。

您可以将访问过的值记录在一个临时表中,JOIN 每个值都与 temptable 一起,如果找到,请不要跟踪该值,但SQL Server 不支持这些东西。

所以你只需使用SELECT DISTINCT

【讨论】:

我只有一个初始条件,但它可以选择多行。您是否有任何文档的链接,说明每一行都使用独立的堆栈进行处理? (我想我可以使用我自己的临时表来模拟所需的效果 - 还没有尝试过)我没有足够的“声誉”来支持你的答案>:( 啊哈,您在我评论时修改了建议临时表的答案:)【参考方案2】:

这是我使用的方法。它已针对多种方法进行了测试,并且性能最高。它结合了 Quassnoi 建议的临时表思想以及使用 distinct 和 left join 来消除递归的冗余路径。递归的级别也包括在内。

我在代码中留下了失败的 CTE 方法,以便您比较结果。

如果有人有更好的主意,我很想知道。

create table #bar (unique_id int identity(10,10), parent_id int, child_id int)
insert #bar  (parent_id, child_id)
SELECT 1,2 UNION ALL
SELECT 2,3 UNION ALL
SELECT 3,4 UNION ALL
SELECT 2,5 UNION ALL
SELECT 2,5 UNION ALL
SELECT 5,6

SET NOCOUNT ON

;with foo(unique_id, parent_id,child_id, ord, lvl) as (
    -- anchor member that happens to select first and last edges:
    select unique_id, parent_id, child_id, row_number() over(order by unique_id), 0
    from #bar where parent_id in (1,3)
union all
-- recursive member:
select b.unique_id, b.parent_id, b.child_id, row_number() over(order by b.unique_id), foo.lvl+1
    from #bar b
    join foo on b.parent_id = foo.child_id
)
select unique_id, parent_id,child_id, ord, lvl from foo

/***********************************
    Manual Recursion
***********************************/
Declare @lvl as int
Declare @rows as int
DECLARE @foo as Table(
    unique_id int,
    parent_id int,
    child_id int,
    ord int,
    lvl int)

--Get anchor condition
INSERT @foo (unique_id, parent_id, child_id, ord, lvl)
select unique_id, parent_id, child_id, row_number() over(order by unique_id), 0
    from #bar where parent_id in (1,3)

set @rows=@@ROWCOUNT
set @lvl=0

--Do recursion
WHILE @rows > 0
BEGIN
    set @lvl = @lvl + 1

    INSERT @foo (unique_id, parent_id, child_id, ord, lvl)
    SELECT DISTINCT b.unique_id, b.parent_id, b.child_id, row_number() over(order by b.unique_id), @lvl
    FROM #bar b
     inner join @foo f on b.parent_id = f.child_id
     --might be multiple paths to this recursion so eliminate duplicates
     left join @foo dup on dup.unique_id = b.unique_id
    WHERE f.lvl = @lvl-1 and dup.child_id is null

    set @rows=@@ROWCOUNT 
END

SELECT * from @foo

DROP TABLE #bar

【讨论】:

很棒,易于理解,运行速度很快!谢谢! 使用此查询超快速执行。帮助我将存储过程的时间从 2 分钟缩短到 1 秒以下。我知道这不是“如何使用 CTE 执行此操作”的答案,但如果您愿意摆脱 CTE 递归,它是一个非常高效的解决方案。【参考方案3】:

您是否知道两条边中的哪一条在树的更深层次上?因为在这种情况下,您可以将边 3->4 设为锚成员并开始向上走,直到找到边 1->2

类似这样的:

with foo(parent_id, child_id)
as
(
    select parent_id, child_id
    from #bar
    where parent_id = 3

    union all

    select parent_id, child_id
    from #bar b
    inner join foo f on b.child_id = f.parent_id
    where b.parent_id <> 1
)
select *
from foo

【讨论】:

恐怕我不知道 - 我通常什至不知道在初始查询中选择的边是否可能属于同一个整体图 :( 不过谢谢!【参考方案4】:

(我不是图表专家,只是稍微探索一下)

DISTINCT 将保证每一行都是不同的,但它不会消除最终不会出现在最后一条边上的图形路径。拿这张图:

insert into #bar (parent_id,child_id) values (1,2)
insert into #bar (parent_id,child_id) values (1,5)
insert into #bar (parent_id,child_id) values (2,3)
insert into #bar (parent_id,child_id) values (2,6)
insert into #bar (parent_id,child_id) values (6,4)

这里查询的结果包括(1,5),它不是从第一条边(1,2)到最后一条边(6,4)的路线的一部分。

您可以尝试这样的方法,仅查找以 (1,2) 开头并以 (6,4) 结尾的路线:

with foo(parent_id, child_id, route) as (
    select parent_id, child_id, 
        cast(cast(parent_id as varchar) + 
        cast(child_id as varchar) as varchar(128))
    from #bar
    union all
    select #bar.parent_id, #bar.child_id, 
        cast(route + cast(#bar.child_id as varchar) as varchar(128)) 
    from #bar
    join foo on #bar.parent_id = foo.child_id
)
select * from foo where route like '12%64'

【讨论】:

我不想消除那些不会在我的“最后”边缘结束的路线(我想要连接到锚点中指定的任何边缘的所有东西) - 但我同意这一点是一个有趣的(单独的)问题:) 如果加上 (6,1),那算不算连通? 是的,但是它会变得循环,你还有一整套其他问题要担心:) (请注意,跟踪您已经访问过的节点的能力实际上也有助于避免在循环图中无限循环!) 嘿,我的意思是(6,1)到你原来的例子!或者说,(7,1) 到我的。还是假设 (1,2) 是一个边缘节点并且在它之前不能有条目?【参考方案5】:

这是你想做的吗?

create table #bar (parent_id int, child_id int)
insert #bar values (1,2)
insert #bar values (2,3)
insert #bar values (3,4)

declare @start_node table (parent_id int)
insert @start_node values (1)
insert @start_node values (3)

;with foo(parent_id,child_id) as (
    select
        parent_id
        ,child_id
    from #bar where parent_id in (select parent_id from @start_node)

    union all

    select
        #bar.*
    from #bar
        join foo on #bar.parent_id = foo.child_id
    where foo.child_id not in (select parent_id from @start_node)
)
select parent_id,child_id from foo

编辑 - @bacar - 我不认为这是 Quasnoi 提出的临时表解决方案。我相信他们建议在每次递归期间基本上复制整个递归成员内容,并将其用作连接以防止重新处理(并且这在 ss2k5 中不受支持)。支持我的方法,对原始方法的唯一更改是在递归成员中的谓词中,以排除在您的起始集中明确存在的递归向下路径。我只添加了 table 变量,以便您在一个位置定义起始 parent_ids,您可以轻松地将此谓词与原始查询一起使用:

where foo.child_id not in (1,3)

【讨论】:

是的 - 这是 Quassnoi 建议的“临时表”解决方案的实现谢谢! 好的,我现在明白了——这适用于所有重复部分都包含在初始集合中的简单情况——它不会阻止重复递归到 other 子图中由初始集合共享,但不直接包含在初始集合中。例如考虑图表:插入 #bar 值 (1,2) 插入 #bar 值 (2,3) 插入 #bar 值 (1,3) 插入 #bar 值 (3,4) 并且初始起始节点仅为 1。您的查询仍然会选择边缘 3->4 两次,因为我无法将访问过的节点插入到递归成员部分的表变量中(正如您所提到的)。 :( @bacar - 感谢您提供有用的示例,您可能需要编辑您的问题以将其合并。您无法使用 CTE 保存任何类型的状态,因此我认为您可能必须使用迭代 tsql 方法或使用 CLR 的方法。 已完成 - 尽管已尝试使其尽可能简单。谢谢!【参考方案6】:

编辑——这根本不起作用。这是一种停止追逐三角形路线的方法。它没有做 OP 想要的。

或者您可以使用递归标记分隔字符串。

我在家里用我的笔记本电脑(没有 sql 服务器)所以这可能不完全正确,但是这里......

; WITH NodeNetwork AS (
  -- Anchor Definition
  SELECT
     b.[parent_Id] AS [Parent_ID]
     , b.[child_Id] AS [Child_ID]
     , CAST(b.[Parent_Id] AS VARCHAR(MAX)) AS [NodePath]
  FROM
     #bar AS b

  -- Recursive Definition
  UNION ALL SELECT
     b.[Parent_Id]
     , b.[child_Id]
     , CAST(nn.[NodePath] + '-' + CAST(b.[Parent_Id] AS VARCHAR(MAX)) AS VARCHAR(MAX))
  FROM
     NodeNetwork AS nn
     JOIN #bar AS b ON b.[Parent_Id] = nn.[Child_ID]
  WHERE
     nn.[NodePath] NOT LIKE '%[-]' + CAST(b.[Parent_Id] AS VARCHAR(MAX)) + '%'
  )
  SELECT * FROM NodeNetwork

或类似的。抱歉,太晚了,我无法测试它。我会在星期一早上检查。这要归功于 Peter Larsson(比索)

这个想法是在这里产生的: http://www.sqlteam.com/forums/topic.asp?TOPIC_ID=115290

【讨论】:

不确定我是否遵循了它的目的。这实际上选择了边缘 3->4 三次次,而不是两次! (我只想要一次)。它还选择 2->3 两次。它也不适用于原始的“任意标准”(其中 parent_id 在 (1,3) 中)。如果我添加它,它给出的结果与我在最初的问题陈述中得到的结果相同。 (FWIW - 以@Quassnoi 的答案/cmets 为真,我看不出任何仅涉及递归 CTE 的解决方案如何可能解决问题,无论您如何操作其中的数据。) 你是对的。这将阻止您追逐三角形路线(如果您有一些条目,其中一个孩子以某种方式指向它自己的父母或祖父母)。它实际上并没有回答你的问题。

以上是关于防止递归 CTE 多次访问节点的主要内容,如果未能解决你的问题,请参考以下文章

Javascript - Node.js - 防止函数的多次执行

Vue中防止按钮的多次点击

mysql递归查询cte

怎么防止堆栈溢出

SQL 递归查询,意淫CTE递归的执行步骤

具有递归 CTE 的 Postgres:在保留树结构的同时按受欢迎程度对子节点进行排序/排序(父节点始终高于子节点)