【数据结构】基础学习

线性数据结构

1. 链表(Linked List)

链表是一种线性数据结构,每个节点包含数据和指向下一个节点的引用(即指针)。

1. 链表的基本操作(Java中的LinkedList类)

LinkedList 是 Java 标准库中的一个双向链表实现。我们将通过一些插入、删除和获取操作来演示其使用。

import java.util.LinkedList;

public class LinkedListExample {
    public static void main(String[] args) {
        // 创建一个LinkedList
        LinkedList<Integer> list = new LinkedList<>();

        // 插入操作
        list.addFirst(10);  // 在头部插入元素
        list.addLast(20);   // 在尾部插入元素
        list.add(1, 15);    // 在索引1处插入元素

        // 获取元素
        System.out.println("头部元素: " + list.getFirst());  // 获取头部元素
        System.out.println("尾部元素: " + list.getLast());   // 获取尾部元素
        System.out.println("索引1处的元素: " + list.get(1));  // 获取索引1处的元素

        // 删除操作
        list.removeFirst();  // 删除头部元素
        list.removeLast();   // 删除尾部元素
        list.remove(0);      // 删除索引0处的元素

        // 最终链表
        System.out.println("最终链表: " + list);
    }
}
时间复杂度分析:
  • addFirst()addLast() 的时间复杂度为 O(1),因为在双向链表中可以直接操作头部和尾部节点。
  • add(index, element) 的时间复杂度为 O(n),因为需要遍历链表找到指定位置。
  • get(index) 的时间复杂度为 O(n),因为链表不支持随机访问,必须从头或尾遍历。
  • removeFirst()removeLast() 的时间复杂度为 O(1)
  • remove(index) 的时间复杂度为 O(n),因为需要遍历链表找到要删除的节点。

2. 手动实现简化的单向链表

为了更好地理解链表的操作,我们可以实现一个简单的单向链表(单链表)并进行插入、删除和获取操作。

class Node {
    int data;
    Node next;

    public Node(int data) {
        this.data = data;
        this.next = null;
    }
}

class SinglyLinkedList {
    private Node head;

    public SinglyLinkedList() {
        this.head = null;
    }

    // 在链表的头部插入元素
    public void addFirst(int data) {
        Node newNode = new Node(data);
        newNode.next = head;
        head = newNode;
    }

    // 在链表末尾插入元素
    public void addLast(int data) {
        Node newNode = new Node(data);
        if (head == null) {
            head = newNode;
        } else {
            Node current = head;
            while (current.next != null) {
                current = current.next;
            }
            current.next = newNode;
        }
    }

    // 删除头部元素
    public void removeFirst() {
        if (head != null) {
            head = head.next;
        }
    }

    // 获取链表中的元素
    public void printList() {
        Node current = head;
        while (current != null) {
            System.out.print(current.data + " ");
            current = current.next;
        }
        System.out.println();
    }
}

public class Main {
    public static void main(String[] args) {
        SinglyLinkedList list = new SinglyLinkedList();
        
        // 插入操作
        list.addFirst(10);  // 插入到头部
        list.addLast(20);   // 插入到尾部
        list.addFirst(5);   // 再插入到头部

        // 输出链表
        System.out.print("当前链表: ");
        list.printList();

        // 删除头部元素
        list.removeFirst();
        System.out.print("删除头部元素后的链表: ");
        list.printList();
    }
}

手动链表实现的关键点:

  • addFirst() 在链表头部插入一个新节点,时间复杂度为 O(1)
  • addLast() 需要遍历整个链表找到尾部,再进行插入操作,时间复杂度为 O(n)
  • removeFirst() 删除链表头部的节点,时间复杂度为 O(1)

3. 什么场景下使用链表更合适?

场景1:频繁的插入和删除操作
在需要频繁进行插入和删除操作的场景中(尤其是在头部或尾部),链表比数组更高效。例如,任务调度系统或 LRU 缓存管理中,链表常用于实现队列或缓存的快速增删操作。

场景2:动态数据的处理
当我们不确定数据集的大小或数据需要频繁扩展时,链表的动态性非常适合。数组大小固定,扩展时需要重新分配内存,而链表无需提前分配存储空间。

场景3:不需要随机访问
链表更适合顺序访问数据的场景,例如流处理或者文件解析。对于需要快速随机访问的数据结构,数组或哈希表可能更合适。

面试题相关

1. 描述一下链表的数据结构?

链表是一种线性数据结构,由一组节点组成,每个节点包含两个部分:

  • 数据域(Data): 存储元素的值。
  • 指针域(Next): 存储指向下一个节点的引用或指针。

链表的特点是元素存储在非连续的内存位置,节点通过指针相互连接。这与数组不同,数组要求内存中的元素连续存储。根据链表的类型,可以是单向链表双向链表循环链表

  • 单向链表:每个节点只指向下一个节点。
  • 双向链表:每个节点有两个指针,分别指向前一个和后一个节点。
  • 循环链表:链表的最后一个节点指向第一个节点,形成一个环状结构。
2. Java 中 LinkedList 使用的是单向链表、双向链表还是循环链表?

Java 中的 LinkedList 实现的是双向链表(Doubly Linked List)
每个节点包含三个部分:

  • 数据域(Data):存储元素的值。
  • 前指针(Prev):指向前一个节点。
  • 后指针(Next):指向下一个节点。

LinkedList 支持从链表的两端进行高效的插入和删除操作,因为每个节点都持有对前后节点的引用。

3. 链表中数据的插入、删除、获取元素,时间复杂度是多少?
  • 插入操作(Insert)

    • 在链表头部或尾部插入:O(1)
    • 在链表中间插入(需要遍历到插入位置):O(n),其中 n 是链表的长度。
  • 删除操作(Delete)

    • 在链表头部或尾部删除:O(1)
    • 在链表中间删除(需要遍历到删除位置):O(n)
  • 获取元素(Access)

    • 单链表或双向链表的获取操作需要从头开始遍历,时间复杂度为 O(n)

由于链表没有像数组一样的索引,随机访问的效率较低,获取某个特定位置的元素通常需要线性时间。

4. 什么场景下使用链表更合适?

链表适用于以下场景:

  • 频繁插入和删除元素的场景:特别是在列表的头部和尾部进行插入或删除操作时,链表非常高效,时间复杂度为 O(1),而数组插入或删除时需要移动其他元素,时间复杂度为 O(n)

  • 动态大小调整的场景:链表可以根据需求动态增长或收缩,而数组需要提前分配固定大小的内存,如果数组满了,必须创建一个新的更大的数组并复制元素,性能会受到影响。

  • 不需要随机访问的场景:链表更适合顺序访问元素的场景,因为链表的随机访问性能较差(O(n)),而数组支持 O(1) 的随机访问。

在需要高效插入、删除操作但随机访问要求不高的情况下,链表是合适的选择。

2. 数组(Array)

数组是一种固定大小的、存储相同数据类型元素的结构,可以通过索引直接访问每个元素。

Java代码示例:

public class Main {
    public static void main(String[] args) {
        int[] arr = new int[3];  // 创建长度为3的数组
        arr[0] = 10;
        arr[1] = 20;
        arr[2] = 30;

        // 打印数组
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i] + " ");  // 输出: 10 20 30
        }
    }
}

面试题相关

1. 数据结构中有哪些是线性表数据结构?

线性表数据结构是指元素按照线性顺序排列的一类数据结构,常见的线性表包括:

  • 数组(Array)
  • 链表(Linked List)
    • 单向链表(Singly Linked List)
    • 双向链表(Doubly Linked List)
    • 循环链表(Circular Linked List)
  • 栈(Stack)
  • 队列(Queue)
    • 普通队列(Regular Queue)
    • 双端队列(Deque)
    • 优先队列(Priority Queue)

这些数据结构的元素排列顺序是线性的,即每个元素有且仅有一个前驱元素和一个后继元素(栈和队列的特殊情况除外)。

2. 数组的元素删除和获取,时间复杂度是多少?
  • 获取元素(Access)
    数组支持随机访问,根据索引直接获取元素,时间复杂度为 O(1)。这是因为数组的元素存储在连续的内存空间,通过索引可以立即定位到元素的位置。

  • 删除元素(Delete)

    • 如果删除的是数组的最后一个元素,时间复杂度为 O(1)
    • 如果删除的是中间或头部的元素,时间复杂度为 O(n),因为删除后需要将后面的所有元素向前移动以填补空位。
      比如删除第 i 个元素后,需要将 i+1 到最后的所有元素向前移动一位。
3. ArrayList 中默认的初始化长度是多少?

Java 中 ArrayList 的默认初始化长度是 10。当你创建一个没有指定初始容量的 ArrayList 时,它会默认为 10。

ArrayList<Integer> list = new ArrayList<>();  // 默认初始容量为10
4. ArrayList 中扩容的范围是多大一次?

ArrayList 的扩容策略是按 1.5 倍(即当前容量的 50%)进行扩容。例如:

  • 如果当前容量是 10,扩容后容量会变为 10 + 10/2 = 15
  • 如果当前容量是 15,扩容后容量会变为 15 + 15/2 = 22

具体的实现逻辑为:

int newCapacity = oldCapacity + (oldCapacity >> 1);

其中 oldCapacity >> 1 表示将容量右移一位,相当于除以 2。

5. ArrayList 是如何完成扩容的,System.arraycopy 各个入参的作用是什么?

ArrayList 在需要扩容时,底层会调用 System.arraycopy() 方法来将旧数组的内容复制到一个更大的新数组中。

System.arraycopy() 的参数含义如下:

public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
  • src:源数组,表示从哪个数组复制数据。
  • srcPos:源数组的起始位置(从该位置开始复制)。
  • dest:目标数组,表示将数据复制到哪个数组。
  • destPos:目标数组的起始位置(从该位置开始粘贴)。
  • length:复制的元素个数。

ArrayList 的扩容过程一般为:

  1. 创建一个新数组,容量为旧数组的 1.5 倍。
  2. 调用 System.arraycopy(),将旧数组的元素复制到新数组。
  3. 将新数组作为 ArrayList 的底层存储数组。

扩容代码示例:

// ArrayList扩容示例
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    
    if (minCapacity - elementData.length > 0) {
        grow(minCapacity);
    }
}

private void grow(int minCapacity) {
    // 计算扩容后的新容量
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0) {
        newCapacity = minCapacity;
    }
    elementData = Arrays.copyOf(elementData, newCapacity);
}

System.arraycopy 可以避免手动循环复制,提高复制效率。

3. 队列(Queue)

队列是一种先进先出(FIFO)的数据结构,最常用的操作是入队(enqueue)和出队(dequeue)。

Java代码示例:

import java.util.LinkedList;
import java.util.Queue;

