83

In the .NET Framework in PresentationCore.dll, there is a generic PriorityQueue<T> class whose code can be found here.

I wrote a short program to test the sorting, and the results weren't great:

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

Results:

2789658
3411390
4618917
6996709
found sorting error
6381637
9367782

There is a sorting error, and if the sample size is increased, the number of sorting errors increases somewhat proportionally.

Have I done something wrong? If not, where is the bug in the code of the PriorityQueue class located exactly?

Smi
  • 12,505
  • 9
  • 53
  • 61
MathuSum Mut
  • 2,538
  • 2
  • 19
  • 50
  • 3
    According to the comments in the source code, Microsoft's been using this code since 2005-02-14. I wonder how a bug like this escaped notice for over 12 years? – Nat May 28 '17 at 00:39
  • 9
    @Nat because the only place microsoft uses it [is here](https://referencesource.microsoft.com/#PresentationCore/Core/CSharp/MS/Internal/FontFace/PhysicalFontFamily.cs,185) and a font choosing a lower priority typeface some of the time is a hard bug to notice. – Scott Chamberlain May 28 '17 at 00:51
  • Just one more reason to not use stuff from a package labeled `Internal`... – rustyx May 10 '21 at 10:26

3 Answers3

86

The behavior can be reproduced using the initialization vector [0, 1, 2, 4, 5, 3]. The result is:

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

(we can see that 3 is incorrectly placed)

The Push algorithm is correct. It builds a min-heap in a straightforward way:

  • Start from the bottom right
  • If the value is greater than the parent node then insert it and return
  • Otherwise, put instead the parent in the bottom right position, then try inserting the value at the parent place (and keep swapping up the tree until the right place has been found)

The resulting tree is:

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

The issue is with the Pop method. It starts by considering the top node as a "gap" to fill (since we popped it):

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

To fill it, it searches for the lowest immediate child (in this case: 1). It then moves the value up to fill the gap (and the child is now the new gap):

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

It then does the exact same thing with the new gap, so the gap moves down again:

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

When the gap has reached the bottom, the algorithm... takes the bottom-rightmost value of the tree and uses it to fill the gap:

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

Now that the gap is at the bottom-rightmost node, it decrements _count to remove the gap from the tree:

                 1
               /   \
              /     \
             4       2
           /  \     
          3    5   

And we end up with... A broken heap.

To be perfectly honest, I don't understand what the author was trying to do, so I can't fix the existing code. At most, I can swap it with a working version (shamelessly copied from Wikipedia):

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

Main issue with that code is the recursive implementation, which will break if the number of elements is too large. I strongly recommend using an optimized thirdparty library instead.


Edit: I think I found out what is missing. After taking the bottom-rightmost node, the author just forgot to rebalance the heap:

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
  • 36,922
  • 3
  • 68
  • 85
  • 4
    The 'algorithmic error' is that you shouldn't move a gap down but first shrink the tree and put the bottom-right element in that gap. Then repair the tree in a simple iterative loop. – Henk Holterman May 28 '17 at 08:38
  • 5
    That's good material for a bug report, you should report it with a link to this post (I think the right place would be at [MS connect](http://connect.microsoft.com/) since PresentationCore is not on GitHub). – Lucas Trzesniewski May 28 '17 at 12:48
  • 4
    @LucasTrzesniewski I'm not sure of the impact on a real-world application (since it's only used for some obscure font-selection code in WPF), but I guess it couldn't hurt to report it – Kevin Gosse May 28 '17 at 13:57
20

Kevin Gosse's answer identifies the problem. Although his re-balancing of the heap will work, it's not necessary if you fix the fundamental problem in the original removal loop.

As he pointed out, the idea is to replace the item at the top of the heap with the lowest, right-most item, and then sift it down to the proper location. It's a simple modification of the original loop:

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

Note also that the code as written has a memory leak. This bit of code:

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

Does not clear the value from _heap[_count - 1]. If the heap is storing reference types, then the references remain in the heap and cannot be garbage collected until the memory for the heap is garbage collected. I don't know where this heap is used, but if it's large and lives for any significant amount of time, it could cause excess memory consumption. The answer is to clear the item after it's copied:

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

My replacement code incorporates that fix.

Nicholas Petersen
  • 7,801
  • 6
  • 50
  • 65
