Respond needs javascript to run. To find out more click here
C++ » Sayfa 5 » Serdar Demir
RSS
 

Archive for the ‘C++’ Category

İleri Düzey Veri Yapıları

10 Eki

globe-Vista-256x256Ağaçlar (Trees)

Dallanan Veri Yapıları

Meyve olarak para veren ağaçlar yoktur, ama dallarını gezerek verilere ulaşabileceğimiz ağaçlar vardır, en azından bazı bilgisayar programlarında.

Belki de onları gerçek ağaçlardan ayırt etmek için onlara “veri ağaçları” demeliyiz.

Veri ağaçları birbirleriyle üst-alt ilişkisi bulunan, yani hiyerarşik (hierarchical) bir düzene sahip bilgileri saklamak için uygundurlar. Soy ağaçlarını veya kuruluşların organizasyon şemalarını oluşturan bireylerin kimlik bilgileri bu türden bir düzene sahip oldukları için bir veri ağacıyla temsil edilebilirler. Bir veri ağacı bir soy ağacı veya organizasyon şeması gibi yukarıdan aşağı doğru dallanan bir ağaç şeklinde düşünülebilir. Başlangıç noktasında bir “kök eleman” (root node) vardır. Buna bağlı sıfır, bir kaç veya bir çok “çocuk eleman” (child node) olabilir. Çocuk elemanların da her biri sıfır, bir kaç veya bir çok çocuk elemana sahip olabilir. Kök elemandan bağlayarak en alttaki çocuk elemana kadarki nesil sayısı “ağacın derinliği” olarak tanımlanır.

Burada şu noktaya dikkat çekelim: Biyolojik bir organizma olan gerçek bir ağacı oluşturan asıl elemanlar dallarıdır. Bir veri ağacının elemanları ise dalların çıkış noktalarıdır; bunlara “düğümler” (nodes) de denir. Bir başka fark da şudur: Veri ağaçlarını kök elemandan çocuk elemanlara doğru aşağı doğru dallanırlarmış gibi tarif ettik, ama bir veri ağacının elemanları bilgisayar hafızasında soldan sağa, yukarıdan aşağı, vb. gibi hiç bir öncelik veya sonralık ilişkisi içinde olmak zorunda değildir.

Veri Ağaçlarının Elemanları

Elemanlar arasında bir öncelik-sonralik ilişkisi de yoksa bu elemanları ancak adres bilgileriyle ilişkilendirebiliriz. Tıpkı sıralı bir listede olduğu gibi her ağaç elemanı kendisine bağlı çocuk elemanların adreslerini içeren bir adres değişkenleri listesi bulundurmak zorundadır. Bu sayede bir programcı bir veri ağacını kök elemandan bağlayıp onun çocuk elemanlarına, onların çocuk elemanlarına, … sırasıyla erişerek baştan başa dolaşabilir. Bu dolaşma işini önce belli bir dal silsilesini izleyerek en alttaki çocuk elemana erişip başa döndükten sonra başka bir silsileyi takip ederek, yani derinlik-öncelikli dolaşım (dept-first traversal) yoluyla, veya önce en üstteki çocuk elemanların her birine erişip daha sonra onların çocuk elemanlarına teker teker erişerek, yani genişlik-öncelikli dolaşım (breadth-first traversal) yoluyla yapabilir. Hangi yolun seçileceği –ki bazen farketmez- veri ağacıyla temsil edilen bilgilerin cinsine ve o bilgilere ne için erişildiğine bağlıdır. Eğer her ağaç elemanı bir de üstten bağlı olduğu ana elemana (parent node) işaret eden bir adres değişkenine sahip ise veri ağacını alta doğru da üste doğru da dolaşmak mümkündür.

Tabi her ağaç elemanı bir takım bilgiler de saklıyor olacaktır. Bu bilgilerin bütünü o elemanın değeri (value) olarak düşünülecektir. Bu bilgiler bir soy ağacındaki bir bireyin kimlik bilgileri, bir kuruluş organizasyonunun bir birimi hakkında bilgiler, bir aktivite sırasında değişik koşullarda alınacak tedbir veya yapılacak etkinlikler hakkında bilgiler, vb. olabilir.

İkili Ağaçlar (Binary Trees)

Hiyerarşik düzene sahip bilgileri temsil etmek için kullanılan veri ağaçlarında bir eleman belirsiz sayıda çocuk elemana sahip olabileceği için her elemanın kısalıp uzayabilen bir çocuk eleman listesine sahip olması gerekir. Bu da bilgilerin ve elemanların birbirleriyle ilişkilendirilebilmesi için bilgisayar hafızasının oldukça verimsiz kullanılmasıyla sonuçlanan bir listeler yığını kullanılması demektir. Her elemanının çocuk eleman sayısı belli olsaydı, örneğin her elemana en fazla iki çocuk düşeceği bilinseydi, bir veri ağacı elemanı çocuk elemanlar için esnek bir liste yerine eleman sayısı sabit bir dizi kullanırdı ve bu da veri depolama işini kolaylaştırırdı. Çocuk eleman sayısını 2 ile sınırlandıran bir veri ağacı “ikili ağaç” diye adlandırılır ve bazı durumlarda veri arama-tarama işlemlerini de hızlandırabildiği için oldukça sık kullanılır.

İkili ağaçlar rasgele sıralandıkları halde çabucak aranıp taranması gereken bilgileri depolamaktır. Bir veri grubunda arama işlemlerinin çabuk yapılabilmesi için sırasız bir liste yerine sıralı bir dizi kullanmamız gerektiğini biliyoruz; ne var ki, sürekli eklemelerle sayıları daha da artacak çok sayıda veriyi sıralı bir dizi oluşturacak şekilde depolamamız imkansız olabilir, veya verilerin tekrar tekrar silinip uzun süren sıralama işlemlerinden sonra tekrar kaydedilmesi gerekebilir. Bu verileri bir ikili veri ağacı oluşturacak şekilde depolayarak sıralı bir dizi oluşturmadan yarılamalı arama algoritmasını kullanarak arama yapabiliriz. Bunun nasıl olduğunu tamsayılardan ibaret bir veri grubundan oluşan bir ikili ağaç üzerinde gösterelim:

Şekil 5‑1 Rasgele sırayla okunan bir grup tamsayının bir ikili ağaç şeklinde depolanması

Şekil 5‑1’de her veri elemanı pSol ve pSağ diye adlandırılmış, çocuk elemanların yerini gösteren iki adres değişkenine, ve bir de tamsayı değere sahiptir. Solda yukarıdan aşağıya doğru yeni okunup ağaca eklenen tamsayı değerler listelenmiştir. Biraz dikkatli bir incelemeyle yeni değerlerin yerleştirilmesinde uygulanan kurallar kolayca anlaşılabilir

İlk okunan değer, 55, doğal olarak kök eleman olarak yerleştirilmiştir. İkinci okunan değer, 73, 55’ten büyük olduğu için kök elemanın sağ çocuğu olarak yerleştirilmiştir. Yani çocuk eleman olarak eklenecek yeni değer ana elemandan daha büyükse sağ çocuk olarak yerleştiriliyor. Üçüncü eklenen değer, 21, kök elemandan küçük olduğu için onun sol çocuğu olarak yerleştirilmiştir. Bu aşamada kök eleman 55’in iki çocuğu vardır ve artık ona yeni çocuk ekleyemeyiz. Dördüncü değer, 37, kök elemandan küçük olduğu için sola yerleştirilmelidir, ama solda zaten bir çocuk olduğu için bu yeni değer soldaki çocuğun bir çocuğu olarak eklenmelidir. Yeni değer 37, ana elemanı 21’den büyük olduğu için onun sağına yerleştirilmelidir. Bir sonra eklenen 14 ise yine 55’den küçük olduğu için onun soluna, 55’in solundaki 21’den de küçük olduğu için onun da soluna yerleştirilmiştir. Son eklenen 98 ise kök eleman 55’den büyük olduğu için onun sağında, 55’in sağındaki 73’den de büyük olduğu için onun da sağına yerleştirilmiştir.

Aşağıdaki şekilde bu veri ağacına bir kaç ekleme daha yapıldıktan sonra veri ağacında bir değer taranırken izelenecek yollar gösterilmiştir.

Şekil 5‑2 İkili bir veri ağacında arama yapılması

Hemen görülüyor ki bu arama işleminde sırasız bir listedeki gibi tek tek bütün elemanların değerlerinin aranan değerle karşılaştırılması gerekmemiştir. Değerler sıralı bir dizi de oluşturmadıkları halde bir yarılamalı arama algoritması uygulanmıştır. En alttaki eleman 93’ü saymazsak ağaç derinliği 3’tür ve aranan değer 47’yi bulmak için atılan adım sayısı da 3’tür. Bir genelleme yapacak olursak, bir ikili veri ağacı üzerinde yapılacak bir arama işleminin adım sayısının ağaç derinliğine eşit olduğunu söyleyebiliriz. Kısa bir not: Algoritma kitapları, örneğin (4), bir veri ağacının derinliğini verirken kök eleman da dahil olmak üzere altalta dizili eleman sayısını verirler. Biz burada altalta dizili dalları saymakla yetindik.

