Kenapa yang minimalis, contoh quicksort Haskell bukan quicksort yang “benar”?

118

Situs web Haskell memperkenalkan fungsi quicksort 5 baris yang sangat menarik , seperti yang terlihat di bawah ini.

quicksort [] = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
    where
        lesser = filter (< p) xs
        greater = filter (>= p) xs

Mereka juga menyertakan "True quicksort in C" .

// To sort array a[] of size n: qsort(a,0,n-1)

void qsort(int a[], int lo, int hi) 
{
  int h, l, p, t;

  if (lo < hi) {
    l = lo;
    h = hi;
    p = a[hi];

    do {
      while ((l < h) && (a[l] <= p)) 
          l = l+1;
      while ((h > l) && (a[h] >= p))
          h = h-1;
      if (l < h) {
          t = a[l];
          a[l] = a[h];
          a[h] = t;
      }
    } while (l < h);

    a[hi] = a[l];
    a[l] = p;

    qsort( a, lo, l-1 );
    qsort( a, l+1, hi );
  }
}

Link di bawah versi C mengarahkan ke halaman yang menyatakan 'Quicksort yang dikutip dalam Pendahuluan bukanlah quicksort "sebenarnya" dan tidak menskalakan untuk daftar yang lebih panjang seperti yang dilakukan kode c.'

Mengapa fungsi Haskell di atas bukan quicksort yang sebenarnya? Bagaimana cara gagal menskalakan daftar yang lebih panjang?

rybosome.dll
sumber
Anda harus menambahkan link ke halaman yang Anda bicarakan.
Staven
14
Itu tidak pada tempatnya, jadi cukup lambat? Pertanyaan bagus sebenarnya!
fuz
4
@FUZxxl: Daftar Haskell tidak dapat diubah sehingga tidak ada operasi yang akan dilakukan saat menggunakan tipe data default. Mengenai kecepatannya - tidak harus lebih lambat; GHC adalah bagian dari teknologi kompiler yang mengesankan dan seringkali solusi haskell yang menggunakan struktur data yang tidak dapat diubah memiliki kecepatan yang sama dengan yang dapat diubah lainnya dalam bahasa lain.
Callum Rogers
1
Apakah ini sebenarnya bukan qsort? Ingatlah bahwa qsort memiliki O(N^2)runtime.
Thomas Eding
2
Perlu dicatat bahwa contoh di atas adalah contoh pengantar Haskell, dan quicksort adalah pilihan yang sangat buruk untuk menyortir daftar. Jenis di Data.List diubah menjadi mergesort pada tahun 2002: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , di sana Anda juga dapat melihat implementasi pengurutan cepat sebelumnya. Implementasi saat ini adalah mergesort yang dibuat pada tahun 2009: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellEl elephant

Jawaban:

75

Quicksort yang sebenarnya memiliki dua aspek yang indah:

  1. Bagilah dan taklukkan: pecahkan masalah menjadi dua masalah kecil.
  2. Partisi elemen di tempat.

Contoh singkat Haskell menunjukkan (1), tetapi tidak (2). Bagaimana (2) dilakukan mungkin tidak jelas jika Anda belum mengetahui tekniknya!

menepuk
sumber
17
informit.com/articles/article.aspx?p=1407357&seqNum=3 - Andrey Alexandrescu
The_Ghost
Untuk penjelasan yang jelas tentang proses pemartisian di tempat, lihat interactivepython.org/courselib/static/pythonds/SortSearch/… .
pvillela
57

Quicksort inplace yang sebenarnya di Haskell:

import qualified Data.Vector.Generic as V 
import qualified Data.Vector.Generic.Mutable as M 

qsort :: (V.Vector v a, Ord a) => v a -> v a
qsort = V.modify go where
    go xs | M.length xs < 2 = return ()
          | otherwise = do
            p <- M.read xs (M.length xs `div` 2)
            j <- M.unstablePartition (< p) xs
            let (l, pr) = M.splitAt j xs 
            k <- M.unstablePartition (== p) pr
            go l; go $ M.drop k pr
klapaucius
sumber
Sumber unstablePartition mengungkapkan bahwa ini memang teknik bertukar tempat yang sama (sejauh yang saya tahu).
Dan Burton
3
Solusi ini salah. unstablePartitionsangat mirip dengan partitionfor quicksort, tetapi tidak menjamin elemen di mposisi itu adil p.
nymk
29

