算法思想有很多,比如常用的有枚举、递推、递归、分治、贪心、试探、动态规划、模拟等。其实大多数的算法思想都源于四种:枚举、递归(分治)、贪心和动态规划。下面找几个记录一下。
枚举
枚举算法思想的最大特点是,在面对任何问题时他会去尝试每一种解决方法。在进行归纳推理是,考察某种时间的所有可能的情况,从而得出结论。
它的一般思路为:
- 确定枚举对象、范围、判定条件
- 逐一列举可能的解,验证是否为问题真正的解。
- 输出解
实例:
鸡兔同笼问题
private int enumeBackRabbit(int foot,int head) {
for (int i = 0, j; i < head; i++) {
j = head - i;
if (i * 4 + j * 2 == foot) {
return j;
}
}
return -1;
}
递归
在计算机编程中递归算法能有效的解决大多数问题,它能够是算法的描述变得十分的简单而且易于理解。
特点:
- 递归过程一般通过函数或着子过程来实现
- 递归算法在函数或者子过程的内部,直接或间接的调用着自己的算法
- 递归算法实际上是把问题转化为规模缩小了的同类问题的子问题,然后再递归调用函数或者过程来表示问题的解
注意:
- 在使用递归时必须有一个明确的结束条件
- 递归散发通常很简洁但是运行效率低
- 在递归调用的过程中系统用栈来存储每一层的返回点和局部变量,如果递归的次数过多,则容易造成栈溢出。故一般不提倡使用递归。
简单的例子:
阶乘问题
private int fact(int i){
if (i <= 1) {
return 1;
} else {
return i * fact(i - 1);
}
}
贪心
假设一个问题比较复杂,暂时找不到全局的最优解,那么我们可以考虑把原问题拆成几个小问题,分别求每个小问题的最优解,最后再把这些局部的最优解叠加起来当做整个问题的最优解。从整体和结果来看可能会出现虽然在每个子问题上是最优的,但是有可能不是全局的最优解。所以贪心算法思想就是考虑每一步目前前的最优策略,而不考虑全局是不是最优。
流程:
- 明确到底什么是最优解
- 明确子问题的最优解
- 分别求出子问题的最优解在堆叠出全局的最优解
实例
背包问题:有一个背包,最多能承载150斤的重量,现在有7个物品,它们的重量和价值如下,我们该如何选择才能让背包带走最多有价值的物品?
重量:
[35,30,60,50,40,10,25]
价值:
[10,40,30,50,35,40,30]
我们经常会遇到这样的情况,对某个条件有限制,但是又希望结合另一个属性让最终的结果最好。
下面我们根据贪心的流程走走,找一下这个最优解:
- 明确什么是最优解 -->在重量限制的范围内,选择价值最大
- 子问题最优 --> 此时对于子问题,有很多定义,比如每次选最高价值的
- 叠加成为最优解
如上,按照价值排序:
重量:
[35,30,60,50,40,10,25]
[50,30,10,40,25,60,35]
价值:
[10,40,30,50,35,40,30]
[50,40,40,35,30,30,10]
key-value:
[35-10, 30-40, 60-30, 50-50, 40-35, 10-40, 25-30]
[50-50, 30-40, 10-40, 40-35, ...]
总重量是:130
总价值是:165
按照物品中质量最小的最为优先选择:
[10-40, 25-30, 30-40, 35-10, 40-35, ...]
总重量是:140
总价值是:155
由此我们看出重量优先还没有价值优先策略更好。
…
…
…
再换一种思想如果采用单位价值密度来排序会是个什么结果
[35-10, 30-40, 60-30, 50-50, 40-35, 10-40, 25-30]
[0.2857, 1.333, 0.5, 1, 0.875, 4, 1.2]
[10-40, 30-40, 25-30, 50-50, 35-10, ...]
总重量是:150
总价值是:170
此时这个策略比之前的都要好。
因此针对不同情况的问题我们需要不同的去定制,以达到子问题的最优。
下面来看看贪心的前提:
- 原问题复杂度过高
- 求全局最优解的数学模型难以建立
- 全局最优解的计算量过大
- 没有必要一定要求出最优解,比较优就可以了
如何把原问题拆分成子问题:
按串行任务分:
时间串行的任务按子任务分解,即每一步都是在前一步的基础上再选择当前的最优解
按规模递减分:
规模较大的复杂问题可以借助递归分解成一个规模小一点点的问题,循环解决,当最后一步的求解完成后就得到了所谓的全局最优解
按并行任务分:
任务不分先后可以分别求解后再按照一定的规则将其组合后得到最终解
最后评估一下你的算法的好坏,一般从成本、速度、价值来参考分别为:耗费多少资源,计算量是否过大,得到了最优解与次优解是否真的有那么大差距。
动态规划
维基百科解释:是一种在数学,管理科学,计算机科学,经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要先解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
换句话说:其核心就是记住已经解决过的子问题的解,以此来节省整体所用的时间。
特点:
- 最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态規劃算法解决问题提供了重要线索。
- 无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
- 子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态規劃算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。
方式:
- 自顶向下的备忘录法
- 自底向上
自顶向下。我们可以假设在解题前,有一个备忘录(即一个存储数据的容器)专门用来记录每个解过的子问题及其解。我们在解题过程碰到每个子问题时,先寻找备忘录,如果备忘录上已经有该子问题,说明先前已经被解决了,现在无需重复直接取出该子问题的解即可;如果备忘录中没有该子问题,则求解并保存到备忘录中。本质上自顶向下方法还是运用到了递归的思想。
自底向上,本质上运用到了迭代的思想,先计算子问题,再由这些子问题计算父问题,直至求解出原问题的解。我们先求出各个子问题的解,然后保存已求解的子问题到我们的备忘录,需要时就取出,以此消除对某些子问题的重复求解。
实例:
斐波那契数列问题
如下为它的数学表达式:
F
n
(
x
)
=
{
F
n
−
1
(
x
)
+
F
n
−
2
(
x
)
n
≥
2
1
n
=
1
0
n
=
0
F_n(x)=\{^{^{0 n=0}_{1 n=1}}_{F_{n-1}(x)+F_{n-2}(x) n\geq2}
Fn(x)={Fn−1(x)+Fn−2(x) n≥21 n=10 n=0
前几个多项式为:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55.....
我们不难发现很多子问题都被重复执行了,比如f(2) 被重复执行了3次,浪费了很多时间。
因为我们每次调用每一个函数的时候都要保留环境配置,这么多的子问题被重复执行导致空间上开销很大。如果在子问题执行的时候把执行过的子问题保存到“备忘录”中,后面要用到的时候直接查备忘录调用的话则可以节约大量的时间。
递归版本:
private int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
动态规划:
private int fib1(int n) {
int f[] = new int[n + 2];
f[0] = 0;
f[1] = 1;
for (int i = 2; i <= n; i++) {
f[i] = f[i - 1] + f[i - 2];
}
return f[n];
}
姑且先说这么多,最后感受一道简单的题,如下:
求前n项和:1,2,3,4,5,6…n。
要求:不能使用乘除法、for、while、switch、case等关键字。
…
…
…
单纯这样的话是不比较简单:
private int fact(int n){
if (n <= 0) {
return 1;
} else {
return n + fact(n - 1);
}
}
如果轻轻的刁难一下呢?改一下条件:
要求:不能使用乘除法、for、while、if、else、switch、case等关键字及条件判断语句(A?B:C)。
…
…
…
…
…
…
去掉if
private int fact1(int n){
return n <= 1 ? 1 : n + fact(n - 1);
}
去掉A?B:C
n <= 1 ? 1 : n + fact(n - 1)
-->
n != 0 && (f(n-1) +n ) != 0
上面的转换之后的式子前面满足后面一定执行,后面之所以加 != 0是为了满足&&
那么最后的式子就可以写成:
private int fact1(int n) {
int sum = n;
boolean t = (n != 0) && (sum += fact1(n - 1)) != 0;
return sum;
}