3.1 数组

考虑这样一个问题:读入一些整数,逆序输出到一行中。已知整数不超过100个。如何编写这个程序呢?首先是循环读取输入。读入每个整数以后,应该做些什么呢?思来想去,在所有整数全部读完之前,似乎没有其他事可做。换句话说,只能把每个数都存下来。存放在哪里呢?答案是:数组。

程序3-1 逆序输出


#include<stdio.h>
#define maxn 105
int a[maxn];
int main()
{
  int x, n = 0;
  while(scanf("%d", &x) == 1)
    a[n++] = x;
  for(int i = n-1; i >= 1; i--)
    printf("%d ", a[i]);
  printf("%d\n", a[0]);
  return 0;
}

语句“int a[maxn]”声明了一个包含maxn个整型变量的数组,它们是:a[0],a[1],a[2],…,a[maxn-1]。注意,没有a[maxn]。

提示3-1:语句“int a[maxn]”声明了一个包含maxn个整型变量的数组,即a[0],a[1],…,a[maxn-1],但不包含a[maxn]。maxn必须是常数,不能是变量。

为什么这里声明maxn为105而不是100呢?因为这样更保险。

提示3-2:在算法竞赛中,常常难以精确计算出需要的数组大小,数组一般会声明得稍大一些。在空间够用的前提下,浪费一点不会有太大影响。

接下来是语句“a[n++]=x”,它做了两件事:首先赋值a[n]=x,然后执行n=n+1。如果觉得难以理解,可以将其改写成“{a[n]=x;n=n+1;}”。注意这里的花括号是不能省略的,因为在默认情况下,for语句的循环体只有一条语句。只有使用花括号时,花括号里的语句才会整体作为循环体。一般地,当表达式里出现n++时,表达式会使用加1前的n计算表达式,当表达式计算完毕之后再给n加1。

和n++相对应的,还有一个++n,表示先给n增加1,然后使用新的n。前缀和后缀“++”运算符是C语言的特色之一。事实上,后面将要介绍的C++语言名字里的“++”就是该运算符。

提示3-3:对于变量n,n++和++n都会给n加1,但当它们用在一个表达式中时,行为有所差别:n++会使用加1前的值计算表达式,而++n会使用加1后的值计算表达式。“++”运算符是C语言的特色之一。

循环结束后,数据被存储在a[0],a[1],…,a[n-1]中,其中变量n是整数的个数(想一想,为什么)。

存好以后就可以输出了:依次输出a[n-1],a[n-2],…,a[1]和a[0]。这里有一个小问题:一般要求输出的行首行尾均无空格,相邻两个数据间用单个空格隔开。这样,一共要输出n个整数,但只有n-1个空格,所以只好分两条语句输出。

在上述程序中,数组a被声明在main函数的外面。请试着把maxn定义中的100改成1000000,比较一下把数组a放在main函数内外的运行结果是否相同。如果相同,试着把1000000改得再大一些。当实验完成之后,读者应该就能明白为什么要把a的定义放在main函数的外面了。简单地说,只有在放外面时,数组a才可以开得很大;放在main函数内时,数组稍大就会异常退出。其道理将在后面讨论,现在只需要记住规则即可。

提示3-4:比较大的数组应尽量声明在main函数外,否则程序可能无法运行。

C语言的数组并不是“一等公民”,而是“受歧视”的。例如,数组不能够进行赋值操作:在程序3-1中,如果声明的是“int a[maxn],b[maxn]”,是不能赋值b=a的。如果要从数组a复制k个元素到数组b,可以这样做:memcpy(b,a,sizeof(int)*k)。当然,如果数组a和b都是浮点型的,复制时要写成“memcpy(b,a,sizeof(double)*k)”。另外需要注意的是,使用memcpy函数要包含头文件string.h。如果需要把数组a全部复制到数组b中,可以写得简单一些:memcpy(b,a,sizeof(a))。

开灯问题。有n盏灯,编号为1~n。第1个人把所有灯打开,第2个人按下所有编号为2的倍数的开关(这些灯将被关掉),第3个人按下所有编号为3的倍数的开关(其中关掉的灯将被打开,开着的灯将被关闭),依此类推。一共有k个人,问最后有哪些灯开着?输入nk,输出开着的灯的编号。kn≤1000。

样例输入:

7 3

样例输出:

1 5 6 7

【分析】

用a[1],a[2],…,a[n]表示编号为1,2,3,…,n的灯是否开着。模拟这些操作即可。

程序3-2 开灯问题


