分治法设计思想
分治法的基本思想是将一个难以直接解决的大问题分解为若干个规模较小的相同问题,递归解决这些小问题,然后将这些小问题的解合并以解决原始问题。包括三个步骤:
- 分解:将原问题分解为若干个规模较小的子问题。
- 解决:递归地解决这些子问题。
- 合并:将子问题的解合并以形成原问题的解。
算法框架
分治法的算法框架:
- 确定分解方式:根据问题的特点,确定如何将问题分解成子问题。
- 确定递归终止条件:确定递归的终止条件,通常是问题规模足够小,可以直接解决。
- 递归调用:递归地调用算法解决子问题。
- 合并结果:将子问题的解合并,形成原问题的解。
分治法在解决查找第k小问题中的应用
查找第k小问题
问题描述:给定一个无序数组,找出数组中的第k小的元素。
分治法思路:
- 分解:将数组分为两部分,左半部分和右半部分。
- 解决:递归地在左右两部分中查找第k小的元素。
- 合并:比较左右两部分的第k小的元素,确定整个数组的第k小的元素。
求解过程:
- 选择基准:选择数组中的一个元素作为基准(pivot),通常选择第一个元素或最后一个元素。
- 分区:将数组分为两部分,一部分包含小于基准的元素,另一部分包含大于或等于基准的元素。
- 递归:
- 如果
k
小于基准左侧元素的数量,则在基准左侧递归查找第k
小的元素。 - 如果
k
等于基准左侧元素的数量,则基准就是第k
小的元素。 - 如果
k
大于基准左侧元素的数量,则在基准右侧递归查找第k
小的元素(因为基准左侧的元素已经被排除)。
- 如果
图解求解过程
假设数组为arr[]
,数组长度为n
,目标是找到第k
小的元素。以下是通过分治法求解过程的图解:
初始数组
[49, 38, 65, 97, 76, 13, 27]
交换元素和被交换元素用**表示
第一次递归调用
start = 0, end = 9, k = 4
- 选择基准为
a[start] = 49
交换过程
- 选择基准:基准为
49
- 初始化指针:
i = start = 0
,j = end = 6
- 开始循环:
- 从右向左找到第一个小于基准的元素:
j
从j=0
开始,找到27
(小于49
),把27交换到 a[i]的位置,i=0。
- 从右向左找到第一个小于基准的元素:
[49, 38, 65, 97, 76, 13, 27,]原来的
[*27*, 38, 65, 97, 76, 13, ]交换后 49
| (基准)
- 继续循环:
- 从左向右找到第一个大于基准的元素:
i
从i=0
开始,找到65
(大于49
),65交换到a[j],j=6
。
- 从左向右找到第一个大于基准的元素:
[27, 38, , 97, 76, 13, *65*] 49
| (基准)
- 继续循环:
- 从右向左找到第一个小于基准的元素:
j
从j=6
开始,找到13
(小于49
),把13交换到a[i],i=2
。
- 从右向左找到第一个小于基准的元素:
[27, 38, *13* , 97, 76, , 65] 49
| (基准)
- 继续循环:
- 从左向右找到第一个大于基准的元素:
i
从i=2
开始,找到97
(大于49
),把97交换到a[5],j=5
。
- 从左向右找到第一个大于基准的元素:
[27, 38, 13, , 76, *97* , 65] 49
| (基准)
- 循环结束:
i
和j
相遇,将基准放回其最终位置。
[27, 38, 13 , 49, 76, 97 , 65]
| (基准)
k = 4
,即k -1= 3=i
在这个过程中,第4小的元素是 49
。
- 如果k - 1 > i,则在右区间找,修改start
- 如果k - 1 <i,则在左区间找,修改end
- 以下是代码
C语言代码及注释
#include <stdio.h>
int QuickSelect(int *a, int start, int end, int k)
{
// 如果要寻找的子序列中只有一个元素且这个元素正好是要找的第k小元素(这里能这样判断其实是因为,前面的寻找的过程其实都是在排序,能找到这一个的时候这个元素的位置绝对是正确的)
if (start == end && start == k - 1)
{
return a[start];
}
else if (start < end)
{
// 记录枢轴的值
int pivot = a[start];
// 用另外的变量来代替start和end避免破坏start和end的值,后面递归的时候还要用到
int i = start, j = end;
// 外层这个循环主要是保证进行多次循环,直到比枢轴元素大的元素都到枢轴元素的右边,比枢轴元素小的元素全部到枢轴元素的左边,也即是确定枢轴元素的位置
while (i != j)
{
// 从右往左扫描,直到找到比枢轴元素小的元素
while (j > i && a[j] >= pivot)
{
j--;
}
// 把比枢轴元素小的元素移动到左边的空位(第一个空位其实就是枢轴的位置)
a[i] = a[j];
// 从左往右扫描,直到找到比枢轴元素大的元素
while (i < j && a[i] <= pivot)
{
i++;
}
// 把比枢轴元素大的元素移动到右边的空位
a[j] = a[i];
}
// 把枢轴元素放回队列
a[i] = pivot;
// 找到了的情况
if (i == k - 1)
{
return a[i];
}
// 要找的元素在右区间,对右区间继续递归查找
else if (k - 1 > i)
{
return QuickSelect(a, i + 1, end, k);
}
// 要找的元素在左区间,对左区间继续递归查找
else
{
return QuickSelect(a, start, i - 1, k);
}
}
}
int main()
{
// 指定序列长度
int n = 10;
// 设置序列
int a[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3};
int res;
// 这里相当于是把所有的k都试了一遍,实际上一般只会问一个k值
for (int k = 1; k <= n; k++)
{
res = QuickSelect(a, 0, n - 1, k);
printf("第%d小的元素是%d\n", k, res);
}
return 0;
}
分治法解决查找第k小的问题通常采用快速选择算法(Quick Select)。快速选择算法是快速排序算法的一个变种,其核心思想是每次选择一个基准,将数组分为两部分,然后根据基准的位置决定在哪个部分继续查找第k小的元素。
时间复杂度分析
不同情况下的时间复杂度分析:
-
最佳情况:每次选择的基准都是数组的中位数,这种情况下,每次分区都能将数组均匀地分为两部分。时间复杂度为[O(n)]。
-
平均情况:在平均情况下,基准的选择是随机的,但期望上仍然能够较好地将数组分为两部分。时间复杂度为[O(n log n)]。
-
最坏情况:如果每次都选择的是最极端的元素(最大或最小),则每次分区只能将数组分为一个元素和其余元素两部分。这种情况下,时间复杂度为[O(n^2)]。
在前面提供的代码实现中,使用了随机化快速选择算法的思想。每次分区操作的时间复杂度为[O(n)],递归调用的次数在平均情况下为[O(log n)]。因此,整个算法的平均时间复杂度为[O(n log n)]。