1.Add方法
单参方法(分析它的时间复杂度)
@Override
public boolean add(T element) {
//make a bigger array and copy over the elements
if (size >= array.length) {
T[] bigger = new Object[array.length * 2];
System.arraycopy(array,0,bigger,0,array.length);
array = bigger;
}
array[size] = element;
size++;
return true;
}
单参数版本很难分析。如果数组中存在未使用的空间,那么它是常数时间,但如果我们必须调整数组的大小,它是线性的,因为
System.arraycopy
所需的时间与数组的大小成正比.(既存在常数时间,又是线性的)
那么
add
是常数还是线性时间的?(先给结论,可以看作是常数的)
- 我们第一次调用
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 上阅读更多信息。重要的想法是,复制数组的额外成本是通过一系列调用展开或“摊销”的。
双参方法
@Override
public void add(int index, T element) {
if (index<0 || index>size) {
throw IndexOutOfBoundsException;
}
//new element(扩容之需)
add(element);
//shift the other element
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(E)
是常数时间,那么add(int, E)
呢?调用add(E)
后,它遍历数组的一部分并移动元素。这个循环是线性的,除了在列表末尾添加的特殊情况中。因此,add(int, E)
是线性的。
2.划分MyArrayList的方法
对于许多方法,我们不能通过测试代码来确定增长级别
常数级别
get
中的每个东西都是常数时间的。所以get
是常数时间
public E get(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException();
}
return array[index];
}
set
中的一切,包括get
的调用都是常数时间,所以set
也是常数时间set它不会显式检查数组的边界;它利用
get
,如果索引无效则引发异常
public E set(int index, E element) {
E old = get(index);
array[index] = element;
return old;
}
线性级别
每次在循环中,
indexOf
调用equals
,所以我们首先要划分equals
public int indexOf(Object target) {
for (int i = 0; i<size; i++) {
if (equals(target, array[i])) {
return i;
}
}
return -1;
}
equals方法
private boolean equals(Object target, Object element) {
if (target == null) {
return element == null;
} else {
return target.equals(element);
}
}
如果我们幸运,我们可能会立即找到目标对象,并在测试一个元素后返回。如果我们不幸,我们可能需要测试所有的元素。平均来说,我们预计测试一半的元素,所以这种方法被认为是线性的(除了在不太可能的情况下,我们知道目标元素在数组的开头)
3.问题规模
RemoveAll
public boolean removeAll(Collection<?> collection) {
boolean flag = true;
for (Object obj: collection) {
flag &= remove(obj);
}
return flag;
}
每次循环中,
removeAll
都调用remove
,这是线性的。所以认为removeAll
是二次的很诱人。但事实并非如此。
在这种方法中,循环对于每个
collection
中的元素运行一次。如果collection
包含m
个元素,并且我们从包含n
个元素的列表中删除,则此方法是O(nm)
的。如果collection
的大小可以认为是常数,removeAll
相对于n
是线性的。但是,如果集合的大小与n
成正比,removeAll
则是平方的。例如,如果collection
总是包含100
个或更少的元素,removeAll
则是线性的。但是,如果collection
通常包含的列表中的 1% 元素,removeAll
则是平方的。
当我们谈论问题规模时,我们必须小心我们正在讨论哪个大小。这个例子演示了算法分析的陷阱:对循环计数的诱人捷径。如果有一个循环,算法往往是 线性的。如果有两个循环(一个嵌套在另一个内),则该算法通常是平方的。不过要小心!你必须考虑每个循环运行多少次。如果所有循环的迭代次数与
n
成正比,你可以仅仅对循环进行计数之后离开。但是,如在这个例子中,迭代次数并不总是与n
成正比,所以你必须考虑更多。
原书链接:https://wizardforcel.gitbooks.io/think-dast/content/5.html
GitHub链接(提供源码):https://github.com/huoji555/Shadow/tree/master/DataStructure