Haskell ve Scheme neden tek başına bağlantılı listeler kullanıyor?


12

Çift bağlantılı bir liste minimum ek yüke sahiptir (her hücre için başka bir işaretçi) ve her iki uca da eklemenizi ve ileri geri gitmenizi ve genellikle çok eğlenmenizi sağlar.


Liste yapıcısı, orijinal listeyi değiştirmeden tek başına bağlı listenin başına ekleyebilir. Bu fonksiyonel programlama için önemlidir. Çift bağlantılı liste hemen hemen çok saf olmayan değişiklikler içerir.
tp1

3
Bir düşünün, çift bağlantılı bir değişmez listeyi nasıl oluşturabilirsiniz? Sen olmalı nextsonraki elemana ve bir önceki eleman noktasının işaretçi prevönceki öğeye sonraki eleman noktasının pointer. Bununla birlikte, bu iki unsurdan biri diğerinden önce oluşturulur, yani bu unsurlardan birinin henüz var olmayan bir nesneye işaret eden bir işaretçisi olması gerekir! Unutmayın, önce bir öğeyi, sonra diğerini oluşturamazsınız ve sonra işaretçileri ayarlayamazsınız - bunlar değişmezdir. (Not: Tembelliği sömürmenin "Düğümün Bağlanması" adı verilen bir yolu olduğunu biliyorum.)
Jörg W Mittag

1
Çoğunlukla bağlantılı listeler çoğu durumda genellikle gereksizdir. Bunlara tersine erişmeniz gerekiyorsa, listedeki öğeleri bir yığının üzerine itin ve O (n) ters algoritması için tek tek açın.
Neil

Yanıtlar:


23

Biraz daha derin bakarsanız, her ikisi de temel dilde diziler de içerir:

  • 5. revize edilmiş Şema Raporu (R5RS), rasgele erişim için doğrusal zamandan daha iyi sabit boyutlu tamsayı ile indekslenmiş koleksiyonlar olan vektör tipini içerir .
  • Haskell 98 Raporunun da bir dizi türü vardır .

Bununla birlikte, işlevsel programlama talimatı, diziler veya çift bağlantılı listeler üzerinde uzun süreli tek bağlantılı listeleri vurgulamıştır. Aslında, büyük olasılıkla fazla vurgulanmıştır. Bununla birlikte, bunun birkaç nedeni vardır.

Birincisi, tek bağlantılı listelerin en basit ama en kullanışlı özyinelemeli veri türlerinden biri olmasıdır. Haskell'in liste türünün kullanıcı tanımlı bir eşdeğeri şöyle tanımlanabilir:

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

Listelerin özyinelemeli veri türü olması, listelerde çalışan işlevlerin genellikle yapısal özyineleme kullandıkları anlamına gelir . Haskell terimleriyle: liste yapıcılarında desen eşleşmesi yaparsınız ve listenin bir alt bölümünde yinelenirsiniz . Bu iki temel işlev tanımında, aslistenin kuyruğuna başvurmak için değişkeni kullanıyorum . Bu nedenle, özyinelemeli çağrıların listede "aşağı indiğini" unutmayın:

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

Bu teknik, işlevinizin tüm sonlu listeler için sonlanacağını garanti eder ve aynı zamanda iyi bir problem çözme tekniğidir - doğal olarak problemleri daha basit, daha dayanıklı alt bölümlere ayırma eğilimindedir.

Dolayısıyla, tek bağlantılı listeler muhtemelen fonksiyonel programlamada çok önemli olan bu teknikleri öğrencilere tanıtmak için en iyi veri türüdür.

İkinci neden, "neden tek bağlantılı listeler" nedeninden daha az, ancak "neden çift bağlantılı listeler veya diziler değil" nedeninden daha fazladır: bu son veri türleri genellikle işlevsel programlamanın çok sık olduğu mutasyon (değiştirilebilir değişkenler) gerektirir uzağa bağlanır. Böylece, olduğu gibi:

  • Şema gibi hevesli bir dilde, mutasyon kullanmadan çift bağlantılı bir liste yapamazsınız.
  • Haskell gibi tembel bir dilde, mutasyon kullanmadan çift bağlantılı bir liste oluşturabilirsiniz. Ancak bu listeye dayalı yeni bir liste yaptığınızda, orijinalin yapısının tamamı olmasa bile çoğunu kopyalamak zorunda kalırsınız. Tek bağlantılı listelerde "yapı paylaşımı" kullanan işlevler yazabilirsiniz; yeni listeler uygun olduğunda eski listelerin hücrelerini yeniden kullanabilir.
  • Geleneksel olarak, dizileri değişmez bir şekilde kullandıysanız, diziyi her değiştirmek istediğinizde her şeyi kopyalamanız gerektiği anlamına geliyordu. ( vectorBununla birlikte, son Haskell kütüphaneleri bu problemi büyük ölçüde geliştiren teknikler bulmuştur).