public class Main {
    public static void main(String[] args) {
        Queue<Integer> queue = new LinkedList<>();
        
        // 入队操作
        queue.add(10);
        queue.add(20);
        queue.add(30);

        // 出队操作
        System.out.println(queue.remove());  // 输出: 10
        System.out.println(queue.remove());  // 输出: 20
    }
}

面试题相关

1. 单端队列和双端队列,分别对应的实现类是哪个?
  • 单端队列(单向队列):

    • Java 中的 Queue 接口 及其实现类,如 LinkedList(也实现了 Queue 接口)、PriorityQueue 等可以用来实现单端队列,通常只能在一端(尾部)插入元素,在另一端(头部)移除元素。
  • 双端队列

    • Java 中的 Deque 接口 对应双端队列,提供了在两端插入和删除元素的能力。常见的实现类有:
      • ArrayDeque:基于数组实现的双端队列。
      • LinkedList:虽然主要是链表实现,但也实现了 Deque 接口,支持双端队列操作。
2. 简述延迟队列/优先队列的实现方式
  • 优先队列(Priority Queue)

    • 优先队列 是一种队列,元素根据优先级进行排序,而不是按插入顺序排列。Java 中的 PriorityQueue 类实现了优先队列,其内部通常通过 二叉堆(Binary Heap) 实现。
    • 每次插入元素时,PriorityQueue 会维护堆的结构,以保证队首(根节点)始终是最小(或最大)的元素。插入和删除操作的时间复杂度为 O(log n)
  • 延迟队列(DelayQueue)

    • 延迟队列 是 Java 中的 BlockingQueue 的一种实现。延迟队列中的元素只有在指定的延迟时间过去后才能从队列中取出。该队列的元素必须实现 Delayed 接口,包含一个 getDelay 方法,用于返回延迟时间。
    • DelayQueue 通过内部的 优先队列(如 PriorityQueue)实现元素的排序,队列会将延迟时间最短的元素优先排在队首,但只有当其延迟时间到达时才能被取出。
3. 二叉堆插入/弹出元素的过程

二叉堆 是完全二叉树,分为 最大堆(Max Heap)最小堆(Min Heap),它们用于实现优先队列。插入和删除的核心在于维护堆的性质。

  • 插入元素过程

    1. 将元素插入到堆的最后一个位置(数组末尾)。
    2. 通过“上浮”操作(bubble-up 或 sift-up),将新插入的元素与其父节点比较,并根据最大堆或最小堆的规则进行交换,直到堆的性质得到恢复。时间复杂度为 O(log n)
  • 弹出元素过程

    1. 从堆中删除根节点(即最大堆的最大值或最小堆的最小值)。
    2. 将堆的最后一个元素移动到根节点的位置。
    3. 通过“下沉”操作(bubble-down 或 sift-down),将根节点的元素与其子节点比较并交换,直到堆的性质恢复。时间复杂度为 O(log n)
4. 延迟队列的使用场景

延迟队列 常用于需要延迟处理任务的场景,如:

  • 任务调度系统:在未来的某个时间点执行任务,延迟队列可以将任务存储并在特定时间后触发任务执行。
  • 定时缓存过期:存储缓存数据并设定到期时间,到期后缓存将从队列中移除或处理。
  • 消息系统:用于实现消息的定时投递,在指定时间后将消息从队列中取出并发送。
5. 延迟队列为什么添加信号量

延迟队列 添加信号量的目的是为了在多线程并发环境下对队列的访问进行协调,确保:

  1. 线程同步:延迟队列使用信号量(如内部的 ReentrantLock)来保证多个线程对队列的并发访问是安全的。例如,当一个线程尝试取出队首元素,而另一个线程尝试插入新元素时,信号量可以防止数据竞争和不一致性。

  2. 阻塞特性:在没有可取出元素的情况下,队列会让线程进入阻塞状态,直到有新元素被插入或已有元素的延迟时间到达。信号量可以帮助线程有序地被唤醒,避免不必要的忙等待。

4. 堆栈(Stack)

堆栈是一种先进后出(LIFO)的数据结构,常见操作包括压栈(push)和弹栈(pop)。

Java代码示例:

import java.util.Stack;

public class Main {
    public static void main(String[] args) {
        Stack<Integer> stack = new Stack<>();
        
        // 压栈操作
        stack.push(10);
        stack.push(20);
        stack.push(30);

        // 弹栈操作
        System.out.println(stack.pop());  // 输出: 30
        System.out.println(stack.pop());  // 输出: 20
    }
}

面试题相关

1. 堆栈的使用场景?

堆栈(Stack)是一种 后进先出(LIFO, Last In First Out) 的数据结构,常用于以下场景:

  • 递归调用:系统调用栈保存函数的递归调用信息,每次调用新函数时,系统将其放入栈顶,函数执行完毕后从栈顶弹出。
  • 表达式求值:如中缀表达式转后缀表达式的算法,或使用栈计算后缀表达式的值。
  • 括号匹配:编译器或解析器可以使用栈来检查括号是否配对。
  • 深度优先搜索(DFS):DFS 使用堆栈来存储要访问的节点。
  • 撤销操作:应用程序(如文本编辑器)可以使用栈来存储用户的操作,方便后续撤销。
2. 为什么不是用 Stack 类?

虽然 Java 的 Stack 类可以实现堆栈功能,但在实际开发中不推荐使用它,原因如下:

  1. Stack 继承自 VectorStack 类是 Java 1.0 时代的实现,它继承了 Vector,而 Vector 是同步的,这意味着 Stack 所有方法都是线程安全的,但在大多数场景下,线程同步是不必要的,反而会造成性能损失。

  2. 性能问题:由于 Stack 是同步的,性能不如 ArrayDequeLinkedList 等没有同步开销的实现。

  3. 替代方案:Java 官方推荐使用 ArrayDequeLinkedList 来实现栈,而不是 Stack 类。ArrayDeque 是一个更高效的栈实现,不会有同步开销。

3. ArrayDeque 是基于什么实现的?

ArrayDeque 是基于 动态数组 实现的双端队列。它内部维护了一个循环数组,通过对数组的大小进行动态调整,支持在头部和尾部进行高效的插入和删除操作。

  • 动态数组使 ArrayDeque 可以灵活调整大小,保证插入和删除操作的性能。
  • 采用循环缓冲区的形式,确保头尾插入/删除操作的效率,避免频繁移动数组中的元素。
4. ArrayDeque 数据结构使用过程叙述

ArrayDeque 既可以作为栈,也可以作为队列使用:

  • 作为栈:可以使用 push() 在队列头部添加元素,用 pop() 从队列头部移除元素,遵循 LIFO(后进先出)的原则。

    ArrayDeque<Integer> stack = new ArrayDeque<>();
    stack.push(1);  // 插入元素
    stack.push(2);
    stack.push(3);
    System.out.println(stack.pop());  // 输出 3,弹出栈顶元素
    
  • 作为队列:可以使用 offer() 在尾部添加元素,用 poll() 从头部移除元素,遵循 FIFO(先进先出)的原则。

    ArrayDeque<Integer> queue = new ArrayDeque<>();
    queue.offer(1);  // 插入元素到队尾
    queue.offer(2);
    queue.offer(3);
    System.out.println(queue.poll());  // 输出 1,取出队头元素
    

ArrayDeque 使用循环数组管理数据,可以在队列的头尾两端高效地进行插入和删除操作。它不会扩容到无限大,只有在数组容量不足时才会动态扩展。

5. ArrayDeque 为什么要初始化 2 的 n 次幂个长度?

ArrayDeque 初始化为 2 的 n 次幂长度 的原因主要有以下几点:

  1. 便于快速扩容:在数组长度为 2 的 n 次幂时,扩容和索引计算可以使用位运算(如 &>> 操作)来替代常规的取模运算,位运算比取模运算效率更高,提升了性能。

  2. 循环数组的高效管理ArrayDeque 是基于循环数组实现的,它使用一个环形缓冲区存储数据,使用 2 的 n 次幂长度可以简化数组下标的计算。在插入、删除时通过 &(array.length - 1) 实现高效的环形数组索引操作。

  3. 减少扩容次数:当数组的大小为 2 的 n 次幂时,扩容所需的时间复杂度较低,并且新的数组大小也更容易管理和对齐,使得内存分配更加高效。

综合来看,初始化为 2 的 n 次幂可以简化循环数组的管理,并通过位运算提高性能。

5. 哈希表(Hash Table)

哈希表是一种通过键值对存储数据的数据结构,通常用作映射(Map)的一部分,可以高效地通过键来查找值。

Java代码示例:

import java.util.HashMap;

public class Main {
    public static void main(String[] args) {
        HashMap<String, Integer> hashMap = new HashMap<>();
        
        // 插入键值对
        hashMap.put("Apple", 1);
        hashMap.put("Banana", 2);
        hashMap.put("Cherry", 3);

        // 通过键查找值
        System.out.println(hashMap.get("Banana"));  // 输出: 2
    }
}

总结:

  • 链表:动态大小,插入/删除操作效率高。
  • 数组:固定大小,随机访问效率高。
  • 队列:FIFO结构,适用于排队场景。
  • 堆栈:LIFO结构,适用于递归、回溯问题。
  • 哈希表:通过键快速查找值,适用于映射场景。

面试题相关

1. 散列表简介

散列表(Hash Table) 是一种用于存储键值对的数据结构,通过哈希函数将键映射到特定的数组位置(称为桶,bucket),从而可以快速地插入、删除和查找元素。它的核心思想是通过哈希函数计算索引,通常能够在 O(1) 时间复杂度内完成基本操作。

关键组成部分:
  • 键(Key):用来查找数据的唯一标识。
  • 值(Value):与键相关联的数据。
  • 哈希函数(Hash Function):根据键生成一个整数(散列值),这个值用于确定数据在数组中的存储位置。

散列表的效率很大程度上依赖于哈希函数的质量,即它如何将不同的键映射到不同的数组位置,以及如何处理哈希冲突


2. 为什么使用散列表?

散列表的主要优势在于其高效的查找、插入和删除操作:

  • 快速查找:通常情况下,散列表的查找操作时间复杂度为 O(1),远快于 O(log n) 的树结构。
  • 快速插入与删除:散列表在最好的情况下插入和删除的时间复杂度也是 O(1),适用于频繁查找和更新的场景。
  • 灵活性:散列表允许动态存储各种类型的数据(数值、字符串、对象等),并且操作简单直接。

常见的使用场景:

  • 数据库中的索引实现。
  • 缓存(如 LRU 缓存)实现。
  • 用于加速字典、集合等数据结构的查询。

3. 拉链法和开放寻址法的区别

散列哈希冲突不可避免,因此必须有冲突解决方法,最常见的两种方法是 拉链法开放寻址法

