Lỗi trong PriorityQueue <T> nội bộ của Microsoft?


81

Trong .NET Framework trong PresentationCore.dll, có một PriorityQueue<T>lớp chung có mã có thể được tìm thấy ở đây .

Tôi đã viết một chương trình ngắn để kiểm tra việc sắp xếp và kết quả không tuyệt vời:

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

Các kết quả:

2789658
3411390
4618917
6996709
found sorting error
6381637
9367782

Có một lỗi sắp xếp và nếu kích thước mẫu được tăng lên, số lượng lỗi sắp xếp sẽ tăng tương ứng.

Tôi đã làm điều gì sai ư? Nếu không, lỗi trong mã của PriorityQueuelớp nằm chính xác ở đâu?


3
Theo các nhận xét trong mã nguồn, Microsoft đã sử dụng mã này từ 2005-02-14. Tôi tự hỏi làm thế nào một lỗi như thế này lại trốn thoát thông báo trong hơn 12 năm?
Nat

9
@Nat bởi vì nơi duy nhất microsoft sử dụng nó là ở đây và phông chữ chọn kiểu chữ ưu tiên thấp hơn đôi khi là một lỗi khó nhận thấy.
Scott Chamberlain

Câu trả lời:


83

Hành vi có thể được tái tạo bằng cách sử dụng vectơ khởi tạo [0, 1, 2, 4, 5, 3]. Kết quả là:

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

(chúng ta có thể thấy rằng 3 được đặt không chính xác)

Các Pushthuật toán là đúng. Nó xây dựng min-heap một cách đơn giản:

  • Bắt đầu từ dưới cùng bên phải
  • Nếu giá trị lớn hơn nút cha thì chèn nó và trả về
  • Nếu không, hãy đặt cây gốc ở vị trí dưới cùng bên phải, sau đó thử chèn giá trị ở vị trí gốc (và tiếp tục hoán đổi cây cho đến khi tìm thấy đúng vị trí)

Cây kết quả là:

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

Vấn đề là với Popphương pháp. Nó bắt đầu bằng cách coi nút trên cùng là "khoảng trống" cần lấp đầy (vì chúng tôi đã đưa nó vào):

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

Để điền vào nó, nó tìm kiếm con thấp nhất ngay lập tức (trong trường hợp này là: 1). Sau đó, nó di chuyển giá trị lên để lấp đầy khoảng trống (và con bây giờ là khoảng trống mới):

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

Sau đó, nó thực hiện điều tương tự với khoảng cách mới, vì vậy khoảng cách lại di chuyển xuống:

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

Khi khoảng trống đã chạm đến đáy, thuật toán ... lấy giá trị dưới cùng bên phải của cây và sử dụng nó để lấp đầy khoảng trống:

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

Bây giờ khoảng cách nằm ở nút ngoài cùng bên phải, nó giảm _countdần để loại bỏ khoảng cách khỏi cây:

                 1
               /   \
              /     \
             4       2
           /  \     
          3    5   

Và chúng ta kết thúc với ... Một đống đổ vỡ.

Thành thật mà nói, tôi không hiểu tác giả đang cố gắng làm gì, vì vậy tôi không thể sửa mã hiện có. Tối đa, tôi có thể hoán đổi nó với một phiên bản đang hoạt động (sao chép một cách đáng xấu hổ từ 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);
    }
}

Vấn đề chính với đoạn mã đó là việc triển khai đệ quy, sẽ bị hỏng nếu số lượng phần tử quá lớn. Tôi thực sự khuyên bạn nên sử dụng thư viện của bên thứ ba được tối ưu hóa để thay thế.


Chỉnh sửa: Tôi nghĩ rằng tôi đã tìm ra những gì còn thiếu. Sau khi sử dụng nút dưới cùng bên phải, tác giả đã quên cân bằng lại đống:

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
'Lỗi thuật toán' là bạn không nên di chuyển một khoảng trống xuống mà trước tiên hãy thu nhỏ cây và đặt phần tử dưới cùng bên phải vào khoảng trống đó. Sau đó sửa chữa cây trong một vòng lặp lặp lại đơn giản.
Henk Holterman

5
Đó là tài liệu tốt cho một báo cáo lỗi, bạn nên báo cáo nó với một liên kết đến bài đăng này (Tôi nghĩ vị trí phù hợp sẽ là ở MS connect vì PresentationCore không có trên GitHub).
Lucas Trzesniewski

4
@LucasTrzesniewski Tôi không chắc về tác động đối với ứng dụng trong thế giới thực (vì nó chỉ được sử dụng cho một số mã lựa chọn phông chữ khó hiểu trong WPF), nhưng tôi đoán không thể báo cáo nó
Kevin Gosse

20

Câu trả lời của Kevin Gosse xác định vấn đề. Mặc dù việc cân bằng lại đống sẽ hoạt động, nhưng không cần thiết nếu bạn khắc phục sự cố cơ bản trong vòng lặp loại bỏ ban đầu.

