数据结构与算法 - 哈希表

一、哈希表的增删改查

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. 什么是哈希算法?有哪些特点?

答:哈希算法是一种将输入数据(通常是任意大小的信息)转换为固定大小的值(即哈希值)的算法。哈希值通常是一个较短的字符串。用于快速查找和数据完整性验证等目的。哈希算法广泛应用于数据结构(如哈希表)、加密和数据完整性验证等领域。

哈希算法的特点:

  • 1. 确定性:相同的输入永远会产出相同的输出(哈希值)
  • 2. 固定输出长度:无论输入数据的大小如何,输出的哈希值长度都是固定的
  • 3. 快速计算:计算哈希值的过程应该是快速的,能够在合理的时间内完成
  • 4. 抗碰撞:尽可能减少不同输入生成相同哈希值的可能性(即碰撞)
  • 5. 不可逆:根据哈希值几乎无法推出原始输入
  • 6. 均匀性:对于任意的输入,输出应尽可能地离散均匀
  • 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方法
    public Object get(Object key) {
        int hash = getHash(key);
        return get(hash, key);
    }

    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 ^ (hash >>> 16);
    }

    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

    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表的分散性

    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)实现

步骤①:导入依赖

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.1-jre</version>
        </dependency>

②更改生成hashCode的代码

    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;
    }

③测试

    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 )== 0时,这些元素会被放置在新数组的前半部分(索引0到n - 1)
  • 当(hash & n )== 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高低位相互异或的方式来减少冲突,怎么理解?

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

执行h ^ (h >>> 16) 的原因:更好地分散哈希值,减少哈希冲突,提高查找性能

  • h >>> 16表示将hashCode向右移动16位(高位补0),低位信息进入高位。这样会将某些高位信息与低位信息结合,充分利用hashCode的所有位。
  • 效果:这个操作能改善不同hashCode之间的分布,使某些特定模式的键(例如,导致恶化的模式,像是连续的数字)不会总是产生相同的散列值,从而减少冲突,提升散列质量。

6. 我们的HashTable中表格容量是2的n次方,很多优化都是基于这个前提,能否不用2的n次方作为表格容量?

答:优化场景:①按位与;②拆分链表;③高低位异或

JDK中的Hashtable的表格容量就不是2^n,而是一个质数

优点:

  • 避免冲突,优质哈希分布:选择一个非2的n次方的初始容量可以降低对某些哈希函数的系统性冲突的影响。例如,某些散列算法的输出在某种程度上具有周期性,使用2的n次方的容量可能会导致较多的散列冲突。
  • 质数的特性:素数具有分解因数的特性,使得任何哈希值与素数取模时产生的结果更加均匀和随机

缺点:

  • 如果使用非2的n次方的容量,那么在计算哈希索引时,仍然需要用取模操作,导致性能下降
  • 内存开销:选择非2的n次方的容量可能会导致更高的内存开销,因为表格可能会有未使用的插槽

7. JDK中的HashMap在链表长度过长会转换成红黑树,对此你怎么看?

答:链表转为红黑树的阈值(默认是8),且数组长度至少为64;当红黑树中的元素数量减少到一定程度(通常是6以下),红黑树会反转回链表(红黑树维护开销和内存开销)。

性能优化,降低查找时间复杂度

  • 在链表结构中,最坏情况下查找、插入和删除操作的时间复杂度为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和它配对的数的索引即可
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

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;
    }
}

解法二:数组

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来组合字母异位词,方法是将每个字符串排序后作为键,将异位词放入到同一个列表中。执行耗时7ms

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());
    }
}

解法二:利用字母出现频率作为键。执行耗时6ms

    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

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;
    }
}

优化:执行耗时6ms

class Solution {
    public boolean containsDuplicate(int[] nums) {
        HashMap<Integer, Object> map = new HashMap<>(nums.length * 2);
        Object value = new Object();
        for (int num : nums) {
            Object put = map.put(num, value);
            if(put != null) {
                return true;
            }
        }
        return false;
    }
}

解法二:HashSet,执行耗时7ms

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集合里已经存在则移除,最后剩下的元素即为出现一次的数字。

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

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 字符怎么办?你能否调整你的解法来应对这种情况?

解法一:

    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 只包含小写字母

解法一:计数

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

  • 将paragraph截取为一个个字符
  • 将单词加入map集合,单词本身作为key,出现次数作为value,避免禁用词加入
  • 在map集合中找到value最大的,返回它对应的key即可
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

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

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

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

/**
 * 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

/**
 * 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;
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值