究极C++避坑指南
update:
- 感谢 @qwqer233 纠正了 11.2.3 章节的用词不准确问题。
-
更新了附加章节(关于编译器,在后面更新中改为第
15 章)的内容。 -
将附加章节改为第
15 章。 -
新增了 12.4 章节。
前言
C++ 是一个很好的编程语言,但它因为许多特性而受到一些人的唾弃。一起来看看这份避坑指南,你一定会有收获。如果有更好的点子或有补充的点,亦或是 Markdown 问题,请评论或私信。
*如果有 Typora 等阅读 Markdown 等工具,建议把源码复制进去,在文章的开头加上 [TOC] 回车,即可获得最佳体验。源码可以自己打(没有傻子吧),也可以私信我。
一、赋值运算
平时,我们经常用到赋值运算。但你知道它有返回值吗?对比以下程序:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a,b;
cin>>a>>b;
if(a=b)cout<<"Yes"<<endl;//a=b 表示将b的值赋给a,返回a
else cout<<"No"<<endl;
return 0;
}
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a,b;
cin>>a>>b;
if(a==b)cout<<"Yes"<<endl;//a==b 表示 a与b相等
else cout<<"No"<<endl;
return 0;
}
利用这点,可以写出以下代码(读入字符串):
#include <bits/stdc++.h>
using namespace std;
int main()
{
char ch;
while((ch=getchar())!='\n')
{
...
}
return 0;
}
二、自加或自减带返回值
看下面两个:
int a=1,b;
b=++a;//先++,再赋值
cout<<a<<' '<<b<<endl;
int a=1,b;
cin>>a;
b=a++;//先赋值,再++
cout<<a<<' '<<b<<endl;
关于数组与 ++与 --操作时,一定要注意!
三、万能头文件
万能头文件包含了几乎所有 C++ 头文件,在比赛中经常被人们使用。它的写法是:
#include <bits/stdc++.h>
但它也有不好的地方,比如下面:
#include <bits/stdc++.h>
using namespace std;
int map[100];//与 STL的map容器重名,编译错误
int main()
{
for(int i=0;i<100;i++)cin>>map[i];
for(int i=0;i<100;i++)cout<<map[i]<<' ';
return 0;
}
一定要慎用万能头文件!
四、vector 越界
有人会问:vector不是不定长数组吗?怎么也会越界?看下面:
#include <bits/stdc++.h>
using namespace std;
int main()
{
vector<int> vec;//长度为0
vec[1]=1;//越界
return 0;
}
这下明白了吧。相当于下面:
#include <bits/stdc++.h>
using namespace std;
int a[0];
int main()
{
a[1]=1;//越界
return 0;
}
vector长度为
#include <bits/stdc++.h>
using namespace std;
int main()
{
vector<int> vec(100);//定义一个长度为100的vector变量vec
vec[1]=1;
return 0;
}
#include <bits/stdc++.h>
using namespace std;
int main()
{
vector<int> vec;//长度为0
vec.resize(100);//将长度变成100
vec[1]=1;
return 0;
}
五、隐式类型转换
编译器有时会“偷偷”地把类型给转换。如:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a=true;//bool -> int
if(a)//int -> bool
{
char ch=32;//int -> char
printf("%d\n",ch);
int i=3;
double j=3.1;
cout<<i+j<<endl;//int -> double
}
return 0;
}
隐式类型转换有时会造成 bug 的出现,一定要小心!
六、宏
宏可以用符号代替一些指令,如:
#define pi 3.1415926
#define INF 0x3f3f3f3f
宏十分方便,可以让代码精简。但有时也会产生 bug:
#include <bits/stdc++.h>
using namespace std;
#define mul(a,b) a*b
#define mul2(a,b) (a)*(b)
int main()
{
cout<<mul(1+2,3+4)<<' '<<mul2(1+2,3+4)<<endl;
return 0;
}
编译器只会无脑替换,至于优先级,它才不在乎。于是,能定义函数就定义函数,写 define 容易错。
七、vector大小
上面第四章讲过 vector的注意事项和第五章的类型转换,这里讲讲 size。
vector.size() 的函数其实返回的是 unsigned int型,它和一个 int型加减会让 int变成 unsigned int,如果结果是负数,就会变成一个很大的值(下溢)。建议写代码的时候写成 (int)vector_name.size()。比如:
#include <bits/stdc++.h>
using namespace std;
int main()
{
vector<int> v={1,2,3};//C++11 新写法
if(-1>v.size())cout<<"-1";
else cout<<"v.size()";
return 0;
}
上述代码中,
八、空指针
大家一般给指针变量赋空值应该是下面这样的吧:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int *p=NULL;
return 0;
}
其实,NULL是一个宏,它代表的是 nullptr 赋空指针值。
#include <bits/stdc++.h>
using namespace std;
int main()
{
int *p=nullptr;//正确,赋空指针
int a=nullptr;//错误,无法转换成int
bool pd=nullptr;//正确,可以转成bool
return 0;
}
九、memset 和 fill
memset以 Byte(字节) 为单位进行赋值。如下:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a[100];
memset(a,0,sizeof(a));//a数组全为零
memset(a,1,sizeof(a));
return 0;
}
至于上述代码第
一个 fill就能来帮忙。
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a[100];
fill(a,a+100,3);//将a全部赋值为3
return 0;
}
十、输入输出的速度
输入有:scanf,cin。输出有:printf,cout。但他们的速度加上不同的优化各不相同。
本机测试(输出
cout:17s
printf:31s
关闭同步流+cout:1s
cout.tie(0)+cout:17s
O1,O2等优化:无效
关闭同步流的指令:ios::sync_with_stdio(false)。
我又测试了输出
cout:141s
printf:314s
关闭同步流+cout:3s
但是这只是我自己电脑上的测试结果,理论上讲,关闭同步流的 cout 比 printf 快一点点。关闭同步流后,cout 和 printf 不能混用!!
十一、未定义行为
几乎每个编程语言都有未定义行为(Undefined Behavior,简称 UB),最著名的是 C/C++(因为它强大的兼容性)。用一张图(网图)总结:
UB 一般不容易检查出来,有时会导致“我在本地 AC,却在 OJ/比赛上 RE/WA/TLE”的情况。UB 分为很多种,下面来逐个介绍。
11.1 地址错误型
11.1.1 数组或 vector 越界
在一个经验丰富的 OIer 的代码里,这个应该在程序里很少见。我们在第四章已经介绍过 vector 越界的相关内容。这里就不详细介绍。
11.1.2 指针漂移
看到这个标题,你可能会有疑问。看看下面这两个代码,你就明白了。
#include <bits/stdc++.h>
using namespace std;
int a[5]={0,1,2,3,4};
int main()
{
int *p=a+5;
cout<<*p<<endl;
return 0;
}
#include <bits/stdc++.h>
using namespace std;
set<int> s;
void init()
{
for(int i=0;i<10;i++)s.insert(i);
}
int main()
{
init();
for(set<int>::iterator it=s.begin();it!=s.end();it++)
{
int tmp=*it;
if(tmp==5)s.erase(it);
else cout<<tmp<<endl;
}
return 0;
}
我们先看第一个代码。这里的
第二个代码中,当 s.end() 和 it 已经失效,于是便 RE 了。
11.1.3 重复销毁已经被销毁的指针
比如:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int *p=new int;
delete p;
delete p;
return 0;
}
我们很容易发现,
11.1.4 总结
有下面几种指针 UB,比较难察觉,有一些上面没有列出来,现在在这里总结。千万不要像P4565题面的主人公一样,分数最后变成一场空。
-
解引用
nullptr指针; -
解引用
new定义失败的指针; -
不恰当使用
erase成员函数; -
重复销毁已经被销毁的指针;
-
指针越界访问;
-
用指针修改
const类型变量。
11.2 变量问题型
11.2.1 有符号型变量溢出
先来看一个代码:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int x;
cin>>x;
if(x+1<x)cout<<"Overflow!";
else cout<<"Not overflow!";
return 0;
}
现在,请把这份代码拷贝到自己的 IDE 里,测试几组数据,看看结果怎样?再打开 -O2 优化,再看看结果?
如果你的编译器版本较高或开了 -O2 优化,输入
原来,是因为 O2 优化惹的祸!我们脱离计算机,谁都知道
#include <bits/stdc++.h>
using namespace std;
int main()
{
int x;
cin>>x;
cout<<"Not overflow!";
return 0;
}
注意,无符号型变量溢出是正常的。将上述程序的 unsigned 类型,再试试?会发现结果就对了。
11.2.2 除 0 操作
我们都知道,
11.2.3 自动省略无副作用的无限循环代码
来看看这份代码:
#include <bits/stdc++.h>
using namespace std;
bool function()
{
unsigned cnt=0;
while(1)
if(cnt<0)return true;//永远不可能返回
return false;//永远不可能返回
}
int main()
{
if(function())cout<<"Amazing!";
else cout<<"Normal!";
return 0;
}
如果在低版本编译器下,程序死循环;但在新版本编译器下,程序无输出,直接结束。原来,是因为新版本编译器直接对 main() 函数进行优化,直接优化成空。
11.2.4 顺序问题
看看下面代码:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a=1;
cout<<(a++ + ++a)<<endl;
return 0;
}
根据我们第二章的内容,这个程序到底输出结果是什么?编译器不一定是按照从左到右进行运算的。当然,有人为了考验编译器考验人脑智商,写出下面的代码:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a=1;
cout<<(++++++++++a)<<endl;
return 0;
}
当然,这个代码也能够运行。
11.2.5 访问未初始化变量
#include <bits/stdc++.h>
using namespace std;
int main()
{
int a;
if(a)cout<<"true"<<endl;
if(!a)cout<<"false"<<endl;
return 0;
}
这个代码会输出什么?在老的编译器里,这两个字符串都会被输出。
十二、毒瘤数据
12.1 卡朴素 Dijkstra
基本所有题朴素 Dijkstra 都过不了吧。
12.2 卡 SPFA
SPFA 是一个非常好的算法,既能求最短/最长路,也能判正/负环。但是,容易被卡。这里推荐读这篇文章。在没有负边的情况下,只能用 Dijkstra 堆优化版本。如果有负边,就用一些奇怪优化的 SPFA。
12.3 容易漏的情况
比如 P1828(请先阅读题目)。
第十一个 Hack 点是这样的,我们大部分人(包括我)都没想到:
计算代码:
int ans=INT_MAX;
for(int i=1;i<=p;i++)//枚举黄油放置地点
{
int tot=0;
spfa(i);
for(int j=1;j<=n;j++)
tot+=1ll*dist[cow[j]];//cow是奶牛位置,dist是原点到i点的距离
ans=min(ans,tot);
}
原因: 其中 0x3F3F3F3F)。由于 int 溢出,所以
解决: 我们可以定义 long long 数组存 0x3f3f3f3f,这样不会溢出。但如果用 printf 输出,别忘了格式符改一下。
正确代码:
long long ans=INT_MAX;
for(int i=1;i<=p;i++)//枚举黄油放置地点
{
long long tot=0;
spfa(i);
for(int j=1;j<=n;j++)
tot+=1ll*dist[cow[j]];
ans=min(ans,tot);
}
12.4 除 0 操作
出现这种情况一般不是单纯的去算除法。若出现这种错误,一般是用除法来简化减法的(如果你之前因为其它情况而出现这个 bug,请在评论中指出)。如:
int x=(a-b)/p+1;//算从a每次减p,减到<b要减多少次
但如果
十三、运算符优先级
这个是最坑人的了。这不,我在做UVA272的时候,就被坑了。
比如,要实现循环读入字符,直到文件结束的功能,我开始是这么写的:
while(c=getchar()!=EOF)//先读入一个字符赋值给c,再判断是否等于EOF
很可惜,当你将
但是我们说回来,!= 比 = 的优先级不知道高哪里去了。所以上述代码的实际意义是:先判断输入的字符是不是 EOF,将表达式的结果(
当然,写三目运算符的时候也要注意这一点(有一些运算符比三目运算符要高,并且再次因为 C/C++ 的强大兼容性 导致输出和预想不一样的结果)。
十四、对拍
这个方法多用于高精度、做出不确定优化(如贪心、推式子等),而我也不经常用,所以对于对拍的话讲得较少。推荐阅读这篇文章。
尽量多造几组测试数据,考虑极端情况。用最大数据测,看看会不会超时。特别注意
十五、关于编译器
15.1 CSP C++编译器配置
据我所知,目前 NOI 系列活动(包括 CSP/NOIP)使用 C++14 编译器。也就是说,atoi,itoa,auto 等 C++11 的新指令完全支持。
15.2 不同IDE设置
- Geany与Code::Blocks
他们俩默认支持 C++98,咋办?可以在代码第一行加上:#pragma GCC diagnostic error "-std=c++11"。大家比赛时应该常用 Jeany 吧。Jeany 可以在标签 生成=>设置生成命令 里面调,方法应该是同下面 Dev-C++,具体方法需要自己摸索,但这个方法不适合手残党,搞废了概不负责。
- Dev-C++
几乎所有人都知道这个 IDE 吧。去到 工具[T] => 编译选项[C] ,在编译器标签里勾选 编译时加入以下命令: 在下面的命令框中输入 -std=c++11,即可启用 C++11 编译器。
- CSP/NOIP 的编译指令
S 组没有优化,编译命令为:-std=c++14。J 组有 -O2 优化,编译命令为:-O2 -std=c++14。由于编译指令无法更改,所以比赛时尤其要注意 UB 的存在。
但在 CSP-J/S 2023 中,J 和 S 都用了编译指令 std=c++14 -O2 -static(至于 -static 是什么意思无关紧要,对代码没有影响,想了解的可以自行搜索)。不知道以后会不会改。