LeetCode题解随笔:排序算法

目录

912. 排序数组

一、归并排序

 315. 计算右侧小于当前元素的个数[*]

493. 翻转对

327. 区间和的个数[*]

 拓展:分治思想

241. 为运算表达式设计优先级[*]

二、快速排序

三、拓展

寻找第K个最大/最小的元素:优先队列

 煎饼排序


912. 排序数组

一、归并排序

归并排序的过程可以在逻辑上抽象成一棵二叉树,树上的每个节点的值是 nums[lo..hi],叶子节点的值就是数组中的单个元素,排序过程如下图所示(来源:labuladong)。

merge 操作会在二叉树的每个节点上都执行一遍,执行顺序是二叉树后序遍历的顺序。 

class Solution {
public:
    vector<int> sortArray(vector<int>& nums) {
        temp.resize(nums.size());
        Sort(nums, 0, nums.size() - 1);
        return nums;
    }
private:
    // 初始化一个辅助数组,避免递归时内存频繁分配释放
    vector<int> temp;
    void Sort(vector<int>& nums, int left, int right) {
        // 只剩下一个元素,无需继续排序
        if (left == right) {
            return;
        }
        int mid = left + (right - left) / 2;
        // 排序左侧数组[left, mid]
        Sort(nums, left, mid);
        // 排序右侧数组[mid + 1, right]
        Sort(nums, mid + 1, right);
        // 将有序的左右数组按顺序合并
        Merge(nums, left, mid, right);
    }
    void Merge(vector<int>& nums, int left, int mid, int right) {
        // 把 nums[left, right] 复制到辅助数组中,以便合并后的结果能够直接存入 nums
        for (size_t i = left; i <= right; ++i)   temp[i] = nums[i];
        // 利用双指针技巧,合并两个有序数组
        int left_p = left, right_p = mid + 1;
        for (size_t p = left; p <= right; ++p) {
            // 左侧数组已经merge完毕,只需要移动右侧数组的指针
            if (left_p == mid + 1)   nums[p] = temp[right_p++];
            // 右侧数组已经merge完毕,只需要移动左侧数组的指针
            else if (right_p == right + 1)   nums[p] = temp[left_p++];
            // 升序:左侧数比右侧数小,加入左侧元素
            else if (temp[left_p] < temp[right_p])   nums[p] = temp[left_p++];
            // 升序:右侧数比左侧数小,加入右侧元素
            else   nums[p] = temp[right_p++];
        }
    }
};

归并排序的技巧在于:不是在 merge 函数执行的时候 new 辅助数组,而是提前把 temp 辅助数组 new 出来了,这样就避免了在递归中频繁分配和释放内存可能产生的性能问题。

 时间复杂度:O(NlogN)

对于归并排序来说,时间复杂度集中在 merge 函数遍历 nums[left,right] 的过程。Merge执行的次数是二叉树节点的个数,每次执行的复杂度就是每个节点代表的子数组的长度,所以总的时间复杂度就是整棵树中「数组元素」的个数

从整体上看,二叉树的高度是 logN,其中每一层的元素个数就是原数组的长度 N,所以总的时间复杂度就是 O(NlogN)。【logN层,每层N次】

 315. 计算右侧小于当前元素的个数[*]

class Solution {
    typedef pair<int, int> Vidx;
public:
    vector<int> countSmaller(vector<int>& nums) {
        // 初始化数组,使之成为键值对,携带下标信息
        vector<Vidx> arr;
        for (int i = 0; i < nums.size(); ++i) {
            arr.push_back(make_pair(nums[i], i));
        }
        temp.resize(nums.size());
        count.resize(nums.size());
        Sort(arr, 0, arr.size() - 1);
        return count;
    }
private:
    // 记录右侧小于 nums[i] 的元素的数量
    vector<int> count;
    // 初始化一个辅助数组,避免递归时内存频繁分配释放;同时还要记录下标位置
    vector<Vidx> temp;
    void Sort(vector<Vidx>& arr, int left, int right) {
        // 只剩下一个元素,无需继续排序
        if (left == right) {
            return;
        }
        int mid = left + (right - left) / 2;
        // 排序左侧数组[left, mid]
        Sort(arr, left, mid);
        // 排序右侧数组[mid + 1, right]
        Sort(arr, mid + 1, right);
        // 将有序的左右数组按顺序合并
        Merge(arr, left, mid, right);
    }
    void Merge(vector<Vidx>& arr, int left, int mid, int right) {
        // 把 nums[left, right] 复制到辅助数组中,以便合并后的结果能够直接存入 nums
        for (size_t i = left; i <= right; ++i)   temp[i] = arr[i];
        // 利用双指针技巧,合并两个有序数组
        int left_p = left, right_p = mid + 1;
        for (size_t p = left; p <= right; ++p) {
            // 左侧数组已经merge完毕,只需要移动右侧数组的指针
            if (left_p == mid + 1)   arr[p] = temp[right_p++];
            // 右侧数组已经merge完毕,只需要移动左侧数组的指针
            else if (right_p == right + 1) {
                arr[p] = temp[left_p++];
                // 更新 count 数组
                count[arr[p].second] += right_p - mid - 1;
            }   
            // 升序:左侧数比右侧数小,加入左侧元素
            else if (temp[left_p].first <= temp[right_p].first) {
                arr[p] = temp[left_p++];
                // 更新 count 数组
                count[arr[p].second] += right_p - mid - 1;
            }   
            // 升序:右侧数比右侧数小,加入右侧元素
            else   arr[p] = temp[right_p++];
        }
    }
};

