数据结构和算法(4)-- 常用算法

1)广度优先搜索算法和狄克斯特拉算法

广度优先搜索是一种图算法。

解决最短路径问题的算法被称为广度优先搜索。

图:

图由节点和边组成。一个节点可能与众多节点直接项链,这些节点被称为邻居。

可以使用图模拟不同的东西是如何相连的。

广度优先搜索是一种用于图的查找算法,可以帮助回答两类问题。

第一,从节点A出发,有前往节点B的路径吗?

第二,从节点A出发,前往节点B的哪条路径最短?

在广度优先搜索的执行过程中,搜索范围从起点开始逐渐向外延伸,即先检查一度关系,再检查二度关系。

算法使用步骤:

 *(1)使用图来建立问题模型;

 *(2)使用广度优先搜索算法解决问题。

广度优先搜索的运行时间为

O(人数 + 边数),这通常写作O(V + E),其中V为顶点(vertice)数,E为边数。

例子:

假设你经营着一家芒果农场,需要寻找芒果销售商,以便将芒果卖给他。为此,我们可以通过广度优先搜索算法,在朋友中查找出符合条件的芒果销售商。

广度优先搜索是一种用于图的查找算法,可帮助我们回答两类问题:

第一类问题:从节点A出发,有前往节点B的路径吗?(在你的人际关系网中,有芒果销售商吗?)

第二类问题:从节点A出发,前往节点B的哪条路径最短?(哪个芒果销售商与你的关系最近?)

将下列关系图用散列表实现:

概述广度优先搜索算法的工作原理:

private static Map> graph = new HashMap<>();   //使用图建立几个人的关系模型

static

{

graph.put("you", new ArrayList(Arrays.asList(new String[] { "alice", "bob", "claire" })));

graph.put("bob", new ArrayList(Arrays.asList(new String[] { "anuj", "peggy" })));

graph.put("alice", new ArrayList(Arrays.asList(new String[] { "peggy" })));

graph.put("claire", new ArrayList(Arrays.asList(new String[] { "thom", "jonny" })));

}

public static boolean BFSearch(String name)

{

//用队列来模拟广度优先搜索中的远近关系,比如以you为起点搜素,先对其邻居即一度关系中的alicebobclaire进行搜索,如果搜索不满足条件,

    //则再对一度关系的邻居即二度关系进行搜索,如peggyanujthomjonny,这样就需要先将一度关系加入搜索名单,再将二度关系加入搜索名单,

    //队列正好可以实现这种数据结构要求。

    Deque searchDeque = new ArrayDeque<>();

    searchDeque.addAll(graph.get(name));

    System.out.println("searchDeque is " + searchDeque);

    List searched = new ArrayList<>();  // 用于记录检查过的人,避免导致无限循环

    while (searchDeque.getFirst() != null)

    {

        String person = searchDeque.pop();

        System.out.println("person is " + person);

        if (!searched.contains(person))   //仅当这个人没检查过时才检查

        {

             if (personIsSeller(person))

            {

                System.out.println(person + " is a mango seller!");

                return true;

            }

            else

            {

                if (graph.get(person) != null)

                {

                    searchDeque.addAll(graph.get(person));

                    searched.add(person); 

                    //将这个人标记为检查过

                }

            }

        }

    } 

         return false;

}

private static boolean personIsSeller(String name)

{

     //名字最后一个字母为m的为满足条件的人

    return name.charAt(name.length() - 1) == 'm';

}


狄克斯特拉算法:

适用场景:有向无环加权图

 *步骤:

 *(1)找出开销最小的节点(节点的开销指的是从起点出发前往该节点需要多长时间);

 *(2)遍历当前节点的所有邻居,如果经当前节点前往该邻居更近,就更新该邻居的开销,同时将该邻居的父节点设置为当前节点;

 *(3)重复这个过程,直到对图中的每个节点都这样做了;

 *(4)计算最终路径,从终点倒过来找最短路径。

例子:

第一步:找出最便宜的节点,假设需要时间无穷大,节点B是最近的——2分钟。

