Почему BFS работает намного быстрее, чем DFS, когда я реализую их оба?

Я пытаюсь использовать DFS и BFS, чтобы найти все простые пути длиной до заданного k, начиная с заданной вершины ориентированного графа. Никакие циклы не допускаются.

Мой код выглядит следующим образом, и я проверил, что оба алгоритма дают правильные результаты.

Я определяю класс графа и помещаю в этот класс код BFS и DFS. В нужном мне графе около 190 000 вершин и 2 500 000 ребер. Средняя исходящая степень каждой вершины равна 105:

public class AdjGraph {
    private int V; // sum of vertices
    private int E; // sum of edges
    private List<Vertex> vertexList;
    private Map<Vertex, List<Edge>> vertexAdj;
    
    public AdjGraph() {
        this.V = 0;
        this.E = 0;
        this.vertexList = new ArrayList<>();
        this.vertexAdj = new HashMap<>();
    }

    public void addVertex(Vertex v) {
        this.vertexList.add(v);
        this.vertexAdj.put(v, new ArrayList<>());
        this.V++;
    }

    public void addEdge(Edge e) {
        Vertex startVertex = e.getStartVertex();
        List<Edge> edgeList = this.vertexAdj.get(startVertex);

        if (!edgeList.contains(e)) {
            edgeList.add(e);
            this.vertexAdj.put(startVertex, edgeList);
        }
    }

    public List<Vertex> getVertexList() {
        return vertexList;
    }

    // Version of DFS
    public Map<Integer, List<List<Vertex>>> findAllPathsUpToLengthByDFS(Vertex start, int k) {
        boolean[] visited = new boolean[vertexList.size()];
        List<Vertex> path = new ArrayList<>();
        Map<Integer, List<List<Vertex>>> allPaths = new HashMap<>();
        findAllPathsUpToLengthByDFSUtil(start, k, visited, path, allPaths);
        return allPaths;
    }

    // Used by the function findAllPathsUpToLengthByDFS(...)
    private void findAllPathsUpToLengthByDFSUtil(Vertex u, int k, boolean[] visited, List<Vertex> path, Map<Integer, List<List<Vertex>>> allPaths) {
        // Mark the current node as visited and store in path
        visited[vertexList.indexOf(u)] = true;
        path.add(u);

        // If current path length exceeds k, backtrack
        if (path.size() - 1 > k) {
            visited[vertexList.indexOf(u)] = false;
            path.remove(path.size() - 1);
            return;
        }

        // If current path length is within k, add it to allPaths
        int pathLength = path.size() - 1;
        if (pathLength <= k) {
            allPaths.computeIfAbsent(pathLength, x -> new ArrayList<>()).add(new ArrayList<>(path));
        }

        // Recur for all the vertices adjacent to this vertex
        for (Edge edge : vertexAdj.get(u)) {
            Vertex v = edge.getEndVertex();
            if (!visited[vertexList.indexOf(v)]) {
                findAllPathsUpToLengthByDFSUtil(v, k, visited, path, allPaths);
            }
        }

        // Remove current vertex from path and mark it as unvisited
        path.remove(path.size() - 1);
        visited[vertexList.indexOf(u)] = false;
    }

    // Version of BFS
    public Map<Integer, List<List<Vertex>>> findAllPathsUpToLengthByBFS(Vertex start, int k) {
        Map<Integer, List<List<Vertex>>> allPaths = new HashMap<>();
        Queue<List<Vertex>> queue = new LinkedList<>();

        // Initialize the queue with the start vertex
        List<Vertex> startPath = new ArrayList<>();
        startPath.add(start);
        queue.add(startPath);

        while (!queue.isEmpty()) {
            List<Vertex> path = queue.poll();
            Vertex last = path.get(path.size() - 1);

            // Add the path to allPaths if its length is within k
            int pathLength = path.size() - 1;
            if (pathLength <= k) {
                allPaths.computeIfAbsent(pathLength, x -> new ArrayList<>()).add(new ArrayList<>(path));
            }

            // If the path length is already k, no need to explore further
            if (pathLength < k) {
                // Explore the neighbors of the last vertex in the path
                for (Edge edge : vertexAdj.get(last)) {
                    Vertex next = edge.getEndVertex();
                    if (!path.contains(next)) { // Avoid cycles
                        List<Vertex> newPath = new ArrayList<>(path);
                        newPath.add(next);
                        queue.add(newPath);
                    }
                }
            }
        }

        return allPaths;
    }
}

Класс вершины:

public class Vertex {
    // The origin names are not id1 and id2.
    // I change them for simplicity.
    private String id1;
    private String id2;

    public Vertex() {
    }

    public Vertex(String id1, String id2) {
        this.id1 = id1;
        this.id2 = id2;
    }

