В Java, как да предавам ефективно и елегантно потомците на дървовиден възел?

Да приемем, че имаме колекция от обекти, които се идентифицират с уникални Strings, заедно с клас Tree, който дефинира йерархия върху тях. Този клас е имплементиран с помощта на Map от възли (представени от техните идентификатори) до Collections от техните съответни идентификатори на деца.

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, изразходва ресурси (време и памет) – спрямо размера на дървото – в същия ред на сложност, в който би било ръчното кодиране на рекурсията. По-специално, междинните обекти, представляващи състоянието на итерация (Streams, Spliterators, ...), образуват стек и следователно изискването за памет във всеки даден момент е в същия ред на сложност като дълбочината на дървото.

  2. Доколкото разбирам това, веднага щом извърша операция за прекратяване на Stream, върната от descendants, извикването на основно ниво към flatMap ще накара всички съдържащи се Streams – по едно за всяко (рекурсивно) повикване към descendants – да бъдат реализирани незабавно. По този начин полученият Stream е мързелив само на първото ниво на рекурсия, но не и след това. (Редактирано според отговора на Тагир Валеев.)

Ако съм разбрал правилно тези точки, въпросът ми е следният: Как мога да дефинирам descendants, така че полученото Stream да е мързеливо?

Бих искал решението да бъде възможно най-елегантно, в смисъл, че предпочитам решение, което оставя състоянието на итерация имплицитно. (За да изясня какво имам предвид с това: знам, че мога да напиша Spliterator, което върви по дървото, като същевременно поддържа изричен стек от Spliterators на всяко ниво. Бих искал да избегна това.)

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


person user4235730    schedule 23.09.2015    source източник
comment
Персонализиран Spliterator изглежда е това, което искате...   -  person fge    schedule 23.09.2015
comment
много подобен въпрос - stackoverflow.com/questions/32656888/   -  person ZhongYu    schedule 24.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 прави паралелно и дори с допълнителна операция peek много по-бързо от вашето. Тръбопроводът на потока не е достатъчно умен, за да измери 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 engine смята, че задачата все още е достатъчно голяма и е разумно да я разделим допълнително. Да правите повече разделяне, отколкото да имате ядра, е много разумно, тъй като можете да завършите работата си по-бързо в случай на изключение/късо съединение или да имате по-балансирано натоварване, ако времето за обработка се различава значително в началото и в края на вашия поток. В този случай Stream engine силно разчита на докладвания приблизителен размер. - 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, който съдържа стек от итератори (сочещи към съответната колекция от стойности), но предполагам, че винаги ще има състояние на "указател" на всяко ниво на дълбочина, дори ако е скрито в flatMap на 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