โ† run

go-03-detect-cycle

1.000
1/1 testsยท algorithms
Challenge ยท difficulty 3/5
# Detect cycle in a directed graph

Implement **`solution.go`** in `package challenge` exporting:

```go
func HasCycle(graph map[string][]string) bool
```

The graph is given as an adjacency map: each key is a node, and its value is the slice of
nodes it has directed edges to. Return `true` if the directed graph contains **any cycle**,
and `false` otherwise.

Rules:

- A **self-loop** (`"a" -> "a"`) counts as a cycle.
- A neighbor that does not appear as a key in the map is a valid node with no outgoing edges.
- An empty map (or `nil`) has no cycle โ†’ `false`.
- Duplicate edges are allowed and must not break detection.

Examples:

- `HasCycle(map[string][]string{"a": {"b"}, "b": {"c"}, "c": {}})` โ†’ `false` (a DAG)
- `HasCycle(map[string][]string{"a": {"a"}})` โ†’ `true` (self-loop)
- `HasCycle(map[string][]string{"a": {"b"}, "b": {"c"}, "c": {"a"}})` โ†’ `true` (3-node cycle)
- `HasCycle(map[string][]string{})` โ†’ `false`
tests/solution_test.go
package challenge

import "testing"

func TestHasCycle(t *testing.T) {
	cases := []struct {
		name  string
		graph map[string][]string
		want  bool
	}{
		{
			name:  "nil graph",
			graph: nil,
			want:  false,
		},
		{
			name:  "empty graph",
			graph: map[string][]string{},
			want:  false,
		},
		{
			name:  "single node no edges",
			graph: map[string][]string{"a": {}},
			want:  false,
		},
		{
			name:  "simple DAG",
			graph: map[string][]string{"a": {"b"}, "b": {"c"}, "c": {}},
			want:  false,
		},
		{
			name: "diamond DAG (shared descendant, no cycle)",
			graph: map[string][]string{
				"a": {"b", "c"},
				"b": {"d"},
				"c": {"d"},
				"d": {},
			},
			want: false,
		},
		{
			name:  "self-loop",
			graph: map[string][]string{"a": {"a"}},
			want:  true,
		},
		{
			name:  "two node cycle",
			graph: map[string][]string{"a": {"b"}, "b": {"a"}},
			want:  true,
		},
		{
			name:  "three node cycle",
			graph: map[string][]string{"a": {"b"}, "b": {"c"}, "c": {"a"}},
			want:  true,
		},
		{
			name: "cycle reachable only from one component",
			graph: map[string][]string{
				"x": {"y"},
				"y": {},
				"a": {"b"},
				"b": {"c"},
				"c": {"b"},
			},
			want: true,
		},
		{
			name: "neighbor not a key (implicit leaf), no cycle",
			graph: map[string][]string{
				"a": {"b"},
				"b": {"c"}, // c is not a key
			},
			want: false,
		},
		{
			name: "duplicate edges, no cycle",
			graph: map[string][]string{
				"a": {"b", "b", "b"},
				"b": {},
			},
			want: false,
		},
		{
			name: "duplicate edges forming cycle",
			graph: map[string][]string{
				"a": {"b", "b"},
				"b": {"a", "a"},
			},
			want: true,
		},
		{
			name: "long chain no cycle",
			graph: map[string][]string{
				"a": {"b"}, "b": {"c"}, "c": {"d"}, "d": {"e"}, "e": {"f"}, "f": {},
			},
			want: false,
		},
		{
			name: "cycle deep in chain",
			graph: map[string][]string{
				"a": {"b"}, "b": {"c"}, "c": {"d"}, "d": {"e"}, "e": {"c"},
			},
			want: true,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := HasCycle(c.graph); got != c.want {
				t.Errorf("HasCycle(%v) = %v, want %v", c.graph, got, c.want)
			}
		})
	}
}
System prompt
You are an expert programmer. Solve the task exactly as specified. Output your solution as fenced code blocks using the required file name(s) and the exact function/type signatures requested. Prefer correctness; do not include prose outside code unless asked.
Proposed solution
```go
package challenge

func HasCycle(graph map[string][]string) bool {
	if graph == nil || len(graph) == 0 {
		return false
	}

	// States: 0 = unvisited, 1 = visiting (in current path), 2 = visited (done)
	state := make(map[string]int)

	// Initialize all nodes present as keys
	for node := range graph {
		state[node] = 0
	}

	var dfs func(string) bool
	dfs = func(node string) bool {
		// If currently visiting this node, we found a cycle
		if state[node] == 1 {
			return true
		}
		// If already fully processed, no cycle via this path
		if state[node] == 2 {
			return false
		}

		// Mark as currently visiting
		state[node] = 1

		// Explore neighbors
		for _, neighbor := range graph[node] {
			// Ensure neighbor is tracked (may not be a key in graph)
			if _, exists := state[neighbor]; !exists {
				state[neighbor] = 0
			}
			if dfs(neighbor) {
				return true
			}
		}

		// Mark as fully visited
		state[node] = 2
		return false
	}

	// Run DFS from each unvisited node
	for node := range graph {
		if state[node] == 0 {
			if dfs(node) {
				return true
			}
		}
	}

	return false
}
```

