分治:如何利用分治法完成数据查找
核心思想就是“分而治之”
把一个大规模、高难度的问题,分解为若干个小规模、低难度的小问题
小问题的答案合并,来得到原问题的答案
很多高效率的算法都是以分治法作为其基础思想,例如快速排序和归并排序。
分治法是什么?
计算机求解问题所需最小时间,与其涉及的数据规模强相关
比如:在一个包含 n 个元素的无序数组中,要求按照从小到大的顺序打印其 n 个元素。
n 个元素之间的两两比较的计算方法,得到从小到大的序列
当数据量 n = 1 时,不需任何计算,直接打印即可;
当数据量 n = 2 时 ,那需要做 1 次比较即可达成目标;
当数据量 n = 3 时,要对这 3 个元素进行两两比较,共计 3 次比较;
而当数据量 n = 10 时,问题就不那么容易处理了,我们需要 45 次比较(计算方式是 0.5*n(n-1) )。
利用分治法,先将一个难以直接解决的大问题,分割成一些可以直接解决的小问题。
仍然无法直接解决,继续递归地分割,直到每个小问题都可解
子问题 互相独立、形式相同。可以采用同一种解法,递归地去解决这些子问题,再将每个子问题的解合并,就得到了原问题的解
分治法的价值
误解:计算机性能还不错,采用分治法相对于全局遍历没有什么差别
例如:在 1000 个有序数字构成的数组 a 中,判断某个数字 c 是否出现过
第一种方法,全局遍历。 复杂度 O(n)。
采用 for 循环,对 1000 个数字全部判断一遍。
第二种方法,采用二分查找。 复杂度 O(logn)。
递归地判断 c 与 a 的中位数的大小关系,并不断缩小范围。
对时间的消耗几乎一样。那分治法的价值又是什么呢
大数据集上,分治法的价值才能显现出来
例子:假如有一张厚度为 1 毫米且足够柔软的纸,问将它对折多少次之后,厚度能达到地球到月球的距离?
只需要对折 39 次
在数组 a 中查找数字 c ,数组 a 的大小拓展到 549,755,813,888 这个量级上,使用第二种的二分查找方法,仅仅需要 39 次判断
复杂度为 O(logn) 相比复杂度为 O(n) 的算法,在大数据集合中性能有着爆发式的提高
分治法的使用
分治法特征:
1、难度在降低
原问题的解决难度,随着数据的规模的缩小而降低。
2、问题可分
题可以分解为若干个规模较小的同类型问题
3、解可合并(分治法完全取决于这个特征)
利用所有子问题的解,可合并出原问题的解
4、相互独立
一个子问题的求解不会影响到另一个子问题
否则分治法需要重复地解决公共子问题,效率低下
分治法需要递归地分解问题,再去解决问题
分治法在每轮递归上,都包含:分解问题、解决问题、合并结果
二分查找
二分查找利用分治法去解决查找问题,需要一个前提,那就是输入的数列是有序的。
- 选择标志 i 将集合 L 分为二个子集合,一般使用中位数;
- 判断标志 L(i) 是否能与要查找的值 des 相等,相等直接返回结果;
- 如果不相等,需要判断 L(i) 与 des 的大小;
- 基于判断的结果决定下步是向左查找还是向右查找。如果向某个方向查找的空间为 0,则返回结果未查到;
- 回到步骤 1。
我们对二分查找的复杂度进行分析。二分查找的最差情况是,不断查找到最后 1 个数字才完成判断。那么此时需要的最大的复杂度就是 O(logn)。
分治法案例
在数组 { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } 中,查找 8 是否出现过
判断 8 和中位数 5 的大小关系。
因为 8 更大,所以在更小的范围 6, 7, 8, 9, 10 中继续查找
此时更小的范围的中位数是 8。
由于 8 等于中位数 8,所以查找到并打印查找到的 8 对应在数组中的 index 值
可以采用两个索引 low 和 high,确定查找范围
1、最初 low 为 0,high 为数组长度减 1。
2、在一个循环体内,判断 low 到 high 的中位数与目标变量 targetNumb 的大小关系。
3、根据结果确定向左走(high = middle - 1)或者向右走(low = middle + 1),来调整 low 和 high 的值。
4、直到 low 反而比 high 更大时,说明查找不到并跳出循环。我们给出代码如下:
package divide_conquer;
/**
* @author hym
* @date 2021/11/8
* @description
*/
public class BinarySearch {
public static void main(String[] args) {
// 需要查找数字
int targetNumber = 8;
// 目标有序数组
int[] sortedArray = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int middleIndex = 0;
int lowIndex = 0;
int highIndex = sortedArray.length - 1;
boolean isFind = false;
while (lowIndex < highIndex) {
middleIndex = (highIndex + lowIndex) / 2;
if (sortedArray[middleIndex] == targetNumber) {
System.out.println(targetNumber + "下标:" + middleIndex);
isFind = true;
break;
} else if (sortedArray[middleIndex] > targetNumber) {
highIndex = middleIndex - 1;
} else {
lowIndex = middleIndex + 1;
}
}
if (!isFind) {
System.out.println(targetNumber + "不存在");
}
}
}
-
二分查找的时间复杂度是 O(logn),这也是分治法普遍具备的特性。
当你面对某个代码题,而且约束了时间复杂度是 O(logn) 或者是 O(nlogn) 时,可以想一下分治法是否可行 -
二分查找的循环次数并不确定。一般是达到某个条件就跳出循环。
因此,编码的时候,多数会采用 while 循环加 break 跳出的代码结构 -
二分查找处理的原问题必须是有序的。
因此,当你在一个有序数据环境中处理问题时,可以考虑分治法。
相反,如果原问题中的数据并不是有序的,则使用分治法的可能性就会很低了
习题
在一个有序数组中,查找出第一个大于 9 的数字,假设一定存在。例如,arr = { -1, 3, 3, 7, 10, 14, 14 }; 则返回 10。
在这里提醒一下,带查找的目标数字具备这样的性质:
第一,它比 9 大;
第二,它前面的数字(除非它是第一个数字),比 9 小。
因此,当我们作出向左走或向右走的决策时,必须满足这两个条件。
package divide_conquer;
/**
* @author hym
* @date 2021/11/8
* @description 在一个有序数组中,查找出第一个大于 9 的数字,假设一定存在。
* 例如,arr = { -1, 3, 3, 7, 10, 14, 14 }; 则返回 10。
*/
public class Practice {
public static void main(String[] args) {
int targetNum = 9;
int[] sortedArr = {-1, 3, 3, 7, 10, 14, 14};
int lowIdx = 0;
int highIdx = sortedArr.length - 1;
int middleIdx = 0;
while (lowIdx < highIdx) {
middleIdx = (lowIdx + highIdx) / 2;
if (sortedArr[middleIdx] > 9 && (middleIdx == 0 || sortedArr[middleIdx - 1] <= 9)) {
// 数字在middle处
System.out.println(targetNum + " " + middleIdx);
break;
} else if (sortedArr[middleIdx] > targetNum) {
// 数字在low middle之间
highIdx = middleIdx - 1;
} else {
// 数字在 middle high之间
lowIdx = middleIdx + 1;
}
}
}
}
总结
面对陌生问题,注意原问题的数据是否有序,预期时间复杂度是否带有logn,是否可以通过小问题合并出原问题答案。均满足,应该考虑分治法