数据结构复习

数据结构定义

描述数据之间一种或多种特定关系的元素的集合。

数据结构分类

数据结构分别为逻辑结构、存储结构(物理结构)和数据的运算

逻辑结构:根据数据元素间关系的不同特性,通常有下列四类基本的结构

  • 集合结构。该结构的数据元素间的关系是“属于同一个集合”Java中的Map接口的实现类HashMap、HashTable等
  • 线性结构:数据元素之间存在着一对一的关系如ArrayList、LinkedList
  • 树形结构:数据元素之间存在着一对多的关系
  • 图形结构:数据元素之间存在着多对多的关系,也称网状结构

存储结构:

数据结构在计算机中的表示(映像)称为数据的物理(存储)结构。

它包括数据元素的表示和关系的表示。

数据元素之间的关系有两种不同的表示方法:顺序映象和非顺序映象,并由此得到两种不同的存储结构:顺序存储结构和链式存储结构。

顺序存储方法:它是把逻辑上相邻的结点存储在物理位置相邻的存储单元里,结点间的逻辑关系由存储单元的邻接关系来体现,由此得到的存储表示称为顺序存储结构。顺序存储结构是一种最基本的存储表示方法,通常借助于程序设计语言中的数组来实现如ArrayList。

链接存储方法:它不要求逻辑上相邻的结点在物理位置上亦相邻,结点间的逻辑关系是由附加的指针字段表示的。由此得到的存储表示称为链式存储结构,链式存储结构通常借助于程序设计语言中的指针类型来实现如ListkedList

下面分别了解下几种数据结构的实现

1.线性结构

  1. ArrayList

底层用数组实现,顺序存储结构,元素间关系是按顺序存储,物理存储位置相邻,元素间逻辑关系由存储单元的邻接关系体现。

初始化时默认容量是10

	/**
	 * Default initial capacity.
	 */
	private static final int DEFAULT_CAPACITY = 10;

扩容

判断容量不足以使用,minCapacity是Arraylist的大小:

{
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)//判断是否需要扩容
            grow(minCapacity);
}

扩容:

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);新数组扩容到原来的1.5
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)//判断是否需要扩容
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

扩容办法,创建一个新的数组,

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)//如果扩容后比list的size+1小
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)//如果扩容后比当前数组支持的最大尺寸大
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:


    elementData = Arrays.copyOf(elementData, newCapacity);
}

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

add方法添加元素:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
	//在数组最后添加一个元素
    elementData[size++] = e;
    return true;
}

public void add(int index, E element) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

    ensureCapacityInternal(size + 1);  // Increments modCount!!

	//将elementData中的元素从 index索引位置开始的size-index个数据 copy到elementData数组中,开始索引是index+1

	//其实就是将index开始到结尾的数据 向后平移一个位置。
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
	//由于平移完index位置空出来了,然后把要添加的元素添加进去,这个过程,发生了平移所以跟链表比比较耗费性能。
    elementData[index] = element;
    size++;
}

remove方法删除元素:

//根据元素删除
public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
		//遍历直到找到元素
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

//根据索引删除
public E remove(int index) {
    if (index >= size)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

    modCount++;
    E oldValue = (E) elementData[index];

    int numMoved = size - index - 1;
    if (numMoved > 0)
		//位置移动
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

优点:长度可变,内部会扩容,有最大长度限制,根据索引能快速定位相应的元素,故支持高效的随机访问。也就是查询比较快。

缺点:

①添加元素:由于底层是数组实现,当在中间或者前面插入数据的时候后面的元素会发生移动。

②删除元素
根据索引删除位置会移动,根据元素的值删除需要先遍历所有元素然后还会发生移动

  1. LinkedList

底层使用链表实现,它不要求逻辑上相连的元素,物理存储也相连,元素间存储关系通过指针实现。

添加元素通过断开之前的链接,将指针指向下一个元素即可:

查找需要遍一半的容器大小所以没有ArrayList查找速度快:

Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

优点:

插入和删除比ArrayList快

缺点:

查询需要遍历,然后一个接一个直到取到目标索引,随机访问比ArrayList差。

2.集合结构

2.1 HashMap

基于数组和链表实现,是一个链表散列的数据结构,用于存储键值对,允许空值空键,只允许一个因为每次都是添加到头部即索引为0的位置。

HashMap 底层就是一个数组结构,数组中的每一项又是一个链表

HashMap的实现原理:

HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据 hash 算法来决定其在数组中的存储位置,在根据 equals 方法判断是否是key相等,如果是则替换原来元素,如果不等,判断是否是树类型,如果是执行树的添加方法,如果不是把当前元素添加到链表的最后面;当需要取出一个Entry 时,也会根据 hash 算法找到其在数组中的存储位置,再根据 equals 方法从该位置上的链表中取出该Entry。

元素的存储:

①判断当前位置没有元素,就把要添加的元素存在这里

②如果hash相同,并且key也相同,或者通过equals方法判断值一样,更新元素

③如果当前元素是treeNote类型,添加到红黑树

④如果链表中元素 个数达到8个修改为红黑树

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
		//当table是null初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
		//1.判断当前位置没有元素,就把要添加的元素存在这里
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
			//2.如果hash相同,并且key也相同,或者通过equals方法判断值一样,更新元素
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)//3.如果当前元素是treeNote类型,添加到红黑树
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
				
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
						//4.将新元素添加到链表的末尾。
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) //如果链表中元素 个数达到8个修改为红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
  }

