从暴力递归到动态规划,记忆化搜索

简介: 从暴力递归到动态规划,记忆化搜索

   

动态规划

暴力递归之所以暴力是因为存在大量的重复计算,比如一个很经典的问题——斐波那契数列。

public static int fibonacci(int n) {
    if(n==1) {
      return 1;
    }
    if(n==2) {
      return 1;
    }
    return fibonacci(n-1)+fibonacci(n-2);
  }

上面的试法,存在大量的重复计算,浪费时间。

image.png

由此引出我们的动态规划,之前写了8篇暴力递归的文章,就是为了这个目的。

因为动态规划就是某一类尝试行为的进一步优化,任何一个动态规划的问题都是以某一个暴力尝试过程中优化后的样子。

题目

假设有排成一行的N个位置,记为1~N, N一定大于或等于2

开始时机器人在其中的M位置上(M一定是1~N中的一个)

如果机器人来到1位置,那么下一步只能往右来到2位置;

如果机器人来到N位置,那么下一步只能往左来到N-1位置;

如果机器人来到中间位置,那么下一步可以往左走或者往右走;

规定机器人必须走K步,最终能来到P位置(P也是1~N中的一个)的方法有多少种

给定四个参数N、M、K、P,返回方法数。

题目什么意思呢,我们先自己带入小的样本量,方便我们弄懂题意:

假设总共有7个数,一开始,机器人在数字3上面,要求必须走满3步,最终能来到数字2上面,方法总共有3种,下图分别列出来:

image.png

package com.harrison.class13;
public class Code02_RobotWalk {
  // N 只能在1~N范围上移动 固定参数
  // cur 当前来到的位置 可变参数
  // rest 还剩多少步要走 可变参数
  // P 最终要到的位置 固定参数
  // 函数含义:
  // 只能在1~N位置上移动,当前在cur位置上,走完rest步后,停在P位置上的方法有多少种
  public static int process1(int N,int cur,int rest,int P) {
    // 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效
    // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效
    if(rest==0) {
      return cur==P?1:0;
    }
    // if没中,还剩rest步要走
    // 如果来到了1位置,没得选,只能往右去2位置
    // 后续的过程就是来到2位置,还剩rest-1步
    if(cur==1) {
      return process1(N, 2, rest-1, P);
    }
    // 如果来到了N位置,没得选,只能往左去N-1位置
    //  后续的过程就是来到N-1位置,还剩rest-1步
    if(cur==N) {
      return process1(N, N-1, rest-1, P);
    }
    // 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右
    // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走
    // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走
    // 走向左、走向右是截然不同的方法,所以总方法数都要算上
    return process1(N, cur-1, rest-1, P)+process1(N, cur+1, rest-1, P);
  }
  public static int ways1(int N,int M,int K,int P) {
    // 参数无效直接返回0
    if(M<1 || M>N || P<1 || P>N || K<1 || N<2) {
      return 0;
    }
    // 总共N个位置,从M点出发,还剩K步,返回最终能到达P的方法数
    return process1(N, M, K, P);
  }
  public static void main(String[] args) {
    System.out.println(ways1(7, 3, 3, 2));
  }
}

上述暴力递归肯定也存在大量的重复计算,

image.png

如果我们可以做出一个缓存的话,那就不用递归,可以直接从缓存中拿数据来用!

所以,接下来进行优化:

// 把cur和rest的组合,返回的结果,加入到缓存中
  public static int process2(int N,int cur,int rest,int P,int[][] dp) {
    if(dp[cur][rest]!=-1) {// 不等于-1,表示已经算过,直接从缓存中拿值
      return dp[cur][rest];
    }
    if(rest==0) {
      // 返回之前先加缓存,底下都这么干
      dp[cur][rest]=cur==P?1:0;
      return dp[cur][rest];
    }
    if(cur==1) {
      dp[cur][rest]=process2(N, 2, rest-1, P,dp);
      return dp[cur][rest];
    }
    if(cur==N) {
      dp[cur][rest]=process2(N, N-1, rest-1, P,dp);
      return dp[cur][rest];
    }
    dp[cur][rest]=process2(N, cur-1, rest-1, P,dp)+process2(N, cur+1, rest-1, P,dp);
    return dp[cur][rest];
  }
  public static int ways2(int N,int M,int K,int P) {
    if(M<1 || M>N || P<1 || P>N || K<1 || N<2) {
      return 0;
    }
    // 这张二维表可以把递归所有的返回值装下
    int[][] dp=new int[N+1][K+1];
    for(int row=0; row<=N; row++) {
      for(int col=0; col<=K; col++) {
        dp[row][col]=-1;// 表示所有的参数组合都没有算过
        // 因为如果算过的话,方法数不可能小于0
      }
    }
    return process2(N, M, K, P,dp);
  }

