算法刻意练习之数组/链表/跳表

算法刻意练习之数组/链表/跳表_第1张图片

1 数组

1.1 数组和链表区别?

1.1.1 简单描述

(1)数组:优点,支持随机访问,根据下标随机访问的时间复杂度为 O(1)。缺点,插入、删除一个数据,为了保证内存数据的连续性,就需要做大量的数据搬移工作

(2)链表:优点,适合插入、删除,时间复杂度 O(1)。缺点,数据并非连续存储的,要根据指针一个结点一个结点地依次遍历,直到找到相应的结点,查找时间复杂度是 O(n)

算法刻意练习之数组/链表/跳表_第2张图片

1.1.2 深入描述

(1)数组由于是紧凑连续存储,相对节约存储空间。可以随机访问,通过索引快速找到对应元素,时间复杂度0(1)。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度O(N) 。而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度0(N)

(2)链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题。如果知道某元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度0(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问,查找元素的时间复杂度0(N);而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间

1.2 特点

(1)申请数组时Memory Controller在内存中给你开辟一块连续的内存地址
算法刻意练习之数组/链表/跳表_第3张图片

(2)通过访问Memory Controller来访问数组,复杂度是O(1)

(3)可以随机访问任何一个元素;

(4)问题在于 插入 和 删除 频繁的情况下, 数组不好用。因为插入和删除需要挪动元素O(1)或O(n), 然后给数组最后一位设置为空来触发GC、O(n)

1.2 演示

(1)增加元素图示
算法刻意练习之数组/链表/跳表_第4张图片
(2)删除元素图示
算法刻意练习之数组/链表/跳表_第5张图片
算法刻意练习之数组/链表/跳表_第6张图片

1.3 时间复杂度

算法刻意练习之数组/链表/跳表_第7张图片

2 链表

1.1 特点

(1)链表标准实现
算法刻意练习之数组/链表/跳表_第8张图片
算法刻意练习之数组/链表/跳表_第9张图片
(2)next 指向下一个元素,多个元素串到一起就像类数组的结构;

(3)只有一个指针叫 单链表两个指针叫 双向链表;头指针用 Head 表示,尾指针用 Tail 表示;最后一个元素的 next 指向 None;如果最后一个元素的 next 指向 Head 就叫 循环链表

算法刻意练习之数组/链表/跳表_第10张图片
(4)双向链表、双端(双向)链表、循环(双向)链表 示意(简)图
算法刻意练习之数组/链表/跳表_第11张图片

(5)应用场景:LRU Cache - Linked list
https://www.jianshu.com/p/b1ab4a170c3c、https://leetcode-cn.com/problems/lru-cache

1.2 演示

(1)增加节点
算法刻意练习之数组/链表/跳表_第12张图片
(2)删除节点
算法刻意练习之数组/链表/跳表_第13张图片

1.3 时间复杂度

算法刻意练习之数组/链表/跳表_第14张图片

3 跳表

3.1 特点

(1)跳表(skip list)对标的是平衡树(AVL Tree)和二分查找,是一种 插入/删除/搜索 都是 O(log n) 的数据结构。

(2)只能用于元素有序的情况

(3)应用场景:Redis - Skip List
https://redisbook.readthedocs.io/en/latest/internaldatastruct/skiplist.html、https://www.zhihu.com/question/20202931

3.2 如何给链表加速

(1)普通链表时间复杂度:查询 O(n),贯穿所有数据结构与算法的中心思想,务必记住:1、升维度;2、空间换时间

(2)

算法刻意练习之数组/链表/跳表_第15张图片
算法刻意练习之数组/链表/跳表_第16张图片
算法刻意练习之数组/链表/跳表_第17张图片

3.3 时间/空间复杂度

算法刻意练习之数组/链表/跳表_第18张图片
算法刻意练习之数组/链表/跳表_第19张图片

2 Collection

2.1 List

(1)概念
算法刻意练习之数组/链表/跳表_第20张图片

  • AbstractList 是一个抽象类,它实现List接口并继承于 AbstractCollection 。对于“按顺序遍历访问元素”的需求,使用List的Iterator 即可以做到,抽象类AbstractList提供该实现;而访问特定位置的元素(也即按索引访问)、元素的增加和删除涉及到了List中各个元素的连接关系,并没有在AbstractList中提供实现(List 的类型不同,其实现方式也随之不同,所以将具体实现放到子类);
  • AbstractSequentialList 是一个抽象类,它继承于 AbstractList。AbstractSequentialList 通过 ListIterator 实现了“链表中,根据index索引值操作链表的全部函数”。此外,ArrayList 通过 System.arraycopy(完成元素的挪动) 实现了“顺序表中,根据index索引值操作顺序表的全部函数”;

ArrayList, LinkedList, Vector, Stack是List的4个实现类(都允许包括null):

  • ArrayList 是一个动态数组。它由数组实现,随机访问效率高,随机插入、随机删除效率低;
  • LinkedList是一个双向链表(顺序表)。LinkedList 随机访问效率低,但随机插入、随机删除效率高。可以被当作堆栈、队列或双端队列进行操作;
  • Vector是矢量队列和ArrayList一样,它也是一个动态数组,由数组实现。但ArrayList是非线程安全的,效率高;而Vector是线程安全的,效率较低;
  • Stack是栈,它继承于Vector。它的特性是:先进后出(FILO, First In Last Out)。

(2)特征

  • Java 中的 List 是对数组的有效扩展,它是这样一种结构:如果不使用泛型,它可以容纳任何类型的元素,如果使用泛型,那么它只能容纳泛型指定的类型的元素。和数组(数组不支持泛型)相比,List 的容量是可以动态扩展的;
  • List 中的元素是“有序”的。这里的“有序”,并不是排序的意思,而是说我们可以对某个元素在集合中的位置进行指定,包括对列表中每个元素的插入位置进行精确地控制、根据元素的整数索引(在列表中的位置)访问元素和搜索列表中的元素;
  • List 中的元素是可以重复的,因为其为有序的数据结构;
  • List中常用的集合对象包括:ArrayList、Vector和LinkedList,其中前两者是基于数组来进行存储,后者是基于链表进行存储。其中Vector是线程安全的,其余两个不是线程安全的;
  • List中是可以包括null的,即使使用了泛型;
  • List 接口提供了特殊的迭代器,称为 ListIterator,除了允许 Iterator 接口提供的正常操作外,该迭代器还允许元素插入和替换,以及双向访问。

(3)参考链接
Java Collection Framework : List

2.1 Set

(1)概念
Set:无顺序,不包含重复的元素
(2)实现类包括

  • HashSet:为快速查找设计的Set。存入HashSet的对象必须定义hashCode()。
  • TreeSet: 保存次序的Set, 底层为树结构。使用它可以从Set中提取有序的序列。
  • LinkedHashSet:具有HashSet的查询速度,且内部使用链表维护元素的顺序(插入的次序)。于是在使用迭代器遍历Set时,结果会按元素插入的次序显示。

2.1 ArrayList

(1)实质
ArrayList底层采用Object类型的数组实现,当使用不带参数的构造方法生成ArrayList对象时,实际上会在底层生成一个长度为10的Object类型数组。是一个动态数组,其容量能自动增长,随着向ArrayList中不断添加元素,其容量也自动增长,自动增长会带来数据向新数组的重新拷贝,原数组会被回收。
  
(2)数组的特点

  • 其三个不同的构造方法。无参构造方法构造的ArrayList的容量默认为10,带有Collection参数的构造方法,将Collection转化为数组赋给ArrayList的实现数组elementData。

  • 用size来标识容器里的元素个数,size()是指“逻辑”长度,是指内存已存在的"实际元素的个数"而“空元素不被计算”。

  • 每个ArrayList实例都有一个容量(Capacity),是ArrayList里的数组的大小,即ArrayList开辟的内存的大小。例如ArrayList默认构造的容量为10,Capacity等于10。elementData是私有的未被序列化的”Object[] 类型的数组”,用来存储ArrayList的对象列表,它的大小等于Capacity,会根据ArrayList容量的增长而动态的增长。 android 获取ArrayList的Capacity,arraylist capacit

  • 自动增加ArrayList大小的思路:ArrayList在每次增加元素时,都要调用扩充容量的方法ensureCapacity来确保足够的容量。当容量不足以容纳当前的元素个数时,就设置新的容量为旧的容量的1.5倍加1,如果设置后的新容量还不够,则直接新容量设置为传入的参数。而后用Arrays.copyof()方法将元素拷贝到新的数组,原数组会被回收。所以,当容量不够时,每次增加元素,都要将原来的元素拷贝到一个新的数组中,非常耗时;因此在事先能确定元素数量的情况下,才使用ArrayList,否则建议使用LinkedList

  • ArrayList基于数组实现,可以通过下标索引直接查找到指定位置的元素,因此查找效率高,但每次插入或删除元素,就要大量地移动元素,插入删除元素的效率低

  • 在查找给定元素索引值等的方法中,源码都将该元素的值分为null和不为null两种情况处理,ArrayList中允许元素为null

(3)为什么ArrayList数据增长是1.5倍加1?

int newCapacity = (oldCapacity * 3)/2 + 1; 

ArrayList初始化默认为10,当第第10个被赋值时list是不会增长长度的,因为此时不需要开辟新的内存。当第11个时将执行会新开辟内存 (if (minCapacity > oldCapacity) 为真 ),因为包含第11个,所以新增后数组长度则为:10+10/2+1,但是实际上未赋值的为5。也就是说每次开辟新内存时,都需要多开辟1为当前赋值用。

(4) ensureCapacity()中为什么要用到oldData[]

Object oldData[] = elementData;  //为什么要用到oldData[](后面并没有用到oldData)

跟elementData = Arrays.copyOf(elementData, newCapacity),在Arrays.copyOf 的实现时,新创建了newCapacity大小的内存,然后把老的elementData放入。这样,由于旧的内存的原引用是elementData, 而elementData指向了新的内存块,如果有一个局部变量oldData变量引用旧的内存块的话,可以证明这块老的内存依然有引用,分配内存的时候就不会被侵占掉,在copy的过程中就会比较安全。然后,在copy完成后,这个局部变量的生命周期也过去了,此时释放才是安全的。否则的话,在copy的时候万一新的内存或其他线程的分配内存侵占了这块老的内存,而copy还没有结束,这将是个严重的事情。
  
(5)trimToSize()调整数组容量(减少容量):将底层数组的容量调整为当前列表保存的实际元素的大小
在使用ArrayList过程中,由 elementData的长度会被拓展,所以经常会出现size很小但elementData.length很大的情况,造成空间的浪费。 ArrayList通过trimToSize方法返回一个新的数组给elementData ,其中:元素内容保持不变,length 和size 相同。

public void trimToSize() {  
   modCount++;  
   int oldCapacity = elementData.length;  
   if (size < oldCapacity) {  
       elementData = Arrays.copyOf(elementData, size);  
   }  
}

(6)为什么不能在ArrayList的For-Each循环中删除元素
在使用迭代器时,迭代器使用Fail-Fast错误检测机制,Collection的结构发生变化,抛出ConcurrentModificationException 。当然,这并不能说明 Collection对象已经被不同线程并发修改,因为如果单线程违反了规则,同样也有会抛出该异常。正确做法是:ConcurrentModificationException应该仅用于检测bug。
   为什么不能在ArrayList的For-Each循环中删除元素

private class Itr implements Iterator {  
        int expectedModCount = ArrayList.this.modCount;  
        public E next() {  
            checkForComodification();  
            /** 省略此处代码 */  
        }  
        public void remove() {  
            checkForComodification();  
        }  
        final void checkForComodification() {  
            if (ArrayList.this.modCount == this.expectedModCount)  return;  
            throw new ConcurrentModificationException();  
        }  
    }  

For-Each遍历(隐式迭代器遍历)正是基于这个迭代器的hasNext()和next()方法来实现的;迭代器在调用 next() 、 remove() 方法时都是调用checkForComodification()方法,“modCount != this.expectedModCount”,触发 fail-fast 机制,抛出ConcurrentModificationException异常。expectedModCount是在Itr中定义的,所以它的值是不可能会修改的,所以会变的就是modCount。通过RemoveRange 、trimToSize和ensureCapcity() 三个方法完成对ArrayList结构上的修改,modCount 的值就递增一次,源码如下:

public boolean remove(Object o) {
        if (o == null) {
            //....
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);//fastRemove()
                    return true;
                }
        }
        return false;
}
private void fastRemove(int index) {
        modCount++;//modCount在递增
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
 }

