前前言
这篇教程写于2017年7月大学一年级的后期的军训期间,此后做了一些小改动。本文希望能用比较简单的方式帮助学弟学妹们迈入编程的门槛。因为比较简洁,文中可能会出现一些错别字和一些小错误,一些概念的解释并不是很严谨,但并不影响大家从0到1迈入编程的大门。如果发现错误,欢迎评论指正。
前言
很多人说,C语言已经是一门过时的编程语言了。但是,经典永不过时,作为即将步入计算机科班的一名大学生,C语言是从今往后深入学习计算机科学以及软件工程的一个重要基础。这篇教程送给即将步入大学的你,希望能给你一个良好的开始。这并不是一个完整的教材,而是一个梗概,一个指导,这个指导上面的具体内容是远远不够的,你需要按照这个指导上面的知识体系来利用互联网资源进行拓展学习,构建自己的知识体系。
C语言的编译器
C语言是一门编程语言,它需要一个编译器来将所写的符合C语言语法规范的一段程序代码来翻译成Windows系统下的计算机所能识别的*.exe二进制指令文件,所以我们需要下载一个编译器来翻译我们的程序代码。
C语言的编译器有很多,VC、VS、Dev C++等等都可以编译C语言代码,当然也可以使用专用的代码编辑器如VS-Code联动GCC编译器一起使用。但作为入门学习使用,还是推荐使用Dev C++,它能让你暂时专注于代码本身。
第一个C语言程序
1 |
|
这就是一个最简单的C语言程序,它的功能就是在控制台上输出一句 Hello World! 的英文。接下来我们用通俗的语言来分析这个程序到底做了一些什么。
1 |
这句代码的作用是从C语言内置的一个仓库中取出_printf()_这个功能和其它功能。我们把这样具有功能的东西称为函数,把这个仓库叫做C语言的头文件,C语言内置了很多头文件不止有这一个,除此之外你还可以从网络获取更有趣的头文件方便开发和实验。
1 | int main() { |
这样结构表示一个函数的主体,在这里main是这个函数的名称。在C语言中,程序会先进入main函数执行命令。有关函数的更多细节,我们会在后面讨论。
1 | printf("Hello World!"); |
这条语句是这个程序的核心,它向控制台输出 Hello World! 这句话。
1 | return 0; |
这条语句表示向调用main函数的地方返回0值,main函数的调用者一般是系统。
通过上面的代码我们可以大概看到,C语言中语句的结尾是分号;
,我们也可以通过一个分号来直接表示一个空语句。一个语句块要用大括号{}
括起来。
数据类型、变量与常量
数据类型
数据类型是类型相同的一组值的集合。例如,1、4、5、7、2……都是整数,因此我们可以把它们归纳为整数类型。在C语言中,最基本的常见数据类型有无类型void、整型int、字符型char、布尔型bool、浮点型float等。下表列举了常见的数据类型举例。
类型 | 整型 | 字符型 | 布尔型 | 浮点型 |
---|---|---|---|---|
举例 | -3000,-1,0,1,2,32767…… | ‘a’,’b’,’c’,’1’,’\0’,’(‘,’*’…… | true,false | 0.01,0.1…… |
说明 | 表示一个整数 | 表示一个字符,用单引号括起来 | 表示对与错 | 表示一个小数 |
占用字节 | 4 | 1 | 1 | 4 |
每个基本类型都有自己的取值范围。比如,在旧版C语言标准中,整型int的取值范围是-32768~32767,大小是2字节。而在现在32位或64位的计算机上,它的取值范围是-2147483648~2147483647(也就是-2^31~2^31-1)。如果给一个int型的变量赋予超出这个范围的值,那么程序就在运行时会发生溢出错误。如果想选用更大范围的整型变量,可以选择long int型或者long long型。
C语言中数据的类型可以相互转化。转化方法可以分为自动转化和强制转化。
自动转化是系统根据赋值时等号两边值的类型进行“类型识别”,具体过程比较复杂,此处不展开。但是可以举一个简单的例子,比如某个整型值为100,而此处的语境要求这个值必须是布尔型,那么这个值会被自动转化成布尔型。转成多少呢?C语言中有这样一个强制转化规定:非0值即为真,所以这个100的值转化为布尔型后为真。
强制转化是开发者直接通过特定的语法进行转化。语法规则如下:
转化后变量 = (要转化成的类型)被转化变量;
强制转化可能导致程序崩溃,所以强制转化也要满足某些规则,具体情况在此也不拓展。
字符型其实是整型的一种特殊形式。C语言所支持的char字符一共只有256个。所以char值转化为整型后的取值为0~255,0~255中的每一个值都对应ASCII码表中的每一个字符。ASCII码表是一个编码表,它可以将真实世界中一个个客观存在的字符翻译成计算机喜欢处理的数字。例如,字符’a’通过ASCII码表翻译后,所对应的整型值为97。‘\0’是一个特殊的字符,它对应的整型值为0。它并不表示一个具体的字符,不严格地说,它可以表示这个字符为“空”。
变量的声明和赋值
变量是已经被赋予类型和名称的一个数据存储单元。类型可以是基本类型和由基本类型构成的其它类型,所谓的“其它类型”,将会在后文中提到。
变量类型 变量名( = 变量初值);
例如,声明一个整型的变量a,初始值为0;声明一个字符型变量c,初始值为’c’,则是
1 | int a = 0; |
如果想声明多个同类型变量,可以这样:
1 | int a = 0,b; |
语句a = 0;
称为赋值语句,它的作用是把值0赋给变量a。若不给整型变量a赋予初始值,那么a的值会是0吗?事实告诉我们,在大多数情况下,声明整型变量时的初始值一般默认为0,但不敢保证在一些情况下它的初始值为之前内存中的“垃圾值”,这与C语言不严谨或者说灵活的垃圾回收机制有关,此处不做过多讨论。大家只要记住,在C语言声明变量时,尽量给变量赋初值,减少程序出错的可能性。
上文中的类型转化也是赋值的一种,类型转换的代码如下:
1 | bool check = 100; //自动类型转化 |
上述代码的赋值结果为:
1 | check = true; |
常量的声明
const 常量类型 常量名 = 常量值;
这只要在上面变量声明的语句前加一个关键的单词_const_就好。值得注意的是:常量的初始化一定有初始值,而且一旦被声明,它的值就无法被改变。
1 | const int a = 0; //声明常量a的值为0 |
除了这种声明方式之外,常量的声明方式还有宏定义常量,以及无需声明的字面常量。
变量的作用域
一个被定义的变量的作用范围叫做这个变量的作用域。
变量按照作用域来分类可以分为全局变量和局部变量,除此之外还有一类“伪局部变量”。
有些变量的定义是在函数之外,头文件引用的下方;有些变量定义在函数体内部;甚至有些变量定义在for循环的头部或者if判断的某路分支上。这些变量的作用域是不同的,定义在函数之外的变量的作用域为这整个代码文件,定义在函数内部的变量作用域为变量定义处到这个函数尾部,定义在for循环头部的变量作用域为整个for循环……还有更多的变量作用域需要大家在写代码时积累。还要注意的是作用域较小的变量名不能和包含这个作用域的变量名相同。
伪局部变量是通过某些关键字,使程序运行到超出作用域时将局部变量的值“锁定”,以假乱真充当全局变量。
运算符与表达式
讲到现在,我们还没接触到加减乘除等运算符的概念。运算符就是对变量和值等数据进行操作的一些符号。常见的运算符有数学运算符+
,-
,*
,/
,%
,它们分别是加减乘除求余。赋值运算符=
,它将等号右边的值赋给左边。逻辑运算符&&
,||
,!
,分别表示与或非。自增自减运算符++
和--
。比较运算符>
,>=
,<
,<=
,==
,!=
,分别表示大于、大于等于、小于、小于等于、等于、不等于。还有难以理解的逗号运算符,
。这些运算符都有自己的优先级,就是当遇到多个运算符相邻或相近时应该先进行哪个符号的运算,当不记得它们的优先级时,我们只需要记住用圆括号()
括起来就可以了,它总能把括号内的东西达到最高运算级别。
以上运算符中,需要注意的有除运算符/
,当它遇到两个整数时,除法后的结果向下取整。比较运算符==
要注意写两个等号,否则就会变成赋值运算符。逻辑运算符的与和或记得是两个&
和|
而不是一个。
表达式的定义有些生涩难懂,但表达式是广泛存在于C语言的代码中的,一个个简短的表达式可以构成一段很长的表达式。表达式可以很简短也可以很长,而且它有自己的值,称为表达式的值。1
是一个表达式,它的值是1;比如在定义变量a之后,a
也是一个表达式,它的值就是a当前的值。赋值语句也是一个表达式,它的值是赋值后的左边的变量的值,如a=1
的值是赋值a的值,也就是1。表达式除了值还有其它属性。如自增表达式a++
的作用是将a的值加1,在这里我们把这个作用称为副作用。
C语言的标准输入输出
在第一个C语言程序中,我们用到了printf()这个函数,这个函数被称为C语言的标准输出函数,它会向控制台发送一串文字,并让控制台打印这段文字。我们把这段文字称作输出流。除了输出流,当然还有输入流和标准输入函数scanf()
,本段内容主要介绍这两个函数的简单使用。
标准输出
标准输出函数最简单的使用就是打印一段文字,下面的例子会直接打印双引号内的字符串Hello World!。
1 | printf("Hello World!"); |
Hello World!
如果我们想通过只使用一个标准输出函数打印两行字符串,我们就要用到转义字符了。转义字符指的是C语言和其它编程语言中一些规定好的可以用文字表达的符号。在这里我们要使用换行符\n,这个转移字符可以在指定位置换行,除了换行符之外,还有其它转义字符。
1 | printf("Hello World!\nSecondly Hello World!"); |
Hello World!
Secondly Hello World!
如果我们想原封不动地打印出”\n”这串字符,只需要在它前面加一个’',这将使得编译器识别它为非转义字符:
1 | printf("Hello World!\\n"); |
Hello World!\n
如果这时候我们想输出一个变量的值,我们用就得使用一些特殊的东西了。比如我们想输出一个整型变量a的值,或是一个字符型变量c的值,我们的百分号就要派上用场了。
1 | int a = 1; |
a is 1.
c is m.
printf(“…… %~ …… %~ ……”,第一个百分号对应的值,第二个百分号对应的值,……,第n个百分号对应的值);
上面代码中的%d*和%c分别表示:此处的东西用一个整数来代替、此处的东西用一个字符来代替。至于用什么数据来代替,我们在这串文本后面按照顺序写出变量名,并用逗号隔开。我们把这样一些符号叫做*格式化输出**符号。之所以叫格式化输出,是因为它可以以规定的格式输出一个数据。比如,%.2f
表示以保留两位小数的形式输出一个浮点数。实际上并不只有这样一种格式,更多的格式需要大家自行学习。同样地,转义字符也不止换行符一个。
标准输入
理解了标准输出函数的格式化输出,标准输入函数就很好理解了。例如输入一个整数和一个字符也是按照上面的格式:
1 | int a; |
10 10
10
10
上面两种格式化输入的方式分别如上所示,其实也不必深究,因为即使上面的两种输入方式互换,依然能保证输入的结果是正确的。和标准输出不同的是这里加上了一个&
符号,因为scanf()
函数要求此处提供的参数是一个地址。大家暂时不用深究,只需要记住在变量前加上一个符号就好了。除了输入字符串的时候:
1 | char string[10]; |
因为字符串名本来就是一个地址了。有关于字符串的讨论将在后面进行。
关于标准输入和标准输出函数的用法,大家不必深究,只需要记住最常规的表达法,保证数据的正常输入输出即可。
程序的注释
注释是对程序代码直截了当的解释,它可以帮助代码阅读者更快地理解代码,注释里的内容不参与程序的编译。写代码注释是软件开发人员必备并且熟练掌握的技能之一。
C语言中的注释主要有两种——行注释和块注释。
行注释一般用于对一小段代码的业务逻辑或算法做出解释,也可以写在一行代码之后。语法规则为:
// 在这里写下你的注释
块注释用于对一个函数或者一个源代码文件的代码做出解释。语法规则为
/* 在这里写下你的注释
在这里写下你的注释
*/
顺序、选择和循环结构
顺序结构
所谓顺序结构,就是按照所写代码的顺序,程序一步一步地按部就班往下执行。在这种情况下,除了唯一一条光明大道,程序再无其它路可走。
选择结构
在顺序结构的基础上,选择结构通过判断来产生多条分支路,至于走哪条路,由判断条件所得的结果来决定。
if语句
if语句根据条件然后判断做了一个简单的选择,条件的值最终会变为bool型。if语句的语法规则如下:
if(条件) {
//符合条件执行的语句
} else {
//不符合条件执行的语句
}
if语句的else分支为可选项,也就是说可以不必出现第一个大括号以后的语句。if语句还可以嵌套使用。下面举例用三种方法通过if语句找到三个数中最大的一个。以下三段代码省略头文件和main函数结构。
1 | int a,b,c,max; |
第一种方法思想很朴素,直接把每两个数互相比较两次,看看谁最大。这样的方法虽然不会出错,但是比较次数比较多。
1 | int a,b,c; |
相比于第一种方法,这种方法则是把所有情况都分类讨论了一遍,也是一种解决问题的思路,但是这种方法需要考虑得稍微多一些。再者我们不推荐像这个程序一样在比较后直接把最大值结果输出,而是应该先定义max变量,在max需要改变时赋值,这样更有利于代码的维护。
1 | int a,b,c,max; |
第三种方法应该是数学方法中常用的迭代,或者叫递推思想的一种简单应用。这个方法首先把a赋给最大值,然后再让最大值不断和后面没有比较过的值进行比较,取max(n+1)=max{max(n),a(n+1)}
,这是写程序时常用的简单套路。
switch语句
switch语句的出现主要是为了解决if嵌套时出现代码风格紊乱和程序执行效率降低的问题,相比于if语句,一个switch语句可以出现多条选择支路,极大地简化了代码的输入量。不过和if语句不同的是,switch语句在做判断时是在判断一个变量或者表达式的值,并根据相应的值和下面的多条路做出选择。switch语句的语法规则如下:
switch(被判断的值) {
case 值1 : //当被判断的值和值1相等时执行的语句;
case 值2 : //当被判断的值和值2相等时执行的语句;
……
default : //当以上情况都不符合时执行的语句;
}
值得注意的是,当被判断的值与值1做完比较之后,如果判断失败会直接和值2做比较,如果判断成功,在执行完第一个判断之后的语句后,还会接着以下的判断。所以如果想终止判断,真正实现选择功能,请在每一个case的执行语句最后加上一句break;跳出switch语句。下面是一个四川大学分数与绩点转化的例程:
1 |
|
三目表达式
三目表达式也叫三元表达式,可以作为if语句的缩写。语法结构为:
条件? 条件成立时的返回值 : 条件不成立时的返回值;
比如取a和b的最大值时可以简写成这样:
1 | int a,b,max; |
是不是简便了很多?
循环结构
循环是结构化程序设计重要的组成部分,没有循环,也许许多程序都无法实现。循环就是在特定条件下反复执行某条或某块语句。
循环其实可以分为计次循环和判断循环。
for语句
for语句用于计次循环,也可以灵活地运用于判断循环。语法结构如下:
for(循环开始时的操作;循环体执行的条件;循环体执行完一次后进行的操作) {
//循环体语句
}
循环最简单的使用方法,大概是求1+2+……+99+100,代码如下:
1 |
|
然而循环结构典型的例子是打印九九乘法口诀表。
1 |
|
代码很简单,主要代码只有四行。然而难点在于如何分析问题。我们知道C语言中的标准输出只能一行一行地输出,所以我们的想法当然是管好每一行,在每一行的最后加一个换行符。我们定义一层循环来输出每一行。在考虑如何输出每一列时,我们发现每一行的列数正好等于行数,i行j列的两个乘数正好又是i和j,所以我们再定义一层内循环来输出每一列,问题就迎刃而解了。用循环结构解决问题的要点在于找到每一次循环时的共同点,或是与上一次循环的递推关系。其实就是高中数学曾经学习过的找“通项”和找“递推”的思想。
while语句和do…while语句
while语句和do…while语句基本上都是用来做判断循环,两者不同点在于while是先执行一次判断再开始循环而do…while是先执行一次循环再判断。
它们的语法结构分别如下:
while(循环执行条件) {
//循环体
}
do {
//循环体
} while(循环执行条件);
在求两个数的最大公约数时,我们采用的算法里必包含循环结构。比如辗转相除法的算法是这样的:先用两个数中较大的一个除以较小的一个,得到第一个余数,再用第一个余数除以较小的一个数,然后用第一个余数除以第二个余数,最后反复用第n个余数除以第n+1个余数,直到余数为0的时候,两个数的最大公因数为最后一次做除法时的除数。以下是通过辗转相除法求两个数最大公因数的例子:
1 |
|
可以看得出来,如果已经了解辗转相除法,想写出程序只不过需要语法的练习罢了。
函数
在程序设计中,如果用一句话来概括函数,函数就是对过程的封装。
结构化的面向过程程序设计需要从最顶层考虑的主要业务逻辑开始,细想做完一件大事需要依次完成哪些小事,然后把这些小事封装成函数,只需要在主要业务逻辑中调用这些函数,在这些函数中实现每一件小事,一个结构化的程序才算完成。
我们通过几个例子来理解函数的几个概念。
先观察数学函数f(x)=2x+1,这个函数对每一个给定的x,都有唯一的f(x)与它对应。那么我们可以把这个x叫做它的参数,给定x时f(x)的值叫做它的返回值。在这个例子中,如果参数x的值为0,那么这个函数就返回1。函数的参数不一定只有一个,但是返回值一定只有一个。这和数学中的函数概念也比较类似。这是因为当给定一组参数时,函数都有唯一的返回值与给定的参数对应。
函数也有副作用,例如C语言中内置的swap()函数是对传入的两个参数进行值交换,我们把这个交换值的作用称为这个函数的副作用。
函数的生命周期是从进入函数到返回为止。函数的返回需要用到return关键字,return后要跟着和函数声明的返回值类型相同的值,如果返回值类型为void,则函数返回时直接写return;即可。
函数的声明
自定义的函数由函数头和函数体组成。
函数头包括返回值、函数名和参数表。语法规则如下:
返回值类型 函数名(参数1类型 参数1,参数2类型 参数2,……);
在自定义函数定义函数时,main函数的函数体之上一般要有一个自定义的函数头,这是因为C语言程序在编译时编译器是按照代码的顺序自上往下编译的,所以如果在main函数之上没有出现过这个函数头,而这个函数在main函数中被调用的话,编译器会不知道这个函数的存在而报错。自定义函数在代码中的位置一般如下所示:
1 | //此处为头文件引用 |
套用以上模板,可以写一个将两数相加的函数。
1 |
|
如果像下面这样写,也一样可以使编译器不报错:
1 |
|
如果有多个自定义函数,只要在函数头声明处加上其它所有自定义函数头即可。或者像第二种方法一样直接在main函数上加其它函数体。
函数的调用
想要调用一个函数,只要在需要调用的地方写上这个函数名和它相同的参数表即可,它的返回值可以有多种利用方式。例如上面的add()函数想要在main函数中调用,主要有如下几种操作:
1 | int a,b,sum; |
以上操作中,可以看出,有返回值的函数在调用时可以不利用返回值,可以利用返回值赋予其它变量,还可以直接将返回值放入其它函数的调用中,这些操作都是合法的,除此之外其实还有其它操作。
我们把调用函数的位置所在的函数称为主调函数,被调用的函数称为被调函数。
函数间的通信
C语言中在进行参数传递时,可以只传递参数的值,或者称为”复制品“,这种方式叫做值传递;也可以传递参数“本身”,这种方式叫做引用传递。这两种传递方式的区别在于:当在被调函数的函数体中对传入参数做改变时,值传递不会改变主调函数中传入参数的值,而引用传递则会对主调函数中传入的参数值做改变。虽然理论上C语言只有值传递,但是通过一些语法变形,引用传递也是可以实现的。
而函数间的通信则围绕着参数传递和返回来实现。
围绕着以上两种传递策略,C语言中函数的通信分为向下通信、向上通信和双向通信。
从输入输出的角度来看问题,其实就是说一个函数只有输入、只有输出和既有输入又有输出的情况。
向下通信
函数的向下通信是指主调函数向被调函数传入参数,而被调函数无法对主调函数做任何改变的一种通信方式。通过向下通信的字面定义可以看出,被调函数的参数只能传入不能传出,因此参数传递方式为值传递,且无返回值,或者返回值不被主调函数利用。
比如在上述add()函数调用的例子中,第一种调用方式对于add()函数而言就是一种向下通信,这里main函数是主调函数,add()函数是被调函数,main函数向add()函数传入两个加数,而add()函数虽然有返回值但main函数并不买账,选择了不接收返回值,这就属于向下通信范畴。第三种调用方式对于printf()而言也是一种向下通信的方式。
向上通信
向上通信指的是被调函数向主调函数传递数据,而主调函数并不提供数据给被调函数的一种调用方式。所以被调函数满足的条件要么是不传参,有返回值;要么就是采用引用调用的方式且不对引用的数据进行修改。
比如这样一个函数就满足第一种条件,不向主调函数索要参数,而返回数据给主调函数。
1 | int getOne() { |
那么第二种方式如何实现C语言中的引用调传递?比如现在有一个初始化的函数,要分别把两个数据初始化成0和1,而我们知道函数的返回值只能有一个,那么怎么解决这个问题呢?这就要用到地址操作符*
和&
了。我们通过这个初始化函数的例子来理解函数的参数引用传递和向上通信。
1 |
|
在初始化的initialize()
函数中,我们的两个参数均为int*
型,这个类型表示整型指针型,它存储的是一个整型变量在内存中的存储地址,也就是说在调用函数传参时,我们要传入的参数是两个地址。在这个函数的实现中,我们可以看到有带有*a和*b的式子,在这两个式子中,*被称为解引用操作符,它的含义是取一个指针型的变量所存储的地址对应的值。在main函数传参时,我们对x和y使用了&
操作符,这个操作符被称为取地址操作符,它的作用是获取某个变量存储在内存中的地址。
所以这个例子中整个传参的过程可以概括为:main函数向initialize()
函数传入x和y的地址,initialize()
函数让这两个地址对应的值发生改变,进而导致主调函数main中x和y的地址所存储的值发生改变,其实就是改变了x和y的值。所以这才出现了被调函数向主调函数输出,而主调函数实际上并没有向被调函数输入的情况。
双向通信
在理解了向上通信的基础上,双向通信就不太困难了。双向通信也可以像上面一样分为两种,一种是值传递产生的输入输出,另一种是引用传递时发生的通信。
依然是上面add()函数的例子,第二种调用add()函数的方式就是一个典型的值传递产生的双向通信。这个函数从主调函数获取两个加数,返回了一个和,既有输入,又有实际返回的输出。
还有一种双向通信则是参数在引用传递时,在发生了向上通信的基础上,也让传入的参数发生了运算,具体看交换两个数的函数的例子:
1 |
|
这个函数的调用已经存在了向上通信,而且在函数的实现中可以发现赋值表达式的右边已经出现了对传入参数的调用,所以这也是一个既有输入又有输出的过程。
递归
递归就是函数一层一层地往下调用自己。先来看一下经典的斐波那契数列问题,通过这个问题来初步理解递归的含义。
斐波那契数列是一串特殊的数列,它的通项满足a(0)=0,a(1)=a(2)=1,当n>2时,满足递推公式a(n)=a(n-1)+a(n-2)
。如果用递归方法,我们可以很快地通过递推关系把代码写出来。
1 |
|
这个程序的运行过程这样的:main函数想求斐波那契数列的第十项,所以它调用了fib(10)。而fib(10)并不知道自己是多少,但是它知道它等于fib(9)和fib(8)的和。fib(9)和fib(8)也不知道自己是多少,但是它们也分别知道自己等于fib(8)与fib(7)、fib(7)与fib(6)的和……直到遇见了fib(1)和fib(2),它们终于知道自己等于1,于是就一层一层地告诉上级,fib(10)终于懂得了自己的值,把这个值告诉了main函数。
其实上面的例子也可以用做梦来比喻。fib()函数做了个梦,这个梦的内容是自己在做梦,这个梦的内容还是自己在做梦……直到它梦到了什么奇怪的东西,它的梦境就一层一层地醒了,最后彻底地醒了。
递归的设计就是这样,想设计出递归函数,就要寻找递推关系和递推终点,在上面的例子中,递推关系是斐波那契数列的递推公式,而递推的终点则是n等于1或2,这就杜绝了无限做梦回不来的情况。
斐波那契数列只是一个简单的例子,要想深刻理解递归,绝不是一两天的事,大家在以后的学习中还会有深刻体会。
递归也有缺点。比起迭代方法,递归函数会在内存中更加占用内存空间,因为在不断往下调用自己时,它总是要先把自己在某一层的状态保存在内存中,这样才能“回得来”。
数组
数组就是一个存储相同类型数据的有序可重复集合。数组存在的意义在于它可以方便地存储一组数据而不是一个。数组在内存中的存储是线性连续的,也就是说相邻两个元素之间是没有间隔的。
数组的声明
数组的完整声明方式语法是这样的:
数组类型 数组名[数组长度] = {第一个元素,第二个元素,……};
但是并不是声明一个数组一定要用完整的声明方式。数组的声明方式主要有以下几种:
1 | int a[10]; |
下面依次介绍以上声明方式的填值结果。
第一种是在内存中申请了10个内存空间,并且不做任何操作,这10个格子中的数据是原来内存中没有清理过的数据,有可能是0。填值结果未知。
第二种是在第一种的基础上,往第一个内存空间中填入0。但是C语言规定,声明数组时若使用花括号赋值,且值没有填满,那么这个数组剩下的空间用0值填满。所以这个数组申请的10个空间全部被0填满,填值结果为{0,0,0,0,0}
。
第三种是申请了5个内存空间,然后用右边的值填满。填值结果为{1,0,3,4,5}
。
第四种是申请了5个内存空间,然后用1和2填充前两个内存空间,其余根据第二种的规则用0填满。填值结果为{1,2,0,0,0}
。
第五种并没有标明声明多少内存空间,但是用到了花括号赋值,所以根据花括号中元素的数目,申请3个内存空间,直接用1,2,3填满。填值结果为{1,2,3}
;
第六种同第五种,只是数据类型发生了改变,填值结果为{'I','l','u','v','U'}
。
第七种是由字符数组构成的字符串,前面说过字符串由’\0’结尾,所以它的填值结果比较特殊,要在后面加上一个字符串结尾符,是{'I','l','u','v','U','\0'}
。
第八种结合了第六种和第七种声明方式,先申请了10个内存空间,然后用{‘I’,’l’,’u’,’v’,’U’,’\0’}填充前6个,剩下以0值填满,而0值对应的字符型值就是’\0’,所以填值结果为{'I','l','u','v','U','\0','\0','\0','\0','\0'}
。
数组中元素的数目叫做数组长度,数组一旦被声明,它的长度就不能再改变。如果非要改变,只能通过C语言的内存分配来实现。
数组的访问
我们用数组名[n]的语法来访问数组的第n-1个元素,所以说n的取值范围是0~数组长度减一。
为什么是这样呢?因为在这里n表示的并不是数组的元素序数,而是表示当前要访问的数组元素与首元素的偏移量,偏移量可以理解为当前要访问元素与首元素的距离。我们把这个n定义为数组下标。
例如上面的第八种声明方式,s[0]的值为’I’,s[2]的值为’u’。
下面举例用数组来实现存储数列a(n)=2n+1的前10项并求和打印。
1 |
|
数组下标的值不能超出它的取值范围,如果超出了则会发生非法访问等意想不到的结果,从而导致程序崩溃。
数组排序
数组排序就是通过某种特定的顺序以及某些特定的算法,对数组中的所有元素进行排序,从而方便对于数组的操作。数组常用的排序方式有:选择排序、插入排序和冒泡排序。三种排序的函数,都是传入一个数组和数组长度,无返回值。以下对于数组排序的讨论都以整型数组为例,从小到大的排序。
选择排序
选择排序的思路是:从数组头部向尾部依次遍历一次。每遍历到一个数时,在这个数之后寻找一个最小的数,与它交换位置,如此往复,得到的结果会是一个从小到大排序好的数组。以下是选择排序的代码:
1 | void selectSort(int array[],int len) { |
插入排序
插入排序和选择排序的思路有相同之处,都是从头到尾遍历一次找当前数之后的最小数。不同之处在于,找到最小数之后,插入排序是把最小的数“插入”到这个位置而不是交换。如何实现插入呢?我们把当前位置到最小数的前一个位置的数都向后挪一个位置,就空出了一个位置,就可以插入了。具体操作是:先记录下最小数,接着从最小数的前一个数开始把数往后移动,直到把当前位置的数往后移动留出一个空位以后,再把当前位置的值赋为最小数,整个插入的过程就完成了。以下是插入排序的代码:
1 | void insertSort(int array[],int len) { |
冒泡排序
冒泡排序的命名是很形象的。其实就是有n个大小不同的气泡,混乱地排成一列,我们依次把相邻两个气泡进行比较,直到把最小的气泡压到底部,把最大的气泡冒到顶部,这就成了一个排好序的数组。
1 | void bubbleSort(int array[],int len) { |
数组查找
在数组中查找某一个元素,需要用到数组查找的算法。以下是两种比较简单的数组查找算法,都以整型数组为例。查找函数传入数组、数组长度以及要寻找的目标,返回是否找到目标,若找到目标则标记找到的第一个目标的数组下标,若找不到则标记这个下标为-1。
顺序查找
顺序查找是最简单的数组查找方式。它的查找方式是将一个数组从头遍历到尾,如果找到目标就返回true,如果遍历结束依然没有找到返回false。以下是顺序查找的代码:
1 | bool sequenceSearch(int array[],int len,int target,int* lctn) { |
二分查找
二分查找适用于已经排序完成的数组。它的思想是数学中的“二分”思想。对于一个排好序(假设是从小到大排好)的数组,我们从中间开始寻找目标,如果发现中间的数比目标大,那么我们就往左边找,从1/4分位点开始找;如果发现中间的数比目标小,我们就往右边找,从3/4分位点开始找……每二分一次查找的范围都会减半。直到把数组二分到不能再分,依然找不到目标时,那就说明目标不存在了。下面的算法可以很好地实现二分查找:
1 | bool binarySearch(int array[],int len,int target,int* lctn) { |
二分查找的思想被广泛应用在计算机系统和信息管理科学中,它能大大减少查找的时间。例如在数据库中,索引的实现就与二分思想有关。
多维数组
多维数组的本质就是一个数组。比如,二维数组就是数组的数组,三维数组就是二维数组的数组,n维数组就是n-1维数组的数组。多维数组在内存中也是线性连续排列的,也就是一个元素连着一个元素,一个数组连着一个数组,一个数组的最后一个元素连着它下一个数组下标为0的元素。但是实际上有时候可以从逻辑上理解为它是一个n维空间中的表。比如二维数组可以理解为一张平面表,三维数组可以理解为空间中的三维表(立方体)。以下就以二维数组为例介绍多维数组。
如果想声明一个二维数组,只需要采用以下的语法:
数组类型 数组名[包含一维数组的数目][其中一维数组的元素个数]( = {填充值});
关于填充值的问题,请看以下两个例子:
1 | int a[3][2] = {1,2,3}; |
a数组直接根据在内存中的排列顺序转化为二维数组,再补0,它的填充结果是{ {1,2},{3,0},{0,0} }
。
根据补0的规则,b数组的填充结果是{ {0,0},{0,0},{0,0} }
。
同样根据补0规则,c数组的填充结果是 { {1,1},{1,1},{1,0} }
。
d数组同c数组。
c数组可以从逻辑上理解为这样一个矩阵或表:
{ {1, 1},
{1, 1},
{1, 0} }
关于更多的填充案例的疑问,只有通过大家动手把程序跑起来看看是怎么样,才可以积累更多的语法规则。
要想访问二维数组中的某一个元素,也是通过下标来实现。比如,上面的例子中,a[0][0]的值就为1。多维数组访问越界同样会导致程序崩溃。
指针
指针通俗地讲其实就是数据的存储地址,所谓地址,指的是一个存储单位在内存中所处的位置。就像学生公寓里的寝室号一样,在给每一个寝室编号后,就可以通过编号找到每一个寝室,这里的编号其实和传说中地址的意义是等价的,通过一个地址,我们就能找到这个地址里所存储的值。
在之前讲到函数通信的时候,我们讲过引用传递需要传递变量的地址,并在函数体中解引用。取地址符号&
与解引用符号*
是互逆的,一个是取一个存储单位对应的地址,一个是取一个地址所存储的值。
指针变量
指针变量的声明和普通变量的声明的差别,只在一个*
号。以下两种方式都可以声明一个指针变量。
变量类型* 指针变量名( = 地址);
变量类型 *指针变量名( = 地址);
变量类型指的是指针变量所“指向”的类型,也就是这个指针变量所存储的地址对应的值的类型。比如int*的指针变量,它所存储的地址对应的变量类型就是int型。比较特殊的是void*型的指针,它可以指向任意一种类型的变量,而如果其它类型的指针变量想要指向这个指针所指的值,必须进行强制类型转换,例如:
1 | int a = 1; |
上面的代码中指针ptr和p其实是指向了同一个整型变量a的,也就是说如果我们改变了a,ptr和p所指向的位置所存储的值都会发生改变,这是一件有趣的事。我们可以通过*ptr
来访问或者改变a的值,但是如果想通过指针p来访问,我们应该这样写:
1 | *((int*)p) |
这样的操作是先进行强制类型转化,告诉它所指的位置是整型变量以后,再解引用取值。
这时候如果我们想改变a的值,我们可以直接给a赋值,也可以将指针变量解引用后赋值。
1 | a = 1; |
这三种操作是等价的,最后都会使a的值为1。这时候再对ptr和p解引用时,它们的值都是1。
在32位编译环境下,我们用sizeof()关键字来查看某个指针的大小,就会发现它们的大小都是4个字节。一个字节是八位,所以四个字节就是32位。32位其实就是2的32次方,大小大约就是4GB,也就是说一个32位的应用程序在寻址的过程中最多可以使用4GB的内存空间,这就是32位操作系统之所以最大只能匹配4GB内存的原因。
如果我们定义了一个指针,而且不让它指向任意一个位置,那么它就是存储在内存中的“野指针”。之所以被称为野指针,是因为它在没被赋值之前是指向一个任意的位置,这是非常危险的。所以如果在声明指针变量时,如果并不想让它指向一个某个存储单位,那最好把它先初始化指向NULL,NULL是一个宏定义常量,它的值其实就是0。
指针变量可以进行加减运算。还是以上面指向a的指针为例,假设a在内存中的地址是1000,那么ptr的值就是1000,那么ptr+1是多少?1001吗?经过实验会发现其实并不完全是。ptr+1的值指的是ptr+sizeof(int)的值。所以一个指针的值加i表示的是指针的值加上i倍它所指向变量类型的大小,比较特殊的还是void*型的指针,它指向一个任意类型的变量,所以它所指向的变量类型是未知的,对这种类型的指针变量加1,地址也只是加了1而已。上述例子如果ptr的值还是1000,那么p的值也是1000,p+1的值就是1001。
多级指针
多级指针保存的是一个指针变量的地址。例如,二级整型指针指向的是一个指向整型变量的指针。通常情况下,二级指针是最常用的。要想使用二级指针,只需要在声明指针变量时多加一个*号即可。
1 | int num; |
注意:n级指针只能指向n-1级指针,否则会使编译器报错。
在解引用时,p2解引用一层后是p1的值,也就是num的地址,解引用两层后才是num的值。
1 | printf("%d %d",*p2,**p2);//第一个数是num的地址,第二个数是num的值 |
如果对二级指针进行加减运算,它每加减的一个的单位大小应该是sizeof(void*)。一般情况下这个大小在32位编译器下是4,在64位编译器下是8。
指针与数组
指针与数组的关系
指针和数组虽然不是同一个东西,但是它们其实是有相同之处的。
一个数组的数组名可以看成是一个指针,它存储的是这个数组下标为0的元素的头部地址。前面有提到过,数组的下标标记的是某个元素对于首元素的距离。以32位环境下的整型数组为例,一个整型变量占用的是4个字节,那么数组下标为i的元素距离首元素头的实际距离就是4倍的i。这和指针的加减运算十分相似。
1 | int a[5] = {0}; |
前面讲过数组头可以看成一个地址,那么a+1表示的就是地址a+sizeof(int),而数组在内存中的存储正好又是线性连续的,所以这正好是数组a下标为1元素的地址,解引用后就是它的值。所以说以上代码中最后两行代码是等价的。
类似地,取地址的表达也是成立的。在调用scanf()函数时,我们想改变a[1]的值,采用以下两种表达也是等价的:
1 | scanf("%d",&a[1]); |
如果是二维数组的话,请看以下例子:
1 | int num; |
打印出来的两个整数都是3,也就是a[1][1]。第一个表达式先把二维数组化为一维数组,然后再用下标取值,而第二个表达式则完全用指针的加法运算律再解引用来完成,两种表达是等价合法的。
同样地,对于一个指针,我们也可以用下标来取它的解引用值。
指针与数组虽然有相似之处,但本质上还是两个东西,比如在遇到二维数组和二级指针的时候。二维数组名的地址加上1,加的是二维数组里的一维数组的大小,而无论是多少级指针地址加上1,永远只会加sizeof(void*)
。
指针数组
指针数组是一个数组,这个数组里面所存的数据类型是指针。在其它某些编程语言的数组存储机制中,这样的数组是很常见的。动态二维数组也需要用到指针数组。
1 | int* ptr_array[5] = {NULL}; |
数组指针
数组指针是一个指针,这个指针是一个指向数组的指针。以下是指向一维数组的一级指针。
1 | int (*array_ptr)[5] = NULL; |
但是当二维数组遇上二级指针,上面的第二种表达就出现了问题。如果把一个二级指针指向一个二维数组,在通过指针访问数组元素的时候会出现一些很有意思的错误,这个错误是之前提到过的指针和数组大小不一造成的。
这时候我们就要利用数组指针,将一个数组指针指向一个二维数组。
1 | int array_2d[5][5] = {{0}};//声明一个二维数组 |
在这里对array_ptr解引用一层(array_ptr[1]等价于*(array_ptr+1))后所加的字节数是5*sizeof(int),正好是一个长度为5的一维整型数组的大小,所以我们实际上是得到了array_2d[1]这个一维数组头指针。再解引用(取下标)一层,我们就成功地通过了二级指针访问了二维数组。
动态内存分配
一般来说,现代程序设计语言的内存空间至少都会分为栈内存和堆内存,这是两块不同的内存区域。
我们之前所讲的数组声明方法,都是给定一个固定的数组长度。这样的数组一旦被声明,长度就无法被改变,我们称这样无法改变长度的数组为静态数组,它分配在栈内存上。静态数组无法延长,当数组被填满时,就无法继续无损地存储数据。为了得到不定长的动态数组,我们需要使用动态内存分配,这样分配出来的空间会在堆内存中。
动态块式分配函数malloc()的副作用是在堆内存中申请一块指定大小的存储空间,返回一个void*型的地址,这个地址是所申请空间头部的地址。
动态数组名 = (动态数组的类型*)malloc(要分配的字节数);
在调用这个函数后,我们要把它返回的地址强制转换并赋给一个指针,然后这个指针就可以充当一个一维数组了。
还要注意的是:如果要分配的字节数传入参数0,会发生意想不到的错误。
1 | int n; |
通常情况下我们要申请一个长度为n的动态数组,可以采用以上结构。
常见的动态内存分配函数还有邻接存储分配函数calloc()、存储再分配函数realloc()等。
如果想延长数组,可以调用realloc()函数。它的原理是再分配一块内存,把原数组中的数据复制一遍,得到一个新的增长的数组。虽然效率比较低,但是可以使用。和malloc()函数一样,它也返回一个动态数组头指针。
原动态数组头 = (原动态数组类型*)realloc(原动态数组,原动态数组想增长到多大);
比如上面的ptr动态数组想要延长为长度为n+1,可以这样写:
1 | ptr = (int*)realloc(ptr,(n+1)*sizeof(int)); |
C语言中申请好的内存空间不会自动释放消失,所以我们需要手动释放这块申请好的内存空间,采用释放存储free()函数。这个函数无返回值,需要提供一块已申请内存的头部指针。
free(需要释放内存的头指针);
1 | free(ptr);//释放以上申请好的内存 |
如果想释放动态数组下标为i元素和之后的元素,可以这样写:
1 | free(ptr+i);//释放数组下标为i和之后的元素 |
不过还是要注意i的范围,不能发生数组越界的情况,否则后果不堪设想。还有,对于字符数组而言,这样的清除方式似乎会出现一些问题,因为字符串结束符’\0’可能会被抹去。
对于二维动态数组的内存分配,我们采取的思路是先申请一个动态二维数组,再给其中的每一个数组动态分配内存。比如要申请一个5x5的动态二维数组:
1 | int** stupid_dynamic_array = (int**)malloc(sizeof(int*) * 5); |
在释放内存时要注意,先释放内层再释放外层。
1 | for(i = 0;i < 5;i++) |
用完的动态内存记得一定要释放哦,否则在服务器上很可能导致堆内存空间被占满,进而导致系统宕机,这样的问题被称为内存泄漏。
指针与数组在函数间的通信
无论是数组还是指针,它们在函数间的通信都统一转化为指针进行。如果参数表中含有指针或数组的话,我们进行的参数传递方式是引用传递。也就是说,如果在被调函数中对数组中的内容或进行了改变,在主调函数中的数组也会改变,因为我们改变的是它的地址所对应的值,斩草除根。
一个函数可以返回普通类型的值,同样也可以返回指针类型的值,我们把这样的函数叫做指针函数。C语言中的函数不能直接返回一个数组,而指针函数就很好地解决了这个问题,我们可以返回一个指针,这个指针指向这个数组的头部,这就成为了一个合法的表达。例如,如果我们要创建一个长度为n的整型数组,元素值等于下标值,且要把这个过程封装成函数的话:
1 | int* initialiseAry(int n) { |
字符串和字符串数组
C语言中是没有定义字符串数据类型的。想要实现C语言中的字符串,我们可以选用字符数组和字符指针。
一段字符串要用双引号括起来,想要声明一个字符串,可以用声明数组的方式来声明:
1 | char str1[] = "I am a string."; |
声明后的字符串的存储其实是这样的:str1 = {‘I’,’ ‘,’a’,’m’,’ ‘,’a’,’ ‘,’s’,’t’,’r’,’i’,’n’,’g’,’\0’}。它是一个字符数组,要想访问这个字符串的第i个字符,可以通过数组的访问方法来实现。字符串尾部新增的’\0’是一个特殊的字符,用它来标记这个字符串结束。当运行时读到这个字符的时候,会识别出这是字符串的结尾。所以即使是只有一个字符的字符串,它也是一个字符串而不是一个字符,例如“a”的存储是这样的:”a” = {‘a’,’\0’}。
字符串还可以通过指针来声明,在创建一个用双引号括起的字符串常量时,系统会在常量区创建一个字符串常量,然后将一个指针指向这个字符串常量的头部。通过指针声明的字符串,因为指针指向的是常量,所以它的值不能被改变。
1 | char* str2 = "I am a string."; |
字符串操作的头文件<string.h>中带有字符串的复制函数strcpy(),它可以将一段字符串复制到另一个地址或者说字符数组中。
strcpy(需要填充的目标字符串,源字符串);
虽然strcpy()函数需要提供的参数是两个个字符串指针,但实际上一般这个函数更加适用于字符数组。因为用指针声明的字符串可以直接通过传递地址来实现。
1 | char string1[100]; |
根据字符串数据的两种存储形式,我们有两种方法来表示一个字符串一维数组。一种是二维字符数组,另一种是指针数组。比如要声明一个长度为5的字符串数组:
1 | char ary_str[5][100] = {{'\0'}}; |
后记
能够看完这篇教程并编写代码实践,基本上就已经步入编程的大门了。C语言是离计算机系统底层比较近的一门高级编程语言,但又蕴含了很多包括结构化编程在内的现代软件工程思想,是入门编程的首选语言之一。在今后的学习中,你还会从Java语言、C++语言(尽管本文也涉及到了C++的一些特性)接触到面向对象编程思想,但这样的特性也是从C语言的结构体(本文未涉及,可以多加了解)中衍生出来的。你还会接触到操作系统、网络、计算机体系结构、数据库等专业课,到那时候你会彻底明白,软件系统设计并不仅仅是编写代码那么简单,但从现在开始,打好C语言的编程基础,对以后却是大有裨益。