第二步:计算经节点B前往其各个邻居所需要的时间。

找到一条前往节点A的更短路径。

对于节点B的邻居,如果找到前往它的更短路径,就更新其开销。在这里,你找到了:

前往节点A的更短路径(时间从6分钟缩短到5分钟);前往终点的更短路径(时间从无穷大缩短到7分钟)。

第三步:重复!

重复第一步:找出可在最短时间内前往的节点。你对节点B执行了第二步,除节点B外,可在最短时间内前往的节点是节点A。

重复第二步:更新节点A的所有邻居的开销。

你发现前往终点的时间为6分钟!

你对每个节点都运行了狄克斯特拉算法(无需对终点这样做)。现在,你知道:前往节点B需要2分钟;前往节点A需要5分钟;前往终点需要6分钟。

对比:

广度优先搜索来查找两点之间的最短路径,那时“最短路径”的意思是段数最少。在狄克斯特拉算法中,你给每段都分配了一个数字或权重,因此狄克斯特拉算法找出的是总权重最小的路径。


2)贪心算法(贪婪算法)

贪婪算法(贪心算法)是指在对问题进行求解时,在每一步选择中都采取最好或者最优(即最有利)的选择,从而希望能够导致结果是最好或者最优的算法。

贪婪算法所得到的结果往往不是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果。

  ** 贪婪算法并没有固定的算法解决框架,算法的关键是贪婪策略的选择,根据不同的问题选择不同的策略。

  ** 必须注意的是策略的选择必须具备无后效性,即某个状态的选择不会影响到之前的状态,只与当前状态有关,所以对采用的贪婪的策略一定要仔细分析其是否满足无后效性。

基本思路

其基本的解题思路为:

1.建立数学模型来描述问题

2.把求解的问题分成若干个子问题

3.对每一子问题求解,得到子问题的局部最优解

4.把子问题对应的局部最优解合成原来整个问题的一个近似最优解

 

案例一

区间调度问题:

假设有如下课程,希望尽可能多的将课程安排在一间教室里:

课程                       开始时间                       结束时间

美术                        9AM                             10AM

英语                        9:30AM                        10:30AM

数学                        10AM                            11AM

计算机                     10:30AM                       11:30AM

音乐                         11AM                            12PM

这个问题看似要思考很多,实际上算法很简单:

1.选择结束最早的课,便是要在这教室上课的第一节课 2.接下来,选择第一堂课结束后才开始的课,并且结束最早的课,这将是第二节在教室上的课。

重复这样做就能找出答案,这边的选择策略便是结束最早且和上一节课不冲突的课进行排序,因为每次都选择结束最早的,所以留给后面的时间也就越多,自然就能排下越多的课了。

每一节课的选择都是策略内的局部最优解(留给后面的时间最多),所以最终的结果也是近似最优解(这个案例上就是最优解)。 (该案例的代码实现,就是一个简单的时间遍历比较过程)


案例二

背包问题:有一个背包,容量为35磅 , 现有如下物品

物品                      重量                   价格

吉他                      15                      1500

音响                       30                     3000

笔记本电脑            20                     2000

显示器                   29                      2999

笔                          1                        200

要求达到的目标为装入的背包的总价值最大,并且重量不超出。

方便计算所以只有3个物品,实际情况可能是成千上万。

同上使用贪婪算法,因为要总价值最大,所以每次每次都装入最贵的,然后在装入下一个最贵的,选择结果如下:

选择: 音响 + 笔,总价值3000 + 200 = 3200

并不是最优解: 吉他 + 笔记本电脑, 总价值1500 + 2000 = 3500


当然选择策略有时候并不是很固定,可能是如下:

(1)每次挑选价值最大的,并且最终重量不超出:

选择: 音响 + 笔,总价值3000 + 200 = 3200

(2)每次挑选重量最大的,并且最终重量不超出(可能如果要求装入最大的重量才会优先考虑):

选择: 音响 + 笔,总价值3000 + 200 = 3200

