미니멀리스트, 예인 Haskell 퀵소트가 "진정한"퀵소트가 아닌 이유는 무엇입니까?


118

Haskell의 웹 사이트는 아래와 같이 매우 매력적인 5 줄 퀵 정렬 기능을 소개 합니다.

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

또한 "C의 True quicksort" 도 포함됩니다 .

// 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 );
  }
}

C 버전 아래의 링크는 '소개에 인용 된 빠른 정렬이 "실제"빠른 정렬이 아니며 C 코드처럼 긴 목록에 맞게 확장되지 않는다는 내용의 페이지로 연결됩니다.

위의 Haskell 함수가 진정한 퀵 정렬이 아닌 이유는 무엇입니까? 더 긴 목록으로 확장하는 데 어떻게 실패합니까?


당신은 당신이 말하는 정확한 페이지에 대한 링크를 추가해야합니다.
Staven

14
제자리에 있지 않아서 상당히 느립니다? 실제로 좋은 질문입니다!
fuz

4
@FUZxxl : Haskell 목록은 변경 불가능하므로 기본 데이터 유형을 사용하는 동안에는 제자리에 작업이 없습니다. 속도에 관해서는-반드시 느린 것은 아닙니다. GHC는 인상적인 컴파일러 기술이며 변경 불가능한 데이터 구조를 사용하는 하스켈 솔루션은 다른 언어의 다른 변경 가능한 구조와 속도가 빠릅니다.
Callum Rogers

1
실제로 qsort가 아닙니까? qsort에는 O(N^2)런타임이 있습니다.
Thomas Eding 2011

2
위의 예제는 Haskell의 입문 예제이며, quicksort는 목록 정렬에 매우 나쁜 선택이라는 점에 유의해야합니다. Data.List의 정렬은 2002 년에 mergesort로 변경되었습니다 : hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , 이전 빠른 정렬 구현도 볼 수 있습니다. 현재 구현은 2009 년에 만들어진 병합 정렬 입니다 : hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellElephant

답변:


75

진정한 퀵소트에는 두 가지 아름다운 측면이 있습니다.

  1. 나누고 정복하십시오 : 문제를 두 개의 작은 문제로 나누십시오.
  2. 요소를 제자리에서 분할합니다.

짧은 Haskell 예제는 (1)을 보여 주지만 (2)는 보여주지 않습니다. 기술을 아직 모르면 (2)가 어떻게 수행되는지 명확하지 않을 수 있습니다!



파티셔닝-인-플레이스 프로세스에 대한 명확한 설명은 interactivepython.org/courselib/static/pythonds/SortSearch/…를 참조하십시오 .
pvillela

57

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

unstablePartition 의 소스 는 그것이 실제로 동일한 인플레 이스 스와핑 기술임을 보여줍니다 (내가 말할 수있는 한).
Dan Burton

3
이 솔루션은 올바르지 않습니다. for unstablePartition와 매우 유사 하지만 th 위치 의 요소 가 . partitionquicksortmp
nymk

29

다음은 "진정한"quicksort C 코드를 Haskell로 음역 한 것입니다. 자신을 보호하십시오.

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

재미 있지 않았나요? 나는 실제로 let처음과 where함수의 끝 에서이 큰 부분을 잘라내어 모든 도우미를 정의하여 앞의 코드를 다소 예쁘게 만듭니다.

  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

그리고 여기에서 그것이 작동하는지 확인하는 멍청한 테스트입니다.

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]

저는 Haskell에서 명령형 코드를 자주 작성하지 않기 때문에이 코드를 정리할 수있는 많은 방법이 있다고 확신합니다.

그래서 뭐?

위의 코드가 매우 길다는 것을 알 수 있습니다. 핵심은 C 코드만큼 길지만, 각 줄은 종종 좀 더 장황합니다. 이것은 C가 당연한 것으로 생각할 수있는 많은 불쾌한 일을 비밀리에 수행하기 때문입니다. 예 : a[l] = a[h];. 이것은 변경 가능한 변수 lh에 액세스 a한 다음 변경 가능한 배열에 액세스 한 다음 변경 가능한 배열을 변경합니다 a. 이런 돌연변이, 배트맨! Haskell에서 변경 및 변경 가능한 변수에 액세스하는 것은 명시 적입니다. "가짜"qsort는 여러 가지 이유로 매력적이지만 그중 가장 중요한 점은 돌연변이를 사용하지 않는다는 것입니다. 이 자체적으로 부과 된 제한은 한눈에 이해하기 훨씬 쉽게 만듭니다.


