Microsoftの内部PriorityQueue <T>のバグ?


82

PresentationCore.dllの.NETFrameworkには、PriorityQueue<T>コードがここにある汎用クラスがあります。

並べ替えをテストするための短いプログラムを作成しましたが、結果は良くありませんでした。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using MS.Internal;

namespace ConsoleTest {
    public static class ConsoleTest {
        public static void Main() {
            PriorityQueue<int> values = new PriorityQueue<int>(6, Comparer<int>.Default);
            Random random = new Random(88);
            for (int i = 0; i < 6; i++)
                values.Push(random.Next(0, 10000000));
            int lastValue = int.MinValue;
            int temp;
            while (values.Count != 0) {
                temp = values.Top;
                values.Pop();
                if (temp >= lastValue)
                    lastValue = temp;
                else
                    Console.WriteLine("found sorting error");
                Console.WriteLine(temp);
            }
            Console.ReadLine();
        }
    }
}

結果:

2789658
3411390
4618917
6996709
found sorting error
6381637
9367782

ソートエラーがあり、サンプルサイズを大きくすると、それに比例してソートエラーの数が増加します。

私は何か間違ったことをした?そうでない場合、PriorityQueueクラスのコードのバグは正確にどこにありますか?


3
ソースコードのコメントによると、Microsoftは2005-02-14からこのコードを使用しています。このようなバグが12年以上にわたって通知を逃れたのだろうか?
ナット2017年

9
@Natは、Microsoftが使用する唯一の場所あり、優先度の低い書体を選択するフォントは、気付くのが難しいバグであるためです。
スコットチェンバレン

回答:


84

動作は、初期化ベクトルを使用して再現できます[0, 1, 2, 4, 5, 3]。結果は次のとおりです。

[0、1、2、4、3、5]

(3が正しく配置されていないことがわかります)

Pushアルゴリズムは正しいです。簡単な方法で最小ヒープを構築します。

  • 右下から始めます
  • 値が親ノードより大きい場合は、それを挿入して戻ります
  • それ以外の場合は、代わりに親を右下の位置に置き、親の場所に値を挿入してみてください(そして、適切な場所が見つかるまでツリーを入れ替え続けます)

結果のツリーは次のとおりです。

                 0
               /   \
              /     \
             1       2
           /  \     /
          4    5   3

問題はPopメソッドにあります。それは、トップノードを埋めるための「ギャップ」と見なすことから始まります(ポップしたため)。

                 *
               /   \
              /     \
             1       2
           /  \     /
          4    5   3

それを埋めるために、それは最も低い直接の子(この場合:1)を検索します。次に、値を上に移動してギャップを埋めます(そして、子が新しいギャップになります)。

                 1
               /   \
              /     \
             *       2
           /  \     /
          4    5   3

次に、新しいギャップでまったく同じことを行うため、ギャップは再び下に移動します。

                 1
               /   \
              /     \
             4       2
           /  \     /
          *    5   3

ギャップが最下部に達すると、アルゴリズムは...ツリーの右下の値を取得し、それを使用してギャップを埋めます。

                 1
               /   \
              /     \
             4       2
           /  \     /
          3    5   *

ギャップが右下のノードにあるので_count、ツリーからギャップを削除するためにデクリメントします。

                 1
               /   \
              /     \
             4       2
           /  \     
          3    5   

そして、私たちは最終的に...壊れたヒープになります。

正直なところ、作者が何をしようとしていたのかわからないので、既存のコードを修正することはできません。せいぜい、私はそれを作業バージョン(ウィキペディアから恥知らずにコピーしたもの)と交換することができます:

internal void Pop2()
{
    if (_count > 0)
    {
        _count--;
        _heap[0] = _heap[_count];

        Heapify(0);
    }
}

internal void Heapify(int i)
{
    int left = (2 * i) + 1;
    int right = left + 1;
    int smallest = i;

    if (left <= _count && _comparer.Compare(_heap[left], _heap[smallest]) < 0)
    {
        smallest = left;
    }

    if (right <= _count && _comparer.Compare(_heap[right], _heap[smallest]) < 0)
    {
        smallest = right;
    }

    if (smallest != i)
    {
        var pivot = _heap[i];
        _heap[i] = _heap[smallest];
        _heap[smallest] = pivot;

        Heapify(smallest);
    }
}

そのコードの主な問題は再帰的な実装であり、要素の数が多すぎると壊れます。代わりに、最適化されたサードパーティライブラリを使用することを強くお勧めします。


編集:私は何が欠けているのかを見つけたと思います。右下のノードを取得した後、作成者はヒープのバランスを取り直すのを忘れました。