(3)每次挑选单位价值最大的(价格/重量),并且最终重量不超出:

选择: 笔+ 显示器,总价值200 + 2999 = 3199

如上最终的结果并不是最优解,在这个案例中贪婪算法并无法得出最优解,只能得到近似最优解,也算是该算法的局限性之一。该类问题中需要得到最优解的话可以采取动态规划算法。


案例三

集合覆盖问题:

假设存在如下表的需要付费的广播台,以及广播台信号可以覆盖的地区。 如何选择最少的广播台,让所有的地区都可以接收到信号。

广播台                                    覆盖地区

K1                                          ID,NV,UT

K2                                          WA,ID,MT

K3                                          OR,NV,CA

K4                                           NV,UT

K5                                           CA,AZ

...                                             ...

如何找出覆盖所有地区的广播台的集合呢,听起来容易,实现起来很复杂,使用穷举法实现:

(1) 列出每个可能的广播台的集合,这被称为幂集。假设总的有n个广播台,则广播台的组合总共有2?个

(2) 在这些集合中,选出覆盖全部地区的最小的集合,假设n不在,但是当n非常大的时候,假设每秒可以计算10个子集

广播台数量n                            子集总数2ⁿ                      需要的时间

5                                               32                                   3.2秒

10                                             1024                               102.4秒

32                                             4294967296                   13.6年

100                                           1.26*100³º                      4x10²³年

目前并没有算法可以快速计算得到准备的值, 而使用贪婪算法,则可以得到非常接近的解,并且效率高:

选择策略上,因为需要覆盖全部地区的最小集合:

(1) 选出一个广播台,即它覆盖了最多未覆盖的地区即便包含一些已覆盖的地区也没关系 

(2) 重复第一步直到覆盖了全部的地区

这是一种近似算法(approximation algorithm,贪婪算法的一种)。在获取到精确的最优解需要的时间太长时,便可以使用近似算法,判断近似算法的优劣标准如下:

     * 速度有多快

     * 得到的近似解与最优解的接近程度

在本例中贪婪算法是个不错的选择,不仅运行速度快,本例运行时间O(n2),最坏的情况,假设n个广播台,每个广播台就覆盖1个地区,n个地区,总计需要查询n*n=O(n2),实现可查看后面的java代码实现。

此时算法选出的是K1, K2, K3, K5,符合覆盖了全部的地区,可能不是预期中的K2, K3,K4,K5(也许预期中的更便宜,更便于实施等等)


小结

1.贪婪算法可以寻找局部最优解,并尝试与这种方式获得全局最优解

2.得到的可能是近似最优解,但也可能便是最优解(区间调度问题,最短路径问题(广度优先、狄克斯特拉))

3.对于完全NP问题,目前并没有快速得到最优解的解决方案

4.面临NP完全问题,最佳的做法就是使用近似算法

5.贪婪算法(近似算法)在大部分情况下易于实现,并且效率不错

/**

 *贪婪算法 - 集合覆盖问题

 *

 *@author Administrator

 *

 */

public class GreedyAlgorithm2

{

    public static void main(String[] args)

    {

        // 初始化广播台信息

        HashMap> broadcasts = new HashMap>();

        broadcasts.put("K1", new HashSet(Arrays.asList(new String[] { "ID", "NV", "UT" })));

        broadcasts.put("K2", new HashSet(Arrays.asList(new String[] { "WA", "ID", "MT" })));

        broadcasts.put("K3", new HashSet(Arrays.asList(new String[] { "OR", "NV", "CA" })));

        broadcasts.put("K4", new HashSet(Arrays.asList(new String[] { "NV", "UT" })));

        broadcasts.put("K5", new HashSet(Arrays.asList(new String[] { "CA", "AZ" })));

         // 需要覆盖的全部地区

        HashSet allAreas = new HashSet(Arrays.asList(new String[] { "ID", "NV", "UT", "WA", "MT", "OR", "CA",  "AZ" }));

         // 所选择的广播台列表

        List selects = new ArrayList();

        HashSet tempSet = new HashSet();

        String maxKey = null;

        while (allAreas.size() != 0)

        {

            maxKey = null;

            for (String key : broadcasts.keySet())

            {

                tempSet.clear();

                HashSet areas = broadcasts.get(key);

                tempSet.addAll(areas);

                // 求出2个集合的交集,此时tempSet会被赋值为交集的内容,所以使用临时变量

                tempSet.retainAll(allAreas);

                // 如果该集合包含的地区数量比原本的集合多

                if (tempSet.size() > 0 && (maxKey == null || tempSet.size() > broadcasts.get(maxKey).size()))

                {

                    maxKey = key;

                }

            }

            if (maxKey != null)

            {

                selects.add(maxKey);

                allAreas.removeAll(broadcasts.get(maxKey));

            }

        }

        System.out.print("selects:" + selects);

    }

}