3
그것은 굉장한 일입니다. GHC가 그런 것에서 어떤 종류의 코드를 생성하는지 궁금합니다.
Ian Ross

@IanRoss : 불순한 퀵소트에서? GHC는 실제로 꽤 괜찮은 코드를 생성합니다.
JD

""가짜 "qsort는 여러 가지 이유로 매력적입니다 ..."제자리 조작 (이미 언급했듯이)이없는 성능은 끔찍할 것 같습니다. 그리고 항상 첫 번째 요소를 피벗으로 취하는 것도 도움이되지 않습니다.
dbaltor

25

제 생각에는 그것이 "진정한 퀵 정렬이 아닙니다"라고 말하는 것은 그 사건을 과장합니다. 저는 이것이 Quicksort 알고리즘 의 유효한 구현이라고 생각합니다 . 특히 효율적인 것은 아닙니다.


9
나는 누군가와이 논쟁을 한 적이 있었다. 나는 QuickSort를 지정한 실제 논문을 찾아 보았고 실제로 제자리에 있었다.
ivanm

2
@ivanm 하이퍼 링크 또는 발생하지 않았습니다. :)
Dan Burton

1
필자는이 논문이 모두 필수적이며 ALGOL의 (현재 인기있는) 재귀 버전은 각주에 불과한 반면, 많은 사람들이 알지 못하는 로그 공간 사용을 보장하는 트릭을 포함하는 것을 좋아합니다. 지금 다른 논문을 찾아봐야 할 것
같아요

6
모든 알고리즘의 "유효한"구현은 동일한 점근 경계를 가져야합니다. 그렇지 않습니까? 멍청한 Haskell quicksort는 원래 알고리즘의 메모리 복잡성을 보존하지 않습니다. 근처에도 안. 이것이 바로 C에서 Sedgewick의 정품 Quicksort보다 1,000 배 이상 느린 이유입니다.
JD

16

이 주장이 시도하는 경우는 quicksort가 일반적으로 사용되는 이유는 그것이 제자리에 있고 결과적으로 상당히 캐시 친화적이기 때문이라고 생각합니다. Haskell 목록에는 이러한 이점이 없기 때문에 주요 이유 d' être가 사라지고 병합 정렬을 사용하여 O (n log n) 을 보장 하는 반면 퀵 정렬을 사용하면 무작위 화 또는 복잡함을 사용해야합니다. 최악의 경우 O (n 2 ) 런타임 을 피하기위한 분할 스키마 .


5
그리고 Mergesort는 (불변) 좋아요 목록에 대한 훨씬 더 자연스러운 정렬 알고리즘으로, 보조 배열로 작업 할 필요가 없습니다.
hugomg

16

게으른 평가 덕분에 Haskell 프로그램은 보이는대로 수행 하지 않습니다 (거의 할 수 없습니다 ).

이 프로그램을 고려하십시오.

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

열성적인 언어에서는 먼저 quicksort실행 한 다음 show, 그런 다음 putStrLn. 함수의 인수는 해당 함수가 실행되기 전에 계산됩니다.

Haskell에서는 그 반대입니다. 함수가 먼저 실행되기 시작합니다. 인수는 함수가 실제로 사용할 때만 계산됩니다. 그리고 목록과 같은 복합 인수는 각 부분이 사용됨에 따라 한 번에 하나씩 계산됩니다.

그래서이 프로그램에서 가장 먼저 일어나는 일은 putStrLn실행 을 시작하는 것입니다.

GHC의 작업 구현은putStrLn String 인수의 문자를 출력 버퍼에 복사합니다. 그러나이 루프에 들어가면 show아직 실행되지 않았습니다. 따라서 문자열에서 첫 번째 문자를 복사 할 때 Haskell은 해당 문자 를 계산 하는 데 필요한 showquicksort호출 의 일부를 평가합니다 . 그런 다음 다음 문자로 이동합니다. 따라서 세 가지 함수 인 ,, 및 모두의 실행 이 인터리브됩니다. 점진적으로 실행되어 평가되지 않은 썽크 그래프를 남겨두고 중단 된 위치를 기억합니다.putStrLnputStrLnshowquicksortquicksort