拉链法(Chaining)
  • 拉链法为每个哈希桶存储一个链表(或其他数据结构)来处理冲突。当多个键被哈希到同一个位置时,所有的键值对会被放入该位置对应的链表中。
  • 优点:插入效率较高,即使哈希冲突严重也不需要频繁调整数组结构。
  • 缺点:如果大量元素映射到同一个桶,链表会变长,导致查找性能退化到 O(n)
// Java中的HashMap使用了拉链法
class Node<K, V> {
    final int hash;
    final K key;
    V value;
    Node<K, V> next;  // 指向下一个元素
}
开放寻址法(Open Addressing)
  • 开放寻址法不使用链表处理冲突,而是在发现冲突时,通过探测(通常是线性探测、二次探测或双重散列)寻找下一个空闲位置来存放冲突的元素。
  • 优点:节省了链表的额外空间开销,所有元素都存储在同一个数组中。
  • 缺点:当负载因子过高时,探测次数增加,性能会显著下降。
// Java中的ConcurrentHashMap使用了开放寻址法(CAS + 链表的组合)

4. 还有哪些方式可以解决散列哈希索引冲突?

除了拉链法和开放寻址法,其他常见的哈希冲突解决方法还有:

  • 双散列法(Double Hashing):使用第二个哈希函数来计算冲突时的探测步长。与开放寻址法类似,但减少了线性或二次探测中的聚集问题。

    index = (hash1(key) + i * hash2(key)) % table_size;
    
  • 线性探测法(Linear Probing):在发生冲突时,依次检查下一个位置,直到找到空位。线性探测容易导致“堆积”(即多个冲突连续发生),影响性能。

  • 二次探测法(Quadratic Probing):探测步长是冲突次数的平方,减少了线性探测的堆积现象。

    index = (hash(key) + i^2) % table_size;
    
  • 再哈希法(Rehashing):当哈希冲突过多时,重新计算哈希表的大小,并根据新的哈希函数重新分配所有元素。

  • 扩容与缩容:哈希表通常在超过一定负载因子(如 0.75)时,会自动扩容以减少冲突几率。缩容则在负载过低时发生。


5. 对应的 Java 源码中,对于哈希索引冲突提供了什么样的解决方案?

在 Java 中,HashMap 是一种非常常用的哈希表实现,Java 中针对哈希冲突的处理主要采用了拉链法和一些优化策略。

HashMap 的冲突解决策略:
  1. 拉链法HashMap 默认使用链表处理哈希冲突。当多个元素映射到同一哈希桶时,这些元素会被存放到一个链表中。

    public V put(K key, V value) {
        int hash = hash(key);
        int index = (n - 1) & hash;
        Node<K, V> head = table[index];
        // 如果冲突,将新元素加入链表中
        if (head != null) {
            // 遍历链表,插入节点
        } else {
            // 创建新的桶
            table[index] = new Node<>(hash, key, value, null);
        }
    }
    
  2. 链表转红黑树:当链表长度超过一定阈值(默认 8)时,HashMap 会将链表转换为 红黑树,以避免链表过长导致的查找性能退化。红黑树的查找性能为 O(log n)

    if (binCount >= TREEIFY_THRESHOLD - 1) {
        treeifyBin(tab, hash);
    }
    
  3. 扩容机制:当 HashMap 的元素超过一定负载因子(默认 0.75)时,HashMap 会自动扩容,将原来的数据重新分配到新的、更大的数组中。扩容会减少冲突,提升性能。

ConcurrentHashMap 的冲突解决策略:
  • ConcurrentHashMap 使用了分段锁机制开放寻址法(CAS 操作),允许多线程并发访问。它在实现上采用了链表和红黑树的组合来处理哈希冲突,同时利用开放寻址避免了过度的锁竞争。

树形数据结构

1. 最大堆(Max Heap)

最大堆是一棵完全二叉树,每个节点的值大于等于其子节点的值。插入和删除操作都保持堆的结构。

Java示例:

import java.util.PriorityQueue;
import java.util.Collections;

public class MaxHeap {
    public static void main(String[] args) {
        // Java中的优先队列默认是最小堆,需要将其转换为最大堆
        PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
        
        // 插入元素
        maxHeap.add(10);
        maxHeap.add(20);
        maxHeap.add(15);
        
        // 打印最大值
        System.out.println("Max Heap Root: " + maxHeap.peek());
        
        // 删除最大值并输出
        maxHeap.poll();
        System.out.println("After removing root, new Max Heap Root: " + maxHeap.peek());
    }
}

2. 最小堆(Min Heap)

最小堆是一棵完全二叉树,每个节点的值小于等于其子节点的值。插入和删除操作也保持堆的结构。

Java示例:

import java.util.PriorityQueue;

public class MinHeap {
    public static void main(String[] args) {
        PriorityQueue<Integer> minHeap = new PriorityQueue<>();
        
        // 插入元素
        minHeap.add(10);
        minHeap.add(20);
        minHeap.add(15);
        
        // 打印最小值
        System.out.println("Min Heap Root: " + minHeap.peek());
        
        // 删除最小值并输出
        minHeap.poll();
        System.out.println("After removing root, new Min Heap Root: " + minHeap.peek());
    }
}

面试题

1. 堆的数据结构是什么样?

堆(Heap) 是一种完全二叉树,用于实现优先队列的数据结构。堆的每个节点都有一个值,并且满足以下性质:

  • 最大堆(Max Heap):父节点的值大于或等于其子节点的值,根节点是最大值。
  • 最小堆(Min Heap):父节点的值小于或等于其子节点的值,根节点是最小值。

堆的存储方式通常是使用数组,因为堆是完全二叉树,节点的位置可以通过数组下标计算:

  • 父节点下标为 i,则左子节点下标为 2*i + 1,右子节点下标为 2*i + 2
2. 堆的数据结构使用场景?

堆广泛用于需要动态维护数据序列并按优先级进行快速访问的场景,常见的使用场景有:

  • 优先队列:堆常用于实现优先队列,可以高效地找到当前最大(或最小)的元素,时间复杂度为 O(1)
  • 堆排序(Heap Sort):基于堆的数据结构设计的排序算法,时间复杂度为 O(n log n),可以用于排序大规模数据。
  • 调度器:操作系统中的任务调度常使用堆来根据任务优先级安排执行顺序。
  • 图算法
    • Dijkstra 算法:用于计算最短路径,堆用于高效查找距离最小的节点。
    • Prim 算法:用于构建最小生成树,使用堆来选择权重最小的边。
3. 堆的数据结构实现方式有哪些?

堆可以通过不同的数据结构实现,常见的实现方式包括:

  • 二叉堆(Binary Heap)

    • 基于完全二叉树实现,常用于最大堆和最小堆。通常使用数组来存储堆。
    • 操作如插入和删除的时间复杂度为 O(log n)
  • 斐波那契堆(Fibonacci Heap)

    • 斐波那契堆的插入操作比二叉堆更高效(O(1)),适用于需要频繁合并堆的情况,如在 Dijkstra 算法中。它采用了多棵堆树的结构,降低了插入和合并的时间复杂度。
  • 配对堆(Pairing Heap)

    • 一种结构简单的堆,虽然理论上的复杂度不如斐波那契堆,但在实际应用中表现良好。
  • 二项堆(Binomial Heap)

    • 由多棵二项树组成的堆,支持高效的合并操作,时间复杂度为 O(log n)
  • 左偏堆(Leftist Heap)

    • 主要用于合并操作的高效堆,应用在需要合并多个优先队列的场景中。
4. 最小堆和最大堆的区别是什么?

最小堆(Min Heap)最大堆(Max Heap) 的主要区别在于节点的排列规则:

  • 最小堆

    • 每个父节点的值都小于或等于其子节点的值。
    • 根节点是整个堆中的最小值。
    • 常用于查找和维护最小值的场景。
  • 最大堆

    • 每个父节点的值都大于或等于其子节点的值。
    • 根节点是整个堆中的最大值。
    • 常用于查找和维护最大值的场景。

在实际应用中,最小堆常用于实现优先队列,而最大堆常用于需要快速获取最大值的场景。

5. 有了解斐波那契堆吗?

是的,斐波那契堆(Fibonacci Heap) 是一种特殊的堆,设计用于使某些操作的时间复杂度更加高效。它主要在图算法(如 Dijkstra 和 Prim 算法)中被应用,因为在这些算法中,频繁的插入合并堆操作使得斐波那契堆具有优势。

斐波那契堆的特点:
  • 插入操作:时间复杂度为 O(1),因为只需将新节点插入一个最小树中即可。
  • 合并堆操作:时间复杂度为 O(1),适合需要频繁合并两个堆的场景。
  • 删除最小元素:时间复杂度为 O(log n),与二叉堆相同。
  • 减小键值:时间复杂度为 O(1),适用于图算法中的最短路径计算。
斐波那契堆的结构:
  • 多棵树:斐波那契堆由多棵堆序树组成,而不是像二叉堆那样是单棵完全二叉树。
  • 懒惰合并:斐波那契堆使用了“懒惰合并”的策略,减少了每次插入操作时的重新排列工作,从而加快了插入和合并操作。

斐波那契堆在理论上提供了更好的性能,但由于结构复杂,在实际使用中,二叉堆和配对堆更为常见,因为它们的实现更简单,且在大多数实际应用场景中性能差异并不显著。

3. 字典树(Trie)

字典树是一种树状结构,主要用于存储字符串。它的每个节点代表一个字符。

Java示例:

class TrieNode {
    TrieNode[] children = new TrieNode[26];
    boolean isEndOfWord;
    
    public TrieNode() {
        isEndOfWord = false;
        for (int i = 0; i < 26; i++) {
            children[i] = null;
        }
    }
}

class Trie {
    private TrieNode root;
    
    public Trie() {
        root = new TrieNode();
    }
    
    // 插入单词
    public void insert(String word) {
        TrieNode node = root;
        for (char ch : word.toCharArray()) {
            int index = ch - 'a';
            if (node.children[index] == null) {
                node.children[index] = new TrieNode();
            }
            node = node.children[index];
        }
        node.isEndOfWord = true;
    }
    
    // 搜索单词
    public boolean search(String word) {
        TrieNode node = root;
        for (char ch : word.toCharArray()) {
            int index = ch - 'a';
            if (node.children[index] == null) {
                return false;
            }
            node = node.children[index];
        }
        return node.isEndOfWord;
    }
}

public class Main {
    public static void main(String[] args) {
        Trie trie = new Trie();
        trie.insert("apple");
        System.out.println(trie.search("apple"));  // 输出 true
        System.out.println(trie.search("app"));    // 输出 false
    }
}

面试题

1. 简述字典树的数据结构

字典树(Trie),又称为前缀树,是一种用于存储字符串集的数据结构。它将字符串中的公共前缀共享,使得查找、插入操作更加高效。字典树的每个节点通常代表一个字符,路径从根节点到某个节点的顺序字符构成了字符串。

