智能程序设计(C语言)


一维数组

计算机学院    杨已彪

yangyibiao@nju.edu.cn

提纲

  • 一维数组: 声明、下标、初始化

  • 为什么需要数组

  • 案例: 数列反向、检查重复数字、大小写转换

  • 现场挑战: 平均分与高于平均、进制转换、回文数

  • sizeof 与变长数组

  • AI 审计


标量变量与聚合变量

到目前为止的变量都是标量:

int x = 5; /* 一个变量存一个值 */

  • 保存单一数据项

C 还支持聚合:

int a[10]; /* 一个名字存一组值 */

  • 存储一组一组的数值

  • 聚合类型有两种: 数组和结构体


本章主角: 一维数组 (它在 C 语言中的作用比多维数组更大)


什么是一维数组

数组: 存多个同类型的值, 按位置取元素

一维数组的元素一个接一个排成一行 (模型图; 实际上它们在内存里连续存放, 中间没有空隙):

                   

  • 同类型: 整个数组只有一种元素类型——声明时定死

  • 按位置: 想取第几个就取第几个, 位置从 0 数起 (下一页讲下标)


数组的声明

int a[10];
│  │ │
│  │ └─ 长度: 10 个元素 (下标 0~9)
│  └──── 数组名: 这批数据共用的名字
└─────── 元素类型: 每个元素都是 int
#define N 10
…
int a[N];

声明一个数组, 需要定两件事: 类型和数量


元素可以是任何类型; 长度必须是(整数)常量表达式

可以用宏定义数组长度——改长度只改一处


数组下标

访问某个元素: 数组名后加方括号, 里面放整数——叫作取下标 (也叫索引)

长度为 n 的数组, 下标范围是 0 到 n-1, 元素依次是 a[0], a[1], ..., a[n-1]:

a[0] \(a[1]\) \(a[2]\) \(a[3]\) \(a[4]\) \(a[5]\) \(a[6]\) \(a[7]\) \(a[8]\) \(a[9]\)
‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌

for (i = 1; i <= 10; i++)
  a[i] = 0;        /*** WRONG: 越界, 还漏了 a[0] ***/

重点: 长度 n 的数组没有 a[n]——上面的循环就是反面教材, 正确范围是 0 到 n-1


数组下标: 循环三件套

a[0] = 1;
printf("%d\n", a[5]);
++a[i];
for (i = 0; i < N; i++)
  a[i] = 0;             /* ① 清零 */

for (i = 0; i < N; i++)
  scanf("%d", &a[i]);   /* ② 读入 */

for (i = 0; i < N; i++)
  sum += a[i];          /* ③ 求和 */

a[i] 是左值, 可以像普通变量一样用——上面三种操作都合法

  • 对数组的常见操作, 几乎都是"循环 + 下标"的这三个模式:

- ① 清零 / 赋初值、② 逐个读入、③ 累加 / 统计


没有捷径: scanf 一次只能读一个元素——想读整个数组必须循环逐个 scanf("%d", &a[i]); 写 scanf("%d", a) 只会读进 a[0]


数组下标: 可以是表达式

a[i + j * 10] = 0;  /* 先算 i + j*10 */
a[i * W + j];       /* 第 i 行第 j 列 */
a[n - 1 - i];       /* 倒数第 i 个 */

下标里可以写任意整数表达式: 先算出值, 再当下标用


  • a[i * W + j]——把二维网格平铺成一维; 周三的二维数组, 底层就是这个

  • a[n - 1 - i]——倒数第 i 个; 待会儿的数列反向、挑战里的回文数都靠它


数组下标: 有副作用的下标

i = 0;
while (i < N)
  a[i++] = 0;      /* ① 下标顺带改了 i */

i = 0;
while (i < N)
  a[i] = b[i++];   /*** ② 危险! ***/

for (i = 0; i < N; i++)
  a[i] = b[i];     /* ③ 安全写法 */

下标里也能写 i++——副作用要小心:

  • ① 只读写 i 一次, 行为确定

  • ② 读 i 和改 i 不分先后, 导致未定义的行为

  • ③ 把自增移出下标——for 的第三段专门干这个


C 不规定 = 两边谁先算——就算你试出来"先算右边", 也只是巧合; 换编译器或开优化, 结果就可能变

= 是右结合: 它只管怎么加括号——a = b = c 拆成 a = (b = (c = 5)); 谁先算, 它不管


演示: 越界写会"伤及无辜"

#include <stdio.h>

int main(void)
{
  int a[4] = {10, 20, 30, 40};
  int i;

  for (i = 0; i < 6; i++)
    printf("a[%d] = %d\n", i, a[i]);   /* 读越界! */
  return 0;
}

运行结果 (本机):

