Can someone explain in simple terms to me what a directed acyclic graph is? - directed-acyclic-graphs

Can someone explain in simple terms to me what a directed acyclic graph is? I have looked on Wikipedia but it doesn't really make me see its use in programming.

graph = structure consisting of nodes, that are connected to each other with edges
directed = the connections between the nodes (edges) have a direction: A -> B is not the same as B -> A
acyclic = "non-circular" = moving from node to node by following the edges, you will never encounter the same node for the second time.
A good example of a directed acyclic graph is a tree. Note, however, that not all directed acyclic graphs are trees.

dots with lines pointing to other dots

I see lot of answers indicating the meaning of DAG (Directed Acyclic Graph) but no answers on its applications. Here is a very simple one -
Pre-requisite graph - During an engineering course every student faces a task of choosing subjects that follows requirements such as pre-requisites. Now its clear that you cannot take a class on Artificial Intelligence[B] without a pre requisite course on Algorithms[A]. Hence B depends on A or in better terms A has an edge directed to B. So in order to reach Node B you have to visit Node A. It will soon be clear that after adding all the subjects with its pre-requisites into a graph, it will turn out to be a Directed Acyclic Graph.
If there was a cycle then you would never complete a course :p
A software system in the university that allows students to register for courses can model subjects as nodes to be sure that the student has taken a pre-requisite course before registering for the current course.
My professor gave this analogy and it has best helped me understand DAG rather than using some complicated concept!
Another real time example -> Real Time example of how DAG's can be used in version system

Example uses of a directed acyclic graph in programming include more or less anything that represents connectivity and causality.
For example, suppose you have a computation pipeline that is configurable at runtime. As one example of this, suppose computations A,B,C,D,E,F, and G depend on each other: A depends on C, C depends on E and F, B depends on D and E, and D depends on F. This can be represented as a DAG. Once you have the DAG in memory, you can write algorithms to:
make sure the computations are evaluated in the correct order (topological sort)
if computations can be done in parallel but each computation has a maximum execution time, you can calculate the maximum execution time of the entire set
among many other things.
Outside the realm of application programming, any decent automated build tool (make, ant, scons, etc.) will use DAGs to ensure proper build order of the components of a program.

Several answers have given examples of the use of graphs (e.g. network modeling) and you've asked "what does this have to do with programming?".
The answer to that sub-question is that it doesn't have much of anything to do with programming. It has to do with problem solving.
Just like linked-lists are data structures used for certain classes of problems, graphs are useful for representing certain relationships. Linked lists, trees, graphs, and other abstract structures only have a connection to programming in that you can implement them in code. They exist at a higher level of abstraction. It's not about programming, it's about applying data structures in the solution of problems.

Directed Acyclic Graphs (DAG) have the following properties which distinguish them from other graphs:
Their edges show direction.
They don't have cycles.
Well, I can think of one use right now - DAG (known as Wait-For-Graphs - more technical details) are handy in detecting deadlocks as they illustrate the dependencies amongst a set of processes and resources (both are nodes in the DAG). Deadlock would happen when a cycle is detected.

I assume you already know basic graph terminology; otherwise you should start from the article on graph theory.
Directed refers to the fact that the edges (connections) have directions. In the diagram, these directions are shown by the arrows. The opposite is an undirected graph, whose edges don't specify directions.
Acyclic means that, if you start from any arbitrary node X and walk through all possible edges, you cannot return to X without going back on an already-used edge.
Several applications:
Spreadsheets; this is explained in the DAG article.
Revision control: if you have a look at the diagram in that page, you will see that the evolution of revision-controlled code is directed (it goes "down", in this diagram) and acyclic (it never goes back "up").
Family tree: it's directed (you are your parents' child, not the other way around) and acyclic (your ancestors can never be your descendant).

A DAG is a graph where everything flows in the same direction and no node can reference back to itself.
Think of ancestry trees; they are actually DAGs.
All DAGs have
Nodes (places to store data)
Directed Edges (that point in the same direction)
An ancestral node (a node without parents)
Leaves (nodes that have no children)
DAGs are different from trees. In a tree-like structure, there must a unique path between every two nodes. In DAGs, a node can have two parent nodes.
Here's a good article about DAGs. I hope that helps.

Graphs, of all sorts, are used in programming to model various different real-world relationships. For example, a social network is often represented by a graph (cyclic in this case). Likewise, network topologies, family trees, airline routes, ...