字典树的特点:
  • 根节点为空,每个节点代表一个字符。
  • 从根到某个节点的路径表示一个字符串的前缀。
  • 叶子节点表示某个完整字符串的结束。
  • 每个节点包含:当前字符、指向子节点的指针(通常为一个数组或哈希表)、是否为字符串结束标记(isEnd 字段)。

例如,插入单词 appleapp 会共享相同的前缀节点 'a' -> 'p' -> 'p'

2. 叙述你怎么来实现一个字典树

字典树的实现可以使用树的节点结构,其中每个节点表示一个字符,并通过子节点链接构成整个字典树。以下是用 Java 实现字典树的示例代码:

class TrieNode {
    TrieNode[] children = new TrieNode[26]; // 26 个字母,假设都是小写字母
    boolean isEndOfWord = false; // 判断是否是某个单词的结尾
}

class Trie {
    private TrieNode root;

    public Trie() {
        root = new TrieNode();
    }

    // 插入单词
    public void insert(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            int index = c - 'a';
            if (node.children[index] == null) {
                node.children[index] = new TrieNode();
            }
            node = node.children[index];
        }
        node.isEndOfWord = true;
    }

    // 查找单词是否存在
    public boolean search(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            int index = c - 'a';
            if (node.children[index] == null) {
                return false;
            }
            node = node.children[index];
        }
        return node.isEndOfWord;
    }

    // 查找是否有前缀
    public boolean startsWith(String prefix) {
        TrieNode node = root;
        for (char c : prefix.toCharArray()) {
            int index = c - 'a';
            if (node.children[index] == null) {
                return false;
            }
            node = node.children[index];
        }
        return true;
    }
}
3. 字典树的实际业务场景

字典树被广泛应用于各种场景,特别是与字符串处理相关的任务:

  • 自动补全与排序:通过存储字典中的词语和常用短语,字典树可以提供高效的自动补全建议,并基于前缀进行排序。
  • 全文搜索:字典树能快速找到所有包含某个前缀的单词,适合用于实现高效的文本检索系统。
  • 网络搜索引擎:搜索引擎使用字典树来加速查询处理和关键词匹配,尤其是在匹配搜索提示时。
  • 生物信息学:字典树可用于 DNA 序列匹配和分析。DNA 序列可视作字符串,字典树有助于快速匹配不同序列的前缀。
4. 字典树的存入和检索的时间复杂度

对于字典树:

  • 插入(存入)单词的时间复杂度为 O(m),其中 m 是单词的长度。
  • 搜索单词的时间复杂度同样为 O(m),其中 m 是所要搜索单词的长度。
  • 由于每个节点只存储一个字母,字典树的空间复杂度与存储的单词总字符数有关,即 O(n),其中 n 是所有单词总字符数。

相比其他字符串数据结构,字典树通过前缀共享降低了存储空间需求,且能够在恒定时间内实现前缀查找。

5. 还有哪些字典树的实现方式

除了标准的字典树(Trie),还有一些其他基于类似思想的树结构,用于处理不同的需求或优化特定操作。

1. 后缀树(Suffix Tree)
  • 后缀树是基于所有字符串后缀构建的字典树。它将字符串的所有后缀插入树中,支持快速的子串匹配操作。
  • 用途:后缀树常用于查找字符串中的子串、最长重复子串等问题。它支持 O(m) 时间复杂度的子串搜索。
2. 哈希树(Hash Trie)
  • 哈希树将字典树的节点使用哈希表来存储,而不是使用数组。这使得哈希树能够处理更大的字母表,例如 Unicode 字符集。
  • 用途:哈希树在需要支持非固定大小的字母表(如全局字符集)时,能减少内存浪费并提升效率。
3. 帽子树(Patricia Tree 或 Radix Tree)
  • **帽子树(压缩字典树)**是一种优化的字典树,其中每个节点不仅仅代表一个字符,而是代表一串公共前缀的字符串。它通过路径压缩来减少树的深度。
  • 用途:帽子树在保存大量键且具有相同前缀的情况下减少了空间复杂度,常用于 IP 地址查找和路由等场景。
4. 压缩前缀树(Compressed Trie)
  • 通过将具有唯一前缀的路径合并,减少节点数量。
  • 用途:适用于内存优化场景,比如 IP 路由和生物信息中的基因序列分析。

这些字典树的不同实现方式主要在于如何优化空间和时间复杂度,以及适应不同的应用场景。

4. 二分搜索树(Binary Search Tree,BST)

二分搜索树是每个节点左子树的值小于节点的值,右子树的值大于节点的值。

Java示例:

class TreeNode {
    int val;
    TreeNode left, right;
    public TreeNode(int val) {
        this.val = val;
        left = right = null;
    }
}

class BinarySearchTree {
    TreeNode root;

    // 插入节点
    public void insert(int key) {
        root = insertRec(root, key);
    }

    private TreeNode insertRec(TreeNode root, int key) {
        if (root == null) {
            root = new TreeNode(key);
            return root;
        }
        if (key < root.val)
            root.left = insertRec(root.left, key);
        else if (key > root.val)
            root.right = insertRec(root.right, key);

        return root;
    }

    // 中序遍历
    public void inorder() {
        inorderRec(root);
    }

    private void inorderRec(TreeNode root) {
        if (root != null) {
            inorderRec(root.left);
            System.out.print(root.val + " ");
            inorderRec(root.right);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        BinarySearchTree bst = new BinarySearchTree();
        bst.insert(50);
        bst.insert(30);
        bst.insert(20);
        bst.insert(40);
        bst.insert(70);
        bst.insert(60);
        bst.insert(80);
        
        bst.inorder();  // 输出:20 30 40 50 60 70 80
    }
}

面试题

1. 二叉搜索树(BST)结构简述

**二叉搜索树(Binary Search Tree, BST)**是一种特殊的二叉树,它满足以下性质:

  • 每个节点的左子树中所有节点的值都小于该节点的值。
  • 每个节点的右子树中所有节点的值都大于该节点的值。
  • 左右子树也分别是二叉搜索树。

二叉搜索树的用途
BST 主要用于动态集合操作,比如查找、插入、删除等。在保持树平衡的情况下,BST 可以高效执行这些操作。

2. 二叉搜索树的插入、删除、索引的时间复杂度

对于高度为 h 的二叉搜索树:

  • 插入的时间复杂度为 O(h),最坏情况下为 O(n)(树退化成链表时)。
  • 删除的时间复杂度为 O(h),最坏情况下为 O(n)
  • 查找/索引的时间复杂度为 O(h),最坏情况下为 O(n)

如果二叉搜索树是平衡的(比如红黑树或 AVL 树),则时间复杂度为 O(log n)

3. 二叉搜索树删除含有双子节点的元素过程叙述

删除一个节点时,根据节点的子节点情况,分为三种情况:

  1. 没有子节点(叶子节点)

    • 直接删除该节点。
  2. 有一个子节点

    • 将该节点的子节点连接到其父节点上,替代它的位置。
  3. 有两个子节点

    • 找到该节点右子树中的最小节点(即中序后继),或左子树中的最大节点(即中序前驱),用它替代该节点的值。
    • 然后删除中序后继或前驱节点,因为它最多有一个子节点(性质保证)。

    具体步骤:

    • 找到要删除节点 T 的中序后继节点 S(右子树中的最小节点)。
    • T 的值替换为 S 的值。
    • 删除 S 节点。
二叉搜索树删除含有双子节点元素的过程示例代码
class TreeNode {
    int val;
    TreeNode left, right;

    TreeNode(int val) {
        this.val = val;
    }
}

public class BinarySearchTree {
    TreeNode root;

    // 删除节点
    public TreeNode deleteNode(TreeNode root, int key) {
        if (root == null) return null;

        if (key < root.val) {
            root.left = deleteNode(root.left, key); // 在左子树中递归删除
        } else if (key > root.val) {
            root.right = deleteNode(root.right, key); // 在右子树中递归删除
        } else {
            // 找到要删除的节点
            if (root.left == null) return root.right; // 只有右子节点或无子节点
            if (root.right == null) return root.left; // 只有左子节点
            
            // 有两个子节点,找到右子树的最小值节点
            TreeNode minNode = findMin(root.right);
            root.val = minNode.val; // 替换当前节点的值为最小值
            root.right = deleteNode(root.right, minNode.val); // 删除右子树中的最小节点
        }
        return root;
    }

    // 查找最小值节点
    private TreeNode findMin(TreeNode node) {
        while (node.left != null) {
            node = node.left;
        }
        return node;
    }
}

这段代码展示了如何删除二叉搜索树中具有双子节点的元素。

4. 二叉搜索树的节点都包括了哪些信息

在一个典型的二叉搜索树中,每个节点通常包含以下信息:

  • 值(value):存储在该节点中的实际数据。
  • 左子节点(left):指向左子树的节点。
  • 右子节点(right):指向右子树的节点。
  • 父节点(parent)(可选):指向该节点的父节点,方便执行某些操作,如向上回溯。

在某些实现中,节点还可以包含其他信息,如节点的高度或子树的大小(用于保持树的平衡)。

5. 为什么 Java HashMap 中使用红黑树而不使用二叉搜索树

在 Java 8 之后,HashMap 的实现中引入了红黑树,当链表长度超过阈值(默认为 8)时,链表会转换成红黑树,而不是二叉搜索树。这是因为:

  1. 红黑树是平衡二叉搜索树

    • 红黑树是一种自平衡的二叉搜索树,保证最坏情况下的时间复杂度为 O(log n)。而普通的二叉搜索树如果不进行平衡操作,可能会退化为链表,导致最坏情况下的操作复杂度变为 O(n)
  2. 哈希冲突处理

    • HashMap 使用红黑树处理哈希冲突时,可以在冲突的情况下仍然保证 O(log n) 的查找、插入和删除时间。
  3. 避免性能退化

    • 普通的二叉搜索树可能在遇到某些特殊数据(如升序或降序插入)时退化为线性结构(即链表),导致性能急剧下降。而红黑树通过旋转和重新着色来保持树的平衡,从而避免这种情况。

5. 平衡二叉树(Balanced Binary Tree)

最常见的平衡二叉树是AVL树和红黑树。AVL树每个节点的左右子树高度差不超过1。

这里以AVL树为例,提供插入操作的示例。

Java示例:

class AVLNode {
    int key, height;
    AVLNode left, right;

    AVLNode(int d) {
        key = d;
        height = 1;
    }
}

class AVLTree {
    AVLNode root;

    int height(AVLNode N) {
        if (N == null)
            return 0;
        return N.height;
    }

