zoukankan      html  css  js  c++  java
  • Day 3 上午

    内容提要:

    动态规划

    数位DP

    树形DP

    区间DP


    动态规划

    斐波那契数列
    f(0)=0,f(1)=1,......,f(n)=f(n-1)+f(n-2)
    0,1,1,2,3,5,8,13......
    他和动态规划有什么关系?
    首先,他有一个边界条件,就是f(0)=0,f(1)=1,相当于它不是从正无穷到负无穷都有定义的数列
    像这种规定好的条件,我们把它叫做 边界条件(不依赖其他值就能直接得到)
    f(n)=f(n-1)+f(n-2)
    f(n)的值依赖于前两项斐波那契数列的值
    这个式子就叫做动态规划中的 转移方程
    每一个f(0),f(1),....,f(n)就叫做动态规划中的 状态
    无后效性等价于有向无环图(DAG)这里先暂时不提
    对于一个动态规划题,我们只需要确定状态,边界条件,转移方程,剩下的就只剩下写代码了

    有三种不同的写法
    1.顺着推
    2.倒着推
    (名字起得好随意,其实就是递推和递归)
    3.记忆化搜索

     

    int n,f[233];
    
    int main()
    {
        cin>>n;
        f[0]=0;
        f[1]=1;
        for(int a=2;a<=n;a++)
        {
            f[a]=f[a-1]+f[a-2];
        }
        cout<<f[n];
    }

     

    数组f代表状态,f(i)代表第i项
    先定义边界状态
    然后写转移方程
    这是逆着推

    for(int a=0;a<n;a++)
    {
        f[a+1]+=f[a];
        f[a+2]+=f[a];
    }
    cout<<f[n];


    顺着推
    想f(a)会影响那些斐波那契的值

    int dfs(int n)
    {
        if(n==0) return 0;
        if(n==1) return 1;
        return dfs(n-1)+dfs(n-2);
    }
    int main()
    {
        cin>>n;
        cout<<dfs(n)<<endl;
        return 0;
    }

    逆着推是当你算f(a)的时候,你要去算他
    顺着推是当你算好f(a)的时候,你用f(a)去更新别人
    两种写法在某些时候会有复杂度的区别

    记忆化搜索
    记忆化搜索的核心是搜索

    以斐波那契数列为例:

    如果我们直接搜索,搜到1就返回1,搜到0就返回0的话,这个代码的复杂度是f(f(n)),因为f(n)是由一个一个f[1]=1累加起来的

    斐波那契通项公式是指数级的,所以当n很大时,复杂度就gg

    记忆化:

    int f[n];
    
    bool suan_le_mei[n];
    
    int dfs(int n)
    {
        if(n==0) return 0;
        if(n==1) return 1;
        if(suan_le_mei[n]) return f[n];
        
        suan_le_mei[n]=true;
        f[n]=dfs(n-1)+dfs(n-2);
    }
    int main()
    {
        cin>>n;
        cout<<dfs(n)<<endl;
        return 0;
    }


    因为当你算f(n)时要算f(n-2),算f(n-1)时也要算f(n-2),但是由于f(n-2)的值不变,所以它重复了
    记忆化搜索就是保证每一个f(n)在搜索中只被算一次
    所以我们两个数组
    一个表示第i项的值
    一个表示第i项有没有被算过
    为了避免重复计算,我们要先看它有没有被算过
    一旦算过就直接返回这一项的值
    否则就先把它标记为算过,再dfs前两项计算这一项的值
    这样写的话复杂度就变成了O(n),因为每个值只被算了一次
    一般来说它用不上,但是要知道怎么写
    矩阵优化的话复杂度就变成了O(log n) 链接

    常见的动规种类:
    数位DP
    树形DP
    状压DP
    区间DP
    其他DP(其他是个什么鬼呦~)

    还有两种NOIP中基本不会涉及
    插头DP
    博弈论DP

    一般来说每一年NOIP中至少会出一道DP题
    但是令人吃鲸的是:最大概率考的是其他DP(鬼才出题人)

    先来看四种有套路的
    数位DP:
    看一个非常简单的题
    读入两个正整数l,r,问你从l到r中有多少个正整数
    答案显然是r-l+1
    但是我们要挑战自己,所以来个数位dp
    [l,r]数的个数=[0,r]数的个数-[0,l-1]数的个数
    我们就把问题化成了求[0,x]这段区间之间有多少个数
    把x的十进制表示写出来
    X=XnXn-1....X0
    数位dp的核心思想就是按照十进制位从高到低一位一位进行dp
    刚才的问题等价于看有多少个v满足0≤v≤x
    V=VnVn-1...V0
    如果有Vn=0就说明有前导0
    给VnVn-1...V0每一个数填上0~9之间的某一个看有多少方案满足v≤x
    数位dp填数的时候是从高往低填的
    比如当你填Vn-3时,Vn-2,Vn-1,Vn已经填好了
    比较两个数的大小从高往低比较
    我们希望填上Vn-3后,v的前四位≤x的前四位
    分情况讨论:
    1.x前三位>v前三位,此时Vn-3可以填0~9的任何一个数
    2.x前三位=v前三位,为了保证填了这一位有v的前四位≤x的前四位,我们能填的数就是0~Xn-3
    数位dp的数组一般至少有两个维度
    f[i][j]
    i表示已经填到了第i位
    j取值要么是0,要么是1
    j=0:表示x的前i位数>v的前i位数
    j=1:表示x的前i位数=v的前i位数
    f[i][j]表示这种情况下的方案数
    转移:我们去枚举第i-1位填什么,然后就转移到f[i-1][j']上
    这就是数位dp的一个核心思路

    代码奉上:

     1 int solve(int x)
     2 {
     3     int n=0;
     4     while(x)
     5     {
     6         z[n]=x%10;
     7         x/=10;
     8         n++;
     9     }
    10     n--;
    11     
    12     memset(f,0,sizeof(f));
    13     
    14     f[n+1][1]=1;
    15     
    16     for(int a=n;a>=0;a--)
    17     {
    18         for(int b=0;b<=1;b++)
    19         {
    20             if(b==0)
    21             {
    22                 for(int c=0;c<=9;c++)
    23                     f[a][0]+=f[a+1][b];
    24             }
    25             else 
    26             {
    27                 for(int c=0;c<=z[a];c++)
    28                 {
    29                     if(c==z[a]) f[a][1]+=f[a+1][b];
    30                     else f[a][0]+=f[a+1][b];
    31                 }
    32             }
    33         }
    34     }
    35     return f[0][0]+f[0][1];
    36 }
    37 
    38 int main()
    39 {
    40     cin>>l>>r;
    41     cout<<solve(r)-solve(l-1)<<endl;
    42     return 0;
    43 }

    注意这里要进行两次dp,所以进行下一次dp之前要请空数组
    我们发现当填第n位时,要从第n+1位转移过来
    但是n+1位都是0,所以他们相等
    所以我们令f[n+1][1]=1

    考虑从第n位到第a+1位是相等还是小于
    然后分情况讨论,枚举0和1
    如果b=0,第a位就可以填0~9的任何一个数
    如果b=1,第a位就可以填0~z[a]之间的数
    然后再次讨论填的数是否=z[a]
    答案是小于的方案加上等于的方案

    Problem 2
    求在[l,r]中的数的数位之和

    把之前的代码稍微改一下

    代码:

    int l,r,z;
    
    int f[23333][2];
    int g[23333][2];
    int solve(int x)
    {
        int n=0;
        while(x)
        {
            z[n]=x%10;
            x/=10;
            n++;
        }
        n--;
        
        memset(f,0,sizeof(f));
        memeste(g,0,sizeof(g));
        
        f[n+1][1]=1;
        g[n+1][1]=0;//因为第n+1位是0 
        
        for(int a=n;a>=0;a--)
        {
            for(int b=0;b<=1;b++)
            {
                if(b==0)
                {
                    for(int c=0;c<=9;c++)
                    {
                        f[a][0]+=f[a+1][b];//每一个方案对数位之和贡献一个c 
                        g[a][0]+=g[a+1][b]+f[a+1][b]*c;
                    }
                        
                }
                else 
                {
                    for(int c=0;c<=z[a];c++)
                    {
                        if(c==z[a]) 
                        {
                            f[a][1]+=f[a+1][b];
                            g[a][1]+=g[a+1][b]+f[a+1][b]*c;
                        }
                        else 
                        {
                            f[a][0]+=f[a+1][b];
                            g[a][0]+=g[a+1][b]+f[a+1][b]*c;
                        }
                    }
                }
            }
        }
        return g[0][0]+g[0][1];
    }
    
    int main()
    {
        cin>>l>>r;
        cout<<solve(r)-solve(l-1)<<endl;
        return 0;
    }

    改造改造就好了

    Problem 3
    求在[l,r]中满足十进制位中相邻两个数字之差至少为2的数有多少个

    既然多了一个条件,最直接的方法就是增加一个维度,变成一个三维状态

    f[i][j][k]
    i和j一样
    k表示第i位填了k
    保证第i位和第i+1位的差至少为2

    洛谷P2657 windy数

    树形dp

    举一个栗子:
    给你一颗n个点的树,问这棵树有多少个点?、
    显然是n个点
    但是我们要挑战自己,所以我们选择用树形dp

    (显然是闲得蛋疼)
    对于树形dp来说,这棵树一定会有一个根
    不能再往下走的点就是叶子节点
    以某个点为根的子树就是它能到达的所有点形成的树

    以根节点为根的子树所对应的显然就是整棵树
    f[i]代表以i为根的子树有多少个点
    如果根节点是1的话,我们最终要求的显然就是f[1]
    边界条件?f[leaf]=1 叶子节点所对应的子树大小就是1
    显然它是从下往上推的
    所以我们在算到p的时候,他下面的都已经被算完了
    f[p]=f[son1]+f[son2]+...+f[sonk]+1(k为儿子的个数)
    以p为根的子树大小=它所有儿子的子树大小之和+1

    伪代码:

    int f[233];
    
    void dfs(int p)
    {
        for(x is p''s son)//枚举x是p的儿子 
        {
            dfs(x);
            f[p]+=f[x]; 
        }
        f[p]++; 
    }
    
    int main()
    {
        cin>>n;//点数 
        read_tree();//读入树
        //由于它是从下往上,所以我们选用dfs
        dfs[1];
        cout<<f[1]<<endl;
        
        return 0;
    }

    又一个栗子:
    给你一个n个点的树,求它的直径
    直径:在这棵树上找到两个点,使它们的距离最远
    考虑一棵子树内的直径怎么算

    从一个点走到另一个点的过程尽可能大
    树状路径一定是向上走到某一个点,在向下走到某一个点
    从一个点向下走两条路,两条路拼起来后形成一条路径
    要让这两条路径都尽可能长
    也就是从一个点向下走最长和次长的路径拼起来,就是以这个点为拐点的最长路径
    f[i][0]代表i向下最长路径的长度
    f[i][1]代表i向下次长路径的长度
    算一个点的答案,要把它所有儿子的答案整合起来
    f[p][0]=max(f[p1][0],f[p2][0],...f[pk][0])+1
    假如最大是p2
    如果我们把f[p2][0]换成f[p2][1]可以吗?
    不行!
    因为如果f[p1][0]最长,f[p2][1]次长,就会重复走,这显然是不合法的
    我们不如直接把它去掉
    f[p][1]=max(f[p1][0],,...f[pk][0])+1

    区间dp

    洛谷P1880 合并石子
    有n堆石子,你只能合并相邻两堆石子
    a1,a2,a3,....,an
    合并一次 n-1 堆石头
    合并两次 n-2 堆石头
    求最小代价

    合并相邻,把所有的合并为一个:区间dp
    状态一定是f[l][r],代表把l和r合并的最小代价

    让我们回到这个题
    这个题求的是f[1][n]
    先定义状态:f[l][r]
    边界:f[i][i]=0当你只有一堆石头时,合并代价是0
    最后一次合并一定是把两堆石头合并成一堆石头
    合并石头并不会改变原本的顺序,所以最后一次合并一定可以找到一条分界线,使得左边为l,右边为r
    合并左边:f[l][p] 合并右边:f[p+1][r]
    转移方程:f[l][r]=min(f[l][p]+f[p+1][r]+sum[l][r])
    思路:枚举断点

    代码:

    int z[manx],f[maxn][maxn];
    
    int main()
    {
        cin>>n;
        for(int a=1;a<=n;a++)
            cin>>z[a];//表示石子数
        memset(f,0x3f,sizeof(f));//初始化为无穷大 
        
        for(int a=1;a<=n;a++)
            f[a][a]=0;
                
        //枚举左端点,右端点,断点 
        for(int l=1;i<=n;l++)//枚举左端点 
        {
            for(int r=l+1;r<=n;i++)//枚举右端点 
            {
                for(int p=l;p<r;p++)
                {
                    f[l][r]=min(f[l][r],f[l][p]+f[p+1][r]+sum[l][r]);
                    //左边合并的代价+右边合并的代价+这段区间的石子之和 
                }
            }
        }
      
        
        cout<<f[1][n]<<endl; 
        
        return 0; 
    }

    石子之和用前缀和计算就好了

    代码非常的简单易懂对吧

    但是

    这是不对的!

    (惊不惊喜,意不意外,刺不刺激

    为什么呢?

    因为它不满足dp的阶段性
    因为你算f[1][n]时要用到f[2][n],但它还没有被算过
    我们注意到长的区间都是由短的区间拼起来的,所以我们枚举区间长度

    int z[manx],f[maxn][maxn];
    
    int main()
    {
        cin>>n;
        for(int a=1;a<=n;a++)
            cin>>z[a];//表示石子数
        memset(f,0x3f,sizeof(f));//初始化为无穷大 
        
        for(int a=1;a<=n;a++)
            f[a][a]=0;
        
        for(int len=2;len<=n;len++)//枚举区间长度 
        {
            for(int l=1,r=len;r<=n;l++,r++)//枚举区间位置 
            {
                for(int p=l;p<r;p++)
                {
                    f[l][r]=min(f[l][r],f[l][p]+f[p+1][r]+sum[l][r]);
                    //左边合并的代价+右边合并的代价+这段区间的石子之和 
                }
            }
        }
        cout<<f[1][n]<<endl; 
        
        return 0; 
    }

    复杂度O(n^3)

    但是第1堆和第n堆是相邻的
    怎么处理?

    如果直接在最后一堆后面再加上一个第一堆石头,这样答案就不一定是f[1][n]了
    它还有可能是f[2][n+1]
    就是min(f[1][n],f[2][n+1])
    但是这样a1和a2就不相邻了
    在最后再加上a2
    这样继续下去,把石子弄成a1,a2,....,an,a1,a2,....,an
    ans=min(f[1][n],f[2][n+1],f[3][n+2],f[4][n+3]....)
    每次合并两个相邻的东西相当于去掉一条边
    只需要合并n-1次,这样的话至少有一条边没有用到,可以看成把这条边断开,就变成了断开的答案
    所以我们刚才就是在枚举到底断开哪一条边
    仍然是一遍dp,只不过区间变成了两边

  • 相关阅读:
    [PHP] class_exists类不存在时会调用__autoload函数
    [Redis] Redis的消息机制- 发布订阅
    [发电] 现在正式入驻爱发电平台
    [MySQL] PHP IP登录限制的实现
    [Redis] 哈希表的Rehash机制
    [Redis] redis的hash类型底层结构哈希表
    [Linux] ls命令的几个常用参数实现按时间/文件大小排序
    [Go] 在gin框架gorm下查询一对多的数据
    [Redis] list底层的数据结构
    [GO]go redis实现滑动窗口限流-redis版
  • 原文地址:https://www.cnblogs.com/lcezych/p/10794212.html
Copyright © 2011-2022 走看看