C语言--指针

知识内容总结

为什么要学习指针?

我们已经学习了如何用数组存放多个相同类型的数据并进行运算,但数组的长度在定义时必须给定以后不能再改变。如果事先无法确定需要处理数据数量,应该如何处理呢?一种方法是估计一个上限,并将该上限作为数组长度,这常常会造成空间浪费;另一种方法是利用指针实现存储空间的动态分配。
指针是C语言中一个非常重要的概念,也是C语言的特色之一。使用指针可以对复杂数据进行处理,能对计算机的内存分配进行控制,在函数调用中使用指针还可以返回多个值。同时指针也可以作为函数的参数,也可以用于数组和字符处理,实现更多功能。

思维导图:

地址和指针

这是计算机中的两个重要概念,在程序运行过程中,变量或者程序代码被存储在以字节为单位组织的存储器中。

地址

在C语言中,如果定义了一个变量,在编译时就会根据该变量的类型给它分配相应大小的内存单元。计算机为了对内存单元中的数据进行操作,一般是按“地址”存取的,也就是说对内存单元进行标识编号。如果把存储器看成一个建筑物,建筑物内的房间就是存储器单元,房间号就是地址。

  • 要注意区分内存单元的内容和内存单元的地址。

指针

在C程序中还有一种使用变量的方法.即通过变量的地址进行操作:用指针访问内存和操纵地址。指针是用来存放内存地址的变量,如果一个措针变量的值是另一个变量的地址,就称该指针变量指向那个变量。

取地址运算

在前面的章节中,已经多次看到了把地址作为scanf()的输人参数的甩法,例如,

scanf("%d",&n);

把输入的值存储到变量所在的内存单元里。其中&n表示变量n的内存地址或存储位置。这里的称为地址运算符,是一元运箅符与其他的一元运算符有同样的优先级和从右到左的结合性。

指针变量的定义

如果在程序中声明一个变量并使用地址作为该变量的值,那么这个变量就是指针变量。定义指针变量的一般形式为:

类型名 *指针变量名;

类型名指定指针变量所指向变量的类型,必须是有效的数据类型,如int,float,char等。指针变量名是指针变量的名称,必须是一个合法的标识符。定义指针变量要使用指针声明符"*"。

  • 指针声明符在定义指针变量时被使用,说明被定义的那个变量是指针。

指针变量本身就是变量,和一般变量不同的是它存放的是地址。指针变量用于存放变量的地址,由于不同类型的变瓧在内存中占用不同大小的存储单元,所以只知道内存地址,还不能确定该地址上的对象。因此在定义指针变量时,除了指针变量名,还需要说明该指针变量所指向的内存空间上所存放数据的类型。

  • 定义多个指针变量时,每一个指针变量前面都必须加上*。

注意,指针变量的类型不是指指针变量本身的类型,而是指它所指向的变量的数据类型。指针变量自身所占的内存空间大小和它所指向的变量数据类型无关,不同类型指针变量所占的内存空间大小都是相同的。指针变量被定义后,指针变量也要先赋值再使用,当然指针变量被赋的值应该是地址。

在定义指针变量时,要注意以下几点:

  1. 指针变量名是一个标识符,要按照C语言标识符的命名规则对指针变量进行命名。
  2. 指针变量的数据类型是它所指向的变量的类型,一般情况下一旦指针变量的类型被确定后,它只能指向同种类型的变量。
  3. 在定义指针变量时需要使用指针声明符“*”,但指针声明符并不是指针的组成部分。
  • 建议用其类型名的首字母作为指针名的首字符,用ptr作为名字,以使程序具有较好的可读性。

指针的基本运算

如果指针的值是某个变量的地址,通过指针就能间接访问那个变量,这些操作由取地址运算符"&"和间接访问运算符“*”完成。此外,相同类型的指针还能进行赋值、比较和算术运算。

取地址运算和间接访问运算

单目运算符&用于给出变量的地址。例如:

int p,a = 3;
p = &a;

