快速排序是目前基於關鍵字的內部排序演算法中平均效能最好的,它採用了分治策略,這既是快速排序的優點也是它的缺點。從快速排序的演算法描述上我們可以發現它具有遞迴的結構:
- (1)確定一個分界,將待排序的陣列分為左、右兩個部分;
- (2)使所有小(大)於臨界值的資料移到左部分,大(小)於臨界值的資料移到右部分;
- (3)這時左、右兩個部分成為了兩個獨立的陣列,分別對它們執行(1)(2)(3)的操作,直到所有資料都是有序的狀態為止。
照這樣的描述我們不難寫出快排的程式碼,我平時遇到排序的問題,只要資料量上了100,想都不想就用快排來解決,但是當我用下面這個程式測試時卻出現了問題:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
#include <stdio.h> #include <time.h> #include <stdlib.h> #define NUM 10000000 /*待排序的資料量*/ void quick_sort(double a[], long left, long right); int main(void) { clock_t t_s, t_e; long i; double a[NUM]; srand(time(NULL)); for (i = 0; i < NUM; ++i) { a[i] = rand(); } t_s = clock(); quick_sort(a, 0, NUM-1); t_e = clock(); double t = (t_e - t_s) / (double)CLOCKS_PER_SEC; /*計算排序用時*/ printf("Quick sort %d items used time:%f s\n", NUM, t); return 0; } void quick_sort(double a[], long left, long right) { long i = left; long j = right; double mid = a[(i + j) / 2]; /*以中間元素作為比較的基準*/ while (i <= j) { while (a[i] < mid) ++i; while (mid < a[j]) --j; if (i <= j) { double t = a[i]; a[i] = a[j]; a[j] =t; ++i; --j; } } if (i < right) quick_sort(a, i, right); if (left < j) quick_sort(a, left, j); } |
我在Linux上執行這個程式出現了”Segmentation fault “錯誤,而當NUM==1000000時卻沒有這個錯誤。查閱相關資料得知這是由於程式遞迴次數太多,大量的壓棧使程式佔用的棧空間超過了作業系統所規定的大小,從而出現的記憶體錯誤。
我用ulimit -s指令的得到的結果是8192,也就是說我的系統預設給每個程式分配的大概是8M的棧空間。用指令ulimit -s unlimited使棧空間變成實際記憶體大小後,上面的程式就可以順利執行而不出錯誤了(因為Linux上不像Windows可以把棧的大小寫入可執行檔案中,所以只能用ulimit -s更改的方法了)。
難道因為棧的限制,快速排序能夠處理的資料量就有上限了嗎?那還不如用選擇排序——雖然慢,但至少不會出錯,於是我找到了這篇文章:快速排序的非遞迴實現。其實說是“非遞迴”,只不過是用自己管理的棧來消除遞迴,演算法本質上沒有區別,而且從這篇文章作者的測試來看,用棧的方法比用遞迴的方法反而更慢(作者將其解釋為:“用棧的效率比遞迴高,但是在這個程式中區域性變數也就是要每次壓棧的資料很少,棧的優勢體現不出來,反而更慢……”,我認為這種觀點是不對的,由於遞迴可以理解為有了一個“系統幫你自動管理的棧”,它的效率肯定是要比你自己管理的棧要高的,況且你在進行彈棧和壓棧操作時又呼叫了新函式,算上呼叫的開支,用棧的方法肯定比遞迴慢),不過棧在這裡的優勢是可以不用考慮作業系統的問題,而且能夠處理的資料量只和記憶體大小有關,不必受到作業系統對棧空間大小的限制(即使用棧,快排也比很多排序演算法要快得多)。
以前在學排序演算法的時候,專門有講怎樣根據實際問題來選擇合適的排序演算法,但是我圖“省事”,就只用快排和簡單選擇排序。遇到了這個問題也讓我對演算法的選擇和實現上有了更多認識,同時也瞭解到用棧消除遞迴在有些場合(比如系統棧空間受限)的重要意義。
前面我說到所謂的“非遞迴”快速排序演算法,不過是用棧來消除了遞迴,它的執行時間肯定比遞迴演算法長,我們不妨來實際實現一下。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
#include <stdio.h> #include <stdlib.h> #include <time.h> #define MAX_TOP 10000 /*一個很大的棧*/ #define NUM 500L /*有關棧的資料結構*/ struct Region { long left; long right; }; struct Stack { struct Region reg[MAX_TOP+1]; long top; }; /*對棧進行操作的函式*/ void init_stack(struct Stack *s); void push_stack(struct Stack *s, struct Region r); struct Region pop_stack(struct Stack *s); int is_stack_empty(struct Stack *s); /*與排序有關的函式*/ long partition(double a[], long left, long right); /*劃分割槽間*/ void nr_qsort(double a[], long left, long right); int main(void) { double a[NUM]; /*待排序資料*/ clock_t t_s, t_e; long i; srand(time(NULL)); for (i = 0; i < NUM; ++i) a[i] = rand() % 1000000; /*統計執行時間*/ t_s = clock(); nr_qsort(a, 0, NUM-1); t_e = clock(); double t = (t_e - t_s) / (double) CLOCKS_PER_SEC; printf("Non Recursive quick sort %ld items used time: %f s\n", NUM, t); return 0; } /*implementation*/ void init_stack(struct Stack *s) { s->top = -1; } void push_stack(struct Stack *s, struct Region r) { if (s->top == MAX_TOP) { fprintf(stderr, "Stack overflow!\n"); exit(0); } s->reg[++s->top] = r; } struct Region pop_stack(struct Stack *s) { if (s->top == -1) { fprintf(stderr, "Stack underflow!\n"); exit(0); } return (s->reg[s->top--]); } int is_stack_empty(struct Stack *s) { return (s->top == -1); } /*返回劃分的區間*/ long partition(double a[], long left, long right) { double base = a[left]; /*以最左邊的元素作為比較基準*/ while (left < right) { while (left < right && a[right] > base) --right; a[left] = a[right]; while (left <right && a[left] < base) ++left; a[right] = a[left]; } a[left] = base; return left; } void nr_qsort(double a[], long left, long right) { struct Stack s; struct Region region, regionlow, regionhi; long p; /*記錄劃分出的分界點*/ init_stack(&s); region.left = left; region.right = right; push_stack(&s, region); while (!is_stack_empty(&s)) { region = pop_stack(&s); p = partition(a, region.left, region.right); if (p-1 > region.left) { regionlow.left = region.left; regionlow.right = p - 1; push_stack(&s, regionlow); } if (region.right > p + 1) { regionhi.left = p + 1; regionhi.right = region.right; push_stack(&s, regionhi); } } } |
在程式碼的第110行至第122行的while迴圈中,做的正是用棧消除遞迴的工作。想想遞迴的演算法中,把劃分好的左右區間界限(即left,right)儲存到了系統管理的棧中,這裡手動把每次劃分出來的區間分界儲存至棧中,當第113和118行的兩個條件不滿足時,所在區間的元素都是有序的狀態,此時不進行壓棧操作而向前返回(即遞迴的回撥)。關於用棧消除遞迴的演算法可以參考關於資料結構的書籍,比如陳銳的《零基礎學資料結構》有關棧的那一章就有介紹。實際執行兩個程式的結果如下:
1 2 3 4 |
$ ./nr_qsort #非遞迴演算法的快排 Non Recursive quick sort 500 items used time: 0.000261 s $ ./qsort #遞迴演算法的快排 Quick sort 500 items used time:0.000104 s |
之所以只用了500個資料,是因為超過1000個資料後,非遞迴快排的速度就慢的令人難以忍受。下面是另外兩次關於遞迴演算法快排的測試:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
$ time ./qsort Quick sort 1000000 items used time:0.289171 s real 0m0.372s user 0m0.332s sys 0m0.012s #下面更改NUM即資料的個數為10000000 $ ./qsort Segmentation fault #超出棧的大小 $ ulimit -s unlimited #更改棧的大小為不受限 $ time ./qsort Quick sort 10000000 items used time:3.259025 s #成功進行了排序 real 0m4.044s user 0m3.740s sys 0m0.172s |
這也印證了之前談到的系統預設限制帶來的問題。