Jim Mischel
  • 122,159
  • 16
  • 161
  • 305
  • 1
    In a benchmark I tested (can be found at pastebin.com/Hgkcq3ex), this version is approximately ~18% slower than the one proposed by Kevin Gosse (even if the clear to default() line is removed and the `_count/2` calculation is hoisted outside the loop). – MathuSum Mut May 30 '17 at 21:43
  • @MathuSumMut: I provided an optimized version. Rather than place the item and continually swap it, I instead just compare with the item in place. That reduces the number of writes, so should increase the speed. Another possible optimization would be to copy `_heap[_count]` to a temporary, which would reduce the number of array references. – Jim Mischel May 30 '17 at 22:53
  • Unfortunately I tried this out and it seems to have a bug as well. Set a queue of type int, and use this custom comparer: `Comparer.Create((i1, i2) => -i1.CompareTo(i2))` - namely, to have it sorted greatest to least (note the negative sign). After pushing in order the numbers: 3, 1, 5, 0, 4, and then walking through dequeueing them all, the return order was: { 5,4,1,3,0 }, so mostly sorted still, but the 1 and 3 are in wrong order. Using Gosse's method above did not have this problem. Note that I did NOT have this problem in normal, ascending order. – Nicholas Petersen Dec 08 '17 at 08:38
  • Just did a test with 4000 random numbers (positive and negative, full range of an int as well), pushed them all into the queue, then retrieved them into a new array, and then OrderBy got a new array from that to test how different the sequences were. It turns out, out of 4000 large numbers, it was just the last two that were off. Last numbers were: 3997] (should have been): 2143404494 - (was): 2143644346 3998] (should have been): 2143644346 - (was): 2143404494 So looks like what your doing wrong in the Pop only has to do with when retrieving the final couple items. – Nicholas Petersen Dec 08 '17 at 08:43
  • 1
    @NicholasPetersen: Interesting. I'll have to look into that. Thanks for the note. – Jim Mischel Dec 08 '17 at 10:18
  • The JimMischel code does 2 comparisons for each level of the heap it examines, while the KevinGosse code does 1 comparison for each level overall in the first loop, and 1 comparison per level in the second loop until it finds the final resting spot. So the two-loop algorithm does fewer comparisons when the final spot is in the bottom 1/3 of the heap, where n^(2/3) of the elements live. That's pretty likely, especially given that the moving item came from the end of the heap, so it tends to be a large element. Coding tricks won't catch up with that. – Sam Bent - MSFT Dec 16 '17 at 01:30
  • Previous comment - meant to say: ... where _all but_ n^(2/3) of the elements live. – Sam Bent - MSFT Dec 18 '17 at 20:21
  • 2
    The bug in @JimMischel's code: the comparison `rightChild < _count-1` should be `rightChild < _count`. This only matters when reducing the count from an exact power of 2, and only when the gap travels all the way down the right edge of the tree. At the very bottom, the rightChild isn't compared to its left sibling, and the wrong element can get promoted, breaking the heap. The larger the tree, the less likely this happens; it's most likely to show up when reducing the count from 4 to 3, which explains Nicholas Petersen's observation about the "final couple items". – Sam Bent - MSFT Dec 18 '17 at 20:37
  • Jim Mischel's version is classic textbook version. Kevin's answer uses a later improvement based on the fact that the replacement of root comes from the last leaf and it should very likely be "heapified" to the end of the array. So the improved algorithm lets the "leaf" to sink greedily to avoid one additional comparison; after it is at bottom, bubble it up. – Tony Sep 09 '18 at 19:52
0

Not reproducible in .NET Framework 4.8

Trying to reproduce this issue in 2020 with the .NET Framework 4.8 implementation of the PriorityQueue<T> as linked in the question using the following XUnit test ...

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

... succeeds in all 1 million test cases:

enter image description here

So it seems like Microsoft fixed the bug in their implementation:

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

As the link in the questions only points to most recent version of Microsoft's source code (currently .NET Framework 4.8) it's hard to say what exactly was changed in the code but most notably there's now an explicit comment not to leak memory, so we can assume the memory leak mentioned in @JimMischel's answer has been addressed as well which can be confirmed using the Visual Studio Diagnostic tools:

enter image description here

If there was a memory leak we'd see some changes here after a couple of million Pop() operations...

Frederik Hoeft
  • 452
  • 3
  • 15