在处理一些基础运算的时候需要用到排序, 顺便整理一下排序的几种方法
1. 冒泡排序。(Bubble sort) 这个最基本的方法就是模拟水泡的运动轨迹来的, 每次都把水泡(最小元素)向上做漂浮。到最后就是一个排序完成的数组了, 下面是C++的实现。可以比较明显的看出时间复杂度在两个for (n)的循环中, 所以平均的时间复杂度是 O(n²),而空间复杂度比较优秀, 就是O(1)。
- template <class T> void bubbleSort(T a[], int n)
- {
- int i, j = 0;
- T temp;
- for ( i = 0; i < n - 1; i++)
- {
- for(j = n-1; j > i ; j--)
- {
- if (a[j] < a[j-1])
- {
- temp = a[j];
- a[j] = a[j-1];
- a[j-1] = temp;
- }
- }
- }
- }
2. 选择排序。(Selection sort) 这个排序和冒泡法稍有不同,先说相同点,同样是2个n的for循环,同样是一个循环找到一个最小值,但是区别在于,冒泡法每次不管怎样都在不挺的做赋值操作来做搬运,二选择排序是每次循环找到一个最小值,然后再把最小值和对应位置的值做交换。代码上看的比较明显。
- template <class T> void selectionSort(T a[], int n)
- {
- T min;
- int i,j,index;
- for(i=0;i<n-1;i++)
- {
- index = i;
- min= a[i];
- for(j=i+1;j<n;j++) //select the min of the rest of array
- {
- if(min > a[j])
- {
- index = j;
- min = a[j];
- }
- }
- // if index == i, we needn't move
- if (index != i)
- {
- T temp = a[i] ;
- a[i] = a[index];
- a[index] = temp;
- }
- }
- }
可以看出这个swap的操作从二次循环的内部搬到了一次循环,减少了移动的数量,这是选择排序的最大贡献。时间复杂度方面还是O(n²),空间的话还是O(1)。
3. 插入排序。(Insertion sort) 这个排序呢就是把每个数组中的元素循环的插入一个已经排序完成的数列中。就像打牌的时候每次摸到一张牌就插入到一个已经排序完成的牌中。这个排序的想法是比较直观的,但是问题在于移动的频率还是很高。
- template<class T> void insertionSort(T a[], int n)
- {
- int i, j;
- T tmp;
- for (i = 1; i < n; i++) {
- tmp = a[i];
- for (j = i-1; j >= 0 && tmp < a[j]; j--)
- a[j+1] = a[j];
- a[j+1] = tmp;
- }
- }
这个实现比较恶心的地方在于其实移动的频率还是很高, 如果原先的数列是个到排序的,那移动的数量就达到最大。可以看到这次的swap实际上还是放在了2次循环的内部,移动次数可想而知。
4. 希尔排序。 (Shell sort) 这是一个值得考虑考虑的排序方法,他首先把整个数列分成若干份,分别在插入排序,接着把这个份数缩小,继续做插入排序.一直做到这个份数为一为止.
- template<class T> void shellSort(T a[], int n)
- {
- // 2.2 is the so-called magic number.
- // it comes from the array 1, 4, 10, 23, 57, 132, 301, 701, 1750.
- int i, j;
- T temp;
- for (int inc = n / 2; inc > 0; inc = (inc == 2 ? 1 : (int)(inc/2.2)) )
- {
- for (i = inc; i < n; i++) {
- temp = a[i];
- for (j = i; j >= inc && a[j - inc] > temp; j -= inc){
- a[j] = a[j - inc];
- }
- a[j] = temp;
- }
- }
- }
从这个代码可以清晰地看出其中的端倪,inc就是所谓的份数.开始的时候很大,后来慢慢变小.据统计, 1, 4, 10, 23, 57, 132, 301, 701, 1750.这样的份数选择是比较高效的,所以才得到了2.2这样一个Magic NumberJ. 这个算法的想法基于减少插入排序移动次数为目的, 每次分份做插入排序即是如此.时间复杂度方面.这个比较难以计算.看到一个数据是 O(n*log²n).当然空间复杂度就是我们的那个temp,也就是O(1).
5. 归并排序 (Merge sort). 其实没有仔细查证中文翻译的确定性因为从开始学这个就是用英文的. (算法导论真是厉害啊).这个算法用到了经典的所谓Divide-and-Conquer的方式. 指的是将两个已经排序的序列合并成一个序列的操作.把一个大数列分成2个字数列,把已经排序好的字数列排序.在处理字数列的排序时同样再次分割出新的字数列,周而复始.所以这个算法最好的表示方法我想应该是递归.
经典的merge sort的代码类似于
- template < class T>
- void mergeSort(T a[] ,int n)
- {
- int i, j, k;
- T tmp;
- // if the number is small, insertion sort is fast enough
- if (n < 64) {
- for (i = 1; i < n; i++) {
- tmp = a[i];
- for (j = i-1; j >= 0 && tmp < a[j]; j--)
- a[j+1] = a[j];
- a[j+1] = tmp;
- }
- return;
- }
- int f = n / 2;
- mergeSort(a, f);
- mergeSort(a+f, n-f);
- /* Merge */
- T *s = new T[n];
- for (i = 0, j = f, k = 0; i < f && j < n;)
- s[k++] = (a[i] < a[j]) ? a[i++] : a[j++];
- while (i < f) s[k++] = a[i++];
- while (j < n) s[k++] = a[j++];
- for (i = 0; i < n; i++)
- a[i] = s[i];
- delete[] s;
- }
这个做了一点小小的改动, 实际上如果这个数列的长度不是很大的话, 插入排序都足够应付了.所以这个实现里面就没有采用全部的归并排序. 如果要启用的话就去掉插入排序的那段即可.(当然需要加上一个递归的收敛条件), 这里的时间复杂度就成了分治法里面的一个经典的公式T(n) = T(n/2)+T(n/2)+Θ(n),通过通过计算可以得出时间复杂度是O(n*log(n)).应该说这个稳定的排序算法还是把时间复杂度提高了一个台阶的.但是就像事情都是有利有弊的一样,这里的空间复杂度比较明显T*s = new T[n];这里分配了一个n的长度的数组来存放中间值,所以空间复杂度高达O(n).
6.堆排序 (Heap sort) 堆排序还是比较有意思的一个排序,因为从这里开始我们引入了一个数据结构。排序的工作(或者在这里应该可以说寻找最大最小值)的工作可以由这个数据结构来完成。堆是一个完全二叉树,但是和搜索树不一样的在于他的节点的值的大小关系在于父亲和子女。以最大堆为例,那就是父亲节点总是大于孩子节点。这样宽松的限制对于找到最大(小)值是有利的。
不废话了, 代码才是王道J
- template<class T> void siftDown(T a[], int i, int bottom)
- {
- T temp;
- int maxChild;
- bool done = false;
- /*
- here need to double check, the left, right child of i is not 2*i,2*i+1.
- the array starts from 0, so the exactly kid is 2*i+1,2*i+2 :)
- */
- while (!done)
- {
- if ( i*2+1 < bottom && a[i*2+1] > a[i])
- maxChild = i*2 + 1;
- else
- maxChild = i;
- if (i*2+2 < bottom && a[i*2+2] > a[maxChild])
- maxChild = 2*i + 2;
- if (a[i] < a[maxChild])
- {
- temp = a[i];
- a[i] = a[maxChild];
- a[maxChild] = temp;
- i = maxChild;
- }
- else
- done = true;
- }
- }
- template<class T> void heapSort(T a[], int n)
- {
- int i;
- T tmp;
- // heapify
- for (i = (n / 2)-1; i >= 0; i--)
- siftDown(a, i, n);
- // find the smallest elements and re-heapify
- for (i = n-1; i >= 1; i--)
- {
- tmp = a[0];
- a[0] = a[i];
- a[i] = tmp;
- siftDown(a, 0, i); // here it's i but not i-1, fix it now!!
- }
- }
总的来看这里的流程就是首先得到数组,给一半的数组元素进行建堆的操作。这里为什么是一半呢?其实很简单,我们的遍历只想覆盖到有子节点的那些个节点,如果没有子节点就根本不需要做这样的操作了。经历这些循环我们就可以得到一个最小堆。当然这个了以后就是拿出最小值,把剩余的元素重新做找最小元素的操作循环到结束。
复杂度方面,先看空间,那很显然, 就是O(1),因为没有额外申请什么空间。时间方面的话应该说这不是一个稳定的排序,复杂度接近O(n*log(n))。
7.快速排序(Quick sort)说实话这个排序让我们想到了归并排序, 因为Divide and Conquer的想法贯穿在这个算法中,但是比之归并排序的优点在于他不用申请额外的O(n)的空间.现在来看这个算法的想法. 首先随机选取数组中的一个元素作为支点(Pivot),不知道我这个翻译合适不合适.类似的意思就是把比pivot小的元素移到它的左边,大于他的元素移到右边.然后继续在左半边和右半边再次寻找新的pivot分别再做这样的操作, 就是这样递归操作把数组完成排序。
- template<class T>
- int partition( T a[], int l, int h)
- {
- int i = l;
- T pivot = a[h]; // let's just choose last element as pivot
- // the optimization could be how to find the best pivot.
- for (int j=l;j<=h-1;j++)
- {
- if (a[j]<= pivot)
- {
- T temp = a[i];
- a[i] = a[j];
- a[j] = temp;
- i ++;
- }
- }
- a[h] = a[i] ;
- a[i] = pivot;
- return i;
- }
- template <class T>
- void quick_sort(T a[], int l, int h)
- {
- if (l >= h) return;
- // low < high
- int p= partition(a, l, h);
- quick_sort(a, l, p-1);
- quick_sort(a, p+1, h);
- }
- template <class T>
- void quickSort(T a[], int n)
- {
- quick_sort(a, 0, n-1);
- }
这里用到了递归,所以把外部的调用又再其中转给了一个递归函数。
这个算法有一个明显的问题在于如果首次选择的pivot非常差, 比如他恰好是数组中最小的值,他效率就会编程非常低下了,就上面的实现而言(选取最后一个元素作为pivot), 如果这个数组是倒排序的, 那么移动的次数就变成了n-1+n-2+n-3+ ….+ 3+2+1,毫无疑问,那是一个O(n²),但是如果我们选取的数字每次都是中位数的话, 那这个问题的复杂度统计又变成了T(n) = T(n/2)+T(n/2)+Θ(n) (所以说他是经典公式啊,看到2次了)。 当然这样的复杂度就是O(n*log(n)).所以说这个一个不稳定算法,最差的时间复杂度达到O(n²), 但是如果我们稍作优化的话,还是稳定在O(n*log(n))。至于空间的话, 不用说就是O(1)。