如何提高 Python 程式碼效率

太傻子發表於2014-07-03

如何提高 Python 程式碼效率

本王用 Python 快兩年了,平時只是用它來寫點小小的分析指令碼,以方便快捷為主,也沒怎麼考慮程式碼效率問題。最近想給自己升升級,提高一下程式碼的檔次。於是找了一堆效率相關的文章,做了些實驗,總結一下。

第一招:蛇打七寸:定位瓶頸

首先,第一步是定位瓶頸。舉個簡單的栗子,一個函式可以從1秒優化到到0.9秒,另一個函式可以從1分鐘優化到30秒,如果要花的代價相同,而且時間限制只能搞定一個,搞哪個?根據短板原理,當然選第二個啦。

一個有經驗的程式設計師在這裡一定會遲疑一下,等等?函式?這麼說,還要考慮呼叫次數?如果第一個函式在整個程式中需要被呼叫100000次,第二個函式在整個程式中被呼叫1次,這個就不一定了。舉這個栗子,是想說明,程式的瓶頸有的時候不一定一眼能看出來。還是上面那個選擇,程式設計師的你應該有感覺的,大多數情況下:一個「可以」從一分鐘優化到30秒的函式會比一個「可以」從1秒優化到0.9秒的函式更容易捕獲我們的注意,因為有很大的進步空間嘛。

所以,這麼多廢話講完,獻上第一招,profile。這是 python 自帶的定位程式瓶頸的利器!雖然它提供了三種選項profile,cProfile,hotshot。還分為內建和外接。但是,個人覺得一種足矣,外接cProfile。心法如下:

python -m profile 逗比程式.py

這招的效果會輸出一系列東西,比如函式被呼叫了幾次,總時間多少,其中有多少是這個函式的子函式花費的,每次花多少時間,等等。嘛一圖勝千言:

  • filename:lineno(function): 檔名:第幾行(函式名)
  • ncalls: 這貨一共呼叫了幾次
  • tottime: 這貨自己總共花了多少時間,也就是要除掉內部函式小弟們的花費
  • percall: 平均每次呼叫花的時間,tottime 除以 ncalls
  • cumtime: 這貨還有它的所有內部函式小弟們的總花費
  • percall: 跟上面那個 percall 差不多,不過是 cumtime 除以 ncalls

找到最值得優化的點,然後幹吧。

第二招:一蛇禪:只需一招

記得剛開始接觸 Python 的時候,有一位學長告訴我,Python 有一個牛逼的理想,它希望每一個用它的人能寫出一模一樣的程式。Python 之禪有云:

There should be one-- and preferably only one --obvious way to do it

所以 Python 系專業的禪師提供了一些常用功能的 only one 的寫法。本王看了一下傳說中的PythonWiKi:PerformanceTips,總結了幾個「不要醬紫」「要醬紫」。

  1. 合併字串的時候不要醬紫:

    s = ""
        for substring in list:
            s += substring
    

    要醬紫:

    s = "".join(slist)
    
  2. 格式化字串的時候不要醬紫:

    out = "<html>" + head + prologue + query + tail + "</html>" 
    

    要醬紫:

    out = "<html>%s%s%s%s</html>" % (head, prologue, query, tail)
    
  3. 可以不用迴圈的時候就不要用迴圈,比如不要醬紫:

    newlist = []
    for word in oldlist:
        newlist.append(word.upper()) 
    

    要醬紫:

    newlist = map(str.upper, oldlist)
    

    或者醬紫:

    newlist = [s.upper() for s in oldlist]  
    
  4. 字典初始化,比較常用的:

    wdict = {}
    for word in words:
        if word not in wdict:
            wdict[word] = 0
        wdict[word] += 1
    

    如果重複的 word 太多了的話,可以考慮用醬紫的模式來省掉大量判斷:

    wdict = {}
    for word in words:
        try:
            wdict[word] += 1
        except KeyError:
            wdict[word] = 1 
    
  5. 儘量減少 function 呼叫次數,用內部迴圈代替,比如,不要醬紫:

    x = 0
    def doit1(i):
        global x
        x = x + i
    list = range(100000)
    t = time.time()
    for i in list:
        doit1(i) 
    

    要醬紫:

    x = 0
    def doit2(list):
        global x
        for i in list:
            x = x + i
    list = range(100000)
    t = time.time()
    doit2(list)
    

第三招:蛇之狙擊:高速搜尋

這一招部分來源於IBM:Python 程式碼效能優化技巧,搜尋演算法的最高境界是O(1)的演算法複雜度。也就是 Hash Table。本王幸本科的時候學了點資料結構。知道 Python 的 list 使用類似連結串列的方法實現的。如過列表很大的話,在茫茫多的項裡面用 if X in list_a 來做搜尋和判斷效率是非常低的。

Python 的 tuple 我用得非常少,不評論。另兩個我用得非常多的是 set 和 dict。這兩個就是用的類似 Hash Table 的實現方法。

  1. 所以儘量不要醬紫:

    k = [10,20,30,40,50,60,70,80,90]
    for i in xrange(10000):
        if i in k:
            #Do something
            continue
    

    要醬紫:

    k = [10,20,30,40,50,60,70,80,90]
    k_dict = {i:0 for i in k}
    #先把 list 轉換成 dictionary 
    for i in xrange(10000):
        if i in k_dict:
            #Do something
            continue
    
  2. 找 list 的交集,不要醬紫:

    list_a = [1,2,3,4,5]  
    list_b = [4,5,6,7,8]  
    list_common = [a for a in list_a if a in list_b]   
    

    要醬紫:

    list_a = [1,2,3,4,5]  
    list_b = [4,5,6,7,8]  
    list_common = set(list_a)&set(list_b)  
    

第四招:小蛇蛇……:想不出來名字了,就是各種小 Tips

  1. 變數交換不需要中間變數:a,b = b,a (這裡有個神坑,至今記憶深刻:True,False = False,True)
  2. 如果使用 Python2.x,用 xrange 代替 range,如果用 Python3.x,range 已經是 xrange 了,xrange 已經木有了。xrange 不會像 range 一樣生成一個列表,而是生成一個迭代器,省記憶體。
  3. 可以用 x>y>z 代替 x>y and y>z。效率更高,可讀性也更好。當然理論上 x>y
  4. add(x,y) 一般會比 a+b 要快?這個本王有所懷疑,實驗了一下,首先 add 不能直接用,要 import operator,第二,我的實驗結果表示 add(x,y) 完全沒有 a+b 快,更何況還要犧牲可讀性。
  5. while 1 確實比 while True 要快那麼一點點。做了兩次實驗,大概快了15%左右。

第五招:無蛇勝有蛇:程式碼之外的效能

程式碼之外嘛,除了硬體之外,就是編譯器了,這裡隆重推薦 pypy。pypy是一種叫做 just-in-time 的即時編譯器。這個編譯器的特點就是編譯一句跑一句,和靜態的編譯器的區別嘛,我在知乎上看到一個非常形象的比喻:

假定你是一個導演,靜態編譯就是讓演員把整個劇本背下來吃透,然後連續表演一個小時。動態編譯就是讓演員表演兩分鐘,然後思考一下,再看一下劇本,再表演兩分鐘……

動態編譯和靜態編譯各有所長,看你演的是電影還是話劇了。

此外還有一個 Cython 可以在 python 裡內建一些 C 的程式碼。我用的非常少,但是關鍵時刻確實有效。

相關文章