    // 右旋转
    AVLNode rightRotate(AVLNode y) {
        AVLNode x = y.left;
        AVLNode T2 = x.right;

        x.right = y;
        y.left = T2;

        y.height = Math.max(height(y.left), height(y.right)) + 1;
        x.height = Math.max(height(x.left), height(x.right)) + 1;

        return x;
    }

    // 左旋转
    AVLNode leftRotate(AVLNode x) {
        AVLNode y = x.right;
        AVLNode T2 = y.left;

        y.left = x;
        x.right = T2;

        x.height = Math.max(height(x.left), height(x.right)) + 1;
        y.height = Math.max(height(y.left), height(y.right)) + 1;

        return y;
    }

    int getBalance(AVLNode N) {
        if (N == null)
            return 0;
        return height(N.left) - height(N.right);
    }

    AVLNode insert(AVLNode node, int key) {
        if (node == null)
            return (new AVLNode(key));

        if (key < node.key)
            node.left = insert(node.left, key);
        else if (key > node.key)
            node.right = insert(node.right, key);
        else
            return node;

        node.height = 1 + Math.max(height(node.left), height(node.right));

        int balance = getBalance(node);

        if (balance > 1 && key < node.left.key)
            return rightRotate(node);

        if (balance < -1 && key > node.right.key)
            return leftRotate(node);

        if (balance > 1 && key > node.left.key) {
            node.left = leftRotate(node.left);
            return rightRotate(node);
        }

        if (balance < -1 && key < node.right.key) {
            node.right = rightRotate(node.right);
            return leftRotate(node);
        }

        return node;
    }

    public void inorder(AVLNode node) {
        if (node != null) {
            inorder(node.left);
            System.out.print(node.key + " ");
            inorder(node.right);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        AVLTree tree = new AVLTree();
        tree.root = tree.insert(tree.root, 10);
        tree.root = tree.insert(tree.root, 20);
        tree.root = tree.insert(tree.root, 30);
        tree.root = tree.insert(tree.root, 40);
        tree.root = tree.insert(tree.root, 50);
        tree.root = tree.insert(tree.root, 25);

        tree.inorder(tree.root);  // 输出:10 20 25 30 40 50
    }
}

面试题

1. AVL 树平衡因子怎么计算?

在 AVL 树中,平衡因子(Balance Factor, BF) 是一个节点的左子树高度减去右子树高度的值,用于判断该节点是否平衡。具体计算方法如下:

平衡因子 = 左子树高度 − 右子树高度 \text{平衡因子} = \text{左子树高度} - \text{右子树高度} 平衡因子=左子树高度右子树高度

对于 AVL 树中的每个节点,平衡因子的值应当在 -1 到 +1 之间。如果某个节点的平衡因子超出这个范围,就需要进行旋转操作来重新平衡树。

2. AVL 树左旋操作的目的是什么?

左旋操作 的主要目的是解决因右子树过高而导致的不平衡问题。当一个节点的右子树比左子树高出两层或更多时(即平衡因子为 -2),左旋操作可以将树的右侧“下沉”,将该节点转变为其右子节点的左子节点,从而重新平衡树。

3. AVL 树左旋操作的流程是什么?

以下是 AVL 树左旋操作的步骤:

  1. 选择旋转点:假设不平衡节点是 x,其右子节点为 y
  2. y 的左子树 变为 x 的右子树(y.left)。
  3. 更新 xy 的位置:将 y 移到 x 的位置,x 变为 y 的左子节点。
  4. 调整指针:将 x 变为 y 的左子节点,将 y 的左子树设置为 x 的右子树。

下面是 Java 代码示例,展示了左旋操作:

class TreeNode {
    int val;
    TreeNode left, right;
    int height;

    TreeNode(int val) {
        this.val = val;
        this.height = 1; // 新节点的高度为 1
    }
}

public class AVLTree {
    // 左旋操作
    private TreeNode leftRotate(TreeNode x) {
        TreeNode y = x.right; // 右子节点
        TreeNode T2 = y.left; // y 的左子树

        // 执行左旋
        y.left = x;
        x.right = T2;

        // 更新高度
        x.height = Math.max(height(x.left), height(x.right)) + 1;
        y.height = Math.max(height(y.left), height(y.right)) + 1;

        // 返回新的根节点
        return y;
    }

    private int height(TreeNode node) {
        return node == null ? 0 : node.height;
    }
}
4. AVL 树什么情况下要左旋+右旋?

在 AVL 树中,有四种主要的不平衡情况,通常需要使用双旋转来恢复平衡:

  1. 左-左(LL):当插入操作导致左子树的左子节点过高时,进行右旋。
  2. 右-右(RR):当插入操作导致右子树的右子节点过高时,进行左旋。
  3. 左-右(LR):当插入操作导致左子树的右子节点过高时,先对左子树的右子节点进行左旋,然后对根节点进行右旋。
  4. 右-左(RL):当插入操作导致右子树的左子节点过高时,先对右子树的左子节点进行右旋,然后对根节点进行左旋。

双旋转通常用于处理更复杂的失衡情况,以下是双旋转操作的示例:

// 左-右(LR)旋转
private TreeNode leftRightRotate(TreeNode node) {
    node.left = leftRotate(node.left); // 对左子树进行左旋
    return rightRotate(node); // 对当前节点进行右旋
}

// 右-左(RL)旋转
private TreeNode rightLeftRotate(TreeNode node) {
    node.right = rightRotate(node.right); // 对右子树进行右旋
    return leftRotate(node); // 对当前节点进行左旋
}
5. AVL 树的插入和读取的时间复杂度
  • 插入操作:AVL 树的插入操作需要维护树的平衡,因此插入的时间复杂度为 O(log n)。虽然每次插入后可能需要进行旋转操作,但每次旋转操作的时间复杂度是常数级别,所以整体复杂度仍为 O(log n)

  • 读取操作(查找操作):AVL 树的查找操作也需要遵循树的平衡性质,因此读取(查找)的时间复杂度为 O(log n)。由于 AVL 树保持平衡,所以查找操作的复杂度较低。

AVL 树的这些复杂度保证了其在各种操作中的高效性,特别是在需要频繁插入和查找的应用场景中。

6. 红黑树(Red-Black Tree)

Java使用示例:

import java.util.TreeMap;

public class RedBlackTreeExample {
    public static void main(String[] args) {
        TreeMap<Integer, String> treeMap = new TreeMap<>();
        
        // 插入元素
        treeMap.put(10, "Ten");
        treeMap.put(20, "Twenty");
        treeMap.put(15, "Fifteen");
        
        // 打印所有键值对
        for (Integer key : treeMap.keySet()) {
            System.out.println(key + " -> " + treeMap.get(key));
        }
        
        // 查找某个键
        System.out.println("Value for key 20: " + treeMap.get(20));
        
        // 删除某个键
        treeMap.remove(15);
        System.out.println("After removal, value for key 15: " + treeMap.get(15));
    }
}

TreeMap 内部使用红黑树来保持有序性。

面试题

1. 红黑树的使用场景

红黑树是一种自平衡的二叉搜索树,其广泛应用于需要高效查找、插入和删除操作的场景。以下是红黑树的典型使用场景:

  1. 数据存储系统:如数据库和文件系统中的索引结构,因为这些系统需要频繁地执行插入、删除和查找操作。
  2. 内存管理:例如,操作系统的内存分配算法中,红黑树可以用来维护空闲内存块。
  3. 集合类实现:如 Java 的 TreeMapTreeSet 类,红黑树提供了有序的键值存储。
  4. 缓存系统:在需要维护一组有序数据并进行高效查找的缓存系统中,红黑树可以作为基础数据结构。
2. 相比于 BST 树,红黑树的用途

红黑树与普通的二叉搜索树(BST)相比,主要有以下优势:

  1. 平衡性

    • 红黑树在插入和删除操作后能够保持自平衡,确保树的高度是对数级别,操作复杂度为 O(log n)。而普通 BST 可能会退化为链表,导致操作时间复杂度为 O(n)
  2. 更一致的性能

    • 由于红黑树始终保持平衡,插入和删除操作的最坏情况下时间复杂度是 O(log n),而普通 BST 在极端情况下(例如有序数据插入)可能表现为线性时间复杂度。
3. B-树是什么意思,都包括哪些?

B-树是一种自平衡的树数据结构,广泛用于数据库和文件系统中。它的主要特点是可以有多个子节点,支持高效的查找、插入和删除操作。

B-树的特点

  1. 多子节点:每个节点可以有多个子节点。
  2. 排序:所有叶子节点在同一层级,并且每个节点的键值是有序的。
  3. 平衡性:所有叶子节点的深度相同,确保查找操作的时间复杂度是对数级别。

B-树的变种

  • B+树:B+树是 B-树的一种变体,其中所有的键都出现在叶子节点,内部节点仅用作导航。
  • B*树:B*树是在 B+树的基础上增加了更多的节点分裂和合并规则,进一步提高了树的使用效率。
4. 新增加一个节点后,什么情况下需要染色、什么情况要左旋、什么情况要左旋+右旋?

在红黑树中,插入新节点后需要执行一些操作以维护树的性质。以下是不同情况下的处理方式:

  1. 染色(重新上色)

    • 在插入新节点后,如果新节点的父节点是红色,则需要进行重新染色。通常这意味着父节点和叔叔节点的颜色需要调整,以保持红黑树的性质。
    • 处理的过程包括将父节点和叔叔节点染成黑色,将祖父节点染成红色,并递归调整上层节点。
  2. 左旋

    • 情况:在树中出现了右-右(RR)不平衡时,即当前节点的右子节点的右子树过高。
    • 处理:对当前节点进行左旋,将右子节点提升为当前节点的新根,当前节点作为其左子节点。
  3. 右旋

    • 情况:在树中出现了左-左(LL)不平衡时,即当前节点的左子节点的左子树过高。
    • 处理:对当前节点进行右旋,将左子节点提升为当前节点的新根,当前节点作为其右子节点。
  4. 左旋 + 右旋

    • 情况:在树中出现了左-右(LR)不平衡时,即当前节点的左子节点的右子树过高。

    • 处理:先对左子节点进行左旋,使其变成左-左情况,然后对当前节点进行右旋。

    • 右旋 + 左旋

    • 情况:在树中出现了右-左(RL)不平衡时,即当前节点的右子节点的左子树过高。

    • 处理:先对右子节点进行右旋,使其变成右-右情况,然后对当前节点进行左旋。

5. 红黑树的特点
  1. 节点颜色:每个节点是红色或黑色。
  2. 根节点:根节点是黑色。
  3. 红色节点的子节点:红色节点的子节点必须是黑色(即没有两个相邻的红色节点)。
  4. 黑色节点的数量:从任何节点到其每个叶子节点的路径上,必须包含相同数量的黑色节点(即黑色高度)。
  5. 叶子节点:所有叶子节点(空节点或 NIL 节点)都是黑色。

红黑树通过这些规则来保证树的平衡性,从而在插入、删除和查找操作中保持对数级别的时间复杂度。

7. 2-3树(2-3 Tree)

2-3树是一种自平衡的查找树,其中每个节点可以有两个或三个子节点。

// 这是一个简化的2-3树节点示例,实际实现会复杂得多
class Node {
    int[] keys;
    Node[] children;
    boolean isLeaf;
    int numKeys;

