动态规划基础模型总结

news2024/11/19 8:34:09

前言

  • 动态规划是用一个数来表示一堆状态,可以看成是对暴搜的优化

暴搜一个一个数枚举过去,但DP是一堆堆数枚举,效率会快很多;

  • 状态数用几维表示:从小到大考虑,看怎么样才能够让答案清楚表达出来

需要一定的积累,一个问题的理解角度太多了;

  • 动态规划时间复杂度:状态数 × 转移计算量;
  • 状态划分的一般技巧:寻找最后一个不同点;
    • 求最值时,子问题划分可以存在重复部分(不影响全局最值);
    • 求数量时,子问题划分不可以重复(每部分该是多少就是多少);

如背包问题中,f[i][j]就看上一个物品编号i-1放了多少个进行划分;

最长上升子序列中,就找f[i][j]的前一个数j放在序列中位置进行划分;

  • 此外,动态规划遇到的模型实在太多了,这里只列举了我通过Acwing算法基础课学习到的一些常见模型,后续还会继续补充自己在算法学习过程遇到的动态规划题型总结;

请添加图片描述


题型

1. 背包DP

请添加图片描述

1.1. 01背包问题

状态表示f[i][j

集合
所有只考虑前i个物品,且总体积不超过j的选法的集合;

属性:
最大值(背包中存放物品的最大价值)

状态计算:
f[i][j划分为第i个选和第i个不选两种情况;

f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i] ] + w)

请添加图片描述

例题: AcWing 2. 01背包问题

01背包朴素思想代码

#include<iostream>
#include<algorithm>
using namespace std;

const int N = 1010;
int f[N][N];
int v[N], w[N];
int n, m;

int main() {
    cin >> n >> m; 
    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
    
    for (int i = 1; i <= n; i++)
        for (int j = 0; j <= m; j++) {
            f[i][j] = f[i-1][j];
            if (j >= v[i]) f[i][j] = max(f[i][j], f[i-1][j-v[i]] + w[i]);
        }
    
    cout << f[n][m] << endl;
    return 0;
}

01背包优化版代码

通过代码逻辑简化,将状态数组从二维变成一维,后续的背包问题优化类似;

  1. f[i][j]的第一维通过循环开始的次序给省略掉了;
  2. if语句的判断直接并入到for循环的第二个判断中去;

优化后的代码会比较抽象,想要理解清楚还是需要从朴素代码出发去思考;

#include<iostream>
#include<algorithm>
using namespace std;

const int N = 1010;
int f[N];
int v[N], w[N];
int n, m;

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
     
    for (int i = 1; i <= n; i++)
        for (int j = m; j >= v[i]; j--) 
            f[j] = max(f[j], f[j - v[i]] + w[i]);
        
    cout << f[m] << endl;
    return 0;
}

1.2. 完全背包问题

状态表示f[i][j]

集合
f[i][j] 表示所有只考虑前i个物品,且总体积不超过j的选法的集合;

属性:
最大值

状态计算:
通过第i个物品选了几个来划分f[i][j]

f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i], f[i - 1][j - 2*v[i]] + 2*w[i], ... );

//因为 f[i][j- v[i]] = max( f[i - 1][j - v[i]], f[i - 1][j - 2*v[i]] + *w[i], ... );
//因此可以进行等价变形

f[i][j] = max(f[i - 1][j], f[i][j - v[i]] + w[i]);

请添加图片描述

例题 AcWing 3. 完全背包问题

完全背包朴素思想代码:

#include<iostream>
#include<algorithm>

using namespace std;

const int N=1010;

int f[N][N];
int n,m;
int v[N],w[N];

int main(){
    cin >> n>>m;

    for(int i=1;i<=n;i++) cin >>v[i] >>w[i];

    for(int i=1;i<=n;i++)
        for(int j=0;j<=m;j++)
            for(int k=0 ; k*v[i] <= j ;k++){
            f[i][j]=max( f[i][j] , f[i-1][j - k * v[i] ] + k * w[i]);
        }

    cout <<f[n][m] <<endl;

    return 0;
}

完全背包代码逻辑优化版代码:

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010;

int n, m;
int f[N];
int v[N], w[N];

int main() {
    cin >> n >> m;

    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];

    for (int i = 1; i <= n; i++)
        for (int j = v[i]; j <= m; j++)
            f[j] = max(f[j], f[j - v[i]] + w[i]);

    cout << f[m] << endl;

    return 0;
}

1.3. 多重背包问题

