六种经典排序算法——冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序

理论性能比较
算法平均时间复杂度最好时间复杂度最坏时间复杂度空间复杂度稳定性
冒泡排序 O ( N 2 ) O(N^2) O(N2) O ( N 2 ) O(N^2) O(N2) O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)稳定
选择排序 O ( N 2 ) O(N^2) O(N2) O ( N 2 ) O(N^2) O(N2) O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)不稳定
插入排序 O ( N 2 ) O(N^2) O(N2) O ( N ) O(N) O(N) O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)稳定
希尔排序 O ( N l o g N ) O(NlogN) O(NlogN) O ( N ) O(N) O(N) O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)不稳定
归并排序 O ( N l o g N ) O(NlogN) O(NlogN) O ( N l o g N ) O(NlogN) O(NlogN) O ( N l o g N ) O(NlogN) O(NlogN) O ( N ) O(N) O(N)稳定
快速排序 O ( N l o g N ) O(NlogN) O(NlogN) O ( N l o g N ) O(NlogN) O(NlogN) O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)不稳定

如果排序算法不稳定,两个元素相等的时候(以a,b为例,未排序数组a在b的前面),它们的实际顺序不一定都是一致的(例如a=b的情况下,a有可能在b的前面,也有可能在b的后面)。如果是稳定的,那么如果原本a在b前面,那么最终排序结果也一定是a在b的前面。

下列排序算法的讲解均按照从小到大进行排序。


冒泡排序

冒泡排序是一种简单的排序算法,它通过两层循环,不停地比较两个相邻的元素,如果顺序错误就将这两个元素进行交换。

public class BubbleSort {
	public void sort(int[] arr) {
		final int len = arr.length;
        for(int i = 0; i < len; i++) {
            for(int j = 0; j < len - i - 1; j++) {
                if(arr[j] > arr[j + 1]) {
                    swap(arr, j, j + 1);
                }
            }
        }
	}
	//交换数组arr中下标为i和j的元素
	private void swap(int[] arr, int i, int j) {
		int t = arr[i];
		arr[i] = arr[j];
		arr[j] = t;
	}
}

其基本思路为:

  1. 从第一个元素开始,比较相邻元素,如果前面的元素比后面的元素大,就在数组中交换这两个元素。
  2. 对每一对元素执行上述操作,从开始的第一个元素到最后一个元素。内层循环完成后,其arr[len - i -1]必定是0~len-i-1中最大的数。
  3. 继续执行1~2步骤,但是不对len - i - 1 ~ len - 1范围内的元素进行比较。

简单的讲,每一次外层循环都是将数组中最大的元素放到arr[len - i -1]这个位置上,当i == len的时候,数组中的元素必然就是有序的了。


选择排序

选择排序的基本思路是:

  1. 找到数组中最小的元素,然后将这个元素与数组中的第一个元素进行位置交换(如果最小的元素就是第一个元素,那么不进行交换)。
  2. 然后,从数组的第2个元素开始,查找数组中最小的元素,将这个最小的元素与第2个元素进行位置交换
  3. 从数组的第3个元素开始······

按照上述步骤以此类推,就可以得到一个有序的数组了。

代码如下:

public class SelectSort {
	public void sort(int[] arr) {
        for(int i = 0; i < arr.length; i++) {
            int min = arr[i];
            int index = i;
            for(int j = i + 1; j < arr.length; j++) {
                if(arr[j] < min) {
                    index = j;
                    min = arr[j];
                }
            }
			//上述循环完成后,其arr[index]就是i ~ arr.length-1范围内最小的数组
            if(index != i)
                swap(arr, i, index);
        }
    }
}

插入排序

插入排序基本思路:

  1. 从数组的第2个元素开始并记录这个元素的值,然后与第1个元素进行比较,如果这个元素比它小,就将这个元素与第1个元素交换(放到它的左边),否则不作任何改动。
  2. 从数组的第3个元素开始并记录这个元素的值,然后与第2个元素进行比较,如果这个元素比它小,那么将这个元素与第2个元素交换(放到它的左边),否则不作任何改动并跳到第3步。接着将交换后元素(此时是第2个元素了)再次与第1个元素进行比较,策略和第一步相同。
  3. 从数组的第4个元素开始并记录这个元素的值······

