11.11二分&&前缀和补充
11/11
每日一题:1547.切棍子的最小成本
思路:区间 DP:从记忆化搜索到递推
一、寻找子问题
示例 1 的 cuts=[1,3,4,5],为方便描述,把 0 和 n=7 也视作切开的位置(木棍端点),得到 cuts=[0,1,3,4,5,7]。我们要解决的问题(原问题)是:切割一根左端点为
cuts[0]=0,右端点为cuts[5]=7的棍子的最小成本。
第一刀切在哪?枚举:
在 cuts[1]=1 切一刀,木棍分成两段。第一段左端点为 cuts[0]=0,右端点为 cuts[1]=1;第二段左端点为 cuts[1]=1,右端点为 cuts[5]=7。
在 cuts[2]=3 切一刀,木棍分成两段。第一段左端点为 cuts[0]=0,右端点为 cuts[2]=3;第二段左端点为 cuts[2]=3,右端点为 cuts[5]=7。
在 cuts[3]=4 切一刀,木棍分成两段。第一段左端点为 cuts[0]=0,右端点为 cuts[3]=4;第二段左端点为 cuts[3]=4,右端点为 cuts[5]=7。
在 cuts[4]=5 切一刀,木棍分成两段。第一段左端点为 cuts[0]=0,右端点为 cuts[4]=5;第二段左端点为 cuts[4]=5,右端点为 cuts[5]=7。
接下来,继续计算这两段木棍各自的最小切割成本。同样地,枚举切割的位置。依此类推。这些问题都是和原问题相似的、规模更小的子问题,可以用递归解决。
注:动态规划有「选或不选」和「枚举选哪个」两种基本思考方式。本题用到的是「枚举选哪个」。
二、状态定义与状态转移方程
根据上面的讨论,我们需要在递归过程中,知道当前切的这根棍子,左端点在哪,右端点在哪。因此,定义状态为
dfs(i,j),表示切割一根左端点为cuts[i],右端点为cuts[j]的棍子的最小成本。枚举在
cuts[k]处切一刀,其中k=i+1,i+2,…,j−1,木棍变成两段:第一段左端点为
cuts[i],右端点为cuts[k],切割这段木棍的最小成本为dfs(i,k)。
第二段左端点为cuts[k],右端点为cuts[j],切割这段木棍的最小成本为dfs(k,j)。
成本之和为dfs(i,k)+dfs(k,j),再算上切割之前木棍的长度cuts[j]−cuts[i],得到
dfs(i,k)+dfs(k,j)+cuts[j]−cuts[i]。枚举k=i+1,i+2,…,j−1,所有成本取最小值,得\(dfs(i,j)= \min^{j−1}_{k = i+1} dfs(i,k)+dfs(k,j)+cuts[j]−cuts[i]\)其中
cuts[j]−cuts[i]与 k 无关,可以提到循环外面。递归边界:
dfs(i,i+1)=0。此时木棍中没有要切割的位置,所以切割成本为 0。递归入口:
dfs(0,m−1),也就是答案。其中 m 是添加了 0 和 n 之后的 cuts 数组的长度。三、递归搜索 + 保存递归返回值 = 记忆化搜索
考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个
memo数组中。
如果一个状态不是第一次遇到(memo 中保存的结果不等于 memo 的初始值),那么可以直接返回 memo 中保存的结果。注意:memo 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 0,并且要记忆化的 dfs(i,j) 也等于 0,那就没法判断 0 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 −1。本题由于
cuts[j]−cuts[i]>0,所以除了递归边界以外,dfs 的返回值均为正数,所以也可以把初始值设置为 0。四.复杂度分析
时间复杂度:\(O(m ^ 3)\),其中 m 为 cuts 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 = 状态个数 × 单个状态的计算时间。本题状态个数等于 \(O(m ^ 2 )\),单个状态的计算时间为 O(m),所以总的时间复杂度为$ O(m ^ 3 )\(。 空间复杂度:\)O(m^ 2)$。保存多少状态,就需要多少空间。五、1:1 翻译成递推(DP法)
我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。具体来说,
f[i][j]的定义和dfs(i,j)的定义是一样的,都表示切割一根左端点为cuts[i],右端点为cuts[j]的棍子的最小成本。相应的递推式(状态转移方程)也和 dfs 一样:
\(f[i][j]= \min^{j−1}_{k=i+1} f[i][k]+f[k][j]+cuts[j]−cuts[i] \)
初始值
f[i][i+1]=0,翻译自递归边界dfs(i,i+1)=0。答案为
f[0][m−1],翻译自递归入口dfs(0,m−1)。问:如何思考循环顺序?什么时候要正序枚举,什么时候要倒序枚举?
答:这里有一个通用的做法:盯着状态转移方程,想一想,要计算
f[i][j],必须先把f[k][j]算出来,由于 i<k,那么只有 i 从大到小枚举才能做到。同理,必须先把同一行的f[i][k]算出来,由于 j>k,那么只有 j 从小到大枚举才能做到。
法一:记忆化搜索
class Solution {
public:
int minCost(int n, vector<int>& cuts) {
cuts.push_back(0);
cuts.push_back(n);
ranges::sort(cuts);
int m = cuts.size();
vector<vector<int>> memo(m , vector<int>(m));
auto dfs = [&](auto& dfs , int i , int j) -> int {
if(i + 1 == j) return 0;
int& res = memo[i][j];
if(res) return res;
res = INT_MAX;
for (int k = i + 1; k < j; k++) {
res = min(res , dfs(dfs , i , k) + dfs(dfs , k , j));
}
res += cuts[j] - cuts[i];
return res;
};
return dfs(dfs , 0 , m - 1);
}
};
法二:DP
class Solution {
public:
int minCost(int n, vector<int>& cuts) {
cuts.push_back(0);
cuts.push_back(n);
ranges::sort(cuts);
int m = cuts.size();
vector<vector<int>> dp(m , vector<int>(m));
for (int i = m - 3; i >= 0 ; i--) {
for (int j = i + 2; j < m; j++) {
int res = INT_MAX;
for (int k = i + 1 ; k < j ; k ++){
res = min(res , dp[i][k] + dp[k][j]);
}
dp[i][j] = res + cuts[j] - cuts[i];
}
}
return dp[0][m - 1];
}
};
代码注释:
C++20新特性:
ranges::sort,让排序更简洁高效(正序、逆序、自定义排序)range::sort(cuts); //等价于 sort(cuts.begin() , cuts.end()); // 传统逆序排序方式 std::sort(numbers.rbegin(), numbers.rend()); // 使用 ranges::sort 进行逆序排序 std::ranges::sort(numbers, std::greater<>()); // 传统自定义排序方式 bool customCompare(int a, int b) { // 自定义排序规则 return a % 3 < b % 3; } std::sort(numbers.begin(), numbers.end(), customCompare); //或者用lambda函数写成 // sort(a, a+n, [](int a,int b){return a>b;});//降序排序 // 使用 ranges::sort 进行自定义排序 std::ranges::sort(numbers, [](int a, int b) { // 自定义排序规则 return a % 3 < b % 3; });捕获选项
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型
所谓
捕获列表,其实可以理解为参数的一种类型,lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的,这时候捕获列表可以起到传递外部数据的作用。
- [] Capture nothing (or, a scorched earth strategy?)
- [&] Capture any referenced variable by reference
- [=] Capture any referenced variable by making a copy
- [=, &foo] Capture any referenced variable by making a copy, but capture variable foo by reference
- [bar] Capture bar by making a copy; don’t copy anything else
- [this] Capture the this pointer of the enclosing class
捕获形式 说明 [] 不捕获任何外部变量 [变量名, …] 默认以值的形式捕获指定的多个外部变量(用逗号分隔),如果引用捕获,需要显示声明(使用&说明符) [this] 以值的形式捕获this指针 [=] 以值的形式捕获所有外部变量 [&] 以引用形式捕获所有外部变量 [=, &x] 变量x以引用形式捕获,其余变量以传值形式捕获 [&, x] 变量x以值的形式捕获,其余变量以引用形式捕获 1. 值捕获
#include <iostream> using namespace std; void learn_lambda_func_3() { auto add = [v1 = 1.2, v2 = 2](int x, int y) -> double{ return x + y + v1 + v2; }; std::cout << "add(3, 4) = " << add(3, 4) << std::endl; } int main() { learn_lambda_func_3(); return 0; }输出:
add(3, 4) = 10.2
与参数传值类似,值捕获的前期是变量可以拷贝。不同之处则在于,被捕获的变量在 lambda 表达式被创建时拷贝,而非调用时才拷贝:
#include <iostream> using namespace std; void learn_lambda_func_1() { int value_1 = 1; auto copy_value_1 = [value_1] { return value_1; }; value_1 = 100; auto stored_value_1 = copy_value_1(); // 这时, stored_value_1 == 1, 而 value_1 == 100. // 因为 copy_value_1 在创建时就保存了一份 value_1 的拷贝 cout << "value_1 = " << value_1 << endl; cout << "stored_value_1 = " << stored_value_1 << endl; } int main() { learn_lambda_func_1(); return 0; }输出结果:
value_1 = 100
stored_value_1 = 12. 引用捕获
与引用传参类似,引用捕获保存的是引用,值会发生变化。
void learn_lambda_func_2() { int value_2 = 1; auto copy_value_2 = [&value_2] { return value_2; }; value_2 = 100; auto stored_value_2 = copy_value_2(); // 这时, stored_value_2 == 100, value_1 == 100. // 因为 copy_value_2 保存的是引用 cout << "value_2 = " << value_2 << endl; cout << "stored_value_2 = " << stored_value_2 << endl; }输出结果:
value_2 = 100
stored_value_2 = 1003. 隐式捕获
手动书写捕获列表有时候是非常复杂的,需要我们在捕获列表中显示列出Lambda表达式中使用的外部变量。但是,开发者想了一个办法,就是将这种机械性的工作可以交给编译器来处理。让编译器根据函数体中的代码来推断需要捕获哪些变量,这种方式称之为隐式捕获。
隐式捕获有两种方式,分别是
[=]和[&]。[=]表示以值捕获的方式捕获外部变量,[&]表示以引用捕获的方式捕获外部变量。#include <iostream> using namespace std; class test { public: void hello() { cout << "test hello!\n"; }; void lambda() { auto fun = [this] { // 捕获了 this 指针 this->hello(); // 这里 this 调用的就是 class test 的对象了 }; fun(); } }; int main() { //显示传递 cout << "/*******显示传递*******/" << endl; int i = 12; cout << &i << endl; //输出:010FF834 //012(8进制)十进制是10。 auto func = [i] { cout << &i << " " << i <<endl; }; func(); // 输出:010FF828 12 cout << endl; //隐式传递 cout << "/*******隐式传递*******/" << endl; [](int j) { cout << &j << " " << j << endl; }(23); //或通过“函数体”后面的‘()’传入参数 cout << endl; cout << "1.拷贝捕获" << endl; int a = 123; cout << &a << endl; //输出:010FF81C auto f = [=] { cout << &a << " "<<a << endl; }; f(); //输出:010FF810 123 cout << endl; cout << "2.引用捕获" << endl; int b = 234; auto f_1 = [&] { cout << &b << " " << b<<endl; }; b = 345; cout << &b << endl; //输出:010FF804 f_1(); //输出:010FF804 345 cout << endl; cout << "3.拷贝与引用混合" << endl; //[&, x],变量x以引用形式捕获,其余变量以传值形式捕获 int c = 456, d = 567; cout << &c << " " << &d << endl; //输出:010FF7EC 010FF7E0 auto f_2 = [=, &d] { cout << &c << " " << &d <<" "<< c << " " << d << endl; //输出:010FF7D4 010FF7E0 456 567 }; f_2(); cout << endl; cout << "4.[bar] 指定引用或拷贝" << endl; int e = 678; cout << &e << endl; //输出:010FF7C4 auto f_3 = [e] { cout << &e << " " << e << endl; }; f_3(); // 输出:010FF7B8 678 cout << endl; cout << "5.[this] 捕获 this 指针" << endl; //我们要跳到类中了 test t; t.lambda(); return 0; }
Lambda表达式还支持混合的方式捕获外部变量,这种方式主要是以上几种捕获方式的组合使用。修改捕获变量
前面我们提到过,在Lambda表达式中,如果以传值方式捕获外部变量,则函数体中不能修改该外部变量,否则会引发编译错误。那么有没有办法可以修改值捕获的外部变量呢?这是就需要使用
mutable关键字,该关键字用以说明表达式体内的代码可以修改值捕获的变量,示例:int main() { int a = 123; cout << &a << endl; auto f = [a]()mutable {cout << ++a <<endl; };//不会报错 cout << a << endl;//输出123 f();//输出124 cout << &a << endl; }输出结果:
006FF954
123
124
006FF954嵌套 Lambda 表达式
你可以将 lambda 表达式嵌套在另一个中,如下例所示。 内部 lambda 表达式将其自变量与 2 相乘并返回结果。 外部 lambda 表达式通过其自变量调用内部 lambda 表达式并在结果上加 3。
// nesting_lambda_expressions.cpp // compile with: /EHsc /W4 #include <iostream> using namespace std; int main() { // The following lambda expression contains a nested lambda // expression. int timestwoplusthree = [](int x) { return [](int y) { return y * 2; }(x) + 3; }(5); // Print the result. cout << timestwoplusthree << endl; }输出:
13
泛型 Lambda (C++14)
我们曾提到了
auto关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。但是 Lambda 表达式并不是普通函数,所以 Lambda 表达式并不能够模板化。这就为我们造成了一定程度上的麻烦:参数表不能够泛化,必须明确参数表类型。幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始,Lambda 函数的形式参数可以使用
auto关键字来产生意义上的泛型:void learn_lambda_func_4() { auto generic = [](auto x, auto y) { return x + y; }; std::cout << "generic(1,2) = " << generic(1, 2) << std::endl; std::cout << "generic(1.1,2.2) = " << generic(1.1, 2.2) << std::endl; }输出:
generic(1,2) = 3
generic(1.1,2.2) = 3.3
99.激光炸弹
思路:二维前缀和
Sum(矩阵和) = s[x2][y2] - s[x2][y1- 1] - s[x1 - 1][y2] + s[x1 - 1][y1 - 1]
本题的题意较为模糊,题解依旧沿用原解释:不能炸掉正方形边上的点。
因此边长为R的正方形,实际上可以摧毁的面积为 (R - 1)^2。
那么本题中
x2=i , y2 = j , x1 = x2 - r + 1 , y1 = y2 - r + 1代入得:
res = max(res , s[i][j] - s[i][j - r] - s[i - r][j] + s[i - r][j - r]);
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5010;
int s[N][N];
int main(){
int n , r;
scanf("%d%d" , &n, &r);
r = min(r , 5001);//如果r>5001则能覆盖到所有的点,直接输出即可。
int x , y , w;
for (int i = 0; i < n; i++) {
cin >> x >> y >> w;
//因为求前缀和是从1开始,相当于把点(x ,y)平移到(x + 1 , y + 1)处
x ++ , y ++;
s[x][y] += w;//因为Xi , Yi可能重合
}
for (int i = 1; i <= 5001; i++) {
for( int j = 1 ; j <= 5001 ; j ++){
s[i][j] += s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] ;//这里省去了分别创建a ,s数组的过程,直接把前缀和迭代到原数组里。
}
}
int res = 0;
for (int i = r; i <= 5001 ; i++) {
for (int j = r; j <= 5001; j++) {
res = max(res , s[i][j] - s[i - r][j] - s[i][j - r] + s[i - r][j - r]);
//求子矩阵的和里的公式。
}
}
cout << res;
return 0;
}
100.增减序列
思路:差分+贪心
本题难在思路,公式的推导。
- 问题的转化:把一个区间所有的数+/- 1 -> 对差分数组 l 、r+1 两个数进行加减操作,因此可以简化。
- 最后要求所有数相同,对应差分数组即:b1、bn+1任意,b2~bn所有数为0
- 原问题即问,至少操作多少次可使b2~bn都为0,b1有多少种值。
分析完毕,接下来看对b数组的操作,分为4种:记对a区间左右下标为L、R进行加减,实际要进行加减的数为bl、b(r+1)。因为每次只能对b加减1,记b2-bn中所有正数和为P,负数和为q。
- 2 <= L <=R <= n - 1 在b2~bn中令某数+1,另一个数-1。 可以进行min{p , q}次,直到另一个为0。
- L = 1 R <= n - 1 b1 和 b2~bn 中的某数 +/-1 。可以进行abs(p - q)次
- 2 <= L R = n b(n+1)和b2~bn中的某数+/- 1 。可以进行abs(p - q)次
- L = 1 R =n 对所有数加减一,无意义。
最小次数 = min{p , q} + abs(p - q) =
max{p , q}b1的取值取决于情况2、3进行的次数,情况2 可以进行0~abs(p - q)次,因此取值有
abs(p-q) + 1种可能对于题目数的定义:0≤ai<2147483648 ,因此定义为int即可。而p、q需定义为long long 型。
常见数的范围:
int:-2147483648~2147483647 ,是10位数
long : -2147483648~2147483647 (后来32位的操作系统中,int 占4字节,范围与 int 相同; )
long long: -9223372036854775808 ~ 9223372036854775807
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int a[N] , b[N];
int main(){
int n ;
scanf("%d" , &n);
for (int i = 1; i <= n; i++) {
scanf("%d" , &a[i]);
b[i] = a[i] - a[i - 1];
}
LL p =0 , q = 0;
for (int i = 2; i <= n; i++) {
if(b[i] > 0) p += b[i];
else q -= b[i];
}
cout << max(p , q) << endl;
cout << abs(p - q) + 1 << endl;
return 0;
}
102.最佳牛围栏
思路:前缀和+二分+平均值
经验:一般最大平均值->转化成二分问题。
解释:假设答案为ans ,每次check的平均数为avg , 当avg<=ans 时,总可以找到一段长度不小于F的区间使得其平均值>=avg;而当avg>ans时,找不到这样的区间。具有二段性,因此可以二分。求平均值时,我们又可以
把每个数都减去avg,便只用判断其总和的正负即可。判断一段数的和又可以用到
前缀和的方法:q[r] - q[l - 1]。想一次循环跑完,便用到
双指针法。要想总和最大,每个循环里把r固定,只需q[l - 1]最小即可,因此记录下全过程q[l - 1]的最小值,再找q[r]的最大值。
取minv时如果q[i1] < q[i2],即使i++,也相当于取的是i1~j+1这个区间,长度大于了F。
我们要寻找一段数列,这个数列满足,长度不小于L,并且它的子段和非负。也就是我们需要的二分判定。
二分:首先我们的mid=(l+r)/2,这里不能右移运算,浮点数除法。然后进行前缀和运算,s[i] = s[i - 1] + a[i] - mid, 这里要找的最优解[l , r] , a[l - 1] 尽可能小,a[r] 尽可能大。
二分最难的地方就在于
check函数的写法,我们先来捋一遍思路,防止写代码的时候思路混乱①:我们要找的是 有没有一段不小于F的区间,使这段区间的平均数尽可能的大,如果我们找到了一段连续的区间且区间长度不小于F且平均数大于我们二分的平均数 那么大于这个数且区间也满足的一定满足了 我们直接判断正确即可
②:根据平均数的一个基本应用,每个数减去我们所算的平均数,如果大于0 ,那么他本身就大于平均数再使用前缀和,如果s[r] - s[l -1] >=0那么说明区间里的数满足条件。因此需要s[r]尽可能大,s[l -1]尽可能小。
③:据②我们还可以继续优化,因为我们不仅需要找F大小区间内,我们还要找>F大小区间内的,我们如果用二次for太费时间了,我们这里可以使用双指针的做法,我们设
i=0,j=F每次使两个数++ 因为i,j始终满足相距F的距离,所以我们用一个变量minv来存储sum[i]所遍历到的最小值,这样我们比较的距离一定是≥F的,此时若用sum[j]去减去minv的话,就能得到我们的最优解,如果这个最优解>= 0 那么就满足我们的指定条件。
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 1e5 + 10;
double a[N] , s[N];
int n ,f;
bool check(double avg){
for (int i = 1; i <= n; i++) {
s[i] = s[i - 1] + a[i] - avg;
}
double minv = 0;
for (int i = 0 , j = f; j <= n; i ++ , j++) {
minv = min(minv , s[i]);
if(s[j] - minv >= 0) return true;
}
return false;
}
int main(){
scanf("%d%d" , &n , &f);
double l = 0 , r = 0;
for (int i = 1; i <= n; i++) {
cin >> a[i];
r = max(r , a[i]);//区间最大值就是数组最大值
}
while(r - l > 1e-5) //题目要求保留三位小数,一般e的指数绝对值取保留位数+2即可
{
double mid = (l + r) / 2;
if(check(mid)) l = mid;//check()==true说明avg<ans,更新左区间
else r = mid;
}
cout << int(r * 1000) ;
return 0;
}
11/12补充
113.特殊排序
思路:二分
本题与一般排序有三个区别:
是交互式,你并不知道大小关系,只能通过调用
compare接口询问;是大小不具备传递性,比如a < b,b < c 并不能推出a < c;
不能超过一万次询问,数据范围为1000,nlogn略小于一万,而CBA算法在最坏情况下的下界也就是nlogn。
对于其性质2仅仅导致答案不唯一,题目仅要求输出一种答案,所以可以忽视该条件。
采用
二分插入排序解决该问题,首先将第一个元素压入向量里,然后二分查找合适的位置r,将待插入元素插入到向量末尾,从后往前不断交换相邻的两个数直到待插入的元素到达指定位置r。注意该二分算法的写法,循环退出时
l = r + 1,意味着r位置的必然小于待插入的元素,r+1及其之后的元素都大于待插入的元素。(r返回的是小于待插入元素x的最大元素的位置,r + 1位置的元素是大于x的,目标就是将x放在r位置的后面)(比yxc大佬的代码更加简练了一点,因为我觉得
mid=1+r+1>>2以致于代码最后还要多一次判断不容易理解,不如直接在循环里就判断好了,后面插入到末尾后只用不断前移,不用再进行判断了)。
class Solution {
public:
vector<int> specialSort(int N) {
vector<int> res(1,1);
for(int i = 2;i <= N;i++){
int l = 0,r = res.size() - 1;
while(l <= r){
int mid = l + r >> 1;
if(compare(res[mid],i)) l = mid + 1;
else r = mid - 1;
}
res.push_back(i);
for(int j = res.size() - 2;j > r;j--) swap(res[j],res[j + 1]);
}
return res;
}
};




浙公网安备 33010602011771号