a[0] = 10
a[1] = 20
a[2] = 30
a[3] = 40
a[4] = -149683968   ← 不知道哪来的值
a[5] = 1
  • 下标范围是 0 ~ n-1; a[4]、a[5] 越界读, C 语言不检查、不报错, 程序照跑

  • 读到的值是垃圾值: 那块内存里的旧数据——每次运行可能都不一样

  • 越界写更危险: 向前写本机直接崩溃 (栈保护); 向后写 a[-2] 正好落在 i 上——循环变量被改, 循环失控; 开 -O2 又没事 (i 进了寄存器)

  • 教训: 循环访问数组时检查边界 (i < n), 不要依赖编译器或运气


为什么需要数组

我们已经会用数组了——回头想想: 它到底解决了什么问题?


  • 第 1 周的"找最高分": 读一个、比一个、丢一个——一个变量就够, 因为不需要回头

  • 有了数组, 问题就敢这样提了:

- 把 10 个成绩逆序打印——读完了还要"回头"

- 算完平均分, 统计有几人高于平均——先算平均, 再回头比每个人

- 给全班成绩排序——数据之间要反复比较


结论: 需要"回头"再看的数据, 必须先存下来——这就是数组要解决的问题


为什么需要数组 (不用数组会怎样)

/* 不用数组: 变量不够用了 */
int n1, n2, n3, n4, n5;
int n6, n7, n8, n9, n10;

scanf("%d", &n1);    /* 第 1 个 */
scanf("%d", &n2);    /* 第 2 个 */
/* … 第 100 个怎么办? 第 i 个呢? */

/* ---- 用数组: 下标管理一批数据 ---- */
int n[10];
int i;

for (i = 0; i < 10; i++)
  scanf("%d", &n[i]);
printf("%d", n[i]);  /* 想要第几个都行 */
  • 10 个变量写 10 行, 1000 个写 1000 行——而且没法用循环

  • 数组给一批同类型数据编号: 用下标就能批量处理

  • 这就是数组的第一个重要性: 让"一批数据"变成一个整体


程序: 数列反向

现场演示: 输入 10 个数, 逆序输出

样例:

输入 10 个数: 34 82 49 102 7 94 23 11 50 31
逆序输出: 31 50 11 23 94 7 102 49 82 34

方向提示: 存储的顺序和打印的顺序是什么关系?


程序: 数列反向 (参考代码)

/* 数列反向 */
#include <stdio.h>

#define N 10

int main(void)
{
  int a[N], i;

  printf("输入 %d 个数: ", N);
  for (i = 0; i < N; i++)
    scanf("%d", &a[i]);

  printf("逆序输出:");
  for (i = N - 1; i >= 0; i--)
    printf(" %d", a[i]);
  printf("\n");

  return 0;
}
  • 正着读 (i 从 0 到 N-1), 倒着打 (i 从 N-1 到 0)——同一数组, 两种遍历方向

  • 数据先全部存下来, 才能"回头"——数组的价值就在这


数组初始化

int a[10] = {1, 2, 3};
/* 前三个 1 2 3, 其余自动补 0 */

int a[10] = {0};
/* 全 0 初始化的惯用写法 */

int a[] = {1, 2, 3};
/* 长度省略: 编译器数出来 = 3 */
  • 初始化式比数组短 → 其余元素自动赋 0

  • 初始化式为空或超长都非法

  • 给了初始化式, 长度可以省略——编译器自己数

  • 大数组只改少数元素, 一个个列出来冗长易错——下一页的指定初始化式来救场


指定初始化式 (C99)

int a[15] = {[2] = 29, [9] = 7};
/* [2]=29 和 [9]=7, 其余自动补 0 */

int a[15] = {[9] = 7, [2] = 29};
/* 顺序无关, 效果一样 */

int b[] = {[23] = 5};
/* 省略长度: 从最大指示符推出 24 */
  • C99 的指定初始化式: [下标] = 值, 只初始化想改的元素

  • 括号中的数字叫指示符, 取值 0 ~ n-1

  • 省略长度时, 编译器从最大指示符推出长度


程序: 检查重复数字

现场演示: 输入一个长整数 (正数), 判断它的各位数字里有没有重复的 (如 28245 有重复的 2)

样例:

输入一个数: 28245
有重复数字

方向提示: 第 3 周的"拆数字"先用起来; 关键是怎么记住哪些数字已经出现过


程序: 检查重复数字 (参考代码)

/* 检查数字中是否有重复的数字 */
#include <stdio.h>

