【算法】day10 分治

1、快速排序

题目912. 排序数组 - 力扣(LeetCode)

分析:朴素的快排是将数组分两类:大于等于 key 和 小于 key。 时间复杂度:N*树高,理想情况(基准点最最后在中间) O(nlogn);最差情况(基本有序,基准点最后在边上)O(n^2)。

为了尽量避免最差情况带来的低效率,我们做出以下优化:

  • 类似于 day6 的颜色分类,把数组分三类:小于 key、等于 key、大于 key。目的是当存在大量重复数字时,直接省去对等于 key 的子数组递归。
  • 随机选取基准,让基准最后的位置递归的左右子数组划分长度更均匀(让树高尽量低)。

时间复杂度:更接近 O(nlogn)

代码

复制代码
class Solution {
    private static final Random random = new Random();

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

    private void qsort(int[] arr, int l, int r) {
        if(l>=r) return; // 空数组或者只有一个数,不用再排序
        int key = arr[l+random.nextInt(r-l+1)]; // 随机获取一个基准 l+(0~n-1)
        // 实现三划分
        int left = l-1, right = r+1, i = l;
        while(i < right) {
            if(arr[i] < key) swap(arr, ++left, i++);
            else if(arr[i] == key) i++;
            else swap(arr, --right, i);
        }
        // 递归,继续排序子数组
        qsort(arr, l, left);
        qsort(arr, right, r);
        return;
    }

    public int[] sortArray(int[] arr) {
        qsort(arr, 0, arr.length-1);
        return arr;
    }
}

2、数组中的第K个最大元素(快速选择)hot

题目215. 数组中的第K个最大元素 - 力扣(LeetCode)

分析

法一:基于堆排序的选择。建立大根堆,做 k-1 次删除堆顶后,堆顶元素就是第 k 大。时间复杂度:建堆 O(n)+删 k 次堆顶O(klogn)=O(n+klogn)=O(n+nlogn)=O(nlogn);空间复杂度:树高 O(logn),因为若是树根向下调整最多调整树高次子树,即递归树高次。

法二:基于快速排序的选择。三划分,然后去对应区间找第 K 大。

  • K <= c:递归 [right, r]。
  • c < K <= c+b:肯定是 key,直接返回 key。
  • c+b < K <= c+b+a:递归 [l, left],更新 K=K-(b+c)。

时间复杂度:O(n),这是我背的,想证明得两页多;空间复杂度:O(logn)。

代码

java 复制代码
class Solution {
    private static Random random = new Random();

    private void swap(int[] nums, int a, int b) {
        int tmp = nums[a];
        nums[a] = nums[b];
        nums[b] = tmp;
    }

    public int findKthLargest(int[] nums, int k) {
        return findK(nums, 0, nums.length-1, k);
    }

    private int findK(int[] nums, int l, int r, int k) {
        if (l == r) return nums[l]; // 最终都会找到第 k 大数,停止递归
        // 三划分
        int left = l-1, right = r+1, i = l;
        int key = nums[l + random.nextInt(r-l+1)];
        while(i < right) {
            if (nums[i] < key) swap(nums, ++left, i++);
            else if (nums[i] == key) i++;
            else swap(nums, --right, i);
        }
        // 到对应区间找第 K 大
        int c = r-right+1;
        int b = right-left-1;
        int a = left-l+1;
        if (k <= c) return findK(nums, right, r, k);
        else if (k <= c+b) return key;
        else return findK(nums, l, left, k-(b+c));
    }
}

3、最小的 k 个数(快速选择)

题目面试题 17.14. 最小K个数 - 力扣(LeetCode)

分析

法一:从小到大排序,然后获得前 k 个,时间复杂度 O(nlogn) 空间复杂度:快速O(1) 但时间复杂度最坏O(n^2);堆递归O(logn)。

法二:基于堆排序的选择。先构建大小为 k 的大根堆,遍历剩下的数,有比堆顶小的就删除堆顶,然后入堆。遍历结束后,堆中的 k 个元素就是前 k 小。 时间复杂度:插入/删除操作O(logk),最坏情况 n 个数都进行了插入删除,O(nlogk) 。空间复杂度:堆的空间 O(k)+递归的空间O(logk) = O(k)

