Comment représentez-vous un graphique dans Haskell?

Il est assez facile de représenter un arbre ou une liste dans un haskell en utilisant des types de données algébriques. Mais comment allez-vous représenter typographiquement un graphique? Il semble que vous ayez besoin de pointeurs. Je suppose que vous pourriez avoir quelque chose comme

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

Et ce serait faisable. Cependant, il se sent un peu découplé; Les liens entre les différents nœuds de la structure ne se sentent pas aussi solides que les liens entre les éléments précédents et suivants d’une liste, ou les parents et les enfants d’un nœud dans un arbre. J’ai l’impression que des manipulations algébriques sur le graphe, telles que je les définissais, seraient quelque peu gênées par le niveau d’indirection introduit par le système de balises.

C’est principalement ce sentiment de doute et de perception de l’inélégance qui me pousse à poser cette question. Existe-t-il une manière meilleure / mathématiquement plus élégante de définir des graphiques dans Haskell? Ou est-ce que je suis tombé sur quelque chose de insortingnsèquement dur / fondamental? Les structures de données récursives sont douces, mais cela semble être autre chose. Une structure de données auto-référentielle dans un sens différent de la façon dont les arbres et les listes sont auto-référentiels. C’est comme si les listes et les arbres étaient auto-référentiels au niveau du type, mais les graphiques sont auto-référentiels au niveau de la valeur.

Alors, qu’est-ce qui se passe vraiment?

Je trouve également difficile de représenter des structures de données avec des cycles dans un langage pur. Ce sont vraiment les cycles qui posent problème. Comme les valeurs peuvent être partagées, tout ADT pouvant contenir un membre du type (y compris les listes et les arbres) est en réalité un DAG (Directed Acyclic Graph). Le problème fondamental est que si vous avez les valeurs A et B, avec A contenant B et B contenant A, alors aucune ne peut être créée avant que l’autre n’existe. Parce que Haskell est paresseux, vous pouvez utiliser un truc connu sous le nom de Tying the Knot pour contourner ce problème, mais cela me fait mal au cerveau (parce que je ne l’ai pas encore fait). J’ai fait plus de mes programmes substantiels dans Mercure que dans Haskell jusqu’à présent, et Mercury est ssortingct, alors le nouage ne sert à rien.

Habituellement, lorsque je me suis heurté à cette question avant de me lancer dans une indirection supplémentaire, comme vous le suggérez; souvent en utilisant une carte des identifiants aux éléments réels, et avoir des éléments contiennent des références aux identifiants plutôt qu’à d’autres éléments. La principale chose que je n’aimais pas à propos de cela (en dehors de l’inefficacité évidente), c’était que cela semblait plus fragile, introduisant les erreurs possibles de rechercher un identifiant qui n’existait pas ou d’essayer d’atsortingbuer le même identifiant à plusieurs élément. Vous pouvez écrire du code pour que ces erreurs ne se produisent pas, bien sûr, et même le cacher derrière des abstractions afin que les seuls endroits où de telles erreurs puissent se produire soient limités. Mais c’est encore une chose de se tromper.

Cependant, un rapide google pour “graphique Haskell” m’a conduit à http://www.haskell.org/haskellwiki/The_Monad.Reader/Issue5/Practical_Graph_Handling , qui ressemble à une lecture utile.

Dans la réponse de shang, vous pouvez voir comment représenter un graphique en utilisant la paresse. Le problème avec ces représentations est qu’elles sont très difficiles à changer. Le tour de passe-passe n’est utile que si vous construisez un graphique une seule fois, puis il ne change jamais.

En pratique, si je veux vraiment faire quelque chose avec mon graphique, j’utilise les représentations plus piétonnes:

  • Liste de bord
  • Liste d’adjacence
  • Atsortingbuez une étiquette unique à chaque nœud, utilisez l’étiquette au lieu d’un pointeur et conservez une carte finie des étiquettes aux nœuds

