Tại sao Haskell quicksort tối giản, ví dụ không phải là quicksort “thực sự”?


118

Trang web của Haskell giới thiệu một chức năng quicksort 5 dòng rất hấp dẫn , như hình dưới đây.

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

Chúng cũng bao gồm "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 );
  }
}

Một liên kết bên dưới phiên bản C dẫn đến một trang có nội dung 'Quicksort được trích dẫn trong phần Giới thiệu không phải là quicksort "thực sự" và không chia tỷ lệ cho các danh sách dài hơn như mã c.'

Tại sao hàm Haskell ở trên không phải là một nhanh chóng thực sự? Làm thế nào để nó không mở rộng được cho các danh sách dài hơn?


Bạn nên thêm một liên kết đến trang chính xác mà bạn đang nói đến.
Staven

14
Nó không đúng vị trí, do đó khá chậm? Câu hỏi hay thực sự!
fuz

4
@FUZxxl: Danh sách Haskell là bất biến vì vậy sẽ không có hoạt động nào được thực hiện trong khi sử dụng các kiểu dữ liệu mặc định. Về tốc độ - nó không nhất thiết phải chậm hơn; GHC là một phần công nghệ biên dịch ấn tượng và các giải pháp haskell thường sử dụng cấu trúc dữ liệu bất biến được tăng tốc với các giải pháp có thể thay đổi khác bằng các ngôn ngữ khác.
Callum Rogers

1
Nó thực sự không phải là qsort? Hãy nhớ rằng qsort có O(N^2)thời gian chạy.
Thomas Eding

2
Cần lưu ý rằng ví dụ trên là một ví dụ giới thiệu về Haskell, và quicksort là một lựa chọn rất tồi để sắp xếp danh sách. Sắp xếp trong Data.List đã được thay đổi thành hợp nhất vào năm 2002: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , ở đó bạn cũng có thể thấy cách triển khai sắp xếp nhanh trước đó. Việc triển khai hiện tại là một hợp nhất được thực hiện vào năm 2009: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellElephant

Câu trả lời:


75

Quicksort thực sự có hai khía cạnh đẹp:

  1. Chia và chinh phục: chia nhỏ vấn đề thành hai vấn đề nhỏ hơn.
  2. Phân vùng các phần tử tại chỗ.

Ví dụ Haskell ngắn cho thấy (1), nhưng không chứng minh (2). Cách (2) được thực hiện có thể không rõ ràng nếu bạn chưa biết kỹ thuật!



Để có mô tả rõ ràng về quy trình phân vùng tại chỗ, hãy xem tương tácpython.org/courselib/static/pythonds/SortSearch/ ….
pvillela

57

Quicksort tại chỗ thực sự trong 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

Nguồn cho phân vùng không ổn định tiết lộ rằng nó thực sự là cùng một kỹ thuật hoán đổi tại chỗ (theo như tôi có thể nói).
Dan Burton

3
Giải pháp này không chính xác. unstablePartitiontương tự như partitionfor quicksort, nhưng nó không đảm bảo phần tử ở mvị trí thứ là chính xác p.
nymk

29

Đây là phiên âm của mã C nhanh "đúng" thành Haskell. Cố lên.

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

Điều đó thật vui, phải không? Tôi thực sự đã cắt bỏ phần lớn này letở phần đầu cũng như wherephần cuối của hàm, xác định tất cả các trình trợ giúp để làm cho đoạn mã trước đó khá đẹp.

  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

Và đây, một bài kiểm tra ngu ngốc để xem nó có hoạt động không.

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]

Tôi không thường xuyên viết mã bắt buộc trong Haskell, vì vậy tôi chắc chắn rằng có rất nhiều cách để làm sạch mã này.

Vậy thì sao?

Bạn sẽ nhận thấy rằng đoạn mã trên rất rất dài. Trọng tâm của nó dài bằng mã C, mặc dù mỗi dòng thường dài hơn một chút. Điều này là do C bí mật làm rất nhiều điều tồi tệ mà bạn có thể coi là đương nhiên. Ví dụ a[l] = a[h];,. Này truy cập các biến có thể thay đổi lh, và sau đó truy cập vào mảng có thể thay đổi a, và đột biến thì mảng có thể thay đổi a. Đột biến thần thánh, người dơi! Trong Haskell, đột biến và truy cập các biến có thể thay đổi là rõ ràng. Qsort "giả" hấp dẫn vì nhiều lý do, nhưng chủ yếu trong số đó là nó không sử dụng đột biến; hạn chế tự áp đặt này làm cho nó dễ hiểu hơn trong nháy mắt.