Berikut ini adalah transliterasi dari kode C quicksort "benar" ke Haskell. Persiapkan dirimu.

import Control.Monad
import Data.Array.IO
import Data.IORef

qsort :: IOUArray Int Int -> Int -> Int -> IO ()
qsort a lo hi = do
  (h,l,p,t) <- liftM4 (,,,) z z z z

  when (lo < hi) $ do
    l .= lo
    h .= hi
    p .=. (a!hi)

    doWhile (get l .< get h) $ do
      while ((get l .< get h) .&& ((a.!l) .<= get p)) $ do
        modifyIORef l succ
      while ((get h .> get l) .&& ((a.!h) .>= get p)) $ do
        modifyIORef h pred
      b <- get l .< get h
      when b $ do
        t .=. (a.!l)
        lVal <- get l
        hVal <- get h
        writeArray a lVal =<< a!hVal
        writeArray a hVal =<< get t

    lVal <- get l
    writeArray a hi =<< a!lVal
    writeArray a lVal =<< get p

    hi' <- fmap pred (get l)
    qsort a lo hi'
    lo' <- fmap succ (get l)
    qsort a lo' hi

Itu menyenangkan, bukan? Saya benar-benar memotong ini besar letdi awal, serta wheredi akhir fungsi, mendefinisikan semua pembantu untuk membuat kode sebelumnya agak cantik.

  let z :: IO (IORef Int)
      z = newIORef 0
      (.=) = writeIORef
      ref .=. action = do v <- action; ref .= v
      (!) = readArray
      (.!) a ref = readArray a =<< get ref
      get = readIORef
      (.<) = liftM2 (<)
      (.>) = liftM2 (>)
      (.<=) = liftM2 (<=)
      (.>=) = liftM2 (>=)
      (.&&) = liftM2 (&&)
  -- ...
  where doWhile cond foo = do
          foo
          b <- cond
          when b $ doWhile cond foo
        while cond foo = do
          b <- cond
          when b $ foo >> while cond foo

Dan di sini, tes bodoh untuk melihat apakah itu berhasil.

main = do
    a <- (newListArray (0,9) [10,9..1]) :: IO (IOUArray Int Int)
    printArr a
    putStrLn "Sorting..."
    qsort a 0 9
    putStrLn "Sorted."
    printArr a
  where printArr a = mapM_ (\x -> print =<< readArray a x) [0..9]

Saya tidak terlalu sering menulis kode imperatif di Haskell, jadi saya yakin ada banyak cara untuk membersihkan kode ini.

Terus?

Anda akan melihat bahwa kode di atas sangat, sangat panjang. Inti dari itu adalah sepanjang kode C, meskipun setiap baris seringkali sedikit lebih bertele-tele. Ini karena C diam-diam melakukan banyak hal buruk yang mungkin Anda anggap remeh. Misalnya a[l] = a[h];,. Ini mengakses variabel yang bisa berubah ldan h, lalu mengakses array yang bisa berubah a, dan kemudian mengubah array yang bisa berubah a. Mutasi suci, batman! Di Haskell, mutasi dan mengakses variabel yang bisa berubah itu eksplisit. Qsort "palsu" menarik karena berbagai alasan, tetapi yang paling utama adalah tidak menggunakan mutasi; pembatasan yang diberlakukan sendiri ini membuatnya lebih mudah untuk dipahami dalam sekejap.

Dan Burton
sumber
3
Itu luar biasa, dengan cara yang membuat mual. Saya bertanya-tanya kode macam apa yang dihasilkan GHC dari sesuatu seperti itu?
Ian Ross
@IanRoss: Dari quicksort yang tidak murni? GHC sebenarnya menghasilkan kode yang lumayan bagus.
JD
Qsort "palsu" menarik karena berbagai alasan ... "Saya khawatir kinerjanya tanpa manipulasi di tempat (seperti yang telah disebutkan) akan buruk. Dan selalu mengambil elemen pertama sebagai pivot juga tidak membantu.
dbaltor
25

Menurut pendapat saya, mengatakan bahwa ini "bukan quicksort yang sebenarnya" terlalu melebih-lebihkan kasusnya. Saya pikir ini adalah implementasi yang valid dari algoritma Quicksort , hanya saja bukan yang sangat efisien.