这两行代码将整型变量的地址赋给整型指针p,使指针p指向变量a。在程序中,“*”除了被用于定义指针变量外,还被用于访问指针所指向的变量,它也称为间接访问运算符。

  • 我们在使用指针编程的时候,要正确理解指针操作的意义,带有间接地址访问符*的变量在不同情况下会有完全不同的意义。

赋值运算

一旦指针被定义并赋值后就可以如同其他类型的变量一样进行赋值运算。例如

int a = 3;
int *p1,*p2;
p1 = &a;
p2 = p1;

将变量a的地址赋给指针pl,再将p1的值赋给指针p2,因此指针pl和p2都指向a变量,两个指针访问的是同一个存储单元。、
指针之间的相互赋值只能在相同类型的指针之间进行,可以在定义时对指针进行赋值,也可以在程序运行过程中根据需要对指针重新赋值。

初始化

C语言中的变量在引用前必须先定义并赋值.指针变量在定义后也要先赋值再引用。在定义指针变量时,可以同时对它赋初值。例如:

int a;
int p1 = &a;
int p2 = p1;

以上对指针pl和p2的赋值都是在定义时进行的,使得指针pl和p2都指向变量a。

在进行指针初始化的时候需要注意以下几点:

  1. 在指针变量定义或者初始化时变量名前面的“*”,只表示该变量是个指针变量,它既不是乘法运算符也不是间接访问符。
  2. 把一个变量的地址作为初始化值赋给指针变量时,该变量必须在此之前已经定义。
  3. 可以用初始化了的指针变量给另一个指针变量作初始化值。
  4. 不能用数值作为指针变量的初值,但可以将一个指针变量初始化为空指针,例如
int *p = NULL;

5.指针变量定义时的数据类型和它所指向的目标变量的数据类型必须一致。

野指针

指针如果没有被赋值,它的值是不确定的,即它指向一个不确定的单元,使用这样的指针,可能会出现难以预料的结果,甚至导致系统错误。

算术运算

C 指针的算术运算只限于两种形式:

指针 +/- 整数 :

假定有一个指针变量p,我们可以对指针变量p进行自增、自减、加上一个整数等操作,所得结果也是一个指针,只是指针所指向的内存地址相比于p所指的内存地址前进或者后退了i个操作数。

在上图中,p 是一个 int 类型的指针,指向内存地址 0x10000008 处。则 p++ 将指向与 p 相邻的下一个内存地址,由于 int 型数据占 4 个字节,因此 p++ 所指的内存地址为 1000000b。其余类推。这种运算并不会改变指针变量 p 自身的地址,只是改变了它所指向的地址。

指针 - 指针

只有当两个指针都指向同一个数组中的元素时,才允许从一个指针减去另一个指针。两个指针相减的结果表示它们之间相隔的数组元素数目。减法运算的值是两个指针在内存中的距离(以数组元素的长度为单位,而不是以字节为单位),因为减法运算的结果将除以数组元素类型的长度。
要特别注意的是,在C语言中,其他的操作如指针相加、相乘和相除,或指针加上和减去一个浮点数都是非法的。

指针与函数

指针作为函数的参数

函数参数也可以是指针类型,如果将某个变量的地址作为函数的实参,相应的形参就是指针。
在C语言中实参和形参之间的数据传递是单向的“值传递”方式,调用函数不能改变实参变量的值。如果传递的值是指针变量,调用函数时可以改变实参指针变量所指向的变量的值。此时需要在函数定义时将指针作为函数的形参,在函数调用时把变量的地址作为实参。
要通过函数调用来改变主调函数中某个变量的值,可以把指针作为函数的参数。在之前的学习中,我们知道了函数只能通过return语句返回一个值。如果希望函数调用能将多个计算结果带回主调函数,用return语句是无法实现的,而将指针作为函数的参数就能使函数返回多个值。

数组名作为函数的参数

数组的形参实际上是一个指针。当进行参数传递时,主函数传递数组的基地址,数组元素本身不被复制,编译器允许在作为参数声明的指针中使用数组方括号。例如:

int sum(int a[],int n);

当字符数组名、字符串常量或字符指针作为函数参数时,相应的形参都是字符指针,它也可以写成数组的形式。

指针作为函数返回值

