一個Quicksort究竟可以寫到多麼短

一個Quicksort究竟可以寫到多麼短

說實話,我從來沒有能一次寫對一個快速排序,總是有各種各樣的錯誤。
快排麻煩就麻煩在,沒辦法去調試它,因爲它是生成遞歸的,只能去靜態調試,或者是不斷的打印數組的狀態以推測錯誤的可能性。 然而快排的基本思想卻是極其簡單的:接收一個數組,挑一個數,然後把比它小的那一攤數放在它的左邊,把比它大的那一攤數放在它的右邊,然後再對這個數左右兩攤數遞歸的執行快排過程,直到子數組只剩一個數爲止。

下面我先用最常用的C語言來寫一個快速排序:

首先可以先寫出一些僞代碼:

void quicksort(int array[], int left, int right)
{
    //Do nothing if left <= right
    //p <- Get a number from array
    //Put elements <= p to the left side
    //Put elements >= p to the right side
    //Put p in the middle slot which index is pivot
    //Recursive quicksort the left parts and right parts
}
然後慢慢的把這些僞代碼轉化成C code:

Step 1:

void quicksort(int array[], int left, int right)
{
    if(left<right)
    {
        //p <- Get a number from array
        //Put elements <= p to the left side
        //Put elements >= p to the right side
        //Put p in the middle slot which index is pivot
        //Recursive quicksort the left parts and right parts
    }
}

Step 2:獲得數組中的某個元素,在這裏總是獲取最左邊的元素

void quicksort(int array[], int left, int right)
{
    if(left<right)
    {
        int p=array[left];
        //Put elements <= p to the left side
        //Put elements >= p to the right side
        //Put p in the middle slot which index is pivot
        //Recursive quicksort the left parts and right parts
    }
}

Step 3:這是比較麻煩的一步,可以遍歷數組,如果碰到比標記值小的數,就把它與前面的數字交換,這樣遍歷一遍之後,小的數字就被移到了前面

void quicksort(int array[], int left, int right)
{
    //Do nothing if left <= right
    if(left<right)
    {
        //p <- Get a number from array
        int p=array[left];
        //Put elements < p to the left side
        //Put elements >= p to the right side
        int i=left,j;
        for(j=left+1;j<=right;j++)
        {
            if(array[j]<p)
            {
                i++;
                swap(array,i,j);
            }
        }
        //Put p in the middle slot which index is pivot
        swap(array,i,left);
        //Recursive quicksort the left parts and right parts
    }
}

Step 4:容易發現之前所做的步驟是一個劃分數組的過程,爲了便於理解,可以把它提升成一個函數。與此同時遞歸的快排左右兩部分就可以了。

void partition(int array[],int left, int right)
{
    //p <- Get a number from array
    int p=array[left];
    //Put elements < p to the left side
    //Put elements >= p to the right side
    int i=left,j;
    for(j=left+1;j<=right;j++)
    {
        if(array[j]<p)
        {
            i++;
            swap(array,i,j);
        }
    }
    //Put p in the middle slot which index is pivot
    swap(array,i,left);
    return i;
}
void quicksort(int array[], int left, int right)
{
    //Do nothing if left <= right
    if(left<right)
    {
        int pivot=partition(array,left,right);
        //Recursive quicksort the left parts and right parts
        quicksort(array,left,pivot-1);
        quicksort(array,pivot+1,right);
    }
}

Step 5: 最後套一個wrapper,就完成了一個基本的qsort

void q_sort(int array[], int size)
{
    quicksort(array, 0, size-1);
}
當然上面的步驟省去了很多的測試及調試過程,之前我也提到過了,我做不到一下子寫一個正確的快排,但一步一步來的話,還是可以寫出來的。 接下來回到標題:quicksort能夠寫多短? 拿之前的C程序來說,把它弄短的途徑無非是展開函數,去除大括號等山寨方法。

Step 1:展開partition函數並去除註釋

void quicksort(int array[], int left, int right)
{
    if(left<right)
    {
        int p=array[left];
        int pivot=left,j;
        for(j=left+1;j<=right;j++)
        {
            if(array[j]<p)
            {
                pivot++;
                swap(array,pivot,j);
            }
        }
        swap(array,pivot,left);
        quicksort(array,left,pivot-1);
        quicksort(array,pivot+1,right);
    }
}

Step 2:去除臨時變量,大括號並把自增自減操作放在一個語句裏