Üçüncü ve son neden Haskell gibi tembel diller için geçerlidir: tembel tek bağlantılı listeler pratikte genellikle uygun bellek içi listelere göre yineleyicilere daha benzerdir . Kodunuz bir listenin öğelerini sırayla tüketiyor ve gittikçe dışarı atıyorsa, nesne kodu yalnızca listede ilerledikçe liste hücrelerini ve içeriğini gerçekleştirir.

Bu, tüm listenin bir anda bellekte bulunması gerekmediği anlamına gelir, sadece geçerli hücre. Mevcut olandan önceki hücreler toplanabilir (çift bağlantılı bir listeyle mümkün olmaz); mevcut olandan daha sonraki hücrelerin oraya gelinceye kadar hesaplanması gerekmez.

Bundan daha da ileri gidiyor. Derleyicinin liste işleme kodunuzu analiz ettiği ve sıralı olarak üretilen ve tüketilen ve sonra "atılan" ara listelerini belirlediği füzyon adı verilen birçok popüler Haskell kütüphanesinde kullanılan teknik vardır . Bu bilgi ile derleyici, bu listelerin hücrelerinin bellek tahsisini tamamen ortadan kaldırabilir. Bu, bir Haskell kaynak programındaki tek bağlantılı bir listenin, derlemeden sonra aslında bir veri yapısı yerine bir döngüye dönüştürülebileceği anlamına gelir .

Füzyon ayrıca yukarıda bahsedilen vectorkütüphanenin değişmez diziler için verimli kod üretmek için kullandığı tekniktir . Haskell'in çok büyük olmayan yerel türünün ( tek bağlantılı karakter listesiyle aynı olan bytestring) yerini alan son derece popüler (bayt dizileri) ve text(Unicode dizeleri) kütüphaneleri için Stringde aynı şey geçerlidir [Char]. Modern Haskell'de füzyon destekli değişmez dizi türlerinin çok yaygınlaştığı bir eğilim var.

Liste birleşimi, tek bağlantılı bir listede ilerleyebileceğiniz, ancak asla geriye gidemeyeceğiniz gerçeğiyle kolaylaştırılır . Bu, işlevsel programlamada çok önemli bir temayı ortaya çıkarır: bir hesaplamanın "şeklini" türetmek için veri türünün "şeklini" kullanma. Öğeleri sırayla işlemek istiyorsanız, tek bağlantılı liste, yapısal özyineleme ile tükettiğinizde, size bu erişim düzenini çok doğal olarak veren bir veri türüdür. Bir soruna saldırmak için "böl ve fethet" stratejisini kullanmak istiyorsanız, ağaç veri yapıları bunu çok iyi destekleme eğilimindedir.

Birçok insan işlevsel programlama vagonundan erken ayrılır, bu yüzden tek bağlantılı listelere maruz kalırlar, ancak daha gelişmiş altta yatan fikirlere maruz kalmazlar.


1
Ne harika bir cevap!
Elliot Gorokhovsky

14

Çünkü değişmezlikle iyi çalışırlar. İki değişmez listeniz olduğunu varsayalım [1, 2, 3]ve [10, 2, 3]. Listedeki her öğenin öğeyi içeren bir düğüm ve listenin geri kalanına bir işaretçi olduğu tekli bağlantılı listeler olarak temsil edilirler, şöyle görünürler:

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

[2, 3]Bölümlerin nasıl aynı olduğunu görüyor musunuz? Değişken veri yapılarında, iki farklı listedir, çünkü bunlardan birine yeni veri yazmanın kodunun diğerini kullanarak kodu etkilemesi gerekmez. İle değişmez verilere ancak, listelerin içerikleri asla değişmeyecek ve kod yeni veri yazamazsınız biliyoruz. Böylece kuyrukları yeniden kullanabilir ve iki listenin yapılarının bir kısmını paylaşmasını sağlayabiliriz:

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

