COMP2521 2,485 words·13 min read

14. Graph Problems

Turning Traversal into an Answer#

Recall 13. Graph Traversal: we can explore exactly the vertices reachable from a source. Many graph problems add one piece of state to that mechanism. To identify components, label each reached vertex. To detect an undirected cycle, remember the edge we came from. To try a Hamiltonian route, remember the current candidate path and undo it when a choice fails.

This note uses simple undirected graphs unless stated otherwise. Directed cycles need a different test, explained in 15. Directed Graph Algorithms and Shortest Paths. Let VV count vertices and EE count edges. C functions here share the matrix Graph, checkVertex and allocation helper from 12. Graphs and Representations. They belong inside that implementation file so the representation is accessible.

Checking for an Undirected Cycle#

Why “Visited Neighbour” Needs Qualification#

Suppose DFS follows 0−10-1. At vertex 1 it sees neighbour 0, which is already visited. That is expected: undirected edges appear in both endpoint lists or matrix cells. Traversing back along the same edge does not establish a cycle.

Pass the parent, the vertex from which this call was reached. Ignore the edge back to that parent. Any other edge to an already visited vertex closes a cycle: the DFS discovery tree already contains a route joining those vertices, and the extra edge closes it.

A second issue is disconnectedness. Starting only at 0 may check an acyclic component while a different component contains a triangle.

An acyclic component on the left and a triangle in a separate component

In the image, DFS from 0 reaches only {0,1,4}\{0,1,4\}. The cycle 2−5−6−22-5-6-2 remains untouched. The complete solution restarts DFS from each unvisited vertex while retaining the same visited array.

C
static bool cycleRec(Graph g, int v, int parent, bool seen[]) {
    seen[v] = true;
    for (int w = 0; w < g->nV; w++) {
        if (!g->edges[v][w] || w == parent) continue;
        if (seen[w]) return true;
        if (cycleRec(g, w, v, seen)) return true;
    }
    return false;
}

bool hasCycle(Graph g) {
    bool *seen = checkedCalloc((size_t)g->nV, sizeof *seen);
    bool result = false;
    for (int v = 0; v < g->nV && !result; v++)
        if (!seen[v]) result = cycleRec(g, v, -1, seen);
    free(seen);
    return result;
}

The source has parent -1 because it has no incoming discovery edge. A successful recursive call returns true through its callers. If every component finishes without finding an extra visited-neighbour edge, the graph is a forest.

For adjacency lists, this adds constant work to ordinary DFS and costs O(V+E)O(V+E) worst-case. The matrix code scans full rows and costs O(V2)O(V^2) worst-case. The visited array and recursion depth need O(V)O(V) auxiliary space.

This parent-vertex test assumes no parallel edges: two distinct parallel edges could form a multigraph cycle, yet both point to the parent and would be skipped. Such graphs require remembering parent edge identity, but the course excludes multigraphs.

Practice. Trace the triangle 0−10-1, 1−21-2, 2−02-0, starting at 0 with ascending neighbours. Which check establishes the cycle?

Note

- Answer
DFS marks 0, follows 1, then follows 2. At 2, neighbour 0 is already visited and is not its parent 1. The discovery route 0−1−20-1-2 plus edge 2−02-0 closes the triangle. Neighbour 1 would be ignored as the parent edge.

Connected Components and Cached Reachability#

A connected component is a maximally connected group of vertices. Number the components 0, 1, 2, … and store componentOf[v] for each vertex. Initialise it to -1, meaning “unassigned”. Starting a traversal from an unassigned vertex labels all vertices reachable from it with the current component number. Increase the number only after that traversal finishes.

componentOf maps nine vertices into components numbered 0, 1 and 2

Read the array by vertex index: componentOf[4]=0, while componentOf[5]=1. Labels are identifiers, not component sizes. The vertices need not appear contiguously: component 0 contains 0, 1 and 4.

C
static void labelRec(Graph g, int v, int label, int componentOf[]) {
    componentOf[v] = label;
    for (int w = 0; w < g->nV; w++)
        if (g->edges[v][w] && componentOf[w] == -1)
            labelRec(g, w, label, componentOf);
}

// Caller owns componentOf, with g->nV slots. Returns component count.
int components(Graph g, int componentOf[]) {
    for (int v = 0; v < g->nV; v++) componentOf[v] = -1;
    int count = 0;
    for (int v = 0; v < g->nV; v++) {
        if (componentOf[v] == -1) {
            labelRec(g, v, count, componentOf);
            count++;
        }
    }
    return count;
}