Keith Thompson
sumber
9
Saya pernah bertengkar dengan seseorang: Saya melihat kertas aktual yang menentukan QuickSort, dan memang ada.
ivanm
2
@ivanm hyperlink atau tidak terjadi :)
Dan Burton
1
Saya suka bagaimana makalah ini sangat penting dan bahkan menyertakan trik untuk menjamin penggunaan ruang logaritmik (yang tidak diketahui banyak orang) sedangkan versi rekursif (sekarang populer) di ALGOL hanyalah sebuah catatan kaki. Kurasa aku harus mencari koran lain sekarang ... :)
hugomg
6
Implementasi yang "valid" dari setiap algoritme harus memiliki batasan asimtotik yang sama, bukan? Quicksort Haskell yang tersamar tidak mempertahankan kerumitan memori apa pun dari algoritme aslinya. Bahkan tidak dekat. Itulah mengapa ini lebih dari 1.000x lebih lambat dari Quicksort asli Sedgewick di C.
JD
16

Saya pikir kasus yang coba dibuat argumen ini adalah alasan mengapa quicksort umum digunakan adalah karena itu ada di tempat dan sebagai hasilnya cukup ramah-cache. Karena Anda tidak memiliki manfaat tersebut dengan daftar Haskell, alasan utamanya hilang, dan Anda mungkin juga menggunakan jenis gabungan, yang menjamin O (n log n) , sedangkan dengan quicksort Anda harus menggunakan pengacakan atau rumit skema partisi untuk menghindari run time O (n 2 ) dalam kasus terburuk.

hammar
sumber
5
Dan Mergesort adalah algoritma pengurutan yang jauh lebih alami untuk daftar yang disukai (tidak dapat diubah), di mana ia dibebaskan dari kebutuhan untuk bekerja dengan array tambahan.
hugomg
16

Berkat evaluasi malas, program Haskell tidak (hampir tidak bisa ) melakukan apa yang terlihat seperti yang dilakukannya.

Pertimbangkan program ini:

main = putStrLn (show (quicksort [8, 6, 7, 5, 3, 0, 9]))

Dalam bahasa yang bersemangat, pertama quicksortakan lari, lalu show, lalu putStrLn. Argumen fungsi dihitung sebelum fungsi tersebut mulai berjalan.

Di Haskell, justru sebaliknya. Fungsi tersebut mulai berjalan lebih dulu. Argumen hanya dihitung saat fungsi benar-benar menggunakannya. Dan argumen gabungan, seperti daftar, dihitung satu per satu, karena setiap bagian digunakan.

Jadi hal pertama yang terjadi dalam program ini adalah yang putStrLnmulai berjalan.

Implementasi GHC dari putStrLn bekerja dengan menyalin karakter dari argumen String ke dalam buffer keluaran. Tapi saat memasuki loop ini, showbelum berjalan. Oleh karena itu, saat akan menyalin karakter pertama dari string, Haskell mengevaluasi pecahan dari panggilan showdan yang quicksortdiperlukan untuk menghitung karakter itu . Kemudian putStrLnberalih ke karakter berikutnya. Jadi eksekusi ketiga functions- putStrLn, showdan quicksort- disisipkan. quicksortdieksekusi secara bertahap, meninggalkan grafik halangan yang tidak dievaluasi untuk mengingat di mana ia tinggalkan.

Sekarang ini sangat berbeda dari apa yang mungkin Anda harapkan jika Anda terbiasa dengan, Anda tahu, bahasa pemrograman lain yang pernah ada. Tidak mudah untuk memvisualisasikan bagaimana quicksortsebenarnya berperilaku di Haskell dalam hal akses memori atau bahkan urutan perbandingan. Jika Anda hanya dapat mengamati perilakunya, dan bukan kode sumbernya, Anda tidak akan mengenali apa yang dilakukannya sebagai quicksort .

Misalnya, versi C dari quicksort mempartisi semua data sebelum panggilan rekursif pertama. Dalam versi Haskell, elemen pertama dari hasil akan dihitung (dan bahkan dapat muncul di layar Anda) sebelum partisi pertama selesai dijalankan — bahkan sebelum pekerjaan apa pun diselesaikan greater.

PS Kode Haskell akan lebih seperti quicksort jika ia melakukan jumlah perbandingan yang sama dengan quicksort; kode seperti yang tertulis melakukan perbandingan dua kali lebih banyak karena lesserdan greaterditentukan untuk dihitung secara independen, melakukan dua pemindaian linier melalui daftar. Tentu saja pada prinsipnya mungkin bagi compiler untuk menjadi cukup pintar untuk menghilangkan perbandingan ekstra; atau kode dapat diubah untuk digunakan Data.List.partition.