状态表示f[i][j

集合
所有只考虑前i个物品,且总体积不超过j的选法的集合;

属性:
最大值(背包中存放物品的最大价值)

状态计算:
通过第i个物品选多少个来划分f[i][j

请添加图片描述
例题:AcWing 4. 多重背包问题

多重背包朴素代码:

#include<iostream>
#include<algorithm>

using namespace std;

const int N=110;
int f[N][N];
int v[N] , w[N] ,s[N];
int n,m;

int main(){
    cin >> n >> m;

    for (int i = 1; i <= n; i++) cin >> v[i] >>w[i] >>s[i];

    for (int i = 1; i <= n; i++)
        for (int j = 0; j <= m; j++)
            for (int k = 0; k <= s[i] && k*v[i] <= j; k++){ //k要小于第i种物品的最大数量 同时也要保证能够装入
                f[i][j] = max(f[i][j], f[i - 1][j - k*v[i]] + k*w[i]);
            }

    cout << f[n][m] <<endl;
    return 0;
}

多重背包代码逻辑优化:

  • 朴素多重背包问题,按照类似完全背包问题的思考方式,时间复杂度为 O ( n   m   s ) O(n\,m\,s) O(nms),一旦问题规模超过 1 0 3 10^3 103,就会直接超时;

  • 通过二进制优化方法可以将原本的每种物品数量的遍历 O ( s ) O(s) O(s)转换为 O ( l o g s ) O(logs) O(logs),使得最终整个程序的时间复杂度为 O ( n   l o g s   m ) O(n\,logs\,m) O(nlogsm)

  • 二进制优化:将原本的s[i]转换一堆堆二进制数(如1,2,4,···),二进制位是从 2 0 2^0 20 开始的,因此通过这一堆二进制可以任意表示1-s中任意一个数,我们就可以将原本的多重背包问题转换为01背包问题,变成这一堆堆的二进制数选还是不选,时间复杂度就会变成 O ( l o g s ) O(logs) O(logs)

例题:多重背包问题 II

多重背包二进制优化代码:

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 12010, M = 2010;

int v[N], w[N];
int f[M];
int n, m;

int main() {
    cin >> n >> m;

    int cnt = 0;    //数组下标 类似于idx 表示当前拆分到了哪一个位置
    for (int i = 1; i <= n; i++) {
        int a, b, s;    //体积、价值和数量
        cin >> a >> b >> s;

        for (int k = 1; k <= s; k *= 2) {   //将第i类物品的数量s[i]拆成一堆一堆的二进制数
            cnt ++;
            v[cnt] = k * a;
            w[cnt] = k * b;
            s -= k;
        }

        if (s > 0) {    //若s没有被完全倍增,就额外自己单独形成一堆
            cnt ++;
            v[cnt] = s * a;
            w[cnt] = s * b;
        }
    }

    for (int i = 1; i <= cnt; i++)
        for (int j = m; j >= v[i]; j--)
            f[j] = max(f[j], f[j - v[i]] + w[i]);

    cout << f[m] << endl;

    return 0;
}

1.4. 分组背包问题

状态表示f[i][j

集合
所有只从前i组中选,且总体积不超过j的选法的集合;

属性:
最大值(背包中存放物品的最大价值)

状态计算:
通过第i个组物品中选了哪个来划分f[i][j

例题:AcWing 9. 分组背包问题

分组背包朴素代码:

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 110;

int v[N][N], w[N][N], s[N];     //s[i]表示第i组物品共有几类物品
int f[N];
int n, m;

int main() {
    cin >> n >> m;

    for (int i = 1; i <= n; i++) {
        cin >> s[i];
        for (int j = 1; j <= s[i]; j++) {
            cin >> v[i][j] >> w[i][j];
        }
    }

    for (int i = 1; i<= n; i++)
        for (int j = m; j >= 0; j--)
            for (int k = 0; k <= s[i]; k++)
                if (j >= v[i][k])
                    f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);

    cout << f[m] << endl;

    return 0;
}

2. 线性DP

状态表示f[][]初始化注意事项:

  • 需要注意在状态转移方程中有哪些状态需要用到但事先没有的,需要我们预处理一下

    • 如在最长上升子序列中需要在循环一开始令f[i] = i表明初始状态
    • 或者像状态转移方程中有i-1j-1出现的话,循环起始就要从1开始
  • 如果要找min,将f[][]初始化为INF;

  • 如果要找有负数的max,要初始化为-INF;

2.1. 数字三角形

请添加图片描述
例题:AcWing 898. 数字三角形

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 510, INF = 1e9;

int a[N][N];
int f[N][N];    //表示从起点走到(i,j)路径上数字之和的最大值
int n;

int main() {
    cin >> n;
    
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= i; j++)
            cin >> a[i][j];
    
    for (int i = 0; i <= n; i++)    //整数中可能存在负数,为了方便最小值的比较,将f(i,j)全都初始化为负无穷
        for (int j = 0; j <= n; j++)
            f[i][j] = -INF;
            
    f[1][1] = a[1][1];
    for (int i = 2; i <= n; i++)
        for (int j = 1; j <= i; j++)
            f[i][j] = max(f[i-1][j-1] + a[i][j], f[i-1][j] + a[i][j]);
    
    int res = -INF;
    for (int j = 1; j <= n; j++)    //遍历最后一行 找底层中的最大值
        if (f[n][j] > res) 
            res = f[n][j];
    
    cout << res << endl;
    
    return 0;
}

2.2. 最长上升子序列

状态表示:
f[i]表示以第i个数结尾的上升子序列的最大长度;

状态划分:
以上升子序列中第i个数的前一个数的位置进行划分

f[i] = max(f[j] + 1);   //(j = 0, 1, 2,..., i-2)

请添加图片描述
例题:AcWing 895. 最长上升子序列

最长上升子序列朴素代码:

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010;

int a[N];
int f[N];   //表示以第i个数结尾的上升子序列的最大长度
int n;

int main() {
    cin >> n;
    
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        f[i] = 1;   //只有i一个数(最小长度为1)
    }
        
    
    for (int i = 1; i <= n; i++) 
        for (int j = 0; j <= i - 1; j++)   
            if (a[j] < a[i])
                f[i] = max(f[i], f[j] + 1);
    
         
    
    int res = 1;
    for (int i = 1; i <= n; i++)    //找出最大值
        res = max(res, f[i]);
        
    cout << res << endl;
    
    return 0;
}

最长上升子序列优化代码 O ( n   l o g n ) O(n\,logn) O(nlogn)

  • 保证每一种长度的上升子序列的最后一个数(假设每一种长度的上升子序列最终只存取一个的情况下),是在相同长度的所有上升子序列中最小的,这样能够使得该长度的上升子序列适应性最强;

  • 假设当前的长度为i,那么就可以使得所有长度为i+1的上升子序列都可以用上这段长度为i的上升子序列(因为长度i+1的上升子序列其前i个数也是一个上升子序列,必然要从所有长度i的上升子序列选一个出来),从而能够产生以下现象:

  • 按长度从小到大排列的上升子序列的最后一个数之间是严格单调递增的,将不同长度的上升子序列的最小结尾数用q[n]存储;
    而想要找到以a[i]结尾的上升子序列的最大长度,只需要在q[n]中找到最大的小于a[i]的数q[k],然后将a[i]插入这个结尾数所代表的上升子序列中,使得以a[i]结尾的最长长度的就是k+1

例题:AcWing 896. 最长上升子序列 II

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 100010;

int a[N];
int q[N];   //存储长度为i的上升子序列结尾最小的数
int n;

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    
    int len = 0;        //表示上升子序列的最大长度 从长度0
    q[0] = -2e9;        //设置临界数据 方便最大值的比较
    for (int i = 1; i <= n; i++) {  //从上升子序列的结尾数中找出最大的小于a[i]的数 并将a[i]插入到这个子序列后面 更新q[len+1]的值
        int l = 0, r = len;
        while (l < r) {
            int mid = l + r + 1 >> 1;
            if (q[mid] < a[i]) l = mid; //从严格单调递增的q[]中找到最大的小于a[i]的数(找红色区域的右边界)
            else r = mid - 1;
        }
        
        len = max(len, r + 1);
        q[r + 1] = a[i];    //更新长度r+1的最小结尾数
        //因为若在严格单调递增的q[]中a[i]的最大最小数是q[r]的话,就说明此时q[r+1]大于a[i],那我们就可以更新其最小结尾数为a[i]
    }
    
    cout << len << endl;
    
    return 0;
}

