如何在Haskell中表示图形?

133

使用代数数据类型在 Haskell 中表示树或列表很容易。但是如何通过排版来表示图形呢?似乎你需要使用指针。我猜你可以这样做:

type Nodetag = String
type Neighbours = [Nodetag]
data Node a = Node a Nodetag Neighbours

这种方式是可行的,但感觉有点解耦;结构中不同节点之间的链接没有像列表中当前、前一个和后一个元素之间或树型结构中节点的父子之间那样紧密。我有一种直觉,通过我定义的标签系统对图进行代数操作可能会受到引入级别的间接性的阻碍。

主要是这种疑虑和感觉不够优雅,促使我提出这个问题。在Haskell中是否有更好/更数学优雅的定义图的方式?还是我碰到了什么困难/根本性的问题?递归数据结构很棒,但这似乎是另一回事。它与树和列表的自我引用不同。就像列表和树在类型层面上是自我引用的,而图在值层面上是自我引用的。

那么,到底发生了什么?


12
您可能会对Martin Erwig的有关函数图算法的论文感兴趣:http://web.engr.oregonstate.edu/~erwig/papers/abstracts.html#JFP01。 fgl 软件包是由此开发而来的。 - John L
99 Haskell 问题 页面展示了一些在问题解决环境下使用的图形示例。它还介绍了不同表示法的简短介绍。 - dopamane
7个回答

64

在shang的回答中,您可以看到如何使用惰性来表示图形。这些表示的问题在于它们非常难以更改。如果您只打算构建一次图形,然后永远不再更改,那么绑结技巧是有用的。

实际上,如果我想要对我的图进行操作,我会使用更加普通的表示方法:

  • 边缘列表
  • 邻接表
  • 为每个节点提供唯一标签,使用标签而不是指针,并保留从标签到节点的有限映射

如果您将频繁更改或编辑图形,则建议使用基于Huet's zipper的表示法。这是GHC在控制流图中内部使用的表示法。您可以在此处阅读有关它的信息:


2
打结的另一个问题是很容易不小心解开它,浪费很多空间。 - hugomg
Tuft的网站似乎有些问题(至少目前是这样),这两个链接都无法使用。我已经找到了一些替代镜像:基于Huet's Zipper的应用控制流图Hoopl: 一个用于数据流分析和转换的模块化、可重用的库 - gntskn

50

我也发现在纯函数编程语言中,尝试表示具有循环结构的数据结构很麻烦。实际上循环结构是真正的问题,因为值可以共享,任何能够包含该类型成员(包括列表和树)的ADT都是DAG(有向无环图)。最根本的问题在于,如果有值A和B,A包含B,而B又包含A,那么在另一个存在之前,它们中的任何一个都无法创建。因为Haskell是惰性的,你可以使用一种称为"tying the knot" 的技巧来解决这个问题,但我觉得这样很困难(因为我还没有做过太多)。到目前为止,我的很多重要的编程工作都是在Mercury中完成的,而Mercury是严格的,所以打结并不起作用。

通常情况下,当我遇到这种情况时,我会采取额外的间接方式,就像你建议的那样;通常是使用一个从ID到实际元素的映射,并让元素包含对ID的引用,而不是对其他元素的引用。我不喜欢这种方法的主要原因是它感觉更加脆弱,引入了查找不存在的ID或将相同的ID分配给多个元素的可能性。当然,您可以编写代码以确保不会出现这些错误,甚至可以将其隐藏在抽象后面,使得这些错误可能发生的位置是有限的。但这仍然是一个容易出错的因素。

然而,快速搜索"Haskell图形",我发现http://www.haskell.org/haskellwiki/The_Monad.Reader/Issue5/Practical_Graph_Handling,看起来很值得一读。


39

正如Ben所提到的,Haskell中的循环数据是通过一种称为“打结”的机制构建的。实际上,这意味着我们使用letwhere子句编写相互递归的声明,这是可行的,因为相互递归的部分是惰性评估的。

这里是一个示例图形类型:

import Data.Maybe (fromJust)

data Node a = Node
    { label    :: a
    , adjacent :: [Node a]
    }

data Graph a = Graph [Node a]

正如你所看到的,我们使用实际的Node引用而不是间接引用。以下是如何实现一个从标签关联列表构建图形的函数。

mkGraph :: Eq a => [(a, [a])] -> Graph a
mkGraph links = Graph $ map snd nodeLookupList where

    mkNode (lbl, adj) = (lbl, Node lbl $ map lookupNode adj)

    nodeLookupList = map mkNode links

    lookupNode lbl = fromJust $ lookup lbl nodeLookupList

我们接受一个由(节点标签, [相邻标签])组成的列表,并通过一个中间查找列表(实际上是将节点连接起来)构建出实际的Node值。关键在于,nodeLookupList (其类型为[(a, Node a)])是使用mkNode构建的,而mkNode反过来又引用nodeLookupList以查找相邻的节点。


21
你还应该提到这种数据结构无法描述图形,它只能描述它们的展开。(在有限空间内的无限展开,但仍然......) - Rotsor
1
哇,我还没有时间详细检查所有答案,但我要说利用惰性求值这样的东西听起来像是在薄冰上滑行。会不会很容易陷入无限递归呢?仍然是非常棒的东西,感觉比我在问题中提出的数据类型好多了。 - TheIronKnuckle
1
@TheIronKnuckle 和 Haskell 程序员经常使用的无限列表相比,差别不是太大 :) - Justin L.

37

