sortaa.jpg

O(N^2)算法

冒泡排序

一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位。

两种优化方式

  • 记录当前轮次是否发生过交换,没有发生过交换表示数组已经有序;
  • 记录上次发生交换的位置,下一轮排序时只比较到此位置。
public static void bubbleSort(int[] arr) {
    for (int i = 0; i < arr.length - 1; i++) {
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
            }
        }
    }
}

选择排序

双重循环遍历数组,每经过一轮比较,找到最小元素的下标,将其交换至首位。

public static void selectionSort(int[] arr) {
    for (int i = 0; i < arr.length - 1; i++) {
        int minIdx = i;
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[j] < arr[minIdx]) {
                minIdx = j; //记录最小值的下标
            }
        }
        swap(arr, i, minIdx);
    }
}

二元选择排序

既然每轮遍历时找出了最小值,何不把最大值也顺便找出来呢?这就是二元选择排序的思想。

使用二元选择排序,每轮选择时记录最小值和最大值,可以把数组需要遍历的范围缩小一倍。

public static void selectionSort2(int[] arr) {
    int minIndex, maxIndex;
    // i 只需要遍历一半
    for (int i = 0; i < arr.length / 2; i++) {
        minIndex = i;
        maxIndex = i;
        for (int j = i + 1; j < arr.length - i; j++) {
            if (arr[minIndex] > arr[j]) {
                // 记录最小值的下标
                minIndex = j;
            }
            if (arr[maxIndex] < arr[j]) {
                // 记录最大值的下标
                maxIndex = j;
            }
        }
        // 如果 minIndex 和 maxIndex 都相等,那么他们必定都等于 i,且后面的所有数字都与 arr[i] 相等,此时已经排序完成
        if (minIndex == maxIndex) break;
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
        // 如果最大值的下标刚好是 i,由于 arr[i] 和 arr[minIndex] 已经交换了,所以这里要更新 maxIndex 的值。
        if (maxIndex == i) maxIndex = minIndex;
        // 将最大元素交换至末尾
        int lastIndex = arr.length - 1 - i;
        temp = arr[lastIndex];
        arr[lastIndex] = arr[maxIndex];
        arr[maxIndex] = temp;
    }
}

插入排序

插入排序有两种写法:

  • 交换法:在新数字插入过程中,不断与前面的数字交换,直到找到自己合适的位置。
  • 移动法:在新数字插入过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到自己的位置后,插入一次即可。
//交换法
public void insertSort(int[] arr) {
    for (int i = 0; i < arr.length; i++) {
        for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
            swap(arr, j, j-1);
        }
    }
}

小结:

  • 选择排序是不稳定的,冒泡排序、插入排序是稳定的;
  • 在这三个排序算法中,选择排序交换的次数是最少的;
  • 在数组几乎有序的情况下,插入排序的时间复杂度接近线性级别。

O(N*logN)算法

希尔排序

希尔排序本质上是对插入排序的一种优化,它利用了插入排序的简单,又克服了插入排序每次只交换相邻两个元素的缺点。它的基本思想是:

  • 将待排序数组按照一定的间隔分为多个子数组,每组分别进行插入排序。这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定间隔取一个值组成一组
  • 逐渐缩小间隔进行下一轮排序
  • 最后一轮时,取间隔为 1,也就相当于直接使用插入排序。但这时经过前面的「宏观调控」,数组已经基本有序了,所以此时的插入排序只需进行少量交换便可完成
public static void shellSort(int[] arr) {
    // 增量序列(增量序列的选择会极大地影响希尔排序的效率)
    for (int gap = arr.length / 2; gap > 0; gap--) {
        //分组
        for (int groupStartIdx = 0; groupStartIdx < gap; groupStartIdx++) {
            //插入排序
            for (int i = groupStartIdx; i < arr.length; i += gap) {
                for (int j = i; j >= gap && arr[j] < arr[j - gap]; j -= gap) {
                    swap(arr, j, j - gap);
                }
            }
        }
    }
}

堆排序

堆排序过程如下:

  • 用数列构建出一个大顶堆,取出堆顶的数字;
  • 调整剩余的数字,构建出新的大顶堆,再次取出堆顶的数字;
  • 循环往复,完成整个排序。
public static void heapSort(int[] arr) {
    // 构建初始大顶堆
    buildMaxHeap(arr);
    for (int i = arr.length - 1; i > 0; i--) {
        // 将最大值交换到数组最后
        swap(arr, 0, i);
        // 调整剩余数组,使其满足大顶堆
        maxHeapify(arr, 0, i);
    }
}

