死磕算法第一弹——数组、集合与散列表

本文整理来源 《轻松学算法——互联网算法面试宝典》/赵烨 编著java

数组

自我解读

数组是一堆数据按照顺序放入的固定长度空间。面试

  1. 数组的长度固定,因此在声明时须要指定数组长度。若是长度不够用,也没有什么办法,想要继续存放数据,只能从新声明一个数组空间。
  2. 数据只可以按顺序访问,虽然开发时能够经过下标直接访问指定位置元素,可是实际上计算机在处理时也是按照顺序访问的

使用场景

固定的长度的情景下,例如游戏中的快捷键等。算法

升级版数组——集合

数组的致命缺点是固定长度,若是没法对长度进行预估。这时候就须要采用集合,其实集合也是基于数组实现的。数据库

集合的概念比较宽泛,如下主要指可变长度列表(也叫动态数组)编程

  • 列表:通常是有序的集合,特色就是有顺序,好比链表、队列、栈等。
  • 集:通常是无序的集合,特色就是没有顺序切数据不能重复,多数语言是使用散列表实现,支持对集进行添加、删除、查找包含等操做。
  • 多重集:通常是无需的集合,特色是没有顺序,可是数据能够有重复值,只是对集进行添加、删除、查找包含、查找一个操做在集合的个数等操做。多重通常能够经过排序转换成列表。
  • 关联数组:其实多数语言是使用散列表实现的,就是经过键(key)获取到值(value)。一样是没有顺序的。
  • 树、图:一样是集合。

集合的实现

以java中的ArrayList为例,它是一个数组的列表,其实就是数组的扩展,或者说是可变长度的数组。数组

具体的实现方式是内部存在一个数组,同时存在一个标记,标记标识着数组里放了多少数据。这样咱们往里面放数据时,就会知道数据应该放在内部数组的哪一个位置,可是这个数组也会存在长度限制,若是超过这个长度限制,内部会从新建立一个数组,将旧的数组复制到新的数组当中,这样就能够继续添加数据了。缓存

在外部,咱们就感受不到ArrayList是有长度限制的,内部已经处理好了。网络

public class ArrayList<T> {

    private static final int INITIAL_SIZE = 10;

    private int size;

    private Object[] array;

    public ArrayList() {
        array = new Object[INITIAL_SIZE];
    }

    public ArrayList(int initial) {
        if (initial <= 0) {
            initial = INITIAL_SIZE;
        }
        array = new Object[initial];
    }

    /**
     * 添加元素
     *
     * @param data 新元素
     */
    public void add(T data) {
        if (size == array.length) {
            array = Arrays.copyOf(array, size * 2);
        }
        array[size++] = data;
    }

    /**
     * 获取指定位置的元素值
     *
     * @param i 指定位置
     * @return 元素值
     */
    @SuppressWarnings(value = "unchecked")
    public T get(int i) {
        if (i > size) {
            throw new IndexOutOfBoundsException("获取元素的位置超过了最大长度");
        }
        return (T) array[i];
    }

    /**
     * 修改指定位置的元素值
     *
     * @param i    指定位置
     * @param data 新的值
     * @return 旧的值
     */
    public T set(int i, T data) {
        T oldData = get(i);
        array[i] = data;
        return oldData;
    }

    /**
     * 获取变长数组的长度
     *
     * @return 数组长度
     */
    public int size() {
        return size;
    }
}

集合的特色

集合的特色和实现有关,那就是变长。边长是相对而言的,内部仍是根据数组实现的,只是在不够长时根据必定的策略生成一个更长的数组,把旧的数组中的数组复制到新的数组中使用。数据结构

正常状况下会有两个系统开销:一个是数组老是比咱们实际使用的长度长,因此存在空间浪费;拎一个是当数组长度不够长时,须要新建一个更长的数组,同时把旧数组的数据复制到新数组中,这个操纵比较消耗系统性能。编程语言

集合的适用场景

