Microsoft의 내부 PriorityQueue <T>에 버그가 있습니까?


82

PresentationCore.dll의 .NET Framework에는 여기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 년 2 월 14 일부터이 코드를 사용하고 있습니다. 12 년 넘게 이런 버그가 어떻게 알림을 피했는지 궁금합니다.
Nat

9
@Nat 왜냐하면 마이크로 소프트가 사용하는 유일한 장소 가 여기에 있고 우선 순위가 낮은 서체를 선택하는 글꼴 눈에 띄기 어려운 버그이기 때문입니다.
Scott Chamberlain

답변:


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   

그리고 우리는 ... 부서진 힙으로 끝납니다.

솔직히 말해서 저자가 뭘하려고했는지 이해가 안 돼 기존 코드를 고칠 수 없다. 기껏해야 작동하는 버전으로 바꿀 수 있습니다 ( 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--;
}

4
'알고리즘 오류'는 간격을 아래로 이동하지 말고 먼저 트리를 축소하고 해당 간격에 오른쪽 하단 요소를 넣어야한다는 것입니다. 그런 다음 간단한 반복 루프에서 트리를 복구합니다.
Henk Holterman

5
즉, 좋은 재료 버그 리포트를 들어,이 게시물에 대한 링크를보고해야이다 (나는에 적절한 장소가 될 것이라고 생각 MS가 연결 PresentationCore은 GitHub의에 아니기 때문에).
Lucas Trzesniewski

4
@LucasTrzesniewski 실제 응용 프로그램에 미치는 영향은 확실하지 않지만 (WPF의 일부 모호한 글꼴 선택 코드에만 사용되기 때문에)보고해도
나쁠

20

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

내 대체 코드에는 해당 수정 사항이 포함되어 있습니다.


1
내가 테스트 한 벤치 마크 (pastebin.com/Hgkcq3ex에서 찾을 수 있음)에서이 버전은 Kevin Gosse가 제안한 것보다 약 ~ 18 % 더 느립니다 (default () 라인이 제거되고 _count/2계산이 외부에있는 경우에도 루프).
MathuSum Mut

@MathuSumMut : 최적화 된 버전을 제공했습니다. 아이템을 놓고 계속해서 교체하는 대신 제자리에있는 아이템과 비교합니다. 이렇게하면 쓰기 횟수가 줄어들 기 때문에 속도를 높여야합니다. 또 다른 가능한 최적화는 _heap[_count]임시 로 복사 하여 배열 참조 수를 줄이는 것입니다.
Jim Mischel

불행히도 나는 이것을 시도했고 버그도있는 것 같습니다. int 유형의 큐를 설정하고 다음 사용자 지정 비교자를 사용합니다 Comparer<int>.Create((i1, i2) => -i1.CompareTo(i2)). 숫자 3, 1, 5, 0, 4를 순서대로 입력 한 다음 모두 대기열에서 빼는 과정을 거친 후 반환 순서는 {5,4,1,3,0}이었으며 대부분은 여전히 ​​정렬되었지만 1 그리고 3은 잘못된 순서입니다. 위의 Gosse의 방법을 사용하면 이러한 문제가 발생하지 않았습니다. 이 문제는 정상적인 오름차순으로 발생하지 않았습니다.
니콜라스 피터슨

1
@NicholasPetersen : 흥미 롭군요. 나는 그것을 조사해야 할 것이다. 메모 감사합니다.
Jim Mischel

2
@JimMischel 코드의 버그 : 비교 rightChild < _count-1rightChild < _count. 이것은 정확한 2의 거듭 제곱에서 카운트를 줄일 때와 간격이 트리의 오른쪽 가장자리까지 내려갈 때만 중요합니다. 맨 아래에서 rightChild는 왼쪽 형제와 비교되지 않으며 잘못된 요소가 승격되어 힙이 깨질 수 있습니다. 나무가 클수록 이런 일이 발생할 가능성이 적습니다. "최종 커플 항목"에 대한 Nicholas Petersen의 관찰을 설명하는 개수를 4에서 3으로 줄일 때 나타날 가능성이 가장 높습니다.
Sam Bent-MSFT

0

.NET Framework 4.8에서 재현 할 수 없음

PriorityQueue<T>다음 XUnit테스트를 사용하여 질문에 링크 된대로 의 .NET Framework 4.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
    }
}

질문 마이크로 소프트의 소스 코드 (4.8 현재 .NET 프레임 워크)의 최신 버전 만 포인트 링크로 정확히 코드에서 변경되었습니다하지만 특히 지금은 명시 적 코멘트가 무슨 말을 열심히 하지 그래서 우리가 할 수있는, 메모리 누수 @JimMischel의 답변에 언급 된 메모리 누수가 해결되었다고 가정합니다. 이는 Visual Studio 진단 도구를 사용하여 확인할 수 있습니다.

여기에 이미지 설명 입력

메모리 누수가 발생하면 몇 백만 번의 Pop()작업 후에 여기에서 일부 변경 사항을 볼 수 있습니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.