5.1 从C到C++

C语言是一门很有用的语言,但在算法竞赛中却不流行,原因在于它太底层,缺少一些“实用的东西”。例如,在2013年ACM/ICPC世界总决赛中,有1347份用C++提交,323份用Java提交,但一份用C提交的都没有。

既然如此,为什么还要花这么多篇幅介绍C语言呢?答案是C++太复杂了。与其把C++学得一知半解,还不如先把C语言的基础打好。前面已经提到过,前4章的所有代码都可以直接作为C++程序进行编译,所以请把前4章内容看作语言的核心部分,而把本章内容看作是可选的工具。如果某些工具难以掌握,索性避开就是了。

C++博大精深,但也有很多让人诟病的地方。好在算法竞赛中的大多数选手只会用到其中很少的特性,本章的任务就是把这些特性介绍给读者,以供选用。

提示5-1:C++的精华与糟粕并存。本章介绍的C++特性是算法竞赛中最常用的部分,虽然不是解题所必需的,但值得学习。

5.1.1 C++版框架

虽然前面介绍的内容都可以直接用在C++程序里,但有些并不是C++的推荐写法,只是为了更好地兼容C语言才如此编写的。下面是C++版的“a+b程序”:


#include<cstdio>
int main() {
  int a, b;
  while(scanf("%d%d", &a, &b) == 2) printf("%d\n", a+b);
  return 0;
}

和之前的C程序比较,唯一的区别是stdio.h变成了cstdio。事实上,stdio.h仍然存在,但是C++中推荐的头文件是cstdio。类似地,string.h变成了cstring,math.h变成了cmath,ctype.h变成了cctype。带.h后缀的头文件依然存在,但并不被C++所推荐使用。

提示5-2:C++能编译大多数C语言程序。虽然C语言中大多数头文件在C++中仍然可以使用,但推荐的方法是在C头文件之前加一个小写的c字母,然后去掉.h后缀。

下面是一个稍微复杂一点的程序,它展示了更多的常用C++特性。


#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 100 + 10;
int A[maxn];
int main() {
  long long a, b;
  while(cin >> a << b) {
    cout << min(a,b) << "\n";
  }
  return 0;
}

这次的变化就大多了,新增的两个头文件不再是以字符c开头的。有人会猜这一定是C++特有的头文件——的确如此。iostream提供了输入输出流,而algorithm提供了一些常用算法,例如代码中的min(1)。Cin>>a的含义是从标注输入中读取a,它的返回值是一个“已经读取了a的新流”,然后从这个新流中继续读取b。如果流已经读完,while循环将退出。这种方式相比scanf的最大优势就是不再需要记忆%d、%s等占位符,同时也避开了前面提到的“long long类型的输入输出占位符不统一”的问题。当然,C++流也不是完美的,其最大缺点就是运行太慢,以至于很多竞赛题目会在题面中的显著位置注明:本题的输入量很大,请不要使用C++的流输入(2)

还有一个新内容:using namespace std。这是什么意思呢?C++中有一个“名称空间”(namespace)的概念,用来缓解复杂程序的组织问题。例如张三写了一个函数叫my_good_function(意思是“我的优秀函数”),李四也写了这样一个函数,但作用和张三的不同。如果有一天需要把他们的程序合在一起用,就会出问题:函数不能重名。虽然后面会讲到C++支持函数重载,但如果这两个函数的参数类型也完全相同,则是不能重载的。一个解决方案是分别把函数写在各自的名称空间里,然后就可以用zhang3:my_good_function() 和li4:my_good_function( )这样的方式进行调用了。

基于这样的考虑,头文件iostream和algorithm里定义的内容放在std名称空间里。如果代码和该名称空间里的内容不重名,就可以用using namespace std的方法把std里的名字导入默认空间(3)。这样就可以用cin代替std::cin,cout代替std::cout,min代替std::min了。不信的话,你可以把这行语句注释掉,再编译一次试试。

提示5-3:C++中可以使用流简化输入输出操作。标准输入输出流在头文件iostream中定义,存在于名称空间std中。如果使用了using namespace std语句,则可以直接使用。

最后还有一个细节:声明数组时,数组大小可以使用const声明的常数(这在C99中是不允许的)。在C++中,这种写法更为推荐,因此本书后面的代码中一律采用这样的写法,而不是用#define声明常数。

顺便一提,C++中的数据类型和C语言很接近,最显著的区别是多了一个bool来表示布尔值,然后用true和false分别表示真和假。虽然仍然可以用int来表示真假,但是用bool可以让程序更清晰。

5.1.2 引用

第4章中曾经介绍过交换两个变量的方法,最后给出的例子用到了指针,看上去不太自然。C++提供了“引用”,虽然在功能上比指针弱,但是减少了出错的可能,提高了代码的可读性。使用引用交换两个变量的方法如下:


#include<iostream>
using namespace std;

void swap2(int& a, int& b) {
  int t = a; a = b; b = t;
}

int main() {
  int a = 3, b = 4;
  swap2(a, b);
  cout << a << " " << b << "\n";
  return 0;
}

是不是很自然?如果在参数名之前加一个“&”符号,就表示这个参数按照传引用(by reference)的方式传递,而不是C语言里的传值(by value)方式传递。这样,在函数内改变参数的值,也会修改到函数的实参。按照第4章介绍的方法进行gdb调试,用b swap2加一个端点,然后用r命令执行,如下所示:


Breakpoint 1, swap2 (a=@0x22ff4c: 3, b=@0x22ff48: 4) at swap2.cpp:5
5        int t = a; a = b; b = t;
(gdb) bt
#0  swap2 (a=@0x22ff4c: 3, b=@0x22ff48: 4) at swap2.cpp:5
#1  0x004013e1 in main() at swap2.cpp:10
(gdb) up
#1  0x004013e1 in main() at swap2.cpp:10
10       swap2(a, b);
(gdb) print &a
$1 = (int *) 0x22ff4c
(gdb) print &b
$2 = (int *) 0x22ff48

看到了吗?main函数里的变量a、b的地址和swap2执行时参数a、b引用的地址一样,实际上是“同一个东西”。

提示5-4:C++中的引用就是变量的“别名”,它可以在一定程度上代替C中的指针。例如,可以用“传引用”的方式让函数内直接修改实参。

细心的读者可能注意到了,为什么函数叫swap2而不是swap呢?因为algorithm这个头文件里已经提供过了swap,可以直接使用。这个swap比此处所写的swap2强大多了:它不仅同时支持int、double等所有内置类型,甚至还支持用户自己编写的结构体。它是怎么做到这一点的呢?我们很快就要学习到。

5.1.3 字符串

还记得前面所说的“数组不是一等公民”吗?C语言中的字符串就是字符数组,所以也不是“一等公民”,处处受限。例如,如何编写一个函数,把两个字符串拼接成一个长字符串?这个任务看上去简单,实际上却暗藏陷阱:新字符串的存储空间从哪里来?从第4章最后的讨论中可以知道:不能在函数中定义一个数组然后返回它的地址,因为函数返回后其中局部变量的地址便失效了。因此“字符串拼接”函数必须申请新的内存空间以存放结果,用完之后还要将申请的空间“退回去”,这会很麻烦。另外,字符串数组本身并不保存字符串长度,每次需要时都要用strlen函数重算一次。如果字符串很长,则strlen函数的开销将不容忽视(4)。为了避免不必要的strlen调用,可以在某个变量中保存字符串的长度,但这样一来,程序会变得更加复杂,难以调试。总而言之,C语言处理字符串并不方便。

C++提供了一个新的string类型,用来替代C语言中的字符数组。用户仍然可以继续用字符数组当字符串用,但是如果希望程序更加简单、自然,string类型往往是更好的选择。例如,C++的cin/cout可以直接读写string类型,却不能读写字符数组;string类型还可以像整数那样“相加”,而在C语言里只能使用strcat函数。

提示5-5:C++在string头文件里定义了string类型,直接支持流式读写。string有很多方便的函数和运算符,但速度有些慢。

考虑这样一个题目:输入数据的每行包含若干个(至少一个)以空格隔开的整数,输出每行中所有整数之和。如果只能使用字符与字符数组,一般有两种方案:一是使用getchar( )边读边算,代码较短,但容易写错,并且相对较难理解(5);二是每次读取一行,然后再扫描该行的字符,同时计算结果。如果使用C++,代码可以很简单。


#include<iostream>
#include<string>
#include<sstream>
using namespace std;

int main() {
  string line;
  while(getline(cin, line)) {
    int sum = 0, x;
    stringstream ss(line);
    while(ss >> x) sum += x;
    cout << sum << "\n";
  }
  return 0;
}

string类在string头文件中,而stringstream在sstream头文件中。首先用getline函数读一行数据(相当于C语言中的fgets,但由于使用string类,无须指定字符串的最大长度),然后用这一行创建一个“字符串流”——ss。接下来只需像读取cin那样读取ss即可。

提示5-6:可以把string作为流进行读写,定义在sstream头文件中。

虽然string和sstream都很方便,但string很慢,sstream更慢,应谨慎使用(6)

5.1.4 再谈结构体