#include<stdio.h>
#include<string.h>
#define maxn 1010
int a[maxn];
int main()
{
  int n, k, first = 1;
  memset(a, 0, sizeof(a));
  scanf("%d%d", &n, &k);
  for(int i = 1; i <= k; i++)
    for(int j = 1; j <= n; j++)
      if(j % i == 0) a[j] = !a[j];
  for(int i = 1; i <= n; i++)
    if(a[i]) { if(first) first = 0; else printf(" "); printf("%d", i); }
  printf("\n");
  return 0;
}

“memset(a,0,sizeof(a))”的作用是把数组a清零,它也在string.h中定义。虽然也能用for循环完成相同的任务,但是用memset又方便又快捷。另一个技巧在输出:为了避免输出多余空格,设置了一个标志变量first,可以表示当前要输出的变量是否为第一个。第一个变量前不应有空格,但其他变量都有。

蛇形填数。n×n方阵里填入1,2,…,n×n,要求填成蛇形。例如,n=4时方阵为:

 

10 11 12 1
9 16 13 2
8 15 14 3
7 6 5 4

上面的方阵中,多余的空格只是为了便于观察规律,不必严格输出。n≤8。

【分析】

类比数学中的矩阵,可以用一个二维数组来储存题目中的方阵。只需声明一个“int a[maxn][maxn]”,就可以获得一个大小为maxn×maxn的方阵。在声明时,二维的大小不必相同,因此也可以声明int a[30][50]这样的数组,第一维下标范围是0,1, 2,…,29,第二维下标范围是0,1,2,…,49。

提示3-5:可以用“int a[maxn][maxm]”生成一个整型的二维数组,其中maxn和maxm不必相等。这个数组共有maxn×maxm个元素,分别为a[0][0], a[0][1],…, a[0][maxm-1], a[1][0],a[1][1],…,a[1][maxm-1],…,a[maxn-1][0],a[maxn-1][1],…, a[maxn-1] [maxm -1]。

从1开始依次填写。设“笔”的坐标为(x,y),则一开始x=0,y=n-1,即第0行,第n-1列(行列的范围是0~n-1,没有第n列)。“笔”的移动轨迹是:下,下,下,左,左,左,上,上,上,右,右,下,下,左,上。总之,先是下,到不能填为止,然后是左,接着是上,最后是右。“不能填”是指再走就出界(例如4→5),或者再走就要走到以前填过的格子(例如12→13)。如果把所有格子初始化为0,就能很方便地加以判断。

程序3-3 蛇形填数


#include<stdio.h>
#include<string.h>
#define maxn 20
int a[maxn][maxn];
int main()
{
  int n, x, y, tot = 0;
  scanf("%d", &n);
  memset(a, 0, sizeof(a));
  tot = a[x=0][y=n-1] = 1;
  while(tot < n*n)
  {
    while(x+1<n && !a[x+1][y]) a[++x][y] = ++tot;
    while(y-1>=0 && !a[x][y-1]) a[x][--y] = ++tot;
    while(x-1>=0 && !a[x-1][y]) a[--x][y] = ++tot;
    while(y+1<n && !a[x][y+1]) a[x][++y] = ++tot;
  }
  for(x = 0; x < n; x++)
  {
    for(y = 0; y < n; y++) printf("%3d", a[x][y]);
    printf("\n");
  }
  return 0;
}

这段程序充分利用了C语言简洁的优势。首先,赋值x=0和y=n-1后马上要把它们 作为数组a的下标,因此可以合并完成;tot和a[0][n-1]都要赋值1,也可以合并完成。这样,就用一条语句完成了多件事情,而且并没有牺牲程序的可读性——这段代码的含义显而易见。

提示3-6:可以利用C语言简洁的语法,但前提是保持代码的可读性。

那4条while语句有些难懂,不过十分相似,因此只需介绍其中的第一条:不断向下走,并且填数。我们的原则是:先判断,再移动,而不是走一步以后发现越界了再退回来。这样,则需要进行“预判”,即是否越界,以及如果继续往下走会不会到达一个已经填过的格子。越界只需判断x+1<n,因为y的值并没有修改;下一个格子是(x+1,y),因此只需“a[x+1][y] == 0”,简写成“!a[x+1][y]”(其中“!”是“逻辑非”运算符)。

提示3-7:在很多情况下,最好是在做一件事之前检查是不是可以做,而不要做完再后悔。因为“悔棋”往往比较麻烦。

细心的读者也许会发现这里的一个“潜在bug”:如果越界,x+1会等于n,a[x+1][y]将访问非法内存!幸运的是,这样的担心是不必要的。“&&”是短路运算符(还记得我们在哪里提到过吗?)。如果x+1<n为假,将不会计算“!a[x+1][y]”,也就不会越界了。

至于为什么是++tot而不是tot++,留给读者思考。