04-虚拟头结点,数组和链表的复杂度分析,动态缩容

1.虚拟头结点

1.观察一下原来的LinkedList中的addremove方法,里面都对index=0时做了特殊处理,说明在头结点执行这两个操作时有特殊情况。
在这里插入图片描述
2.为什么会对index=0时做特殊处理:

  • 因为我们插入时,要先找到插入位置的前一个节点;移除操作时,也需要先找到移除位置的前一个节点。但是index=0时,它是没有前一个节点的,所以我们要为index=0单独处理。

3.那么我们引入一个虚拟头结点,不存储数据,让它作为头结点。原来的头结点后移一位,但是还作为0节点,这样0节点和后面的节点就都一样了。我们不对虚拟头结点做add,remove等操作,那么后面节点的都可以统一处理,这样就能够统一所有节点的处理逻辑,让代码更加精简。

图示:

在这里插入图片描述
4.代码

  1. 那么链表创建出来就肯定需要有一个虚拟头结点
	//提供一个构造:初始化一个虚拟头结点;没有虚拟头结点的:直接用默认的构造
	public LinkedList2(){
    
    
		first = new Node<E>(null, null);
	}
  1. node()方法:第0个结点位于虚拟头结点的后一个节点
    在这里插入图片描述

  2. add()方法

    最后逻辑都是perNode.next = new Node<E>(element, perNode.next);统一的。

    注意:传进来index=0时,前一个节点就是虚拟头结点:注意first是一个指向虚拟头结点的引用

在这里插入图片描述

  1. remove()方法
    在这里插入图片描述
  2. toString()

在这里插入图片描述
5.无论有没有虚拟头结点,都是可以的。看自己习惯。

2.复杂度分析

3个维度:

  • 最好情况复杂度
  • 最坏情况复杂度
  • 平均情况复杂度

2.1.ArrayList

1.ge(int index)取(查)数据,时间复杂度:O(1)

	public E get(int index) {
    
    
		rangeCheck(index);
		return elements[index];
	}

为什么数组访问元素elements[index]的时间复杂度是O(1):

  • 因为写下这个语句时,编译器其实已经帮我们计算好了这个元素的地址值(位置)。
  • 怎么计算:数组的首地址+index * 4字节。

所以数组根据索引访问元素的时间复杂度是O(1),哪个下标都是O(1),和下标无关:即随机访问(存取:因为定位很快)非常快

2.set(int index, E element)存(改)数据,时间复杂度:O(1)

	public E set(int index, E element) {
    
    
		rangeCheck(index);
		
		E old = elements[index];
		elements[index] = element;
		return old;
	}

3.add(int index, E element)增加元素

	public void add(int index, E element) {
    
    
		rangeCheckForAdd(index);
		//确保容量足够:查看容量是否足够,不够就扩容
		ensureCapacity(size + 1);
		for(int i = size; i > index; i--) {
    
    
			elements[i] = elements[i-1]; 
		}
		elements[index] = element;
		size++;
	}
  • 先忽略扩容的时间复杂度
  • 注意:O(n)中的n,是指时间复杂度,不一定是参数。
  • 那么动态数组中谁才是数据规模n?—>size = n。一般容器的数据规模,都是其已存放的元素的数量size。
  • 前面的get/set和size无关,所以没有考虑size。而且永远都是O(1),没有最好,最坏,平均,和数据规模无关。

分析:添加元素需要挪动

  • 最好情况;插入到最后,不需要挪动位置:O(1)
  • 最坏情况:插入到头部,移动size个元素:O(n)
  • 平均情况:插入到任何位置,平均移动(1+2+…+n)/n个元素:O(n/2) --> O(n)

4.remove(int index)删除元素

	public E remove(int index) {
    
    
		rangeCheck(index);
		
		E old = elements[index];
		//index后的元素前移
		while (index < size-1) {
    
    
			elements[index] = elements[index+1];
			index++;
		}
		
		elements[--size] = null;
		return old;
	}

删除元素也是要多动元素的:

  • 最好情况;不需要挪动位置:O(1)
  • 最坏情况:移动size个元素:O(n)
  • 平均情况:平均移动(1+2+…+n)/n个元素:O(n/2) --> O(n)

5.ArrayList复杂度分析小结:

  • 改set,查get:复杂度是O(1)
  • 增add,删remove:平均复杂度O(n)

2.2.LinkedList

1.get(int index)查/取元素:平均复杂度O(n)

	@Override
	public E get(int index) {
    
    
		return node(index).element;
	}
	//返回索引位置处的节点
	private Node<E> node(int index) {
    
    
		rangeCheck(index);
		Node<E> node = first;
		for (int i = 0; i < index; i++) {
    
    
			node = node.next;
		}
		return node;
	}

分析:

  • get调用了node()方法,所以要分析node()方法的复杂度。
  • 数据规模仍然是size。

那么找元素和数据规模有什么关系:

  • 最好情况:找第一个,找一次,O(1)
  • 最坏情况:找最后一个,找size次,O(n)
  • 平均情况:平均找找(1+2+…+n)/2,找size/2次,O(n)

2.set(int index, E element)存/改元素:平均复杂度O(n)

	@Override
	public E set(int index, E element) {
    
    
		Node<E> oldNode = node(index);
		E oldElement = oldNode.element;
		oldNode.element = element;
		return oldElement;
	}