3
Điều đó thật tuyệt vời, theo một cách tạo cảm giác buồn nôn. Tôi tự hỏi GHC tạo ra loại mã gì từ thứ như vậy?
Ian Ross

@IanRoss: Từ quicksort không tinh khiết? GHC thực sự tạo ra mã khá tốt.
JD

"Qsort" giả "hấp dẫn vì nhiều lý do khác nhau ..." Tôi e rằng hiệu suất của nó mà không có thao tác tại chỗ (như đã lưu ý) sẽ rất tệ. Và luôn luôn lấy yếu tố đầu tiên làm trục xoay cũng không giúp ích gì.
dbaltor

25

Theo tôi, nói rằng nó "không phải là một quicksort thực sự" đã nói quá về trường hợp này. Tôi nghĩ rằng đó là một triển khai hợp lệ của thuật toán Quicksort , chỉ không phải là một thuật toán đặc biệt hiệu quả.


9
Tôi đã tranh luận điều này với ai đó một lần: Tôi đã tra cứu bài báo thực tế chỉ định QuickSort, và thực sự là đúng chỗ.
ivanm

2
@ivanm siêu liên kết hoặc nó đã không xảy ra :)
Dan Burton

1
Tôi thích cách bài báo này là tất cả bắt buộc và thậm chí bao gồm cả thủ thuật để đảm bảo sử dụng không gian logarit (mà nhiều người không biết về) trong khi phiên bản đệ quy (hiện phổ biến) trong ALGOL chỉ là một chú thích cuối trang. Đoán tôi sẽ phải tìm kiếm mà giấy khác bây giờ ... :)
hugomg

6
Việc triển khai "hợp lệ" bất kỳ thuật toán nào cũng phải có cùng các giới hạn tiệm cận, bạn có nghĩ vậy không? Haskell quicksort khốn nạn không bảo tồn bất kỳ độ phức tạp bộ nhớ nào của thuật toán ban đầu. Thậm chí không gần. Đó là lý do tại sao nó chậm hơn 1.000 lần so với Quicksort chính hãng của Sedgewick ở C.
JD

16

Tôi nghĩ rằng trường hợp mà lập luận này cố gắng đưa ra là lý do tại sao quicksort được sử dụng phổ biến là do nó được cài đặt tại chỗ và khá thân thiện với bộ nhớ cache. Vì bạn không có những lợi ích đó với danh sách Haskell, nên xu hướng chính của nó đã biến mất và bạn cũng có thể sử dụng sắp xếp hợp nhất, đảm bảo O (n log n) , trong khi với nhanh chóng, bạn phải sử dụng ngẫu nhiên hoặc phức tạp lược đồ phân vùng để tránh thời gian chạy O (n 2 ) trong trường hợp xấu nhất.


5
Và Mergesort là một thuật toán sắp xếp tự nhiên hơn nhiều cho các danh sách thích (bất biến), nơi nó được giải phóng khỏi sự cần thiết phải làm việc với các mảng phụ trợ.
ômomg

16

Nhờ đánh giá lười biếng, một chương trình Haskell không (gần như không thể ) làm những gì nó trông giống như nó.

Hãy xem xét chương trình này:

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

Trong một ngôn ngữ háo hức, trước tiên quicksortsẽ chạy, sau đó show, sau đó putStrLn. Các đối số của một hàm được tính toán trước khi hàm đó bắt đầu chạy.

Ở Haskell thì ngược lại. Chức năng bắt đầu chạy trước. Các đối số chỉ được tính khi hàm thực sự sử dụng chúng. Và một đối số ghép, giống như một danh sách, được tính toán từng phần một, khi mỗi phần của nó được sử dụng.

Vì vậy, điều đầu tiên xảy ra trong chương trình này là putStrLnbắt đầu chạy.

Việc triển khai của GHCputStrLn hoạt động bằng cách sao chép các ký tự của đối số Chuỗi vào bộ đệm đầu ra. Nhưng khi nó vào vòng lặp này, showvẫn chưa chạy. Do đó, khi nó đi sao chép ký tự đầu tiên từ chuỗi, Haskell sẽ đánh giá phân số của showquicksortcác lệnh gọi cần thiết để tính toán ký tự đó . Sau đó putStrLnchuyển sang ký tự tiếp theo. Vì vậy, việc thực hiện cả ba functions- putStrLn, showquicksort- được xen kẽ. quicksortthực thi tăng dần, để lại một biểu đồ của các lần thu hồi không được đánh giá khi nó đi để ghi nhớ nơi nó đã dừng lại.