在使用 merge 函数合并两个有序数组的时候,其实是可以知道一个元素 nums[i] 后边有多少个元素比 nums[i] 小的。如下图所示(来源:labuladong):

把 temp[i] 放到 nums[p] 上时,我们不仅知道 temp[i] < temp[j],还能确定 左闭右开区间 [mid + 1, j) 中的元素都是 temp[i] 右侧的、较小的元素。

换句话说,在对 nuns[lo..hi] 合并的过程中,每当执行 nums[p] = temp[i] 时,就可以确定 temp[i] 这个元素后面比它小的元素个数为 j - mid - 1

 count应当不断被累加,因为每次递归时,累加的都是新的右侧的数组中比nums[p]小的元素数量。

493. 翻转对

class Solution {
public:
    int reversePairs(vector<int>& nums) {
        res = 0;
        temp.resize(nums.size());
        Sort(nums, 0, nums.size() - 1);
        return res;
    }
private:
    int res;
    // 初始化一个辅助数组,避免递归时内存频繁分配释放
    vector<int> temp;
    void Sort(vector<int>& nums, int left, int right) {
        // 只剩下一个元素,无需继续排序
        if (left == right) {
            return;
        }
        int mid = left + (right - left) / 2;
        // 排序左侧数组[left, mid]
        Sort(nums, left, mid);
        // 排序右侧数组[mid + 1, right]
        Sort(nums, mid + 1, right);
        // 将有序的左右数组按顺序合并
        Merge(nums, left, mid, right);
    }
    void Merge(vector<int>& nums, int left, int mid, int right) {
        // 把 nums[left, right] 复制到辅助数组中,以便合并后的结果能够直接存入 nums
        for (size_t i = left; i <= right; ++i)   temp[i] = nums[i];
        
        // 对两个有序数组,判断左侧元素nums[i] > 2*nums[j]是否成立
        // 由于数组有序,可以优化统计效率:维护左闭右开区间 [mid+1, end) 中的元素乘 2 小于 nums[i]
        int end = mid + 1;
        for (size_t i = left; i <= mid; ++i) {
            while (end <= right && (long)nums[i] > 2 * (long)nums[end])  end++;
            res += end - mid - 1;
        }

        // 利用双指针技巧,合并两个有序数组
        int left_p = left, right_p = mid + 1;
        for (size_t p = left; p <= right; ++p) {
            // 左侧数组已经merge完毕,只需要移动右侧数组的指针
            if (left_p == mid + 1)   nums[p] = temp[right_p++];
            // 右侧数组已经merge完毕,只需要移动左侧数组的指针
            else if (right_p == right + 1)   nums[p] = temp[left_p++];
            // 升序:右侧数比左侧数小,加入右侧元素
            else if (temp[left_p] > temp[right_p])   nums[p] = temp[right_p++];
            // 升序:左侧数比右侧数小,加入左侧元素
            else   nums[p] = temp[left_p++];
        }
    }
};

本题所在Merge函数中添加了一段代码,在两个有序数组内,寻找右侧数组小于左侧数组当前元素的一半的元素数量。

【这种i<j,或寻找右侧/左侧元素个数的问题,都可以用分治思想解决,都可以借助于归并排序的模板】

327. 区间和的个数[*]