2.3. 最长公共子序列

状态表示:
集合
f[i][j]表示所有A[1-i]B[1-j]的公共子序列的集合;

属性:
max(求最长公共子序列)

状态计算:
a[1-i]b[1-j]中各自序列的最后一个数在公共子序列中是否出现分为四种情况:

  1. a[i]与b[j]都不包含; f[i-1][j-1]

  2. a[i]不包含,b[j]包含; f[i-1][j]

  3. a[i]包含,b[j]不包含; f[i][j-1]

  4. a[i]与b[j]都包含; f[i-1][j-1]

其中第一种情况可以被第二、三种情况的方案覆盖;

请添加图片描述
例题:AcWing 897. 最长公共子序列

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010;

int n, m;
int f[N][N];    //f[i][j]表示从a[1-i]与b[1-j]的公共子序列集合中的最大值
char a[N], b[N];

int main() {
    cin >> n >> m >> a + 1 >> b + 1;
    
    // for(int i=1;i<=n;i++)
    //     cin >> a[i];

    // for(int i=1;i<=m;i++)
    //     cin >> b[i];
    
    for (int i = 1; i <=n; i++)
        for (int j = 1; j <= m; j++) {
            f[i][j] = max(f[i-1][j], f[i][j-1]);
            if (a[i] == b[j])
                f[i][j] = max(f[i][j], f[i-1][j-1] + 1);
        }
    
    cout << f[n][m] << endl;
    
    return 0;
}

2.4. 最短编辑距离

1. 状态表示:
f[i][j]表示将a[1~i]变为b[1~j]的所有操作方式的操作次数的最小值;

2. 状态计算:
a[1~i]变为b[1~j]的最后一步操作,划分为对a[i]的删除、添加、修改;

  • 先让a[1~i-1]变成b[1~j],再将a[i]删除:

  • 先让a[1~i]变成b[1~j-1],再在a[i]后面插入b[j]

  • 先让a[1~i-1]变成b[1~j-1],若a[i] != b[j],再将a[i]修改为b[j];

三者取最小值,在循环过程中a[]就会自动选择操作次数最小的对应操作,最终自己找到最优解;

请添加图片描述
例题:AcWing 902. 最短编辑距离

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010;

int f[N][N];    //表示a[1~i]变为b[1~j]的所有操作方式的最少操作次数
char a[N], b[N];
int n, m;

int main() {
    cin >> n >> a + 1;
    cin >> m >> b + 1;
    
    //f[][]边界预处理
    for (int i = 1; i <= n; i++) f[i][0] = i;   //删除操作 从a的i个字符变成b的0个字符 只能进行i次删除
    for (int i = 1; i <= m; i++) f[0][i] = i;   //插入操作 从a的0个字符变成b的i个字符 只能进行i次插入
    
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            f[i][j] = min(f[i-1][j] + 1, f[i][j-1] + 1);
            if (a[i] == b[j]) f[i][j] = min(f[i][j], f[i-1][j-1]);
            else f[i][j] = min(f[i][j], f[i-1][j-1] + 1);
        }
    }
        
    cout << f[n][m] << endl;
    
    return 0;
}

2.5. 编辑距离

  • 可以看成二维最短编辑距离;
  • 将多个字符串用二维数组存储在一起,然后再将两个字符串之间最短编辑距离的计算单独抽象成一个函数;

例题:AcWing 899. 编辑距离

#include<iostream>
#include<algorithm>
#include<cstring>

using namespace std;

const int N = 1010, M = 12;

char a[N][M];     //二维数组a用于存储多个字符串,b用于存储代询问字符
int f[M][M];
int n, m;

int judeg(char a[], char b[]) { //计算从a到b的最短编辑距离
    int la = strlen(a + 1), lb = strlen(b + 1);     //返回字符串长度,遇到空字符'\0'停止(不计入长度)
    
    for (int i = 1; i <= la; i++) f[i][0] = i;
    for (int i = 1; i <= lb; i++) f[0][i] = i;
    
    int res = 0;
    for (int i = 1; i <= la; i++) {
        for (int j = 1; j <= lb; j++) {
            f[i][j] = min(f[i-1][j] + 1, f[i][j-1] + 1);
            
            if (a[i] == b[j]) f[i][j] = min(f[i][j], f[i-1][j-1]);
            else f[i][j] = min(f[i][j], f[i-1][j-1] + 1);
            //f[i][j] = min(f[i][j], f[i - 1][j - 1] + (a[i] != b[j])); //合二为一
        }
    }
    
    return f[la][lb];
}

int main() {
    cin >> n >> m;
    
    for (int i = 1; i <= n; i++)
        cin >> a[i] + 1;    //a[][]中a[]是某一行的起始地址, +1表示从下标1开始输入
    
    while (m--) {
        int k;      //操作次数上限和待查询字符串
        char b[M];
        cin >> b + 1 >> k;
        
        int res = 0;    //满足条件的字符串个数
        for (int i = 1; i <= n; i++) {
            if (judeg(a[i], b) <= k) 
                res++;
        }
        
        cout << res << endl;
    }
    
    return 0;
}

3. 区间DP

3.1 石子合并

状态表示:
集合
f[i][j]表示将[i,j]合并为一堆的方案的集合;

