Как в Java эффективно и элегантно передавать потомков узла дерева?

Предположим, у нас есть набор объектов, идентифицируемых уникальными String, а также класс Tree, определяющий для них иерархию. Этот класс реализован с использованием Map от узлов (представленных их идентификаторами) до Collection идентификаторов соответствующих дочерних узлов.

class Tree {
  private Map<String, Collection<String>> edges;

  // ...

  public Stream<String> descendants(String node) {
    // To be defined.
  }
}

Я хочу включить потоковую передачу потомков узла. Вот простое решение:

private Stream<String> children(String node) {
    return edges.getOrDefault(node, Collections.emptyList()).stream();
}

public Stream<String> descendants(String node) {
    return Stream.concat(
        Stream.of(node),
        children(node).flatMap(this::descendants)
    );
}

Прежде чем продолжить, я хотел бы сделать следующие утверждения об этом решении. (правильно ли я говорю об этом?)

  1. Обход Stream, возвращенного из descendants, потребляет ресурсы (время и память) — относительно размера дерева — в том же порядке сложности, что и ручное кодирование рекурсии. В частности, промежуточные объекты, представляющие состояние итерации (Streamс, Spliteratorс, ...), образуют стек, и поэтому потребность в памяти в любой момент времени находится в том же порядке сложности, что и глубина дерева.

  2. Насколько я понимаю это, как только я выполняю завершающую операцию над Stream, возвращенным из descendants, корневой вызов flatMap вызовет немедленную реализацию всех содержащихся Streams — по одному для каждого (рекурсивного) вызова descendants. Таким образом, результирующий Stream ленив только на первом уровне рекурсии, но не выше. (Отредактировано в соответствии с ответом Тагира Валеева.)

Если я правильно понял эти пункты, мой вопрос заключается в следующем: Как я могу определить descendants так, чтобы результирующее Stream было ленивым?

Я хотел бы, чтобы решение было максимально элегантным, в том смысле, что я предпочитаю решение, которое оставляет состояние итерации неявным. (Чтобы пояснить, что я имею в виду: я знаю, что могу написать Spliterator, который проходит по дереву, поддерживая явный стек из Spliterator на каждом уровне. Я бы хотел этого избежать.)

(Возможно ли в Java сформулировать это как рабочий процесс производитель-потребитель, как это можно использовать в таких языках, как Julia и Go?)


person user4235730    schedule 23.09.2015    source источник
comment
Пользовательский Spliterator кажется тем, что вам нужно...   -  person fge    schedule 23.09.2015
comment
Я бы серьезно подумал об использовании чего-то вроде TreeTraverser в Guava, а затем обернул бы его как Stream.   -  person Louis Wasserman    schedule 24.09.2015
comment
этот ответ содержит некоторые предложения по увеличению лени, возможно, применимые к вашей ситуации.   -  person the8472    schedule 24.09.2015


Ответы (5)


Для меня ваше решение уже настолько элегантно, насколько это возможно, и его ограниченная лень не по вашей вине. Самое простое решение — подождать, пока это не исправят разработчики JRE. Это было сделано с помощью Java 10.

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

