1.分治与二分查找
1.1分治算法介绍
分治法即“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……
分治算法可以求解的一些经典问题
- 二分搜索
- 大整数乘法
- 棋盘覆盖
- 合并排序
- 快速排序
- 线性时间选择
- 最接近点对问题
- 循环赛日程表
- 汉诺塔
1.2分治算法基本步骤
分治法在每一层递归上都有三个步骤:
- **分解:**将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
- **解决:**若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
- **合并:**将各个子问题的解合并为原问题的解
1.3分治算法设计模式
if |P|≤n0
then return(ADHOC(P))
//将P分解为较小的子问题 P1 ,P2 ,…,Pk
for i←1 to k
do yi ← Divide-and-Conquer(Pi) // 递归解决Pi
T ← MERGE(y1,y2,…,yk) // 合并子问题
return(T)
- 其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。
- ADHOC§是该分治法中的基本子算法,用于直接解小规模的问题P。 因此,当P的规模不超过n0时直接用算法ADHOC§求解。
- 算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2
,…,Pk的相应的解y1,y2,…,yk合并为P的解
1.4汉诺塔
在一根柱子上从下往上按照大小顺序摞着64片圆盘,圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。
1.4.1分治思路
- 如果 A 塔上只有一个盘子:
- 直接将 A 塔的盘子移动到 C 塔:A —> C
- 如果 A 塔上有两个盘子:
- 先将 A 塔上面的盘子移动到 B 塔:A —> B
- 再将 A 塔最下面的盘子移动到 C 塔:A --> C
- 最后将 B 塔上面的盘子移动到 C 塔:B --> C
- 如果 A 塔上有三个盘子:
- n >= 2 时,就体现出了分治算法的思想:我们将 A 塔上面的盘子看作一个整体,最下面的单个盘子单独分离出来,分三步走
- 先将 A 塔上面的盘子看作一个整体,移动到 B 塔(把 C 塔当做中转站)
- 这样 A 塔就只剩下一个最大的盘子,将 A 塔剩下的盘子移动到 C 塔
- 最后将 B 塔上面的盘子移动到 C 塔(把 A 塔当做中转站)
1.4.2代码实现
解决汉诺塔问题:
public class Hanoitower {
public static void main(String[] args) {
hanoiTower(3, 'A', 'B', 'C');
}
//汉诺塔的移动的方法
//使用分治算法
public static void hanoiTower(int num, char a, char b, char c) {
//如果只有一个盘
if(num == 1) {
System.out.println("第1个盘从 " + a + "->" + c);
} else {
//如果我们有 n >= 2 情况,我们总是可以看做是两个盘 1.最下边的一个盘 2. 上面的所有盘
//1. 先把 最上面的所有盘 A->B, 移动过程会使用到 c
hanoiTower(num - 1, a, c, b);
//2. 把最下边的盘 A->C
System.out.println("第" + num + "个盘从 " + a + "->" + c);
//3. 把B塔的所有盘 从 B->C , 移动过程使用到 a塔
hanoiTower(num - 1, b, a, c);
}
}
}
程序运行结果
第1个盘从 A->C
第2个盘从 A->B
第1个盘从 C->B
第3个盘从 A->C
第1个盘从 B->A
第2个盘从 B->C
第1个盘从 A->C
我想捋一下,这个代码虽然见的很多,但感觉还是很新颖的,首先必须具有参数num也就是盘子的个数,其次如果按顺序把ABC作为参数,即代表一种解决方案,把A上的移动到C以B作为中转塔,那么此代码的核心出是你怎么体现思想里面的即把上面的所有盘子单独作为一个整体,分离出下面的大盘子,就当作是两个盘子来进行分治,
1.5二分查找
1.5.1二分查找基础版(一定要流畅写出)
public static int binarySearch(int[] arr,int value){
int left = 0;
int right = arr.length-1;
while(left <= right){
int midIndex = (left+right)/2;
if(arr[midIndex] > value){
right = midIndex-1;
}else if(arr[midIndex] < value){
left = midIndex+1;
}else {
return midIndex;
}
}
return -1;
}
1.5.2细节处理:
1.界限left<=right
首先最重要的细节就是为什么是left<=right,如果没注意到这个细节没事自己去手写的时候很容易就left<right,试想面试真的问这个问题你又如何去简单精炼的描述出这个问题,二分查找是建立在分治的基础上,那这里的left=0,right=arr.length-1就代表的是一个区间也就是问题的规模,如果while循环条件是left<right,也就是说当left=right的时候不再进入循环,但是left=right左右下标重合的时候此时还有一个数据没有进行比较处理,只有当退出条件是left>right两个下标进行错位,代表着整个问题的区域已经完全涵盖完全一个不漏,再去进行最后的return返回退出
2.溢出处理
如果数组的元素非常多,假如大约有20亿个数据,那么数据元素小标对应的区间也在0到20亿,这样是可以的,因为整形的最大值是2的31次方-1在21亿左右,那初始的midIndex下标就是在10亿,如果用上述代码去计算midIndex时首先要计算left+right=30亿左右,已经超过整形最大值溢出了,这里最好的做法是midIndex=(right-left+1)/2+left,这个计算方式也很好理解,right-left+1就是实际的left下标到right下标的元素个数,由于是只是纯粹的元素个数所以再除以2后想要得到真正的物理下标位置就要再加上left的起始下标,这样尽量以减法的形式进行运算可以更有效地避免类型溢出的问题
3.查重处理
我想要稍微改进一下,这个太过于呆板,我觉得至少要考虑一下查重问题,
比如一组数据{12,12,12,12,12,12,23,23,23,23,23,23,23,34,45,56,67}
我可能通过midIndex最后找到中间某个位置的12或23,能不能去最后返回特定位置的你想要的数据,比如最左边的12,最右边的23?那其实也很好写,就给最后return midIndex之前再加个while循环判断一下就ok,但是mid必须要大于left不然会越界
4.运算效率处理
有计算机底子的朋友都知道用>>运算符比除以2效率会更高,所以midIndex=(right-left)>>1+left,如果你不加思索直接这样写那就又上当了,右移运算符的优先级是小于加法的,所以表达式会先计算1+left,这就完的蛋蛋了,而且如果写代码时编译器本身不提示就会很难看出来,你要用右移的话得加括号midIndex=((right-left)>>1)+left
5.黄金分割
还是针对于除2操作,不过这次是从数学角度,除以2相当于乘0.5,熟悉数学的朋友应该会有内个感觉,就是乘0.618,黄金分割点,虽然说我们的二分查找是对数据区域进行折半不停的除2操作,但是这里乘黄金分割点和单纯乘0.5还是有着天差地别的,黄金分割点的效率更高
1.5.3二分查找进阶版
public static int FindValue(int[] arr,int left,int right,int val){
int pos = -1
if(left <= right){
int mid = (right - left + 1) / 2 + left;
if(val < arr[mid]){
pos = FindValue(arr,left,mid-1,vla);
}else if(val > arr[mid]){
pos = FindValue(arr,mid+1,right,val);
}else{
while(mid > left && arr[mid-1] == val){
--mid;
pos = mid;
}
}
}
}
2.动态规划
2.1动态规划算法介绍
- 动态规划(Dynamic Programming)算法的核心思想是:将大问题划分为小问题进行解决,从而一步步获取最优解的处理算法
- 动态规划算法与分治算法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
- 与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。 (即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解 )
- 动态规划可以通过填表的方式来逐步推进,得到最优解
2.2背包问题
2.2.1背包问题介绍
背包问题主要是指一个给定容量的背包、若干具有一定价值和重量的物品,如何选择物品放入背包使物品的价值最大。其中又分01背包和完全背包(完全背包指的是:每种物品都有无限件可用)
这里的问题属于01背包,即每个物品最多放一个。而无限背包可以转化为01背包。
2.2.2代码思路
背包容量为 4 磅,物品价格以及重量表如下:
物品 | 重量 | 价格 |
---|---|---|
吉他(G) | 1 | 1500 |
音响(S) | 4 | 3000 |
电脑(L) | 3 | 2000 |
先来填表 v ,对应着数组 v[][]
,我来解释下这张表:
- 算法的主要思想:利用动态规划来解决。
- 每次遍历到的第 i 个物品,根据 w[i - 1](物品重量)和 val[i -
1](物品价值)来确定是否需要将该物品放入背包中,C为背包的容量 v[i][j]
表示在前 i 个物品中能够装入容量为 j 的背包中的最大价值
物品 | 0磅 | 1磅 | 2磅 | 3磅 | 4磅 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | |
吉他(G) | 0 | ||||
音响(S) | 0 | ||||
电脑(L) | 0 |
- 对于第一行(i=1),目前只有吉他可以选择,这时不管背包容量多大,也只能放一把吉他
物品 | 0磅 | 1磅 | 2磅 | 3磅 | 4磅 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | |
吉他(G) | 0 | 1500(G) | 1500(G) | 1500(G) | 1500(G) |
音响(S) | 0 | ||||
电脑(L) | 0 |
- 对于第二行(i=2),目前存在吉他和音响可以选择,新物品为音响,重量为 4 磅,尝试将其放入背包
- 在 v[2][4] 单元格,尝试将音响放入容量为 4 磅的背包中,看看背包还剩多少容量?还剩 0 磅,再去找找 0磅能放入最大价值多高的物品:v[1][0] = 0
- 与上一次 v[1][4] 比较 , v[1][4] < v[2][4] ,发现确实比之前放得多,采取此方案
物品 | 0磅 | 1磅 | 2磅 | 3磅 | 4磅 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | |
吉他(G) | 0 | 1500(G) | 1500(G) | 1500(G) | 1500(G) |
音响(S) | 0 | 1500(G) | 1500(G) | 1500(G) | 3000(S) |
电脑(L) | 0 |
- 对于第三行(i=3),目前存在吉他和音响、电脑可以选择,新物品为电脑,重量为 3 磅,尝试将其放入背包
- 当背包容量为 3 磅时,可以放入电脑
-
在
v[3][3]
单元格,尝试将电脑放入容量为 3 磅的背包中,看看背包还剩多少容量?还剩 0 磅,再去找找 0磅能放入最大价值多高的物品:v[2][0] = 0
-
与上一次
v[2][3]
比较 ,v[2][3] < v[3][3]
,发现确实比之前放得多,采取此方案
-
- 当背包容量为 4 磅时,可以放入电脑
- 在
v[3][4]
单元格,尝试将电脑放入容量为 4 磅的背包中,看看背包还剩多少容量?还剩 1 磅,再去找找 1磅能放入最大价值多高的物品:v[2][1] = 1500
,所以总共能放入的重量为v[3][4] = 3500
- 与上一次
v[2][4]
比较 ,v[2][4] < v[3][4]
,发现确实比之前放得多,采取此方案
- 在
- 当背包容量为 3 磅时,可以放入电脑
物品 | 0磅 | 1磅 | 2磅 | 3磅 | 4磅 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | |
吉他(G) | 0 | 1500(G) | 1500(G) | 1500(G) | 1500(G) |
音响(S) | 0 | 1500(G) | 1500(G) | 1500(G) | 3000(S) |
电脑(L) | 0 | 1500(G) | 1500(G) | 2000(L) | 3500(L+G) |
总结公式:
- 当前新增物品的重量 > 背包的重量,则直接拷贝上次的方案
if (w[i - 1] > j) {
// 因为我们程序i 是从1开始的,所以是 w[i-1]
v[i][j] = v[i - 1][j];
}
- 当前新增物品的重量 <= 背包的重量
- 尝试将新物品放入背包,看看还剩多少容量
- 尝试剩余的容量填满,看看此时背包里物品的价值和上次比,哪个更大,取价格更大的方案即可
if (w[i - 1] <= j) {
// 因为我们程序i 是从1开始的,所以是 w[i-1]
v[i][j] = Math.max(v[i - 1][j], val[i - 1] + v[i - 1][j - w[i - 1]]);
}
为什么可以这样做?将大问题拆成小问题
- 第一步:求得第一步步骤的最优解
- 第二步:求得第二步步骤的最优解,第二步的最优解依赖于第一步的最优解
…
- 第 n 步:求得第 n 步步骤的最优解,第 n 步的最优解依赖于第 n-1 步的最优解
2.2.3代码实现
背包问题算法
public class KnapsackProblem {
public static void main(String[] args) {
int[] w = {
1, 4, 3 };// 物品的重量
int[] val = {
1500, 3000, 2000 }; // 物品的价值 这里val[i]
int m = 4; // 背包的容量
int n = val.length; // 物品的个数
// 创建二维数组,
// v[i][j] 表示在前i个物品中能够装入容量为j的背包中的最大价值
int[][] v = new int[n + 1][m + 1];
// 为了记录放入商品的情况,我们定一个二维数组
int[][] path = new int[n + 1][m + 1];
// 初始化第一行和第一列, 这里在本程序中,可以不去处理,因为默认就是0
for (int i = <