【排序三部曲】2. 高级排序算法

474 阅读12分钟

归并排序

1. 代码

public static void merge(int[] arr, int left, int right) {
    if (left == right) {
        return ;
    }
    int middle = left + ((right - left) >> 1);
    merge(arr, left, middle);
    merge(arr, middle + 1, right);
    mergeHelp(arr, left, middle, right);
}

public static void mergeHelp(int[] arr, int left, int middle, int right) {
    // 排序的暂存数组
    int[] help = new int[right - left + 1];
    // 暂存数组的头指针
    int index = 0;
    // 左半部分头指针
    int p1 = left;
    // 右半部分头指针
    int p2 = middle + 1;
    // 在p1和p2都没有越界时
    while (p1 <= middle && p2 <= right) {
        help[index ++] = arr[p1] <= arr[p2] ? arr[p1 ++] : arr[p2 ++];
    }
    // p1或者p2越界,将多余的全部存入暂存数组
    while (p1 <= middle) {
        help[index ++] = arr[p1 ++];
    }
    while (p2 <= right) {
        help[index ++] = arr[p2 ++];
    }
    // 将help中排序好的元素覆盖原数组相应元素
    System.arraycopy(help, 0, arr, left, help.length);
}

2. master公式分析时间复杂度

从代码可知,a = 2,由T(N/2)得到b = 2, 由mergeHelp方法的时间复杂度为O(N)得到d = 1。

由master公式logba = 1 = d,所以,归并排序的时间复杂度 = O(N^d*logN) = O(NlogN)

注意:

  1. master公式:T(N) = aT(N/b) + O(N^d)

    • a:递归的次数
    • b:表示每次递归是原来的1/b个规模
    • O(N^d):子结果合并的时间复杂度

    满足如上公式的程序都可以根据master公式计算时间复杂度:

    • log(b,a) > d :时间复杂度为O(N^log(b,a))
    • log(b,a) = d :时间复杂度为O(N^d * logN))
    • log(b,a) < d :时间复杂度为O(N^d)
  2. 为什么归并排序时间复杂度能够做到O(NlogN)?

    在冒泡,选择,插入等一系列时间复杂度为O(N^2)的排序算法中,确定一个值的位置需要做大量的无用比较。而在归并排序中,比较的行为并没有被浪费,每一次比较的结果是让递归的子数组有序,导致每一轮合并的子数组都是有序的。这就代表了比较的行为并没有浪费,而是作为比较信息传递了下去。

3. 额外空间复杂度

额外空间复杂度 = O(N),因为归并排序是使用了外排序的方式,意思就是使用外部的数组作为暂存数组,排完序后再将暂存数组的内容拷贝到原数组。在该排序过程中,只需要创建和待排序数组的长度一致的暂存数组即可。

归并排序的扩展

