Błąd w wewnętrznym PriorityQueue <T> firmy Microsoft?

82

W .NET Framework w PresentationCore.dll istnieje PriorityQueue<T>klasa ogólna, której kod można znaleźć tutaj .

Napisałem krótki program do testowania sortowania, ale wyniki nie były świetne:

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

Wyniki:

2789658
3411390
4618917
6996709
found sorting error
6381637
9367782

Występuje błąd sortowania, a jeśli rozmiar próby jest zwiększony, liczba błędów sortowania wzrasta proporcjonalnie.

Czy zrobiłem coś złego? Jeśli nie, to gdzie dokładnie znajduje się błąd w kodzie PriorityQueueklasy?

MathuSum Mut
źródło
3
Zgodnie z komentarzami w kodzie źródłowym, Microsoft używa tego kodu od 14.02.2005. Zastanawiam się, jak taki błąd umknął uwadze ponad 12 lat?
Nat
9
@Nat, ponieważ jedyne miejsce, w którym firma Microsoft go używa, znajduje się tutaj, a czcionka wybierająca krój o niższym priorytecie przez pewien czas jest trudnym do zauważenia błędem.
Scott Chamberlain

Odpowiedzi:

84

Zachowanie można odtworzyć za pomocą wektora inicjalizacyjnego [0, 1, 2, 4, 5, 3] . Wynik to:

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

(widzimy, że 3 jest nieprawidłowo umieszczone)

PushAlgorytm jest poprawny. Buduje min-stertę w prosty sposób:

  • Zacznij od prawego dolnego rogu
  • Jeśli wartość jest większa niż węzeł macierzysty, wstaw ją i zwróć
  • W przeciwnym razie umieść rodzica w prawym dolnym rogu, a następnie spróbuj wstawić wartość w miejscu nadrzędnym (i kontynuuj zamianę drzewa, aż zostanie znalezione właściwe miejsce)

Powstałe drzewo to:

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

Problem dotyczy Popmetody. Zaczyna się od potraktowania górnego węzła jako „luki” do wypełnienia (odkąd go usunęliśmy):

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

Aby go wypełnić, wyszukuje najniższe bezpośrednie dziecko (w tym przypadku: 1). Następnie przesuwa wartość w górę, aby wypełnić lukę (a dziecko jest teraz nową luką):

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

Następnie robi dokładnie to samo z nową luką, więc luka ponownie się obniża:

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

Kiedy luka osiągnęła najniższy poziom, algorytm ... pobiera skrajną prawą dolną wartość drzewa i wykorzystuje ją do wypełnienia luki:

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

Teraz, gdy przerwa znajduje się w prawym dolnym węźle, zmniejsza się, _countaby usunąć lukę z drzewa:

                 1
               /   \
              /     \
             4       2
           /  \     
          3    5   

I kończymy z ... Zepsuty stos.

Szczerze mówiąc, nie rozumiem, co autor próbował zrobić, więc nie mogę naprawić istniejącego kodu. Co najwyżej mogę zamienić go na działającą wersję (bezwstydnie skopiowaną z Wikipedii ):

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

Głównym problemem z tym kodem jest rekurencyjna implementacja, która zepsuje się, jeśli liczba elementów będzie zbyt duża. W zamian zdecydowanie zalecam używanie zoptymalizowanej biblioteki innej firmy.


Edycja: Myślę, że odkryłem, czego brakuje. Po wybraniu węzła znajdującego się najbardziej na dole po prawej stronie, autor po prostu zapomniał o ponownym zbilansowaniu sterty:

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--;
}
Kevin Gosse
źródło
4
„Błąd algorytmiczny” polega na tym, że nie należy przesuwać przerwy w dół, ale najpierw zmniejszyć drzewo i umieścić w tej luce prawy dolny element. Następnie napraw drzewo w prostej pętli iteracyjnej.
Henk Holterman
5
To dobry materiał na raport o błędzie, powinieneś zgłosić go z linkiem do tego postu (myślę, że właściwym miejscem byłoby połączenie MS, ponieważ PresentationCore nie jest na GitHub).
Lucas Trzesniewski
4
@LucasTrzesniewski Nie jestem pewien wpływu na rzeczywistą aplikację (ponieważ jest używany tylko do jakiegoś niejasnego kodu wyboru czcionek w WPF), ale myślę, że nie zaszkodzi to zgłosić
Kevin Gosse
20

Odpowiedź Kevina Gosse identyfikuje problem. Chociaż jego ponowne równoważenie sterty zadziała, nie jest to konieczne, jeśli naprawisz podstawowy problem w pierwotnej pętli usuwania.

