线性数据结构总结

数组

数组是一种线性数据结构。建立数组时会在内存中划分出一块连续的内存区域,数据会保存在这块连续区域的每块索引。java

数组支持的操做

查询(get(int index))

首先说明经过下标获取元素的时间复杂度为O(1)。由于数组是一块连续的内存区域,而且每一个元素的大小都相等,经过一个线性方程就很快能找到改下标对应的内存地址。例如若是是一个int数组,0位置的内存地址为b,index是你要找的下标,那么显然你要获取的内存地址就是b+4*index,4就是一个int占的字节数。因此计算机经过b+type_size*index来计算下标。固然,并非全部的数组都是如此计算下标的,例若有的虚拟机在开辟数组空间时并非开辟的连续空间。node

插入(add(int index,E e))

增长时若是要指定增长到的数组下标通常将要对数组元素进行移动,例若有一个十个元素的整型数组,要将一个数字A插入到第一个元素的位置,那么就须要先将全部元素从后往前向后移动一个位置再将A插入到第一个位置。因此数组的插入操做平均时间复杂度为O(n)算法

删除(remove(int index))

和插入同样,指定下标进行删除。例若有十个元素,删除第一个元素,那么就须要将数组元素从当前元素由前至后向前移一个下标。因此数组的删除操做平均时间复杂度为O(n)数组

修改(set(int index,E e))

修改就比较简单了,直接获取下标改变当前元素的值便可浏览器

动态数组

不少高级语言都有数组这个基本结构,可是在使用他们的时候若是咱们增长的元素超过一开始定义它的总个数的话是没办法继续添加的。因此,当咱们一开始不知道这个数组的大小时这就比较麻烦了,咱们就须要本身定义动态数组,咱们不须要管他的初始容量。bash

实现本身的数组

实现动态数组主要须要重写数组的增长、删除操做,还要实现扩容操做数据结构

增长

本身实现的增长操做和原来的区别就是要判断是否须要将数组扩容。扩容的条件为当数组的长度和元素的个数相同时就须要扩容,通常扩容为两倍。 扩容的步骤:app

  • 将原数组复制一份并让大小为原数组的2倍
  • 将原数组的全部元素写入到新数组中,并增长新添加的元素
删除

本身实现的删除操做和原来的区别就是要判断是否须要将数组缩容,缩容是有必要的。缩容的条件比扩容的条件多一点,就是缩容前的数组大小要大于等于2,而且当前元素个数为数组大小的一半。 缩容的步骤:函数

  • 先将原数组的元素删除
  • 将原数组复制一份并让大小为原数组的1/2倍
  • 将原数组的全部元素写入到新数组中

扩容,缩容产生的震荡

产生的震荡和咱们的扩容,缩容条件有关,若是按照上面的条件进行扩容和缩容,那么若是这个数组的元素个数若是在8和9之间徘徊,那么数组的大小就会在8,16中徘徊。测试

而震荡的解决方法和具体的需求有关,咱们能够将缩容的条件改成"缩容前的数组大小要大于等于2,而且当前元素个数为数组大小的1/4"

java.util.ArrayList中如何扩容

扩容确定在

//新增方法
    public boolean add(E e) {
        //modCount是记录修改次数的(迭代器判断结构是否变),迭代器fail-fast机制就靠它
        modCount++;
        //elementData是数组元素,size是数组大小
        add(e, elementData, size);
        return true;
    }
    
    private void add(E e, Object[] elementData, int s) {
        //数组大小和数组元素个数相等,要扩容了
        if (s == elementData.length)
            elementData = grow(s+1);
        elementData[s] = e;
        size = s + 1;
    }
    
    private Object[] grow(int minCapacity) {
        //能够看到也是经过复制一个新数组
        return elementData = Arrays.copyOf(elementData,
                                           newCapacity(minCapacity));
    }
    
    //接下来就是如何真正实现扩容的!!!
    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        //新大小是老大小的1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        //若是新大小比oldCapacity+1小
        if (newCapacity - minCapacity <= 0) {
            //若是数组是空的,第一次添加元素,就直接扩容到10和minCapacity中大的那个
            if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
                return Math.max(DEFAULT_CAPACITY, minCapacity);
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return minCapacity;
        }
        //返回大的那个,不超过最大
        return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
    }
复制代码

能够看到扩容的思路仍是很简单的,本人环境是jdk1.9,至于其余版本的扩容思路我想都差很少。至于缩容,这里就再也不阐述了,arraylist的源码仍是比较容易理解的。

链表

链表和数组都是线性结构,可是链表是一个一个的节点组成的,这个节点有一个next指针指向下一个节点,也就是说链表不须要连续的数组空间。如图:

image

链表的操做

下面来看一下如何对链表进行增删改查

增长

增长的操做时间复杂度为O(1),不用像数组同样去移动数组元素,如A->C,增长B到A的后面 增长步骤:

  • 将A的next指针保存为temp
  • 将A的next指针指向B
  • 将B的next指向temp

删除

