排序算法总结

在实际中待排序的数很少是单独的数值,它们通常是被称为记录的数据集的一部分。每个记录包含一个关键字,就是排序问题中要重排的值。记录的剩余部分由卫星数据组成,通常与关键字是一同存取的。如果每个记录包含大量的卫星数据,我们通常重排记录指针的数组,而不是记录本身,这样可以降低数据移动量。如果输入数组中仅有常量个元素需要在排序过程中存储在数组之外,则称排序算法是原址的。


以下排序算法的原理并不做过多介绍(网上太多了),只介绍个人认为比较重要的


1.插入排序

插入排序是很符合直观认识的一种排序,我们排列手中扑克牌的方法实际就是插入排序。

伪代码

for i = 2 to A.length
      key = A[i]
      j = i-1
      while j>0 and A[j]>key
            A[j+1] = A[j]
            j = j-1
      A[j+1] = key

这里我们保证A[1...i-1]始终是以排序的,这就是本段代码的循环不变式写代码之前构造一个不变式,写的过程中,大脑中始终清醒的记住并维护这一不变式,可以帮助我们更好的书写代码。本段代码中的不变式简单分析如下

初始化:这里在循环之前,我们认为A[1]是已排序的,也就是循环不变式是满足的。

保持:循环过程中,比A[i]大的数依次后移,相对顺序并没有被破坏,比A[i]小的数保持原状,A[i]插入到相应位置后,A[1...i]仍然是排好序的。

终止:因为循环的过程中,不变式始终是保持的,那么循环结束后整个数组就是已排序的。

上面是对于数组的情形,有时候可能还会对链表进行插入排序,比如leetcode上147题

147. Insertion Sort List

Sort a linked list using insertion sort.

代码如下(仅供参考)

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
public class Solution {
    public ListNode insertionSortList(ListNode head) {
        if(head == null || head.next == null) return head;
        ListNode tmpHead = new ListNode(Integer.MIN_VALUE),tmp = head,pre = tmpHead;
        tmpHead.next = head;
        while(tmp != null){
            ListNode j = tmpHead.next,prej = tmpHead;
            while(j != tmp && tmp.val >= j.val) {prej = j; j = j.next;}
            if(j != tmp){
                pre.next = tmp.next;
                ListNode tt = tmp.next;
                prej.next = tmp;
                tmp.next = j;
                tmp = tt;
            }else{
                pre = tmp;
                tmp = tmp.next;
            }
        }
        return tmpHead.next;
    }
}
这里的思路与数组插入在思想上是一致的,但是由于是单链表不能像数组那样随机访问,因此只能从前向后比较,查找插入位置。上面代码中采用了一个小技巧,插入一个新的tmpHead,其值为整数最小值,这样可以避免进行head节点的判断。前面提到的不变式同样适用这里的代码。除此之外,在遍历到tmp节点,并在前面查找插入位置时,可以认为这里有第二个不变式,就是tmp始终满足  prej.val <= tmp.val < j.val 或者 j == tmp。循环的过程中可以保证这个不变式始终是满足的,读者可以自行证明。这也算是书写代码的技巧吧,可以保证思路清晰。

时间复杂度

我们来看一下插入排序的最坏情况,对于数组而言,最坏情况发生在数组逆序的时候,这时前面的所有元素都要进行比较移动,时间复杂度为O(n^2)。最好情况发生在数组本身已经排好序的时候,这时每个元素只与其前面的一个元素进行比较依次,时间复杂度为O(n).平均情况下需要N^2/4次比较和赋值操作(大约)。

空间复杂度

插入排序属于原址排序,空间复杂度为常量级。

稳定性

插入排序是稳定的


2.选择排序

选择排序比插入排序还要简单,其思想是每次从剩余的所有元素中找出一个最小的作为当前最小值,与插入排序不同,选择排序是先选择位置再选择元素,插入排序则是先选择元素再选择位置。

伪代码

for i = 1 to A.length-1

      min = i

      for j = i+1 to A.length

            if A[j] < A[min]

                  min = j

       swap(A,i,min);

时间复杂度

前面提到过,对于插入排序,最好情况下,也就是元素排好序的情况下,时间复杂度为O(n),但是对于选择排序,仍然是O(n^2). 事实上,不论元素顺序如何,选择排序都要进行O(n^2)次比较,这是对于后面元素的最小值只有全部比较之后才能得出,而且选择排序并没有利用之前已排序的信息。

空间复杂度

选择排序仍然是原址排序,空间复杂度为常量级。

稳定性

选择排序是不稳定的,比如下面这种情况【6A,6B,3,6C】:

第一次选择一个最小的3与6A进行交换,变成【3,6B,6A,6C】,这时第一个6(6A)交换到了6B,6C的中间,这使得我们无法区分它们的相对顺序。


