Joker karakterli parantez dengesi
Problem
Yalnızca {, } ve * karakterlerinden oluşan bir string veriliyor. * bir joker: istersen {, istersen }, istersen boş string yerine sayılabilir. Jokerleri uygun seçtiğinde string dengeli bir süslü parantez dizisine dönüşebiliyorsa true, hiçbir seçimle dönüşemiyorsa false döndür. Dengeli demek: her { kendisinden sonra gelen bir } ile kapanıyor ve hiçbir önekte kapanan sayısı açılanı geçmiyor. Boş string dengelidir.
Bu, Goldman Sachs online assessment'ında bir adayın raporladığı biçim. LeetCode 678'deki problem birebir aynıdır; tek fark orada ( ve ) kullanılmasıdır. Parantez türü tek olduğu sürece karakterin kendisi önemli değil, aşağıdaki çözüm iki sürümü de çözer.
Girdi: "{*}}"
Çıktı: true (* yerine { seçilirse "{{}}")
Girdi: "*{"
Çıktı: false (son { hiçbir şekilde kapanamaz)
Girdi: "{{*}*"
Çıktı: true (ilk * boş, ikinci * } seçilirse "{{}}")
Netleştirme soruları
- String'de bu üç karakter dışında karakter olabilir mi? (Olursa yok mu sayılmalı, hata mı?)
- Birden fazla parantez türü (
{},(),[]) aynı anda olabilir mi? (Olursa problem değişir; aşağıya bak.) - Boş string dengeli kabul ediliyor mu?
- String ne kadar uzun olabilir? (3^k deneme yapan brute force'un nerede çöktüğünü belirler.)
- Sadece evet/hayır mı isteniyor, yoksa jokerlerin nasıl atanacağı da mı?
İpuçları
İpucu 1
Joker olmasaydı tek bir sayaç yeterdi: { görünce artır, } görünce azalt, hiçbir an negatife düşmesin, sonunda sıfır olsun. Joker bu sayacı tek bir değer olmaktan çıkarıyor. Ne oluyor?
İpucu 2
Her adımda "şu an kaç açık parantez olabilir?" sorusunun cevabı bir küme. Bu kümenin elemanları birbirine nasıl yayılıyor? Arada boşluk kalabiliyor mu?
İpucu 3
Kümeyi sadece en küçük ve en büyük değeriyle tut: minOpen ve maxOpen. maxOpen negatife düşerse en iyimser senaryoda bile fazla kapatma var. minOpen negatife düşerse sıfıra çek. Sonunda minOpen == 0 mı?
Yaklaşım
Brute force: Her * için üç seçeneği ({, }, boş) dene, her tam atamayı klasik sayaç kontrolüyle doğrula. k joker için 3^k atama, her biri O(n): O(n · 3^k). Önek negatife düştüğünde dalı kesmek pratikte yardımcı olur ama en kötü durumu değiştirmez. Mülakatta başlangıç olarak söylenir, n = 100 bile bunu imkânsız kılar.
DP, ulaşılabilir denge kümesi: i karakter işlendikten sonra "açık parantez sayısı b olabilir mi?" sorusunu reachable[b] dizisinde tut. Başta yalnızca reachable[0] doğru. { her b'yi b+1'e, } her b > 0'ı b-1'e taşır; * üçünü birden yapar. Sonunda reachable[0] cevaptır. O(n²) zaman, O(n) bellek. LeetCode çözümlerinde görülen dp[i][j] aralık DP'si (i..j alt string'i dengeli mi) ise O(n³)'tür; bu problem için gereksiz pahalıdır.
Greedy, aralık: DP'deki kümeye dikkatle bakınca hep kesintisiz bir aralık olduğu görülür. { ve } aralığı bütünüyle kaydırır; * her değeri b-1, b, b+1'e yaydığı için aralık iki yana birer genişler ve içinde boşluk oluşmaz. Negatif değerleri atmak da aralığın alt ucunu kırpmaktan ibarettir. Bu yüzden kümenin tamamı yerine iki sayı yeter:
minOpen: jokerler mümkün olduğunca kapatma (ya da boş) seçilirse açık sayısı,maxOpen: jokerler mümkün olduğunca açma seçilirse açık sayısı.
Kurallar: { ikisini de artırır, } ikisini de azaltır, * minOpen'ı azaltıp maxOpen'ı artırır. maxOpen < 0 ise hemen false: hiçbir atamada önek negatif olmaktan kurtulamaz. minOpen negatife düşerse sıfıra çekilir, çünkü negatif açık sayısı geçerli bir durum değildir; o jokerler kapatma yerine boş sayılır. Sonunda minOpen == 0 ise sıfır açıkla bitiren bir atama vardır. O(n) zaman, O(1) bellek.
Bu greedy, DP'nin durum sıkıştırılmış hâlidir; desen alanında "Dinamik programlama" yazmasının nedeni bu. Mülakatta greedy'yi doğrudan söyleyen adaydan "neden aralık?" sorusunun cevabı beklenir; cevap yukarıdaki kesintisizlik gözlemidir.
Alternatif, iki stack: Açılan parantezlerin ve jokerlerin index'lerini ayrı iki stack'te tut. } gelince önce açılan stack'inden, yoksa joker stack'inden eşle. Sonda kalan her açılan parantez için, kendisinden sonra gelen bir joker gerekir (index karşılaştırması). O(n) zaman, O(n) bellek. Atamayı da üretmek gerekiyorsa en okunaklı yol budur.
Birden fazla parantez türü olursa: {, (, [ ve her birinin yerine geçebilen joker birlikte olursa aralık hilesi çalışmaz; çünkü artık sadece sayı değil, açık parantezlerin sırası önemlidir. O sürüm Valid Parentheses'taki stack ile jokerleri birleştiren daha pahalı bir aramadır. Mülakatçı bunu takip sorusu olarak sorabilir.
Java çözümü
public final class ValidParenthesisString {
// LeetCode 678 sürümü: '(' , ')' ve '*'
public boolean checkValidString(String s) {
return isBalanced(s, '(', ')');
}
// Greedy: açık parantez sayısının alabileceği değerler hep bir aralıktır [minOpen, maxOpen].
// O(n) zaman, O(1) ek bellek. Aday raporundaki sürüm: isBalanced(s, '{', '}').
public boolean isBalanced(String s, char open, char close) {
int minOpen = 0; // '*' mümkün olduğunca kapatıyorsa
int maxOpen = 0; // '*' mümkün olduğunca açıyorsa
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == open) {
minOpen++;
maxOpen++;
} else if (c == close) {
minOpen--;
maxOpen--;
} else if (c == '*') {
minOpen--; // '*' kapatma olabilir
maxOpen++; // ya da açma
} else {
throw new IllegalArgumentException("Beklenmeyen karakter: " + c);
}
if (maxOpen < 0) {
return false; // en iyimser senaryoda bile fazla kapatma var
}
minOpen = Math.max(minOpen, 0); // negatif açık sayısı geçerli bir durum değil
}
return minOpen == 0; // 0 açıkla bitirmek mümkün mü?
}
// DP: i karakterden sonra ulaşılabilen açık sayılarının kümesi. O(n^2) zaman, O(n) bellek.
public boolean isBalancedDp(String s, char open, char close) {
int n = s.length();
boolean[] reachable = new boolean[n + 2];
reachable[0] = true;
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
if (c != open && c != close && c != '*') {
throw new IllegalArgumentException("Beklenmeyen karakter: " + c);
}
boolean[] next = new boolean[n + 2];
for (int balance = 0; balance <= i; balance++) {
if (!reachable[balance]) {
continue;
}
if (c == open || c == '*') {
next[balance + 1] = true;
}
if ((c == close || c == '*') && balance > 0) {
next[balance - 1] = true;
}
if (c == '*') {
next[balance] = true; // '*' boş string
}
}
reachable = next;
}
return reachable[0];
}
}
Açma ve kapama karakterleri parametre olduğu için aynı kod hem aday raporundaki {/} sürümünü hem LeetCode'un (/) sürümünü çözer. Üç karakter dışında bir karakter geldiğinde sessizce yok saymak yerine IllegalArgumentException atılıyor; netleştirme sorusunun cevabına göre değiştirilebilir.
Karmaşıklık
| Yaklaşım | Zaman | Ek bellek |
|---|---|---|
| Brute force (her jokere 3 seçenek) | O(n · 3^k) | O(n) |
Aralık DP (dp[i][j]) | O(n³) | O(n²) |
| Ulaşılabilir denge kümesi | O(n²) | O(n) |
| İki stack (index) | O(n) | O(n) |
| Greedy aralık | O(n) | O(1) |
k: joker sayısı.
Kenar durumlar ve testler
main içinde derlenip çalıştırıldı. 20.000 rastgele string (0-12 karakter, (, ), *) için greedy, DP ve 3^k brute force aynı sonucu verdi; her string ayrıca {/} karakterlerine çevrilip isBalanced(s, '{', '}') ile de doğrulandı. 1.000.000 karakterlik girdiler greedy ile anında çözüldü.
| Durum | Girdi | Beklenen | Neyi yakalar |
|---|---|---|---|
| Boş | "" | true | Taban durum |
| Sadece joker | "*", "**" | true | Joker boş olabilir |
| Rapor örneği | "{*}}" | true | Joker açma olarak |
| Yanlış sıra | "}{" | false | Önekte negatif denge |
| Joker önde | "*{" | false | Joker, kendinden sonraki {'i kapatamaz |
| Joker arkada kapatır | "{{*}*" | true | minOpen'ı sıfıra çekme |
| Kapanmayan açık | "{{}" | false | Sonda minOpen > 0 |
| Erken çıkış | ")*" | false | maxOpen < 0 kontrolü |
| Uzun | "(" × 500.000 + "*" × 500.000 | true | O(n), taşma yok |
| Geçersiz karakter | "(a)" | exception | Sözleşme |
En sık yapılan hata minOpen'ı negatife düşmeye bırakmak: "(*)*" gibi girdilerde fazladan "kapatma" sayılır ve doğru cevap false'a döner. İkinci sık hata, maxOpen < 0 kontrolünü atlamak: minOpen her adımda sıfıra çekildiği için ")" ya da "())" gibi girdiler sonunda minOpen == 0 görünür ve yanlışlıkla true döner. Önek bir kez çöktüyse sonradan gelen karakterler onu kurtaramaz; bu yüzden kontrol her adımda yapılır.
Mülakatçı için
Online assessment'ta geldiyse süre baskısı altında greedy'yi bulmak beklenir; canlı görüşmede ise asıl ölçülen, adayın tek sayaçlı klasik çözümden "küme" fikrine nasıl geçtiği. Brute force'u söyleyip karmaşıklığını hesaplayan, sonra DP ile kümeyi tutan ve kümenin aralık olduğunu fark edip O(1) belleğe inen aday, problemin tamamını gerekçesiyle çözmüş olur. Greedy'yi ezberden yazan adaya "neden iki sayı yetiyor?" diye sormak ezberle anlamayı ayırır.
İpucu zamanlaması: Aday brute force'ta takılırsa birinci ipucuyla "joker olmasaydı?" sorusunu sor. DP'ye geldiyse ve O(n²)'de kalırsa küçük bir örnekte kümeyi yazdırıp "boşluk görüyor musun?" diye sor.
- Tek parantez türü olduğunu ve boş string'in dengeli sayıldığını netleştirdi.
- Jokersiz klasik sayaç çözümünü ve önek kuralını açıkça söyledi.
- Brute force'un 3^k olduğunu hesapladı.
- Durumu "olası açık sayıları kümesi" olarak modelledi.
- Kümenin kesintisiz bir aralık olduğunu gerekçelendirdi ve iki sayıya indirdi.
maxOpen < 0için erken çıkışı veminOpen'ı sıfıra çekmeyi doğru yerde yaptı."*("ve"(*)"gibi küçük örneklerle kodu elle yürüttü.
Takip soruları:
- Sadece evet/hayır değil, jokerlerin geçerli bir atamasını da döndür. (Beklenen: iki stack ile index eşleme; kalan açıklar sonraki jokerlerle kapatılır, kullanılmayan jokerler boş olur.)
- Üç parantez türü ve hepsinin yerine geçebilen joker olursa? (Beklenen: aralık fikri bozulur, sıra önemli hale gelir; stack durumlu arama ya da DP ve daha yüksek maliyet.)
- String bir stream olarak, karakter karakter geliyor; her an "şu ana kadarki önek hâlâ geçerli bir dizinin başı olabilir mi?" sorusunu cevapla. (Beklenen: aynı
minOpen/maxOpen;maxOpen >= 0olduğu sürece evet.)
Benzer problemler
- Valid Parentheses (LeetCode 20) (bu sette:
valid-parentheses). - Minimum Remove to Make Valid Parentheses (LeetCode 1249) (bu sette:
minimum-remove-to-make-valid-parentheses). - Minimum Add to Make Parentheses Valid (LeetCode 921): tek sayaçlı greedy.
- Longest Valid Parentheses (LeetCode 32): stack ya da DP.
- Generate Parentheses (LeetCode 22): aynı önek kuralıyla backtracking.