Алгоритм с эффективным использованием памяти для задачи `take n (sort xs)` (sorted prefix)

Я хочу взять n самых больших элементов из ленивого списка.

Я слышал, что сортировка слиянием, реализованная в Data.List.sort, является ленивой и не создает больше элементов, чем необходимо. Это может быть правдой с точки зрения сравнений, но определенно не так, когда речь идет об использовании памяти. Следующая программа иллюстрирует проблему:

{-# LANGUAGE ScopedTypeVariables #-}

module Main where

import qualified Data.Heap as Heap
import qualified Data.List as List

import System.Random.MWC
import qualified Data.Vector.Unboxed as Vec

import System.Environment

limitSortL n xs = take n (List.sort xs)
limitSortH n xs = List.unfoldr Heap.uncons (List.foldl' (\ acc x -> Heap.take n (Heap.insert x acc) ) Heap.empty xs) 

main = do
  st <- create
  rxs :: [Int] <- Vec.toList `fmap` uniformVector st (10^7)

  args <- getArgs
  case args of
    ["LIST"] -> print (limitSortL 20 rxs)
    ["HEAP"] -> print (limitSortH 20 rxs)

  return ()

Время выполнения:

Data.List:

./lazyTest LIST +RTS -s 
[-9223371438221280004,-9223369283422017686,-9223368296903201811,-9223365203042113783,-9223364809100004863,-9223363058932210878,-9223362160334234021,-9223359019266180408,-9223358851531436915,-9223345045262962114,-9223343191568060219,-9223342956514809662,-9223341125508040302,-9223340661319591967,-9223337771462470186,-9223336010230770808,-9223331570472117335,-9223329558935830150,-9223329536207787831,-9223328937489459283]
   2,059,921,192 bytes allocated in the heap
   2,248,105,704 bytes copied during GC
     552,350,688 bytes maximum residency (5 sample(s))
       3,390,456 bytes maximum slop
            1168 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0:  3772 collections,     0 parallel,  1.44s,  1.48s elapsed
  Generation 1:     5 collections,     0 parallel,  0.90s,  1.13s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    0.82s  (  0.84s elapsed)
  GC    time    2.34s  (  2.61s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    3.16s  (  3.45s elapsed)

  %GC time      74.1%  (75.7% elapsed)

  Alloc rate    2,522,515,156 bytes per MUT second

  Productivity  25.9% of total user, 23.7% of total elapsed

Данные.Heap:

./lazyTest HEAP +RTS -s 
[-9223371438221280004,-9223369283422017686,-9223368296903201811,-9223365203042113783,-9223364809100004863,-9223363058932210878,-9223362160334234021,-9223359019266180408,-9223358851531436915,-9223345045262962114,-9223343191568060219,-9223342956514809662,-9223341125508040302,-9223340661319591967,-9223337771462470186,-9223336010230770808,-9223331570472117335,-9223329558935830150,-9223329536207787831,-9223328937489459283]
 177,559,536,928 bytes allocated in the heap
     237,093,320 bytes copied during GC
      80,031,376 bytes maximum residency (2 sample(s))
         745,368 bytes maximum slop
              78 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0: 338539 collections,     0 parallel,  1.24s,  1.31s elapsed
  Generation 1:     2 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time   35.24s  ( 35.46s elapsed)
  GC    time    1.24s  (  1.31s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time   36.48s  ( 36.77s elapsed)

  %GC time       3.4%  (3.6% elapsed)

  Alloc rate    5,038,907,812 bytes per MUT second

  Productivity  96.6% of total user, 95.8% of total elapsed

Очевидно, что limitSortL намного быстрее, но он также очень требует памяти. В больших списках он попадает в размер ОЗУ.

Есть ли более быстрый алгоритм для решения этой проблемы, который не требовал бы памяти?

Изменить: пояснение: я использую Data.Heap из пакета кучи, я не пробовал пакет кучи.


person Tener    schedule 10.05.2011    source источник
comment
Можете ли вы дать разумную верхнюю границу n?   -  person Robin Green    schedule 10.05.2011
comment
Что вы имеете в виду? Размер входного списка или размер выходного списка?   -  person Tener    schedule 10.05.2011
comment
n, который вы ввели в первой строке, то есть размер выходного списка.   -  person Robin Green    schedule 10.05.2011
comment
Это было бы более убедительно, если бы вы работали с реальными данными. Создание очень большого вектора вызывает у меня подозрения; GHC обманывает вас, когда может расшифровать данные, которые вы создаете.   -  person Dan Burton    schedule 10.05.2011
comment
@Dan Я думаю, что это правильный подход, поскольку все, что он может сказать, это какой-то случайный список определенной длины. Я, конечно, видел этот подход в других тестах.   -  person Tener    schedule 10.05.2011


Ответы (6)


Итак, мне действительно удалось решить проблему. Идея состоит в том, чтобы отбросить причудливые структуры данных и работать вручную ;-) По сути, мы разбиваем список ввода на части, сортируем их и сворачиваем [[Int]] список, выбирая n наименьшие элементы на каждом шаге. Уловка заключается в правильном слиянии аккумулятора с отсортированным блоком. Мы должны использовать seq, иначе лень вас укусит, и результат все равно потребует много памяти для вычислений. Кроме того, я смешиваю слияние с take n, просто для большей оптимизации. Вот вся программа вместе с предыдущими попытками:

{-# LANGUAGE ScopedTypeVariables, PackageImports #-}     
module Main where

import qualified Data.List as List
import qualified Data.List.Split as Split
import qualified "heaps" Data.Heap as Heap -- qualified import from "heaps" package

import System.Random.MWC
import qualified Data.Vector.Unboxed as Vec

import System.Environment

limitSortL n xs = take n (List.sort xs)
limitSortH n xs = List.unfoldr Heap.uncons (List.foldl' (\ acc x -> Heap.take n (Heap.insert x acc) ) Heap.empty xs)
takeSortMerge n inp = List.foldl' 
                        (\acc lst -> (merge n acc (List.sort lst))) 
                        [] (Split.splitEvery n inp)
    where
     merge 0 _ _ = []
     merge _ [] xs = xs
     merge _ ys [] = ys
     merge f (x:xs) (y:ys) | x < y = let tail = merge (f-1) xs (y:ys) in tail `seq` (x:tail) 
                           | otherwise = let tail = merge (f-1) (x:xs) ys in tail `seq` (y:tail)


main = do
  st <- create

  let n1 = 10^7
      n2 = 20

  rxs :: [Int] <- Vec.toList `fmap` uniformVector st (n1)

  args <- getArgs

  case args of
    ["LIST"] ->  print (limitSortL n2 rxs)
    ["HEAP"] ->  print (limitSortH n2 rxs)
    ["MERGE"] -> print (takeSortMerge n2 rxs)
    _ -> putStrLn "Nothing..."

  return ()

Производительность во время выполнения, потребление памяти, время сборки мусора:

LIST       3.96s   1168 MB    75 %
HEAP       35.29s    78 MB    3.6 %
MERGE      1.00s     78 MB    3.0 %
just rxs   0.21s     78 MB    0.0 %  -- just evaluating the random vector
person Tener    schedule 10.05.2011
comment
Тогда вы должны принять свой собственный ответ. Это принесет этот ответ на первый план для будущих читателей. - person Robin Green; 11.05.2011
comment
Это возможно только через два дня. Если кто-то не отправит лучший ответ, я сделаю это. - person Tener; 11.05.2011

Существует множество алгоритмов выбора, специализирующихся именно на этом. Алгоритм, основанный на разделах, является «классическим», но, как и Quicksort, он не совсем подходит для списков Haskell. Википедия не показывает ничего, что связано с функциональным программированием, хотя я подозреваю, что описанный «выбор турнира» такой же или не сильно отличается от вашего текущего решения для сортировки слиянием.

Если вас беспокоит потребление памяти, вы можете использовать очередь с приоритетом - она ​​использует O (K) памяти и время O (N * logK) в целом:

queue := first k elements
for each element in the rest:
    add the element to the queue
    remove the largest element from the queue
convert the queue to a sorted list
person hugomg    schedule 10.05.2011
comment
Википедия говорит, что алгоритмы выбора предназначены для выбора n-го наибольшего или наименьшего элемента, а не n. То есть это проблема взятия, возможно, нескольких элементов. Кроме того, альтернатива, упомянутая в вопросе, уже является типом очереди. - person Robin Green; 10.05.2011
comment
Однако да, некоторые материалы на этой странице Википедии действительно выглядят потенциально полезными, например раздел Нелинейный общий алгоритм выбора. - person Robin Green; 10.05.2011
comment
@Green: Нет. Взять n-е по величине означает также найти все остальные n-е наибольшие элементы в процессе. Взгляните на первый раздел, это просто быстрая сортировка с одним рекурсивным вызовом вместо двух. - person hugomg; 10.05.2011
comment
По сути, это то, что OP делает в своем решении на основе кучи. Я предполагаю, что реальный вопрос в том, есть ли более эффективная реализация. - person hammar; 10.05.2011

«Быстрая сортировка и k-е наименьшие элементы», всегда заинтересованный Генрих Апфельмус: http://apfelmus.nfshost.com/articles/quicksearch.html

person sclv    schedule 11.05.2011
comment
Он перечисляет наименьшее k = дубль k. sort 'как его решение, которое является именно решением LIST из моего вопроса. - person Tener; 11.05.2011
comment
@Tener: Прочтите все это. Он продолжает обсуждать ряд лучших версий сортировки для этой цели, чем та, которая представлена ​​в Data.List. - person sclv; 11.05.2011

Простите, если я не могу расшифровать

 Vec.toList `fmap` uniformVector st (10^7)

но как долго будет этот список? Ясно ли, что какой бы ленивой ни была сортировка слиянием, она должна будет реализовать, по крайней мере, весь список?

Обновлять:

Я слышал, что сортировка слиянием, реализованная в Data.List.sort, является ленивой и не создает больше элементов, чем необходимо.

Это ничего не говорит о потреблении пространства для слияния, прежде чем оно сможет начать доставку первых элементов списка. В любом случае ему придется пройти (и тем самым реализовать) весь список, выделить для объединения подсписки и т. Д. Вот ссылка от http://www.inf.fh-flensburg.de/lang/algorithmmen/sortieren/merge/mergen.htm

Недостатком сортировки слиянием является то, что для временного массива b требуется дополнительное пространство размером Θ (n).

Есть разные возможности для реализации слияния функций. Самый действенный из них - вариант б. Он требует вдвое меньше дополнительного места, он быстрее, чем другие варианты, и он стабилен.

person Ingo    schedule 10.05.2011
comment
Ну нет. Придется реализовать вектор, а не список. И вектор в этом случае намного компактнее. - person Tener; 10.05.2011
comment
Ну да, вы передаете список на сортировку. Как он мог найти n самых больших / самых маленьких элементов, даже не просматривая весь список? - person Ingo; 10.05.2011
comment
Я никогда не говорил, что он не может пройти весь список. - person Tener; 10.05.2011
comment
Что-то не так с моими вопросами или есть еще одна причина, по которой вы не даете мне подсказки, чтобы я мог оценить размер списка (и подсписок, которые всегда будут создавать)? - person Ingo; 10.05.2011
comment
Мне очень жаль, но я честно посчитал ваши вопросы риторическими. Пожалуйста, задавайте ваши вопросы более прямо. Я не говорю, что поведение, демонстрируемое сейчас, неверно. Я прошу лучшего алгоритма для решения этой конкретной проблемы. - person Tener; 10.05.2011
comment
Почему же тогда мы не можем просто установить, насколько плохое текущее решение? Может, окажется, что все не так уж и плохо. Но до сих пор у нас есть только ваше утверждение, что это так. - person Ingo; 10.05.2011
comment
Наилучшее возможное решение - вычислить результат в Θ (выходной размер) памяти. На самом деле это просто сделать, см. Решение Data.Heap. Вопрос в том, как это сделать эффективно. - person Tener; 10.05.2011

Возможно, вы неправильно оцениваете проблему. Это могло быть скорее из-за слишком лени, чем из-за недостатка.

Может быть, вам стоит попробовать более строгую структуру данных или изменяемый массив в монаде ST.

Для подхода с изменяемым массивом вы можете ограничить количество перемещений на вставку до n/2 вместо n-1, записав индекс h, который «указывает на» заголовок очереди, хранящейся в массиве, и разрешив очереди «оборачиваться» внутри массива.

person Robin Green    schedule 10.05.2011

Эффективность памяти редко бывает сильной стороной haskell. Тем не менее, не так сложно создать алгоритм сортировки, который был бы более ленивым, чем сортировка слиянием. Например, вот простая быстрая сортировка:

qsort [] = []
qsort (x:xs) = qcombine (qsort a) b (qsort c) where
    (a,b,c) = qpart x (x:xs) ([],[],[])
qpart _ [] ac = ac
qpart n (x:xs) (a,b,c)
    | x > n = qpart n xs (a,b,x:c)
    | x < n = qpart n xs (x:a,b,c)
    | otherwise = qpart n xs (a,x:b,c)
qcombine (a:as) b c = a:qcombine as b c
qcombine [] (b:bs) c = b:qcombine [] bs c
qcombine [] [] c = c

Я использовал явную рекурсию, чтобы было очевидно, что происходит. Каждая часть здесь действительно ленивая, что означает, что qcombine никогда не будет вызывать qsort c, если это не нужно. Это должно снизить использование памяти, если вам нужны только первые несколько элементов.

Вы можете создать лучший алгоритм сортировки для этой конкретной задачи, который использует части стиля быстрой сортировки, чтобы получить первые n элементов списка в несортированном порядке. Затем просто вызовите высокоэффективный алгоритм сортировки, если они вам нужны по порядку.

пример такого подхода:

qselect 0 _ = []
qselect n [] = error ("cant produce " ++ show n ++ " from empty list")
qselect n (x:xs)
    | al > n = qselect n a
    | al + bl > n = a ++ take (al - n) b
    | otherwise = a ++ b ++ (qselect (n - al - bl) c) where
        (a,al,b,bl,c,cl) = qpartl x (x:xs) ([],0,[],0,[],0)

qpartl _ [] ac = ac
qpartl n (x:xs) (a,al,b,bl,c,cl)
    | x > n = qpartl n xs (a,al,b,bl,x:c,cl+1)
    | x < n = qpartl n xs (x:a,al+1,b,bl,c,cl+1)
    | otherwise = qpartl n xs (a,al,x:b,bl+1,c,cl)

Опять же, этот код не самый чистый, но я хочу прояснить, что он делает.

Для случая, когда вы хотите взять очень маленькое число, оптимальна сортировка выбора. Например, если вам нужен только самый высокий элемент в списке, вы можете перебирать его один раз, давая большую тэту размера списка.

С другой стороны, если вам нужен почти весь список, но вы не заботитесь о его порядке, вы можете многократно «удалить» самые нижние элементы в списке.

Оба этих подхода, а также быстрая сортировка выше, равны O (n ^ 2), но вы хотите иметь стратегию, которая часто работает в больших O (k * n) и, как правило, не использует тонну пространства.

Другой вариант - использовать алгоритм сортировки на месте для управления использованием памяти. Я не знаю ни одного ленивого вида на месте, но если они существуют, это было бы идеально.

person Philip JF    schedule 11.05.2011