Each run labels one whole component, because DFS finds precisely the reachable vertices. Different runs cannot overlap, because previously labelled vertices are skipped. This proves both completeness and separation of the resulting groups. Cost is O(V+E)O(V+E) for adjacency lists or O(V2)O(V^2) for this matrix implementation, with O(V)O(V) result and recursion space.

Once labels are computed, undirected reachability can be answered in O(1)O(1): a route from vv to ww exists exactly when componentOf[v] == componentOf[w]. Cache the count and labels inside the graph wrapper when queries are frequent.

The cache describes a particular graph state. Inserting an edge between different components merges them; removing a bridge, an edge with no alternative endpoint route, splits a component. Adding an edge within a component or removing an edge on a cycle does not change the component partition. A simple implementation invalidates the cache after an edge update and recomputes on the next query. A constant-time cached query does not make updates constant-time.

Practice. Two components are connected by a newly inserted edge. Why can the component count decrease by exactly one, rather than two?

Note

- Answer
The new edge merges its two endpoint components into one. All other components are unaffected. Replacing two groups with one changes CC to C−1C-1. If its endpoints were already in the same component, the count stays CC.

Note

Checkpoint
Undirected cycle detection skips the parent edge and checks every component. Component labelling is one shared traversal over the graph, followed by constant-time label comparisons. Cached answers must be recomputed or maintained after graph changes.

Hamiltonian Paths and Circuits#

A Hamiltonian path visits every vertex exactly once. A Hamiltonian circuit also has an edge from its last vertex back to its first. In the circuit the start is repeated only to close the route; it is counted as one vertex visit.

A Hamiltonian path and circuit in the same five-vertex graph

The orange route is 0−2−1−3−40-2-1-3-4: it includes all five vertices. The green circuit is 0−1−3−4−2−00-1-3-4-2-0. Neither is required to use every edge. This is why vertex coverage, rather than just ordinary reachability, is the hard constraint.

Backtracking Through Candidate Paths#

Ordinary DFS permanently marks a vertex once its reachable region is explored. Hamiltonian search needs a different rule: onPath[v] means v is in the current candidate path. If that candidate fails, clear its mark so another route can use it.

At a call for vertex vv, mark vv and decrease the number of vertices left. If none remain, a Hamiltonian path has been found. Otherwise try each adjacent vertex not currently on the path. If all choices fail, undo the mark and return false.

Consider edges 0−10-1, 0−20-2, 1−21-2, 1−31-3, 2−42-4, 3−43-4. Starting at 0, try 0−1−2−4−30-1-2-4-3: it succeeds. But if the edge 3−43-4 is absent, the branch 0−1−2−40-1-2-4 stops early and must release 4, then 2, before trying 0−1−30-1-3. Clearing marks is the operation that restores the caller's candidate state; it is not forgetting explored reachability.

Here is the lecture algorithm with explicit cleanup on both successful and unsuccessful returns. Cleanup makes repeated calls safe and lets a test inspect that the temporary array has been restored.

C
static bool hamRec(Graph g, int v, bool onPath[], int left,
                   int start, bool circuit) {
    onPath[v] = true;
    left--;
    if (left == 0) {
        bool ok = !circuit || g->edges[v][start];
        onPath[v] = false;
        return ok;
    }
    for (int w = 0; w < g->nV; w++) {
        if (g->edges[v][w] && !onPath[w] &&
            hamRec(g, w, onPath, left, start, circuit)) {
            onPath[v] = false;
            return true;
        }
    }
    onPath[v] = false;
    return false;
}

bool hasHamiltonianPath(Graph g) {
    bool *onPath = checkedCalloc((size_t)g->nV, sizeof *onPath);
    bool found = false;
    for (int start = 0; start < g->nV && !found; start++)
        found = hamRec(g, start, onPath, g->nV, start, false);
    free(onPath);
    return found;
}

bool hasHamiltonianCircuit(Graph g) {
    if (g->nV < 3) return false; // simple undirected cycle convention
    bool *onPath = checkedCalloc((size_t)g->nV, sizeof *onPath);
    bool found = hamRec(g, 0, onPath, g->nV, 0, true);
    free(onPath);
    return found;
}

For paths, we try each starting vertex because a valid route need not start at 0. For circuits, fixing start 0 loses nothing: any spanning circuit passes through 0 and can be described starting there. The base case checks the closing edge. Having included every vertex is not enough to establish a circuit.

Correctness follows from the candidate invariant: current marks identify a simple route from the chosen start to vv. We extend it only along real edges to unused vertices. The successful base case covers exactly VV vertices; exhaustive alternatives ensure that a valid route, if one exists, is eventually tried. Undoing marks preserves the invariant when returning to try a different choice.