PPS Contoh klasik dari algoritma Haskell ternyata tidak berperilaku seperti yang Anda harapkan adalah saringan Eratosthenes untuk menghitung bilangan prima.

Jason Orendorff
sumber
2
lpaste.net/108190 . - itu melakukan "jenis pohon yang terdeforestasi", ada benang merah tua tentangnya. cf. stackoverflow.com/questions/14786904/… dan terkait.
Will Ness
1
Penampilan Ya, itu adalah karakterisasi yang cukup bagus dari apa yang sebenarnya dilakukan oleh program.
Jason Orendorff
Mengenai komentar saringan, jika ditulis sebagai padanan primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], masalah paling langsungnya mungkin akan lebih jelas. Dan itu sebelum kami mempertimbangkan untuk beralih ke algoritma saringan yang sebenarnya.
Will Ness
Saya bingung dengan definisi Anda tentang kode apa yang "terlihat seperti itu". Kode Anda "tampak" bagi saya seperti panggilan putStrLnyang merupakan aplikasi berpotongan showke aplikasi berpotongan quicksortke daftar literal --- dan itulah yang dilakukannya! (sebelum pengoptimalan --- tetapi bandingkan kode C dengan assembler yang dioptimalkan kapan-kapan!). Mungkin maksud Anda "berkat evaluasi malas, program Haskell tidak melakukan apa yang dilakukan kode yang tampak serupa dalam bahasa lain"?
Jonathan Pemeran
4
@jcast Saya rasa ada perbedaan praktis antara C dan Haskell dalam hal ini. Sangat sulit untuk melanjutkan debat yang menyenangkan tentang topik semacam ini di utas komentar, sama seperti yang saya ingin sampaikan sambil minum kopi di kehidupan nyata. Beri tahu saya jika Anda pernah ke Nashville dengan waktu luang satu jam!
Jason Orendorff
12

Saya percaya bahwa alasan kebanyakan orang mengatakan bahwa Haskell Quicksort yang cantik bukanlah Quicksort yang "benar" adalah kenyataan bahwa ia tidak ada pada tempatnya - jelas, tidak mungkin ketika menggunakan tipe data yang tidak dapat diubah. Tetapi ada juga keberatan bahwa ini tidak "cepat": sebagian karena ++ mahal, dan juga karena ada kebocoran spasi - Anda tetap berpegang pada daftar masukan saat melakukan panggilan rekursif pada elemen yang lebih rendah, dan dalam beberapa kasus - misalnya ketika daftar menurun - ini menghasilkan penggunaan ruang kuadrat. (Anda mungkin mengatakan bahwa membuatnya berjalan dalam ruang linier adalah cara terdekat yang bisa Anda dapatkan "di tempat" menggunakan data yang tidak dapat diubah.) Ada solusi yang tepat untuk kedua masalah tersebut, dengan menggunakan parameter yang terakumulasi, tupling, dan fusi; lihat S7.6.1 dari Richard Bird '

Jeremy Gibbons
sumber
4

Ini bukanlah gagasan mutasi elemen di tempat dalam pengaturan fungsional murni. Metode alternatif di utas ini dengan array yang bisa berubah kehilangan semangat kemurnian.