1. 小和问题

  1. 描述

    比方说,[1, 3, 4, 2, 5]这个数组。

    对于1来说,左边没有数,小和 = 0。

    对于3来说,左边有1 ----> 1比3小,所以3的小和 = 1。

    对于4来说,左边有1、3 ----> 1、3都比4小,所以4的小和 = 1 + 3 = 4。

    对于2来说,左边有1、3、4 ----> 1比2小,所以2的小和 = 1。

    对于5来说,左边有1、3、4、2 ----> 1、3、4、2都比5小,所以5的小和 = 1 + 3 + 4 + 2 = 10。

    对于整个数组来说,小和是数组中每个元素的小和的和,所以数组的小和 = 0 + 1 + 4 + 1 + 10 = 16。

  2. 解决思路

    如果只按照描述来看,那么解法一定是暴力算法。对于每一个数,都要从数组的0位置遍历到该位置,事件复杂度为O(N^2)。

    如果我们换一个思路,还是[1, 3, 4, 2, 5]这个数组。

    对于1来说,右边有3、4、2、5 ----> 3、4、2、5都比1大,所以1的小和 = 4 * 1 = 4。

    对于3来说,右边有4、2、5 ----> 4、5都比3大,所以3的小和 = 2 * 3 = 6。

    对于4来说,右边有2、5 ----> 5比4大,所以4的小和 = 1 * 4 = 4。

    对于2来说,右边有5 ----> 5比2大,所以2的小和 = 1 * 2 = 2。

    对于5来说,右边没有数,小和 = 0。

    对于整个数组来说,小和是数组中每个元素的小和的和,所以数组的小和 = 4 + 6 + 4 + 2 = 16。

    按照上面描述的思路,可以使用归并排序的思路进行求解。

  3. 代码

    基于归并排序的思路,一边给数组排序,一边求小和,每一个数都不重算和漏算。

    每一轮左右组合并时,右组都不会产生小和。

    public static int smallSum(int[] arr, int left, int right) {
        if (left == right) {
            return 0;
        }
        int middle = left + ((right - left) >> 1);
        return smallSum(arr, left, middle)
            + smallSum(arr, middle + 1, right)
            + smallSumHelp(arr, left, middle, right);
    }
    
    public static int smallSumHelp(int[] arr, int left, int middle, int right) {
        int[] help = new int[right - left + 1];
        int p1 = left;
        int p2 = middle + 1;
        int index = 0;
        int smallSum = 0;
        while (p1 <= middle && p2 <= right) {
            smallSum += arr[p1] < arr[p2] ? (right - p2 + 1) * arr[p1] : 0;
            // 和归并唯一的区别在于:如果左组和右组元素相等时,右组元素进help
            help[index ++] = arr[p1] < arr[p2] ? arr[p1 ++] : arr[p2 ++];
        }
        while (p1 <= middle) {
            help[index ++] = arr[p1 ++];
        }
        while (p2 <= right) {
            help[index ++] = arr[p2 ++];
        }
        System.arraycopy(help, 0, arr, left, help.length);
        return smallSum;
    }
    

2. 逆序对问题

  1. 描述

    右边的数比左边的数小的两个数构成一个逆序对。

    比方说,[5, 3, 4, 2, 1]这个数组。

    对于5来说,右边有3、4、2、1比5小 ----> 逆序对:[5, 3]、[5, 4]、[5, 2]、[5, 1]

    对于3来说,右边有2、1比3小 ----> 逆序对:[3, 2]、[3, 1]

    对于4来说,右边有2、1比4小 ----> 逆序对:[4, 2]、[4, 1]

    对于2来说,右边有1比2小 ----> 逆序对:[2, 1]

    对于1来说,右边无数比1小 ----> 逆序对:无

    整个数组的逆序对数 = 4 + 2 + 2 + 1 = 9

快速排序

1. 划分数组

  1. 描述

    给一个数组,从数组中选出一个数target。按照target对数组进行划分。数组中小于target的数全部划分到数组的左边,数组中大于等于target的数全部划分到数组的右边。

    要求:时间复杂度为O(N),额外空间复杂度为O(1)。

  2. 思路

    在数组arr的最左侧设定一个小于target的区间,并开始从左向右遍历数组。

    如果arr[i] < target:arr[i]和区间相邻右边的数交换,并且区间右扩一位。

    如果arr[i] > target:继续遍历,不做任何操作。

    当 i 越界时,区间内所有的数全部小于等于target,区间外的数全部大于等于target。

  3. 代码

    整个遍历过程中,都是小于target的数逐渐把大于等于target的数往右边推。

    public static void divideArray(int[] arr, int target) {
        // 小于区间的边界
        int less = -1;
        for (int i = 0; i < arr.length; i ++) {
            if (arr[i] < target) {
                swap(arr, i, ++ less);
            }
        }
    }
    

2. 荷兰国旗问题

  1. 描述

    一个数组,从数组中选出一个数target。按照target对数组进行划分。数组中小于target的数全部划分到target的左边,数组中大于target的数全部划分到target的右边,数组中等于target的数全部排在小于target的数和大于target的数的中间。

    要求:时间复杂度为O(N),额外空间复杂度为O(1)。

  2. 思路

    在数组的最左边设置小于target的区域,在数组的最右边设置大于target的区域。

    遍历数组:

    • arr[i] < target:arr[i] 和小于target区域的下一个交换,小于target区域右扩,i++。
    • arr[i] = target:i++。
    • arr[i] > target:arr[i] 和大于target区域的前一个交换,大于target区域左扩,i不变。
  3. 代码

    public static void netherlandsFlag(int[] arr, int target) {
        // 标记小于target的区域
        int less = -1;
        // 标记大于target的区域
        int more = arr.length;
        int i = 0;
        // 当遍历的索引和大于target的区域重合时,分区结束
        while (i < more) {
            if (arr[i] < target) {
                swap(arr, i ++, ++ less);
            } else if (arr[i] > target) {
                swap(arr, i, -- more);
            } else {
                i ++;
            }
        }
    }
    