    // Getters and Setters

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }

        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }

        Vertex other = (Vertex) obj;

        return id1.equals(other.id1) && id2.equals(other.id2);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id1, id2);
    }
}

Класс края:

public class Edge {
    private Vertex startVertex;
    private Vertex endVertex;

    public Edge(Vertex startVertex, Vertex endVertex) {
        this.startVertex = startVertex;
        this.endVertex = endVertex;
    }

    // Getters for startVertex and endVertex
    public Vertex getStartVertex() {
        return startVertex;
    }

    public Vertex getEndVertex() {
        return endVertex;
    }

    @Override
    public boolean equals(Object obj) {

        if (this == obj) {
            return true;
        }

        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }

        Edge other = (Edge) obj;

        return Objects.equals(startVertex, other.startVertex) &&
                Objects.equals(endVertex, other.endVertex);
    }

    @Override
    public int hashCode() {
        return Objects.hash(startVertex, endVertex);
    }
}

Вот пример. Объем данных в этом примере относительно невелик, поэтому разница во времени выполнения неочевидна. Когда график большой, разница очевидна. На моем графике BFS работает 2 мс, а DFS — 15 с при k = 1:

public static void main(String[] args) {
    AdjGraph graph = new AdjGraph();

    for (int i = 0; i < 1000; i++) {
        Vertex v = new Vertex(String.valueOf(i), String.valueOf(i));
        graph.addVertex(v);
    }

    List<Vertex> vertexList = graph.getVertexList();
    for (int i = 0; i < vertexList.size(); i++) {
        for (int j = 0; j < vertexList.size(); j++) {
            if (j != i) {
                graph.addEdge(new Edge(vertexList.get(i), vertexList.get(j)));
            }
        }
    }

    Vertex start = vertexList.get(0); // Start node
    int k = 1; // Maximum path length

    Long t1 = System.currentTimeMillis();
    Map<Integer, List<List<Vertex>>> allPaths = graph.findAllPathsUpToLengthByBFS(start, k);
//  Map<Integer, List<List<Vertex>>> allPaths = graph.findAllPathsUpToLengthByBFS(start, k);
    Long t2 = System.currentTimeMillis();
    System.out.println("Running time: " + (t2 - t1) + "ms");
}

Удивительно, но BFS намного быстрее, чем DFS. Кто-нибудь знает, почему? Может быть, это потому, что в DFS слишком много повторяющихся вычислений? Как я могу их оптимизировать?

Спасибо!

Можете ли вы добавить код, с помощью которого вы измерили, что один быстрее другого? Не могли бы вы также добавить недостающие определения классов (Vertex, Edge) и методы, чтобы мы могли скомпилировать этот код и воспроизвести тайминги?

trincot 29.06.2024 13:56

@trincot Я добавлю их прямо сейчас. Они немного сложны.

ivygrowing 29.06.2024 13:59

Хорошо, чем проще вы это сделаете, тем лучше. Если разницу во времени можно продемонстрировать с помощью графа с 20 узлами вместо 1000, то это предпочтительнее. Если у вас есть методы, которые не используются, не включайте их. Просто постарайтесь быть минималистичными, не потеряв при этом работоспособности и демонстрации проблемы.

trincot 29.06.2024 14:02

Спасибо за завершение занятий. Теперь нам нужен (минимальный) основной код драйвера, который демонстрирует, что один быстрее другого. Кроме того, в вашем коде есть неопределенные переменные, например tripId, routeId,

trincot 29.06.2024 14:16

@trincot Я использую файл для создания большого графика. Я попытаюсь напрямую создать пример. Это может занять у меня некоторое время.

ivygrowing 29.06.2024 14:21

Я понимаю, но вы могли бы создать более крупный график с некоторой симметрией, например, A связан с B и C, B с C и D, C с D и E и т. д., пока у вас не будет достаточно узлов, которые будут иметь значение для времени. Это можно создать с помощью некоторого цикла.

trincot 29.06.2024 14:22

Что такое tripId и routeId?

trincot 29.06.2024 14:23

Извини. Это id1 и id2. Я их уже исправил.

ivygrowing 29.06.2024 14:26

@trincot Я добавил пример, и он работает успешно. Можно продемонстрировать разницу во времени работы BFS и DFS.

ivygrowing 29.06.2024 14:36

Хорошо, просто getVertexList не определен, а main должен иметь тип void.

trincot 29.06.2024 14:40

@trincot Моя вина. Сейчас я их исправил.

ivygrowing 29.06.2024 14:44
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
1
11
62
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Основная причина заключается в том, что в вашей реализации DFS вы все еще ищете соседей, даже если уже достигли максимальной длины пути. Другими словами, ваш базовый вариант выхода находится на один уровень глубже, чем вы могли бы сделать, а это означает, что выполняется множество итераций без какой-либо возможности добавить новый путь.