internal void Pop()
{
    Debug.Assert(_count != 0);

    if (_count > 1)
    {
        // Loop invariants:
        //
        //  1.  parent is the index of a gap in the logical tree
        //  2.  leftChild is
        //      (a) the index of parent's left child if it has one, or
        //      (b) a value >= _count if parent is a leaf node
        //
        int parent = 0;
        int leftChild = HeapLeftChild(parent);

        while (leftChild < _count)
        {
            int rightChild = HeapRightFromLeft(leftChild);
            int bestChild =
                (rightChild < _count && _comparer.Compare(_heap[rightChild], _heap[leftChild]) < 0) ?
                    rightChild : leftChild;

            // Promote bestChild to fill the gap left by parent.
            _heap[parent] = _heap[bestChild];

            // Restore invariants, i.e., let parent point to the gap.
            parent = bestChild;
            leftChild = HeapLeftChild(parent);
        }

        // Fill the last gap by moving the last (i.e., bottom-rightmost) node.
        _heap[parent] = _heap[_count - 1];

        // FIX: Rebalance the heap
        int index = parent;
        var value = _heap[parent];

        while (index > 0)
        {
            int parentIndex = HeapParent(index);
            if (_comparer.Compare(value, _heap[parentIndex]) < 0)
            {
                // value is a better match than the parent node so exchange
                // places to preserve the "heap" property.
                var pivot = _heap[index];
                _heap[index] = _heap[parentIndex];
                _heap[parentIndex] = pivot;
                index = parentIndex;
            }
            else
            {
                // Heap is balanced
                break;
            }
        }
    }

    _count--;
}

4
「アルゴリズムエラー」とは、ギャップを下に移動するのではなく、最初にツリーを縮小して、そのギャップに右下の要素を配置することです。次に、単純な反復ループでツリーを修復します。
ヘンクホルターマン2017年

5
これはバグレポートの良い資料です。この投稿へのリンクを付けて報告する必要があります(PresentationCoreはGitHubにないためMS接続で適切な場所になると思います)。
Lucas Trzesniewski 2017年

4
@LucasTrzesniewski実際のアプリケーションへの影響はわかりませんが(WPFの一部のあいまいなフォント選択コードにのみ使用されているため)、報告しても問題はないと思います
Kevin Gosse 2017年

20

KevinGosseの答えが問題を特定します。彼のヒープのリバランスは機能しますが、元の削除ループの根本的な問題を修正する場合は必要ありません。

彼が指摘したように、アイデアは、ヒープの一番上にあるアイテムを一番下の右端のアイテムに置き換えてから、適切な場所にふるいにかけることです。これは、元のループを単純に変更したものです。

internal void Pop()
{
    Debug.Assert(_count != 0);

    if (_count > 0)
    {
        --_count;
        // Logically, we're moving the last item (lowest, right-most)
        // to the root and then sifting it down.
        int ix = 0;
        while (ix < _count/2)
        {
            // find the smallest child
            int smallestChild = HeapLeftChild(ix);
            int rightChild = HeapRightFromLeft(smallestChild);
            if (rightChild < _count-1 && _comparer.Compare(_heap[rightChild], _heap[smallestChild]) < 0)
            {
                smallestChild = rightChild;
            }

            // If the item is less than or equal to the smallest child item,
            // then we're done.
            if (_comparer.Compare(_heap[_count], _heap[smallestChild]) <= 0)
            {
                break;
            }

            // Otherwise, move the child up
            _heap[ix] = _heap[smallestChild];

            // and adjust the index
            ix = smallestChild;
        }
        // Place the item where it belongs
        _heap[ix] = _heap[_count];
        // and clear the position it used to occupy
        _heap[_count] = default(T);
    }
}

記述されたコードにメモリリークがあることにも注意してください。このコードのビット:

        // Fill the last gap by moving the last (i.e., bottom-rightmost) node.
        _heap[parent] = _heap[_count - 1];

から値をクリアしません_heap[_count - 1]。ヒープが参照型を格納している場合、参照はヒープに残り、ヒープのメモリがガベージコレクションされるまでガベージコレクションできません。このヒープがどこで使用されているかはわかりませんが、ヒープが大きく、かなりの時間存続すると、メモリが過剰に消費される可能性があります。答えは、コピー後にアイテムをクリアすることです。

_heap[_count - 1] = default(T);

私の交換コードにはその修正が組み込まれています。


1
私がテストしたベンチマーク(pastebin.com/Hgkcq3exにあります)では、このバージョンは、Kevin Gosseによって提案されたバージョンよりも約18%遅くなっています(clear to default()行が削除され、_count/2計算が外部に引き上げられた場合でも)ループ)。
MathuSum Mut 2017

