Perché il minimalista, esempio Haskell quicksort non è un "vero" quicksort?


118

Il sito Web di Haskell introduce una funzione quicksort a 5 righe molto interessante , come mostrato di seguito.

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

Includono anche un "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 );
  }
}

Un collegamento sotto la versione C rimanda a una pagina che afferma "Il quicksort citato nell'introduzione non è il" vero "quicksort e non scala per elenchi più lunghi come fa il codice c."

Perché la funzione Haskell di cui sopra non è un vero quicksort? Come fa a non scalare per elenchi più lunghi?


Dovresti aggiungere un collegamento alla pagina esatta di cui stai parlando.
Staven

14
Non è a posto, quindi piuttosto lento? Bella domanda in realtà!
fuz

4
@ FUZxxl: gli elenchi Haskell non sono modificabili, quindi non verrà eseguita alcuna operazione durante l'utilizzo dei tipi di dati predefiniti. Quanto alla sua velocità, non sarà necessariamente più lenta; GHC è un pezzo impressionante della tecnologia del compilatore e molto spesso le soluzioni haskell che utilizzano strutture di dati immutabili sono all'altezza di altre mutabili in altri linguaggi.
Callum Rogers

1
In realtà non è qsort? Ricorda che qsort ha O(N^2)runtime.
Thomas Eding

2
Va notato che l'esempio sopra è un esempio introduttivo di Haskell e che quicksort è una pessima scelta per l'ordinamento degli elenchi. L'ordinamento in Data.List è stato cambiato in mergesort nel 2002: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , lì puoi anche vedere la precedente implementazione dell'ordinamento rapido. L'attuale implementazione è un mergesort realizzato nel 2009: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellElephant

Risposte:


75

Il vero quicksort ha due bellissimi aspetti:

  1. Dividi e conquista: spezza il problema in due problemi più piccoli.
  2. Partiziona gli elementi sul posto.

Il breve esempio di Haskell mostra (1), ma non (2). Il modo in cui (2) è fatto potrebbe non essere ovvio se non conosci già la tecnica!



Per una chiara descrizione del processo di partizionamento sul posto vedere interactivepython.org/courselib/static/pythonds/SortSearch/… .
pvillela

57

Vero quicksort sul posto in 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

La fonte per unstablePartition rivela che è effettivamente la stessa tecnica di scambio sul posto (per quanto ne so).
Dan Burton

3
Questa soluzione non è corretta. unstablePartitionè molto simile a partitionfor quicksort, ma non garantisce che l'elemento in mesima posizione sia giusto p.
nymk

29

Ecco una traslitterazione del codice C "vero" quicksort in Haskell. Preparati.

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

È stato divertente, no? In realtà ho tagliato questo grande letall'inizio, così come wherealla fine della funzione, definendo tutti gli helper per rendere il codice precedente piuttosto carino.

  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

E qui, un test stupido per vedere se funziona.

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]

Non scrivo codice imperativo molto spesso in Haskell, quindi sono sicuro che ci sono molti modi per ripulire questo codice.

E allora?

Noterai che il codice sopra è molto, molto lungo. Il cuore è lungo quanto il codice C, anche se ogni riga è spesso un po 'più prolissa. Questo perché il C fa segretamente molte cose brutte che potresti dare per scontate. Ad esempio a[l] = a[h];,. Ciò accede alle variabili mutabili le h, quindi accede all'array mutabile a, quindi muta l'array mutabile a. Santa mutazione, Batman! In Haskell, la mutazione e l'accesso alle variabili mutabili sono espliciti. Il "falso" qsort è attraente per vari motivi, ma il principale tra questi è che non utilizza la mutazione; questa restrizione autoimposta lo rende molto più facile da capire a colpo d'occhio.


3
È fantastico, in un modo che fa venire la nausea. Mi chiedo che tipo di codice GHC produce da qualcosa del genere?
Ian Ross

@ IanRoss: Dal quicksort impuro? GHC produce effettivamente codice abbastanza decente.
JD

"Il" falso "qsort è attraente per vari motivi ..." Temo che le sue prestazioni senza la manipolazione sul posto (come già notato) sarebbero terribili. E anche prendere sempre il 1 ° elemento come perno non aiuta.
dbaltor

25

A mio parere, dire che "non è un vero quicksort" esagera il caso. Penso che sia una valida implementazione dell'algoritmo Quicksort , ma non particolarmente efficiente.


9
Una volta ho avuto questa discussione con qualcuno: ho cercato il documento effettivo che specificava QuickSort, ed è effettivamente sul posto.
ivanm

2
Collegamenti ipertestuali @ivanm o non è successo :)
Dan Burton

