排序的基本知识

排序是很重要的,一般排序都是针对数组的排序,可以简单想象一排贴好了标号的箱子放在一起,顺序是打乱的因此需要排序。

排序的有快慢之分,常见的基于比较的方式进行排序的算法一般有六种。

  • 冒泡排序(bubble sort)
  • 选择排序(selection sort)
  • 插入排序(insertion sort)
  • 归并排序(merge sort)
  • 堆排序(heap sort)
  • 快速排序(quick sort)

前三种属于比较慢的排序的方法,时间复杂度在\(O(n^2)\)级别。后三种会快一些。但是也各有优缺点,比如归并排序需要额外开辟一段空间用来存放数组元素,也就是\(O(n)\)的空间复杂度。

快速排序的三种实现

这里主要说说快速排序,通常有三种实现方法:

  • 顺序法
  • 填充法
  • 交换法

下面的代码用java语言实现

可以用下面的测试代码,也可以参考文章底部的整体的代码。

public class Test {
    public static void main(String[] args) {
        int[] nums = {7,8,4,9,3,2,6,5,0,1,9};
        QuickSort quickSort = new QuickSort();
        quickSort.quick_sort(nums, 0, nums.length-1);
        System.out.println(Arrays.toString(nums));
    }
}

递归基本框架

所有的快速排序几乎都有着相同的递归框架,先看下代码

    public void quick_sort(int[] array, int start, int end) {
        if(start < end){
            int mid = partition(array, start, end);
            quick_sort(array, start, mid-1);
            quick_sort(array, mid+1, end);
        }
    }

代码有如下特点

  • 因为快速排序是原地排序(in-place sort),所以不需要返回值,函数结束后输入数组就排序完成
  • 传入quick_sort函数的参数有数组array,起始下标start和终止下标end。这样方便对子数组进行操作。
  • 代码使用了分治(divide and conquer)的思想,并用递归来完成

单看递归的框架,思路其实很简单。

  1. 利用partition函数在数组中找到一个mid下标,通过移动元素,使得mid左边的元素都比mid小,右边的都比mid大。
  2. 递归地,对mid左边和右边的元素进行快速排序。
  3. 不断进行下去,区间会越来越小,函数如果start==end说明区间只有一个元素,也就不用排序,这就是终止条件。

快速排序的副产品就是快速选择算法,因为partition函数实际上返回的mid值就是array[mid]在已经排序的array里面所处的顺位。

因此真正的难点就落在了怎么样对数组进行划分了,首先介绍顺序法

顺序法

    public int partition(int[] array, int start, int end) {
        int firstHigh = start; // > pivot
        int pivot = array[end];
        for(int j = start; j < end; j++) {
            if(array[j] <= pivot) {
                swap(array, firstHigh, j);
                firstHigh++;
            }
        }
        swap(array, firstHigh, end);
        return firstHigh;
    }

2-3行是一些指针的定义,这里选取最后一个元素作为主元(pivot),

因此任务也就变成了:调整数组使得pivot左侧的元素都比pivot小,右侧的都比pivot大。

partition的目标
小于或者等于pivot pivot 大于pivot
array[start...mid-1] array[mid] array[mid+1...end]

4-9行是循环体,代表着如何将给定范围的数组变成表格中的样子,不断循环最终做到上面表格中的样子,其中firstHigh变量也大于pivot。

循环过程
小于或者等于pivot firstHigh 大于pivot unexplored
array[start...firstHigh-1] array[firstHigh] array[firstHigh...j-1] array[j...end]
  1. 初开始循环j=start,整个数组都是unexplored的状态,firstHigh也等于start。
  2. 逐个遍历数组元素与pivot比较,
  3. 如果array[j] > pivot,可知array[j]处于pivot右边的高区,容易知道j始终大于等于firstHigh,此时不需要做额外操作,j向右移动一位,高区多一个元素。
  4. 如果array[j] <= pivot,在左边,array[j]应该移动到左边的低区,只需与firstHigh交换即可,然后firstHigh向右移动一位,低区多一个元素
  5. j==end循环结束,除了pivot所有的元素都被遍历,小于等于pivot都在firstHigh左边,大于都在firstHigh右边。
  6. 此时需要考虑pivot的具体位置,pivot还在最后一位没有动,注意到firstHigh左侧都比pivot小,因此array[end]array[firstHigh]交换即可,也就是10行
  7. 返回的下标就是firstHigh,这就是pivot的位置