Như anh ấy đã chỉ ra, ý tưởng là thay thế mục ở trên cùng của đống bằng mục thấp nhất, ở ngoài cùng bên phải, sau đó sàng lọc nó xuống vị trí thích hợp. Đó là một sửa đổi đơn giản của vòng lặp ban đầu:

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

Cũng lưu ý rằng mã như được viết có một bộ nhớ bị rò rỉ. Đoạn mã này:

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

Không xóa giá trị khỏi _heap[_count - 1]. Nếu heap đang lưu trữ các loại tham chiếu, thì các tham chiếu vẫn ở trong heap và không thể được thu gom rác cho đến khi bộ nhớ cho heap được thu gom rác. Tôi không biết đống này được sử dụng ở đâu, nhưng nếu nó lớn và tồn tại trong một khoảng thời gian đáng kể, nó có thể gây tiêu thụ bộ nhớ quá mức. Câu trả lời là xóa mục sau khi được sao chép:

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

Mã thay thế của tôi kết hợp sửa chữa đó.


1
Trong điểm chuẩn mà tôi đã kiểm tra (có thể tìm thấy tại pastebin.com/Hgkcq3ex), phiên bản này chậm hơn khoảng ~ 18% so với phiên bản do Kevin Gosse đề xuất (ngay cả khi dòng clear to default () bị xóa và _count/2tính toán được đưa ra bên ngoài vòng lặp).
MathuSum Mut

@MathuSumMut: Tôi đã cung cấp một phiên bản được tối ưu hóa. Thay vì đặt món hàng và liên tục hoán đổi nó, thay vào đó tôi chỉ so sánh với món hàng tại chỗ. Điều đó làm giảm số lần ghi, do đó nên tăng tốc độ. Một cách tối ưu khác có thể xảy ra là sao chép _heap[_count]vào một tệp tạm thời, điều này sẽ làm giảm số lượng tham chiếu mảng.
Jim Mischel

Thật không may, tôi đã thử điều này và có vẻ như nó cũng có một lỗi. Đặt một hàng đợi kiểu int và sử dụng trình so sánh tùy chỉnh này: Comparer<int>.Create((i1, i2) => -i1.CompareTo(i2))- cụ thể là, để nó được sắp xếp từ lớn nhất đến nhỏ nhất (lưu ý dấu âm). Sau khi đẩy theo thứ tự các số: 3, 1, 5, 0, 4, và sau đó chuyển qua xếp lại tất cả, thứ tự trả về là: {5,4,1,3,0}, vì vậy hầu hết vẫn được sắp xếp, nhưng số 1 và 3 là sai thứ tự. Sử dụng phương pháp của Gosse ở trên không có vấn đề này. Lưu ý rằng tôi KHÔNG gặp sự cố này theo thứ tự bình thường, tăng dần.
Nicholas Petersen

1
@NicholasPetersen: Thật thú vị. Tôi sẽ phải xem xét điều đó. Cảm ơn vì đã lưu ý.
Jim Mischel

2
Lỗi trong mã của @ JimMischel: rightChild < _count-1nên so sánh rightChild < _count. Điều này chỉ quan trọng khi giảm số đếm từ lũy thừa chính xác là 2 và chỉ khi khoảng trống đi xuống hết mép phải của cây. Ở dưới cùng, RightChild không được so sánh với người anh em bên trái của nó, và phần tử sai có thể được thăng cấp, phá vỡ đống. Cây càng lớn, điều này càng ít xảy ra; nó có nhiều khả năng xuất hiện nhất khi giảm số lượng từ 4 xuống 3, điều này giải thích quan sát của Nicholas Petersen về "cặp mục cuối cùng".
Sam Bent - MSFT

0

Không thể tái tạo trong .NET Framework 4.8

Đang cố gắng tái tạo sự cố này vào năm 2020 với việc triển khai .NET Framework 4.8 PriorityQueue<T>như được liên kết trong câu hỏi bằng cách sử dụng XUnitthử nghiệm sau ...

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

... thành công trong tất cả 1 triệu trường hợp thử nghiệm:

nhập mô tả hình ảnh ở đây

Vì vậy, có vẻ như Microsoft đã sửa lỗi trong quá trình triển khai của họ:

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

Vì liên kết trong các câu hỏi chỉ trỏ đến phiên bản mã nguồn gần đây nhất của Microsoft (hiện tại là .NET Framework 4.8) nên rất khó để nói chính xác những gì đã được thay đổi trong mã nhưng đáng chú ý nhất là hiện có một nhận xét rõ ràng không làm rò rỉ bộ nhớ, vì vậy chúng tôi có thể giả sử rằng lỗi rò rỉ bộ nhớ được đề cập trong câu trả lời của @ JimMischel đã được giải quyết cũng như có thể được xác nhận bằng cách sử dụng các công cụ Chẩn đoán Visual Studio:

nhập mô tả hình ảnh ở đây

Nếu có sự cố rò rỉ bộ nhớ, chúng tôi sẽ thấy một số thay đổi ở đây sau vài triệu Pop()thao tác ...

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.