在每次暴力递归之前先在缓存中查看是否已经算过,如果已经算过,就直接从缓存中拿值,可以省去很多重复计算的过程。这就是动态规划,它的另一个名字叫做记忆化搜索。是动态规划中最糙的一种。它不关心状态的依赖,就是一个很傻的缓存,遇到了重复的过程就从缓存中拿结果,如果碰到没算过的过程才去算。

那么经典的动态规划是怎样的呢?

经典的动态规划需要对表进行精细化分析,很明显上述递归过程只有两个可变参数,所以可以看成一个二维表:(在1~7位置上,从2出发,还剩下5步,最终要走到3位置上)

image.png

接下来就按我们一开始暴力递归的思路去填这张表:

1)cur是在1~7上,不存在0,所以第0行全画叉;

// 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效
    // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效
    if(rest==0) {
      return cur==P?1:0;
    }
• 1
• 2
• 3
• 4
• 5

image.png

2)cur==1的时候,依赖左下角的位置;

// 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效
    // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效
    if(rest==0) {
      return cur==P?1:0;
    }

image.png

3)cur==N的时候,依赖左上角的位置;

// 如果来到了N位置,没得选,只能往左去N-1位置
    //  后续的过程就是来到N-1位置,还剩rest-1步
    if(cur==N) {
      return process1(N, N-1, rest-1, P);
    }

image.png

4)除此以外,任何一个普遍位置都依赖左上角和左下角的值。

// 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右
    // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走
    // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走
    // 走向左、走向右是截然不同的方法,所以总方法数都要算上
    return process1(N, cur-1, rest-1, P)+process1(N, cur+1, rest-1, P);

这么看来,改动态规划根本不需要知道原来题意是什么,完全根据暴力递归的思路来改就可以了。也就是说,如果某个人写了一个二维可变参数的递归,那么你就可以只根据他的递归怎么写就可以改出动态规划,都不用关心原题意是什么!

接下来根据上述原则把这个二维表格填好,然后根据主函数的调用方式返回(从2出发,还剩下5步):

image.png

附上这道题目两种方法的完整代码:

package com.harrison.class13;
public class Code02_RobotWalk {
  // N 只能在1~N范围上移动 固定参数
  // cur 当前来到的位置 可变参数
  // rest 还剩多少步要走 可变参数
  // P 最终要到的位置 固定参数
  // 函数含义:
  // 只能在1~N位置上移动,当前在cur位置上,走完rest步后,停在P位置上的方法有多少种
  public static int process1(int N,int cur,int rest,int P) {
    // 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效
    // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效
    if(rest==0) {
      return cur==P?1:0;
    }
    // if没中,还剩rest步要走
    // 如果来到了1位置,没得选,只能往右去2位置
    // 后续的过程就是来到2位置,还剩rest-1步
    if(cur==1) {
      return process1(N, 2, rest-1, P);
    }
    // 如果来到了N位置,没得选,只能往左去N-1位置
    //  后续的过程就是来到N-1位置,还剩rest-1步
    if(cur==N) {
      return process1(N, N-1, rest-1, P);
    }
    // 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右
    // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走
    // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走
    // 走向左、走向右是截然不同的方法,所以总方法数都要算上
    return process1(N, cur-1, rest-1, P)+process1(N, cur+1, rest-1, P);
  }
  public static int ways1(int N,int M,int K,int P) {
    // 参数无效直接返回0
    if(M<1 || M>N || P<1 || P>N || K<1 || N<2) {
      return 0;
    }
    // 总共N个位置,从M点出发,还剩K步,返回最终能到达P的方法数
    return process1(N, M, K, P);
  }
  // 把cur和rest的组合,返回的结果,加入到缓存中
  public static int process2(int N,int cur,int rest,int P,int[][] dp) {
    if(dp[cur][rest]!=-1) {// 不等于-1,表示已经算过,直接从缓存中拿值
      return dp[cur][rest];
    }
    if(rest==0) {
      // 返回之前先加缓存,底下都这么干
      dp[cur][rest]=cur==P?1:0;
      return dp[cur][rest];
    }
    if(cur==1) {
      dp[cur][rest]=process2(N, 2, rest-1, P,dp);
      return dp[cur][rest];
    }
    if(cur==N) {
      dp[cur][rest]=process2(N, N-1, rest-1, P,dp);
      return dp[cur][rest];
    }
    dp[cur][rest]=process2(N, cur-1, rest-1, P,dp)+process2(N, cur+1, rest-1, P,dp);
    return dp[cur][rest];
  }
  public static int ways2(int N,int M,int K,int P) {
    if(M<1 || M>N || P<1 || P>N || K<1 || N<2) {
      return 0;
    }
    // 这张二维表可以把递归所有的返回值装下
    int[][] dp=new int[N+1][K+1];
    for(int row=0; row<=N; row++) {
      for(int col=0; col<=K; col++) {
        dp[row][col]=-1;// 表示所有的参数组合都没有算过
        // 因为如果算过的话,方法数不可能小于0
      }
    }
    return process2(N, M, K, P,dp);
  }
  public static void main(String[] args) {
    System.out.println(ways1(7, 2, 5, 3));
    System.out.println(ways2(7, 2, 5, 3));
  }
}