C++除了支持结构体struct之外,还支持类class。C++不再需要用typedef的方式定义一个struct,而且在struct里除了可以有变量(称为成员变量)之外还可以有函数(称为成员函数)。在工程中,一般用struct定义“纯数据”的类型,只包含较少的辅助成员函数,而用class定义“拥有复杂行为”的类型,不过为了简单起见,本书中只使用struct而不使用class。另外,“成员变量”、“成员函数”、“构造函数”等很多C++ struct里新加的概念同样适用于class(7),所以不用担心在本章中学到的内容为“非主流”。

提示5-7:C++中的结构体除了可以拥有成员变量(用a.x的方式访问)之外,还可以拥有成员函数(用a.add(1,2)的方式访问)。为了简单起见,本书中只使用struct而不使用class,但struct的很多概念和写法同样适用于class。

下面是一个例子:


#include<iostream>
using namespace std;

struct Point {
  int x, y;
  Point(int x=0, int y=0):x(x),y(y) {}
};

Point operator + (const Point& A, const Point& B) {
  return Point(A.x+B.x, A.y+B.y);
}

ostream& operator << (ostream &out, const Point& p) {
  out << "(" << p.x << "," << p.y << ")";
  return out;
}

int main() {
  Point a, b(1,2);
  a.x = 3;
  cout << a+b << "\n";
  return 0;
}

上面的代码多数可以“望文知义”。结构体Point中定义了一个函数,函数名也叫Point,但是没有返回值。这样的函数称为构造函数(ctor)。构造函数是在声明变量时调用的,例如,声明Pointa,b(1,2)时,分别调用了Point( )和Point(1,2)。注意这个构造函数的两个参数后面都有“=0”字样,其中0为默认值。也就是说,如果没有指明这两个参数的值,就按0处理,因此Point( )相当于Point(0,0)。“:x(x),y(y)”则是一个简单的写法,表示“把成员变量x初始化为参数x,成员变量y初始化为参数y”。也可以写成:

Point(intx=0,inty=0){this->x=x;this->y=y;}

这里的“this”是指向当前对象的指针。this->x的意思是“当前对象的成员变量x”,即(*this).x。

提示5-8:C++中的结构体可以有一个或多个构造函数,在声明变量时调用。

提示5-9:C++中的函数(不只是构造函数)参数可以拥有默认值。

提示5-10:在C++结构体的成员函数中,this是指向当前对象的指针。

接下来为这个结构体定义了“加法”,并且在实现中用到构造函数。这样,就可以用a+b的形式计算两个结构体a和b的“和”了。

最后,定义这个结构体的流输出方式,然后就可以用cout << p来输出一个Point结构体p了。

5.1.5 模板

回顾第4章中介绍过的sum函数:


int sum(int* begin, int* end) {
  int *p = begin;
  int ans = 0;
  for(int *p = begin; p != end; p++)
    ans += *p;
  return ans;
}

这个函数没有错误,但比较局限——只能求整数数组的和,不能求double数组的和,更不能求Point数组的和。没关系,可以把这个函数改一下。


template<typename T>
T sum(T* begin, T* end) {
  T *p = begin;
  T ans = 0;
  for(T *p = begin; p != end; p++)
   ans = ans + *p;
   return ans;
}

这样,就可以用sum函数给double数组和Point数组求和了。


int main() {
  double a[] = {1.1, 2.2, 3.3, 4.4};
  cout << sum(a, a+4) << "\n";
  Point b[] = { Point(1,2), Point(3,4), Point(5,6), Point(7,8) };
  cout << sum(b, b+4) << "\n";
  return 0;
}

细心的读者应该已发现了上述sum函数和第4章中写的有点不同:把“ans+=*p”改成了“ans=ans+*p”。这样做的原因是Point结构体中只定义了“+”运算符,没有定义“+=”。

结构体和类(class)也可以是带模板的。例如,上述Point结构体中的x和y是int型的,但有时需要的是double型的x和y,“+”和“<<”的逻辑不变。可以用类似的写法把Point变成模板。


template <typename T<
struct Point {
  T x, y;
  Point(T x=0, T y=0):x(x),y(y) {}
};
然后把“+”和“<<”的代码也稍加改变:
template <typename T>
Point<T> operator + (const Point<T>& A, const Point<T>& B) {
  return Point<T>(A.x+B.x, A.y+B.y);
}
template <typename T>
ostream& operator << (ostream &out, const Point<T>& p) {
  out << "(" << p.x << "," << p.y << ")";
  return out;
}

这样就可以同时使用int型和double 型的Point 了:


int main() {
  Point<int> a(1,2), b(3,4);
  Point<double> c(1.1,2.2), d(3.3,4.4);
  cout << a+b << " " << c+d << "\n";
  return 0;
}

虽然模板在工程中的应用范围很广,而且功能十分强大(8),但选手们却很少会在算法竞赛中亲自编写模板。那为什么还要介绍模板呢?主要是因为模板有助于读者更好地理解STL。