1
Adoro il modo in cui questo documento è imperativo e include anche il trucco per garantire l'uso logaritmico dello spazio (che molte persone non conoscono) mentre la versione ricorsiva (ora popolare) in ALGOL è solo una nota a piè di pagina. Immagino che dovrò cercare quell'altro documento ora ... :)
hugomg

6
Un'implementazione "valida" di qualsiasi algoritmo dovrebbe avere gli stessi limiti asintotici, non credi? Il Quicksort Haskell imbastardito non preserva la complessità della memoria dell'algoritmo originale. Neanche vicino. Ecco perché è oltre 1.000 volte più lento del vero Quicksort di Sedgewick in C.
JD

16

Penso che il caso in cui questo argomento cerca di fare sia che il motivo per cui il quicksort è comunemente usato è che è sul posto e di conseguenza abbastanza adatto alla cache. Dal momento che non hai questi vantaggi con gli elenchi Haskell, la sua principale ragion d'essere è sparita, e potresti anche usare merge sort, che garantisce O (n log n) , mentre con quicksort devi usare randomizzazione o complicato schemi di partizionamento per evitare O (n 2 ) run time nel peggiore dei casi.


5
E Mergesort è un algoritmo di ordinamento molto più naturale per elenchi di Mi piace (immutabili), in cui è liberato dalla necessità di lavorare con array ausiliari.
hugomg

16

Grazie alla valutazione pigra, un programma Haskell non lo fa (quasi non può ) fare quello che sembra.

Considera questo programma:

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

In una lingua desiderosa, prima quicksortcorreva, poi show, poiputStrLn . Gli argomenti di una funzione vengono calcolati prima che la funzione inizi a essere eseguita.

In Haskell è l'opposto. La funzione inizia a essere eseguita per prima. Gli argomenti vengono calcolati solo quando la funzione li utilizza effettivamente. E un argomento composto, come un elenco, viene calcolato un pezzo alla volta, man mano che viene utilizzato ogni pezzo di esso.

Quindi la prima cosa che accade in questo programma è che putStrLninizia a funzionare.

L'implementazione di GHCputStrLn funziona copiando i caratteri dell'argomento String in un buffer di output. Ma quando entra in questo ciclo, shownon è ancora stato eseguito. Pertanto, quando va a copiare il primo carattere dalla stringa, Haskell valuta la frazione di showe quicksortchiama necessarie per calcolare quel carattere . Quindi putStrLnpassa al personaggio successivo. Quindi l'esecuzione di tutte e tre le funzioni putStrLn- show, e quicksort- è intercalata. quicksortviene eseguito in modo incrementale, lasciando un grafico di thunk non valutati mentre va a ricordare dove è stato interrotto.

Ora questo è molto diverso da quello che potresti aspettarti se hai familiarità, sai, con qualsiasi altro linguaggio di programmazione. Non è facile visualizzare come quicksortsi comporta effettivamente in Haskell in termini di accessi alla memoria o anche l'ordine dei confronti. Se potessi solo osservare il comportamento, e non il codice sorgente, non riconosceresti cosa sta facendo come un quicksort .

Ad esempio, la versione C di quicksort partiziona tutti i dati prima della prima chiamata ricorsiva. Nella versione Haskell, il primo elemento del risultato verrà calcolato (e potrebbe anche apparire sullo schermo) prima che la prima partizione sia terminata, anzi prima che venga fatto qualsiasi lavoro greater.

PS Il codice Haskell sarebbe più simile a quicksort se facesse lo stesso numero di confronti come quicksort; il codice come scritto fa il doppio dei confronti perché lessere greatersono specificati per essere calcolati in modo indipendente, facendo due scansioni lineari attraverso l'elenco. Ovviamente è possibile in linea di principio che il compilatore sia abbastanza intelligente da eliminare i confronti extra; oppure il codice potrebbe essere modificato per essere utilizzatoData.List.partition .

PPS Il classico esempio di algoritmi Haskell che si rivelano non comportarsi come ci si aspettava è il setaccio di Eratostene per il calcolo dei numeri primi.


2
lpaste.net/108190 . - sta facendo "l'ordinamento degli alberi disboscati", c'è un vecchio thread reddit a riguardo. cf. stackoverflow.com/questions/14786904/… e correlati.
Will Ness

1
sembra Sì, questa è una caratterizzazione abbastanza buona di ciò che il programma fa effettivamente.
Jason Orendorff

Per quanto riguarda l'osservazione del setaccio, se fosse scritta come un equivalente primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], il suo problema più immediato sarebbe forse più chiaro. E questo prima di considerare il passaggio al vero algoritmo del setaccio.
Will Ness

