数据结构思维笔记(三)ArrayList

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值