挑战笔记

挑战程序设计竞赛笔记

1. 第二章

2.1 最基础的穷竭算法

  1. 经典dfs部分和问题,做了一点小改动,主要是我习惯用初始下标为1.
#include<cstdio>
#include<algorithm>
#include<cmath>
#include<iostream>
#include<queue>
#include<stack>
#include<vector>
#include<cstring>
#include<cstdlib>
using namespace std;
typedef long long ll;
int n, m, A[25];
bool dfs(int i, int sum){ //i表示当前已经遍历了的项数。
    if(i > n )return sum == m;
    if(dfs(i+1, sum + A[i]))return true; //选择第i项的状态
    if(dfs(i+1, sum))return true; //不选择第i项的状态
    return false;
}
int main(){
    cin >> n >> m;
    for(int i = 1; i <= n;i++)
        scanf("%d", A + i);
    if(dfs(1, 0)){
        printf("YES\n");
    }else{
        printf("NO\n");
    }
    return 0;
}
  1. 使用bfs求迷宫最短路时,我一般使用一个结构体构造每一个点的状态,其他的部分按照模板。
struct N{
    int x, y, step;
    N(int a, int b, int c):x(a), y(b), step(c){}
}
  1. C++中提供了特殊的函数,可以把n个元素的n!种不同排列生成出来(全排列);又或者,通过使用位运算,可以枚举从n个元素中取出k个的共
    C n k C^k_n Cnk
    种状态;再或是某个集合中的全部子集等。使用方法为:
#include<algorithm>
//即使有重复的元素也会生成所有排列
//next_permutation是按照字典序生成下一个排列的,因此当前这一个排列需要先输出出来
int main(){
    int A[5];
    for(int i  = 0;i < 5;i++){
        A[i] = i + 1;
    }
    do{
        for(int i = 0;i  < 5;i++)printf("%d",A[i]);
        puts("");
    }while(next_permutation(A, A + 5));
    return 0;
}
  1. 关于C++全局变量的存放空间:

C++中的全局变量和静态变量被分配到同一块内存中:全局/静态存储区。
在C语言中显示初始化的全局变量保存在数据段中,而未显示初始化的全局变量保存在BSS段中。

2.2 一直往前!贪心法

  1. 利用贪心法,根据时间区间求选到最多的工作数量。首先按照每个工作的结束时间排好序然后优先选择结束时间更早的,选择完一个工作之后,保存下这个工作的结束时间最为下一个开始时间的比较。

关于本问题的贪心算法的证明:

结束时间越早之后可以选择的工作也就越多。这是该算法能够正确处理问题的一个直观解释。下面给出更严谨的证明:

(1).与其他选择方案相比,该算法的选择方案在选取了相同数量的更早开始的工作时,其最终结束时间不会比其他方案更晚。

(2).所以,不存在选取更多的工作的选择方案。

#include<cstdio>
#include<algorithm>
#include<cmath>
#include<iostream>
#include<queue>
#include<stack>
#include<vector>
#include<cstring>
#include<cstdlib>
using namespace std;
typedef long long ll;
int n;
struct N{
    int s, t;
}P[105];

bool cmp(const N &a, const N &b){
    if(a.t == b.t)return a.s < b.s;
    return a.t < b.t;
}
void solve(){
    sort(P+1, P+n+1, cmp);
    int t = 0, ans = 0;
    for(int i = 1;i <= n;i++){
        if(t < P[i].s){
            ans++;
            t = P[i].t;
        }
    }
    printf("%d\n", ans);
}
int main(){
    //每次选择结束时间最早的
    cin >> n;
    for(int i = 1;i <= n;i++){
        scanf("%d%d", &P[i].s, &P[i].t);
    }
    solve();
    return 0;
}
  1. 使用贪心法求字典序最小问题:

给定长度为N的字符串S,构造出一个字符串T。起初T是空串,随后反复进行以下操作:

  • 从S的头部删除一个字符,加到T的末尾
  • 从S的尾部删除一个字符,加到T的末尾

