【问题标题】:In Java, how do I efficiently and elegantly stream a tree node's descendants?在 Java 中,如何高效、优雅地流式传输树节点的后代?
【发布时间】:2015-09-23 20:44:55
【问题描述】:

假设我们有一个由唯一的Strings 标识的对象集合,以及一个定义它们的层次结构的类Tree。该类是使用Map 从节点(由它们的 ID 表示)到它们各自子 ID 的 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. 遍历从descendants 返回的Stream 会消耗资源(时间和内存)——相对于树的大小——其复杂性顺序与手动编码递归的顺序相同。特别是,代表迭代状态的中间对象 (Streams, Spliterators, ...) 形成一个堆栈,因此任何给定时间的内存需求与树的深度的复杂度顺序相同。

  2. 据我了解this,一旦我对从descendants 返回的Stream 执行终止操作,对flatMap 的根级调用将导致所有包含的Streams –对descendants 的每个(递归)调用一个 - 立即实现。因此,生成的Stream 仅在第一级递归上是惰性的,但不会超出。 (根据Tagir Valeevs answer编辑。)

如果我正确理解了这些要点,我的问题是:如何定义 descendants 以使生成的 Stream 是惰性的?

我希望解决方案尽可能优雅,因为我更喜欢隐式迭代状态的解决方案。 (澄清我的意思:我知道我可以编写一个 Spliterator 遍历树,同时在每个级别上保持一个显式的 Spliterators 堆栈。我想避免这种情况。)

(Java 中是否有一种方法可以将其表述为生产者-消费者工作流,就像在 Julia 和 Go 等语言中使用的那样?)

【问题讨论】:

  • 自定义Spliterator 似乎是您想要的...
  • 我会认真考虑使用 Guava 的 TreeTraverser 之类的东西,然后将其包装为 Stream
  • this answer 包含一些增加懒惰的建议,也许它适用于你的情况。

标签: java algorithm java-8 java-stream


【解决方案1】:

对我来说,您的解决方案已经尽可能优雅,它的有限懒惰不是您的错。最简单的解决方案是等到 JRE 开发人员修复它。 It has been done with 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;
    }
}

使用它只需在代码中添加flatMap 方法的import static 并将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 时。

【讨论】:

  • 有趣的解决方案。尝试flatMap(IntStream.range(0, 1000000).boxed(), Stream::of).parallel().collect(Collectors.summingInt(Integer::intValue)) 并与 JDK 版本进行比较。你的速度非常慢(比如慢了 30-50 倍)。
  • 请注意,当像flatMap(IntStream.range(0, 1000).boxed().parallel(), Stream::of) .map(i-&gt;{ LockSupport.parkNanos(1); return i; }) .collect(Collectors.summingInt(Integer::intValue)); 那样提高每个项目的开销时,执行确实受益于并行执行,并且两种实现都相当。
  • 哦,我明白了。我的测试的真正问题是我应该并行化 before 你的 flatMap 因为添加无辜的boxed() 已经使源拆分器不可拆分。这工作正常flatMap(IntStream.range(0, 1000000).boxed().parallel(), Stream::of).collect(Collectors.summingInt(Integer::intValue))
  • Long.MAX_VALUE 由 API 指定为“未知大小”,而不是“非常大的大小”。如果流实现解释错了,那就是一个错误。由于该函数可以返回从空流到无限流的任何东西,我认为估计任何大小都是无效的。推理也是错误的。如果子任务由于异常或短路而更快完成,它们不应该获得新任务,因为整个操作无论如何都应该结束。尽管如此,出于实际目的,我还是添加了它。
  • 也许你可以看看this;它可以帮助你
【解决方案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 层。

【讨论】:

  • 所以换句话说,Stream 在第一层是懒惰的,但不会超过。我将编辑问题以反映这一点。不过,基本问题保持不变,对吧?
  • @user4235730,基本上是的。仍然有很多事情取决于如何定义懒惰。如果要将结果收集到列表中(带有一些过滤、映射等),或者使用 forEach 终端操作,则在处理前一个元素之前,不会从树中请求下一个元素。您只能在短路或直接使用 stream.iterator()/stream.spliterator() 时看到非懒惰。
【解决方案3】:

不是真正的答案,只是一个想法:

如果您查看值集合并在下一步将最后看到的值“解析”为一个新的值集合,以相同的方式递归返回下一个值,那么无论如何实现,它总是会以一些结果结束一种指向树中当前深度“级别”的值集合中当前元素的“指针”,并且还有某种堆栈保存所有这些“指针”。