public class FlatMappingSpliterator<E,S> extends Spliterators.AbstractSpliterator<E>
implements Consumer<S> {

    static final boolean USE_ORIGINAL_IMPL
        = Boolean.getBoolean("stream.flatmap.usestandard");

    public static <T,R> Stream<R> flatMap(
        Stream<T> in, Function<? super T,? extends Stream<? extends R>> mapper) {

        if(USE_ORIGINAL_IMPL)
            return in.flatMap(mapper);

        Objects.requireNonNull(in);
        Objects.requireNonNull(mapper);
        return StreamSupport.stream(
            new FlatMappingSpliterator<>(sp(in), mapper), in.isParallel()
        ).onClose(in::close);
    }

    final Spliterator<S> src;
    final Function<? super S, ? extends Stream<? extends E>> f;
    Stream<? extends E> currStream;
    Spliterator<E> curr;

    private FlatMappingSpliterator(
        Spliterator<S> src, Function<? super S, ? extends Stream<? extends E>> f) {
        // actually, the mapping function can change the size to anything,
        // but it seems, with the current stream implementation, we are
        // better off with an estimate being wrong by magnitudes than with
        // reporting unknown size
        super(src.estimateSize()+100, src.characteristics()&ORDERED);
        this.src = src;
        this.f = f;
    }

    private void closeCurr() {
        try { currStream.close(); } finally { currStream=null; curr=null; }
    }

    public void accept(S s) {
        curr=sp(currStream=f.apply(s));
    }

    @Override
    public boolean tryAdvance(Consumer<? super E> action) {
        do {
            if(curr!=null) {
                if(curr.tryAdvance(action))
                    return true;
                closeCurr();
            }
        } while(src.tryAdvance(this));
        return false;
    }

    @Override
    public void forEachRemaining(Consumer<? super E> action) {
        if(curr!=null) {
            curr.forEachRemaining(action);
            closeCurr();
        }
        src.forEachRemaining(s->{
            try(Stream<? extends E> str=f.apply(s)) {
                if(str!=null) str.spliterator().forEachRemaining(action);
            }
        });
    }

    @SuppressWarnings("unchecked")
    private static <X> Spliterator<X> sp(Stream<? extends X> str) {
        return str!=null? ((Stream<X>)str).spliterator(): null;
    }

    @Override
    public Spliterator<E> trySplit() {
        Spliterator<S> split = src.trySplit();
        if(split==null) {
            Spliterator<E> prefix = curr;
            while(prefix==null && src.tryAdvance(s->curr=sp(f.apply(s))))
                prefix=curr;
            curr=null;
            return prefix;
        }
        FlatMappingSpliterator<E,S> prefix=new FlatMappingSpliterator<>(split, f);
        if(curr!=null) {
            prefix.curr=curr;
            curr=null;
        }
        return prefix;
    }
}

Все, что вам нужно для его использования, это добавить в код import static метода flatMap и изменить выражения вида stream.flatmap(function) на flatmap(stream, function).

т.е. в вашем коде

public Stream<String> descendants(String node) {
    return Stream.concat(
        Stream.of(node),
        flatMap(children(node), this::descendants)
    );
}

то у вас полное ленивое поведение. Я тестировал это даже с бесконечными потоками…

Обратите внимание, что я добавил переключатель, позволяющий вернуться к исходной реализации, например. при указании    -Dstream.flatmap.usestandard=true в командной строке.