    Node(int degree, boolean isLeaf) {
        keys = new int[degree - 1];
        children = new Node[degree];
        this.isLeaf = isLeaf;
        numKeys = 0;
    }
}

class TwoThreeTree {
    private Node root;
    private int degree;

    TwoThreeTree(int degree) {
        this.degree = degree;
        root = new Node(degree, true);
    }

    // 插入、删除及搜索操作会更复杂,这里仅展示一个结构的初始化
    public void insert(int key) {
        // 插入操作的实现将涉及到分裂节点等操作
    }
}

public class Main {
    public static void main(String[] args) {
        TwoThreeTree tree = new TwoThreeTree(3);
        tree.insert(10);
        tree.insert(20);
        tree.insert(15);

        // 实际使用时,需要实现遍历和验证树结构
    }
}

面试题

1. 2-3 树的数据结构描述

2-3 树 是一种自平衡的搜索树,每个节点可以有两种形态:

  • 2 节点:包含一个元素,两个子节点。
  • 3 节点:包含两个元素,三个子节点。
2-3 树的特性:
  1. 每个节点
    • 2 节点:存储一个键,最多有两个子节点。
    • 3 节点:存储两个键,最多有三个子节点。
  2. 所有叶子节点:具有相同的深度(即树的高度一致)。
  3. 节点之间的键值:始终是有序的,即父节点的键值大于左子树中所有键值、小于右子树中所有键值。
2. 2-3 树一个节点最多可以存放几个元素
  • 2 节点:一个元素。
  • 3 节点:两个元素。

因此,一个节点最多可以存放两个元素。

3. 2-3 树插入节点时间复杂度

2-3 树的插入操作通常包括以下步骤:

  1. 在叶子节点处进行插入操作。
  2. 处理可能发生的节点分裂,以保持树的平衡。

插入的时间复杂度为 O(log n),因为在最坏情况下需要对树的高度进行调整,而树的高度是对数级别的。

4. 2-3 树一个节点有 3 个元素,如何迁移?需要旋转吗?

在 2-3 树中,节点的数量分布是有严格限制的。每个节点最多只能有两个元素,因此当一个节点(如 3 节点)被插入新元素后,必须进行分裂以保持树的性质。

处理过程

  1. 分裂节点:当一个节点(如 3 节点)达到两个元素并需要插入新元素时,它会被分裂成两个 2 节点,其中中间的元素上升到父节点。
  2. 更新父节点:中间元素被提升到父节点,如果父节点也满了,则递归处理。

例如,假设我们有一个 3 节点([A, B]),并且我们要插入 C。节点 [A, B] 会被分裂为两个 2 节点,并且中间的元素 B 将被提升到父节点。

5. 2-3 树的手写示例

下面是一个简单的 2-3 树的 Java 实现,包括插入操作和节点分裂的处理:

class Node {
    int[] keys = new int[2]; // 2-3 节点最多包含两个键
    Node[] children = new Node[3]; // 2-3 节点最多有三个子节点
    int numKeys = 0; // 当前节点的键数量

    // 判断当前节点是否为 2 节点
    boolean is2Node() {
        return numKeys == 1;
    }

    // 判断当前节点是否为 3 节点
    boolean is3Node() {
        return numKeys == 2;
    }
}

class TwoThreeTree {
    Node root;

    // 插入操作
    public void insert(int key) {
        if (root == null) {
            root = new Node();
            root.keys[0] = key;
            root.numKeys = 1;
        } else {
            Node newRoot = insert(root, key);
            if (newRoot != null) {
                root = newRoot; // 新的根节点
            }
        }
    }

    private Node insert(Node node, int key) {
        if (node.children[0] == null) { // 叶子节点
            if (node.is2Node()) {
                insertInto2Node(node, key);
                return null;
            } else { // 当前节点是 3 节点
                return splitNode(node, key);
            }
        } else { // 内部节点
            Node child = getChild(node, key);
            Node newChild = insert(child, key);
            if (newChild != null) {
                return splitNode(node, newChild.keys[0], newChild);
            }
            return null;
        }
    }

    private void insertInto2Node(Node node, int key) {
        if (key < node.keys[0]) {
            node.keys[1] = node.keys[0];
            node.keys[0] = key;
        } else {
            node.keys[1] = key;
        }
        node.numKeys = 2;
    }

    private Node splitNode(Node node, int key) {
        Node newNode = new Node();
        if (key < node.keys[0]) {
            newNode.keys[0] = node.keys[1];
            node.numKeys = 1;
            newNode.numKeys = 1;
            newNode.children[0] = node.children[1];
            newNode.children[1] = node.children[2];
            node.children[1] = node.children[0];
            node.children[2] = null;
            node.keys[1] = key;
        } else {
            newNode.keys[0] = key;
            node.numKeys = 1;
            newNode.numKeys = 1;
            newNode.children[0] = node.children[1];
            newNode.children[1] = node.children[2];
            node.children[1] = node.children[0];
            node.children[2] = null;
        }
        return newNode;
    }

    private Node getChild(Node node, int key) {
        if (key < node.keys[0]) {
            return node.children[0];
        } else if (node.numKeys == 1 || key < node.keys[1]) {
            return node.children[1];
        } else {
            return node.children[2];
        }
    }

    private Node splitNode(Node node, int key, Node newChild) {
        Node newNode = new Node();
        if (key < node.keys[0]) {
            newNode.keys[0] = node.keys[1];
            newNode.children[0] = node.children[2];
            newNode.children[1] = newChild;
            node.keys[1] = key;
            node.numKeys = 1;
            newNode.numKeys = 1;
        } else {
            newNode.keys[0] = key;
            newNode.children[0] = newChild;
            newNode.children[1] = node.children[2];
            node.numKeys = 1;
            newNode.numKeys = 1;
        }
        return newNode;
    }
}

解释:

  • 插入操作:首先在叶子节点插入元素,如果节点已满则进行分裂。
  • 分裂节点:当节点变为 3 节点时,将其分裂为两个 2 节点,并将中间的键提升到父节点。
  • 处理树的高度:分裂节点的过程中可能需要递归地更新父节点的结构,如果父节点也变满,则需要进一步处理。

这个简单的实现示例可以帮助理解 2-3 树的基本操作。实际应用中,还需要处理更多细节和特殊情况。

8. 并查集(Disjoint Set)

并查集用于处理一些不交集的合并和查找操作,常用于处理动态连通性问题。

Java示例:

class DisjointSet {
    private int[] parent;
    private int[] rank;

    public DisjointSet(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            rank[i] = 0;
        }
    }

    public int find(int u) {
        if (parent[u] != u) {
            parent[u] = find(parent[u]);  // 路径压缩
        }
        return parent[u];
    }

    public void union(int u, int v) {
        int rootU = find(u);
        int rootV = find(v);

        if (rootU != rootV) {
            if (rank[rootU] > rank[rootV]) {
                parent[rootV] = rootU;
            } else if (rank[rootU] < rank[rootV]) {
                parent[rootU] = rootV;
            } else {
                parent[rootV] = rootU;
                rank[rootU]++;
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        DisjointSet ds = new DisjointSet(5);

        ds.union(0, 1);
        ds.union(1, 2);
        ds.union(3, 4);

        System.out.println("Find 0: " + ds.find(0));  // 输出:0
        System.out.println("Find 2: " + ds.find(2));  // 输出:0
        System.out.println("Find 3: " + ds.find(3));  // 输出:3
        System.out.println("Find 4: " + ds.find(4));  // 输出:3
    }
}

面试题

并查集叙述

并查集(Disjoint Set Union, DSU)是一种数据结构,用于处理一些不交集(disjoint sets)合并和查询操作。它支持两个主要操作:

  1. 查找(Find):确定某个元素属于哪个集合(即找到元素的根节点)。
  2. 合并(Union):将两个集合合并为一个集合。

并查集的核心在于高效地处理集合的合并和查找操作,它可以通过以下两个主要策略来优化:

  1. 路径压缩(Path Compression):在查找操作中,将路径上的节点直接连接到根节点,从而加快后续的查找操作。
  2. 按秩合并(Union by Rank/Size):在合并操作中,将较小的树挂在较大的树下,从而保持树的高度尽可能小。

并查集的使用场景

并查集广泛应用于处理具有集合集合关系的问题,以下是一些常见的应用场景:

  1. 网络连接问题:如判断网络中的两个节点是否连通。
  2. 动态连通性问题:在图的动态变化中维护连通性。
  3. 克鲁斯卡尔算法:用于解决最小生成树问题,通过并查集来检测图中是否形成环。
  4. 图的连通性:在处理图算法时,判断不同节点是否在同一个连通分量中。

并查集怎么合并元素?

在并查集中,合并两个元素时,实际上是将它们所在的集合合并。基本步骤如下:

  1. 找到根节点:对两个元素分别进行查找操作,找到它们各自的根节点。
  2. 连接根节点:将一个根节点连接到另一个根节点上,形成一个新的集合。

并查集合并元素的优化策略

并查集的合并操作可以通过以下策略进行优化:

  1. 按秩合并(Union by Rank/Size)

    • 策略:在合并两个集合时,总是将较小的树(节点数较少)挂在较大的树上。这样可以避免树的高度过高,保证操作的时间复杂度接近常数级别。
    • 实现:使用一个额外的数组来记录每个根节点的秩(树的高度)或大小(子树的节点数),合并时总是将秩或大小较小的树挂在秩或大小较大的树下。
  2. 路径压缩(Path Compression)

    • 策略:在查找操作中,将路径上的每个节点直接连接到根节点。这样在后续的查找操作中,可以大大减少树的高度。
    • 实现:在执行查找操作时,将当前节点的所有父节点直接连接到根节点。

如何压缩路径?

路径压缩是一个在查找操作中优化并查集的方法,通过将路径上的所有节点直接连接到根节点,来减少树的高度。以下是路径压缩的具体实现方法:

路径压缩实现代码示例(Java):

class DisjointSet {
    private int[] parent;
    private int[] rank;

    public DisjointSet(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i; // 初始时每个元素的父节点是自己
            rank[i] = 0;   // 初始时每个元素的秩为 0
        }
    }

    // 查找操作(带路径压缩)
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // 路径压缩
        }
        return parent[x];
    }

