数据结构与算法(九)-六大常用算法思想
1.贪心算法思想
greedy algorithm,又称贪婪算法。是一种在每一步选择中都采取在当前状态下最好或最优的选择。是对问题产生整体最优解
或者是整体最优解的近似解
。贪心算法在最优子结构的问题中最尤为效。
1.1 基本思路
- 建立数学模型来描述问题。
- 把求解的问题分成若干个子问题。
- 对每一子问题求解,得到子问题的局部最优解。
- 把子问题的解局部最优解合成原来解问题的一个解。
最优子结构性质:
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。问题的最优子结构性质是该问题可用贪心算法求解的关键特征。
贪心算法的一般流程
Greedy(C){ //C是问题的输入集合即候选集合
S={ }; //初始解集合为空集
while (not solution(S)){ //集合S没有构成问题的一个解
x=select(C); //在候选集合C中做贪心选择
if feasible(S, x) //判断集合S中加入x后的解是否可行
S=S+{x};
C=C-{x};
}
return S;
}
1.2 实例分析
贪心算法不一定是整体的最优解。使用贪心算法是前提是:局本部最优策略能导致产生全局最优解。
1.背包问题
水果 | 质量(kg) | 价值(元) |
---|---|---|
苹果 | 100 | 100 |
梨 | 30 | 90 |
香蕉 | 60 | 120 |
菠萝 | 20 | 80 |
圣女果 | 50 | 75 |
可见这个问题使用贪心算法即可实现。分析得到单价
水果 | 单价(元/kg) |
---|---|
苹果 | 1 |
梨 | 3 |
香蕉 | 2 |
菠萝 | 4 |
圣女果 | 1.5 |
肯定是先装单价最多的水果,那么就得到了菠萝20kg,梨30kg,圣女果50kg。
下面来看一个实战题
2.把数组排成最小的数
https://leetcode-cn.com/problems/container-with-most-water/
输入一个非负整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。如数组[3,30,34,5,9]
,得到就是"3033459"
.
分析:
本质上还是一个排序题,假设数组nums的任意两个字符串为x和y,排序规则:
- 若拼接字符串
x + y
>y + x
,则x大于
y,使得x在y后; - 若拼接字符串
x + y
<y + x
,则x小于
y,使得x在y前;
根据以上贪心
规则,套用任何排序方法对nums排序即可。
public String minNumber(int[] nums) {//巧妙转化为String[]数组进行操作
String[] str = new String[nums.length];
for (int i = 0; i < nums.length; i++) str[i] = String.valueOf(nums[i]);
Arrays.sort(str,(x,y) -> (x + y).compareTo(y + x));//这里调用内置函数法 自己实现快排实现排序性能更优
StringBuilder res = new StringBuilder();
for(String s:str) res.append(s);
return res.toString();
}
贪心算法所作的选择可以依赖于以往所作过的选择,但决不依赖于将来的选择,也不依赖于子问题的解,因此贪心算法与其他算法相比具有一定的速度优势。如果一个问题可以同时用几种方法解决,贪心算法应该是最好的选择之一。
2.分治算法思想
将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。即原问题的解即子问题的解的合并。快排、归并排序就用到此思想。
2.1 适用情况
- 该问题的规模缩小到一定的程度就可以容易地解决;
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质;(递归思想)
- 利用该问题分解出的子问题的解可以合并为该问题的解;
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
第三条特征是关键,如果具备第一条和第二条特征,而不具备第三条特征,则考虑用贪心法或动态规划法。
第四条特征涉及到分治法的效率,如果各子问题是不独立,一般用动态规划法较好。
2.2 算法流程
分治法在每一层递归上都有三个步骤:
- 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
- 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题;
- 合并:将各个子问题的解合并为原问题的解。
思维过程:
- 一定是先找到最小问题规模时的求解方法;
- 然后考虑随着问题规模增大时的求解方法;
- 找到求解的递归函数式后(各种规模或因子),设计递归程序。
2.3 算法实例
详细分析请参看
归并:https://blog.csdn.net/yeahPeng11/article/details/117912723
二分:https://blog.csdn.net/yeahPeng11/article/details/118074093
归并排序
//写代码的时候先写合并操作merge() 再写分解操作
public int[] mergeSort(int[] arr){
if(arr.length < 2) return arr;
//拆为两份 一份最少为1个数据
int mid = arr.length/2;
int[] left = Arrays.copyOfRange(arr,0,mid);//使用Arrays.copyOfRange()复制数组
int[] right = Arrays.copyOfRange(arr,mid,arr.length);
//分解+合并
return merge(mergeSort(left),mergeSort(right));//分解为左 右实现
}
//合并 双指针很巧妙
private int[] merge(int[] left ,int[] right){
//新数组
int[] newArr = new int[left.length + right.length];
int l = 0,r = 0;//分别代表left和right数组的指针
for (int i = 0; i < newArr.length; i++) {
if(l >= left.length) newArr[i] = right[r++];
else if(r >= right.length) newArr[i] = left[l++];
else if(left[l] < right[r]) newArr[i] = left[l++];//升序
else newArr[i] = right[r++];
}
return newArr;
}
二分查找
public int binarySearch(int[] arr,int start,int end,int entry){
//可行性判断
if(start > end) return -1;
int mid = start + (end - start) / 2;//不使用mid = (start + end)/2 看下面注释分析
if(arr[mid] == entry) return mid;
else if(arr[mid] > entry)
return binarySearch(arr,start,mid-1,entry);
else
return binarySearch(arr,mid+1,end,entry);
}
分治算法和动态规划都是解决子问题,然后对解进行合并。但是分治算法是寻找远小于原问题的子问题(因为对于计算机来说计算数据量小的问题很快),同时分治算法的效率并不一定好,而动态规划的效率取决于子问题的个数的多少,子问题的个数远小于子问题的总数的情况下(也就是重复子问题多),算法才会很高效。
3.回溯算法思想
回溯算法就是一种有组织的系统选优搜索法
,按选优条件向前搜索,以达到目的。但当搜索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回去再走的计数为回溯法,而满足回溯条件的某个状态的点称为回溯点
。图的深度优先搜索DFS
的实现就是借助了回溯算法思想。
回溯法常常可以避免搜索所有可能的解,所以它适用于求解组织数量较大的问题。
另外,回溯法是深度优先(dfs)算法
3.1 算法分析
在搜索过程中,通常采用两种策略避免无效搜索(这两种方式统称为剪枝函数
):
- 用约束条件剪除得不到的可行解的子树;
- 用目标函数剪取得不到的最优解的子树.
解空间树:问题的解空间一般使用解空间树的方式来组织,树的根节点位于第1层,表示搜索的初始状态,依次向下排列。
解空间树的动态搜索:在搜索至树中任一节点时,先判断该节点对应的部分是否是满足约束条件,或者是否超出目标函数的界,也就是判断该节点是否包含问题的最优解。不包含,则跳过对该节点为根的子树的搜索,即所谓的剪枝;否则,进入该节点为根的子树,继续按照深度优先策略搜索。(这也是为什么回溯可以避免搜索所有的解,做到及时止损)
在用回溯法求解时,常常遇到两种典型的解空间树:
子集树:当所有的问题是从n个元素的集合中找出满足某种性质的子集时,相应的解空间树成为子集树;
排列树:当所给出问题是确定n个元素满足某种性质的排列时,相应的解空间称为排列树。
3.2 算法流程
-
设置初始化的方案(给变量赋初始值,读入已知数据等)
-
变换方式去试探,若全部试完侧转(7)
-
判断此法是否成功(通过约束函数),不成功则转(2)
-
试探成功则前进一步再试探
-
正确方案还是未找到则转(2)
-
以找到一种方案则记录并打印
-
退回一步(回溯),若未退到头则转(2)
-
已退到头则结束或打印无解
3.3 算法案例
实战:
https://leetcode-cn.com/problems/ji-qi-ren-de-yun-dong-fan-wei-lcof/
N皇后问题
问题:在n×n格的棋盘上放置彼此不受攻击的n个皇后。按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。N皇后问题等价于在n×n格的棋盘上放置n个皇后,任何2个皇后不放在同一行或同一列或同一斜线上。(n=1或n>=4才有解)
分析:从n×n个格子中选择n个格子摆放皇后。可见回溯法解空间树为子集树。数组queen
中的第i
个元素表示第i
个皇后,其坐标是(i,queen(i))
。约束条件有两个:
- 所有任意两个皇后不能在同一列,因此任意两个
queen[i]
和queen[j]
的值不能相同(i != j
); - 所有任意两个皇后不能再对角线上,把棋盘的方格变成一个二维数组,假设有两个皇后分别放在
(i,j)
和(m,n)
,当(m-i) == (n-j)
时两者在对角线上。使用isMatch(int k)
方法判断是否冲突。
只要违背了上述两个约束,就回溯到上一步继续判断。
public class NQueen {
/**
* 皇后数组
* 下标对于棋盘行数,值表示列数,即位置为(i,a(i)).
*/
private int[] queen;
/**
* 求解n皇后问题
*
* @param n
*/
public List<int[]> backMethod(int n) {
List<int[]> res = new ArrayList<>();
//初始化数组
queen = new int[n];
//初始化皇后起点
Arrays.fill(queen, -1);
//从第一个皇后开始
int k = 0;
while (true) {
//第k个皇后要后移一个
queen[k]++;//一开始由-1增加
//判断是否应该回到上一行搜索
if (queen[k] >= n) {
//皇后越界,此行没有位置可以放置皇后
if (k > 0) {//把须修改后的数组位置改为-1
queen[k] = -1;
k--;//回到没越界处 回溯
continue;//跳出下面判断
} else break;
}
if (!isMatch(k)) {//不与之前的皇后冲突
k++;
if (k >= n) {//越界
//存储答案
res.add(queen);
k--;//回到上一个皇后 回溯
}
}
}
return res;
}
/**
* 判断我们第k个皇后是否与之前的皇后冲突
* @param k
* @return true表示冲突
*/
private boolean isMatch(int k){
for (int i = k-1; i > -1 ; i--) {
if(queen[k] == queen [i] || Math.abs(queen[k]-queen[i]) == Math.abs(k-i))
//Math.abs(queen[k]-queen[i]) == Math.abs(k-i) 表示在一条对角线上
return true;
}
return false;
}
}
4.动态规划算法思想
4.1 基本思想与策略
基本思想与分治法类似,将待求解的问题分解为若干个子问题,按顺序求解子问题,前一子问题的解,为后一子问题的求解提供了实用的信息。有效的解决了重叠子问题
的重复计算。
动态规划法与分治法的最大区别是:适合用于动态规划求解的问题,经过分解后得到的子问题往往不是互相独立的,即下一个子问题的解是建立在上一个子问题解的基础之上得到的。
动态规划算法的核心就是记住已经解决过的子问题的解,每次决策依赖于当前状态,又随即引起状态的转移。而记住求解的方式有两种:自顶向下的备忘录法和自底向上。
4.2 适用情况
是否能够采用动态规划求解的问题一般具有三个特性:
- 最优化原理:问的最优解所包括的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理;
- 无后效性:某一阶段的状态是确定的,之后的过程不会影响到曾经的状态,仅仅与当前状态有关;
- 有重叠子问题:子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次用到(该性质可以不满足,但是动态规划的优势就在此)。
在Markdown编辑器中设置字体颜色的方法之一:<font color=‘red’>文本</font>
4.3 求解的基本步骤
问题是一个多阶段决策问题,一般有初始状态开始,通过对中间阶段决策的选择,达到结束的姿态。形成的一个决策序列:
动态规划算法的设计步骤:
- 划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段,在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的(即不能由
阶段1
跳到阶段6
),否则无法求解; - 确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来,并且状态的选择要无后效性;
- 确定决策并写出状态的转移方程:决策和状态转移是密不可分的,状态转移方程:
当前状态=上一阶段的状态+决策
,可根据两个阶段的状态之间的关系推导出决策方法和状态转移方程(常用); - 寻找边界条件:给出的状态方程是一个递推公式,需要递推的终止条件。
实际应用中,可以按照以下步骤进行:
- 分析最优解;
- 递归定义最优解;
- 自底向上或自顶向下的记忆方式(备忘录法)计算出最优值;
- 根据计算优值时得到的信息,构造问题的最优解。
4.4 常见动态规划问题
斐波那契数列
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))。斐波那契数列的定义如下:(答案需要取模 1e9+7(1000000007))
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
1.递归解法
我直接就是给出递归解法:
public int fib1(int n) {
//数据合法性检验
if (n < 0) return -1;
if (n == 0) return 0;
if (n <= 2) return 1;
//递归
return (fib1(n-1) + fib1(n-2)) % 1000000007;
}
2.递归优化(动态规划)
本题分析我们发现会计算很多的重复问题
如果我们采用备忘录的方式记录计算过的子问题答案,那么在需要计算被记录的子问题时,直接获取到效率会高很多。请看:
/**
* 返回fib(n)的值
* 自顶向下
* @param n fib中某项
* @param memory 备忘录
* @return fib中n项的值
*/
private int fib2(int n, int[] memory) {
//-1表示备忘录中没有记录f(n)的值
if (memory[n] != -1) return memory[n];
if (n <= 2) memory[n] = 1;
else memory[n] = (fib(n - 1, memory) + fib(n - 2, memory)) % 1000000007;
return memory[n];
}
//公共接口
public int fib2(int n) {
//数据不合法
if (n <= 0) return -1;
//创建备忘录 并初始化
int[] memory = new int[n + 1];
Arrays.fill(memory, -1);
return fib(n, memory);
}
3.迭代优化(动态规划)
我们发现,上述的备忘录法
采用了递归的方法,而我们也知道迭代法普遍优于递归法,能不能把算法变为迭代法?当然是可以的,我们可以先计算出f(1)
,f(2)
,f(3)
,f(4)
…,这也就是动态规划的核心
public int fib3(int n) {
int a=0,b=1,sum;
for (int i = 0; i < n; i++) {
sum = (a+b) % 1000000007;
a = b;
b = sum;
}
return a;
}
4.测试
public class Main {
public static void main(String[] args) {
Main m = new Main();
long l1 = System.nanoTime();
System.out.println(m.fib1(50));
long l2 = System.nanoTime();
System.out.println("fib1所花时间:"+(l2-l1));
long l3 = System.nanoTime();
System.out.println(m.fib2(50));
long l4 = System.nanoTime();
System.out.println("fib2所花时间:"+(l4-l3));
long l5 = System.nanoTime();
System.out.println(m.fib3(50));
long l6 = System.nanoTime();
System.out.println("fib3所花时间:"+(l6-l5));
}
}
输出可见效率提升之大
586268941
fib1所花时间:40009540500
586268941
fib2所花时间:176800
586268941
fib3所花时间:62800
5.枚举算法思想
5.1 枚举算法基本思想与策略
在进行归纳时,如果诸葛考察了某类事件的所有可能情况,因而得出一个结论,那么该结论是可靠的,这种归纳方法叫做枚举法。
在使用枚举算法解题的基本思路如下:
- 确定枚举对象、范围和判定条件;
- 逐一枚举可能的解并验证每个解是否是问题的解。
5.2 枚举算法步骤
通常为三步:
- 确定解题的可能范围,不能遗漏任何一个真正的解,同步避免重复;
- 判定是否为真正解的方法;
- 为例提高解决问题的效率,是可能解的范围降至最小。
5.3 枚举算法实例
百钱买百鸡
公鸡每只5元,母鸡每只3元,三只小鸡1元,用100元买100只鸡,问公鸡、母鸡、小鸡各多少只?
分析:利用枚举法解决该问题,以三种鸡为的个数为枚举对象m
,g
,x
,其中m+g+x=100
。且买鸡钱总数5*m+3*g+x/3 = 100
作为判断,穷举各种鸡的个数。
public void buyChicken() {
for (int g = 0; g <= 20; g++) {//公鸡最多可买20个
for (int m = 0; m <= 33; m++) {//母鸡最多可买33个
int x = 100 - g - m;// 三种鸡的总数是100只
if (((x % 3) == 0) && ((5 * g + 3 * m + x / 3) == 100)) {// 总花费为100元
System.out.println("公鸡为:" + g + ",母鸡为:" + m + ",小鸡为:" + x);
}
}
}
}
6.分支限界算法思想
6.1分支界限法的搜索策略
在当前节点处,先生成其所有的子节点,然后再从当前节点的子节点中选择下一个扩展节点。为了有效地选择下一个扩展节点,加速搜索的进程,在每一个节点处,计算一个函数值(限界),并根据函数值,从当前节点表中选择一个最有利的节点作为扩展节点,使搜索朝着解空间上有最优解的分支推进,以便尽快地找出一个最优解。分支限界法解决了大量离散最优化的问题。
6.2 选择方法
- 队列式(FIFO)分支限界法:队列式分支限界法将节点表组织成一个队列,并将队列的先进先出原则选取下一个节点为当前扩展节点;
- 优先队列式分支限界法:优先队列式分支限界法将节点表组织成一个优先队列,并将优先队列中规定的节点优先级选取优先级最高的下一个节点成为当前扩展节点。如果选择这种选择方式,往往将数据排成
大顶堆
或者小顶堆
来实现。
对比回溯法:
- 回溯法是找出解空间中满足约束条件的所有解;分支限界法是找出满足约束条件的一个解,或是满足约束条件的解中找出使某一目标函数值达到极大或极小的解,即在某种意义下的最优解;
- 回溯法是
深度优先
的搜索算法,分支界限法是广度优先
或以最小耗费优先
的搜索算法。
6.3 分支界限算法实例
其实就是对广度优先算法是应用,这里参看图的广度优先遍历,找到最短路径
找到图begin->target
的最短路径。例:下图的0->7
最短路径之一:0-1-2-5-7
。
详细步骤参看:
https://blog.csdn.net/yeahPeng11/article/details/118309593中的图知识点