Why the Search Can Be Very Expensive#

There are V!V! possible vertex orderings for a path. Prefixes branch into many alternatives: first VV choices, then V−1V-1, then V−2V-2, and so on. Edges prune illegal choices, and the search stops on a success, but graphs with no solution can force extensive exploration.

The slides describe the factorial search as O(V!)O(V!), counting possible candidate orderings. A literal implementation also checks neighbours and eligibility. A conservative bound for the matrix code above is O(V⋅V!)O(V\cdot V!): there are O(V!)O(V!) prefix calls and each can scan VV matrix cells. This polynomial factor does not change the central contrast with polynomial-time traversal. Auxiliary space is O(V)O(V) for marks and recursion depth, excluding the graph: exponential work does not mean all candidates are stored simultaneously.

The Hamiltonian existence decision problem is NP-complete; in particular it is NP-hard, as the slides state. This does not prove that no polynomial-time algorithm could ever exist. It says none is known and a polynomial solution would solve a wider class of difficult decision problems. Brute force remains useful on small instances or heavily constrained graphs.

Practice. A star has centre 0 and leaves 1, 2, 3. Why does it have no Hamiltonian path, even though it is connected?

Note

- Answer
To move between any two leaves we must use the centre. A simple route can use the centre only once, allowing at most two leaves, one before and one after it. Reaching all three leaves would repeat 0. Connectedness guarantees separate routes between pairs, not one route visiting every vertex exactly once.

Eulerian Paths and Circuits#

An Eulerian path uses every edge exactly once. An Eulerian circuit also ends where it starts. Vertices may repeat. The lecture uses “path” here for what many graph-theory texts call an Eulerian trail, since edges are unique but vertices need not be.

An Eulerian path repeats vertex 0 while using each edge once; an Eulerian circuit closes at 4

The left route 4−2−0−1−3−04-2-0-1-3-0 repeats vertex 0 but uses every edge once. Its endpoints are 4 and 0. On the right, 4−2−0−1−3−44-2-0-1-3-4 closes into a circuit. Count edges, rather than applying the Hamiltonian no-repeated-vertex condition.

Seven Bridges of Konigsberg model: land areas are vertices and bridges are edges

In the bridge model each land area becomes a vertex and each bridge an edge. Several bridges connect the same land areas, so the historical example is a multigraph even though the course's implementations mainly use simple graphs. The degree argument still explains the obstruction.

The Degree Test and Its Reason#

Whenever a route enters an internal vertex along one unused edge, it must leave along another. Those edges pair up. In a circuit, every visit pairs an arrival with a departure, so every degree must be even. In an open Eulerian route, the start has one unpaired departure and the end one unpaired arrival; exactly those two vertices have odd degree.

The precise undirected tests are:

  • An Eulerian path exists iff there are zero or two odd-degree vertices, and all vertices of non-zero degree lie in one connected component.
  • An Eulerian circuit exists iff every degree is even, and all vertices of non-zero degree lie in one connected component.

These conditions are also sufficient. Starting at an odd vertex when there are two, or any non-isolated vertex otherwise, unused edges can form a trail without stranding it at an inappropriate internal vertex: the parity pairs arrivals and departures. If unused edges remain, connectivity supplies a vertex on the current trail from which another closed trail can be made and spliced in. Repeating uses all edges. This is the idea behind an efficient constructive Euler traversal; the slides require the existence test rather than its full construction.

Why Isolated Vertices Do Not Matter#

A graph with an isolated vertex can still have an Eulerian path covering every edge

Vertex 4 has no edges to cover. Excluding it from the connectivity requirement allows the remaining component's Eulerian route. But two disconnected triangles do not have a single Eulerian route: every degree is even, yet no route can jump between their edge-containing components.

For a graph with no edges, the lecture's existence test returns true for both conditions: there is nothing to traverse. This uses the empty-edge-route convention. If an application demands a nonempty route, state that extra requirement explicitly.

C
static void reachRec(Graph g, int v, bool seen[]) {
    seen[v] = true;
    for (int w = 0; w < g->nV; w++)
        if (g->edges[v][w] && !seen[w]) reachRec(g, w, seen);
}

