【问题标题】:Bug in Microsoft's internal PriorityQueue<T>?微软内部 PriorityQueue<T> 中的错误?
【发布时间】:2017-10-28 12:30:23
【问题描述】:

在 .NET Framework 的 PresentationCore.dll 中,有一个通用的 PriorityQueue&lt;T&gt; 类,其代码可以在 here 找到。

我写了一个小程序来测试排序,结果不是很好:

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类的代码中的bug究竟在哪里?

【问题讨论】:

  • 根据源代码中的cmets,微软从2005-02-14就开始使用这个代码。我想知道像这样的错误是如何在 12 年多的时间里逃过注意的?
  • @Nat 因为微软唯一使用它的地方is here 并且有时选择较低优先级字体的字体是一个很难注意到的错误。
  • 还有一个理由不使用标有Internal的包中的东西...

标签: c# .net priority-queue


【解决方案1】:

可以使用初始化向量[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   

我们最终得到...一个破碎的堆。

老实说,我不明白作者试图做什么,所以我无法修复现有代码。最多,我可以将它换成一个工作版本(无耻地从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);
    }
}

该代码的主要问题是递归实现,如果元素数量太大,它将中断。我强烈建议改用优化的第三方库。


编辑:我想我发现了缺少的东西。取了最右下角的节点后,作者只是忘记重新平衡堆了:

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

【讨论】:

  • “算法错误”是您不应该向下移动间隙,而是首先缩小树并将右下角的元素放在该间隙中。然后在一个简单的迭代循环中修复树。
  • 这是一个很好的错误报告材料,你应该用这个帖子的链接报告它(我认为正确的地方应该是MS connect,因为 PresentationCore 不在 GitHub 上)。
  • @LucasTrzesniewski 我不确定对实际应用程序的影响(因为它仅用于 WPF 中一些晦涩的字体选择代码),但我想报告不会有什么坏处它
  • 嗯,当你去实现一个堆而不知道如何删除一个元素并重新堆化结构时会发生这种情况......
【解决方案2】:

Kevin Gosse 的回答指出了问题所在。尽管他对堆的重新平衡会起作用,但如果您解决了原始删除循环中的基本问题,则没有必要。

正如他所指出的,这个想法是用最低、最右边的项目替换堆顶部的项目,然后将其筛选到适当的位置。这是对原始循环的简单修改:

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

我的替换代码包含该修复程序。

【讨论】:

  • 在我测试的一个基准测试中(可以在 pastebin.com/Hgkcq3ex 找到),这个版本比 Kevin Gosse 提出的版本慢了大约 18%(即使 clear to default() 行被删除,_count/2 计算被提升到循环之外)。
  • @MathuSumMut:我提供了一个优化版本。我没有放置物品并不断地交换它,而是只与原处的物品进行比较。这减少了写入次数,因此应该提高速度。另一种可能的优化是将_heap[_count] 复制到一个临时的,这将减少数组引用的数量。
  • 不幸的是我试过了,它似乎也有一个错误。设置一个 int 类型的队列,并使用此自定义比较器:Comparer&lt;int&gt;.Create((i1, i2) =&gt; -i1.CompareTo(i2)) - 即,将其从大到小排序(注意负号)。按顺序推入数字:3、1、5、0、4,然后将它们全部出列,返回顺序为:{ 5,4,1,3,0 },所以大部分仍然排序,但是 1和 3 的顺序错误。使用上面Gosse的方法没有这个问题。请注意,我在正常的升序中没有这个问题。
  • @NicholasPetersen:很有趣。我得调查一下。感谢您的来信。
  • @JimMischel 代码中的错误:比较 rightChild &lt; _count-1 应该是 rightChild &lt; _count。这仅在从 2 的精确幂中减少计数时才重要,并且仅当间隙一直沿树的右边缘向下移动时才有意义。在最底部,rightChild 不与它的左兄弟进行比较,并且错误的元素可以被提升,从而破坏堆。树越大,这种情况发生的可能性就越小;它最有可能在将计数从 4 减少到 3 时出现,这解释了 Nicholas Petersen 对“最后几项”的观察。
【解决方案3】:

在 .NET Framework 4.8 中无法重现

尝试在 2020 年使用 PriorityQueue&lt;T&gt; 的 .NET Framework 4.8 实现重现此问题,如问题中使用以下 XUnit 测试链接...

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 万个测试用例中均成功:

所以微软似乎修复了他们实施中的错误:

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),因此很难说代码中到底发生了什么变化,但最值得注意的是现在有一个明确的注释 not 泄漏内存,因此我们可以假设@JimMischel 的答案中提到的内存泄漏也已得到解决,这可以使用 Visual Studio 诊断工具进行确认:

如果发生内存泄漏,我们会在几百万次Pop() 操作后看到一些变化......

【讨论】:

    猜你喜欢
    • 2011-12-14
    • 1970-01-01
    • 2016-03-19
    • 2010-11-06
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-12-12
    • 1970-01-01
    相关资源
    最近更新 更多