集合的使用场景不少。如今基本上全部的批量查询及得到必定条件的数据列表,都使用变长数组去存储返回的结果。好比查询某游戏中的一个瓦加包囊里的全部物品,若不清楚物品的数量,则会用变长数组去存储返回的结果。

博客的文章列表、评论列表等,只要涉及列表,就会有集合的身影。

变长数组的查询效率很低,全部须要使用一些复杂的数据结构,帮助咱们完成更高效的算法实现。

数组与变长数组的性能

虽然集合这个变长数组比普通数组高级一些,可是本质仍是基于数组实现的,因此与数组的性能差很少。

对数组的操做,计算机须要根据咱们提供具体操做的位置,从头至尾一个一个地寻找到指定位置,因此在数组中增长元素、修改元素、获取元素等操纵的时间复杂度都为O(n)。

变长数组也有性能损耗的问题,在插入元素时发现其中的固定长度不够,则须要新建更长的数组,还要复制元素,都会形成性能的损耗。

散列表

集合实际上是不少种,散列表也算集合中的一种。实际上顺序的存储是按照一个一个地按顺序访问元素,当这个总量很大且咱们所要访问的元素比较靠后,性能就会很低。

散列表是一种空间换时间的存储结构,是在算法中提升效率的一种比较经常使用的方式,可是所须要空间太大也会让人头疼,因此一般是二者之间权衡。

什么是散列表

试想一下,咱们要在手机通信录中查询一我的,咱们不多会从第1我的开始往下一直找,由于这样太慢了。咱们常常作的事情是根据这我的的名字首字母去查询。好比姓张,那么咱们必定会滑到最后,由于“Z”姓的名字都在最后。

还有在查字典的时候,要查找一个单词,确定不会从第一个开始查,而是从这个单词的首字母,找到以后再找第2个字母、第三个字母以此类推,这样就能够快速跳到哪一个单词所在页。

其实这就用到散列表的思想。

散列表,幼教哈希表(Hash Table),可以经过给定的关键字的值去直接访问到具体的对应值的一个数据结构。也就是说,把关键字映射到一个表中的位置来直接访问记录,以加快访问速度。

一般咱们把关键字称为Key,把对应的记录称为Value,因此也能够说是经过Key访问一个映射表来获得Value的地址。而这个映射表,也叫做散列函数或哈希函数,存放记录的数组叫作散列表。

其中有个特殊状况,就是经过不一样的Key,可能访问到同一个地址,这种现象叫作碰撞(Collision)。而经过某个Key必定会获得惟一的Value地址。

目前,这个哈希函数比较经常使用的实现方法比较多,一般须要考虑几个因素:关键字的长度、哈希表的大小、关键字的分布状况、记录的查找频率,等等。

下面简单介绍几种哈希函数:

  1. 直接寻址法:取关键字或关键字的某个线性函数值为散列地址。
  2. 数字分析法:经过对数据的分析,发现数据中冲突较少的部分,并构造散列地址。例如学号,一般同一届学生的学号,其中前面的部分差异不太大,因此用后面部分来构造散列地址。
  3. 平方取中法:当没法肯定关键字里哪几位的比较平均时,能够先求出关键字的平方值,而后按须要取平方值的中间极为做为散列地址。这是由于:计算平方以后的中间极为和关键字中的每一位都相关,因此不一样的关键字会以较高的几率产生不一样的散列地址。
  4. 取随机数法:使用一个随机函数,取关键字的随机值做为散列地址,这种方式一般用于关键字的长度不一样的场合。
  5. 除留取余法:取关键字被某个不大于散列表表长n的数m除后所得的玉树p为散列地址。这种方式也能够在用过其余方法以后在使用。该函数对m的选择很重要,通常取素数或者直接用n。

对散列表函数产生冲突的解决办法

散列表为何会冲突?是由于不一样的key经过哈希函数可能会产生相同的地址。

开放地址法(开放寻址法)

实际上就是当须要存储时,对Key进行哈希以后,发现这个地址已经有值了,这时改怎么办?不能放在这个地址,否则以前的映射就会被覆盖。这是计算出来的地址进行探测再哈希,好比日后移动一位,若是没有人占用,就用这个地址。若是超过长度,则对总长度取余。这里移动的地址就是产生冲突时的增列序量