이제 이것은 다른 프로그래밍 언어에 익숙하다면 예상 할 수있는 것과는 크게 다릅니다. quicksort메모리 액세스 또는 비교 순서 측면에서 Haskell에서 실제로 어떻게 작동하는지 시각화하는 것은 쉽지 않습니다 . 소스 코드가 아닌 동작 만 관찰 할 수 있다면 Quicksort로 무엇을하는지 인식하지 못할 것 입니다.

예를 들어 C 버전의 quicksort는 첫 번째 재귀 호출 전에 모든 데이터를 분할합니다. Haskell 버전에서는 첫 번째 파티션 실행이 완료 되기 전에 결과의 첫 번째 요소가 계산되고 화면에 나타날 수도 있습니다. 실제로에서 작업이 완료 되기 전에 말입니다 greater.

추신 : Haskell 코드는 Quicksort와 같은 수의 비교를했다면 더 quicksort와 비슷할 것입니다. 작성된 코드는 많아 비교 두번 수행 lesser하고 greater목록을 통해 2 개의 선형 스캔하고, 독립적으로 계산하도록 동작한다. 물론 원칙적으로 컴파일러가 추가 비교를 제거 할만큼 똑똑 할 수 있습니다. 또는을 사용하도록 코드를 변경할 수 있습니다 Data.List.partition.

PPS 하스켈 알고리즘의 고전적인 예가 예상대로 작동하지 않는 것으로 밝혀진 것은 소수 계산을위한 에라토스테네스체입니다 .


2
lpaste.net/108190 . -그것은 "삼림 벌채 나무 정렬"을하고 있고, 그것에 대한 오래된 reddit 스레드 가 있습니다. cf. stackoverflow.com/questions/14786904/… 및 관련.
Will Ness

1
보이는 프로그램이 실제로 무엇을하는지 꽤 좋은 특성입니다 그 예.
Jason Orendorff

체 발언 재, 그것은 동등한로 작성된 primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], 가장 즉각적인 문제는 아마 명확 할 것이다. 그리고 그것은 진정한 체 알고리즘으로의 전환을 고려 하기 전 입니다.
Will Ness

나는 "그 것처럼 보이는"코드에 대한 당신의 정의에 혼란 스럽습니다. 귀하의 코드 는 목록 리터럴 에 대한 썽킹 된 응용 프로그램에 대한 썽킹 된 응용 putStrLn프로그램을 호출 하는 것처럼 "보여집니다". 그게 바로 그 일입니다! (최적화 전에 ---하지만 C 코드를 최적화 된 어셈블러와 언젠가 비교하십시오!). "게으른 평가 덕분에 하스켈 프로그램이 다른 언어에서 비슷한 모양의 코드가하는 일을하지 않는다"는 뜻일까요? showquicksort
Jonathan Cast

4
@jcast 나는 이와 관련하여 C와 Haskell 사이에 실질적인 차이가 있다고 생각합니다. 이런 종류의 주제에 대해 댓글 스레드에서 유쾌한 토론을 계속하는 것은 정말 어렵습니다. 실생활에서 커피에 대해 이야기하고 싶습니다. 내슈빌에 간 적이 있으면 알려주세요.
Jason Orendorff

12

대부분의 사람들이 예쁜 Haskell Quicksort가 "진정한"Quicksort가 아니라고 말하는 이유는 그것이 제자리에 있지 않다는 사실입니다. 분명히 불변 데이터 유형을 사용할 때는 불가능합니다. 그러나 "빠르지 않다"는 이의도 있습니다. 부분적으로는 값 비싼 ++ 때문이고 공간 누수가 있기 때문입니다. 더 적은 요소에 대해 재귀 호출을 수행하는 동안 입력 목록에 매달리고 있습니다. 경우에 따라-예를 들어 목록이 감소하는 경우-이로 인해 2 차 공간이 사용됩니다. (선형 공간에서 실행하는 것이 불변 데이터를 사용하여 "인플레 이스"에 가장 가깝다고 말할 수 있습니다.) 누적 매개 변수, tupling 및 융합을 사용하여 두 문제에 대한 깔끔한 솔루션이 있습니다. Richard Bird의 S7.6.1 참조