From a source code or even three address(TAC) code perspective you can visualize the problem really easily at this page...
http://cgm.cs.mcgill.ca/~hagha/topic30/topic30.html#Exptree
If you go to the expression tree section, and then page down a bit it shows the "topological sorting" of the tree, and the algorithm for how to evaluate the expression.
So in that case you can use the DAG to evaluate expressions, which is handy since evaluation is normally interpreted and using such a DAG evaluator will make simple intrepreters faster in principal because it is not pushing and popping to a stack and also because it is eliminating common sub-expressions.
The basic algorithm to compute the DAG in non ancient egyptian(ie English) is this:
1) Make your DAG object like so
You need a live list and this list holds all the current live DAG nodes and DAG sub-expressions. A DAG sub expression is a DAG Node, or you can also call it an internal node. What I mean by live DAG Node is that if you assign to a variable X then it becomes live. A common sub-expression that then uses X uses that instance. If X is assigned to again then a NEW DAG NODE is created and added to the live list and the old X is removed so the next sub-expression that uses X will refer to the new instance and thus will not conflict with sub-expressions that merely use the same variable name.
Once you assign to a variable X, then co-incidentally all the DAG sub-expression nodes that are live at the point of assignment become not-live, since the new assignment invalidates the meaning of sub expressions using the old value.
class Dag {
TList LiveList;
DagNode Root;
}
// In your DagNode you need a way to refer to the original things that
// the DAG is computed from. In this case I just assume an integer index
// into the list of variables and also an integer index for the opertor for
// Nodes that refer to operators. Obviously you can create sub-classes for
// different kinds of Dag Nodes.
class DagNode {
int Variable;
int Operator;// You can also use a class
DagNode Left;
DagNode Right;
DagNodeList Parents;
}
So what you do is walk through your tree in your own code, such as a tree of expressions in source code for example. Call the existing nodes XNodes for example.
So for each XNode you need to decide how to add it into the DAG, and there is the possibility that it is already in the DAG.
This is very simple pseudo code. Not intended for compilation.
DagNode XNode::GetDagNode(Dag dag) {
if (XNode.IsAssignment) {
// The assignment is a special case. A common sub expression is not
// formed by the assignment since it creates a new value.
// Evaluate the right hand side like normal
XNode.RightXNode.GetDagNode();
// And now take the variable being assigned to out of the current live list
dag.RemoveDagNodeForVariable(XNode.VariableBeingAssigned);
// Also remove all DAG sub expressions using the variable - since the new value
// makes them redundant
dag.RemoveDagExpressionsUsingVariable(XNode.VariableBeingAssigned);
// Then make a new variable in the live list in the dag, so that references to
// the variable later on will see the new dag node instead.
dag.AddDagNodeForVariable(XNode.VariableBeingAssigned);
}
else if (XNode.IsVariable) {
// A variable node has no child nodes, so you can just proces it directly
DagNode n = dag.GetDagNodeForVariable(XNode.Variable));
if (n) XNode.DagNode = n;
else {
XNode.DagNode = dag.CreateDagNodeForVariable(XNode.Variable);
}
return XNode.DagNode;
}
else if (XNode.IsOperator) {
DagNode leftDagNode = XNode.LeftXNode.GetDagNode(dag);
DagNode rightDagNode = XNode.RightXNode.GetDagNode(dag);
// Here you can observe how supplying the operator id and both operands that it
// looks in the Dags live list to check if this expression is already there. If
// it is then it returns it and that is how a common sub-expression is formed.
// This is called an internal node.
XNode.DagNode =
dag.GetOrCreateDagNodeForOperator(XNode.Operator,leftDagNode,RightDagNode) );
return XNode.DagNode;
}
}
So that is one way of looking at it. A basic walk of the tree and just adding in and referring to the Dag nodes as it goes. The root of the dag is whatever DagNode the root of the tree returns for example.
Obviously the example procedure can be broken up into smaller parts or made as sub-classes with virtual functions.
As for sorting the Dag, you go through each DagNode from left to right. In other words follow the DagNodes left hand edge, and then the right hand side edge. The numbers are assigned in reverse. In other words when you reach a DagNode with no children, assign that Node the current sorting number and increment the sorting number, so as the recursion unwinds the numbers get assigned in increasing order.
This example only handles trees with nodes that have zero or two children. Obviously some trees have nodes with more than two children so the logic is still the same. Instead of computing left and right, compute from left to right etc...
// Most basic DAG topological ordering example.
void DagNode::OrderDAG(int* counter) {
if (this->AlreadyCounted) return;
// Count from left to right
for x = 0 to this->Children.Count-1
this->Children[x].OrderDag(counter)
// And finally number the DAG Node here after all
// the children have been numbered
this->DAGOrder = *counter;
// Increment the counter so the caller gets a higher number
*counter = *counter + 1;
// Mark as processed so will count again
this->AlreadyCounted = TRUE;
}