在C语言中,函数返回值也可以是指针类型,不过,我们要注意,不能在实现函数时返回在函数内部定义的局部数据对象的地址,这是因为所有的局部数据对象在函数返回时就会消亡,其值不再有效。因此,返回指针的函数一般都返回全局数据对象或主凋函数中教据对象的地址,不能返回在函数内部定义的局部数据对象的地址。

指针、数组和地址间的关系

在定义数组时,编译器必须分配基地址和足够的存储空间,以存储数组的所有元素。数组的基地址是在内存中存储数组的起始位置,它是数组中第一个元素的地址,因此数组名本身是一个地址即指针值。在访问内存方面,指针和数组几乎是相同的,当然也有区别,这些区别是微妙且重要的:指针是以地址作为值的变量,而函数名的值是一个特殊的固定地址,可以把它看作是指针常量。
数组名可以使用指针形式,而指针变量也可以转换为数组形式。

字符串和字符指针

字符串常量是用一对双引号括起来的字符序列,字符串常量在内存中的存放位置由系统自动安排。由于字符串常量是一串字符,通常被看作一个特殊的一维字符数组,字符串常量中的所有字符在内存中连续存放。所以,系统在存储一个字符串常量时,先给定一个起始地址,从该地址指定的存储单元开始,连续存放该字符串中的字符。字符串常量实质上是一个指向该字符串首字符的指针常量。例如,字符串"hello"的值是一个地址,从它指定的存储单元开始连续存放该字符串的6个字符。如果定义一个字符指针接收字符串常虽的值,该指针就指向字符串的首字符。
字符数组和字符指针都可以用来处理字符串,但是二者有重要区别,例如:

char a[] = "ThiS iS a string";
char *p = "ThiS iS a string";

字符数组a在内存中占用了一块连续的单元,有确定的地址,每个数组元素放字符串的一个字符。字符指针p只占用一个可以存放地址的内存单元,存储字符串首字符的地址。如果要改变数组a所代表的字符串,只能改变数组元素的内容。如果要改变指针p所代表的字符串,通常直接改变指针的值,让它指向新的字符串。因为p是指针变量,它的值可以改变,转而指向其他单元。例如:

strcpy(a,"hello");
p = "hello";

分别改变了sa和sp所表示的字符串。而因为数组名是常量,不能对它赋值。

  • 为了尽量避免引用未赋值的指针所造成的危害,在定义指针时,可先将它的初值置为空,如
char *s = NULL;

动态内存分配

程序中需要使用各种变量来保存被处理的数据和各种状态信息,变量在使用前必须被定义且安排好存储空间包括内存起始地址和存储单元大小。C语言的全局变量、静态局部变量的存储是在编译时确定的,其存储空间的实际分配在程序开始执行 前完成。对于局部自动变量,在执行进人变量定义所在的复合语句时为它们分配存储单元,这种变量的大小也是静态确定的。以静态方式安排存储的做法也有限制,例如当数据量很大时,我们要先定义一个很大的数组,以保证输入的项数不要超过数组能容纳的范围。
一般情况下运行中的很多存储要求在写程序时无法确定,因此需要一种机制可以根据运行时的实际存储需求分配适当的存储区,用于存放那些在运行中才能确定数量的数据。在C语言中主要用两种方法使用内存:一种是由编译系统分配的内存区;另一种是用内存动态分配方式,留给程序动态分配的存储区。
动态分配的存储区在用户的程序之外,不是由编译系统分配的,而是由用户在程序中通过动态分配获取的。使用动态内存分配能有效地使用内存,同一段内存区域可以被多次使用,使用时申请,用完就释放。

动态内存分配函数

头文件:#include <stdlib.h>

malloc()函数

该函数用于申请一块连续的指定大小的内存块区域以void*类型返回分配的内存区域地址,在内存的动态存储区中分配一个长度为size的连续空间。此函数的返回值是分配区域的起始地址,或者说,此函数是一个指针型函数,返回的指针指向该分配域的开头位置。
函数原型

void *malloc(unsigned int size);

返回值:如果分配成功则返回指向被分配内存的指针(此存储区中的初始值不确定),否则返回空指针NULL。
当内存不再使用时,应使用free()函数将内存块释放。