这个方法算是比较常见的吧,但也有点不好写。

填充法

public int insertPartition(int[] array, int start, int end) {
    int pivot = array[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (low < high && array[high] >= pivot) {
            high--;
        }
        array[low] = array[high];
        while (low < high && array[low] < pivot) {
            low++;
        }
        array[high] = array[low];
    }
    array[low] = pivot;
    return low;
}

接下来一头一尾两个指针往中间走的做法了,2-4行做基本的设置。主元选在start位置,其实选end也行,留作练习吧。low和high是两个指针分别指向开头和末尾。

这里的partition目标有一些变化,按照那个标准也能写,无非就是小改动。

partition的目标
小于pivot pivot 大于或者等于pivot
array[start...mid-1] array[mid] array[mid+1...end]

5-14行使用while循环,因为使用赋值操作,所以叫做填充法

  1. 首先从high指针开始,如果high指向的元素array[high] >= pivot,high本来就是从右边开始的,说明此时不需要交换,high所处的位置已经满足条件,内部是未知状态,high左移一位。
  2. 但是当array[high] < pivot时,因此array[high]要放到左边去,注意到已经array[low]已经用pivot变量保存了,这个位置可以认为是空出来了(虽然没有真正空出来),因此第9行array[low] = array[high];,就这样放到了左边。
  3. low指针同理,但是当执行过array[low]=array[high]后,array[high]相当于也空了出来。
  4. 因此当array[low] >= pivot时,需要向右边填充就去找array[high]

注意到外层while循环的条件被附带到了内层,这是很常见的,内层循环改变变量的值,有的时候不可避免就不满足外层循环的条件了。内外层循环都附带low<high的条件,保证退出循环一定low=high

low与high撞上的时候,这里实质上是一个空位,因为当循环结束时,空位左边都小于pivot,空位右边都大于等于pivot,并且循环遍历了除了pivot以外所有的元素。因此填入pivot即可。

返回low或者high都可以,这就是pivot应该在的位置。

交换法

感觉这个方法见的最多。先给一个不常见的写法

一个不常见的写法

public int swapPartition(int[] array, int start, int end) {
    int pivot = array[end];
    int low = start;
    int high = end-1;
    while (low < high) {
        while (low < high && array[high] >= pivot) {
            high--;
        }
        while (low < high && array[low] < pivot) {
            low++;
        }
        if (low < high) {
            swap(array, low, high);
        }
    }
    if (pivot > array[low]) {
        low++;
    }
    swap(array, end, low);
    return low;
}

2-4行,设置pivot为最后一个元素,一般都是第一个元素,这里最后一个也无妨。如果是第一个可以当成作业完成。既然pivot已经指定,high可以从end-1开始往回走。

partition的目标
小于pivot pivot 大于或者等于pivot
array[start...mid-1] array[mid] array[mid+1...end]

partition的目标仍然与填充法相同。

5-15行是循环体,很容易看懂

  1. 右边找小的,左边找大的,两边都找到就交换,low往右边走,high往左边走。可以保证low左边都比pivot小,high右边都大于等于pivot
  2. low,high指针都要往中间走,碰上循环结束。
  3. 碰上的时候,左侧一定都小于pivot,右边都大于等于pivot,那么中间是不是应该填pivot?于是与array[end]交换?

不能想的太直接,16-18行加了一个判断,当pivot <= array[low]时,那当然可以把array[low]换到右边去,因为大一些吗。如果pivot > array[low]呢?

...... array[low] array[low+1] ...... array[end]
....... low==high low+1 ...... end

那么array[low]应该在左边,可以考虑变通以下,array[end]与low+1位置的元素交换,这样就仍然满足条件。最后返回low+1即可,low++本身就是low自增,所以如16-20行所示。

low++会数组越界吗?不会。因为判断条件为array[high] >= pivot,因此high指针一定向左边移动一步

为了方便与下一节相对比,给出pivot取array[start]的另一个版本