Эту проблему можно решить, изменив эту часть:


        for (Edge edge : vertexAdj.get(u)) {
            Vertex v = edge.getEndVertex();
            if (!visited[vertexList.indexOf(v)]) {
                findAllPathsUpToLengthByDFSUtil(v, k, visited, path, allPaths);
            }
        }

к этому:

        if (pathLength < k) { // *********
            for (Edge edge : vertexAdj.get(u)) {
                Vertex v = edge.getEndVertex();
                if (!visited[vertexList.indexOf(v)]) {
                    findAllPathsUpToLengthByDFSUtil(v, k, visited, path, allPaths);
                }
            }
        }

Обратите внимание, что именно этот шаблон вы использовали в своей реализации BFS. И с этим изменением вы можете удалить другую обработку базового случая:

        // If current path length exceeds k, backtrack
        if (path.size() - 1 > k) {
            visited[vertexList.indexOf(u)] = false;
            path.remove(path.size() - 1);
            return;
        }

Однако есть еще кое-что, что вы можете сделать для улучшения реализации DFS, хотя это будет иметь менее драматический эффект:

  • visited[vertexList.indexOf(v)] — относительно медленная операция при наличии большого количества вершин. Было бы более эффективно использовать хэш-набор вместо массива.
  • Еще лучше, вы могли бы создать path упорядоченный хэш-набор (LinkedHashSet), чтобы можно было отбросить visited и использовать path.contains для этой цели.

Вот версия реализации DFS, в которой реализованы эти изменения. Я назвал функции буквой «2» в конце, чтобы вы могли включить их в свой проект и сравнить производительность с исходными функциями:

    // Improved Version of DFS (DFS2)
    public Map<Integer, List<List<Vertex>>> findAllPathsUpToLengthByDFS2(Vertex start, int k) {
        // Use an insertion-ordered set so you don't need a separate visited array
        Set<Vertex> path = new LinkedHashSet<>();
        Map<Integer, List<List<Vertex>>> allPaths = new HashMap<>();
        findAllPathsUpToLengthByDFSUtil2(start, k, path, allPaths);
        return allPaths;
    }

    private void findAllPathsUpToLengthByDFSUtil2(Vertex u, int k, Set<Vertex> path, Map<Integer, List<List<Vertex>>> allPaths) {
        path.add(u);
        int pathLength = path.size() - 1;

        allPaths.computeIfAbsent(pathLength, x -> new ArrayList<>()).add(new ArrayList<>(path));

        if (pathLength < k) { // This is where you should stop recursion
            for (Edge edge : vertexAdj.get(u)) {
                Vertex v = edge.getEndVertex();
                if (!path.contains(v)) {
                    findAllPathsUpToLengthByDFSUtil2(v, k, path, allPaths);
                }
            }
        }
        path.remove(u);
    }

Сравнение DFS с BFS

Хотя DFS требует дополнительных затрат на стек вызовов, с точки зрения памяти преимущества DFS перед BFS начинают проявляться, когда k становится больше. Например, я попробовал ваш main код, но с графом всего в 25 узлов (вместо 1000), но с k, равным 5. Тогда мы видим, что BFS оказывается медленнее, чем DFS. В основном это связано с тем, что в памяти хранится множество частичных путей, в то время как DFS по своей природе использует только один частичный путь.

Большое спасибо! Ваше объяснение невероятно ясное и полезное. Я искренне ценю время и усилия, которые вы вложили, чтобы помочь мне. Я тщательно оценю, как эти изменения повлияют на производительность во время выполнения :)

ivygrowing 29.06.2024 16:15

Пожалуйста! ;-)

trincot 29.06.2024 16:15

Эй, у меня есть еще один вопрос. Код алгоритма поиска с возвратом обычно включает в себя оценку того, удовлетворено ли условие завершения в первую очередь. Если оно удовлетворено, то возвращайтесь. Однако функция findAllPathsUpToLengthByDFSUtil2 не имеет этой части и по-прежнему успешно выполняется. Почему?

ivygrowing 30.06.2024 14:31

Это похоже на else утверждения if. Вместо этого оператора переноса if мы могли бы использовать оператор if с противоположным условием, и внутри его тела сделать { path.remove(u); return; } , а под этим блоком if у вас будет развернутый цикл for для рекурсивного случая. Все сводится к одному и тому же. Иногда вы видите явный базовый вариант, иногда он неявный, поскольку опущен else.

trincot 30.06.2024 14:35

Понятно! Большое спасибо! Алгоритм возврата действительно для меня немного сложен, лол.

ivygrowing 30.06.2024 14:47

Другие вопросы по теме