3.冒泡排序

冒泡排序的思想相对前两者稍微复杂了一点,但是也比较简单。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越大的元素会经由交换慢慢“浮”到数列的顶端,故名。

伪代码

for i = 1 to A.length

      for j = 1 to A.length-i

            if(A[j]>A[j+1]) swap(A,j,j+1);

冒泡排序每一轮循环会把一个最大值“浮”到顶端,这个过程相当于选择排序中每次选择一个最大元素放到相应的位置,同时每个元素交换到不如紧邻其后的元素大时便停止交换,这又类似于插入排序中给相应元素挪出位置时的操作。因此,从冒泡排序中我们看到了插入排序与选择排序的影子

时间复杂度

当元素本身已经排好序的情况下,需要进行O(n^2)次比较,但是不需要交换操作。当元素已排好序,但是逆序的情况下,需要O(n^2)次比较和交换操作。因此,在最坏的情况下,冒泡排序不如选择排序(交换次数多于选择排序);最好的情况下,基本上与选择排序一样。因此,选择排序整体上略优于冒泡排序。

对于为什么冒泡排序不会优于选择排序其实也好理解,首先冒泡排序每一轮循环做到把一个最大值选出来并放到相应的位置上,这与选择排序的效果是完全一致的,但是冒泡排序除了比较中间还进行了交换操作,但是选择排序则仅仅是比较,比较完成后至多进行一次交换操作。这就保证了冒泡排序效率不会高于选择排序。这一点也比较有意思,我们上面提到了冒泡排序包含了插入排序与选择排序的思想,一般一个算法如果融合了两个算法,那么它的效率一般要高于原来的两个算法,比如A*算法就是这样,但是冒泡排序例外^_^

空间复杂度

冒泡排序也是原址排序,不需要额外空间

稳定性

冒泡排序是稳定的,因为它只在相邻元素之间进行比较与交换操作,只要对相等的值不予交换就可以做到稳定性。


4.归并排序

归并排序是分治思想的典型应用。在前面介绍插入排序的时候,我们发现如果元素是已经排序的,那么算法效率就会很高,只要进行O(n)次比较。也就是说,元素越是有序的,效率就会越高。归并排序正是受到了这种启发。它对数组的两侧分别进行排序,然后再对整个数组进行排序,递归进行这样的过程。

Java代码如下

public static void merge(int[] a,int l,int r){
	if(l == r) return;
	int m = (l+r)>>1;
	int[] tmp = new int[r-l+1];
	int pl = l,pr = m+1,index=0;
	while(pl <= m && pr <= r){
		if(a[pl] < a[pr]) tmp[index++] = a[pl++];
		else tmp[index++] = a[pr++];
	}
	while(pl<=m) tmp[index++] = a[pl++];
	while(pr<=r) tmp[index++] = a[pr++];
	for(int i = 0;i<r-l+1;i++) a[i+l] = tmp[i];
}
public static void mergeSort(int[] a,int l,int r){
	if(l >= r) return;
	int m = (l+r)>>1;
	mergeSort(a,l,m);
	mergeSort(a,m+1,r);
	merge(a,l,r);
}

上面是针对数组形式的,在merge的过程中我们new了一个数组来暂时保存中间结果,最后再复制回原来的数组中。下面我们来看一下链表形式的归并排序。leetcode 148题:

148. Sort List

Sort a linked list in O(n log n) time using constant space complexity.

代码如下(仅供参考)

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
public class Solution {
    public ListNode sortList(ListNode head) {
        if(head == null || head.next == null) return head;
        ListNode tail = head;
        while(tail.next!=null) tail = tail.next;
        return mergeSort(head,tail);
    }
    
    public ListNode mergeSort(ListNode head,ListNode tail){
        if(head == tail) return head;
        ListNode slow = head,fast = head.next;
        if(fast!=null) fast = fast.next;
        while(fast != null){
            slow = slow.next;
            fast = fast.next;
            if(fast!=null) fast = fast.next;
        }
        ListNode left = slow.next;
        slow.next = null;
        tail.next = null;
        ListNode htmp = mergeSort(head,slow);
        ListNode stmp = mergeSort(left,tail);
        
        ListNode res = null;
        ListNode last = null;
        while(htmp!=null && stmp!=null){
            if(htmp.val <= stmp.val){
                if(res == null) {res = htmp; last = res;htmp = htmp.next;continue;}
                last.next = htmp;
                last = last.next;
                htmp = htmp.next;
            }else{
                if(res == null) {res = stmp; last = res;stmp = stmp.next;continue;}
                last.next = stmp;
                last = last.next;
                stmp = stmp.next;
            }
        }
        if(htmp == null) last.next = stmp;
        if(stmp == null) last.next = htmp;
        return res;
    }
}
对链表进行归并排序相比数组要复杂不少,主要原因一个是单链表不能像数组那样随机访问,因此在找中间位置的时候,没有数组方便,上面采用快慢指针来寻找中间位置。另一个原因是,对链表元素的修改很容易造成相互影响,需要细心处理。

