一、由问题引出哈希表
为了介绍哈希表,我们先来看leetcode上一个简单的问题。
解决思路:
先创建一个映射,然后扫描一遍传入的字符串,将每个字符对应出现的频率存入到映射中,之后在扫描一遍传入的字符串,返回第一个频率为1的字符,如果不存在则返回-1.
另外,题目中告诉我们假定该字符串只包含小写字母,那么也就是说我们这个映射所映射的字符只可能是a~z这26种。
在这种情况下,我们可以使用一种简单思维就是我们不使用像二叉树这样的数据结构来实现这个映射,我们直接制作一个包含有26个元素的数组,数组的中存储的数据就是对应索引在字母表中对应的字母出现的频率
例如:索引为0的位置存储的值代表的是字母a出现的频率,索引为1代表字母b的频率,索引2代表字母c的频率,以此类推。
这样做不仅仅是理解起来简单,更重要的是当我们进行读写操作时,时间复杂度为O(1)
解决代码:
public int firstUniqChar(String s) { int[] freq = new int[26]; for(int i = 0 ; i < s.length() ; i ++) freq[s.charAt(i) - 'a'] ++; for(int i = 0 ; i < s.length() ; i ++) if(freq[s.charAt(i) - 'a'] == 1) return i; return -1; }
上述问题理解起来还是比较容易的,介绍这个问题的原因是因为这个问题背后隐藏着哈希表这种数据结构。那么到底什么是哈希表呢?
二、什么是哈希表?
在解决上面说的问题时,我们开辟了一个有26个空间的int数组frequency,其实就是一个哈希表。
具体来说,我们想要做的其实就是让每一个字符和一个数字之间进行一个映射关系,那么这个数字呢表示的是字符在整个字符串中出现的频率。
这里有一个关键点,我们能使用这样一个数组就能解决问题,即我们能直接使用freq[0]获取到字符a的频率,是因为我们将每一个字符都和一个索引进行了对应,使得字符和索引之间有个对应关系,之后就可以直接使用这个索引在数组中寻找到相应的字符的映射内容。这里的关系就是这个索引的值等于这个字符的ASCII码减去字符a的ASCII码。
哈希表的本质就是把我们真正关心的那个内容在我们这个问题中是字符对应的这个内容(键)转化成一个索引,然后直接用一个数组来存储相应的频率(值),那么由于数组本身是支持随机访问的,所以我们可以使用O(1)的时间复杂度来完成各项的操作,这就是哈希表的一个巨大优势。
三、使用哈希表需要处理的两件任务
在对哈希表有一定的了解后,我们可以看出来,对于哈希表有两个核心问题。
1、设计合理的哈希函数
对于我们所关注的这个内容,拿上述问题来说,我们关注的是字符它所对应的频率,那么对于每一个字符,我们必须首先把它转化成一个索引。在一般情况里,一个哈希表中是可以存储各种相应的数据类型的,对于每种数据类型,我们都需要一个方法把它转化成一个索引,相应的我们关心的这个类型转换成索引的这个函数,就称之为是哈希函数。
如果更严谨的来说,在上述问题中,我们的哈希函数其实就可以写成这个样子
F(ch) = ch – ‘a’
F(ch)就是对于给定的一个字符,我们通过这个函数f就把它转化成一个索引,这个转化的方法就是非常简单的,用ch对应的ascii值减去a对应的ascii的值就好了。
那么我们有了这个哈希函数,能够把我们真正关心的这个数据类型转换成索引,之后我们只需要在哈希表上进行操作就好了,在这个问题中这个操作非常的简单,直接在这个数组对应的索引上去查找或者进行加操作就好了,非常的容易。
不过并不是所有的时候哈希表都是这么容易的,我们在这个问题中把键转化为索引的转化方式正好是一一对应的这样的一种转化,所以我们可以非常容易地直接用一个数组来存储所有的内容,但在大多数情况下我们处理的数据会更加复杂。
比如说我们对居民的信息感兴趣,那么在这里我们识别每一个居民的这个键可能是他对应的身份证号,但是在我国身份证号是非常复杂的,一共有18位数,很显然这个18位数我们整体把它看作一个整数来说就太大了,此时我们就不能直接用这个数字来当做数组的索引,因为这样做,实际上也浪费了很多的空间,当然有更多的数据类型,它本身就跟索引可能8竿子打不着,区别巨大。
最典型的情况就是字符串,比如说我们关注学生的信息,但是我们是用学生的名字来作为键查看每个学生的具体信息,那么这会这个键就是一个字符串,我们如何设计一个哈希函数,将字符串转化为索引?这就是哈希表中我们需要考虑的问题。
除此之外还有各种数据结构牵扯到这种问题,包括比如说我们关系的这个键可能是一个浮点型或者是一个复合的类型,比如说是一个日期,每一个日期包含可能有年月日相关的信息,甚至更多一些例如时分秒这样的信息,那么这样一种复合的数据类型,我们在使用哈希表这种数据结构的时候都需要将它们首先转化为一个索引才可以使用,那么相应的,我们就需要设计一个合理的哈希函数,那么这就是我们在学习哈希表的时候要处理的第1个任务。
2、避免哈希冲突
很多时候,我们就不得不处理一个在哈希表中非常关键的问题就是两个不同的键,通过我们的哈希函数之后,它们对应了同样一个索引,那么这种情况呢,通常称之为叫做产生了哈希冲突。
那么我们在哈希表上的操作其实最复杂的部分也就是在解决这种哈希冲突上。
如果我们设计的哈希函数非常好,是一一对应的,那就像我们在之前所完成的这个问题一样,我们对哈希表的操作也将非常简单,不过对于更一般的情况,我们在哈希表上的操作主要就要考虑如何解决哈希冲突,那么这就是我们学习哈希表要主要解决的第2个关键问题。
另外还有一种极端情况,就是假设我们只有一个空间,所有的元素都存储在其中,这样其实就变成了一个链表,时间复杂度变为O(n)。不过这种情况基本不可能出现。
四、总结
对于哈希表这种数据结构,它充分的体现了我们在学习算法设计领域的时候,一个非常重要的非常经典的思想,也就是用空间换时间。仔细的思考一下自己处理的很多算法问题,包括学习的很多经典算法,其实本质上都是用空间换时间,很多时候我们多存储一些东西、预处理一些东西、缓存一些东西,那么在实际执行我们的算法任务的时候,我们完成这个任务得到这个结果就会快很多,哈希表非常完美的体现了这一点。
回到我么上面说的身份证号的例子,那么假如我们可以开辟无限大的数组或者更准确的说的话,我们可以开辟这个18个9这么多的空间,这样的一个数组的话,那么我们完全可以用在这一小节所使用的这种最为简单的一个数组的方式,来解决我们所需要的这种数据存储的功能,或者是这种映射的功能,我们在这样巨大的一个数组中可以使用O(1)的时间完成对任意一个身份证号,相应的这个居民的信息增删改查相应的内容是非常容易的,不过实际上很难能开辟一个这么大的空间,有兴趣可以实际的计算一下,开辟这么大的一个空间,就算每一个空间只存储一个32位的整型的话,那么整体这是多么大的一个空间。
另外的一个极端就是我们对应的这个数组只有一个位置,只有1的空间,此时其实就相当于我们要存储的所有的内容都会产生哈希冲突,我们把所有的内容都堆着。在这唯一的这个数组空间中,假设我们以链表的方式来组织我们整体的这个数据的话,那相应的各项操作所完成的时间复杂度就变成了o(n)这个级别,这就是设计哈希表的两个极端情况。
哈希表整体就是在这二者之间产生一个平衡。