没错,图形不是代数的。要解决这个问题,你有几个选项:

  1. 考虑无限树代替图形。将图中的循环表示为它们的无限展开。在某些情况下,您可以使用“tying the knot”技巧(在其他答案中有很好的解释),通过在堆中创建循环来甚至能够在有限空间内表示这些无限树;但是,您将无法从Haskell内部观察或检测这些循环,这使得许多图形操作变得困难或不可能。
  2. 文献中提供了各种图代数。首先想到的是第二节中描述的图构造器集合 Bidirectionalizing Graph Transformations。这些代数通常保证任何图形都可以代数地表示;但是,关键的是,许多图形将没有一个标准表示。因此,仅仅进行结构上的相等性检查是不够的;正确地完成它等同于找到图形同构 - 众所周知这是一个困难的问题。
  3. 放弃代数数据类型;通过给每个节点分配唯一值(例如, Int)并直接引用它们,而不是代数地表示节点身份。通过使类型抽象并提供一个界面来为您摆弄间接引用,可以使此过程更加方便。例如,fgl和Hackage上的其他实用图库采取了这种方法。
  4. 想出一种全新的方法,适合您的用例。这是一件非常困难的事情。 =)

因此,每个选项都有其优缺点。选择最适合您的那个。


“你将无法从Haskell内部观察或检测到这些周期”并不完全正确——有一个库可以让你做到!请查看我的回答。” - Artelius
图现在是代数的了!https://hackage.haskell.org/package/algebraic-graphs - Josh.F
代数图形包似乎属于这里的第二种情况。仅仅因为你可以使用代数语言来描述一个图形(如该包中所示)或者代数式模式匹配(如fgl中所示),并不意味着图形是代数的。 - Daniel Wagner

21

有一些人简单提到了fgl和Martin Erwig的《归纳图和函数图算法》,但有必要写一篇答案,以实际展示归纳表示方法背后的数据类型。

在他的论文中,Erwig提出了以下类型:

type Node = Int
type Adj b = [(b, Node)]
type Context a b = (Adj b, Node, a, Adj b)
data Graph a b = Empty | Context a b & Graph a b

(在fgl中的表示略有不同,并充分利用了类型类 - 但思想基本相同。)

Erwig描述了一个多重图,其中节点和边缘具有标签,并且所有边缘都是有向的。Node具有某种类型a的标签;边缘具有某种类型b的标签。Context只是(1) 指向特定节点的带标签边缘列表, (2) 相应的节点, (3) 节点的标签,以及 (4) 指向节点的带标签边缘列表。因此,可以归纳地将Graph构想为Empty,或者作为已存在的Graph&合并的Context
正如Erwig所指出的,我们不能自由地使用Empty&生成Graph,就像我们可能会使用ConsNil构造函数生成列表,或者使用LeafBranch生成Tree一样。同样,与列表不同(正如其他人所提到的),没有任何关于Graph的规范表示形式。这些是关键的差异。

尽管如此,使得这种表示如此强大并且与列表和树的典型Haskell表示非常相似的是,这里的Graph数据类型是归纳定义的。列表是归纳定义的事实使我们能够对其进行简洁的模式匹配,处理单个元素,并递归地处理列表的其余部分;同样,Erwig的归纳表示允许我们逐个Context递归地处理图形。
这种图的表示方式适合简单定义一种在图上映射的方法(gmap),以及执行无序图上的折叠的方法(ufold)。

本页中的其他评论很棒。然而,我写这篇答案的主要原因是当我读到诸如“图不是代数”的短语时,我担心一些读者将不可避免地产生这样的(错误)印象:没有人找到一种漂亮的方法来表示图形,在这种表示中允许对其进行模式匹配、映射、折叠,或者通常做我们通常用列表和树做的那种酷炫的函数式操作。


6
任何有关在Haskell中表示图形的讨论都需要提到Andy Gill的data-reify library(这里是 论文)。
“绕结”风格的表示法可以用于创建非常优雅的DSL(请参见下面的示例)。然而,这种数据结构的使用受到限制。Gill的库允许你拥有两全其美的好处。你可以使用“绕结”DSL,但是可以将基于指针的图形转换为基于标签的图形,以便在其上运行所选的算法。
这是一个简单的例子:
-- Graph we want to represent:
--    .----> a <----.
--   /               \
--  b <------------.  \
--   \              \ / 
--    `----> c ----> d

-- Code for the graph:
a = leaf
b = node2 a c
c = node1 d
d = node2 a b
-- Yes, it's that simple!



-- If you want to convert the graph to a Node-Label format:
main = do
    g <- reifyGraph b   --can't use 'a' because not all nodes are reachable
    print g

要运行上述代码,您需要以下定义:
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeFamilies #-}
import Data.Reify
import Control.Applicative
import Data.Traversable

--Pointer-based graph representation
data PtrNode = PtrNode [PtrNode]

--Label-based graph representation
data LblNode lbl = LblNode [lbl] deriving Show

--Convenience functions for our DSL
leaf      = PtrNode []
node1 a   = PtrNode [a]
node2 a b = PtrNode [a, b]


-- This looks scary but we're just telling data-reify where the pointers are
-- in our graph representation so they can be turned to labels
instance MuRef PtrNode where
    type DeRef PtrNode = LblNode
    mapDeRef f (PtrNode as) = LblNode <$> (traverse f as)

我希望强调这是一个简单的DSL,但是“天空是极限!”我设计了一个非常丰富的DSL,包括一种漂亮的树形语法,用于使节点向其某些子节点广播初始值,并提供许多方便函数来构造特定的节点类型。当然,Node数据类型和mapDeRef定义要复杂得多。

2

我喜欢这个图的实现,它来自这里

import Data.Maybe
import Data.Array

class Enum b => Graph a b | a -> b where
    vertices ::  a -> [b]
    edge :: a -> b -> b -> Maybe Double
    fromInt :: a -> Int -> b

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