sum() 函式效能堪憂,列表降維有何良方?

豌豆花下貓發表於2019-04-27

sum() 函式效能堪憂,列表降維有何良方?

本文原創並首發於公眾號【Python貓】,未經授權,請勿轉載。

原文地址:mp.weixin.qq.com/s/mK1nav2vK…

Python 的內建函式 sum() 可以接收兩個引數,當第一個引數是二維列表,第二個引數是一維列表的時候,它可以實現列表降維的效果。

在上一篇《如何給列表降維?sum()函式的妙用》中,我們介紹了這個用法,還對 sum() 函式做了擴充套件的學習。

那篇文章釋出後,貓哥收到了一些很有價值的反饋,不僅在知識面上獲得了擴充,在思維能力上也得到了一些啟發,因此,我決定再寫一篇文章,繼續跟大家聊聊 sum() 函式以及列表降維。若你讀後有所啟發,歡迎留言與我交流。

有些同學表示,沒想到 sum() 函式竟然可以這麼用,漲見識了!貓哥最初在交流群裡看到這種用法時,也有同樣的想法。整理成文章後,能得到別人的認可,我非常開心。

學到新東西,進行分享,最後令讀者也有所獲,這鼓舞了我——應該每日精進,並把所學分享出去。

也有的同學早已知道 sum() 的這個用法,還指出它的效能並不好,不建議使用。這是我不曾考慮到的問題,但又不得不認真對待。

是的,sum() 函式做列表降維有奇效,但它效能堪憂,並不是最好的選擇。

因此,本文想繼續探討的話題是:(1)sum() 函式的效能到底差多少,為什麼會差?(2)既然 sum() 不是最好的列表降維方法,那是否有什麼替代方案呢?

stackoverflow 網站上,有人問了個“How to make a flat list out of list of lists”問題,正是我們在上篇文章中提出的問題。在回答中,有人分析了 7 種方法的時間效能。

先看看測試程式碼:

import functools
import itertools
import numpy
import operator
import perfplot

def forfor(a):
    return [item for sublist in a for item in sublist]

def sum_brackets(a):
    return sum(a, [])

def functools_reduce(a):
    return functools.reduce(operator.concat, a)

def functools_reduce_iconcat(a):
    return functools.reduce(operator.iconcat, a, [])

def itertools_chain(a):
    return list(itertools.chain.from_iterable(a))

def numpy_flat(a):
    return list(numpy.array(a).flat)

def numpy_concatenate(a):
    return list(numpy.concatenate(a))

perfplot.show(
    setup=lambda n: [list(range(10))] * n,
    kernels=[
        forfor, sum_brackets, functools_reduce, functools_reduce_iconcat,
        itertools_chain, numpy_flat, numpy_concatenate
        ],
    n_range=[2**k for k in range(16)],
    logx=True,
    logy=True,
    xlabel='num lists'
    )
複製程式碼

程式碼囊括了最具代表性的 7 種解法,使用了 perfplot (注:這是該測試者本人開發的庫)作視覺化,結果很直觀地展示出,隨著資料量的增加,這幾種方法的效率變化。

sum() 函式效能堪憂,列表降維有何良方?

從測試圖中可看出,當資料量小於 10 的時候,sum() 函式的效率很高,但是,隨著資料量增長,它所花的時間就出現劇增,遠遠超過了其它方法的損耗。

值得注意的是,functools_reduce 方法的效能曲線幾乎與 sum_brackets 重合。

在另一個回答中,有人也做了 7 種方法的效能測試(巧合的是,所用的視覺化庫也是測試者自己開發的),在這幾種方法中,functools.reduce 結合 lambda 函式,雖然寫法不同,它的時間效率與 sum() 函式也基本重合:

from itertools import chain
from functools import reduce
from collections import Iterable  # or from collections.abc import Iterable
import operator
from iteration_utilities import deepflatten

def nested_list_comprehension(lsts):
    return [item for sublist in lsts for item in sublist]

