Mr.Mou @ ShiShi AP Center

AP CSA 4.9:ArrayList 遍历

在 4.8 中,我们学会了用 ArrayList 保存一组对象,并使用 add()、get()、set()、remove() 等方法操作单个元素。

现在,如果要逐个检查整个名单、计算所有学生的得分,或者删除名单中所有符合条件的元素,就需要使用遍历(traversal)。

这一课表面上和 4.4 的 Array Traversals 很相似,但有一个重要的新变化:ArrayList 可以在循环执行过程中改变大小。 这也意味着,删除一个元素后,剩下的元素会移动,原来的 index 可能不再对应同一个元素。

核心理解:遍历 ArrayList 时,不仅要知道当前访问哪个 index,还要注意列表的 size() 是否发生变化。删除之后,下一步应该检查哪个位置?这是本课最关键的问题。

核心知识点

1. 先思考一个问题:为什么删掉一个名字后,还会剩下同名的人?

假设报名名单是:

Index:     0       1       2       3
Names:   [Mia,    Leo,    Leo,    Eva]

我们想用循环删除所有叫 "Leo" 的人:

for (int i = 0; i < names.size(); i++)
{
    if (names.get(i).equals("Leo"))
    {
        names.remove(i);
    }
}

看上去很合理:逐个检查,遇到 Leo 就删除。但最终名单却是:

[Mia, Leo, Eva]

为什么还剩一个 Leo?

关键在于:当第一个 Leo 被删除时,第二个 Leo 会立即左移到同一个 index。然而 for loop 随后执行了 i++,跳到了下一个位置,没有检查刚刚移过来的 Leo。

学会解释这个过程,就理解了 4.9 最重要的知识点。

2. Array 和 ArrayList:遍历写法有什么不同?

我们已经在 4.4 学过数组遍历。ArrayList 也能使用 indexed for、while 和 enhanced for loop。

区别主要在于获取元素数量与访问元素的写法。

需要做什么? Array ArrayList
获取元素数量 arr.length list.size()
读取索引为 i 的元素 arr[i] list.get(i)
替换索引为 i 的元素 arr[i] = value; list.set(i, value);
完整遍历条件 i < arr.length i < list.size()
删除元素并改变数量 不支持直接删除 list.remove(i)

注意:ArrayList 不能写成 list[i],也没有 list.length。它通过方法访问元素和获取数量。

以下各节的示例使用 ArrayList。在完整 Java 文件中,需要先写:

import java.util.ArrayList;

3. Indexed for Loop:使用 index 遍历

这是最直接的遍历方式:

ArrayList<Integer> scores = new ArrayList<Integer>();
scores.add(85);
scores.add(90);
scores.add(95);

for (int i = 0; i < scores.size(); i++)
{
    System.out.println(scores.get(i));
}

输出:

85
90
95

把循环拆成三个部分:

部分 写法 含义
初始化 int i = 0 从第一个元素开始
条件 i < scores.size() 只访问现有的元素
更新 i++ 每轮转到下一个 index

每轮变化:

循环轮次 i scores.get(i)
1 0 85
2 1 90
3 2 95
结束检查 3 3 < 3 为 false,不再访问

这种方式最适合需要使用 index 的任务,例如替换某个位置的元素,或者控制遍历范围。

3.1 修改某个位置的值:set()

例如,让列表中每个分数增加 5 分:

for (int i = 0; i < scores.size(); i++)
{
    scores.set(i, scores.get(i) + 5);
}

结果:

[90, 95, 100]

这段代码使用了两种操作:

  1. scores.get(i):读取当前元素。
  2. scores.set(i, ...):用计算出的新值替换当前元素。

set() 会替换元素,但不会改变 ArrayList 的大小。 因此,这个例子不会发生删除元素造成的 index 左移问题。

4. 越界:为什么使用 < size(),而不是 <= size()?

一个有 3 个元素的 ArrayList,有效 index 是:

Index:       0      1      2
Elements:  [85,    90,    95]
size() = 3

最后一个 index 是 size() - 1,不是 size()。

下面这段代码有问题:

for (int i = 0; i <= scores.size(); i++)
{
    System.out.println(scores.get(i));
}

当 i == 3 时,条件 3 <= 3 仍然成立,程序会尝试调用:

scores.get(3);

但 index 3 并不存在,因此产生 IndexOutOfBoundsException。

正确写法:

for (int i = 0; i < scores.size(); i++)
{
    System.out.println(scores.get(i));
}

4.1 两个名称非常相似的 Exception

数据结构 访问无效 index 时的异常
Array ArrayIndexOutOfBoundsException
ArrayList IndexOutOfBoundsException

两者都表示索引超出有效范围,但考试可能要求判断具体异常类型。

还要联系 4.8 学过的方法边界:

5. Enhanced for Loop:只需要依次访问元素时

和数组一样,ArrayList 也可以使用 enhanced for loop:

ArrayList<String> names = new ArrayList<String>();
names.add("Mia");
names.add("Leo");
names.add("Eva");

for (String name : names)
{
    System.out.println(name);
}

输出:

Mia
Leo
Eva

它的含义是:依次取出列表中的每个元素,将其保存到循环变量 name 中,然后执行循环体。

不需要自己管理 i,也不需要写 get(i)。每一轮,循环变量得到的是当前元素的副本。对于对象类型(如 String 或 Student),这个副本是对象引用的副本,仍然指向原来的对象。

5.1 用 enhanced for 计算结果

例如,计算整数列表中的所有分数之和:

int sum = 0;

for (Integer score : scores)
{
    sum += score;
}

System.out.println(sum);

假设 scores 的内容是 [85, 90, 95],输出是 270。

这里用到了 4.7 的 unboxing(自动拆箱):score 是 Integer 引用,但在加法运算中可以自动转换为 int 值。

5.2 Enhanced for 不适合什么情况?

如果需要知道 index,或者在遍历时通过 add()、remove() 改变列表大小,就不应该使用 enhanced for。

任务 建议使用
打印列表中所有元素 Enhanced for
计算所有数值的总和 Enhanced for
根据 index 调用 set(i, value) Indexed for
删除所有符合条件的元素 while 或反向 indexed for
向某个 index 插入元素 Indexed for / while(按具体任务设计)

尤其注意:对 ArrayList 来说,enhanced for 并不是一个适合边遍历、边增加或删除元素的工具。 原因在第 8 节详细解释。

6. while Loop:删除元素时更容易控制 index

现在回到第 1 节的问题:如何删除名单中所有的 Leo?

先看正确写法:

int i = 0;

while (i < names.size())
{
    if (names.get(i).equals("Leo"))
    {
        names.remove(i);
    }
    else
    {
        i++;
    }
}

最重要的是:只有没有删除元素时,才执行 i++。

为什么?我们来逐步追踪。

6.1 删除后,后面的元素会左移

初始名单:

Index:      0       1       2       3
          [Mia,    Leo,    Leo,    Eva]

当 i = 1,删除第一个 Leo:

names.remove(1);

剩下的内容立刻变成:

Index:      0       1       2
          [Mia,    Leo,    Eva]
                   ↑
             第二个 Leo 左移到了 index 1

所以,下一次必须继续检查 index 1,而不是马上去检查 index 2。

6.2 正确删除所有 Leo 的追踪表

循环轮次 进入循环时 i 当前 names.get(i) 操作 操作后的名单 下次 i
1 0 Mia 不删除,i++ [Mia, Leo, Leo, Eva] 1
2 1 Leo remove(1),不增加 i [Mia, Leo, Eva] 1
3 1 Leo remove(1),不增加 i [Mia, Eva] 1
4 1 Eva 不删除,i++ [Mia, Eva] 2
结束 2 — 2 < size() 为 false [Mia, Eva] —

最终列表:

[Mia, Eva]

这次没有漏掉任何 Leo。

6.3 为什么这种写法能够处理连续重复的元素?

在循环的每一轮:

这是一条比死记代码更重要的规则:

删除后,位置没有空出来,而是被后面的元素补上了。应该先检查补上来的元素,再决定是否向前移动 index。

6.4 包装成完整的 Method