属性:
最小值(最小合并代价)

状态计算:
把形成f[i][j]前的最后一次合并的左区间的最后一个数k的分布当成划分状态依据;遍历所有k的可能取值就能够得到集合的最小值

f[i][j] = min(f[i][j], f[i][k] + f[k+1][j] + s[j] - s[i-1]);

例题:AcWing 282. 石子合并

#include<iostream>
#include<algorithm>

using namespace std;

const int N=310;
int f[N][N];    //表示将[i,j]合并为一堆的方案的集合
int s[N];       //前缀和数组
int n;

int main() {
    cin >> n;

    for (int i = 1;i <= n; i++){
        cin >> s[i];
        s[i] += s[i-1];  //石子的质量的前缀和数组
    }

    for (int len = 2; len <= n; len++){  //按区间长度从小到大枚举 长度为1无法合并 直接从2开始
        for (int i = 1; i + len - 1 <= n; i++){  //左端点
            int j = i + len - 1;    //右端点
            f[i][j] = 1e9;  //初始化成无穷大 以便于寻找最小值
            for (int k = i; k <= j - 1; k++){
                f[i][j] = min(f[i][j], f[i][k] + f[k+1][j] + s[j] - s[i - 1]);
            }
        }
    }

    cout << f[1][n] <<endl;

    return 0;
}

4. 计数类DP

4.1. 整数划分

整数划分从完全背包思想入手:

将整数划分问题看成完全背包问题:
背包容量是n,物品的种类共有n种,其体积分别是1、2、3、…、n,每种物品都可以无限放;

1. 状态表示:
集合:f[i][j]表示所有只考虑1~i,且总体积恰好是j的选法;
属性:数量;
(只考虑用1~i拼凑出j的所有选法的数量)

2. 状态计算:
f[i][j]考虑成i-1选了多少个进行划分;

f[i][j] = f[i-1][j] + f[i-1][j-i] + f[i-1][j-2i] + ... + f[i-1][j-si];
f[i][j-i] = f[i-1][j-i] + f[i-1][j-2i] + ... + f[i-1][j-si];

f[i][j] = f[i-1][j] + f[i][j-i];

请添加图片描述

例题:AcWing 900. 整数划分

完全背包问题朴素解法:

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010, M = 1e9 + 7;

int f[N][N];    //只考虑用1~i拼凑出j的所有选法的数量
int n;

int main() {
    cin >> n;
    
    //处理边界 虽然题目中没有涉及到0 但由于状态转移方程中有j-i出现 所以也要从实际角度出发考虑边界0
    for (int i = 0; i <= n; i++) f[i][0] = 1;   //0就表示一个数都不选 也代表一种方案
    
    for (int i = 1; i <= n; i++)
        for (int j = 0; j <= n; j++) {
            f[i][j] = f[i-1][j];
            if (j >= i)
                f[i][j] = (f[i-1][j] + f[i][j-i]) % M;
        }
            
    cout << f[n][n] << endl;
    
    return 0;
}

完全背包优化解法:

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010, M = 1e9 + 7;

int f[N];    //只考虑用1~i拼凑出j的所有选法的数量
int n;

int main() {
    cin >> n;
    
    //处理边界 边界预处理
    f[0] = 1;   //0就表示一个数都不选 也代表一种方案
    
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++)
            f[j] = (f[j] + f[j-i]) % M;
        
    cout << f[n] << endl;
    
    return 0;
}

从集合思想出发分析:

1. 状态表示:
集合:f[i][j]表示总和为i,构成数为j的所有方案;
属性:数量;

2. 状态计算:
f[i][j]方案中的最小值是否为1进行划分;

f[i][j] = f[i-1][j-1] + f[i-j][j];
  • f[i-1][j-1]:所有方案同时减去一个最小值1,总个数j1个,总和i1
  • f[i-j][j]:方案中的所有数都减1,因为最小值大于1所以减完之后不会有数变成0,总和i-j,总个数j不变;

请添加图片描述

#include<iostream>
#include<algorithm>

using namespace std;

const int N = 1010, M = 1e9 + 7;

int f[N][N];  //表示所有总和为i,构成数数量为j的方案的数量
int n;

int main() {
    cin >> n;

    f[0][0] = 1;    //0边界只有一种情况
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= i; j++)    //j <= i 因为i不可能由超过i个数构成 如f[0][1]不合法
            f[i][j] = (f[i-1][j-1] + f[i-j][j]) % M;
    
    int res = 0;
    for (int i = 1; i <= n; i++)
        res = (res + f[n][i]) % M;  //加起来后还要再做一次取模,因为加完之后可能会超模
    
    cout << res << endl;
    
    return 0;
}

5. 数位统计DP

5.1. 计数问题

数位统计DP最重要的是分类讨论:

设计出count(n,x)函数:能够计算出1~nx出现的次数(x范围为0~9);

求解给定ab两个数之间x的出现次数:count(b, x) - count(a - 1, x)

以所有数第4位上x的出现次数为例:

  1. x > 0

1.jpg

  1. x = 0时,第一种情况(第四位前面的数从001开始)需要进行特判,第二种情况不变

2.jpg

边界情况:

在代码循环过程中,需要额外注意一些边界特殊情况;

  1. 枚举最高位上x的出现次数时,由于最高位前面没有数字因此不用考虑第一种情况;
    第一种情况的次数计算进入条件:if (i < n - 1)

  2. 当判断的数字x = 0

    • 需要注意最高位由于不能为0,因此枚举n的每一位时只能从次高位开始枚举;

    • 此外,由于没有前导零的存在,x = 0的前面数范围不能从000开始只能从001开始,用1来占位,因此001会比000的数据范围少一个10^i

请添加图片描述

例题:AcWing 338. 计数问题

#include<iostream>
#include<algorithm>
#include<vector>
#include<cmath>
using namespace std;
int n;

int get_spec_len(vector<int> a, int l, int r) { //将n的第l位到第r位取出
    int res = 0;
    for (int i = r; i >= l; i--)    //从最高位开始乘十
        res = res * 10 + a[i];
    return res;
}

