算法学习笔记(一):重要排序算法及其应用

48 阅读8分钟

选择排序

从下标 ii 开始遍历,每次从 [i+1,n)[i + 1, n) 选择一个最小的数,放在位置 iiii 的起始位置为 0,结束位置为 n2n - 2

代码:

public static void selectionSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    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);
    }
}

冒泡排序

endend 为最终位置,从 i=0i = 0 开始,每次比较 arr[i]arr[i]arr[i+1]arr[i + 1],如果 arr[i]>arr[i+1]arr[i] > arr[i + 1],则交换,直到 i=end1i = end - 1,最后一轮交换完成,最大数“冒泡”到了 endend 的位置。endend 的起始位置为 n1n - 1,结束位置为 11

代码:

public static void bubbleSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    for (int end = arr.length - 1; end > 0; end--) {
        for (int i = 0; i < end; i++) {
            if (arr[i] > arr[i + 1]) {
                swap(arr, i, i + 1);
            }
        }
    }
}

插入排序

假设 [0,i1][0, i - 1] 已经有序,每次 ii 要进来,需要选一个位置插入进去。具体过程为从 i=1i = 1 开始([0,0][0, 0] 已经有序),令 j=i1j = i - 1,每次比较 arr[j]arr[j]arr[j+1]arr[j + 1],如果 arr[j]>arr[j+1]arr[j] > arr[j + 1],则两两交换,直到 j=0j = 0ii 的结束位置为 n1n - 1(即最后一个元素要插入进去)。

代码:

public static void insertionSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    for (int i = 1; i < arr.length; i++) {
        int j = i - 1;
        while (j >= 0 && arr[j] > arr[j + 1]) {
            swap(arr, j, j + 1);
            j--;
        }
    }
}

归并排序

递归实现

归并排序运用了分治的思想,既然要将 [0,n1][0, n - 1] 上的数进行排序,可以先对左半边 [0,mid][0, mid] 进行排序,然后再对右半边 [mid+1,n1][mid + 1, n - 1] 进行排序,最后,将两个半边进行合并,使用双指针的方法很容易实现。

代码:

public static void mergeSort(int[] arr, int l, int r) {
    if (l == r) {
        return;
    }
    int mid = (l + r) / 2;
    mergeSort(arr, l, mid);
    mergeSort(arr, mid + 1, r);
    merge(l, mid, r);
}

merge 过程需要一个辅助数组来实现:

public static void merge(int l, int mid, int r) {
    int i = l;
    int j = mid + 1;
    int k = l;
    while (i <= mid && j <= r) {
        help[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];
    }
    while (i <= mid) {
        help[k++] = arr[i++];
    }
    while (j <= r) {
        help[k++] = arr[j++];
    }
    for (k = l; k <= r; k++) {
        arr[k] = help[k];
    }
}

非递归实现

取步长为 1,2,4,8,16,...1, 2, 4, 8, 16, ...,每次进行 merge。

边界情况:左边有元素,右边没有元素(即 mid+1nmid + 1 \geq n),直接不用 merge 了,此步长结束。左边有元素,右边也有但不够,可以直接合并,rr 不能超过 n1n - 1

public static void mergeSort2() {
    for (int step = 1; step < n; step *= 2) {
        int l = 0, mid, r;
        while (l < n) {
            mid = l + step - 1;
            if (mid + 1 >= n) {
                break;
            }
            r = Math.min(l + (step * 2) - 1, n - 1);
            merge(l, mid, r);
            l = r + 1;
        }
    }
}

应用

分治思想:看一个问题在大范围上的答案是否等于,左半边的答案 + 右半边的答案 + 跨越左右产生的答案

归并排序特性:左半边和右半边都有序,在这种性质下,计算跨越左右产生的答案是是否能受益(即加快计算)。

例题一

计算数组的小和,在求解跨越左右产生的答案时,如果左右元素都有序,可以使用双指针做到 O(n)O(n) 的时间复杂度。ii 指向 lljj 指向 mid+1mid + 1,如果 imi \leq marr[i]arr[j]arr[i] \leq arr[j],则将 arr[i]arr[i] 累加到 sumsum,直到不满足条件,将 sum(rj+1)sum * (r - j + 1) 累积到 ansans。然后 jj 向后移动一位,ii 从上次循环的位置重复上述过程。

代码:

import java.io.*;
import java.util.*;

public class Main {
    private static int MAXN = 100001;

    private static int n;
    
    private static int[] s = new int[MAXN];

    private static int[] help = new int[MAXN];

    public static void main(String[] args) throws IOException {
        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
        StreamTokenizer in = new StreamTokenizer(bf);
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        in.nextToken();
        n = (int) in.nval;
        for (int i = 0; i < n; i++) {
            in.nextToken();
            s[i] = (int) in.nval;
        }
        out.println(smallSum(0, n - 1));
        out.flush();
        bf.close();
        out.close();
    }