3)动态规划

1、什么是动态规划?

这里参考百度百科,动态规划是求解决策过程最优化的数学方法。把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。

2、什么时候要用动态规划?

如果要求一个问题的最优解(通常是最大值或者最小值),而且该问题能够分解成若干个子问题,并且小问题之间也存在重叠的子问题,则考虑采用动态规划。

3、怎么使用动态规划?

我把下面称为动态规划五部曲:

1. 判题题意是否为找出一个问题的最优解

2. 从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题

3. 从下往上分析问题 ,找出这些问题之间的关联(状态转移方程)

4. 讨论底层的边界问题

5. 解决问题(通常使用数组进行迭代求出最优解)


动态规划算法的两种形式

上面已经知道动态规划算法的核心是记住已经求过的解,记住求解的方式有两种:①自顶向下的备忘录法 ②自底向上。

例子1:

剑指Offer(第二版)面试题14:剪绳子

给你一根长度为n的绳子,请把绳子剪成m段 (m和n都是整数,n>1并且m>1)每段绳子的长度记为k[0],k[1],…,k[m].请问k[0]k[1]…*k[m]可能的最大乘积是多少?

例如,当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18.

看完题目,我们按照上面提到的“动态规划五部”解决问题

1、判题题意是否为找出一个问题的最优解

看到字眼是“可能的最大乘积是多少”,判断是求最优解问题,可以用动态规划解决;

2、从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题

题目中举了个例子:当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18;我们可以从这里开始突破,把长度为8绳子的最大乘积分解为数个子问题,长度为8我们可以把它看成长度为1和7的绳子的和,或者长度 为2和6的绳子的和,或者长度为3和5的绳子的和and so on!

到这里,相信大家已经看到一丝真理了吧?

3. 从下往上分析问题 ,找出这些问题之间的关联(状态转移方程)

在第二点时,我们已经从上到下分析问题了,现在我们要从下往上分析问题了。分析可知,

f(8) 的值就是f(1)*f(7),f(2)*f(6),f(3)*f(5),f(4)*f(4)它们之中的最小值,即f(8) = Max{f(1)*f(7),f(2)*f(6),f(3)*f(5),f(4)*f(4)}

只要知道f(1)到f(7)的值就能求出f(8);对于f(7),只要知道f(1)到f(6)的值就能求出f(6);对于f(6),只要知道f(1)到f(5)的值就能求出f(6);以些类推,我们只要知道前几个边界的值,就能一步步迭代出后续的结果!

状态转移方程:f(n)=Max{f(n-i)*f(i)} i={1,2,3,…,n/2}

4. 讨论底层的边界问题

底层的边界问题说的就是最小的前几个数值的f(n)的值,本题中就是f(0)、f(1)、f(2)、f(3)的值

对于f(0),长度为0的绳子,没办法剪,没有意义

对于f(1),长度为1的绳子,没办法剪,设为1

对于f(2),长度为2的绳子,只有一种剪法,剪成两段长度为1的绳子,但剪后的乘积为1,比自身更小;如果不是求自身的值,要求乘积最大值的话就没必要剪。