int count(int n, int x) {   //计算从1-n中数字x出现的次数
    if (!n) return 0;   //因为计算方程中出现a-1, 需要处理好边界0; 当n=0时没有意义直接返回0

    vector<int> a;
    
    while (n) {     //取出n的每一位十进制数,最低位放在a[0],最高位放在a[n-1]
        a.push_back(n % 10);
        n /= 10;
    }
    
    n = a.size();   //n的总位数
    
    int res = 0;
    for (int i = n - 1 - !x; i >= 0; i--) {  //枚举每一位上x出现的次数; 当x=0时只能从次高位开始枚举
        if (i < n - 1) {    //根据i前面的数进行讨论; 最高位跳过该步
            res += get_spec_len(a, i + 1, n - 1) * pow(10, i);
            if (!x) res -= pow(10, i);    //当x为0时, 只能从001开始会比000开始少一种情况
        }
        
        if (a[i] == x) {    //对于当前位是否为x进行讨论
            res += get_spec_len(a, 0, i - 1) + 1;
        }else if(a[i] > x){
            res += pow(10, i);
        }
    }
    
    return res;
}

int main() {
    int a, b;
    
    while (cin >> a >> b, a || b) {
        if (a > b) swap(a, b);  //输入的数据不一定a<b, 而使用前缀和公式需要a<b形式
        
        for (int i = 0; i <= 9; i++) 
            cout << count(b, i) - count(a - 1, i) << " ";
        
        cout << endl;
    }
    
    return 0;
}

6. 状态压缩DP

6.1. 蒙德里安的梦想

状态压缩DP思路:

如果先把横的都放完,这时候剩下的竖着放只有一种方法;

所以棋盘小方格放法的总方案数就等所有横着放合法的方案数;

如何判断此时横着放的方案是否合法(整个棋盘都能被小方格填满):看棋盘所有剩余位置,能否填充满竖着的小方块;从每一列看过去,每一列内部所有连续的空着的小方块,是否是偶数个(这样才能够用1×2的小方块填充满且只有一种摆放的方法);

1. 状态表示:
f[i][j]表示已经将前i-1列都摆好,且从第i-1列伸出到第i列的状态是j的所有方案;

2. 状态计算:
由于第i-1列共有n行,每一行的不同状态(每一行伸或者不伸出去)可以组合成2^n种情况;

如状态j = 00100表示只有第三行的i-1列伸出去到i列,其它行都没有伸出来;

因此每种状态可以划分为2^n种状态子集,用于表示每行中i-1列是否有伸出去到i列;

f[i][j]是在f[i-1][k]的基础上进行延申的;f[i][j]可以在f[i-1][k]的第i-1列的空白位置进行延申;因此f[i][j]的所有方案数可以通过尝试遍历所有的f[i-1][k],尝试是否可以在i-2i-1列的k状态基础上进行i-1列到i列的j状态延申(即检查这样的状态续接是否合法);

for (int i = 1; i <= m; i ++ )
    for (int j = 0; j < 1 << n; j ++ )
        for (int k = 0; k < 1 << n; k ++ )
            if (检查从k到j的状态续接是否合法)
                f[i][j] += f[i - 1][k];

如何保证最后方案是合法的?

  1. i-1列必须是在第i-2列所在行没有伸出去的基础上才能伸出去,不然就会导致方格重叠;即f[i-1][k] & f[i][j] = 0两个相邻列不能在同一行伸出去,才能合法地继续选择小方格下去;

  2. 因为f[i][j]表示的是前i-1列已经摆好(小方块摆放位置已经固定),那么前i-1列都要合法,即每一列的状态需要:列中的所有空着的位置的长度必须是偶数,即f[i-1][k] | f[i][j]不能存在连续的奇数0;
    f[i-1][k] | f[i][j]表示此时第i列的状态,也可以表示第i列中真正空着的位置,即第i列中没有小方块伸进来也没有小方块伸出去的位置)

if ((j & k) == 0 && st[j | k])
    f[i][j] += f[i - 1][k];

代码注意事项:

  1. 因为f[i][j]记录的是有几种方案可以形成当前的状态j,而f[i][j]的状态转移方程又是以所有状态数M进行划分的,状态的方式是用十进制去表示二进制,每一位十进制上只能放0或1,多少行决定有多少位,如00100就表示只有第三行伸出去了,题目中的行最多可以有11行,所以需要存储11位十进制;
    int类型的数据范围到不了 1 0 11 10^{11} 1011,因此需要用long long类型的数据存储;

  2. 进行DP状态转移方程计算的时候,要注意第一层列循环,要从第2列开始(因为第1列前面没有列),到第m+1列结束(由于状态的定义要多往外枚举一列,f[m][]表示前m列已经摆好(f从0下标开始))

例题:AcWing 291. 蒙德里安的梦想

状态压缩朴素DP代码:

#include<iostream>
#include<cstring>
#include<algorithm>
typedef long long ll;
using namespace std;
const int N = 15, M = 1 << 11;

ll f[N][M]; //状态有11位十进制,int不够存
bool st[M]; //表示每一种列状态的01分布是否合法
int n, m;

int main() {
    while (cin >> n >> m, n | m) {
        //预处理st[i] 遍历每一种二进制状态是否合法(连续的0是否是偶数个)
        for (int i = 0; i < (1 << n); i++) {   
            int cnt = 0;    //当前段连续0的个数
            st[i] = true;
            
            for (int j = 0; j < n; j++) {   //遍历二进制状态中的每一位(也可以看成检查列中的每一行)
                if ((i >> j) & 1) { //取出i的第j位数 检查是否为1 遇到1就可以检查上一段连续0的个数
                    if (cnt & 1) {  //若cnt为奇数 表示有奇数个连续的0
                        st[i] = false;
                    }
                    cnt = 0;    //cnt清0 重新计算下一段连续0的个数
                }
                else cnt++;  //遇到0计数加一
            }
            
            if (cnt & 1) st[i] = false; //最后一段连续0计数完成之后 再检查一次 
        }
        
        memset(f, 0, sizeof f);
        f[0][0] = 1;
        for (int i = 1; i <= m; i++) //从第2列开始 到第m+1列结束
            for (int j = 0; j < (1 << n); j++)
                for (int k = 0; k < (1 << n); k++)
                    if ((j & k) == 0 && st[j | k])
                        f[i][j] += f[i-1][k];
        
        cout << f[m][0] << endl;
        
    }
    
    return 0;
}