这是因为您既需要有关树(堆栈)中更高级别的信息,也需要指向当前级别的当前元素的“指针”。在这种情况下,一个导致另一个。

当然,你可以将它实现为一个Spliterator,它包含一个迭代器堆栈(指向相应的值集合),但我想每个深度级别总会有一个“指针”状态,即使它是隐藏在 Java 的 flatMap(或相关)临时对象中。

作为替代方案:如何使用具有对其父节点的引用的节点的“真实”树?另外,向树的根添加一个映射,该映射包含对所有单个节点的引用,以简化对子子子子项的访问。我猜Spliterator 的实现会非常简单,因为它只需要一个对当前节点的引用以进行遍历和一个停止标准(初始节点值)来停止在树中走得太“高”。

【讨论】:

  • 我知道迭代状态必须存在于某个地方,但我希望它是隐式的。如果我手动滚动递归,还有一个堆栈,但从迭代的角度来看,它隐含在运行时环境中。如果我使用像flatMap 这样的高阶迭代原语,则迭代状态隐藏在该原语中,这对我来说也很好。我只是想避免定义我自己的持有迭代状态的对象。关于您的替代方案:我不明白那会给我们带来什么。您能否详细说明Spliterator 的实现会如何更容易?
  • 顺便说一句,如果这不是“真正的答案”,也许应该是一个或多个 cmets?
  • 评论对于评论字段来说太长了,所以我去找了一个答案。当关注Spliterator.tryAdvance 时,只需要记住子节点和当前节点的索引;由于每个节点都知道其父节点,因此树内部的深度会自动保持。不需要堆栈。但这将是一个与您的数据结构大体上不同的数据结构。
  • 只是为了确保我理解正确:您是在谈论 left-child, right-sibling 树表示?在那种情况下:是的,只需要当前节点。但是“子节点索引”是什么意思?
  • 取决于实现并假设一个父级可以有多个或两个子级,则可能需要下一个子级的索引。但是我想根据您的情况,这种方法在错误的方向上走得太远了,所以最好坚持其他想法;)
【解决方案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()和其他不正确的事情,只是想分享一下。

【讨论】:

  • 虽然你称它为ListtopLevelNodes 这里真的是一个堆栈:你只在最后追加和删除。并且与一堆迭代器相比,这会在每一层实现迭代中的对象,因此它需要更多的内存。 (复杂度的顺序不再是高度,而是高度乘以节点度数。)抱歉,我不觉得这“更优雅”。
  • 是的,我同意你的论点
  • 你为什么用get(last),后面跟着remove(last)?这是一个不必要的步骤。只需使用String result = topLevelNodes.remove(last);。除此之外,使用ArrayDeque 能够有效地从头部删除将允许保持节点的正确顺序......
  • 感谢您对remove 的评论。但是从头部删除并插入尾部会增加内存消耗,因此list(或只是stack)更适合。
【解决方案5】:

让我们先通过技术讨论来回答问题--

  1. TreeNode 还可以保存对用户对象的引用,其使用权留给用户。使用 toString()TreeNode 询问其字符串表示形式会返回其用户对象的字符串表示形式。
  2. 一个树节点最多可以有一个父节点和 0 个或多个子节点。 TreeNode 提供检查和修改节点的父节点和子节点的操作,以及检查节点所属的树的操作。一个节点的树是所有节点的集合,这些节点可以通过从节点开始并沿着所有可能的链接到父节点和子节点来到达。没有父节点的节点是其树的根;没有孩子的节点是叶子。一棵树可能由许多子树组成,每个节点都充当自己子树的根。
  3. Java 8 现有的 DefaultMutableTrrNode 被修改。
  4. 该类提供了枚举,用于以各种顺序有效地遍历树或子树,或遵循两个节点之间的路径。
  5. 这不是线程安全的类。如果您打算在多个线程中使用 TreeNode(或 TreeNode 的树),则需要自己进行同步。一个好的约定是在树的根节点上进行同步。
  6. 此类的序列化对象将与未来的 Swing 版本不兼容。当前的序列化支持适用于运行相同版本 Swing 的应用程序之间的短期存储或 RMI。从 1.4 开始,支持长期存储 已将所有 JavaBeans™ 添加到 java.beans 包中。

检查在 Git 中贡献的 TreeNode 的修改版本 - TreeNode

【讨论】:

  • 你尝试过 Git 贡献的 TreeNode 的新修改方法吗?
猜你喜欢
  • 2020-04-06
  • 1970-01-01
  • 2020-08-15
  • 1970-01-01
  • 1970-01-01
  • 2016-01-21
  • 2022-08-10
  • 2010-10-21
  • 2017-01-15
相关资源
最近更新 更多