Arhn - архитектура программирования

Счетчик достижимости для всех узлов в направленном ациклическом графе

Итак, на конкурсе программирования на Hackerrank была задача под названием «Ациклический граф», которая в основном сводится к подсчету количества узлов, достижимых из каждого узла в «направленном ациклическом графе». Например, скажем, у вас есть такой график:

[ 1 ] ---->[ 2 ]--->[ 4 ]--->[ 5 ]
[ 3 ] ------/

Счетчик достижимости (включая исходный узел):

Node 1: 4
Node 2: 3
Node 3: 4
Node 4: 2
Node 5: 1

Мой подход был обходом «сначала в глубину» с мемоизацией. Немного осмотрелся, но кажется, что время выполнения нельзя улучшить еще больше из-за пересчета, который происходит в таких случаях:

[ 1 ] ---->[ 2 ]--->[ 4 ]--->[ 5 ]
[ 3 ] ------/--------/

Третий узел будет считать четвертый узел, хотя второй узел уже считал четвертый узел. Что еще хуже, я решаю эти задачи только в JavaScript. Это мой основной язык, и я получаю удовольствие от раздвигания его границ. Никто в таблице лидеров еще не решил ее на JavaScript, но я предполагаю, что это возможно. После конкурса мне удалось пройти 13 тестов из 24 со следующим кодом:

function Solution( graph, nodes ) {

    var memory = new Array( nodes + 1 )
      , result = 0;

    graph.forEach( ( a, v ) => DepthFirstSearch( graph, v, memory ) );

    // challenge asks for an output variation, but the accurate 
    // reachability count of every node will be contained in "d.length".
    memory.forEach( ( d, i ) => { if ( i && ( 2 * d.length ) >= nodes ) result++; } );

    return result;
}

function DepthFirstSearch( graph, v, memory ) {

    if ( memory[ v ] ) return memory[ v ];

    var descendants = new Uint16Array( [ v ] );

    graph[ v ].forEach( u => {

        descendants = MergeTypedArrays( 
            DepthFirstSearch( graph, u, memory ),  
            descendants
        );
    } );
                                           // make elements unique
                                           // to avoid over counting
    return memory[ v ] = Uint16Array.from( new Set( descendants ) ); 
}

function MergeTypedArrays(a, b) {

    var c = new a.constructor( a.length + b.length );

    c.set( a );
    c.set( b, a.length );

    return c;
}

// adjacency list
var graph = [ 
    [],    // 0
    [ 2 ], // 1
    [ 4 ], // 2
    [ 2 ], // 3
    [ 5 ], // 4
    []     // 5
];

var nodes = 5;

Solution( graph, nodes );

Он не работает для всех входных данных размером более 50 КБ, предположительно входных данных с большим набором узлов и ребер (т.е. 50 000 узлов и 40 000 ребер). Не сумев определить или придумать более быстрый и эффективный алгоритм памяти, я совершенно не понимаю, что делать дальше. Думал о том, чтобы сделать DFS итеративной, но я думаю, что потребление памяти для запоминания тысяч массивов затмит это, что, по-видимому, является основной проблемой. Я получаю «Abort Called» и «Runtime Error» на Hackerrank для 11 неудачных тестов (в отличие от «Timeout»). Также попробовал «bitSets» с «union», но потребление памяти оказалось хуже, поскольку массивы bitSets должны быть достаточно большими, чтобы хранить числа до 50 000.

Ограничения:

1 ≤ n,m ≤ 5×10^4
1 ≤ a(i),b(i) ≤ n and a(i) ≠ b(i)
It is guaranteed that graph G does not contain cycles.

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