@MathuSumMut:最適化されたバージョンを提供しました。アイテムを配置して継続的に交換するのではなく、代わりに、配置されているアイテムと比較します。これにより書き込みの数が減るので、速度が上がるはずです。別の可能な最適化は_heap[_count]、一時的なものにコピーすることです。これにより、配列参照の数が減ります。
ジムミッシェル2017年

残念ながら、これを試してみましたが、バグもあるようです。int型のキューを設定し、次のカスタム比較子を使用します。-Comparer<int>.Create((i1, i2) => -i1.CompareTo(i2))つまり、最大から最小にソートします(負の符号に注意してください)。3、1、5、0、4の番号を順番に押してから、すべてのキューをデキューした後、返される順序は{5,4,1,3,0}でした。ほとんどの場合、まだ並べ替えられていますが、1と3の順序が間違っています。上記のGosseの方法を使用しても、この問題は発生しませんでした。通常の昇順ではこの問題は発生しなかったことに注意してください。
ニコラスピーターセン2017

1
@NicholasPetersen:興味深い。私はそれを調べる必要があります。メモをありがとう。
ジムミッシェル2017

2
@JimMischelのコードのバグ:比較rightChild < _count-1rightChild < _count。である必要があります。これは、カウントを正確な2の累乗から減らす場合、およびギャップがツリーの右端まで移動する場合にのみ重要です。一番下では、rightChildは左の兄弟と比較されておらず、間違った要素が昇格してヒープを壊す可能性があります。ツリーが大きいほど、これが発生する可能性は低くなります。カウントを4から3に減らすと表示される可能性が最も高くなります。これは、ニコラス・ピーターセンが「最後のカップルのアイテム」について観察したことを説明しています。
サム・ベント- MSFT

0

.NET Framework4.8では再現できません

PriorityQueue<T>次のXUnitテストを使用して、質問にリンクされているの.NET Framework4.8実装を使用して2020年にこの問題を再現しようとしています ...

public class PriorityQueueTests
{
    [Fact]
    public void PriorityQueueTest()
    {
        Random random = new Random();
        // Run 1 million tests:
        for (int i = 0; i < 1000000; i++)
        {
            // Initialize PriorityQueue with default size of 20 using default comparer.
            PriorityQueue<int> priorityQueue = new PriorityQueue<int>(20, Comparer<int>.Default);
            // Using 200 entries per priority queue ensures possible edge cases with duplicate entries...
            for (int j = 0; j < 200; j++)
            {
                // Populate queue with test data
                priorityQueue.Push(random.Next(0, 100));
            }
            int prev = -1;
            while (priorityQueue.Count > 0)
            {
                // Assert that previous element is less than or equal to current element...
                Assert.True(prev <= priorityQueue.Top);
                prev = priorityQueue.Top;
                // remove top element
                priorityQueue.Pop();
            }
        }
    }
}

... 100万のテストケースすべてで成功します。

ここに画像の説明を入力してください

したがって、Microsoftは実装のバグを修正したようです。

internal void Pop()
{
    Debug.Assert(_count != 0);
    if (!_isHeap)
    {
        Heapify();
    }

    if (_count > 0)
    {
        --_count;

        // discarding the root creates a gap at position 0.  We fill the
        // gap with the item x from the last position, after first sifting
        // the gap to a position where inserting x will maintain the
        // heap property.  This is done in two phases - SiftDown and SiftUp.
        //
        // The one-phase method found in many textbooks does 2 comparisons
        // per level, while this method does only 1.  The one-phase method
        // examines fewer levels than the two-phase method, but it does
        // more comparisons unless x ends up in the top 2/3 of the tree.
        // That accounts for only n^(2/3) items, and x is even more likely
        // to end up near the bottom since it came from the bottom in the
        // first place.  Overall, the two-phase method is noticeably better.

        T x = _heap[_count];        // lift item x out from the last position
        int index = SiftDown(0);    // sift the gap at the root down to the bottom
        SiftUp(index, ref x, 0);    // sift the gap up, and insert x in its rightful position
        _heap[_count] = default(T); // don't leak x
    }
}

質問のリンクはMicrosoftのソースコードの最新バージョン(現在は.NET Framework 4.8)を指しているだけなので、コードで何が正確に変更されたかを正確に言うのは難しいですが、特にメモリリークをしないように明示的なコメントがあります。 @JimMischelの回答に記載されているメモリリークも対処されていると想定します。これは、VisualStudio診断ツールを使用して確認できます。

ここに画像の説明を入力してください

メモリリークが発生した場合、数百万回のPop()操作後にここでいくつかの変更が見られます...

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.