If you know what trees are in programming, then DAGs in programming are similar but they allow a node to have more than one parent. This can be handy when you want to let a node be clumped under more than just a single parent, yet not have the problem of a knotted mess of a general graph with cycles. You can still navigate a DAG easily, but there are multiple ways to get back to the root (because there can be more than one parent). A single DAG could in general have multiple roots but in practice may be better to just stick with one root, like a tree. If you understand single vs. multiple inheritance in OOP, then you know tree vs. DAG. I already answered this here.

The name tells you most of what you need to know of its definition: It's a graph where every edge only flows in one direction and once you crawl down an edge your path will never return you to the vertex you just left.
I can't speak to all the uses (Wikipedia helps there), but for me DAGs are extremely useful when determining dependencies between resources. My game engine for instance represents all loaded resources (materials, textures, shaders, plaintext, parsed json etc) as a single DAG. Example:
A material is N GL programs, that each need two shaders, and each shader needs a plaintext shader source. By representing these resources as a DAG, I can easily query the graph for existing resources to avoid duplicate loads. Say you want several materials to use vertex shaders with the same source code. It is wasteful to reload the source and recompile the shaders for every use when you can just establish a new edge to the existing resource. In this way you can also use the graph to determine if anything depends on a resource at all, and if not, delete it and free its memory, in fact this happens pretty much automatically.
By extension, DAGs are useful for expressing data processing pipelines. The acyclic nature means you can safely write contextual processing code that can follow pointers down the edges from a vertex without ever reencountering the same vertex. Visual programming languages like VVVV, Max MSP or Autodesk Maya's node-based interfaces all rely on DAGs.

A directed acyclic graph is useful when you want to represent...a directed acyclic graph! The canonical example is a family tree or genealogy.

Related

What is the best data structure to model a path through an undirected graph?