    // 合并操作(带按秩合并)
    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);

        if (rootX != rootY) {
            // 按秩合并
            if (rank[rootX] > rank[rootY]) {
                parent[rootY] = rootX;
            } else if (rank[rootX] < rank[rootY]) {
                parent[rootX] = rootY;
            } else {
                parent[rootY] = rootX;
                rank[rootX]++;
            }
        }
    }
}

解释:

  1. 查找(find):递归地查找元素的根节点,并在过程中将路径上的每个节点直接连接到根节点,达到路径压缩的效果。
  2. 合并(union):将两个集合合并时,使用按秩合并的策略,确保树的高度尽可能小,减少操作的时间复杂度。

通过路径压缩和按秩合并,并查集能够高效地处理集合的合并和查找操作,并在实际应用中表现出接近常数时间复杂度。

图论

图数据结构(Graph Data Structure)

图是一种由节点(顶点)和节点之间的连接(边)组成的结构,广泛用于建模现实世界的关系网络。图可以是有向的或无向的,有权或无权。图的常见操作包括搜索、遍历、路径查找等。

1. 图的表示

图可以通过邻接矩阵邻接表表示。

**邻接矩阵:**使用二维数组表示图的连接关系,矩阵中的值表示两个顶点是否有边。

class GraphMatrix {
    private int[][] adjMatrix;
    private int numVertices;

    public GraphMatrix(int numVertices) {
        this.numVertices = numVertices;
        adjMatrix = new int[numVertices][numVertices];
    }

    public void addEdge(int i, int j) {
        adjMatrix[i][j] = 1;  // 无向图可以同时设置 adjMatrix[i][j] 和 adjMatrix[j][i]
    }

    public void removeEdge(int i, int j) {
        adjMatrix[i][j] = 0;
    }

    public void printGraph() {
        for (int i = 0; i < numVertices; i++) {
            for (int j = 0; j < numVertices; j++) {
                System.out.print(adjMatrix[i][j] + " ");
            }
            System.out.println();
        }
    }
}

邻接表: 使用链表或数组列表来存储每个顶点的邻接节点。

import java.util.LinkedList;

class GraphList {
    private LinkedList<Integer>[] adjList;

    public GraphList(int numVertices) {
        adjList = new LinkedList[numVertices];
        for (int i = 0; i < numVertices; i++) {
            adjList[i] = new LinkedList<>();
        }
    }

    public void addEdge(int i, int j) {
        adjList[i].add(j);  // 无向图可以同时加入 adjList[i] 和 adjList[j]
    }

    public void printGraph() {
        for (int i = 0; i < adjList.length; i++) {
            System.out.print("Vertex " + i + ": ");
            for (Integer neighbor : adjList[i]) {
                System.out.print(neighbor + " ");
            }
            System.out.println();
        }
    }
}

2. 图的遍历

图的两种主要遍历方式是深度优先搜索(DFS)广度优先搜索(BFS)

DFS:

import java.util.*;

class GraphDFS {
    private LinkedList<Integer>[] adjList;

    public GraphDFS(int numVertices) {
        adjList = new LinkedList[numVertices];
        for (int i = 0; i < numVertices; i++) {
            adjList[i] = new LinkedList<>();
        }
    }

    public void addEdge(int i, int j) {
        adjList[i].add(j);
    }

    public void DFS(int start) {
        boolean[] visited = new boolean[adjList.length];
        DFSUtil(start, visited);
    }

    private void DFSUtil(int v, boolean[] visited) {
        visited[v] = true;
        System.out.print(v + " ");
        for (int neighbor : adjList[v]) {
            if (!visited[neighbor]) {
                DFSUtil(neighbor, visited);
            }
        }
    }
}

BFS:

import java.util.*;

class GraphBFS {
    private LinkedList<Integer>[] adjList;

    public GraphBFS(int numVertices) {
        adjList = new LinkedList[numVertices];
        for (int i = 0; i < numVertices; i++) {
            adjList[i] = new LinkedList<>();
        }
    }

    public void addEdge(int i, int j) {
        adjList[i].add(j);
    }

    public void BFS(int start) {
        boolean[] visited = new boolean[adjList.length];
        Queue<Integer> queue = new LinkedList<>();
        visited[start] = true;
        queue.add(start);

        while (!queue.isEmpty()) {
            int v = queue.poll();
            System.out.print(v + " ");
            for (int neighbor : adjList[v]) {
                if (!visited[neighbor]) {
                    visited[neighbor] = true;
                    queue.add(neighbor);
                }
            }
        }
    }
}

面试题

图的使用场景

是一种用于表示对象及其之间关系的数据结构。图在很多实际应用中都有重要作用,以下是一些常见的使用场景:

  1. 社交网络:在社交媒体平台中,用户与用户之间的关系可以用图来表示,节点表示用户,边表示他们之间的联系(例如好友、关注者)。
  2. 网络路由:计算机网络中,节点表示计算机或路由器,边表示它们之间的连接,图用于寻找最优路径、网络流量分析等。
  3. 任务调度:在任务调度和资源分配中,任务之间的依赖关系可以用图表示,帮助确定任务的执行顺序。
  4. 图像处理:在图像分割、图像配准等领域,图可以用来表示像素之间的关系。
  5. 搜索引擎:网页之间的超链接可以表示为图,图算法用于网页排名、搜索优化等。
  6. 电路设计:电路图可以用图来表示,节点代表电路组件,边代表电线或连接。
  7. 旅行问题:在旅行规划中,地点可以用图表示,边表示地点之间的旅行路线,图算法可以帮助寻找最短路径。

图的分类

图的分类可以基于多个标准进行:

  1. 按边的方向

    • 无向图:边没有方向,表示两个节点之间的双向关系。
    • 有向图:边有方向,表示一个节点指向另一个节点的单向关系。
  2. 按权重

    • 加权图:边具有权重,通常用于表示成本、距离等。
    • 无权图:边没有权重,表示仅有连接关系。
  3. 按图的连通性

    • 连通图:任意两个节点之间都有路径相连。
    • 非连通图:存在一些节点无法通过任何路径相连。
  4. 按是否有环

    • 有环图:图中存在至少一个环。
    • 无环图:图中不存在环。
  5. 特殊图

    • :一种特殊的无环连通图。
    • 森林:一组树的集合,或是无环的非连通图。

图怎么存放权重值

在加权图中,边的权重可以通过以下两种主要方式存储:

  1. 邻接矩阵(Adjacency Matrix)

    • 使用一个二维数组表示图,其中 matrix[i][j] 表示从节点 i 到节点 j 的边的权重。如果是无向图,则矩阵是对称的。对于无权图,矩阵中可以用 10 表示边的存在与否。
    int[][] graph = new int[V][V];
    
  2. 邻接表(Adjacency List)

    • 使用一个数组或链表来表示每个节点的邻居,同时存储边的权重。例如,每个节点的邻接表可以存储一个包含邻接节点和边权重的列表。
    import java.util.*;
    
    class Graph {
        private final int V;
        private final LinkedList<Edge>[] adjList;
    
        class Edge {
            int dest;
            int weight;
    
            Edge(int dest, int weight) {
                this.dest = dest;
                this.weight = weight;
            }
        }
    
        Graph(int V) {
            this.V = V;
            adjList = new LinkedList[V];
            for (int i = 0; i < V; i++) {
                adjList[i] = new LinkedList<>();
            }
        }
    
        void addEdge(int src, int dest, int weight) {
            adjList[src].add(new Edge(dest, weight));
            // For undirected graph:
            adjList[dest].add(new Edge(src, weight));
        }
    }
    

图的广度遍历(BFS)

广度遍历(BFS, Breadth-First Search)是一种图遍历算法,用于逐层访问节点。BFS 的主要思想是从起始节点开始,访问所有相邻的节点,然后依次访问下一层的节点。

BFS 实现示例(Java):

import java.util.*;

class GraphBFS {
    private final int V;
    private final LinkedList<Integer>[] adjList;

    GraphBFS(int V) {
        this.V = V;
        adjList = new LinkedList[V];
        for (int i = 0; i < V; i++) {
            adjList[i] = new LinkedList<>();
        }
    }

    void addEdge(int src, int dest) {
        adjList[src].add(dest);
        adjList[dest].add(src); // For undirected graph
    }

    void BFS(int start) {
        boolean[] visited = new boolean[V];
        LinkedList<Integer> queue = new LinkedList<>();

        visited[start] = true;
        queue.add(start);

        while (queue.size() != 0) {
            int node = queue.poll();
            System.out.print(node + " ");

            for (Integer neighbor : adjList[node]) {
                if (!visited[neighbor]) {
                    visited[neighbor] = true;
                    queue.add(neighbor);
                }
            }
        }
    }
}

图的深度遍历(DFS)

深度遍历(DFS, Depth-First Search)是一种图遍历算法,优先访问节点的深层次子节点。DFS 可以使用递归或栈来实现。

DFS 实现示例(Java):

import java.util.*;

class GraphDFS {
    private final int V;
    private final LinkedList<Integer>[] adjList;

    GraphDFS(int V) {
        this.V = V;
        adjList = new LinkedList[V];
        for (int i = 0; i < V; i++) {
            adjList[i] = new LinkedList<>();
        }
    }

    void addEdge(int src, int dest) {
        adjList[src].add(dest);
        adjList[dest].add(src); // For undirected graph
    }

    void DFS(int start) {
        boolean[] visited = new boolean[V];
        DFSUtil(start, visited);
    }

    private void DFSUtil(int node, boolean[] visited) {
        visited[node] = true;
        System.out.print(node + " ");

        for (Integer neighbor : adjList[node]) {
            if (!visited[neighbor]) {
                DFSUtil(neighbor, visited);
            }
        }
    }
}

总结

  • 可以用于表示各种对象及其关系,并在多种实际场景中得到应用。
  • 图的分类:按边的方向、权重、连通性、环的存在等进行分类。
  • 权重存储:使用邻接矩阵或邻接表存储图的权重。
  • BFS:逐层访问节点,适合寻找最短路径。
  • DFS:深度优先访问节点,适合路径查找和连通性检测。

布隆过滤器(Bloom Filter)

布隆过滤器是一种用于快速判断元素是否在集合中的概率型数据结构。它具有非常低的误判率(即可能误判一个不存在的元素为存在),但不会误判实际存在的元素为不存在。它使用多个哈希函数和位数组来实现。

布隆过滤器的基本原理:

  1. 初始化一个位数组,将所有位设为0。
  2. 使用多个不同的哈希函数将元素映射到位数组的多个位置,并将这些位置的值设为1。
  3. 在查询时,同样使用这些哈希函数检查位数组中相应位置的值,如果所有位置都为1,则认为元素可能存在;如果有一个位置为0,则元素一定不存在。

布隆过滤器的Java实现:

import java.util.BitSet;
import java.util.Random;

class BloomFilter {
    private BitSet bitSet;
    private int bitSize;
    private int[] hashSeeds;  // 哈希种子
    private int numHashFunctions;