4

순전히 기능적인 설정에서 요소를 제자리에서 변경하는 아이디어가 아닙니다. 가변 배열을 가진이 스레드의 대체 방법은 순수함의 정신을 잃었습니다.

빠른 정렬의 기본 버전 (가장 표현력이 풍부한 버전)을 최적화하려면 최소한 두 단계가 있습니다.

  1. 누산기를 통해 선형 연산 인 연결 (++)을 최적화합니다.

    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. 중복 된 요소를 처리하기 위해 삼항 빠른 정렬 (Bentley 및 Sedgewick에서 언급 한 3 방향 파티션)으로 최적화합니다.

    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. 2와 3을 결합하고 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

또는 중복 된 요소가 다수가 아닌 경우 :

    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

안타깝게도 중앙값 3은 동일한 효과로 구현할 수 없습니다. 예를 들면 다음과 같습니다.

    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] 

다음 4 가지 경우에 대해 여전히 성능이 좋지 않기 때문입니다.

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

이 4 가지 경우 모두 명령형 중앙값 3 개 접근 방식으로 잘 처리됩니다.

실제로 순전히 기능적인 설정에 가장 적합한 정렬 알고리즘은 병합 정렬이지만 빠른 정렬은 아닙니다.

자세한 내용은 https://sites.google.com/site/algoxy/dcsort 에서 저의 지속적인 글을 방문 하십시오.


놓친 또 다른 최적화가 있습니다. 2 개의 필터 대신 파티션을 사용하여 하위 목록을 생성합니다 (또는 유사한 내부 함수의 폴더를 사용하여 3 개의 하위 목록을 생성).
제레미 목록

3

무엇이 진정한 퀵 정렬이 아닌지에 대한 명확한 정의가 없습니다.

그들은 제자리에서 정렬하지 않기 때문에 진정한 퀵 정렬이 아니라고 부르고 있습니다.

C의 진정한 Quicksort는 제자리에서 정렬됩니다.


-1

목록에서 첫 번째 요소를 가져 오면 런타임이 매우 나 빠지기 때문입니다. 3의 중앙값을 사용하십시오. 첫 번째, 중간, 마지막.


2
목록이 무작위이면 첫 번째 요소를 사용하는 것이 좋습니다.
Keith Thompson

2
그러나 정렬되거나 거의 정렬 된 목록을 정렬하는 것이 일반적입니다.
여호수아

7
하지만 qsort IS O(n^2)
Thomas Eding 2010

8
qsort는 평균 n log n, 최악의 n ^ 2입니다.
Joshua

3
기술적으로 입력이 이미 정렬되었거나 거의 정렬되지 않은 경우 임의의 값을 선택하는 것보다 나쁘지 않습니다. 잘못된 피벗은 중앙값에서 떨어진 피벗입니다. 첫 번째 요소는 최소 또는 최대에 가까울 경우에만 잘못된 피벗입니다.
Platinum Azure

-1

누구에게나 Haskell에서 quicksort를 작성 해달라고 요청하면 본질적으로 동일한 프로그램을 얻게 될 것입니다. 그것은 분명히 quicksort입니다. 다음은 몇 가지 장점과 단점입니다.

장점 : 안정성을 통해 "진정한"퀵 정렬을 개선합니다. 즉, 동일한 요소 간의 시퀀스 순서를 유지합니다.

장점 : O (n) 번 발생하는 일부 값으로 인해 2 차 동작을 방지하는 3 원 분할 (<=>)로 일반화하는 것은 간단합니다.

장점 : 필터의 정의를 포함해야하더라도 읽기가 더 쉽습니다.

단점 : 더 많은 메모리를 사용합니다.

단점 : 특정 낮은 엔트로피 순서에서 2 차 동작을 피할 수있는 추가 샘플링으로 피벗 선택을 일반화하는 것은 비용이 많이 듭니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.