Bây giờ điều này hoàn toàn khác với những gì bạn có thể mong đợi nếu bạn đã quen thuộc với bất kỳ ngôn ngữ lập trình nào khác. Không dễ để hình dung cách quicksortthực sự hoạt động trong Haskell về quyền truy cập bộ nhớ hoặc thậm chí thứ tự so sánh. Nếu bạn chỉ có thể quan sát hành vi chứ không phải mã nguồn, bạn sẽ không nhận ra nó đang làm gì như một mạch nhanh .

Ví dụ, phiên bản C của quicksort phân vùng tất cả dữ liệu trước cuộc gọi đệ quy đầu tiên. Trong phiên bản Haskell, phần tử đầu tiên của kết quả sẽ được tính toán (và thậm chí có thể xuất hiện trên màn hình của bạn) trước khi phân vùng đầu tiên chạy xong — thực sự là trước khi bất kỳ công việc nào được thực hiện greater.

PS Mã Haskell sẽ giống quicksort hơn nếu nó thực hiện cùng số lượng so sánh với quicksort; mã như được viết thực hiện nhiều gấp đôi so sánh vì lessergreaterđược chỉ định để được tính toán độc lập, thực hiện hai lần quét tuyến tính qua danh sách. Tất nhiên về nguyên tắc, trình biên dịch có thể đủ thông minh để loại bỏ các so sánh thừa; hoặc mã có thể được thay đổi để sử dụng Data.List.partition.

PPS Ví dụ cổ điển của thuật toán Haskell hóa ra không hoạt động như bạn mong đợi là cái sàng của Eratosthenes để tính toán số nguyên tố.


2
lpaste.net/108190 . - nó đang làm "phân loại cây bị chặt phá", có một chủ đề reddit cũ về nó. cf. stackoverflow.com/questions/14786904/… và liên quan.
Will Ness

1
trông Có, đó là một đặc điểm khá tốt về những gì chương trình thực sự làm.
Jason Orendorff

lại nhận xét sàng lọc, nếu nó được viết như một tương đương primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], vấn đề trước mắt nhất của nó có lẽ sẽ rõ ràng hơn. Và đó là trước khi chúng tôi xem xét chuyển sang thuật toán sàng thực sự.
Will Ness,

Tôi bối rối với định nghĩa của bạn về mã "trông giống như nó". Mã của bạn "trông" với tôi giống như nó gọi putStrLnmột ứng dụng bị thu gọn của showmột ứng dụng bị thu gọn quicksortvào danh sách theo nghĩa đen --- và đó chính xác là những gì nó làm! (trước khi tối ưu hóa --- nhưng đôi khi hãy so sánh mã C với trình hợp dịch được tối ưu hóa!). Có thể ý bạn là "nhờ đánh giá lười biếng, một chương trình Haskell không thực hiện những gì mã trông tương tự trong các ngôn ngữ khác"?
Jonathan Cast

4
@jcast Tôi nghĩ rằng có một sự khác biệt thực tế giữa C và Haskell về vấn đề này. Thực sự rất khó để thực hiện một cuộc tranh luận thú vị về loại chủ đề này trong một chuỗi bình luận, cũng như tôi rất muốn nói chuyện với nó ngoài cà phê trong đời thực. Hãy cho tôi biết nếu bạn đang ở Nashville với một giờ rảnh rỗi!
Jason Orendorff

12

Tôi tin rằng lý do mà hầu hết mọi người nói rằng Haskell Quicksort xinh đẹp không phải là một Quicksort "thực sự" là thực tế là nó không đúng vị trí - rõ ràng, nó không thể xảy ra khi sử dụng các kiểu dữ liệu bất biến. Nhưng cũng có ý kiến ​​phản đối rằng nó không "nhanh chóng": một phần vì ++ đắt tiền, và cũng bởi vì có một khoảng trống - bạn bám vào danh sách đầu vào trong khi thực hiện lệnh gọi đệ quy trên các phần tử nhỏ hơn, và trong một số trường hợp - ví dụ khi danh sách đang giảm - điều này dẫn đến việc sử dụng không gian bậc hai. (Bạn có thể nói rằng làm cho nó chạy trong không gian tuyến tính là cách gần nhất mà bạn có thể đến "tại chỗ" bằng cách sử dụng dữ liệu bất biến.) Có các giải pháp gọn gàng cho cả hai vấn đề, sử dụng tích lũy các tham số, tupling và hợp nhất; xem S7.6.1 của Richard Bird '


