Skip to content

Add DFS with parent-completion constraint for DAG traversal #6467

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

163 changes: 163 additions & 0 deletions src/main/java/com/thealgorithms/graph/PredecessorConstrainedDfs.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* This class includes a DFS variant that visits a successor only when all of its
* predecessors have already been visited
* </p>
* <p>Related reading:
* <ul>
* <li><a href="https://en.wikipedia.org/wiki/Topological_sorting">Topological sorting</a></li>
* <li><a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a></li>
* </ul>
* </p>
*/

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>(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 <T> TraversalEvent<T> 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 <T> TraversalEvent<T> 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 <b>all</b> its predecessors have been visited.
* If a child is encountered early (some parent unvisited), a SKIP event is recorded.
*
* <p>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.</p>
*
* @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 <T> List<TraversalEvent<T>> dfsRecursiveOrder(Map<T, List<T>> successors, T start) {
if (successors == null) {
throw new IllegalArgumentException("successors must not be null");
}
// derive predecessors once
Map<T, List<T>> predecessors = derivePredecessors(successors);
return dfsRecursiveOrder(successors, predecessors, start);
}

/**
* Same as {@link #dfsRecursiveOrder(Map, Object)} but with an explicit predecessors map.
*/
public static <T> List<TraversalEvent<T>> dfsRecursiveOrder(Map<T, List<T>> successors, Map<T, List<T>> 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<TraversalEvent<T>> events = new ArrayList<>();
Set<T> visited = new HashSet<>();
int[] order = {0};
dfs(start, successors, predecessors, visited, order, events);
return Collections.unmodifiableList(events);
}

private static <T> void dfs(T currentNode, Map<T, List<T>> successors, Map<T, List<T>> predecessors, Set<T> visited, int[] order, List<TraversalEvent<T>> 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 <T> boolean allParentsVisited(T node, Set<T> visited, Map<T, List<T>> predecessors) {
for (T parent : predecessors.getOrDefault(node, List.of())) {
if (!visited.contains(parent)) {
return false;
}
}
return true;
}

private static <T> boolean appearsAnywhere(Map<T, List<T>> successors, T node) {
if (successors.containsKey(node)) {
return true;
}
for (List<T> neighbours : successors.values()) {
if (neighbours != null && neighbours.contains(node)) {
return true;
}
}
return false;
}

private static <T> Map<T, List<T>> derivePredecessors(Map<T, List<T>> successors) {
Map<T, List<T>> predecessors = new HashMap<>();
// ensure keys exist for all nodes appearing anywhere
for (Map.Entry<T, List<T>> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, List<String>> diamond() {
Map<String, List<String>> 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<TraversalEvent<String>> 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<Integer, List<Integer>> 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<Integer, List<Integer>> successors = new HashMap<>();
successors.put(10, List.of(20));
successors.put(20, List.of(30));
successors.put(30, List.of());

Map<Integer, List<Integer>> 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<String, List<String>> 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");
}
}