Bir konuya açıklık getirmeden geçmeyelim: Bir ikili ağaç yalnızca her elemanı en fazla iki çocuk elemana sahip olabilen bir ağaçtır. Yukarıdaki gibi yarılamalı arama algoritmasına izin verecek şekilde küçük elemanları sola, büyük elemanları sağa (tam tersi de olabilir) yerleştirerek oluşturulan bir ikili veri ağacına “ikili arama ağacı” (binary search tree) denir. Her ikili ağaç yarılamalı arama yapılacak şekilde düzenlenmiş olmayabilir.

Dengeli ve Dengesiz Ağaçlar

Bu şekildeki gibi hemen her dalda derinliği aynı olan bir veri ağacı “dengeli ağaç” (balanced tree) diye adlandırılır. Dengeli bir veri ağacı tıpkı dalları her yöne eşit açılarla ve eşit uzunluklarda uzayan gerçek bir ağa. gibi sağlıklı ve bir simetrik görüntüye sahip olacaktır. N elemanlı bir ikili ağacın derinliği, eğer ağaç dengeliyse yaklaşık lg(N/2)’ye eşittir. Bu da lg(N) – 1 demektir. Bu sonuç, bu tür bir veri ağacı üzerindeki bir arama işleminin oldukça kısa süreceği anlamına gelir. Şekil 5‑2’deki gibi rasgele sırayla gelen değerlerle oluşturulan bir ikili veri ağacı oldukça dengeli olacaktır.

Ama veri ağacına eklenecek değerlerin geliş sırası fazla düzenli ise ağaç çoğunlukla sola, ya da çoğunlukla sağa doğru dal verecektir ki o zaman da bir sıralı listeden farksız hale gelir. Böyle dengesiz bir ağaç üzerinde yapılacak bir arama işleminin adım sayısı yine ağaç derinliğine eşittir, ama ağaç derinliği hemen hemen eleman sayısına eşit olacağı için lg(N/2)’den çok daha fazla olabilir. Yani dengesiz bir ağaçta arama yapmak doğrusal arama yapmakla eşdeğerdir.

Veri Ağaçlarlarıyla İlgili Özel İşlemler

Veri Ağacı Oluşturulması

Bir veri ağacında önemli olan her elemanın çocuk elemanlarını gösteren bir adres değişkenleri listesine veya dizisine sahip olmasıdır. Bu koşulu sağlayan elemanlar bilgisayar hafızasında bir bağlantılı liste veya bir dizi halinde saklanabilir. İkili ağaçların çocuk eleman sayıları 2 olarak sabitlenmiş için bir ikili ağacı bir diziyle temsil etmek mümkündür. Örneğin, Şekil 5‑2’deki veri ağacı aşağıdaki gibi bir diziyle temsil edilebilir:

Şekil 5‑3 Bir diziyle temsil edilen bir ikili arama ağacı

Dizi şeklindeki bu ikili ağaçta her eleman okunduğu sırayla diziye sokulmuş, sol ve sağ çocukların adresleri olarak basitçe dizi içindeki sıra numaraları verilmiştir. Gerçek bir bilgisayar programında bu adresler bayt numaraları olurdu, ama onlar da bu sıra numaralarıyla doğrusal ilişkili olurdu. Şimdi yapay dille ikili arama ağacı oluşturacak bir algoritma tasarlayalım. Bu algoritmayı kendini çağıran bir fonksiyonla oluşturmak daha kolay olabilir. Çünkü yapacağımız şey, ağacı temsil eden diziye eklenecek her yeni elemanı kök elemanla tanıştırmak ve mümkünse yeni elemanı ona “evlat edindirtmektir”. Yeni elemanın taşıdığı değer kök elemanınkinden küçükse kök eleman onu sol çocuk (yok eğer büyükse sağ çocuk) olarak kabul edebilir. Sol çocuğu (ya da sağ çocuğu) varsa da sol çocuğunun (ya da sağ çocuğun) adresini veya sıra numarasını verir ki o zaman biz çocuk edintirten fonksiyonu bir kez daha çağırıp yeni elemanı evlat edinecek bir başka eleman ararız. Aşağıdaki kod örneğindeki fÇocukAta fonksiyonu bu evlat edindirme işini işte bu şekilde yapacaktır. Fonksiyona parametre olarak veri ağacını temsil eden dizi (buradaki ismi Ağaç), yeni elemanı çocuk eleman olarak kabul etmesi planlanan ana elemanın sıra numarası (ana), ve yeni eklenen çocuk elemanın sıra numarası (çocuk) gönderilmektedir.

Kod Örneği 5‑1 Bir ikili arama ağacını temsil edecek bir dizi oluşturulması

fÇocukAta(pAnaEleman, pÇocukEleman)

{

“çocuk elemanın değeri küçükse ana elemanın

“soluna yerleştirilecek

if(pÇocukEleman.Değer < pAnaEleman.Değer)

{

“Bu elemanın sol çocuğu yoksa

“yeni eleman sol çocuk olacak

if(pAnaEleman.pSol ≠ BOŞ)
{ fÇocukAta(pAnaEleman.pSol, pÇocukEleman) }

else

{ pAnaEleman.pSol = pÇocukEleman}

}
“küçük değil de büyük ya da eşit çocuklar sağa

else

{

“Bu elemanın sağ çocuğu yoksa

“yeni eleman sağ çocuk olacak

if(pAnaEleman.pSağ ≠ BOŞ)
{ fÇocukAta(pAnaEleman.pSağ, pÇocukEleman) }

else

{ pAnaEleman.pSağ = pÇocukEleman }

}

}

Bu fonksiyon diziye yeni eklenen her çocuk için bir kere, ana eleman sıra numarası olarak kök elemanın sıra numarası –ki o da 1- verilerek çağrılacaktır. Önce kök elemanı, olmazsa onun çocuklarını deneye deneye bu fonksiyon her yeni eleman için bir ana eleman bulacaktır. Bu şekilde oluşturulan bir ikili arama ağacının her elemanını yerleştirmek dizinin o anki derinliği kadar adım gerektirir. Örneğin, 32’den fazla elemanı olan dengeli bir ikili arama ağacına 32. eleman eklenmesi yaklaşık 4 adım (lg(32) − 1) gerektirecektir.

Eleman Bulma

Bu kez de yukarıdaki algoritma ile yarattığımız ikili arama ağacında belli bir değeri aradığımızı varsayalım. Araman değeri X ile temsil edelim. Bir ikili arama ağacında arama yaparken önce kök elemanın değerini aranan değerle karşılaştırır, eşitlik göremezsek onun çocuk elemanlarından birini deneriz. Aranan değer kök elemanınkinden küçükse sol çocuk, değilse sağ çocuk için bu karşılaştırma işlemini tekrarlarız. Demek ki bir ikili arama ağacındaki arama algoritması da kendini çağıran bir fonksiyon ile oluşturulabilir:

Kod Örneği 5‑2 Bir diziyle temsil edilen ikili veri ağacında belli bir değer aranması

fElemanBul(pAğaçEleman, X)

{

“aranan değer küçükse elemanın soluna bak

if(pAğaçEleman.Değer > X)

{

“Bu elemanın sol çocuğu yoksa aranan

“değer bulunamadı demektir.

if(pAğaçEleman.pSol ≠ BOŞ)
{ fElemanBul(pAğaçEleman.pSol, X) }

else

{ SONUÇ: HAYIR }

}
“aranan değer küçük değilse elemanın sağına bak

else if(pAğaçEleman.Değer < X)

{

“Bu elemanın sağ çocuğu yoksa aranan

“değer bulunamadı demektir.

if(pAğaçEleman.pSağ ≠ BOŞ)
{ fElemanBul(pAğaçEleman.pSağ, X) }

else

{ SONUÇ: HAYIR }

}

“Aranan değer elemanın değerine eşitse aranan

“değer bulunmuş demektir.

SONUÇ: EVET

}

Bu fonksiyon aranan değer için bir kere, elemanno için kök eleman sıra numarası, yani 1, verilerek çağrılır. Fonksiyon sonuç olarak aranan değerin bulunup bulunamadığını gösteren bir mantıksal belirteç gönderecektir.

Eleman Silme

Bir veri ağacından eleman silerken dikkat edilmesi gereken şey o elemana bağlı çocuk elemanların ve onlara bağlı çocuk elemanların, … kısacası silinecek elemana bağlı tüm silsilenin –ki buna “alt-ağaç” (subtree) denir- de silinmesinin gerektiğidir. Bu tıpkı bir ticari kurumun bir birimini kapatıp o birimde çalışan elemanlarını işten çıkartması veya o birime ait malları likidite etmesi gibi düşünülebilir.

