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

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 함수가 진정한 퀵 정렬이 아닌 이유는 무엇입니까? 더 긴 목록으로 확장하는 데 어떻게 실패합니까?



답변

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

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

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


답변

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


답변

다음은 “진정한”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는 여러 가지 이유로 매력적이지만 그중 가장 중요한 점은 돌연변이를 사용하지 않는다는 것입니다. 이 자체적으로 부과 된 제한은 한눈에 이해하기 훨씬 쉽게 만듭니다.


답변

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


답변

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


답변

게으른 평가 덕분에 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 하스켈 알고리즘의 고전적인 예가 예상대로 작동하지 않는 것으로 밝혀진 것은 소수 계산을위한 에라토스테네스체입니다 .


답변

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