【算法01】—动态规划
目录
一、动态规划(Dynamic Programming,DP)
1.1 【编程题】斐波那契数列
1.2【编程题】青蛙跳台阶扩展问题
1.3【编程题】最小花费爬楼梯
1.4【编程题】不同路径(机器人走方格)
1.5【编程题】不同路径机器人走方格(有障碍)
1.6【编程题】走方格的方案数(同上)
1.7【编程题】拆分词句
1.8【编程题】三角形
二、背包理论
2.1 二维dp数组01背包
2.2 一维dp数组 01背包
一、动态规划(Dynamic Programming,DP)
动态规划是分治思想的延伸:将大问题化解为小问题的分治过程中,保存对这些小问题已经处理好的结果,并供后面处理更大规模的问题时直接使用这些结果。
特点:
1. 把原来的问题分解成了几个相似的子问题。
2. 所有的子问题都只需要解决一次。
3. 储存子问题的解。
本质:对问题状态的定义和状态转移方程的定义(状态以及状态之间的递推关系)。
考虑角度:状态定义(要求:定义的状态要形成递推关系)、状态间的转移方程定义、 状态的初始化、返回结果
适用场景:最大值/最小值, 可不可行, 是不是,方案个数
对于动态规划问题,拆解为如下五步骤:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组(打印数组)
1.1 【编程题】斐波那契数列
斐波那契数列—牛客
题目:斐波那契数列,现在要求输入一个正整数 n ,输出斐波那契数列的第 n 项。
斐波那契数列(黄金分割数列):1、1、2、3、5、8、13、21、34、…,以递推的方法定义:
F(0)=0,F(1)=1, F(n)=F(n-1)+F(n-2)(n ≥2,n ∈ N*)
输入描述:
一个正整数n
输出描述:
输出一个正整数
注意·!!!斐波那契数组,要单独考虑n=0和n=1的情况
1.递归方法:时间复杂度O(2^n),输入较大时,可能栈溢出,递归过程中有大量的重复计算
public class Solution {public int Fibonacci(int n) {if(n <= 1) return n;int f = Fibonacci(n-1)+Fibonacci(n-2); //如果n>2 则输出freturn f;}
}
2.动态规划: 空间复杂度为O(1)、空间复杂度为O(n)
状态:F(n)
状态递推:F(n)=F(n-1)+F(n-2)
初始值:F(1)=F(2)=1
返回结果:F(N)
public class Solution {public int Fibonacci(int n) { //空间复杂度为O(1)if(n <= 0) return 0;if(n == 1 || n == 2) return 1;int ret = 0;int fn1 = 1, fn2 = 1;for(int i = 3; i <= n; i++) {ret = fn1 + fn2;fn1 = fn2;fn2 = ret;}return ret;}
}//方法二: //空间复杂度为O(n)public int Fibonacci(int n) { int[] dp= new int[n+1]; //创建一个数组保存中间状态的解dp[0] = 0;dp[1] = 1;for(int i = 2; i < n+1; i++) {dp[i] = dp[i-1] + dp[i-2];}return dp[n];}
1.2【编程题】青蛙跳台阶扩展问题
牛客—青蛙跳台阶
题目1:青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶(n为正整数)总共有多少种跳法。
方法1:递归
1.逆向思维:若从第n个台阶进行下台阶,下一步有2中可能,一种走到第n-1个台阶,一种是走到第n-2个台阶,可得到如下关系:f[n] = f[n-1] + f[n-2]. (f[n] 表示在第n个台阶上的方法数)
2.初始条件:f[0] = f[1] = 1
3.看到此问题可以想到斐波那契数组,使用动态规划:可优化空间,优化掉递归的栈空间,动态规划直接从子树求得答案。过程是从下往上。
//递归方法
class Solution {public int jumping(int num) {if(num<=1) return n;return jumping(num-1)+jumping(num-2);}//优化1:动态规划public int jumping(int num) { int[] arr= new int[num+1]; //创建一个数组存放每一级台阶可以的方法数arr[0] = 0;arr[1] = 1;for(int i = 2; i < num+1; i++) {arr[i] = arr[i-1] + arr[i-2];}return arr[num]; // 返回num此时对应的方法数量}//优化2:可以发现在这个过程中,计算当前台阶数只用到了前两个台阶的值,因此,只需定义三个变量即可public int jumping(int num) { int a = 1;int b = 1;int sum = 0;for(int i=2,i <= num,i++) {sum = a + b;a = b;b = sum;}return sum;}
}
题目2:一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶(n为正整数)总共有多少种跳法。
解析:此问题是上面的延伸:可得到关系 :f(n)=f(n-1)+…f(1)---->f(n)=2*f(n-1)
public class Solution {
//方法一:排列
// 每个台阶看成一个位置,除过最后一个位置,其它位置都有两种可能性,
// 所以总的排列数为2^(n-1)*1 = 2^(n-1)public int jumpFloorII(int n) {int sum = 1;if (n == 1) return 1; //如果只有一个台阶,则只有一种方法for(int i = 1; i < n; i++) { //从第二级台阶开始sum *= 2;}return sum;}
}
1.3【编程题】最小花费爬楼梯
746. 使用最小花费爬楼梯
给一个整数数组 cost
,其中 cost[i]
是从楼梯第 i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。可以选择从下标为 0
或下标为 1
的台阶开始爬楼梯。计算并返回达到楼梯顶部的最低花费。
输入:cost = [10,15,20]
输出:15
解释:从下标为 1 的台阶开始。 支付 15 ,向上爬两个台阶,到达楼梯顶部, 总花费 15。
输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。
解析:
!!!!!!!!每当你爬上一个阶梯,都要花费对应的体力值cost[i]
1.确定dp数组以及下标的含义:
dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]。
2.确定递推公式:
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
3. dp数组如何初始化
dp[0] =cost[0] ; dp[1] = cost[1];
4.确定遍历顺序
从前到后遍历cost数组
5.举例推导dp数组
// 方式一:第一步支付费用
class Solution {public int minCostClimbingStairs(int[] cost) {int len = cost.length; //求数组长度int[] dp = new int[len+1]; //动态规划初始化数组dp大小一般为n+1dp[0] = 0;dp[1] = 0; // dp[i]为到达第i个台阶所需要支付的费用
//!!!!!!!!每当你爬上一个阶梯,都要花费对应的体力值cost[i]for(int i = 2;i < len+1; i++) {dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]); //支付费用后,有两种可选方式,爬一个台阶或者两个台阶,取每一步(局部)最小值,即可求得最后所有步的最小值}return dp[len]; //返回爬上最后一个台阶需要花费的费用}
}// 方式二:第一步不支付费用
class Solution {public int minCostClimbingStairs(int[] cost) {int len = cost.length;int[] dp = new int[len];dp[0] = cost[0];dp[1] = cost[1];for (int i = 2; i < len; i++) {dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];}//最后一步,如果是由倒数第二步爬,则最后一步的体力花费可以不用算return Math.min(dp[len - 1], dp[len - 2]);}
}
1.4【编程题】不同路径(机器人走方格)
62. 不同路径
一个机器人位于 m *n
网格的左上角 (起始点标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(标记为 “Finish” )。问总共有多少条不同的路径?
解析:
1.根据分析画图可得到:当n或m为1时,ret = 1,只有一种方法
2.m = 2,且 n = 2时,f(2,2) = 2;
2.递归公式f(m,n) = f(m-1,n) + f(m,n-1)
1.动态规划解法:
class Solution {public int uniquePaths(int m, int n) { int[][] dp = new int[m][n];for(int i = 0; i < m; i++) { dp[i][0] = 1; //初始化,表示一条竖线,故只有一种方法·}for(int i = 0; i < n; i++) { //初始化,表示一条横线dp[0][i] = 1;}for(int i = 1;i < m; i++) {for(int j = 1; j < n; j++) {dp[i][j] = dp[i-1][j] + dp[i][j-1]; //动态规划推导公式}}return dp[m-1][n-1]; //f(m,n) = f(m-1,n)+f(m,n-1)}
}
2.递归方法:
class Solution {public int uniquePaths(int m, int n) {if(m <= 0 || n <= 0) return 0 ;if(m == 1 || n == 1) return 1 ;if(m == 2 && n == 2) return 2 ;return uniquePaths(m-1,n) + uniquePaths(m,n-1);}
}
1.5【编程题】不同路径机器人走方格(有障碍)
63. 不同路径 II
机器人位于 m*n
网格的左上角 (起始点标记为 “Start” )每次只能向下或者向右移动一步。机器人到网格的右下角( “Finish”)。现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?网格中的障碍物和空位置分别用 1
和 0
来表示。
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
解析:
1.确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有 dp[i][j] 条不同的路径。
2.确定递推公式
无障碍进行递推:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)
if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j]dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
3.初始化
若无障碍 dp[i][0] = dp[0][i] = 1,有障碍则为·0
4.确定遍历顺序
从递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 中得出,一定是从左到右一层一层遍历,保证推导dp[i][j]时,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值。
5.举例推导dp数组
完整代码:
class Solution {public int uniquePathsWithObstacles(int[][] obstacleGrid) {int m = obstacleGrid.length;int n = obstacleGrid[0].length;int[][] dp = new int[m][n];if(obstacleGrid[m-1][n-1] == 1 || obstacleGrid[0][0] == 1) {return 0; //起点和终点若是障碍物,则不同通行}for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {dp[i][0] = 1;}for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {dp[0][j] = 1;}for(int i = 1; i < m; i++) { //无障碍动规操作 ,有障碍为0for(int j = 1; j < n; j++) {dp[i][j] = (obstacleGrid[i][j] == 0) ? (dp[i-1][j] + dp[i][j-1]): 0; //无障碍执行动规,有障碍0}}return dp[m-1][n-1];}
}
1.6【编程题】走方格的方案数(同上)
走方格
请计算n*m的棋盘格子(n为横向的格子数,m为竖向的格子数)从棋盘左上角出发沿着边缘线从左上角走到右下角,总共有多少种走法,要求不能走回头路,即:只能往右和往下走,不能往左和往上走。
注:沿棋盘格之间的边缘线行走
输入描述:
输入两个正整数n和m,用空格隔开。(1≤n,m≤8)
输出描述:
输出一行结果 如:输入:2 2---->6
解析:总路径:(n,m)=(n-1,m)+(n,m-1) ---->使用递归
2.当n==1 && m>= 1------>对应路径数n+m;
3.当m==1 && n>= 1------>对应路径数n+m
终止条件m,n = 1
4.当m,n都>1时,如下情况:每走一步有两种情况,因此用递归方法来实现
import java.util.Scanner;
public class Main {public static void main(String[] args) {Scanner in = new Scanner(System.in);while (in.hasNextInt()) { //多组样例,因此需要循环读入int n = in.nextInt();int m = in.nextInt();System.out.println(func(n,m));}}public static int func(int n,int m) { //处理输入的两个数据,递归函数if((n == 1 && m >=1) || (m == 1 && n >=1)) {return m+n;}return func(n-1,m)+func(n,m-1); }
}
1.7【编程题】拆分词句
给定一个字符串s和一组单词dict,判断s是否可以用空格分割成一个单词序列,使得单词序列中所有的单词都是dict中的单词(序列可以包含一个或多个单词),如:
给定s=“nowcode”;dict=["now", "code"].
返回true,因为"nowcode"可以被分割成"now code".
import java.util.Set;public class Solution {public boolean wordBreak(String s, Set<String> dict) {if(s == null || s.length() == 0) return false;boolean[] dp = new boolean[s.length()+1]; //给定一个状态数组,存放每个字符是否被分割的true,false值,判定其是否能够被分割,dp[0] = true; //初始值for(int i = 1;i <= s.length();i++) { //遍历字符串,从1开始,因为下标0给了初始状态for (int j = i - 1; j >= 0; j--) {if (dp[j] && dict.contains(s.substring(j, i))) { // 字典里有字符串的子串 dp[i] = true;}}}return dp[s.length()];}
}
1.8【编程题】三角形
给出一个三角形,计算从三角形顶部到底部的最小路径和,每一步都可以移动到下面一行相邻的数字,如 : 给出的三角形如下:
[[20],[30,40],[60,50,70],[40,10,80,30]]
最小的从顶部到底部的路径和是20 + 30 + 50 + 10 = 110。
问题:从顶部到底部的最小路径和
状态F(i,j):(0,0)到(i,j)的最小路径和
状态递推:F(i,j) = min(i-1,j-1), F(i-1,j) + array[i , j]
初始值:F(1)=F(2)=1
返回结果:F(N)
解析:
1. 新增一个数组来存储当前层到下一层各个节点最短的路径值
2.用这个三角形数组每一层本身来存储到达当前层的最短路径,这样就不需要额外的存储空间。。
import java.util.*;
public class Solution {public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {if (triangle.size() == 0 || triangle == null) return 0;int n = triangle.size(); //记录三角形的层数 (外层数组) int[] temp = new int[n]; //创建一个数组,存放到达每一层的最小步数,数组大小为层数 (内层数组)for (int i = 0; i < n; i++) // triangle.get(n-1)获取(n-1) 行的所有元素 ----->(n-1)行中i位置的元素temp[i] = triangle.get(n-1).get(i); //获取最后一层 i位置 元素//继续由下向上运算for(int i = n-2; i >= 0; i--){ //i代表行数,j为每一行的元素for(int j = 0; j <= i; j++){temp[j] = triangle.get(i).get(j)+Math.min(temp[j],temp[j+1]); //获取当前行i-1的最小值min + 上一层i节点}}return temp[0];}
}
二、背包理论
2.1 二维dp数组01背包
1. 确定dp数组以及下标的含义
dp[i][j] :[0,i ] 物品里任意,放进容量为 j 的背包里,价值总和的最大值。
2.确定递推公式
- 不放物品 i :最大价值为dp[ i - 1] [ j ],也即是物品 i 的容量 > 背包 j 的体积
- 放物品 i :最大价值为dp[ i - 1] [ j - weight[ i ] ] + value[ i ] ---> 也即是 i-1 个物品的价值+第 i 个物品的价值 ( value[ i ] 为物品 i 的价值;weight[ i ]为物品 i 的容量)
递归公式: dp[ i ][ j ] = max(dp[i - 1][ j ], dp[i - 1][j - weight[ i ]] + value[ i ]);
3.dp数组如何初始化
1) 若背包容量 j = 0(dp[ i ][ 0 ]),背包价值总和 dp[ i ][ j ] = 0。
2) i 为 0,存放编号0的物品,各个容量的背包所能存放的最大价值 dp[ i ][ j ]。
当 j < weight [ 0 ] 时,dp[ 0 ][ j ] = 0,因为背包容量 < 物品容量,(装不下,最大价值为0)
当j >= weight [ 0 ] 时,dp[ 0 ][ j ] = value[ 0 ],背包容量只要大于物品容量即可存放。
先遍历 物品 还是 背包?都可以!! 先遍历物品更好理解。
二维dp数组实现01背包 完整代码:
public static void main(String[] args) {int[] goodsWeight = {1, 3, 4}; // 物品容量int[] value = {15, 20, 30}; // 物品价值int bagSize = 4; //背包最大体积testweightbagproblem(goodsWeight, value, bagSize); //递归}public static void testweightbagproblem(int[] goodsWeight, int[] value, int bagSize){int goodsNum= goodsWeight.length; //物品个数int value0 = 0; //价值为0int[][] dp = new int[goodsNum+ 1][bagSize + 1]; //dp[i][j]包容量为j,前i个物品的最大价值for (int i = 0; i <= goodsNum; i++){ //背包容量为0,价值0(不能放物品)dp[i][0] = value0;}for (int i = 1; i <= goodsNum; i++){ //先遍历物品,再遍历背包容量for (int j = 1; j <= bagSize; j++){if (j < goodsWeight[i - 1]){ //背包容量 < 物品i,则i不能放入dp[i][j] = dp[i - 1][j];}else{dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - goodsWeight[i - 1]] + value[i - 1]);}}}for (int i = 0; i <= goodsNum; i++){ //打印dp数组for (int j = 0; j <= bagSize; j++){System.out.print(dp[i][j] + " ");}System.out.print("\n");}}
2.2 一维dp数组 01背包
二维:dp[ i ][ j ] = max(dp[ i ][ j ], dp[ i ][ j - weight[i] ] + value[i]);
dp[ j ]:容量为j的背包,所背物品价值dp[ j ]
递推公式:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
初始化:非0下标都初始化为0
一维dp数组实现01背包 完整代码:
public static void main(String[] args) {int[] goodsWeight = {1, 3, 4};int[] value = {15, 20, 30};int bagWight = 4;testWeightBagProblem(goodsWeight, value, bagWight); //递归方法}public static void testWeightBagProblem(int[] goodsWeight, int[] value, int bagWeight){int doodsNum = goodsWeight.length;int[] dp = new int[bagWeight + 1]; // dp[j]背包容量为 j 的最大价值for (int i = 0; i < doodsNum; i++){ //先遍历物品,再遍历背包容量for (int j = bagWeight; j >= weight[i]; j--){dp[j] = Math.max(dp[j], dp[j - goodsWeight[i]] + value[i]);}}for (int j = 0; j <= bagWeight; j++){ //打印dp数组System.out.print(dp[j] + " ");}}
【算法01】—动态规划相关推荐
- Java入门算法(动态规划篇2:01背包精讲)
本专栏已参加蓄力计划,感谢读者支持❤ 往期文章 一. Java入门算法(贪心篇)丨蓄力计划 二. Java入门算法(暴力篇)丨蓄力计划 三. Java入门算法(排序篇)丨蓄力计划 四. Java入门算 ...
- NOI入门级:算法之动态规划
糖糖讲动态规划算法,找零钱完全背包问题,LeetCode 322 糖糖讲动态规划算法,找零钱完全背包问题,LeetCode 322_哔哩哔哩_bilibili 程序员面试再也不怕动态规划了,看动画,学 ...
- 【算法】动态规划笔记
[算法]动态规划笔记 动态规划: 将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解 动态规划会将每个求解过的子问题的解记录下来,这样下一次碰到同样的子问题时,就可以直接使 ...
- 算法:动态规划窃贼问题C语言实现
算法:动态规划窃贼问题C语言实现 目录 算法:动态规划窃贼问题C语言实现 第一章 问题描述 1.1问题描述 第二章 算法思想及算法设计分析 2.1算法思想 2.2设计算法 2.3算法分析 2.4填表结 ...
- 数据结构与算法学习⑥(动态规划 题解 背包和打家劫舍问题)
数据结构与算法学习⑥(动态规划 动态规划 1.初识动态规划 1.1.从贪心说起 1.1.1.贪心的特点 1.1.2.贪心的局限性 1.1.3.贪心失效后怎么办 1.1.4.从最优化问题到递归 1.2. ...
- 算法学习--动态规划与贪心算法
动态规划与贪心算法都是一种递推算法,都是用局部最优解来推导全局最优解:是对遍历解空间的一种优化:当问题具有最优子结构时,可以用动态规划来解决,而贪心算法是动态规划的特例 动态规划 1. 动态规划的思想 ...
- 零起点学算法01——第一个程序Hello World!
零起点学算法01--第一个程序Hello World! Description 题目很简单 输出"Hello World!"(不含引号),并换行. Input 没有输入 Outpu ...
- Bellman-Ford 算法 和 动态规划
Floyd算法: 状态: d[k][i][j]定义:"只能使用第1号到第k号点作为中间媒介时,点i到点j之间的最短路径长度." 动态转移方程: d[k][i][j]=min(d[k ...
- 五大经典算法之动态规划
一.概念起源 动态规划,又名DP算法(取自其Dynamic Programming的缩写),最初是运筹学的一个分支,是用来求解决策过程最优化的数学方法. 二.基本思想 把 多阶段过程 转化为一 ...
- 贪心算法和动态规划的区别
一.动态规划 动态规划(简称DP)的思想是把一个大的问题进行拆分,细分成一个个小的子问题,且能够从这些小的子问题的解当中推导出原问题的解. 性质 1.最优子结构性:既所拆分的子问题的解是最优解. 2 ...
最新文章
- spring 配置文件 数据库引入
- python读excel乱码_Python读写excel练习_去除excel中乱码行,并添加列
- 线性代数:第三章 矩阵的初等变换与线性方程组(1)矩阵的初等变换 矩阵的秩
- 设置socket.Receive()的等待时延
- lol服务器不稳定补偿地址,LOL9月4日更新bug补偿地址在哪里 9月4日更新bug补偿地址分享...
- 基于 abp vNext 和 .NET Core 开发博客项目 - Blazor 实战系列(五)
- linux中重定向学习总结
- sqlserver 查询中使用Union或Union All
- 论游戏项目中的左与右
- mysql使用裸设备_请教dd清空裸设备问题
- Netty工作笔记0069---Protobuf使用案例
- 李飞飞最新研究成果!斯坦福正在用算法判断政治倾向
- 团队-象棋游戏-代码设计规范
- 线程池原理_Java线程池实现原理
- 关闭计算机端口的命令行,关闭端口命令,小编教你如何关闭电脑80端口
- c语言数字转化为英文版,(C语言编写的英语数字转化代码数字转化为用英语表达的数字.doc...
- 佛理(引用别人的东西,怕忘记了不好找,拿来了,哈哈)
- 安全架构--8--我设计的企业安全体系架构
- 那年花一个钟用PS改证件照的背景色,今天用Excel我只花了60秒!
- Python开发【第六章】:面向对象
热门文章
- c语言嵌入式学习,学习嵌入式C语言的秘诀
- VAX 的使用快捷键
- xmlspy使用必备的技巧
- 前端使用阿里巴巴矢量图库的图标大全
- HTML 中获取现在时间,实时时间获取
- android 不限速迅雷,迅雷不限速分享(手机+PC端打包) – 长期更新
- linux修改u盘mbr,远景论坛U盘版 完美 4G/8G/16G WINPE+LINUXPE+MACPE+10.9正式版MBR安装版 制作超简单...
- ong拼音汉字_拼音ong的正确发音
- 好用的python工具_Python哪些工具好用?老男孩Python开发
- 软件项目管理第十章笔记---项目采购管理