目标是要构造出字典序尽可能小的字符串T。

1 <= N <= 2000;

S中只包含大写英文字母

分析:

这道题很容易想到一个贪心算法:

扫描二维码关注公众号,回复: 12945247 查看本文章
  • 每次比较S中开头和结尾更小的,然后加入T中。

但是这个地方很容易就忽略了S串中开头和结尾字符相等的情况。因此我们就要比较下一个字符的大小,下一个字符也可能相同,就还需要继续比较,所以得到以下算法:

  • 按照字典序比较S和将S反转后的字符串S’。
  • 如果S较小,就从S的开头取一个字符,追加到T的末尾。
  • 如果S’较小,就从S的末尾取一个文字,追加到T的末尾。
  • 如果相等,继续比较下一个字符。

得到的代码如下:

#include<cstdio>
#include<algorithm>
#include<cmath>
#include<iostream>
#include<queue>
#include<stack>
#include<vector>
#include<cstring>
#include<cstdlib>
using namespace std;
typedef long long ll;

int N;
char S[1005];

void solve(){
    bool left;
    int a, b;
    a = 0;
    b = strlen(S) - 1;
    while(a <= b){
        for(int i = 0;a + i < N;i++){
            if(S[a + i] < S[b - i]){
                left = true;
                break;
            }else if (S[a + i] > S[b - i]){
                left = false;
                break;
            }
            //这里没有给出相等的判断,则相等的时候需要继续比较下一个字符。
        }
        if(left)putchar(S[a++]);
        else putchar(S[b--]);
    }
    putchar('\n');
}

int main(){
    cin >> N;
    scanf("%s", S);
    solve();
    return 0;
}
  1. 使用贪心求添加最小标记点

给出N个点,以及每个点在坐标上的位置。从N个点中选择若干个添加标记,使得每个点间距为R之间的点一定含有标记点的存在,求最少添加多少个点。

void solve2(){
    sort(A + 1, A +1 + N);
    int i = 1, ans = 0;
    while(i <= N){
        //s是没有被覆盖的最左的点的位置
        int s = A[i++];
        //一直向右前进直到距s的距离大于R的点
        while(i <= N && A[i] <= s + R)i++;
        //p是新加上标记的点的位置
        int  p = A[i - 1];
        while(i <= N && A[i] <= p + R)i++;
        ans++;
    }
    printf("%d\n", ans);
}
  1. POJ3253:使用贪心法求最短切割木板的开销。

给出准备切割的n块木板的长度,未切割前的木板长度为其总和。已知每切割一块木板,需要的开销是这个木板本身的长度,求将一块整的木板切割为这n个木板的最小开销程度。

分析:

用一个二叉树来描述整个切割过程,首先要将整个木板切割为较均匀的两块,然后再重复这个过程,最终总的开销一定是最小的。

于是我们知道最优解的情况下,最短的木板一定是最后切割出来的,所以我们反向合并最短木板。每次取出一块最短木板和一块次段木板合并为一块,再放入到所有木板中去,再重复这个过程,找到最短与次短合并,最后只剩下一块木板时,就停止合并,每次合并记录下开销就行了。

void solve2(){
    ll ans = 0;
    while(n > 1){
        //求出最短板子与此短板子
        int mill1 = 1, mill2 = 2;
        if(L[mill1] > L[mill2])swap(L[mill1], L[mill2]);
        //遍历求最短与次短的模板,这个方法在很多情况下都会用到。
        for(int i = 3;i <= n;i++){
            if(L[i] < L[mill1]){
                mill2 = mill1;
                mill1 = i;
            }else if(L[i] < L[mill2]){
                mill2 = i;
            }
        }
        int t = L[mill1] + L[mill2];
        ans += t;
        if(mill1 == n)swap(mill1, mill2);
        L[mill1] = t; //用mill1保存新值,用mill2保存最后一个值
        L[mill2] = L[n];
        n--;
    }
    printf("%lld\n", ans);
}

