本文内容为使用数组实现能够动态扩容且高效索引每个元素的通用类型的列表。
1. 数组构建列表
之前我们实现的列表想要索引一个中间元素都是需要遍历列表才能找到的,查找效率低下,而数组的一大特性就是无论规模有多大,想要索引某个元素的时间复杂度都是 O ( 1 ) O(1) O(1)。
我们可以用数组来设计之前写过的列表,我们先设定数组的最大长度为100,当数组还未填满时可以称未使用的那部分为后备数组:
java
package CS61B.Lecture7;
public class AList {
private int[] items;
private int size;
private final int MAX_SIZE = 100;
public AList() {
this.items = new int[this.MAX_SIZE];
this.size = 0;
}
public int get(int idx) {
return this.items[idx];
}
public int size() {
return this.size;
}
public void addLast(int x) {
if (this.size == this.MAX_SIZE) return;
this.items[this.size++] = x;
}
public int getLast() {
if (this.size == 0) return 0;
return this.items[this.size - 1];
}
public void removeLast() {
if (this.size == 0) return;
this.size--;
}
public static void main(String[] args) {
AList L = new AList();
for (int i = 0; i < 5; i++) L.addLast(i);
System.out.println(L.getLast()); // 4
L.removeLast();
for (int i = 0; i < L.size(); i++)
System.out.print(L.get(i) + " "); // 0 1 2 3
}
}
2. 改变AList的大小
如果用户的需求是需要一个能无限插入的列表,但数组在 Java 中的长度是固定的,当空间用满后如何调整数组的大小呢?此时只能创建一个新的更长的数组,然后将已满数组的内容全部复制到新数组中。
那么此时又有一个新的问题,每次需要新创建数组时这个新数组的长度设为多少合适呢?这个扩容的比例我们一般称作扩容因子 ,在动态数组(如 Java 的 ArrayList
或 Python 的 list
)中,设置扩容因子是为了权衡时间复杂度和空间复杂度,其合理性可以通过均摊分析(Amortized Analysis)和内存管理优化来解释。
动态数组的扩容需要将旧数据复制到新内存空间,这是一个时间复杂度为 O ( n ) O(n) O(n) 的操作。若扩容策略不当,频繁扩容会导致性能下降。例如:
- 如果每次扩容固定大小 (例如加一),插入 n n n 个元素的总时间复杂度为 O ( n 2 ) O(n^2) O(n2),无法接受。
- 如果每次扩容指数倍 (例如两倍),插入 n n n 个元素的总时间复杂度可优化为 O ( n ) O(n) O(n),均摊到每次操作为 O ( 1 ) O(1) O(1)。
数学推导如下(以两倍扩容为例):
假设初始容量为1,插入 n n n 个元素需要扩容 k k k 次(满足 2 k ≥ n 2^k\ge n 2k≥n)。总复制操作次数为:
1 + 2 + 4 + ⋯ + 2 k − 1 = 2 k − 1 ≈ 2 n 1+2+4+\dots +2^{k-1}=2^k-1\approx 2n 1+2+4+⋯+2k−1=2k−1≈2n
因此,总时间复杂度为 O ( n ) O(n) O(n),均摊到每次插入操作为 O ( 1 ) O(1) O(1)。
Java 与 Python 的动态数组扩容策略如下:
- Java:
ArrayList
默认扩容为1.5倍,扩容公式为newCapacity = oldCapacity + (oldCapacity >> 1)
,减少内存浪费。 - Python:
list
采用过度分配(over-allocation),扩容公式为new_size = (new_len >> 3) + (new_len < 9 ? 3 : 6)
,近似1.125倍。
现在我们可以实现具有动态扩容功能的列表:
java
package CS61B.Lecture7;
public class AList {
private int[] items;
private int size;
private int MAX_SIZE = 2;
public AList() {
this.items = new int[this.MAX_SIZE];
this.size = 0;
}
public int get(int idx) {
return this.items[idx];
}
public int size() {
return this.size;
}
// 将数组长度扩充为capacity
private void resize(int capacity) {
int[] newItems = new int[capacity];
System.arraycopy(this.items, 0, newItems, 0, this.size);
this.items = newItems;
}
public void addLast(int x) {
if (this.size == this.MAX_SIZE) {
this.MAX_SIZE *= 2;
this.resize(this.MAX_SIZE);
}
this.items[this.size++] = x;
}
public int getLast() {
if (this.size == 0) return 0;
return this.items[this.size - 1];
}
public void removeLast() {
if (this.size == 0) return;
this.size--;
}
public static void main(String[] args) {
AList L = new AList();
for (int i = 0; i < 5; i++) L.addLast(i);
System.out.println(L.getLast()); // 4
L.removeLast();
for (int i = 0; i < L.size(); i++)
System.out.print(L.get(i) + " "); // 0 1 2 3
}
}
此时会发现当列表扩容后如果用户将元素又删除了导致列表有很多空余的位置,因此还要能够实现多余空间的回收,一般会用使用率来表示列表的空间占用情况: R = s i z e / i t e m . l e n g t h R=size/item.length R=size/item.length,一个典型的解决方案就是当 R < 0.25 R<0.25 R<0.25 时将列表的大小减半。具体细节会在以后的内容中实现。
3. 通用类型ArrayList
现在我们尝试和上一节课一样使用泛型将 AList
改为通用类型的,但是当我们使用以下代码创建数组时出现了问题:
java
this.items = new T[this.MAX_SIZE];
这涉及到类型擦除(Type Erasure),先看下面这个例子:
java
public class GenericClass<T> {
T[] items;
public GenericClass() {
items = new T[10]; // 编译错误
}
}
Java 的泛型是通过类型擦除实现的。在运行时,泛型类型参数(如 T
)会被擦除,替换为它们的上下界(如果没有上下界,则替换为 Object
)。这意味着在运行时,T
的具体类型信息是不可用的,因此 new T[10]
无法被正确解析,因为编译器无法确定 T
的实际类型,这会导致编译错误。
但在某些情况下,泛型类型参数仍然可以被安全地使用。具体来说:
- 变量声明:当你声明一个泛型类型的变量(如
T[] items
)时,Java 编译器会保留这种类型信息用于编译时的类型检查,但不会在运行时保留T
的具体类型。在运行时,T
会被替换为它的最具体已知类型,通常是Object
(如果没有更具体的上下界)。 - 运行时行为:尽管
T
在运行时被擦除,但只要不涉及具体的实例化(如new T[]
),这种声明是合法的。
现在我们回答两个问题:
(1)为什么 T[] items
不会出错?
T[] items
的声明不会引发编译错误,因为这里只是声明了一个变量,而没有尝试实例化它。编译器会在编译时检查类型安全性 ,但不会在运行时强制要求 T
的具体类型。在运行时,T[]
会被处理为 Object[]
,但只要不涉及具体的数组实例化,这种声明是合法的。
(2)为什么 new T[size]
会出错?
- 类型擦除的限制:在运行时,
T
的具体类型信息已经被擦除,编译器无法确定T
的实际类型。因此,new T[size]
无法被正确解析,因为T
可能被擦除为Object
,而new Object[size]
与T[]
在类型上是不匹配的。 - 类型安全问题:如果允许
new T[size]
,可能会导致运行时的类型安全问题。例如,如果T
是一个具体的子类型(如String
),而你实例化了一个Object[]
,那么在后续代码中可能会引发ClassCastException
。
虽然 T
在运行时被擦除,但 Java 的泛型机制仍然保留了足够的信息来确保类型安全,我们可以用一下方法解决:
java
public class GenericClass<T> {
T[] items;
public GenericClass() {
items = (T[]) new Object[10]; // 警告:未检查的转换
}
}
这样写是合法的,但会产生警告,因为编译器无法验证这种转换是否安全,但这种转换在运行时通常是安全的,因为 Object[]
可以兼容任何类型的数组。只要你在后续代码中正确使用 items
,不会引发 ClassCastException
。
最后我们就可以实现一个通用类型的 AList
:
java
package CS61B.Lecture7;
public class AList<T> {
private T[] items;
private int size;
private int MAX_SIZE = 2;
public AList() {
this.items = (T[]) new Object[this.MAX_SIZE];
this.size = 0;
}
public T get(int idx) {
return this.items[idx];
}
public int size() {
return this.size;
}
// 将数组长度扩充为capacity
private void resize(int capacity) {
T[] newItems = (T[]) new Object[capacity];
System.arraycopy(this.items, 0, newItems, 0, this.size);
this.items = newItems;
}
public void addLast(T x) {
if (this.size == this.MAX_SIZE) {
this.MAX_SIZE *= 2;
this.resize(this.MAX_SIZE);
}
this.items[this.size++] = x;
}
public T getLast() {
if (this.size == 0) return null;
return this.items[this.size - 1];
}
public void removeLast() {
if (this.size == 0) return;
this.size--;
}
public static void main(String[] args) {
AList<String> L = new AList<>();
for (int i = 0; i < 5; i++) L.addLast("Char: " + (char) (i + 'A'));
System.out.println(L.getLast()); // Char: E
L.removeLast();
for (int i = 0; i < L.size(); i++)
System.out.print(L.get(i) + " "); // Char: A Char: B Char: C Char: D
}
}