状态压缩优化思路:
事先预处理存储好所有合法状态,枚举出第i-1 列所有状态中可以合法地转移到第i列状态j的状态,减少状态计算的第三重循环的枚举数,从而不用去枚举所有的状态再进行判断;

状态压缩优化DP代码:

#include<iostream>
#include<cstring>
#include<algorithm>
#include<vector>
typedef long long ll;
using namespace std;
const int N = 15, M = 1 << 11;

ll f[N][M]; //状态转移方程可以划分为M种子状态 而状态有11位十进制,int不够存
bool st[M]; //表示每一种列状态的01分布是否合法
vector<ll> state[M]; //存储每一种状态的合法子状态
int n, m;

int main() {
    while (cin >> n >> m, n | m) {
        //预处理st[i] 遍历每一种二进制状态是否合法(连续的0是否是偶数个)
        for (int i = 0; i < (1 << n); i++) {   
            int cnt = 0;    //当前段连续0的个数
            st[i] = true;
            
            for (int j = 0; j < n; j++) {   //遍历二进制状态中的每一位(也可以看成检查列中的每一行)
                if ((i >> j) & 1) { //取出i的第j位数 检查是否为1 遇到1就可以检查上一段连续0的个数
                    if (cnt & 1) {  //若cnt为奇数 表示有奇数个连续的0
                        st[i] = false;
                    }
                    cnt = 0;    //cnt清0 重新计算下一段连续0的个数
                }
                else cnt++;  //遇到0计数加一
            }
            
            if (cnt & 1) st[i] = false; //最后一段连续0计数完成之后 再检查一次 
        }
        
        //预处理好所有状态的合法子状态
        for (int i = 0; i < (1 << n); i++) {
            state[i].clear();
            for (int j = 0; j < (1 << n); j++) {
                if ((i & j) == 0 && st[i | j])
                    state[i].push_back(j);
            }
        }
            
        memset(f, 0, sizeof f);
        f[0][0] = 1;
        for (int i = 1; i <= m; i++) //从第2列开始 到第m+1列结束
            for (int j = 0; j < (1 << n); j++)
                for (auto k : state[j]) //增强for循环 自动枚举状态j的所有合法子状态
                        f[i][j] += f[i-1][k];
        
        cout << f[m][0] << endl;
        
    }
    
    return 0;
}

6.2. 最短Hamilton图

1. 状态表示
集合:
f[i][j]表示所有从0走到j,走过的所有点是i的所有路径;

(这里将走过的所有点压缩成ii可以看成一种状态,用二进制表示,1或0以及对应的位序表示该结点编号是否已经走过)

属性:
最小值(最短Hamiltion路径)

2. 状态划分
f[i][j]中到达j的前驱结点k进行划分,根据前驱结点的种类进行分类;

由于kj的上一个结点,那么上一步的路径状态就要从状态i回溯回去,由于j是最后一个结点,因此只需在i中把结点j剔除即可,即上一步的路径状态为:i - {j};

f[i][j] = min(f[i - {j}][k] + w[k][j]);

代码注意事项:

  1. 如何将i的二进制表示形式中的第j位修改为0?

    i - (1 << j)

  2. 求的是状态最小值,因此需要先将所有状态初始化为最大值,然后将起点f[1][0]设为0,从初始状态第一个点的最小值慢慢收敛到全局状态都是最小值状态;

(f[1][0]表示从起点到起点,走过的路径为只有第零个点0···1,也就是初始状态)

memset(f, 0x3f, sizeof f);
f[1][0] = 0;
  1. 在进程的虚拟空间中,静态变量或全局变量通常存储在堆中,而函数内部定义的变量通常存储在栈中,C++默认的栈空间大小为4MB;所以若把诸如int f[M][N]; 这样的变量数组存储在函数内部就会发生内存溢出,所以通常都会把这样的大变量大数组放在函数体外部;

例题:AcWing 91. 最短Hamilton路径

最短Hamiltion图状态压缩代码:

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 21, M = 1 << 20;

int f[M][N];    //f[i][j]表示从起点走到j, 所有经过的点为i的路径权重最小值
int w[N][N];
int n;

int main() {
    cin >> n;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            cin >> w[i][j];
    
    memset(f, 0x3f, sizeof f);
    f[1][0] = 0;
    for (int i = 0; i < (1 << n); i++) {
        for (int j = 0; j < n; j++) {
            if ((i >> j) & 1)   //i中需要有走过j结点才行
                for (int k = 0; k < n; k++) {   //枚举所有可能的前驱结点
                    if ((i - (1 << j)) >> k & 1)    //前驱状态需要走过k结点才合法
                        f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);
                }  
        }
    }
    
    cout << f[(1 << n) - 1][n - 1] << endl;
    return 0;
}

7. 树型DP

7.1. 没有上司的舞会

1. 状态表示

集合:
f[u][0]表示所有从以u为根的子树中选择,并且不选u这个点的方案;
f[u][1]表示所有从以u为根的子树中选择,并且选u这个点的方案;

属性:
max(树中的最大权值之和)

2. 状态计算:

  • 当不选u为这个点的时候,子树的根结点选或不选都可以,看哪个情况的权值更大,把所有子树的max(f[u][0], f[u][2])之和求出来,就是以u为根结点的树的最大权值;

  • 当选了u这个点的时候,其孩子结点就不能够再选,因此子树的根结点都要是0;

f[u][0] = max(f[s1][0], f[s1][1]) + max(f[s2][0], f[s2][1]) + ... ;
f[u][1] = f[s1][0] + f[s2][0] + ... + happy[u] ;

代码注意:

  1. 如何找到根结点的结点编号?

