Медиана на потока от цели числа, използващи Heap в Java

1. Общ преглед

В този урок ще научим как да изчислим медианата на поток от цели числа.

Ще продължим, като посочим проблема с примери, след това ще анализираме проблема и накрая ще внедрим няколко решения в Java.

2. Изложение на проблема

Медианата е средната стойност на подреден набор от данни. За набор от цели числа има точно толкова елементи, по-малки от медианата, колкото и по-големи.

В подреден набор от:

  • нечетен брой цели числа, средният елемент е медианата - в подредения набор {5, 7, 10} медианата е 7
  • четен брой цели числа, няма среден елемент; медианата се изчислява като средната стойност на двата средни елемента - в подредения набор {5, 7, 8, 10} медианата е (7 + 8) / 2 = 7,5

Нека приемем, че вместо краен набор четем цели числа от поток от данни. Можем да определим медианата на поток от цели числа като медианата на множеството от цели числа, прочетени до момента .

Нека формализираме изявлението на проблема. Като се има предвид вход от поток от цели числа, трябва да проектираме клас, който изпълнява следните две задачи за всяко цяло число, което четем:

  1. Добавете цялото число към множеството от цели числа
  2. Намерете медианата на целите числа, прочетени до момента

Например:

add 5 // sorted-set = { 5 }, size = 1 get median -> 5 add 7 // sorted-set = { 5, 7 }, size = 2 get median -> (5 + 7) / 2 = 6 add 10 // sorted-set = { 5, 7, 10 }, size = 3 get median -> 7 add 8 // sorted-set = { 5, 7, 8, 10 }, size = 4 get median -> (7 + 8) / 2 = 7.5 .. 

Въпреки че потокът е неограничен, можем да предположим, че можем да държим всички елементи на потока в паметта наведнъж.

Можем да представим нашите задачи като следните операции в код:

void add(int num); double getMedian(); 

3. Наивен подход

3.1. Сортиран списък

Нека започнем с проста идея - можем да изчислим медианата на сортиран списък с цели числа чрез достъп до средния елемент или средните два елемента от списъка по индекс. Сложността във времето на операцията getMedian е O (1) .

Докато добавяме ново цяло число, трябва да определим правилната му позиция в списъка така, че списъкът да остане сортиран. Тази операция може да се извърши за O (n) време, където n е размерът на списъка . И така, общите разходи за добавяне на нов елемент към списъка и изчисляване на новата медиана са O (n) .

3.2. Подобряване на наивния подход

В добавка пистите операция в линейното време, което не е оптимално. Нека се опитаме да разгледаме това в този раздел.

Можем да разделим списъка на два сортирани списъка - по-малката половина от целите числа, сортирани в намаляващ ред, и по-голямата половина от целите числа в нарастващ ред . Можем да добавим ново цяло число в подходящата половина, така че размерът на списъците да се различава най-много с 1:

if element is smaller than min. element of larger half: insert into smaller half at appropriate index if smaller half is much bigger than larger half: remove max. element of smaller half and insert at the beginning of larger half (rebalance) else insert into larger half at appropriate index: if larger half is much bigger than smaller half: remove min. element of larger half and insert at the beginning of smaller half (rebalance) 

Сега можем да изчислим медианата:

if lists contain equal number of elements: median = (max. element of smaller half + min. element of larger half) / 2 else if smaller half contains more elements: median = max. element of smaller half else if larger half contains more elements: median = min. element of larger half

Въпреки че само подобрихме сложността във времето на операцията по добавяне с някакъв постоянен фактор, ние постигнахме напредък.

Нека анализираме елементите, до които имаме достъп в двата сортирани списъка . Потенциално имаме достъп до всеки елемент, докато ги преместваме по време на операцията (сортирано) добавяне . По-важното е, че имаме достъп до минимума и максимума (екстремуми) съответно на по-голямата и по-малката половина, по време на операцията за добавяне за ребалансиране и по време на операцията getMedian .

Виждаме, че екстремумите са първите елементи на съответните им списъци . И така, трябва да оптимизираме за достъп до елемента с индекс 0 за всяка половина, за да подобрим общото време на работа на операцията за добавяне .

4. Подход, базиран на купчина

Нека да усъвършенстваме разбирането си за проблема, като приложим наученото от нашия наивен подход:

  1. Трябва да получим минималния / максималния елемент на набор от данни за O (1) време
  2. Елементите не трябва да се съхраняват в сортиран ред , стига да можем ефективно да получим минималния / максималния елемент
  3. Трябва да намерим подход за добавяне на елемент към нашия набор от данни, който струва по-малко от O (n) време

След това ще разгледаме структурата от данни на Heap, която ни помага ефективно да постигаме целите си.

4.1. Структура на данните от купчината

Heap е структура от данни, която обикновено се изпълнява с масив, но може да се разглежда като двоично дърво .

Купчините са ограничени от свойството на купчината:

4.1.1. Макс - куп Свойство

(Детски) възел не може да има стойност по-голяма от тази на родителя си. Следователно, в max-heap , коренният възел винаги има най-голямата стойност.

4.1.2. Мин - купчина имота

(Родителски) възел не може да има стойност, по-голяма от тази на неговите деца. По този начин, в min-heap , коренният възел винаги има най-малката стойност.

В Java класът PriorityQueue представлява купчина. Нека да продължим напред към нашето първо решение, използвайки купища.

