挑战程序设计竞赛笔记
1. 第二章
2.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;
}
- 使用bfs求迷宫最短路时,我一般使用一个结构体构造每一个点的状态,其他的部分按照模板。
struct N{
int x, y, step;
N(int a, int b, int c):x(a), y(b), step(c){}
}
- 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;
}
- 关于C++全局变量的存放空间:
C++中的全局变量和静态变量被分配到同一块内存中:全局/静态存储区。
在C语言中显示初始化的全局变量保存在数据段中,而未显示初始化的全局变量保存在BSS段中。
2.2 一直往前!贪心法
- 利用贪心法,根据时间区间求选到最多的工作数量。首先按照每个工作的结束时间排好序然后优先选择结束时间更早的,选择完一个工作之后,保存下这个工作的结束时间最为下一个开始时间的比较。
关于本问题的贪心算法的证明:
结束时间越早之后可以选择的工作也就越多。这是该算法能够正确处理问题的一个直观解释。下面给出更严谨的证明:
(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;
}
- 使用贪心法求字典序最小问题:
给定长度为N的字符串S,构造出一个字符串T。起初T是空串,随后反复进行以下操作:
- 从S的头部删除一个字符,加到T的末尾
- 从S的尾部删除一个字符,加到T的末尾
目标是要构造出字典序尽可能小的字符串T。
1 <= N <= 2000;
S中只包含大写英文字母
分析:
这道题很容易想到一个贪心算法:
- 每次比较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;
}
- 使用贪心求添加最小标记点
给出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);
}
- 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 记录结果再利用的“动态规划”
-
最简单的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; }
-
最常用的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]);
}
- 最长公共子序列问题。给定两个字符串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]);
}
- 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]); }
- 交换价值与费用定义的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;
}
}
}
- 多重部分和问题
有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]=⎩⎪⎨⎪⎧mi−1dp[i+1][j−ai]−1(dp[i][j]≥0)(j<ai或者dp[i+1][j−ai]≤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");
}
- 最长上升子序列问题
有一个长为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]。这样就把两种情况用同一种方式处理了!妙啊妙!
- 有关计数问题的DP
- 划分数问题:
有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]);
}
- 多重集组合数问题
有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;
}
}
}
}