O(1) ekleme, silme ve rastgele eleman
Problem
Tamsayılar için bir küme sınıfı yaz:
insert(val): değer yoksa ekler vetruedöner; varsafalse.remove(val): değer varsa siler vetruedöner; yoksafalse.getRandom(): kümedeki elemanlardan birini eşit olasılıkla döndürür (çağrıldığında küme boş değildir).
Üç işlem de ortalama O(1) olmalı.
insert(5) -> true
insert(5) -> false
remove(7) -> false
getRandom() -> 5
insert(7) -> true
remove(5) -> true
getRandom() -> 7
Netleştirme soruları
getRandomboş kümede çağrılabilir mi? (LeetCode'da hayır; production'da exception ya daOptionalInt.)- "Eşit olasılık" tam olarak uniform mu? Testte rastgeleliği nasıl doğrulayacağız? (Seed enjekte edilebilir olmalı.)
- Tekrar eden değerlere izin var mı? (Hayır; izin olsaydı LeetCode 381 olurdu.)
- Thread-safe olmalı mı?
- O(1) ortalama mı, en kötü durum mu? (Ortalama; HashMap en kötü durumda O(1) değildir.)
İpuçları
İpucu 1
HashSet ekleme ve silmeyi O(1) yapar ama içinden rastgele bir eleman seçmek O(n)'dir. Rastgele erişimi O(1) yapan yapı hangisi? O yapıda O(1) silme nasıl yapılır?
İpucu 2
ArrayList'te rastgele indeks seçmek O(1). Ortadan silmek O(n), ama sondan silmek O(1). Silinecek elemanı sona nasıl taşırsın?
İpucu 3
Değerden indekse bir HashMap tut. Silerken: silinecek elemanın indeksine son elemanı yaz, son elemanın map'teki indeksini güncelle, sonra listenin sonunu sil. Silinen eleman zaten sondaysa ne olur? Dikkatli ol.
Yaklaşım
Neden tek yapı yetmez:
| Yapı | insert | remove | getRandom |
|---|---|---|---|
HashSet | O(1) | O(1) | O(n) (iteratörle k'ıncı elemana yürümek) |
ArrayList | O(1) amortize | O(n) (arama + kaydırma) | O(1) |
TreeSet | O(log n) | O(log n) | O(n) |
ArrayList + HashMap (seçilen çözüm): Liste elemanları yoğun bir dizide tutar (rastgele indeks için), map her değerin listedeki indeksini tutar (O(1) arama için).
insert: map'te varsafalse; yoksa sona ekle, indeksini map'e yaz.remove: indeksi map'ten al. Son elemanı o indekse yaz ve son elemanın map girdisini güncelle (swap-remove). Sonra listenin sonunu sil. Sıra bozulur, ama küme için sıranın önemi yok.getRandom:values.get(random.nextInt(size)). Liste boşluksuz olduğu için her eleman eşit olasılıklıdır.
Neden uniform? Liste her zaman tam olarak kümedeki n elemanı, boşluksuz içerir; nextInt(n) her indeksi 1/n olasılıkla seçer. "Silinenleri işaretle, getRandom'da işaretliyse tekrar dene" yaklaşımı da uniformdur ama çok silme sonrası beklenen süre bozulur.
Java çözümü
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Random;
public final class RandomizedSet {
private final List<Integer> values = new ArrayList<>(); // rastgele erişim için yoğun dizi
private final Map<Integer, Integer> indexOf = new HashMap<>(); // değer -> values içindeki indeks
private final Random random;
public RandomizedSet() { this(new Random()); }
RandomizedSet(Random random) { this.random = random; } // testte seed'li Random
public boolean insert(int val) {
if (indexOf.containsKey(val)) return false;
indexOf.put(val, values.size());
values.add(val);
return true;
}
public boolean remove(int val) {
Integer idx = indexOf.remove(val);
if (idx == null) return false;
int lastIdx = values.size() - 1;
int last = values.get(lastIdx);
if (idx != lastIdx) { // silineni sonuncuyla değiştir (swap-remove)
values.set(idx, last);
indexOf.put(last, idx);
}
values.remove(lastIdx); // sondan silmek O(1)
return true;
}
public int getRandom() {
if (values.isEmpty()) throw new NoSuchElementException("set is empty");
return values.get(random.nextInt(values.size()));
}
}
Java'ya özgü iki tuzak:
List<Integer>.remove(...):values.remove(lastIdx)buradaintile çağrıldığı için indeksle silme yapar (doğru olan).values.remove(val)yazılsaydıvalindeks olarak yorumlanırdı;values.remove(Integer.valueOf(val))ise değeri arayıp O(n)'de silerdi.Integer idxileint lastIdxkarşılaştırması:idx != lastIdxifadesindeidxunboxing'e uğrar ve değer karşılaştırılır. İkiIntegernesnesini!=ile karşılaştırmak ise referans karşılaştırmasıdır ve -128..127 aralığı dışında yanlış sonuç verir.
Random'ın constructor ile enjekte edilmesi testte seed vermeyi sağlar. Eşzamanlı kullanımda paylaşılan bir Random contention yaratır; ThreadLocalRandom.current() tercih edilir.
Karmaşıklık
- Zaman:
insertamortize O(1) (ArrayList büyümesi + HashMap ortalama),removeortalama O(1),getRandomO(1). - Bellek: O(n); liste ve map.
Kenar durumlar ve testler
Testler main içinde çalıştırıldı:
- Tekrar ekleme
false; olmayanı silmefalse. - Tek elemanlı kümede
getRandom. - İlk elemanı silmek (sonuncuyla takas) ve son elemanın kendisini silmek (takas gereksiz;
idx == lastIdxdalı). - Boş kümede
getRandom:NoSuchElementException. - Negatif ve büyük değerler (
-3,1000): indeks/değer karışıklığı yok. - 20.000 rastgele işlem
HashSetreferansıyla karşılaştırıldı;getRandomher zaman kümede olan bir değer döndürdü. - Uniformluk: 8 elemanlı kümede (iki silme sonrası) 800.000
getRandomçağrısında her değerin frekansı beklenen değerin %2'si içinde kaldı.
Rastgele davranışı test etmenin iki yolu: seed sabitleyip deterministik çıktıyı kontrol etmek, ya da çok sayıda örnekle dağılımı istatistiksel toleransla kontrol etmek. İkincisi daha anlamlıdır ama tolerans çok dar seçilirse flaky olur.
Mülakatçı için
Bu soru iki yapıyı birleştirme becerisini ölçer (LRU ile aynı ruhta). Gözlemle: swap-remove fikrine kendisi ulaşıyor mu? Son elemanın map indeksini güncelliyor mu? Silinen eleman zaten sondaysa ne olduğunu düşünüyor mu (sıralama hatası: önce map'i güncelleyip sonra silinen anahtarı map'ten kaldırmak, son eleman kendisiyse onu yanlışlıkla siler)? Java'da remove(int) ile remove(Object) farkını biliyor mu?
İpucu zamanlaması: Aday HashSet ile getRandom'u O(n) yapıp durursa ilk ipucunu ver. Liste fikrine ulaşıp ortadan silmede takılırsa ikinci ipucunu ver. Üçüncü ipucundaki "son eleman kendisiyse" uyarısını vermek yerine testi adaydan iste.
- Boş küme, tekrar ve uniformluk gereksinimlerini netleştiriyor.
- Neden tek bir standart yapının yetmediğini işlem maliyetleriyle açıklıyor.
- Liste + değer-indeks map'i ve swap-remove ile üç işlemi O(1) yapıyor.
- Takas sonrası map'i güncelliyor; silinen eleman sondaysa doğru davranıyor.
List.remove(int)/remove(Object)veIntegerkarşılaştırma tuzaklarından kaçınıyor.getRandom'un neden uniform olduğunu ve nasıl test edileceğini açıklıyor.
Takip sorusu 1: Tekrar eden değerlere izin ver (multiset); getRandom her kopyayı eşit olasılıkla seçsin. Beklenen: map'in değeri indeks kümesi (Map<Integer, Set<Integer>>); swap-remove sırasında taşınan son elemanın indeks kümesini güncellemek (LeetCode 381).
Takip sorusu 2: Ağırlıklı rastgele seçim: her değerin bir ağırlığı var. Beklenen: statik küme için prefix toplam + binary search, O(log n) (LeetCode 528); dinamik için Fenwick ağacı; O(1) için alias method (ön işleme O(n)).
Takip sorusu 3: Bunu bir A/B test servisinde kullanıcıyı rastgele varyanta atamak için mi kullanırsın? Beklenen: Hayır. Atama deterministik olmalı (aynı kullanıcı her istekte aynı varyantı görmeli); hash(userId + experimentId) % 100 gibi tutarlı hashleme kullanılır. Rastgelelik ile deterministik dağıtım arasındaki farkı görmek senior sinyalidir.
Benzer problemler
- Insert Delete GetRandom O(1) - Duplicates allowed (LeetCode 381)
- Random Pick with Weight (LeetCode 528)
- LRU Cache (LeetCode 146)
- Design HashSet (LeetCode 705)
- Random Pick with Blacklist (LeetCode 710)