提交 b3318859 编写于 作者: W wizardforcel

ch3.

上级 bbf16128
# 第三章 `ArrayList`
本章一举两得:我展示了上一个练习的解法,并展示了一种使用摊销分析来划分算法的方法。
## 3.1 划分`MyArrayList`的方法
对于许多方法,我们不能通过测试代码来确定增长级别。例如,这里是`MyArrayList``get`的实现:
```java
public E get(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException();
}
return array[index];
}
```
`get`中的每个东西都是常数时间的。所以`get`是常数时间,没问题。
现在我们已经划分了`get`,我们可以使用它来划分`set`。这是我们以前的练习中的`set`
```java
public E set(int index, E element) {
E old = get(index);
array[index] = element;
return old;
}
```
该解决方案的一个有些机智的部分是,它不会显式检查数组的边界;它利用`get`,如果索引无效则引发异常。
`set`中的一切,包括`get`的调用都是常数时间,所以`set`也是常数时间。
接下来我们来看一些线性的方法。例如,以下是我的实现`indexOf`
```java
public int indexOf(Object target) {
for (int i = 0; i<size; i++) {
if (equals(target, array[i])) {
return i;
}
}
return -1;
}
```
每次在循环中,`indexOf`调用`equals`,所以我们首先要划分`equals`。这里就是:
```java
private boolean equals(Object target, Object element) {
if (target == null) {
return element == null;
} else {
return target.equals(element);
}
}
```
此方法调用`target.equals`;这个方法的运行时间可能取决于`target``element`的大小,但它不依赖于该数组的大小,所以出于分析`indexOf`的目的,我们认为这是常数时间。
回到之前的`indexOf`,循环中的一切都是常数时间,所以我们必须考虑的下一个问题是:循环执行多少次?
如果我们幸运,我们可能会立即找到目标对象,并在测试一个元素后返回。如果我们不幸,我们可能需要测试所有的元素。平均来说,我们预计测试一半的元素,所以这种方法被认为是线性的(除了在不太可能的情况下,我们知道目标元素在数组的开头)。
`remove`的分析也类似。这里是我的时间。
```java
public E remove(int index) {
E element = get(index);
for (int i=index; i<size-1; i++) {
array[i] = array[i+1];
}
size--;
return element;
}
```
它使用`get`,这是常数时间,然后从`index`开始遍历数组。如果我们删除列表末尾的元素,循环永远不会运行,这个方法是常数时间。如果我们删除第一个元素,我们遍历所有剩下的元素,它们是线性的。因此,这种方法同样被认为是线性的(除了在特殊情况下,我们知道元素在末尾,或到末尾距离恒定)。
## 3.2 `add`的划分
这里是`add`的一个版本,接受下标和元素作为参数:
```java
public void add(int index, E element) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException();
}
// add the element to get the resizing
add(element);
// shift the other elements
for (int i=size-1; i>index; i--) {
array[i] = array[i-1];
}
// put the new one in the right place
array[index] = element;
}
```
这个双参数的版本,叫做`add(int, E)`,它使用了单参数的版本,称为`add(E)`,它将新的元素放在最后。然后它将其他元素向右移动,并将新元素放在正确的位置。
在我们可以划分双参数`add`之前,我们必须划分单参数`add`
```java
public boolean add(E element) {
if (size >= array.length) {
// make a bigger array and copy over the elements
E[] bigger = (E[]) new Object[array.length * 2];
System.arraycopy(array, 0, bigger, 0, array.length);
array = bigger;
}
array[size] = element;
size++;
return true;
}
```
单参数版本很难分析。如果数组中存在未使用的空间,那么它是常数时间,但如果我们必须调整数组的大小,它是线性的,因为`System.arraycopy`所需的时间与数组的大小成正比。
那么`add`是常数还是线性时间的?我们可以通过考虑一系列`n`个添加中,每次添加的平均操作次数,来分类此方法。为了简单起见,假设我们以一个有`2`个元素的空间的数组开始。
+ 我们第一次调用`add`时,它会在数组中找到未使用的空间,所以它存储`1`个元素。
+ 第二次,它在数组中找到未使用的空间,所以它存储`1`个元素。
+ 第三次,我们必须调整数组的大小,复制`2`个元素,并存储`1`个元素。现在数组的大小是`4`
+ 第四次存储`1`个元素。
+ 第五次调整数组的大小,复制`4`个元素,并存储`1`个元素。现在数组的大小是`8`
+ 接下来的`3`个添加储存`3`个元素。
+ 下一个添加复制`8`个并存储`1`个。现在的大小是`16`
+ 接下来的`7`个添加复制了`7`个元素。
以此类推,总结一下:
+ `4`次添加之后,我们储存了`4`个元素,并复制了两个。
+ `8`次添加之后,我们储存了`8`个元素,并复制了`6`个。
+ `16`次添加之后,我们储存了`16`个元素,并复制了`14`个。
现在你应该看到了规律:要执行`n`次添加,我们必须存储`n`个元素并复制`n-2`个。所以操作总数为`n + n - 2`,为`2 * n - 2`
为了得到每个添加的平均操作次数,我们将总和除以`n`;结果是`2 - 2 / n`。随着`n`变大,第二项`2 / n`变小。参考我们只关心`n`的最大指数的原则,我们可以认为`add`是常数时间的。
有时线性的算法平均可能是常数时间,这似乎是奇怪的。关键是我们每次调整大小时都加倍了数组的长度。这限制了每个元素被复制的次数。否则 - 如果我们向数组的长度添加一个固定的数量,而不是乘以一个固定的数量 - 分析就不起作用。
这种划分算法的方式,通过计算一系列调用中的平均时间,称为摊销分析。你可以在 <http://thinkdast.com/amort> 上阅读更多信息。重要的想法是,复制数组的额外成本是通过一系列调用展开或“摊销”的。
现在,如果`add(E)`是常数时间,那么`add(int, E)`呢?调用`add(E)`后,它遍历数组的一部分并移动元素。这个循环是线性的,除了在列表末尾添加的特殊情况中。因此, `add(int, E)`是线性的。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册