ускорение удаления дубликатов, когда они находятся рядом

Я ищу что-то вроде #'delete-duplicates, но знаю, что все элементы списка уже отсортированы, или обратно отсортированы, или хотя бы расположены так, что дубликаты уже будут соседствовать друг с другом. Я хочу использовать эти знания, чтобы убедиться, что скорость выполнения не пропорциональна квадрату количества элементов в списке. Использование #'maplist для расширения моего собственного решения тривиально, но есть ли что-то уже в языке? Было бы стыдно изобретать велосипед.

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

 1 (defun one-shot (cardinality)
 2   (labels ((generate-list (the-count)
 3              (let* ((the-list (make-list the-count)))
 4                (do ((iterator 0 (1+ iterator)))
 5                  ((>= iterator the-count))
 6                  (setf (nth iterator the-list) iterator))
 7                the-list)))
 8     (let* ((given-list (generate-list cardinality))
 9            (stripped-list)
10            (start-time)
11            (end-time))
12       (setf start-time (get-universal-time))
13       (setf stripped-list (delete-duplicates given-list :test #'eql))
14       (setf end-time (get-universal-time))
15       (princ "for n = ")
16       (princ cardinality)
17       (princ ", #'delete-duplicates took ")
18       (princ (- end-time start-time))
19       (princ " seconds")
20       (terpri))))
21 (one-shot 20000)
22 (one-shot 40000)
23 (one-shot 80000)
for n = 20000, #'delete-duplicates took 6 seconds
for n = 40000, #'delete-duplicates took 24 seconds
for n = 80000, #'delete-duplicates took 95 seconds

person Bill Evans at Mariposa    schedule 04.11.2013    source источник
comment
Я не думаю, что в языке есть что-то подобное, но это в значительной степени кодирование длин серий без сохранения количества вхождений. Кроме того, почему бы просто не использовать макрос time для определения времени ( после того, как вы скомпилируете свою функцию)?   -  person Joshua Taylor    schedule 04.11.2013
comment
Джошуа, ты прав во всех отношениях, хотя я хотел, чтобы вывод был более аккуратным, чем time.   -  person Bill Evans at Mariposa    schedule 04.11.2013
comment
Вы используете очень плохой способ создания списка чисел. Ваша подфункция GENERATE-LIST использует как минимум три больших антишаблона в Лиспе.   -  person Rainer Joswig    schedule 04.11.2013
comment
Райнер, спасибо за наводку. Не могли бы вы объяснить первые три антипаттерна, которые вы видите?   -  person Bill Evans at Mariposa    schedule 05.11.2013
comment
Вы составляете список NIL - 1-й цикл. Затем прибавляешь в отдельную петлю — 2-я петля. Затем вы устанавливаете n-й элемент в списке - еще один цикл для каждого элемента. Просто это проходит по списку 1/2 N^2 раза. Антипаттерны: 1) несколько циклов. 2) сначала создается список, а затем устанавливается содержимое. 3) повторный доступ к n-му элементу. Лучше: 1) один цикл. 2) при создании списка задайте элементы. 3) избегайте NTH и просто используйте один цикл, здесь начальный. Имейте в виду: списки представляют собой односвязные cons-ячейки. Списки не являются массивами.   -  person Rainer Joswig    schedule 05.11.2013
comment
Также стоит отметить, что создаваемый список не является хорошим тестовым примером для delete-duplicates. Реализация может правильно выполнить один проход по списку, создав хэш-таблицу элементов, чтобы проверить, есть ли какие-либо повторяющиеся элементы, а затем, если их нет, просто вернуть список. Это было бы линейно по длине списка и правильно. В вашем тестовом списке нет повторяющихся элементов, поэтому на самом деле он не проверяет основные функции delete-duplicates.   -  person Joshua Taylor    schedule 05.11.2013
comment
Джошуа, это правда. Однако первоначальный вопрос заключался в том, существует ли особенность языка, которая, как известно, использует преимущества всех дубликатов, уже сгруппированных вместе; и временные числа, демонстрирующие, что удвоение длины списка увеличивает время в четыре раза, иллюстрируют то, чего я пытался избежать. А в реальном приложении я отказался от списка в пользу хеша.   -  person Bill Evans at Mariposa    schedule 06.11.2013


Ответы (5)


Ничего подобного в языке нет, но что-то вроде этого делает всего один проход по списку:

