Graphs

In your latest homework assignment, the spec mentions that a graphical representation is convenient for representing website links, but we should stop to consider that Graphs themselves are a data type that we can explore!

Graphs are about as fun as a data type can get (if you ask me).

A graph is an abstract data type consisting of some number of connected Nodes, with fewer restrictions than Trees.

In graphical terms, we typically call Nodes vertices and the connections between them edges, which can be either directed (the edge points from one vertex to another) or not.

Such a loose definition! Let's look at a graph...



Graph Properties


There are many, many graph-analytic properties that depend on their application, but we'll look at some of the most common now.

A graph is said to be directed if every edge has at least one directed arrowhead.

This might even mean bidirected arrows with arrows on either end!


A graph is said to be acyclic if it contains no cycles.

A cycle exists in a graph whenever it is possible, by simply following some path of directed edges, to visit a node twice.


A very common graph representation involves the case where both of these properties are combined:

A directed, acyclic graph (also known as a DAG) is a directed graph with no directed cycles.


Is the following graph a DAG?


Is the following graph a DAG?

In what scenarios do you think it's important that graphs are directed and acyclic?

One example is when temporal ordering is important between vertices (e.g., if A occurs before B in a planning graph, then it's important for there to be no cycle like A -> B -> C -> A).


Graph Use-Cases


Now that we've seen some simple graphs and their properties, let's think about when we might use them.

Suggest some possible uses for graphs.

  • Shortest distances between geographic points.

  • Mapping social network relationships

  • Depicting links between websites / Wikipedia Articles

  • Planning for autonomous agents

  • Many other AI applications (search, probabilistic reasoning, etc.)


Graphs vs. Trees


Seeing how a graph is just any collection of vertices and edges, let's think for a second...

Are all trees also graphs?

Yes! Trees are just graphs with some additional constraints (which we'll see in a moment).


Are all graphs also trees?

No! Graphs can have a variety of properties that violate the constraints of trees; let's see those in the following table.


Graphs vs. Trees

Property

Trees

Graphs

Notion of root

Yes

Not necessarily

One path from root to any node

Yes

Not necessarily

Can contain cycles

No

Yes

Nodes can have multiple inbound arrows

No

Yes

Have methods of traversing nodes

Yes

Yes*


I put a star next to the graph capacity for traversal simply because, unlike with, say, a binary tree, in which we have defined traversal methods like left-to-right postorder, graphs can be traversed starting at any node and following a variety of different traversal schemas.

Additionally, we're not guaranteed that we could traverse all elements of a graph, even if we have a systematic algorithm for doing so. Take the following graph, for example:


Starting at any one of these nodes, we're not guaranteed that we'll visit every other node in the graph! (in fact, in this case it's not possible)

Long story short, there are a variety of niceties we had with trees that are lost with the loose constraints of graphs... but we also gain some cool stuff too.


Graph Traversals


There are a variety of graph traversals for different applications, but for our introduction, let's just examine 1 or 2.

First, of course, we'll need some definitions.

A traversal algorithm provides a procedural means of visiting edge-connected vertices in a graph.

Typically, we use traversal algorithms to process vertices in a graph, or are used in search operations.

While running, traversals maintain a frontier of vertices it has yet to visit, where the frontier is some DS that facilitates a traversal order.

A depth-first traversal of a graph, starting at some vertex V, maintains a Stack frontier that proceeds as follows:

  create Stack frontier, Set visited
  push starting vertex onto frontier
  while frontier is not empty:
      pop vertex U on top of stack
      if U is in visited: continue
      visit U and add U to visited set
      for each child N of U:
          if N is not in visited:
              push N onto frontier

Note the "mark V as 'Visited'" step above; how would you go about accomplishing this efficiently and with clean programming practices?

Use a Set to track the collection of already-visited nodes! Note how it would be unwise to store some "visited" field on the Nodes themselves (if implemented as objects) since we would have to start / end each traversal by reseting that field (computationally wasteful).


Notice a couple key properties about traversals:

  • The "Depth" first part of this traversal lends itself to the fact that the frontier is a Stack (FILO) and so the most recently added children of any Node (which are "deeper" from the traversal's origin) are visited first.

  • Traversals are not necessarily unique; although we say that we must add all unvisited children of a node to the frontier, this does not specify a particular ordering of that addition!

Show a couple of depth-first traversals starting with node 1 in the following DAG:


Some viable depth-first traversals:

  • 1, 2, 4, 64, 16, 8, 32

  • 1, 16, 32, 8, 4, 64, 2


A breadth-first traversal of a graph, starting at some vertex V, maintains a Queue frontier that proceeds as follows:

  create Queue frontier, Set visited
  push starting vertex onto frontier
  while frontier is not empty:
      pop vertex U at front of Queue
      if U is in visited: continue
      visit U and add U to visited set
      for each neighbor N of U:
          if N is not in visited:
              push N onto frontier

This is useful for tracing dependencies where we have to resolve the parents before the children!

What are some viable breadth-first traversals of the following graph?

A viable breadth-first traversal: 1, 2, 16, 8, 32, 4, 64

Why do you think this traversal method is called "breadth first?"

It visits vertices level by level (breadth) spanning outward from the start vertex.


Representing Graphs

OK cool, but how do we store these graphs? What data structures can we use to represent them?

1) The most obvious approach is to use some custom object class where we encode Nodes (vertexes) and Edges (with references to other Nodes) and can maintain data in either inner-class.

Benefits: Useful when the graph modeled is directed, sparse, and there is extra data to be stored in each Node (e.g., city population where Nodes are used to model cities on a geographic map).

Problems: this approach might be overkill if we have only undirected edges or lots of them, or if we're *only* using the Nodes to store edges (in which case, the Node objects may be wasting memory / allocation time)!

Some alternative representations are:


2) An adjacency matrix is a 2D array with a row / column for every node, and an indication at index [i][j] of whether node i is connected to node j.

Usually we indicate adjacencies with the number 1 to represent an edge going from node i to node j, and a 0 if there is no edge between them in an undirected graph.

However, we may also use an adjacency matrix when there are both directed and undirected edges by coding, e.g., 2 to represent an undirected connection, 1 to represent an edge from node i to j, -1 to represent an edge from node j to i.

For example, an adjacency matrix denoting the graph (where each matrix index i corresponds to the node with value 2^i; so, for example, [0][1] corresponds to the edge from the node with value 1 to the node with value 2) above (with undirected edges instead) might look like:

       1  2  4  8  16 32 64
  1   [0, 1, 0, 0, 1, 0, 0]
  2   [0, 0, 1, 0, 0, 0, 0]
  4   [0, 0, 0, 0, 0, 0, 1]
  8   [0, 0, 1, 0, 0, 0, 1]
  16  [0, 0, 0, 1, 0, 1, 0]
  32  [0, 0, 0, 0, 0, 0, 0]
  64  [0, 0, 0, 0, 0, 0, 0]

Benefits: Useful for large, densely connected graphs with potentially directed and undirected graphs.

Problem: this matrix can have a lot of 0s and waste space when there aren't many edges in our graph. Also, in general, how to map the nodes to keys, or more complex data to store in each Node?


3) An adjacency map stores each edge connection as a Map of Node keys to Lists / Sets of keys to which that Node is connected.

For example, an adjacency map with integer keys (representing the ints at each node) for the above graph might look like:

  keys  lists
   1    [] -> 2 -> 16
   2    [] -> 4
   4    [] -> 64
   8    [] -> 4 -> 64
  16    [] -> 8 -> 32
  32    []
  64    []

Solves the index mapping problem of adjacency matrices while being more memory efficient for more sparse graphs.

Problem: this adjacency list can be wasteful when our graph has a lot of edges.



Graph Applications

We won't have time to examine this section during the present semester, but will see it come back in Algorithms!

Graphs are popularly used to represent geographical data and distances between points (physical or otherwise, in the cases of Networking applications).

For this reason, one of the most ubiquitous graph algorithms helps us compute efficient distances between nodes in a graph.

Given a graph with vertexes = geographical locations, and edges connecting adjacent locations with some distances between them, Dijkstra's algorithm tells us the least distance we would have to travel from a given vertex S to any other vertex in the graph.


The following example graph lists locations A through E that are separated by distances indicated along the edges.

What is the shorted distance from A to E? What path does it take?

A -> C -> E, with a total cost of 5.


Goal: We'd like to have a way of automatically computing the shortest distance from a node to every other node in the network. What are some strategies you could consider to accomplish this?

Main gist: we want to calculate the minimum distance to get from the start to every vertex in the network in a modified breadth-first fashion so that we always know the shortest distance to vertex S so that we can then compute the shortest distance from the start to any neighbor of S!


Dijkstra's Algorithm


Dijkstra's (one of the most ubiquitous algorithms and, in my opinion, most fun to say) algorithm does precisely the above.

Dijkstra's algorithm operates by maintaining several data structures:

  • dist: for each node, tracks the optimal distance from the start node. Each element in this vector / map starts out with value infinity, except for the start node, which starts out with value 0.

  • done: for each node, tracks whether or not we have already computed the optimal travel distance from it to its neighbors. Implemented as an initially empty Set.

  • prev: for each node, tracks the node *before* this one along the shortest path from the start node.

  function Dijkstra(graph, start):
      initialize dist, done, prev (as stated above)
      while done not all true: // Still paths to explore
          u = min distance, not done node from start // track via priority queue
          add u to done
          for each node v in undone neighbors(u)
              d = distance(u, v) // distance between u and v from graph edge
              oldBest = dist[v]
              candidate = d + dist[u]
              if candidate < oldBest
                  dist[v] = candidate // Update best distance
                  prev[v] = u // Update best path
      return (dist, prev)

Let's trace this algorithm through our example now (sorry, forgot to illustrate the prev list below, left as an exercise!).

With the completed dist, prev structures returned from above, how do we find the best path from the source to any destination?

Just start at the destination's key / index in prev and walk backwards!

There are many variants of Dijkstra's algorithm, some suited for different purposes than a general shortest-distance computer, but the gist is the same of each: a procedural way of finding shortest distances between nodes in a graph.

And that's Djiksta's algorithm!


There are variants of the complexity based on the particular implementation, but generally, most vertexes are well connected, and so we have, for |E| = number of edges in the graph and |V| = number of vertexes, O(|E| + |V|^2), which usually can be proven to be O(|V|^2).



  PDF / Print