İki listeyi kullanan kod hiçbir zaman bunları değiştirmeyeceğinden, bir listeyi diğerini etkileyen değişiklikler konusunda endişelenmemize gerek kalmaz. Bu aynı zamanda listenin önüne bir öğe eklerken, tamamen yeni bir liste kopyalayıp yapmanız gerekmediği anlamına gelir.

Ancak, denemek ve temsil eğer [1, 2, 3]ve [10, 2, 3]olarak iki kat bağlantılı listeleri:

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

Artık kuyruklar aynı değil. Birinci [2, 3]bir işaretçi sahiptir 1başında, ama ikinci bir işaretçi sahiptir 10. Ayrıca, listenin başına yeni bir öğe eklemek istiyorsanız, listenin önceki başlığını değiştirerek yeni başlığa işaret etmesi gerekir.

Birden fazla kafa problemi, her düğümün bilinen kafaların bir listesini saklaması ve yeni listelerin oluşturulmasını değiştirmesi ile düzeltilebilir, ancak daha sonra listenin farklı kafalara sahip sürümleri olduğunda bu listeyi çöp toplama döngülerinde tutmak için çalışmanız gerekir. farklı kod parçalarında kullanıldığı için farklı ömürleri vardır. Karmaşıklık ve ek yük ekler ve çoğu zaman buna değmez.


8
Bununla birlikte, kuyruk paylaşımı sizin ima ettiğiniz gibi olmaz. Genel olarak, kimse hafızadaki tüm listeleri gözden geçirmez ve ortak ekleri birleştirmek için fırsatlar aramaz. Paylaşımı sadece olur bu algoritmalar yazılır nasıl düşüyor, örneğin eğer bir parametre içeren bir fonksiyon xsyapılara 1:xstek bir yerde ve 10:xsbaşka.

0

@ sacundim'in cevabı çoğunlukla doğrudur, ancak dil tasarımları ve pratik gereksinimler hakkında takas hakkında başka önemli bilgiler de vardır.

Nesneler ve referanslar