4.2. Първо решение

Нека заменим списъците в нашия наивен подход с две купчини:

  • Мин купчина, която съдържа по-голямата половина от елементите, с минималния елемент в корена
  • Максимална купчина, която съдържа по-малката половина от елементите, с максималния елемент в корена

Сега можем да добавим входящото цяло число към съответната половина, като го сравним с корена на min-heap. След това, ако след вмъкването размерът на едната купчина се различава от тази на другата купчина с повече от 1, можем да балансираме купчините, като по този начин поддържаме разлика в размера най-много 1:

if size(minHeap) > size(maxHeap) + 1: remove root element of minHeap, insert into maxHeap if size(maxHeap) > size(minHeap) + 1: remove root element of maxHeap, insert into minHeap

С този подход можем да изчислим медианата като средна стойност на кореновите елементи на двете купчини, ако размерът на двете купчини е равен. В противен случай основният елемент на купчината с повече елементи е медианата .

Ще използваме класа PriorityQueue за представяне на купчините. Свойството на купчината по подразбиране на PriorityQueue е min-heap. Можем да създадем max-heap с помощта на Comparator.reverserOrder, който използва обратния на естествения ред:

class MedianOfIntegerStream { private Queue minHeap, maxHeap; MedianOfIntegerStream() { minHeap = new PriorityQueue(); maxHeap = new PriorityQueue(Comparator.reverseOrder()); } void add(int num) { if (!minHeap.isEmpty() && num  minHeap.size() + 1) { minHeap.offer(maxHeap.poll()); } } else { minHeap.offer(num); if (minHeap.size() > maxHeap.size() + 1) { maxHeap.offer(minHeap.poll()); } } } double getMedian() { int median; if (minHeap.size()  maxHeap.size()) { median = minHeap.peek(); } else { median = (minHeap.peek() + maxHeap.peek()) / 2; } return median; } }

Преди да анализираме времето за работа на нашия код, нека разгледаме сложността на времето на операциите по купчина, които сме използвали:

find-min/find-max O(1) delete-min/delete-max O(log n) insert O(log n) 

Така че, операцията getMedian може да се извърши за O (1) време, тъй като изисква само функциите find-min и find-max . Сложността във времето на операцията за добавяне е O (log n) - три обаждания за вмъкване / изтриване, всяко от които изисква O (log n) време.

4.3. Инвариантно решение за размера на купчината

В предишния ни подход сравнихме всеки нов елемент с основните елементи на купчините. Нека изследваме друг подход, използващ купчина, в който можем да използваме свойството heap, за да добавим нов елемент в съответната половина.

Както направихме за предишното ни решение, започваме с две купчини - минимална купчина и максимална купчина. След това нека въведем условие: размерът на max-heap трябва да бъде (n / 2) през цялото време, докато размерът на min-heap може да бъде или (n / 2), или (n / 2) + 1 , в зависимост от общия брой елементи в двете купчини . С други думи, можем да позволим само на min-heap да има допълнителен елемент, когато общият брой на елементите е нечетен.

С нашия инвариант на размера на купчината можем да изчислим медианата като средната стойност на кореновите елементи на двете купчини, ако размерите на двете купчини са (n / 2) . В противен случай основният елемент на min-heap е медианата .

Когато добавяме ново цяло число, имаме два сценария:

1. Total no. of existing elements is even size(min-heap) == size(max-heap) == (n / 2) 2. Total no. of existing elements is odd size(max-heap) == (n / 2) size(min-heap) == (n / 2) + 1 

Можем да поддържаме инварианта, като добавяме новия елемент към една от купчините и ребалансираме всеки път:

Ребалансирането работи чрез преместване на най-големия елемент от max-heap в min-heap или чрез преместване на най-малкия елемент от min-heap в max-heap. По този начин, въпреки че не сравняваме новото цяло число, преди да го добавим към купчина, последващото ребалансиране гарантира, че почитаме основния инвариант на по-малките и по-големите половинки .

Нека внедрим нашето решение в Java, използвайки PriorityQueues :

class MedianOfIntegerStream { private Queue minHeap, maxHeap; MedianOfIntegerStream() { minHeap = new PriorityQueue(); maxHeap = new PriorityQueue(Comparator.reverseOrder()); } void add(int num) { if (minHeap.size() == maxHeap.size()) { maxHeap.offer(num); minHeap.offer(maxHeap.poll()); } else { minHeap.offer(num); maxHeap.offer(minHeap.poll()); } } double getMedian() { int median; if (minHeap.size() > maxHeap.size()) { median = minHeap.peek(); } else { median = (minHeap.peek() + maxHeap.peek()) / 2; } return median; } }

Сложността във времето на нашите операции остава непроменена : getMedian струва O (1) време, докато добавянето се изпълнява във времето O (log n) с точно същия брой операции.

И двете решения, базирани на купчина, предлагат подобна сложност в пространството и времето. Докато второто решение е умно и има по-чисто изпълнение, подходът не е интуитивен. От друга страна, първото решение следва естествено нашата интуиция и е по-лесно да разсъждаваме относно правилността на добавянето му .

5. Заключение

В този урок научихме как да изчислим медианата на поток от цели числа. Оценихме няколко подхода и внедрихме няколко различни решения в Java, използвайки PriorityQueue .

Както обикновено, изходният код за всички примери е достъпен в GitHub.