经典排序算法

冒泡排序

算法描述

  1. 比较范围初始化为 0 ~ N-1,当前元素初始化为数组首位元素
  2. 将当前元素按照顺序依次与后续元素逐一进行比较,每次比较过程结束后,选取最大项作为当前元素,最后实现将首个最大的元素移至数组末尾
  3. 排除最后一个元素,缩小比较范围至 0 ~ N-2
  4. 重复步骤 2 与步骤 3 直至实现排序

代码实现

public int[] bubbleSort(int[] A, int n) {
    for ( int i = 0; i < n; i++ ) {
        for (int j = 0; j < n-i-1; j++ ) {
            if (A[j+1]<A[j]) {
                int tmp;
                tmp = A[j];
                A[j] = A[j+1];
                A[j+1] = tmp;
            }
        }
    }
    return A;
}

选择排序

算法描述

  1. 设定初始选择范围 0 ~ N-1
  2. 选择出最小的元素并放置在首位
  3. 缩小选择范围至 1 ~ N-1
  4. 重复步骤 2 与步骤 3 直至实现排序

代码实现

public int[] selectionSort(int[] A, int n) {
    for (int i = 0; i < n-1; i++ ) {
        for ( int j = i+1; j < n; j++ ) {
            if ( A[i] > A [j] ) {
                int tmp;
                tmp = A[i];
                A[i] = A[j];
                A[j] = tmp;
            }
        }
    }
    return A;
}

插入排序

  • 所需比较次数平均只有选择排序的一半
  • 对部分有序的数组十分高效

算法描述

  1. 将插入元素与数组内现有元素进行一一对比
  2. 若数组内现有元素比插入元素大,则现有元素后移
  3. 重复步骤 2,直至现有元素小于插入元素则将其插入数组

代码实现

public int[] insertionSort(int[] A, int n) {
    int j;
    for (int i = 1; i < n; i++ ) {
        int tmp = A[i];
        // 将现有元素往后移动,直到插入位
        for ( j = i; j > 0 && tmp < A[j-1]; j-- ) {
            A[j] = A[j-1];
        }
        A[j] = tmp;
    }
    return A;
}

* 上述三种排序时间复杂度均为 O ( N 2 ) O(N^2) O(N2)


希尔排序

实质:步长变化的插入排序特例,也称为缩减增量(步长)排序。

算法描述

  1. 设定初始步长为 h h h
  2. 从数组第 h h h 位开始,索引设为 h h h,将元素 x x x 与前面相差步长位数 h h h 的元素 y y y 进行比较
    1. x &lt; y x&lt;y x<y,则两者交换位置,且交换位置之后的元素 x x x (此时位于前 h h h 位)再与当前位置的前 h h h 位元素 z z z 进行比较,循环往复,直至前 h h h 位小于 0 0 0,则停止本轮比较
    2. x &gt; y x&gt;y x>y,则直接停止本轮比较
  3. 当前索引加一,步长不变,重复步骤 2 进行下一位元素的比较
  4. 缩小步长,重复步骤 2 与步骤 3,直至步长为 1 时停止比较

代码实现

public static int[] shellSort (int[] A, int n) {
    int j;
    for ( int gap = n / 2; gap > 0; gap /= 2) {
        for ( int i = gap; i < n; i++ ) {
            int tmp = A[i];
            for( j = i; j >= gap && tmp < A[j - gap]; j-= gap ) 
                A[j] = A[j - gap];
            A[j] = tmp;
        }
    }
    return A;
}

性能分析

希尔排序的运行时间快于插入排序与选择排序,但是具体运行时间依赖于步长序列的选择,其中:

  1. 选择希尔步长 { N / 2 , ( N / 2 ) / 2 , . . . , 1 } \{N/2, (N / 2)/2, ..., 1\} {N/2,(N/2)/2,...,1} 时,最坏情形运行时间为 O ( N 2 ) O(N^2) O(N2)
  2. 选择 Hibbard 步长 { 1 , 3 , . . . , 2 k 1 } \{1, 3, ..., 2^{k-1}\} {1,3,...,2k1} 时,最坏情形运行时间为 O ( N 3 / 2 ) O(N^{3/2}) O(N3/2)

归并排序

分治(divide and conquer)策略:将问题划分为一些小的问题之后使用递归算法求解,而治的阶段则是将分的阶段解得的各答案修补在一起。

算法描述

多个元素需要排序的情况:

  1. 递归地将前半部分数据和后半部分数据各自归并排序
  2. 使用合并算法将排序后的两部分数据合并到一起

代码实现

/* 自顶向下 从整个数组开始划分归并 适用于任何分治类算法 */
public static int[] mergeSort(int[] A, int n) {
	mergeSort(A, 0, n-1);
	return A;
}

