一、哈希表的增删改查
java
package com.itheima.datastructure.hashtable;
/**
* 哈希表
* 给每份数据分配一个编号,放入表格(数组)
* 建立编号与表格索引的关系,将来就可以通过编号快速查找数据
* 1. 理想情况编号唯一,表格能容纳所有数据
* 2. 现实是不能说为了容纳所有数据造一个超大表格,编号也有可能重复
* 解决
* 1. 有限长度的数组,以【拉链】方式存储数据
* 2. 允许编号适当重复,通过数据自身来进行区分
*/
public class HashTable {
// 节点类
static class Entry {
int hash; // 哈希码
Object key; // 键
Object value; // 值
Entry next; // next指针
public Entry(int hash, Object key, Object value) {
this.hash = hash;
this.key = key;
this.value = value;
}
}
Entry[] table = new Entry[16];
int size = 0; // 元素个数
float loadFactor = 0.75f; // 加载因子
int threshold = (int) (loadFactor * table.length); // 扩容阈值
/**
* 求模运算替换为位运算
* 前提:数组长度是2的n次方
* hash % 数组长度 等价于 hash & (数组长度 - 1)
*/
/**
* 根据hash码获取value
* @param hash
* @param key
* @return
*/
public Object get(int hash, Object key) {
// 1. 计算索引
int index = hash & (table.length - 1);
if(table[index] == null) {
return null;
}
// 2. 遍历比较key
Entry p = table[index];
while(p != null) {
if(p.key.equals(key)) {
return p.value;
}
p = p.next;
}
// 3. 没找到
return null;
}
/**
* 向hash表存入新key、value,如果key重复,则更新value
* @param hash
* @param key
* @param value
*/
public void put(int hash, Object key, Object value) {
// 1. 计算索引
int index = hash & (table.length - 1);
// 2. index处有空位,直接新增
if(table[index] == null) {
table[index] = new Entry(hash, key, value);
} else {
// 3. index处无空位,沿链表查找,有重复key则更新,否则新增
Entry p = table[index];
while(true) {
if(p.key.equals(key)) {
// 更新
p.value = value;
return;
}
if(p.next == null) {
break;
}
p = p.next;
}
// 新增
p.next = new Entry(hash, key, value);
}
size++;
if(size > threshold) {
// 扩容
resize();
}
}
/**
* 数组容量扩容
*/
private void resize() {
// 1. 创建一个容量翻倍的数组
Entry[] newTable = new Entry[table.length << 1];
// 2. 转移元素到新数组当中
for (int i = 0; i < table.length; i++) {
// 拿到每个链表的表头
Entry p = table[i];
if(p != null) {
// 拆分链表,移动到新数组
/**
* 拆分规律
* 一个链表最多拆分为两个
* hash & table.length == 0 的为一组
* hash & table.length != 0 的为一组
*/
Entry a = null;
Entry b = null;
Entry aHead = null;
Entry bHead = null;
while (p != null) {
if((p.hash & table.length) == 0) {
// 分配到a
if(a != null) {
a.next = p;
} else {
aHead = p;
}
a = p;
} else {
// 分配到b
if(b != null) {
b.next = p;
} else {
bHead = p;
}
b = p;
}
p = p.next;
}
// 规律:a链表保存索引位置不变,b链表索引位置+table.length
if(a != null) {
a.next = null;
newTable[i] = aHead;
}
if(b != null) {
b.next = null;
newTable[i + table.length] = bHead;
}
}
}
// 替换并更新扩容阈值
table = newTable;
threshold = (int) (loadFactor * table.length);
}
/**
* 根据hash码删除,返回删除的value
* @param hash
* @param key
* @return
*/
public Object remove(int hash, Object key) {
// 1. 计算索引
int index = hash & (table.length - 1);
// 2. 不存在对应数据
if(table[index] == null) {
return null;
}
Entry p = table[index];
Entry prev = null; // 前驱节点
while(p != null) {
if(p.key.equals(key)) {
// 找到了则删除
if(prev == null) {
// 删除的是头节点
table[index] = p.next;
} else {
prev.next = p.next;
}
size--;
return p.value;
}
prev = p;
p = p.next;
}
// 没找到
return null;
}
}
二、生成hashCode
1. 什么是哈希算法?有哪些特点?
答:哈希算法是一种将输入数据(通常是任意大小的信息)转换为固定大小的值(即哈希值)的算法。哈希值通常是一个较短的字符串。用于快速查找和数据完整性验证等目的。哈希算法广泛应用于数据结构(如哈希表)、加密和数据完整性验证等领域。
哈希算法的特点:
-
- **确定性:**相同的输入永远会产出相同的输出(哈希值)
-
- 固定输出长度:无论输入数据的大小如何,输出的哈希值长度都是固定的
-
- 快速计算:计算哈希值的过程应该是快速的,能够在合理的时间内完成
- 4.抗碰撞:尽可能减少不同输入生成相同哈希值的可能性(即碰撞)
-
- 不可逆:根据哈希值几乎无法推出原始输入
-
- 均匀性:对于任意的输入,输出应尽可能地离散均匀
- 7.散列性:哈希算法应该能够支持散列表的扩容、收缩等操作,而不会对散列表中已有的数据造成影响
2. 常见的哈希算法有哪些?
MD5(Message-Digest Algorithm 5)
- 输入长度:128位(16字节)
- 常用于数据完整性校验,但由于存在碰撞漏洞,已不再推荐用于安全性敏感的应用
SHA-1(Secure Hash Algorithm 1)
- 输出长度:160位(20字节)
- 已知存在一定的漏洞,逐渐被弃用
SHA-2(Secure Hash Algorithm 2)
- 包括多个变种:SHA-224、SHA-256、SHA-384、SHA-512等
- SHA-256和SHA-512是最常用的变种,以其更高的安全性受到重视
SHA-3(Secure Hash Algorithm)
- 一种新的哈希函数标准,改进了SHA-2
- 提供多种输出长度,最大可达512位
bcrypt
- 主要用于密码散列的哈希算法,具有高计算复杂性,增加破解难度
- 适用于存储密码和防止暴力破解
Argon2
- 一种现代密码散列算法,赢得了2015年密码哈希竞赛
- 设计用于提供内存硬化防御,适合存储和处理密码
3. Object.hashCode
- Object的hashCode方法默认是生成随机数作为hash值(会缓存在对象头中)
- 缺点是包含相同值的不同对象,它们的hashCode不一样,不能用hash值来反映对象的值特征,因此诸多子类都会重写hashCode方法
java
public Object get(Object key) {
int hash = getHash(key);
return get(hash, key);
}
private static int getHash(Object key) {
int hash = key.hashCode();
return hash;
}
public void put(Object key, Object value) {
int hash = getHash(key);
put(hash, key, value);
}
public Object remove(Object key) {
int hash = getHash(key);
return remove(hash, key);
}
4. String.hashCode
java
public static void main(String[] args) {
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s1.hashCode());
System.out.println(s2.hashCode());
// 原则:值相同的字符串生成相同的hash码,尽量让值不同的字符串来生成不同的hash码
/*
对于 abc a * 100 + b * 10 + c
对于 bac b * 100 + a * 10 + c
*/
int hash = 0;
for (int i = 0; i < s1.length(); i++) {
char c = s1.charAt(i);
System.out.println((int) c);
// (a * 10 + b) * 10 + c => a * 100 + b * 10 + c 2^5
hash = (hash << 5) - hash + c;
}
System.out.println(hash);
}
- 经验表明,如果每次乘的是较大质数,可以更好地降低hash冲突,因此改【乘10】为【乘31】
- 【乘31】可以等价为【乘32 - hash】,进一步可以转为更高效的【左移5位 - hash】
5. 检查hash表的分散性
java
public void print() {
int[] sums = new int[table.length];
for (int i = 0; i < table.length; i++) {
Entry p = table[i];
while(p != null) {
sums[i]++;
p = p.next;
}
}
// System.out.println(Arrays.toString(sums));
// 流中的元素默认是基本类型(int),boxed() 方法将它们转换为对应的对象类型(Integer)。这一步是必要的,因为后面会使用对象类型的操作
Map<Integer, Long> collect = Arrays.stream(sums).boxed().collect(Collectors.groupingBy(e -> e, Collectors.counting()));
System.out.println(collect);
}
public static void main(String[] args) {
// 测试Object.hashCode
HashTable table = new HashTable();
for (int i = 0; i < 200000; i++) {
Object obj = new Object();
table.put(obj, obj);
}
table.print();
// 测试String.hashCode
HashTable table2 = new HashTable();
List<String> strings = Files.readAllLines(Path.of("words"));
for(String str : strings) {
table2.put(str, str);
}
table2.print();
}
6. MurmurHash
(1)概述
MurmurHash是一种非加密的哈希函数,由Austin Appleby开发。它被广泛应用于需要高性能和低碰撞风险的场景,尤其是在散列表和数据库中。MurmurHash的设计理念是快速度和良好的分布性。以下是一些关键特性和版本信息:
关键特性
1. 高效性
- MurmurHash是一种非常快的哈希函数,特别是在处理大量数据时,它在性能上优于许多其他通用哈希函数。
2. 良好的分布
- 生成的哈希值在输入范围内具有良好的均匀分布,能够有效地减少哈希碰撞的概率
3. 简单的实现
- MurmurHash的实现相对简单,适合直接用于许多编程语言的散列表中。
4. 非加密性
- MurmurHash不是为加密目的设计的,因此不适合安全敏感的应用(例如数字加密或数字签名)。它主要用于数据结构的实现和快速比较。
版本
MurmurHash有几个主要版本,最常用的包括:
- MurmurHash1:最早的版本,简单易用,但在哈希碰撞方面不够强
- MurmurHash2:改进了性能和安全性,特别是在32位系统上。对于散列算法的应用效果显著好于MurmurHash1。
- MurmurHash3:目前最流行的版本,提供了更好的性能和较低的碰撞率。它支持32位和128位输出,适用于不同的应用场景。
应用场景
- 数据库:MurmurHash被广泛应用于键值存储和数据库索引
- 分布式系统:许多大数据处理框架(如Apache Hadoop和Apache Spark)使用MurmurHash来进行数据分片和负载均衡。
- 缓存层:用于实现高速缓存的哈希表,以快速查找和存储数据。
(2)实现
步骤①:导入依赖
XML
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
②更改生成hashCode的代码
java
private static int getHash(Object key) {
if(key instanceof String k) {
return Hashing.murmur3_32().hashString(k, StandardCharsets.UTF_8).asInt();
}
int hash = key.hashCode();
return hash;
}
③测试
java
public static void main(String[] args) {
HashTable table = new HashTable();
int hash = Hashing.murmur3_32().hashString("abc", StandardCharsets.UTF_8).asInt();
System.out.println(hash);
table.print();
}
三、思考
前提:数组长度是 2 的 n 次方
1. 为什么计算索引位置用式子 【hash & (数组长度 - 1)】等价于 【hash % 数组长度】?
答:
①数组长度的二进制特性
假设数组长度是一个2的幂,即数组长度 = 2^n,那么数组长度 - 1的二进制表示就是将所有低位的位都设为1。例如,如果数组长度为8(即2^3),那么
- 数组长度 = 8(二进制1000)
- 数组长度 - 1 = 7(二进制0111)
②用位运算替代取模
计算hash % 8实际上是要求hash除以8的余数。而使用位运算hash & 7,可以通过与7的位与运算得到相同的结果。这是因为:
- hash % 8计算的是hash除以8后的余数,这个余数必定在0到7之间。
- hash & 7也仅保留了hash的最低3位(对应7的二进制0111),因此同样在0到7之间。
2. 为什么旧链表会拆分成两条,一条 hash & 旧数组长度 == 0 ,另一条 hash & 旧数组长度 != 0?
答:旧数组长度换算成二进制后,其中的1就是我们要检查的倒数第几位
- 旧数组长度8 -> 二进制 1000 -> 检查倒数第4位
- 旧数组长度 16 -> 二进制 10000 -> 检查倒数第5位
hash & 旧数组长度就是用来检查扩容前后索引位置(余数)会不会变。
在哈希表扩容时,旧链表拆分成两条链表是为了将哈希表的空间更加有效地利用,确保数据均匀分布到新位置。具体而言,这种拆分的核心思路是基于数组索引的计算和哈希函数的性质。
当我们扩容哈希表时,通常会将数组的长度加倍。例如,如果当前数组长度为n,扩容后长度为2n。此时,原先存储在哈希表中的元素需要重新计算它们的新索引位置。
索引拆分的逻辑:在新的数组中,hash值取模2n的结果可以分为两类,基于原数组长度n:
- 当(hash & (n - 1))== 0时,这些元素会被放置在新数组的前半部分(索引0到n - 1)
- 当(hash & (n - 1))== 1时,这些元素会被放置在新数组的后半部分(索引n到2n - 1)
3. 为什么拆分后的两条链表,一个原索引不变,另一个是原索引 + 旧数组长度?
答:如果扩容前容量为8,则key为9的元素在旧数组中索引为 9 % 8 = 1;扩容后容量为16,key为9的元素在新数组中所有为 9 % 16 = 9,即原索引1 + 旧数组长度8 = 9。
4. 我们的代码里使用了尾插法,如果改成头插法呢?
答:在哈希表中,如果将存储冲突处理的算法从尾插法改为头插法,主要会影响链表中的元素插入顺序。在尾插法中,新的元素会被添加到链表的末尾,而在头插法中,新的元素会被添加到链表的开头。
尾插法:
- 新元素在链表的末尾插入
- 保留了插入顺序,对于某些应用(如按插入顺序遍历元素)更加友好
头插法:
- 新元素在链表的开头插入
- 可能会导致遍历时顺序于插入顺序相反,最终在访问链表时会从最新插入的元素开始
- 在计算复杂度上,两者在平均情况下一般是O(1),但是头插法可能在一些实现上有更轻量级的更新操作。
- 在多线程环境下,使用头插法会面临几个关键问题,主要是由于并发访问和数据一致性问题。以下是一些常见的问题:①数据竞争;②不一致的链表状态;③原子性问题;④死锁和活锁。
5. JDK中的HashMap中采用了将对象hashCode高低位相互异或的方式来减少冲突,怎么理解?
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
执行h ^ (h >>> 16) 的原因:更好地分散哈希值,减少哈希冲突,提高查找性能
- h >>> 16表示将hashCode向右移动16位,低位信息进入高位。这样会将某些高位信息与低位信息结合,充分利用hashCode的所有位。
- 效果:这个操作能改善不同hashCode之间的分布,使某些特定模式的键(例如,导致恶化的模式,像是连续的数字)不会总是产生相同的散列值,从而减少冲突,提升散列质量。
6. 我们的HashTable中表格容量是2的n次方,很多优化都是基于这个前提,能否不用2的n次方作为表格容量?
答:优化场景:①按位与;②拆分链表;③高低位异或
JDK中的Hashtable的表格容量就不是2^n,而是一个质数
优点:
- 避免冲突,优质哈希分布:选择一个非2的n次方的初始容量可以降低对某些哈希函数的系统性冲突的影响。例如,某些散列算法的输出在某种程度上具有周期性,使用2的n次方的容量可能会导致较多的散列冲突。
- 质数的特性:素数具有分解因数的特性,使得任何哈希值与素数取模时产生的结果更加均匀和随机。
缺点:
- 如果使用非2的n次方的容量,那么在计算哈希索引时,仍然需要用取模操作,导致性能下降
- 内存开销:选择非2的n次方的容量可能会导致更高的内存开销,因为表格可能会有未使用的插槽
7. JDK中的HashMap在链表长度过长会转换成红黑树,对此你怎么看?
答:
性能优化,降低查找时间复杂度
- 在链表结构中,最坏情况下查找、插入和删除操作的时间复杂度为O(n)。一旦链表长度超过阈值(默认为8),将其转换为红黑树后,查找、插入和删除的时间复杂度降到O(log n),大大提高了性能,尤其在数据集较大时更为明显。
负载均衡,动态调整桶的结构
- 当某个桶内的链表过长时,表明该桶发生了较多的冲突。这种转换机制有助于更均匀地分布元素,从而改善整体性能。即使在负载较高的情况下,处理性能也能得到保证。
减少性能瓶颈,解决热点问题
- 在实际应用中,可能会出现某些键的访问频率远高于其他键的情况。通过将长链表转换为红黑树,可以有效减少坚固的性能瓶颈。
减少碰撞攻击
- 恶意用户可以构造大量具有相同哈希值的键,以使HashMap中的一个桶的链表长度超过阈值,导致链表被转换为红黑树。如果不变为红黑树,可能会导致系统性能显著下降,甚至可能导致拒绝服务(Dos)攻击。通过大量的哈希碰撞,攻击者可能会消耗额外的内存和CPU资源,迫使应用程序扩展其内存或造成频繁的GC(垃圾回收)。
四、习题
1. 两数之和
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入:nums = [3,2,4], target = 6
输出:[1,2]
示例 3:
输入:nums = [3,3], target = 6
输出:[0,1]
提示:
2 <= nums.length <= 10^4
-10^9 <= nums[i] <= 10^9
-10^9 <= target <= 10^9
- 只会存在一个有效答案
进阶: 你可以想出一个时间复杂度小于 O(n^2)
的算法吗?
解法一:HashMap
- 循环遍历数组,拿到每个数组x
- 以target - x作为key到hash表查找:①若没找到,将x作为key,它的索引作为value放入hash表;②若找到了,返回x和它配对的数的索引即可
java
class Solution {
public int[] twoSum(int[] nums, int target) {
HashMap<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int k = target - nums[i];
if(map.containsKey(k)) {
return new int[]{i, map.get(k)};
}
map.put(nums[i], i);
}
return null;
}
}
2. 无重复字符的最长子串
给定一个字符串 s
,请你找出其中不含有重复字符的 最长 子串
的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
提示:
0 <= s.length <= 5 * 10^4
s
由英文字母、数字、符号和空格组成
解题思路:
- 用begin和end表示字串开始和结束位置
- 用hash表检查重复字符
- 从左向右查看每个字符,如果:①没遇到重复字符,调整end;②遇到重复字符,调整begin;③将当前字符放入hash表
- end - start + 1是当前字串长度
解法一:HashMap
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int begin = 0;
HashMap<Character, Integer> map = new HashMap<>();
int maxLength = 0;
for (int end = 0; end < s.length(); end++) {
char ch = s.charAt(end);
if (map.containsKey(ch)) {
// 重复时调整begin
begin = Math.max(begin, map.get(ch) + 1);
}
// 将当前字符放入hash表
map.put(ch, end);
// 更新maxLength
maxLength = Math.max(maxLength, end - begin + 1);
}
return maxLength;
}
}
解法二:数组
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int[] map = new int[128];
Arrays.fill(map, -1);
int begin = 0, maxLength = 0;
for (int end = 0; end < s.length(); end++) {
char ch = s.charAt(end);
if (map[ch] != -1) {
// 重复时调整begin
begin = Math.max(begin, map[ch] + 1);
}
map[ch] = end;
maxLength = Math.max(maxLength, end - begin + 1);
}
return maxLength;
}
}
3. 字母异位词分组
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
示例 1:
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
示例 2:
输入: strs = [""]
输出: [[""]]
示例 3:
输入: strs = ["a"]
输出: [["a"]]
提示:
1 <= strs.length <= 10^4
0 <= strs[i].length <= 100
strs[i]
仅包含小写字母
解法一:使用HashMap来组合字母异位词,方法是将每个字符串排序后作为键,将异位词放入到同一个列表中
java
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String str : strs) {
// 对字符数组进行排序
char[] charArray = str.toCharArray();
Arrays.sort(charArray);
String sortedStr = new String(charArray);
// 根据排序后的字符串将原字符串加入到对应的列表当中
if (!map.containsKey(sortedStr)) {
map.put(sortedStr, new ArrayList<>());
}
map.get(sortedStr).add(str);
}
return new ArrayList<>(map.values());
}
}
解法二:利用字母出现频率作为键
java
static class ArrayKey {
int[] key = new int[26];
// 利用字母出现频率作为键
public ArrayKey(String str) {
for (int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);
key[ch - 'a']++;
}
}
@Override
public boolean equals(Object obj) {
if(this == obj) return true;
if(obj == null || getClass() != obj.getClass()) return false;
ArrayKey arrayKey = (ArrayKey) obj;
return Arrays.equals(key, arrayKey.key);
}
@Override
public int hashCode() {
return Arrays.hashCode(key);
}
}
public List<List<String>> groupAnagrams(String[] strs) {
HashMap<ArrayKey, List<String>> map = new HashMap<>();
for (String str : strs) {
// 如果该ArrayKey不在map中,自动创建一个新的ArrayList,将当前字符串添加到对应的列表中
List<String> strings = map.computeIfAbsent(new ArrayKey(str), k -> new ArrayList<>());
strings.add(str);
}
return new ArrayList<>(map.values());
}
4. 存在重复元素
给你一个整数数组 nums
。如果任一值在数组中出现 至少两次 ,返回 true
;如果数组中每个元素互不相同,返回 false
。
示例 1:
输入:nums = [1,2,3,1]
输出:true
示例 2:
输入:nums = [1,2,3,4]
输出:false
示例 3:
输入:nums = [1,1,1,3,3,4,3,2,4,2]
输出:true
提示:
1 <= nums.length <= 10^5
-10^9 <= nums[i] <= 10^9
解法一:HashMap,执行耗时12ms
java
class Solution {
public boolean containsDuplicate(int[] nums) {
HashMap<Integer, Integer> map = new HashMap<>();
for (int num : nums) {
if (map.containsKey(num)) {
return true;
}
map.put(num, num);
}
return false;
}
}
解法二:HashSet,执行耗时7ms
java
class Solution {
public boolean containsDuplicate(int[] nums) {
HashSet<Integer> set = new HashSet<>();
for (int num : nums) {
// 利用集合元素的唯一性
if (!set.add(num)) {
return true;
}
}
return false;
}
}
5. 找出出现一次的数字
给你一个 非空 整数数组 nums
,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。
示例 1 :
输入:nums = [2,2,1]
输出:1
示例 2 :
输入:nums = [4,1,2,1,2]
输出:4
示例 3 :
输入:nums = [1]
输出:1
提示:
1 <= nums.length <= 3 * 10^4
-3 * 10^4 <= nums[i] <= 3 * 10^4
- 除了某个元素只出现一次以外,其余每个元素均出现两次。
解法一:HashSet,执行耗时9ms
解题思路:如果元素在set集合里已经存在则移除,最后剩下的元素即为出现一次的数字。
java
class Solution {
public int singleNumber(int[] nums) {
HashSet<Integer> set = new HashSet<>();
for (int num : nums) {
if (!set.add(num)) {
set.remove(num);
}
}
return set.toArray(new Integer[0])[0];
}
}
解法二:异或运算,执行耗时1ms
java
class Solution {
public int singleNumber(int[] nums) {
int result = 0;
for (int num : nums) {
// 相同为0,相异为1
result ^= num;
}
return result;
}
}
6. 有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
注意: 若 s 和 t中每个字符出现的次数都相同,则称 s 和 t互为字母异位词。
示例 1:
输入: s = "anagram", t = "nagaram"
输出: true
示例 2:
输入: s = "rat", t = "car"
输出: false
提示:
1 <= s.length, t.length <= 5 * 10^4
s
和t
仅包含小写字母
**进阶:**如果输入字符串包含 unicode 字符怎么办?你能否调整你的解法来应对这种情况?
解法一:
java
public boolean isAnagram(String s, String t) {
return Arrays.equals(getKey(s), getKey(t));
}
private static int[] getKey(String s) {
int[] array = new int[26];
char[] chars = s.toCharArray();
for (char ch : chars) {
array[ch - 'a']++;
}
return array;
}
7. 字符串中的第一个唯一字符
给定一个字符串 s
,找到 它的第一个不重复的字符,并返回它的索引 。如果不存在,则返回 -1
。
示例 1:
输入: s = "leetcode"
输出: 0
示例 2:
输入: s = "loveleetcode"
输出: 2
示例 3:
输入: s = "aabb"
输出: -1
提示:
1 <= s.length <= 10^5
s
只包含小写字母
解法一:
java
class Solution {
public int firstUniqChar(String s) {
int[] array = new int[26];
char[] chars = s.toCharArray();
for(char ch : chars) {
array[ch - 'a']++;
}
for(int i = 0; i < chars.length; i++) {
char ch = chars[i];
if(array[ch - 'a'] == 1) {
return i;
}
}
// 不存在
return -1;
}
}
8. 最常见的单词
给你一个字符串 paragraph
和一个表示禁用词的字符串数组 banned
,返回出现频率最高的非禁用词。题目数据 保证 至少存在一个非禁用词,且答案唯一。
paragraph
中的单词 不区分大小写 ,答案应以 小写形式返回。
示例 1:
输入:paragraph = "Bob hit a ball, the hit BALL flew far after it was hit.", banned = ["hit"]
输出:"ball"
解释:
"hit" 出现了 3 次,但它是禁用词。
"ball" 出现了两次(没有其他单词出现这么多次),因此它是段落中出现频率最高的非禁用词。
请注意,段落中的单词不区分大小写,
标点符号会被忽略(即使它们紧挨着单词,如 "ball,"),
并且尽管 "hit" 出现的次数更多,但它不能作为答案,因为它是禁用词。
示例 2:
输入:paragraph = "a.", banned = []
输出:"a"
提示:
1 <= paragraph.length <= 1000
paragraph
由英文字母、空格' '
、和以下符号组成:"!?',;."
0 <= banned.length <= 100
1 <= banned[i].length <= 10
banned[i]
仅由小写英文字母组成
解法一:执行耗时16ms
java
class Solution {
/**
* 1. 将paragraph截取为一个个单词
* 2. 将单词加入map集合,单词本身作为key,出现次数作为value,避免禁用词加入
* 3. 在map集合中找到value最大的,返回它对应的key即可
*
* @param paragraph
* @param banned
* @return
*/
public String mostCommonWord(String paragraph, String[] banned) {
Set<String> bannedSet = Set.of(banned);
// 1.将paragraph截取为一个个单词
String[] split = paragraph.toLowerCase().split("[^A-Za-z]+");
HashMap<String, Integer> map = new HashMap<>();
// 2. 单词加入map集合,单词本身作为key,出现次数作为value
for (String key : split) {
// 避免禁用词加入
if (bannedSet.contains(key)) {
continue;
}
/*
* Integer value = map.get(key);
* if(value == null) {
* value = 0;
* }
* map.put(key, value + 1);
*/
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
}
// 3. 在map集合中找到value最大的
Optional<Map.Entry<String, Integer>> optional = map.entrySet().stream().max(Map.Entry.comparingByValue());
// 返回对应的key,不存在则返回空
return optional.map(Map.Entry::getKey).orElse(null);
}
}
优化1:后两行避免lamda表达式,执行耗时13ms
java
class Solution {
public String mostCommonWord(String paragraph, String[] banned) {
Set<String> bannedSet = Set.of(banned);
// 1.将paragraph截取为一个个单词
String[] split = paragraph.toLowerCase().split("[^A-Za-z]+");
HashMap<String, Integer> map = new HashMap<>();
// 2. 单词加入map集合,单词本身作为key,出现次数作为value
for (String key : split) {
// 避免禁用词加入
if (!bannedSet.contains(key)) {
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
}
}
// 3. 在map集合中找到value最大的key
int max = 0;
String maxKey = null;
for (Map.Entry<String, Integer> e : map.entrySet()) {
Integer value = e.getValue();
if (value > max) {
max = value;
maxKey = e.getKey();
}
}
return maxKey;
}
}
优化2:避免使用正则表达式,执行耗时8ms
java
public class Solution {
public String mostCommonWord(String paragraph, String[] banned) {
// 将 banned 转换为 HashSet
Set<String> bannedSet = new HashSet<>(Set.of(banned));
HashMap<String, Integer> map = new HashMap<>();
// 处理段落中的字符
char[] chars = paragraph.toLowerCase().toCharArray();
StringBuilder sb = new StringBuilder();
for (char ch : chars) {
// 处理字母
if (ch >= 'a' && ch <= 'z') {
sb.append(ch);
} else {
// 如果 sb 不是空的,说明有一个单词被构建
if (sb.length() > 0) {
String key = sb.toString();
// 只有非禁用词才统计
if (!bannedSet.contains(key)) {
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
}
sb.setLength(0); // 清空 StringBuilder
}
}
}
// 处理最后一个单词(如果段落以字母结尾)
if (sb.length() > 0) {
String key = sb.toString();
if (!bannedSet.contains(key)) {
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
}
}
// 在 map 中找到 value 最大的 key
int max = 0;
String maxKey = null;
for (Map.Entry<String, Integer> e : map.entrySet()) {
Integer value = e.getValue();
if (value > max) {
max = value;
maxKey = e.getKey();
}
}
return maxKey;
}
}
优化3:使用map.put(key, map.getOrDefault(key, 0) + 1);替换map.compute(key, (k, v) -> v == null ? 1 : v + 1); 执行耗时7ms
java
public class Solution {
public String mostCommonWord(String paragraph, String[] banned) {
// 将 banned 转换为 HashSet
Set<String> bannedSet = new HashSet<>(Set.of(banned));
HashMap<String, Integer> map = new HashMap<>();
// 处理段落中的字符
char[] chars = paragraph.toLowerCase().toCharArray();
StringBuilder sb = new StringBuilder();
for (char ch : chars) {
// 处理字母
if (ch >= 'a' && ch <= 'z') {
sb.append(ch);
} else {
// 如果 sb 不是空的,说明有一个单词被构建
if (sb.length() > 0) {
String key = sb.toString();
// 只有非禁用词才统计
if (!bannedSet.contains(key)) {
map.put(key, map.getOrDefault(key, 0) + 1);
}
sb.setLength(0); // 清空 StringBuilder
}
}
}
// 处理最后一个单词(如果段落以字母结尾)
if (sb.length() > 0) {
String key = sb.toString();
if (!bannedSet.contains(key)) {
map.put(key, map.getOrDefault(key, 0) + 1);
}
}
// 在 map 中找到 value 最大的 key
int max = 0;
String maxKey = null;
for (Map.Entry<String, Integer> e : map.entrySet()) {
Integer value = e.getValue();
if (value > max) {
max = value;
maxKey = e.getKey();
}
}
return maxKey;
}
}
9. 根据前序遍历与中序遍历构造二叉树
给定两个整数数组 preorder
和 inorder
,其中 preorder
是二叉树的先序遍历 , inorder
是同一棵树的中序遍历,请构造二叉树并返回其根节点。
示例 1:
输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出: [3,9,20,null,null,15,7]
示例 2:
输入: preorder = [-1], inorder = [-1]
输出: [-1]
提示:
1 <= preorder.length <= 3000
inorder.length == preorder.length
-3000 <= preorder[i], inorder[i] <= 3000
preorder
和inorder
均 无重复 元素inorder
均出现在preorder
preorder
保证 为二叉树的前序遍历序列inorder
保证 为二叉树的中序遍历序列
解法一:递归。执行耗时7ms
java
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public TreeNode buildTree(int[] preOrder, int[] inOrder) {
if (preOrder.length == 0) {
return null;
}
// 创建根节点
int rootValue = preOrder[0];
TreeNode root = new TreeNode(rootValue);
// 区分左右子树
for (int i = 0; i < inOrder.length; i++) {
if (inOrder[i] == rootValue) {
// 0 ~ i-1 左子树
// i+1 ~ inOrder.length -1 右子树
int[] inLeft = Arrays.copyOfRange(inOrder, 0, i); // [4,2]
int[] inRight = Arrays.copyOfRange(inOrder, i + 1, inOrder.length); // [6,3,7]
int[] preLeft = Arrays.copyOfRange(preOrder, 1, i + 1); // [2,4]
int[] preRight = Arrays.copyOfRange(preOrder, i + 1, inOrder.length); // [3,6,7]
root.left = buildTree(preLeft, inLeft); // 2
root.right = buildTree(preRight, inRight); // 3
break;
}
}
return root;
}
}
解法二:HashMap。执行耗时2ms
java
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
HashMap<Integer, Integer> map = new HashMap<>();
public TreeNode buildTree(int[] preOrder, int[] inOrder) {
for(int i = 0; i < inOrder.length; i++) {
map.put(inOrder[i], i);
}
return helper(preOrder, 0, 0, inOrder.length - 1);
}
private TreeNode helper(int[] preOrder, int preBegin, int inBegin, int inEnd) {
if(inBegin > inEnd) {
return null;
}
int rootValue = preOrder[preBegin];
TreeNode root = new TreeNode(rootValue);
int i = map.get(rootValue);
int leftSize = i - inBegin;
root.left = helper(preOrder, preBegin + 1, inBegin, i - 1);
root.right = helper(preOrder, preBegin + 1 + leftSize, i + 1, inEnd);
return root;
}
}