I'm working on modeling a path search and deduction board game, to practice some concepts I am learning in school. This is a first attempt at analyzing graphs for me, and I would appreciate some advice on what kind of data structure might be appropriate for what I am trying to do.
The game I am modeling presents as a series of ~200 interconnected nodes, as shown below. Given a known starting position for the adversary (node 84, for example, in the figure below) the goal is to identify possible locations of the adversary's hideout. The adversary's moves away from 84 are, naturally unknown.
Fig 1 - Illustrative Sub-Graph with Adversary Initial Position at Node 84
Initially, this leads to a situation like the one below. Given the adversary started at 84, he/she can only be at 66, 86 or 99 after taking their first turn. And so on.
Fig 2 - Possible Locations for Adversary after 1, 2 and 3 Turns (Based on Fig 1 Graph)
So far, I have modeled the board itself as an undirected graph - using an implementation of OCaml's ocamlgraph library. What I am now trying to do is to model the path taken by the adversary through the graph, so as to identify potential locations of the adversary after each turn.
While convenient for illustration purposes, the tree representation implied by the figure above has several drawbacks:
First, keeping track of all possible paths through the network is unnecessary (I care only about terminal location of the adversary's hideout, not the path taken) as well as burdensome: each node is connected to ~7 other nodes, on average. By the time we hit the end of the game's 15 turns, that's a lot of branches!
Second, I suspect pruning would become an issue as well. Indeed, part of the exercise here is to maximally exploit the limited information about the adversary's movements that revealed as the game goes on. This information either states that the adversary "has never been to node X" or "has previously visited node X."
Information of the first type (e.g. "adversary has never been to node 65") would lead me to want to prune the tree "from above" by traveling down through the branches and cutting off any branch that is invalidated by the revealed information.
Fig 3 - Pruning from the Top ("Adversary Has Never Been to Node 65")
Information of the second type (e.g. "Adversary has Visited Node 100") would, however, invite pruning "from below" to cut off any branch that was not consistent with the information.
Fig 4 - Pruning from the Bottom (e.g. "Adversary Has Visited Node 100")
It seems to me that a naive tree approach would be a messy proposition, so I thought I would ask for any suggestions or advice on the best data structure to use here, or how to better approach the problem.
It's really hard to give advice for your case, as any optimization should be preceded by profiling. It sounds like you need a bitset of some sort and/or incidence matrix. For BitSet you can either use Batteries implementation or just implement your own using OCaml arbitrary precision numbers with Zarith library. For incidence matrix, you can opt into trivial _ array array, use the Bigarray module, or, again, use Zarith and implement your own efficient representation using bitwise operations.
And if I were you, I would start with defining the abstraction that you need (i.e., the interface) then start with a drop in implementation, and later optimize based on the real input, by substituting implementations.

Traverse through a DAG-like structure to produce another DAG-like structure in Clojure

I have a DAG-like structure that is essentially a deeply-nested map. The maps in this structure can have common values, so the overall structure is not a tree but a direct acyclic graph. I'll refer to this structure as a DAG for brevity.
The nodes in this graph are of different but finite number of categories. Each category can have its own structure/keywords/number-of-children. There is one unique node that is the source of this DAG, meaning from this node we can reach all nodes in the DAG.
The task is to traverse through the DAG from the source node, and convert each node to another one or more nodes in a new constructed graph. I'll give an example for illustration.
The graph in the upper half is the input one. The lower half is the one after transformation. For simplicity, the transformation is only done on node A where it is split into node 1 and A1. The children of node A are also reallocated.
What I have tried (or in mind):
Write a function to convert one object for different types. Inside this function, recursively call itself to convert each of its children. This method suffers from the problem that data are immutable. The nodes in the transformed graph cannot be changed randomly to add children. To overcome this, I need to wrap every node in a ref/atom/agent.
Do a topological sort on the original graph. Then convert the nodes in the reversed order, i.e., bottom-up. This method requires a extra traverse of the graph but at least the data need not to be mutable. Regarding the topological sort algorithm, I'm considering DFS-based method as stated in the wiki page, which does not require the knowledge of the full graph nor a node's parents.
My question is:
Is there any other approaches you might consider, possibly more elegant/efficient/idiomatic?
I'm more in favour of the second method, is there any flaws or potential problems?
Thanks!
EDIT: On a second thought, a topological sorting is not necessary. The transformation can be done in the post-order traversal already.
This looks like a perfect application of Zippers. They have all the capabilities you described as needed and can produce the edited 'new' DAG. There are also a number of libraries that ease the search and replace capability using predicate threads.
I've used zippers when working with OWL ontologies defined in nested vector or map trees.
Another option would be to take a look at Walkers although I've found these a bit more tedious to use.

Graph data structure memory management

I would like to implement a custom graph data structure for my project and I had a question about proper memory management.
Essentially, the data structure will contain nodes that have two vectors: one for edges coming into the node and one for edges coming out of the node (no looped edges). The graph is connected. The graph will also contain one 'entry' node that will have no edges coming into it. An edge is simply a pointer to a node.
My question here is: What would be the best method of clearing up memory for this type of data structure? I understand how to do it if there was only one entry edge (at which point this structure degenerates to a n-ary tree), but I'm not sure what to do in the case where there are multiple nodes that have edges going into a single node. I can't just call delete from an arbitrary entry node because this will likely result in 'double free' bugs later on.
For example, suppose I had this subgraph:
C <-- B
^
|
A
If I were to call delete from node B, I would remove the memory allocated for C, but A would still have a pointer to it. So if I wanted to clear all the nodes A had connections to, I would get a double free error.
You will need to perform a search to figure out which node is still connected to the input edge, when you remove a component. If you end up with more than one connected group, you will need to figure out which one of these contains the entry node and remove all others.
No greedy (local) algorithm for this can exist, which can be shown by a simple thought experiment:
Let A, B be subgraphs connected only through the node n, which shall be removed. We are left with two unconnected subgraphs. There is no way of knowing (without a whole bunch of state per node) if we have just removed the only route to the entry node for A or B. And, it is necessary to figure that out, so that the appropriate choice of removing either A or B can be made.
Even if every node stored every single route to the entry node, it would mean you have to clean up all routes in all nodes whenever you remove a single node.
Solution Sketch
Let us talk about a graphical representation of what we need to do:
First, Color the node that is being deleted black. Then perform the following for every node we encounter:
For uncolored nodes:
If the node we came from is black, give this node a new color
If the node we came from is colored, give this node the same color
Travel through every outgoing edge
For colored nodes:
If the node we came from is black, just return
If the node we came from is the same color, just return
If the node we came from has a different color, merge the two colors (e.g. by remembering that green and blue are the same, or by painting every green node blue)
Travel through every outgoing edge
At the end we will know which connected components will exist after we delete the current node. All connected components (plus our original to be deleted node) which do not contain the entry node must be deleted (Note: This may delete every single node, if our to-be-deleted node was the entry node...)
Implementation
You will need a data structure like the following:
struct cleanup {
vector<set<node*>> colors;
node* to_be_deleted;
size_t entry_component;
};
The index into the vector of lists will be your "color". The "color black" will be represented by usage of to_be_deleted. Finally, the entry_component will contain the index of the color that has the entry node.
Now, the previous algorithm can be implemented. There are quite a few things to consider, and the implementation may end up being different, depending on what kind of support structures you already keep for other operations.
The answer depends on the complexity of the graph:
If the graph is a tree, each parent can own its children and delete them in its destructor.
If the graph is a directed acyclic graph, an easy and performant way to handle it is to do reference counting on the nodes.
If the graph can be cyclic, you are out of luck. You will need to keep track of each and every node in your graph, and then do garbage collection. Depending on your use case, you can either do the collection by
cleaning up everything when you are done with the complete graph, or by
repeatedly marking all connected nodes and cleaning up all the unreachable ones.
If there is any possibility to get away with option 1 or 2 (possibly tweaking the problem to ensure that the graph fulfills the constraint), you should do so; option 3 implies significant overheads in terms of code complexity and runtime.
There are a couple of ways. One way is to make your nodes know what other nodes have edges to it. So, if you delete C from B, C will need to remove the edge to it from A. So later when you remove/delete A, it won't try to delete C.
std::shared_ptr or some other type of reference counting may also work for you.
Here's a simple way to avoiding memory problems when implementing a graph: Don't use pointers to represent edges.
Instead, give each node a unique ID number (an incrementing integer counter will suffice). Keep a global unordered_map<int, shared_ptr<Node> > so that you can quickly look up any Node by its ID number. Then each Node can represent its edges as a set of integer Node IDs.
After you delete a Node (i.e. remove it from the global map of Nodes), it's possible that some other Nodes will now have "dangling edges", but that will be easy to detect and handle because when you go to look up the now-removed Node's ID in your global map, the lookup will fail. You can then gracefully respond by ignoring that edge, or by removing that edge its the source Node, or etc.
The advantages of doing it this way: The code remains very simple, and there is no need to worry about reference-cycles, memory leaks, or double-frees.
The disadvantages: It's a little bit less efficient to traverse the graph (since doing a map lookup takes more cycles than a simple pointer dereference) and (depending on what you are doing) the 'dangling edges' might require occasional cleanup sweeps (but those are easy enough to do... just iterate over the global map, and for each node, check each edge in its edge-set and remove the ones with IDs that aren't present in the global map)
Update: If you don't like doing a lot of unordered_map lookups, you could alternatively get very similar functionality by representing your edges using weak_ptr instead. A weak_ptr will automagically become NULL/invalid when the object it is pointing at goes away.