法三:基于快速排序的选择。每个子数组:随机选择基准+三划分。

  • k <= a:在 < key 的子数组里找前 k 小的数。
  • a < k <= a+b:返回 < key 的子数组+(k-a)个key,即数组中前 k 个数。
  • a+b < k <= a+b+c:确定前 a+b 小的数,在 > key 的子数组里找前 k-(a+b) 小的数。

时间复杂度:背的 O(n)

空间复杂度:最坏O(n),通常最理想O(logn);也有可能 O(1),数字的所有元素相同的情况。

代码

java 复制代码
class Solution {
    private static Random random = new Random();

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

    private void qsort(int[] arr, int l, int r, int k) {
        if (l >= r) return; // 没子数组了,不用排了
        // 随机选取基准+三划分
        int key = arr[l+random.nextInt(r-l+1)];
        int left = l-1, right = r+1, i = l;
        while(i < right) {
            if (arr[i] < key) swap(arr, ++left, i++);
            else if (arr[i] == key) i++;
            else swap(arr, --right, i);
        }
        // 分情况找前 k 小
        int a = left-l+1;
        int b = right-left-1;
        int c = r-right+1;
        if (k <= a) qsort(arr, l, left, k);
        else if (k <= a+b) return;
        else qsort(arr, right, r, k-(a+b));
    }

    public int[] smallestK(int[] arr, int k) {
        qsort(arr, 0, arr.length-1, k);
        int[] ret = new int[k];
        for(int i = 0; i < k; i++) ret[i] = arr[i];
        return ret;
    }
}

4、归并排序

题目912. 排序数组 - 力扣(LeetCode)

分析:前面的快速排序:先排序,后划分(类似二叉树先序遍历);归并排序:先划分,后合并时排序(类似二叉树后序遍历)。时间复杂度:树高 O(logn) * 每层遍历个数 O(n) = O(nlogn)。空间复杂度:递归深度 O(logn) + 额外数组存放合并后的数组 O(n) = O(n)

代码

java 复制代码
class Solution {
    private static int[] tmp;

    // 归并排序
    public int[] sortArray(int[] arr) {
        tmp = new int[arr.length];
        qsort(arr, 0, arr.length-1);
        return arr;
    }

    private void qsort(int[] arr, int l, int r) {
        if (l >= r) return; // 终止条件,没有子数组了不再需要排序
        // 先划分
        int mid = l+(r-l)/2;
        qsort(arr, l, mid);
        qsort(arr, mid+1, r);
        // 再合并
        merge(arr, l, mid, r);
    }

    private void merge(int[] arr, int l, int mid, int r) {
        int i = l, j = mid+1, k = 0;
        while(i <= mid && j <= r)
            tmp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];
        while(i <= mid) tmp[k++] = arr[i++];
        while(j <= r) tmp[k++] = arr[j++];
        // 复位
        for(int m = l; m <= r; m++) {
            arr[m] = tmp[m-l];
        }
    }
}

5、数组中的逆序对(基于归并排序)

题目LCR 170. 交易逆序对的总数 - 力扣(LeetCode)

分析

暴力解法:固定一个数,然后遍历后续数字查找。时间复杂度 O(n^2)。

基于归并排序:先计数左子数组中的逆序对,再计数右子数组的逆序对,最后计数一左一右的逆序对。

若是升序排序:

① 以 cur1 为准:找谁比我小。

  • cur1>cur2,cur2 及 cur2 前的都比 cur1 小,计数 (cur2-(mid+1)+1),cur2 之后的不确定,cur2++,但后续会重复计数。行不通。

② 以 cur2 为准:谁比我大。

  • cur1 > cur2,cur1 之前的都比 cur2 小(升序),cur1 及 cur1 之后的都比 cur2 大,计数(mid-cur1+1),此时的 cur2 已找完比它大的,找下一个 cur2,cur2++。
  • cur1 <= cur2,cur1 及其之前的都小于等于 cur2 ,不需要统计,但 cur1 之后的还不确定,cur1++。

若是降序排序:

① 以 cur1 为准:谁比我小

  • cur1 > cur2,cur2 之前的都比 cur1 大,cur2 及其之后的都比 cur1 小,已找完此时 cur1 在右子数组中所有比他小的,统计(r-cur2+1),cur1++。
  • cur1 < cur2,cur2 及其之前的都比 cur1 大,之后的不确定,cur2++,继续找。