Bir ikili veri ağacından bir eleman silecek kodun yazılması bu bölümün sorularından birinde okuyuculara bırakılmıştır. Kendini çağıran bir fonksiyon kullanarak basit görünüşlü bir kod yazılabilir. Kendini yinelemeyen bir basit algoritmanın kodu biraz daha karmaşık gözükecektir.

fElemanSil(pAğaçEleman)

{

“varsa bütün çocuk elemanları sildikten
“sonra asıl elemanı sil

pÇocuk = pAğaçEleman.ÇocukListesi.pİLK
while(pÇocuk ≠ BOŞ)
{
fElemanSil(pÇocuk)
pÇocuk = pÇocuk.pSonraki
}
SİL(pAğaçEleman)

}

 
2 Comments

Posted in C++

 

Kendini Yineleyen Algoritmalar

10 Eki

Kendini Yineleme (Recursion) ile Problem Çözümü

Fonksiyon Kavramı

Kendini yineleyen algoritmalardan söz etmeden önce fonksiyon (function) kavramı üzerinde durmamız gereklidir.

Bilişim Sözlüğü’nde (1) “işlev” diye çevrilen fonksiyon terimi, bir bilgisayar programında, verilen belli değer(ler)i alıp belli işlemler yaparak bir sonuç değeri gönderen bir kod blokuna verilen addır.Yapısal (procedural) programlama dillerini kullanan programcılar, sık sık beraberce tekrarlanmaları gerekecek işlemleri ayrı bir fonksiyon oluşturacak şekilde gruplarlar. Bir fonksiyondaki işlemlerin yapılması gerekli olduğunda o fonksiyona verilen adı geçen bir program komutyla o fonksiyonu “çağırırlar”. Bu çağırma (call) terimi gerçek hayattaki gibi anlaşılmamalıdır. Kastedilen şey adı belirtilen fonksiyonun temsil ettiği kod blokunun bilgisayar hafızasına yüklenerek çalışmaya hazır hale getirilmesidir. Fonksiyonlar ancak gerektiklerinde bilgisayar hafızasına yüklendikleri için bir bütün olarak yüklendiklerinde çok fazla hafıza ve işlem gücü gerektirecek programlar fonksiyonlara ayrılarak yazılması faydalı olacaktır.

Basit bir örnek olarak bir n tamsayı değerine kadarki tamsayıların toplamını hesaplayan bir fonksiyon yazalım:

fToplam(n)

{
toplam = 0

for(i=1àn)
{
toplam = toplam+i
}

SONUÇ: toplam

}

Bu kitapta kullandığımız yapay kodda fonksiyonları ayırt etmek için adlarının başına f harfi koyuyoruz. Programlama dillerinin hemen hepsinde olduğu gibi, yapay kodumuzda da bir fonksiyonu fonksiyon adı, aç parantez, parametre listesi, kapa parantez şeklinde yazıyoruz. Parametreler bir fonksiyonun yapacağı işte kullanacağı değerlerdir. Bu örnekteki fonksiyon sayıları toplayabilmek için 1’den kaça kadar sayıları toplayacağını bilmelidir. O nedenle parametre olarak toplanacak son sayının değerini istemektedir. Fonksiyon bünyesindeki işlemler döngüler ve karşılaştırma ifadelerinde olduğu gibi bir kod bloku içine yerleştirilmiştir. Fonksiyonun işi bittiğinde göndereceği sonucu ise SONUÇ: ifadesi ile belirtiyoruz.

Kendini Çağıran Fonksiyon (Recursive Function) Örnekleri

Bir önceki kısımda 1’den n’e kadar tamsayıların toplamını bulan fonksiyon matematiksel olarak şu işlemi yapmaktadır:

Aynı toplam şu şekilde de ifade edilebilir:

Kısacası, 1’den n’e kadar tamsayıların toplamını bulmak için önce 1’den (n-1)’e kadar tamsayıların toplamını bulup bu toplama n eklemek de mümkündür. Toplam bulan fonksiyonumuzu aşağıdaki gibi değiştirerek bunu uygulamaya koyabiliriz:

fToplam(n)

{
SONUÇ: n + fToplam(n-1)

}

Bu yeni örneğimizde fToplam fonksiyonu tuhaf bir davranış göstererek yapacağı işin bir kısmını yine kendisine havale etmektedir. 1’den 5’e kadar tamsayıların toplamını bulmak için fToplam(5) şeklinde bir çağrı yapsak, fonksiyon 1’den 4’e kadar tamsayıların toplamını bulup bu sonucu 5 ile toplamak için yine kendisini çağıracaktır. 1’den 4’e kadar tamsayıların toplamını bulması için çağrılan fToplam ise 1’den 3’e kadar tamsayıların toplamını bulmak için yine kendisini çağıracaktır.

Fonksiyonun bu haliyle ardışık çağırmalar sonsuza kadar devam edip gider, çünkü her defasında fonksiyon parametresi n bir eksiltilerek fonksiyon tekrar çağrılmakta, ama herhangi bir durma koşulu bulunmamaktadır. Bu fonksiyonu bir kere çağırmak programı sonsuz döngüye sokmak demektir. Bu durumu düzeltmek için fonksiyonu aşağıdaki gibi değiştirebiliriz:

Kod Örneği 4‑1 Kendini çağırarak belli bir değere kadar olan tamsayıları toplayan fonksiyon

fToplam(n)

{

if(n ≠ 1)
SONUÇ: n + fToplam(n-1)

else
SONUÇ: 1
}

Bu haliyle fToplam fonksiyonu parametre değeri 1 olduğunda 1’den 1’e kadar tamsayıların toplamını kendisini çağırmadan 1 olarak gönderiyor ve böylece parametre değerini sonsuza kadar eksilterek işi uzatmıyor.

Aşağıdaki kendini çağıran fonksiyon da parametre olarak gönderilen bir tamsayının faktöriyelini hesaplar:

Kod Örneği 4‑2 Bir tamsayının faktöriyelini hesaplayan kendini çağıran fonksiyon

fFaktöriyel(n)
{
if(n ≠ 1)
SONUÇ: n * fToplam(n-1)

else
SONUÇ: 1
}

Kendini Çağıran Fonksiyonların Analizi

Yukarıdaki iki kendini çağıran fonksiyon örneğimizi birer algoritma gibi düşünüp adım sayılarını bulmaya çalışırsak işimizin kolay olmadığını hemen görürüz. 1’den n’e kadar olan tamsayıların toplamını veya çarpımını bulan bir fonksiyonun adım sayısına Ã(n) dersek, bu adım sayısı 1’den (n-1)’e kadar olan tamsayıların toplamı veya çarpımı için gerekli adım sayısı Ã(n-1)’e bağlıdır:

Ã(n) = Ã(n-1) + 1

Matematikte “tekrarlı denklem” (recurrence equation), veya “özyineli denklem” (1) diye bilinen bu tür bir denklemin çözümünü bulmak için biraz mantık yürütmemiz gereklidir. En azından n = 1 için sonucun 1 olarak gönderildiğini, yani n = 1 için adım sayısının Ã(1) = 1 olduğunu biliyoruz. Demek ki Ã(2) = 2, Ã(3) = 3, … , Ã(n) = n.

Bir kod örneği yazmadan da kendini yineleyen bir algoritmanın adım sayısını bulabiliriz. Örnek olarak “Hanoi kulesi” problemini ele alalım. Bu problemde 3 adet çubuktan birine geçirilmiş n adet halkanın hepsinin bir başka çubuğa aktarılması istenir. Yalnız uyulması gereken bir kural vardır: Halkalar ilk bulundukları çubukta küçükler üstte, büyükler altta olacak şekilde yerleştirilmişlerdir ve hiç bir aktarma işleminde bir büyük halka bir küçük halkanın üzerine konamaz.

Aşağıdaki şekil n = 3 halka için yapılması gereken aktarmaları gösteriyor.

Görüldüğü gibi n = 3 halka için 7 adım gerekmiştir. Herhangi bir sayıda halka için kaç adım gerekeceğini doğrudan bulamayabiliriz, ama şekilde izlenen yol kendini yineleyen bir algoritma şeklinde ifade edilirse problem çözümünün önce n‑1 halkanın (şekildeki 1 ve 2 nolu halkalar) soldaki çubuktan ortadaki çubuğa, solda tek kalan halkanınsa (3 nolu halka) sağdaki çubuğa aktarılmasından sonra orta çubuktaki n‑1 halkanın (yine 1 ve 2) sağ çubuğa, biraz önce sağ çubuğa geçirilmiş tek halkanın (yani 3) üstüne konması gerektiğini anlarız(8). Yani problemin çözümü n−1 halkanın 2 kere bir çubuktan diğerine, kalan 1 halkanınsa yalnızca 1 kere bir başka çubuğa aktarılmasını gerektirir. Bu şekilde ifade edilen kendini yineleyen algoritmaya ait tekrarlı denklem

Ã(n) = 2×Ã(n-1) + 1