class Solution {
public:
    int countRangeSum(vector<int>& nums, int lower, int upper) {
        res = 0;
        this->lower = lower;    
        this->upper = upper;
        temp.resize(nums.size() + 1, 0);
        preSum.resize(nums.size() + 1, 0);
        for (size_t i = 0; i < nums.size(); ++i)  preSum[i + 1] = preSum[i] + nums[i];
        Sort(preSum, 0, preSum.size() - 1);
        return res;
    }
private:
    int res;
    int lower;
    int upper;
    // 前缀和数组,用于快速计算区间和。用long类型防止和溢出
    vector<long> preSum;
    // 初始化一个辅助数组,避免递归时内存频繁分配释放
    vector<long> temp;
    void Sort(vector<long>& nums, int left, int right) {
        // 只剩下一个元素,无需继续排序
        if (left == right) {
            return;
        }
        int mid = left + (right - left) / 2;
        // 排序左侧数组[left, mid]
        Sort(nums, left, mid);
        // 排序右侧数组[mid + 1, right]
        Sort(nums, mid + 1, right);
        // 将有序的左右数组按顺序合并
        Merge(nums, left, mid, right);
    }
    void Merge(vector<long>& nums, int left, int mid, int right) {
        // 把 nums[left, right] 复制到辅助数组中,以便合并后的结果能够直接存入 nums
        for (size_t i = left; i <= right; ++i)   temp[i] = nums[i];
        
        // 维护左闭右开区间 [start, end) 中的元素和 nums[i] 的差在 [lower, upper] 中
        int start = mid + 1;
        int end = mid + 1;
        for (size_t i = left; i <= mid; ++i) {
            // 如果 nums[i] 对应的区间是 [start, end),
            // 那么 nums[i+1] 对应的区间一定会整体右移,类似滑动窗口
            while (start <= right && nums[start] - nums[i] < lower)  start++;
            while (end <= right && nums[end] - nums[i] <= upper)    end++;
            res += end - start;
        }

        // 利用双指针技巧,合并两个有序数组
        int left_p = left, right_p = mid + 1;
        for (size_t p = left; p <= right; ++p) {
            // 左侧数组已经merge完毕,只需要移动右侧数组的指针
            if (left_p == mid + 1)   nums[p] = temp[right_p++];
            // 右侧数组已经merge完毕,只需要移动左侧数组的指针
            else if (right_p == right + 1)   nums[p] = temp[left_p++];
            // 升序:右侧数比左侧数小,加入右侧元素
            else if (temp[left_p] > temp[right_p])   nums[p] = temp[right_p++];
            // 升序:左侧数比右侧数小,加入左侧元素
            else   nums[p] = temp[left_p++];
        }
    }
};

首先,看到区间和时,要想到利用前缀和数组进行快速计算。于是就可以对前缀和数组进行归并排序,在merge函数中统计区间和满足要求的个数。

由于merge时两个数组是有序的,可以进行效率优化,即维护一个滑动窗口,让窗口中的元素和 nums[i] 的差落在 [lower, upper] 中。

归并排序算法,递归的 sort 函数就是二叉树的遍历函数,而 merge 函数就是在每个节点上做的事情。

在每个节点上做事时都可以满足以下两点:操作两个有序数组、左侧的元素在原数组中的下标一定小于右侧的元素。尤其是第二点,对于上述“右侧元素小于当前元素数目”、“翻转对”和“区间和(利用前缀和计算时要求j>i)”等问题适用。

 拓展:分治思想

归并排序即利用了分治思想,要把目光聚焦于局部。

241. 为运算表达式设计优先级[*]

class Solution {
public:
    // 剪枝去重
    unordered_map<string, vector<int>> record;
    vector<int> diffWaysToCompute(string expression) {
        if (record.count(expression))    return record[expression];
        vector<int> res;
        int sz = expression.size();
        for (int i = 0; i < sz; ++i) {
            char str = expression[i];
            if (str == '-' || str == '*' || str == '+') {
                // 分治思想
                vector<int> left = diffWaysToCompute(expression.substr(0, i));
                vector<int> right = diffWaysToCompute(expression.substr(i + 1, sz - i - 1));
                // 通过子问题的结果,合成原问题的结果
                for (int num1 : left) {
                    for (int num2 : right) {
                        if (str == '+')  res.push_back(num1 + num2);
                        else if (str == '-')   res.push_back(num1 - num2);
                        else if (str == '*')   res.push_back(num1 * num2);
                    }
                }
            }
        }
        // 结果为空,说明运算符只有一个数字
        // 【这行代码也是递归的返回条件】
        if (res.empty())   res.push_back(stoi(expression));
        record[expression] = res;
        return res;
    }
};