//初始化,扩容,调用put方法时,1-3是初始化步骤,4-7是扩容步骤

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {//4.原hashmap有值
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; //5.newCap 新数组容量变为原来 ,新newThr临界点变为原来的两倍
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;//1.初始化hashmap容量为16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
		//初始化需要扩容的临界值
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;//计算临界值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;//2.更新这个全局的临界值
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;//3.初始化一个容量为newCap的数组,默认是16
    if (oldTab != null) {//6.如果不是初始化,原map里有数据
        for (int j = 0; j < oldCap; ++j) {//7.对数组遍历
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {//如果索引j对应的元素不是null
                oldTab[j] = null;//清空原数组里的元素,回收资源
                if (e.next == null)//7.1如果链表里只有一个元素,直接放到新数组中
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)//7.2如果元素是红黑树
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 7.3如果 数组对应位置存放的是链表
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
//因为hashcode的第n位是0/1的概率相同, 理论上链表的数据会均匀分布到当前下标或高位数组对应下标。
                    do {

                        next = e.next;
						//e.hash oldCap是0 和1 的概率各位50%
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;//当前下标
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
						//高位数组对应下标
                    }//这是1.8使用HashMap为什么用&得到下标,而不是% 如果使用了取模%,除了&的速快一些,还在于将原链表的元素分散到新数组两个位置
                }
            }
        }
    }
    return newTab;
}

碰撞产生及解决
通过key生成的hashcode相同,所以元素存储的的bucket位置相同,‘碰撞’会发生,这时候会通过equse检查key是否相同,若相同则替换原来的元素,若不同则添加到链表的尾端。

Fail-Fast 机制:

我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了 map,那么将抛出 ConcurrentModificationException,这就是所谓 fail-fast 策略

判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:

注意到 modCount 声明为 volatile,保证线程之间修改的可见性。

负载因子:

负载因子 loadFactor 衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

当哈希表的数据个数超过负载因子和当前容量的乘积时, 哈希表要再做一次哈希(重建内部数据结构), 哈希表每次扩容为原来的2倍。

负载因子平衡了时间和空间复杂度。 负载因子越大会降低空间使用率,但提高了查询性能(表现在哈希表的大多数操作是读取和查询)

扩容

HashMap的的扩容大体上分为三步:

  1. 确定新Node数组的容量,以及更新扩容临界点的值
  2. 根据新的容量创建新数组
  3. 遍历原数组,将原数组元素取出,放入新的数组中,这一步值得注意的是,当原数组对应位置以链表形式存储了多个元素时,会把这些元素分散存储到原位置 和原位置高位的位置。

如果两个键的hashcode相同,你如何获取值对象?

通过key生成的hash值,找到元素在bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象

你了解重新调整HashMap大小存在什么问题吗?

多线程的情况下,可能产生条件竞争(race condition)

重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?:)

为什么String, Interger这样的wrapper类适合作为键?

因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

为了实现一个尽量分布均匀的hash函数,利用的是Key值的HashCode来做某种运算。因此问题来了,如何进行计算,才能让这个hash函数尽量分布均匀呢?

一种简单的方法是将Key值的HashCode值与HashMap的长度进行取模运算,即 index = HashCode(Key) % hashMap.length,但是,但是!这种取模方式运算固然简单,然而它的效率是很低的, 而且,如果使用了取模%, 那么HashMap在容量变为2倍时, 需要再次rehash确定每个链表元素的位置,浪费了性能。
因此为了实现高效的hash函数算法,HashMap的发明者采用了位运算的方式。那么如何进行位运算呢?可以按照下面的公式:

为什么HashMap初始值是16,扩容需要是2倍?