3. 快速排序

快排1.0和2.0的时间复杂度都是:O(N^2)。

快排1.0和2.0总是拿数组的最后一个值做划分。所以快排1.0和2.0的最坏情况为类似于[1, 2, 3, 4, 5, 6, 7]这样的数组,因为这样的数组划分值打的很偏,两次递归的数据规模差距太大,导致某一方的数据规模将近原数组的100%。当划分值打在中点的地方效果最好,根据master公式计算出时间复杂度才是O(N*logN)。

快排3.0时间复杂度:O(N*logN)。

快排3.0的划分值是随机选出来,然后放到最后一个位置上的。所以好情况和差情况都是概率事件,每一种情况的概率都是1/N,将所有事件求概率累加和数学期望得到时间复杂度为O(N*logN)。

public static int[] partition(int[] arr, int left, int right) {
    // 小于区域边界
    int less = left - 1;
    // 大于区域边界
    int more = right + 1;
    // 数组最后一个数作为划分目标
    int target = arr[right];
    int index = left;
    // 从左向右遍历(荷兰国旗问题)
    while (index < more) {
        if (arr[index] < target) {
            swap(arr, index ++, ++ less);
        } else if (arr[index] > target) {
            swap(arr, index, -- more);
        } else {
            index ++;
        }
    }
    // 小于区域边界和大于区域边界之间的数在这一轮就排好了
    return new int[]{less, more};
}

public static void quickSort(int[] arr, int left, int right) {
    if (left < right) {
        int random = (int) (Math.random() * (right - left));
        swap(arr, left + random, right);
        int[] bounds = partition(arr, left, right);
        quickSort(arr, left, bounds[0]);
        quickSort(arr, bounds[1], right);
    }
}

4. 额外空间复杂度

拿快排1.0和2.0来说,每一次都是拿数组最后一个数作为划分目标划分数组。

最坏情况是类似于[1, 2, 3, 4, 5, 6, 7]这样的数组,第一次递归时,划分出[1, 2, 3, 4, 5, 6]和[7]。第二次递归时,划分出[1, 2, 3, 4, 5]和[6],这样以来,递归在栈中就会开出N层。所以额外空间复杂度为O(N)。

最好情况就是划分目标在每一次划分之后,都会在数组的正中,这样就模拟了二叉树入栈的过程。这样以来,递归在栈中就会开出logN层,所以额外空间复杂度为O(logN)。

快排3.0的额外空间复杂度还是一个概率问题,将所有事件求概率累加和数学期望最后能够将额外空间复杂度收敛到O(logN)。

完全二叉树

1. 定义

一个满二叉树或者从左往右依次变满的二叉树为完全二叉树。

2. 数组模拟完全二叉树

  • 完全二叉树的节点数使用变量size来记录。
  • 构建完全二叉树的过程中,假新节点在数组中的下标为 i
    • 新节点的父节点在数组中的下标为 (i - 1) / 2
    • 新节点的左子节点在数组中的下标为 (i * 2) + 1
    • 新节点的右子节点在数组中的下标为 (i * 2) + 2

1. 定义

堆,是一种特殊的完全二叉树,只有大根堆和小根堆两种堆。

大根堆:在完全二叉树中,每一个子树中最大的节点是该子树的根节点。

小根堆:在完全二叉树中,每一个子树中最小的节点是该子树的根节点。

2. 堆的基本操作

堆的基本操作的单个使用或者组合使用可以解决堆的所有问题

HeapInsert过程

/**
 * 完全二叉树通过向上遍历调整构成堆
 * @param arr 模拟大根堆的数组
 * @param index 新节点在数组中的下标
 */
