63、迭代器模式(下)
1、问题描述
理解这个问题最关键的是理解 "快照" 两个字
所谓 "快照",指我们为容器创建迭代器的时候,相当于给容器拍了一张快照(Snapshot),之后即便我们增删容器中的元素,快照中的元素并不会做相应的改动
而迭代器遍历的对象是快照而非容器,这样就避免了在使用迭代器遍历的过程中,增删容器中的元素,导致的不可预期的结果或者报错
容器 list 中初始存储了 3、8、2 三个元素
尽管在创建迭代器 iter1 之后,容器 list 删除了元素 3,只剩下 8、2 两个元素,但通过 iter1 遍历的对象是快照,而非容器 list 本身,所以遍历的结果仍然是 3、8、2
同理 iter2、iter3 也是在各自的快照上遍历,输出的结果如代码中注释所示
List<Integer> list = new ArrayList<>();
list.add(3);
list.add(8);
list.add(2);
Iterator<Integer> iter1 = list.iterator(); // snapshot: 3, 8, 2
list.remove(2); // list: 3, 8
Iterator<Integer> iter2 = list.iterator(); // snapshot: 3, 8
list.remove(3); // list: 8
Iterator<Integer> iter3 = list.iterator(); // snapshot: 8
// 输出结果: 3 8 2
while (iter1.hasNext()) {
System.out.print(iter1.next() + " ");
}
System.out.println();
// 输出结果: 3 8
while (iter2.hasNext()) {
System.out.print(iter1.next() + " ");
}
System.out.println();
// 输出结果: 8
while (iter3.hasNext()) {
System.out.print(iter1.next() + " ");
}
System.out.println();
如果由你来实现上面的功能,你会如何来做呢,下面是针对这个功能需求的骨架代码,其中包含 ArrayList、SnapshotArrayIterator 两个类
public class SnapshotArrayIterator<E> implements Iterator<E> {
// TODO: 成员变量、私有函数等随便你定义
@Override
public boolean hasNext() {
// TODO: 由你来完善
}
// 返回当前元素, 并且游标后移一位
@Override
public E next() {
// TODO: 由你来完善
}
}
public class ArrayList<E> implements List<E> {
// TODO: 成员变量、私有函数等随便你定义
@Override
public void add(E obj) {
// TODO: 由你来完善
}
@Override
public void remove(E obj) {
// TODO: 由你来完善
}
@Override
public Iterator<E> iterator() {
return new SnapshotArrayIterator(this);
}
}
2、解决方案一
我们先来看最简单的一种解决办法,在迭代器类中定义一个成员变量 snapshot 来存储快照
每当创建迭代器的时候,都拷贝一份容器中的元素到快照中,后续的遍历操作都基于这个迭代器自己持有的快照来进行
public class SnapshotArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> snapshot;
public SnapshotArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.snapshot = new ArrayList<>();
this.snapshot.addAll(arrayList);
}
@Override
public boolean hasNext() {
return cursor < snapshot.size();
}
@Override
public E next() {
E currentItem = snapshot.get(cursor);
cursor++;
return currentItem;
}
}
这个解决方案虽然简单,但代价也有点高
每次创建迭代器的时候,都要拷贝一份数据到快照中,会增加内存的消耗
如果一个容器同时有多个迭代器在遍历元素,就会导致数据在内存中重复存储多份
庆幸的是,Java 中的拷贝属于浅拷贝,也就是说,容器中的对象并非真的拷贝了多份,而只是拷贝了对象的引用而已
关于深拷贝、浅拷贝,我们在第 47 讲中有详细的讲解,你可以回过头去再看一下
那有没有什么方法,既可以支持快照,又不需要拷贝容器呢
3、解决方案二
我们可以在容器中,为每个元素保存两个时间戳,一个是添加时间戳 addTimestamp,一个是删除时间戳 delTimestamp
- 当元素被加入到集合中的时候,我们将 addTimestamp 设置为当前时间,将 delTimestamp 设置成最大长整型值(Long.MAX_VALUE)
- 当元素被删除时,我们将 delTimestamp 更新为当前时间,表示已经被删除
注意:这里只是标记删除,而非真正将它从容器中删除 - 每个迭代器也保存一个迭代器创建时间戳 snapshotTimestamp,也就是迭代器对应的快照的创建时间戳
当使用迭代器来遍历容器的时候,只有满足 addTimestamp < snapshotTimestamp < delTimestamp 的元素,才是属于这个迭代器的快照
如果元素的 addTimestamp > snapshotTimestamp,说明元素在创建了迭代器之后才加入的,不属于这个迭代器的快照
如果元素的 delTimestamp < snapshotTimestamp,说明元素在创建迭代器之前就被删除掉了,也不属于这个迭代器的快照
这样就在不拷贝容器的情况下,在容器本身上借助时间戳实现了快照功能
注意:我们没有考虑 ArrayList 的扩容问题,感兴趣的话,你可以自己完善一下
public class SnapshotArrayIterator<E> implements Iterator<E> {
private long snapshotTimestamp;
private int cursorInAll; // 在整个容器中的下标, 而非快照中的下标
private int leftCount; // 快照中还有几个元素未被遍历
private ArrayList<E> arrayList;
public SnapshotArrayIterator(ArrayList<E> arrayList) {
this.snapshotTimestamp = System.currentTimeMillis();
this.cursorInAll = 0;
this.leftCount = arrayList.actualSize();
this.arrayList = arrayList;
justNext(); // 先跳到这个迭代器快照的第一个元素
}
@Override
public boolean hasNext() {
return this.leftCount >= 0; // 注意是 >=, 而非 >
}
@Override
public E next() {
E currentItem = arrayList.get(cursorInAll);
justNext();
return currentItem;
}
private void justNext() {
while (cursorInAll < arrayList.totalSize()) {
long addTimestamp = arrayList.getAddTimestamp(cursorInAll);
long delTimestamp = arrayList.getDelTimestamp(cursorInAll);
if (snapshotTimestamp > addTimestamp && snapshotTimestamp < delTimestamp) {
leftCount--;
break;
}
cursorInAll++;
}
}
}
public class ArrayList<E> implements List<E> {
private static final int DEFAULT_CAPACITY = 10;
private int actualSize; // 不包含标记删除元素
private int totalSize; // 包含标记删除元素
private Object[] elements;
private long[] addTimestamps;
private long[] delTimestamps;
public ArrayList() {
this.elements = new Object[DEFAULT_CAPACITY];
this.addTimestamps = new long[DEFAULT_CAPACITY];
this.delTimestamps = new long[DEFAULT_CAPACITY];
this.totalSize = 0;
this.actualSize = 0;
}
@Override
public void add(E obj) {
elements[totalSize] = obj;
addTimestamps[totalSize] = System.currentTimeMillis();
delTimestamps[totalSize] = Long.MAX_VALUE;
totalSize++;
actualSize++;
}
@Override
public void remove(E obj) {
for (int i = 0; i < totalSize; ++i) {
if (elements[i].equals(obj)) {
delTimestamps[i] = System.currentTimeMillis();
actualSize--;
}
}
}
public int actualSize() {
return this.actualSize;
}
public int totalSize() {
return this.totalSize;
}
public E get(int i) {
if (i >= totalSize) {
throw new IndexOutOfBoundsException();
}
return (E) elements[i];
}
public long getAddTimestamp(int i) {
if (i >= totalSize) {
throw new IndexOutOfBoundsException();
}
return addTimestamps[i];
}
public long getDelTimestamp(int i) {
if (i >= totalSize) {
throw new IndexOutOfBoundsException();
}
return delTimestamps[i];
}
}
上面的解决方案相当于解决了一个问题,又引入了另外一个问题
ArrayList 底层依赖数组这种数据结构,原本可以支持快速的随机访问,在 O(1) 时间复杂度内获取下标为 i 的元素
但现在删除数据并非真正的删除,只是通过时间戳来标记删除,这就导致无法支持按照下标快速随机访问了
如果你对数组随机访问这块知识点不了解,可以去看我的《数据结构与算法之美》专栏,这里我就不展开讲解了
我们来看怎么解决这个问题:让容器既支持快照遍历,又支持随机访问
解决的方法也不难,我稍微提示一下:我们可以在 ArrayList 中存储两个数组
一个支持标记删除的,用来实现快照遍历功能;一个不支持标记删除的(也就是将要删除的数据直接从数组中移除),用来支持随机访问
对应的代码我这里就不给出了,感兴趣的话你可以自己实现一下
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17538551.html