假设要删除 ArrayList<String> 中所有与 target 相同的字符串,并返回是否至少删除过一个:

public static boolean removeAllMatches(ArrayList<String> names, String target)
{
    boolean found = false;
    int i = 0;

    while (i < names.size())
    {
        if (names.get(i).equals(target))
        {
            names.remove(i);
            found = true;
        }
        else
        {
            i++;
        }
    }

    return found;
}

这里 found 的初始值是 false;只有真正删除了匹配项,才变成 true。

注意:return found; 必须放在循环外面,否则可能只检查一个元素就结束了。

7. 另一种安全方法:从后往前删除

当使用 indexed for loop 时,还有一种常见且可靠的做法:从最后一个元素开始,向前遍历。

for (int i = names.size() - 1; i >= 0; i--)
{
    if (names.get(i).equals("Leo"))
    {
        names.remove(i);
    }
}

为什么反向遍历不会漏检?

因为 remove(i) 只会让当前位置之后的元素左移,而我们下一轮要检查的是 i - 1,也就是当前位置之前的元素。

这些尚未检查的位置不会因为这次删除而改变。

例如,初始内容:

[Mia, Leo, Leo, Eva]

访问顺序:

i 当前元素 处理结果
3 Eva 保留
2 Leo 删除,变成 [Mia, Leo, Eva]
1 Leo 删除,变成 [Mia, Eva]
0 Mia 保留

结果同样是:

[Mia, Eva]

两种可靠的删除写法对比

方式 遍历方向 删除之后该怎么办?
正向 while 从前往后 不要增加 index;下轮检查左移过来的元素
反向 for 从后往前 正常 i--;不影响前面尚未检查的元素

两种方式都值得掌握。AP 题目可能要求修复错误,也可能要求直接写出删除符合条件元素的方法。

8. ConcurrentModificationException:为什么 Enhanced for 不能边遍历边删除?

假设我们尝试这样写:

for (String name : names)
{
    if (name.equals("Mia"))
    {
        names.remove(0);
    }
}

这不是安全的写法。

Enhanced for loop 在遍历过程中,会依赖一个内部的遍历机制,来记录列表的遍历状态。

如果我们在循环执行时直接使用 add() 或 remove() 改变列表的大小,可能破坏遍历机制所预期的状态,从而产生:

ConcurrentModificationException

College Board 的表述是 can result in(可能导致),不是保证每一种情况都会立刻抛出异常。所以不能仅因为某一次运行没有报错,就认定这种写法安全。

8.1 不要混淆两类错误

情况 典型问题
Indexed loop 调用 get() 使用了无效的 index IndexOutOfBoundsException
Enhanced for 遍历时调用 add() / remove() 改变列表大小 可能出现 ConcurrentModificationException
Indexed loop 删除元素后仍然无条件 i++ 可能漏检左移过来的元素;不一定抛异常

这些错误的原因不同,不能用一种 Exception 名称概括所有情况。

8.2 Enhanced for 中,哪些操作仍然可以做?

可以读取数据、计算总和、打印元素、调用对象方法。

与 4.4 相同,如果元素是对象引用,通过循环变量调用 mutator method,可以修改原对象的内部状态。这不会因为对象属性改变,就自动成为“添加或删除列表元素”。

假设 Student 类提供 setGPA(double) 方法来修改成绩。请区分以下两种操作:

for (Student student : students)
{
    student.setGPA(3.8); // 修改原 Student 对象的属性
}

和:

for (Student student : students)
{
    student = new Student("Tom", 4.0); // 只改变局部循环变量的引用
}

第一个例子中,列表里的每个元素仍然引用原来的 Student 对象,只是对象的分数发生了变化。

第二个例子中,重新赋值的是局部变量 student,并没有把 students 列表中的元素替换成新对象。

一句话:修改对象属性,和改变 ArrayList 的大小,是两种不同的操作。

9. AP 代码追踪:连续的 0 为什么没有被全部删除?

这是 Runestone 4.9 专门设计的一类题目,直接对应 4.9.A.2。

初始列表:

[0, 0, 4, 2, 5, 0, 3, 0]

代码:

int k = 0;

while (k < nums.size())
{
    if (nums.get(k) == 0)
    {
        nums.remove(k);
    }

    k++;
}

这里的 nums 是 ArrayList<Integer>。读取元素时会自动拆箱,因此 nums.get(k) == 0 是合法的数值比较。

关键错误是:不管有没有删除元素,每轮都会执行 k++。

完整追踪:

进入时 k 当前元素 操作后 nums 之后的 k
0 0:删除 [0, 4, 2, 5, 0, 3, 0] 1
1 4:保留 [0, 4, 2, 5, 0, 3, 0] 2
2 2:保留 [0, 4, 2, 5, 0, 3, 0] 3
3 5:保留 [0, 4, 2, 5, 0, 3, 0] 4
4 0:删除 [0, 4, 2, 5, 3, 0] 5
5 0:删除 [0, 4, 2, 5, 3] 6
6 不再进入 6 < 5 为 false —

最终:

[0, 4, 2, 5, 3]

最前面的第二个 0 被漏掉了。

怎样改正?

只需要调整 k++ 的位置:

int k = 0;

while (k < nums.size())
{
    if (nums.get(k) == 0)
    {
        nums.remove(k);
    }
    else
    {
        k++;
    }
}

修正后,会删除所有的 0,最终得到:

[4, 2, 5, 3]

AP 代码追踪建议:每次遇到 remove(index),立即把列表重新写一遍,再判断下一轮访问哪个 index。不要继续看删除前的旧索引表。

10. 对象列表:如何遍历 Student 并筛选成绩?

Runestone 4.9 还使用 ArrayList<Student> 练习访问对象及其属性。

假设 Student 类有:

public String getName()
public double getGPA()

以及将学生信息转换成字符串的 toString() 方法。

我们建立一个学生名单:

ArrayList<Student> students = new ArrayList<Student>();

students.add(new Student("Skyler", 3.4));
students.add(new Student("Ayanna", 4.0));
students.add(new Student("Lin", 3.6));
students.add(new Student("Sunil", 3.0));

假设题目要求:打印所有 GPA 大于 3.5 的学生姓名。

使用 indexed for:

for (int i = 0; i < students.size(); i++)
{
    if (students.get(i).getGPA() > 3.5)
    {
        System.out.println(students.get(i).getName());
    }
}

输出:

Ayanna
Lin

读懂这行代码:

students.get(i).getGPA()

需要先执行 students.get(i),取得当前位置的 Student 对象引用,再调用该对象的 getGPA() 方法。

这是 method chaining(方法连续调用) 的一个例子。

同样的任务也可以使用 enhanced for:

for (Student student : students)
{
    if (student.getGPA() > 3.5)
    {
        System.out.println(student.getName());
    }
}

因为这里只读取并打印信息,没有添加或删除列表元素,enhanced for 很适合。

10.1 打印整个对象时会发生什么?

如果 Student 类已经正确重写 toString():

for (Student student : students)
{
    System.out.println(student);
}

System.out.println(student) 会调用该对象的 toString(),输出它提供的文字表示。

不要把这件事误解成“列表自动展开了 Student 的所有属性”。输出什么内容,取决于 Student 类的 toString() 实现。

11. 教材中的延伸活动:有序插入与读取文件

Runestone 4.9 除了遍历和删除,还提供了两类练习。它们有助于应用已有知识,但要分清本课的核心考点。

11.1 把新名字插入已经排好序的名单

如果 nameList 已经按字母顺序排列,可以先找出插入位置,再使用 4.8 的 add(index, value):

public void insertInOrder(String name)
{
    int index = 0;

    while (index < nameList.size() &&
           nameList.get(index).compareTo(name) < 0)
    {
        index++;
    }

    nameList.add(index, name);
}

它的关键思路是:先遍历,找到应该插入的位置;然后插入,而不是替换。

这里假设名单原本已经按字母顺序排列。compareTo() 的基础知识来自前面的 String 课程。

11.2 java.nio.file:知道有这种工具即可

教材还介绍了 Files.readAllLines() 和 List,用于从文件中读入一组字符串。

