1. 贪心算法
1.1 贪心算法的概述:
贪心算法是一种对某些求最优解问题的更简单、更迅速的设计技术。
贪心算法的特点是一步一步地进行,常以当前情况为基础根据某个优化测度作最优选择,而不考虑各种可能的整体情况,省去了为找最优解要穷尽所有可能而必须耗费的大量时间。
贪心算法采用自顶向下,以迭代的方法做出相继的贪心选择,每做一次贪心选择,就将所求问题简化为一个规模更小的子问题,通过每一步贪心选择,可得到问题的一个最优解。
虽然每一步上都要保证能获得局部最优解,但由此产生的全局解有时不一定是最优的,所以贪心算法不要回溯
1.2 贪心算法适用的问题
贪心策略的前提是:局部最优策略能导致产生全局最优解。
实际上,贪心算法使用的情况比较少,一般对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析可以做出判断。
1.3 贪心算法的实现框架
从问题的某一初始解出发:
while (能朝给定总目标前进一步)
{ 利用可行的决策,求出可行解的一个解元素;
}
由所有解元素组合成问题的一个可行解。
1.4 贪心策略的选择
用贪心算法只能通过解局部最优解的策略来达到全局最优解,因此一定要注意判断问题是否适合采用贪心算法策略,找到解是否一定是问题的最优解。
1.5 例题分析
1.5.1 分糖果问题
n个小朋友玩完游戏后,老师准备给他们发糖果;每个人有一个分数a[i],如果比左右的人分数高,那么糖果也要比左右的多,并且每个小朋友至少有一颗。问老师最少准备多少糖果?
这个题目不能直接用动态规划去解,比如用dp[i]表示前i个人需要的最少糖果数。
因为(前i个人的最少糖果数)这种状态表示会收到第i+1个人的影响,如果a[i]>a[i+1],那么第i个人应该比第i+1个人多。即是这种状态表示不具备无后效性。
如果是我们分配糖果,我们应该怎么分配?
- 答案是:从分数最低的开始。
按照分数排序,从最低开始分,每次判断是否比左右的分数高。
假设每个人分c[i]个糖果,那么对于第i个人有c[i]=max(c[i-1],c[c+1])+1
;
(c[i]默认为0,如果在计算i的时候,c[i-1]为0,表示i-1的分数比i高)
但是,这样解决的时间复杂度为O(NLogN),主要瓶颈是在排序。如果提交,会得到Time Limit Exceeded
的提示。
- 因此我们需要对贪心的策略进行优化:
我们把左右两种情况分开看。- 如果只考虑比左边的人分数高时,容易得到策略:
从左到右遍历,如果a[i]>a[i-1],则有c[i]=c[i-1]+1;否则c[i]=1。 - 再考虑比右边的人分数高时,此时我们要从数组的最右边,向左开始遍历:
如果a[i]>a[i+1], 则有c[i]=c[i+1]+1;否则c[i]不变;
- 如果只考虑比左边的人分数高时,容易得到策略:
- 这样讲过两次遍历,我们可以得到一个分配方案,并且时间复杂度是O(N)。
1.5.2 小船过河问题
n个人要过河,但是只有一艘船;船每次只能做两个人,每个人有一个单独坐船的过河时间a[i],如果两个人(x和y)一起坐船,那过河时间为a[x]和a[y]的较大值。问最短需要多少时间,才能把所有人送过河?
题目给出关键信息:1、两个人过河,耗时为较长的时间;
还有隐藏的信息:2、两个人过河后,需要有一个人把船开回去;
要保证总时间尽可能小,这里有两个关键原则:应该使得两个人时间差尽可能小(减少浪费),同时船回去的时间也尽可能小(减少等待)。
先不考虑空船回来的情况,如果有无限多的船,那么应该怎么分配?
- 答案:每次从剩下的人选择耗时最长的人,再选择与他耗时最接近的人。
再考虑只有一条船的情况,假设有A/B/C三个人,并且耗时A<B<C。
那么最快的方案是:A+B去, A回;A+C去;总耗时是A+B+C。(因为A是最快的,让其他人来回时间只会更长,减少等待的原则)
如果有A/B/C/D四个人,且耗时A<B<C<D,这时有两种方案:
1、最快的来回送人方式,A+B去;A回;A+C去,A回;A+D去; 总耗时是B+C+D+2A (减少等待原则)
2、最快和次快一起送人方式,A+B先去,A回;C+D去,B回;A+B去;总耗时是 3B+D+A (减少浪费原则)
对比方案1、2的选择,我们发现差别仅在A+C和2B;
- 为何方案1、2差别里没有D?
因为D最终一定要过河,且耗时一定为D。
如果有A/B/C/D/E 5个人,且耗时A<B<C<D<E,这时如何抉择?
仍是从最慢的E看。(参考我们无限多船的情况)
- 方案1,减少等待;先送E过去,然后接着考虑四个人的情况;
- 方案2,减少浪费;先送E/D过去,然后接着考虑A/B/C三个人的情况;(4人的时候的方案2)
到5个人的时候,我们已经明显发了一个特点:问题是重复,且可以由子问题去解决。
根据5个人的情况,我们可以推出状态转移方程
dp[i] = min(dp[i - 1] + a[i] + a[1], dp[i - 2] + a[2] + a[1] + a[i] + a[2]);
再根据我们考虑的1、2、3、4个人的情况,我们分别可以算出dp[i]的初始化值:
dp[1] = a[1];
dp[2] = a[2];
dp[3] = a[2]+a[1]+a[3];
dp[4] = min(dp[3] + a[4] + a[1], dp[2]+a[2]+a[1]+a[4]+a[2]);
由上述的状态转移方程和初始化值,我们可以推出dp[n]的值。
1.5.3 背包问题
问题描述 有一个背包,背包容量是M=150。有7个物品,物品可以分割成任意大小。要求尽可能让装入背包中的物品总价值最大,但不能超过总容量。
**问题分析: **
-
目标函数: ∑pi最大,使得装入背包中的所有物品pi的价值加起来最大。
-
约束条件:装入的物品总重量不超过背包容量:∑wi<=M( M=150)
-
贪心策略:
- 选择价值最大的物品
- 选择价值最大的物品
- 选择单位重量价值最大的物品
有三个物品A,B,C,其重量分别为{30,10,20},价值分别为{60,30,80},背包的容量为50,
- 分别应用三种贪心策略装入背包的物品和获得的价值如下图所示:
算法的三种策略:
- 算法设计:
- 计算出每个物品单位重量的价值
- 按单位价值从大到小将物品排序
- 根据背包当前所剩容量选取物品
- 如果背包的容量大于当前物品的重量,那么就将当前物品装进去。否则,那么就将当前物品舍去,然后跳出循环结束。
2. 加权有向图
与加权无向图不同的时。这种图的特点是边有方向性,即边从一个顶点指向另一个顶点,并且每条边都有一个与之相关的权重,这个权重通常代表了从一点到另一点的成本或距离。
2.1 加权有向图边的表示
2.1.1 API设计
类名 | DirectedEdge |
---|---|
构造方法 | DirectedEdge(int v,int w,double weight):通过顶点v和w,以及权重weight值构造一个边对象 |
成员方法 | 1. public double weight():获取边的权重值 2. public int from():获取有向边的起点 3. public int to():获取有向边的重点 |
成员变量 | 1. private final int V:起点 2. private final int W:终点 3. private final double weight:当前边的权重 |
2.2 加权有向图的实现
2.2.1 API 设计
类名 | EdgeWeightedDigraph |
---|---|
构造方法 | EdgeWeightedDigraph(DirectedEdge V):创建一个包含V个顶点但不包含边的有向图 |
成员方法 | 1. public int V():获取图中顶点的数量 2. public int E():获取图中边的数量 3. public void addEdge(DirectedEdge e):向有向图中添加一条边e 4. public Queue<DirectedEdge> adj(int v):获取由v指出的边所连接的所有顶点 5. public Queue<DirectedEdge> edges():获取加权有向图的所有边 |
成员变量 | 1. private final int V:记录顶点数量 2. private int E:记录边数量 3. private Queue<DirectedEdge>[] adj:邻接表 |
2.2.2 代码实现
package com.renecdemo.weighted;import com.renecdemo.graph.Queue;/*** 加权有向图*/
public class EdgeWeightedDigraph {private final int V;// 记录顶点数量private int E;// 记录边数量private Queue<DirectedEdge>[] adj;// 邻接表public EdgeWeightedDigraph(int v) {this.V = v;this.E = 0;this.adj = new Queue[V];for (int i = 0; i < adj.length; i++) {adj[i] = new Queue<>();}}public int V(){return V;}public int E(){return E;}/*** 向有向图中添加一条边e* @param e*/public void addEdge(DirectedEdge e){// 因为e是有方向的,所以只需要让e出现在起点的邻接表中即可int v = e.from();adj[v].enqueue(e);E++;}/*** 获取由 v顶点 指出的边所连接的所有顶点 - 返回该顶点所在的邻接表即可* @param v 顶点* @return*/public Queue<DirectedEdge> adj(int v){return adj[v];}/*** 获取加权有向图的所有边* @return*/public Queue<DirectedEdge> edges(){// 遍历图中的每一个顶点Queue<DirectedEdge> allEdges = new Queue<>();for (int v = 0; v < V; v++) {for (DirectedEdge e : adj[v]) {allEdges.enqueue(e);}}return allEdges;}}
3. 最短路径
有了加权有向图之后,我们立刻就可以联想到在实际生活汇总的使用场景,例如在一副地图中,找到顶点a和顶点b之间的路径,这条路径可以是距离最短,也可以是时间最短,也可以是费用最小等,如果我们把距离/时间/费用
看作是成本,那么就需要找到顶点a和顶点b之间成本最小的路径。
3.1 最短路径的 定义以及性质
3.1.1 定义:
- 在一副加权有向图中,从顶点s到顶点t的最短路径是所有从顶点s到顶点t的路径中总权重最小的那条路径
3.1.2 性质:
- 路径具有方向性
- 权重不一定等价于距离,权重可以是距离、时间、花费等内容,权重最小指的是成本最低
- 只考虑连通图,一副图中并不是所有的顶点都是可达的,如果s和t不可达,那么它们之间也就不存在最短路径。为了简化问题,这里只考虑连通图
- 最短路径不一定是唯一的。从一个顶点到达另一个顶点的权重最小的路径可能会有很多条,这里只需要找出一条即可
3.1.3 最短路径树
给定一副加权有向图和一个顶点s,以s为起点的一颗最短路径树是图的一副子图,它包含顶点s以及从s可达的所有顶点。
这颗有向图的根节点为s,树的每条路径都是有向图中的一条最短路径
3.2 最短路径树的API设计
类名 | DijkstraSP |
---|---|
构造方法 | pulbic DijkstraSP(EdgeWeightedDigraph G,int s):根据一副加权有向图G和顶点s,创建一个计算顶点为s的最短路径树对象 |
成员方法 | 1. private void relax(EdgeWeightedDigraph G,int v):松弛图G中的顶点 2. public double disTo(int v):获取从顶点s到顶点v的最短路径的总权重 3. public boolean hasPathTo(int v):判断从顶点s到顶带你v是否可达 4. public Queue<DirectedEdge> pathTo(int v):查询从起点s到顶点v的最短路径中所有的边 |
成员变量 | 1. private DirectedEdge[] edgeTo:索引代表顶点,指表示从顶点s到当前顶点的最短路径上的最后一条边 2. private double[] distTo:索引代表顶点,值从顶点s到当前的最短路径的总权重 3. private IndexMinPriorityQueue<Double> pq:存放树种顶点到非树中顶点之间的有效横切边 |
3.2 松弛技术
3.2.1 松弛技术的概述
松弛这个词来源于生活:一条橡皮筋沿着两个顶点的某条路径紧紧展开,如果这两个顶点之间的路径不止一条,还有存在更短的路径,那么把皮筋转移到更短的路径上,皮筋就可以放松了。
松弛这种简单的原理刚好可以用来计算最短路径树。
在我们的API中,需要用到两个成员变量edgeTo和distTo,分别存储边和权重。一开始给定一幅图G和顶点s,我们只知道图的边以及这些边的权重,其他的一无所知,此时初始化顶点s到顶点s的最短路径的总权重disto[s]=0;顶点s到其他顶点的总权重默认为无穷大,随着算法的执行,不断的使用松弛技术处理图的边和顶点,并按一定的条件更新edgeTo和distTo中的数据,最终就可以得到最短路径树。
3.2.2 边的松弛:
放松边v->w意味着检查从s到w的最短路径是否先从s到v,然后再从v到w?
-
如果是,则v-w这条边需要加入到最短路径树中,更新edgeTo和distTo中的内容:edgeTo[w]=表示v->w这条边的DirectedEdge对象,distTo[w]=distTo[v]+v->w这条边的权重;
-
如果不是,则忽略v->w这条边。
3.2.3顶点的松弛:
顶点的松弛是基于边的松弛完成的,只需要把某个顶点指出的所有边松弛,那么该顶点就松弛完毕。例如要松弛顶点v,只需要遍历v的邻接表,把每一条边都松弛,那么顶点v就松弛了。
如果把起点设置为顶点0,那么找出起点0到顶点6的最短路径0->2->7>3->6的过程如下:
3.3 Dijkstra 算法的代码实现
package com.renecdemo.dijkstra;import com.renecdemo.graph.Queue;
import com.renecdemo.queue.IndexMinPriorityQueue;
import com.renecdemo.weighted.DirectedEdge;
import com.renecdemo.weighted.EdgeWeightedDigraph;/*** Dijkstra算法*/
public class DijkstraSP {// 索引代表顶点,值表示从顶点s到当前顶点的最短路径上的最后一条边private DirectedEdge[] edgeTo;// 索引代表顶点,值从顶点s到当前的最短路径的总权重private double[] distTo;// 存放树种顶点到非树中顶点之间的有效横切边private IndexMinPriorityQueue<Double> pq;/*** 根据一副加权有向图G和顶点s,创建一个计算顶点为s的最短路径树对象* @param G* @param s*/public DijkstraSP(EdgeWeightedDigraph G, int s){// 初始化成员变量this.edgeTo = new DirectedEdge[G.V()];this.distTo = new double[G.V()];// 初始化权重数组,所有边的权重都为Double类型的无限大for (int i = 0; i < distTo.length; i++) {distTo[i] = Double.POSITIVE_INFINITY;}this.pq = new IndexMinPriorityQueue<>(G.V());// 找到图G中以顶点s为起点的最短路径树// 默认让s进入到最短路径树中distTo[s] = 0.0;pq.insert(s,0.0);while (!pq.isEmpty()){relax(G,pq.delMin()); // 弹出最新规划出的顶点}}/*** 松弛图G中的顶点 - 找出最短路径树* @param G 指定的图* @param v 起点所至的顶点*/private void relax(EdgeWeightedDigraph G,int v){// 遍历 v顶点 的邻接表for (DirectedEdge e : G.adj(v)) {// 获取到该边的终点int w = e.to();// 通过e边 达到 v顶点 的另一个顶点/*通过松弛技术,判断从s到w的最短路径是否需要先从顶点s到顶点v,如何再由顶点v到顶点w判断:从起点到达 v顶点的权重+e边的权重 是否小于 起点直接到达 w顶点的权重*/if (distTo(v)+e.weight() < distTo(w)){// 变换distTo[w] = distTo(v)+e.weight();edgeTo[w] = e;// 判断pq中是否已经存在顶点wif (pq.contains(w)){pq.changeItem(w,distTo(w));}else {pq.insert(w,distTo(w));// 更新pq队列}}}}/*** 获取从顶点s到顶点v的最短路径的总权重* @param v* @return*/public double distTo(int v){return distTo[v];}/*** 判断从顶点s到顶带你v是否可达* @param v* @return*/public boolean hasPathTo(int v){return distTo[v]<Double.POSITIVE_INFINITY;}/*** 查询从起点s到顶点v的最短路径中所有的边* @param v* @return*/public Queue<DirectedEdge> pathTo(int v){// 判断顶点s到顶点v是否可达if (!hasPathTo(v)){return null;}// 创建队列对象Queue<DirectedEdge> allEdges = new Queue<>();// 顶点逆推while (true){// 得到v顶点的边DirectedEdge e = edgeTo[v];if (e==null){break;}allEdges.enqueue(e);// 更新 v顶点 —— e.from():获得e边的除了v顶点的另外一个顶点v = e.from();}// 返回队列return allEdges;}
}
4. 前置文章
- 浅入数据结构 “堆” - 实现和理论
- 开始熟悉 “二叉树” 的数据结构
- 队列 和 符号表 两种数据结构的实现
- 队列的进阶结构-优先队列
- 2-3树思想与红黑树的实现与基本原理
- B树和B+树的实现原理阐述
- 图的基本原理和API实现
- 有向图与拓扑排序的实现原理与基本实现
- 加权无向图和最小生成树的实现与原理概述
5. ES8 如何使用?
快来看看这篇好文章吧~~!!
😊👉(全篇详细讲解)ElasticSearch8.7 搭配 SpringDataElasticSearch5.1 的使用