Büyük Sayı Algoritmaları - Bölme
Daha önceki yazılarda, değişken-genişlikteki doğal sayılar üzerinde toplama, çıkarma ve çarpma algoritmalarına değinmiştik. Bu yazıda, D. Knuth'un The Art of The Computer Programming kitabının 4.3.1'inci kısmında anlatılan Algoritm D.'den bahsedeceğim. Bu algoritma, değişken-genişlikteki doğal sayılarda bölme işlemi yaparken, kabul edilebilir bir performansa sahip. Kodlara geçmeden önce, algoritmayı inceleyelim.
İki doğal sayı olan \( u = (u_{m+n-1} \ldots u_1u0)_b \) ve \(v = (v_{n-1} \ldots v_1v0)_b\) için, \(v_{n-1} \neq 0\) ve \(n > 1\) olmak üzere, b-tabanındaki bölümü \(\lfloor u / v \rfloor = (q_mq_{m-1} \ldots q_0)_b\) ve kalanı \(u \mod v = (r_{n-1} \ldots r_1r_0)_b\) olarak tanımlıyoruz.
- D.1 [Normalizasyon] Bu adımda, bölenin en üst hanesi sayı tabanının yarısından büyük veya eşit olacak, ancak, bölenin hane sayısı artmayacak şekilde, bölünen ve böleni bir \(d\) sayısı ile çarpıyoruz. Bu işlemi bilgisayarda yaparken, bölenin en üst biti 1 olacak kadar, iki sayıyı kaydırabiliriz. Bu adımda, bölünenin hane sayısı 1 artmalı. Eğer \(d\) ile çarpıldığında hane sayısı artmamışsa, sayının başına bir 0 eklememiz gerekiyor.
- D.2 [Döngü Başlangıcı] \(j = m\). Döngü sayacını bölünen ve bölenin hane sayılarının farkına eşitliyoruz.
- D.3 [\(\hat{q}\) hesabı] \(\hat{q} \leftarrow \lfloor (u_{j+n}b + u_{j+n-1}) / (v_{n-1}) \rfloor\) ve \(\hat{r} \leftarrow(u_{j+n}b + u_{j+n-1}) \mod v_{n-1} \) olsun. Türkçe ifade edersek, bölünenin üstten iki hanesini, bölenin en üst hanesine bölüp, sonucu \(\hat{q}\) ve kalanı \(\hat{r}\) olarak tanımla. Burada hesaplanan \(\hat{q}\), bölümün bir hanesinin tahmini değeri olacak. Kitaptaki açıklamalardan, burada hesaplanan tahmini değerin, olması gerekenden en çok 2 fazla olabileceği anlaşılıyor. Eğer \(\hat{q} = b\) ise, veya \(\hat{q}v_{n-2} > b\hat{r} + u_{j+n-2}\) ise \(\hat{q}\) değerini 1 azaltıp, \(\hat{r}\) değerine \(v_{n-1}\) ekleyip, \(\hat{r} < b\) ise bu testi tekrar ediyoruz. Burada yapılan işlemi de Türkçe ifade etmek gerekirse, bölen ve bölünenin birer hanelerine daha bakarak, \(\hat{q}\) değerini düzeltiyoruz. Böylece, \(\hat{q}\)'in 2 fazla olduğu durumların tümünü, 1 fazla olduğu durumların büyük bir çoğunluğunu hızlıca elemiş oluyoruz. Ben bu iddiayı kanıtlamadım, ancak, birçok farklı sayı ile test ederek teyit ettim.
- D.4 [Çarp Çıkar] \((u_{j+n} \ldots u_j)_b = (u_{j+n} \ldots u_j)_b - \hat{q}(v_{n-1} \ldots v_0)_b \). Bu adımda, bir önceki adımda hesaplanan \(\hat{q}\) ile böleni çarpıp, j (döngü sayacı) kadar hane kaydırarak, bölünenden çıkarıyoruz. Çıkarma işleminin sonucu negatif ise, bunu hatırlamamız gerekiyor.
- D.5 [Kalanı Kontrol Et] \(q_j \leftarrow \hat{q}\) olarak ayarla. Eğer, bir önceki adımın sonucu negatif ise D.6'ya, değilse, D.7'ye geç.
- D.6 [Geri Ekle] \(q_j\)'yi 1 azalt ve \((u_{n+j} \ldots u_j)_b\)'ye \((0v_{n-1} \ldots v_n)_b\) ekle. D. Knuth bu adımın gerekli olduğu durumların çok nadir olduğunu belirtmiş. Ben de algoritmayı test ederken, milyarlarca rastgele bölme işlemi yapmama rağmen bu adıma gelemediğim için, bu adımı tetikleyecek bir rastgele sayı üreticisi yazmak zorunda kaldım. D. Knuth, bu adımın gerekli olma ihtimalini 2/b olarak vermiş. Benim bulabildiğim örneklerin çoğunda, hem bölenin, hem bölünenin üstten bir alttaki hanesi sıfırdı.
- D.7 [Döngü] j'yi 1 azalt ve, eğer \(j >= 0\) ise D.3'e git.
- D.8 [Düzelt] Bölme işleminin sonucu \((q_m \ldots q_0)_b\). Eğer kalan bulmak gerekiyorsa, \((u_{n-1} \ldots u_0)_b\) sayısını D.1'deki \(d\) sayısına bölmeliyiz. Eğer D.1'de bit kaydırma yapılmışsa, aynı miktarda sağa bit kaydırarak kalanı elde edebiliriz.
Algoritmayı C'de yazmadan önce, onluk tabanda bir örnek yapalım. Bölünen 713892 ve bölen 152 olsun.
- D.1 [Normalizasyon] Onluk tabanda işlem yaptığımız için, bölenin üst hanesinin 5 veya daha büyük bir sayı olmasını istiyoruz. \(d\)'yi 6 seçersek, bölünen 713892 x 6 = 4283352, bölen ise 152 x 6 = 912 olur.
- D.2 [Döngü Başlangıcı] j = (6 - 3) = 3.
- D.3 [\(\hat{q}\) hesabı] \(\hat{q} = \lfloor \frac{42}{9} \rfloor = 4 \), \(\hat{r} = 42 \mod 9 = 6\). Düzeltme testini yaptığımızda, \(4 * 1 \ngtr 68\) olduğundan, düzeltme yapmayacağız. Bu hesaptaki 4 \(\hat{q}\), 1 bölenin bir sonraki hanesi, 6 \(\hat{r}\) ve 8 bölünenin bir sonraki hanesi.
- D.4 [Çarp Çıkar] 4 x 912 = 3648. 4283 - 3648 = 635. Bölünenin yeni değeri, 635352.
- D.5 [Kalanı Kontrol Et] Sonucun 3. hanesi (0 tabanlı indekslemeye göre) 4 olarak tespit edildi. Bir önceki adımın sonucu (635) pozitif olduğu için, D.7'ye geç.
- D.7 [Döngü] j = 2, D.3'e geç.
- D.3 [\(\hat{q}\) hesabı] \(\hat{q} = \lfloor \frac{63}{9} \rfloor = 7 \), \(\hat{r} = 63 \mod 9 = 0\). Düzeltme testini yaptığımızda, \(7 * 1 > 5\) olduğu için, \(\hat{q} = 6\) ve \(\hat{r} = 0 + 9 = 9\). Testi tekrar yaptığımızda, \(7 * 1 \ngtr 95\) olduğundan, devam ediyoruz.
- D.4 [Çarp Çıkar] 6 x 912 = 5472. 6353 - 5472 = 881. Bölünenin yeni değeri, 88152.
- D.5 [Kalanı Kontrol Et] Sonucun 2. hanesi (0 tabanlı indekslemeye göre) 6 olarak tespit edildi. Bir önceki adımın sonucu (881) pozitif olduğu için, D.7'ye geç.
- D.7 [Döngü] j = 1, D.3'e geç.
- D.3 [\(\hat{q}\) hesabı] \(\hat{q} = \lfloor \frac{88}{9} \rfloor = 9 \), \(\hat{r} = 88 \mod 9 = 7\). Düzeltme testini yaptığımızda, \(9 * 1 \ngtr 71\) olduğundan, düzeltme yapılmayacak.
- D.4 [Çarp Çıkar] 9 x 912 = 8208. 8815 - 8208 = 607. Bölünenin yeni değeri, 6072.
- D.5 [Kalanı Kontrol Et] Sonucun 1. hanesi (0 tabanlı indekslemeye göre) 9 olarak tespit edildi. Bir önceki adımın sonucu (607) pozitif olduğu için, D.7'ye geç.
- D.7 [Döngü] j = 0, D.3'e geç.
- D.3 [\(\hat{q}\) hesabı] \(\hat{q} = \lfloor \frac{60}{9} \rfloor = 6 \), \(\hat{r} = 60 \mod 9 = 6\). Düzeltme testini yaptığımızda, \(6 * 1 \ngtr 67\) olduğu için, herhangi bir düzeltme yapmayacağız.
- D.4 [Çarp Çıkar] 6 x 912 = 5472. 6072 - 5472 = 600. Bölünenin yeni değeri, 600.
- D.5 [Kalanı Kontrol Et] Sonucun 0. hanesi (0 tabanlı indekslemeye göre) 6 olarak tespit edildi. Bir önceki adımın sonucu (600) pozitif olduğu için, D.7'ye geç.
- D.7 [Döngü] j = -1, döngüden çık.
- D.8 [Düzelt] Elde ettiğimiz haneleri yanyana getirerek, bölümü 4696 olarak buluyoruz. kalanı bulmak için, 600'ü 6'ya bölmemiz gerekiyor. Dolayısıyla, kalan, 600/6 = 100 oluyor.
Algoritmayı dikkatli takip ettiyseniz, normalizasyon adımı haricinde, kağıt/kalemle bölme işleminden bir farkı yok. Kağıt/kalemle 4283352 sayısını, 912 ile bölerek bunu teyit edebilirsiniz.
Algoritmanın ilk adımında yapılacak kaydırma miktarını hesaplamak için, sayının üst 0 bitlerini
saymamız gerekiyor. Bunun için, aşağıdaki nlz
(number of leading zeros) fonksiyonunu kullanacağız.
Tanıdığımız derleyecilerde, derleyecilerin intrinsic fonksiyonlarını kullanacağız, aksi durumda,
nlz
fonksiyonunu kendimiz sağlamak zorundayız. Aşağıdaki fonksiyonda, kitaptakinden farklı bir
notasyon kullandım. Bölüm ve kalan, yukarıdaki gibi q
ve r
, bölünen n
, bölünenin uzunluğu nlen
,
bölen d
ve bölenin uzunluğu dlen
. Döngü sayacı olarak da, j
yerine i
kullandım.
Bu fonksiyonla ilgili değinmek istediğim birkaç husus var. Öncelikle, geçici hafıza için malloc
/free
yerine
alloca
kullandım. Geçici hafızayı stack üzerinde tutmak genellikle daha etkin olsa da, eğer çok büyük
sayılar arasında bölme işlemi yapacaksanız, stack alanını tüketebilirsiniz. Böyle bir riski öngörüyorsanız,
alloca
yerine malloc
/free
tercih etmelisiniz. Bir diğer konu da, çarp/çıkar aşaması. Daha önceki
yazılarda, çarpma ve çıkarma işlemleri için ayrı fonksiyonlar tanımlamıştık. Ancak, döngü içerisinde ekstra
fonksiyon çağrısından ve çarpma/çıkarmanın ara sonucu için ekstra hafıza ayırma yükünden kaçınmak için,
çarpma ve çıkarma işlemlerini buraya bir for
döngüsü ile dahil ettim.
Yukarıdaki algoritma, GMP kütüphanesinde, basecase division olarak geçiyor. Bazı özel durumlar için, daha verimli algoritmalar kullansalar da, bölme işlemi için genellikle kullanılan algoritma bu gibi görünüyor.
Ek Kaynaklar
- Knuth D. Art of The Computer Programming Vol 2 Section 4.3.1
- GMP Division Algorithms
- Wikipedia - Division algorithm