Вопрос: Как я могу оптимизировать это дальше? Есть ли более эффективный подход?


  • У вас есть дополнительная информация о тестовых примерах, например. какие у них ограничения по времени/памяти? Честно говоря, 50 тысяч узлов и 40 тысяч ребер — это крошечный граф, и даже у плохой реализации не должно быть с этим проблем. 07.03.2016
  • @MartinS Да, максимальное время выполнения JavaScript составляет 10 секунд, а максимальный объем памяти — 512 МБ. Я просто запускал ваши версии локально; рекурсивная ошибка при переполнении стека, а итеративная занимает ~ 13 минут для этот тестовый пример (самая первая цифра — это количество узлов, цифра рядом с ней — это количество ребер, строки под этими двумя цифрами — это фактические ребра). Вероятность успеха в вызове составляет 7,87%. Я думаю, что это немного больше, чем мы понимаем. Я продолжаю возвращаться к мемоизации, так как она выполняет много повторяющейся работы. 07.03.2016
  • Что такое a and b в этих ограничениях 1 ≤ n,m ≤ 5×10^4 1 ≤ a(i),b(i) ≤ n and a(i) ≠ b(i) ? 07.03.2016
  • @MartinS Извините, что не указал, что a и b относятся к краям. Чтобы устранить двусмысленность, здесь приведен скриншот описание задачи. 08.03.2016
  • Спасибо, это очень помогает :) 08.03.2016

Ответы:


1

Поиск в глубину (DFS) — один из хороших способов решения этой проблемы. Другим способом может быть поиск в ширину (BFS), который также может работать параллельно и может быть очень хорошо оптимизирован, но все это за счет гораздо более высокой сложности кода. Поэтому я бы рекомендовал придерживаться DFS.

Во-первых, я должен извиниться, но мои навыки JavaScript не очень хороши (т. е. их не существует), поэтому мои решения ниже используют Java, но идеи должны быть легко переносимы.

В вашем первоначальном вопросе отсутствует одна очень важная деталь: нам нужно только найти все узлы, где количество достижимых узлов больше или равно |V| / 2

Почему это имеет значение? Вычисление количества достижимых узлов для каждого узла требует больших затрат, поскольку мы должны выполнять DFS или BFS, начиная с каждого узла в графе. Но если нам нужно найти только узлы с вышеуказанным свойством, это намного проще.

Пусть successors(n) — все узлы, достижимые из n, а ancestor(n) — все узлы, которые могут достигать n. Мы можем использовать следующие наблюдения, чтобы резко сократить пространство поиска:

  • если количество узлов, достижимых из n, меньше, чем |V| / 2, то ни один узел в successors(n) не может иметь большее число
  • если количество узлов, достижимых из n, больше или равно |V| / 2, тогда все узлы в ancestors(n) будут иметь большее число

Как мы можем это использовать?

  1. При создании графика также создайте транспонированный график. Это означает, что при сохранении ребра a-›b вы сохраняете b-›a в транспонированном графе.
  2. Создайте массив, в котором хранятся узлы, которые следует игнорировать, инициализируйте его с помощью false
  3. Реализуйте функцию на основе DFS, которая определяет, имеет ли данный узел количество доступных узлов >= |V| / 2 (см. ниже).
  4. В этой функции игнорируйте узлы, помеченные как игнорируемые.
  5. Если для узла n количество узлов меньше, чем |V| / 2, пометить все узлы в successors(n) как игнорируемые
  6. В противном случае подсчитайте все узлы в ancestors(n) и пометьте их как проигнорированные.

Решение с использованием итеративной DFS

public int countReachable(int root, boolean[] visited, boolean[] ignored, Graph graph) {
    if (ignored[root]) {
        return 0;
    }

    Stack<Integer> stack = new Stack<>();
    stack.push(root);

    int count = 0;
    while (stack.empty() == false) {
        int node = stack.pop();
        if (visited[node] == false) {
            count++;
            visited[node] = true;
            for (int neighbor : graph.getNeighbors(node)) {
                if (visited[neighbor] == false) {
                    stack.push(neighbor);
                }
            }
        }
    }
    if (count * 2 >= graph.numNodes()) {
        return markAndCountAncestors(root, visited, ignored, graph);
    } else {
        return markSuccessors(root, visited, ignored, graph);
    }
}