删除的时间复杂度为O(1),不用像数组同样去移动数组元素,如A->B->C,删除B 删除步骤:

  • 获取变量temp保存节点B
  • 将A的next指针指向C(free掉B)

查询

查询的时间复杂度为O(n),由于链表不像数组能够直接经过下标计算出内存地址。因此必须经过遍历找到相应下标节点。

这里的增长和删除时间复杂度为O(1)很好理解,咱们可能会得出一个结论那就是数组的查询修改效率高,链表的删除增长效率高。但实际上这也是分状况的。当咱们在对一个节点进行插入或删除时咱们要去遍历到指定位置(由于咱们只有头节点地址或者尾节点地址)。以前作过一个测试,对于java中的结构LinkedList(双向链表)和ArrayList(双向链表),在增长大概一百万个元素的时候会发现数组的增长方法效率更高,由于在指定位置对链表添加元素链表要去遍历。

虚拟头节点:最后,通常链表都会有一个头节点,这个节点指向链表的首节点,这个头节点的用处是当咱们将删除最后一个元素的时候不用专门判断删除只有一个元素的状况

栈,队列

栈和队列很类似,因此结合起来一块儿看。

  • 栈是一个FILO(先进后出)的结构,它能够由数组或链表实现。常见的例如在浏览器浏览网页,在当前网页进入另外一个网页至关于入栈,返回至关于出栈。
  • 队列是一个FIFO(先进先出)的结构,它能够由数组或链表实现。队列的应用也很广,例如排队。

基于数组的栈实现

结构:数组array,top变量

数组用来保存进入的数据。top指针指向最后一个元素的下一个位置,若是栈为空top指向0。

举个例子:

image
如上图

  • 栈空:当栈为空的时候top指向0下标
  • 栈满:当元素满了后top==array.length;
  • 入栈:若是有元素入栈array[top++]=入栈元素
  • 出栈:当元素要出栈时return array[top--];

基于链表的栈实现

结构:top指针,节点

  • 节点中有data,next指向下一个节点
  • top指针用来指向栈最上面的节点

如图:

image

  • 栈为空:top指向null
  • 入栈:构造新节点node,新节点node的next指向top指针的指向,top指针指向新节点node
  • 出栈:用node保存top指向的节点,top指针指向node的next指针指向的节点,返回node

队列

基于链表的队列

结构:虚拟头节点,节点

  • 虚拟头节点:front指针指向第一个元素,rear指针指向最后一个元素
  • 节点:data数据元素,next下一个节点指向

如图:

image

  • 空队列:front和rear都指向null
  • 入队:构造新节点,rear指针的next指针指向新节点
  • 出队:判断为非空,保存front指向的节点node,front指向node的next节点,返回node

基于数组的队列

基于数组的队列和基于数组的栈有一些不一样。能够想到,当数组队列入队时能够增长到数组后面,出队时将前面的元素移出,那么就会出现问题就是前面出队的元素会变为不可用但又无法用,有一种解决方式能够在出队的时候把后面的元素放在前面,就像前面动态数组那样删除首元素便可,可是若是是这样每出队一次就总体移动一次元素未免也太耗时了一些。因此这里引入循环队列。

理解循环队列

循环队列要解决的问题就是数组队列浪费空间的问题。循环队列并非在物理地址上是循环的,而是在逻辑上循环的。

结构:数组,front指向头,rear指向队尾的后一位元素

front=front%array.length

如图:

image

  • 图中有两个指针front、rear,front表明队头下标,rear表明队尾下标。
  • 队列为空:front和rear都指向一个位置
  • a,b,c入队,队头下标front不变,rear(rear=(rear+1)%array.length)指向最后元素的下一个坐标
  • a出队,front指向下一个元素(front=(front+1)%array.length)
  • 最后当增长到如图d2那样(rear+1)%array.length=front时表明元素已满

要注意的点

  1. 入队时,rear=(rear+1)%array.length
  2. 出队时,front=(front+1)%array.length
  3. 在循环队列中至关于咱们浪费了一个元素位置,这样作的好处是咱们能够区别开空队和满队的区别。
  4. 空队列:front==rear
  5. 满队列:(rear+1)%array.length==front,这个时候就须要扩容了
  6. 扩容:扩容的步骤和动态数组的逻辑类似,只不过要从新给front和rear赋值

哈希表

当咱们想在学校想找到某我的的信息,咱们会向教务处去查询学号,而教务处得到你提供的学号就会给你一个学生的信息。这里经过学号得到学生信息就是用了哈希表的思想,而学号和学生信息的对应关系就是哈希函数,而若是两个学号对应到了同一个学生信息就是哈希冲突。

因此接下来来看一下哈希表中的名词:

  • 哈希表:经过给定的关键字的值直接访问到具体对应的值的一个数据结构
  • 哈希函数:将给定关键字转化为内存地址索引
  • 哈希冲突:不一样的关键字经过哈希函数转化为同一个索引

哈希表做用