时间复杂度
归并排序的时间复杂度为O(nlgn)。同样的,我们来看一下排好序的情况。已经按照升序排序时,根据我们上面merge的实现,已排序与未排序复杂度并没有什么不同,都需要遍历所有元素;逆序时也是一样的。因此,最坏情况下复杂度也是O(nlgn)

空间复杂度

merge的过程中需要额外空间,复杂度为O(n)

稳定性

稳定的,因为在归并的过程中,我们只要做到在两个元素相等时,先保存左边的值即可保证相对顺序不被打乱。

5.快速排序

它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列

Java代码:

public static int partition(int[] a,int l,int r){
	int p = l-1;
	for(int i = l;i < r;i++){
		if(a[i] <= a[r]){
			exchange(a,p+1,i);
			p++;
		}
	}
	exchange(a,r,p+1);
	return p+1;
}
public static void exchange(int[] a,int i,int j){
	int tmp = a[i];
	a[i] = a[j];
	a[j] = tmp;
}
public static void qSort(int[] a,int l,int r){
	if(l>=r) return;
	int p = partition(a,l,r);
	qSort(a,l,p-1);
	qSort(a,p+1,r);
}
上面最总要的还是partition操作,因此我们重点来解释一下这个函数。还是从不变式入手,这里的不变式如下

(我们选择的pivot=A[r])

1.对任意l<=k<=p, A[k] <= pivot

2.对任意 p+1<=k<=i-1, A[k] > pivot(其中i是我们当前遍历的元素)

接下来我们看一下循环过程是如何保证该不变式的:

1.初始:初始时p = l-1,[l,p]之间没有数据,我们认为它符合要求。

2.循环保持:因为当A[i]<=pivot时,我们会把A[i]与A[p+1]进行交换,并更新p = p+1,这使的上面的约束得以满足;当A[i]>pivot时,我们没有对p进行更新操作,只是更新了i=i+1,这仍然保持了上面的不变式约束,这都是显而易见的。

3.循环终止,当i = r-1时,r之前的元素满足上面的不变式,现在把pivot与p+1进行交换,由于A[p+1]>pivot,A[l..p]<=pivot,那么交换之后A[l,r]仍然满足上述不变式,因此它是正确的。

时间复杂度

快速排序的时间复杂度为O(nlgn)。我们来看一下特殊情况,按照上面的partition方法,如果元素原本已经是排好序的,

比如a=[1,2,3],调用qSort(a,0,2)

那么第一次partition操作,1,2会分别与3进行比较,最终返回的位置为2==> qSort(a,0,1) qSort(a,3,2);第二个qSort直接返回了,继续看第一个qSort

第二次调用partition,1与2比较,最终返回位置1(数组索引),进一步调用qSort(a,0,0); qSort(a,2,1);此时两个qSort均返回了。最终a=[1,2,3]。

我们发现当数组是有序的时候,快速排序的时间复杂度为O(n^2),具体过程类似于下面的for循环

for(int i = A.length-1; i >= 0; i--){

      for(int j = 0;i < i-1; j++){

            if .....

      }

}

上面分析的是元素正序排列时的情况,实际上,当元素逆序排列时,也会有同样的情况。

空间复杂度

partition操作并没有使用额外空间,空间主要是在递归的过程中,复杂度为O(lgn)

稳定性

如果pivot是随机选择的话,就是不稳定的。比如[1,2A,2B,2C],假设我们随机选择了2B作为pivot,那么所有不大于2的元素都移到左边,最终元素会成为[1,2A,2C,2B].但是如果固定的选择右侧的元素,作为pivot,则是稳定的。


6.堆排序

关于堆的概念性的知识不做过多说明,网络上到处都是。考虑一下前面介绍的选择排序,每一次为了找到一个当下最小元素都需要遍历一次剩余所有元素,事实上这些元素大都已经在上一次遍历过了,那么为什么不能利用前面的比较信息呢?堆排序就很好的利用了这些信息。

Java代码

public static void maxHeapify(int[] a,int heapSize,int index){
	if(index*2 > heapSize) return;
	int largest = index;
	if(a[index] < a[index*2]) largest = index*2;
	if(2*index<heapSize && a[largest] < a[index*2+1]) largest = index*2+1;
	if(largest!=index){
		int tmp = a[largest];
		a[largest] = a[index];
		a[index] = tmp;
		maxHeapify(a,heapSize,largest);
	}
}
public static void buildHeap(int[] a){
	int pos = (a.length-1)/2;
	for(int i = pos;i>0;i--) maxHeapify(a,a.length-1,i);
}