Test output (stdout)

ackage":"challenge","Test":"TestHasCycle/neighbor_not_a_key_(implicit_leaf),_no_cycle"}
{"Time":"2026-07-21T16:18:28.216484912+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/neighbor_not_a_key_(implicit_leaf),_no_cycle","Output":"=== RUN   TestHasCycle/neighbor_not_a_key_(implicit_leaf),_no_cycle\n"}
{"Time":"2026-07-21T16:18:28.216486612+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/neighbor_not_a_key_(implicit_leaf),_no_cycle","Output":"--- PASS: TestHasCycle/neighbor_not_a_key_(implicit_leaf),_no_cycle (0.00s)\n"}
{"Time":"2026-07-21T16:18:28.216487533+02:00","Action":"pass","Package":"challenge","Test":"TestHasCycle/neighbor_not_a_key_(implicit_leaf),_no_cycle","Elapsed":0}
{"Time":"2026-07-21T16:18:28.216488863+02:00","Action":"run","Package":"challenge","Test":"TestHasCycle/duplicate_edges,_no_cycle"}
{"Time":"2026-07-21T16:18:28.216489603+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/duplicate_edges,_no_cycle","Output":"=== RUN   TestHasCycle/duplicate_edges,_no_cycle\n"}
{"Time":"2026-07-21T16:18:28.216491603+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/duplicate_edges,_no_cycle","Output":"--- PASS: TestHasCycle/duplicate_edges,_no_cycle (0.00s)\n"}
{"Time":"2026-07-21T16:18:28.216494313+02:00","Action":"pass","Package":"challenge","Test":"TestHasCycle/duplicate_edges,_no_cycle","Elapsed":0}
{"Time":"2026-07-21T16:18:28.216495423+02:00","Action":"run","Package":"challenge","Test":"TestHasCycle/duplicate_edges_forming_cycle"}
{"Time":"2026-07-21T16:18:28.216496293+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/duplicate_edges_forming_cycle","Output":"=== RUN   TestHasCycle/duplicate_edges_forming_cycle\n"}
{"Time":"2026-07-21T16:18:28.216497653+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/duplicate_edges_forming_cycle","Output":"--- PASS: TestHasCycle/duplicate_edges_forming_cycle (0.00s)\n"}
{"Time":"2026-07-21T16:18:28.216498613+02:00","Action":"pass","Package":"challenge","Test":"TestHasCycle/duplicate_edges_forming_cycle","Elapsed":0}
{"Time":"2026-07-21T16:18:28.216499473+02:00","Action":"run","Package":"challenge","Test":"TestHasCycle/long_chain_no_cycle"}
{"Time":"2026-07-21T16:18:28.216500163+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/long_chain_no_cycle","Output":"=== RUN   TestHasCycle/long_chain_no_cycle\n"}
{"Time":"2026-07-21T16:18:28.216501123+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/long_chain_no_cycle","Output":"--- PASS: TestHasCycle/long_chain_no_cycle (0.00s)\n"}
{"Time":"2026-07-21T16:18:28.216502893+02:00","Action":"pass","Package":"challenge","Test":"TestHasCycle/long_chain_no_cycle","Elapsed":0}
{"Time":"2026-07-21T16:18:28.216503803+02:00","Action":"run","Package":"challenge","Test":"TestHasCycle/cycle_deep_in_chain"}
{"Time":"2026-07-21T16:18:28.216504523+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/cycle_deep_in_chain","Output":"=== RUN   TestHasCycle/cycle_deep_in_chain\n"}
{"Time":"2026-07-21T16:18:28.216505593+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle/cycle_deep_in_chain","Output":"--- PASS: TestHasCycle/cycle_deep_in_chain (0.00s)\n"}
{"Time":"2026-07-21T16:18:28.216506623+02:00","Action":"pass","Package":"challenge","Test":"TestHasCycle/cycle_deep_in_chain","Elapsed":0}
{"Time":"2026-07-21T16:18:28.216507923+02:00","Action":"output","Package":"challenge","Test":"TestHasCycle","Output":"--- PASS: TestHasCycle (0.00s)\n"}
{"Time":"2026-07-21T16:18:28.216509173+02:00","Action":"pass","Package":"challenge","Test":"TestHasCycle","Elapsed":0}
{"Time":"2026-07-21T16:18:28.216510423+02:00","Action":"output","Package":"challenge","Output":"PASS\n"}
{"Time":"2026-07-21T16:18:28.216716923+02:00","Action":"output","Package":"challenge","Output":"ok  \tchallenge\t0.001s\n"}
{"Time":"2026-07-21T16:18:28.216737343+02:00","Action":"pass","Package":"challenge","Elapsed":0.001}
Reasoning (chain-of-thought)
The user wants a Go function `HasCycle` that detects cycles in a directed graph represented as an adjacency map.