person Holger    schedule 24.09.2015
comment
Интересное решение. Попробуйте flatMap(IntStream.range(0, 1000000).boxed(), Stream::of).parallel().collect(Collectors.summingInt(Integer::intValue)) и сравните с версией JDK. Ваш очень медленный (например, в 30-50 раз медленнее). - person Tagir Valeev; 25.09.2015
comment
Обратите внимание, что при увеличении накладных расходов на элемент, как в flatMap(IntStream.range(0, 1000).boxed().parallel(), Stream::of) .map(i->{ LockSupport.parkNanos(1); return i; }) .collect(Collectors.summingInt(Integer::intValue));, выполнение действительно выигрывает от параллельного выполнения, и обе реализации находятся на одном уровне. - person Holger; 25.09.2015
comment
Зачем полагаться на чувства, когда можно измерить. JDK flatMap делает это параллельно и даже с дополнительной операцией просмотра намного быстрее, чем у вас. Потоковый конвейер недостаточно умен, чтобы измерить Q вашей задачи. Он не может оценить, быстрый Integer::intValue или нет, это просто черный ящик. Даже без просмотра он запускает все потоки FJP. Вы можете проверить через Thread.enumerate после завершения задачи. Например, IntStream.range(0,2) запустит только 1 дополнительный поток. - person Tagir Valeev; 25.09.2015
comment
Ага, понятно. Настоящая проблема с моим тестом заключалась в том, что я должен распараллелить до вашего flatMap, поскольку добавление невинного boxed() уже делает исходный разделитель неразделимым. Это прекрасно работает flatMap(IntStream.range(0, 1000000).boxed().parallel(), Stream::of).collect(Collectors.summingInt(Integer::intValue)). - person Tagir Valeev; 25.09.2015
comment
JDK flatMap работает параллельно, как показывает мой пример с более высокой рабочей нагрузкой. Он также запускает новые потоки в вашем примере, но назначение рабочих нагрузок, похоже, отличается. Кстати. в моей системе размещение parallel() не имело значения — оно могло разделить исходный диапазон, а разделение подпотоков (часть внутри if(split == null)) в целом не может иметь значения, так как в этом примере подпотоки имеют длину один. Но по какой-то причине реализация Stream разбивает диапазон снова и снова, вместо того, чтобы создавать ‹ количество ядер › рабочих наборов. - person Holger; 25.09.2015
comment
На самом деле причина в том, что вы отказываетесь оценивать размер своей задачи, просто всегда проходите Long.MAX_VALUE. Таким образом, движок Stream считает, что задача все еще достаточно велика и имеет смысл разделить ее дальше. Выполнение большего разделения, чем наличие ядер, очень разумно, так как вы можете быстрее закончить свою работу в случае исключения/короткого замыкания или иметь более сбалансированную нагрузку, если время обработки значительно различается в начале и в конце вашего потока. В этом случае движок Stream в значительной степени зависит от заявленного предполагаемого размера. - person Tagir Valeev; 25.09.2015
comment
Long.MAX_VALUE определяется API как «неизвестный размер», а не «очень большой размер». Если реализация потока интерпретирует это неправильно, это ошибка. Поскольку функция может возвращать что угодно, от пустого до бесконечного потока, я не думаю, что допустимо оценивать любой размер. Рассуждение тоже неверно. Если подзадачи выполняются быстрее из-за исключения или короткого замыкания, они не должны получать новые задачи, поскольку вся операция все равно должна завершиться. Тем не менее, из практических соображений добавлю. - person Holger; 25.09.2015
comment
@Holger: «Самое простое решение — подождать, пока оно не будет исправлено разработчиками JRE». – Вам известно о таких планах? - person user4235730; 25.09.2015
comment
Я принял этот ответ в основном из-за его первого абзаца: мне кажется, что сейчас нет элегантного решения моей проблемы, поэтому я пока выберу исходное решение и приму последствия. Но спасибо @Holger за эту реализацию flatMap, это было очень поучительно. - person user4235730; 29.09.2015
comment
Возможно, вы могли бы взглянуть на это; это может помочь тебе - person fge; 29.04.2016

Вы немного ошибаетесь, говоря, что поток flatMap не ленив. Он несколько ленив, хотя его лень действительно ограничена. Давайте используем некоторые пользовательские Collection для отслеживания запрошенных элементов внутри вашего класса Tree:

private final Set<String> requested = new LinkedHashSet<>();

private class MyList extends AbstractList<String> implements RandomAccess
{
    private final String[] data;

    public MyList(String... data) {
        this.data = data;
    }

    @Override
    public String get(int index) {
        requested.add(data[index]);
        return data[index];
    }

    @Override
    public int size() {
        return data.length;
    }
}

Теперь давайте предварительно инициализируем ваш класс некоторыми данными дерева:

public Tree() {
    // "1" is the root note, contains three immediate descendants
    edges.put("1", new MyList("2", "3", "4"));
    edges.put("2", new MyList("5", "6", "7"));
    edges.put("3", new MyList("8", "9", "10"));
    edges.put("8", new MyList("11", "12"));
    edges.put("5", new MyList("13", "14", "15"));
    edges.put("7", new MyList("16", "17", "18"));
    edges.put("6", new MyList("19", "20"));
}

Наконец, давайте проверим, сколько элементов на самом деле запрашивается из вашего списка при разных предельных значениях:

public static void main(String[] args) {
    for(int i=1; i<=20; i++) {
        Tree tree = new Tree();
        tree.descendants("1").limit(i).toArray();
        System.out.println("Limit = " + i + "; requested = (" + tree.requested.size()
                + ") " + tree.requested);
    }
}

Вывод следующий:

Limit = 1; requested = (0) []
Limit = 2; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 3; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 4; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 5; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 6; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 7; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 8; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 9; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 10; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 11; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 12; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 13; requested = (12) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18]
Limit = 14; requested = (18) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10]
Limit = 15; requested = (18) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10]
Limit = 16; requested = (18) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10]
Limit = 17; requested = (18) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10]
Limit = 18; requested = (18) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10]
Limit = 19; requested = (18) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10]
Limit = 20; requested = (19) [2, 5, 13, 14, 15, 6, 19, 20, 7, 16, 17, 18, 3, 8, 11, 12, 9, 10, 4]

