基础算法思想与搜索枚举
位运算
【一】基本位运算
举例:正数 1010 1010 1010 和 0011 0011 0011( 32 32 32 位)
-
按位与:
&
0010 0010 0010 -
按位或:
|
1011 1011 1011 -
按位异或:
^
1001 1001 1001 -
取反:
~
0101 0101 0101 1100 1100 1100 -
二进制左移:
<<
2 2 2 位: 101000 101000 101000 1100 1100 1100 -
二进制右移:
>>
2 2 2 位: 10 10 10 0 0 0
原码:二进制
反码:原码取反
补码:反码 + 1 +1 +1
【二】位运算应用
-
表示集合:二进制中的每一位 0 / 1 0/1 0/1 代表集合中某元素的存在情况。
-
特殊题目要求的位运算。
技巧:
1.获取一个数二进制的某一位:
inline int getBit(int a,int b){return (a>>b)&1;}
- 将一个数二进制设为 0 / 1 / 取反 0/1/\text{取反} 0/1/取反:
inline int unsetBit(int a,int b){return a&~(1<<b);} // 设为 0
inline int setBit(int a,int b){return a|(1<<b)}; // 设为 1
inline int flapBit(int a,int b){return a^(1<<b)}; // 取反
A P1100 高低位交换
分为前 16 16 16 位和后 16 16 16 位。
-
右移 16 16 16 位。
-
左移 16 16 16 位。
-
相加。
#include <iostream>
using namespace std;
int main() {
unsigned int n;
cin>>n;
cout<<(n>>16)+(n<<16)<<endl;
}
【三】bitset
std::bitset
是标准库中一个存储
0
/
1
0/1
0/1 的大小不可变的容器。
#include <bitset>
bitset<1010> s,s1; // 声明对象
// bitset<长度> s;
// bitset<长度> s(初始化元素);
// bitset<长度> s(string 类型 01 串);
// bitset<长度> s(char[] 类型 01 串);
// bitset 的下标从 0 开始,遍历时是从右往左遍历,也就是说它的第 0 位是二进制最右面的那个数。
s[1]=1; // 和数组一样,通过下标更改元素
s.set(1,true); // 含义同上
cout<<s[1]<<endl; // operator []
cout<<s.test(1)<<endl; // 功能同上
cout<<(s!=s1)<<endl; // operator == !=
s|=s1; // operator & | ^ &= |= ^=
s<<=1; // operator << >> <<= >>=
cout<<s.count()<<endl; // 统计 1 的个数
cout<<s.size()<<endl; // 定义时定义的长度
cout<<s.any()<<endl; // 是否至少有一个 1
cout<<s.none()<<endl; // 是否所有位都为 0
cout<<s.all()<<endl; // 是否所有位都为 1
cout<<s.to_string()<<endl; // 转化成字符串然后输出
// 以下两个成员函数如果无参数,默认是全部设 0 或全部取反。
s.reset(1); // 将第 1 位改为 0(同:s[1]=0)
s.flip(1); // 将第 1 位取反(同:s[1]^=1)
B B3632 集合运算1
使用 bitset
。
#include <bits/stdc++.h>
using namespace std;
bitset<64> a,b,c;
int main(){
int x,y;
cin>>x;
for(int i=1,tmp;i<=x;i++)cin>>tmp,a.set(tmp,1);
for(int i=1,tmp;i<=x;i++)cin>>tmp,b.set(tmp,1);
cout<<a.count()<<endl;
c=a&b;
for(int i=0;i<=63;i++)
if(c[i])
cout<<i<<" ";
cout<<endl;
return 0;
}
搜索
【一】搜索分类
【二】暴力枚举
对于比较复杂的题目,暴力枚举可以拿部分分。
框架:
void dfs(int n,...){
if(所有状态均已枚举完成){
...
return;
}
for(遍历当前状态所有可能性){
if(判断某一个可能是否合法){
调整状态;
dfs(n+1,...);
撤回状态调整(回溯);
}
}
}
A P1157 组合的输出
暴力枚举即可,时间复杂度 O ( C n r ) \mathcal{O}(C^r_n) O(Cnr)。
#include <bits/stdc++.h>
using namespace std;
int a[21];
bool vis[21];
int n, r;
void dfs(int pos) {
if(pos == r + 1) {
for(int i = 1; i <= r; ++i)
cout << setw(3) << a[i];
cout << endl;
return;
}
for(int i = pos; i <= n; ++i)
if(!vis[i] && a[pos - 1] < i) {
vis[i] = 1;
a[pos] = i;
dfs(pos + 1);
vis[i] = 0;
}
}
int main() {
cin >> n >> r;
dfs(1);
return 0;
}
【三】图的遍历
深度优先搜索和广度优先搜索(时间复杂度均为 O ( n + m ) \mathcal{O}(n+m) O(n+m)。
// 深度优先搜索
void dfs(int u){
vis[u]=1;
for(所有 u 相邻的节点 v){
if(!vis[v])
dfs(v);
}
}
// dfs(s);
// 广度优先搜索
void bfs(int s){
queue<XXX> q;
q.push(s);
vis[s]=1;
while(!q.empty()){
XXX u=q.front();
q.pop();
for(所有 u 相邻的节点 v){
if(!vis[v])q.push(v),vis[v]=1;
}
}
}
B P5318 【深基18.例3】查找文献
可以用邻接表存图,数据输入完成后直接对所有的 vector
排序,然后分别
DFS
\textit{DFS}
DFS,
BFS
\textit{BFS}
BFS 即可。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100010;
vector<int> p[MAXN];
int n, m;
bool vis[MAXN];
queue<int> q;
void dfs(int x) {
cout << x << " ";
for (int i = 0; i < p[x].size(); i++)
if (!vis[p[x][i]]) {
vis[p[x][i]] = true;
dfs(p[x][i]);
}
}
void bfs() {
memset(vis, 0, sizeof vis);
vis[1] = 1;
q.push(1);
while (!q.empty()) {
int x = q.front();
q.pop();
cout << x << " ";
for (int i = 0; i < p[x].size(); i++)
if (!vis[p[x][i]]) {
vis[p[x][i]] = true;
q.push(p[x][i]);
}
}
}
int main() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
p[u].push_back(v);
}
for (int i = 1; i <= n; i++)
sort(p[i].begin(), p[i].end());
vis[1] = true;
dfs(1);
cout << endl;
bfs();
cout << endl;
return 0;
}
【四】拓展技巧
I. 记忆化搜索
记忆化搜索是一种通过记录已经遍历过的状态的信息,从而避免对同一状态重复遍历的搜索实现方式。
记忆化搜索确保了每个状态只访问一次。
需要保证无后效性,即从不同的路径走到一个共同状态,后续的状态变迁都是一样的,和之前采用何种路径到这个状态没有关系。
C P1434 滑雪
从该点出发能够滑行的距离最大值是一定的,故可以使用记忆化搜索。
无后效性:从某一点出发能够滑行的距离最大值是固定的。
用 f x , y f_{x,y} fx,y 记录从 x , y x,y x,y 点出发能够滑行的距离最大值,每次 DFS \textit{DFS} DFS 到一个点,先看一下这个点的 f f f 是否已经求出,如果求出了直接调用即可,而不必重新再运算一次。
#include <bits/stdc++.h>
using namespace std;
int arr[100][100];
int dis[4][2] = {0, 1, 0, -1, 1, 0, -1, 0};
int f[100][100];
int max2 = 0;
int n = 0;
int m = 0;
int dfs(int x, int y) {
int max1 = 0;
if (f[x][y] != 0) {
return f[x][y];
}
for (int i = 0; i < 4; i++) {
int fx = x + dis[i][1];
int fy = y + dis[i][0];
if (fx >= 0 && fx < n && fy >= 0 && fy < m && arr[fx][fy] < arr[x][y]) {
max1 = max(max1, dfs(fx, fy));
}
}
return max1 + 1;
}
int main() {
int max1 = -1;
cin >> n >> m;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
cin >> arr[i][j];
}
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
f[i][j] = dfs(i, j);
max1 = max(max1, f[i][j]);
}
}
cout << max1 << endl;
return 0;
}
II. 剪枝初步
常见剪枝方法:
-
最优性剪枝:当当前解已经比已有解差时,直接停止搜索。
-
可行性剪枝:当当前解已经不可用时,直接停止搜索。
ans=最坏情况;
void dfs(int n,...){
if(当前的答案比 ans 还要差)return; // 最优性剪枝
if(当前的状态已经不可用了)return; // 可行性剪枝
// 判断是否所有状态均已枚举完成。
// 遍历当前状态的所有可能性,并进行对应调整。
}
D P1434 猫粮规划
搜索时注意剪枝即可。
#include <bits/stdc++.h>
using namespace std;
int a[41], w[41];
int n, l, r, ans;
void dfs(int pos) {
int sum = 0;
for(int i = 1; i < pos; ++i)
sum += a[i] * w[i];
if(sum > r)
return;
if(pos == n + 1) {
if(l <= sum && sum <= r)
++ans;
return;
}
for(int i = 0; i <= 1; ++i) {
a[pos] = i;
dfs(pos + 1);
}
}
int main() {
cin >> n >> l >> r;
for(int i = 1; i <= n; ++i)
cin >> w[i];
dfs(1);
cout << ans << endl;
return 0;
}
贪心算法
贪心算法,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。贪心算法所做出的仅是某种意义上的局部最优解。
使用贪心算法解题一定需要保证从不同的路径走到一个共同状态,后续的状态变迁都是一样的,和之前采用何种路径到这个状态没有关系,即无后效性。同时一定要确保自己大概能证明其正确性;如果不能保证,那么就要做好这道题拿的分很少或者拿不到分的心理准备。
思路和实现流程:从问题的某一初始解出发。当能朝给定总目标前进一步时,求出可行解的一个解元素;最后,由所有解元素组合成问题的一个可行解。
证明正确性:大胆假设,小心求证。
-
反证法:如果交换方案中任意两个元素/相邻的两个元素后,答案不会变得更好,那么可以推定目前的解已经是最优解了。
-
归纳法:先算出边界情况(例如 n = 1 n=1 n=1)的最优解 F 1 F_1 F1,然后再证明:对于每个 F n F_n Fn,都可以由 F n − 1 F_{n-1} Fn−1 推导出结果来。
证伪贪心算法往往采用构造出一个反例的方式。
A P1208 [USACO1.3] 混合牛奶 Mixing Milk
优先选择从单价最低的农民处购买牛奶,由便宜到贵进行购买。
证明(反证法):易证,如果已经由便宜到贵全部购买,换一位农民购买不会更便宜,即不会使答案更优。
#include <bits/stdc++.h>
using namespace std;
struct node{
int p,a;
}c[5010];
int n,m,sum,ans;
bool cmp(node a,node b){
if(a.p==b.p)return a.a>b.a;
return a.p<b.p;
}
int main(){
cin>>n>>m;
for(int i=1;i<=m;i++)cin>>c[i].p>>c[i].a;
sort(c+1,c+m+1,cmp);
for(int i=1;i<=m;i++){
if(sum+c[i].a>=n){
ans+=c[i].p*(n-sum);
break;
}
sum+=c[i].a,ans+=c[i].p*c[i].a;
}
cout<<ans<<endl;
return 0;
}
B B3635 硬币问题
可以证明,贪心算法是有后效性的,因此此题应当使用 DP \textit{DP} DP 或搜索。
// bfs
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
const int N = 1000010, C[] = {1, 5, 11};
int n, dis[N];
void bfs() {
queue<int> q;
q.push(0);
memset(dis, 0x3f, sizeof(dis));
dis[0] = 0;
while(!q.empty()) {
int cur = q.front();
q.pop();
if(cur == n) cout<<dis[cur];
for(int c : C)
if(dis[cur] + 1 < dis[cur + c])
dis[cur + c] = dis[cur] + 1, q.push(cur + c);
}
}
int main() {
cin>>n;
bfs();
return 0;
}
二分
二分查找
在一个有序序列中查找某一元素的算法。
时间复杂度 O ( log n ) \mathcal{O}(\log{n}) O(logn)。
猜数游戏
小 A 想一个 1 1 1~ 2 10 2^{10} 210 的整数,小 B 猜这个数。小 A 会告诉小 B 她说的数字是大于、等于还是小于生成的数字。
朴素算法是从 1 1 1 开始枚举,时间复杂度 O ( n ) \mathcal{O}(n) O(n),会超时,而且小 A 和小 B 会很累。
优秀的策略:
假定序列单调递增:
-
在指定的区域之间尝试中间值。
-
如果中间值是答案则直接输出答案。
-
如果中间值太大,则处理左区间。
-
如果中间值太小,则处理右区间。
-
每次可以将范围缩小至一半。
一般来说,二分查找只能在有序数组中查找某一元素。
二分搜索模板
int a[100010];
int binary_search(int L,int R,int k){
int l=L,r=R,ans=-1;
while(l<=r){
int mid=(l+r)/2;
if(a[mid]==k)l=mid+1;// 或 r=mid-1;
else if(a[mid]<k)l=mid+1;
else r=mid-1;
}
return ans;
}
A 查找重复序列中的某数字第一次出现的位置
int a[100010];
int binary_search(int x){
int ans=-1,l=1,r=n;
while(l<=r){
int mid=(l+r)/2;
if(a[mid]==x)ans=x,r=mid-1;
else if(a[mid]<x)l=mid+1;
else r=mid-1;
}
return ans;
}
二分答案
二分答案与二分查找类似。
有一类题,答案具有有界性和单调性且直接求解很难,但是如果验证某解是否符合题意相对容易。那这类题便可采用二分答案求解。
单调性:设答案区间 [ 1 , n ] [1,n] [1,n],存在某一合法解 x x x,使得 x + 1 , x + 2 , . . . , n x+1,x+2,...,n x+1,x+2,...,n 都符合要求,而 1 , 2 , . . . , x − 1 1,2,...,x-1 1,2,...,x−1 都不符合要求。则 x x x 为答案,且可以说答案具有单调性。
二分答案本质:每次缩小答案的规模,在答案合法条件下不断逼近最优,得到答案。
一般而言,可用二分答案解决的题目具有以下特征:
-
求最小的最大值
-
求最大的最小值
-
求满足条件的最小(大)值
-
求最接近某值的一个值
-
求最小的能满足条件的代价
B P9455 [入门赛 #14] 塔台超频 (Hard Version)
二分答案。
#include <bits/stdc++.h>
using namespace std;
// 二分答案
int a[500010],b[500010],n;
bool check(int x){
int maxx=a[1]+b[1]+x;
for(int i=2;i<=n;i++){
if(maxx<a[i])return false;
maxx=max(maxx,a[i]+b[i]+x);
}
return true;
}
int main(){
cin>>n;
for(int i=1;i<=n;i++)cin>>a[i]>>b[i];
int l=0,r=1e9,mid,ans;
while(l<=r){
mid=(l+r)/2;
if(check(mid))ans=mid,r=mid-1;
else l=mid+1;
}
cout<<ans<<endl;
return 0;
}
C P1824 进击的奶牛
二分答案。
#include <bits/stdc++.h>
using namespace std;
// 二分答案
int a[500010],n,c;
bool check(int x){
int cnt=0,lc=a[1];
for(int i=2;i<=n&&cnt<=n-c;i++){
if(a[i]-lc<x)++cnt;
else lc=a[i];
}
return cnt<=n-c;
}
int main(){
cin>>n>>c;
for(int i=1;i<=n;i++)cin>>a[i];
sort(a+1,a+n+1);
int l=0,r=a[n]-a[1],mid,ans;
while(l<=r){
mid=(l+r)/2;
if(check(mid))ans=mid,l=mid+1;
else r=mid-1;
}
cout<<ans<<endl;
return 0;
}
前缀和、差分与双指针
前缀和
前缀和可以简单理解为“数列的前 n n n 项的和”。它是一种重要的与处理方式,能大大降低查询的时间复杂度(查询的时间复杂度为 O ( 1 ) \mathcal{O}(1) O(1))。
一般来讲,我们会预处理一个数组。对数组中的每个元素,我们记录从起始到该元素对应下标/状态所有数字的总和。
- 一维前缀和
预处理: s u m i = ∑ j = 1 i a j = s u m i − 1 + a i sum_i=\sum\limits_{j=1}^{i}a_j=sum_{i-1}+a_i sumi=j=1∑iaj=sumi−1+ai
查询: ∑ i = l r a i = s u m r − s u m l − 1 \sum\limits_{i=l}^{r}a_i=sum_r-sum_{l-1} i=l∑rai=sumr−suml−1
- 二维前缀和
预处理: s u m i , j = ∑ k = 1 i ∑ l = 1 j s u m k , l = s u m i − 1 , j + s u m i , j − 1 + s u m i − 1 , j − 1 + a i , j sum_{i,j}=\sum\limits_{k=1}^{i}\sum\limits_{l=1}^{j}sum_{k,l}=sum_{i-1,j}+sum_{i,j-1}+sum_{i-1,j-1}+a_{i,j} sumi,j=k=1∑il=1∑jsumk,l=sumi−1,j+sumi,j−1+sumi−1,j−1+ai,j
查询: ∑ i = x 1 x 2 ∑ j = y 1 y 2 a i , j = s u m x 2 , y 2 + s u m x 1 − 1 , y 1 − 1 − s u m x 2 , y 1 − 1 − s u m x 1 − 1 , y 2 \sum\limits_{i=x1}^{x2}\sum\limits_{j=y1}^{y2}a_{i,j}=sum_{x2,y2}+sum_{x1-1,y1-1}-sum_{x2,y1-1}-sum_{x1-1,y2} i=x1∑x2j=y1∑y2ai,j=sumx2,y2+sumx1−1,y1−1−sumx2,y1−1−sumx1−1,y2
- 多维前缀和
可以使用容斥原理推导。
A P8218 【深进1.例1】求区间和
一维前缀和模板。
#include <iostream>
using namespace std;
typedef long long ll;
const int N = 1e5 + 5;
int a[N];
ll sum[N];
int main() {
int n;
cin >> n;
for(int i = 1; i <= n; ++i) {
cin >> a[i];
sum[i] = sum[i - 1] + a[i];
}
int m;
cin >> m;
for(int i = 1, l, r; i <= m; ++i) {
cin >> l >> r;
cout << sum[r] - sum[l - 1] << endl;
}
return 0;
}
差分
差分可以简单地理解为前缀和的逆运算。
它也是一种重要的处理方式,能大大降低多次修改但最终只有一次查询的时间复杂度。
一般来讲,我们会处理一个数组,对数组中的每个元素,我们记录该元素对应下表/状态代表的值与其之前一个元素的值的差值。
形式化地讲,对一个长度为 n n n 的数组 a a a,我们对其建立差分数组 f f f,结果如下:
f i = { a 1 i = 1 a i − a i − 1 2 ≤ i ≤ n f_i=\begin{cases} a_1&i=1 \\ a_i-a_{i-1}&2\le i\le n \end{cases} fi={a1ai−ai−1i=12≤i≤n
主要作用:多次修改但最终只有一次查询。
举例:给定 n n n 个整数, m m m 次修改,第 i i i 次修改将区间 [ l i , r i ] [l_i,r_i] [li,ri] 中的数字统一 + x i +x_i +xi。
如果使用暴力,需要对每个修改从 l i l_i li 枚举到 r i r_i ri,时间复杂度会很大。
但是如果我们按照如下方式使用差分:
f l i + = x i , f r i + 1 − = x i f_{l_i}+=x_i,f_{r_i+1}-=x_i fli+=xi,fri+1−=xi
在最后查询时,我们遍历 1 1 1~ n n n,然后进行前缀和即可得到每一个修改后的元素。
B 差分模板
#include <bits/stdc++.h>
using namespace std;
int n,m,a[100010],l[100010],r[100010],x[100010],f[100010];
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++)cin>>a[i];
for(int i=1;i<=m;i++)cin>>l[i]>>r[i]>>x[i],f[l[i]]+=x[i],f[r[i]+1]-=x[i];
int cur=0;
for(int i=1;i<=n;i++)cur+=f[i],a[i]=cur;
for(int i=1;i<=n;i++)cout<<a[i]<<" ";
cout<<endl;
return 0;
}
双指针
双指针是同时使用两个指针,指向序列、链表结构上的位置,或指向树、图结构中的节点,通过同向移动或相向移动来维护、统计信息。
-
利用序列有序性。
-
维护区间信息。
-
快慢指针(例:在单向链表中找环)。
时间复杂度一般为 O ( n ) \mathcal{O}(n) O(n)。
C P1102 A-B 数对
- 维护序列有序性。
将数组排序后使用双指针,前指针枚举 A A A,后指针尝试寻找满足条件的 B B B。
对于某个 A A A,当后指针指向的元素小于 A − C A-C A−C 时,不断向数组增大方向跳跃,直至找到 ≥ A − C \ge A-C ≥A−C 的元素。
#include <bits/stdc++.h>
using namespace std;
int n,c,l,r,a[200010];
long long sum;
int main(){
cin>>n>>c;
for(int i=1;i<=n;i++)cin>>a[i];
sort(a+1,a+n+1);
l=r=1;
for(int i=1;i<=n;i++){
while(a[l]<a[i]-c&&l<=n)++l;
while(a[r]<=a[i]-c&&r<=n)++r;
if(a[i]-a[l]==c)sum+=r-l;
}
cout<<sum<<endl;
return 0;
}
D P1147 连续自然数和
- 维护区间信息。
使用双指针,用 l , r l,r l,r 两个指针,维护区间和 s = ∑ i = l r i s=\sum\limits_{i=l}^{r}i s=i=l∑ri
在 r < M r<M r<M( r ≥ M r\ge M r≥M 时显然不符合条件)的情况下,每次让 r r r 右移一位, s ← s + r s\gets s+r s←s+r。当 s > M s>M s>M 时,不断右移 l l l,同时 s ← s − l s\gets s-l s←s−l。如果移动 l l l 后 s = M s=M s=M,则输出此时的 l , r l,r l,r。
#include <bits/stdc++.h>
using namespace std;
int l,r,m,cur;
int main(){
cin>>m;
l=r=cur=1;
while(r<m){
while(cur>m)cur-=l++;
if(cur==m)cout<<l<<" "<<r<<endl;
cur+=++r;
}
return 0;
}
E 在单向链表中找环
- 快慢指针(例:在单向链表中找环)。
首先两个指针都指向链表的头部,令一个指针一次走一步,另一个指针一次走两步,如果它们相遇了,证明有环,否则无环。
总时间复杂度为 O ( n ) \mathcal{O}(n) O(n)。
找到环的起点 找到环的起点 找到环的起点:在两个指针相遇后,将其中一个指针移到表头,让两者都一步一步走,再度的位置即为环的起点。
证明:设而这第一次相遇时慢指针一共走 k k k 步,快指针走了 2 k 2k 2k 步,设单指针在环上走了 l l l 步,环长为 C C C,有 2 k = n × C + l + ( k − l ) ⟹ k = n × C 2k=n\times C+l+(k-l)\implies k=n\times C 2k=n×C+l+(k−l)⟹k=n×C。
第一次相遇时 n = 1 n=1 n=1,即 k = C k=C k=C。