void quicksort(int array[], int left, int right)
{
    if(left<right){
        int pivot=left,j;
        for(j=left+1;j<=right;j++)
            if(array[j]<array[left])
                swap(array,++pivot,j);
        swap(array,pivot,left);
        quicksort(array,left,pivot-1);
        quicksort(array,pivot+1,right);
    }
}

Step 3:利用C的指針算術,去掉多餘的參數

void quicksort(int *array, int n)
{
    if(n>1){
        int pivot=0,j;
        for(j=1;j<n;j++)
            if(array[j]<array[0])
                swap(array,++pivot,j);
        swap(array,0,pivot);
        quicksort(array,pivot);
        quicksort(array+pivot+1,n-pivot-1);
    }
}
這樣的話可以把原本20多行的快排縮減到10行,但是這樣有什麼意義呢,程序的可讀性大爲下降,而且運行效率也沒有絲毫的提升。此外,指針算術很可能會導致各種越界錯誤。 況且C語言的話,再短也短不了多少了,接下來可以看看快排在其它的語言中的實現,鑑於Java,C#之類的語言實際上和C是一個系列的(都是基於Von-Neuman體系的Imperative Language)。我來展示如何用Declarative Language中來編寫quicksort,在這裏我使用Scheme(函數式語言)和Python(腳本語言)來演示。

接下來展示如何用Scheme編寫的quicksort

Scheme是MIT的Guy Steele和Jay Sussman開發的一種函數式編程語言,大名鼎鼎的Sicp(Structure and Intepretation of Computer Programs)和Htdp(How to design programs)採用的就是Scheme語言,它的語法非常簡單,但功能很強大,mit的6.001課程就選用scheme作爲計算機專業學生的入門語言。
;; define x as value 1
(define x 1)
;; define l as a list of 1 2 3
(define x (list 1 2 3))
;; define f as a square function
(define (f x) (* x x))
;; define a function use lambda
(define f (lambda (x) (* x x)))
;; use a high-order function filter, will return (list 1 2 3)
(filter (lambda (x) (<= x 3)) (list 1 2 3 4 5))
關於Scheme語言的更多內容可以參考The little schemer或是網上的specification,在這裏就不贅述了。

讓我們回到快速排序這個話題,按照之前的思路,首先寫一下僞碼:

;; Sort a number list via quicksort algorithm
;; list of numbers -> list of numbers
(define (q-sort l)
  ;;get a number p from l
  ;;get numbers<=p from l-{p} as small part
  ;;get number>p from l-{p} as bigger part
  ;;recursively quicksort on small part and bigger part
  ;;combine small part, p, bigger part together as the sorted list
  )

Step 1: 首先獲得序列中的某個數,在這裏取第一個數

;; Sort a number list via quicksort algorithm
;; list of numbers -> list of numbers
(define (q-sort l)
  ;;get a number p from l
  (let ((p (first l)))
      ;;get numbers<=p from l-{p} as small part
      ;;get number>p from l-{p} as bigger part
      ;;recursively quicksort on small part and bigger part
      ;;combine small part, p, bigger part together as the sorted list
     )
  )

Step 2: 從序列中提取出比標記值小的一攤數和大的一攤數,然後對其進行遞歸的快排調用

;; Sort a number list via quicksort algorithm
;; list of numbers -> list of numbers
(define (q-sort l)
  (cond
    [(empty? l) empty]
    [(empty? (rest l)) (list (first l))]
    [else
    ;;get a number p from l
    ;;get numbers<=p from l-{p} as small part
    ;;get number>p from l-{p} as bigger part
     (let ((small-part (filter (lambda (x) (<= x (first l))) (rest l)))
           (big-part (filter (lambda (x) (> x (first l))) (rest l))))
        ;;recursively quicksort on small part and bigger part
       )]
    )
  )

Step 3:爲這個遞歸函數加上終止條件

;; Sort a number list via quicksort algorithm
;; list of numbers -> list of numbers
(define (q-sort l)
  (cond
    [(empty? l) empty]
    [(empty? (rest l)) (list (first l))]
    [else
    ;;get a number p from l
    ;;get numbers<=p from l-{p} as small part
    ;;get number>p from l-{p} as bigger part
     (let ((small-part (filter (lambda (x) (<= x (first l))) (rest l)))
           (big-part (filter (lambda (x) (> x (first l))) (rest l))))
        ;;recursively quicksort on small part and bigger part
       (append (q-sort small-part)
               (list (first l))
               (q-sort big-part)))]
    )
  )
可以發現scheme程序的一大特點就是聲明性,你只需告訴它what to do,而C程序的話則強調How to do,實際上,上面的程序根本不需要註釋,它自己的代碼已經足夠說明自己的用途了。