所以,使用For-Each遍历,调用list.remove()使得modCount++,报ConcurrentModificationException异常:

//For-Each遍历--隐式Iterator:报异常
for (String temp : list) {
  if ("4".equals(temp)) {
	  list.remove(temp);
  }
}
//数据删除不完整
for (int i = 0; i < list.size(); i++) {
  String temp = list.get(i);
  if ("3".equals(temp)) {
	  list.remove(temp);
  }
}
//显示Iterator,正确删除
Iterator itr = list.iterator();  
while (it.hasNext()) {  
   String temp = itr.next();  
   if ("3".equals(temp)) {
	   itr.remove();
	}
}

而当我们使用CopyOnWriteArrayList时,我们直接使用增强型for循环遍历删除即可,此时使用iterator遍历删除反而会出现问题(与ArrayList不同,由于CopyOnWriteArrayList的iterator是对其List的一个“快照”,因此是不可改变的,所以无法使用iterator遍历删除)。
  
(7)并发下的ArrayList出现问题,解决方案

//add方法可能出现数组容量检测的并发问题
public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;
    return true;
}
  • 数组不需要扩容时,线程1:size=n,size=n+1,elementData[n+1] = e;线程2:size=n,size=n+1,elementData[n+1] = e;多线程同时调用add方法的时候会出现元素覆盖的问题。

  • 数组容量检测的并发问题:新建一个ArrayLis内部数组容器的容量为默认容量10,当我们用两个线程同时添加第10个元素的时候。线程1先执行插入9,让size==10;线程2由于数组容量为10,而此操作往index为11的位置设置元素值,因此会抛出数组越界异常。并发下的ArrayList错误分析
    ArrayList源码和多线程安全问题分析

  • 采取更换的容器为:Vector和CopyOnWriteArrayList。CopyOnWriteArrayList它规避了只读操作(如get/contains)并发的瓶颈,但是它为了做到这点,在修改操作中做了很多工作和修改可见性规则。 此外,修改操作还会锁住整个List,因此这也是一个并发瓶颈。所以CopyOnWriteArrayList并不算是一个通用的并发List。
    【Java 高并发】并发下的ArrayList&&HashMap