Sono confuso dalla tua definizione di quale codice "sembra che faccia". Il tuo codice "sembra" a me come se chiama putStrLnun'applicazione showthunk di un'applicazione thunk quicksorta un elenco letterale --- e questo è esattamente ciò che fa! (prima dell'ottimizzazione --- ma a volte confronta il codice C con l'assembler ottimizzato!). Forse intendi "grazie a una valutazione pigra, un programma Haskell non fa quello che un codice dall'aspetto simile fa in altre lingue"?
Jonathan Cast

4
@jcast Penso che ci sia una differenza pratica tra C e Haskell in questo senso. È davvero difficile portare avanti un piacevole dibattito su questo tipo di argomento in un thread di commenti, per quanto mi piacerebbe averlo davanti a un caffè nella vita reale. Fammi sapere se sei mai a Nashville con un'ora libera!
Jason Orendorff

12

Credo che il motivo per cui la maggior parte delle persone dice che il grazioso Haskell Quicksort non è un "vero" Quicksort è il fatto che non è sul posto - chiaramente, non può essere quando si utilizzano tipi di dati immutabili. Ma c'è anche l'obiezione che non è "veloce": in parte a causa del costoso ++, e anche perché c'è una perdita di spazio - ti aggrappi alla lista di input mentre esegui la chiamata ricorsiva sugli elementi minori, e in alcuni casi, ad esempio quando l'elenco è in diminuzione, ciò si traduce in un utilizzo quadratico dello spazio. (Si potrebbe dire che farlo funzionare nello spazio lineare è quanto di più vicino si possa ottenere "sul posto" utilizzando dati immutabili.) Esistono soluzioni precise per entrambi i problemi, utilizzando parametri di accumulo, tupling e fusione; vedere S7.6.1 di Richard Bird '


4

Non è l'idea di mutare elementi sul posto in contesti puramente funzionali. I metodi alternativi in ​​questo thread con array mutabili hanno perso lo spirito di purezza.

Ci sono almeno due passaggi per ottimizzare la versione base (che è la versione più espressiva) di ordinamento rapido.

  1. Ottimizza la concatenazione (++), che è un'operazione lineare, per accumulatori:

    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. Ottimizza per l'ordinamento rapido ternario (partizione a 3 vie, menzionata da Bentley e Sedgewick), per gestire elementi duplicati:

    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. Combina 2 e 3, fai riferimento al libro di 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

O in alternativa se gli elementi duplicati non sono la maggioranza:

    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

Sfortunatamente, la mediana di tre non può essere implementata con lo stesso effetto, ad esempio:

    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] 

perché funziona ancora male per i seguenti 4 casi:

  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, ...]

Tutti questi 4 casi sono ben gestiti dall'approccio mediano imperativo di tre.

In realtà, l'algoritmo di ordinamento più adatto per un'impostazione puramente funzionale è ancora merge-sort, ma non quick-sort.

Per i dettagli, visita i miei scritti in corso su: https://sites.google.com/site/algoxy/dcsort


C'è un'altra ottimizzazione che ti sei perso: usa la partizione invece di 2 filtri per produrre le sotto-liste (o foldr su una funzione interna simile per produrre 3 sotto-liste).
Jeremy List

3

Non esiste una definizione chiara di cosa sia e cosa non sia un vero quicksort.

Lo chiamano non un vero quicksort, perché non si ordina sul posto:

Il vero quicksort in C ordina sul posto


-1

Perché prendere il primo elemento dalla lista si traduce in un runtime pessimo. Usa la mediana di 3: primo, medio, ultimo.


2
Prendere il primo elemento va bene se l'elenco è casuale.
Keith Thompson

2
Ma l'ordinamento di un elenco ordinato o quasi ordinato è comune.
Joshua

7
Ma qsort è O(n^2)
Thomas Eding

8
qsort è la media n log n, il peggiore n ^ 2.
Joshua

3
Tecnicamente, non è peggio che scegliere un valore casuale a meno che l'input non sia già ordinato o quasi ordinato. I perni difettosi sono i perni che sono lontani dalla mediana; il primo elemento è solo un cattivo perno se è vicino al minimo o al massimo.
Platinum Azure

-1

Chiedete a chiunque di scrivere quicksort in Haskell e otterrete essenzialmente lo stesso programma - è ovviamente quicksort. Ecco alcuni vantaggi e svantaggi:

Pro: Migliora l'ordinamento rapido "vero" essendo stabile, cioè preserva l'ordine di sequenza tra elementi uguali.

Pro: è banale generalizzare a una divisione a tre vie (<=>), che evita il comportamento quadratico a causa di un valore che si verifica O (n) volte.

Pro: è più facile da leggere, anche se si dovesse includere la definizione di filtro.

Contro: usa più memoria.

Contro: è costoso generalizzare la scelta del pivot mediante un ulteriore campionamento, che potrebbe evitare un comportamento quadratico su determinati ordinamenti a bassa entropia.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.