算法 --- 分治(快排)
分治(快排)
分治(以快排为代表)算法最适合解决“将复杂问题分解后,子问题的解能直接合并为原问题解”的题目类型。
详细说明:
分治法的核心思想是“分而治之”,它通过以下三个步骤解决问题:
-
分(Divide):将原问题分解成若干个规模较小的子问题。
-
治(Conquer):递归地解决这些子问题。如果子问题规模足够小,则直接求解。
-
合(Combine):将子问题的解合并,形成原问题的解。
适用于分治法的题目类型和特征:
-
问题可分解性:
-
问题必须能够被自然地分解为几个规模更小的、结构相同的子问题。
-
典型例子:归并排序、快速排序。一个大的无序数组可以分解为两个小的无序数组进行排序。
-
-
子问题独立性:
-
各子问题之间相互独立,没有重叠(或者重叠很少)。这是分治与动态规划的关键区别(动态规划的子问题有重叠)。
-
典型例子:快速排序。划分后的左右两个子数组的排序过程互不干扰。
-
-
解可合并性:
-
这是最关键的一点。必须能够高效地将子问题的解合并为原问题的解。如果合并步骤非常复杂,那么分治法可能不是最佳选择。
-
典型例子:
-
归并排序:合并两个已排序的子数组为一个有序数组的操作非常高效(O(n))。
-
快速排序:其“合并”步骤实际上什么都不用做,因为经过“分”和“治”之后,整个数组已经自然有序了。这是快排比归并排序更高效的原因之一。
-
-
什么题目具体适用于分治(快排)?
快排是分治法最经典的应用,但它本身是一种排序算法。更广泛地说,分治法适用于以下几类问题:
-
排序与选择问题:
-
快速排序:通过一个基准值(pivot)将数组“分”为左右两部分,递归“治”之,无需“合”。
-
归并排序:将数组平分为两部分,递归排序后,再合并。
-
寻找第K大/小元素(快速选择算法):思路类似快排,每次划分后只在包含目标的那一部分递归,平均时间复杂度为 O(n)。
-
-
计算问题:
-
大整数乘法:将大整数分解为更小的部分进行计算,再合并结果。
-
矩阵乘法(Strassen算法):将大矩阵分块,用更少的乘法次数完成计算。
-
-
几何问题:
-
最近点对问题:将平面上的点集按x坐标分成两半,分别递归求解两半中的最近点对,再合并处理跨越两部分的点对。
-
-
数据结构操作:
-
线段树、归并树的构建和查询,其本质就是分治思想。
-
总结:
当你遇到一个问题时,可以问自己这三个问题来判断是否适用分治:
-
我能把它分成几个一样的、更小的问题吗?(可分)
-
这些小问题可以独立解决吗?(独立)
-
解决完小问题后,我能很容易地把它们的答案组合成最终答案吗?(可合)
如果答案都是“是”,那么分治法就很可能是你的得力工具。快排正是完美符合这三点的典范。
题目练习
75. 颜色分类 - 力扣(LeetCode)
解法(快排思想 - 三指针法使数组分三块):
算法思路:
类比数组分两块的算法思想,这里是将数组分成三块,那么我们可以再添加一个指针,实现数组分三块。
设数组大小为 n,定义三个指针 left
,cur
,right
:
-
left
:用来标记 0 序列的末尾,因此初始化为-1
; -
cur
:用来扫描数组,初始化为0
; -
right
:用来标记 2 序列的起始位置,因此初始化为n
。
在 cur
往后扫描的过程中,保证:
-
[0, left]
内的元素都是 0; -
[left + 1, cur - 1]
内的元素都是 1; -
[cur, right - 1]
内的元素是待定元素; -
[right, n]
内的元素都是 2。
算法流程:
a. 初始化 cur = 0
,left = -1
,right = numsSize
;
b. 当 cur < right
的时候(因为 right
表示的是 2 序列的左边界,因此当 cur
碰到 right
的时候,说明已经将所有数据扫描完毕了),一直进行下面循环:
根据 nums[cur]
的值,可以分为下面三种情况:
i. nums[cur] == 0
;说明此时这个位置的元素需要在 left + 1
的位置,因此交换 left + 1
与 cur
位置的元素,并且让 left++
(指向 0 序列的边界),cur++
(为什么可以 ++
呢,是因为 left + 1
位置要么是 0,要么是 cur
,交换完毕之后,这个位置的值已经符合我们的要求,因此 cur++
);
ii. nums[cur] == 1
;说明这个位置应该在 left
和 cur
之间,此时无需交换,直接让 cur++
,判断下一个元素即可;
iii. nums[cur] == 2
;说明这个位置的元素应该在 right - 1
的位置,因此交换 right - 1
与 cur
位置的元素,并且让 right--
(指向 2 序列的边界),cur
不变(因为交换过来的数是没有被判断过的,因此需要在下轮循环中判断)
c. 当循环结束之后:
-
[0, left]
表示 0 序列; -
[left + 1, right - 1]
表示 1 序列; -
[right, numsSize - 1]
表示 2 序列。
left 一点是要从-1开始的,因为还没有确定下标0位置是否为0!
class Solution {
public:void sortColors(vector<int>& nums) {int left = -1, cur = 0, right = nums.size();while(cur < right){if(nums[cur] == 0) swap(nums[++left], nums[cur++]);else if(nums[cur] == 1) cur++;else swap(nums[--right], nums[cur]);}}
};
912. 排序数组 - 力扣(LeetCode)
解法(数组分三块思想 + 随机选择基准元素的快速排序):
算法思路:
我们在数据结构阶段学习的快速排序的思想可以知道,快排最核心的一步就是 Partition(分割数据):将数据按照一个标准,分成左右两部分。
如果我们使用荷兰国旗问题的思想,将数组划分为 左中右 三部分:左边是比基准元素小的数据,中间是与基准元素相同的数据,右边是比基准元素大的数据。然后再去递归的排序左边部分和右边部分即可(可以舍去大量的中间部分)。
在处理数据量有很多重复的情况下,效率会大大提升。
算法流程:
随机选择基准算法流程:
函数设计:int randomKey(vector<int>& nums, int left, int right)
a. 在主函数那里种一颗随机数种子;
b. 在随机选择基准函数这里生成一个随机数;
c. 由于我们要随机产生一个基准,因此可以将随机数转换成随机下标:让随机数 % 上区间大小,然后加上区间的左边界即可。
快速排序算法主要流程:
a. 定义递归出口;
b. 利用随机选择基准函数生成一个基准元素;
c. 利用荷兰国旗思想将数组划分成三个区域;
d. 递归处理左边区域和右边区域。
class Solution {
public:int getRandom(vector<int>& nums, int l, int r){int tmp = rand();return nums[tmp % (r - l + 1) + l];}void qsort(vector<int>& nums, int l, int r){if(l >= r) return;int key = getRandom(nums, l, r);int cur = l, left = l - 1, right = r + 1;while(cur < right){if(nums[cur] < key) swap(nums[++left], nums[cur++]);else if(nums[cur] == key) cur++;else swap(nums[--right], nums[cur]);}// [l, left] [left + 1, right - 1] [right, r]qsort(nums, l, left);qsort(nums, right, r);}vector<int> sortArray(vector<int>& nums) {srand(time(NULL));qsort(nums, 0, nums.size() - 1);return nums; }
};
215. 数组中的第K个最大元素 - 力扣(LeetCode)(重要)
解法(快速选择算法):O(N)!!!
算法思路:
在快排中,当我们把数组「分成三块」之后:[l, left] [left + 1, right - 1] [right, r],我们可以通过计算每一个区间内元素的「个数」,进而推断出我们要找的元素是在「哪一个区间」里面。
那么我们可以直接去「相应的区间」去寻找最终结果就好了。
class Solution {
public:int findKthLargest(vector<int>& nums, int k) {srand(time(NULL));return qsort(nums, 0, nums.size() - 1, k);}int qsort(vector<int>& nums, int l, int r, int k){if(l == r) return nums[l];int key = getRandom(nums, l, r);int left = l - 1, cur = l, right = r + 1;while(cur < right){if(nums[cur] < key) swap(nums[++left], nums[cur++]);else if(nums[cur] == key) cur++;else swap(nums[--right], nums[cur]);} int c = r - right + 1;int b = right - left - 1;if(c >= k) return qsort(nums, right, r, k);else if(b + c >= k) return key;else return qsort(nums, l, left, k - b - c);}int getRandom(vector<int>& nums, int left, int right){return nums[rand() % (right - left + 1) + left];}
};
LCR 159. 库存管理 III - 力扣(LeetCode)
解法(快速选择算法):
算法思路:
在快排中,当我们把数组「分成三块」之后:[l, left] [left + 1, right - 1] [right, r],我们可以通过计算每一个区间内元素的「个数」,进而推断出最小的 k 个数在哪些区间里面。
那么我们可以直接去「相应的区间」继续划分数组即可。
class Solution {
public:vector<int> inventoryManagement(vector<int>& nums, int k) {srand(time(NULL));qsort(nums, 0, nums.size() - 1, k);return {nums.begin(),nums.begin() + k};}void qsort(vector<int>& nums, int l, int r, int k){if(l >= r) return;int key = getRandom(nums, l, r);int left = l - 1, right = r + 1, cur = l;while(cur < right){if(nums[cur] < key) swap(nums[++left], nums[cur++]);else if(nums[cur] == key) cur++;else swap(nums[--right], nums[cur]);}int a = left - l + 1;int b = right - left -1;if(a > k) return qsort(nums, l, left, k);else if(a + b >= k) return;else return qsort(nums, right, r, k - a - b);} int getRandom(vector<int>& nums, int left, int right){return nums[rand() % (right- left + 1) + left];}
};