calloc()函数

该函数功能为在内存的动态存储区中分配num个长度为size的连续空间。如果要求的空间无效,那么此函数返回指针。在分配了内存之后,calloc()函数会通过将所有位设置为0的方式进行初始化。比如,调用calloc()函数为n个整数的数组分配存储空间,且保证所有整数初始化为0。
函数原型:

void* calloc(unsigned int num,unsigned int size);

calloc与malloc的区别在于,在动态分配完内存后,自动初始化该内存空间为零,而malloc不做初始化,分配到的空间中的数据是随机数据。

realloc()函数

该函数将先判断当前的指针是否有足够的连续空间,如果有,扩大mem_address指向的地址,并且将mem_address返回,如果空间不够,先按照newsize指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来mem_address所指内存区域(注意:原来指针是自动释放,不需要使用free),同时返回新分配的内存区域的首地址。
函数原型:

void *realloc(void *mem_address, unsigned int newsize);

返回值:如果重新分配成功则返回指向被分配内存的指针,否则返回空指针NULL。

free()函数

释放之前调用 calloc、malloc 或 realloc 所分配的内存空间。如果传递的参数是一个空指针,则不会执行任何动作。该函数不返回任何值。
函数原型:

void free(void *ptr)

指针数组

C语言中的数组可以是任何类型,如果数组的各个元素都是指针类型,用于存放内存地址,那么这个数组就是指针数组。一维指针数组定义的一般格式为:

类型名  *数组名[数组长度];

类型名指定数组元素所指向的变量的类型。对于我们来说,关键是要掌握指针数组中,每个数组元素中存放的内容都是地址,通过数组元素可以访问它所措向的单元。指针数组是由指针变量构成的数组,在操作时,既可以直接对数组元素进行赋值和引用,也可以间接访问数组元素所指的单元内容,改变或引用该单元的内容。

指针数组操作多个字符串

指针数组与二维数组

代码实现:

定义二维字符数组时必须指定列长度,该长度要大于最长的字符串的有效长度。由于各个字符串的长度一般并不相同,会造成内存单元的浪费。而指针数组并不存放 字符串,仅仅用数组元素指向各个字符串,就没有类似的问题。

用指针数组操作多个字符串

伪代码:

函数定义void sort(char *a[], int n)
定义变量i, j用于进行排序操作;
定义指针变量*temp作为交换数据的中间变量;
for i = 0; i < n; i++ do   //对指针数组冒泡排序
   for j = 0; j < n - i - 1; j++ do
      if (strcmp(a[j + 1], a[j]) > 0)   //字符串之间作比较需要用字符串比较函数strcmp
         temp = a[j + 1];
         a[j + 1] = a[j];
         a[j] = temp;
      end if
   end for
end for

代码实现:

动态输入多个字符串

前面的举例在用指针数组操作多个字符串时,都是通过初始化的方式对指针数组赋值,使指针数组的元素指向字符串。如果需要输入多个字符串,我们可以利用动态内存分配来配合输入。
阅读裁判代码:

在裁判的代码中,根据输入的字符串的长短,通过函数malloc动态分配相应大小的内存单元,并将此单元的首地址保存在指针数组的相应元素中,即数组的元素指向这些动态分配的内存单元。采用动态分配内存的方法处理多个字符串的输入的优点在于, 数据的多少来申请和分配内存空间,从而提高了内存的使用率。

二级指针

定义:

类型名 **变量名;


我们很明白,任何变量都有地址,一级指针的值虽然是地址,但这个地址做为一个值亦需要空间来存放,二级指针就是为了获取这个地址。一级指针所关联的是地址里的数据,这个数据可以是任意类型并做任意用途,但二级指针所关联的数据只有一个类型一个用途,就是为了提供对于内存地址的读取或改写。
如果存在A指向B的指向关系,则A是B的地址,“A”表示通过这个指向关系间接访问B.如果B的值也是一个指针,它指向C,则B是C的地址,“B”表示间接访问C,如果C是整型、实型或者结构体等类型的变量或者是存放这些类型的数据的数组元素,则B(即C的地址)是普通的指针,称为一级指针,用于存放一级指针的变量称为一级指针变量,指向一级指针变量的"A"则是“二级指针”。

  • 二维数组名也是一个二级指针,指针数组名也是二级指针,因此用数组下标能完成的操作也能用指针完成。