再哈希

在产生冲突以后,使用关键字的其余部分继续计算地址,若是仍是有冲突,则继续使用其余部分再计算地址。这种方式的缺点是时间增长了。

链地址法

链地址法其实就是对Key经过哈希以后落到同一个地址上的值,作一个链表。其实在不少高级语言的实现中,也是使用这种方式处理冲突的。

创建一个公共溢出区

这种方式是创建一个公共溢出区,当地址存在冲突时,把新的地址房子公共溢出区里。

散列表的存储结构

一个好的散列表设计,除了须要选择一个性能较好的哈希函数,不然冲突时没法避免的,因此一般还须要一个好的冲突解决方式。

举例,采用除留取余法做为哈希函数、链地址法做为解决冲突的方式。

这里以Key的类型为整型数字为例,用Key对长度8取余,一定会等到很大的冲突,这时么个地址并不放真正的值,而是记录一个链表的起始地址。固然,在实际状况下咱们不会让这个哈希表这么短,只是简单举例子。

经过这种方式,咱们能够快速地知道Key所对应的Value在哪一个地址上,若是这个地址的链表比较长,则也须要一个一个地去检索。

一般状况下,新增元素若是遇到冲突,那么链表会有两种的方式去插入元素。

  • 一种方式是直接把新元素的下一个元素指向原来链表的第1个元素,而后把刚刚对应上的那个地址链表头的下一个元素指向新建的元素。这种方式的好处是在插入元素时会比较快,由于不须要遍历链表,而是直接改变头部的指向关系。
  • 另外一种方式是使链表元素有序,这种方式的劣势就是在每次插入元素时须要遍历链表,在中间打开链表插入元素。

这里以整数为例,对于其余类型不少编程语言都具备必定的实现方式。好比Java中的每一个对象都有个hashCode方法,经过获取字符串的这个方法,就能够将一个字符串轻松的转换成整型。固然这种方法还可能返回负数,这也是能够直接使用绝对值解决的。

散列表的特色

散列表有两种用法:一种是Key的值与Value的值同样,通常咱们称这种状况的结构为Set(集合);而若是Key和Value所对应的内容不同时,那么咱们称这种状况为Map,也就是人们俗称的键值对集合。

根据散列表的存储结构,咱们能够得出散列表的如下特色。

  1. 访问速度很快:因为散列表有散列函数,能够将制定的Key都用射到一个地址上,因此在访问一个Key(键)对应的Value(值)时,根本不须要一个一个地进行查找,能够直接跳到那个地址。因此咱们在对散列表进行添加、删除、修改、查找等操做时,速度都很快。
  2. 须要额外的空间:首先,散列表其实是存不满的,若是一个散列表恰好可以存满,那么确定是一个巧合。并且当散列表中的元素的使用率愈来愈高时性能就会降低,因此通常会选择扩容来解决这个问题。另外,若是有冲突的话,则也须要额外的空间去存储的,好比链表地址法,不但须要额外的空间,甚至还须要使用其余的数据结构。这个特色很经常使用的表达,叫作“空间换时间”,在大多数的时候,对于算法的实现,为了可以更好的性能,每每会考虑牺牲些空间,让算法可以更快些。
  3. 无序:散列表有个很是明显的特色就是无序。为了更快的访问元素,散列表是根据散列函数直接找到存储地址的,这样咱们的访问速度就可以更快,可是对于有序访问就没有办法应对了。
  4. 可能会产生碰撞:没有完美的散列函数,不管如何总会产生冲突,这是就须要采用冲突解决方案,这也使散列表更加复杂。一般在不一样的高级语言的实现,对于冲突的解决方案不必定同样。

散列表的适用场景

根据散列表的特色能够想到,散列表比较适合无序、须要快速访问的状况

缓存