index = hash(Key) & (hashMap.length - 1);

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

上面是计算index 的代码

答:我们知道index的值是根据key的hash值与上数组长度减一出来的,
如果是小于16的值做与运算时有些index结果的出现几率会更大,而有些index结果永远不会出现7(比如0111)这不符而均匀分布的设计理念。

接下来我们以Key值为“apple”的例子来演示这个过程:

计算“apple”的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。

HashMap默认初始长度是16,计算hashMap.Length-1的结果为十进制的15,二进制的1111。

把以上两个结果做 与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。

可以看出来,hash算法得到的index值完全取决与Key的HashCode的最后几位。这样做不但效果上等同于取模运算,而且大大提高了效率。

那么回到最初的问题,初始长度为什么是16或者2的次幂?如果不是会怎么样?

我们假设HaspMap的初始长度为10,重复前面的运算步骤:

单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 101110001110101110 1011 :

然后我们再换一个HashCode 101110001110101110 1111 试试 :

这样我们可以看到,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!

所以这样显然不符合Hash算法均匀分布的原则。

而长度是16或者其他2的次幂,Length - 1的值的所有二进制位全为1(如15的二进制是1111,31的二进制为11111),这种情况下,index的结果就等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。这也是HashMap设计的玄妙之处。

为什么获取下标时用按位与&,而不是取模%?
一方面与运算速度较快,另一方面在扩容时,可以把链表元素分散到原位置和高位数组对应下标,这也是jdk1.8做的优化。

你知道hash的实现吗?为什么要这样实现?

在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的 (h = k.hashCode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit
都参与到hash的计算中,同时不会有太大的开销。

如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。

HashMap与HashTable的区别

  1. HashMap允许存在一个空键值对,而HashTable不允许
  2. HashMap是非线程安全的,HashTable通过synchronized对象锁实现线程安全,效率低
  3. 不保证有序(比如插入的顺序)、也不保证序不随时间变化。(由于HashMap允许null键,所以通过if(key == null))的方式,并不能判断某个key键是否存在,而是用containesKey(key)的方式)

如何解决HashMap的线程安全问题?

  1. 同步方法封装对hashmap的调用
  2. 如果不封装Hashmap, 可以使用Collections.synchronizedMap 方法调用HashMap实例。在创建HashMap实例时避免其他线程操作该实例,即保证了线程安全。
  3. 可以使用ConcurrentHashMap
2.2 HashTable

相当于给HashMap方法加上同步锁

2.3 LinkedHashMap

LinkedHashMap 是 HashMap 的一个子类,有序的HashMap,它保留插入的顺序,如果需要输出的顺序和输入时的相同,那么就选用 LinkedHashMap

2.4 ConcurrentHashMap

ConcurrentHashMap 的成员变量中,包含了一个 Segment 的数组(final Segment<K,V>[] segments;),而 Segment 是 ConcurrentHashMap 的内部类,然后在 Segment 这个类中,包含了一个 HashEntry 的数组(transient volatile HashEntry<K,V>[] table;)。而 HashEntry 也是 ConcurrentHashMap 的内部类。HashEntry 中,包含了 key 和 value 以及 next 指针(类似于 HashMap 中 Entry),所以 HashEntry 可以构成一个链表。

所以通俗的讲,ConcurrentHashMap 数据结构为一个 Segment 数组,Segment 的数据结构为 HashEntry 的数组,而 HashEntry 存的是我们的键值对,可以构成链表。

首先,我们看一下 HashEntry 类。

在实际的应用中,散列表一般的应用场景是:除了少数插入操作和删除操作外,绝大多数都是读取操作,而且读操作在大多数时候都是成功的。正是基于这个前提,ConcurrentHashMap 针对读操作做了大量的优化。通过 HashEntry 对象的不变性和用 volatile 型变量协调线程间的内存可见性,使得 大多数时候,读操作不需要加锁就可以正确获得值。这个特性使得 ConcurrentHashMap 的并发性能在分离锁的基础上又有了近一步的提高。

ConcurrentHashMap 是一个并发散列映射表的实现,它允许完全并发的读取,并且支持给定数量的并发更新。相比于 HashTable 和用同步包装器包装的 HashMap(Collections.synchronizedMap(new HashMap())),ConcurrentHashMap 拥有更高的并发性。在 HashTable 和由同步包装器包装的 HashMap 中,使用一个全局的锁来同步不同线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。这虽然保证多线程间的安全并发访问,但同时也导致对容器的访问变成串行化的了。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

路漫-其修远兮

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值