最終的Scheme程序:

;; Sort a number list via quicksort algorithm
;; list of numbers -> list of numbers
(define (q-sort l)
  (cond
    [(empty? l) empty]
    [(empty? (rest l)) (list (first l))]
    [else
     (let ((small-part (filter (lambda (x) (<= x (first l))) (rest l)))
           (big-part (filter (lambda (x) (> x (first l))) (rest l))))
       (append (q-sort small-part)(list (first l))(q-sort big-part)))]
    )
  )

最後我們看看如何用Python來構建一個快速排序:

需要注意的是,Python和前面的C、Scheme語言不一樣,它是一個multi-paradigm programming language,換句話說,用python既可以procedural programming ,也可以oop甚至是fp。 首先用C的思想來寫一個python版的快排:

Step 1: 列出僞代碼,要實現的步驟

def q_sort(l):
    #get first number p from l
    #move elements<p to the left side
    #move elements>=p to the right side
    #recursively quicksort left and right part
    #combine them together

Step 2: 進一步細化,這裏利用了Python支持在函數內部定義函數的性質

def q_sort(l):
    def quicksort(l,left,right):
        #get first number p from left end
        #move elements<p to the left side
        #move elements>=p to the right side
        #recursively quicksort left and right part
        #combine them together
    quicksort(l,0,len(l)-1)

Step 3: 再一步細化移動list元素的過程,就完成了這個函數,Python支持多重賦值,所以交換元素非常方便

def q_sort(l):
    def quicksort(l,left,right):
        if right>left:
            #get first number p from left end
            pivot,j,tmp=left,left+1,l[left]
            #move elements<p to the left side
            #move elements>=p to the right side
            while j<=right:
                if l[j]<tmp:
                    pivot=pivot+1
                    l[pivot],l[j]=l[j],l[pivot]
                j=j+1
            l[left],l[pivot]=l[pivot],l[left]
            #recursively quicksort left and right part
            quicksort(l,left,pivot-1)
            quicksort(l,pivot+1,right)
    quicksort(l,0,len(l)-1)
python有一個很好用的特性就是list comprehension,利用這個特性可以寫出聲明性很強的代碼,比如說:
# Get all even numbers between 1 and 100
[x for x in range(1,101) if x%2==1]
# Get all line which start with 'From'
[line for line in lines if line.startwith('From')]

我們可以直接使用這個特性來構建一個更易懂的quicksort,回到之前的第一步:

def q_sort(l):
    #get first number p from l
    #move elements<p to the left side
    #move elements>=p to the right side
    #recursively quicksort left and right part
    #combine them together

Step 2: 利用python的list slice和list comprehension,很容易得到比標記值大和小的兩部分,然後把它們拼接起來即可

def q_sort(l):
    #get first number p from l
    p=l[0]
    #move elements<p in l-{p} to the left side
    small_part=[x for x in l[1:] if x<p]
    #move elements>=p in l-{p} to the right side
    big_part=[x for x in l[1:] if x>=p]
    #recursively quicksort left and right part and combine them together
    return q_sort(small_part)+[p]+q_sort(big_part)
執行程序,oops,進入死循環了。

Step 3: 增加終止條件

def q_sort(l):
    if len(l)<=1:
        return l
    else:
        #get first number p from l
        p=l[0]
        #move elements<p in l-{p} to the left side
        small_part=[x for x in l[1:] if x<p]
        #move elements>=p in l-{p} to the right side
        big_part=[x for x in l[1:] if x>=p]
        #recursively quicksort left and right part and combine them together
        return q_sort(small_part)+[p]+q_sort(big_part)
這次運行結果正常了,需要注意的是,這個q_sort並不是進行原地排序,而是不斷的生成新的list,當list的元素很多時會對性能造成很大的負面影響,這裏只是爲了演示python的特性才這麼寫。 如何把它變得更短呢?首先去掉註釋試試

Step 4: 去除註釋

def q_sort(l):
    if len(l)<=1:
        return l
    else:
        p=l[0]
        small_part=[x for x in l[1:] if x<p]
        big_part=[x for x in l[1:] if x>=p]
        return q_sort(small_part)+[p]+q_sort(big_part)
去除註釋之後的q_sort函數只剩下8行了,還可以更短些嗎?答案是肯定的。 注意到q_sort裏面用了不少的臨時變量,可以把它們去除掉

Step 5: 去除臨時變量

def q_sort(l):
    if len(l)<=1:
        return l
    else:
        return q_sort([x for x in l[1:] if x<l[0]])+[l[0]]+q_sort([x for x in l[1:] if x>=l[0]])