一般咱们在开发程序的时候,对于一些经常使用的信息会作存储,用的就是散列表。好比咱们要缓存用户信息,通常用户信息都会有惟一表示的字段,好比ID。这时作缓存,能够把ID做为Key,而Value用来存储用户的详细信息,这里的Value一般是一个对象,包含用户的一些关键字段,好比名字、年龄等。

在咱们每次须要获取一个用户信息是,就不用与数据库这类的本地磁盘存储交互了(大多数时候,数据库可能与咱们的服务不在一台机器上,还会有相应的网络性能损耗),能够直接从内存中获得结果。这样不只能够快速获取数据,也可以减轻数据库的压力。

快速查找

这里说的查找,不是排序,而是在集合中找到是否存在指定元素。

这种场景不少,好比咱们要在指定的用户列表中查找是否存在指定的用户,这时就可使用散列表了。在这种场景下使用的散列表其实就是上面提到的Set类型,实际上不须要Value这个值。

还有一个场景,咱们通常对网站的操做会有一个ip地址黑名单,咱们认为某些ip有大量非法操做,因而封锁了这个ip对咱们网站的访问。这个ip是如何存储的呢?就是使用散列表。当一个访问行为发送过来时,咱们会获取其ip,判断是否存在于黑名单中,若是存在,则禁止访问。这种状况也是使用Set。

以上两种状况使用列表也能够解决,当列表愈来愈长时,查找速度很慢。散列表则不会。

散列表性能分析

散列表的访问,若是没有碰撞,那么咱们彻底能够认为对元素的访问是O(1)的时间复杂度,由于对于任何元素的访问,均可以经过散列函数获得元素的值所在地址。

但其实是不可能没有碰撞,因此咱们不得不对碰撞进行必定的处理。

咱们经常使用链表的方式进行解决(固然,也有一些语言使用开放寻址方式解决,Java使用链表解决),因为可能会产生碰撞,而碰撞以后访问须要遍历列表,因此时间复杂度将变成O(L),其中L为链表的长度。固然,在大多数时候不必定会碰撞,而不少Key也不必定恰好碰撞到一个地址上,因此性能仍是很不错的。

若是可能分配的地址即散列表的元素大部分被使用了,这时再向散列表中添加元素,就会很容易产生碰撞了,甚至散列表分配的地址越日后使用,越容易被占用。这种状况下就可使用扩容

在使用散列表的时候,通常不会等到真的占满了才会去扩容,而是会提早扩容。这里设计一个扩容因子的术语(也叫作载荷因子),是一个小数,在使用散列表的过程当中,不会等到把全部地址都用完才去扩容,而是会在占用到地址达到散列表长度乘以扩容因子的这个值时去扩容,通常的扩容是在原有的基础上乘以2做为新的长度。

Java中扩容因子默认为0.75。

扩容的时候,除了简单的增长原来散列表的长度,还须要把以前那些因为碰撞而存放在一个地址的链表上的元素从新进行哈希运算,有可能以前存在碰撞的元素,如今就不会碰撞了。

public class HashTable<K, V> {

    /**
     * 默认散列表的初始长度
     * 设置小一点,这样咱们可以清楚看到扩容。
     * 实际使用过程当中是能够初始化传参的,扩容是很是损耗性能的
     */
    private static final int DEFAULT_INITIAL_CAPACITY = 4;

    /**
     * 扩容因子
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 散列表数组
     */
    private Entry[] table = new Entry[DEFAULT_INITIAL_CAPACITY];

    /**
     * 散列表元素的个数
     */
    private int size = 0;

    /**
     * 散列表使用地址的个数
     */
    private int use = 0;


    /**
     * 添加元素
     *
     * @param key   键
     * @param value 值
     */
    public void put(K key, V value) {
        int index = hash(key);

        if (table[index] == null) {
            table[index] = new Entry<K, V>(null, null, null);
        }
        Entry e = table[index];
        if (e.next == null) {
            //不存在值,向链表添加,可能扩容,要使用table属性
            table[index].next = new Entry<>(key, value, null);
            size++;
            use++;
            //不存在值,说明是个未用过的地址,须要判断是否须要扩容
            if (use >= table.length * LOAD_FACTOR) {
                resize();
            }
        } else {
            //自己存在值,修改已有的值
            for (e = e.next; e != null; e = e.next) {
                Object k = e.key;
                if (Objects.equals(k, key)) {
                    e.value = value;
                    return;
                }
            }
            //不存在相同的值,直接向链表中添加元素
            Entry temp = table[index].next;
            //往链表开头添加元素
            table[index].next = new Entry<>(key, value, temp);
            size++;
        }
    }