    public BloomFilter(int bitSize, int numHashFunctions) {
        this.bitSize = bitSize;
        this.bitSet = new BitSet(bitSize);
        this.numHashFunctions = numHashFunctions;
        this.hashSeeds = new int[numHashFunctions];

        // 生成不同的哈希种子
        Random rand = new Random();
        for (int i = 0; i < numHashFunctions; i++) {
            hashSeeds[i] = rand.nextInt();
        }
    }

    // 简单哈希函数
    private int hash(String data, int seed) {
        int hash = 0;
        for (int i = 0; i < data.length(); i++) {
            hash = seed * hash + data.charAt(i);
        }
        return Math.abs(hash % bitSize);
    }

    // 添加元素到布隆过滤器
    public void add(String data) {
        for (int i = 0; i < numHashFunctions; i++) {
            int hashValue = hash(data, hashSeeds[i]);
            bitSet.set(hashValue);
        }
    }

    // 检查元素是否可能存在
    public boolean mightContain(String data) {
        for (int i = 0; i < numHashFunctions; i++) {
            int hashValue = hash(data, hashSeeds[i]);
            if (!bitSet.get(hashValue)) {
                return false;  // 如果有一位不为1,则元素一定不存在
            }
        }
        return true;  // 所有位都为1,则可能存在
    }
}

public class Main {
    public static void main(String[] args) {
        BloomFilter bloomFilter = new BloomFilter(1000, 3);

        // 添加元素
        bloomFilter.add("hello");
        bloomFilter.add("world");

        // 查询元素
        System.out.println(bloomFilter.mightContain("hello"));  // 输出 true
        System.out.println(bloomFilter.mightContain("java"));   // 输出 false
    }
}

布隆过滤器适用于需要快速查询且允许一定误判的场景,例如缓存命中、黑名单检测等。

面试题

布隆过滤器的使用场景

布隆过滤器是一种空间高效的概率数据结构,用于检测一个元素是否在集合中。它具有以下典型使用场景:

  1. 缓存系统:用于判断某个元素是否在缓存中,以减少对后端存储的访问。例如,Google Bigtable 使用布隆过滤器来避免不必要的磁盘访问。
  2. 数据库:用于优化数据库查询,减少磁盘IO操作。例如,HBase 和 Cassandra 使用布隆过滤器来快速排除不可能包含目标数据的块。
  3. 网络安全:用于检测恶意软件或病毒是否已知,避免对完整数据库进行检查。
  4. 数据去重:在大数据处理和数据清洗中,布隆过滤器可以用于快速判断数据是否已经出现过。
  5. 搜索引擎:在全文搜索和索引构建中,布隆过滤器用于提高检索效率,避免对无关数据进行处理。

布隆过滤器的实现原理和方式

布隆过滤器的核心原理是使用多个哈希函数将元素映射到一个位数组中,并通过这些位数组中的位来判断一个元素是否存在。它的基本实现方式如下:

  1. 位数组:布隆过滤器使用一个大小为 m 的位数组(通常初始化为全零)。
  2. 哈希函数:布隆过滤器使用 k 个哈希函数,每个哈希函数将元素映射到位数组中的某些位置。
  3. 插入操作:对于每个要插入的元素,使用 k 个哈希函数计算出 k 个位置,并将这些位置的位设置为 1。
  4. 查询操作:对于每个查询的元素,使用相同的 k 个哈希函数计算出 k 个位置,并检查这些位置的位是否都是 1。如果所有位置的位都是 1,则可能存在;如果有任何位置的位为 0,则一定不存在。

布隆过滤器的代码示例(Java):

import java.util.BitSet;
import java.util.function.Function;

public class BloomFilter {
    private final BitSet bitSet;
    private final int size;
    private final Function<String, Integer>[] hashFunctions;

    @SuppressWarnings("unchecked")
    public BloomFilter(int size, int hashFunctionsCount) {
        this.size = size;
        bitSet = new BitSet(size);
        hashFunctions = new Function[hashFunctionsCount];
        for (int i = 0; i < hashFunctionsCount; i++) {
            final int seed = i;
            hashFunctions[i] = s -> (s.hashCode() + seed) % size;
        }
    }

    public void add(String item) {
        for (Function<String, Integer> hashFunction : hashFunctions) {
            int hash = hashFunction.apply(item);
            bitSet.set(hash);
        }
    }

    public boolean mightContain(String item) {
        for (Function<String, Integer> hashFunction : hashFunctions) {
            int hash = hashFunction.apply(item);
            if (!bitSet.get(hash)) {
                return false;
            }
        }
        return true;
    }
}

如何提高布隆过滤器的准确性

布隆过滤器的准确性主要体现在误报率(false positive rate),它是布隆过滤器返回“存在”时实际并不在集合中的概率。可以通过以下方式提高布隆过滤器的准确性:

  1. 增加位数组的大小:扩大位数组的长度 m,可以减少哈希冲突,从而降低误报率。
  2. 增加哈希函数的数量:使用更多的哈希函数 k,可以减少哈希冲突,但需要在实现时平衡性能和误报率。
  3. 使用更好的哈希函数:选择质量较高的哈希函数,以均匀地分布哈希值,减少哈希冲突。

哈希计算方式

布隆过滤器的哈希计算方式可以有多种,常见的哈希函数包括:

  1. 除法哈希hash(x) = (a * x + b) % p,其中 ab 是常数,p 是质数。
  2. 乘法哈希hash(x) = floor(m * (x * A % 1)),其中 A 是一个介于 0 和 1 之间的常数。
  3. 一致性哈希:使用哈希函数的组合(例如 MD5、SHA1)来生成多个哈希值,以减少冲突。
  4. MurmurHash、CityHash:高效且低冲突的哈希函数,适用于布隆过滤器。

布隆过滤器的实现类型

  1. 标准布隆过滤器:基本的布隆过滤器实现,如前面所示。

  2. 计数布隆过滤器(Counting Bloom Filter)

    • 允许删除元素,通过维护计数器而非单个位来记录元素的出现次数。
  3. 分层布隆过滤器(Layered Bloom Filter)

    • 将多个布隆过滤器分层组合,每一层有不同的误报率,提升整体性能。
  4. 弹性布隆过滤器(Cuckoo Bloom Filter)

    • 结合了布隆过滤器和鸽巢哈希的思想,提高了性能和空间利用率。

Guava 和 RedisBloom 中的布隆过滤器

  1. Guava

    • Guava 是 Google 开源的 Java 工具库,其中包含了布隆过滤器的实现。Guava 的布隆过滤器实现支持定制哈希函数,并提供了优化的性能。

    示例代码:

    import com.google.common.hash.BloomFilter;
    import com.google.common.hash.Funnels;
    
    BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 1000);
    bloomFilter.put(123);
    boolean mightContain = bloomFilter.mightContain(123);
    
  2. RedisBloom

    • RedisBloom 是 Redis 的一个模块,提供了高效的布隆过滤器实现。它支持各种数据结构的扩展,如布隆过滤器、计数布隆过滤器等。

    示例代码:

    # 添加元素到布隆过滤器
    BF.ADD myBloomFilter "value1"
    
    # 检查元素是否在布隆过滤器中
    BF.EXISTS myBloomFilter "value1"
    

RedisBloom 的具体实现可以参考其 GitHub 仓库:RedisBloom 了解更多细节。

补充

  1. HashMap 是基于什么样的数据结构实现的?

    • HashMap 是基于哈希表(Hash Table)实现的。它使用数组来存储链表或红黑树(当元素个数较多时)来处理哈希碰撞。
  2. HashMap 的哈希桶为什么不用链表实现?

    • 实际上,HashMap 在早期版本中是使用链表来实现哈希桶的。当某个桶中的元素数量较多时,链表会变得不够高效。在 Java 8 及之后的版本中,HashMap 对于链表长度超过一定阈值(默认为 8)时,会将链表转换为红黑树,以提高查找效率。
  3. HashMap 的哈希计算如何尽可能降低元素的碰撞?

    • HashMap 通过使用哈希函数(通常是对元素的 hashCode 进行扰动操作)来尽可能均匀地分布元素,降低碰撞的可能性。哈希函数在计算哈希值时会应用一些扰动函数,以减少哈希碰撞的概率。
  4. HashMap 用到的什么散列算法;平方散列?斐波那契散列?哈希散列(扰动函数)?

    • HashMap 的哈希算法主要使用扰动函数来计算哈希值。Java 8 及之后版本使用了一个扰动函数,它通过对 hashCode 进行位运算和混合操作来生成最终的哈希值,这样可以减少碰撞的可能性。具体的扰动函数是 (hash ^ (hash >>> 16))
  5. HashMap 链表 + 红黑树来装填碰撞元素,AVL 树和二叉堆也是树形结构可以替换红黑树吗,为什么?

    • AVL 树和二叉堆可以作为红黑树的替代,但通常红黑树被选择是因为它的插入和删除操作的复杂度较低,且在维持平衡方面更为高效。AVL 树虽然保证了更严格的平衡,但其插入和删除操作可能需要更多的旋转。二叉堆通常用于优先队列,不适合用于 HashMap 中的键值存储。
  6. HashMap 的红黑树与 2-3 树有什么关系,B-树都包括哪些?Binary SearchTree 是 B-树吗?

    • 红黑树和 2-3 树都是自平衡的二叉树,但实现和性质略有不同。红黑树是一种较为常见的自平衡树,用于 HashMap 中以保持操作的时间复杂度。2-3 树是一种 B-树的特例,B-树是一类自平衡的多路查找树。B-树有多个变种,包括 B+树、B*树等。Binary Search Tree(BST)不是 B-树,而是一种基本的二叉树,其中每个节点的左子树都包含比节点值小的元素,而右子树包含比节点值大的元素。
  7. HashMap 的红黑树什么时候左旋、什么时候右旋、什么时候染色?

    • 红黑树的旋转操作(左旋和右旋)是为了维持树的平衡。左旋和右旋主要在插入和删除操作时进行,以保持树的平衡性。染色操作用于保持红黑树的性质,例如根节点始终是黑色,红色节点不能有两个连续的红色节点等。在 HashMap 中,具体的旋转和染色操作由红黑树的插入和删除算法来管理。
  8. HashMap 的哈希桶,散列元素和布隆过滤器有相似的地方?

    • HashMap 的哈希桶和布隆过滤器都涉及到哈希技术。HashMap 使用哈希表来存储键值对,并使用哈希函数来减少碰撞;布隆过滤器使用多个哈希函数来检查一个元素是否可能在集合中。两者都依赖于哈希技术,但布隆过滤器更侧重于空间效率和查询是否存在的能力,而 HashMap 更侧重于高效的键值对存储和检索。

你可能感兴趣的:(速成课系列,数据结构,学习,java)