使用数组和链表实现栈和队列的复杂度分析

​ 抽象数据类型(abstract data type,ADT)是带有一组操作的一些对象的集合

1 表ADT

A 1 , A 2 , ⋯   , A N A_1,A_2,\cdots,A_N A1,A2,,AN

​ 如上,这是一个大小为N的表。当然,若N为0,则称其为空表。

​ 对于除空表以外的任何表,A1可以看作是A2的前驱,而A2则是A1的后继。在一个表中,表中第一个元素是A1,而表的最后一个元素是An,我们将不给A1设定前驱,不给An设定后继。这样一个表,也可以称为线性表

​ 对于表的操作,主要了解新增add,删除remove以及查找get这些方法即可。

1.1 数组实现表

​ 数组具有连续的存储单元,物理和逻辑上均具有连续性。使用下标进行访问,时间复杂度仅为O(1),使用它来实现表非常方便。

​ 对表的所有操作均可以使用数组来实现,虽然数组是使用固定容量capacity创建的(静态数据结构),但在需要的时候可以用2倍的容量来进行创建一个新的数组(ArrayList扩容为原来的1.5倍),这便解决了静态数据结构最大的难题。

E[] newArr = new int[capacity * 2];
for(int i = 0; i < oldArr.length; i++)
    newArr[i] = oldArr[i];
oldArr = newArr;

​ 进行查找操作,对于使用下标进行访问,比如get(int index),其时间消耗仅为O(1)。但查找特定元素get(E e),需要花费O(n)的时间,因为需要遍历数组。

​ 相对于查找操作,新增add和删除remove的开销就相对高昂。最坏的情况下,在下标0处进行新增add操作,则需要将原有元素全部后移一位,这便意味着开销为O(n);而对于删除来说,删除下标为0的元素,则需要将数组元素向前移动一个位置,开销也为0(n);当然,如果你仅在数组末尾进行插入和删除操作,那么开销仅为O(1)。

1.2 链表实现表

​ 相对于数组,使用链表的好处是不用预先设置容量,因为链表在内存中的地址可以是不连续的,存在零散的空间即可使用链表。所以说链表是一种动态的数据结构。

​ 链表由一系列节点节点组成,这些节点在内存中不必相连。每一个节点包含节点的元素以及该节点后继节点的链,我们称其为next链。不存在后继则将next设置为null。

​ 访问链表,需要找到该链表的头节点。初始化每个链表时,均会设置一个头节点head。head中包含元素以及链向后继元的next。通过next,可以访问到head的后继元节点。

Node cur = head;
while(cur != null)
    cur = cur.next;

​ 当我们需要查找target元素或者根据下标来找元素(链表的下标是人为设想的,我们通常把head节点所处位置设为下标0),由上述代码可知,查找的开销高达O(n)。

​ remove方法仅需要修改一个next引用就可以实现,使用一句代码即可A1.next = A2.next;开销仅为O(1),因为不涉及元素的移动。

​ 使用add方法需要使用new操作符获取一个新的节点,此后进行两次引用的调整,如下图,虚线为原来的next引用。开销也仅仅为O(1)。

Node X = new Node();
X.next = A1.next;
A1.next = X;

​ 值得注意的是,如果需要在指定位置插入新元素或者删除指定位置的元素,那么开销仍然达到了O(n)。链表本身是不连续的,要找到需要修改的节点,则必须遍历链表找到它的前驱,操作建立在该节点上(后续有用到该名词,均用prev表示)。上图中,A1就是prev。

​ 当然,如果在头节点插入或者删除一个节点(前提是该链表不能为空),这种情况操作的开销也为O(1)。如果链表还维护了一个尾节点tail,那么在尾节点执行插入的操作开销也为O(1)。

1.3 性能分析

​ 对以上两个不同的数据结构实现表作一个简单的总结。对于数组,仅仅在数组末尾进行插入和删除操作,开销为O(1),而在数组下标0处插入和删除开销为O(n)。相反的是,对于链表,在链表头插入和删除元素,开销为O(1),链表尾插入删除开销则为O(n)。若链表维护一个尾节点,则在链表尾插入新元素的开销也可以下降至O(1)。

​ 现在为了测试性能,设计了一个数组和链表。为了方便测试,数组仅仅实现了带有resize(扩容)的add方法;而链表则维护了一个尾指针tail,实现了从末尾插入新元素。talk is cheap,show you the code!

	// 数组Array
	E[] data;
	// 数组中元素的个数
	int size;
    // 向数组的末尾添加元素
    void addLast(E e) {
        add(size, e);
    }

    // 在指定位置添加元素
    void add(int index, E e) {
		... check the index is valid ...
        if (size == data.length)
            resize(2 * data.length);// 扩容两倍
		// addLast不会执行该语句
        for (int i = size - 1; i >= index; i--)
            data[i + 1] = data[i];
        data[index] = e;
        size++;
    }

	// 将数组容量改为newCapacity大小
    private void resize(int newCapacity) {
        E[] newData = (E[]) new Object[newCapacity];
        for (int i = 0; i < size; i++)
            newData[i] = data[i];
        data = newData;
    }
	
	// 链表LinkedList
	// 头指针,尾指针
	Node head,tail;
	// 向链表尾部添加元素
    public void addLast(E e) {
        if (tail == null) {
            tail = new Node(e);
            head = tail;
        } else {
            Node newNode = new Node(e);
            tail.next = newNode;
            tail = newNode;
        }
        size++;
    }

​ 上述只展示和核心部分的代码,更详细的可以到我的GitHub中查看,具体地址为:https://github.com/mcrwayfun/java-data-structure下的19-Arrays-Compare-LinkedList

​ 现在对Array和LinkedList分别进行10万和1000万次addLast操作。因为两者的开销均为O(1),从理论上来说花费的时间应该一致,但结果并不是如此。

// 10W
Array add 10万次 time cost :0.0902896
LinkedList add 10万次 time cost :0.05899271
// 1000W
Array add 1000万次 time cost :23.43166362
LinkedList add 1000万次 time cost :50.80542133

​ 进行10W次操作时,花费时间差不多(虽然ArrayList存在扩容的时间消耗,但其均摊复杂度仍为常数时间)。但是,1000W次操作,LinkedList插入操作花费是ArrayList的两倍。因为LinkedList插入时会执行new操作,这将导致额外的时间消耗。

你可能感兴趣的:(java,数据结构)