    private static long smallSum(int l, int r) {
        if (l == r) {
            return 0;
        }
        int mid = (l + r) / 2;
        return smallSum(l, mid) + smallSum(mid + 1, r) + merge(l, mid, r);
    }

    private static long merge(int l, int mid, int r) {
        long ans = 0;
        for (int i = l, j = mid + 1; j <= r; j++) {
            long sum = 0;
            while (i <= mid && s[i] <= s[j]) {
                sum += s[i++];
            }
            ans += (sum * (r - j + 1));
        }
        
        int a = l, b = mid + 1, i = l;
        while (a <= mid && b <= r) {
            help[i++] = s[a] <= s[b] ? s[a++] : s[b++];
        }
        while (a <= mid) {
            help[i++] = s[a++];
        }
        while (b <= r) {
            help[i++] = s[b++];
        }
        for (i = l; i <= r; i++) {
            s[i] = help[i];
        }

        return ans;
    }
}

例题二

493. 翻转对,在求解跨越左右产生的答案时,同样如果左右元素都有序,可以使用双指针做到 O(n)O(n) 的时间复杂度。 ii 指向 lljj 指向 mid+1mid + 1,如果 i<=mi <= m 并且 nums[i]2nums[j]nums[i] \leq 2 * nums[j]ii 向后移动,表示 nums[i]nums[i] 不能加入到答案中,当不满足条件时,此时对于 nums[j]nums[j]ii 及其后面的元素都能加入到答案中,即 ans+=(midi+1)ans += (mid - i + 1)。然后 jj 向后移动一位,ii 从上次循环结束的位置重复上述过程。

代码:

class Solution {
    private static int MAXN = 50001;
    
    private static int[] help = new int[MAXN];

    public int reversePairs(int[] nums) {
        return count(nums, 0, nums.length - 1);
    }
    
    public int count(int[] arr, int l, int r) {
        if (l == r) {
            return 0;
        }
        int mid = (l + r) / 2;
        return count(arr, l, mid) + count(arr, mid + 1, r) + merge(arr, l, mid, r);
    }
    
    public int merge(int[] arr, int l, int mid, int r) {
        int ans = 0;
        for (int i = l, j = mid + 1; j <= r; j++) {
            while (i <= mid && (long) arr[i] <= (long) arr[j] * 2) {
                i++;
            }
            ans += (mid - i + 1);
        }
        
        int a = l, b = mid + 1, i = l;
        while (a <= mid && b <= r) {
            help[i++] = arr[a] <= arr[b] ? arr[a++] : arr[b++];
        }
        while (a <= mid) {
            help[i++] = arr[a++];
        }
        while (b <= r) {
            help[i++] = arr[b++];
        }
        for (i = l; i <= r; i++) {
            arr[i] = help[i];
        }
        
        return ans;
    }
}

快速排序

实现

对于一个待排序的数组,随机选择一个元素 xx,以 xx 对数组进行划分。划分完成后,[l,first1][l, first - 1] 的元素都小于 xx[first,last][first, last] 的元素都等于 xx[last+1,r][last + 1, r] 的元素都大于 xx

partition 代码:

public static int[] partition(int l, int r, int x) {
    /**
     * 1) arr[i] < x,发配到小于 x 的区域,交换 arr[i] 和 arr[first],first++, i++
     * 2) arr[i] = x, 中间区域就是等于 x, 直接 i++
     * 3) arr[i] > x, 发配到大于 x 的区域,交换 arr[i] 和 arr[last],last--,i 不变,last 交换过来后它的值还不确定
    */
    int first = l, last = r;
    int i = l;
    while (i <= last) {
        if (arr[i] == x) {
            i++;
        } else if (arr[i] < x) {
            swap(arr, first++, i++);
        } else {
            swap(arr, i, last--);
        }
    }
    return new int[]{first, last};
}

划分完成后,数组变成了 [<x,<x,<x,...,=x,=x,...,>x,>x,>x][<x, <x, <x, ..., =x, =x, ..., >x, >x, >x],等于 xx 的部分起始位置为 firstfirst,终止位置为 lastlast,接下来只需要递归地对 [l,first1][l, first - 1][last+1,r][last + 1, r] 进行快速排序。

代码:

public static void quickSort(int[] arr, int l, int r) {
    if (l >= r) {
        return;
    }
    int x = arr[l + (int) (Math.random() * (r - l + 1))];
    int[] res = partition(l, r, x);
    int left = res[0], right = res[1];
    quickSort(arr, l, left - 1);
    quickSort(arr, right + 1, r);
}

应用

例题一

数组中的第K个最大元素,初始时,ll 指向 00rr 指向 n1n - 1,进行 partition 操作,如果 ii 落在了 [first,last][first, last] 之间,直接返回 arr[i]arr[i],如果 i<firsti < first,说明左半边的元素个数大于 ii,需要去左边半找,即 r=first1r = first - 1i>lasti > last 同理。

class Solution {
    public int findKthLargest(int[] nums, int k) {
        return randomSelect(nums, nums.length - k);
    }
	
