七、数组

前面所有题目都是「读进来、处理、扔掉」。这一页开始,数据要留下来。

为什么需要留下来?因为有些操作必须回头访问前面的数据:找最大值的位置、判断是否递增、排序、统计每个数出现了几次。这些都得先把数据存进数组。

这一页 5 章 25 道题,核心是四个套路:

  1. 遍历——用循环把数组走一遍
  2. 桶——用下标本身表示数值,这是最巧妙也最常用的技巧
  3. 排序——把数据排好,很多问题就自动解决了
  4. 前缀和 / 差分——把「区间求和」和「区间修改」从 O(n) 降到 O(1)

最后一个稍难,但它是后面所有区间题的基础,值得花时间。

22. 数组的存取与遍历

22.1 为什么要数组

假设要读 100 个数,然后倒着输出。

不用数组,你只能边读边处理——但倒着输出要求你先记住全部 100 个数,这做不到(不能用 100 个变量)。

int a[105];                    // 声明一个能装 105 个整数的数组
for (int i = 0; i < n; i++) {
    scanf("%d", &a[i]);        // 第 i 个位置放进读到的数
}
for (int i = n - 1; i >= 0; i--) {
    printf("%d ", a[i]);       // 倒着输出
}

a[0]、a[1]、a[2]……每个都是一个独立的变量,用方括号里的数字区分。那个数字叫下标。

为什么开 105 而不是 100? 因为题目说 n 最大 100,但下标是 0 到 n-1,如果 n 真的等于 100,你要用 a[99],开 100 刚好够。多开 5 个是保险——

数组越界不会报错

int a[100];
a[100] = 5;      // 越界了!但编译器不拦你

C++ 不检查数组下标是否越界。a[100] 会写到数组后面的内存里,可能覆盖掉别的变量,也可能暂时没事。

表现是:本地跑正常,交到 OJ 上崩溃;或者换个编译器结果就变了。 这种 bug 能查一整天。

习惯:数组一律多开 5~10 个。 题目说 n ≤ 1000,你开 1005。

22.2 下标从 0 开始

int a[5] = {10, 20, 30, 40, 50};
//  a[0]=10  a[1]=20  a[2]=30  a[3]=40  a[4]=50

第一个元素是 a[0],不是 a[1]。 最后一个元素是 a[n-1],不是 a[n]。

这是初学最容易错的地方。遍历 n 个元素的标准写法:

for (int i = 0; i < n; i++) {      // 注意是 i < n,不是 i <= n
    // 处理 a[i]
}

写成 i <= n 就会访问 a[n]——越界,读到垃圾值。

为什么从 0 开始

因为数组名 a 本质上是指向第一个元素的指针,a[i] 的意思是「从 a 开始往后数 i 个位置」。

数 0 个位置就是第一个元素,所以 a[0] 是第一个。这个设计让编译器的计算更快,代价是初学者要适应一下。

数组的基本操作

三题从简到难,先把「声明、读入、访问」这套动作做熟。

215 那题如果读进来的数全是 0 或者奇怪的数,检查是不是把 scanf("%d") 用在了 double 数组上。

22.3 遍历:求和、求平均、找最大值

数组的绝大多数操作,都是「用一个循环走一遍」。

int sum = 0;
for (int i = 0; i < n; i++) sum += a[i];

int mx = a[0];                       // 用第一个元素做初值,最保险
for (int i = 1; i < n; i++) {
    if (a[i] > mx) mx = a[i];
}

注意最大值的初值:用 a[0],不是 0。 第 13 章讲过这个坑,有了数组之后更简单——直接把第一个元素当初始答案就行,不用 INT_MIN 了。

遍历

1144 这题要注意:题目问的「位置」是从 0 数还是从 1 数?人以 1 开始数,代码从 0 开始数,输出时常常要 +1。

第 22 章验收

  • 会声明数组、读入、遍历输出
  • 知道下标范围是 0 到 n-1,循环条件写 i < n
  • 知道数组要开大一点,越界不报错但会出鬼
  • 找最大值时用 a[0] 做初值