def itertools_chain_from_iterable(lsts):
    return list(chain.from_iterable(lsts))

def pythons_sum(lsts):
    return sum(lsts, [])

def reduce_add(lsts):
    return reduce(lambda x, y: x + y, lsts)

def pylangs_flatten(lsts):
    return list(flatten(lsts))

def flatten(items):
    """Yield items from any nested iterable; see REF."""
    for x in items:
        if isinstance(x, Iterable) and not isinstance(x, (str, bytes)):
            yield from flatten(x)
        else:
            yield x

def reduce_concat(lsts):
    return reduce(operator.concat, lsts)

def iteration_utilities_deepflatten(lsts):
    return list(deepflatten(lsts, depth=1))


from simple_benchmark import benchmark

b = benchmark(
    [nested_list_comprehension, itertools_chain_from_iterable, pythons_sum, reduce_add,
     pylangs_flatten, reduce_concat, iteration_utilities_deepflatten],
    arguments={2**i: [[0]*5]*(2**i) for i in range(1, 13)},
    argument_name='number of inner lists'
)

b.plot()
複製程式碼

sum() 函式效能堪憂,列表降維有何良方?

這就證實了兩點:sum() 函式確實效能堪憂;它的執行效果實際是每個子列表逐一相加(concat)。

那麼,問題來了,拖慢 sum() 函式效能的原因是啥呢?

在它的實現原始碼中,我找到了一段註釋:

/* It's tempting to use PyNumber_InPlaceAdd instead of
PyNumber_Add here, to avoid quadratic running time
when doing 'sum(list_of_lists, [])'.  However, this
would produce a change in behaviour: a snippet like

empty = []
sum([[x] for x in range(10)], empty)

would change the value of empty. */
複製程式碼

為了不改變 sum() 函式的第二個引數值,CPython 沒有采用就地相加的方法(PyNumber_InPlaceAdd),而是採用了較耗效能的普通相加的方法(PyNumber_Add)。這種方法所耗費的時間是二次方程式的(quadratic running time)。

為什麼在這裡要犧牲效能呢?我猜想(只是淺薄猜測),可能有兩種考慮,一是為了第二個引數(start)的一致性,因為它通常是一個數值,是不可變物件,所以當它是可變物件型別時,最好也不對它做修改;其次,為了確保 sum() 函式是個 純函式 ,為了多次執行時能返回同樣的結果。

那麼,我要繼續問:哪種方法是最優的呢?

綜合來看,當子列表個數小於 10 時,sum() 函式幾乎是最優的,與某幾種方法相差不大,但是,當子列表數目增加時,最優的選擇是 functools.reduce(operator.iconcat, a, []),其次是 list(itertools.chain.from_iterable(a)) 。

事實上,最優方案中的 iconcat(a, b) 等同於 a += b,它是一種就地修改的方法。

operator.iconcat(a, b)
operator.__iconcat__(a, b)
a = iconcat(a, b) is equivalent to a += b for a and b sequences.
複製程式碼

這正是 sum() 函式出於一致性考慮,而捨棄掉的實現方案。

至此,前文提出的問題都找到了答案。

我最後總結一下吧:sum() 函式採用的是非就地修改的相加方式,用作列表降維時,隨著資料量增大,其效能將是二次方程式的劇增,所以說是效能堪憂;而 reduce 結合 iconcat 的方法,才是大資料量時的最佳方案。

這個結果是否與你所想的一致呢?希望本文的分享,能給你帶來新的收穫。

相關連結:

如何給列表降維?sum()函式的妙用 :mp.weixin.qq.com/s/cr_noDx6s…

stackoverflow 問題:stackoverflow.com/questions/9…

sum() 函式效能堪憂,列表降維有何良方?

公眾號【Python貓】, 本號連載優質的系列文章,有喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫作、優質英文推薦與翻譯等等,歡迎關注哦。後臺回覆“愛學習”,免費獲得一份學習大禮包。

相關文章