// 构建初始大顶堆
private static void buildMaxHeap(int[] arr) {
    // 从最后一个非叶子结点开始调整大顶堆,最后一个非叶子结点的下标就是 arr.length / 2-1
    for (int i = arr.length / 2 - 1; i >= 0; i--) {
        maxHeapify(arr, i, arr.length);
    }
}

// 调整大顶堆,第三个参数表示剩余未排序的数字的数量,也就是剩余堆的大小
private static void maxHeapify(int[] arr, int i, int heapSize) {
    // 左子结点下标
    int l = 2 * i + 1;
    // 右子结点下标
    int r = 2 * i + 2;
    // 记录根结点、左子树结点、右子树结点三者中的最大值下标
    int largest = i;
    // 与左子树结点比较
    if (l < heapSize && arr[l] > arr[largest]) {
        largest = l;
    }
    // 与右子树结点比较
    if (r < heapSize && arr[r] > arr[largest]) {
        largest = r;
    }
    if (largest != i) {
        // 将最大值交换为根结点
        swap(arr, i, largest);
        // 再次调整交换数字后的大顶堆
        maxHeapify(arr, largest, heapSize);
    }
}

快速排序

快速排序算法的基本思想是:

  • 从数组中取出一个数,称之为基数(pivot)
  • 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个区域
  • 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成

分区算法

双指针分区算法:从 left 开始,遇到比基数大的数,记录其下标;再从 right 往前遍历,找到第一个比基数小的数,记录其下标;然后交换这两个数。继续遍历,直到 left 和 right 相遇。然后就和刚才的算法一样了,交换基数和中间值,并返回中间值的下标。

public static void quickSort(int[] arr) {
    quickSort(arr, 0, arr.length - 1);
}

private static void quickSort(int[] arr, int left, int right) {
    // 如果区域内的数字少于 2 个,退出递归
    if (left >= right) return;

    int mid = partition(arr, left, right);
    quickSort(arr, left, mid - 1);
    quickSort(arr, mid + 1, right);
}

//将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
private static int partition(int[] arr, int start, int end) {
    int pivot = arr[start];
    int left = start + 1; //左边界
    int right = end; //右边界

    while (left < right) {
        // 找到第一个大于基数的位置
        while (left < right && arr[left] <= pivot) left++;
        // 找到第一个小于基数的位置
        while (left < right && arr[right] >= pivot) right--;
        if (left < right) {
            swap(arr, left, right);
            left++;
            right--;
        }
    }
    // 如果 left 和 right 相等,单独比较 arr[right] 和 pivot
    if (left == right && arr[right] > pivot) right--;
    // 将基数和中间数交换
    swap(arr, start, right);
    return right;
}

归并排序

递归将两个有序的列表合并成一个有序的列表

public static void mergeSort(int[] arr) {
    if (arr.length == 0) return;
    int[] result = mergeSort(arr, 0, arr.length - 1);
    // 将结果拷贝到 arr 数组中
    for (int i = 0; i < result.length; i++) {
        arr[i] = result[i];
    }
}

// 对 arr 的 [start, end] 区间归并排序
private static int[] mergeSort(int[] arr, int start, int end) {
    // 只剩下一个数字,停止拆分,返回单个数字组成的数组
    if (start == end) return new int[]{arr[start]};
    int middle = (start + end) / 2;
    // 拆分左边区域
    int[] left = mergeSort(arr, start, middle);
    // 拆分右边区域
    int[] right = mergeSort(arr, middle + 1, end);
    // 合并左右区域
    return merge(left, right);
}

// 将两个有序数组合并为一个有序数组
private static int[] merge(int[] arr1, int[] arr2) {
    int len1 = arr1.length, len2 = arr2.length;
    int[] result = new int[len1 + len2];
    int idx1 = 0, idx2 = 0;
    while (idx1 < len1 && idx2 < len2) {
        if (arr1[idx1] <= arr2[idx2]) {
            result[idx1 + idx2] = arr1[idx1++];
        } else {
            result[idx1 + idx2] = arr2[idx2++];
        }
    }
    // 将剩余数字补到结果数组之后
    while (idx1 < arr1.length)
        result[idx1 + idx2] = arr1[idx1++];
    while (idx2 < arr2.length)
        result[idx1 + idx2] = arr2[idx2++];
    
    return result;
}

O(N)算法

计数排序

伪计数排序