23. 统计与标记

这一章讲数组最巧妙的一个用法:用下标表示数值。

23.1 桶:下标就是答案

问题:统计每个数出现了几次。

普通思路是「对每个数,遍历一遍数组数一数」,复杂度 O(n²)。更快的做法是开一个「桶」:

int cnt[1005] = {0};           // 桶,初始全 0
for (int i = 0; i < n; i++) {
    int x;
    scanf("%d", &x);
    cnt[x]++;                  // 数值 x 出现一次,就把第 x 个桶 +1
}
// 现在 cnt[7] 就是 7 出现的次数

cnt[x]++ 这一行是本章的灵魂。 它把「数值」直接当成了「下标」,省掉了查找的过程。

桶排序就是由此而来:

for (int i = 0; i <= 最大值; i++) {
    for (int j = 0; j < cnt[i]; j++) printf("%d ", i);
}

从小到大遍历下标,就自然排好序了。复杂度 O(n + 最大值)——比任何比较排序都快,前提是数值范围不大。

桶的大小由数值范围决定,不是 n

int cnt[1005];        // 如果题目给的数最大是 10^9,这个桶直接爆炸

开桶之前先看题目:数值最大是多少?如果是 10 万以内,桶排序很合适;如果是 10 亿,就得换方法。

开之前先看一眼数据范围。 这是做所有题的第一习惯。

桶

229 这类题用桶之后,判断条件从「数一数」变成「看一眼 cnt[x]」,代码会短很多。

23.2 标记数组:记录「有没有出现过」

有时候不需要计数,只需要知道某个值「出现过没有」:

bool used[1005] = {false};
used[x] = true;                 // 标记 x 出现过
if (used[y]) { /* y 出现过 */ }

bool 数组比 int 数组更合适:意图清楚,而且省内存(每个元素只占 1 字节)。

标记

228 有个不用数组的巧办法:把所有数异或起来,成对的会抵消,剩下的就是答案。但用标记数组或桶也能做,先写能过的版本。

23.3 预处理:先算好,再查表

有些题要反复问「某个值对应的答案是多少」。每次都重新算太慢,不如先把所有答案算好存起来:

int f[1000005];
for (int i = 1; i <= n; i++) {
    f[i] = f[i-1] + 计算(i);      // 一次算完
}
// 后面直接查 f[x] 就行,O(1)

这种「打表」的思路,在后面动态规划里会变成核心方法。现在先体会一下:用空间换时间。

预处理

232 那题如果 m 很大,注意数组要开到 m+5。

23.4 差值数组:相邻之间的变化

差值

  • 有趣的跳跃 —— 通常是「相邻两数之差的绝对值能否覆盖 1 到 n-1」

这题的套路:算出差值,用标记数组记录哪些差值出现过,最后检查是不是全都有。又是桶的思想。

第 23 章验收

  • 会用 cnt[x]++ 做桶计数
  • 知道桶的大小由数值范围决定,不是 n
  • 会用 bool 数组做标记
  • 理解「用空间换时间」的预处理思路

24. 排序

排序是最常用的操作之一。很多题排完序就做完了——找中位数、找第 k 大、判断重复,都是这样。

24.1 手写排序:选择排序和冒泡排序

选择排序的思路:每一轮找出剩下元素里最小的,放到前面。

for (int i = 0; i < n - 1; i++) {
    int k = i;
    for (int j = i + 1; j < n; j++) {
        if (a[j] < a[k]) k = j;      // 找最小值的下标
    }
    swap(a[i], a[k]);                // 换到前面
}

冒泡排序的思路:相邻两个比较,大的往后换,一轮下来最大的就到了末尾。

for (int i = 0; i < n - 1; i++) {
    for (int j = 0; j < n - 1 - i; j++) {
        if (a[j] > a[j+1]) swap(a[j], a[j+1]);
    }
}