public static void heapSort(int[] a){
	buildHeap(a);
	for(int i = 1;i < a.length-1;i++){
		int tmp = a[1];
		a[1] = a[a.length-i];
		a[a.length-i] = tmp;
		maxHeapify(a,a.length-i-1,1);
	}
}

时间复杂度

平均时间复杂度O(nlgn).看一下特殊情况,当元素为逆序排列时,对于最大堆来说,buildHeap操作只需要O(n)次比较,节省了一部分时间,但是之后在heapSort中,顶部元素每交换依次都需要O(lgn)次比较交换操作,整体复杂度还是O(nlgn)。建堆的时间复杂度为O(n).

空间复杂度

常量级

稳定性

不稳定,考虑一下这种情况

    1a

  /     \

1b     2

假设这是一个最大堆,那么1a就需要与2交换,之前是[1a,1b,2]交换完之后是[2,1b,1a],因此是不稳定的。

7.计数排序

基本思想是:对每一个输入元素x,确定小于x的元素个数。利用这一信息,就可以直接把x放到相应输出数组中的位置上了。

伪代码如下(来自算法导论)

COUNTINT-SORT(A,B,k)

let C[0...k] be a new array

for i = 0 to k

      C[i] = 0

for j = 1 to A.length

      C[A[j]] = C[A[j]]+1

for i = 1 to k

      C[i] = C[i] + C[i-1]

for j = A.length downto 1

      B[C[A[j]]] = A[j]

      C[A[j]] = C[A[j]]-1

从上面的代码可以看出,计数排序适合数组元素范围不大的情况,比如学生的成绩,对于元素数值范围很大的,空间浪费严重。

时间复杂度

线性时间复杂度O(n)

空间复杂度

O(n)

稳定性

稳定的

8.基数排序

伪代码(算法导论):

假设n个d位的元素存放在数组A中,其中第一位是最低位,第d位是最高位

RADIX-SORT(A,d)

for i = 1 to d

      use a stable sort to sort array A on digit i

给定n个d位数,其中每一个数位有k个可能的取值。如果RADIX-SORT使用的稳定排序方法耗时theta(n+k),那么它就可以在theta(d(n+k))时间内将这些数排好序。

时间复杂度

O(n)

空间复杂度

最坏情况下为O(k+n)

稳定性

稳定的

9.桶排序

桶排序把元素分部到不同的桶中,每个桶再分别排序,可以使用其他排序算法,也可以递归的使用桶排序算法。

它的工作过程包括以下几步(维基百科):

  1. Set up an array of initially empty "buckets".
  2. Scatter: Go over the original array, putting each object in its bucket.
  3. Sort each non-empty bucket.
  4. Gather: Visit the buckets in order and put all elements back into the original array.
伪代码(维基百科):


function bucketSort(array, n) is
  buckets ← new array of n empty lists
  for i = 0 to (length(array)-1) do
    insert array[i] into buckets[msbits(array[i], k)]
  for i = 0 to n - 1 do
    nextSort(buckets[i]);
  return the concatenation of buckets[0], ...., buckets[n-1]

上面代码中array就是待排序的数组,n是要使用的buckets的个数,函数msbits(x,k)返回 k most significant bits of x (floor(x/2^(size(x)-k)));也可以使用不同的方法,只要能把元素合理的分配到不同的buckets中。nextSort是排序函数,如果使用bucketSort作为nextSort就会产生一种基数排序算法,特别的n=2时,相应的变为快速排序算法

桶排序与前面的归并排序,快速排序,计数排序,基数排序都有很相似的地方,或者说桶排序是一种更“广义”的排序算法。当n=2,nextSort为bucketSort时,通过把元素分散到2个不同的桶中,然后递归调用bucketSort,最后对元素进行合并,这与归并算法和快速排序算法的思想是一致的。当bucket的大小为1时,array中的元素按照其自身的大小分散到bucket中时,桶排序算法蜕化为计数排序;当array中的元素按照个位数字,十位数字...分别分散到不同的桶中时候,桶排序又蜕化为了基数排序


时间复杂度

桶排序要想做到平均O(n)的时间复杂度,桶的个数必须等于数组长度,而且数组元素必须在元素值范围内均匀分布。如果这些条件不能满足,桶排序的效率就会依赖于nextSort,一般为O(n^2)复杂度的插入排序,这就使得桶排序不如O(nlgn)的比较排序。

空间复杂度

O(n)

稳定性

稳定的



参考

1.《算法导论》

2. https://en.wikipedia.org/wiki/Radix_sort

3. https://en.wikipedia.org/wiki/Bucket_sort


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值