例题讲解

合并两个有序数组

伪代码

函数定义
定义变量idxa,idxb,控制指针a,b指向单元的下标变量;
定义指针*num指向一个还未开辟的数组;
定义变量idx,控制指针num指向单元的下标变量;

动态内存分配给指针num,大小为(m + n) * sizeof(int);
while idxa + idxb < m + n do  //按照数字大小顺序将两个数组存放进新数组
   if idxb >= n   //当数组b数据存储完毕时,只需存储数组a
      num[idx++] = a[idxa++];
   else if idxa >= m   //当数组a数据存储完毕时,只需存储数组b
      num[idx++] = b[idxb++];
   else if a[idxa] < b[idxb]   //比较数组a,b下一个单元的大小,将较小的数据写入新数组
      num[idx++] = a[idxa++];
   else
      num[idx++] = b[idxb++];
   end if
   end if
   end if
end while
for idx = 0; idx < m + n; idx++
   a[idx] = num[idx];
end for

代码实现

本题知识点

1.动态内存分配开辟新数组

num = (int*)malloc((m + n) * sizeof(int));

详细知识点见上文
2.构造新数组:利用循环结构和判断结构,将两个数组的数据按顺序写入新数组
3.将数组数据拷贝到另一个数组

for idx = 0; idx < m + n; idx++ do
   a[idx] = num[idx];

说反话-加强版

伪代码

主函数

定义数组str[500001]存放输入的句子;
输入数组str;
调用函数SetContrary,传入参数数组str的地址,数组str的长度;

void SetContrary(char* str, int bit);

定义变量i,j控制循环并作为下标移动指针;
定义变量Word记录单个单词长度;
定义变量flag判断是否需要输出空格;
for i = bit - 1; i >= 0; i-- do   //从后往前遍历字符串
   if *(str + i) == ' ' && word != 0  //如果遇到空格且记录的单词长度不为零
      if flag != 0   //如果不是第一个单词,输出空格
         输出空格;
      end if
      for j = i + 1; j <= i + word; j++ do   //输出单词
         输出字符*(str + j);
      end if
      Word = 0;   //清零变量,记录下一个单词长度
      flag++;   //记录单词数量
   else if *(str + i) != ' '   //如果不是空格,单词长度加一
      Word++;
   end if
end for
if word > 0   //单独输出最后一个单词
   if flag != 0
      输出空格
   end if
   for j = 0; j <= word - 1; j++ do
      输出字符*(str + j);
   end for
end if

代码实现

本题知识点

1.函数传递数组

SetContrary(str, strlen(str));

传入数组的首地址给函数。
2.输出单个字符串中的部分字符
利用一个变量存储需要输出的字符数量,用判断结构找到需要输出的节点,利用循环结构输出;

for (i = bit - 1; i >= 0; i--)
{
      if (*(str + i) == ' ' && word != 0)
      {
            for (j = i + 1; j <= i + word; j++)
	    { 
	       printf("%c", *(str + j));
	    }
	    word = 0;
	    flag++;
      }
      else if (*(str + i) != ' ')
      {
            word++;
      }
}

3.利用变量移动指针指向的单元

printf("%c", *(str + j));

删除字符串中的子串

伪代码

定义指针*ptr用于记录子串查找结果;
定义字符数组mom[81],son[81]分别输入母串和子串;

输入母串;
输入子串;
while (ptr = strstr(mom, son)) != NULL do   //查找母串是否含有子串,若strstr函数返回值不为NULL,证明有查找到子串
   *ptr = '\0';   //标记查找到子串的位置为结束符‘\0’
   strcat(mom, ptr + strlen(son));   //将字符串的后半部分复制到结束符之前的部分
end while
puts(mom);

代码实现

知识点

1.字符串函数的应用

字符串查找函数strstr()