int main(void)
{
  int digit_seen[10] = {0};   /* 0=没出现过, 1=出现过 */
  int digit;
  long n;

  printf("输入一个数: ");
  scanf("%ld", &n);
  while (n > 0) {
    digit = n % 10;
    if (digit_seen[digit])
      break;
    digit_seen[digit] = 1;
    n /= 10;
  }
  if (n > 0)
    printf("有重复数字\n");
  else
    printf("没有重复数字\n");
  return 0;
}
  • digit_seen 是标志数组: 下标是数字 (0~9), 值 0/1 表示"出现过没有"——{0} 全 0 初始化, 上一页刚学过

  • 拆一位 → 查表 → 标记录 → 拆下一位; 查到已出现就 break

  • 数组当"记账本"用——这是标志数组的经典模式

  • 用 long 读入: 数字可能超过 10 位, int 装不下——第 2 周的类型范围


案例: 大小写转换

现场演示: 输入一个单词 (不含空格), 全部转成大写输出

样例:

输入一个单词: hello
HELLO

方向提示: 第 2 周讲过 char 就是小整数——ASCII 表里 'a' 和 'A' 差多少?


案例: 大小写转换 (参考代码)

/* 大小写转换 */
#include <stdio.h>

int main(void)
{
  char s[100];
  int i;

  printf("输入一个单词: ");
  scanf("%s", s);              /* 遇空格就停, 自动补 '\0' */

  for (i = 0; s[i] != '\0'; i++)
    if (s[i] >= 'a' && s[i] <= 'z')
      s[i] = s[i] - 'a' + 'A';  /* 'a' 比 'A' 大 32 */

  printf("%s\n", s);
  return 0;
}
  • scanf("%s", s): 遇空格就停, 末尾自动补 '\0'

  • 读数组不加 &——为什么, 指针周揭晓

  • 循环条件 s[i] != '\0': 数组遍历的新写法

  • 'a' 比 'A' 大 32——加餐: 换成 (s[i] - 'a' + 3) % 26 + 'a' 就是凯撒加密

  • 周三的发牌程序, 还会见到字符数组当对照表


现场挑战: 平均分与高于平均

现场演示: 输入 10 个成绩, 输出平均分和高于平均分的人数

第 1 周找最高分, 一个变量流式搞定; 现在要先算平均、再回头比每个人——还能不存吗?

样例:

输入 10 个成绩: 5 8 3 8 2 9 1 9 9 4
平均分 5.8, 高于平均 5 人

方向提示: 平均分算出来之前没人能比; 算出来之后, 数据还在吗?


现场挑战: 平均分与高于平均 (参考代码)

/* 平均分与高于平均分的人数 */
#include <stdio.h>
#define N 10

int main(void)
{
  int score[N], i, sum = 0, count = 0;
  double avg;

  printf("输入 %d 个成绩: ", N);
  for (i = 0; i < N; i++) {
    scanf("%d", &score[i]);
    sum += score[i];          /* 第一遍: 读入并求和 */
  }
  avg = sum / (double) N;

  for (i = 0; i < N; i++)     /* 第二遍: 回头比平均 */
    if (score[i] > avg)
      count++;

  printf("平均分 %.1f, 高于平均 %d 人\n", avg, count);
  return 0;
}
  • 两遍扫描: 第一遍读入+求和, 第二遍回头比平均——第二遍之前, 数据必须还在 (数组)

  • 对比: "最高分并列"一遍就能数 (更新 max 时重置 count), 但"高于平均"非两遍不可

  • sum / N 是整数除法——(double) 强转, 不然 58/10 变成 5


现场挑战: 进制转换

现场演示: 输入十进制数 n 和进制 base (2~10), 输出转换结果

样例:

输入十进制数和进制: 13 2
1101
输入十进制数和进制: 100 8
144

方向提示: 注意输出顺序——先算出来的数字要后输出


现场挑战: 进制转换 (参考代码)

/* 进制转换: 反复取余存数组, 逆序输出 */
#include <stdio.h>

int main(void)
{
  int n, base, digits[32];
  int len = 0, i;

  printf("输入十进制数和进制: ");
  scanf("%d%d", &n, &base);

  do {
    digits[len++] = n % base;  /* 余数存进数组 */
    n /= base;
  } while (n > 0);

  for (i = len - 1; i >= 0; i--)  /* 逆序输出 */
    printf("%d", digits[i]);
  printf("\n");
  return 0;
}
  • 短除法的每一步余数先后顺序是反的——存进数组再逆序输出, 正好接上 reverse 的写法

  • len 既是"下一个空位"又是"已存个数"——数组配合计数变量的经典搭档

  • do-while 保证 n=0 也能输出一个 0; 16 进制要输出字母, 留作加餐


现场挑战: 回文数

现场演示: 输入一个整数, 判断它是不是回文数 (正着读倒着读一样, 如 1221、12321)

样例:

输入一个整数: 1221
1221 是回文数
输入一个整数: 1234
1234 不是回文数