按照上述步骤以此类推,就可以得到一个有序的数组了。用动图表示如下:

代码:

public class InsertSort {
	public void sort(int[] arr) {
        for(int i = 1; i < arr.length; i++) {
            final int num = arr[i];
            for(int j = i - 1; j >= 0 && num < arr[j]; j--) {
                swap(arr, j, j + 1);
            }
        }
    }
}

可以看出,如果数组本来就是有序的,那么其时间复杂度可以达到 O ( n ) O(n) O(n)


希尔排序

希尔排序可以堪看成是插入排序的改进版本。其思想是使数组间隔为h的元素是有序的,我们称之为h有序数组,如何对间隔h的数组进行排序呢,答案是通过插入排序,不过比较的时候并不一定是比较前一个元素,而是比较这个元素前面h长度的元素。然后不断减小h的值,重复上述步骤,直到h为1。这样在每次循环执行“插入排序”的时候就能显著减少比较的次数。

希尔排序之所以性能好,是因为充分利用了插入排序的特性——如果数组中的元素越有序(如果是从小到大排序,那么最坏的情况是数组是从大到小有序),那么性能就越好,最极端的情况就是上面所说的,如果数组本来就是有序的,那么其时间复杂度可以达到 O ( N ) O(N) O(N)

public class ShellSort {
    public void sort(int[] arr) {
        int h = 1;
        while(h < arr.length / 3)
            h = h * 3 + 1;  //根据数组的长度计算出一个合理的h值       
        while(h >= 1) {
        	//这里看上去是不是和插入排序很相似?
            for(int i = h; i < arr.length; i += h) {
                int t = arr[i];
                for(int j = i - h; j >= 0 && t < arr[j]; j -= h) {
                    swap(arr, j, j + h);
                }
            }
			//减小h的值,这里我们直接除以3(其实减1也可以,只不过从统计学上来讲除以3性能更好)
            h /= 3; 
        }
    }
}

归并排序

归并排序采用了分治思想,即将一个大的问题划分为多个小问题解决后再进行合并得出结果。归并排序将一个数组递归地划分为两半分别进行排序并将两边的排序结果复制到一个临时数组中,然后按照临时数组中的内容归并到原数组中。归并排序时间复杂度恒为 O ( N l o g N ) O(NlogN) O(NlogN),但是缺点是需要一个长度和原数组相同的临时数组,也就是说它的空间复杂度为 O ( N ) O(N) O(N)

public class MergeSort {
    public void sort(int[] arr) {
        int[] tarr = new int[arr.length];
        sort(arr, tarr, 0, arr.length - 1);
    }

    private void sort(int[] arr, int[] tarr, int st, int ed) {
        if(st >= ed)
            return;
        int mid = (st + ed) / 2;
        //不停地将一个数组拆分为两半
        sort(arr, tarr, st, mid);
        sort(arr, tarr, mid + 1, ed);
        //此时tarr进行归并
        merge(arr, tarr, st, mid, ed);
    }

    private void merge(int[] arr, int[] tarr, int st, int mid, int ed) {
        for(int i = st; i <= ed; i++)
            tarr[i] = arr[i];

        int a = st, b = mid + 1;
        for(int i = st; i <= ed; i++) {
            if(b > ed)
                arr[i] = tarr[a++];
            else if(a > mid)
                arr[i] = tarr[b++];
            else if(tarr[a] < tarr[b])
                arr[i] = tarr[a++];
            else
                arr[i] = tarr[b++];
        }
    }
}

merge开始时,arr数组的st ~ mid范围内的元素和mid + 1 ~ ed范围内的元素都是有序的,但是st ~ ed范围内的元素并不是有序的。所以merge方法的目的就是将两个有序的数组整合为一个有序的数组。