Si vous souhaitez modifier ou modifier le graphique fréquemment, je vous recommande d’utiliser une représentation basée sur la fermeture à glissière de Huet. C’est la représentation utilisée en interne dans GHC pour les graphes de stream de contrôle. Vous pouvez lire à ce sujet ici:

  • Un graphique de contrôle de stream applicatif basé sur la fermeture à glissière de Huet

  • Hoopl: une bibliothèque modulaire et réutilisable pour l’parsing et la transformation des stream de données

Comme Ben l’a mentionné, les données cycliques dans Haskell sont construites par un mécanisme appelé “nouer le nœud”. En pratique, cela signifie que nous écrivons des déclarations mutuellement récursives en utilisant let clauses let ou where , ce qui fonctionne parce que les parties mutuellement récursives sont évaluées paresseusement.

Voici un exemple de type de graphique:

 import Data.Maybe (fromJust) data Node a = Node { label :: a , adjacent :: [Node a] } data Graph a = Graph [Node a] 

Comme vous pouvez le voir, nous utilisons des références Node réelles au lieu de l’indirection. Voici comment implémenter une fonction qui construit le graphique à partir d’une liste d’associations d’étiquettes.

 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 

Nous prenons une liste de (nodeLabel, [adjacentLabel]) et construisons les valeurs Node réelles via une liste de recherche intermédiaire (qui fait le nœud réel). L’astuce est que nodeLookupList (qui a le type [(a, Node a)] ) est construit en utilisant mkNode , qui à son tour renvoie au nodeLookupList pour trouver les nœuds adjacents.

C’est vrai, les graphiques ne sont pas algébriques. Pour faire face à ce problème, vous avez plusieurs options:

  1. Au lieu de graphiques, considérez les arbres infinis. Représenter des cycles dans le graphique en tant que leur déroulement infini. Dans certains cas, vous pouvez utiliser l’astuce connue sous le nom de «lier le nœud» (bien expliquée dans certaines des autres réponses) pour représenter ces arbres infinis dans un espace fini en créant un cycle dans le tas; Cependant, vous ne pourrez ni observer ni détecter ces cycles depuis Haskell, ce qui rend difficile ou impossible toute une série d’opérations sur les graphiques.
  2. Il existe une variété d’algèbres de graphes disponibles dans la littérature. On pense tout d’abord à la collection de constructeurs de graphes décrite à la section deux des transformations de graphes bidirectionnelles . La propriété habituelle garantie par ces algèbres est que tout graphe peut être représenté algébriquement; Cependant, beaucoup de graphiques n’auront pas de représentation canonique . Donc, vérifier l’égalité structurellement ne suffit pas; le faire correctement revient à trouver l’isomorphisme des graphes – connu pour être un problème difficile.
  3. Abandonner les types de données algébriques; Représenter explicitement l’identité du nœud en leur atsortingbuant des valeurs uniques (disons Int s) et en les référant indirectement plutôt qu’algébriquement. Cela peut être considérablement plus pratique en rendant le type abstract et en fournissant une interface qui jongle avec l’indirection pour vous. C’est l’approche adoptée par exemple par fgl et d’autres bibliothèques de graphes pratiques sur Hackage.
  4. Proposez une nouvelle approche adaptée à votre cas d’utilisation. C’est une chose très difficile à faire. =)

Il y a donc des avantages et des inconvénients à chacun des choix ci-dessus. Choisissez celui qui vous convient le mieux.

J’ai toujours aimé l’approche de Martin Erwig dans “Graphiques inductifs et algorithmes fonctionnels”, que vous pouvez lire ici . FWIW, j’ai déjà écrit une implémentation Scala, voir https://github.com/nicolast/scalagraphs .

Quelques autres ont brièvement mentionné les fgl inductifs et les graphes fonctionnels de fgl et Martin Erwig, mais cela vaut probablement la peine d’écrire une réponse qui donne une idée des types de données derrière l’approche de représentation inductive.