Таким образом, когда запрашивается только корневая заметка, доступ к дочерним элементам не осуществляется (поскольку Stream.concat является умным). Когда запрашивается первый непосредственный дочерний элемент, обрабатывается все поддерево для этого дочернего элемента, даже если в нем нет необходимости. Тем не менее, второй непосредственный дочерний элемент не обрабатывается до тех пор, пока не завершится первый. Это может быть проблематично для сценариев короткого замыкания, но в большинстве случаев работа вашего терминала не является коротким замыканием, поэтому это все еще хороший подход.

Что касается ваших опасений по поводу потребления памяти: да, оно ест память в соответствии с глубиной дерева (и, что более важно, съедает стек). Если ваше дерево имеет тысячи уровней вложенности, у вас возникнут проблемы с вашим решением, поскольку вы можете нажать StackOverflowError для настройки по умолчанию -Xss. Для нескольких сотен уровней глубины это сработает нормально.

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

person Tagir Valeev    schedule 24.09.2015
comment
Другими словами, Stream ленив на первом уровне, но не дальше. Я отредактирую вопрос, чтобы отразить это. Однако основная проблема остается той же, верно? - person user4235730; 24.09.2015
comment
@ user4235730, в основном да. Еще многое зависит от того, как определить лень. Если вы хотите собрать результаты в список (с некоторой фильтрацией, сопоставлением и т. д.) или использовать терминальную операцию forEach, то следующий элемент не будет запрашиваться из дерева, пока не будет обработан предыдущий. Неленивость вы можете увидеть только при коротком замыкании или если вы используете stream.iterator()/stream.spliterator() напрямую. - person Tagir Valeev; 24.09.2015

Не реальный ответ, а просто мысль:

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

Это потому, что вам нужна как информация о более высоких уровнях в дереве (стеке), так и «указатель» на текущий элемент на текущем уровне. В этом случае одно вызывает другое.

Конечно, вы можете реализовать это как Spliterator, который содержит стек итераторов (указывающий на соответствующую коллекцию значений), но я полагаю, что на каждом уровне глубины всегда будет состояние «указатель», даже если оно скрыто в плоской карте Java ( или связанные) временные объекты.

В качестве альтернативы: как насчет использования «настоящего» дерева с узлами, которые содержат ссылку на его родительский узел? Кроме того, добавление карты в корень дерева, которая содержит ссылку на все отдельные узлы, чтобы упростить доступ к под-под-под-дочернему элементу. Я предполагаю, что реализация Spliterator тогда будет очень простой, потому что ей просто нужна ссылка на текущий узел для обхода и критерий остановки (начальное значение узла), чтобы перестать слишком «высоко» подниматься по дереву.

person jCoder    schedule 23.09.2015
comment
Я знаю, что состояние итерации должно где-то жить, но я хотел, чтобы оно было неявным. Если я прокручиваю рекурсию вручную, стек тоже есть, но с точки зрения итерации он неявно присутствует в среде выполнения. Если я использую примитив итерации более высокого порядка, такой как flatMap, состояние итерации скрыто в этом примитиве, и меня это тоже устраивает. Я просто хотел избежать определения собственного объекта, содержащего состояние итерации. Что касается вашей альтернативы: я не понимаю, что это нам даст. Не могли бы вы уточнить, как будет проще реализовать Spliterator? - person user4235730; 24.09.2015
comment
И, кстати, если это «ненастоящий ответ», может быть, вместо этого должен быть один или несколько комментариев? - person user4235730; 24.09.2015
comment
Комментарий был слишком длинным для поля комментариев, поэтому я пошел за ответом. При фокусировке на Spliterator.tryAdvance нужно помнить только индекс подузла и текущего узла; глубина внутри дерева автоматически поддерживается тем фактом, что каждый узел знает своего родителя. Нет необходимости в стеке. Но это будет вообще другая структура данных, чем ваша. - person jCoder; 24.09.2015
comment
Просто чтобы убедиться, что я правильно вас понял: вы говорите о представлении дерева левый дочерний элемент, правый родственный элемент? В этом случае: да, нужен только текущий узел. Но что вы подразумеваете под «индексом подузла»? - person user4235730; 25.09.2015
comment
В зависимости от реализации и предположения, что один родитель может иметь более одного или двух дочерних элементов, может потребоваться индекс, для которого следующий дочерний элемент. Но я думаю, что этот подход заходит слишком далеко в неправильном направлении в соответствии с вашим сценарием, поэтому лучше придерживайтесь одной из других идей;) - person jCoder; 25.09.2015

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