(8)使用Collections.sort抛出ConcurrentModificationException异常,原因和具体分析参考以下内容:
Java 8 Collections.sort 新实现
java.util.ConcurrentModificationException异常排查

我们的解决方案:

(9)Arrays.copyof()和System.arraycopy()方法
Arrays.copyof()方法实际上是在其内部又创建了一个长度为newlength的数组,调用System.arraycopy()方法,将原来数组中的元素复制到了新的数组中。
System.arraycopy()方法。该方法被标记了native,调用了系统的C/C++代码,在JDK中是看不到的,但在openJDK中可以看到其源码。该函数实际上最终调用了C语言的memmove()函数,因此它可以保证同一个数组内元素的正确复制和移动,实现效率要更高,很适合用来大批量处理数组。

public static  T[] copyOf(T[] original, int newLength) {  
    return (T[]) copyOf(original, newLength, original.getClass());  
}
/**
 * original:源数组  newLength:目标数组的长度 newType:目标数组的类型
 */
public static  T[] copyOf(U[] original, int newLength, Class newType) {  
    T[] copy = ((Object)newType == (Object)Object[].class)  
        ? (T[]) new Object[newLength]  
        : (T[]) Array.newInstance(newType.getComponentType(), newLength);  
    System.arraycopy(original, 0, copy, 0,  Math.min(original.length, newLength));  
    return copy;
}