public static void countingSort9(int[] arr) {
    // 建立长度为 9 的数组,下标 0~8 对应数字 1~9
    int[] counting = new int[9];
    // 遍历 arr 中的每个元素
    for (int element : arr) {
        // 将每个整数出现的次数统计到计数数组中对应下标的位置
        counting[element - 1]++;
    }
    int index = 0;
    // 遍历计数数组,将每个元素输出
    for (int i = 0; i < 9; i++) {
        // 输出的次数就是对应位置记录的次数
        while (counting[i] != 0) {
            arr[index++] = i + 1;
            counting[i]--;
        }
    }
}

真正的计数排序

举个例子,班上有 1010 名同学:他们的考试成绩分别是:7, 8, 9, 7, 6, 7, 6, 8, 6, 67,8,9,7,6,7,6,8,6,6,他们需要按照成绩从低到高坐到 0~90~9 共 1010 个位置上。
用计数排序完成这一过程需要以下几步:

  • 第一步仍然是计数,统计出:44 名同学考了 66 分,33 名同学考了 77 分,22 名同学考了 88 分,1 名同学考了 99 分;
  • 然后从头遍历数组:第一名同学考了 77 分,共有 44 个人比他分数低,所以第一名同学坐在 44 号位置(也就是第 55 个位置);
  • 第二名同学考了 88 分,共有 77 个人(44 + 33)比他分数低,所以第二名同学坐在 77 号位置;
  • 第三名同学考了 99 分,共有 99 个人(44 + 33 + 22)比他分数低,所以第三名同学坐在 99 号位置;
  • 第四名同学考了 77 分,共有 44 个人比他分数低,并且之前已经有一名考了 77 分的同学坐在了 44 号位置,所以第四名同学坐在 55 号位置。
  • ...依次完成整个排序

区别就在于计数排序并不是把计数数组的下标直接作为结果输出,而是通过计数的结果,计算出每个元素在排序完成后的位置,然后将元素赋值到对应位置。

public static void countingSort9(int[] arr) {
    // 建立长度为 9 的数组,下标 0~8 对应数字 1~9
    int[] counting = new int[9];
    // 遍历 arr 中的每个元素
    for (int element : arr) {
        // 将每个整数出现的次数统计到计数数组中对应下标的位置
        counting[element - 1]++;
    }
    // 记录前面比自己小的数字的总数
    int preCounts = 0;
    for (int i = 0; i < counting.length; i++) {
        int temp = counting[i];
        // 将 counting 计算成当前数字在结果中的起始下标位置。位置 = 前面比自己小的数字的总数。
        counting[i] = preCounts;
        // 当前的数字比下一个数字小,累计到 preCounts 中
        preCounts += temp;
    }
    int[] result = new int[arr.length];
    for (int element : arr) {
        // counting[element - 1] 表示此元素在结果数组中的下标
        int index = counting[element - 1];
        result[index] = element;
        // 更新 counting[element - 1],指向此元素的下一个下标
        counting[element - 1]++;
    }
    // 将结果赋值回 arr
    for (int i = 0; i < arr.length; i++) {
        arr[i] = result[i];
    }
}

基数排序

将整数按位数切割成不同的数字,然后按每个位数分别比较。

  • 找出数组中最大的数字的位数 maxDigitLength
  • 获取数组中每个数字的基数
  • 遍历 maxDigitLength 轮数组,每轮按照基数对其进行排序
public class RadixSort {

    public static void radixSort(int[] arr) {
        if (arr == null) return;
        // 找出最大值
        int max = 0;
        for (int value : arr) {
            if (value > max) {
                max = value;
            }
        }
        // 计算最大数字的长度
        int maxDigitLength = 0;
        while (max != 0) {
            maxDigitLength++;
            max /= 10;
        }
        // 使用计数排序算法对基数进行排序
        int[] counting = new int[10];
        int[] result = new int[arr.length];
        int dev = 1;
        for (int i = 0; i < maxDigitLength; i++) {
            for (int value : arr) {
                int radix = value / dev % 10;
                counting[radix]++;
            }
            for (int j = 1; j < counting.length; j++) {
                counting[j] += counting[j - 1];
            }
            // 使用倒序遍历的方式完成计数排序
            for (int j = arr.length - 1; j >= 0; j--) {
                int radix = arr[j] / dev % 10;
                result[--counting[radix]] = arr[j];
            }
            // 计数排序完成后,将结果拷贝回 arr 数组
            System.arraycopy(result, 0, arr, 0, arr.length);
            // 将计数数组重置为 0
            Arrays.fill(counting, 0);
            dev *= 10;
        }
    }
}

hhhhh