// circuit=false checks existence of an Eulerian path;
// circuit=true checks existence of an Eulerian circuit.
bool hasEulerian(Graph g, bool circuit) {
    int *degree = checkedCalloc((size_t)g->nV, sizeof *degree);
    bool *seen = checkedCalloc((size_t)g->nV, sizeof *seen);
    int odd = 0, start = -1;
    for (int v = 0; v < g->nV; v++) {
        for (int w = 0; w < g->nV; w++)
            degree[v] += g->edges[v][w] ? 1 : 0;
        if (degree[v] % 2 != 0) odd++;
        if (degree[v] > 0 && start == -1) start = v;
    }
    if (start != -1) reachRec(g, start, seen);
    bool connected = true;
    for (int v = 0; v < g->nV; v++)
        if (degree[v] > 0 && !seen[v]) connected = false;
    bool parity = circuit ? odd == 0 : (odd == 0 || odd == 2);
    free(seen); free(degree);
    return connected && parity;
}

Degree calculation over lists examines 2E2E entries. Connectivity is one DFS; checking its result is O(V)O(V). Thus the adjacency-list algorithm costs O(V+E)O(V+E) time and O(V)O(V) auxiliary space. The matrix code costs O(V2)O(V^2) because both degree calculation and DFS scan rows. Testing existence is much cheaper than Hamiltonian exhaustive search.

Practice. Do two disjoint cycles pass the degree test? Do they have an Eulerian circuit? What changes if one component is just an isolated vertex?

Note

- Answer
Two cycles have only even degrees, so parity alone passes. They fail the non-zero-degree connectivity condition, hence have no single Eulerian circuit covering both cycles. A cycle plus an isolated vertex passes both conditions: the isolated vertex contributes no edge requiring a visit.

More exam-style practice#

Work each prompt before opening its answer. State the invariant or cost assumption that makes your reasoning valid.

Question 1. An undirected graph has exactly two odd-degree vertices and all non-isolated vertices connected. Does it have an Eulerian trail, an Eulerian circuit, both or neither?

Note

- Answer
It has an Eulerian trail beginning at one odd vertex and ending at the other, but no Eulerian circuit. In a circuit every visit uses an entering and leaving edge, so every vertex must have even degree.

Question 2. A DFS cycle checker sees an already visited neighbour in an undirected graph. Why must it compare that neighbour with the current vertex’s parent?

Note

- Answer
Each tree edge appears in both adjacency lists. Seeing the edge back to the parent is expected and does not form a cycle. A visited neighbour other than the parent reveals a second route and therefore a cycle.

Question 3. A graph has 10 vertices but only 3 reachable from s. Compare the result of a connected-components query with a reachability query from s.

Note

- Answer
Reachability from s returns only those 3 vertices. Connected-components computation labels every vertex, possibly across several additional components. Reusing one DFS result as if it described all graph connectivity would be incorrect.

Note

Checkpoint
Hamiltonian means each vertex once and generally requires backtracking. Eulerian means each edge once and permits repeated vertices. For undirected Eulerian existence, pair degrees and check connectivity only among vertices with incident edges.

Tractable and Intractable Problems#

A polynomial-time algorithm has a bound such as V2V^2, VEVE, or (V+E)3(V+E)^3, with a fixed exponent. The lecture calls a problem tractable when a polynomial-time solution is known, and intractable when no efficient polynomial-time solution is known in the general case. Large polynomial costs can still be impractical; this distinction concerns scaling rather than actual stopwatch speed.

Small changes to a question can radically change its difficulty:

Problem with stated meaning General behaviour
Fewest-edge path BFS in polynomial time
Cheapest path with suitable weight assumptions Polynomial-time shortest-path algorithms
Longest simple path Generally intractable; repeating vertices would change the problem
Does a non-trivial clique exist? Test triples for a triangle in polynomial time
Largest clique Generally intractable
Two-colour vertices with different colours at adjacent endpoints BFS/DFS parity colouring in polynomial time
Three-colour such a graph Generally intractable
Eulerian route existence Degree and connectivity checks
Hamiltonian route existence No known polynomial-time algorithm for arbitrary graphs

For two-colouring, give the start one colour and every newly discovered neighbour the opposite colour. If an edge joins equal colours, no consistent two-colouring exists in that component; an odd cycle is the obstruction. Repeat across components. This is another example of a small amount of extra state turning traversal into a decision algorithm.

Graph isomorphism asks whether two graphs become identical after renaming their vertices. Equal vertex and edge counts and matching degree patterns are necessary but not sufficient. The lecture presents it as a bonus problem, rather than assigning a standard traversal solution. Avoid assuming that changing labels changes the underlying structure.

Practice. Why does “does the graph contain a clique?” need a size convention?

Note

- Answer
A single vertex is already a clique of size 1, and any edge is a clique of size 2. The lecture means a non-trivial clique, size at least 3, for which finding a triangle suffices. Asking for a clique of arbitrary input size, or a maximum clique, is a different and harder problem.