方向提示: 先想清楚"第 i 位"和"倒数第 i 位"在数组里的下标关系


现场挑战: 回文数 (参考代码)

/* 回文数: 数字拆进数组, 首尾对称比较 */
#include <stdio.h>

int main(void)
{
  long n, m;
  int digits[20], len = 0, i;

  printf("输入一个整数: ");
  scanf("%ld", &n);

  m = n;                       /* 拆数字, 个位在前 */
  do {
    digits[len++] = m % 10;
    m /= 10;
  } while (m > 0);

  for (i = 0; i < len / 2; i++)      /* 只比到一半 */
    if (digits[i] != digits[len - 1 - i]) {
      printf("%ld 不是回文数\n", n);
      return 0;
    }
  printf("%ld 是回文数\n", n);
  return 0;
}
  • 首尾对称: 第 i 位 vs 第 len-1-i 位——下标关系的推演是这题的灵魂

  • 发现不对称可以提前 return, 不用比完

  • 只比到 len/2, 奇数长度中间那位不用比


对数组使用sizeof运算符

  • 用 sizeof 表达数组长度: for (i = 0; i < sizeof(a) / sizeof(a[0]); i++) 清零——日后改长度, 循环不用改

  • 可能告警: i 是 int (有符号), sizeof 返回 size_t (无符号); 此处安全, 也可强转避免: (int) (sizeof(a) / sizeof(a[0]))

  • 为数组大小定义宏: #define SIZE ((int) (sizeof(a) / sizeof(a[0])))——宏要加括号, 防止运算顺序不一致


变长数组 (C99)

int n, i;

printf("输入几个数? ");
scanf("%d", &n);

int a[n];        /* C99: 长度运行时才定 */

for (i = 0; i < n; i++)
  scanf("%d", &a[i]);
int a[3 * i + 5];   /* 长度可以是任意表达式 */

数组 a 的长度由变量 n 决定, 是变长数组:

  • 长度在程序执行时才计算——需要多少就开多少, 不多占、不漏放

  • 长度不一定是变量, 任意(整数)表达式都可以:


工程上少用: C11 把 VLA 降为可选特性 (MSVC 一直不支持), 长度在栈上运行时分配、不可控——课堂了解即可, 实践优先用固定长度数组


程序: 计算利息 (数组保存中间状态)

#define NUM_RATES 5
#define NUM_YEARS 5

double balance[NUM_RATES];
for (int r = 0; r < NUM_RATES; r++) {
  balance[r] = 100.0;
  for (int y = 1; y <= NUM_YEARS; y++) {
    balance[r] = balance[r] * (1 + 0.01 * (r + 3));   /* 每年复利 */
    printf("%8.2f", balance[r]);
  }
  printf("\n");
}

本金 100 元, 5 种利率各存 5 年, 列出每年的余额:

关键: 第 y+1 年的余额依赖第 y 年——每一年的中间值都要存下来

  • 数组的第三种用途: 除了"存一批数据"和"批量处理", 还能保存中间状态


AI 审计: 有 bug 吗?

课堂互动: 下面三段代码都是 AI 写的——每组先投票: 有 bug / 没 bug

/* 公共前提 */
#define N 10
int a[N], i;

/* ① 把数组 a 全部清零 */
for (i = 1; i <= N; i++)
  a[i] = 0;

/* ② 求数组 a 的最大值 */
int max = 0;
for (i = 0; i < N; i++)
  if (a[i] > max)
    max = a[i];

/* ③ 把数组全部初始化为 1 */
int b[N] = {1};      /* 以为全是 1? */

三个 bug 都是本讲刚讲过的知识点


AI 审计: 揭晓

① 有 bug: 下标越界 (a[N] 不存在) 且漏了 a[0]——正确写法 for (i = 0; i < N; i++)


② 有 bug: max = 0 初始化的老坑——成绩全是负数时, 最大值"恒为 0"; 正确写法 max = a[0], 从下标 1 开始比


③ 有 bug: {1} 只把 b[0] 初始化成 1, 其余自动补 0, 不是全 1——想全 1 得写循环


带走清单: 下标 0~n-1 吗? 最值用 a[0] 起步吗? 初始化式比数组短会怎样? ——写数组循环前先问自己这三句


课堂小测 (1/2)

  1. int a[10]; 中数组元素的下标范围是什么?越界会发生什么?
  2. int a[] = {1, 2, 3}; 中 a 的长度是多少?怎么用 sizeof 计算元素个数?

课堂小测 (2/2)

  1. 写代码: 找出数组 {5, 2, 9, 1} 中的最小值。
  2. C99 的变长数组 (VLA) 长度可以是什么?


周三见!


未完待续