前置知识和介绍
-
前置知识:没有。
-
曾经我刷算法题曾苦于哈希表久矣。 因为,总觉得不理解哈希表原理,那么我始终“畏惧”使用哈希表这种数据结构。网上帖子大多是上了给你一堆粗糙庞大模糊的概念, 哈希函数,哈希碰撞,链式哈希,开放寻址,直接寻址表, 又有说法哈希表是数组链表红黑树的结合。然后,又去查了一下红黑树, 彻底劝退了。
-
前期, 我们应该忽略哈希表的内部底层细节, 学会如何使用才是关键。 后期,数据结构有了经验和能力可以完整学习哈希表数据结构和哈希算法等等
写下此篇,一方面回顾自己的使用细节, 另一方面帮助需要的朋友们快速入门哈希表并将它们应用在算法题的练习上。 -
最后,本篇虽然以Java语言介绍,但任意语言有对等的概念。比如C++中哈希表,有序表的概念,python中的字典等。C语言刷题要手搓😭😭😭, 复杂题C语言版本已经不会写了。😵
哈希表
一.为什么学习哈希表?
因为查询速度快, 涉及查询问题, 往往离不开哈希表. 从时间复杂度,增删查改都是
O
(
1
)
O(1)
O(1), 不了解时间复杂度的朋友, 可以理解为查询速度是最快的一种数据结构.
但是, 速度不如数组.原因:涉及动态表,摊还时间,函数调用的开销等等.数组同样可以支持很快的查询, 一定条件下可以用数组充当哈希表结构.
二.介绍
首先介绍Java中内置哈希表HashSet
,HashMap
。
这里必须了解key的概念, 下面会以数组开始引入"key"的概念。
先回顾一下数组的知识:
哈希表是普通数组的推广, 一般地,我们知道普通数组可以直接寻址(通过索引也就是下标)可以快速访问数组元素。比如给定一个长度为n
的数组,那么索引范围是[0,n-1]
, 数组中作索引的永远是一个该范围的整数。
那么,有没有一种“魔法”,可以让这个索引变得更广泛。比如,用字符串作索引呢?
或者用一个浮点数而不是整数,还可以用字符,甚至对象呢?
哈希表就是这个魔法。
key就是哈希表中索引的统称, 它被称之为键的东西。功能上,它等同于数组的下标。
类型?由你决定,它可以是任意类型(Java实现了泛型机制)。
Java中哈希表有两种形式,一种是HashSet,和HashMap。
下面依次介绍HashSet和HashMap。
1. HashSet
-
key在哈希表中存储两种形式, 一种是以值形式存储,另一种是对象的内存地址(这表明key执行某个对象的实际内存位置)。
-
HashSet它不关心key对应的值是什么,只关心key是否存在。
HashSet可以理解为布尔数组
举个比方:
boolean[] visit = new boolean[10];
10个空间的布尔数组,比如查询下标为1的数组元素是否存在?它返回false或者true。至于下标1的元素到底是什么,我们不关心。这个好比,如果我们关心值是什么,取决于值的类型,就使用了int类型,double,char类型的数组,而不是一个布尔类型。
比如,我们关心一段文本中,某个单词是否出现,而不是其它的(这个单词出现了多少次)等问题。我们就使用HashSet,把它想象成布尔数组。它可以有效节约内存,根据问选择是否使用HashSet?
结合上面的两点用代码举例:
Java中的8大基本类型(8大包装类)和字符串类型都是根据值类型比较的。—一共九种类型
//Integer, Long, Byte, Short 整型
//Float, Double 浮点型
//Boolean 布尔型
//Character 字符型
//String 字符串类型
你可能疑问String类不是一个自定义类型?
解释:
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println(str1 == str2);
System.out.println(str1.equals(str2));
}
/**
* output:
* false
* true
* /
- str1 和 str2尽管字面值是一样的, 但是由于String类是引用类型。str1,str2实际引用的实际对象的地址。 因此不能用==比较,而是改用equals方法判断两个字符串是否相等。
- 现在请记住,对于哈希表包括后面的有序表,字符串就是按“值”比较的,具体原因和细节留待日后对于字符串哈希算法的学习。
HashSet使用
前置导入: import java.util.HashSet;
实例化例子:HashSet<String> set = new HashSet<>();
HashSet后面的菱形符号可以指定一个类型, 这里以String类型举例。
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
//创建一个HashSet
HashSet<String> set = new HashSet<>();
//将str1加入表中
set.add(str1);
//查询str1是否在表中
System.out.println(set.contains(str1));
//查询str2是否在表中
System.out.println(set.contains(str2));
//将str2加入表中
set.add(str2);
//查看表内大小
System.out.println(set.size());
//移除表中元素str1
set.remove(str1);
//清空哈希表
set.clear();
//判断哈希表是否为空。
System.out.println(set.isEmpty());
}
/**
* output:
* true
* true
* 1
* true
*/
再次强调,HashSet中key存储String都是字面值。
set.add(str1);
这句话是将str1中的"hello"作为key,而不是str1本身,str1其实是某个存放"hello"String对象的内存地址.set.contains(str1);
对于字符串来说,这句话意思是查询表中是否有字面值"hello"存在.set.contains(str2);
,这条语句也是true,因为对于字符串来说看字符串字面值.str2的字面值也是"hello".- HashSet对于重复元素key只会记录一次.比如
set.add(str2);
,由于字面值"hello"前面存储过,那么不会存储相同的"hello".
可以通过set.size()
查看表的大小, 实际输出结果是1而不是2.
HashSet对于以上9大类型可以高效去重. set.remove(str1);
,移除str1字面值"hello". 移除哈希表中.set.clear();
清空哈希表.set.isEmpty();
判断哈希表是否为空.
总结:
上面介绍了如下方法
增加:add
删除:remove
查询:size,isEmpty,contains.
修改:不需要这个操作,其实是删除或者增加
清空:clear
2. HashMap
HashMap使用
HashMap与HashSet的区别在于:HashMap会将key与value绑定(关联)在一起. 简单理解, HashSet是与布尔值(true || false)绑定.可以说HashSet本质也是一种HashMap.(Java源码也是通过map实现的set).
HashMap需要导包:import java.util.HashMap;
public static void main(String[] args) {
//key-value :key->姓名, value:编号
HashMap<String, Integer> map = new HashMap<>();
//关联key-value。
map.put("张三", 1);
map.put("李华",2);
map.put("7k7k",3);
System.out.println("张三:"+map.containsKey("张三"));
map.put("李四", 1);//不同的key可以绑定已经存在的value。
map.put("张三", 4);//"张三"与值"4"重新绑定。
System.out.println("李四:"+map.containsKey("李四"));
System.out.println("张三:"+map.containsKey("张三"));
//获取值
System.out.println("7k7k:"+map.get("7k7k"));
//output: 3
//查询表的大小
System.out.println(map.size());
map.remove("李四");
System.out.println("李四:"+map.containsKey("李四"));
//清空哈希表
map.clear();
//判断表中是否为空。
System.out.println(map.isEmpty());
}
//output:
// 张三:true
// 李四:true
// 张三:true
// 7k7k:3
// 4
// false
// true
HashMap方法介绍:
put
方法. 这是一种键值关联方法, 可以称作HashMap的添加方法.但关联和绑定这个词更为准确.若key不存在,则指定key绑定value;若key存在,则重新绑定key为新的value;不同的key支持绑定同一值或者某个对象的内存地址.containsKey
方法,需要注意HashSet中判断key是否存在的方法是contains
, 不要混淆了. 功能是判断哈希表中key是否存在;
补充, HashMap多了一个值value, 因此有了containsValue
判断哈希表是否有值的存在。get
方法,根据key获取值remove
方法, 移除哈希表中的指定键为key的键值对;比如上述代码就移除了("李四", 1)
这一组键值对。clear
方法, 清空HashMap的键值对。set.size()
, 查看当前哈希表的有效数据个数。set.isEmpty();
判断哈希表是否为空.
总结:
上面介绍了如下方法
增加:put
删除:remove
查询:get,size,isEmpty,containsKey,containsValue.
修改:put
清空:clear
有序表
TreeSet和TreeMap拥有对应前面介绍HashSet,HashMap的方法。
Tree前缀和Hash前缀的有什么区别呢? 注意标题!
目前仅作了解即可:Tree前缀的底层是红黑树, Hash前缀的底层是哈希表。
Tree前缀的是有序的,Hash前缀的是无序的。 它们都被称作表。由于带tree前缀的要维护有序性, 肯定有代价。时间上不如哈希表,
O
(
l
o
g
n
)
O(logn)
O(logn).
Tree前缀不仅支持Hash前缀同样的增删查改操作, 而且有许多支持有序操作的方法。
使用条件:
TreeMap: import java.util.TreeMap.
TreeSet: import java.util.TreeSet.
TreeMap
先实例化一个TreeMap
TreeMap<Integer, String> treeMap = new TreeMap<>();
public static void main(String[] args) {
// 底层红黑树
TreeMap<Integer, String> treeMap = new TreeMap<>();
treeMap.put(5, "这是5");
treeMap.put(7, "这是7");
treeMap.put(1, "这是1");
treeMap.put(2, "这是2");
treeMap.put(3, "这是3");
treeMap.put(4, "这是4");
treeMap.put(8, "这是8");
System.out.println(treeMap.containsKey(1));
System.out.println(treeMap.containsKey(10));
System.out.println(treeMap.get(4));
treeMap.put(4, "张三是4");
System.out.println(treeMap.get(4));
treeMap.remove(4);
System.out.println(treeMap.get(4) == null);
System.out.println(treeMap.firstKey());
System.out.println(treeMap.lastKey());
// TreeMap中,所有的key,<= 4且最近的key是什么
System.out.println(treeMap.floorKey(4));
// TreeMap中,所有的key,>= 4且最近的key是什么
System.out.println(treeMap.ceilingKey(4));
}
/*
true
false
这是4
张三是4
true
1
8
3
5
*/
上面的例子可以看出, treeMap的方法与HashMap的相似。
不过,其本身是有序的,支持有序操作。
这里忽略重复方法的说明, 重点说明有序方法:
firstKey
获取第一个键,也就是比较逻辑中最小的键。由于这里比较的整型, 是整个key中的最小值:1lastKey
获取最后一个键, 比较逻辑中最大的键。这里是整型,故打印:8floorKey
方法, 查找指定key小的且最近的key。比如,这里指定4<=且最近,由于4删除了, 故返回最近的3ceilingKey
方法, 查找指定key大且最近的key。 比如, 这里指定4>=且最近, 4删除了,故返回较大的4.
总结:
总结4个方法
返回最小键和最大键:firstKey,lastKey
返回指定范围的键:floorKey,ceilingKey
TreeSet
先实例化一个简单TreeSet
TreeSet<Integer> set = new TreeSet<>();
public static void main(String[] args) {
TreeSet<Integer> set = new TreeSet<>();
set.add(3);
set.add(3);
set.add(4);
set.add(4);
System.out.println("有序表大小 : " + set.size());
while (!set.isEmpty()) {
System.out.println(set.pollFirst());
// System.out.println(set.pollLast());
}
/**
* 有序表大小 : 2
* 3
* 4
*/
}
- TreeSet与HashSet同样不支持添加同样的键;比如上述对3,4重复添加,重复元素不能添加进去。
pollFirst()
,先删除当前逻辑上最小的元素。pollLast()
, 先删除当前逻辑上最大的元素。
比较器使用
有序表会同哈希表一样去重, 如果不想去重呢?
那么就需要定制比较器了。
比较器应用于排序,堆(优先级队列),有序表
。
比较器实现的两种方式实现一个比较器类
或者lamada表达式
。都会在下文接受。
首先,在一个Main类中创建一个如下内部类
public static class Employee{
public int company;//所在公司编号
public int age;//年龄
public Employee(int c,int a) {
company = c;
age = a;
}
}
定义类Comparator接口
前置:import java.util.Comparator;
首先, 需要设计一个EmployeeComparator类, 我这里同样是内部类。它必须实现这样的一个比较器接口Comparator
。
- 这个
Comparator
后面跟着菱形符号,放要实现比较逻辑的类名。 EmployeeComparator implements Comparator<Employee>
, 让这个比较器类实现Comparator接口Comparator<Employee>
这个类有一个方法compare方法,里面有两个参数,参数类型是菱形符号内你指定的类型。- 接着重写这个
compare
方法。按照自己期望的逻辑,比较返回一个一个整数。 注意:重写方法前面加上注解@Overrides
.
先阅读如下代码, 然后看后面的文字描述。
public static class EmployeeComparator implements Comparator<Employee>{
@Override
public int compare(Employee o1, Employee o2) {
return o1.age - o2.age;
}
}
- 上述代码是根据年龄age比较。
- compare方法返回一个整数。 根据返回值的正负或0决定谁的优先级高。 逻辑上,谁“小”谁优先级更高。
public int compare(Employee o1, Employee o2) {
// 如果返回负数认为o1(第一个参数)的优先级更高
// 如果返回正数认为o2(第二个参数)的优先级更高
//返回0,则优先级相同。在有序表中返回0会认为重复了不再添加进去。
return o1.age - o2.age;
}
排序
Arrays类中sort函数可以接受一个比较器对象。在sort函数最后一个参数上传参一个比较器对象。
Arrays.sort(arr, new EmployeeComparator());
public static void main(String[] args) {
Employee s1 = new Employee(2, 27);
Employee s2 = new Employee(1, 60);
Employee s3 = new Employee(4, 19);
Employee s4 = new Employee(3, 23);
Employee s5 = new Employee(1, 35);
Employee s6 = new Employee(3, 55);
Employee[] arr = { s1, s2, s3, s4, s5, s6 };
Arrays.sort(arr, new EmployeeComparator());
for (Employee e : arr) {
System.out.println(e.company + " , " + e.age);
}
}
// output:
// 4 , 19
// 3 , 23
// 2 , 27
// 1 , 35
// 3 , 55
// 1 , 60
箭头函数(lamada表达式)可以简化这种写法:
不要定义类,实例化对象这么麻烦了。
Arrays.sort(arr, (a,b)->a.age - b.age);
a,b是两个形式参数名, 不需要说明类型(编译器自动推导类型)。->
后面跟着实际compare函数的那条return语句后面部分。
lamada表达式书写很简洁
我们可以实现更复杂的排序逻辑。
Arrays.sort(arr, (a,b)->a.company != b.company ? a.company - b.company : a.age - b.age);
这条语句说明, 先选择公司优先级高的,然后比较年龄优先级高的。
有序表使用
对于有序表中的自定义类型必须传递比较器对象或者lamada表达式。
TreeSet<Employee> treeSet = new TreeSet<>((a,b)->a.age-b.age);
for(Employee e: arr) {
treeSet.add(e);
}
System.out.println("treeSet.size():" + treeSet.size());
treeSet.add(new Employee(1, 35));
System.out.println("treeSet.size():" + treeSet.size());
//treeset.size():6
//treeset.size():6
由于实际根据年龄进行比较的, 那么年龄重复了对于有序表就不再添加了。
能设计一个比较器让有序表不去重吗?
只需要更新比较逻辑。
// 如果不想去重,就需要增加更多的比较
// 比如对象的内存地址、或者如果对象有数组下标之类的独特信息。 原生的toString方法可以当成内存的地址的字符串形式。注意Java中不能直接对引用变量作差,因此需要toString转化为字符串比较。
TreeSet<Employee> treeSet2 = new TreeSet<>((a, b) -> a.company != b.company ? (a.company - b.company)
: a.age != b.age ? (a.age - b.age) : a.toString().compareTo(b.toString()));
for (Employee e : arr) {
treeSet2.add(e);
}
System.out.println("treeSet.size():" +treeSet2.size());
// 不会去重
treeSet2.add(new Employee(2, 27));
System.out.println("treeSet.size():" +treeSet2.size());
/**
* treeSet2.size():6
* treeSet2.size():7
* /
堆和优先级队列
Java中PriorityQueue默认小堆。
如果要更改为大根堆, 需要传递比较器对象或者lamada表达式。
自定义类型,同样需要上述传参。 堆本身允许重复元素。
不举例。
参考
- 左程云数据结构与算法, 视频链接请在主页的about页面查找。或B站油管自行搜索。