解决本题的关键有两点:

1、不要思考整体,而是把目光聚焦局部,只看一个运算符

2、明确递归函数的定义是什么,设计好递归逻辑和返回条件

只看一个运算符的意思是:例如针对算式1 + 2 * 3 - 4 * 5,单独看“-”,左边的算式1 + 2 * 3加括号后可能的结果分别减去右边的算式4 * 5加括号后的结果就是单独看“-”号得到的结果。对每一个运算符,单独拎出来,对其左右两侧的算式加括号,就能把所有结果列举出来。

所以可以将问题分而治之,“分”是指分别看某一个运算符左右两侧的算式,“治”是假设子问题已经经过递归函数处理到了最小粒度,即左右两侧只有两个数字了,那么根据符号进行求解就可以得到子问题的答案。

注意递归的返回条件,即已经无法再继续划分,expression最小粒度是一个数字了,直接返回当前数字的值即可。


二、快速排序

 快速排序是先将一个元素排好序,然后再将剩下的元素排好序。类似二叉树的前序遍历。

vector<int> sortArray(vector<int>& nums) {
        random_shuffle(nums.begin(), nums.end());
        Sort(nums, 0, nums.size() - 1);
        return nums;
    }
    void Sort(vector<int>& nums, int lo, int hi) {
        // 只剩下一个元素,无需继续切分
        if (lo >= hi)    return;
        // 对 nums[lo,hi] 进行切分
        // 使得 nums[lo,p-1] <= nums[p] < nums[p+1,hi]
        int p = Partition(nums, lo, hi);
        Sort(nums, lo, p - 1);
        Sort(nums, p + 1, hi);
    }
    int Partition(vector<int>& nums, int lo, int hi) {
        // 取第一个位置的元素为基准元素
        int pivot = nums[lo];
        int left = lo + 1;
        int right = hi;
        // 当left > right时结束循环 以保证区间[lo, hi]都被覆盖
        while (left <= right) {
            // 此while循环结束时 恰好 nums[left] > pivot
            while (left < hi && nums[left] <= pivot)   left++;
            // 此while循环结束时 恰好 nums[right] <= pivot
            while (right > lo && nums[right] > pivot)   right--;
            // 此时[lo, left) <= pivot && (right, hi] > pivot已经满足
            if (left >= right) {
                break;
            }
            swap(nums[left], nums[right]);
        }
        // 将pivot放到合适的位置 即pivot左边元素较小 右边元素较大
        swap(nums[lo], nums[right]);
        return right;
    }

过程参考:(3条消息) 快速排序(详细讲解)_梦里Coding的博客-CSDN博客_快速排序

最好的情况:每一次base值都刚好平分整个数组,O(nlogn)
最坏的情况:每一次base值都是数组中的最大/最小值,O(n^2)


三、拓展

寻找第K个最大/最小的元素:优先队列

215. 数组中的第K个最大元素

int findKthLargest(vector<int>& nums, int k) {
        // 小顶堆,堆顶是最小元素,pop时先删除最小元素
        priority_queue<int, vector<int>, greater<int>> pq(nums.begin(), nums.end());
        while (pq.size() > k)  pq.pop();
        return pq.top();
    }

 煎饼排序

969. 煎饼排序

    vector<int> res;
    vector<int> pancakeSort(vector<int>& arr) {
        sort(arr, arr.size());
        return res;
    }
    void sort(vector<int>& arr, int n) {
        if (n == 1)    return;
        // 寻找最大饼的索引
        int max_pancake = 0;
        int max_idx = 0;
        for (int i = 0; i < n; ++i) {
            if (arr[i] > max_pancake) {
                max_pancake = arr[i];
                max_idx = i;
            }
        }
        // 第一次翻转,将最大饼翻到最上面
        reverse(arr.begin(), arr.begin() + max_idx + 1);
        res.push_back(max_idx + 1);
        // 第二次翻转,将最大饼翻到最下面
        reverse(arr.begin(), arr.begin() + n);
        res.push_back(n);
        // 递归调用
        sort(arr, n - 1);
    }

 煎饼排序的思路是:

1、找到前 k 个饼中最大的那个;

2、把这个最大的饼(下表为idx)移到最底下【涉及两步操作:前idx个饼翻一次将最大饼翻到最上方 + 前k个饼翻一次将最大饼翻到这一堆的最底下】;

3、递归调用 pancakeSort(arr, k - 1)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值