public int swapPartition1(int[] array, int start, int end) {
    int pivot = array[start];
    int low = start + 1;
    int high = end;
    while (low < high) {
        while (low < high && array[low] <= pivot) {
            low++;
        }
        while (low < high && array[high] > pivot) {
            high--;
        }
        if (low < high) {
            swap(array, low, high);
        }
    }
    if (pivot < array[low]) {
        low--;
    }
    swap(array, start, low);
    return low;
}

一个常见的写法

public int swapPartition1(int[] array, int start, int end) {
    int pivot = array[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (low < high && array[high] > pivot) {
            high--;
        }
        while (low < high && array[low] <= pivot) {
            low++;
        }
        if (low < high) {
            swap(array, low, high);
        }
    }
    swap(array, start, low);
    return low;
}

这里pivot又换成开头了,但是不管在哪里,都要思路清晰,知道该做什么改动能让程序依旧正确。

但是这个写法很严格,几乎一点不能改动的。

流程与一个不常见的写法一节中基本类似,但是

  1. low=start不能动,必须是这个,不能是low=start+1,这个举一个只有两个元素的数组的例子就知道了
  2. 6-8行的内循环和9-11行的内循环不能对调
  3. 两个内循环中的判断必须如上面所写,array[high] > pivotarray[low] <= pivot,>和<=不能轻易改成别的。也就是partition的目标是固定的。(一旦改动,low必须是start+1,但是这样写不可避免最后swap又要加判断,反而和不常见的写法一样了。)
partition的目标
小于或者等于pivot pivot 大于pivot
array[start...mid-1] array[mid] array[mid+1...end]

如此才能舍去一个不常见的写法中16-18行的判断条件

最关键的点在于循环位置不能对调。循环终止,low与high碰上,有以下几种情况

  1. high先停住,low增加碰上high停止。high停下来是因为array[high] <= pivot当然能与array[start]交换换到左边去
  2. low先停住,high减少碰上low停止。low停下来是因为array[low] > pivot,但是紧跟着swap(array, low, high);,所以low先停下来也无妨,low的位置的元素仍然满足array[low] <= pivot,换到左边去没有问题。

鉴于这个写法太过严格不建议这么写

完整代码

import java.util.Arrays;

public class QuickSort {
    public static void main(String[] args) {
        int[] nums = {7,8,4,9,3,2,6,5,0,1,9,4,7,3};
        quickSort(nums,0, nums.length-1);
        System.out.println(Arrays.toString(nums));
    }

    public static void quickSort(int[] array, int start, int end) {
        if(start < end){
            int mid = swapPartition1(array, start, end);
            quickSort(array, start, mid-1);
            quickSort(array, mid+1, end);
        }
    }
    public static int partition(int[] array, int start, int end) {
        int firstHigh = start;
        int pivot = array[end];
        for(int j=start; j < end; j++) {
            if(array[j] <= pivot) {
                swap(array, firstHigh, j);
                firstHigh++;
            }
        }
        swap(array, firstHigh, end);
        return firstHigh;
    }

    public static int insertPartition(int[] array, int start, int end) {
        int pivot = array[start];
        int low = start;
        int high = end;
        while (low < high) {
            while (low < high && array[high] >= pivot) {
                high--;
            }
            array[low] = array[high];
            while (low < high && array[low] < pivot) {
                low++;
            }
            array[high] = array[low];
        }
        array[low] = pivot;
        return low;
    }

    /* 为啥这是正确的 */
    public static int swapPartition1(int[] array, int start, int end) {
        int pivot = array[start];
        int low = start;
        int high = end;
        while (low < high) {
            while (low < high && array[high] > pivot) {
                high--;
            }
            while (low < high && array[low] <= pivot) {
                low++;
            }
            if (low < high) {
                swap(array, low, high);
            }
        }
        swap(array, start, low);
        return low;
    }

    /*这个肯定是正确的*/
    public static int swapPartition(int[] array, int start, int end) {
        int pivot = array[end];
        int low = start;
        int high = end;
        while (low < high) {
            while (low < high && array[low] < pivot) {
                low++;
            }
            while (low < high && array[high] >= pivot) {
                high--;
            }
            if (low < high) {
                swap(array, low, high);
            }
        }
        if (pivot > array[low]) {
            low++;
        }
        swap(array, end, low);
        return low;
    }

    public static void swap(int[] array, int i, int j) {
        int tmp;
        tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
    }
}