public static int[] mergeSort(int[] A, int left, int right) {
    if (left < right) {
        int mid = (left + right) / 2;
        mergeSort(A, left, mid);
        mergeSort(A, mid + 1, right);
        merge(A, left, mid, right);
    }
    return A;
}

/* 自底向上 首先归并微型数组,再成对归并得到的子数组 适用于用链表组织的数据 */
public static void sort(int[] A, int n) {
    int[] aux = new int[n];
    for(int sz = 1; sz < n; sz = sz + sz) { // sz:子数组大小;left:子数组索引
        for(int left = 0; left < n - sz; left += sz + sz) {
            merge(A, left, left + sz - 1, Math.min(left + sz + sz - 1, n-1);
        } 
     }
}
                  

public static void merge(int[] A, int left, int mid, int right) {
    // 归并 A[left..mid] 与 A[mid+1...right]
    int[] tmp = new int[right - left + 1];
    int tmp_left = left;
    int tmp_mid = mid + 1;
    int index = 0;
    
    // 左右半边都没用尽
    while ( tmp_left <= mid && tmp_mid <= right ) {
        if ( A[tmp_left] < A[tmp_mid] ) {
            tmp[index++]  = A[tmp_left++];
        } else {
            tmp[index++] = A[tmp_mid++];
        }
    }
    
    // 左半边没用尽
    while (tmp_left <= mid) {
        tmp[index++] = A[tmp_left++];
    }
    
    // 右半边没用尽
    while (tmp_cent <= right) {
        tmp[index++] = A[tmp_mid++];
    }
    
    // 覆盖原数组需要排序部分数据
    for (int i = 0; i< tmp.length; i++) {
        A[left + i] = tmp[i];
    }
}

性能分析

耗时: T ( 1 ) = 1 T(1) = 1 T(1)=1 T ( N ) = 2 T ( N / 2 ) + N T(N) = 2T(N/2) + N T(N)=2T(N/2)+N

化简计算得到: T ( N ) = 2 k T ( N / 2 k ) + k N T(N) = 2^kT(N/2^k) + kN T(N)=2kT(N/2k)+kN,利用 k = l o g N k = logN k=logN,即 T ( N ) = N T ( 1 ) + N l o g N = O ( N l o g N + N ) T(N) = NT(1) + NlogN = O(NlogN+N) T(N)=NT(1)+NlogN=O(NlogN+N)

则有:算法通过对输入数据一趟排序实现,其最坏情况时间复杂度为: O ( N l o g N O(NlogN O(NlogN) ,任意其他基于比较的排序算法所需最少比较次数也为该值,即归并算法是一种渐进最优的基于比较排序的算法。

  • 能够保证将任意长度为 N N N 的数组排序所需时间与 N l o g N NlogN NlogN 成正比

存在问题

  • 合并两个已排序表时需要用到附加内存,与 N N N 成正比;
  • 运行时间严重依赖于比较元素和在数组以及临时数组中移动元素的相对开销。

快速排序

  • 长度为 N N N 的数组排序所需时间与 N l o g N NlogN NlogN 成正比
  • 原地排序,仅需要一个很小的辅助栈

基础算法描述

  1. 选择数组中部一个元素作为比较元素
  2. 将比较元素按照数组顺序从首位开始比较,划分数组为前小后大两个子部分。
    1. 从数组左端开始扫描直到找到一个大于等于比较元素的元素
    2. 从数组右端开始扫描知道找到一个小于等于比较元素的元素
    3. 交换上述两个元素位置
    4. 左指针与右指针相遇时,交换比较元素与左子数组最右侧的元素(即右指针处元素)
  3. 递归实现子部分的划分
  • 切分不平衡时可能导致程序极为低效

基础代码实现

public static int[] quickSort(int[] A, int n) {
    sort(A, 0, n);
    return A;
}

public static void sort(int[] A, int lo, int hi) {
	if (hi <= lo) 
        return;
    int j = partition(A, lo, hi);
    sort(A, lo, j - 1);
    sort(A, j + 1, hi);
}

public static int partition(int[] A, int lo, int hi) {
    int i = lo, j = hi + 1;
    int v = A[lo];

    while (true) {
        while (v < A[--j])
            if (j == lo)
                break;
        while (v > A[++i])
            if (i == hi)
                break;
        if (i >= j)
            break;
        swap(A, i, j);
    }
    swap(A, lo, j);
    return j;
}

优化算法描述

在排序之前最好先将数组随机打乱,以防止出现最坏情况并使运行时间可以预计

  • 在针对小数组排序时应切换到插入排序

  • 枢纽元选取优化:

    1. 如果数组 S 中元素个数是 0 或 1,则返回。
    2. 取 S 中的任意一个元素 v,称之为枢纽元。
      1. 随机选取(安全做法)
      2. 三数中值分割法选取:选取左、右、中三个元素并使用其中值。
      3. 选取首位元素或互异关键字中的较大者极容易产生劣质排序,如输入为预排序或是反序时。因此不应使用。
    3. 将 S - {v} (S 中其余元素)划分为两个不相交的集合: S 1 = { x S v x v } S_1 = \{x \in S-{v}|x\le v\} S1={xSvxv} S 2 = { x S v x v } S_2 = \{x \in S-{v}|x\ge v\} S2={xSvxv}
      1. 将枢纽元移动到数组最后,与待分割数组剥离。
      2. 将枢纽元与首位相比,若首位大于枢纽元且末位小于枢纽元,则首位与末位交换数值,否则比较此位,以此类推缩小比较范围。
      3. 反复进行步骤 3.2 , 实现排序。
  • 三向切分

    将数组切分为三部分,分别对应小于、等于和大于比较元素的数组元素

    信息量最优,适用于含有大量重复元素的数组排序,将排序时间从线性对数级别降低到了线性级别

优化代码实现

  • 三数中值分割法选取枢纽元
public static void quickSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    process(arr, 0, arr.length - 1);
}

public static void process(int[] arr, int left, int right) {
    if (left < right) {
        // 随机选取枢纽元
        int random = left + (int) (Math.random() * (right - left + 1));
        // 将枢纽元移动至最后
        swap(arr, random, right);
        // 划分
        int mid = partition(arr, left, right);
        // 递归
        process(arr, left, mid - 1);
        process(arr, mid + 1, right);
    }
}

public static int partition(int[] arr, int left, int right) {
    int pivot = left - 1;
    int index = left;
    while (index <= right) {
        if (arr[index] <= arr[right]) {
            swap(arr, ++pivot, index);
        }
        index++;
    }
    return pivot;
}
  • 三向切分
public static void quick3way(int[] A, int lo, int hi) {
    if (hi <= lo) {
        return;
    }
    
    int lt = lo, i = lo + 1, gt = hi;
    int v = A[lo];
    
    while (i <= gt) {
        if (A[i] < v) {
            swap(A, lt++, i++);
        } else if (A[i] > v) {
            swap(A, i, gt--);
        } else i++;
    }
    
    quick3way(A, lo, lt - 1);
    quick3way(A, gt + 1, hi);
}

性能分析

基本的快速排序关系: T ( N ) = T ( i ) + T ( N i 1 ) + c N T(N)=T(i)+T(N-i-1)+cN T(N)=T(i)+T(Ni1)+cN

  1. 最坏情况
    • 枢纽元始终是最小元素
    • 递推关系为: T ( N ) = T ( N 1 ) + c N , N &gt; 1 T(N)=T(N-1)+cN, N&gt;1 T(N)=T(N1)+cN,N>1
    • 时间复杂度: O ( N 2 ) O(N^2) O(N2)
  2. 最好情况
    • 枢纽元正好位于中间
    • 递推关系为: T ( N ) = 2 T ( N / 2 ) + c N , N &gt; 1 T(N)=2T(N/2)+cN, N&gt;1 T(N)=2T(N/2)+cN,N>1
    • 时间复杂度: O ( N l o g N ) O(NlogN) O(NlogN)
  3. 平均情况
    • 递推关系为: T ( N ) = 2 N [ j = 0 N 1 T ( j ) ] + c N , N &gt; 1 T(N)=\frac{2}{N}[\sum^{N-1}_{j=0}T(j)]+cN, N&gt;1 T(N)=N2[j=0N1T(j)]+cN,N>1
    • 时间复杂度: O ( N l o g N ) O(NlogN) O(NlogN)

堆排序

堆相关概念

在堆中,位置 k 的父节点位置为 k/2 (向下取整),两个子节点的位置分别为 2k 与 2k + 1。

移动时,从 A[k] 向上一层就令 k = k/2,向下一层则令 k = 2k 或 2k + 1。

算法描述

  1. 建立大根堆
    • a r r a y [ i ] &gt; = a r r a y [ 2 i ] array[i] &gt;= array[2 * i ] array[i]>=array[2i] a r r a y [ i ] &gt; = a r r a y [ 2 i + 1 ] array[i] &gt;= array[2 * i + 1] array[i]>=array[2i+1]; 称为大根堆
  2. 将堆顶元素(即最大值)进行移动与末位交换,保存到结果数组中,脱离堆
  3. 调整重建大小减一的大根堆
    • 调整方法为:若【根节点的关键字】小于【左右子女中关键字较大者】,则交换。
  4. 重复步骤 2 与步骤 3 直至堆为空

代码实现

public static void swap(int[] arr, int a, int b) {
    int tmp = arr[a];
    arr[a] = arr[b];
    arr[b] = tmp;
}

public static int leftChild(int i) {
    return 2*i;
}

public static void percDown(int[] arr, int start, int end) {
    int tmp;
    int child;
    for (tmp = arr[start]; leftChild(start) < end; start = child) {
        child = leftChild(start);
        // 取节点较大的子节点的下标
        if (child != end - 1 && arr[child] < arr[child + 1]) {
            child++;
        }
        // 根节点 >= 左右子女中关键字较大者,则调整结束
        if (tmp < arr[child]) {
            // 将左右子结点中较大值array[child]调整到双亲节点上
            arr[start] = arr[child];
        } else {
            break;
        }
    }
    // 被调整的结点的值放入最终位置
    arr[start] = tmp;
}

public static int[] heapSort(int[] A, int n) {
    // 建立大根堆
    for ( int i = n / 2; i >= 0; i-- ) {
        percDown(A, i, n);
    }

    // 堆顶移至堆底,脱离堆,调整大根堆
    n--;
    while (n > 1) {
        swap(A, 0, n--);
        percDown(A, 0, n);
    }
// for ( int i = n - 1; i > 0; i-- ) {
// swap(A, 0, i);
// percDown(A, 0, i);
// }
    return A;
}

性能分析

最坏情况使用 2 N l o g N O ( N ) 2NlogN-O(N) 2NlogNO(N)次比较:

  1. 构建堆阶段:最多比较 2 N 2N 2N
  2. 调整阶段:最多比较 2 l o g i 2\lfloor{logi}\rfloor 2logi

平均情况: 2 N l o g N O ( N l o g l o g N ) 2NlogN-O(NloglogN) 2NlogNO(NloglogN),与最坏情况相差不大,因此算法较为稳定。

  • 能够在插入操作和删除最大元素操作混合的动态场景中保证对数级别的运行时间。

缺点

数组元素很少与相邻的其他元素进行比较,因此无法利用缓存,缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法。

桶排序 O ( M ) O(M) O(M)

计数排序算法描述

  1. 根据数组元素的最大值构造一组桶
  2. 将数组元素放到值相同的桶内
  3. 顺序倒出桶内元素,结束排序

计数排序代码实现

public static int[] countingSort (int[] A, int n ) {
    int max = -1;
    for ( int i = 0; i < A.length; i++ ) {
        if (A[i] > max)
            max = A[i];
    }

    int[] bucket = new int[max+1];
    for ( int i = 0; i < A.length; i++ ) {
        bucket[A[i]] ++;
    }

    int[] res = new int[n];
    int index = 0;
    for ( int i = 0; i < bucket.length; i++ ) {
        while ( bucket[i] != 0 ) {
            res[index++] = i;
            bucket[i]--;
        }
    }
    return res;
}

基数排序算法描述

  1. 构造值为 0 ~ 9 的十个桶
  2. 以个位值为标准将元素按照计数排序放入桶中
  3. 顺序倒出
  4. 将标准更新为十位值,再次运行步骤 2 与步骤 3
  5. 直至所有位数计算完毕结束排序

基数排序代码实现

public static int[] radixSort ( int[] A, int n ) {

    int[] bucket = new int[10];
    int[] res = new int[n];

    for ( int i = 0; i < bucket.length; i++ ) {
        bucket[i] = i;
    }

    int max = -1;
    for ( int i = 0; i < A.length; i++ ) {
        if (A[i] > max)
            max = A[i];
    }

    int time = 1;
    int tmp = max / 10;
    while ( tmp != 0 ) {
        time ++;
        tmp = tmp/10;
    }

    for ( int i = 1; i < time; i++ ) {

        for( int j = 0; j < bucket.length; j++ ){
            bucket[j] = 0;
        }

        for( int k = 0; k < n; k++ ){
            int num = getTimes(A[k], i);
            bucket[num]++;
        }
        for( int m = 0; m < 9; m++ ){
            bucket[m + 1] = bucket[m] + bucket[m + 1];
        }
        for( int p = n - 1; p >= 0; p-- ){
            int num = getTimes(A[p], i);
            res[ bucket[num] - 1 ] = A[p];
            bucket[num]--;
        }
        for( int q = 0; q < n; q++ ){
            A[q] = res[q];
        }
    }
    return A;
}
public static int getTimes(int x, int k){
    int times = (int)Math.pow(10, k);
    return x / times % 10;
}

各个排序算法性能比较