哈希表的查找时间复杂度为O(1),哈希表查找的思路是直接经过关键字查找到元素。增长删除的时间复杂度也是O(1),也就是说哈希表

哈希表的结构

底层有一个Node数组array,当前元素个数M

  • 这个Node有key(键),value(值),next(下一个Node节点)。
  • M为当前array中有的元素数量

哈希函数

由于咱们要实现对于不一样的key尽量的经过哈希函数得出不一样的值。因此对于哈希函数的选取是比较重要的。

如下为常见哈希函数:

  • 直接寻址法:取关键字或关键字的某个线性函数值为散列地址。如H(key)=key或H(key) = a?key + b。a,b为常数。
  • 数字分析法:经过对数据的分析,发现数据中冲突较少的部分,并构造散列地址。例如身份证号,咱们能够提取出身份证号中关键的数字,例如表示出生年月和后六位。
  • 平方取中法:取关键字平方后的中间几位做为散列地址。
  • 取随机数法:使用一个随机函数,取关键字的随机值做为散列地址,这种方式一般用于关键字长度不一样的场合。
  • 除留取余法:H(key) = key MOD p。key为关键字,MOD为取余操做,p为哈希表的长度,p最好为质数,能够达到最小可能的哈希冲突

接下来咱们将哈希函数都设置为除留取余法进行分析。

哈希冲突

哈希冲突就是不一样的两个key,他们hash(key)以后获得的结果相同。对于哈希冲突的解决也有多种

  • 链地址法:若A通过hash以后肯定在数组的下标为2,B通过hash以后肯定数组下标也为2,B就跟在A以后造成链表。这时整个哈希表就造成了数组+链表的结构。
  • 开放地址法:
    • 线性探测:当发现哈希冲突时将下标分别进行+1直到下标位置没有元素再放入
    • 平方探测:当发现哈希冲突时将下标分别进行1^2, 2^2, 3^2...直到下标位置没有元素再放入
    • 伪随机序列法:当发现冲突就将key随机加一个数再取模,直到下标位置没有元素再放入
  • 再哈希法:这种方法是同时构造多个不一样的哈希函数,若是第一个哈希冲突了就用第二个哈希函数,以此类推

接下来咱们将哈希冲突解决方式设置为链地址法进行分析。

哈希表操做过程

增长操做

例如用户要增长一个K,V键值对,先将K的哈希值计算出来,再将哈希值对数组长度取模得到下标,若是下标没有元素,就将K,V封装成Node放入这个下标,若是下标已经有元素,就K,V封装成Node加入到这个下标元素的最后面。

查找操做

例如一个用户要经过K查找这个节点,经过hash(K)再取模获得这个数组的下标,若是这个下标没有元素,表明查找失败,若是这个下标有元素,那就从这个元素向后面的链表遍历,有就返回。

删除和修改逻辑相似

HashMap的实现方式

java中的HashMap是封装的很是好的一个哈希表,经过分析它的实现也可让咱们更完善本身的哈希表设计

咱们来看一下它有哪些关键字段

//负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //哈希表中的数组
    transient Node<K,V>[] table;
    
    //数组中每一个元素保存的下面这个节点
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        //..一些节点其余的方法
        
    }
复制代码

接下来来看一个put操做(增长操做)的流程。

//增长一个key,value
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    //采用的hash算法
    static final int hash(Object key) {
        int h;
        //能够看到这里hash算法用的key的hashCode和h右移16位的异或运算
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    //最后调用的putVal操做
    /**
     * hash是传来的哈希值
     * key是键,value是值
     * onlyIfAbsent为false表明多个key会重写
     * evict为false表明表处于建立状态
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //若是一开始hashmap没有元素的话初始化hashmap大小
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //没有哈希冲突的话直接构造新的链表节点添加进数组中,i为计算出的下标
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //接下来是有哈希冲突的状况
        else {
            Node<K,V> e; K k;
            //这里计算出下标的节点的哈希值要等于以前传过来计算好的哈希值。而且要引用同一个对象而且equals方法也要相等!!这里表示判断为同一个对象的逻辑!!我曾经在这里踩过坑。。其次,若是两次的key都为null的话
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //这里不是同一个对象而且是TreeNode结构(当链表节点个数大于等于8的时候会转化为红黑树)
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //接下来正常添加链表节点
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //若是value变换以后这里会返回老的value
            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;
    }
复制代码

关于HashMap中的put操做,有如下几点注意的地方

  • 若是传来的key为null,putVal仍是按常规操做进行添加,同上面的逻辑同样,而且得出来的哈希值为0
  • 如何判断两个key是否相等,简单来讲就是两个键hashCode和equals必须同时相同或者说保持一致。好久以前我踩过的一个坑就是两个对象他们的hashCode返回的相同值,而equals却返回false,致使添加的时候老是会添加两个元素,事实上这也是个人设计出错,一个类最好保证重写的hashCode和quals方法可以一致

下一篇会总结树结构数据类型,而且下一篇会把本身实现的数据类型分享出来

相关文章
相关标签/搜索