只剩下5行了!

Step 6: 利用Python的3元表達式 if else 來改寫上面的函數,注意此處的邏輯是相同的

def q_sort(l):
    return l if len(l)<=1 else q_sort([x for x in l[1:] if x<l[0]])+[l[0]]+q_sort([x for x in l[1:] if x>=l[0]])
現在只剩下2行了,需要注意的是python也提供了lambda表達式,因此q_sort可以被進一步簡化:

Final Step: 利用lambda表達式,再次簡化

q_sort= lambda l: l if len(l)<=1 else q_sort([x for x in l[1:] if x<l[0]])+[l[0]]+q_sort([x for x in l[1:] if x>=l[0]])
一個Quicksort究竟可以寫到多麼短呢?上面的代碼給出了答案,1行就足夠了。

總結:

我們可以看到不同的語言在處理同樣的問題時所採用的方案也是截然不同的,C語言強調的是how to do,而Scheme、Python則強調的是what to do。使用C的話要注重細節,因爲每一個小失誤都可能會使程序down掉,這是imperative language的通性,而諸如scheme、python這樣的語言,所注重的則不是實現的細節,而是解決問題的邏輯,我們只需要告訴電腦what to do,而compiler將會爲你生成對應的代碼,這樣我們就不需要把大把的精力花費在調試程序的細節方面了。

一些人認爲程序語言只是表述思想的一種方式,換句話說,他們認爲語言無關緊要,重要的是所謂的思想。甚至有一些人認爲掌握一門主流語言(諸如C/C++/Java/C#等)就足夠了,因爲語言都是相通的。 的確,C語言的熟手學Java語法,可能也就是兩三天的功夫,而C++的熟手學C#的使用的話頂多就是一上午不到。但需要注意的是,知道一門語言的語法和熟練使用一門語言是很不一樣的,每一種語言都有它獨到的一面,而這些成分需要使用此語言相當長的時間才能夠體會到。

Steve McConnel曾說過,要Program into a language而不是Program on a language[4],打個比方,假設我熟悉C,然後花了兩天學會了Java的語法,但這不代表我就能寫出像樣的Java程序了,因爲我不知道GC,不知道Spring, Hibernate等一系列框架,也不熟悉OOP。 換個說法,我們從初中開始學英語學到現在也有十年多了,但我們說的English還是被外國人稱爲Chinglish,why?我認爲這不是我們發音不夠好或是不夠流利,而是我們一直都是thinking in the Chinese way,在這種情況下是不可能說出純正的English來的。

所以我認爲諸如語言無關論的觀點都是錯誤的,一方面持有這些觀點的人的眼光一般很狹窄,只是限定在命令式程序設計語言語言的圈子裏(因爲根本不知道其它範式的程序設計語言的存在,所以他們自然認爲所有語言都是相通的了);另外一方面,這些觀點的持有者認爲語言並不會影響思想,但實際上並不是如此,一個Lisp程序員和一個C程序員去處理同一個問題的思路肯定相差甚遠,拋開程序設計語言,即使是不同的語言,比如說漢語和英語,也會影響人的思路(著名的Sapir-Whorf假設),這也就是我們常說的東方人思維和西方人思維的產生原因之一[5]。

Eric Raymond在他的How to become a hacker一文中就曾提到過作爲一個Hacker應該掌握Python, C/C++, Java, Perl這5種語言[6],因爲它們各自代表了一種截然不同的編程方式:
It's best, actually, to learn all five of Python, C/C++, Java, Perl, and LISP. Besides being the most important hacking languages, they represent very different approaches to programming, and each will educate you in valuable ways.
Peter Norvig則更加直接[7]:
Learn at least a half dozen programming languages. Include one language that supports class abstractions (like Java or C++), one that supports functional abstraction (like Lisp or ML), one that supports syntactic abstraction (like Lisp), one that supports declarative specifications (like Prolog or C++ templates), one that supports coroutines (like Icon or Scheme), and one that supports parallelism (like Sisal)
所以他說纔會說Teach Yourself Programming in Ten Years

References

[1] The practice of Programming. Brian W Kernighan and Rob Pike. Addison Wisley [2] How to design programs. MIT Press. [3] Learning Python 3rd edition. O'Reilly Press [4] Code complete 2nd edition. Microsoft Press [5] Linguistic Relativity. Wikipedia [6] How to become a hacker. Eric S Raymond [7] Learning programming in Ten Years. Peter Norvig

發佈了14 篇原創文章 · 獲贊 6 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章