两种都是 O(n²),n = 1000 时约 100 万次,能过;n = 10 万时就超时了。

先学会手写,是为了理解「排序在干什么」。 实际做题用下一节的 sort。

手写排序

手写一遍的价值:你会知道 sort 帮你做了什么,出问题时也能自己排查。

24.2 sort:直接用标准库

#include <algorithm>          // bits/stdc++.h 已经包含了

sort(a, a + n);               // 从小到大,排 a[0] 到 a[n-1]
sort(a, a + n, greater<int>());  // 从大到小

sort 的复杂度是 O(n log n),n = 100 万也能在 1 秒内跑完。没有特殊要求就用它,别手写。

注意两个参数是地址区间:a 是起点,a + n 是终点(不是 a + n - 1)。这是「左闭右开」的写法,排的是 a[0] 到 a[n-1]。

sort(a, a+n-1) 少排一个

很多人会写成 sort(a, a + n - 1),觉得「n 个元素,最后一个下标是 n-1」。

但 sort 的第二个参数是结束位置的下一个位置,所以应该是 a + n。

写错了不会报错,只是最后一个元素没参与排序。检查方法:排完输出看看末尾对不对。

排序之后,很多问题会变简单:

  • 找中位数 → 排序后取 a[n/2]
  • 找第 k 大 → 排序后取 a[n-k]
  • 判断有没有重复 → 排序后看相邻是否相等
  • 去重 → 排序后跳过相同的

排序

  • 大志买书 —— 可能需要「去重 + 排序」,排序后跳过重复的

第 24 章验收

  • 能手写选择排序或冒泡排序
  • 会用 sort(a, a + n),且知道第二个参数是 a + n
  • 知道排序能解决哪几类问题

25. 前缀和与差分

这一章讲两个「用一点空间,换大量时间」的技巧。它们看着简单,但是后面所有区间题的基础。

25.1 前缀和:快速求区间和

问题:给一个数组,反复问「第 l 个到第 r 个的和是多少」。

暴力做法每次都要累加,一次 O(n),问 m 次就是 O(nm),很容易超时。

前缀和的做法是先算好一个累加数组:

int s[100005];
s[0] = 0;
for (int i = 1; i <= n; i++) {
    s[i] = s[i-1] + a[i];        // s[i] = 前 i 个数的和
}

s[i] 表示前 i 个元素的和。有了它,区间 [l, r] 的和就是:

int sum = s[r] - s[l-1];

O(1) 出答案。原理是「前 r 个的和」减去「前 l-1 个的和」,剩下的正好是第 l 到第 r 个。

注意 s[0] = 0 这个哨兵。 有了它,l = 1 时 s[l-1] 就是 s[0] = 0,公式不用特判。

前缀和的下标

s[i] 表示的是 a[1] 到 a[i] 的和,所以数组要从下标 1 开始存。

如果你习惯从 0 开始存(a[0] 是第一个),前缀和公式要相应改成 s[r+1] - s[l]。

两种写法都对,但别混用。 混用是前缀和题最常见的错误来源。

前缀和

218 做完,自己加一组测试:数组 1 2 3 4 5,问 [2,4] 的和,答案应该是 9。

25.2 差分:快速做区间修改

前缀和的逆操作。问题:把数组的第 l 到第 r 个元素都加上 v,做 m 次,最后输出整个数组。

暴力做法每次改 r-l+1 个元素,O(nm)。差分的做法是只改两个位置:

d[l] += v;
d[r+1] -= v;

然后最后做一次前缀和还原:

int cur = 0;
for (int i = 1; i <= n; i++) {
    cur += d[i];
    a[i] += cur;
}

为什么只改两个位置就够? 因为做前缀和的时候,d[l] 的 +v 会一直往后传递到 d[r+1] 处的 -v,正好抵消。中间那一段就都被加上了 v。

这是「用两个点表示一个区间」的思想,非常巧妙。