教材明确说明这部分不属于 AP CSA 考试要求。 因此本指南不展开 List interface 或 java.nio.file 的具体语法;文件读取的正式考点已在 4.6 学过。

教材的旧版 Word Pairs FRQ 挑战可以作为额外编程实践,但不需要为了它提前学习 4.10 的完整算法内容。

12. AP Exam Focus:官方四项核心考点

College Board 的正式学习目标是 4.9.A:编写遍历 ArrayList 的代码,并确定遍历后的结果。

官方考点 必须理解与掌握的内容
4.9.A.1 遍历是通过迭代或递归,访问 ArrayList 的全部元素,或按顺序访问其中一部分。本课重点使用循环。
4.9.A.2 遍历过程中删除元素需要特殊处理,否则左移后的元素可能被跳过。
4.9.A.3 访问超出有效索引范围的位置,会产生 IndexOutOfBoundsException。
4.9.A.4 在 enhanced for 遍历过程中改变列表大小,可能导致 ConcurrentModificationException;因此不要在其中 add() 或 remove()。

官方建议技能:

这也说明:本课的练习应该重点训练代码追踪、解释原因、修复错误和编写安全遍历代码,而不是提前学习 4.10 的所有标准 ArrayList 算法。

13. Common Mistakes:常见错误

常见错误 正确理解
把 list.size() 写成 list.length ArrayList 使用 size() 方法,array 才使用 length 属性
把 list.get(i) 写成 list[i] ArrayList 使用 get(i) 读取元素
遍历时使用 i <= list.size() 最后会访问无效 index;应使用 i < list.size()
认为 get(list.size()) 可以得到最后一个元素 最后一个元素是 get(list.size() - 1)
把 ArrayList 的越界异常写成 ArrayIndexOutOfBoundsException 对 ArrayList,应使用 IndexOutOfBoundsException
删除一个元素后仍然无条件 i++ 左移到当前 index 的元素可能被跳过
认为 remove(i) 后 index i 变成了空位置 后面的元素会左移补上这个位置
用 enhanced for 边遍历边删除或增加元素 可能触发 ConcurrentModificationException;使用 indexed loop 或 while
认为 enhanced for 不能修改 Student 对象的属性 可以通过循环变量调用 mutator 方法;不能靠重新赋值循环变量替换列表元素
使用 == 判断两个 String 是否有相同内容 比较字符串内容应使用 .equals()

14. Mini Practice:AP 风格练习

以下题目主要考查循环追踪、索引边界、删除时的元素移动和修复错误。

Practice 1:读取与计算

ArrayList<Integer> nums = new ArrayList<Integer>();
nums.add(4);
nums.add(6);
nums.add(8);

int sum = 0;
for (int i = 0; i < nums.size(); i++)
{
    sum += nums.get(i);
}

System.out.println(sum);

输出是什么?

A. 3
B. 18
C. 24

答案:B。 4 + 6 + 8 = 18;循环依次访问 index 0、1、2。

Practice 2:索引越界

ArrayList<String> words = new ArrayList<String>();
words.add("cat");
words.add("dog");

for (int i = 0; i <= words.size(); i++)
{
    System.out.println(words.get(i));
}

哪项描述正确?

A. 正常打印两个单词后结束。
B. 会在尝试访问 index 2 时产生 IndexOutOfBoundsException。
C. 会产生 ArrayIndexOutOfBoundsException。

答案:B。 words.size() 是 2,有效索引只有 0 和 1。应把 <= 改为 <。

Practice 3:连续元素被漏检

初始列表:

["A", "B", "B", "C"]

执行:

for (int i = 0; i < letters.size(); i++)
{
    if (letters.get(i).equals("B"))
    {
        letters.remove(i);
    }
}

最终列表是什么?

A. [A, C]
B. [A, B, C]
C. [A, B, B, C]

答案:B。 删除第一个 B 后,第二个 B 左移到 index 1,但循环立即执行 i++,跳过了它。

Practice 4:修复删除代码

已知 names 是一个 ArrayList<String>。请补全代码,删除所有 "Leo",而不漏检元素。

int i = 0;

