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 PriorityQueue
klasy?
c#
.net
priority-queue
MathuSum Mut
źródło
źródło
Odpowiedzi:
Zachowanie można odtworzyć za pomocą wektora inicjalizacyjnego
[0, 1, 2, 4, 5, 3]
. Wynik to:(widzimy, że 3 jest nieprawidłowo umieszczone)
Push
Algorytm jest poprawny. Buduje min-stertę w prosty sposób:Powstałe drzewo to:
0 / \ / \ 1 2 / \ / 4 5 3
Problem dotyczy
Pop
metody. 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ę,
_count
aby 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--; }
źródło
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ę.
źródło
_count/2
obliczenia zostaną podniesione na zewnątrz pętla)._heap[_count]
do pliku tymczasowego, co zmniejszyłoby liczbę odwołań do tablic.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.rightChild < _count-1
powinno 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”.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ącegoXUnit
testu ...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:
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:
Gdyby nastąpił wyciek pamięci, zobaczylibyśmy tutaj pewne zmiany po kilku milionach
Pop()
operacji ...źródło