Функция пометки предков

Это просто еще одна DFS, но с использованием транспонированного графа. Обратите внимание, что мы можем повторно использовать массив visited, поскольку все значения, которые мы будем использовать, равны false, поскольку это ациклический граф.

public int markAndCountAncestors(int root, boolean[] visited, boolean[] ignored, Graph graph) {   
    Stack<Integer> stack = new Stack<>();
    stack.push(root);
    visited[root] = false;

    int count = 0;
    while (stack.empty() == false) {
        int node = stack.pop();
        if (visited[node] == false && ignored[node] == false) {
            count++;
            visited[node] = true;
            ignored[node] = true;
            for (int neighbor : graph.transposed.getNeighbors(node)) {
                if (visited[neighbor] == false && ignored[node] == false) {
                    stack.push(neighbor);
                }
            }
        }
    }
    return count;
}

Функция пометки преемников

Обратите внимание, что у нас уже есть преемники, поскольку они являются просто узлами, где мы установили visited в значение true.

public int markSuccessors(int root, boolean[] visited, boolean[] ignored, Graph graph) {
    for(int node = 0; node < graph.numNodes(); node++) {
        if (visited[node)) {
            ignored[node] = true;
        }
    }
    return 0;
}

Функция для вычисления результата

public void solve(Graph graph) {
    int count = 0;
    boolean[] visited = new boolean[graph.numNodes()];
    boolean[] ignored = new boolean[graph.numNodes()];
    for (int node = 0; node < graph.numNodes(); node++) {
        Arrays.fill(visited, false); // reset visited array
        count += countReachable(node, visited, ignored, graph);
    }
    System.out.println("Result: " + count);
}

В опубликованном вами большом тестовом примере это выполняется за 7,5 секунды для меня. Если вы инвертируете порядок итераций (например, в solve вы начинаете с самого большого идентификатора узла), он сокращается до 4 секунд, но это немного похоже на мошенничество ^^

