究极C++避坑指南

· · 个人记录

update:

- 加入了 $update$ 模块。 --- $update\ on\ 2023/03/28$: - 增添第 $12$ 章(毒瘤数据)。 --- $update\ on\ 2023/04/25$: - 更改第 $1$ 章输入字符串的代码,修改了第 $9$ 章的 Byte 拼写。 --- $update\ on\ 2023/10/20$: - 修改了附加章节(关于编译器,在后面更新中改为第 $15$ 章)的部分用词。 - 更新了 $update$ 模块的外观。 - 增添第 $14$ 章(对拍)。 - 更新了前言。 --- $update\ on\ 2023/10/27: update\ on\ 2023/10/28:

前言

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长度为 0,却访问了 vec_1,肯定越界啦。可以在定义时写成下面两种方法:

#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;
}
mul$ 执行算法:$1+2\times3+4=11 mul2$ 执行算法:$(1+2)\times(3+4)=21

编译器只会无脑替换,至于优先级,它才不在乎。于是,能定义函数就定义函数,写 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;
}

上述代码中,-1 会被转换成 4294967295(补码全是 1,都被当成了正数,首位表示负数的 1 也被转换成了正数)。于是,输出的将是 -1

八、空指针

大家一般给指针变量赋空值应该是下面这样的吧:

#include <bits/stdc++.h>
using namespace std;
int main()
{
    int *p=NULL;
    return 0;
}

其实,NULL是一个宏,它代表的是 0,可以起到赋空指针值的作用。但其实有个专门的 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

memsetByte(字节) 为单位进行赋值。如下:

#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;
}

至于上述代码第 7 行,是这样的:

一个 int4 字节,每个字节赋值为一,则 a 数组每个元素的值为二进制下的 000000010000000010000000100000001(即十进制下的 16843009)。那么如何赋值为 1 呢?此时 fill就能来帮忙。

#include <bits/stdc++.h>
using namespace std;
int main()
{
    int a[100];
    fill(a,a+100,3);//将a全部赋值为3
    return 0;
}

十、输入输出的速度

输入有:scanfcin。输出有:printfcout。但他们的速度加上不同的优化各不相同。

本机测试(输出 1100000):

cout:17s
printf:31s
关闭同步流+cout:1s
cout.tie(0)+cout:17s
O1,O2等优化:无效

关闭同步流的指令:ios::sync_with_stdio(false)。 我又测试了输出 11000000

cout:141s
printf:314s
关闭同步流+cout:3s

但是这只是我自己电脑上的测试结果,理论上讲,关闭同步流的 coutprintf 快一点点。关闭同步流后,coutprintf 不能混用!!

十一、未定义行为

几乎每个编程语言都有未定义行为(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;
}

我们先看第一个代码。这里的 p 指针指向了一个啥也不是的位置,输出是一个随机值。

第二个代码中,当 it 指针删掉 5 以后,这里的 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;
}

我们很容易发现,p 被销毁了两次,从而导致 RE。

11.1.4 总结

有下面几种指针 UB,比较难察觉,有一些上面没有列出来,现在在这里总结。千万不要像P4565题面的主人公一样,分数最后变成一场空。

  1. 解引用 nullptr 指针;

  2. 解引用 new 定义失败的指针;

  3. 不恰当使用 erase 成员函数;

  4. 重复销毁已经被销毁的指针;

  5. 指针越界访问;

  6. 用指针修改 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 优化,输入 2147483648,发现输出的竟然是 \texttt{Not overflow!}!这是怎么回事?

原来,是因为 O2 优化惹的祸!我们脱离计算机,谁都知道 x+1>x。所以,编译器会自动帮我们把程序改成这样:

#include <bits/stdc++.h>
using namespace std;
int main()
{
    int x;
    cin>>x;
    cout<<"Not overflow!";
    return 0;
}

注意,无符号型变量溢出是正常的。将上述程序的 x 改为 unsigned 类型,再试试?会发现结果就对了。

11.2.2 除 0 操作

我们都知道,0 是不能当除数的。所以,编译器会把结果看成 NaN(即 not a number)。

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);
}

原因: 其中 2,3,4 号牧场是奶牛初始点。我们发现:无论怎么走,当黄油放置在 5 号点,三个奶牛走到那里的距离均为 INF(假设 INF= 0x3F3F3F3F)。由于 int 溢出,所以 3 \times INF 变成了负数,WA 了。

解决: 我们可以定义 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要减多少次

但如果 p=0,那么 bug 出现了(看第十四章我惨痛的教训)。所以还是要看第十四章的对拍。

十三、运算符优先级

这个是最坑人的了。这不,我在做UVA272的时候,就被坑了。

比如,要实现循环读入字符,直到文件结束的功能,我开始是这么写的:

while(c=getchar()!=EOF)//先读入一个字符赋值给c,再判断是否等于EOF

很可惜,当你将 c 输出的时候,它输出了一个框。难道是乱码?No!当我们查看ASCII表并用整数形式输出 c 的时候,就豁然开朗了。c 的 ASCII 值竟然是 1。但是“标题开始”这个字符是特殊字符,正常是打不出来的,显示了一个框。

但是我们说回来,c 的值明显是不正常的。怎么回事呢?我们再看看运算符优先级表,发现 !== 的优先级不知道高哪里去了。所以上述代码的实际意义是:先判断输入的字符是不是 EOF,将表达式的结果(10)存储到 c 中。这样,c 的值是 1 也就不足为奇了。

当然,写三目运算符的时候也要注意这一点(有一些运算符比三目运算符要高,并且再次因为 C/C++ 的强大兼容性 导致输出和预想不一样的结果)。

十四、对拍

这个方法多用于高精度、做出不确定优化(如贪心、推式子等),而我也不经常用,所以对于对拍的话讲得较少。推荐阅读这篇文章。

尽量多造几组测试数据,考虑极端情况。用最大数据测,看看会不会超时。特别注意 0。(我惨痛的教训:210 -> 180)。

十五、关于编译器

15.1 CSP C++编译器配置

据我所知,目前 NOI 系列活动(包括 CSP/NOIP)使用 C++14 编译器。也就是说,atoiitoaauto 等 C++11 的新指令完全支持。

15.2 不同IDE设置

  1. Geany与Code::Blocks

他们俩默认支持 C++98,咋办?可以在代码第一行加上:#pragma GCC diagnostic error "-std=c++11"。大家比赛时应该常用 Jeany 吧。Jeany 可以在标签 生成=>设置生成命令 里面调,方法应该是同下面 Dev-C++,具体方法需要自己摸索,但这个方法不适合手残党,搞废了概不负责

  1. Dev-C++

几乎所有人都知道这个 IDE 吧。去到 工具[T] => 编译选项[C] ,在编译器标签里勾选 编译时加入以下命令: 在下面的命令框中输入 -std=c++11,即可启用 C++11 编译器。

  1. 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 是什么意思无关紧要,对代码没有影响,想了解的可以自行搜索)。不知道以后会不会改。