【数据结构】Map、Set与哈希表底层原理

文章目录

  • 一、二叉搜索树
    • [1.1 二叉搜索树的定义](#1.1 二叉搜索树的定义)
    • [1.2 核心操作](#1.2 核心操作)
    • [1.3 性能瓶颈与改进方向](#1.3 性能瓶颈与改进方向)
  • 二、Map接口:Key-Value键值对容器
    • [2.1 Map的特性](#2.1 Map的特性)
    • [2.2 内部类:Map.Entry<K,V>](#2.2 内部类:Map.Entry<K,V>)
    • [2.3 Map的常用方法](#2.3 Map的常用方法)
    • [2.4 TreeMap与HashMap的区别](#2.4 TreeMap与HashMap的区别)
  • 三、Set接口:无重复元素的集合
    • [3.1 Set的特性](#3.1 Set的特性)
    • [3.2 Set的常用方法](#3.2 Set的常用方法)
    • [3.3 TreeSet与HashSet的区别](#3.3 TreeSet与HashSet的区别)
  • 四、哈希表:HashMap与HashSet的底层
    • [4.1 哈希表的核心思想](#4.1 哈希表的核心思想)
    • [4.2 哈希冲突(Hash Collision)](#4.2 哈希冲突(Hash Collision))
    • [4.3 哈希表的实现(简化版HashMap)](#4.3 哈希表的实现(简化版HashMap))
    • [4.4 哈希表与Java类集的关联](#4.4 哈希表与Java类集的关联)

一、二叉搜索树

1.1 二叉搜索树的定义

二叉搜索树是一种具有排序特性的二叉树,满足以下规则:

  • 若左子树不为空,左子树上所有节点的值均小于根节点的值;
  • 若右子树不为空,右子树上所有节点的值均大于根节点的值;
  • 左右子树也分别为二叉搜索树。

对于数组{5,3,4,1,7,8,2,6,0,9},构建的二叉搜索树如下:

复制代码
        5
      /   \
     3     7
    / \   / \
   1   4 6   8
  / \         \
 0   2         9

1.2 核心操作

(1)查找

查找逻辑遵循左小右大原则:

  1. 若根节点值等于目标值,返回当前节点
  2. 若目标值小于根节点值,在左子树中查找;
  3. 若目标值大于根节点值,在右子树中查找。
java 复制代码
public TreeNode search(int key){
        TreeNode cur = root;
        while(cur!=null){
            if(cur.val< key)
                cur=cur.right;
            else if(cur.val> key)
                cur=cur.left;
            else
                return cur;
        }
        return null;
    }

时间复杂度 :取决于树的高度,最优为完全二叉树的O(log₂N),最差为单支树的O(N)

(2)插入

  1. 若树为空,直接将新节点作为根节点;
  2. 若树非空,按查找逻辑遍历,找到插入位置(父节点的左/右子树为空处);
  3. 若插入值小于父节点值,作为左子节点插入;否则作为右子节点插入。
java 复制代码
public void insert(int key) {
        if (root == null) {
            root = new TreeNode(key);
            return;
        }
        TreeNode cur = root;
        TreeNode parent = root;
        while (cur != null) {
            if (cur.val > key) {
                parent = cur;
                cur = cur.left;
            } else if (cur.val < key) {
                parent = cur;
                cur = cur.right;
            }else {
                return;
            }
        }
        if (parent.val > key)
            parent.left = new TreeNode(key);
        else
            parent.right = new TreeNode(key);
    }

(3)删除

删除需分三种情况处理,保证删除后树的结构仍满足二叉搜索树规则:

  • 情况1:待删除节点(cur)无左子树

    若cur是根节点,根节点更新为cur的右子树;否则,将父节点(parent)的对应指针(左/右)指向cur的右子树。

  • 情况2:待删除节点(cur)无右子树

    逻辑与情况1对称,将父节点的对应指针指向cur的左子树。

  • 情况3:待删除节点(cur)左右子树均存在

    采用"替换法":在cur的右子树中找到中序遍历的第一个节点(即右子树中最小节点,称为后继节点),用该节点的值覆盖cur的值,再删除后继节点(后继节点必满足情况1或情况2)。

java 复制代码
 public void remove(int key) {
        TreeNode cur = root;
        TreeNode parent = null;

        while (cur != null) {
            if (cur.val < key) {
                parent = cur;
                cur = cur.right;
            }else if(cur.val > key){
                parent = cur;
                cur = cur.left;
            }
            else {
                removeNode(parent, cur);
                break;
            }
        }

    }

    private void removeNode(TreeNode parent, TreeNode cur) {
        //情况1
        if(cur.left==null){
            if(cur==root)
                root=cur.right;
            else if(cur==parent.left)
                parent.left=cur.right;
            else if(cur==parent.right)
                parent.right = cur.right;
        }

        //情况2
        else if(cur.right==null){
            if(cur==root)
                root=cur.left;
            else if(cur==parent.left)
                parent.left=cur.left;
            else if(cur==parent.right)
                parent.right = cur.left;
        }

        //情况3
        else{
            TreeNode tmpParent = cur;
            TreeNode tmp = cur.right;
            //找到右树的最小节点
            while(tmp.left!=null){
                tmpParent = tmp;
                tmp=tmp.left;
            }
            cur.val = tmp.val;

            //删除右树最小节点
            if(tmpParent.left==tmp)
                tmpParent.left = tmp.right;
            else if(tmpParent.right == tmp)
                tmpParent.right = tmp.right;
        }
    }

1.3 性能瓶颈与改进方向

二叉搜索树的性能依赖于树的结构:若插入顺序有序(如1,2,3,4),会退化为单支树,此时查找/插入/删除的时间复杂度变为O(N),完全失去优势。

改进方案 :使用平衡二叉搜索树(如红黑树),通过颜色规则和旋转操作维持树的平衡,确保最坏情况下的时间复杂度仍为O(log₂N)。这也是TreeMapTreeSet的底层实现。

二、Map接口:Key-Value键值对容器

Map是Java中存储键值对(Key-Value)的核心接口,其设计目标是支持高效的键查找、插入和删除。

2.1 Map的特性

  • 不继承自Collection接口,独立成为顶层接口;
  • Key唯一(不可重复),Value可重复;
  • 支持Key的快速查找,底层实现决定查找效率;
  • 常用实现类:TreeMap(红黑树实现)、HashMap(哈希表实现)。
  • Map没有实现Iterable,实现的类不可以通过迭代器进行遍历。如果需要遍历,可以先调用entrySet()返回到Set中,再遍历:
java 复制代码
        Set<Map.Entry<String,Integer>> entrySet = treeMap.entrySet();
        for(Map.Entry<String,Integer> entry : entrySet){
            System.out.println("key: "+entry.getKey()+" value: "+ entry.getValue());
        }

2.2 内部类:Map.Entry<K,V>

Map通过内部类Map.Entry存储单个键值对,提供了键值对的访问方法:

方法 功能
K getKey() 返回当前Entry的Key
V getValue() 返回当前Entry的Value
V setValue(V value) 修改当前Entry的Value,返回旧值

注意:Map.Entry不提供修改Key的方法,若需修改Key,需先删除原键值对,再重新插入。

2.3 Map的常用方法

方法 功能描述
V get(Object key) 根据Key获取Value,Key不存在返回null
V getOrDefault(Object key, V defaultValue) 根据Key获取Value,Key不存在返回默认值
V put(K key, V value) 插入键值对,Key已存在则覆盖Value,返回旧Value
V remove(Object key) 删除Key对应的键值对,返回删除的Value
Set<K> keySet() 返回所有Key的集合(不可重复)
Collection<V> values() 返回所有Value的集合(可重复)
Set<Map.Entry<K,V>> entrySet() 返回所有键值对的集合
boolean containsKey(Object key) 判断是否包含指定Key
boolean containsValue(Object value) 判断是否包含指定Value

2.4 TreeMap与HashMap的区别

对比维度 TreeMap HashMap
底层结构 红黑树(平衡二叉搜索树) 哈希桶(数组+链表/红黑树)
时间复杂度 插入/删除/查找:O(log₂N) 插入/删除/查找:O(1)(平均情况)
有序性 按Key自然排序(或自定义排序) 无序
Key限制 不可为null,需支持比较(实现Comparable或提供Comparator 可为null(仅允许一个null Key)
Value限制 可为null 可为null(允许多个null Value)
线程安全 不安全 不安全
适用场景 需要Key有序的场景(如排序统计) 无需有序,追求高效读写的场景

三、Set接口:无重复元素的集合

Set接口继承自Collection,核心特性是"存储无重复的Key",底层实现依赖于Map(将Key作为Map的Key,Value使用一个默认空对象填充)。

3.1 Set的特性

  • 仅存储Key,不存储Value;
  • Key唯一(重复元素插入失败);
  • Set实现了Iterable,实现的类可以通过迭代器进行遍历。
  • TreeSet不可以插入nullkey,而HashSet可以。
  • 常用实现类:TreeSet(红黑树实现)、HashSet(哈希表实现)、LinkedHashSet(哈希表+双向链表,保留插入顺序)。

3.2 Set的常用方法

方法 功能描述
boolean add(E e) 插入元素,重复元素返回false
boolean contains(Object o) 判断是否包含指定元素
boolean remove(Object o) 删除指定元素,成功返回true
int size() 返回元素个数
Iterator<E> iterator() 返回迭代器,用于遍历元素
void clear() 清空集合

3.3 TreeSet与HashSet的区别

对比维度 TreeSet HashSet
底层结构 红黑树 哈希桶(数组+链表/红黑树)
时间复杂度 插入/删除/查找:O(log₂N) 插入/删除/查找:O(1)(平均情况)
有序性 按Key自然排序 无序(LinkedHashSet保留插入顺序)
Key限制 不可为null,需支持比较 可为null(仅允许一个null
去重逻辑 基于比较器(Comparable/Comparator 基于hashCode()equals()
适用场景 需要有序去重的场景 无需有序,追求高效去重的场景

四、哈希表:HashMap与HashSet的底层

哈希表(Hash Table)是一种"键值对映射"的数据结构,通过哈希函数将Key映射到存储地址,实现O(1)级别的高效读写,是HashMap和HashSet的底层实现。

4.1 哈希表的核心思想

理想的搜索场景是"无需比较,直接定位"。哈希表通过以下逻辑实现:

  1. 哈希函数(Hash Function):将Key转换为存储地址(如hash(key) = key % 数组长度);
  2. 插入元素:通过哈希函数计算地址,将键值对存入该地址;
  3. 查找元素:通过哈希函数计算地址,直接访问该地址获取元素。

示例:对于数据集合{1,4,5,6,7,9},哈希函数为hash(key) = key % 10(数组长度为10),存储结果如下:

数组索引 0 1 2 3 4 5 6 7 8 9
存储元素 - 1 - - 4 5 6 7 - 9

4.2 哈希冲突(Hash Collision)

(1)冲突的定义

当两个不同的Key通过哈希函数计算出相同的存储地址时,称为哈希冲突。

例如""Key=4和Key=44,4%10=444%10=4,会映射到同一地址)。

冲突是必然存在的------因为哈希表的数组长度有限,而Key的范围可能无限,无法避免不同Key映射到同一地址。我们能做的是"尽量降低冲突率"。

(2)冲突的避免

冲突避免的核心是"优化哈希函数"和"调节负载因子"。

① 哈希函数设计原则
  • 定义域覆盖所有Key,值域在[0, 数组长度-1]之间;
  • 计算结果均匀分布(减少冲突);
  • 计算效率高(简单易实现)。
常用哈希函数
函数类型 实现逻辑 适用场景
直接定制法 Hash(key) = A*key + B(线性函数) Key范围小且连续(如年龄)
除留余数法 Hash(key) = key % p(p为接近数组长度的质数) 通用场景(HashMap采用类似逻辑)
平方取中法 对Key平方后取中间几位 未知Key分布,Key位数较少
折叠法 将Key分割为若干部分,叠加求和后取模 Key位数较多(如手机号)
② 负载因子调节(重点)

负载因子(Load Factor)定义:负载因子 = 已存储元素个数 / 数组长度

  • 负载因子越大,数组越满,冲突率越高;
  • 负载因子越小,数组越空,空间利用率越低。

结论:负载因子需控制在合理范围(java中HashMap默认0.75)。当负载因子超过阈值时,会触发数组扩容(通常扩容为原来的2倍),从而降低负载因子,减少冲突。

(3)冲突的解决

当冲突发生时,需通过特定方式处理,常用方案有"闭散列"和"开散列"。

① 闭散列(开放定址法)

逻辑:当地址冲突时,在数组中寻找下一个空位置存储元素。

  • 线性探测:从冲突地址开始,依次向后查找空位置(如Key=44冲突后,查找索引5、6、7、8,找到空位置8存储);
  • 缺陷:容易导致"数据堆积"(冲突元素集中在某一区域),降低查找效率;
  • 二次探测 :改进线性探测,查找逻辑为H i = (H0 ± i²) % 数组长度(i=1,2,3...),分散冲突元素,但空间利用率较低(负载因子需≤0.5)。
② 开散列(链地址法,重点)

逻辑:数组的每个位置(称为"桶")存储一个链表(数组长度超过64且链表长度超过8,转为红黑树),冲突的元素被加入同一个桶的链表中。

示例:Key=4和Key=44冲突后,都存入索引4的桶中,形成链表:

数组索引 0 1 2 3 4 ...
存储元素 - 1 - - 4→44→null ...

开散列的优势

  • 空间利用率高(无需预留空位置);
  • 冲突处理简单(仅在桶内链表操作);
  • 性能稳定(HashMap采用此方案,当链表长度超过8时,转为红黑树,进一步优化查找效率)。

4.3 哈希表的实现(简化版HashMap)

以下是基于开散列(链地址法)的简化版哈希表实现,包含put(插入)、get(查找)和resize(扩容)操作:

java 复制代码
public class HashBuck {
    //键值对
    static class Node{
        public int key;
        public int val;
        public Node next;

        public Node(int key,int val){
            this.key = key;
            this.val = val;
        }
    }
    //哈希数组
    public Node[] arr = new Node[10];
    //已存节点个数
    public int usedSize;
    //负载因子阈值
    public static final double DEFAULT_LOAD_FACTOR = 0.75f;

    //插入
    public void put(int key, int val){
        int index = key% arr.length;
        Node cur = arr[index];
        while(cur!=null){
            //key存在则替换val
            if(cur.key==key){
                cur.val=val;
                return;
            }
            cur=cur.next;
        }
        //key不存在,新建节点
        Node newNode = new Node(key,val);
        //头插法
        newNode.next = arr[index];
        arr[index]=newNode;
        usedSize++;

        //检查负载因子
        if(doLoadFactor() >= DEFAULT_LOAD_FACTOR){
            resize();
        }

    }

    //扩容
    private void resize() {
        Node[] newArr = new Node[arr.length*2];
        for (int i = 0; i < arr.length; i++) {
            Node cur = arr[i];
            while(cur!=null){
                int index = cur.key % newArr.length;
                //记录cur的下一个节点,防止后面修改cur.next后找不到原来的节点
                Node curNext = cur.next;

                //头插法
                cur.next = newArr[index];
                newArr[index]=cur;
                cur = curNext;
            }
        }
        arr = newArr;
    }

    //计算负载因子
    private double doLoadFactor(){
        return usedSize*1.0/ arr.length;
    }

    //根据key获取val
    public int getValue(int key){
        int index = key % arr.length;
        Node cur = arr[index];
        while(cur!=null){
            if(cur.key==key)
                return cur.val;
            cur=cur.next;
        }
        return -1;
    }
}

注意: 扩容不可以直接把原来的数组复制到新数组中,因为哈希地址会随着数组长度改变,必须遍历每一个元素,重新分配地址。

对于引用类型的数据:

  1. 重写hashCode()方法,保证逻辑上认为相等的对象hashcode也相等;再调用hashcode()得到这个对象的hashcode,再计算哈希地址。
  2. 判断key是否相等时,不可以用==来判断,而是要调用重写的equals()方法。

例如:

java 复制代码
import java.util.Objects;

public class Student {
    int id;
    String name;

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return id == student.id && Objects.equals(name, student.name);
    }

    @Override
    public int hashCode() {
    //当id和name相等时,hashcode也相等
        return Objects.hash(id, name);
    }
}

4.4 哈希表与Java类集的关联

  1. HashMap/HashSet的底层实现:均基于哈希表(开散列),HashSet本质是"Key为元素、Value为默认空对象的HashMap";
  2. 冲突处理:JDK8中,当桶内链表长度超过8时,自动转为红黑树;当长度小于6时,转回链表(平衡时间和空间效率);
  3. 自定义Key的要求 :若使用自定义类作为HashMap的Key或HashSet的元素,必须覆写hashCode()equals()方法,且需满足:
    • equals()返回true的两个对象,hashCode()必须相等;
    • hashCode()相等的两个对象,equals()不一定返回true(避免误判为相同元素)。
相关推荐
橘子师兄2 小时前
C++AI大模型接入SDK—API接入大模型思路
开发语言·数据结构·c++·人工智能
L.EscaRC2 小时前
深度解析 Spring 框架核心代理组件 MethodProxy.java
java·开发语言·spring
拽着尾巴的鱼儿2 小时前
Spring 缓存 @Cacheable 实现原理
java·spring·缓存
dabidai2 小时前
JSR-250JavaEE规范
java
Jackson@ML2 小时前
2026最新版IntelliJ IDEA安装使用指南
java·ide·intellij-idea
tobias.b2 小时前
408真题解析-2010-4-数据结构-平衡二叉树插入
数据结构·计算机考研·408真题解析
逍遥德2 小时前
函数式编程 Java Lambda Stream及其实现类常用函数
java·后端·spring
2501_941982052 小时前
Java 分布式环境下的 Access_Token 一致性方案:如何避免多节点冲突?
java·开发语言·分布式
历程里程碑2 小时前
哈希3 : 最长连续序列
java·数据结构·c++·python·算法·leetcode·tornado