智能程序设计(C语言)


一维数组

计算机学院    杨已彪

yangyibiao@nju.edu.cn

提纲

  • 数组: 声明、下标与越界

  • 为什么需要数组

  • 程序: 数列反向

  • 数组初始化与指定初始化式

  • 案例: 平均分与高于平均、检查重复数字、统计正负零

  • 现场挑战: 进制转换、回文数

  • sizeof 与变长数组

  • AI 审计


标量变量与聚合变量

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

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

  • 保存单一数据项

C 还支持聚合:

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

  • 存储一组一组的数值

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


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


什么是一维数组

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

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

int a[10]; 每个 int 占 4 字节 下标 0 1 2 3 4 5 6 7 8 9 a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8] a[9] 内存中连续存放,元素之间没有空隙 下标范围 0 ~ 9;长度 10 的数组没有 a[10]

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

  • 按位置: 想取第几个就取第几个, 位置从 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 语言不检查、不报错, 程序照跑

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

  • 越界读只是"看"了别人的内存——下一页的越界写才动真格


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

代码
#include <stdio.h>
int main(void)
{
  int a[4] = {10, 20, 30, 40};
  int i;
  for (i = 0; i < 4; i++) {
    printf("第 %d 轮\n", i);
    if (i == 2) {
      a[-2] = -100;      /* 越界往回写, 命中 i! */
    }
  }
  return 0;
}

运行结果 (本机):

第 0 轮
第 1 轮
第 2 轮
第 -99 轮
…(循环失控, 一路狂奔)
要点
  • 本机布局: i 紧挨在 a 前面——a[-2] 正好是 i, 循环变量被劫持

  • 向前写 a[4] = 100 又是另一种下场: 栈保护直接 abort

  • 开 -O2 反而没事: i 进了寄存器, 内存里没有它

  • 不报错的错才是最危险的 bug: 一切由布局和优化决定, 所以叫未定义行为


为什么需要数组

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


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

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

    • 把 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 个数, 逆序输出

⚡ 在 VS Code 中打开 task-reverse.c (骨架)

样例:

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

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

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

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

参考代码(先自己写,再展开)
/* 数列反向 */
#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;
}

数组初始化与指定初始化式

常规初始化

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

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


案例: 平均分与高于平均

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

⚡ 在 VS Code 中打开 task-average.c (骨架)

第一节课找最高分, 一个变量流式处理即可; 现在要先算平均、再回头比每个人——还能不存吗?

样例:

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

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

  • 两遍扫描: 第一遍读入+求和, 第二遍回头比平均——第二遍之前, 数据必须还在 (数组)

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

  • sum / N 是整数除法——(double) 强转, 不然 58/10 变成 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;
}

程序: 检查重复数字

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

⚡ 在 VS Code 中打开 task-repdigit.c (骨架)

样例:

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

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

解法图(先自己想,再展开)
输入 28245:从个位拆起,边拆边记账 digit_seen[10] 0 1 2 3 4 5 6 7 8 9 0 0 1 0 1 1 0 0 0 0 拆出的数字 5 → 4 → 2 → 8 → 2 下标 = 数字 (0~9),值 = 出现过没有 (0/1) 查到 digit_seen[2] 已是 1 → 判定重复!

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

代码
/* 检查数字中是否有重复的数字 */
#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 装不下——前面的类型范围


案例: 统计正数、负数与零

现场演示: 先读入 n, 再读入 n 个整数, 统计其中正数、负数、零各有多少个

⚡ 在 VS Code 中打开 skeleton.c (任务 A, 骨架)

样例:

输入个数: 6
输入 6 个整数: 3 -1 0 7 -4 0
正数 2 个, 负数 2 个, 零 2 个

方向提示: 三个计数器, 遍历一次数组即可

  • 先读入再遍历: 两个 for 循环各管一件事, 比边读边统计更清晰

  • 三个计数器必须先初始化为 0——局部变量不会自动清零

  • a[i] > 0 / < 0 / 其余: 三分支用 if / else if / else 写最自然

  • 数组大小用 #define N 100; 实际个数用变量 n——容量与个数分开

参考代码(先自己写,再展开)
/* count_sign.c: 统计正数、负数、零的个数 */
#include <stdio.h>
#define N 100

int main(void)
{
  int a[N], n, i;
  int pos = 0, neg = 0, zero = 0;

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

  for (i = 0; i < n; i++) {
    if (a[i] > 0) {
      pos++;
    } else if (a[i] < 0) {
      neg++;
    } else {
      zero++;
    }
  }

  printf("正数 %d 个, 负数 %d 个, 零 %d 个\n", pos, neg, zero);
  return 0;
}

现场挑战: 进制转换

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

⚡ 在 VS Code 中打开 skeleton.c (任务 B, 骨架)

样例:

输入十进制数和进制: 13 2
1101

输入十进制数和进制: 100 8
144

输入十进制数和进制: 31 16
1f

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


常量数组: 查表

/* 0~15 转十六进制字符 */
#include <stdio.h>

int main(void)
{
  const char hex_chars[] =
    {'0','1','2','3','4','5','6','7','8','9',
     'A','B','C','D','E','F'};

  int n;
  printf("输入 0~15: ");
  scanf("%d", &n);
  printf("%c\n", hex_chars[n]);   /* 下标查表 */
  return 0;
}

做完了几个案例, 学一个新工具: 用数组查表

  • 声明时加 const, 数组变成只读——编译器帮你看住, 想改直接报错

  • 查表: 值当下标, 取出对应字符——repdigit 的标志数组是"记", 这张表是"取"

  • 下一题进制转换的 16 进制输出、后面生命游戏的图案打印, 都要靠它


进制转换: 先动手算一遍

13 转二进制, 短除法:

算式 余数
13 ÷ 2 = 6 1
6 ÷ 2 = 3 0
3 ÷ 2 = 1 1
1 ÷ 2 = 0 1

余数出现的顺序是 1 → 0 → 1 → 1, 而答案是 1101

发现: 先算出来的余数要最后输出——顺序正好反了

把"反了"掰正: 存进数组, 倒着打——就是数列反向的写法


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

/* 进制转换(含 16 进制): 反复取余存下标, const 查表输出 */
#include <stdio.h>

int main(void)
{
  const char digit[] =
    {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'};
  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("%c", digit[digits[i]]);   /* 查表输出 */
  }
  printf("\n");
  return 0;
}
  • 短除法的每一步余数先后顺序是反的——存进数组再逆序输出, 正好接上 reverse 的写法

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

  • 输出用的就是前面学的 const 查表——数字 0~15 变字符


现场挑战: 回文数

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

⚡ 在 VS Code 中打开 task-palindrome.c (骨架)

样例:

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

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


回文数: 先想清楚对称关系

1221 拆进数组 (个位在前):

digits = {1, 2, 2, 1} len = 4 下标 0 1 2 3 1 2 2 1 2 ↔ 2 1 ↔ 1 只比 len/2 = 2 组:digits[i] 与 digits[len-1-i]

第 i 位要对的, 是第 len-1-i 位:

  • i=0: digits[0] 对 digits[3] → 1 对 1 ✓

  • i=1: digits[1] 对 digits[2] → 2 对 2 ✓

只比到 len/2 就够——奇数长度中间那位不用比; 发现不对称就可以提前判"不是"


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

/* 回文数: 数字拆进数组, 首尾对称比较 */
#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])))——宏要加括号, 防止运算顺序不一致


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

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

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


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

代码
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];   /* 长度可以是任意表达式 */

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

#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) 长度可以是什么?



未完待续