最后来点方法论的总结:

暴力递归的分析过程抽象出来就是动态规划的转义方程,任何一个动态规划都是由暴力尝试的尝试的那个种子改过来的。只要可变参数是有限几个,三个可变参数就是一张三维表,两个可变参数就是一张二维表,一个可变参数就是一张一维表。只要能试出由可变参数代表的一个暴力递归,就可以改成动态规划。注意:不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归,而暴力递归是跟自然智慧最贴合的,知道怎么拆,所以比编动态转义方程容易。

有些暴力递归改不成动态规划的原因是它没有足够多的重复过程。当然改还是可以改,但是没有必要。

题 -> 找到暴力递归的写法(尝试)-> 分析暴力递归过程中是有重复解的(可变参数不讲究组织就是记忆化搜索,记忆化搜索进行精细化组织就是经典的动态规划)

不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归!

不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归!

不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归!

重要的事情说三遍。


相关文章
|
算法 安全 数据安全/隐私保护
什么是国密证书?
什么是国密证书?
538 0
|
数据安全/隐私保护 虚拟化 Windows
如何在 VM 虚拟机中安装 Windows Server 2008 操作系统保姆级教程(附链接)
如何在 VM 虚拟机中安装 Windows Server 2008 操作系统保姆级教程(附链接)
|
Kubernetes 监控 调度
Kubernetes Pod调度:从基础到高级实战技巧
Kubernetes Pod调度:从基础到高级实战技巧
2616 0
|
5月前
|
网络协议 安全 应用服务中间件
云服务器怎么开启被关闭的端口?手把手教你开启端口
在使用云服务器时,若发现某些服务无法访问,可能是端口被关闭。本文介绍了端口关闭的原因、检查方法及开启步骤。原因包括初始设置限制、防火墙规则和外部网络策略;可通过netstat或ss命令检查端口状态,用ufw、iptables或firewalld调整防火墙规则。最后提供了解决常见问题的建议,确保端口正常开放并可供外网访问。
1098 9
|
1月前
|
数据采集 机器学习/深度学习 监控
代理IP并发控制:多线程爬虫的加速引擎
在数据采集领域,多线程爬虫结合代理IP并发控制技术,有效突破反爬机制。通过动态代理池与智能并发策略,显著提升采集效率并降低封禁率,成为高效数据抓取的关键方案。
84 0
|
6月前
|
JSON Java 数据格式
微服务——SpringBoot使用归纳——Spring Boot中的全局异常处理——处理系统异常
本文介绍了在Spring Boot项目中如何通过创建`GlobalExceptionHandler`类来全局处理系统异常。通过使用`@ControllerAdvice`注解,可以拦截项目中的各种异常,并结合`@ExceptionHandler`注解针对特定异常(如参数缺失、空指针等)进行定制化处理。文中详细展示了处理参数缺失异常和空指针异常的示例代码,并说明了通过拦截`Exception`父类实现统一异常处理的方法。虽然拦截`Exception`可一劳永逸,但为便于问题排查,建议优先处理常见异常,最后再兜底处理未知异常,确保返回给调用方的信息友好且明确。
793 0
微服务——SpringBoot使用归纳——Spring Boot中的全局异常处理——处理系统异常
|
10月前
|
存储 前端开发 JavaScript
React 文件上传组件 File Upload
本文介绍了如何在 React 中实现文件上传组件,包括基本的概念、实现步骤、常见问题及解决方案。通过 `&lt;input type=&quot;file&quot;&gt;` 元素选择文件,使用 `fetch` 发送请求,处理文件类型和大小限制,以及多文件上传和进度条显示等高级功能,帮助开发者构建高效、可靠的文件上传组件。
753 3
|
SQL 关系型数据库 MySQL
MySQL SQL error: #1271 - Illegal mix of collations for operation ‘UNION‘
MySQL SQL error: #1271 - Illegal mix of collations for operation ‘UNION‘
1097 0
|
前端开发 JavaScript API
说一说你对混合开发(Hybrid Development)的了解。
混合开发(Hybrid App)融合Web与原生技术,实现跨平台开发,降低多平台工作量。使用JavaScript等Web技术提升开发效率,通过React Native、Flutter等框架结合原生API。虽性能略逊于原生,但体验接近,且更新便捷、成本效益高。丰富的社区支持和成功案例(如网易云音乐、闲鱼)证明其可行性。随着技术进步,混合开发的潜力和应用将不断扩大。
584 1
|
运维 网络协议 Linux
2023年河南省中等职业教育技能大赛网络建设与运维项目比赛试题(一)
2023年河南省中等职业教育技能大赛网络建设与运维项目比赛试题(一)