Pure functional tree with parent pointer

I know that RB tree with left and right child can be implemented in pure functional way without degrading log n performance. Can tree with parent pointer be implemented in logarithm time? Seems like cyclic reference child->parent and parent->child requires all tree to be cloned, thus linear time.
Fully persistent, purely functional data structures are tree shaped, but may utilize pointer sharing to become a directed acyclic graph. However, once you introduce a pointer cycle, you can't "change" part of that subgraph without copying that entire subgraph.
The solution is to add an indirection: You assign "identities" to values, which can be objects (like Clojure's atoms) or simple values used as look up keys (such as numbers or symbols). You can think of a immutable pointer as an implementation of IDeref which always returns the same object. A cyclic graph can be represented as an adjacency graph, where "deref-ing" a node by name is the same as looking it up in a the map of names to nodes.
For more information on representing fully persistent graphs, see Fully Persistent Graphs - Which One to Choose?.

How can one create cyclic (and immutable) data structures in Clojure without extra indirection?

I need to represent directed graphs in Clojure. I'd like to represent each node in the graph as an object (probably a record) that includes a field called :edges that is a collection of the nodes that are directly reachable from the current node. Hopefully it goes without saying, but I would like these graphs to be immutable.
I can construct directed acyclic graphs with this approach as long as I do a topological sort and build each graph "from the leaves up".
This approach doesn't work for cyclic graphs, however. The one workaround I can think of is to have a separate collection (probably a map or vector) of all of the edges for an entire graph. The :edges field in each node would then have the key (or index) into the graph's collection of edges. Adding this extra level of indirection works because I can create keys (or indexes) before the things they (will) refer to exist, but it feels like a kludge. Not only do I need to do an extra lookup whenever I want to visit a neighboring node, but I also have to pass around the global edges collection, which feels very clumsy.
I've heard that some Lisps have a way of creating cyclic lists without resorting to mutation functions. Is there a way to create immutable cyclic data structures in Clojure?
You can wrap each node in a ref to give it a stable handle to point at (and allow you to modify the reference which can start as nil). It is then possible to possible to build cyclic graphs that way. This does have "extra" indirection of course.
I don't think this is a very good idea though. Your second idea is a more common implementation. We built something like this to hold an RDF graph and it is possible to build it out of the core data structures and layer indices over the top of it without too much effort.
I've been playing with this the last few days.
I first tried making each node hold a set of refs to edges, and each edge hold a set of refs to the nodes. I set them equal to each other in a (dosync... (ref-set...)) type of operation. I didn't like this because changing one node requires a large amount of updates, and printing out the graph was a bit tricky. I had to override the print-method multimethod so the repl wouldn't stack overflow. Also any time I wanted to add an edge to an existing node, I had to extract the actual node from the graph first, then do all sorts of edge updates and that sort of thing to make sure everyone was holding on to the most recent version of the other thing. Also, because things were in a ref, determining whether something was connected to something else was a linear-time operation, which seemed inelegant. I didn't get very far before determining that actually performing any useful algorithms with this method would be difficult.
Then I tried another approach which is a variation of the matrix referred to elsewhere. The graph is a clojure map, where the keys are the nodes (not refs to nodes), and the values are another map in which the keys are the neighboring nodes and single value of each key is the edge to that node, represented either as a numerical value indicating the strength of the edge, or an edge structure which I defined elsewhere.
It looks like this, sort of, for 1->2, 1->3, 2->5, 5->2
(def graph {node-1 {node-2 edge12, node-3 edge13},
node-2 {node-5 edge25},
node-3 nil ;;no edge leaves from node 3
node-5 {node-2 edge52}) ;; nodes 2 and 5 have an undirected edge
To access the neighbors of node-1 you go (keys (graph node-1)) or call the function defined elsewhere (neighbors graph node-1), or you can say ((graph node-1) node-2) to get the edge from 1->2.
Several advantages:
Constant time lookup of a node in the graph and of a neighboring node, or return nil if it doesn't exist.
Simple and flexible edge definition. A directed edge exists implicitly when you add a neighbor to a node entry in the map, and its value (or a structure for more information) is provided explicitly, or nil.
You don't have to look up the existing node to do anything to it. It's immutable, so you can define it once before adding it to the graph and then you don't have to chase it around getting the latest version when things change. If a connection in the graph changes, you change the graph structure, not the nodes/edges themselves.
This combines the best features of a matrix representation (the graph topology is in the graph map itself not encoded in the nodes and edges, constant time lookup, and non-mutating nodes and edges), and the adjacency-list (each node "has" a list of its neighboring nodes, space efficient since you don't have any "blanks" like a canonical sparse matrix).
You can have multiples edges between nodes, and if you accidentally define an edge which already exists exactly, the map structure takes care of making sure you are not duplicating it.
Node and edge identity is kept by clojure. I don't have to come up with any sort of indexing scheme or common reference point. The keys and values of the maps are the things they represent, not a lookup elsewhere or ref. Your node structure can be all nils, and as long as it's unique, it can be represented in the graph.
The only big-ish disadvantage I see is that for any given operation (add, remove, any algorithm), you can't just pass it a starting node. You have to pass the whole graph map and a starting node, which is probably a fair price to pay for the simplicity of the whole thing. Another minor disadvantage (or maybe not) is that for an undirected edge you have to define the edge in each direction. This is actually okay because sometimes an edge has a different value for each direction and this scheme allows you to do that.
The only other thing I see here is that because an edge is implicit in the existence of a key-value pair in the map, you cannot define a hyperedge (ie one which connects more than 2 nodes). I don't think this is a big deal necessarily since most graph algorithms I've come across (all?) only deal with an edge that connects 2 nodes.
I ran into this challenge before and concluded that it isn't possible using truly immutable data structures in Clojure at present.
However you may find one or more of the following options acceptable:
Use deftype with ":unsynchronized-mutable" to create a mutable :edges field in each node that you change only once during construction. You can treat it as read-only from then on, with no extra indirection overhead. This approach will probably have the best performance but is a bit of a hack.
Use an atom to implement :edges. There is a bit of extra indirection, but I've personally found reading atoms to be extremely efficient.