浅谈指针

· · 个人记录

目录

\texttt{Part 0} 啥是指针

\texttt{Part 0.1} 定义

在计算机科学中,指针(英语:\rm Pointer),是编程语言中的一类数据类型及其对象或变量,用来表示或存储一个存储器地址,这个地址的值直接指向(\rm points to)存在该地址的对象的值。

——引用自维基百科。

简单来说,一个\bf type类型的指针就是用来存储\bf type类型的变量/常量的地址的东西。

几个例子:

int *a;     //一个指向int变量地址的指针
char *b;    //一个指向char变量地址的指针
int **c;    //一个指向[int类型指针]的地址的指针

\texttt{Part 0.2} 大小&运算

不同的数据类型对指针所占用的内存大小有什么关联呢?我们可以用sizeof(p)来获取指针p所占用的空间。

以下是一个检测指针所占内存的程序。

#include<bits/stdc++.h>
using namespace std;
char *a; int *b; string *c;
struct Node{
    int u,v,w;
}*d;
int main(){
    printf("size of a = %d\n",sizeof(a));
    printf("size of b = %d\n",sizeof(b));
    printf("size of c = %d\n",sizeof(c));
    printf("size of d = %d\n",sizeof(d));
    return 0;
}

32位操作系统下,输出均为4(\tt{Byte}),而在64位操作系统(如洛谷\rm IDE),输出的结果均为8(\tt{Byte})。也就是说,指针占用的内存与类型无关,只与操作系统有关

\texttt{Part 1} 指针的运算

\texttt{Part 1.1} 运算符

在指针里,有两个非常重要的运算:取地址(一般为&运算符),以及间接寻址(一般为*运算符)。

给指针赋值时,由于指针实际存储的是地址,因此我们需要获得指向的变量的地址。比如说,int a; int *p=&a,就是将int类型的变量a的地址赋给了p。取地址和间接寻址互为逆运算,也就是说,我们使用*p就可以取得变量a的数值了。同样,也可以通过*p=b来将a的值赋为b

但是,有关地址的运算符不止这两个。这里补充一下[]运算符。

假如你使用了一条语句int A[100],来定义了一个长度为100,下标范围是[0,100)的数组A。这时候,你可以使用[]运算符来获取A数组某一位的值。比如说,A[20]就可以获得数组中第21个变量的值,A[50]=3就可以把数组中第51个变量赋值为3

事实上,A相当于一个整形指针,它存储的值就是分配到的这个长度为100的数组的第一个元素。假如你定义了一个int指针p,并且将p赋值为了A(即p=A不需要&运算符),那么p[20]返回的其实就是A[20]p[50]=3就相当于A[50]=3

\texttt{Part 1.2} 地址偏移

上文提到,假如我们定义了int A[100],实际上就是让系统分配给了我们一个长度为100的内存,并让A指向这段内存的第一位。

事实上,你可以认为内存的分配是连续的(虽然在底层可能不连续)。也就是说,假如数组第一位的地址为0xff00,那么第二位就是0xff04,第三位就是0xff08,(每个int类型占4个字节),第n位就是0xff00+4*n。因此,A[x]本质上就是*(A+x),也就是将第一位的地址向后偏移了\rm sizeof(int)\it \times x位。

多维数组的分配方式略有不同。多维数组实际占用的内存大小就是每一位的乘积。char A[20][30]就是分配了一个长度为20\times 30=600的字符数组。实际上,我们取A[10][15]的值,就是取了*(A+10*30+15)(第一个*是间接寻址运算符,第二个是乘号)。也就是说,多维数组在实现方面就是展开成一维数组。但是,多维数组的指针的定义比较特殊。假如有一个数组A[20][30],那么定义它的指针应当是int (*p)[30]。为什么要这样呢?如果编译器不知道数组第二维的长度,就无法知道如何偏移。

此外,p+200实际上是向后数了200p指针的类型长度的地址。这也就是为什么指针要明确类型。

\texttt{Part 2} 应用

学过了上面的基础知识,让我们看看指针有啥好玩的用处吧。

\texttt{Part 2.1} 别名

这是一个很多教材都会使用的例子。假如我们要频繁访问一个名字很长的东西(比如int数组A[B[i][j]+C[j][i]][k]),那么我们完全可以用指针缩短长度。比如上例中,可以直接写成int *p=&(A[B[i][j]+C[j][i]][k]),下面操作的时候就可以直接使用*p来调用了。