    // 求第 i 小的数(下标从 0 开始)
    public int randomSelect(int[] arr, int i) {
        int ans = 0;
        int l = 0, r = arr.length - 1;
        while (l <= r) {
            int x = arr[l + (int) (Math.random() * (r - l + 1))];
            int[] res = partition(arr, l, r, x);
            int first = res[0], last = res[1];
            if (i < first) {
                r = first - 1;
            } else if (i > last) {
                l = last + 1;
            } else {
                ans = arr[i];
                break;
            }
        }
        return ans;
    }

    public int[] partition(int[] arr, int l, int r, int x) {
        int first = l, last = r, i = l;
        while (i <= last) {
            if (arr[i] < x) {
                swap(arr, i++, first++);
            } else if (arr[i] > x) {
                swap(arr, i, last--);
            } else {
                i++;
            }
        }
        return new int[]{first, last};
    }

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

堆排序

堆的结构是一颗完全二叉树,堆分为大根堆和小根堆,对于每颗子树,最上面的元素都是最大的或最小的。

使用数组存储堆:ii 的父亲节点为 (i1)/2(i - 1) / 2ii 的左孩子为 i2+1i * 2 + 1,右孩子为 i2+2i * 2 + 2,使用 sizesize 标识堆的大小,防止越界操作。

操作(以大根堆为例):

  • 向上调整大根堆:如果 arr[i]arr[i] 大于它的父亲 arr[(i1)/2]arr[(i - 1) / 2],则相互交换,重复该过程。
  • 向下调整大根堆:自己和左孩子、右孩子比较,最大的那个数放到当前位置,令 ii 指向该位置,重复过程。
public static void heapInsert(int i) {
    while (arr[i] > arr[(i - 1) / 2]) {
        swap(arr, i, (i - 1) / 2);
        i = (i - 1) / 2;
    }
}

public static void heapify(int i, int size) {
    int l = i * 2 + 1;
    while (l < size) {
        int best = l + 1 < size && arr[l + 1] > arr[l] ? l + 1 : l;
        best = arr[best] > arr[i] ? best : i;
        // 如果最大元素是它自己,则直接结束 heapify 过程
        if (best == i) {
            break;
        }
        swap(arr, best, i);
        i = best;
        l = i * 2 + 1;
    }
}

堆排序:对于一个数组,首先需要建堆,可以等价于依次对每个数进行 heapInsert 操作,最后 sizesizenn。然后进行排序,此时 00 位置为最大元素 arr[0]arr[0],直接把它与最后位置的数 arr[n1]arr[n - 1] 交换,并对 00 位置进行 heapify 操作,重复此过程直到 size=1size = 1

public static void heapSort() {
    for (int i = 0; i < n; i++) {
        // 向堆中插入元素
        heapInsert(i);
    }
    int size = n;
    while (size > 1) {
        // 把最大的数放在最后
        swap(arr, 0, --size);
        // 刚才最后的数来到了 0,做一次 heapify 操作,保持大根堆的性质
        heapify(0, size);
    }
}

上述建堆过程时间复杂度为 log1+log2+...+logn=nlognlog1 + log2 + ... + logn = n * logn,此建堆过程为自顶向下,如果自底向上建堆,可以做到 O(n)O(n)

public static void heapSort() {
    // 从最后一个父节点开始进行 heapify
    for (int i = (n - 1) / 2; i >= 0; i--) {
        heapify(i, n);
    }
    int size = n;
    while (size > 1) {
        // 把最大的数放在最后
        swap(arr, 0, --size);
        // 刚才最后的数来到了 0,做一次 heapify 操作,保持大根堆的性质
        heapify(0, size);
    }
}

基数排序

假设数组中元素都是非负数,如果有负数,可以加上最小值全部变为正值。

// round 为数组元素中的最大位数
public static void radixSort(int round) {
    for (int offset = 1; round > 0; offset *= 10, round--) {
        int[] cnt = new int[10];
        for (int i = 0; i < n; i++) {
            cnt[(i / offset) % 10]++;
        }
        /**
             * 前缀数量分区技巧
             * 统计出 cnt 后,进行前缀和计算,表示:
             * <= 0 的数有 cnt[0] 个
             * <= 1 的数有 cnt[1] 个
             * ...
             * <= 9 的数有 cnt[9] 个
             * 目标:将 arr 中的元素按照 (arr[i] / offset) % 10 放入 help 中
             * 从后往前遍历 arr,保证稳定性
             * 假设当前 (arr[i] / offset) % 10 = 2,并且 <= 2 的数有 8 个
             * 所以可以将它放在 help[7]
             */
        for (int i = 1; i < 10; i++) {
            cnt[i] += cnt[i - 1];
        }
        for (int i = n - 1; i >= 0; i--) {
            help[--cnt[(arr[i] / offset) % 10]] = arr[i];
        }
        for (int i = 0; i < n; i++) {
            arr[i] = help[i];
        }
    }
}