(10)参考链接
Java Collection Framework : List

ArrayList源码剖析.md

2.2 LinkedList

(1)概念
LinkedList是List接口的双向链表实现,不是线程安全的。LinkedList实现了List中所有可选操作,并且允许元素包括null。除了实现List 接口外,LinkedList为在列表的开头及结尾进行获取(get)、删除(remove)和插入(insert)元素提供了统一的访问操作,而这些操作允许LinkedList作为Stack(栈)、Queue(队列)或Deque(双端队列:double-ended queue)进行使用。
(2)结构图
算法刻意练习之数组/链表/跳表_第21张图片

(2)学习链接
在我的另一篇博客:数据结构与算法之LinkedList源码分析

(3)并发问题分析
当多个线程同时获取到相同的尾节点的时候,然后多个线程同时在此尾节点后面插入数据的时候会出现数据覆盖的问题。例如:在linkLast(E e)时候,last即尾节点共享资源,当多个线程同时执行此方法的时候,进行重复的插入出现数据覆盖。LinkedList源码和并发问题分析

(3)ArrayList与LinkedList区别

  • ArrayList内部是使用可自动增长的动态数组实现的,所以是用查询消耗小,但是如果插入元素和删除元素开销会很大,因为里面需要数组的移动。
  • LinkedList是使用双链表实现的,所以查询会非常消耗资源,但是插入和删除元素消耗小。

2.3 Vector

(1)概念
  Vector也是基于数组实现的,类似ArrayList,是一个动态数组,其容量能自动增长。很多实现方法都加入了同步语句,因此是线程安全的(其实也只是相对安全,有时候还是要加入同步语句来保证线程的安全),可以用于多线程环境。
(2) 参考链接
Vector源码剖析.md

(3)总结(Vector的源码实现总体与ArrayList类似)

  • Vector有四个不同的构造方法。无参构造方法的容量为默认值10,仅包含容量的构造方法则将容量增长量明置为0。
  • 注意扩充容量的方法ensureCapacityHelper。与ArrayList相同,Vector在每次增加元素时,都要调用该方法来确保足够的容量。当容量不足以容纳当前的元素个数时,就先看构造方法中传入的容量增长量参数CapacityIncrement是否为0,如果不为0,就设置新的容量为旧容量+容量增长量如果为0,就设置新的容量为旧的容量的2倍如果设置后的新容量还不够,则直接设置新容量为传入的参数(所需的容量),而后同样用Arrays.copyof()方法将元素拷贝到新的数组。
  • 很多方法都加入了synchronized同步语句,来保证线程安全
  • 同样在查找给定元素索引值等的方法中,源码都将该元素的值分为null和不为null两种情况处理,Vector中也允许元素为null。
  • 其他很多地方都与ArrayList实现大同小异,Vector现在已经基本不再使用。

你可能感兴趣的:(数据结构与算法)