用别名还有一个好处。假如我们需要交换两个数组AB(或者结构体),逐次交换显然非常浪费时间。但假如我们用了两个指针int *_A=A,*_B=B来分别指向这两个数组,那么交换的时候直接交换指针就非常快速了。

(当然,交换名字实际上并没有改变内存的实际情况。因此如果需要交换数组的某一部分,还是要逐个交换。

一个简单的实验:

```cpp #include<bits/stdc++.h> using namespace std; const int MAXN =1e5+3; int A[MAXN],B[MAXN],t=1e5; int main(){ int t0=clock(); for(int i=1;i<=t;++i) A[i]=i,B[i]=-i; for(int i=1;i<=t;++i) swap(A,B); printf("Used [%d]ms.\n",clock()-t0); return 0; }//注:swap函数实际上依次交换了A,B数组的每个元素。 ``` $\bf{II}:$交换代表数组的指针 ``` #include<bits/stdc++.h> using namespace std; const int MAXN =1e5+3; int M1[MAXN],M2[MAXN],*A=M1,*B=M2,t=1e5; int main(){ int t0=clock(); for(int i=1;i<=t;++i) A[i]=i,B[i]=-i; for(int i=1;i<=t;++i) swap(A,B); printf("Used [%d]ms.\n",clock()-t0); return 0; } ``` 后者远快于前者。 当然,交换数组所在指针实际上只是交换了名字,但是内存上并没有交换。不过大多数需要交换数组/结构体的题目(比如滚动数组优化内存,$\rm Splay$交换儿子之类的)都可以用这种方法节省时间。 ### $\texttt{Part 2.2}$ 申请内存 同样是一个非常经典的应用。我们可以使用$\bf new$关键字,申请一段内存。此时就需要用指针存储这段内存的首指针了。比如,`int *p=new int[20];`,就是申请了一个长度为$20$的$\rm int$数组。我们使用`p[x]`就能进行访问。 当然,有些时候你还需要释放内存。比如,上例我们可以使用`free(p)`来释放申请到的内存。 这里给出一个可以动态申请内存的数组的例子: ```cpp #include<bits/stdc++.h> using namespace std; struct array{ int *P[20]; void iit(){ //初始化,将所有指针指向空 for(int i=0;i<20;++i) P[i]=NULL; } int *operator [](int idx){ //访问操作 ++idx; int len=0,tmp=idx; if(idx>>16) len|=16,idx>>=16; if(idx>>8) len|=8,idx>>=8; if(idx>>4) len|=4,idx>>=4; if(idx>>2) len|=2,idx>>=2; if(idx>>1) len|=1,idx>>=1; len+=idx-1; //这部分用来定位idx的位数 if(P[len]==NULL) P[len]=new int[1<<len]; //动态申请内存 return P[len]+(tmp-(1<<len)); } void dlt(){ //释放内存 for(int i=0;i<20;++i) free(P[i]); } }A; int main(){ A.iit(); scanf("%d",A[100000]); printf("%d\n",*A[100000]); //要注意的是,A返回的实际上是内存地址,因此要用* A.dlt(); return 0; } ``` 这个例子中,我们重定义了`[]`运算符,用来动态申请内存。 当我们输入一个数组下标时,就会找到这个下标二进制下最高位。不妨设这一位是第$x$位(从右往左数,从0开始)。接着我们在对应字段申请对应于下标$[2^x,2^{x+1})$的内存,然后返回我们需要的内存的地址。 定位的复杂度为$\mathcal O(\log \log N)$。 ### $\texttt{Part 2.3}$ 函数传参 好像不少入门书籍都没有介绍这一点……这里做一些补充。 其实这方面的应用非常广泛。很多函数,比如`lower_bound`,`memcpy`,`memset`等,似乎都需要传入数组。但是事实上,我们并不需要传入整个数组,而是传入它的**头指针**。比如下面这个例子: ```cpp #include<bits/stdc++.h> using namespace std; const int MAXN =1e5+3; int find(int *A,int len,int x){ if(A[0]==x) return 0; int p=0; for(int k=1<<20;k;k>>=1) if(p|k<=len&&A[p|k]<x) p|=k; return A[p+1]==x?p+1:-1; } int P[10]={1,1,2,4,5,6,7,8,8,12}; int main(){ printf("find 1:%d\n",find(P,10,1)); printf("find 3:%d\n",find(P,10,3)); printf("find 4:%d\n",find(P,10,4)); printf("find 8:%d\n",find(P,10,8)); printf("find 13:%d\n",find(P,10,13)); return 0; } ``` 这是一个二分查找的例子。在这个例子中,我们传入了长度为$10$的数组$P$并进行二分查找。但事实上,我们只是传入了$P$数组的起始内存地址,然后通过内存偏移直接访问数组内的数字。 然后让我们看看多维数组。 ```cpp #include<bits/stdc++.h> using namespace std; const int MAXN =1e5+3; int f(int x,int y,int z,int (*A)[20][10]){ return A[x][y][z]; } int P[30][20][10]; int main(){ P[13][16][4]=5; printf("%d\n",f(13,16,4,P)); return 0; } ``` 在这个例子中,我们查询了$A_{x,y,z}$。由于多维数组的偏移量与开头以外的长度有关,因此我们需要告诉编译器数组$A$的后两个位的长度大小。 举一个经典的例题:[P4205 智慧珠游戏](https://www.luogu.com.cn/problem/P4205)。 **题目描述**:用$12$个智慧珠填满棋盘。无解输出`No solution`。 考虑将每个智慧珠用这种方式储存:任取给定图案的某个格子作为起始位置,用字符`A,B,C,D`表示从当前位置向上、右、下、左走一格,用`<`表示退一步。只需要用数组$dir=\tt\{\{-1,0\},\{1,0\},\{1,0\},\{0,-1\}\}$存储每种走法对$x,y$坐标产生的偏转量。取$dir_{op\mod 4}$即可找到对应的偏移量;给$op$加上偏移量$t$,就能决定图案的旋转;对$x$坐标进行颠倒,就能上下翻转。 预处理好每个方块,直接暴搜即可。 事实上,我们还可以使用指针对其中出现的大量多维数组进行简化。假如我们有数组`W[a][b][c][2]`,就可以开一个指针`(*p)[2]=W[a][b]`,那么`p[x][y]=W[a][b][x][y]`了。 通过这些优化,可以大大降低码量,使得这个码农题瞬间变得非常简单了。(码量:$2\rm kB$) **参考代码:** ```cpp #include<bits/stdc++.h> #pragma GCC optimize(2) #define up(l,r,i) for(int i=l;i<=r;i++) #define dn(l,r,i) for(int i=l;i>=r;i--) #define mkp(x,y) make_pair(x,y) using namespace std; typedef unsigned long long u64; const int INF =2147483647; const int N =10+3,S=8+3,M=64+3; const char P[][S]={"","AB","BBB","ABB","ABC","CCBB","BC<BB", "ABBC","CBAB","BBCB","BA<C<B","CBCB","ABBB"}; const int Q[]={0,3,1,7,0,3,7,3,7,7,0,3,7}; const int dir[4][2]={{-1,0},{0,1},{1,0},{0,-1}}; int X[M],Y[M],tot,B[N][N]; bool flg,V[N]; int W[N][N][S][2],num,T[N][S]; void iit(){ int x,y,tx,ty,(*w)[2],d,t,o,ox,oy; up(1,12,i) up(0,Q[i],j){ x=y=tx=ty=0,w=W[i][j],t=1,o=(j>3?-1:1); for(const char *p=P[i];*p;++p){ if(*p=='<') x=ox,y=oy; else d=(*p+j)&3,ox=x,oy=y,x+=dir[d][0]*o,y+=dir[d][1]; w[++t][0]=x,w[t][1]=y; if(x<=tx) ty=(x<tx?y:min(y,ty)),tx=x; } up(1,t,k) w[k][0]-=tx,w[k][1]-=ty; T[i][j]=t; } } int _; void dfs(int u){ if((++_)>6e6){puts("No solution"),exit(0);} int x=X[u],y=Y[u]; if(B[x][y]) dfs(u+1); else up(1,12,i) if(!V[i]) up(0,Q[i],j){ bool flg=1; int (*w)[2]=W[i][j],t=T[i][j]; up(1,t,k){ int nx=x+w[k][0],ny=y+w[k][1]; if(nx>9||ny<0||ny>nx||B[nx][ny]){flg=0;break;} } if(!flg) continue; up(1,t,k) B[w[k][0]+x][w[k][1]+y]=i; ++num,V[i]=true; if(num==12){ up(0,9,a) up(0,a+1,b) putchar(b==a+1?'\n':'A'+B[a][b]-1); exit(0); } else dfs(u+1),--num,V[i]=false; up(1,t,k) B[w[k][0]+x][w[k][1]+y]=0; } } char readc(){ char c; while(!isalpha(c=getchar())&&c!='.'); return c; } int main(){ iit(); up(0,9,i) up(0,i,j){ char t=readc(); if(t!='.') B[i][j]=t-'A'+1,V[t-'A'+1]=true; X[++tot]=i,Y[tot]=j; } up(1,12,i) if(V[i]) ++num; dfs(1); puts("No solution"); return 0; } ``` ### $\texttt{Part 2.4}$ 下标偏移 这里给出一道经典题,作为例子:[P1835 素数密度](https://www.luogu.com.cn/problem/P1835)。 **题目描述**:给定区间$[L,R],1\le L\le R\le 2^{31}-1,R-L\le 10^6)$,请计算区间中素数的个数。 解法非常简单。用素数筛筛出$[1,\sqrt{R}]$内所有的素数$P_i$,然后用这些素数去筛选$[L,R]$内的数字,剩下来的就是素数。 写代码时有一个关键的步骤:`vis[P[i]*j-L]=true;`如果你希望偷懒,那么可以声明布尔指针`bool *T=vis-L`。那么,访问`T[x]`,就相当于访问了`vis[x-L]`。 参考代码: ```cpp #include<bits/stdc++.h> #define up(l,r,i) for(int i=l;i<=r;i++) #define dn(l,r,i) for(int i=l;i>=r;i--) using namespace std; const int MAXN =1e6+3,MAXM=2e5+3,MAXK=5e3+3; bool V[MAXK],U[MAXN],*W; int L,R,T,ans,n,P[MAXM]; int main(){ scanf("%d%d",&L,&R),W=U-L,T=sqrt(R)+1; for(int i=2;i<=T;++i){ if(!V[i]) P[++n]=i; for(int j=2;j<=T/i;++j) V[i*j]=true; } for(int i=1;i<=n;++i){ for(int j=max((L-1)/P[i]+1,2);j<=R/P[i];++j) W[P[i]*j]=true; } for(int i=R;i>=max(2,L);--i) ans+=!W[i]; printf("%d\n",ans); return 0; } ``` 此外,这种方法还可以简单地实现负数下标。假如需要定义数组`A[s..t]`,表示下标区间在$[s,t)$的数组$s<t$,$t-s$不是一个很大的数字,那么我们可以开一个用来存放实际数字的数组`int M[t-s]`,然后用指针`int *A=M-s`。那么访问`A[s]`,就是访问了`M[0]`,`A[t]`就是`M[t-s]`,以此类推。 ## $\texttt{Part 3}$ 数组模拟指针 这是一个非常常见的东西。和实际中的内存池类似,我们可以用数组模拟内存池进行空间的分配。具体而言,我们用一个数组$M$来存放实际使用的空间,而用非负整数表示某个变量在$M$中的位置。 比如说,写平衡树时,我们用`L[x],R[x]`分别表示$x$节点左右儿子的编号。实际上,$x,L_x,R_x$都是在一个大内存池中的地址。 用数组模拟指针的优点,主要在于**节省时间**和**节省空间**。用指针零零碎碎地申请内存,会导致内存不连续等问题,造成一定的时间损耗(不如不能卡进$\rm Cache$里之类的)。同时,在上文提到,一般指针会占用$4$或$8$字节的空间,如果需要的空间很大,那么这样会是一个不小的开销。 但是指针的好处就在于它在真正的内存池内。你可以使用`*p`来直接访问地址$p$处的内容,而非`M[p]`。同时,使用指针将会方便多个数组之间的访问。 因为内存池不是本文重点,这里不再赘述。 ## $\texttt{Part 4}$ 其他东西 ### $\texttt{Part 4.1}$ 字符数组 当初学$\rm C++$时,肯定会学习到**字符数组**和**字符串**($\bf string$)。我当时就遇到一个很大的问题,就是字符数组和字符串的读入,以及两者的区别。 `scanf`里有一个特殊的读入字符数组的方法,就是`scanf("%s",s)`。它与其他使用`scanf`输入的变量有个很大的区别,就是不需要取地址运算符`&`,并能直接读入了整个字符数组。实际上,`s`就是字符数组$s$的首位内存的地址。而其他变量需要获得它的地址,就要用`&`。关于字符串的读入,由于$\rm string$是$\rm C++$的特性,因此`scanf`**不支持**字符串的输入。需要用`cin>>s`来输入。 另外,很多`cstring`库里的函数都是针对**字符数组**的。而传入的东西就是我们需要操作的字符数组的起始内存。但是$\bf string$本质上是**一个**结构体,并且使用了其他的内存分配方式,因此无法用字符数组的操作。 此外如果你使用编辑器的某些功能,就可以看到,形如`"abc_1234"`的东西是$\bf const\ char*$类型的,也就是字符数组常量。实际上这段内容返回的是这个常量的首位地址。要注意的是,它的末尾会以`\0`结尾作为结束的标志。 ### $\texttt{Part 4.2}$ 函数指针 $\rm C++$同时也提供了一种能够指向**函数**的指针。 它的定义方式如下:`<返回值类型> (*<指针名称>)(<参数类型>)`。比如说,`int (*p)(int,int)`就可以指向一个返回值为$\bd int$型,并且两个参数都为$\bf int$类型的函数。指向函数后,$p$自己就相当于那个函数。如`p=max; c=p(a,b);`就等价于`c=max(a,b)`。 函数指针还有一个特别的作用,就是在函数中传入指定的函数(有点饶舌)。比如下面这个例子: ```cpp #include<bits/stdc++.h> using namespace std; void sort(int *A,int *B,int s,int t,bool (*cmp)(int,int)){ if(s==t) return; int mid=(s+t)>>1,p1=s,p2=mid+1,p3=s; sort(A,B,s,mid,cmp),sort(A,B,mid+1,t,cmp); while(p1<=mid&&p2<=t){ cmp(A[p1],A[p2])?(B[p3++]=A[p1++]):(B[p3++]=A[p2++]); } while(p1<=mid) B[p3++]=A[p1++]; while(p2<=t ) B[p3++]=A[p2++]; for(int i=s;i<=t;++i) A[i]=B[i]; } int P[10]={2,3,6,1,7,9,2,4,1,7},Q[10]; bool my_cmp(int a,int b){ return a<b; } int main(){ sort(P,Q,0,9,my_cmp); for(int i=0;i<10;++i) printf("%d ",P[i]); return 0; } ``` 它是一个能够使用自定义比较器的归并排序。我们向`sort`函数内传入了函数指针`cmp`,调用时使用了自定义比较器`my_cmp`,其本质就是将函数地址传入了`sort`里。 ### $\texttt{Part 4.3}$ 万能指针 由于一些原因,$\bf int$类型的指针只能指向$\bf int$类型,$\bf char$指针只能指向$\bf char$类型。这样会造成一个困扰,就是函数传参时需要进行显式数据类型转换。事实上,我们可以用$\bf void$类型的指针,来**接受任何类型的指针**。比如`memset`的原型为`void* memset( void* dest, int ch, std::size_t count )`,这使得它可以接受任何类型的指针到$dest$中,也方便了我们的操作。 然而万能指针**不能进行数值操作**。也就是说,你需要将万能指针强制类型转换为某种指针,才能操作。如一个可能的`memset`代码: ```cpp void* memset(void* dst,int val, size_t count){ void* ret = dst; while(count--) *(char*)dst=(char)val,dst=(char*)dst+1; return ret; } ``` ## 总结 指针是$C/C++$中用来指向内存地址的特殊的类型,可以用来处理很多内存相关的问题。 在代码中使用指针,可以一定程度上优化时间和空间,并且减少很多码量,也能增加一定的可读性。 缺点就是在竞赛中,可能并不需要对内存进行很多操作。 ## 参考资料 - 中文$\rm cppreference$上对指针的介绍。 <https://zh.cppreference.com/w/cpp/language/pointer> - 中文维基百科词条。 <https://zh.wikipedia.org/zh-hans/%E6%8C%87%E9%92%88>