diff --git a/src/main/java/com/thealgorithms/graph/PredecessorConstrainedDfs.java b/src/main/java/com/thealgorithms/graph/PredecessorConstrainedDfs.java new file mode 100644 index 000000000000..2cf4ed23c44f --- /dev/null +++ b/src/main/java/com/thealgorithms/graph/PredecessorConstrainedDfs.java @@ -0,0 +1,163 @@ +package com.thealgorithms.graph; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * DFS that visits a successor only when all its predecessors are already visited, + * emitting VISIT and SKIP events. + *

+ * This class includes a DFS variant that visits a successor only when all of its + * predecessors have already been visited + *

+ *

Related reading: + *

+ *

+ */ + +public final class PredecessorConstrainedDfs { + + private PredecessorConstrainedDfs() { + // utility class + } + + /** An event emitted by the traversal: either a VISIT with an order, or a SKIP with a note. */ + public record TraversalEvent(T node, + Integer order, // non-null for visit, null for skip + String note // non-null for skip, null for visit + ) { + public TraversalEvent { + Objects.requireNonNull(node); + // order and note can be null based on event type + } + + /** A visit event with an increasing order (0,1,2,...) */ + public static TraversalEvent visit(T node, int order) { + return new TraversalEvent<>(node, order, null); + } + + /** A skip event with an explanatory note (e.g., not all parents visited yet). */ + public static TraversalEvent skip(T node, String note) { + return new TraversalEvent<>(node, null, Objects.requireNonNull(note)); + } + + public boolean isVisit() { + return order != null; + } + + public boolean isSkip() { + return order == null; + } + + @Override + public String toString() { + return isVisit() ? "VISIT(" + node + ", order=" + order + ")" : "SKIP(" + node + ", " + note + ")"; + } + } + + /** + * DFS (recursive) that records the order of first visit starting at {@code start}, + * but only recurses to a child when all its predecessors have been visited. + * If a child is encountered early (some parent unvisited), a SKIP event is recorded. + * + *

Equivalent idea to the Python pseudo in the user's description (with successors and predecessors), + * but implemented in Java and returning a sequence of {@link TraversalEvent}s.

+ * + * @param successors adjacency list: for each node, its outgoing neighbors + * @param start start node + * @return immutable list of traversal events (VISITs with monotonically increasing order and SKIPs with messages) + * @throws IllegalArgumentException if {@code successors} is null + */ + public static List> dfsRecursiveOrder(Map> successors, T start) { + if (successors == null) { + throw new IllegalArgumentException("successors must not be null"); + } + // derive predecessors once + Map> predecessors = derivePredecessors(successors); + return dfsRecursiveOrder(successors, predecessors, start); + } + + /** + * Same as {@link #dfsRecursiveOrder(Map, Object)} but with an explicit predecessors map. + */ + public static List> dfsRecursiveOrder(Map> successors, Map> predecessors, T start) { + + if (successors == null || predecessors == null) { + throw new IllegalArgumentException("successors and predecessors must not be null"); + } + if (start == null) { + return List.of(); + } + if (!successors.containsKey(start) && !appearsAnywhere(successors, start)) { + return List.of(); // start not present in graph + } + + List> events = new ArrayList<>(); + Set visited = new HashSet<>(); + int[] order = {0}; + dfs(start, successors, predecessors, visited, order, events); + return Collections.unmodifiableList(events); + } + + private static void dfs(T currentNode, Map> successors, Map> predecessors, Set visited, int[] order, List> result) { + + if (!visited.add(currentNode)) { + return; // already visited + } + result.add(TraversalEvent.visit(currentNode, order[0]++)); // record visit and increment + + for (T childNode : successors.getOrDefault(currentNode, List.of())) { + if (visited.contains(childNode)) { + continue; + } + if (allParentsVisited(childNode, visited, predecessors)) { + dfs(childNode, successors, predecessors, visited, order, result); + } else { + result.add(TraversalEvent.skip(childNode, "⛔ Skipping " + childNode + ": not all parents are visited yet.")); + // do not mark visited; it may be visited later from another parent + } + } + } + + private static boolean allParentsVisited(T node, Set visited, Map> predecessors) { + for (T parent : predecessors.getOrDefault(node, List.of())) { + if (!visited.contains(parent)) { + return false; + } + } + return true; + } + + private static boolean appearsAnywhere(Map> successors, T node) { + if (successors.containsKey(node)) { + return true; + } + for (List neighbours : successors.values()) { + if (neighbours != null && neighbours.contains(node)) { + return true; + } + } + return false; + } + + private static Map> derivePredecessors(Map> successors) { + Map> predecessors = new HashMap<>(); + // ensure keys exist for all nodes appearing anywhere + for (Map.Entry> entry : successors.entrySet()) { + predecessors.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()); + for (T childNode : entry.getValue()) { + predecessors.computeIfAbsent(childNode, key -> new ArrayList<>()).add(entry.getKey()); + } + } + return predecessors; + } +} diff --git a/src/test/java/com/thealgorithms/graph/PredecessorConstrainedDfsTest.java b/src/test/java/com/thealgorithms/graph/PredecessorConstrainedDfsTest.java new file mode 100644 index 000000000000..e2c6d468768f --- /dev/null +++ b/src/test/java/com/thealgorithms/graph/PredecessorConstrainedDfsTest.java @@ -0,0 +1,90 @@ +package com.thealgorithms.graph; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.thealgorithms.graph.PredecessorConstrainedDfs.TraversalEvent; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PredecessorConstrainedDfsTest { + + // A -> B, A -> C, B -> D, C -> D (classic diamond) + private static Map> diamond() { + Map> g = new LinkedHashMap<>(); + g.put("A", List.of("B", "C")); + g.put("B", List.of("D")); + g.put("C", List.of("D")); + g.put("D", List.of()); + return g; + } + + @Test + void dfsRecursiveOrderEmitsSkipUntilAllParentsVisited() { + List> events = PredecessorConstrainedDfs.dfsRecursiveOrder(diamond(), "A"); + + // Expect visits in order and a skip for first time we meet D (via B) before C is visited. + var visits = events.stream().filter(TraversalEvent::isVisit).toList(); + var skips = events.stream().filter(TraversalEvent::isSkip).toList(); + + // Visits should be A(0), B(1), C(2), D(3) in some deterministic order given adjacency + assertThat(visits).hasSize(4); + assertThat(visits.get(0).node()).isEqualTo("A"); + assertThat(visits.get(0).order()).isEqualTo(0); + assertThat(visits.get(1).node()).isEqualTo("B"); + assertThat(visits.get(1).order()).isEqualTo(1); + assertThat(visits.get(2).node()).isEqualTo("C"); + assertThat(visits.get(2).order()).isEqualTo(2); + assertThat(visits.get(3).node()).isEqualTo("D"); + assertThat(visits.get(3).order()).isEqualTo(3); + + // One skip when we first encountered D from B (before C was visited) + assertThat(skips).hasSize(1); + assertThat(skips.get(0).node()).isEqualTo("D"); + assertThat(skips.get(0).note()).contains("not all parents"); + } + + @Test + void returnsEmptyWhenStartNotInGraph() { + Map> graph = Map.of(1, List.of(2), 2, List.of(1)); + assertThat(PredecessorConstrainedDfs.dfsRecursiveOrder(graph, 99)).isEmpty(); + } + + @Test + void nullSuccessorsThrows() { + assertThrows(IllegalArgumentException.class, () -> PredecessorConstrainedDfs.dfsRecursiveOrder(null, "A")); + } + + @Test + void worksWithExplicitPredecessors() { + Map> successors = new HashMap<>(); + successors.put(10, List.of(20)); + successors.put(20, List.of(30)); + successors.put(30, List.of()); + + Map> predecessors = new HashMap<>(); + predecessors.put(10, List.of()); + predecessors.put(20, List.of(10)); + predecessors.put(30, List.of(20)); + + var events = PredecessorConstrainedDfs.dfsRecursiveOrder(successors, predecessors, 10); + var visitNodes = events.stream().filter(TraversalEvent::isVisit).map(TraversalEvent::node).toList(); + assertThat(visitNodes).containsExactly(10, 20, 30); + } + + @Test + void cycleProducesSkipsButNoInfiniteRecursion() { + Map> successors = new LinkedHashMap<>(); + successors.put("X", List.of("Y")); + successors.put("Y", List.of("X")); // 2-cycle + + var events = PredecessorConstrainedDfs.dfsRecursiveOrder(successors, "X"); + // Only X is visited; encountering Y from X causes skip because Y's parent X is visited, + // but when recursing to Y we'd hit back to X (already visited) and stop; no infinite loop. + assertThat(events.stream().anyMatch(TraversalEvent::isVisit)).isTrue(); + assertThat(events.stream().filter(TraversalEvent::isVisit).map(TraversalEvent::node)).contains("X"); + } +}