public class TreeIterator {
    private Tree tree;
    private List<String> topLevelNodes;

    public TreeIterator(Tree t, String node) {
        topLevelNodes = new List();
        topLevelNodes.add(node);
        tree = t;
    }

    public String next() {
        if (topLevelNodes.size() > 0) {
            int last = topLevelNodes.size() - 1;
            String result = topLevelNodes.get(last);
            topLevelNodes.remove(last);
            topLevelNodes.addAll(tree.get(result));
            return result;
        }
        return null;
    }
}

Извините за new List() и другие неправильные вещи, просто хотел поделиться идеей.

person sbeliakov    schedule 23.09.2015
comment
Хотя вы называете это List, topLevelNodes здесь действительно стек: вы только добавляете и удаляете в конце. И по сравнению со стеком итераторов, это материализует итерируемые объекты на каждом уровне, поэтому ему требуется больше памяти, чем необходимо. (Порядок сложности больше не высота, а высота, умноженная на степень узла.) Извините, но я не нахожу это «более элегантным». - person user4235730; 24.09.2015
comment
Да, я согласен с вашими доводами - person sbeliakov; 24.09.2015
comment
Почему вы используете get(last), а затем remove(last)? Это ненужный шаг. Просто используйте String result = topLevelNodes.remove(last);. Кроме того, использование ArrayDeque для эффективного удаления из головы позволит поддерживать правильный порядок узлов… - person Holger; 24.09.2015
comment
Спасибо за замечание о remove. Однако удаление из головы и вставка в конец увеличит потребление памяти, поэтому list (или просто stack) подходит лучше. - person sbeliakov; 24.09.2015

Давайте сначала ответим на вопрос, предоставив техническое обсуждение -

  1. TreeNode может также содержать ссылку на пользовательский объект, использование которого остается за пользователем. Запрашивая TreeNode его строковое представление с помощью toString(), вы возвращаете строковое представление своего пользовательского объекта.
  2. У узла дерева может быть не более одного родителя и 0 или более дочерних элементов. TreeNode предоставляет операции для проверки и изменения родительского и дочернего узлов узла, а также операции для проверки дерева, частью которого является узел. Дерево узлов — это набор всех узлов, до которых можно добраться, начав с узла и следуя всем возможным ссылкам на родителей и потомков. Узел без родителя является корнем своего дерева; узел без детей является листом. Дерево может состоять из множества поддеревьев, причем каждый узел выступает в качестве корня для своего собственного поддерева.
  3. Существующий DefaultMutableTrrNode Java 8 изменен.
  4. Этот класс предоставляет перечисления для эффективного обхода дерева или поддерева в различных порядках или для следования по пути между двумя узлами.
  5. Это не потокобезопасный класс. Если вы собираетесь использовать TreeNode (или дерево TreeNodes) более чем в одном потоке, вам необходимо выполнить собственную синхронизацию. Хорошим соглашением является синхронизация на корневом узле дерева.
  6. Сериализованные объекты этого класса не будут совместимы с будущими версиями Swing. Текущая поддержка сериализации подходит для краткосрочного хранения или RMI между приложениями, использующими одну и ту же версию Swing. Начиная с версии 1.4, в пакет java.beans добавлена ​​поддержка долговременного хранения всех JavaBeans™.

Проверьте эту модифицированную версию TreeNode, добавленную в Git — TreeNode

person Vaibhav Atray    schedule 21.05.2021
comment
вы пробовали новые модифицированные методы TreeNode, добавленные в Git? - person Vaibhav Atray; 24.05.2021