② 以 cur2 为准,行不通。

时间复杂度:O(nlogn),空间复杂度 O(n)

代码

java 复制代码
class Solution {
    private static int[] tmp;

    public int reversePairs(int[] record) {
        tmp = new int[record.length];
        return sort(record, 0, record.length-1);
    }

    private int sort(int[] nums, int l, int r) {
        if (l >= r) return 0; // 子数组不够两个数,没有逆序对
        // 先排序,左子数组逆序对 + 右子数组逆序对
        int mid = l+(r-l)/2;
        int ret = sort(nums, l, mid) + sort(nums, mid+1, r);
        // 合并,同时找出一左一右的逆序对个数
        int i = l, j = mid+1, k = 0;
        while (i <= mid && j <= r) {
            if (nums[i] > nums[j]) {
                ret += mid-i+1;
                tmp[k++] = nums[j++];
            } else tmp[k++] = nums[i++];
        }
        while(i <= mid) tmp[k++] = nums[i++];
        while(j <= r) tmp[k++] = nums[j++];
        // 复位
        for(int m=l; m <= r; m++)
            nums[m] = tmp[m-l];
        return ret;
    }
}

6、计算右侧小于当前元素的个数

题目315. 计算右侧小于当前元素的个数 - 力扣(LeetCode)

分析

暴力解法:O(n^2)

基于归并排序:左子数组计数+右子数组计数+一左一右计数。

一左一右计数算法,符合合并时的规律:

以左子树的 cur1 为基准,谁比我小。

① 降序排序:

  • cur1 > cur2,cur2 之前的都比 cur1 大,cur2 及其之后的都比 cur1 小,计数 (r-cur2+1),已找完 cur1 所有满足条件的右子数组元素,cur1++。
  • cur1 <= cur2,cur2 及其之前的都大于等于 cur1,cur2 之后的不确定,目前没有满足条件的不计数,cur2++。

② 升序排序:

  • cur1 > cur2,cur2 及其之前的都比 cur1 小,计数 (cur2-(mid+1)+1),cur2 之后的不确定,继续 cur2++,但后续会重复计数,不可行。

注意:需要用 counts 数组计数原数组各位置元素满足条件的右元素个数,但是排序时已经把顺序打乱了,所以需要先用 index 数组记录每个元素的位置,然后位置同元素一起排序(元素与位置绑定),计数时通过 index 获取索引 i 后,计数 count[i]。

代码

java 复制代码
class Solution {
    private static int[] index; // 元素原索引
    private static int[] tmp; // 暂存放合并后的子数组
    private static int[] tmp2; // 暂存放合并后的子数组原索引
    private static int[] counts; // 计数原数组每个元素其右满足条件的元素个数

    public List<Integer> countSmaller(int[] nums) {
        int n = nums.length;
        index = new int[n];
        tmp = new int[n];
        tmp2 = new int[n];
        counts = new int[n];
        // 初始化 index 数组
        for(int i = 0; i < nums.length; i++) index[i] = i;
        sort(nums, 0, nums.length-1);

        List<Integer> ret = new ArrayList<>();
        for(int count : counts) ret.add(count);
        return ret;
    }

    private void sort(int[] nums, int l, int r) {
        if (l >= r) return; // 子数组不够两个数,没有满足条件的
        // 先排序,统计左子数组、统计右子数组
        int mid = l+(r-l)/2;
        sort(nums, l, mid);
        sort(nums, mid+1, r);
        // 合并,统计一左一右
        int i = l, j = mid+1, k = 0;
        while (i <= mid && j <= r) {
            if (nums[i] > nums[j]) {
                counts[index[i]] += r-j+1; // 统计
                tmp2[k] = index[i];
                tmp[k++] = nums[i++];
            } else {
                tmp2[k] = index[j];
                tmp[k++] = nums[j++];
            }
        }
        while(i <= mid) {
            tmp2[k] = index[i];
            tmp[k++] = nums[i++];
        }
        while(j <= r) {
            tmp2[k] = index[j];
            tmp[k++] = nums[j++];
        }
        // 复位
        for(int m=l; m <= r; m++) {
            index[m] = tmp2[m-l];
            nums[m] = tmp[m-l];
        }   
    }
}