4

Nó không phải là ý tưởng về việc thay đổi các yếu tố tại chỗ trong các cài đặt chức năng thuần túy. Các phương thức thay thế trong chuỗi này với các mảng có thể thay đổi đã làm mất đi tinh thần thuần khiết.

Có ít nhất hai bước để tối ưu hóa phiên bản cơ bản (là phiên bản biểu đạt nhất) của sắp xếp nhanh.

  1. Tối ưu hóa phép nối (++), là một phép toán tuyến tính, bằng bộ tích lũy:

    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. Tối ưu hóa để sắp xếp nhanh bậc ba (phân vùng 3 chiều, được Bentley và Sedgewick đề cập), để xử lý các phần tử trùng lặp:

    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. Kết hợp 2 và 3, tham khảo cuốn sách của 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

Hoặc cách khác nếu các phần tử trùng lặp không phải là phần lớn:

    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

Thật không may, không thể triển khai trung bình của ba với cùng một hiệu ứng, ví dụ:

    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] 

vì nó vẫn hoạt động kém trong 4 trường hợp sau:

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

Tất cả 4 trường hợp này đều được xử lý tốt bằng cách tiếp cận trung vị của ba.

Trên thực tế, thuật toán sắp xếp phù hợp nhất cho một cài đặt chức năng thuần túy vẫn là sắp xếp hợp nhất, nhưng không phải sắp xếp nhanh.

Để biết chi tiết, vui lòng truy cập bài viết đang viết của tôi tại: https://sites.google.com/site/algoxy/dcsort


Có một tối ưu hóa khác mà bạn đã bỏ qua: sử dụng phân vùng thay vì 2 bộ lọc để tạo danh sách con (hoặc trình gấp trên một chức năng bên trong tương tự để tạo 3 danh sách con).
Danh sách Jeremy

3

Không có định nghĩa rõ ràng về cái gì là và cái gì không phải là một nhanh chóng thực sự.

Họ gọi nó không phải là một nhanh chóng thực sự, bởi vì nó không sắp xếp đúng vị trí:

Quicksort thực sự trong C sắp xếp tại chỗ


-1

Bởi vì lấy phần tử đầu tiên từ danh sách dẫn đến thời gian chạy rất tệ. Sử dụng trung vị của 3: đầu tiên, giữa, cuối cùng.


2
Lấy phần tử đầu tiên là ok nếu danh sách là ngẫu nhiên.
Keith Thompson

2
Nhưng việc sắp xếp một danh sách đã sắp xếp hoặc gần sắp xếp là phổ biến.
Joshua

7
Nhưng qsort IS O(n^2)
Thomas Eding

8
qsort là trung bình n log n, xấu nhất n ^ 2.
Joshua

3
Về mặt kỹ thuật, không tệ hơn việc chọn một giá trị ngẫu nhiên trừ khi đầu vào đã được sắp xếp hoặc sắp xếp gần hết. Các trục xấu là các trục nằm cách xa dải phân cách; phần tử đầu tiên chỉ là một trục quay xấu nếu nó gần mức tối thiểu hoặc tối đa.
Platinum Azure

-1

Yêu cầu bất kỳ ai viết quicksort trong Haskell, và về cơ bản bạn sẽ nhận được cùng một chương trình - nó rõ ràng là quicksort. Dưới đây là một số ưu điểm và nhược điểm:

Pro: Nó cải thiện trên nhanh "true" bằng cách ổn định, tức là nó duy trì thứ tự trình tự giữa các phần tử bằng nhau.

Pro: Việc tổng quát hóa thành phép chia ba chiều (<=>) là điều nhỏ nhặt, điều này tránh được hành vi bậc hai do một số giá trị xuất hiện O (n) lần.

Chuyên nghiệp: Nó dễ đọc hơn - ngay cả khi người ta phải bao gồm định nghĩa của bộ lọc.

Con: Nó sử dụng nhiều bộ nhớ hơn.

Kết luận: Sẽ rất tốn kém khi tổng quát hóa sự lựa chọn trục bằng cách lấy mẫu thêm, điều này có thể tránh được hành vi bậc hai trên một số orderings entropy thấp nhất định.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.