首先merge会将arrst ~ ed范围的元素复制到tarrst ~ ed中,两个索引ab分别指向tarr的两个有序数组的首个元素。在循环中,会将索引ab指向的元素进行比较,如果a指向的元素小,就将tarr[a]赋值给arr[i],并将a索引加1;如果b指向的元素小,就将tarr[b]赋值给arr[i],并将b索引加1。如果a或者b的索引已经越界(a大于mid或者b大于ed),就只将没有越界的数组剩下的部分从下标i开始全部赋值给arr


快速排序

快速排序也是分治思想的一种典型的运用,相比归并排序,快速排序并不需要 O ( N ) O(N) O(N)的额外空间及其数组的复制操作。快速排序和归并排序是互补的,归并排序将数组分为两个子数组进行排序,并将有序的子数组归并成一个有序的数组。而快速排序的方式则是当两个子数组都有序时整个数组就自然有序了。

快速排序的过程如下:

  1. 首先选择数组的第一个元素并记录元素的值k,遍历后面元素,如果小于k,那么将元素放置于该元素的左边,如果大于k,就放置于该元素的右边。我们称该操作为分区
  2. 以元素k所在的位置为界,将数组划分为两半,然后将这个子数组同样执行第1~第2步的操作,直到数组不可再分为止。
  3. 上述操作完成后,整个数组就是有序的了。
public class FastSort {
    public void sort(int[] arr) {
        sort(arr, 0, arr.length - 1);
    }

    private void sort(int[] arr, int st, int ed) {
        if(st >= ed)
            return;
        //返回的是原本处于arr[st]的元素在完成分区操作后的位置
        int k = partition(arr, st, ed);
        //将数组划分为两个子数组(不包含k所在的位置)
        sort(arr, st, k - 1);
        sort(arr, k + 1, ed);
    }
	//分区方法,以arr[st]的值为基准
    private int partition(int[] arr, int st, int ed) {
        final int num = arr[st];
        //两个索引,分别指向数组的"第二个"元素和数组的"最后一个"元素
        //我们称i为首索引,j为尾索引
        int i = st + 1, j = ed;
        while(true) {
        	//递减尾索引,直到发现该索引指向的值比arr[st]大
            while(j != st && arr[j] > num)
                j--;
            //递增首索引,直到发现该索引指向的值比arr[ed]大
            while (i != ed + 1 && arr[i] < num)
                i++;
            //如果索引发生碰撞,那么分区操作已经基本完成
            if(i >= j)
                break;
            //交换此时首索引和尾索引指向的元素
            swap(arr, i, j);
        }
        //将arr[st]的值放到合适的位置,即左边的所有的元素比它小,右边所有的元素比它大
        swap(arr, st, j);
        return j;
    }

    private void swap(int[] arr, int i, int j) {
        int t = arr[i];
        arr[i] = arr[j];
        arr[j] = t;
    }
}

快速排序的难点在于如何执行分区操作,也就是将比k小的元素放置于左边,比k大的元素放置于右边,并且要以 O ( N ) O(N) O(N)的时间复杂度完成。

上述partition方法中,我们将arr数组的st~ed范围内的元素视为本次分区操作的子数组。首先取arr[st]为基准,我们的目的是找出arr数组的st~ed范围内比arr[st]小的元素放到左边,比arr[st]大的元素放到右边。

定以两个索引iji指向st + 1j指向ed,并进入一个循环:

  1. 递减尾索引j,直到发现该索引指向的值比arr[st]
  2. 递增首索引i,直到发现该索引指向的值比arr[ed]
  3. 如果指针发生了碰撞,说明分区操作已经基本完成,退出循环。
  4. 交换ij的元素,保证左侧的元素比arr[st]小,右侧元素比arr[st]

退出循环后,我们只需要将arr[st]放置于arrj位置上,并将arr[j]的元素放置于st即可。

(《算法》第四版 切分操作示意图)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值