`
bencode
  • 浏览: 109632 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

《算法导论》读书笔记7 (散列表)

阅读更多

《算法导论》第三部分 数据结构 略过 队列 链表,我们到了 散列表

 

散列表是最常用的数据结构之一,特别是 ruby js等动态语言在语法层次上对它进行了支持。只是在java中,有那么点点绕(每次使用的时候,心里会疙瘩一下,不知道你们有没有这种感觉)。

 

本章真是纠结,因为看得懂的以前都看过,不懂的现在还是看不懂。 还好看不懂的部分都是加星号的。

 

 

散列表是这样一个东西:它能以key为索引保存东西, 然后在需要的时候嗖的一下就能找出来,灰常地快:)

 

 

	@Test
	public void test() {
		HashTable ht = new HashTable();
		
		Object o1 = new Object();
		ht.put("o1", o1);
		
		Object o2 = new Object();
		ht.put("o2", o2);
		
		assertEquals(o1, ht.get("o1"));
		assertEquals(o2, ht.get("o2"));
	}

 

 get方法要在常量时间内找到需要的东西。O(1)  <--- 要这样的复杂度

 

先不管这些,但至少HashTable看起来像这样:

 

public class HashTable {
	
	public void put(String key, Object value) {
		//...
	}
	
	public Object get(String key) {
		//...
		return null;
	}
}

 

上面的代码当然是通不过测试的(PS: 请先忘记java.util.HashTable)

 

要想get足够快,那么最好是跟据 key 直接计算出存储位置, 然后就能一下子找到啦。

 

差不多像这样:

 

public class HashTable {
	
	private Object[] table = new Object[1000];
	
	public void put(String key, Object value) {
		int hash = hash(key.hashCode());
		if (table[hash] == null) {
			table[hash] = value;
		} else{
			throw new RuntimeException("撞车啦,怎么办?");
		}
	}
	
	public Object get(String key) {
		int hash = hash(key.hashCode());
		return table[hash];
	}
	
	private int hash(int hashCode) {
		return -1; // 这里需要返回一个数 [0, table.length - 1]
	}
}

 

运行测试,数组越界, 因为我们还未实现 hash 这个方法。

 

hash 的作用是把关键字等可能地散列到 table 中去

 

除法散列乘法散列,等等。

 

 

先试一个除法散列

 

	private int hash(int hashCode) {
		int capacity = table.length;
		return hashCode % capacity;
	}

 

capacity 不应该是 2 的幂, 否则的话值为hashCode的低 k 位, 高位就会浪费掉,可能会造成很多碰撞

 

可以选择2的整数幂不大接近的质数。

 

 

现在运行测试,是通过滴:)

 

但是等等, 有时候我们需要这样:

 

	@Test
	public void test2() {
		HashTable ht = new HashTable();
		
		Object o1 = new Object();
		ht.put("o1", o1);
		
		Object anotherO1 = new Object();
		ht.put("o1", anotherO1);	// 更新
		assertEquals(anotherO1, ht.get("o1"));
	}

  

 

我们需要重构代码,把key也给保存起来。

 

首先添加一个结构, 保存key 和value

 

public class HashTable {
	
	public static  class Entry {
		private String key;
		private Object value;
		
		public Entry(String key, Object value) {
			this.key = key;
			this.value = value;
		}
		
		public String getKey() {
			return key;
		}
		public Object getValue() {
			return value;
		}
	}
	
	private Entry[] table = new Entry[1000];  // 原来的Object[] 改成 Entry[]

 

重构put

 

 

	public void put(String key, Object value) {
		int hash = hash(key.hashCode());
		if (table[hash] == null || table[hash].getKey().equals(key)) {
			table[hash] = new Entry(key, value);
		} else{
			throw new RuntimeException("撞车啦,怎么办?");
		}
	}

 

重构get

 

	public Object get(String key) {
		int hash = hash(key.hashCode());
		Entry entry = table[hash];
		return entry == null ? null : entry.getValue(); 
	}

 

 可以看到,测试又通过了:)

 

再看乘法散列

 

	private int hash(int hashCode) {
		int capacity = table.length;
		double a = 0.6180334;  // 万能的黄金分割
		return (int) (((hashCode * a) % 1) * capacity);
	}

 

用常数(A) 乘hashCode  取小数 再乘capacity

 

Knuth认为 黄金分割数 是比较理想的值((根号5 - 1) / 2 ~ 0.618), 股民朋友们一定认识

 

乘法散列 的优点是:

 

对 capacity 没有什么特别的要求, 一般选择它为 2 的整数幂。

 

因为这样可以使用移位代替乘法计算。

 

然后黄金分割数 A 如果可以表示成 2654435769  / (2 ^32)

 

那就可以简化成:

              ((hashCode * 2654435769) 
                  & ((1 << 32) - 1) )   // 只保留 32 位
               >> (32 - p)

 

重购代码试试看:

 

首先,数组空间大小为 2 ^ p

 

	private int p = 10;
	private Entry[] table = new Entry[1 << p];

 

然后:

 

	private int hash(int hashCode) {
		long k = 2654435769L;
		return (int)(((k * hashCode) & ((1L << 32) - 1)) >> (32 - p));
	}

 

测试还是通过滴。

 

下面, 让我们加多一点元素,搞坏它。

 

	@Test
	public void test3() {
		HashTable ht = new HashTable();
		
		for (int i = 0; i < 1000; i++) {
			Object o = new Object();
			ht.put("key" + i, o);
			assertEquals(o, ht.get("key" + i));
			System.out.println("Ok: " + i);
		}
	}

  

运行测试,失败, 可以看到控制台只输出到 108

 

RuntimeException,   撞车了怎么办?

 

可以采用链接法开放寻址法搞定

 

先来 链接法

 

首先重构Entry, 把自己串起来

 

	public static class Entry {
		private String key;
		private Object value;
		private Entry next;
		
		public Entry(String key, Object value) {
			this(key, value, null);
		}
		public Entry(String key, Object value, Entry next) {
			this.key = key;
			this.value = value;
			this.next = next;
		}
		
		public String getKey() {
			return key;
		}
		
		public Object getValue() {
			return value;
		}
		public void setValue(Object value) {
			this.value = value;
		}
		
		public Entry getNext() {
			return next;
		}
	}

 

同时也添加了一个 setValue 方法, 这样更容易在链表中“更新元素”

 

然后重构put

 

	public void put(String key, Object value) {
		int hash = hash(key.hashCode());
		Entry entry = table[hash];
		
		if (entry == null) { // 位置没被使用过, 直接用
			table[hash] = new Entry(key, value);
			return;
		}
		for (Entry o = entry; o != null; o = o.getNext()) {
			if (o.getKey().equals(key)) {  // 看看key节点是否存在, 如果是,就更新它
				o.setValue(value);
				return;
			}
		}
		table[hash] = new Entry(key, value, entry);  // 这里我们串起来
	}

 

 

可以看到,测试正常运行:)

 

但是随着散列表中的元素越来越多,碰撞机率也越来越大,最好当元素数量达到一定量时,自动扩充容量,这样才能保证其优异的查找性能。

 

但是我们先看看,现在的散列表, 运行test3时,碰撞几率是多少。

 

为此,我们重构, 发生碰撞时, 统计次数。

 

	private int size = 0;  // 统计表中元素个数
	private int collideCount = 0;  // 统计碰撞次数
	
	public int getSize() {
		return size;
	}
	public float getCollideRate() {
		return size > 0 ? ((float) collideCount) / size : 0;
	}

 

public void put(String key, Object value) {
		int hash = hash(key.hashCode());
		Entry entry = table[hash];
		
		if (entry == null) {
			table[hash] = new Entry(key, value);
			size++;  // 这里
			return;
		}
		collideCount++;  // 这里
		for (Entry o = entry; o != null; o = o.getNext()) {
			if (o.getKey().equals(key)) {
				o.setValue(value);
				return;
			}
		}
		table[hash] = new Entry(key, value, entry);
		size++; // 还有这
	}

 

测试:

 

	@Test
	public void test4() {
		HashTable ht = new HashTable();
		
		for (int i = 0; i < 1000; i++) {
			ht.put("key" + i, new Object());
		}
		System.out.println(ht.getCollideRate());
	}

 

输出:0.309

 

总的容量为 1024, 有1000个元素, 其中309个是发生碰撞。事故挺严重的。

 

 

下面我们重构HashTable类, 让其每次达到容量的 0.75(装载因子) 就扩充容量:)

 

	private int p = 4;
	private Entry[] table = new Entry[1 << p];
	private float loadFactor = 0.75f;

 

首先, 我们的初始化容量为 16个(1 << 4), 然后 load factor 为0.75

 

	public void put(String key, Object value) {
		if (table.length * loadFactor < size) {
			resize();
		}

 

然后在put 前检查一下, 如有必要 resize

 

 

	private void resize() {
		Entry[] old = table;
		
		p += 1;
		table = new Entry[1 << p];
		size = 0;
		collideCount = 0;
		
		for (int i = 0; i < old.length; i++) {
			Entry entry = old[i];
			while (entry != null) {
				put(entry.getKey(), entry.getValue());
				entry = entry.getNext();
			}
		}
	}

 

 

写个测试:

 

	@Test
	public void test5() {
		HashTable ht = new HashTable();
		
		for (int i = 0; i < 1000; i++) {
			Object o = new Object();
			ht.put("key" + i, o);
			assertEquals(o, ht.get("key" + i));
		}
		System.out.println(ht.getSize());
		assertTrue(ht.getSize() == 1000);
		System.out.println(ht.getCollideRate());
	}

 

这个时候,同样是添加到1000个, loadFactor 此时为 0.08

 

我们的散列表初始大小为16,  添加到1000个,要进行若干次 resize, resize开销比较大。

 

我们可以重构代码, 构造函数中指定容量大小,以避免不必要的resize开销

 

但这里不做了,因为现在只是为了说明算法, 但是使用 java.util.HashMap时,就晓得了。

 

 

解决碰撞还有开放寻址法

 

也是灰常容易滴, 我们添加两个方法, put2, 和 get2, 实现看看。

 

使用最简单的 线性探查

 

	public void put2(String key, Object value) {
		if (table.length * loadFactor < size) {
			resize();
		}
		
		int hash = hash(key.hashCode());
		Entry entry = table[hash];
		
		int nextHash = hash;
		while (entry != null) {
			if (entry.getKey().equals(key)) {
				entry.setValue(value);
				return;
			}
			nextHash = (nextHash + 1) % table.length;  // 看看下一个位置
			entry = table[nextHash];
		}
		table[nextHash] = new Entry(key, value);
		size++;
		
		if (hash != nextHash) {
			collideCount++;
		}
	}

 

	public Object get2(String key) {
		int hash = hash(key.hashCode());
		Entry entry = table[hash];
		while (entry != null) {
			if (entry.getKey().equals(key)) {
				return entry.getValue();
			}
			hash = (hash + 1) % table.length;
			entry = table[hash];
		}
		return null;
	}

 

同样,写一个测试:

 

	@Test
	public void test6() {
		HashTable ht = new HashTable();
		
		for (int i = 0; i < 1000; i++) {
			Object o = new Object();
			ht.put2("key" + i, o);
			assertEquals(o, ht.get2("key" + i));
		}
		System.out.println(ht.getSize());
		assertTrue(ht.getSize() == 1000);
		System.out.println(ht.getCollideRate());
	}

 

线性探查比较容易实现, 但是容易造成“堆在一起”的问题, 书中称为:一次群集

 

可以采用二次探查, 或双重散列,更好地避免这种现象。

 

 

//----------

 

下面看看java.util.HashMap的实现,更好地了解散列表。

 

先看 put:

 

	public V put(K key, V value) {
		if (key == null)  // null 也可以为key
			return putForNullKey(value);
		int hash = hash(key.hashCode()); // 关心的地方
		int i = indexFor(hash, table.length); // 关心的地方
		for (Entry<K, V> e = table[i]; e != null; e = e.next) {
			Object k;
			if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
				V oldValue = e.value;
				e.value = value;
				e.recordAccess(this);
				return oldValue;
			}
		}

		modCount++;
		addEntry(hash, key, value, i); // 关心的地方
		return null;
	}

 

代码中 hash 和 indexFor addEntry 是我们关心的地方。

 

此外: HashMap 允许使用值为 null 的key

 

有一个 if 语句:

 

if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  

 

先看看 hash值是否相等, 再判断equals

 

这也给出了我们重写equals和 hash的原则: 如果你重写了equals, 那么你一定要重写 hashCode, 如果两个对象equals,那么hashCode也一定要相等, 否则在HashMap等容器中将不能正确工作。参看《Effective Java》

 

 

再来看看 hash 和 indexFor  (中文注释是我加的)

 

   /**
     * Applies a supplemental hash function to a given hashCode, which
     * defends against poor quality hash functions.  This is critical
     * because HashMap uses power-of-two length hash tables, that
     * otherwise encounter collisions for hashCodes that do not differ
     * in lower bits. Note: Null keys always map to hash 0, thus index 0.
     */
    static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

 

hash 根据 原hashCode产生更好的散列值, 因为table的容量大小刚好为2的整数幂, 所以必须这样做,否则hash code的高位将浪费(取模时) --- 见上面 除法散列

 

indexFor: 等于 h % length,

 

所以,HashMap 采用 改进的除法散列

再看看 addEntry

 

	void addEntry(int hash, K key, V value, int bucketIndex) {
		Entry<K, V> e = table[bucketIndex];
		table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
		if (size++ >= threshold)
			resize(2 * table.length);
	}

 

table 也成倍扩展的

 

//~~完结

 

 

分享到:
评论

相关推荐

    算法导论系列读书笔记之二

    作为“算法导论系列读书笔记之二”,本文将主要探讨第二章的内容,这一章通常涵盖基础的数据结构和算法,为后续章节的学习打下坚实的基础。 在算法分析中,"循环不变式"是一个至关重要的概念。它是指在循环开始前、...

    算法导论 读书笔记

    在本读书笔记中,涉及到的算法知识点主要包含在《算法导论》的附录A习题解答中,内容涵盖等差级数求和、调和级数性质、无穷递减几何级数、求和的渐近上界及下界、积分求近似值以及思考题中求和的界等问题。...

    算法导论系列读书笔记之六

    《算法导论》系列读书笔记之六主要涵盖了优先级队列、堆排序以及大根堆和最大堆等重要概念。这些知识点在计算机科学与技术领域,尤其是数据结构和算法分析中占据着核心地位。下面将对这些内容进行深入的探讨。 ...

    读书笔记:算法导论 练习笔记.zip

    读书笔记:算法导论 练习笔记

    算法导论读书笔记(整理别人的)

    这篇读书笔记主要涵盖了以下几个方面的重要知识点: 1. **算法基础**:算法是解决问题的步骤序列,是计算机科学的灵魂。书中首先介绍了算法的基本概念、设计方法和分析技巧,包括时间复杂度和空间复杂度的计算,...

    算法导论 学习笔记.pdf

    算法导论学习笔记 本资源是对《算法导论》的学习笔记,涵盖了算法的基础知识、算法分析、函数的增长、递归式等方面的内容。 一、算法基础知识 算法是指将输入转换为输出的一系列计算步骤,目的是为了有效利用...

    麻省理工算法导论及笔记

    《算法导论》是计算机科学领域的一本经典著作,由Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest和Clifford Stein四位作者共同编写。这本书深入浅出地介绍了各种基础和高级算法,为学生和研究人员提供了...

    算法导论系列读书笔记之附录A的习题解答

    这篇读书笔记将针对附录A中的习题进行解析,帮助读者深化对算法的理解。 首先,附录A的习题涵盖了许多基础概念和理论,例如排序、搜索、图算法、动态规划等。在排序方面,可能会涉及冒泡排序、插入排序、选择排序、...

    算法导论第四版 英文

    在《算法导论第四版》中,作者详细探讨了各种基础数据结构,比如数组、链表、栈、队列、散列表、树和图等。每种数据结构都有其特定的应用场景和性能特点,书中通过丰富的图示和Java代码,帮助读者深入理解每种数据...

    MIT 算法导论 课堂笔记

    通过深入学习这本《MIT算法导论》的课堂笔记,你可以系统地掌握算法的精髓,提高解决问题的能力,为未来的编程生涯打下坚实的基础。同时,Word文档的格式使得笔记易于阅读和整理,方便你在学习过程中随时查阅和复习...

    算法导论第四章答案

    算法导论第四章答案算法导论第四章答案算法导论第四章答案算法导论第四章答案

    算法导论中文版

    5. 搜索算法:讲解线性搜索、二分搜索等基础搜索技术,以及散列表和二叉搜索树等高级搜索结构的构建和搜索过程。 6. 高级数据结构:包括红黑树、B树、B+树、伸展树等自平衡树结构,及其在数据库和文件系统中的应用...

    算法导论第三版完整版答案

    《算法导论》是计算机科学领域的一本经典著作,它深入浅出地介绍了算法的设计、分析和实现。这本书的第三版更是对前两版进行了完善和更新,涵盖了更广泛的主题和最新的研究成果。针对你提到的“算法导论第三版完整版...

    算法导论22章课后习题答案

    最近在研习算法导论,发现课后习题的精彩程度甚至不亚于正文,对于算法导论的爱好者而言,这是一份不错的参考资料

    算法导论 第二十五章 答案

    算法导论 第二十五章 答案 算法导论 第二十五章 答案 算法导论 第二十五章 答案

    算法导论答案算法导论教师手册

    《算法导论教师手册》为教师提供了丰富的教学资源,包括详尽的讲座笔记、习题解答和案例分析,有助于教师更好地理解和传授算法知识,同时也能帮助学生深入学习,巩固理论知识,提高解决实际问题的能力。 总之,...

    算法导论 第三版 中文pdf

    算法导论 第三版 中文pdf

    算法导论答案第四版英文版

    《算法导论》是计算机科学领域的一本经典著作,它由Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest和Clifford Stein四位作者共同撰写,全面深入地介绍了算法的设计、分析以及计算问题的解决方案。这本书...

    算法导论试题及答案

    《算法导论》是计算机科学领域的一本经典教材,由Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest和Clifford Stein四位作者共同编写,广泛应用于全球各大高校的教学中,包括知名的麻省理工学院(MIT)。...

Global site tag (gtag.js) - Google Analytics