【问题标题】:speeding up deleting duplicates when they're adjacent当它们相邻时加快删除重复项
【发布时间】:2013-11-04 18:58:35
【问题描述】:

我正在寻找类似#'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

【问题讨论】:

  • 我认为语言中没有类似的东西,但这几乎是run-length encoding,没有保留出现次数。另外,为什么不直接使用time 宏来做你的计时(在确保编译你的函数之后)?
  • 您正在使用一种非常糟糕的方式来创建数字列表。您的子功能 GENERATE-LIST 在 Lisp 中使用了至少三个大的反模式。
  • Rainer,感谢您的提醒。您能解释一下您看到的前三个反模式吗?
  • 您列出了 NIL - 第一个循环。然后你在一个单独的循环中计数 - 第二个循环。然后在列表中设置第 n 个项目 - 每个项目的另一个循环。就这样遍历列表 1/2 N^2 次。 反模式:1)多个循环。 2)首先创建列表,然后设置内容。 3) 重复访问第 n 个元素。 更好:1)一个循环。 2)在列表创建期间设置项目。 3) 避免 NTH 并且只使用一个循环,这里是初始循环。 记住:列表是单独链接的 cons 单元格。列表不是数组。
  • 还值得指出的是,正在构建的列表对于delete-duplicates 来说并不是一个好的测试用例。一个实现可以有效地通过列表建立一个元素的哈希表来检查是否有任何重复的元素,如果没有,则简单地返回列表。这在列表的长度上是线性的,并且是正确的。您的测试列表没有重复的元素,因此它并没有真正测试delete-duplicates 的主要功能。

标签: lisp common-lisp clisp gnu-common-lisp


【解决方案1】:

在语言中没有这样的东西,但这样的东西只会使列表通过:

(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 pointed out,也许可以使用(loop for head on list finally (return list) do ...) 简化此迭代。但是,3.6 Traversal Rules and Side Effects 表示在对象遍历期间修改列表的cdr 链会导致未定义的行为。但是,尚不清楚loop for head on list 在技术上是否是对象遍历操作。关于循环的文档在6.1.2.1.3 The for-as-on-list subclause 中说

在 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 以适当的方式工作。 (请注意,del-dups:test 将是 member:test-not。)注意:这实际上有一个小问题,因为 key 函数将调用 最终列表中的每个元素两次:一次是尾部的第一个元素,一次是headcar

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

【讨论】:

  • for head = list then (cdr head) 不会和for head on list 一样吗?
  • @wvxvw 我不这么认为,因为我们在循环执行期间修改了list 的结构(尽管head)。 for head on list 可能被允许做一些“聪明”的事情,比如在执行do 部分之前 为下一次迭代抓取单元格,但我们实际上希望确保修改结构,并且然后选择cdr。我认为3.6 Traversal Rules and Side Effects 可能与此处相关。
  • @wvxvw 然后,6.1.2.1.3 The for-as-on-list subclause: 说:“变量 var 绑定到 form1 中列表的连续尾部。在每次迭代结束时,将函数 step-fun 应用于列表; step-fun 的默认值为 cdr。”听起来for head on list 没问题。我不确定哪个和“3.6 遍历规则和副作用”更适用。这可能取决于for-as-on-list 是否被视为对象遍历形式。
  • 我从未深入研究过它,但我将它与破坏性树遍历函数一起使用,我不记得这样做有什么问题,但我真的只使用 SBCL 来做。
  • 我从来不理解loop 的吸引力......无论如何,(re RLE)map head . group 在惰性 eval 下也是如此(当然,除了它创建了新结构)。跨度>
【解决方案2】:

我希望 REMOVE-DUPLICATES 具有线性时间实现。 (确实*在我的本地 SBCL 安装中。)

请注意,REMOVE-DUPLICATES 和 DELETE-DUPLICATES 被指定为具有相同的返回值,并且不保证 DELETE-DUPLICATES 的副作用。

* 仅当 :test 为 #'eq,#'eql, #'equal 或 #'equalp(它依赖于哈希表)并且没有 :key 或 :test 时才采用线性时间码路径- 不提供参数。

【讨论】:

  • 它最多可以有O(n log n),并且只有当参数列表的元素可以通过某些标准<进行排序时,not (a < b) AND not (b < a) 相当于@987654324 @.
  • @WillNess 不,它不必是O(n log n)。 SBCL 使用哈希表来存储到目前为止看到的所有元素,并根据哈希表中的内容添加/删除下一个元素。所以它是O(n),尽管它有一个很大的常数。
  • @wvxvw 我在这里删除了我的评论,将速度与 Joshua Taylor 的基于删除相邻重复的速度进行比较。在目前的形式下,他的实现大部分时间都在查找 'eql,如果你用 #'eql 替换它,它确实有一个比 remove-duplicates 更好的常数。
  • @wvxvw 伙计们,只有在通过eql 等进行比较时才可能使用哈希表(其中相等测试可能形成等价关系); thisn: (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
  • 虽然值得注意,但可能需要将其编辑到您的答案中以供未来用户查看。
【解决方案3】:

记录一下:你的测试代码基本上就是这样的:

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

在删除重复缓慢的情况下,与实现维护人员交谈也可能很有用。

例如,(one-shot 1000000) 在我的 Mac 上的 CCL 中运行一秒钟。在 LispWorks 中运行时间为 0.155 秒。

【讨论】:

  • 但是即使 delete-duplicates 在这种情况下很快(注意在这种情况下没有重复项;它可能会在列表中进行单次传递,识别并返回列表) ,这并没有解决它的性能是否通常与列表的长度线性。 OP 的任务可以在线性时间内执行,但是 (i) delete-duplicates 不能保证是线性的,并且 (ii) delete-duplicates 不能解决 OP 仅删除相邻重复项的问题,例如,在这种情况下像(1 1 2 1 1) 这样的列表,其中输出应该是(1 2 1)
  • Rainer,你的计时数据很有趣。我不想承认我在哪台机器上运行它。但我的兴趣主要不在于它的运行速度,而是它的扩展方式:列表长度加倍是时间加倍(好),还是时间加倍(坏)?约书亚正确地强调了这一点。
  • @BillEvansatMariposa cf。 en.wikipedia.org/wiki/…
  • @Will Ness:我的观点完全正确,但维基百科表达得更优雅。
【解决方案4】:

语言标准中没有类似的东西。但是,您可以使用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(练习留给读者)。

有关破坏性实施,请参阅 the other answer

PS。除非你在时间上做一些棘手的事情,否则你最好使用time

【讨论】:

  • 您是否有特定的基于reduce 的实现?使用reduce删除重复项很容易,但是重用列表结构的高效delete 似乎特别困难,因为传递给reduce 的函数将获得元素列表而不是作为列表“脊椎”的 cons 单元格。
  • @JoshuaTaylor:抱歉,我的意思是非破坏性删除。
【解决方案5】:

有点不同的方法:

(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

线性时间也会增加。

【讨论】:

  • 现在不要去修改文字数据;当然你的意思是(compress-duplicates (list 1 1 1 1 … 5))。 ;)
  • @JoshuaTaylor 啊是的,好吧,最终在 REPL 中测试时它也可以这样工作,但我会更改它以避免不必要的误解。
  • @ 为什么不简单地将(%compress-duplicates list (cdr list)) list 作为labels 的主体?
  • @WillNess %compress-duplicates 返回列表的末尾,所以这是行不通的,但如果我把它带到列表的头部,它可以,但似乎更容易捕获头部列表,然后将其返回,而不是直接将其传递到最后而不更改它。
  • 但是在通话后有list。你不需要捕获list,它已经存在了。
猜你喜欢
  • 1970-01-01
  • 2015-11-29
  • 2018-11-28
  • 1970-01-01
  • 2020-06-01
  • 1970-01-01
  • 2015-01-05
  • 1970-01-01
  • 2018-08-29
相关资源
最近更新 更多