Setidaknya ada dua langkah untuk mengoptimalkan versi dasar (yang merupakan versi paling ekspresif) dari penyortiran cepat.

  1. Optimalkan penggabungan (++), yang merupakan operasi linier, dengan akumulator:

    qsort xs = qsort' xs []
    
    qsort' [] r = r
    qsort' [x] r = x:r
    qsort' (x:xs) r = qpart xs [] [] r where
        qpart [] as bs r = qsort' as (x:qsort' bs r)
        qpart (x':xs') as bs r | x' <= x = qpart xs' (x':as) bs r
                               | x' >  x = qpart xs' as (x':bs) r
  2. Optimalkan ke pengurutan cepat terner (partisi 3 arah, disebutkan oleh Bentley dan Sedgewick), untuk menangani elemen duplikat:

    tsort :: (Ord a) => [a] -> [a]
    tsort [] = []
    tsort (x:xs) = tsort [a | a<-xs, a<x] ++ x:[b | b<-xs, b==x] ++ tsort [c | c<-xs, c>x]
  3. Gabungkan 2 dan 3, lihat buku Richard Bird:

    psort xs = concat $ pass xs []
    
    pass [] xss = xss
    pass (x:xs) xss = step xs [] [x] [] xss where
        step [] as bs cs xss = pass as (bs:pass cs xss)
        step (x':xs') as bs cs xss | x' <  x = step xs' (x':as) bs cs xss
                                   | x' == x = step xs' as (x':bs) cs xss
                                   | x' >  x = step xs' as bs (x':cs) xss

Atau sebagai alternatif jika elemen yang diduplikasi bukan mayoritas:

    tqsort xs = tqsort' xs []

    tqsort' []     r = r
    tqsort' (x:xs) r = qpart xs [] [x] [] r where
        qpart [] as bs cs r = tqsort' as (bs ++ tqsort' cs r)
        qpart (x':xs') as bs cs r | x' <  x = qpart xs' (x':as) bs cs r
                                  | x' == x = qpart xs' as (x':bs) cs r
                                  | x' >  x = qpart xs' as bs (x':cs) r

Sayangnya, median-of-three tidak dapat diimplementasikan dengan efek yang sama, misalnya:

    qsort [] = []
    qsort [x] = [x]
    qsort [x, y] = [min x y, max x y]
    qsort (x:y:z:rest) = qsort (filter (< m) (s:rest)) ++ [m] ++ qsort (filter (>= m) (l:rest)) where
        xs = [x, y, z]
        [s, m, l] = [minimum xs, median xs, maximum xs] 

karena kinerjanya masih buruk untuk 4 kasus berikut:

  1. [1, 2, 3, 4, ...., n]

  2. [n, n-1, n-2, ..., 1]

  3. [m-1, m-2, ... 3, 2, 1, m + 1, m + 2, ..., n]

  4. [n, 1, n-1, 2, ...]

Semua 4 kasus ini ditangani dengan baik dengan pendekatan median-of-three yang penting.

Sebenarnya, algoritme pengurutan yang paling cocok untuk pengaturan yang murni berfungsi adalah jenis gabungan, tetapi bukan pengurutan cepat.

Untuk detailnya, silakan kunjungi tulisan saya yang sedang berlangsung di: https://sites.google.com/site/algoxy/dcsort

Larry LIU Xinyu
sumber
Ada optimasi lain yang Anda lewatkan: gunakan partisi, bukan 2 filter untuk menghasilkan sub-daftar (atau lipat pada fungsi dalam yang serupa untuk menghasilkan 3 sub-daftar).
Daftar Jeremy
3

Tidak ada definisi yang jelas tentang apa itu quicksort dan apa yang bukan quicksort yang sebenarnya.

Mereka menyebutnya bukan quicksort yang sebenarnya, karena tidak diurutkan di tempat:

Quicksort benar dalam tipe C di tempat

Piotr Praszmo
sumber
-1

Karena mengambil elemen pertama dari daftar menghasilkan waktu proses yang sangat buruk. Gunakan median dari 3: pertama, tengah, terakhir.

Joshua
sumber
2
Mengambil elemen pertama tidak masalah jika daftarnya acak.
Keith Thompson
2
Tetapi mengurutkan daftar yang diurutkan atau hampir diurutkan adalah hal biasa.
Joshua
7
Tapi qsort IS O(n^2)
Thomas Eding
8
qsort rata-rata n log n, terburuk n ^ 2.
Joshua
3
Secara teknis, ini tidak lebih buruk daripada memilih nilai acak kecuali input sudah diurutkan atau hampir diurutkan. Pivot buruk adalah pivot yang berada jauh dari median; elemen pertama hanya poros yang buruk jika mendekati minimum atau maksimum.
Platinum Azure
-1

Minta siapa saja untuk menulis quicksort di Haskell, dan pada dasarnya Anda akan mendapatkan program yang sama - ini jelas quicksort. Berikut beberapa kelebihan dan kekurangannya:

Pro: Ini meningkatkan quicksort "benar" dengan menjadi stabil, yaitu mempertahankan urutan urutan di antara elemen yang sama.

Pro: Adalah sepele untuk menggeneralisasi menjadi tiga arah split (<=>), yang menghindari perilaku kuadrat karena beberapa nilai yang terjadi O (n) kali.

Pro: Lebih mudah dibaca - bahkan jika harus menyertakan definisi filter.

Kontra: Menggunakan lebih banyak memori.

Kontra: Sangat mahal untuk menggeneralisasi pilihan pivot dengan pengambilan sampel lebih lanjut, yang dapat menghindari perilaku kuadrat pada urutan entropi rendah tertentu.

mercator
sumber