    /**
     * 删除
     *
     * @param key 键
     */
    private void remove(K key) {
        int index = hash(key);
        Entry e = table[index];
        Entry pre = table[index];

        if (e != null && e.next != null) {
            for (e = e.next; e != null; pre = e, e = e.next) {
                Object k = e.key;
                if (Objects.equals(k, key)) {
                    pre.next = e.next;
                    size--;
                    return;
                }
            }
        }
    }

    /**
     * 获取
     *
     * @param key 键
     * @return 值
     */
    @SuppressWarnings("unchecked")
    public V get(K key) {
        int index = hash(key);
        Entry e = table[index];
        if (e != null && e.next != null) {
            for (e = e.next; e != null; e = e.next) {
                Object k = e.key;
                if (Objects.equals(k, key)) {
                    return (V) e.value;
                }
            }
        }
        //若是没有找到,则返回null;
        return null;
    }

    /**
     * 获取散列表中的元素个数
     *
     * @return 元素个数
     */
    public int size() {
        return size;
    }

    /**
     * 非散列表该有的方法,只是为了让咱们知道确实扩容了
     *
     * @return 散列表长度
     */
    public int getLength() {
        return table.length;
    }

    /**
     * 扩容
     */
    @SuppressWarnings("unchecked")
    private void resize() {
        int newLength = table.length * 2;
        Entry[] oldTable = table;
        table = new Entry[newLength];
        use = 0;
        for (Entry anOldTable : oldTable) {
            if (anOldTable != null && anOldTable.next != null) {
                Entry e = anOldTable;
                while (null != e.next) {
                    Entry next = e.next;
                    //从新计算哈希值,放入新的地址中
                    int index = hash((K) next.key);
                    if (table[index] == null) {
                        use++;
                        table[index] = new Entry<K, V>(null, null, null);
                    }
                    Entry temp = table[index].next;
                    table[index].next = new Entry<>((K) next.key, (V) next.value, temp);
                    e = next;
                }
            }
        }
    }

    /**
     * 根据Key的hashCode,经过哈希函数获取位域散列表数组中的哪一个位置
     *
     * @param key 键
     * @return 散列值
     */
    private int hash(K key) {
        return key.hashCode() % table.length;
    }

    public class Entry<Key, Value> {
        Key key;
        Value value;
        Entry next;

        Entry(Key key, Value value, Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }
}

测试:

public class HashTableTest {

    @Test
    public void main() {
        HashTable<Integer,Integer> hashTable = new HashTable<>();
        hashTable.put(1,10);
        hashTable.put(2,20);
        //和key为1的元素落到了同一个散列表地址上了,实际是一个长度为2
        hashTable.put(5,50);
        //散列表长度为4
        Assert.assertEquals(4,hashTable.getLength());
        //总长度为4,添加上钙元素就大于等于3了,须要进行扩容
        hashTable.put(3,30);
        //散列长度为8
        Assert.assertEquals(8,hashTable.getLength());
        //在扩容后4个元素分别落在不一样的地址上
        //使用了第5个地址
        hashTable.put(6,60);
        //使用了第6个地址,为8的0.75倍,又须要扩容
        hashTable.put(7,70);
        Assert.assertEquals(16,hashTable.getLength());

        Assert.assertEquals(10, (int) hashTable.get(1));
        Assert.assertEquals(30,(int)hashTable.get(3));
        Assert.assertEquals(50,(int)hashTable.get(5));
        Assert.assertEquals(60,(int)hashTable.get(6));
    }

}
相关文章
相关标签/搜索