9

今日は、任意の深さのグラフをトラバースして、それを単一の列挙可能なものにフラット化するメソッドを実装する予定でした。代わりに、私は最初に少し検索して、これを見つけました:

public static IEnumerable<T> Traverse<T>(this IEnumerable<T> enumerable, Func<T, IEnumerable<T>> recursivePropertySelector)
{
    foreach (T item in enumerable)
    {
        yield return item;

        IEnumerable<T> seqRecurse = recursivePropertySelector(item);

        if (seqRecurse == null) continue;
        foreach (T itemRecurse in Traverse(seqRecurse, recursivePropertySelector))
        {
            yield return itemRecurse;
        }
    }
}

理論的にはこれは良さそうに見えますが、実際には、同等の手書きコードを使用してグラフを調べ、必要なことをすべて実行するよりも、パフォーマンスが大幅に低下することがわかりました。これは、このメソッドでは、返されるすべてのアイテムについて、スタックが任意の深さのレベルまで巻き戻される必要があるためだと思います。

また、再帰が排除されれば、このメソッドははるかに効率的に実行されると思います。また、再帰を排除するのがあまり得意ではありません。

再帰を排除するためにこのメソッドを書き直す方法を知っている人はいますか?

助けてくれてありがとう。

編集:すべての詳細な回答をありがとうございました。元のソリューションとEricのソリューションのベンチマークを試しましたが、列挙子メソッドを使用せず、代わりにラムダを再帰的にトラバースしました。奇妙なことに、ラムダ再帰は他の2つのメソッドのいずれよりも大幅に高速です。

class Node
{
    public List<Node> ChildNodes { get; set; } 

    public Node()
    {
        ChildNodes = new List<Node>();
    }
}

class Foo
{
    public static void Main(String[] args) 
    {
        var nodes = new List<Node>();
        for(int i = 0; i < 100; i++)
        {
            var nodeA = new Node();
            nodes.Add(nodeA);
            for (int j = 0; j < 100; j++)
            {
                var nodeB = new Node();
                nodeA.ChildNodes.Add(nodeB);
                for (int k = 0; k < 100; k++)
                {
                    var nodeC = new Node();
                    nodeB.ChildNodes.Add(nodeC);
                    for(int l = 0; l < 12; l++)
                    {
                        var nodeD = new Node();
                        nodeC.ChildNodes.Add(nodeD);
                    }
                }
            }
        }            

        nodes.TraverseOld(node => node.ChildNodes).ToList();
        nodes.TraverseNew(node => node.ChildNodes).ToList();

        var watch = Stopwatch.StartNew();
        nodes.TraverseOld(node => node.ChildNodes).ToList();
        watch.Stop();
        var recursiveTraversalTime = watch.ElapsedMilliseconds;
        watch.Restart();
        nodes.TraverseNew(node => node.ChildNodes).ToList();
        watch.Stop();
        var noRecursionTraversalTime = watch.ElapsedMilliseconds;

        Action<Node> visitNode = null;
        visitNode = node =>
        {
            foreach (var child in node.ChildNodes)
                visitNode(child);
        };

        watch.Restart();
        foreach(var node in nodes)
            visitNode(node);
        watch.Stop();
        var lambdaRecursionTime = watch.ElapsedMilliseconds;
    }
}

TraverseOldが元のメソッドであるのに対し、TraverseNewはEricのメソッドであり、明らかにラムダはラムダです。

私のマシンでは、TraverseOldは10127ミリ秒、TraverseNewは3038ミリ秒、ラムダ再帰は1181ミリ秒かかります。

これは、列挙子メソッド(yield returnを使用)が即時実行とは対照的に3倍の時間がかかる可能性があるという典型的なものですか?それともここで何か他のことが起こっていますか?

4

4 に答える 4

21

まず第一に、あなたは絶対に正しいです。グラフに平均深度dのノードがn個ある場合、単純なネストされたイテレーターは、時間的にO(n * d)、スタック内にO(d)の解を生成します。dがnの大部分である場合、これはO(n 2)アルゴリズムになる可能性があり、dが大きい場合は、スタックを完全に吹き飛ばすことができます。

ネストされたイテレーターのパフォーマンス分析に興味がある場合は、元C#コンパイラ開発者のWesDyerのブログ投稿を参照してください。

http://blogs.msdn.microsoft.com/wesdyer/2007/03/23/all-about-iterators