Jak zaznaczył, chodzi o to, aby zastąpić przedmiot znajdujący się na szczycie stosu najniższym, najbardziej po prawej stronie, a następnie przesiać go w odpowiednie miejsce. To prosta modyfikacja oryginalnej pętli:

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

Należy również zauważyć, że w napisanym kodzie występuje wyciek pamięci. Ten fragment kodu:

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

Nie usuwa wartości z _heap[_count - 1]. Jeśli sterta przechowuje typy odwołań, odwołania pozostają w stercie i nie mogą zostać wyrzucone do pamięci, dopóki pamięć sterty nie zostanie wyrzucona. Nie wiem, gdzie jest używana ta sterta, ale jeśli jest duża i żyje przez znaczną ilość czasu, może spowodować nadmierne zużycie pamięci. Odpowiedź brzmi: wyczyść element po skopiowaniu:

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

Mój kod zastępczy zawiera tę poprawkę.

Jim Mischel
źródło
1
W teście porównawczym, który przetestowałem (można znaleźć na pastebin.com/Hgkcq3ex), ta wersja jest około ~ 18% wolniejsza niż ta zaproponowana przez Kevina Gosse (nawet jeśli linia clear to default () zostanie usunięta, a _count/2obliczenia zostaną podniesione na zewnątrz pętla).
MathuSum Mut
@MathuSumMut: dostarczyłem zoptymalizowaną wersję. Zamiast umieszczać przedmiot i ciągle go zamieniać, po prostu porównuję go z elementem na miejscu. Zmniejsza to liczbę zapisów, więc powinno zwiększyć prędkość. Inną możliwą optymalizacją byłoby skopiowanie _heap[_count]do pliku tymczasowego, co zmniejszyłoby liczbę odwołań do tablic.
Jim Mischel
Niestety wypróbowałem to i wydaje się, że ma również błąd. Ustaw kolejkę typu int i użyj tej niestandardowej funkcji porównującej: Comparer<int>.Create((i1, i2) => -i1.CompareTo(i2))- a mianowicie, aby posortować ją od największej do najmniejszej (zwróć uwagę na znak ujemny). Po przesunięciu w kolejności liczb: 3, 1, 5, 0, 4, a następnie usunięciu ich wszystkich z kolejki, kolejność powrotu była następująca: {5,4,1,3,0}, więc w większości nadal posortowano, ale 1 a 3 są w złej kolejności. Zastosowanie powyższej metody Gosse'a nie miało tego problemu. Zauważ, że NIE miałem tego problemu w normalnej, rosnącej kolejności.
Nicholas Petersen
1
@NicholasPetersen: Interesujące. Muszę się temu przyjrzeć. Dzięki za wiadomość.
Jim Mischel,
2
Błąd w kodzie @ JimMischel: porównanie rightChild < _count-1powinno być rightChild < _count. Ma to znaczenie tylko wtedy, gdy zmniejszasz liczbę z dokładnej potęgi 2 i tylko wtedy, gdy przerwa przechodzi całą drogę w dół prawej krawędzi drzewa. Na samym dole, prawe Dziecko nie jest porównywane do swojego lewego rodzeństwa, a niewłaściwy element może zostać awansowany, niszcząc kupę. Im większe drzewo, tym mniejsze prawdopodobieństwo, że tak się stanie; najprawdopodobniej pojawi się po zmniejszeniu liczby z 4 do 3, co wyjaśnia obserwację Nicholasa Petersena dotyczącą „ostatniej pary pozycji”.
Sam Bent - MSFT
0

Nie można odtworzyć w .NET Framework 4.8

Próbując odtworzyć ten problem w 2020 r. Z implementacją .NET Framework 4.8 z PriorityQueue<T>połączeniem w pytaniu za pomocą następującego XUnittestu ...

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

... pomyślnie we wszystkich 1 milion przypadków testowych:

wprowadź opis obrazu tutaj

Wygląda więc na to, że Microsoft naprawił błąd w ich implementacji:

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

Ponieważ odsyłacz w pytaniach wskazuje tylko na najnowszą wersję kodu źródłowego Microsoftu (obecnie .NET Framework 4.8), trudno powiedzieć, co dokładnie zostało zmienione w kodzie, ale przede wszystkim jest teraz wyraźny komentarz, aby nie wyciekać pamięci, więc możemy załóżmy, że wyciek pamięci wspomniany w odpowiedzi @ JimMischel został również rozwiązany, co można potwierdzić za pomocą narzędzi diagnostycznych Visual Studio:

wprowadź opis obrazu tutaj

Gdyby nastąpił wyciek pamięci, zobaczylibyśmy tutaj pewne zmiany po kilku milionach Pop()operacji ...

Frederik Hoeft
źródło