散列表

作者: yifyif | 来源:发表于2018-08-05 18:45 被阅读122次

    转载请注明出处!https://www.jianshu.com/p/e325578eb512

    链表实现 Github源码
    数组实现 Github源码


    如果你需要一个简单而不失优雅的无序数据表,那么散列表一定是你的首选。

    在我们分布较好(近似均摊)的散列表内,查找、插入等操作一般在常数级别

    所有的数据结构都是在时间与空间上作出了平衡选择,而散列表则非常好的找到了平衡点。

    散列表的难度比较简单,核心难度在于如何设计出一个优秀的散列函数,可以使我们均匀的分布每个键值。幸运的是,Java已经为我们准备好了不少常用的数据类型(Integer, String, Double, File, URL等)的散列函数——hashCode()

    散列函数

    对于散列表而言,所有存储的键值,不论是什么类型的数据,都要通过散列函数转化为数组中的索引实现存储。而如何设计优秀散列函数,一直是算法专家和数学专家的问题。我们在这里仅提供一种简单思路。

    • 假设散列表大小为M长度的整数数组,则每个存储的键值都应该分布在 0~M-1的数组区间。

    假设键值的散列值为正整数k,则我们可以将k % M,那么我们得到的就是分布在0~M-1的某个值了。

    • hashCode()返回的是一个32位比特整数,一般为内存地址经算法产生。
        private int hash(Key key) {
            if (key == null) throw new IllegalArgumentException();
            return ((key.hashCode() & 0x7fffffff) % M);
        }
    

    上述代码是一个简单的散列函数,其中0x7fffffff是最大的正整数(无符号32位 0111 1111 1111 1111 1111 1111 1111 1111),通过&0x7fffffff,可以得到一个去符号的31位整数,然后再与M取余即可得到键的hash索引。

    • 有时候hash索引会产生重复,处理好小整数M内的重复情况是关键。

    软缓存

    对于有些散列函数,在处理过程中比较耗时,我们可以采用临时变量存储计算后的hashCode(),Java中的String就是如此处理的。

    散列表实现——链表

    链表实现即为每个键所转化的索引放入一个M大小的数组中,再将索引重复的键值以链表的方式存储在每个数组内。(数组嵌套链表)

    插入示意图

    假设我们这里有一个顺序存放数据的链表SequentialSearchST,拥有get(key), put(key,value), delete(key)几个常用的方法,具体代码参见我的Github源码

    构造 constructor:

    public class SeparateChainingHashST<Key, Value> {
        private int N;//键值总对数
        private int M;//散列表大小
        private SequentialSearchST<Key, Value>[] st;//存放链表
    
        public SeparateChainingHashST() {
            this(997);//默认构造器
        }
    
        public SeparateChainingHashST(int M) {
            this.M = M;
            st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[M];
            for (int i = 0; i < M; i++)
                st[i] = new SequentialSearchST();
        }
    
        private int hash(Key key) {
            if (key == null) throw new IllegalArgumentException();
            return ((key.hashCode() & 0x7fffffff) % M);
        }
    }
    

    查找 get:

        public Value get(Key key) {
            if (key == null) throw new IllegalArgumentException();
            if (isEmpty()) throw new NoSuchElementException();
            return (Value) st[hash(key)].get(key);
        }
    

    插入 put:

        public void put(Key key, Value value) {
            if (key == null) throw new IllegalArgumentException();
            if (value == null) {
                delete(key);
            } else {
                //动态调整散列表的大小
                if (N >= 10 * M) resize(2 * M);
                if (!st[hash(key)].contains(key)) N++;
                st[hash(key)].put(key, value);
            }
        }
    

    动态调整散列表的大小,这里的调整临界值非固定,这里达到1/10就调整只是为了较为均衡的分布利用空间。

    动态调整 resize:

    动态调整散列表的大小不是必须的,但如同数组的动态变换大小一样,这样可以优化内存的空间利用,减少重复copy次数带来的开销。

        private void resize(int chains) {
            SeparateChainingHashST<Key, Value> temp = new SeparateChainingHashST(chains);
            for (int i = 0; i < M; i++) {
                Iterator<Key> iterator = st[i].keys().iterator();
                while (iterator.hasNext()) {
                    Key key = iterator.next();
                    Value value = st[i].get(key);
                    temp.put(key, value);
                }
            }
            M = temp.M;
            N = temp.N;
            st = temp.st;
        }
    

    删除 delete:

    public void delete(Key key) {
            if (key == null) throw new IllegalArgumentException();
            if (isEmpty()) throw new NoSuchElementException();
            if (get(key) == null) throw new NoSuchElementException();
            if (st[hash(key)].contains(key))
                N--;
            st[hash(key)].delete(key);
            if (M > 4 && N <= 2 * M) resize(M / 2);
        }
    

    散列表实现——数组

    实现散列表的另一种方式是通过数组来完成,用大小为M 的数组和N 的键值对(M > N)来存储。其中当键值对重复时,我们需要使用数组中的空位来解决冲突。这种方法也被称为开放地址散列表

    当碰撞发生时(某键的散列值已被使用),则我们直接向下寻找位置:

    • 命中,键值相同。
    • 未命中,键值为空(没有键),此时可执行插入。
    • 继续向下,该键与当前键不同。

    开放地址散列表的核心思想是与其将内存用作链表(与链表实现对比),不如将他们作为散列表中的空元素,这些空元素可以作为查找结束的标志。

    插入实现过程

    我们在维护键对应的值的时候,采用并行数组来解决,一条保存键,一条保存值。

    public class LinearProbingHashST<Key, Value> {
        private int N;      //total number of item
        private int M;      //Size of map , M > N
        private Key[] keys;
        private Value[] values;
    
        public LinearProbingHashST() {
            this(16);//default construct
        }
    
        public LinearProbingHashST(int cap) {
            this.M = cap;
            this.N = 0;
            keys = (Key[]) new Object[M];
            values = (Value[]) new Object[M];
        }
    
        private int hash(Key key) {
            if (key == null) throw new IllegalArgumentException();
            return ((key.hashCode() & 0x7fffffff) % M);
        }
        ...
    }
    

    查找 get:

        public Value get(Key key) {
            if (key == null) throw new IllegalArgumentException();
            if (isEmpty()) throw new NoSuchElementException();
            for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
                if (keys[i].equals(key)) {
                    return values[i];
                }
            }
            return null;
        }
    

    由于是使用数组实现,所以查找比较简单,开销很小,最差情况也能在 O(logN) 内能完成。

    插入 put:

        public void put(Key key, Value value) {
            if (key == null) throw new IllegalArgumentException();
            if (N >= M / 2) resize(2 * M);
            if (value == null) delete(key);
            int i;
            for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
                if (keys[i].equals(key)) {
                    values[i] = value;
                    return;
                }
            }
            keys[i] = key;
            values[i] = value;
            N++;
        }
    

    插入的时候,要注意散列表大小最好需要动态变化。这里为了保证使用的内存量和键值对数量的比例在某一范围内,采用1/2。

    删除 delete:

    1. 先查找到键的位置
    2. 将键赋空删除
    3. 如果删除的键后面没有键(不连续),则删除操作结束。
    4. 如果删除的键后面连续还有键,则需要将后面的连续键向前移动一格位置。

    前三步不难理解,那么第四步这么做的原因,主要是因为如果不将后面的键向前移动位置使之保持连续,那么在之后的查找过程中会出现错误。
    比如(见上图)我们查找H,此时C已被删除。若我们不向前移动H,那么查找操作将会返回不存在H,因为H实际存储在7的位置(散列值为4)。

        public void delete(Key key) {
            if (key == null) return;
            if (isEmpty() || !contains(key)) throw new NoSuchElementException();
            int i = hash(key);
            while (!key.equals(keys[i])) {
                i = (i + 1) % M;
            }
            keys[i] = null;
            values[i] = null;
            i = (i + 1) % M;
            while (keys[i] != null) {
                Key keyToRedo = keys[i];
                Value valueToRedo = values[i];
                keys[i] = null;
                values[i] = null;
                N--;
                put(keyToRedo, valueToRedo);
                i = (i + 1) % M;
            }
            N--;
            if (N > 0 && N <= M / 8) resize(M / 2);
        }
    

    键簇:

    上面删除操作中提到连续的键值,这里引入一个概念——键簇来表示连续的键。数组实现的散列表操作平均开销是与键簇的分布直接相关的。

    显然只有短小的键簇才能保证较高的效率。

    因为当键簇长了后,每次查找(例如查找H)的遍历次数会越来越多,导致耗时增加。我们希望散列表中键簇的分布均匀且长度大部分合适。
    对于不断的插入键,散列表会越填越满,直至连成大的键簇。这样性能会变的很差,为了保证性能,我们只能牺牲内存开销,动态的调整散列表的大小。

    调整数组大小 resize:

        private void resize(int cap) {//must be needed
            LinearProbingHashST<Key, Value> t;
            t = new LinearProbingHashST<>(cap);
            for (int i = 0; i < M; i++)
                if (keys[i] != null)
                    t.put(keys[i], values[i]);
            keys = t.keys;
            values = t.values;
            M = t.M;
        }
    

    实现比较简单,直接赋值一个新的大小的数组即可。

    性能:

    对于各种常见的符号表而言,这里做一个性能汇总:

    算法 最坏情况 平均情况 内存使用
    顺序查找(无序链表) N N 48N
    二分查找(有序数组) logN logN 16N
    二叉树查找(二叉树) N 1.39logN 64N
    红黑树查找(红黑树) 2logN logN 64N
    散列表(数组) logN N/M 48N+32M
    散列表(链表) logN 1.5(get) 2.5(put) 32N~128N

    根据经验而言,一般会优先选用散列表,实现简单并且性能优秀。除非是其他因素,才会选择红黑树来实现,因为红黑树在logN内支持的操作更多。

    谢谢观看。


    参考文献:《算法导论》 《Algorithms, 4th Edition》

    相关文章

      网友评论

        本文标题:散列表

        本文链接:https://www.haomeiwen.com/subject/zrzbvftx.html