虽然题目在说明结点的父子关系时,结点编号是不固定的随机的,但是可以利用额外的布尔数组来记录每个结点是否有父结点;只需按序遍历结点编号,找到那个没有父结点的,那就肯定是根结点;

int root = 1;   
while (has_father[root]) 
    root ++;

例题:AcWing 285. 没有上司的舞会

具体代码:

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 6010;

int happy[N];
int f[N][2];
int h[N], ne[N], e[N], idx;
bool has_father[N];
int n;

void add(int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

void dfs(int u) {
    f[u][1] = happy[u];    //初始化状态
    
    for (int i = h[u]; i != -1; i = ne[i]) {
        int j = e[i];
        dfs(j);
        
        f[u][0] += max(f[j][0], f[j][1]);
        f[u][1] += f[j][0] ;
    }
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> happy[i];
    
    memset(h, -1, sizeof h);
    for (int i = 1; i < n; i++) {
        int a, b;
        cin >> a >> b;
        has_father[a] = true;
        add(b, a);  //父结点指向子结点
    }
    
    int root = 1;   //找到根结点的结点编号
    while (has_father[root]) root ++;
        
    dfs(root);
    
    cout << max(f[root][0], f[root][1]) << endl;
    return 0;
}

8. 记忆化搜索

动态规划用递归的方式编写,这里以滑雪问题为例;

8.1. 滑雪

1. 滑雪问题状态表示:
集合
f[i][j]表示所有从(i, j)开始滑的路径;

属性
最大值(滑雪的最大路径长度)

2. 滑雪问题状态划分
(i, j)为起点,下一步可以分别走上下左右方向,那么就可以根据下一步的方向把f[i][j]分为四种情况取最值;

f[i][j] = max(f[i][j+1] + 1, f[i+1][j] + 1, f[i][j-1] + 1, f[i-1][j] + 1);

注意事项:

  • 下一步选的点需要比上一步点小,需要先进行一下判断再走下一步
if (a <= n && a >= 1 && b <= m && b >= 1 && h[a][b] < h[x][y])
  • 因为是一直走下去的没有折回,路径不能出现环,所以每次走下一步只能走之前没有走过的点;
if (f[x][y] != -1) return f[x][y]; 

例题:AcWing 901. 滑雪

滑雪问题记忆化搜索代码:

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 310;
int f[N][N];    //表示从(i,j)开始滑的最长滑雪长度
int h[N][N];
int n, m;

int dp(int x, int y) {
    //int &v = f[x][y]; //将f[x][y]另命名为v 通过引用符&将v与f[x][y]直接挂钩(更为简便的写法)
    if (f[x][y] != -1) return f[x][y];  //若f[x][y]已经被计算过 就直接返回(为了避免出现环)
    
    f[x][y] = 1;  //初始化 只滑过自己一个点
    int dx[4] = {1, 0, -1, 0}, dy[4] = {0, -1, 0, 1};
    for (int i = 0; i < 4; i++) {
        int a = x + dx[i], b = y + dy[i];   //上下左右后的坐标
        if (a <= n && a >= 1 && b <= m && b >= 1 && h[a][b] < h[x][y])
            f[x][y] = max(f[x][y], dp(a, b) + 1);
    }
    
    return f[x][y]; 
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++)
            cin >> h[i][j];
            
    memset(f, -1, sizeof f);    //滑雪长度肯定是正整数 初始值设为-1就行
    int res = 0;    
    for (int i = 1; i <= n; i++)    //遍历从每一个点出发后的最大滑雪长度
        for (int j = 1; j <= m; j++)
            res = max(res, dp(i, j));
        
    cout << res << endl;
    return 0;
}

Reference

  1. 动态规划 - OI Wiki
  2. yxc背包九讲问题
  3. dd大牛的《背包九讲》
  4. 【DP专辑】ACM动态规划总结
  5. Acwing代码汇总

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1500848.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

下载一些ROS的包的方式

ROS Index 我们可以去ROS Index网站下载一些我们需要的包。打开浏览器在网址框输入index.ros.org。或者点击此处链接ROS Index 在这个网站中我们可以浏览并找到我们需要的包&#xff0c;也可以下载它的源代码或者仅安装到我们的系统中来使用。&#xff08;安装过程在终端中进行…

LeetCode的使用方法

LeetCode的使用方法 一、LeetCode是什么&#xff1f;1.LeetCode简介2.LeetCode官网 二、LeetCode的使用方法1.注册账号2.力扣社区力扣编辑器 2.1 讨论发起讨论参与讨论关注讨论 2.2 文章撰写文章关注文章 3.力扣面试官版测评面试招聘竞赛 4.力扣学习LeetBook 书架我的阅读猜您喜…

【PyTorch】进阶学习:探索BCEWithLogitsLoss的正确使用---二元分类问题中的logits与标签形状问题

【PyTorch】进阶学习&#xff1a;探索BCEWithLogitsLoss的正确使用—二元分类问题中的logits与标签形状问题 &#x1f308; 个人主页&#xff1a;高斯小哥 &#x1f525; 高质量专栏&#xff1a;Matplotlib之旅&#xff1a;零基础精通数据可视化、Python基础【高质量合集】、Py…

智能指针基础知识【C++】【RAII思想 || unique_ptr || shared_ptrweak_ptr || 循环引用问题】

目录 一&#xff0c;为什么需要智能指针 二&#xff0c;内存泄露的基本认识 1. 内存泄露分类 2. 常见的内存检测工具 3&#xff0c;如何避免内存泄露 三&#xff0c;智能指针的使用与原理 1. RAII思想 2. 智能指针 &#xff08;1. unique_ptr &#xff08;2. shared_…

【重制版】WSDM 2024 2023时空时序论文总结

&#x1f31f;【紧跟前沿】“时空探索之旅”与你一起探索时空奥秘&#xff01;&#x1f680; 欢迎大家关注时空探索之旅 WSDM 2024于2024年3月4日-3月8日在墨西哥梅里达&#xff08;Mrida, Mxico&#xff09;正在举行。目前官网已经放出了所有被录用论文的表单&#xff08;链接…

2024037期传足14场胜负前瞻