包含文件:string.h
函数名: strstr
函数原码:

char *strstr(const char *s1,const char *s2)
{
 int len2;
 if(!(len2=strlen(s2)))//此种情况下s2不能指向空,否则strlen无法测出长度,这条语句错误
     return(char*)s1;
 for(;*s1;++s1)
 {
     if(*s1==*s2 && strncmp(s1,s2,len2)==0)
     return(char*)s1;
 }
 return NULL;
}

语法:

strstr(str1,str2)

str1: 被查找目标
str2: 要查找对象
返回值:若str2是str1的子串,则返回str2在str1的首次出现的地址;如果str2不是str1的子串,则返回NULL。

2.字符串长度函数strlen()

头文件:string.h
格式:strlen
功能:计算给定字符串的长度,不包括'\0'在内,返回s的长度,不包括结束符NULL。

3.字符串连接函数

原型:

extern char *strcat(char *dest, const char *src);

头文件:string.h
功能:把src所指向的字符串(包括“\0”)复制到dest所指向的字符串后面(删除dest原来末尾的“\0”)。要保证dest足够长,以容纳被复制进来的src。src中原有的字符不变。返回指向dest的指针。src和dest所指内存区域不可以重叠且dest必须有足够的空间来容纳src的字符串。
2.活用字符串结束符'\0'
编译器判断字符串时,是通过读取字符数组直到结束符,则判断为一个字符串。本题代码中利用结束符把一个字符串分割成两个字符串(原字符串末尾还有一个结束符),然后操作指针,利用字符串拼接函数实现集成移动字符串。

延伸阅读

zoj 1418 Lazy Math Instructor

题意概括

先输入一个数字,接着输入n组字符串,输入的字符串都是一个算式,程序需要判断一组的算式是否等价,等价就输出"YES",不等价就输出"NO"。

代码分析:

  1. 这代码我看不懂,但是这道题涉及有考虑优先级的算式运算,所以怀疑使用了数据结构中的栈结构,经过咨询了学长、学姐之后确认了这段代码使用了栈结构。虽然是还没有学的知识,但是我还是强行去读了代码。栈的思想是“先进后出,后进先出”,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始。用数组来类比,相当于我操作一个只能从尾部操作的数组,从数组后面加入新元素,并使得数组有限长度加1,从数组尾部删除一个元素,则使数组长度减1。用在一个死胡同停车为例,如图所示:
  2. 这段代码我最大的疑惑是全局变量中的那个二维数组,它起到了判断符号优先级的作用,用于确认数据是进栈、出栈还是运算;
  3. 解题的目的是去判断等式是否等价,但是判断等式是否等价不是要去整理这两个算式,而是去计算这两个算式,因为字母虽然在算式中表示一个变量,但是字母是可以通过一定的处理进行代数运算的,因此这道题就变成了计算两个等式的结果并比较是否相等的问题了;
  4. 了解了什么是栈之后,就能大概知道这道题时怎么实现判断算式等价了,首先先把一个等式搞成后缀表达式,然后利用栈来进行计算,遇到数字就把数据放到栈里面,然后遇见符号就出栈进行计算,再把结果入栈,这样就可以有效去处理涉及考虑优先级的算式运算了,因此如何去处理这个表达式就显得尤其重要;
  5. 这段代码用了7个函数,其中一个函数是起到计算的作用,其他函数都是在起着数据处理的作用,我在跑代码调试的时候我是看不懂代码在干嘛的,只能看到不断在变化的4个数组和n个变量的值,但是这也说明了这段代码的函数接口做得很好,数据的输入和返回都能够准确无误地完成,变量的设计也很合理,都能够起到一定的作用;
  6. 运用了“#include <ctype.h>”头文件下的字符分类函数,isdigit()函数主要用于检查其参数是否为十进制数字字符,isalpha()函数用于判断字符ch是否为英文字母。

参考资料

《C语言程序设计(第三版)》——何钦铭、颜辉
指针
动态内存分配函数
一级指针与二级指针
数组指针和指针数组的区别

posted @ 2019-12-01 14:37  乌漆WhiteMoon  阅读(815)  评论(0编辑  收藏  举报