Помилка у внутрішньому PriorityQueue Microsoft <T>?


82

У .NET Framework у PresentationCore.dll є загальний 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 використовує цей код з 14.02.2005. Цікаво, як така помилка уникала сповіщення більше 12 років?
Nat

9
@Nat, оскільки тут використовується єдине місце, де Microsoft використовує його , і шрифт, що вибирає шрифт із нижчим пріоритетом, часом важко помітити.
Скотт Чемберлен,

Відповіді:


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   

І у нас закінчується ... Розбита купа.

Чесно кажучи, я не розумію, що намагався зробити автор, тому я не можу виправити існуючий код. Щонайбільше, я можу поміняти його робочою версією (безсоромно скопійованою з Вікіпедії ):

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
`` Помилка алгоритму '' полягає в тому, що ви не повинні переміщати пробіл вниз, а спочатку зменшувати дерево і поміщати нижній правий елемент в цей зазор. Потім відремонтуйте дерево у простому ітераційному циклі.
Хенк Холтерман,

5
Це хороший матеріал для звіту про помилку, ви повинні повідомити про це з посиланням на цю публікацію (я думаю, що правильне місце буде в MS connect, оскільки PresentationCore не на GitHub).
Лукас Тшешнєвський,

4
@LucasTrzesniewski Я не впевнений у впливі на реальний додаток (оскільки він використовується лише для деяких незрозумілих кодів вибору шрифтів у WPF), але, мабуть, це не зашкодило б повідомити про це
Кевін Гос,

20

Відповідь Кевіна Госсе визначає проблему. Хоча його перебалансування купи буде працювати, це не обов'язково, якщо ви вирішите основну проблему в початковому циклі видалення.

Як він зазначив, ідея полягає в тому, щоб замінити предмет у верхній частині купи найнижчим, самим правим предметом, а потім просіяти його до потрібного місця. Це проста модифікація вихідного циклу:

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), ця версія приблизно на ~ 18% повільніша за версію, запропоновану Кевіном Госсом (навіть якщо рядок, встановлений за замовчуванням (), видалено, а _count/2розрахунок розміщено за межами петля).
MathuSum Mut

@MathuSumMut: Я надав оптимізовану версію. Замість того, щоб розміщувати предмет і постійно його міняти місцями, я замість цього просто порівнюю його з місцем. Це зменшує кількість записів, тому слід збільшувати швидкість. Іншою можливою оптимізацією було б копіювання _heap[_count]в тимчасове, що зменшило б кількість посилань на масив.
Джим Мішель,

На жаль, я спробував це, і, схоже, у нього також є помилка. Встановіть чергу типу int і скористайтеся цим користувальницьким порівняльником: Comparer<int>.Create((i1, i2) => -i1.CompareTo(i2))- а саме, щоб він сортувався найбільшим до найменшого (зверніть увагу на негативний знак). Після натискання в порядку цифр: 3, 1, 5, 0, 4, а потім проходження через зняття їх усіх із порядку, порядок повернення був: {5,4,1,3,0}, тому переважно сортували все-таки, але 1 і 3 знаходяться в неправильному порядку. Використання наведеного вище методу Госсе не мала цієї проблеми. Зверніть увагу, що у мене НЕ було цієї проблеми в звичайному порядку за зростанням.
Ніколас Петерсен

1
@NicholasPetersen: Цікаво. Мені доведеться це вивчити. Дякую за записку.
Джим Мішель

2
Помилка в коді @ JimMischel: порівняння rightChild < _count-1має бути rightChild < _count. Це має значення лише при зменшенні кількості з точної потужності 2, і лише тоді, коли щілина проходить по правому краю дерева. Внизу rightChild не порівнюється з лівим братом, і неправильний елемент може бути підвищений, порушивши купу. Чим більше дерево, тим менше ймовірність цього трапиться; це найімовірніше з’явиться при зменшенні підрахунку з 4 до 3, що пояснює спостереження Ніколаса Петерсена щодо «останніх парних предметів».
Сем Бент - MSFT

0

Не відтворюється в .NET Framework 4.8

Спроба відтворити цю проблему у 2020 році за допомогою реалізації .NET Framework 4.8 PriorityQueue<T>зв’язаного у запитанні за допомогою наступного 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();
            }
        }
    }
}

... досягає успіху у всіх 1 мільйонах тестів:

введіть тут опис зображення

Отже, схоже, 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
    }
}

Оскільки посилання у запитаннях вказує лише на найновішу версію вихідного коду Microsoft (на даний момент .NET Framework 4.8), важко сказати, що саме було змінено в коді, але найголовніше, що зараз є явний коментар, щоб не втратити пам’ять, тому ми можемо припустимо, також було розглянуто витік пам’яті, згаданий у відповіді @ JimMischel, що можна підтвердити за допомогою інструментів діагностики Visual Studio:

введіть тут опис зображення

Якби виникла пам’ять, то через пару мільйонів Pop()операцій ми побачили б тут деякі зміни ...

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.