7、翻转对

题目493. 翻转对 - 力扣(LeetCode)

分析:暴力解法 O(n^2)

基于归并排序:

以 cur1 为基准,找右子数组中满足条件的。

降序:

  • cur1 <= 2*cur2,cur2 之前的都大于 cur2,因此 cur2 及其之前的 2 倍肯定大于等于 cur1,不计数;cur2 之后的不确定,cur2++。
  • cur1 > 2*cur2(cur2 一直++后,出现的第一个 cur2,其2倍比 cur1 小),cur2 之前的2倍都大于等于 cur1,不计数。cur2 及其之后的 2 倍肯定比 cur1 小,计数 (r-cur2+1),此时的 cur1 与之匹配的 cur2 个数已全部统计,cur1++。(cur1++后,cur1变小,当前cur2 之前的2倍都比上一个 cur1大,因此cur2之前的2倍肯定比当前 cur1 大不满足条件,因此 cur2 不需要回退)
  • 但因为归并排序的判断条件是 cur1 与 cur2 的比较;而翻转对的判断是 cur1 与 2*cur2 的比较,因此不能把翻转对的统计放到合并时执行。
  • 我们希望利用一左一右数组的有序性,因此在合并之前统计翻转对。

以 cur2 为准,找左子数组中满足 cur1*1/2 > cur2

升序:

  • cur1*1/2 <= cur2,cur1 之前的都小于 cur1,cur1及其之前的 1/2 都小于等于 cur2,不满足条件,不统计;cur1 之后的1/2 不确定,因此 cur1++。
  • cur1*1/2 > cur2(左子数组中找到的第一个 cur1 与此时的 cur2 匹配),cur1 之前的都不匹配,cur1 之后的肯定比 cur1 大,都匹配,计数 (mid-cur1+1),与当前 cur2 匹配的 cur1 已全部统计,找下一个 cur2 与之匹配的 cur1 个数,cur2++。

时间复杂度:O(2n*logn) = O(nlogn);空间复杂度:O(n)

代码

java 复制代码
class Solution {
    private static int[] tmp;

    public int reversePairs(int[] nums) {
        tmp = new int[nums.length];
        return sort(nums, 0, nums.length-1);
    }

    private int sort(int[] nums, int l, int r) {
        if (l >= r) return 0; // 子数组不够两个数,没有满足条件的
        // 先排序,统计左子数组+右子数组
        int mid = l+(r-l)/2;
        int ret = sort(nums, l, mid) + sort(nums, mid+1, r);
        // 统计一左一右
        int cur1 = l, cur2 = mid+1;
        while(cur1 <= mid && cur2 <= r) {
            // *2 转换为 /2,避免溢出
            if (nums[cur1]/2.0 <= nums[cur2]) cur2++;
            else {
                ret += r-cur2+1;
                cur1++;
            }
        }
        // 合并
        int i = l, j = mid+1, k = 0;
        while (i <= mid && j <= r) tmp[k++] = nums[i] >= nums[j] ? nums[i++] : nums[j++];
        while(i <= mid) tmp[k++] = nums[i++];
        while(j <= r) tmp[k++] = nums[j++];
        // 复位
        for(int m=l; m <= r; m++) nums[m] = tmp[m-l];
        return ret;  
    }
}
相关推荐
liu****3 小时前
1.模拟算法
开发语言·c++·算法·1024程序员节
又是忙碌的一天3 小时前
算法学习 13
数据结构·学习·算法
June`3 小时前
前缀和算法:高效解决区间和问题
算法·1024程序员节
再卷也是菜3 小时前
算法基础篇(9)倍增与离散化
c++·算法
pearlthriving4 小时前
list的介绍
数据结构·list·1024程序员节
Lei_3359675 小时前
[数据结构]哈希表、字符串哈希
数据结构·哈希算法·散列表
给大佬递杯卡布奇诺6 小时前
FFmpeg 基本数据结构 AVInputFormat 分析
数据结构·c++·ffmpeg·音视频
ゞ 正在缓冲99%…6 小时前
leetcode2826.将三个组排序
算法·leetcode·动态规划
给大佬递杯卡布奇诺6 小时前
FFmpeg 基本数据结构 AVCodecContext分析
数据结构·c++·ffmpeg·音视频