06.03.2016
  • Спасибо за подсказки, Мартинс. Ваш подход был фактически тем, что я попробовал первым, мой исходный код выглядит почти идентично. Это не слишком хорошо тренировалось. На самом деле он медленнее (для двух тестовых случаев), чем код, который я представил, и выдает ошибки только в 13 случаях (проходит те же тестовые случаи, что и мой алгоритм, не может обрабатывать больший ввод). Я только перешел к подходу, который представил, пытаясь кэшировать. Я думаю, что в вашем итеративном коде могут быть некоторые логические ошибки. Я не мог заставить его работать для других тестовых случаев без модификаций, это пересчитывается для круговых случаев. В любом случае, я ценю это. 07.03.2016
  • @marcbraulio Я только что увидел, что вам нужно переместить count++; после проверки того, посещали ли вы уже узел - извините за это. 07.03.2016
  • Не беспокойтесь :) и вам также нужно изменить visited[node] = false; на visited[node] = true;. Без этого изменения он все равно пройдет тест, который я представил, но не пройдет циклические случаи. Ознакомьтесь с этим тестовым примером, и вы увидите, что я m говоря (самая первая цифра — это количество узлов, цифра рядом с ней — это количество ребер, строки под этими двумя цифрами — это фактические ребра). Он должен вывести Node 1: 3, Node 2: 4, Node 3: 2, Node 4: 7, Node 5: 6, Node 6: 8, Node 7: 10, Node 8: 5, Node 9: 1, Node 10: 9 07.03.2016
  • @marcbraulio Я использовал правильный код, но почему-то разместил его здесь неправильно ^^ Я обновил свой ответ, потому что он был бы слишком длинным для комментария 07.03.2016
  • Не проблема, я ценю это. Итак, вы говорите мне, что ваша итеративная DFS на Java запустила это тестовый пример за 2 мс? Основываясь на тестах, которые я провел с печально известной проблемой N-Queens, JavaScript (Node.js - V8) всего примерно в три раза медленнее, чем C. И да, я использую то, что я считаю списком смежности, для представления моего график (изображенный в моем коде выше). Я читал, что это одна из самых эффективных структур данных графа, вы рекомендуете другой способ? 08.03.2016
  • О нет, он выполняется за 2 мс для упомянутого вами тестового примера (input02.txt), для более крупного (input11.txt) требуется 10 секунд. Но с дополнительной информацией о вопросе я понял, как это улучшить. Однако потребуется некоторое время, чтобы проверить это ^^ 08.03.2016
  • Извините, хотел ответить раньше. Прежде чем вы предложили оптимизировать >= |V| / 2, мне удалось уменьшить input11.txt с ~13м до ~300с. После оптимизации >= |V| / 2 я снизил его до ~99 с. С файлом input02.txt никогда не возникало проблем, он постоянно работал 9 мс на любой версии. Перевернутая итерация ничего не сделала для меня, и я подозреваю, что она не зависит от тестового примера. Получил доступ к редакции (и автору code), не похоже, что они заботятся о >= |V| / 2. 08.03.2016
  • Все проблемы (более 200), которые я решил до сих пор, имели решение JS, которое заняло не более 2 секунд, не уверен, что ваше решение 4s-7s пройдет все тестовые случаи (ограничение времени для Java составляет всего 4 секунды), но если вы дайте мне функцию Java для анализа ребер графа, я могу отправить ее и сообщить вам. Чат может быть лучшим на данный момент. Спасибо за внимание, мужик. Я отмечаю ваш ответ как правильный, поскольку он на самом деле более оптимален, чем мой. 09.03.2016
  • @marcbraulio Я сделал несколько небольших оптимизаций и сократил время до 2,5 секунд. Проблема в том, что он использует проприетарный код для загрузки и представления графика, поэтому я не могу поделиться им с вами. Но я могу реализовать это вручную, когда у меня будет время ^^ 09.03.2016
  • Красиво и без забот. Что вы думаете о подходе в редакции? Замена visited[neighbor] == false && ignored[node] == false на visited[neighbor] == false && ignored[neighbor] == false немного отрубила мне время. 09.03.2016
  • Вы сами придумали этот алгоритм или он взят из книги или статьи? 20.09.2016
  • DFS — хорошо известный алгоритм из теории графов, но код, который я написал сам 13.01.2017
  • Новые материалы

    Коллекции публикаций по глубокому обучению
    Последние пару месяцев я создавал коллекции последних академических публикаций по различным подполям глубокого обучения в моем блоге https://amundtveit.com - эта публикация дает обзор 25..

    Представляем: Pepita
    Фреймворк JavaScript с открытым исходным кодом Я знаю, что недостатка в фреймворках JavaScript нет. Но я просто не мог остановиться. Я хотел написать что-то сам, со своими собственными..

    Советы по коду Laravel #2
    1-) Найти // You can specify the columns you need // in when you use the find method on a model User::find(‘id’, [‘email’,’name’]); // You can increment or decrement // a field in..

    Работа с временными рядами спутниковых изображений, часть 3 (аналитика данных)
    Анализ временных рядов спутниковых изображений для данных наблюдений за большой Землей (arXiv) Автор: Рольф Симоэс , Жильберто Камара , Жильберто Кейрос , Фелипе Соуза , Педро Р. Андраде ,..

    3 способа решить квадратное уравнение (3-й мой любимый) -
    1. Методом факторизации — 2. Используя квадратичную формулу — 3. Заполнив квадрат — Давайте поймем это, решив это простое уравнение: Мы пытаемся сделать LHS,..

    Создание VR-миров с A-Frame
    Виртуальная реальность (и дополненная реальность) стали главными модными терминами в образовательных технологиях. С недорогими VR-гарнитурами, такими как Google Cardboard , и использованием..

    Демистификация рекурсии
    КОДЕКС Демистификация рекурсии Упрощенная концепция ошеломляющей О чем весь этот шум? Рекурсия, кажется, единственная тема, от которой у каждого начинающего студента-информатика..