미니멀리스트, 예인 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];. 이것은 변경 가능한 변수 l및 h에 액세스 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은 해당 문자 를 계산 하는 데 필요한 show및 quicksort호출 의 일부를 평가합니다 . 그런 다음 다음 문자로 이동합니다. 따라서 세 가지 함수 인 ,, 및 모두의 실행 이 인터리브됩니다. 점진적으로 실행되어 평가되지 않은 썽크 그래프를 남겨두고 중단 된 위치를 기억합니다.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.