Key requirements:
1. Input: `graph map[string][]string`
2. Output: `bool` (true if cycle exists, false otherwise)
3. Self-loops count as cycles
4. Nodes referenced as neighbors but not as keys are valid nodes with no outgoing edges
5. Empty/nil map โ†’ false
6. Duplicate edges should not break detection

Algorithm:
I'll use DFS with three states for each node:
- 0: unvisited
- 1: visiting (currently in the recursion stack)
- 2: visited (fully processed)

If we encounter a node with state 1 during DFS, we've found a cycle.

Steps:
1. Handle nil/empty graph
2. Create a state map for all nodes
3. For each unvisited node, start DFS
4. In DFS:
   - Mark current node as "visiting"
   - For each neighbor:
     - If neighbor is "visiting", return true (cycle found)
     - If neighbor is "unvisited", recursively check
   - Mark current node as "visited"
   - Return false

Let me implement this:

```go
package challenge

func HasCycle(graph map[string][]string) bool {
    if graph == nil || len(graph) == 0 {
        return false
    }

    // States: 0 = unvisited, 1 = visiting, 2 = visited
    state := make(map[string]int)

    // Initialize all nodes in the graph as unvisited
    for node := range graph {
        state[node] = 0
    }

    var dfs func(string) bool
    dfs = func(node string) bool {
        // If node is currently being visited, we found a cycle
        if state[node] == 1 {
            return true
        }
        // If node is already fully visited, no cycle through this path
        if state[node] == 2 {
            return false
        }

        // Mark as visiting
        state[node] = 1

        // Visit all neighbors
        for _, neighbor := range graph[node] {
            // Initialize neighbor state if not seen before
            if _, exists := state[neighbor]; !exists {
                state[neighbor] = 0
            }
            if dfs(neighbor) {
                return true
            }
        }

        // Mark as visited
        state[node] = 2
        return false
    }

    // Check all nodes
    for node := range graph {
        if state[node] == 0 {
            if dfs(node) {
                return true
            }
        }
    }

    return false
}
```

Wait, I need to be careful. The graph map only contains keys for nodes that have outgoing edges. But neighbors might not be keys. My code handles this by initializing state for neighbors when encountered.

Let me trace through the examples:

1. `{"a": {"b"}, "b": {"c"}, "c": {}}`
   - Start DFS from "a": state["a"] = 1
   - Visit "b": state["b"] = 1
   - Visit "c": state["c"] = 1
   - "c" has no neighbors, state["c"] = 2
   - Back to "b", state["b"] = 2
   - Back to "a", state["a"] = 2
   - No cycle found โ†’ false โœ“

2. `{"a": {"a"}}`
   - Start DFS from "a": state["a"] = 1
   - Visit "a": state["a"] == 1, return true โœ“

3. `{"a": {"b"}, "b": {"c"}, "c": {"a"}}`
   - Start DFS from "a": state["a"] = 1
   - Visit "b": state["b"] = 1
   - Visit "c": state["c"] = 1
   - Visit "a": state["a"] == 1, return true โœ“

4. `{}`
   - Empty map, return false โœ“

This looks correct. Let me finalize the solution.