şeklindedir. Buna göre …

Ã(2) = 2×Ã(1) + 1 = 2×1 + 1

Ã(3) = 2×Ã(2) + 1 = 2×2×1 + 2×1 + 1

Ã(4) = 2×Ã(3) + 1 = 2×2×2×1 + 2×2×1 + 2×1 + 1

Ã(5) = 2×Ã(4) + 1 = 2×2×2×2×1 + 2×2×2×1 + 2×2×1 + 2×1 + 1

… anlaşılıyor ki Ã(n) =

Kendisini Yineleyen Algoritma Örnekleri

Kendini çağıran fonksiyonlara “özyinelemeli yordamlar” da denebilir (1). Kendini çağıran fonksiyonlar arama, sıralama gibi özel işlemlerin parçalara bölünerek yapılmasında yardımcı olabilirler. Bir problemi ayrı ayrı çözülmeleri daha kolay olan alt problemlere ayırıp öyle çözen algoritmalara “böl-ve-çöz algoritmaları” (divide-and-conquer algorithms) adı verilir. Böl-ve-çöz algoritmaları verilen problemi genellikle az çok eşit büyüklükte parçalara ayırırlar. Problemi parçalara ayırmasalar da belli işlemleri içiçe döngülerde yineleyen algoritmaların genel adı “kendisini yineleyen” ya da “özyinelemeli” algoritmalardır.

Birleştirerek Sıralama (Merge Sort)

Böl-ve-çöz türü algoritmaların tipik bir örneği olan birleştirmeli sıralama algoritması bir diziyi sıralamak için onu eşit iki parçaya (iki yarım diziye) bölüp o parçaları ayrı ayrı sıralar. Sıralanmış dizi sıralanmış yarım dizilerin birleştirilmesiyle oluşturulur. Anlaşılacağı gibi yarım dizilerin de her biri iki eşit parçaya bölünerek o parçaların ayrı ayrı sıralanıp birleştirilmesiyle elde edilir.

Birleştirmeli sıralama algoritmasını kendini çağıran bir fonksiyon kullanarak oluşturmak istersek algoritma aşağıdaki gibi yazılmalıdır:

fBirleştirSırala(dizi, ilk, son)
{

if(ilk < son)

{

orta = (ilk+son)/2

“Fonksiyon, dizinin ilk yarısını sıralamak için

“kendisini çağırıyor

fBirleştirSırala(dizi, ilk, orta)

“Fonksiyon, dizinin ikinci yarısını sıralamak için

“kendisini çağırıyor

fBirleştirSırala(dizi, orta+1, son)

“Sıralanmış dizileri birleştiriyor

fBirleştir(dizi, ilk, orta, son)

}

}

Bu kod örneğinde bazı işlemleri açıklamak için çift tırnak “ ile başlayan satırlar soktuk. Kolayca anlaşılacağı gibi fonksiyonumuz her çağrılışında kendisine sıralanması için gönderilmiş diziyi ikiye bölüyor, ve ilk yarım diziyle ikinci yarım diziyi sıralamak için yine kendisini çağırıyor. Bu kendini çağırma işlemi birer elemanlı iki yarım dizi kalana kadar devam ediyor. Tek elemanlı yarım dizilerin sıralanmasına gerek olmadığı için kendini çağırma işlemi bitiyor ve yarım dizilerin birleştirilmesi işi başlıyor. Program içiçe çağırmalar döngüsünden her çıkışında, sıralamış olduğu yarım dizileri birleştirerek tüm diziyi sıralı hale sokmuş oluyor.

Algoritmanın en önemli kısmı sıralanmış yarım dizilerin birleştirilmesi kısmıdır. Bu işi yapan fonksiyon birleştirilecek sıralı yarım dizilerin –asıl dizinin eleman indeks noları olarak- başlangıç ve bitiş konumlarını alacak, onların elemanlarını karşılaştıra karşılaştıra hangi elemanın birleştirilmiş dizide nerede olması gerektiğini bulacaktır. Sıralı yarım dizileri birleştirildikten sonra, geçici dizi elemanları asıl diziye aktarılmalıdır.

Kod Örneği 4‑3 Birleştirmeli sırala algoritmasının yarım dizileri birleştiren fonksiyonu

