В Java, как эффективно и элегантно потопить потомков дерева node?

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

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

  // ...

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

Я хотел бы включить потоковое воспроизведение потомков node. Простым решением является следующее:

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)
    );
}

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

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

  • Как я понимаю this, как только я выполняю операцию завершения на Stream, возвращенном из descendants, вызов корневого уровня flatMap приведет к тому, что все содержащиеся Stream - по одному для каждого (рекурсивного) вызова descendants - будут реализованы немедленно. Таким образом, результат Stream ленив только на первом уровне рекурсии, но не выше. (Отредактировано согласно Ответ Тагира Валеева.)

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

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

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

Ответ 1

Для меня ваше решение уже настолько изящно, насколько возможно, и ограниченная лень этого не ваша вина. Самое простое решение - подождать, пока оно не будет исправлено разработчиками JRE.

Однако, если эта ограниченная лень сегодняшней реализации действительно вызывает беспокойство, возможно, это время решить это в общем виде. Ну, речь идет о реализации 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 в командной строке.

Ответ 2

Вы немного ошибаетесь, говоря, что поток 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 уровней.

Ответ 3

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

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

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

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

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

Ответ 4

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

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() и другие неправильные вещи, просто хотел поделиться идеей.