while (i < names.size())
{
    if (names.get(i).equals("Leo"))
    {
        names.remove(i);
    }
    else
    {
        // Write one statement here.
    }
}

答案:

i++;

解释:只有未删除时才前进;删除后必须继续检查左移到当前位置的元素。

Practice 5:反向删除

初始列表:

[2, 3, 4, 5, 6]

执行:

for (int i = nums.size() - 1; i >= 0; i--)
{
    if (nums.get(i) % 2 == 0)
    {
        nums.remove(i);
    }
}

最终列表是什么?

A. [3, 5]
B. [2, 4, 6]
C. [3, 4, 5]

答案:A。 反向遍历可以安全地删除偶数,保留 3 和 5。

Practice 6:Enhanced for 的限制

ArrayList<Integer> nums = new ArrayList<Integer>();
nums.add(1);
nums.add(2);
nums.add(3);

for (Integer n : nums)
{
    nums.add(10);
}

哪项描述最符合官方考纲?

A. 一定正常结束,最后有 6 个元素。
B. 修改列表大小可能导致 ConcurrentModificationException,不应这样写。
C. 会导致 IndexOutOfBoundsException,因为 nums.add(10) 的 index 是 10。

答案:B。 add(10) 在这里是添加值为 10 的元素,不是把元素插入 index 10。问题在于遍历时改变列表大小。

Practice 7:Student 对象列表

假设:

ArrayList<Student> students = new ArrayList<Student>();
students.add(new Student("Mia", 3.4));
students.add(new Student("Leo", 3.8));
students.add(new Student("Eva", 4.0));

Student 类提供 getName() 和 getGPA()。

下面的代码:

for (Student student : students)
{
    if (student.getGPA() > 3.5)
    {
        System.out.println(student.getName());
    }
}

输出是什么?

A. Mia、Leo、Eva
B. Leo、Eva
C. Mia

答案:B。 只有 Leo(3.8)和 Eva(4.0)的 GPA 大于 3.5。这段代码没有改变列表大小,可以使用 enhanced for。

Practice 8:编写方法(FRQ-style)

任务: 编写方法 removeShortWords,删除 ArrayList<String> words 中所有长度小于 4 的字符串,并返回删除的元素数量。

方法签名:

public static int removeShortWords(ArrayList<String> words)

示例:

Before: [cat, dog, apple, sun, book]
After:  [apple, book]
Return: 3

参考答案:

public static int removeShortWords(ArrayList<String> words)
{
    int removed = 0;
    int i = 0;

    while (i < words.size())
    {
        if (words.get(i).length() < 4)
        {
            words.remove(i);
            removed++;
        }
        else
        {
            i++;
        }
    }

    return removed;
}

评分重点:

  1. 使用 i < words.size(),每轮按列表当前大小判断边界。
  2. 正确判断 words.get(i).length() < 4。
  3. 删除后不增加 i,避免漏检。
  4. 正确累计并返回删除数量。

Unit 4.9 核心词汇(Vocabulary)

Vocabulary 中文理解
ArrayList traversal ArrayList 遍历;按顺序访问列表元素
indexed for loop 索引式 for 循环;使用 index 和 get(i) 访问列表
enhanced for loop 增强型 for 循环;依次获得元素,不需要显式 index
iteration 迭代;循环的一次执行过程
index 索引;元素的当前位置编号
size() 返回列表当前的元素数量
get(index) 读取指定索引的元素
set(index, value) 替换指定位置的元素,不改变列表大小
remove(index) 删除指定位置的元素,后面的元素左移
IndexOutOfBoundsException 索引越界异常;访问了列表不存在的位置
ConcurrentModificationException 并发修改异常;例如 enhanced for 遍历中改变列表大小可能触发
structural modification 结构性修改;例如添加、删除元素,改变列表大小
method chaining 方法连续调用;例如 students.get(i).getGPA()

下一课预告: 4.9 重点学习如何安全遍历 ArrayList,特别是删除元素时如何避免漏检。4.10 将继续学习怎样在 ArrayList 上实现常见算法,例如统计、查找、比较和重排元素。

参考教材与官方考纲: