Algoritma · Heap / Top-K

Akan veride medyan

Zor~30 dkAmazonGoogleMicrosoftMetaLeetCode 295: Find Median from Data Stream
00:00/ 30:00 hedefÖnce kendin çöz; ipuçlarını sırayla aç.

Problem

Sayıların tek tek geldiği bir yapı tasarla. İki işlem var:

  • addNum(num): yeni bir tamsayıyı yapıya ekler.
  • findMedian(): şimdiye kadar eklenen tüm sayıların medyanını döndürür. Eleman sayısı tekse sıralı halin ortasındaki eleman, çiftse ortadaki iki elemanın ortalaması (ondalıklı olabilir).

Her iki işlem de çok sık çağrılacak; özellikle findMedian hızlı olmalı.

text
addNum(6)    findMedian() -> 6.0
addNum(2)    findMedian() -> 4.0     (2, 6)
addNum(9)    findMedian() -> 6.0     (2, 6, 9)
addNum(1)    findMedian() -> 4.0     (1, 2, 6, 9)

Netleştirme soruları

  • Hiç eleman yokken findMedian çağrılırsa ne olmalı? (Exception, NaN, Optional?)
  • addNum ve findMedian oranı ne? (Okuma ağırlıklıysa ekleme biraz pahalı olabilir.)
  • Değer aralığı? Değerler küçük bir aralıkta mı (ör. 0-100)? (Sayaç dizisiyle çok daha basit çözüm.)
  • İki büyük int'in ortalaması taşar mı? (Integer.MAX_VALUE iki kez gelirse.)
  • Thread-safe olmalı mı? Elemanlar silinebilir mi (kayan pencere)?

İpuçları

İpucu 1

Medyanı bulmak için tüm sıralamaya ihtiyacın yok; sadece "ortadaki bir ya da iki eleman" önemli. Veriyi iki yarıya ayırırsan hangi bilgiler yeterli olur?

İpucu 2

Küçük yarının en büyüğünü ve büyük yarının en küçüğünü O(1)'de bilmek yeterli. Bu iki değeri hangi iki yapı verir?

İpucu 3

Küçük yarı için max-heap, büyük yarı için min-heap. İki değişmezi koru: max(küçük) ≤ min(büyük) ve boyutlar eşit ya da küçük yarı bir fazla. Yeni sayıyı önce küçük yarıya ekle, onun en büyüğünü büyük yarıya aktar, sonra boyutu dengele.

Yaklaşım

Brute force 1, her sorguda sırala: addNum O(1) (listeye ekle), findMedian O(n log n). Sorgular sıksa çok yavaş.

Brute force 2, sıralı liste: addNum'da binary search ile yeri bul ve araya ekle; findMedian O(1). Ama ArrayList'e araya ekleme kaydırma gerektirir: O(n). n ekleme için O(n²) toplam. Küçük n'de pratikte fena değil (System.arraycopy hızlıdır), ama asimptotik olarak yetersiz.

Optimal, iki heap: Veriyi iki yarıya böl:

  • lower: küçük yarı, max-heap (tepesi küçük yarının en büyüğü).
  • upper: büyük yarı, min-heap (tepesi büyük yarının en küçüğü).

Değişmezler: (1) lower'daki her eleman upper'dakilerden küçük ya da eşit; (2) lower.size() == upper.size() ya da lower.size() == upper.size() + 1.

Bu durumda medyan: boyutlar farklıysa lower'ın tepesi, eşitse iki tepenin ortalaması. O(1).

addNum üç adım:

  1. Sayıyı lower'a ekle.
  2. lower'ın en büyüğünü upper'a taşı. Bu adım değişmez (1)'i garanti eder: yeni sayı hangi yarıya ait olursa olsun, sınırdaki eleman doğru tarafa geçer.
  3. upper artık daha büyükse en küçüğünü lower'a geri taşı. Bu adım değişmez (2)'yi garanti eder.

Her addNum sabit sayıda (en fazla 5) heap işlemi yapar: O(log n). "Hangi heap'e eklemeliyim?" diye karşılaştırma dalları yazmak da mümkündür, ama bu "her zaman aynı yoldan geç" versiyonu, dal hatalarını ortadan kaldırdığı için mülakatta daha güvenlidir.

Java'da max-heap new PriorityQueue<>(Collections.reverseOrder()) ile kurulur. Ortalama için ((long) a + b) / 2.0: iki büyük int'in toplamı int'te taşar; testte Integer.MAX_VALUE iki kez eklenerek denendi.

Java çözümü

java
import java.util.Collections;
import java.util.PriorityQueue;

public final class MedianFinder {

    // lower: küçük yarı (max-heap), upper: büyük yarı (min-heap)
    // Değişmezler: max(lower) <= min(upper) ve lower.size() == upper.size() veya bir fazlası
    private final PriorityQueue<Integer> lower = new PriorityQueue<>(Collections.reverseOrder());
    private final PriorityQueue<Integer> upper = new PriorityQueue<>();

    public void addNum(int num) {
        lower.offer(num);
        upper.offer(lower.poll());                                 // küçük yarının en büyüğü büyük yarıya geçer
        if (upper.size() > lower.size()) {
            lower.offer(upper.poll());                             // boyut dengesini geri kur
        }
    }

    public double findMedian() {
        if (lower.isEmpty()) {
            throw new IllegalStateException("no numbers added yet");
        }
        if (lower.size() > upper.size()) {
            return lower.peek();
        }
        return ((long) lower.peek() + upper.peek()) / 2.0;         // long: iki büyük int toplamı taşmasın
    }
}

Karmaşıklık

  • addNum: O(log n); sabit sayıda heap işlemi.
  • findMedian: O(1); iki peek.
  • Bellek: O(n); her eleman tam olarak bir heap'te durur.
  • Değer aralığı küçükse (ör. 0-100) 101 elemanlı bir sayaç dizisi ile addNum O(1), findMedian O(101) olur; takip sorusu olarak çok sorulur.

Kenar durumlar ve testler

main içinde derlenip çalıştırıldı; 500 rastgele senaryoda (her biri 1-40 ekleme, değerler -20..20) her eklemeden sonra medyan, listeyi sıralayan referansla karşılaştırıldı.

DurumİşlemlerBeklenenNeyi yakalar
Boşken sorgufindMedian()IllegalStateExceptionSözleşme
Tek / çift / tek / çift6, 2, 9, 16.0, 4.0, 6.0, 4.0Boyut dengesi
TaşmaMAX, MAXMAX (double)long toplam
Negatif yarım-1, -2-1.5/ 2 tamsayı bölmesi hatası
Hepsi aynı5 kez 33.0Eşitlikte değişmez

Negatif örnek önemli bir tuzağı gösterir: (a + b) / 2 tamsayı bölmesiyle yazılırsa -3 / 2 = -1 olur ve sonuç -1.0 çıkar; 2.0 ile bölmek gerekir.

Mülakatçı için

Bu soru bir veri yapısı tasarımı sorusudur: adayın değişmez (invariant) tanımlayıp her işlemde koruyabilmesi ölçülür. İki heap fikrini bilen çok aday var; ayrım, değişmezleri açıkça söyleyip söylemediği ve addNum'un neden değişmezleri koruduğunu açıklayabilmesidir. Max-heap'i Java'da nasıl kuracağını bilmemek küçük ama gerçek bir eksiktir.

İpucu zamanlaması: Aday sıralı listeyle başlarsa kabul et, ekleme maliyetini sor. 10 dakika içinde iki heap fikri gelmezse ikinci ipucunu ver. Dengeleme kodunda dallar karışıyorsa "her sayıyı önce bir heap'ten geçirip diğerine aktarsan?" diye öner. Ortalamada tamsayı bölmesi yaparsa -1, -2 örneğini ver.

  • Boş durumda sorguyu, değer aralığını ve okuma/yazma oranını netleştirdi.
  • Sıralı liste gibi bir ara çözümü maliyetiyle birlikte söyledi.
  • Veriyi iki yarıya ayıran max-heap + min-heap yapısını kurdu.
  • İki değişmezi (sıra ve boyut) açıkça tanımladı.
  • addNum'un her iki değişmezi de koruduğunu gösterdi.
  • Medyanı ondalıklı ve taşmasız hesapladı.
  • Karmaşıklıkları doğru söyledi.

Takip soruları:

  1. Değerlerin %99'u 0-100 arasında; buna göre optimize et. (Beklenen: 0-100 için sayaç dizisi, aralık dışı için iki küçük heap ya da sıralı yapı; medyan neredeyse her zaman sayaçtan bulunur.)
  2. Son k elemanın medyanı (kayan pencere). (Beklenen: silme gerekir; iki heap + lazy deletion (silinecekleri map'te say, tepeye geldiğinde at) ya da iki TreeMap; LeetCode 480.)
  3. Bu bir gecikme izleme servisi; 50. değil 99. yüzdelik (p99) gerekiyor ve veri milyarlarca ölçüm. (Beklenen: kesin hesap pahalı; HdrHistogram, t-digest ya da DDSketch gibi yaklaşık yapılar; Micrometer/Prometheus'ta histogram kovaları ve quantile hesaplamanın birleştirilebilirlik sorunu.)

Benzer problemler

  • Sliding Window Median (LeetCode 480).
  • IPO (LeetCode 502): iki heap ile açgözlü seçim.
  • Kth Largest Element in a Stream (LeetCode 703): boyutu k min-heap.
  • Top K Frequent Elements (LeetCode 347) (bu sette: top-k-frequent-elements).
  • Sliding Window Maximum (LeetCode 239) (bu sette: sliding-window-maximum).