fBirleştir (dizi, ilk, orta, son)
{

“Birinci yarım dizinin elemanlarını tarayacak döngü indeksi

i = ilk

“İkinci yarım dizinin elemanlarını tarayacak döngü indeksi

j = orta+1

“Yarım dizileri birleştirirecek geçici dizi için indeks

k = 1

“ Birinci ve ikinci yarım dizileri geçici bir dizide

“ birleştirecek döngü

while((i ≤ orta) VE (j ≤ son)

{

if(dizi[i] < dizi[j])

{

geçicidizi[k] = dizi[i]

i++, k++

}

else

{

geçicidizi[k] = dizi[j]

j++, k++

}

}

“ Yarım dizilerden birinin erken bitmesi halinde

“ diğerinin elemanları birleştirilmiş diziye aktar

while(i ≤ orta)

{

geçicidizi[k] = dizi[i]

i++, k++

}

while(j ≤ son)

{

geçicidizi[k] = dizi[j]

j++, k++

}

“ Geçici diziyi asıl diziye aktar

k = 1

for(i=ilkàson)

{

dizi[i] = geçicidizi[k]

k++

}

}

Bu kod örneğinde ilk while döngüsünün devam koşulu iki koşul ifadesinin “VE” ile birleştirilmesiyle oluşturulmuştur. Yani bu döngünün devam etmesi için belirtilen her iki koşulun da sağlanması gereklidir. İki koşuldan birinin sağlanması yeterli olsaydı “VEYA” birleşimi kullanacaktık.

Aşağıdaki şekil bu koddaki işlemler yapılarak iki tane dört elemanlı sıralı dizinin sekiz elemanlı bir sıralı dizi oluşturacak şekilde birleştirilmesini gösteriyor:

Şekil 4‑1 Birleştirerek sıralama algoritmasında iki sıralı yarım dizinin birleştirilmesi

Her defasında soldaki yarım diziden bir elemanla sağdaki yarım diziden bir eleman karşılaştırılmıştır. Karşılaştırılan elemanlar gri gölgeli olarak gösterilmiştir. Karşılaştırmanın sonucuna bağlı olarak hangi elemanın birleştirilmiş dizide hangi konuma gittiği okla gösterilmiştir.

Birleştirerek Sıralama Algoritmasının Analizi

Bu kez tek tek döngülerdeki adım sayısını belirleyip toplamak yerine basitçe bir tekrarlı denklem kurup onu çözelim. Birleştirerek sıralama algoritması her defasında elindeki diziyi iki yarıya bölüyor ve onları ayrı ayrı sıralıyor. Demek ki N elemanlı bir dizinin sıralanması için gerekli adım sayısı Ã(N) yarım dizileri sıralamk için gerekli adım sayısı Ã(N/2)’nin iki katından az değildir. Yarım dizilerin ayrı ayrı sıralanmasından sonra birleştirme işlemi geliyor ki, o da yarım dizilerden tek tek elemanların birleştirilmiş diziye, oradan da geriye aktarılmalarından ibaretdir. Yani birleştirme işleminin adım sayısı N kadardır. Böylece birleştirerek sıralama algoritmasının adım sayısı için elimizde aşağıdaki gibi bir tekrarlı denklem vardır:

Ã(N) = 2 × Ã(N/2) + N

Bu denklemi çözmek için tekrarlı denklemlerin çözüm yöntemlerini tarışmaya gerek yoktur. Mantık yürtümek yeterlidir. İlk olarak, tek elemanlı bir dizi için tek bir işlem gerektiğine göre, Ã(1) = 1 yazıp işe başlayalım:

Ã(1) = 20

Ã(20) = 2 × Ã(20) + 21 = 2×20 + 21

Ã(22) = 2 × Ã(21) + 22 = 2 ×2×20 + 2×21 + 22

Ã(23) = 2 × Ã(22) + 23 = 2×2×2×20 + 2×2×21 + 2×22 + 23

Ã(2n) = 2 × Ã(2n-1) + 2n = (n + 1) × 2n

Bu son denklemde 2n yerine N koyarsak aşağıdaki formülü elde ederiz:

Ã(N) ≈ N lg(N)

Hatırlarsanız, 2n = N ise n = log2(N) ki biz bunu lg(N) diye yazıyoruz.

Adım sayısı N2 ile orantılı olan çifterli sıralama ve araya sokarak sıralama algoritmalarıyla karşılaştırıldığında, birleştirmeli sıralama algoritması daha avantajlı gözüküyor. Elimizde 1,000,000 (bir milyon, ya da 106) elemanlı bir dizi olsaydı, araya sokarak sıralama algoritması 1012 adım, yani bir trilyon adım gerektirecekti. Ama birleştirerek sıralama algoritması 20,000,000 (20 milyar) adım gerektirecektir, nerdeyse diğerinin yüzbinde biri kadar! Hesapladığımız bu adım sayısı dizideki elemanların önceden sıralı olup olmadıklarına bağlı değildir. Elemanların ikişer ikişer karşılaştırılmaları sonucuna bakılarak yerdeğiştirme yapılmadığı için elemanlar doğru sırada da olsa, tersine sıralı da olsa adım sayısı aynı olacaktır.

Birleştirerek sıralama algoritmasının olumsuz yanları da yok değildir. Bu algoritmaya dayalı bir program kendini çağıran bir fonksiyon kullanacağı için içiçice her çağrılışta fonksiyon kodu tekrar bilgisayar hafızasına yüklenecek, dolayısıyla aynı kod tekrar tekrar yüklenecek, gittikçe daha fazla yer işgal edecektir. Bundan başka, sıralı dizilerin geçici bir dizide birleştirilmeleri de fazladan hafıza kullanılması anlamına gelir.

Hızlı Sıralama (Quicksort)

Birleştirerek sıralama algoritması diziyi teker elemanlı alt dizilere erişene kadar bölüyor, ama bölme yaparken sıralamak yerine, yarım dizileri sıralı olacak şekilde birleştiriyordu. Bu işlemi biraz değiştirir, diziyi bölerken küçük elemanları bir tarafa, büyük elemanları bir tarafa atacak şekilde bölersek, diziyi bölerken bir yandan da sıralayan bir algoritma elde ederiz. Bu algoritmanın yaygın adı “hızlı sıralama” (quicksort)’dur. Kendini yineleyen versiyonu aşapıdaki gibi yazılabilir:

fHızlıSırala(dizi, ilk, son)
{
if(ilk < son)
{
orta = fAyir(dizi, ilk, son)
fHızlıSırala(dizi, ilk, orta)
fHızlıSırala(dizi, orta+1, son)
}
}

Birleştirerek sıralama algoritmasında asıl iş birleştirme işlemini yapan fBirleştir fonksiyonunda yapılıyordu. Bu algoritmada ise asıl işi yapan fonksiyon diziyi iki parçaya ayırmakla (partitioning) yükümlü fAyır fonksiyonudur. Bu fonksiyon, dizinin kendisine gönderilen kısmından bir eleman seçer, bu elemandan küçük elemanları bir tarafa, büyük elemanları öbür tarafa ayıracak şekilde gönderilen diziyi iki alt diziye ayırır. Sonuç olarak da gönderilen dizinin bölünme sınırı olan elemanın indeksini gönderir.

Kod Örneği 4‑4 Hızlı sıralama algoritmasının alt dizileri ayıran fonksiyonu

fAyır(dizi, ilk, son)
{
sınırdeğer = dizi[ilk]
i = ilk
j = son
“Dikkat! Sonsuz döngü!
while()
{
while(dizi[j] > sınırdeğer) { j– }
while(dizi[i] < sınırdeğer) { i++ }

if(i < j)
{

fYerdeğiştir(dizi, i, j)

i++, j–

}

else
{  SONUÇ: j  }
}
}

Bu fonksiyonda, çifterli sıralama algoritmasındaki gibi, karşılaştırma sonucuna bağlı olarak indeks numaraları verilen iki elemanın değerlerinin yerdeğiştirmesi gerekebilir. Bu yer değiştirme işlemi aşağıdaki gibi bir fonksiyona havale edilebilir.

fYerdeğiştir(dizi, i, j)
{
if(dizi[i] > dizi[j])
{
tmp = dizi[j]
dizi[j] = dizi[i]
dizi[i] = tmp
}
}

fAyır fonksiyonu için yazdığımız kodda devam koşulu olmayan bir while döngüsü kullandık. Bu tür bir döngü içerde bir çıkış koşulu konmazsa sonsuza kadar devam edip gider. Bu nedenle döngü başlangıcından önceki satıra bir uyarı koyduk. Koyduğumuz uyarı, bu algoritmaya dayalı program yazacak okuyucularımıza döngüden çıkış koşulu koymaları gerektiğini hatırlatıyor. Bizim yazdığımız kodda i < j koşulunun gerçekleşmemesi durumunda döngüden çıkılıyor. Yani i ≥ j olduğunda fAyır fonksiyonu bu iki indeksin ortak değerini fonksiyon sonucu olarak gönderiyor. Bu sonuç dizinin bölünme sınırı olacaktır.

Bu algoritmaya ait kodda kullandığımız yeni bir işlem de var: dizi[j]’nin değeri sınırdeğer’den büyükse j indeksini bir azaltmak için işlemini kullanıyoruz.

Aşağıdaki şekil Kod Örneği 4‑4’deki fAyır fonksiyonunun 8 elemanlı bir dizi üzerindeki ilk çalışması sonucunda diziyi nerede ayıracağını gösteriyor. Görüldüğü gibi sınır değeri olarak kabul edilen dizinin 1’inci elemanından küçük veya eşit değerler soldaki alt diziye, büyük veya eşit değerler de sağdaki alt diziye yerleştirilmiş, böylece bu ilk geçişte dizi sıralı duruma bir parça yaklaşmıştır.

Şekil 4‑2 Hızlı sıralama algoritmasının ayırma fonksiyonunun 8 elemanlı bir dizi üzerinde ilk çalışması

Hızlı sıralama algoritması bundan sonra alt diziler üzerinde ayrı ayrı çalışınca her biri kendi içinde sıralanmış olacak, sıralı alt dizileri birleştirecek bir başka fonksiyona gerek kalmayacaktır.

Hızlı Sıralama Algoritmasının Analizi

Eğer Kod Örneği 4‑4’deki fAyır fonksiyonu her çağrıldığında kendisine gönderilen diziyi veya alt diziyi tam ortadan ikiye bölerse bu fonksiyonu kullanan hızlı sıralama algoritmasının adım sayısı da tıpkı birleştirerek sıralama algoritmasında olduğu gibi Ã(N) = 2 × Ã(N/2) + N tekrarlı denklemini sağlar ve dolayısıyla Ã(N) ≈ N lg(N) olarak bulunur. Ama bu fonksiyon gönderilen alt dizinin ilk elemanını alt diziyi ayıracak sınır değer olarak kullandığına göre alt diziyi başından mı, ortasından mı, yoksa sonundan mı böleceği önceden belli değildir. Elemanları rasgele dizilmiş bir dizi söz konusu olduğunda hızlı sıralama algoritmasının adım sayısı N lg(N) kadar iyi olmasa da onun en fazla 1.5 veya 2.0 katı olacaktır, ama ters sıralı bir dizi söz konusu olduğunda adım sayısı N 2 ile orantılı olacak kadar kötü olabilir.

Kısacası hızlı sıralama algoritmasının adım sayısı birleştirmeli sıralama algoritmasınınki gibi elemanların dizilişinden bağımsız değildir, ama fazladan bir başka dizi kullanmayıp diziyi yerinde sıralaması açısından avantajlıdır.

 
1 Comment

Posted in C++

 

Sıralama Algoritmaları (Sorting Algorithms)

10 Eki

Sıralama Algoritmaları (Sorting Algorithms)

Bir dizide belli bir değerin aranmasından sonra gerekli olabilecek ikinci önemli işlem, dizideki elemanların sıralanmasıdır. Bir sıralama işlemi ileride istenen verilere çabuk erişim sağlayacağı için de önemlidir.

Araya Sokarak Sıralama (Insertion Sort)[1]

Daha önce Şekil 2‑1’de birbiri ardına çekilen iskambil kartlarının her birinin daha önceden çekilmiş kartlar arasına, büyüklük sırasına göre ait olduğu yere sokulmasıyla beş kartlık sıralı bir dizi oluşturmaları için gereken işlemleri göstermiştik. Bir diziye veya başka bir veri grubuna her eklenen elemanın daha önceden yerleştirilmiş elemanlar arasında ait olduğu yere sokulmasıyla yapılan bir sıralama işlemine “araya sokarak sıralama” diyebiliriz. Bu tür bir sıralama işlemi ne en basit olanı, ne de en çabuk olanıdır. Ama günlük hayatta sık kullanılan, insanların kolay anladığı bir sıralama işlemi olduğu için, bu kısımda ilk olarak bir araya sokarak sıralama algoritmasını nasıl tasarlayabileceğimizi tartışacağız.

Elemanlarının değerleri küçükten büyüğe doğru artacak şekilde sıralanmış bir veri grubu oluşturmak istediğimizi varsayalım. İlk elemandan başlayarak her yeni elemanı ait olduğu yere sokarak veri grubunu her zaman sıralı olacak şekilde düzenli tutacağız. Algoritmamız için kod yazmadan önce, gerekli işlemleri düzyazıyla yazalım.

..Yeni değer olduğu sürece tekrarla..

{

..Yeni değeri öğren..

..Veri grubunu, başından başlayarak,
yeni elemandan büyük bir eleman bulana kadar tara..

..Yeni elemanı kendinden büyük ilk elemandan önceki konuma yerleştir..

}

Bir araya sokarak sıralama algoritması basitçe bu şekilde tarif edilebilir. Bu algoritmaya dayalı bir kod yazacağımız zaman elimizdeki veri grubunun cinsini bilmemiz gerekir. Bir dizi için yeni elemanı ait olduğu yere sokmak demek, diziye bir ara eleman eklemek demektir, ve dizinin sonraki elemanlarının sona doğru birer adım kaydırılmalarını gerektirir. Bir bağlantılı listede ise yalnızca yeni elemanın yerleşeceği konumdan bir önceki ve bir sonraki konumlardaki elemanların bağlantılarının değiştirilmesi yeterli olacaktır. Yani bu açıdan bir dizide bu işlem daha çok adım gerektirir. Öte yandan, bir dizide yeni elemanın ait olduğu yeri bulmak işlemi yarılamalı aramayla kısaltılabilir. Bir listede ise mutlaka baştan başlayarak bütün elemanların taranması gerekebilir. Bu işlem de bir dizide daha kolaydır.

Aşağıda her defasında bir eleman araya sokarak bir dizinin elemanların değerleri küçükten büyüğe artacak şekilde sıralanışını gösteren bir algoritma sunuyoruz. Listeye sokulan N adet değer ya klavyeden girilecek, ya bir dosyadan okunacak, ya da bir başka veri grubundan aktarılacaktır. Nasıl yapıldığı algoritmayı etkilemediği için bu işlem basitçe yazılı olarak ifade edilmiştir. Her adımda yeni değer X adlı bir değişkende saklanıyor ve A adlı dizinin mevcut elemanları arasında ait olduğu yere sokuluyor:

Kod Örneği 3‑4 Bir araya sokarak sıralama algoritması

..X’in değerini öğren..

dizi[1] = X

for(i=2àN)

{

.. aranandeğer’in değerini öğren..

for(j=1ài-1)

{
if(X < dizi[j])
{

for(k=iàj+1,-1)
{
dizi[k] = dizi[k-1]
}
..döngüden çık..
}

}

dizi[j] = X

}

Bu algoritma biraz karmaşık olduğu için adım adım açıklama gerektiriyor. Öncelikle ilk iki satırı açıklayalım. Diziye herhangi bir eleman sokmadan önce dizide en az bir eleman olmalıdır ki sonradan sokulacak elemanlar mevcut elemanlarla karşılaştırılabilsin. Bu nedenle ilk işimiz ilk öğrendiğimiz yeni değeri dizinin 1’inci elemanı olarak başlangıç konumuna sokmaktır.

Algoritmanın geri kalanı içiçe üç döngüden oluşuyor. En dıştaki for(i=2àN) döngüsünün yalnızca diziye yeni eklenecek değerleri saymaktır. Bunun içindeki for(j=1ài) döngüsü yeni öğrenilen i’inci değeri dizide mevcut elemanlarla karşılaştırma işini yapıyor. En dıştaki i döngüsünün her başlayışında dizide i-1 eleman bulunduğu için j döngüsü 1’inci elemandan başlayıp (i-1)’inci elemana kadar gidiyor. Eğer ardı ardına yapılan karşılaştırmalarda yeni değer X mevcut elemanlardan birinden küçük çıkarsa yeni değerin kendisinden büyük elemanın yerine geçmesi gerektiği için, o noktada bir üçüncü döngü (k döngüsü) başlıyor ve dizi elemanları sona doğru birer konum kaydırılıyor. Kaydırma işlemi bitince yeni değer X ait olduğu yere sokuluyor ve j döngüsünden çıkılarak i döngüsüne devam ediliyor. N adet değer bu şekilde sokulunca en dıştaki döngü de sona eriyor. En içteki döngüdeki ,-1 ifadesi indeks değişkeni k’nın döngünün her adımında birer eksiltileceğini belirtiyor.

Çifterli Sıralama (Bubble Sort)[2]

Araya sokarak sıralama yöntemi boş bir diziye eleman eklerken bir yandan da sıralamak için yararlıdır, ama elemanları mevcut bir diziyi sıralamak için kullanışsızdır. Elemanları mevcut bir diziyi sıralamak –ki buna “yerinde sıralamak” (sorting in place) diyoruz- için de algoritmalar vardır. Bunlar arasında çifterli sıralama öğrenilmesi ve uygulanması en basit sıralama algoritmalarından biridir. Dizi elemanların ikişer ikişer karşılaştırılıp gerekiyorsa yerdeğiştirtilmesiyle yapılır. Değişik şekillerde uygulanabilir. Burada iki değişik şeklini göstereceğiz.

Kod yazmadan önce gerçek bir örnek üzerinde çalışalım. Aşağıda rasgele yerleştirilmiş sayılardan oluşan 5 elemanlı bir dizinin sıralanışını gösteriyoruz:

Şekil 3‑1 Bir dizinin çifterli sıralama yöntemiyle sıralanması

Her adımda karşılaştırılan iki eleman gri gölgeli gösterilmiştir. Eğer karşılaştırılan iki elemandan baş tarafa yakın olanı diğerinden büyük çıkarsa küçük çıkan eleman onun yerine getirilmiştir. İki elemanın yerdeğiştirmesi (swap) aslında değerlerinin yerdeğiştirmesi demektir. Örneğin, ikinci adımda 1’inci ve 3’üncü elemanların yer değiştirmeleri aslında şu şekilde gerçekleşir:

tmp = dizi[3]

dizi[3] = dizi[1]

dizi[1] = tmp

Değerlerin yer değiştirmesi sırasında 3’üncü elemanın değerinin kaybedilmemesi için tmp adlı geçici bir değişkene aktarılması gerekmiştir. “Geçici” anlamına gelen temporary teriminin kısaltması olan tmp yerdeğiştirme işlemlerinde kullanılan geçici değişkenlere verilen popüler bir addır. Elbette her programcı her değişkene kullandığı dilin izin verdiği her adı koyabilir, ama tmp gibi sık kullanılan özel bir ad programın o an yaptığı işi daha anlaşılır kılacaktır.

Şimdi yukarıda örneğini sunduğumuz çifterli sıralama uygulamasının kodunu yazalım:

Kod Örneği 3‑5 Bir çifterli sıralama algoritması

for(i=1àN-1)

{

for(j=i+1 à N)

{

if(dizi[i] > dizi[j])

{

tmp = dizi[j]

dizi[j] = dizi[i]

dizi[i] = tmp

}

}

}

Şekilden de, kod örneğinden de anlaşılacağı gibi bu algoritma aslında bir dizinin önce em küçük, sonra ikinci en küçük, sonra üçüncü en küçük, … elemanlarının bulunup, birinci, ikinci, üçüncü, … sıralara yerleştirilmesinden ibarettir.

Saymalı Sıralama (Counting Sort)

Önceki kısımlarda tanıttığımız iki sıralama algoritması da bir dizi içindeki elemanları her defasında iki elemanın değerini karşılaştırarak sıralıyordu. Eğer bir dizideki elemanların değerleri bazı özel koşulları sağlıyorsa hiç karşılaştırma yapılmaksızın sıralama yapabilecek algoritmalar da vardır.

N sayıda öğrencinin sınav notlarının, araya sokarak sıralama yapacak bir algoritma kullanılmaksızın, rasgele sırayla bilgisayara girilerek bir diziye aktarıldığını varsayalım. Öğrenci sayısı N, 100’den çok fazla olduğu için notlar dizisi 1’den 100’e kadar olası her tamsayı değerden en az bir kaç tane içermektedir (Evet, bu sınavda 0 alan olmamış!). Eğer dizi içindeki notları tek tek inceleyip 1 ile 100 arasında hangi nottan kaç tane olduğu sayan bir program yazarsak, bu program bize sınav notlarının bir frekans tablosunu vermiş olacaktır. Notlar dizisini sıralı olarak oluşturmak için tek yapmamız gereken şey, bu frekans tablosuna bakıp bakıp hangi nottan kaç tane varsa o sayıda notu sırayla bir başka diziye sokmaktır. Aşağıdaki basit kod örneği bir 1’den 100’e kadar N adet tamsayı değer içeren dizi adlı dizideki değerlerin frekans tablosunu oluşturarak bir sıralıdizi adlı yeni bir dizi oluştutuyor:

Kod Örneği 3‑6 Sınav notlarını sayarak sıralayan bir algoritma

for(i=1àN)

{

frekans[dizi[i]]++

}

n = 1

for(i=1à100)

{

for(j=1àfrekans[i])

{

sıralıdizi[n] = i

n++

}

}

Bu algoritmadaya dayalı bir programda frekans dizisi 100 elemanlı bir tamsayı dizisi olarak tanımlanmalı ve bu dizinin her elemanı program başlangıcında 0 değeri taşımalıdır. İlk döngünün yaptığı tek şey dizi’deki het not değeri için o not değerine karşılık gelen frekans dizisi elemanı bir arttırılmıştır. Böylelikle frekans dizisi elemanlarının taşıdığı değerler hangi nottan kaç tane olduğunu belirtiyor olacaktır. İkinci döngüde 1’den 100’e kadar her tamsayı için frekans dizisinin o numaralı elemanındaki değer kadar ekleme yapıyor sıralıdizi’ye.

Algoritmaların Analizi

Bir Algoritmanın Performans Ölçütleri

Şimdiye ladar her tanıttığımız algoritma için basitçe adım sayısını tahmin ederek o algoritmanın verimli olup olmadığı konusunda fikir yürüttük. Bir algoritmadaki adım sayısı o algoritmaya dayalı bir bilgisayar programının yapacağı işlem sayısını, dolayısıyla o programın toplam çalışma süresini belirleyeceği için önde gelen performans ölçütlerinden biridir. Bilgisayarda değerlerin saklanması için kullanılan hafıza birimlerinin sayısı da bir başka önemli performans ölçütüdür. Saymalı sıralama algoritmalarının adım sayısını azaltmak uğruna fazladan hafıza işgal edecek yeni diziler yarattığını belirtmiştik. Sınırsız hafızaya sahip ideal bilgisayarlara sahip olduğumuzu, veya bigisayar hafızasının sınırlı geldiği durumlarda başka veri depolama aygıtlarını kullanabileceğimizi varsayarak, ikinci performans ölçütünü görmezlikten gelebiliriz. Ama algoritmanın adım sayısı her zaman bizi sınırlayacak önemli bir ölçüt olarak kalır.

Adım Sayısıyla Analiz

Bir algoritmadaki her komutu bir “adım” olarak düşünelim ve algoritmayı uyarlayan bir programın çalışması sırasında her adımın aynı süre harcattığını varsayalım. Bir algoritmanın “maliyetini” tahmin etmek için en basit yol budur. Örnek olarak Kod Örneği 3‑4’de sunulan araya sokarak sıralama algoritmasını bu yolla analiz edelim.

1. ..X’in değerini öğren..

2. dizi[1] = X

3. for(i=2àN)

{

4.   ..X’in değerini öğren..

5.   for(j=1ài-1)

{
6.     if(X < dizi[j])
{

7.        for(k=iàj+1,-1)
{
8.          dizi[k] = dizi[k-1]
}
..döngüden çık..
}

}

9. dizi[j] = X

}

Analiz yaparken hangi komutun kaç kez uygulanacağını bulmamız gerekir. Komutları tanıyabilelim diye onlara numaralar verdik. Okuyucularımız için şurası açıktır ki sırayla, tek tek uygulanacak her komut tek bir kez uygulanır. Ama bir koşul ifadesine bağlı bir komut ancak o koşul gerçekleştiğinde uygulanır. Algoritma analizlerinde en fazla önem taşıyan komutlarsa döngü bloklarındaki komutlardır. Bunlar defalarca uygulanacakları için algoritmanın maliyetini bulmak için bir döngünün kaç kez uygulandığını bilmek önemlidir.

Analizimize örnek olan algoritmada 1. , 2. ve 9. komutlar koşul veya döngü blokları içinde yer almadıklarına göre mutlaka birer kere uygulanacaklardır. Dolayısıyla algoritmanın maliyetine 3 adım olarak katkıda bulunurlar.

3. , 4. ve 5. komutlar i döngüsü kaç kere çalışırsa o kadar sayıda uygulanacak­lardır. Bu algoritmayla N elamanlı bir dizi oluşturulacaksa 2. komut ile ilk eleman okunduktan sonra N-1 eleman daha eklenip sıraya sokulmalıdır. Yani i döngüsü N-1 kere uygulanacaktır. Zaten döngü sınırları da (2’den N’e kadar, ikisi de dahil) aynı şeyi söylüyor. Demek ki onların katkısı 3 (N-1) adım.

6. komut 5. komut olarak numaralandırdığımız j döngüsü içinde yer alıyor. j döngüsü her i değeri için i kere uygulanacağına göre 6. komutun katkısı adım sayısı olarak

olacaktır.

7. komut bir koşul ifadesine (6. komut) bağlı olduğu için onun kaç kez uygulanacağını bulmak biraz düşünmeyi gerektirir. Koşul ifadesi yeni okunan değer mevcut j’inci elemandan daha küçükse uygulanacaktır. Eğer sırayla okunan değerler zaten küçükten büyüğe sıralı geliyorsa o zaman koşul ifadesinin blokundaki komutlar hiç uygulanmayacaktır. Bu en iyi durumdur. Ama eğer okunan değerler tersine sıralı gelirse her yeni okunan değer dizinin başındaki değerden küçük olacağı için 7. ve 8. komutlar 6. komutun her uygulanışında uygulanacaklardır. Bu ise en kötü durumdur. En iyi durumda 7. ve 8. komutlar 0 adım olarak sayılabilir. Ama en kötü durumda

adım olarak sayılmalıdır.

8. komut en içteki k döngüsü içinde yer alıyor ve uygulanma sayısı hem i, hem de j değerine bağlı. Onun katkısı ise

olacaktır.

Sonuç olarak algoritmanın adım sayısı olarak maliyeti sıralanacak değerlerin geliş sırasına bağlıdır.

En iyi durumda adım sayısı  3 + 3 (N-1) + (N-1) (N+2) / 2

En kötü durumda adım sayısı  3 + 3 (N-1) + 3 (N-1) (N+2) / 2

olacaktır.

Bu sonuçların açık halini N cinsinden birer polinom gibi yazabiliriz ama o kadar ayrıntıya girmeye gerek yok. O kadar ayrıntıya girecek olsak hangi adımın gerçekte ne kadar zaman gerektirdiğini de hesaba katmamız gerekirdi. Bizim için önemli olan polinomun derecesidir. Çünkü eleman sayısı N büyüdükçe en yüksek üslü terim daha küçük üslü terimlerden daha da büyük olacak, yani sonuca en fazla etki eden terim o olacaktır. Bu analizimizde her iki durumda da en yüksek üslü terim N2’dir. Demek ki algoritmanın adım sayısı dizideki eleman sayısının karesiyle orantılıdır. İki durum arasında farklı olan tek şey adım sayısı ile N2 arasındaki orantılılık katsayısıdır. En iyi durumda adım sayısı N2/2 iken en kötü durumda 3 N2/2.

Peki bu algoritma rasgele bir uygulamada nasıl bir performans gösterecektir? Bu soruyu sorarak algoritmanın ortalama durumdaki adım sayısını sormuş oluyoruz. Ortalama durumda her yeni okunan değerin mevcut elemanlar arasında ortada bir yere gireceğini varsayabiliriz. O zaman ortalama durumdaki performansın en iyi ve en kötü durumlardaki adım sayılarının ortalaması kadar adım sayısı olacağını söylemiş oluyoruz, yani N2 kadar.

İyileştirilmiş bir Algoritma

Yukarıdaki analizimiz bize daha önce sunduğumuz araya sokarak sıralama algoritmasının adım sayısının oluşturulacak sıralı dizideki eleman sayısına ve diziye eklenecek elemanların geliş sırasına bağlı olduğunu gösterdi. Her ne kadar en kötü durumdaki adım sayısı en iyi durumdakinin 3 katı olsa da adım sayısının her iki durumda da eleman sayısınınkaresiyle orantılı olduğunu gördük. Sanki algoritmamız çok da iyi tasarlanmamış gibi. Biraz düşünecek olursak sorunu görürüz: Diziye eklenecek değerler zaten sıralı gelirse her yeni eklenecek değer için yalnızca tek karşılaştırma (mevcut ilk elemanla karşılaştırılır ve ondan küçük çıkar) gerekecektir, ama yeni değer ilk sıraya oturabilsin diye mevcut elemanların hepsinin sona doğru birer hane kaydırılmaları gerekir. Eklenecek değerlerin tersine sıralı gelmeleri durumunda ise her yeni eleman sona yerleşecek ve kaydırma gerekmeyecektir, ama mevcut elemanların her biriyle karşılaştırılmaları gerekir. Yani her i’inci eleman en iyi durumda 1 karşılaştırma ve i-1 kaydırma, en kötü durumda i-1 karşılaştırma gerektiriyor. Tabi böylece her eleman mevcut eleman sayısı kadar adım gerektiriyor ve algoritmanın toplam adım sayısı her durumda en son eleman sayısının karesiyle orantılı çıkıyor.

Bu noktada kendimize neden “Karşılaştırma işine baştan değil de sondan bağlamıyoruz?” diye soralım. Eğer karşılaştırma işine sondan başlayıp geriye doğru gitseydik, eklenecek değerlerin zaten sıralı geldiği en iyi durumda her yeni değer doğrudan en sona eklenir ve hiç kaydırma gerektirmezdi. Comen, Reiserson ve Rivest’in (7) sunduğu araya sokarak sıralama algoritması işte bu kolaylıktan yararlandığı için daha önce sunduğumuz algoritmaya göre çok daha avantajlıdır. Aşağıdaki kod örneği onların algoritmasına dayalıdır:

1. for(i=1àN)

{

2.   ..X’in değerini öğren..

3.   dizi[i] = X
4.   j = i-1
5.   while(j>0 ve dizi[j] > X)

{
6.     dizi[j+1] = dizi[j]
7.     j–

}

}

}

