什么是分治算法
分治算法是一种常见的问题解决方法,它将一个复杂的问题划分为多个相同或相似的子问题,然后递归地解决这些子问题,最后将子问题的解合并得到原问题的解。
分治算法通常包含三个步骤:
-
分解(Divide):将原问题划分成多个相同或相似的子问题。这里的划分策略取决于具体问题的特性。
-
解决(Conquer):递归地解决子问题。对于规模较小的子问题,可以直接求解。
-
合并(Combine):将子问题的解合并成原问题的解。这一步骤通常在递归的回溯过程中完成。
分治算法的核心思想是将复杂的问题划分为规模更小的子问题,并通过递归地解决这些子问题来解决原问题。通过合并子问题的解,最终得到原问题的解。
分治算法常用于解决一些具有重叠子问题性质的问题,例如归并排序、快速排序和求解最大子数组等。它可以提高问题的求解效率,降低问题的复杂度。
然而,分治算法并非适用于所有类型的问题。在应用分治算法时,需要满足以下条件:
- 原问题可以划分为多个规模较小的子问题。
- 子问题的解可以合并成原问题的解。
- 子问题之间相互独立,即子问题之间没有重叠部分。
通过合理地划分问题、解决子问题和合并子问题的解,分治算法可以帮助我们有效地解决各种复杂的问题。
常见的用分治算法解决的问题
1、归并排序
归并排序是一种常见的排序算法,其基本思想是将待排序的数组分成两个子数组,分别对这两个子数组进行递归排序,然后将两个有序的子数组合并成一个有序的数组。下面是归并排序的算法思路
-
分解:将待排序的数组分解为两个子数组。选择一个中间位置(mid)将数组一分为二,分别得到左子数组和右子数组。
-
递归排序:对左子数组和右子数组分别进行递归排序。将这一步骤递归地应用于左子数组和右子数组,直到子数组的长度为1或0,即无法再分解。
-
合并:将两个有序的子数组合并成一个有序的数组。创建一个临时数组(tmp)来存储合并后的结果。从两个子数组的起始位置开始,比较两个子数组的元素,将较小的元素放入临时数组中,并将对应子数组的指针向后移动一位。重复这个过程直到其中一个子数组的所有元素都被放入临时数组中。将剩余的子数组中的元素依次放入临时数组的末尾。最后,将临时数组中的元素复制回原始数组的对应位置。
-
递归结束:当子数组的长度为1或0时,递归结束。此时,数组已经有序。
代码实现
/*算法思路:首先我们需要将一个待排序的序列分成若干个子序列,我们采用2分法,在中间(mid)处划分成了两个序列1、我们采用递归思想,将一个序列在mid处划分,直到不能划分为止
2、将划分的子序列进行排序,然后将排序好的子序列合并*/void merge(int a[],int start,int mid,int end){int *tmp = (int *)malloc((end-start+1)*sizeof(int)); // 动态分配一个临时存放数组int i = start;int j = mid+1;int k = 0;// 排序while(i<=mid && j<=end){if(a[i]<=a[j]){tmp[k++] = a[i++];}else{tmp[k++] = a[j++];}}// 如果还剩下序列,直接加到数组最后面while(i<=mid){tmp[k++] = a[i++];}while(j<=end){tmp[k++] = a[j++];}// 最后合并子序列for(int i=0;i<k;i++){a[start+i] = tmp[i]; }free(tmp);}void merge_sort(int a[],int start,int end){if(a==NULL || start>=end){return ;}// 递归划分子序列int mid = (start+end)/2;merge_sort(a,start,mid);merge_sort(a,mid+1,end);// 合并排序好的子序列merge(a,start,mid,end);
}
2、最大子数组问题
最大子数组问题是一个经典的问题,其目标是在给定数组中找到一个子数组,使得该子数组的元素和最大
-
基本情况:如果数组的长度为1,直接返回该元素作为最大子数组。
-
分解:将原数组划分为两个子数组。找到数组的中间位置,将数组一分为二,分别得到左子数组和右子数组。
-
递归求解:对左子数组和右子数组分别进行递归求解最大子数组。即对左子数组和右子数组分别调用最大子数组算法。
-
跨越中点的最大子数组:考虑包含中点的子数组,即左子数组的最右边元素和右子数组的最左边元素之间的连续子数组。从中点开始向左扩展,记录最大和,然后从中点开始向右扩展,记录最大和。将这两个和相加,得到跨越中点的最大子数组和。
-
合并:比较左子数组、右子数组和跨越中点的最大子数组的和,取最大的作为最终的最大子数组和。
-
返回结果:返回最大子数组的起始位置、结束位置和和值。
代码实现
#include <stdio.h>
#include <limits.h>// 求解跨越中点的最大子数组和
int maxCrossingSum(int arr[], int low, int mid, int high) {int leftSum = INT_MIN; // 表示无穷小,因为负数也要进行求和运算 int sum = 0;// 求出左边子序列的最大值 for (int i = mid; i >= low; i--) {sum += arr[i];if (sum > leftSum) {leftSum = sum;}}int rightSum = INT_MIN;sum = 0;// 求右边子序列的最大值 for (int i = mid + 1; i <= high; i++) {sum += arr[i];if (sum > rightSum) {rightSum = sum;}}return leftSum + rightSum;
}// 分治法求解最大子数组和
int maxSubArrayUtil(int arr[], int low, int high) {// 当子序列只有一个元素时if (low == high) {return arr[low];}int mid = (low + high) / 2;// 最大子数组和可能在左边、右边或跨越中点int leftMax = maxSubArrayUtil(arr, low, mid);int rightMax = maxSubArrayUtil(arr, mid + 1, high);// 合并之后的值int crossMax = maxCrossingSum(arr, low, mid, high);// 返回三者中的最大值// condition ? expression_if_true : expression_if_false;// condition 条件为真,则返回 expression_if_true,否则返回expression_if_falsereturn (leftMax > rightMax && leftMax > crossMax) ? leftMax : ((rightMax > leftMax && rightMax > crossMax) ? rightMax : crossMax);
}// 对外接口,调用分治法求解最大子数组和
int maxSubArray(int arr[], int numsSize) {return maxSubArrayUtil(arr, 0, numsSize - 1);
}int main() {int nums[] = {1,-2,4,5,-2,8,3,-2,6,7,3,-1};int numsSize = sizeof(nums) / sizeof(nums[0]);int result = maxSubArray(nums, numsSize);printf("子数组最大和为: %d\n", result);return 0;
}
在这里会有一个问题,就是如果你输入的数组 num = [-1,-1,-2,-2]时,你返回的结果是-2,但其实真正的最大值是-1,所以分治算法并不是解决最大子数组问题的最优解,在这个给大家分享另一种算法思路,那就是利用动态规划的算法思想来解决问题。
思路:
1、定义一个动态规划数组 D,其中 D[i] 表示以第 i 个元素结尾的最大子数组和。
2、递推关系是关键,它告诉我们如何通过已知信息计算新的信息:D[i] = max(D[i-1] + num[i], num[i])
代码如下
# include <stdio.h>int max(int a,int b){if(a>b){return a;}else{return b;}
}int maxSubArray(int* num,int size){if(size == 0){return 0;}int D[size];D[0] = num[0]; // 动态规划数组,D[i]表示以第i个元素结尾的最大子数组和int maxSum = D[0]; // 初始化第一个元素// 从第二个元素开始遍历数组,根据递推关系更新动态规划数组for(int i = 1;i<size;i++){D[i] = max((D[i-1]+num[i]),num[i]);if(maxSum < D[i]){maxSum = D[i];}}return maxSum;
}int main(){int nums[] = {-2, 1, -3, 4, -1, 2, 1, -5, 4};int size = sizeof(nums) / sizeof(nums[0]);int result = maxSubArray(nums, size);printf("Maximum Subarray Sum: %d\n", result);return 0;
}
3、逆序对问题
逆序对问题是一个经典的问题,其目标是计算一个数组中逆序对的数量。逆序对指的是数组中的两个元素,若其在数组中的相对顺序与排序后的顺序相反,则它们构成一个逆序对。
例如num = [5,2,3,6,8] 其中num[0] = 5,num[1] = 2, 这就是一个逆序对。
下面是逆序对问题的一种常见算法思路,基于归并排序的思想:
-
分解:将原数组划分为两个相等的子数组,直到子数组的长度为 1。这可以通过递归实现。
-
递归求解:对左子数组和右子数组分别进行递归求解逆序对的数量。
-
合并与计数:在合并左右子数组的过程中,统计逆序对的数量。合并时,使用两个指针分别指向左子数组和右子数组的起始位置,并进行比较。若右子数组当前元素小于左子数组当前元素,则构成逆序对,并将逆序对的数量累加。同时,将较小的元素放入合并后的数组中,移动对应的指针。若左子数组或右子数组遍历完毕,则将剩余的元素直接放入合并后的数组中。
-
返回结果:返回逆序对的数量。
代码实现
# include <stdio.h>
# include<stdlib.h>// 归并排序辅助函数:将两个有序数组合并为一个有序数组,并计算逆序对个数
int merge(int* num,int low,int mid,int high){int count = 0;int* tmp = (int *)malloc((high-low+1)*sizeof(int));int i = low;int j = mid+1;int k = 0;while(i<=mid && j<=high){if(num[i]<=num[j]){tmp[k++] = num[i++];}else{tmp[k++] = num[j++];count += (mid-i+1); // 左数组剩余元素都大于arr[j],构成逆序对}}while(i<=mid){tmp[k++] = num[i++];}while(j<=high){tmp[k++] = num[j++];}// 将合并后的结果拷贝回原数组for(int m = 0;m<k;m++){num[low+m] = tmp[m];}free(tmp);return count;
}// 归并排序
int merge_sort(int* num,int low,int high){int count = 0;if(num == NULL || low >= high){return 0;} int mid = (low+high)/2;// 分别对左右两个子数组进行归并排序,并累加逆序对个数count += merge_sort(num,low,mid);count += merge_sort(num,mid+1,high);// 合并两个有序数组,并累加逆序对个数count += merge(num,low,mid,high);return count;} // 求解逆序对
int countInversePairs(int* num ,int length) {int count = merge_sort(num,0,length-1);return count;}int main(){
// int arr[] = {13,8,10,6,15,18,12,20,9,14,17,19};int arr[] = {4,6,8,3,5};
// int arr[] = {9, 5, 7, 2, 4, 1, 6, 8, 3};int length = sizeof(arr) / sizeof(arr[0]);int count = countInversePairs(arr,length);printf("%d",count);return 0;
}
4、快速排序
快速排序是一种常用的排序算法,其基于分治思想进行排序。下面是快速排序的算法思路:
-
选择基准:从数组中选择一个元素作为基准(pivot)。一般可以选择第一个元素、最后一个元素或者随机选择一个元素作为基准。
-
分区操作:将数组中的元素按照与基准的大小关系划分为两个子数组,一个子数组中的元素小于等于基准,另一个子数组中的元素大于基准。可以通过使用两个指针,一个指向数组的起始位置,一个指向数组的结束位置,通过交换元素来实现分区操作。
- 初始化两个指针
i
和j
,分别指向数组的起始位置和结束位置。 - 从指针
i
开始向右移动,直到找到一个元素大于基准。 - 从指针
j
开始向左移动,直到找到一个元素小于基准。 - 如果
i
小于等于j
,交换指针i
和j
所指向的元素。 - 继续移动指针,直到
i
大于j
。
- 初始化两个指针
-
递归排序:对基准左边的子数组和右边的子数组分别进行递归排序。即,对左子数组和右子数组重复上述步骤,直到子数组的长度为 1 或 0。
-
合并结果:递归排序完成后,数组已经有序。
代码实现
# include<stdio.h>// 交换数组中两个元素的值
void swap(int* a,int* b){int tmp = *a;*a = *b;*b = tmp;
}// 划分函数,返回基准元素的最终位置
int partition(int* num,int low,int high){int pivot = num[high]; // 选择最后一个元素作为基准元素int i = low - 1; // i表示小于基准的元素的右边界for(int j = low;j<=high-1;j++){if(num[j]<=pivot){i++;swap(&num[i],&num[j]); // 将小于等于基准的元素交换到左侧}} swap(&num[i+1],&num[high]); // 将基准元素放到正确的位置return i+1; // 返回基准元素的最终位置} // 快速排序函数
void Quick_sort(int* num,int low,int high){if(low<high){int pivotIndex = partition(num,low,high); // 划分数组,获取基准元素的位置递归对基准元素左侧和右侧的子数组进行排序Quick_sort(num,low,pivotIndex-1);Quick_sort(num,pivotIndex+1,high);}
} int main(){int num[] = {9, 5, 7, 2, 4, 1, 6, 8, 3};int length = sizeof(num) / sizeof(num[0]); Quick_sort(num, 0, length - 1);printf("排序结果:");for (int i = 0; i < length; i++) {printf("%d ", num[i]);}printf("\n");return 0;
}
5、次序选择问题
次序选择问题(Order Statistics Problem)是指从一个未排序的数组中找到第 k 小(或第 k 大)的元素。下面是一种常见的算法思想,基于快速排序的分治思想来解决次序选择问题:这个可以看作快速排序的应用。
-
选择基准:从数组中选择一个元素作为基准(pivot)。一般可以选择第一个元素、最后一个元素或者随机选择一个元素作为基准。
-
分区操作:将数组中的元素按照与基准的大小关系划分为两个子数组,一个子数组中的元素小于等于基准,另一个子数组中的元素大于基准。可以通过使用两个指针,一个指向数组的起始位置,一个指向数组的结束位置,通过交换元素来实现分区操作。
- 初始化两个指针
i
和j
,分别指向数组的起始位置和结束位置。 - 从指针
i
开始向右移动,直到找到一个元素大于基准。 - 从指针
j
开始向左移动,直到找到一个元素小于基准。 - 如果
i
小于等于j
,交换指针i
和j
所指向的元素。 - 继续移动指针,直到
i
大于j
。
- 初始化两个指针
-
递归选择:根据分区操作后基准的位置,将问题转化为在基准的左边或右边子数组中寻找第 k 小(或第 k 大)的元素。若基准的位置为 k-1,则找到了第 k 小(或第 k 大)的元素;若基准的位置大于 k-1,则在左边子数组中递归选择第 k 小(或第 k 大)的元素;若基准的位置小于 k-1,则在右边子数组中递归选择第 k-j 小(或第 k-j 大)的元素。
-
返回结果:最终返回第 k 小(或第 k 大)的元素。
代码实现
#include <stdio.h>// 交换数组中两个元素的值
void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}// 划分函数,返回基准元素的最终位置
int partition(int arr[], int low, int high) {int pivot = arr[low]; // 选择第一个元素作为基准元素int i = low; // i表示小于基准的元素的右边界for (int j = low + 1; j <= high; j++) {if (arr[j] < pivot) {i++;swap(&arr[i], &arr[j]); // 将小于基准的元素交换到左侧}}swap(&arr[low], &arr[i]); // 将基准元素放到正确的位置return i; // 返回基准元素的最终位置
}// 快速选择函数
int quickSelect(int arr[], int low, int high, int k) {if (low == high) {return arr[low]; // 当数组只有一个元素时,直接返回该元素}int pivotIndex = partition(arr, low, high); // 划分数组,获取基准元素的位置if (k == pivotIndex) {return arr[k]; // 基准元素即为第k小(或第k大)的元素} else if (k < pivotIndex) {return quickSelect(arr, low, pivotIndex - 1, k); // 在基准元素的左侧递归查找} else {return quickSelect(arr, pivotIndex + 1, high, k); // 在基准元素的右侧递归查找}
}int main() {下·int arr[] = {9, 5, 7, 2, 4, 1, 6, 8, 3};int length = sizeof(arr) / sizeof(arr[0]);int k = 4; // 查找第4小的元素int result = quickSelect(arr, 0, length - 1, k - 1);printf("第%d小的元素是:%d\n", k, result);return 0;
}
总结一下,其实分治思想就是一个将原问题化成多个子问题的算法思想,其中有三个重要的步骤
分解原问题:将原问题分解成多个子问题
解决子问题:递归求解各个子问题
合并问题解:将结果合并为原问题解