使用严格函数式编程从 poset 生成 DAG

Posted

技术标签:

【中文标题】使用严格函数式编程从 poset 生成 DAG【英文标题】:Generate a DAG from a poset using stricly functional programming 【发布时间】:2012-02-08 23:03:01 【问题描述】:

这是我的问题:我有一个(非空但可能不是不同的)集合 s_i 的序列 S,并且对于每个 s_i,需要知道 S(i ≠ j)中有多少个集合 s_j 是 s_i 的子集。

我还需要增量性能:一旦我得到所有计数,我可以用 s_i 的某个子集替换一组 s_i 并增量更新计数。

使用纯函数式代码执行所有这些将是一个巨大的优势(我在 Scala 中编写代码)。

由于集合包含是部分排序,我认为解决我的问题的最佳方法是构建一个 DAG,它表示集合的 Hasse 图,边表示包含,并将整数值连接到每个节点表示节点下方的子 dag 的大小加 1。然而,我已经被困了好几天,试图开发从偏序构建 Hasse 图的算法(我们不谈论增量!),即使我认为它将是一些标准的本科材料。

这是我的数据结构:

case class HNode[A] (
  val v: A,
  val child: List[HNode[A]]) 
  val rank = 1 + child.map(_.rank).sum

我的 DAG 由根列表和一些偏序定义:

class Hasse[A](val po: PartialOrdering[A], val roots: List[HNode[A]]) 
  def +(v: A): Hasse[A] = new Hasse[A](po, add(v, roots))

  private def collect(v: A, roots: List[HNode[A]], collected: List[HNode[A]]): List[HNode[A]] =
    if (roots == Nil) collected
    else 
      val (subsets, remaining) = roots.partition(r => po.lteq(r.v, v))
      collect(v, remaining.map(_.child).flatten, subsets.filter(r => !collected.exists(c => po.lteq(r.v, c.v))) ::: collected)
    

我被困在这里了。最后我想为 DAG 添加一个新值 v 是:

    在 DAG 中找到 v 的所有“根子集”rs_i,即 v 的子集,使得 rs_i 的任何超集都不是 v 的子集。这可以通过在图表(collect 函数,可能不是最优甚至有缺陷)。 构建新节点 n_v,其子节点是之前找到的 rs_i。 现在,让我们找出应在何处附加 n_v:对于给定的根列表,找出 v 的超集。如果没有找到,将 n_v 添加到根并从根中删除 n_v 的子集。否则,对超集的孩子递归执行第 3 步。

我还没有完全实现这个算法,但是对于我看似简单的问题来说,它似乎过于复杂和非最佳。是否有一些更简单的算法可用(谷歌对此一无所知)?

【问题讨论】:

这个算法对我来说似乎非常简单,没有不必要的复杂。究竟是什么问题?它的 Scala 代码几乎不会比您的描述长。 (虽然我认为你甚至没有完全描述它。) 好吧,自从我进入函数式编程(大约 6 个月前)以来,我在处理递归数据结构时已经习惯了单行。开发一个三步算法感觉很尴尬,它不在于单个递归调用(第 1 步与第 3 步断开连接)。此外,该算法检查子集两次(第 1 步和第 3 步),感觉不对。 作为参考,我最近实现了一个二项式堆,感觉要容易得多(尽管可能是因为算法定义得更好)。 您有两件本质上不同的事情要做:如果合适,将新集合添加为根节点,并将其粘贴到子列表中并构建适当的子列表(至少一件事,可能二)。将所有这些都放在一条合理长度的行中似乎非常乐观。 实际上,我在之前的错误分析中设法做到了,我发现部分排序会导致一棵树。我认为用 DAG 替换树会很容易,该死,我错了:部分排序意味着我的新元素的子集可以出现在 DAG 中的任何位置,而不仅仅是在一个特定的子树中。 【参考方案1】:

经过一番努力,我终于按照我最初的直觉解决了我的问题。收集方法和排名评估有缺陷,我用尾递归作为奖励重写了它们。这是我得到的代码:

final case class HNode[A](
  val v: A,
  val child: List[HNode[A]]) 
  val rank: Int = 1 + count(child, Set.empty)

  @tailrec
  private def count(stack: List[HNode[A]], c: Set[HNode[A]]): Int =
    if (stack == Nil) c.size
    else 
      val head :: rem = stack
      if (c(head)) count(rem, c)
      else count(head.child ::: rem, c + head)
    