分析:调用了node()方法,所以要分析node()方法的复杂度。和get()一样的复杂度情况

  • 最好情况:找第一个,找一次,O(1)
  • 最坏情况:找最后一个,找size次,O(n)
  • 平均情况:平均找找(1+2+…+n)/2,找size/2次,O(n)

3.add(int index, E element)增加元素:平均复杂度O(n)

	@Override
	public void add(int index, E element) {
    
    
		rangeCheckForAdd(index);
		//插到头部:注意first是一个Node引用,指向第一个节点
		if(index == 0) {
    
    
			first = new Node<E>(element, first);
		}else {
    
    
			Node<E> perNode = node(index-1);
			perNode.next = new Node<E>(element, perNode.next);
			//perNode.next = newNode;
		}
		size++;
	}

分析:

  • 最好情况:直接添加到头部,不用查找添加的位置(直接用head节点),O(1)。
  • 最坏情况:添加到最后,要查找最后一个节点(插入位置),O(n)。
  • 平均情况:平均找找(1+2+…+n)/2,找size/2次,O(n)。

4.remove(int index)删除元素:平均复杂度O(n)

	@Override
	public E remove(int index) {
    
    
		rangeCheck(index);
		Node<E> oldNode = null;
		if(index == 0) {
    
    
			oldNode = first;
			first = first.next;
		}else {
    
    
			rangeCheck(index);
			Node<E> preNode = node(index-1);
			oldNode = preNode.next;
			preNode.next = preNode.next.next;
		}
		size--;
		return oldNode.element;
	}

分析:

  • 最好情况:不用查找位置(直接用head节点),O(1)。
  • 最坏情况:要查找最后一个节点,O(n)。
  • 平均情况:平均找找(1+2+…+n)/2,找size/2次,O(n)。

注意:

  • 一些教材上说的,链表的添加和删除操作的复杂度是O(1),这个是什么意思?
  • 指的是:不考虑查找添加/删除的位置的复杂度,只单独考虑删除和添加操作的那一刻。
  • 但是封装出来的对外的那个接口,整体的平均时间复杂度不是O(1),是O(n)。

但是链表有一个好处:

  • 节省内存,用一个创建一个。数组是一下申请一大块,可能会造成浪费。

5.链表LinkedList时间复杂度小结:增删改查都是O(n)

2.3两者复杂度比较

在这里插入图片描述
数组的时间复杂度更低,链表更省内存。

2.4.均摊复杂度

1.分析一下ArrayList中的add()的复杂度

	public void add(E element) {
    
    
		add(size, element);
	}
  1. 每次都是直接添加到数组末尾,那么不需要移动元素,所以复杂度是O(1)。

  2. 如果数组数量不够,需要扩容呢?

    扩容因为涉及到复制元素到新申请的空间中,所以复杂度是O(n)
    在这里插入图片描述

  3. 所以最好情况时间复杂度O(1);最坏情况时间复杂度O(n);那么平均时间复杂度呢?
    O(n/2)肯定是不合适的,因为决定大部分情况都不会扩容,都是O(1)。

  4. 均摊复杂度:假设一个数组的最大容量是4,那么前面4次添加的操作都是1次,最后一次扩容的时间操作是4次。那么就可以这样理解:因为前面4次的积累导致了,扩容的发生,所以前面4次添加,相当于,每次的操作都是2次,也就是O(1)复杂度。

  5. 所以平均复杂度O(1)。

  6. 什么情况下适合使用均摊复杂度呢?
    经过连续的多次复杂度比较低的情况后,出现个别复杂度比较高的情况。

3.动态数组的缩容

1.分析一下这种情况:

动态数组在使用过程中会慢慢的扩容,当扩容到很大时,此时又容量又满了,又进行了一次扩容。大小新增了一般,但是只添加了1个元素。而且后期没有再添加元素了,那么就有将近一半的空间没有使用。

或者当数组容量非常大时,我们又进行了一系列的删除操作/清空操作clear。之后有没有再向数组中添加元素。那么数组还是有许多空间美欧利用到。

所以,在内存使用比较紧张的情况下,动态数组有比较多的剩余空间,可以考虑进行动态缩容操作。

2.删除操作时,判断一下是否要进行缩容。比如剩余空间占总容量的一半时,并且总容量大于10时,就进行缩容,缩成原来的一半。

在这里插入图片描述

代码:

	/**
	 * 动态缩容
	 */
	private void trim() {
    
    
		int capacity = elements.length;
		int newCapacity = capacity >> 1;
		//已有元素达到数组容量的一半,或者容量不超过10,不用缩容
		if(size >= newCapacity || capacity <= DEFAULT_CAPACITY) return;
		
		//否则剩余空间还很多,需要进行缩容
		E[] newElements = (E[])new Object[newCapacity];
		for (int i = 0; i < size; i++) {
    
    
			newElements[i] = elements[i];
		}
		elements = newElements;
		System.out.println("缩容了: "+"旧容量,"+capacity+"新容量,"+newCapacity);
	}

3.复杂度震荡:

如果扩容倍数,缩容时机(条件)设计不得当,有可能导致复杂度震荡。

比如扩容为原来的2倍,已有元素小于或等于数组容量的一半时缩容:那么扩容后立马执行删除操作,就又会执行缩容。那么在这个时候,反复进行添加,删除操作,就会一直进行扩容,缩容操作,一直都是O(n)的复杂度。

怎么避免:扩容倍数和缩容的时机相乘不等于1,即可。

猜你喜欢

转载自blog.csdn.net/tttxxl/article/details/115226679