(defun delete-adjacent-duplicates (list &key key (test 'eql))
  (loop
     for head = list then (cdr head)
     until (endp head)
     finally (return list)
     do (setf (cdr head)
              (member (if (null key) (car head)
                          (funcall key (car head)))
                      (cdr head)
                      :key key :test-not test))))

Как указал @wvxvw , эту итерацию можно упростить, используя (loop for head on list finally (return list) do ...). Однако в 3.6 Правила обхода и побочные эффекты говорится, что изменение цепочки cdr списка во время обхода объекта приводит к неопределенному поведению. Однако неясно, является ли loop for head on list технически операцией обхода объекта или нет. В документации о цикле говорится в 6.1.2.1.3 перечислить подпункт, который

В подпункте for-as-on-list конструкция for или as выполняет итерацию по списку. … Переменная var привязана к последовательным концам списка в form1. В конце каждой итерации к списку применяется функция step-fun; значение по умолчанию для step-fun — cdr. … Конструкция for или as вызывает завершение при достижении конца списка.

Это говорит о том, что пошаговая функция всегда применяется в конце итерации, поэтому похоже, что loop for head on list должно быть в порядке. В любом случае, любых возможных проблем можно было бы избежать, используя вместо этого цикл do:

(defun delete-adjacent-duplicates (list &key key (test 'eql))
  (do ((head list (cdr head)))
      ((endp head) list)
    (setf (cdr head)
          (member (if (null key) (car head)
                      (funcall key (car head)))
                  (cdr head)
                  :key key :test-not test))))

Идея состоит в том, чтобы начать со списка head, затем установить его cdr на первый хвост, который начинается с другого элемента, затем продвигать голову и продолжать, пока ничего не останется. Это должно быть линейно по длине списка, при условии, что member реализовано разумным образом. Использование member означает, что вам не нужно делать никакой дополнительной работы, чтобы заставить :key и :test работать надлежащим образом. (Обратите внимание, что :test для del-dups будет :test-not для member.) Примечание: на самом деле есть небольшая проблема, заключающаяся в том, что функция key будет вызываться дважды для каждого элемента в окончательный список: один раз, когда это первый элемент хвоста, и один раз, когда это car из head.

CL-USER> (delete-adjacent-duplicates (list 1 1 1 1 2 2 3 3 3))
(1 2 3)
CL-USER> (delete-adjacent-duplicates (list 1 2 2))
(1 2)
CL-USER> (delete-adjacent-duplicates (list 1 3 5 6 4 2 3 5) :key 'evenp)
(1 6 3)

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

person Joshua Taylor    schedule 04.11.2013
comment
Разве for head = list then (cdr head) не будет таким же, как for head on list? - person ; 05.11.2013
comment
@wvxvw Я так не думаю, потому что мы изменяем структуру list во время выполнения цикла (хотя head). for head on list может быть разрешено делать что-то «умное», например захват ячейки для следующей итерации перед выполнением части do, но на самом деле мы хотим убедиться, что изменили структуру, а затем возьми cdr. Я думаю, что здесь может быть уместным 3.6 Правила обхода и побочные эффекты. - person Joshua Taylor; 05.11.2013
comment
@wvxvw Опять же, 6.1.2.1.3 Список for-as-on-list subclause: говорит: «Переменная var привязана к последовательным концам списка в form1. В конце каждой итерации к списку применяется функция step-fun; значение по умолчанию для step-fun — cdr». Похоже, for head on list в порядке. Я не уверен, что из этого и «3.6 Правила обхода и побочные эффекты» применимо больше. Это может зависеть от того, считается ли for-as-on-list формой обхода объекта или нет. - person Joshua Taylor; 05.11.2013
comment
Я никогда не исследовал его глубоко, но я использовал его с деструктивными функциями обхода дерева, и я не помню проблем с этим, но я действительно использовал только SBCL для этого. - person ; 05.11.2013
comment
Я никогда не понимал привлекательности loop... Во всяком случае, (re RLE) map head . group делает то же самое, при ленивом eval (за исключением того, что он создает новую структуру, конечно). - person Will Ness; 06.11.2013
comment
@WillNess Я думаю, что отчасти привлекательность loop заключается в том, что с его помощью можно легко выполнять задачи быстро и в удобочитаемой форме. Иногда он более удобочитаем, чем do, и упрощает смешивание различных видов итераций. Тем не менее, я думаю, что обычно стоит спросить после написания loop: «Хорошо, у меня есть кое-что, что работает, теперь это можно упростить?» - person Joshua Taylor; 06.11.2013

Я ожидаю, что REMOVE-DUPLICATES будет иметь линейную реализацию времени. (И действительно, это происходит * при моей локальной установке SBCL.)

Обратите внимание, что REMOVE-DUPLICATES и DELETE-DUPLICATES возвращают одно и то же значение, и побочные эффекты DELETE-DUPLICATES не гарантируются.

* Путь линейного временного кода используется только в том случае, если :test имеет значение #'eq,#'eql, #'equal или #'equalp (он основан на хеш-таблице) и нет аргумента :key или :test-not поставляется.

person m-n    schedule 05.11.2013
comment
он может иметь в лучшем случае O(n log n) и только в том случае, если элементы списка аргументов могут быть упорядочены по некоторому критерию <, так что not (a < b) AND not (b < a) эквивалентно (a == b). - person Will Ness; 05.11.2013
comment
@WillNess нет, это не обязательно должно быть O(n log n). SBCL использует хеш-таблицу для хранения всех элементов, просмотренных до сих пор, и добавляет/удаляет следующий элемент на основе того, что находится в хеш-таблице. Итак, это O(n), хотя с ним связана большая константа. - person ; 05.11.2013
comment
@wvxvw Я удалил свой комментарий здесь, сравнивая скорость со скоростью удаления смежных дубликатов Джошуа Тейлора. В его текущей форме его реализация тратит большую часть своего времени на поиск 'eql, и если вы замените его на #'eql, он действительно будет иметь значительно лучшую константу, чем remove-duplicates. - person m-n; 06.11.2013
comment
@wvxvw ребята, хэш-таблица возможна только при сравнении по eql и т.п. (где проверка на равенство, возможно, формирует отношение эквивалентности); это линейно в n: (defun tt (n) (length (remove-duplicates (loop for i from 2 to n collect i) :test #'(lambda(a b)(> (gcd a b) 1)) :from-end t)))? И если да, то оценивается ли (tt 541) как 100? - person Will Ness; 06.11.2013
comment
@WillNess Я рад, что вы указали на это. Он использует хеш-таблицу только в том случае, если тест указан для работы с хеш-таблицами (#'eq, #'eql, #'equal, #'equalp), и поэтому не дает алгоритма линейного времени для генерации списка простые числа. Он также не использует путь линейного временного кода, когда им предоставляется ключевой аргумент или аргумент test-not. - person m-n; 06.11.2013
comment
@ m-n правильно, так что удаление соседних тоже имеет место. :) Это всегда будет линейно. - person Will Ness; 06.11.2013
comment
довольно интересно отметить, возможно, вы захотите отредактировать его в своем ответе, чтобы будущие пользователи могли его увидеть. - person Sim; 08.11.2013

Для справки: ваш тестовый код в основном такой:

(defun one-shot (n &aux (list (loop for i below n collect i)))
  (time (delete-duplicates list))
  (values))

Также может быть полезно поговорить с сопровождающими реализации в случае медленного удаления дубликатов.

Например, (one-shot 1000000) выполняется за секунду в CCL на моем Mac. В LispWorks это выполняется за 0,155 секунды.

person Rainer Joswig    schedule 04.11.2013
comment
Но даже если delete-duplicates в этом случае работает быстро (и обратите внимание, что в этом случае нет дубликатов; он может выполнять один проход по списку, распознавая это и возвращая список), это не решает, является ли его производительность обычно линейно зависит от длины списка или нет. Задача OP может быть выполнена за линейное время, но (i) delete-duplicates не гарантируется линейность, и (ii) delete-duplicates не решает проблему OP по удалению только смежных дубликатов, например, в случае списка, подобного (1 1 2 1 1), где вывод должен быть (1 2 1). - person Joshua Taylor; 05.11.2013
comment
Райнер, ваши цифры интригуют. Я бы не хотел признавать, на какой машине я это запускаю. Но меня в первую очередь интересовало не то, как быстро он работает, а то, как он масштабируется: удвоение длины списка удваивает время (хорошо) или увеличивает время в четыре раза (плохо)? Иисус Навин правильно подчеркивает этот момент. - person Bill Evans at Mariposa; 05.11.2013
comment
@BillEvansatMariposa ср. en.wikipedia.org/wiki/ - person Will Ness; 05.11.2013
comment
@Will Ness: Это моя точка зрения, но Википедия выражает ее более элегантно. - person Bill Evans at Mariposa; 05.11.2013

Ничего подобного в языковом стандарте нет. Однако вы можете сделать это с помощью loop:

(defun remove-adjacent-duplicates (list &key (test #'eql))
  (loop for obj in list 
        and prev = nil then obj 
        for take = t then (not (funcall test obj prev))
        when take collect obj))

или с помощью reduce (упражнение предоставляется читателю).

См. другой ответ для деструктивной реализации.

PS. Если вы не делаете ничего сложного со временем, вам лучше использовать time.

person sds    schedule 04.11.2013
comment
Вы имеете в виду конкретную реализацию на основе reduce? Легко удалить дубликаты с помощью reduce, но эффективный delete, который повторно использует структуру списка, кажется особенно сложным, поскольку функция, переданная reduce, получит элементы списка, а не недостатки ячейки, которые являются «позвоночником» списка. - person Joshua Taylor; 05.11.2013
comment
@JoshuaTaylor: извините, я имел в виду неразрушающее удаление. - person sds; 05.11.2013

Немного другой подход:

(defun compress-duplicates (list &key (test #'eql))
  (labels ((%compress-duplicates (head tail)
             (if (null tail)
               (setf (cdr head) tail)
               (progn (unless (funcall test (car head) (car tail))
                        (setf (cdr head) tail head (cdr head)))
                      (%compress-duplicates head (cdr tail))))))
    (%compress-duplicates list (cdr list)) 
    list))
                  
(compress-duplicates (list 1 1 1 2 2 3 4 4 1 1 1))
;; (1 2 3 4 1)

Тест реализации SBCL delete-duplicates:

(defun test-delete-duplicates ()
  (labels ((%test (list)
             (gc)
             (time (delete-duplicates list))))
    (loop
       :repeat 6
       :for list := (loop :for i :from 0 :below 1000
                       :collect (random 100))
       :then (append list list) :do (%test (copy-list list)))))

;; (test-delete-duplicates)

;; Evaluation took:
;;   0.002 seconds of real time
;;   0.002000 seconds of total run time (0.002000 user, 0.000000 system)
;;   100.00% CPU
;;   3,103,936 processor cycles
;;   0 bytes consed
  
;; Evaluation took:
;;   0.003 seconds of real time
;;   0.003000 seconds of total run time (0.003000 user, 0.000000 system)
;;   100.00% CPU
;;   6,347,431 processor cycles
;;   0 bytes consed
  
;; Evaluation took:
;;   0.006 seconds of real time
;;   0.006000 seconds of total run time (0.005000 user, 0.001000 system)
;;   100.00% CPU
;;   12,909,947 processor cycles
;;   0 bytes consed
  
;; Evaluation took:
;;   0.012 seconds of real time
;;   0.012000 seconds of total run time (0.012000 user, 0.000000 system)
;;   100.00% CPU
;;   25,253,024 processor cycles
;;   0 bytes consed
  
;; Evaluation took:
;;   0.023 seconds of real time
;;   0.022000 seconds of total run time (0.022000 user, 0.000000 system)
;;   95.65% CPU
;;   50,716,442 processor cycles
;;   0 bytes consed
  
;; Evaluation took:
;;   0.049 seconds of real time
;;   0.050000 seconds of total run time (0.050000 user, 0.000000 system)
;;   102.04% CPU
;;   106,747,876 processor cycles
;;   0 bytes consed

Показывает линейную скорость.


Тест реализации ECL delete-duplicates:

;; (test-delete-duplicates)
;; real time : 0.003 secs
;; run time  : 0.003 secs
;; gc count  : 1 times
;; consed    : 95796160 bytes
;; real time : 0.007 secs
;; run time  : 0.006 secs
;; gc count  : 1 times
;; consed    : 95874304 bytes
;; real time : 0.014 secs
;; run time  : 0.014 secs
;; gc count  : 1 times
;; consed    : 95989920 bytes
;; real time : 0.028 secs
;; run time  : 0.027 secs
;; gc count  : 1 times
;; consed    : 96207136 bytes
;; real time : 0.058 secs
;; run time  : 0.058 secs
;; gc count  : 1 times
;; consed    : 96617536 bytes
;; real time : 0.120 secs
;; run time  : 0.120 secs
;; gc count  : 1 times
;; consed    : 97412352 bytes

Линейное время также увеличивается.

person Community    schedule 04.11.2013
comment
Не изменяйте литеральные данные сейчас; конечно, вы имели в виду (compress-duplicates (list 1 1 1 1 … 5)). ;) - person Joshua Taylor; 05.11.2013
comment
@JoshuaTaylor ах, да, хорошо, в конце концов, это тоже работает при тестировании в REPL, но я изменю его, чтобы избежать ненужных недоразумений. - person ; 05.11.2013
comment
@ почему бы не просто (%compress-duplicates list (cdr list)) list в качестве тела labels? - person Will Ness; 05.11.2013
comment
@WillNess %compress-duplicates возвращает конец списка, так что это не сработает, но если бы я перенес заголовок списка, это могло бы быть, но мне показалось проще просто захватить заголовок списка, а затем вернуть его, а не просто передать это до конца, не меняя его. - person ; 05.11.2013
comment
Но для этого есть list после звонка. Вам не нужно захватывать list, он уже есть. - person Will Ness; 05.11.2013
comment
это классический обработчик списка сверху вниз, который вы там написали: цикл, чтобы сделать что-то для его эффекта, а затем вернуть измененный объект. (хотя в CL нет гарантии записи хвоста...). - person Will Ness; 06.11.2013