Dans son article, Erwig présente les types suivants:

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

(La représentation dans fgl est légèrement différente, et fait bon usage des types de texte – mais l’idée est essentiellement la même.)

Erwig décrit un multigraphe dans lequel les nœuds et les arêtes ont des étiquettes et dans lequel tous les bords sont dirigés. Un Node a une étiquette de type a ; une arête a une étiquette de type b . Un Context est simplement (1) une liste de contours étiquetés pointant vers un nœud particulier, (2) le nœud en question, (3) l’étiquette du nœud et (4) la liste des contours étiquetés du nœud. Un Graph peut alors être conçu de manière inductive comme étant Empty ou comme un Context fusionné (avec & ) dans un Graph existant.

Comme Erwig le note, nous ne pouvons pas générer un Graph avec Empty et & , car nous pourrions générer une liste avec les constructeurs Cons et Nil , ou un Tree avec Leaf et Branch . Trop, contrairement aux listes (comme d’autres l’ont mentionné), il n’y aura pas de représentation canonique d’un Graph . Ce sont des différences cruciales.

Néanmoins, ce qui rend cette représentation si puissante et si semblable aux représentations typiques de listes et d’arbres de Haskell, c’est que le type de données Graph est défini par induction . Le fait qu’une liste soit inductive est ce qui nous permet d’y associer de manière si concise un motif, de traiter un seul élément et de traiter récursivement le rest de la liste; De même, la représentation inductive d’Erwig nous permet de traiter récursivement un graphique, un Context à la fois. Cette représentation d’un graphe se prête à une définition simple d’un moyen de mapper sur un graphe ( gmap ), ainsi qu’à un moyen d’effectuer des plis non ordonnés sur des graphes (en ufold ).

Les autres commentaires sur cette page sont géniaux. La principale raison pour laquelle j’ai écrit cette réponse est que lorsque je lis des phrases telles que «les graphes ne sont pas algébriques», je crains que certains lecteurs ne reviennent inévitablement avec l’impression (erronée) que personne n’a trouvé de moyen de représenter des graphes. dans Haskell d’une manière qui leur permet de faire des correspondances, de les cartographier, de les plier ou de faire généralement le genre de choses fonctionnelles que nous avons l’habitude de faire avec les listes et les arbres.

Toute discussion sur la représentation de graphes dans Haskell nécessite une mention de la bibliothèque data-reify d’Andy Gill (voici le papier ).

La représentation de style “attacher le nœud” peut être utilisée pour créer des DSL très élégantes (voir l’exemple ci-dessous). Cependant, la structure des données est d’une utilité limitée. La bibliothèque de Gill vous offre le meilleur des deux mondes. Vous pouvez utiliser un DSL “liant le nœud”, mais ensuite convertir le graphique basé sur un pointeur en un graphique basé sur une étiquette afin que vous puissiez y exécuter vos algorithmes de choix.

Voici un exemple simple:

 -- Graph we want to represent: -- .----> a <----. -- / \ -- b <------------. \ -- \ \ / -- `----> c ----> d -- Code for the graph: a = leaf b = node2 ac c = node1 d d = node2 ab -- 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 

Pour exécuter le code ci-dessus, vous aurez besoin des définitions suivantes:

 {-# 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 ab = 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) 

Je tiens à souligner qu'il s'agit d'un DSL simpliste, mais le ciel est la limite! J'ai conçu un DSL très caractéristique, comprenant une belle syntaxe arborescente permettant à un nœud de diffuser une valeur initiale à certains de ses enfants, et de nombreuses fonctions pratiques pour la construction de types de nœuds spécifiques. Bien sûr, le type de données Node et les définitions mapDeRef étaient beaucoup plus impliqués.

J’aime cette mise en œuvre d’un graphe pris ici

 import Data.Maybe import Data.Array class Enum b => Graph ab | a -> b where vertices :: a -> [b] edge :: a -> b -> b -> Maybe Double fromInt :: a -> Int -> b