// ...

  private def add(v: A, roots: List[HNode[A]]): List[HNode[A]] = 
    val newNode = HNode(v, collect(v, roots, Nil))
    attach(newNode, roots)
  

  private def attach(n: HNode[A], roots: List[HNode[A]]): List[HNode[A]] =
    if (roots.contains(n)) roots
    else 
      val (supersets, remaining) = roots.partition  r =>
        // Strict superset to avoid creating cycles in case of equal elements
        po.tryCompare(n.v, r.v) == Some(-1)
      
      if (supersets.isEmpty) n :: remaining.filter(r => !po.lteq(r.v, n.v))
      else 
        supersets.map(s => HNode(s.v, attach(n, s.child))) ::: remaining
      
    

  @tailrec
  private def collect(v: A, stack: List[HNode[A]], collected: List[HNode[A]]): List[HNode[A]] =
    if (stack == Nil) collected
    else 
      val head :: tail = stack

      if (collected.exists(c => po.lteq(head.v, c.v))) collect(v, tail, collected)
      else if (po.lteq(head.v, v)) collect(v, tail, head :: (collected.filter(c => !po.lteq(c.v, head.v))))
      else collect(v, head.child ::: tail, collected)
    

我现在必须检查一些优化: - 在收集子集时切断具有完全不同集合的分支(如 Rex Kerr 建议的那样) - 看看按大小对集合进行排序是否会改进过程(如 michus 建议的那样)

下面的问题是解决 add() 操作的(最坏情况)复杂性。 集合的数量为 n,最大集合的大小为 d,复杂度可能是 O(n²d),但我希望它可以细化。这是我的推理:如果所有集合都是不同的,则 DAG 将简化为一系列根/叶。因此,每次我尝试向数据结构中添加节点时,我仍然必须检查是否包含已存在的每个节点(在收集和附加过程中)。这导致 1 + 2 + ... + n = n(n+1)/2 ∈ O(n²) 包含检查。

每个集合包含测试都是 O(d),因此是结果。

【讨论】:

一些带有随机生成集合的简单基准测试往往证实了 O(n²d) 复杂度,即使在平均情况下也是如此。 以上代码有一个bug:在attach过程中创建HNodes会分裂DAG中的节点。我正在做这个。【参考方案2】:

假设您的 DAG G 包含每个集合的节点 v,具有属性 v.s(集合)和 v.count(集合的实例数),包括一个节点 G.root 和 @ 987654326@(如果此集合从未出现在您的收藏中,则为 G.root.count=0)。

然后要计算 s 的不同子集的数量,您可以执行以下操作(在 Scala、Python 和伪代码的混杂中):

sum(apply(lambda x: x.count, get_subsets(s, G.root)))

在哪里

get_subsets(s, v) :
   if(v.s is not a subset of s, , 
      union(v :: apply(v.children, lambda x: get_subsets(s, x))))

但在我看来,出于性能原因,最好放弃这种纯粹的函数式解决方案......它在列表和树上运行良好,但除此之外,事情变得艰难。

【讨论】:

这个答案假设 DAG 存在,不是吗?我的第一个问题是从偏序生成 DAG。经过一些进一步的研究,我似乎想计算传递闭包的逆,可能与拓扑排序有关。 好吧,实际上我所拥有的只是部分订单。在我的问题的根源上,我没有 v.children。我想尽可能高效地找出孩子(我希望比 O(n²) 更好) 是的,我认为 DAG 已经存在。要构建它,作为第一步,您可以按大小对集合进行排序;子集总是小于超集。下一步,我将构建一个人工根节点,其中 set = union of all sets。然后想法变成了,以递减的大小顺序获取集合,为它创建一个节点,并决定哪些是它的“最小”超集;你想链接到那些并且只有那些。从根节点开始,迭代地下降到所有超集节点,直到达到那些“最小”超集;每次到达这样的超集时,添加一个链接。

以上是关于使用严格函数式编程从 poset 生成 DAG的主要内容,如果未能解决你的问题,请参考以下文章

函数式编程

函数式编程

Scala基础篇-函数式编程的重要特性

纯函数式编程

C#函数式编程中的惰性求值详解

译文不要害怕函数式编程