总言
主要内容:编程题举例,理解分治的思想(主要是对快排、并归的应用)。
文章目录
1、基本介绍
分治是一种解决问题的策略,它将一个大问题分解成若干个小问题,这些小问题与原问题类型相同但规模更小,然后递归地解决这些小问题,最后将小问题的解合并,从而得到原问题的解。这种策略在许多算法中都有应用,如排序、搜索、图论问题等。
快速排序就是基于分治策略的一种排序算法。它的基本思想是选取一个基准值,通过一趟排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。快速排序的时间复杂度为O(nlogn),是目前基于比较的内部排序里被认为是最好的方法,特别适用于大数据集。
归并排序则是另一种基于分治策略的排序算法。它的主要思路是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的,然后再把有序子序列合并为整体有序序列。归并排序的过程可以分为分解和合并两个步骤,分解是将序列每次折半划分,合并则是将划分后的序列段两两合并后排序。这种排序算法是稳定的,适用于各种规模的数据集。
2、颜色分类(medium)
题源:链接
2.1、题解
1)、思路分析
此题的思路本质可划分为双指针(双指针复习链接:移动零。),但对此进行了一点变动,使用的是三指针:一个用于遍历数组的指针+两个用于划分区域的指针。
PS:上述指针如何取名完全可随意,不必生搬硬套。
如何判断移动?
1、初始时: i = 0
,left = -1
, right = numsSize
(若数组尾元素下标为n-1
,right
指向尾元素下一个位置,即n
)
2、i
从左到右遍历过程中:
①、若nums[cur] == 0
;说明 i 位置指向的元素要纳入左区域范围内。因此,交换 left + 1 与 i 位置的元素,并让 left++ (因为纳入了新元素, 0 序列的右边界应当右滑扩展),cur++ (为什么可以 ++ ?因为 left + 1 位置要么是 0 ,要么是 1,交换完毕之后,这个位置的值已经符合我们的要求,因此 cur++ );
②、nums[cur] == 1
;说明这个位置应该在 left 和 cur 之间,此时无需交换,直接让 cur++ ,判断下⼀个元素即可;
③、nums[cur] == 2
;说明这个位置的元素应该在 right - 1 的位置,因此交换 right - 1 与 cur 位置的元素,并且让 right-- (指向 2 序列的左边界),cur 不变(因为交换过来的数是没有被判断过的,因此需要在下轮循环中判断)
2)、题解
class Solution {
public:
void sortColors(vector<int>& nums) {
int left = -1, right = nums.size(), i = 0;
while(i < right)//right表⽰的是2序列的左边界,因此当碰到right时,说明已经将所有数据扫描完毕了
{
if(nums[i] == 0)
swap(nums[i++],nums[++left]);
else if(nums[i] == 1)
i++;
else //nums[i] == 2
swap(nums[i],nums[--right]);//cur 不变,因为交换过来的数是没有被判断过的
}
}
};
3、快速排序(medium)
题源:链接
3.1、题解:三指针版本的快排
1)、思路分析
这题的题解方法有多种,这里我们主要学习快排,后续讲以同样的题讲解归并。
快排,我们在排序章节讲解过几种写法:Hoare法、挖坑法、双指针法,并且当时对key值的优化使用的是三数取中。这里我们讲解三指针法,以及随机数法选取key值。
三指针法: 这里三指针的原理同上述颜色分类。
从待排序的数组中选择一个元素作为基准值(key),以该基准值进行排序,将数组划分为左、中 、右三部分(以升序为例)。
三指针排序的过程在上述颜色分类讲解过,这里不做赘述。
在对当前序列进行三块划分处理后,再去递归排序左边部分和右边部分即可(可以舍去大量的中间部分,减少不必要的交换操作。 在处理有大量重复元素的数组时,效率会大大提升)。
随机数法取key值: 在主函数中种一颗随机数种子, 由于我们要随机产生一个满足[left,right]区间范围内的基准元素值,因此可以将生成随机数转换成为生成当前[left,right]区间的随机下标。如何做到?
(rand() % (right -left +1 )) + left
解释:这里我们设区间大小为n,让随机数 % 上区间大小,可得 [0,n-1]范围内的随机数,加上区间的左边界,即可得到 [left, left + (n -1)] 范围内的值,即[left, right]区间范围内的值。
2)、题解
class Solution {
public:
void quick(vector<int>& nums, int left, int right)
{
if(left >= right) return; //递归结束条件
//随机数法选取key值
int n = right - left + 1;//当前段区间内元素总数
int index = (rand() % n) + left;//获取到[left, right]区间段内的随机下标
int key = nums[index];//这里不能直接用key的下标进行排序(即与nums[index]比较是错误的)。
//因为快排属于交换排序,index下标位置的值会在排序过程中被交换。
//三指针法进行区间排序:[left,right]
int i = left, l = left - 1, r = right + 1;
while(i < r)
{
if(nums[i] < key) swap(nums[++l],nums[i++]);
else if(nums[i] == key) i++;
else swap(nums[--r],nums[i]);//交换过来的数是没有被判断过,此处i不能++
}
//对左右排序:[left, l] [l+1, r-1] [r,right]
quick(nums,left,l);
quick(nums,r,right);
}
vector<int> sortArray(vector<int>& nums) {
srand(time(nullptr));// 种下⼀个随机数种⼦
quick(nums,0,nums.size()-1);//使用递归法排序,这里传入[left,right]区间
return nums;
}
};
4、快速选择算法(medium)
题源:链接
4.1、题解
1)、思路分析
这题若使用的是选择排序,比如直接选择排序或堆排序(堆排时间复杂度为
N
l
o
g
N
NlogN
NlogN),也可以解决此题:
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
//建堆
priority_queue<int> maxHeap(nums.begin(),nums.end());
//取前K-1个数
while(--k)//此写法下要用前置自减
{
maxHeap.pop();
}
//取第K个数
return maxHeap.top();
}
};
这里我们主要学习应用快排:
2)、题解
使用升序的写法:
class Solution {
public:
int qsort(vector<int>& nums, int k, int left, int right) {
if (left >= right) // 递归调用
return nums[left];
int n = right - left + 1; // 当前区间的元素总数
int id = rand() % n + left; // 获取当前区间段的随机下标
int key = nums[id]; // 获取该下标上的元素值(作为key值使用)
int i = left, l = left - 1, r = right + 1;
while (i < r) // 排升序,找第k大。
{
if (nums[i] < key)
swap(nums[i++], nums[++l]);
else if (nums[i] == key)
i++;
else
swap(nums[i], nums[--r]);
}
// 用于判断下一轮递归区间:[left,l] [l+1,r-1] [r, right]
int a = l - left + 1;
int b = r - 1 - (l + 1) + 1;
int c = right - r + 1;
if (c >= k)
return qsort(nums, k, r, right);
else if (b + c >= k)
return key;
else
return qsort(nums, k - b - c, left, l);
}
int findKthLargest(vector<int>& nums, int k) {
srand(time(nullptr)); // 种下随机种子
return qsort(nums, k, 0, nums.size() - 1); // 传入的区间边界应该是闭区间[left,right]
}
};
使用降序的写法:只是对当前序列的排序部分及判断下一轮递归的条件做了变动。
while (i < r) // 排降序,找第k大。
{
if (nums[i] < key)
swap(nums[i], nums[--r]);
else if (nums[i] == key)
i++;
else
swap(nums[i++], nums[++l]);
}
// 用于判断下一轮递归区间:[left,l] [l+1,r-1] [r, right]
int a = l - left + 1;
int b = r - 1 - (l + 1) + 1;
int c = right - r + 1;
if (a >= k)
return qsort(nums, k, left, l);
else if (a + b >= k)
return key;
else
return qsort(nums, k - b - a, r, right);
5、最小的 k 个数(medium)
题源:链接
5.1、题解
1)、思路分析
此题解法多种,可用堆的TOP-K(
N
l
o
g
K
NlogK
NlogK)、可用排序(如快排:
N
l
o
g
N
NlogN
NlogN)、还可以用快速选择算法(即上述题4讲解的原理。)
这里主要演示快速选择算法。
2)、题解
以升序为例: 最后我们返回时,前K个元素区间虽然没有完全有序,但已经满足了找出题目最小的K个数的要求。
class Solution {
public:
void qsort(vector<int>& arr, int left, int right, int k)
{
if(left >= right) return;
int n = right - left + 1;
int index = rand() % n + left;
int key = arr[index];
//三指针排序: [left,right],排升序
int L = left - 1 , R = right + 1, i = left;
while(i < R)
{
if(arr[i] < key) swap(arr[++L], arr[i++]);
else if(arr[i] == key) ++i;
else swap(arr[--R], arr[i]);
}
//对左右区间:[left, L] [L+1, R-1] [R,right]
int a = L - left + 1;
int b = R-1 - (L+1) + 1;
int c = right - R + 1;
if(a >= k) qsort(arr, left, L, k);
else if(a + b >= k) return;
else qsort(arr, R, right, k-a-b);
}
vector<int> smallestK(vector<int>& arr, int k) {
srand(time(nullptr));
qsort(arr,0,arr.size()-1,k);
return {arr.begin(),arr.begin()+k};
}
};
6、归并排序(medium)
题源:链接
6.1、题解:复习快排(二路归并)
1)、思路分析
在上述,我们用快排解过此题,这里我们使用归并排序来解决。PS:归并排序相关复习见博文:常见排序。
这里主要是指二路归并。 总体思想不变,细节实现看个人写法:
1、分割: 将待排序的数组从中间分割成两个子数组,直到子数组的大小为1(即每个子数组只包含一个元素,自然是有序的)。
2、递归排序: 递归地对子数组进行归并排序(「左半部分排序」 + 「右半部分排序」)。
3、合并: 将两个已排序的子数组合并成一个有序的大数组。(通常借助一个辅助数组,可以是每次归并时创建临时变量,也可以用全局变量或者在堆区开辟)
2)、题解
class Solution {
public:
vector<int> temp;
void MergeSort(vector<int>& nums, int left, int right)
{
if(left >= right) return;
//这里使用二路归并,选择中间点将区间划分为两段:[left,mid][mid+1,right]
int mid = ( right + left ) / 2;//题中给出数据不大(也可以使用防越界版本的求中间值)
//先对左右区间排序
MergeSort(nums,left,mid);
MergeSort(nums,mid+1,right);
//再合并两个有序数组(来到此处,意味着当前左右两段区间各自有序)
int cur1 = left;
int cur2 = mid+1;
int i = 0;
while(cur1 <= mid && cur2 <= right)
temp[i++] = nums[cur1] < nums[cur2] ? nums[cur1++] : nums[cur2++];
//处理剩余区间
while(cur1 <= mid) temp[i++] = nums[cur1++];
while(cur2 <= right) temp[i++] = nums[cur2++];
//将排序好的元素重新返回原数组中(可借助memcpy等函数)
for(int j = left; j <= right; ++j)
{
nums[j] = temp[j - left];
}
}
vector<int> sortArray(vector<int>& nums) {
temp.resize(nums.size());//归并需要借助一个辅助数组,这里可以每次归并时定义,也可以直接搞一个全局变量(或者堆上申请)
MergeSort(nums,0,nums.size()-1);
return nums;
}
};
关于将排序好的元素重新返回原数组中,其它方法:
//可以使用memcpy进行拷贝
memcpy(&nums[begin], &temp[begin], sizeof(int)* (end - begin + 1));
// 也可以使用std::copy替代memcpy
std::copy(temp.begin() + begin, temp.begin() + end + 1, nums.begin() + begin);
相关文档查阅:链接。
题外话:
理论上也可以实现k路归并排序(k-way merge sort),其中k是大于1的整数。在k路归并排序中,待排序的数组被分割成k个更小的子数组,而不是仅仅两个。然后,这k个子数组被分别排序,并最终合并成一个完整的有序数组。
k路归并排序在某些特定场景下可能具有优势,比如当待排序的数据分布在多个不连续的内存区域时,或者当使用并行计算资源时。然而,实现k路归并排序通常比实现二路归并排序更为复杂,并且不一定总是带来性能上的提升。因此,在实际应用中,二路归并排序更为常见和流行。
7、数组中的逆序对(hard)
题源:链接
7.1、题解
1)、思路分析
直接解法: 双层循环暴力枚举,看在难度hard的份上,大概率超时。
问题一:为什么可以使用归并排序?
1、题目要求找出数组中所有逆序对,既如此,将数组一分为二,先找出左区间中的逆序对,再找出右区间的逆序对,最后选出一左一右在两区间的逆序对。效果与从左到右/从右到左线性遍历查找等同。
2、对此进行优化。在找出左区间中的逆序对后我们对其排个序,同理,在找出右区间的逆序对后也对其排序。此时,尽管左右两区间内部元素顺序发生改变,但对于在两区间中分别选取元素组成一左一右的逆序对并无影响。
3、回顾上述历程,这不就和归并的思想一样吗?当然,在选取出一左一右的逆序对后,我们要对当前整体区间也进行排序。
归并排序的过程:
• 先排序左数组;
• 再排序右数组;
• 左数组和右数组合⼆为⼀。
逆序对中两个元素:
• 全部从左数组中选择逆序对
• 全部从右数组中选择逆序对
• ⼀个选左数组另⼀个选右数组,组成逆序对
问题二:如何使用归并排序?及,为什么这里使用归并会提高效率(单调性)
除了这里举例的两种(升序+固定右值找左侧;降序+固定左值找右侧),如何选取与组合看个人风格。
2)、题解
升序写法如下:
class Solution {
public:
int tmp[50005]={0};
int mergesort(vector<int>& record, int left, int right)
{
if(left >= right) return 0;
//取中数,分区间:[left, mid] [mid+1, right]
int mid = (left+right) / 2;
int sum = 0;//用于统计
//左区间找逆序对+排序、右区间找逆序对+排序
sum += mergesort(record,left,mid)+ mergesort(record,mid+1,right);
//一左一右找逆序对+排序
int cur1 = left, cur2 = mid + 1;
int i = 0;
while(cur1 <= mid && cur2 <= right)//排升序,找左数比右数大
{
if(record[cur1] <= record[cur2])
tmp[i++] = record[cur1++];
else//record[cur1] > record[cur2]
{
sum += mid - cur1 + 1;
tmp[i++] = record[cur2++];
}
}
//排序:处理剩余元素
while(cur1 <= mid) tmp[i++] = record[cur1++];
while(cur2 <= right) tmp[i++] = record[cur2++];
//将有序数列返回原数列中
for(int j = left; j <= right; ++j)
record[j]=tmp[j-left];
//返回
return sum;
}
int reversePairs(vector<int>& record) {
return mergesort(record,0,record.size()-1);
}
};
降序写法总体不变,只需稍加改动即可:
while(cur1 <= mid && cur2 <= right)//排降序,找左数比右数大
{
if(record[cur1] <= record[cur2])
tmp[i++] = record[cur2++];
else//record[cur1] > record[cur2]
{
sum += right - cur2 + 1;
tmp[i++] = record[cur1++];
}
}
8、计算右侧小于当前元素的个数(hard)
题源:链接
8.1、题解
1)、思路分析
有了上一题的铺垫,这一道题的解法与之类似,但是这一道题要求的不是求总的个数,而是要返回一个数组,记录每一个元素的右边有多少个元素比自己小。
这里存在一个问题,在归并排序的过程中,元素的下标是会跟着变化的。 当前我们查找出的顺序与需要返回的顺序不一定完全匹配。
因此,为了确保返回时数组输出的元素顺序,我们需要一个额外数组 index,将数组元素和对应的下标绑定在一起归并,也就是在归并nums中元素的时候,顺势将其下标index也转移到对应的位置上。(意味着辅助数组也需要两个,分别用于排序归并后的nums、index。)
2)、题解
这是vector<int> index
、vector<int> count
使用全局变量的版本。
class Solution {
public:
int tmp_n[100005];//辅助数组1:归并时用于排序nums
int tmp_i[100005];//辅助数组2:归并时用于排序index
vector<int> index;//记录元素对应下标
vector<int> count;//用于返回输出结果
void mergesort(vector<int>& nums, int left, int right)
{
if(left>=right) return;
//求中值划分区间:[left,mid] [mid+1,right]
int mid = (left + right) /2;
//先对左右区间进行找数+归并排序:
mergesort(nums, left, mid);
mergesort(nums,mid+1, right);
//再对当前整段区间进行找数+排序:
int cur1 = left, cur2 = mid+1;
int i = 0;
while(cur1<=mid && cur2 <= right)
{
if(nums[cur1] <= nums[cur2])
{
tmp_n[i] = nums[cur2];
tmp_i[i++] = index[cur2++];//注意这里一次处理两个数组
}
else//nums[cur1] > nums[cur2]
{
count[index[cur1]] += right - cur2 + 1;
tmp_n[i] = nums[cur1];
tmp_i[i++] = index[cur1++];
}
}
//处理剩余区间
while(cur1 <= mid)
{
tmp_n[i]=nums[cur1];
tmp_i[i++]=index[cur1++];
}
while(cur2 <= right)
{
tmp_n[i]=nums[cur2];
tmp_i[i++]=index[cur2++];
}
//将获取到的有序数据写回原数组中
for(int j = left; j <= right; ++j)
{
nums[j]=tmp_n[j-left];
index[j]=tmp_i[j-left];
}
}
vector<int> countSmaller(vector<int>& nums) {
int n = nums.size();
index.resize(n);//重新扩容
count.resize(n);
for(int i = 0; i < n; ++i)//初始化下标
{
index[i] = i;
}
mergesort(nums, 0, n-1);
return count;
}
};
如果不使用全局变量,也可如下,但需要传参时多增几个参数。总之写法不一。
class Solution {
public:
int tmp_n[100005];
int tmp_i[100005];
void mergesort(vector<int>& nums, int left, int right, vector<int>& index, vector<int>& count)
{
if(left >= right) return;
//[left,mid] [mid+1,right]
int mid = (left + right) /2;
//左右区间:
mergesort(nums, left, mid, index,count);
mergesort(nums,mid+1, right, index,count);
//当前整个区间:
int cur1 = left, cur2 = mid+1;
int i = 0;
while(cur1<=mid && cur2 <= right)
{
if(nums[cur1] <= nums[cur2])
{
tmp_n[i] = nums[cur2];
tmp_i[i++] = index[cur2++];
}
else//nums[cur1] > nums[cur2]
{
count[index[cur1]] += right - cur2 + 1;
tmp_n[i] = nums[cur1];
tmp_i[i++] = index[cur1++];
}
}
//处理剩余区间
while(cur1 <= mid)
{
tmp_n[i]=nums[cur1];
tmp_i[i++]=index[cur1++];
}
while(cur2 <= right)
{
tmp_n[i]=nums[cur2];
tmp_i[i++]=index[cur2++];
}
//写回原数组中
for(int j = left; j <= right; ++j)
{
nums[j]=tmp_n[j-left];
index[j]=tmp_i[j-left];
}
}
vector<int> countSmaller(vector<int>& nums) {
int n = nums.size();
vector<int> index(n);
vector<int> count(n);
for(int i = 0; i < n; ++i)
{
index[i] = i;
}
mergesort(nums, 0, n-1, index, count);
return count;
}
};
关于count[index[cur1]] += right - cur2 + 1;
为什么要用+=
,而不能使用+
?
要知道归并的过程,元素位置是变化的。 使用 += 是因为我们需要累计 nums[cur1]
右侧小于它的元素数量。在归并排序的合并过程中,cur1
会遍历左子数组的所有元素,每次遇到一个比右子数组当前元素大的左子数组元素时,我们都需要将右子数组中剩余的元素数量(即 right - cur2 + 1
)累加到对应的 count
中。因此,这是一个累加操作,每次都需要将新的数量加到之前累计的数量上。
如果使用 +
而不是 +=
,那么每次只会将 right - cur2 + 1
赋值给 count[index[cur1]]
,而不会保留之前累计的数量。这会导致 count 数组中每个位置只存储了最后一次计算得到的数量,而不是所有累计的数量,从而得到错误的结果。
9、翻转对(hard)
题源:链接
9.1、题解
1)、思路分析
翻转对和逆序对的定义大同小异,逆序对是前面的数要大于后面的数。而翻转对是前面的一个数要大于后面某个数的两倍。因此,我们依旧可以用归并排序的思想来解决这个问题。
而与上个问题不同的是,上一道题我们可以一边合并一遍计算小于当前元素右侧元素,但是这道题要求的是左边元素大于右边元素的两倍,若直接合并的话,是无法快速计算出翻转对的数量的。
因此,在归并排序之前,我们可以借助左右区间已经排序好的单调性,先完成对翻转对的统计。由于两个指针都是不回退的的扫描到数组的结尾,因此两个有序序列求出翻转对的时间复杂度是
O
(
N
)
O(N)
O(N)。
2)、题解
以升序为例:
class Solution {
public:
int tmp[50005] = {0};
int mergesort(vector<int>& nums, int left, int right) {
if (left >= right)
return 0;
//[left,mid] [mid+1, right]
int mid = (left + right) / 2;
int sum = 0;
sum += mergesort(nums, left, mid) + mergesort(nums, mid + 1, right);
// 统计:(单调性为升序)
int cur1 = left, cur2 = mid + 1;
while(cur1 <= mid && cur2 <= right)
{
if(nums[cur1]/2.0 > nums[cur2])
{
sum += mid - cur1 + 1;
cur2++;
}
else cur1++;
}
// while (cur2 <= right) // 统计部分其它写法:升序的情况
// {
// while (cur1 <= mid && nums[cur2] >= nums[cur1] / 2.0)
// cur1++;
// if (cur1 > mid)
// break;
// sum += mid - cur1 + 1;
// cur2++;
// }
// 排序:升序版,[left, mid][mid+1, right]
cur1 = left, cur2 = mid + 1;
int i = left;
while (cur1 <= mid && cur2 <= right) {
if (nums[cur1] > nums[cur2])
tmp[i++] = nums[cur2++];
else
tmp[i++] = nums[cur1++];
}
while (cur1 <= mid)
tmp[i++] = nums[cur1++];
while (cur2 <= right)
tmp[i++] = nums[cur2++];
//写回
for (int j = left; j <= right; ++j) {
nums[j] = tmp[j];
}
return sum;
}
int reversePairs(vector<int>& nums) {
return mergesort(nums, 0, nums.size()-1);
}
};
以降序为例:
class Solution {
public:
int tmp[50005] = {0};
int mergesort(vector<int>& nums, int left, int right) {
if (left >= right)
return 0;
//[left,mid] [mid+1, right]
int mid = (left + right) / 2;
int sum = 0;
sum += mergesort(nums, left, mid) + mergesort(nums, mid + 1, right);
// 统计:(单调性为降序)
int cur1 = left, cur2 = mid + 1;
while (cur1 <= mid && cur2 <= right) {
if (nums[cur1] / 2.0 > nums[cur2]) {
sum += right - cur2 + 1;
cur1++;
} else
cur2++;
}
// while (cur1 <= mid) // 统计部分其它写法:降序的情况
// {
// while (cur2 <= right && nums[cur2] >= nums[cur1] / 2.0)
// cur2++;
// if (cur2 > right)
// break;
// ret += right - cur2 + 1;
// cur1++;
// }
// 排序:降序版,[left, mid][mid+1, right]
cur1 = left, cur2 = mid + 1;
int i = left;
while (cur1 <= mid && cur2 <= right) {
tmp[i++] = nums[cur1] > nums[cur2] ? nums[cur1++] : nums[cur2++];
}
while (cur1 <= mid)
tmp[i++] = nums[cur1++];
while (cur2 <= right)
tmp[i++] = nums[cur2++];
// 写回
for (int j = left; j <= right; ++j) {
nums[j] = tmp[j];
}
return sum;
}
int reversePairs(vector<int>& nums) {
return mergesort(nums, 0, nums.size() - 1);
}
};