这里有一个模板,可以通用:

//遍历求最短与次短的模板,这个方法在很多情况下都会用到。
for(int i = 3;i <= n;i++){
    if(L[i] < L[mill1]){
        mill2 = mill1;
        mill1 = i;
    }else if(L[i] < L[mill2]){
        mill2 = i;
    }
}

2.3 记录结果再利用的“动态规划”

  1. 最简单的01背包问题

    记忆化搜索解法,动态规划就是从这个演变过来的,因此必须要掌握:

    //01背包问题记忆化搜索解法
    int rec2(int i, int j){
        if(dp[i][j] != 0)return dp[i][j];//记忆化搜索
        int res;
        if(i == n)return 0;
        if(j < w[i]){
            res = rec2(i +1, j); // 不选第i种
        }
        if(j > w[i]){
            res = max(rec2(i + 1, j), rec2(i+1, j - w[i])+v[i]); //选还是不选第i种看谁更大
        }
        return dp[i][j] = res;
    }
    
  2. 最常用的01背包模板:

void solve(){
    memset(dp, 0, sizeof dp);
    for(int i = 1;i <= n;i++){
        for(int j = W; j >= w[i];j--){
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    printf("%d\n", dp[W]);
}
  1. 最长公共子序列问题。给定两个字符串s和t,求出两个字符串的最长公共子序列的长度。

分析:

使用dp数组定义:dp[i ] [j ]的值表示前i位s字符串和前j位t字符串的最长公共子序列的长度。

void solve(){
    memset(dp, 0, sizeof dp);
    for(int i = 0;i < n;i++){
        for(int j = 0;j < m;j++){                                                                                                                          
            if(s[i] == t[j]){	//当i与j的字符相同时,则当前位置的最长公共子串就等于前面的最长公共子串+1.
                dp[i+1][j+1] = dp[i][j] +1;
            }else{
                dp[i+1][j+1] = max(dp[i][j+1], dp[i+1][j]); //不相等时,比较前面两种最长公共子串的较大者为当前位置的最长公共子串。
            }
        }
    }
    printf("%d\n", dp[n][m]);
}

​ 4. 完全背包问题。n种物品,价值vi,重量wi,每种物品可以无限取得,求W容量的背包可获得的最大价值。

  • 这里直接给出完全背包的一维优化下的写法,只需要记住一点,完全背包与01背包最大的不同就在于一维优化时的写法:01背包是将背包容量从W迭代到w[i],是递减;完全背包时将背包容量从w[i]迭代到W,是递增。
int dp2[maxn];
//常用的完全背包模板
void solve3(){
    memset(dp2, 0, sizeof dp2);
    for(int i = 1;i <= n;i++){
        for(int j = w[i];j <= W;j++){
            dp2[j] = max(dp2[j],  dp2[j - w[i]] + v[i]);
        }
    }
    printf("%d\n", dp2[W]);
}
  1. DP数组的滚动数组优化:

在dp[i+1] [j] = max(dp[i] [j], dp[i+1] [j-w[i]] + v[i]);这一递推式当中,dp[i+1]计算时只需要dp[i]和dp[i+1],所以可以利用结合奇偶性写成如下形式:

int dp[2][maxn];
void solve2(){
    memset(dp, 0, sizeof dp);
    for(int i = 1;i <= n;i++){
        for(int j = 0;j <= W;j++){
            if(j < w[i]){
                dp[i & 1][j] = dp[(i-1) & 1][j];
            }else{
                dp[i & 1][j] = max(dp[(i-1) & 1][j], dp[i & 1][j-w[i]] + v[i]);
            }
        }
    }
    printf("%d\n", dp[n & 1][W]);
}
  1. 交换价值与费用定义的01背包问题。

在实际问题中,有时物品的价值范围较小,而重量范围过于太大,如果用以前的01背包的dp状态表示的话,时间复杂度为O(nW),就远远不够了。但是我们可以通过一点该表将时间复杂度改为O(n(v1+v2+…+vn))。

0 < n < 101

0 < wi < 107+1

0 < vi < 101

0 < W < 109

  • 定义dp[i][j]表示将前i件物品组成一个价值为j的背包所占用的最小空间(费用)。所以可以得到他的状态转移方程为dp[i][j] = min(dp[i - 1][j], dp[i-1][j-v[i]]+ w[i])。这个地方我和书本上的形式不太一样,主要还是按照自己的习惯来。解释一下:将前i件物品组成价值为j的背包所占的最小空间,就等于,将前i-1件物品组成一个价值为j的背包(不放入第i件)和将前i-1件组成一个价值为j-v[i]的背包并且再放入第i件物品(放入第i件)中所占空间最小的。

代码在书上的基础上略加优化,采用一维完全背包的优化策略:

void solve(){
    memset(dp, INF, sizeof dp);
    dp[0] = 0;//dp[i]存放,当价值为i时,最小的背包重量值。
    for(int i = 1;i <= n;i++){
        for(int j = lim;j >= v[i];j--){
            dp[j] = min(dp[j], dp[j-v[i]] + w[i]);
        }
    }
    int ans = 0;
    //其中lim是所有物品的最大价值总和。
    for(int i = lim;i >= 0;i--){
        if(dp[i] <= W){
            printf("%d  %d\n", i, dp[i]);
            break;
        }
    }
}
  1. 多重部分和问题

有n种不同大小的数字ai,每种各mi个。判断是否可以从这些数字之中选出若干使他们的和恰好为K。

分析: 这道题的思维比较独特,需要重点学习一下!!

首先,我们定义dp数组,dp[i+1][j]表示用前i种数组合相加到和为j时,第i种数最多能剩下多少个(不能加到j的情况下就为-1)。

根据上面的定义,这样如果前i-1种数相加能得到j的话,第i个数就可以留下mi个。此外,前i种数相加的和为j-ai时第i种数还剩下k的话,用前i种数相加和为j-ai时第i种数就一定还剩下k-1个(第i种数的值为ai)。由此得到递推式子:
d p [ i + 1 ] [ j ] = { m i ( d p [ i ] [ j ] ≥ 0 ) − 1 ( j < a i 或 者 d p [ i + 1 ] [ j − a i ] ≤ 0 ) d p [ i + 1 ] [ j − a i ] − 1 ( 其 它 ) dp[i+1][j] = \left\{ \begin{aligned} m_i& & (dp[i][j]\geq 0) \\ -1 & & (j < a_i 或者dp[i+1][j-a_i]\leq 0) \\ dp[i+1][j-a_i] - 1& & (其它) \\ \end{aligned} \right. dp[i+1][j]=mi1dp[i+1][jai]1(dp[i][j]0)(j<aidp[i+1][jai]0)()
这样,只要最终看是否满足dp[n][k]>= 0就知道答案了。

void solve(){
   memset(dp, -1, sizeof dp);
   dp[0][0] = 0;dp[i][j]表示前i种数字加到j时还剩下的个数,加不到j就为-1
   for(int i = 1;i <= n;i++){
       for(int j = 0;j <= k;j++){
           if(dp[i - 1][j] >= 0){//用前i-1种数相加就能得到j了,所以第i种数剩下的个数不变
               dp[i][j] = N[i];
           }else if(j < A[i] || dp[i][j-A[i]] <= 0){
               //如果所求和小于第i个数或者前i个数凑合连凑到j-A[i]都不行,则前j个数不肯能得到和为j
               dp[i][j] = -1;
           }else{
               dp[i][j] = dp[i][j-A[i]] - 1;
           }
       }
   }
   if(dp[n][k] >= 0)printf("Yes\n");
   else printf("No\n");
}

以下是将数组重复利用后的版本

int n, k, A[105], M[105];
int dp[100005];
void solve(){
    memset(dp, -1, sizeof dp);
    dp[0] = 0;//dp[i]表示加到和为i时,剩下的第i种数字个数,此为重复利用数组
    for(int i = 1;i <= n;i++){
        for(int j = 0;j <= k;j++){
            if(dp[j] >= 0){
                dp[j] = M[i];
            }else if(j < A[i] || dp[j - A[i]] <= 0 ){
                dp[j] = -1;
            }else{
                dp[j] =dp[j-A[i]] -1;
            }
        }
    }
    if(dp[k] >= 0)printf("Yes\n");
    else printf("No\n");
}
  1. 最长上升子序列问题

有一个长为n的数列a0, a1, …,an-1。请求出这个序列种最长的上升子序列的长度。上升子序列指的是对于任意的i < j都满足ai < aj的子序列。

这里有两种方式的DP。

分析:

方法一:

  • 定义dp[i]: 指的是以ai为末尾的最长上升子序列的长度。

以ai为结尾的上升子序列是:1.只包含ai的子序列;2.再满足j < i并且aj<ai的以aj结尾的上升子列末尾,追加上ai后得到的子序列。

这二者之一就可以得到如下的递推关系:

dp[i] = max{1, dp[j]+1 | j < i 并且aj < ai}

使用这一递推公式可以在O(n2)时间内解决这个问题。

void solve(){//使用O(n^2)的复杂度解决
    for(int i = 1;i <= n;i++){
        dp[i] = 1;
        for(int j = 1;j < i;j++){
            if(A[j] < A[i]){
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }
    printf("%d\n", dp[n]);
}

方法二:

经过分析得到,如果子序列长度相同,那么末位元素较小的在之后会更有优势(更容易增加它的子序列长度),所以反过来用DP针对长度相同情况下最小的末位元素求解。

  • dp[i] :定义为长度为i+1的上升子序列种末尾元素的最小值(不存在的话就是INF)

dp的维护:最开始全部dp[i]初始化为INF。然后从前到后逐渐考虑数组元素,对于每个aj,如果i=0或者dp[i-1] < aj的话,就用dp[i] = min(dp[i] , aj)进行更新(也就是长度为i的最长上升子序列的末尾元素值小于当前元素,所以就将长度为i+1的最小末尾元素更新为较小者)。最终找出使得dp[i] < INF的最大的i+1就是结果了。这个dp直接实现的话时间复杂度仍然是O(n2)。

通过分析可以使用二分优化上面的算法。首先dp数组种除INF之外时单调递增的,所以可以知道对于每个aj最多只需要1次更新。对于这次更新应该在什么位置,不必逐个遍历,可以利用二分搜索,这样就可以再O(nlogn)时间内求出结果。

void solve2(){
    memset(dp, INF, sizeof dp);
    for(int i = 1;i <= n;i++){
        *lower_bound(dp + 1, dp + n + 1, A[i]) = A[i];
    }
    printf("%d\n", (int)(lower_bound(dp + 1, dp + 1 + n, INF) - dp - 1));
    // 两个指针相减在C++种是longint。
}

个人理解:

dp[i]代表子序列长度为i时的末尾最小元素。当有两个子序列长度都为i时,我们只保留末尾元素较小的哪一个并更新dp[i]。使用cnt保存最长上升子序列的长度。我们遍历每一个元素A[i],如果A[i]大于dp[cnt]的话我们就更新dp[++cnt]为A[i],既是子序列长度加1;如果A[i] <= dp[cnt]的话,我们就需要通过二分找到dp中第一个大于A[i]的位置并更新它。这样也就是去更新前面的长度中,末尾的最小元素,对于后面新加入的数将会更有利。

再而,我们将这个看似繁琐的过程优化一下,就变成了:将dp数组初始化为INF,每次遍历一个数,这个数必然会作为最长上升子序列中某个长度的结尾的数(就像上面分析的一样),因此我们直接再dp数组种找到第一个大于等于A[i]的数,并将它更新为A[i]。这样就把两种情况用同一种方式处理了!妙啊妙!

  1. 有关计数问题的DP
  1. 划分数问题:

有n个无区别的物品,将他们划分成不超过m组,求出划分方法数摸M的余数。

书上给的定义的dp数组是:

dp[i][j], 讲的是j的i划分总数。但是当我严格的整理完它的思路时,发现并不是这样,或者说翻译有一点问题。这里给定的dp[i][j]代表的是j的不超过i的划分总数,也就是说i可以从1取到i-1。

接下来讨论不同情况,(书上的说法,有个别地方有一点别扭,容易让我弄晕)。

考虑n的m的划分总数,如果每一个划分ai都大于0,那么对每一项ai减去1就等于了[n-m的m的划分总数];另外考虑,如果我们将第m项划分为0的话,那么此时是不是就等于了[n的m-1的划分总数]。所以根据这两条分析,就可以得出以下状态转移方程:

dp[i][j] = dp[i][j-i] + dp[i-1][j]

然后我们需要对这个状态转移方程分情况讨论,万一给的数是5你让我划分成6份怎么行呢?

所以当j >= i时,

dp[i][j] = dp[i-1][j](j的不超过i-1划分,相当于当前划分位置i-1取0的全部情况)+dp[i][j-i](当前位置不取0,将每个划分位置减去1的情况)

当j < i 时,

dp[i][j] = dp[i-1][j];当前划分位置只能取0。

得到以下代码

void solve(){
    dp[0][0] = 1;//dp[i][j]表示j的不超过i的划分总数
    for(int i = 1; i <= m;i++){
        for(int j = 0;j <= n;j++){
            if(j - i >= 0){
                dp[i][j] = (dp[i - 1][j] + dp[i][j-i]) % M;
            }else{//划分总数小于将要划分的份数
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    printf("%d\n", dp[m][n]);
}

  1. 多重集组合数问题

有n种物品,第i种物品有ai个。不同种类的物品可以互相区分但相同种类的无法区分。从这些物品种取出m个的话,有多少种取法?求出方案数摸M的余数。

这道题十分具有特色,因为书上的推倒我花了一个小时没有看懂,所以我只有另寻它路,找了一下网上各位大佬的解释,才看明白。

首先,为了不重复计数,同一种类的物品最好一次性处理好。于是定义以下dp数组:

dp[i+1][j]:从前i种物品种取出j个的组合总数

为了从前i种物品中取出j个,可以从前i-1种物品种取出j-k个,再从第i种物品种取出k个添加进来;这里我们分两种情况讨论:

  • 没有取其i种物品:dp[i][j]在前i-1种物品中取出j个。
  • 取了第i种物品:dp[i+1][j-1]先从第i种中取一个,再从前i种中取出j-1个。但是这里得分两种情况:如果j>= ai +1,那么就会遍历到dp[i] [j-ai -1]的情况,也就是前i- 1种取了j-(ai + 1)个,那么第i种就要取ai + 1个,这显然是不可能的,因此要减去dp[i] [j-ai -1]的情况。

综合上述情况可以得到代码如下:

void solve(){
    //一个都不取的方法只有一种
    for(int i = 0; i <= n;i++){
        dp[i][0] = 1;
    }
    for(int i = 0;i < n;i++){
        for(int j = 1;j <= m;j++){
            if(j - 1- a[i] >= 0){
                //在有取余数的情况下,要避免减法运算得出的结果是负数。
                dp[i+1][j] = (dp[i][j] + dp[i+1][j-1] - dp[i][j-1-a[i]] + M) % M;
            }else{
                dp[i+1][j] = (dp[i+1][j-1] + dp[i][j]) % M;
            }
        }
    }
}

2.4 加工并存储数据的数据结构

猜你喜欢

转载自blog.csdn.net/qq_40596572/article/details/104646433