差分

  • 差分 —— 模板题
  • 校门外的树 —— 典型的区间操作。可以用差分,也可以直接标记

223 如果用标记数组直接做,复杂度是 O(nm),数据小能过。先用简单方法做对,再想能不能优化。

25.3 区间覆盖

区间覆盖

  • 铺地毯 —— 可能是「求某个点被多少张地毯覆盖」或「求最上面一张」

这类题的套路:如果问「某个点被覆盖几次」,用差分;如果问「最后盖住的是谁」,倒着找第一个覆盖它的。

第 25 章验收

  • 会写前缀和,且知道 s[0] = 0 的作用
  • 会写差分,能说清「为什么改两个点就能表示一个区间」
  • 知道这两种技巧都是在「预处理换查询速度」

26. 数组综合

26.1 最大连续子段和

问题:在一个数组里,找一段连续的子数组,使它们的和最大。

最直接的暴力是三重的(枚举左右端点 + 求和),O(n³)。稍微优化是两重,O(n²)。

但有一遍扫描的做法:

int best = a[0], cur = a[0];
for (int i = 1; i < n; i++) {
    cur = max(a[i], cur + a[i]);      // 要么接着上一段,要么从自己重新开始
    best = max(best, cur);
}

cur = max(a[i], cur + a[i]) 的意思是:如果前面那段的和是负的,还不如从当前这个数重新开始。

这就是贪心的思路——负的历史拖累,不如丢掉。

最大子段和

这题如果数组全是负数,best 的初值必须是 a[0] 而不是 0,否则会输出 0(而正确答案是最大的那个负数)。

26.2 双指针

两个下标一起移动,通常一个往右一个也往右,用来处理「连续区间」的问题。

int l = 0, r = 0;
while (r < n) {
    // 加入 a[r]
    while (不满足条件) {
        // 移出 a[l],l 右移
        l++;
    }
    // 此时 [l, r] 是一个合法区间
    r++;
}

双指针

233 的思路:左右各一个指针,每次移动较短的那一侧。为什么?因为移动长的一侧不可能让结果变大。

26.3 其他

综合

  • 求A/B的值 —— 求 A/B 的值,可能涉及高精度或整除细节

如果 A 和 B 特别大(超过 long long),这就是高精度题。先用 long long 试试,不行再想别的。

数组板块总结

25 道题做完,你应该能回答这些问题:

  • 数组下标从几开始?越界会怎样?
  • 桶排序为什么快?什么时候不能用?
  • sort(a, a+n) 里第二个参数为什么是 a + n?
  • 前缀和的 s[0] 为什么要单独置 0?

这一页最重要的不是记住模板,而是建立「什么时候该用数组」的判断力。 看到题目第一反应应该是:这批数据我需不需要存下来?存下来之后,用遍历、桶、排序还是前缀和?

判断对了,代码就是水到渠成。判断错了,写再多也是白写。

延伸阅读

服务器上有一整套 OI-Wiki(621 页),是给竞赛选手写的参考手册。下面几页跟这一页相关,但不是每一页都该现在看——标了「现在跳过」的,等以后再说。

  • 可以先看:数组 —— 数组的内存布局图。看懂「下标从 0 开始」为什么是 0 而不是 1
  • 学完这一页再看:前缀和 & 差分 —— 这章的核心工具,wiki 讲得更系统。差分那半章可以推到第 26 章再看
  • 学完这一页再看:排序应用 —— 排序之后能做什么——名次、去重、找中位数
  • 学完这一页再看:排序简介 —— 各种排序的复杂度对比。这章用 sort 就够,但知道它内部干了什么不吃亏

看不懂是正常的,不是你的问题

OI-Wiki 默认读者已经会写程序、目标是打 NOI。你现在需要的那部分,讲义里已经写了; wiki 是用来「以后查得更深」的,不是用来补课的。

© HiJudge · 内容采用 CC BY-NC-SA 4.0 许可协议            此页面修订于: 2026-11-01 03:44:09

results matching ""

    No results matching ""