dasblinkenlightのソリューションは、標準的なアプローチのバリエーションです。私は通常、次のようなプログラムを作成します。

public static IEnumerable<T> Traverse<T>(
    T root, 
    Func<T, IEnumerable<T>> children)
{
    var stack = new Stack<T>();
    stack.Push(root);
    while(stack.Count != 0)
    {
        T item = stack.Pop();
        yield return item;
        foreach(var child in children(item))
            stack.Push(child);
    }
}

そして、あなたが複数のルーツを持っている場合:

public static IEnumerable<T> Traverse<T>(
    IEnumerable<T> roots, 
    Func<T, IEnumerable<T>> children)
{
    return from root in roots 
           from item in Traverse(root, children)
           select item ;
}

ここで、高度に相互接続されたグラフまたは循環グラフがある場合、トラバーサルは必要なものではないことに注意してください。下向きの矢印が付いたグラフがある場合:

          A
         / \
        B-->C
         \ /
          D

その場合、トラバーサルはA、B、D、C、D、C、Dです。循環グラフまたは相互接続されたグラフがある場合、必要なのは推移閉包です。

public static IEnumerable<T> Closure<T>(
    T root, 
    Func<T, IEnumerable<T>> children)
{
    var seen = new HashSet<T>();
    var stack = new Stack<T>();
    stack.Push(root);

    while(stack.Count != 0)
    {
        T item = stack.Pop();
        if (seen.Contains(item))
            continue;
        seen.Add(item);
        yield return item;
        foreach(var child in children(item))
            stack.Push(child);
    }
}

このバリエーションでは、これまでに生成されたことがないアイテムのみが生成されます。

また、再帰を排除するのがあまり得意ではありません。

私は、再帰を排除する方法と、一般的な再帰プログラミングについて、いくつかの記事を書いてきました。この主題に興味がある場合は、以下を参照してください。

http://blogs.msdn.com/b/ericlippert/archive/tags/recursion/

特に:

http://blogs.msdn.com/b/ericlippert/archive/2005/08/01/recursion-part-two-unrolling-a-recursive-function-with-an-explicit-stack.aspx

http://blogs.msdn.com/b/ericlippert/archive/2005/08/04/recursion-part-three-building-a-dispatch-engine.aspx

http://blogs.msdn.com/b/ericlippert/archive/2005/08/08/recursion-part-four-continuation-passing-style.aspx

于 2012-04-20T21:10:21.063 に答える
8

そうです、コード内で木やグラフを再帰的に歩くことはyield return、非効率の大きな原因です。

一般に、再帰コードはスタックを使用して書き直します。これは、コンパイルされたコードで通常実装される方法と同様の方法です。

私はそれを試す機会がありませんでしたが、これはうまくいくはずです:

public static IEnumerable<T> Traverse<T>(this IEnumerable<T> enumerable, Func<T, IEnumerable<T>> recursivePropertySelector) {
    var stack = new Stack<IEnumerable<T>>();
    stack.Push(enumerable);
    while (stack.Count != 0) {
        enumerable = stack.Pop();
        foreach (T item in enumerable) {
            yield return item;
            var seqRecurse = recursivePropertySelector(item);
            if (seqRecurse != null) {
                stack.Push(seqRecurse);
            }
        }
    }
}
于 2012-04-20T20:42:40.563 に答える
2

再帰がスタックでどのように機能するかの基本を複製することで、いつでも再帰を排除できます。

  1. スタックの一番上に最初のアイテムを置きます
  2. スタックが空でない間に、スタックからアイテムをポップします
  3. 現在のノードに子がある場合は、それらをスタックに追加します
  4. 現在のアイテムを返します。
  5. 手順1に進みます。

クレイジースマート理論的答え:https ://stackoverflow.com/a/933979/29093

http://cs.saddleback.edu/rwatkins/CS2B/Lab%20Exercises/Stacks%20and%20Recursion%20Lab.pdf

于 2012-04-20T20:40:02.920 に答える
0

コードでキューを使用できます。キューは、最上位ノードに等しい1つの要素を持つリストとして初期化できます。次に、リストの各要素を最初の要素から繰り返し処理する必要があります。最初の要素に子ノードが含まれている場合は、それらすべてをキューの最後に追加します。次に、次の要素に移動します。キューの最後に到達すると、グラフは完全にフラットになります。

于 2012-04-20T20:38:14.143 に答える