对于f(3),长度为3的绳子,只有一种剪法,剪成两段长度为1和2的绳子,但剪后的乘积为2,比自身更小;如果不是求自身的值,要求乘积最大值的话也没必要剪。

5、解决问题

这一步就是写代码了

public static int cutting(int n)

{

    // 长度小于等等于1没办法剪

    if (n <= 1)

          return 0;

     // 对于f(2),长度为2的绳子,只有一种剪法,剪成两段长度为1的绳子,剪后的乘积为1

    if (n == 2)

         return 1;

    // 对于f(3),长度为3的绳子,只有一种剪法,剪成两段长度为12的绳子,但剪后的乘积为2

    if (n == 3)

        return 2;

    int max = 0;

    // 数组用于存储绳子乘积最大值

    int value[] = new int[n + 1];

    value[0] = 0;

    value[1] = 1;

    // 剪后的乘积为1,比自身更小;如果不是求自身的值,要求乘积最大值的话就没必要剪

    value[2] = 2;

    // 剪后的乘积为2,比自身更小;如果不是求自身的值,要求乘积最大值的话也没必要剪

    value[3] = 3;

     // f(4)开始迭代

    for (int i = 4; i <= n; i++)

    {

        max = 0;

        for (int j = 1; j <= i / 2; j++)

        {

            int val = value[j] * value[i - j];

            max = val > max ? val : max;

        }

        value[i] = max;

    }

    max = value[n];

    return max;

}

对于刚学习动态规划的同学,看完上面那题会有点吃力吧?那我们再来一题简单的,给大家增加信心!


例子2:

跳台阶问题

一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级台阶总共有多少种跳法。

1、判题题意是否为找出一个问题的最优解

这个我还真的看不出,直觉判断这题可以通过动态规划迭代出来….有经验的网友可以分享下看法,指导一下本小白白。

2、从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题

题目中没有给粟子,我们可以自己举点粟子。例如,跳上一个6级台阶台阶,有多少种跳法;由于青蛙一次可以跳两阶,也可以跳一阶,所以我们可以分成两个情况

1)青蛙最后一次跳了两阶,问题变成了“跳上一个4级台阶台阶,有多少种跳法”

2)青蛙最后一次跳了一阶,问题变成了“跳上一个5级台阶台阶,有多少种跳法”

由上可得f(6) = f(5) + f(4);

由此类推,f(4)=f(3) +f(2)

3、从下往上分析问题 ,找出这些问题之间的关联(状态转移方程)

跟上面的例题一相同,可以由f(1)逐渐迭代上去

由2可得,状态转移方程为:f(n)=f(n-1)+f(n-2)

4、边界情况分析

跳一阶时,只有一种跳法,所以f(1)=1

跳两阶时,有两种跳法,直接跳2阶,两次每次跳1阶,所以f(2)=2

跳两阶以上可以分解成上面的情况

5、解决问题

public static int jump(int n)

{

     // 无意义的情况

    if (n <= 0)

        return 0;

   if (n == 1)

       return 1;

   if (n == 2)

       return 2;

   // 数组用于存储跳n阶的跳法数

   int[] value = new int[n + 1];

   value[0] = 0;

   value[1] = 1;

   value[2] = 2;

    for (int i = 3; i <= n; i++)

    {

        value[i] = value[i -1] + value[i - 2];

    }

    return value[n];

}


动态规划详细内容:

https://mp.weixin.qq.com/s?__biz=MzIxMjE5MTE1Nw==&mid=2653190796&idx=1&sn=2bf42e5783f3efd03bfb0ecd3cbbc380&chksm=8c990856bbee8140055c3429f59c8f46dc05be20b859f00fe8168efe1e6a954fdc5cfc7246b0&scene=21#wechat_redirect


相关链接:

https://blog.csdn.net/weixin_38278878/article/details/80037455

https://blog.csdn.net/rock_joker/article/details/68928150

https://blog.csdn.net/u013309870/article/details/75193592


 

参考书目:《算法》、《算法图解》

你可能感兴趣的:(数据结构和算法(4)-- 常用算法)