Bu diller genellikle ilişkisiz olan nesneleri zorunlu (veya varsayın) dinamik kapsamlarını (veya C'nin parlance, içinde ömür boyu arasında anlam farklılıklarından dolayı aynı olmasa da, nesneler bu diller arasındaki aşağıya bakınız) (birinci sınıf başvuruları kaçınarak, varsayılan olarak örneğin C'deki nesne işaretçileri) ve anlamsal kurallarda öngörülemeyen davranışlar (örneğin ISO C'nin anlambilimle ilgili tanımlanmamış davranışı).

Ayrıca, bu tür dillerdeki (birinci sınıf) nesneler kavramı konservatif olarak kısıtlayıcıdır: varsayılan olarak hiçbir "konum" özelliği belirtilmez ve garanti edilmez. Bu, nesneleri sınırsız dinamik uzantılara sahip olmayan (örn. C ve C ++ 'da) bazı ALGOL benzeri dillerde tamamen farklıdır, burada nesneler genellikle bellek konumlarıyla birleştirilmiş "tür depolama" anlamına gelir.

Nesnelerin içindeki depolamayı kodlamak, ömürleri boyunca deterministik hesaplama efektleri ekleyebilmek gibi bazı ek faydalara sahiptir, ancak bu başka bir konudur.

Veri yapılarının benzetim problemleri

Birinci sınıf referanslar olmadan, tekli bağlantılı listeler, bu veri yapılarının temsilinin doğası ve bu dillerdeki sınırlı ilkel işlemler nedeniyle birçok geleneksel (istekli / değişebilir) veri yapısını etkili ve taşınabilir bir şekilde simüle edemez. (Aksine, C'de, sıkı bir şekilde uyumlu bir programda bile bağlantılı listeleri kolayca türetebilirsiniz .) Ve diziler / vektörler gibi bu gibi alternatif veri yapıları, pratikte tekli bağlantılı listelere kıyasla bazı üstün özelliklere sahiptir. Bu yüzden R 5 RS yeni ilkel operasyonlar getiriyor.

Ancak vektör / dizi türleri ile çift bağlantılı listeler arasında farklılıklar vardır. Bir dizinin genellikle O (1) erişim zamanı karmaşıklığı ve daha az alan yükü olduğu varsayılır; bunlar listeler tarafından paylaşılmayan mükemmel özelliklerdir. (Her ne kadar açıkça konuşulsa da, ikisi de ISO C tarafından garanti edilmez, ancak kullanıcılar neredeyse her zaman bunu bekler ve hiçbir pratik uygulama bu örtülü garantileri çok açık bir şekilde ihlal etmez.) İkili bağlantılı bir liste, her iki özelliği de tek bağlantılı bir listeden daha da kötü hale getirir geri / ileri yineleme ayrıca daha az ek yüke sahip bir dizi veya vektör (tamsayı indeksleriyle birlikte) tarafından da desteklenir. Bu nedenle, iki bağlantılı bir liste genel olarak daha iyi performans göstermez. Daha da kötüsü, temel uygulama ortamı (örn. libc) tarafından sağlanan varsayılan ayırıcı kullanıldığında, önbellek verimliliği ve dinamik bellek ayırma gecikmesi hakkındaki performans felaketsel olarak dizilerin / vektörlerin performansından daha kötüdür. Bu nedenle, çok özel ve "akıllı" bir çalışma zamanı olmadan, bu tür nesne oluşturmalarını yoğun bir şekilde optimize etmeden, dizi / vektör türleri genellikle bağlantılı listelere tercih edilir. (Örneğin, ISO C ++ kullanarak,std::vectortercih edilmelidir std::listvarsayılan). Bu yüzden, özel olarak desteğe (doubly-) bağlı listeler yeni ilkelleri tanıtmak için kesinlikle destek dizisi / uygulamada vektör veri yapıları için çok faydalı değildir.

Adil olmak gerekirse, listeler hala dizilerden / vektörlerden daha iyi bazı özelliklere sahiptir:

  • Listeler düğüm tabanlıdır. Öğelerin listelerden kaldırılması , diğer düğümlerdeki diğer öğelere olan referansı geçersiz kılmaz . (Bu, bazı ağaç veya grafik veri yapıları için de geçerlidir.) OTOH, diziler / vektörler, geçersiz kılınan sondaki konuma referans verebilir (bazı durumlarda büyük yeniden tahsis ile).
  • Listeler olabilir splice O (1) zaman. Yeni dizilerin / vektörlerin mevcut olanlarla yeniden oluşturulması çok daha maliyetlidir.

Bununla birlikte, bu özellikler, zaten tek başına kullanılabilen yerleşik tekil listeler desteğine sahip bir dil için çok önemli değildir. Halen farklılıklar olmasına rağmen, nesnelerin zorunlu dinamik uzantılarına sahip dillerde (bu genellikle sarkan referansları uzak tutan bir çöp toplayıcı olduğu anlamına gelir), amaçlara bağlı olarak geçersiz kılma da daha az önemli olabilir. Dolayısıyla, çift bağlantılı listelerin kazandığı tek durumlar şunlar olabilir:

  • Hem yeniden tahsis edilmeyen garanti hem de çift yönlü yineleme gereklilikleri gereklidir. (Öğe erişiminin performansı önemliyse ve veri kümesi yeterince büyükse, bunun yerine ikili arama ağaçlarını veya karma tablolarını seçerdim.)
  • Verimli çift yönlü ekleme işlemleri gereklidir. Bu oldukça nadirdir. (Yalnızca bir tarayıcıda doğrusal geçmiş kayıtları gibi bir şeyin uygulanmasıyla ilgili gereksinimleri karşılıyorum.)

Değişmezlik ve kenar yumuşatma

Haskell gibi saf bir dilde nesneler değişmezdir. Şemanın nesnesi genellikle mutasyon olmadan kullanılır. Böyle bir gerçek, nesne sabitleme ile bellek verimliliğini etkin bir şekilde geliştirmeyi mümkün kılar - aynı değerde birden fazla nesnenin anında paylaşılması.

Bu, dil tasarımında agresif bir üst düzey optimizasyon stratejisidir. Bununla birlikte, bu uygulama sorunlarını içerir. Aslında, alttaki depolama hücrelerine örtük takma adlar getirir. Örtüşme analizini zorlaştırır. Sonuç olarak, birinci sınıf olmayan referansların ek yükünü ortadan kaldırmak için daha az olasılık olabilir, hatta kullanıcılar asla onlara dokunmaz. Şema gibi dillerde, mutasyon tamamen dışlanmadığında, bu aynı zamanda paralelliğe de müdahale eder. Yine de, tembel bir dilde (zaten zaten thunk'ların neden olduğu performans sorunları var) Tamam olabilir.

Genel amaçlı programlama için, bu tür dil tasarımı seçimi sorunlu olabilir. Ancak bazı yaygın fonksiyonel kodlama kalıplarında, diller hala iyi çalışıyor gibi görünüyor.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.