2024037期售止时间为3月9日&#xff08;周六&#xff09;20点00分&#xff0c;敬请留意&#xff1a; 本期深盘多&#xff0c;1.5以下赔率4场&#xff0c;1.5-2.0赔率5场&#xff0c;其他场次是平半盘、平盘。本期14场整体难度中等。以下为基础盘前瞻&#xff0c;大家可根据自身…

干货 | MSC细胞培养 “秘籍”

MSC培养细节&#xff0c;这里有您想知道的~ MSC&#xff1a;间充质干细胞&#xff0c;是一群贴壁生长、形态类似于成纤维细胞的多能成体干细胞&#xff0c;存在于脐带、骨髓和脂肪组织等多种组织中&#xff0c;并且可以分化成多种不同的组 实验数据分享 1、样本&#xff1a;冻…

ChatGLM:CPU版本如何安装和部署使用

前段时间想自己部署一个ChatGLM来训练相关的物料当做chatgpt使用&#xff0c;但是奈何没有gpu机器&#xff0c;只能使用cpu服务器尝试使用看看效果 我部署的 Chinese-LangChain 这个项目&#xff0c;使用的是LLM&#xff08;ChatGLM&#xff09;embedding(GanymedeNil/text2vec…

Pytorch基础:Tensor的flatten方法

相关阅读 Pytorch基础https://blog.csdn.net/weixin_45791458/category_12457644.html?spm1001.2014.3001.5482 在Pytorch中&#xff0c;flatten是Tensor的一个重要方法&#xff0c;同时它也是一个torch模块中的一个函数&#xff0c;它们的语法如下所示。 Tensor.flatten…

达梦数据库——如何查看数据库大字段中的数据内容

今天get到一个小知识点 分享给大家&#xff0c;如何在数据库查看大字段中的数据内容。 以下为演示步骤&#xff0c;简单易懂&#xff0c;操练起来吧 首先创建一个含有CLOB、TEXT的大字段测试表 create table "SYSDBA"."CS"("COLUMN_1" CLOB,&qu…

JavaScript极速入门(2)

JQuery W3C标准给我们提供了一系列函数,让我们可以操作: 网页内容 网页结构 网页样式 但是原生的JavaScript提供的API操作DOM元素时,代码比较繁琐,冗长.我们学习使用JQuery来操作页面对象. JQuery是一个快速,简洁且功能丰富的JavaScript框架,于2006年发布.它封装JavaScript常…

干货!Python函数中的参数类型

1.必须参数 调用函数的时候&#xff0c;必须以正常的顺序传参&#xff0c;实参的数量和形参的数量保持一致 def demo(name, age):print("我的姓名是&#xff1a;%s, 年龄是&#xff1a;%d"%(name, age))demo("张三", 22) # 我的姓名是&#xff1a;张三…

黑马点评-发布探店笔记

探店笔记 探店笔记类似点评网站的评价&#xff0c;往往是图文结合。 对应的表有两个&#xff1a; tb_blog&#xff1a;探店笔记表&#xff0c;包含笔记中的标题、文字、图片等 tb_blog_comments&#xff1a;其他用户对探店笔记的评价 流程如下&#xff1a; 上传接口&#…

pytest测试框架使用基础07 fixture—parametrize获取参数的几种常用形式

【pytest】parametrize获取参数的几种常用形式: a.数据结构 b.文件 c.数据库 d.conftest.py配置一、直接在标签上传参 1.1 一个参数多个值 pytest.mark.parametrize("参数", (参数值1, 参数值2, 参数值3))示例&#xff1a; import pytest # 单个参数的情况 pytest.…

枚举 --java学习笔记

什么是枚举 枚举是一种特殊类 格式&#xff1a; 修饰符 enum 枚举类名{ 名称1&#xff0c;名称2&#xff0c;...&#xff1b; //枚举类的第一行必须罗列的是枚举对象的名字 其他成员... } 枚举类的第一行只能罗列一些名称&#xff0c;这些名称都是常量&#xff0c;…

[C++]类和对象,explicit,static,友元,构造函数——喵喵要吃C嘎嘎4

希望你开心&#xff0c;希望你健康&#xff0c;希望你幸福&#xff0c;希望你点赞&#xff01; 最后的最后&#xff0c;关注喵&#xff0c;关注喵&#xff0c;关注喵&#xff0c;大大会看到更多有趣的博客哦&#xff01;&#xff01;&#xff01; 喵喵喵&#xff0c;你对我真的…

Python数据处理实战(4)-上万行log数据提取并作图进阶版

系列文章&#xff1a; 0、基本常用功能及其操作 1&#xff0c;20G文件&#xff0c;分类&#xff0c;放入不同文件&#xff0c;每个单独处理 2&#xff0c;数据的归类并处理 3&#xff0c;txt文件指定的数据处理并可视化作图 4&#xff0c;上万行log数据提取并作图进阶版&a…

Vue组件中的scoped属性

Vue组件中的scoped属性的作用是&#xff1a;当前的单文件组件的css样式只用于当前组件的template模板&#xff0c;在Vue脚手架汇总组件间关系时避免样式命名重复的情况。 原理&#xff1a;使用data-*属性在template模板中使用样式的HTML元素上添加额外属性&#xff0c;再利用标…

(sub)三次握手四次挥手

TCP的三次握手与四次挥手理解及面试题 序列号seq&#xff1a;占4个字节&#xff0c;用来标记数据段的顺序&#xff0c;TCP把连接中发送的所有数据字节都编上一个序号&#xff0c;第一个字节的编号由本地随机产生&#xff1b;给字节编上序号后&#xff0c;就给每一个报文段指派一…

即插即用篇 | YOLOv8 引入 ParNetAttention 注意力机制 | 《NON-DEEP NETWORKS》

论文名称:《NON-DEEP NETWORKS》 论文地址:https://arxiv.org/pdf/2110.07641.pdf 代码地址:https://github.com/imankgoyal/NonDeepNetworks 文章目录 1 原理2 源代码3 添加方式4 模型 yaml 文件template-backbone.yamltemplate-small.yamltemplate-large.yaml