Bu iyileştirilmiş algoritmada 1. , 2. , 3. , 4. ve 5. adımlar N kez uygulacağı için 5N adım olarak sayılırlar. 6. ve 7. komutlar ama eklenecek değerlerin tersine sıralı geldiği en kötü durumda  adım olarak sayılırlar. En iyi durumda ise hiç uygulanmazlar. Kısacası bu algoritmanın adım sayısı en iyi durumda N ile, en kötü durumda N2 ile orantılı olacaktır. Ortalama durumdaki adım sayısı bu kez de en iyi ve en kötü durumlardaki adım sayılarının ortalaması alarak bulabiliriz ama büyük N değerleri için N değeri N2 değerine göre çok küçük olacağına göre ortalama durumdaki adım sayısı da ister istemez N2 ile orantılı çıkar. Ortalama adım sayısına N2 / 2 diyebiliriz.

Çifterli Sıralama Algoritmasının Analizi

Yukarıdaki iki algoritma analizi örneğinde de kesin adım sayısına değil de adım sayısı ile eleman sayısı arasındaki ilişikiye baktık. Bu durumda analiz metodumuzu basitleştirmek için birbirlerinin ardı sıra uygulanacak her komut dizisini tek bir adım gibi kabul edebiliriz. Böylece özellikle döngüler ve koşul ifadelerinin kaçar kez uygulanacaklarını bulmak analiz için yeterli olacaktır.

