浅谈指针
囧仙
·
2020-05-15 20:57:45
·
个人记录
目录
- $0.1$ 定义
- $0.2$ 大小&基本操作
- $1.1$ 运算符
- $1.2$ 地址偏移
- $2.1$ 别名
- $2.2$ 申请内存
- $2.3$ 函数传参
- $2.4$ 下标偏移
- $4.1$ 字符数组
- $4.2$ 函数指针
- $4.3$ 万能指针
总结
\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实际上是向后数了200 个p指针的类型长度的地址。这也就是为什么指针要明确类型。
\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来调用了。
用别名还有一个好处。假如我们需要交换两个数组A ,B (或者结构体),逐次交换显然非常浪费时间。但假如我们用了两个指针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>