• 区间DP&树形DP


    目录

     

    区间DP

    树形DP

    例题

    A:POJ-2955 Brackets

    B:UVA-10003 Cutting Sticks

    C:POJ-1651 Multiplication Puzzle

    D:HDU-3506 Monkey Party

    E:POJ-2342 Anniversary party

    F:HDU-1054 Strategic Game

    G:ZOJ-3201 Tree of Tree

    H:POJ-3659 Cell Phone Network


    两个定义(做题要用到):

    定义1 对于图G=(V,E)来说, 最小支配集 指的是从V中取尽量少的点组成一个集合,使得对于V中剩余的点都与取出来的点有边相连。也就是说,设V‘是图G的一个支配集,则对于图中的任意一个顶点u,要么属于集合V’,要么与V‘中的顶点相邻。在V’中出去任何元素后V‘不再是支配集,则支配集是极小支配集。称G的所有支配集中顶点个数最少的支配集为最小支配集,最小支配集中顶点的个数称为支配数。

    定义2 对于图G=(V,E)来说, 最小点覆盖 指的是从V中取尽量少的点组成一个集合,使得E中所有的边都与取出来的点相连。也就是说,设V‘是图G的一个顶点覆盖,则对于图中的任意一条边(u,v),要么u属于集合V’,要么v属于集合V‘。在V‘中除去任何元素后V’不在是顶点覆盖,则V‘是极小顶点覆盖。称G的所有顶点覆盖中顶点个数最少的覆盖为最小点覆盖。

    区间DP(转自区间DP

    例题:石子归并。

      描述 有N堆石子排成一排,每堆石子有一定的数量。现要将N堆石子并成为一堆。合并的过程只能每次将相邻的两堆石子堆成一堆,并将新的一堆石子数记为该次合并的得分。

      给组测试数据 

         输入   4    表示有4堆石子

           4 4 5 9  表示每堆石子的个数

         输出  54         表示最大的得分   

      分析:主要思想就是一个一个累加:4 4 5 9 先看下去是我想知道dp[i][j]的最大值,i表示起始位置,j表示终止位置,所以我肯定是想求出dp[1][4]间的最大值但是我从1到4可是如图这三种截取方法,所以我先从小的开始记录。

    dp[1][1]=4;dp[2][2]=4;dp[3][3]=5;dp[4][4]=9。然后我在长度为2的时候记录,dp[1][2]=4+4=8,dp[2][3]=8+5=14;dp[3][4]=14+9=23;这样加起来的值就是8+14+23=45;但是如果我反过来呢?dp[1][2]=5+9=14;dp[2][3]=14+4=18;dp[3][4]=18+4=22;这样加起来的值就是14+18+22=54。很明显,54就是所求的最大值。

    如图,如果我相求圈着的这个三个的值,我完全可以有图上这两种分割,并且分割出来的值是已经知道的了。

    动态规划的思想:先两两合并,在三三合并,最后再NN合并,在合并过程中,较大的区间可以拆分成已经的小区间进行计算,省时又省力。比如,我可以在三三合并的时候可以拆分成一一还有二三相加。即把当前阶段的合并方法细分成前一阶段已计算出的方法,选择其中的最优方案。

    具体来说我们应该定义一个数组dp[i,j]用来表示合并方法,i表示从编号为i的石头开始合并,j表示所求区间的结尾,sum表示的是石头的数量。

    对于上面的例子来说,

       第一阶段:dp[1][1],dp[2][2],dp[3][3],dp[4][4] 因为一开始还没有合并,所以这些值应该全部为0。

        第二阶段:两两合并过程如下,其中sum(i,j)表示石头的数量,即从i开始数j个数的和

                  dp[1,2]=dp[1,1]+dp[2,2]+sum[1,2];

        dp[2,3]=dp[2,2]+dp[3,3]+sum[2,3];

        dp[3,4]=dp[3,3]+dp[4,4]+sum[4,4];

        第三阶段:三三合并可以拆成两两合并,拆分方法有两种,前两个为一组或后两个为一组

         dp[1,3]=dp[1,2]+dp[3,3]+sum[1,3]或dp[1,3]=dp[1,1]+dp[2,3]+sum[1,3];取其最优

     dp[2,4]=dp[2,2]+dp[3,4]+sun[2,4]或dp[2,4]=dp[2,3]+dp[3,3]+sum[2,4];取其最优

        第四阶段:四四合并的拆分方法用三种,同理求出三种分法的得分,取其最优即可。以后第五阶段、第六阶段依次类推,最后在第六阶段中找出最优答案即可。

      动态方程为dp[i][j]=dp[i][k]+dp[k+1][j]+sum[i][j];

     参考代码。

    #include <iostream>
    #include <cstdio>
    #include <cstring>
    
    using namespace std;
    
    //#define MAX 999999
    
    int main ()
    {
        int dp[210][210],sum[210][210],a[210];
        int n;
        while(~scanf("%d",&n))
        {
            //memset(dp,MAX,sizeof(dp));
            for (int i=1; i<=n; i++)
                scanf("%d",&a[i]);
            for (int i=1; i<=n; i++)
            {
                dp[i][i]=0;//初始化为0
                sum[i][i]=a[i];//将每堆石子的个数赋值进来
            }
            for (int len=1; len<n; len++)//按长度从小到大枚举
            {
                for (int i=1; i<=n&&i+len<=n; i++)//i表示开始位置
                {
                    int j=len+i;                    //j表示长度为len的一段区间的结束位置
                    for (int k=i; k<j; k++)         //用k来表示分割区间
                    {
                        sum[i][j]=sum[i][k]+sum[k+1][j];
                        if (dp[i][j]<dp[i][k]+dp[k+1][j]+sum[i][j])
                            dp[i][j]=dp[i][k]+dp[k+1][j]+sum[i][j];
                        //cout<<i<<" "<<j<<" "<<sum[i][j]<<" "<<k<<" "<<dp[i][j]<<endl;
                    }
                }
            }
            cout<<dp[1][n]<<endl;
        }
        return 0;
    }

    另一种:
    一条直线上有N堆石子,现在要将所有石子合并成一堆,每次只能合并相邻的两堆,合并花费为新合成的一堆石子的数量,求最小的花费。

    1堆,花费为0 
    2堆,花费为sum[2] 
    3堆,花费为min(a[1] + a[2], a[2] + a[3]) + sum[3] 
    如果我们有n堆,合并的最后一次操作一定是从两堆合并成一堆,

    第一种模型就是将大区间从任意位置分成两个小区间
    规定dp[i][j]为合并第i堆到第j堆的最小花费 
    DP方程为: 
    dp[i][j] = min(dp[i][k] + dp[k+1][j]) + sum[j] - sum[i-1] (i <= k < j)    sum[i]为前i项和
     

    memset(dp, 0x3f, sizeof(dp));
    for (int i = 1; i <= n; i++) 
        dp[i][i] = 0;
    for (int len = 2; len <= n; len++)
    {
        for (int i = 1, j = len; j <= n; i++, j++)
        {
            for (int k = i; k < j; k++)
            {
                if(dp[i][j] > dp[i][k] + dp[k+1][j] + sum[j] - sum[i-1])
                    dp[i][j] = dp[i][k] + dp[k+1][j] + sum[j] - sum[i-1];
            }
        }
    }
    printf("%d
    ", dp[1][n]);

    树形DP

    树形 DP,即在树上进行的 DP。树形DP具体还是要根据题来说,就根据E~H题来讲解。

    例题

    A:POJ-2955 Brackets:给一个括号组成的字符串,问最多能匹配多少个括号 像([)]这样的字符串匹配度为2,但是像()[]、[()]的字符串匹配度为4,也就是说括号具有分隔作用。长度为1的串匹配度 0 。长度为2的串匹配度 0 或 2。

    规定dp[i][j]为合并第i到第j个字符的最大匹配度 
    长度为n时,我们可以先检测a[i]和a[j]是否匹配, 
    匹配dp[i][j] = dp[i+1][j-1] + 2 
    不匹配,那我们可以按第一种模型处理,从任意位置分成两个区间

    #include <iostream>
    #include<cstdio>
    #include<cstring>
    #include<algorithm>
    #include<string>
    using namespace std;
    int dp[105][105];
    int main()
    {
        char str[105];
        while(scanf("%s",str+1)!=EOF){
            memset(dp,0,sizeof(dp));
            if(str[1]=='e')
                break;
            int n=strlen(str+1);
            for(int i=1;i<=n;i++)
                dp[i][i]=0;
            for(int len=1;len<=n;len++){
                for(int i=1,j=len;j<=n;i++,j++){
                    if((str[i]=='('&&str[j]==')')||(str[i]=='['&&str[j]==']'))
                        dp[i][j]=dp[i+1][j-1]+2;
                    for(int k=i;k<j;k++)
                        dp[i][j]=max(dp[i][j],dp[i][k]+dp[k+1][j]);
                }
    
            }
            cout<<dp[1][n]<<endl;
        }
    }

    B:UVA-10003 Cutting Sticks:题目的意思是有个长度为l的木棍,这个木棍需要加工n次,给出n次加工的位置,每次加工的花费是当前木棍的长度,求加工的最小花费。
    与矩阵连乘多少有点类似,不过这里不是枚举最后一次切割的位置,这里最后一次切割的花费和之前的切割次序是相关的。所以不能这样枚举,但我们发现第一次切割时的花费是固定的,可以尝试一下枚举第一次切割的位置。用dp[i][j]代表第i次切割到第j次切割的最优值,我们可以得到这一段的总长度是pos[j+1]-pos[i-1],这里注意边界,要特殊处理下,然后枚举这一段第一次切割的位置k,转移方程为dp[i][j]=min(dp[i][k-1]+dp[k+1][j]+pos[j+1]-pos[i-1]),其中k为从i到j。这里也要注意边界的问题,当i大于j的时候dp[i][j]应该为0,其他的情况都应该初始化为一个足够大的值。只要注意号边界就可以了。这里有两种写法,一种是记忆化搜索,另一种是递推。
     

    #include <iostream>
    #include<cstdio>
    #include<cstring>
    #include<algorithm>
    #include<string>
    using namespace std;
    const int INF=0x3f3f3f3f;
    int dp[105][105];
    int a[105];
    int main()
    {
        int s;
        while(scanf("%d",&s)&&s){
            int n;
            scanf("%d",&n);
            memset(dp,0,sizeof(dp));
            for(int i=1;i<=n;i++)
                scanf("%d",&a[i]);
            a[0]=0;a[++n]=s;
            for(int len=2;len<=n;len++){
                for(int i=0,j=len;j<=n;i++,j++){
                    int tmp=INF;
                    for(int k=i+1;k<j;k++)
                        if(tmp>dp[i][k]+dp[k][j]+a[j]-a[i])
                             tmp=dp[i][k]+dp[k][j]+a[j]-a[i]; //a[j]-a[i]即转移要付出的代价
                     dp[i][j]=tmp;
                }
            }
            printf("The minimum cutting is %d.
    ",dp[0][n]);
        }
    }

    C:POJ-1651 Multiplication Puzzle:题意:从一堆放好的卡片中取一个卡片(不能取开头和结尾的卡片),每次取出一个卡片的时候,就计算一个值:该卡片上的数字和他左右卡片的数字的乘积。重复这个操作直到只剩下两个卡片,将所有得到的值加起来。求这个值的最小值是多少。 题意:dp[i][j]表示从第i张到第j张的最小的值,状态转移方程dp[i][j] = min(dp[i][j],dp[i][k] + dp[k][j] + a[i] * a[k] * a[j]),k表示最后一个取走的卡牌,i<k<j。

    #include <iostream>
    #include <cstdio>
    #include <vector>
    #include <algorithm>
    #include <cstring>
    using namespace std;
    #define maxn 105
    #define INF 0x3f3f3f3f
    int main()
    {
        int dp[maxn][maxn];
        int a[maxn];
        int n;
        while(cin>>n){
            memset(dp,0,sizeof(dp));
            for(int i=1;i<=n;i++)
               cin>>a[i];
            for(int l=2;l<=n;l++){
                for(int i=1,j=l+i;j<=n;j++,i++){
                        dp[i][j]=INF;
                    for(int k=i+1;k<=j-1;k++){
                        dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]+a[i]*a[j]*a[k]);
                    }
                }
            }
            cout<<dp[1][n]<<endl;
        }
        return 0;
    }

    D:HDU-3506 Monkey Party:(石子合并的升级版)题意: 一群猴子围成一圈,每个猴子有一个权值,然后每次选相邻的两个猴子交友,代价就是两个权值和,a和b交友之后呢,a的朋友和b的朋友就是朋友了,代价是a,b的朋友的总和,让你求最小代价。分析: 这个和经典的取石子问题及其相似,不同的是,这个可以围成一个圈,我们只要预处理下 n*2即可,然后用四边形不等式优化下,不懂的点这里,注意清零即可。(其实没必要明白优化原理,只需要知道使用条件和s数组的初始化和更新即可。

    #include <iostream>
    #include <cstdio>
    #include <vector>
    #include <algorithm>
    #include <cstring>
    using namespace std;
    const int maxn=2e3+10;
    #define INF 0x3f3f3f3f
    int dp[maxn][maxn];
    int a[maxn],s[maxn][maxn],sum[maxn];
    int n;
    int main()
    {
    
        while(cin>>n){
            sum[0]=0;
            memset(dp,0,sizeof(dp));
            for(int i=1;i<=n;i++){
               cin>>a[i];
               sum[i]=sum[i-1]+a[i];
            }
            for(int i=0;i<=2*n;i++)
                s[i][i]=i;
            for(int i=n+1;i<=2*n;i++)
                sum[i]=sum[n]+sum[i-n];
            for(int l=2;l<=n;l++){
                for(int i=1,j=l+i-1;j<=2*n;j++,i++){
                        dp[i][j]=INF;
                    for(int k=s[i][j-1];k<=s[i+1][j];k++){
                        if(dp[i][j]>dp[i][k]+dp[k+1][j]+sum[j]-sum[i-1]){
                            dp[i][j]=dp[i][k]+dp[k+1][j]+sum[j]-sum[i-1];
                            s[i][j]=k;
                        }
                    }
                }
            }
            int ans=INF;
            for(int i=1;i<=n;i++)
                ans=min(ans,dp[i][i+n-1]);
            cout<<ans<<endl;
        }
        return 0;
    }

    E:POJ-2342 Anniversary party:题意:某公司要举办一次晚会,但是为了使得晚会的气氛更加活跃,每个参加晚会的人都不希望在晚会中见到他的直接上司,现在已知每个人的活跃指数和上司关系(当然不可能存在环),求邀请哪些人(多少人)来能使得晚会的总活跃指数最大。题解:树形dp.dp[node][1] += dp[i][0]; i是node的下属,1表示上司(node)去,0表示下属(i)不去dp[node][0] += max(dp[i][1],dp[i][0]);这个题看代码应该就能看明白。

    #include <iostream>
    #include<cstdio>
    #include<cstring>
    #include<algorithm>
    #include<string>
    using namespace std;
    const int INF=0x3f3f3f3f;
    const int maxn=6e3+10;
    int dp[maxn][2];
    int father[maxn],vis[maxn];
    int n;
    void treedp(int node)
    {
        vis[node]=1;
        for(int i=1;i<=n;i++){
            if(!vis[i]&&father[i]==node){
                treedp(i);
                dp[node][1]+=dp[i][0];  //上司来,下属不来
                dp[node][0]+=max(dp[i][0],dp[i][1]);   //上司不来,下属来不来都可以
            }
        }
    }
    int main()
    {
        int f,c,root;
        while(scanf("%d",&n)==1){
            memset(dp,0,sizeof(dp));
            memset(vis,0,sizeof(vis));
            memset(father,0,sizeof(father));
            for(int i=1;i<=n;i++)
                scanf("%d",&dp[i][1]);
            root=0;
            while(scanf("%d %d",&c,&f)&&c&&f){
                father[c]=f;
                root=f;
            }
            while(father[root])
                root=father[root];
            treedp(root);
            printf("%d
    ",max(dp[root][0], dp[root][1]));
        }
    }

    F:HDU-1054 Strategic Game:(最小顶点覆盖问题)题意:一棵树,要放置哨兵,要求最少放置多少哨兵能监视到所有的结点。

    解题思路:放置哨兵无非两种情况,要嘛放,要嘛不放,我们可以用dp[i][1]来表示第i个结点放置哨兵,dp[i][0]来表示第i个结点不放置哨兵,我们可以从上往下,从左往右来遍历树,所以这就用到了树形DP的知识,我们很容易知道,如果父亲结点没放哨兵,那么子结点肯定要放置哨兵,如果父亲放置了哨兵,那么子结点可以考虑放或者不放。所以很容易写出状态转移方程dp[v][1] += min(dp[u][1],dp[u][0]),dp[v][0] += dp[u][1],由于子结点可能有多个,我们要依次从左到右遍历所以我们必须开一个brother来存放它前一个兄弟结点,然后只要DFS递归的来从上往下遍历就可以得解了。(外附二分图匹配做法)

    #include <iostream>
    #include<cstdio>
    #include<cstring>
    #include<algorithm>
    #include<string>
    using namespace std;
    const int INF=0x3f3f3f3f;
    const int maxn=6e3+10;
    int dp[maxn][2];
    int father[maxn],son[maxn],brother[maxn];
    int n;
    void treedp(int root)
    {
        dp[root][0]=0;
        dp[root][1]=1;
        int f=son[root];
        while(f!=-1){
            treedp(f);//纵向遍历
            dp[root][1]+=min(dp[f][0],dp[f][1]);
            dp[root][0]+=dp[f][1];
            f=brother[f];//切换到下一个兄弟结点,横向遍历
        }
    }
    int main()
    {
        int m,root,num,cnt,num1;
        while(scanf("%d",&m)==1){
            memset(son,-1,sizeof(son));
            memset(father,0,sizeof(father));
            for(int i=0;i<m;i++){
                scanf("%d:(%d)",&num,&cnt);
                for(int j=0;j<cnt;j++)
                {
                    cin>>num1;
                    brother[num1]=son[num];
                    son[num]=num1;
                    father[num1]=1;
                }
            }
            for(int i=0;i<m;i++){
                if(!father[i]){
                    root=i;
                    break;
                }
            }
            treedp(root);
            printf("%d
    ",min(dp[root][0], dp[root][1]));
        }
    }

    二分图匹配做法

    #include <bits/stdc++.h>
    using namespace std;
    const int maxn=1500+7;
    int n;
    vector<int> G[maxn];
    int nxt[maxn],visited[maxn],cnt;
    int find(int x){
    	for(int i = 0;i<G[x].size();++i){
    		int v=G[x][i];
    		if(!visited[v]){
    			visited[v]=1;
    			if(nxt[v]==-1||find(nxt[v])){
    				nxt[v]=x;
    				return 1;
    			}
    		}
    	}
    	return 0;
    }
    int match(){
    	int ans=0;
    	memset(nxt,-1,sizeof(nxt));
    	for(int i = 0;i<n;++i){
    		memset(visited,0,sizeof(visited));
    		if(find(i)) ans++;
    	} 
    	return ans;
    }
    int main(int argc, char** argv) {
    	while(~scanf("%d",&n)){
    		for(int i = 0;i<=1500;++i) G[i].clear();
    		for(int i = 0;i<n;++i){
    			int x,y,num;
    			scanf("%d:(%d)",&x,&num);
    			if(!num) continue;
    			while(num--){
    				scanf("%d",&y);
    				G[x].push_back(y);
    				G[y].push_back(x);
    			} 
    		}
    		printf("%d
    ",match()/2);
    	}
    	return 0;
    }

    G:ZOJ-3201 Tree of Tree:题意:有一个n个点的树,每个点有个权值,问大小为k的子树的最大权值和为多少。题解:设dp[x][j]为x点大小为j的子树的最大权值和,则dp[x][j] = max(dp[x][j], dp[x][k] + dp[v][j - k]);任选一点为根往下搜索即可!

    #include<stdio.h>
    #include<string.h>
    #include<algorithm>
    #include<vector>
    using namespace std;
    const int MAXN=105;
    vector<int> tree[MAXN];  
    int n,k;
    int dp[MAXN][MAXN];  //dp[i][k]表示以i为根节点的最大的k个节点的和 
    int vis[MAXN];
    int  maxn;
    void dfs(int u)
    {
    	vis[u]=1;
    	for(int i=0;i<tree[u].size();i++)
    	{
    		int v=tree[u][i];
    		if(!vis[v])
    		{
    			dfs(v);
    			for(int j=k;j>=1;j--)  //每个根节点不仅要找出k个节点和的最大值,还要算出1到k-1的 对应的节点和的最大值,是为了给后面的根节点用 
    			for(int m=1;m<j;m++)
    			dp[u][j]=max(dp[u][j],dp[u][m]+dp[v][j-m]); //根节点的m个和 加上 儿子的j-m个和 一共j个和  不断地更新dp[u][j] 
    		}
    	}
    }
    int main()
    {
      
        while(scanf("%d%d",&n,&k)!=EOF)
        {
        	maxn=0;
        	for(int i=0;i<n;i++)    //每次一定不要忘记将树清空!!! 
        	tree[i].clear();
    	    memset(dp,0,sizeof(dp));
    	    memset(vis,0,sizeof(vis));
    	    for(int i=0;i<n;i++)
    	    	scanf("%d",&dp[i][1]);
    	    for(int i=0;i<n-1;i++)
    	    {
    	        int u,v;
    	        scanf("%d%d",&u,&v);
    	        tree[u].push_back(v);
    	        tree[v].push_back(u);
    	    }
    	    dfs(0);
    	    for(int i=0;i<n;i++)
    	    {
    	    	if(dp[i][k]>maxn) maxn=dp[i][k];
    	    }
    	    printf("%d
    ",maxn);
     
        }
       
        return 0;
    }

    H:POJ-3659 Cell Phone Network:(最小支配集问题)有一棵树,问最少取树中的多少个节点,能使其与树的其他节点都联通。(一个点只与其有边相连的点联通)。具体题解可看代码中的注释。

    #include <iostream>
    #include <cstdio>
    #include <vector>
    #include <algorithm>
    using namespace std;
    #define MAXN 10010
    #define INF 0x3f3f3f3f
    vector <int> G[MAXN];
    int dp[MAXN][MAXN];   //dp[i][0]表示取i结点时的最小结点数
                          //dp[i][1]表示不取i结点时,i被其儿子覆盖时的最小结点数
                          //dp[i][2]表示不选点i,i被其父亲覆盖时的最小结点数
                          //树形DP状态转移从叶子结点开始向根不断转移,所以用深搜。
                          //搜到叶子结点就结束。
                          //转移的时候可以将结点以下给圈起来。
                          //取与不取,树上背包
    int vis[MAXN];
    void dfs(int u)
    {
        vis[u]=1;
        int i;
        int flag=1,tmp=INF;
        for(i=0;i<G[u].size();i++)
        {
            int v=G[u][i];
            if(!vis[v])                             //额,图的最小支配集吧
            {
                dfs(v);
                dp[u][0]+=min(dp[v][0],min(dp[v][1],dp[v][2]));
                dp[u][2]+=min(dp[v][0],dp[v][1]);   //不取的话,肯定没有父亲这个选项啊
                                                    //父亲只有一个,所以不必做过多的讨论
                                                    //儿子比较多,可以取所有儿子,但必须选一个儿子
                                                    //但选了一个儿子就要做标记
                if(dp[v][0]<=dp[v][1])
               {
                  dp[u][1]+=dp[v][0];
                  flag=0;
               }
                else
               {
                  dp[u][1]+=dp[v][1];
                  tmp=min(tmp,dp[v][0]-dp[v][1]);
               }
            }
        }
        if(flag)    //还没有选儿子,加上这个差值,补回一个儿子
        dp[u][1]+=tmp;
        return ;
    }
    int main()
    {
        int n;
        int i=0;
        int u,v;
        scanf("%d",&n);
        for(i=1;i<n;i++)
        {
             scanf("%d%d",&u,&v);
             G[u].push_back(v);
             G[v].push_back(u);
        }
        for(i=0;i<=n;i++)         //dp[i][0]的i从0开始,因为存在下标为0的结点
        {                        //再次强调结点的下标一定是0~n-1
            dp[i][0]=1;
            dp[i][1]=0;
            dp[i][2]=0;
        }
        if(n==1)
        printf("1
    ");
        else
        {
          dfs(1);
          printf("%d
    ",min(dp[1][0],dp[1][1]));
        }
        return 0;
    }
  • 相关阅读:
    jQuery Ajax 实例 全解析
    用Javascript评估用户输入密码的强度
    常用网址
    常用的107条Javascript
    根据键盘操作表格
    HTML5吧
    css3动画简介以及动画库animate.css的使用
    jquery插件下载地址
    CEO、COO、CFO、CTO
    springboot与shiro配置
  • 原文地址:https://www.cnblogs.com/shmilky/p/14089043.html
Copyright © 2020-2023  润新知