Bu kestirme metodu birkaç sayfa önce sunduğumuz çifterli sıralama algoritmasını analiz etmekte kullanabiliriz.

for(i=1àN-1)

{

for(j=i+1 à N)

{

if(dizi[i] > dizi[j])

{

tmp = dizi[j]

dizi[j] = dizi[i]

dizi[i] = tmp

}

}

}

Görüldüğü gibi bu algoritma içiçe iki döngü kullanıyor ve içteki döngünün içinde ise bir koşul ifadesi var. Bu koşul gerçekleşirse ona bağlı kod bloku içindeki komutlar uygulanacak, gerçekleşmezse uygulanmayacaklardır. Ama durum ne olursa olsun bu koşul ifadesini tek adım olarak sayarız, çünkü koşul ifadesi içinde bir döngü yok. Dolayısıyla analizimiz için önemli olan içiçe döngülerin kaçar kez uygulanacaklarıdır. Dıştaki i dögüsü N-1 kez, içteki j döngüsü N-i kez uygulanacaktır. Sonuç olarak adım sayısı:

olarak bulunur. Yani bu algoritmanın adım sayısı eleman sayısının karesi ile orantılıdır. En içteki koşul ifadesinin uygulanması veya uygulanmaması –ardışık komutları tek bir adım saydığımız için- sonucu değiştirmediğine göre elemanların başlangıçtaki sırasının hiç bir önemi yoktur. Zaten sıralı bir diziyi sıralamaya çalışıyor da olsak bu algoritmanın adım sayısı cinsinden maliyeti aynı olacaktır.