public static void heapInsert(int[] arr, int index) {
    while(arr[index] > arr[(index - 1) / 2]) {
        swap(arr, index, (index - 1) / 2);
        index = (index - 1) / 2;
    }
}

Heapify过程

 /**
  * 一次堆化过程
  * 完全二叉树通过向下遍历调整构建成堆
  * @param arr 承载完全二叉树的数组
  * @param index 当前节点在数组中的下标
  * @param heapSize 堆的容量
  */
public static void heapify(int[] arr, int index, int heapSize) {
    int leftChildIndex = (index * 2) + 1;
    // 判断当前节点是否有左孩子,有左孩子才会有右孩子
    while (leftChildIndex < heapSize) {
        // 左孩子和右孩子比较出一个大的
        int largestIndex = leftChildIndex + 1 < heapSize && arr[leftChildIndex + 1] > arr[leftChildIndex]
            ? leftChildIndex + 1 :leftChildIndex;
        // 左孩子和右孩子中大的那个再和自身比较出一个更大的
        largestIndex = arr[largestIndex] > arr[index] ? largestIndex : index;
        if (largestIndex == index) {
            break;
        }
        swap(arr, index, largestIndex);
        index = largestIndex;
        leftChildIndex = (index * 2) + 1;
    }
}

3. 总结

  • HeapInsert:一般用于在堆中插入新节点的调整,将堆中某一个节点值变小的调整。
  • Heapify:一般用于在堆中删除某个节点的调整,将堆中某一个节点值变大的调整。

堆排序

1. 过程

  • 先让待排序的数组形成一个大根堆,然后堆顶和堆中最后一个元素交换,此时最大的数就是数组中最后一个数。
  • 交换完成后,在 0~heapSize-1 的范围里进行 heapify 的过程,重新调整成大根堆。
  • 随着堆中元素的减少,每次减少的元素都会从数组的最后往前填充。
  • 每次都这么做,直到堆中的元素减完,最后就将原先无序的数组排好序了。

2. 代码

public static void heapSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return ;
    }
    // 构建大根堆
    for (int i = 0; i < arr.length; i ++) {
        heapInsert(arr, i);
    }
    // 最初的堆容量为数组元素个数
    int heapSize = arr.length;
    // 每一轮循环将堆中的根和最后一个节点交换,然后切断原根和堆的联系,对新根做heapify操作
    while (heapSize > 1) {
        swap(arr, 0, -- heapSize);
        heapify(arr, 0, heapSize);
    }
}

3. 时间复杂度

堆排序时间复杂度 = 构建大根堆时间复杂度 + 每一次heapify时间复杂度

由完全二叉树的性质可得heapInsert过程和heapify的时间复杂度都是O(logN),假设数组中有N个节点

  1. 构建大根堆的过程包含N次heapInsert过程,所以时间复杂度是O(NlogN)
  2. 每一次堆排序都需要进行一次heapify,平均的时间复杂度是O(NlogN)
  3. 堆排序时间复杂度也是O(NlogN)

注意:

如果在堆排序中,并不是由一个个节点heapInsert构建大根堆,而是在完全二叉树自底向上从右往左对每个节点heapify构建大根堆。那么在构建大根堆的操作上,第一个方法的时间复杂度为O(NlogN),第二个方法的时间复杂度为O(N),效率更高。

第二个方法的时间复杂度计算:

假设完全二叉树为满二叉树且有N个节点。

由完全二叉树的性质可得,最底层的节点是 N/2 数量级,倒数第二层的节点是 N/4 数量级,倒数第三次的节点是 N/8 数量级...以此类推。

最底层的节点进行heapify要最多调整1次,倒数第二层的节点进行heapify要最多调整2次,倒数第三层的节点进行heapify要最多调整3次...以此类推。

那么时间复杂度 :T(N) = (1 * N/2) + (2 * N/4) + (3 * N/8) + (4 * N/16) + ... —> ①式

①式等号两边 * 2 : 2 * T(N) = N + (2 * N/2) + (3 * N/4) + (4 * N/8) + ... —> ②式

②式 - ①式 : T(N) = N + N/2 + N/4 + N/8 + N/16 + ... —> 等比数列

由等比数列求和公式:T(N) = O(N)

使用第二个方法的代码:

public static void heapSort(int[] arr) {
     if (arr == null || arr.length < 2) {
         return ;
     }
     // 构建大根堆
     for (int i = arr.length - 1; i >= 0; i --) {
         heapify(arr, i, arr.length);
     }
     // 最初的堆容量为数组元素个数
     int heapSize = arr.length;
     // 每一轮循环将堆中的根和最后一个节点交换,然后切断原根和堆的联系,对新根做heapify操作
     while (heapSize > 1) {
         swap(arr, 0, -- heapSize);
         heapify(arr, 0, heapSize);
     }
}

4. 额外空间复杂度

  1. heapInsert中没有申请额外变量,没有递归
  2. heapify中有限几个变量,没有递归
  3. 堆排序额外空间复杂度是O(1)

堆排序的扩展

1. 堆扩容

通常情况下,都是使用数组实现堆结构的实际结构,使用变量heapSize来控制堆的范围。但是如果一直向数组中添加元素,那么数组一定会在一个时刻耗尽,如果数组耗尽,一般会成倍扩容。因为数组成倍扩容的操作不会对堆操作的时间复杂度产生较大的影响。

为什么数组的扩容不会影响堆的性能?

数组扩容,会开辟一个新数组,将原数组中所有的元素拷贝进入新数组,所以单次扩容的时间复杂度为:O(N)

再看扩容的次数,假设数组中有2N个元素,那么扩容的路径是:1 —> 2 —> 4 —> 8 —> 16 —> ... —> N,所以扩容的次数是logN。

所以整体时间复杂度为:O(NlogN)

如果将整体的时间代价平均到扩容的每一个元素时,那么时间复杂度为:O(NlogN) / N = O(logN)

所以数组的扩容并不会影响到堆的最终表现,只可能在堆操作的某一个时刻稍慢一点。

2. 系统堆结构和自定义堆结构

很多语言中都封装了堆结构,例如Java语言中的PriorityQueue,底层的实现就是小根堆。

系统提供的堆结构,类似于黑盒。程序员与黑盒交流的方式就是程序员给黑盒提供参数,黑盒返回一个结果给程序员。在不修改堆中元素的值时使用系统堆结构较为方便。

程序员如果在使用系统堆结构存储数据的过程中,改变了系统堆结构中数据的值,那么系统堆结构必须耗费大量的代价才能重新调整成堆结构,因为系统会对原堆中每一个元素观察是否需要heapInsert或者heapify。

程序员如果需要修改堆中的数据,并且只用极小的代价就可以重新调整成堆结构,那么就需要自己手写一个堆结构。这样,你就可以只对修改的那个数据做heapInsert或者heapify。

3. 比较器构建堆结构

使用比较器构建大根堆的比较规则

// 大根堆的构建规则是谁大谁排前面
public static class BigRootHeapComparator implements Comparator<Integer> {

    @Override
    public int compare(Integer arg1, Integer arg2) {
        return arg2 - arg1;
    }
}

通过比较器构建大根堆

// PriorityQueue默认无参为小根堆,但是传入比较器后,构建的就是大根堆
PriorityQueue<Integer> bigRootHeap = new PriorityQueue<>(new BigRootHeapComparator());

4. 面试题

已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离不可以超过k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数据进行排序。

public static void suitableSort(int[] arr, int k) {
    // 如果k大于数组长度,则没有意义
    if (k > arr.length) {
        return ;
    }
    // 构建小根堆
    PriorityQueue<Integer> heap = new PriorityQueue<>();
    int i, j;
    // 将数组前k个元素放入小根堆,小根堆容量恒定为k
    for (i = 0; i < k; i ++) {
        heap.add(arr[i]);
    }
    // 将数组k之后的元素每放入堆中一个,同时将小根堆的根弹出从左往右覆盖数组
    for (j = 0; j < arr.length; i ++, j ++) {
        heap.add(arr[i]);
        // poll方法弹出小根堆中最小的元素
        arr[j] = heap.poll();
    }
    // 当数组中最后一个元素也进入小根堆后,每次弹出根覆盖数组,将小根堆弹空
    while (!heap.isEmpty()) {
        arr[j ++] = heap.poll();
    }
}