Dinamik Programlama
Dinamik programlamanın (DP) temelinde ardı ardına verilen kararların bulunması / hesaplanması fikri vardır; ilgilendiği problemler her verilen kararın diğer karar seçeneklerini ortaya çıkardığı türden problemlerdir, ve her seferinde bu seçeneklerin arasından bir tanesi seçilmelidir. Amaç en iyi karar zincirini bulmaktır. Metot olarak kullanılanlar kısmen "açgözlü algoritmalar (greedy algorithms)" olarak bilinen algoritmaların yaptığına benzer fakat açgözlü algoritmalar en kısa yolu bulmaya uğraşırken, gezilen düğümlerde sadece "o an için" en iyi seçimi yapar. Bu tür seçim nihai sonuç göze alındığı zamanen iyi sonucu vermeyebilir. Alttaki grafiğe bakarsak,
diyelim ki a
noktasından f
noktasına en kısa yoldan ulaşmaya
çalışıyoruz - açgözlü algoritma a,b,c,d
üzerinden gidiş yapardı
çünkü her an, sadece o an için en iyi olanı seçerdi. Fakat en iyi yol
a,b,d
üzerinden giden yoldur. Gösterilen çizit / ağ yapısı (graph)
yönlü ve çevrimsiz (directed, acyclic graph -DAG-) olarak bilinen bir
yapı. Tipik kısa yol problemleri bu yapılar üzerinde çalışırlar.
DP problemleri özellikle bir problemi alt problemlere bölebildiğimiz zaman faydalıdırlar, ve bu alt problemler tekrar tekrar hesaplanıyorlarsa da bu daha iyidir, çünkü DP o alt problemleri önbellekleyerek (caching) tekrar hesaplanmadan geri getirilmelerini sağlayabilir.
Üstteki en kısa yol problemini DP ile çözelim.
Önce bazı teorik, mantıksal konular: tümevarımsal olarak düşünelim. Diyelim ki üstteki DAG'de $a,f$ arasındaki en kısa yolu kesinlikle "biliyoruz". Ve yine diyelim ki bu yol üzerinde / bir ara nokta $x$ noktası var. O zaman, $a,x$, ve $x,f$ arasındaki yollar da tanım itibariyle en kısadır. İspatlayalım: eğer mesela $x,f$ arasındaki en kısa yol bildiğimizden başka olsaydı, o zaman eldekini atıp o yolu kullanıyor olurduk (en kısa olduğunu kesin biliyoruz ya), ve bu sefer o alternatif en kısa olurdu. Fakat ilk başta en kısa yolu bildiğimiz faraziyesi ile başladık. Bir çelişki elde ettik, demek ki ara noktanın kısalığı doğrudur $\square$
Bu ispattan hareketle kısa yolu tek sayısal (numeric) bir değer olarak hesaplamaya uğraşabiliriz.
Öyle bir fonksiyon $d(v)$ olsun ki herhangi bir $v$ nodu için o nod'dan bitiş noktasına olan en kısa uzaklığı kesin biliyor olsun (dikkat, bu hesabın nasıl olacağını düşünmüyoruz şimdilik, sadece olabileceğini, olmuş olduğunu farz ediyoruz). Çoğu tümevarımsal tasarımda olduğu gibi hesabın kendisinin özyinelilik (recursive) çağrı zincirinin mekaniği içinde halolmasını amaçlıyoruz. Doğru olan bir ifadeyi düşünüyoruz öncelikle, ve hesabın kendisini sürekli bir sonraki noktaya erteliyoruz.
Devam edelim: $u,v$ arasındaki parça mesafeler $w(u,v)$'dir. Şimdi, eğer bir ara nokta $u$'ya gelmişsek -yine tümevarımsal olarak düşünmeye devam ediyoruz- bu noktanın her komşusu $w$ için $d(w)$'yi "bildiğimize" göre, en kısa yol için tek yapmamız gereken her seçim anında en minimal $w(u,v) + d(v)$'yi $u$'nun uzaklığı olarak almaktır.
Veri yapısı olarak DAG'ı alttaki gibi gösterelim,
DAG = {
'a': {'b':2, 'f': 9},
'b': {'d':2, 'c':1, 'f': 6},
'c': {'d':7},
'd': {'e':2, 'f': 3},
'e': {'f':4},
'f': {}
}
Böylece $w(u,v)$ basit bir Python sözlük (dictionary) erişimi haline
geliyor, a,b
arası parça mesafe için
print DAG['a']['b']
2
En kısa yolu bulacak program
from functools import wraps
def memo(func):
cache = {}
@wraps(func)
def wrap(*args):
if args not in cache:
print 'onbellekte yok -', args[0]
cache[args] = func(*args)
else: print 'onbellekte var -', args[0]
return cache[args]
return wrap
from memo import *
def rec_dag_sp(W, s, t):
@memo
def d(u):
print 'Dugum:' + u[0]
if u == t: print 'Son nokta t, geri donus'; return 0
min_dist = min(W[u][v]+d(v) for v in W[u])
print 'Geri donus,',u,'uzerindeyiz, mesafe=',min_dist
return min_dist
return d(s)
dist = rec_dag_sp(DAG, 'a', 'f')
print 'toplam mesafe=', dist
onbellekte yok - a
Dugum:a
onbellekte yok - b
Dugum:b
onbellekte yok - c
Dugum:c
onbellekte yok - d
Dugum:d
onbellekte yok - e
Dugum:e
onbellekte yok - f
Dugum:f
Son nokta t, geri donus
Geri donus, e uzerindeyiz, mesafe= 4
onbellekte var - f
Geri donus, d uzerindeyiz, mesafe= 3
Geri donus, c uzerindeyiz, mesafe= 10
onbellekte var - d
onbellekte var - f
Geri donus, b uzerindeyiz, mesafe= 5
onbellekte var - f
Geri donus, a uzerindeyiz, mesafe= 7
toplam mesafe= 7
Şimdi çağrı mekaniğinin hakikaten nasıl işlediğini görelim. Not: Önbellek kodlaması dekoratör kullanıyor, dekoratörler hakkında bir yazı için [2].
Başlangıç $u$, oradan, minimum seçerken, sürekli $d()$ çağrısı yapıyoruz, yani $d()$ kendini çağırıyor. Çağrının geri dönmesinin tek yolu son noktaya erişmek. Bu ne demektir? Programımız daha hesap yapmadan "derinliğine bir dalış" yapıyor. Son noktalara gelene kadar özyineli çağrıları ardı ardına uyguluyor, esas hesapları geri dönüş sırasında yapıyor. Bu nasıl ise yarıyor? Ayrıca önbelleklemenin hakikaten işleyip işlemediğini nasıl bileceğiz? Ya da önbellekteki bir değerin hep en iyisi olduğunu nereden bileceğim?
Bu arada, böyle bir yaklaşımda, önbellek değeri bir kez set edildi mi, hiç değiştirmeye gerek yok.
Nokta d
'ye bakalım. Bu noktanın mesafesi (yani son nokta f
'ye
uzaklığı) kararlaştırılırken algoritma d
'nin her komşusuna
bakacaktır, bunu for v in W[u])
ile yapacaktır. Her komşu için
f
'ye gelene kadar o yol derinliğine takıp edilecektir. Üstteki
çıktıda görüyoruz ki d
sonrası iki komşu e,f
için önce
d-f
ve d-e-f
gidişi yapılmıştır (amaç hep o son noktaya
ulaşmak, unutmayalım). 'Komşulara bakma ve aralarından en azı seçme"
mantığı tüm bu yollar denenene kadar bekleyecektir, ancak hepsi bittikten
sonra içlerinden bir minimum seçecektir.
İşte şimdi niye her düğümdeki minimum hesabının en iyisi olduğunu
anlıyoruz, çünkü o noktadan nihai noktaya varış için tüm alternatifler
deneniyor. O derine dalışın sonuçları arasından bir tanesi
seçiliyor. önbellekteki değer bu sebeple bir kez set ediliyor, ve hiç
değişmiyor. Tabii ki önbellekteki değer tekrar tekrar kullanılabiliyor,
c
için bir d
uzaklığı gerektiğinde bu önbellekten servis
edilecektir.
Ve her düğümdeki minimum hesabı en iyiyse, bu hesapları kullanan başlangıca yakın noktaların hesabı da doğal olarak en iyisi (kısası) olacaktır. Başta tümevarımsal olarak belirttiğimizin tekrar ifade edilmesidir bu.
Kısa Yol Tarifini Bulmak
Mesafe hesabı işte böyle yapılıyor... Peki en kısa yolun kendisini nasıl
biliriz? Yani önce şuraya, sonra şuraya git türünden yol tarifi bilgisi
nasıl hesaplanır? Aslında komşular arasındaki en kısa mesafeyi seçme
problemi, o komşular içinden hangisinin o en mesafeyi sağladığını hatırlama
problemine oldukça benziyor. Yani, her düğüm üzerindeyken ve komşular
arasından en kısa mesafeyi seçerken, o mesafenin "hangi komşudan"
geldiğini hatırlamak ve bunu bir yerlere kaydetmek yeterli. Her düğüm için,
son noktaya olan en kısa mesafe değişmediğine göre, "o mesafe bilgisinin
geldiği komşunun hangisi olduğu" bilgisi de değişmeyecektir. Ve her nokta
için o "ebeveyn komşu" bilindiği zaman herşey işleyip bittikten sonra en
kısa yol tarifi için eldeki kayda bakarız, ve başlangıç noktası
a
'dan başlayarak zıplaya zıplaya o ebeveyn zinciri ile sona kadar
geliriz. Bu değişiklikleri ekleyince kod şu hale gelir,
import numpy as np
from memo import *
def rec_dag_sp2(W, s, t, debug=True):
parent = {}
@memo
def d(u):
if u == t: return 0
distances = [W[u][v]+d(v) for v in W[u]]
min_dist = min(distances)
parent[u] = list(W[u])[np.argmin(distances)]
if debug: print 'Geri donus,',u,'uzerindeyiz, mesafe=',min_dist
return min_dist
return d(s), parent
import sp
dist, parent = sp.rec_dag_sp2(DAG, 'a', 'f')
print 'ebeveynler', parent
onbellekte yok - a
onbellekte yok - b
onbellekte yok - c
onbellekte yok - d
onbellekte yok - e
onbellekte yok - f
Geri donus, e uzerindeyiz, mesafe= 4
onbellekte var - f
Geri donus, d uzerindeyiz, mesafe= 3
Geri donus, c uzerindeyiz, mesafe= 10
onbellekte var - d
onbellekte var - f
Geri donus, b uzerindeyiz, mesafe= 5
onbellekte var - f
Geri donus, a uzerindeyiz, mesafe= 7
ebeveynler {'a': 'b', 'c': 'd', 'b': 'd', 'e': 'f', 'd': 'f'}
Not: argmin
bir liste içindeki en minimal değerin indisini verir.
İşte sonuç. Başlangıç a
, onun ebeveyni b
. b
'ye
bakıyoruz, onunki d
. Oradan f
'ye atlıyoruz, ve sonuca erişmiş
oluyoruz, en kısa yol a-b-d-f
.
Analiz
Açgözlü yaklaşımdan bu yaklaşımın farkını şimdi daha iyi görebiliriz, açgözlü teknik her düğümde en azı bizzat takip eder, ve kısayol hesabı, mesafe hesabı hep bu takip eylemi sırasın o anda yapılır, elde bir toplam vardır ve ona eklenir, vs. Bu yaklaşım daha hangi yolu seçtiği, sonradan, birkaç adım sonrasında hiçbir seçimle ilgilenmez. Dinamik Programlama ise takip etme eylemi ile hesap eylemini birbirinden ayırır, ve tümevarımsal bir tanımdan yola çıkarak, hep en kısa, en optimali bulmayı başarır.
DP algoritmasının karmaşıklığı, $M$ tane bağlantı (edges) ve $N$ tane düğüm
için $O(N + M)$'dir. Yani çözüm lineer zamandadır! Alt problemleri tekrar
tekrar çözüyoruz evet, ve @memo
ibaresini koddan çıkartsaydık
algoritmamızın üstel (exponential) zamanda işlediğini görürdük, ki bu çok
kötüdür. Fakat çözülen alt problemleri bir daha çözmeyip sonuçlarını
önbellekten aldığımız için algoritma son derece hızlı işliyor.
Kaynaklar
[1] Hetland, M., L., Python Algorithms, 8. Bolum
[2] Bayramlı, Dekoratorler, Onbellek Kodlamasi, Fonksiyon Degistirmek, https://burakbayramli.github.io/dersblog/sk/2013/07/onbelleklemeyi-dekorator-ile-yapmak.html
Yukarı