Sıralama Algoritmalarının Bağlantılı Listelere Uygulanışı

Listelerle dizilerin aynı özelliklere sahip olmadığını belirtmiştik. En önemli fark dizilerin sıra numarasıyla erişim özelliğinin listelerde olmayışıdır. Dolayısıyla sıra numarasıyla erişim gerektiren bir algoritmanın bir listeyi sıralamakta kullanılamaz. Bu durumda yukarıda tanıtıp analizini yaptığımız araya sokarak sıralama algoritması listelere uyarlanamaz gibi gözüküyor ama aslında uyarlanabilir. Dizilerde sıra numarasıyla erişim mümkün olduğu için biz bu algoritmayı bir diziyi sıralamakta kullanırken elemanlara indeksleme yoluyla erişmekte sakınca görmedik, ama bu algoritma gerçek anlamda sıra numarası gerektirmez. Dikkatle incelenirse yeni elemanların sırayla eklendiklerini ve mevcut elemanlarla karşılaştırıldıklarında sırayla karşılaştırıldıklarını görürüz. Yani yalnızca sırayla erişime izin veren bir bağlantılı liste de araya sokarak sıralama metoduyla sıraşanabilir.

Bir bağlantılı listenin araya sokarak sıralama metoduyla sıralanışı aşağıdaki sözel komutlarla tarif edilebilir:

..Yeni değer olduğu sürece tekrarla..

{

..Yeni değeri öğren..

..Veri grubunu, başından başlayarak,
yeni elemandan büyük bir eleman bulana kadar tara..

..Yeni elemanı kendinden büyük ilk elemandan önceki konuma yerleştir..

}

Bu komutlar tanıdık geliyor, çünkü bir dizinin araya sokarak sıralanmasını gösterirken de aynı komutlarla işe başlamıştık. Çünkü genel prensip aynıdır. Bir bağlantılı listede de yeni elemanın mevcut elemanlar arasında (veya onlardan önce veya onlardan sonra) girmesi gereken yer bulunacaktır. Farklı olan şey yeni eleman yerleştirilirken o yerin öncesi ve sonrasındaki elemanlar ile yeni eklenen eleman arasında gerekli bağlantıların tekrar kurulmasıdır. Halbuki bir dizide yeni elemanın yerleştirilmesi mevcut elemanların kaydırılmalarını gerektirebiliyordu.

Aşağıdaki kod örneği araya sokarak sıralama algoritmasının tek bağlantılı bir listeye uyarlanmış bir halidir.

Kod Örneği 3‑7 Tek bağlantılı bir listenin araya sokarak sıralama algoritmasıyla sıralanışı

“ İlk elemanı yarat ve ilk değeri ilk elemana ata

..X’in değerini öğren..

YARAT(pYeniEleman)

pYeniEleman.Deger = X

pYeniEleman.pSonraki = BOŞ;

pILK = pYeniEleman;

“ Sırasıyla diğer elemanları yarat

for (i = 1; i < N; i++)

{

..X’in değerini öğren..

YARAT(pYeniEleman)

pYeniEleman.Deger = X

pYeniEleman.pSonraki = BOŞ;

“ Yeni elemanın değeri ilk elemanın değerinden küçükse

“ yeni elemanı ilk eleman olarak işaretle
if (pILK.Deger > pYeniEleman.Deger)

{

pYeniEleman.pSonraki = pILK;

pILK = pYeniEleman;

}

“ Degilse yeni elemanin girecegi konumu bul

else

{

for (pEleman = pILK;
pEleman.pSonraki != null;
pEleman = pEleman.pSonraki)

{

if (pEleman.pSonraki.Deger > pYeniEleman.Deger)

{

.. döngüden çık ..

}

}

“ Yeni elemanı bu elemandan sonraki konuma yerleştir.

pYeniEleman.pSonraki = pEleman.pSonraki;

pEleman.pSonraki = pYeniEleman;

}

}

Bu kod örneğinde de içiçe iki döngü olduğu için toplam adım sayısı eleman sayısının karesiyle orantılı olacakmış gibi gözükiyor. Durumun gerçekten böyle olup olmadığını bulmak için döngülerin kaçar kez uygulanacaklarını bulalım. Yine eklenecek değerlerin zaten sıralı veya tersine sıralı geldiği durumlara bakalım. Eklenecek değerlerin sıralı geldiği durumda, dıştaki i döngüsünün her uygulanışı için mevcut elemanları yeni elemanla karşılaştıran içteki döngü i-1 kez çalışacaktır. Toplam adım sayısı

yani N2 ile orantılıdır. Ama yeni eklenecek değerlerin tersine sıralı geldiği durumda her yeni eklenen değer en başa oturacağı için içteki döngü hiç başlamayacak, yeni elemanın ilk eleman olarak belirlenmesi yeterli olacaktır. Bu durumda toplam adım sayısı eleman sayısı ile orantılı olacaktır. Eklenecek değerlerin tersine sıralı gelmesi diziye uyarlanmış algoritmada dezavantaj iken listeye uyarlanmış algoritmada avantaj sağlaması yalnızca bizim algoritmayı uyarlama şeklimizden ileri gelir. Eğer çifte bağlantılı bir liste oluşturup yeni elemanın mevcut elemanlarla karşılaştırılması işlemine sondan başlamış olsaydık işte o zaman toplam adım sayısı, aynı diziye uyarladığımız iyileştirilmiş algoritmada olduğu gibi, eklenecek değerlerin zaten sıralı gelmesi durumunda eleman sayısı ile orantılı olur, tersine sıralı gelmeleri halinde eleman sayısının karesiyle orantılı olurdu.

 
1 Comment

Posted in C++