Java八股文——数据结构「数据结构篇」

了解哪些数据结构?

面试官您好,我了解并使用过多种数据结构。在我的理解中,数据结构可以分为几个大的类别,每一类都有其独特的优势和适用场景。

1. 线性结构 (Linear Structures)

这类结构的特点是数据元素之间存在一对一的线性关系,像一条线一样。

  • 数组 (Array)

    • 特点 :它是一块连续的内存空间 ,通过索引来访问元素,所以随机访问速度极快,时间复杂度是 O(1)。
    • 缺点:插入和删除元素比较慢,因为需要移动后续所有元素,平均时间复杂度是 O(n)。
    • Java实现java.util.ArrayList 的底层就是动态数组。
    • 应用场景 :适合读多写少,并且需要频繁按索引查找元素的场景。
  • 链表 (Linked List)

    • 特点 :由一系列节点组成,内存空间不要求连续。每个节点除了存储数据,还存有指向下一个(或上一个)节点的指针。
    • 优点插入和删除元素非常快,只需要修改相邻节点的指针即可,时间复杂度是 O(1)。
    • 缺点随机访问很慢,必须从头节点开始遍历,时间复杂度是 O(n)。
    • Java实现java.util.LinkedList。它同时实现了 ListDeque 接口,既可以当列表用,也可以当栈或队列用。
    • 应用场景 :适合写多读少,需要频繁进行插入和删除操作的场景。
  • 栈 (Stack)

    • 特点 :一种后进先出 (LIFO) 的数据结构。所有操作都在栈顶进行。
    • Java实现java.util.Stack(线程安全但已不推荐),现在更推荐使用 java.util.Deque 接口,其实现类如 ArrayDeque 效率更高。
    • 应用场景:函数调用栈、表达式求值、括号匹配、撤销/重做(Undo/Redo)功能。
  • 队列 (Queue)

    • 特点 :一种先进先出 (FIFO) 的数据结构。在队尾入队,在队头出队。
    • Java实现java.util.Queue 接口,常用实现有基于链表的 LinkedList 和基于数组的 ArrayDeque
    • 应用场景:任务调度(如线程池的任务队列)、广度优先搜索(BFS)、消息队列。

2. 哈希结构 (Hash-based Structures)

  • 哈希表 (Hash Table)
    • 特点 :通过一个哈希函数,将键(Key)直接映射到内存中的一个位置,从而实现快速访问。它以键值对(Key-Value)的形式存储数据。
    • 优点 :在没有哈希冲突的理想情况下,插入、删除、查找的平均时间复杂度都是 O(1),性能极高。
    • 缺点:哈希冲突是需要解决的核心问题(通常用链地址法或开放地址法解决)。数据是无序的。
    • Java实现java.util.HashMapHashtable (线程安全但已不推荐)、ConcurrentHashMap(分段锁/CAS实现的高效线程安全哈希表)。
    • 应用场景:几乎无处不在,如缓存系统、配置信息存储、需要快速通过Key查找Value的任何场景。

3. 树形结构 (Tree Structures)

这类结构是分层的,元素之间是一对多的关系。

  • 二叉搜索树 (Binary Search Tree, BST)

    • 特点:左子节点的值小于父节点,右子节点的值大于父节点。
    • 优点:使得查找、插入、删除的平均时间复杂度都为 O(log n)。
    • 缺点:在极端情况下可能退化成链表,时间复杂度降为 O(n)。
  • 平衡二叉搜索树 (Balanced BST)

    • 特点 :为了解决BST的退化问题,通过自平衡操作(如旋转),确保树的高度大致保持在 log n 级别。
    • Java实现java.util.TreeMapjava.util.TreeSet 的底层就是一种自平衡的红黑树 (Red-Black Tree)
    • 应用场景 :当需要存储有序数据,并且要进行高效的增删改查时,比如数据库索引、排行榜。
  • 堆 (Heap)

    • 特点:一种特殊的完全二叉树,分为大顶堆(父节点大于等于子节点)和小顶堆(父节点小于等于子节点)。
    • 优点:可以以 O(1) 的时间复杂度获取到最大值或最小值,插入和删除的复杂度是 O(log n)。
    • Java实现java.util.PriorityQueue(优先队列),其底层就是用堆实现的。
    • 应用场景:实现优先队列、求"Top K"问题、堆排序。
  • 字典树 (Trie / Prefix Tree)

    • 特点:一种专门用于高效存储和检索字符串的多叉树。
    • 应用场景 :搜索引擎的自动补全/联想提示、IP路由表、拼写检查。

4. 图结构 (Graph Structures)

  • 图 (Graph)
    • 特点:由顶点(Vertex)和边(Edge)组成,用于表示多对多的关系,比树形结构更复杂。
    • Java实现 :Java标准库中没有直接的图实现,通常需要我们自己根据场景,使用邻接矩阵邻接表来构建。
    • 应用场景:社交网络的好友关系、地图软件的路径规划、依赖关系分析。

总结对比

数据结构 主要优点 主要缺点 Java中的实现
ArrayList O(1) 随机访问 O(n) 插入/删除 动态数组
LinkedList O(1) 插入/删除 O(n) 随机访问 双向链表
HashMap O(1) 平均增删查 无序,哈希冲突影响性能 哈希表
TreeMap O(log n) 增删查,且有序 性能略低于HashMap 红黑树
PriorityQueue O(1) 查最值,O(log n) 增删 只能访问最值

数组和链表区别是什么?

面试官您好,数组和链表是两种最基础也是最重要的线性数据结构。它们的核心区别主要体现在内存存储方式 上,这个根本区别导致了它们在访问效率、插入删除效率、内存使用和缓存友好度上表现出截然不同的特性。

1. 内存存储方式(根本区别)

  • 数组 (Array) :在内存中是一块连续的存储空间。它在创建时就需要指定大小(或在动态数组中有一个初始容量),所有元素都紧密地挨在一起。
  • 链表 (Linked List) :在内存中是非连续的、离散的存储空间。它由一系列独立的节点组成,每个节点除了存放数据外,还必须包含一个或多个指针,用来指向下一个(或上一个)节点,通过指针将这些离散的节点串联起来。

2. 访问效率(读操作)

  • 数组极高 。因为内存是连续的,我们可以通过一个简单的数学公式 address = base_address + index * element_size 直接计算出任何一个元素的内存地址,从而实现O(1)时间复杂度的随机访问
  • 链表较低 。由于内存不连续,我们无法直接定位到某个元素,必须从头节点(head)开始,沿着指针一个一个地向后遍历,直到找到目标元素。因此,它的随机访问 时间复杂度为O(n)

3. 插入和删除效率(写操作)

  • 数组较低

    • 插入:在一个非末尾的位置插入元素,需要将该位置之后的所有元素都向后移动一位,以腾出空间。
    • 删除:删除一个非末尾位置的元素后,需要将该位置之后的所有元素都向前移动一位,以填补空缺。
    • 这两种操作的平均时间复杂度都是O(n)
  • 链表极高

    • 插入/删除:只需要找到目标位置的前驱和后继节点,然后修改它们的指针指向即可,无需移动任何其他数据。
    • 在已经定位到目标位置的前提下,这个操作的时间复杂度是O(1)。(需要注意,如果算上查找目标位置的时间,总复杂度仍然是O(n))

4. 内存使用与CPU缓存友好度

  • 数组

    • 内存使用:可能会造成一定的空间浪费。比如,一个动态数组为了减少扩容次数,可能会预分配比实际需要更多的空间。
    • 缓存友好度非常高 。由于数据在内存中是连续存放的,当CPU访问一个数组元素时,根据空间局部性原理,它会把该元素周围的数据也一起加载到高速缓存(CPU Cache)中。这样,当程序接下来访问相邻元素时,就可以直接从缓存中快速读取,大大提升了遍历速度。
  • 链表

    • 内存使用:除了数据本身,每个节点都需要额外的空间来存储指针,所以总的内存开销会比数组大。
    • 缓存友好度非常低 。因为链表的节点在内存中是随机分布的,访问一个节点后,下一个节点很可能不在CPU缓存中,导致缓存频繁失效(Cache Miss),需要从主内存中重新加载,这会严重影响性能。

总结对比表

特性/维度 数组 (Array) 链表 (Linked List)
内存结构 连续存储 离散存储
随机访问 (读) O(1),极快 O(n),慢
插入/删除 (写) O(n),慢 O(1) (定位后),快
内存开销 可能有预分配浪费,但无指针开销 有额外的指针开销
缓存友好度 ,遍历速度快 ,遍历时缓存命中率低
适用场景 读多写少,需要频繁按索引访问的场景 写多读少,需要频繁插入删除的场景
Java实现 ArrayList LinkedList

在实际开发中,由于现代CPU的缓存机制对性能影响巨大,ArrayList 在绝大多数情况下的综合性能都优于 LinkedList ,即使是在涉及部分插入/删除的场景。只有在需要对列表的头部和尾部 进行大量操作时,LinkedList 作为队列(Deque)的优势才能真正体现出来。

说一下队列和栈的区别

面试官您好,队列和栈都是非常重要的线性数据结构 ,它们最核心的区别在于数据进出的规则不同,这导致了它们的应用场景也截然不同。

可以把它们想象成两种不同的通道:

  • 队列 (Queue) 就像一个 "单向隧道"或者"排队买票" 。先进去的人先出来,遵循先进先出 (First-In, First-Out, FIFO) 的原则。
  • 栈 (Stack) 就像一个 "死胡同"或者"一摞盘子" 。最后放进去的盘子最先被拿出来,遵循后进先出 (Last-In, First-Out, LIFO) 的原则。

我从以下几个方面来详细对比它们:

1. 操作规则(核心区别)

  • 队列

    • 它有两个开口:一个队尾(Rear/Tail)用于入队(Enqueue) ,一个队头(Front/Head)用于出队(Dequeue)
    • 数据只能从队尾进,从队头出。
    • 它只有一个开口,就是栈顶(Top)
    • 所有的入栈(Push)出栈(Pop) 操作都在栈顶进行。

2. 元素访问顺序

  • 队列 :元素的处理顺序和它们被添加的顺序是完全一致的。先添加的任务会被先处理,保证了公平性。
  • :元素的处理顺序和它们被添加的顺序是完全相反的。最后添加的元素会被最先处理,这体现了一种"后来者居上"或者"逆序"的特性。

3. 应用场景

正是因为它们操作规则的不同,导致了它们在解决问题时扮演的角色完全不同。

  • 队列的应用场景(强调"公平"和"顺序")

    • 任务调度/缓冲:比如操作系统的进程调度、线程池的任务队列。新来的任务排在队尾,工作线程从队头取任务执行,保证了先来先服务。
    • 广度优先搜索 (BFS):在图或树的遍历中,BFS利用队列来保证一层一层地按顺序访问节点。
    • 消息队列 (Message Queue):在分布式系统中,用作生产者和消费者之间的解耦和异步通信,实现削峰填谷。
  • 栈的应用场景(强调"逆序"、"配对"和"现场保存")

    • 函数调用栈:这是最经典的应用。每当一个函数被调用,它的上下文信息(参数、局部变量、返回地址)就被压入栈中;函数返回时,再从栈顶弹出,恢复调用者的现场。
    • 表达式求值/语法解析:比如中缀表达式转后缀表达式,以及计算后缀表达式的值。
    • 括号匹配:用栈来检查代码或表达式中的括号是否成对出现。
    • 撤销/重做 (Undo/Redo) 功能:将用户的操作依次压入"操作栈",撤销就是出栈,重做就是反向操作。
    • 深度优先搜索 (DFS):无论是显式使用栈,还是通过递归(递归本身就是利用函数调用栈),DFS都体现了栈的后进先出思想。

4. Java中的实现

  • 队列 :由 java.util.Queue 接口定义,常用实现类有 LinkedListArrayDeque
  • :虽然有 java.util.Stack 类,但它基于 Vector 实现,性能较差,已不推荐使用。现在官方推荐使用 java.util.Deque (双端队列) 接口及其实现类(如 ArrayDeque)来模拟栈的行为,因为它提供了更丰富的 push/pop API,且性能更好。

总结对比表

特性 队列 (Queue) 栈 (Stack)
规则 先进先出 (FIFO) 后进先出 (LIFO)
操作端 队尾入队,队头出队 (两个口) 栈顶入栈,栈顶出栈 (一个口)
核心思想 公平排队、顺序处理 逆序处理、现场保存与恢复
典型应用 任务队列、BFS、消息中间件 函数调用、括号匹配、Undo功能、DFS
Java实现 Queue 接口 (LinkedList, ArrayDeque) Deque 接口 (ArrayDeque)

介绍一下数据结构中的栈?怎么用Java实现?

面试官您好,栈(Stack)是一种非常重要的、遵循后进先出(Last-In, First-Out, LIFO) 原则的线性数据结构。

1. 什么是栈?

您可以把栈想象成一个一摞盘子 或者一个死胡同 。所有的操作都只能在一端 进行,这一端我们称之为栈顶(Top)

  • 入栈 (Push):就像往这摞盘子最上面放一个新盘子。
  • 出栈 (Pop):就像从最上面拿走一个盘子。

这个"后进先出"的特性,决定了最后放进去的元素,总是最先被取出来。

核心操作包括:

  • push(item): 将一个元素压入栈顶。
  • pop(): 移除并返回栈顶的元素。
  • peek(): 查看栈顶的元素,但不移除它。
  • isEmpty(): 检查栈是否为空。
  • size(): 返回栈中元素的数量。

2. 栈的应用场景

栈的LIFO特性使它在计算机科学中应用极为广泛,特别是在需要保存和恢复现场 或者处理逆序关系的场景:

  • 函数调用栈:这是最经典的应用。每当一个函数被调用,它的上下文信息(参数、局部变量、返回地址)就被压入一个系统栈中;当函数返回时,再从栈顶弹出,恢复调用者的现场。
  • 表达式求值:如中缀表达式转后缀表达式,以及后缀表达式的计算。
  • 括号匹配:用栈来检查代码或表达式中的各种括号是否成对出现。
  • 浏览器的"后退"功能:将访问过的页面URL依次压入栈中,点击后退就是一次出栈操作。
  • 深度优先搜索 (DFS):在图或树的遍历中,可以使用栈来保存待访问的节点。

3. 如何用 Java 实现栈?

在Java中,实现栈主要有以下几种方式:

方式一:官方推荐方式 --- 使用 Deque 接口

在现代Java开发中,官方不再推荐 使用古老的 java.util.Stack 类(因为它继承自 Vector,有不必要的同步开销)。而是强烈推荐使用 Deque(双端队列)接口 及其实现类 ArrayDeque 来模拟栈的行为。

ArrayDeque 提供了标准的 push, pop, peek 方法,并且性能比 Stack 更好。

java 复制代码
import java.util.Deque;
import java.util.ArrayDeque;

public class StackExample {
    public static void main(String[] args) {
        // 使用ArrayDeque实现栈
        Deque<String> stack = new ArrayDeque<>();

        // 入栈
        stack.push("Apple");
        stack.push("Banana");
        stack.push("Cherry");

        System.out.println("栈顶元素: " + stack.peek()); // 输出: Cherry

        // 出栈
        while (!stack.isEmpty()) {
            System.out.println("出栈: " + stack.pop());
        }
        // 输出顺序: Cherry, Banana, Apple
    }
}
方式二:手动实现 --- 基于数组

这是一种常见的面试题,用于考察对数据结构基本原理的理解。

java 复制代码
public class ArrayStack<E> {
    private Object[] stack;
    private int top; // 指向栈顶元素的索引
    private int capacity;

    public ArrayStack(int capacity) {
        this.capacity = capacity;
        this.stack = new Object[capacity];
        this.top = -1; // 初始化栈顶指针,-1表示栈为空
    }

    public boolean push(E item) {
        if (isFull()) {
            System.out.println("栈已满,无法入栈!");
            return false;
        }
        stack[++top] = item;
        return true;
    }

    @SuppressWarnings("unchecked")
    public E pop() {
        if (isEmpty()) {
            throw new IllegalStateException("栈为空,无法出栈!");
        }
        return (E) stack[top--];
    }

    @SuppressWarnings("unchecked")
    public E peek() {
        if (isEmpty()) {
            throw new IllegalStateException("栈为空!");
        }
        return (E) stack[top];
    }

    public boolean isEmpty() {
        return top == -1;
    }

    public boolean isFull() {
        return top == capacity - 1;
    }

    public int size() {
        return top + 1;
    }
}

优缺点分析

  • 优点:实现简单,由于内存连续,缓存命中率高,访问速度快。
  • 缺点:容量固定,需要预先指定大小。如果空间用尽,可能会发生"栈溢出";如果分配空间过大,则会造成浪费。
方式三:手动实现 --- 基于链表

这种方式可以实现一个动态扩容的栈。

java 复制代码
public class LinkedStack<E> {
    // 内部节点类
    private static class Node<E> {
        E item;
        Node<E> next;

        Node(E item, Node<E> next) {
            this.item = item;
            this.next = next;
        }
    }

    private Node<E> top; // 指向栈顶节点
    private int size;

    public LinkedStack() {
        this.top = null;
        this.size = 0;
    }

    public void push(E item) {
        // 新节点成为新的栈顶,其next指向旧的栈顶
        Node<E> newNode = new Node<>(item, this.top);
        this.top = newNode;
        size++;
    }

    public E pop() {
        if (isEmpty()) {
            throw new IllegalStateException("栈为空,无法出栈!");
        }
        E item = top.item;
        top = top.next; // 将栈顶指针移到下一个节点
        size--;
        return item;
    }

    public E peek() {
        if (isEmpty()) {
            throw new IllegalStateException("栈为空!");
        }
        return top.item;
    }

    public boolean isEmpty() {
        return top == null;
    }



    public int size() {
        return size;
    }
}

优缺点分析

  • 优点:容量是动态的,按需分配,不会有溢出问题。
  • 缺点:每个元素都需要额外的空间来存储指针,有一定的内存开销。由于内存不连续,缓存友好度不如数组。

如何使用两个栈实现队列?

面试官您好,用两个栈来实现一个队列是一个非常经典的算法题,它的核心思想是利用栈的 "后进先出"(LIFO) 特性,通过两次"反转",来巧妙地模拟出队列的 "先进先出"(FIFO) 特性。

实现思路

我们需要准备两个栈:

  1. 一个输入栈 (inStack) :专门负责处理所有入队 (enqueue) 的操作。
  2. 一个输出栈 (outStack) :专门负责处理所有出队 (dequeue)查看队头 (peek) 的操作。

核心规则:

  • 入队 add(element) :非常简单,直接将元素 pushinStack 中。

  • 出队 poll():这是最关键的一步。

    1. 首先,检查 outStack 是否为空。
    2. 如果 outStack 不为空 ,说明里面还有之前"倒腾"过来的、顺序正确的元素,直接 pop 出栈顶元素即可。
    3. 如果 outStack 为空 ,就必须进行一次 "倒水" 操作:将 inStack 中的所有元素,一个一个地 pop 出来,然后 pushoutStack 中。这个过程完成后,inStack 会变空,而 outStack 中的元素顺序就和它们最初入队的顺序完全一致了(先进的元素现在位于栈顶)。然后再从 outStackpop 出栈顶元素。
    4. 如果两个栈都为空,说明队列为空,返回 null
  • 查看队头 peek() :逻辑和出队完全一样,只是最后一步不是 pop,而是 peek 查看 outStack 的栈顶元素。

Java 代码实现

下面是一个具体的Java代码实现,遵循了队列的接口规范:

java 复制代码
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.NoSuchElementException;

public class QueueWithTwoStacks<E> {

    private final Deque<E> inStack = new ArrayDeque<>();
    private final Deque<E> outStack = new ArrayDeque<>();

    /**
     * 入队操作
     */
    public void add(E element) {
        // 直接压入输入栈
        inStack.push(element);
    }

    /**
     * 出队操作
     */
    public E poll() {
        // 如果输出栈为空,则尝试从输入栈"倒水"
        if (outStack.isEmpty()) {
            transferInToOut();
        }
        // 如果输出栈仍然为空(说明整个队列都为空),返回null
        if (outStack.isEmpty()) {
            return null;
        }
        // 从输出栈弹出元素
        return outStack.pop();
    }
    
    /**
     * 查看队头元素
     */
    public E peek() {
        if (outStack.isEmpty()) {
            transferInToOut();
        }
        if (outStack.isEmpty()) {
            return null;
        }
        return outStack.peek();
    }
    
    /**
     * 检查队列是否为空
     */
    public boolean isEmpty() {
        return inStack.isEmpty() && outStack.isEmpty();
    }

    /**
     * 核心的"倒水"操作:将输入栈的元素转移到输出栈
     */
    private void transferInToOut() {
        while (!inStack.isEmpty()) {
            outStack.push(inStack.pop());
        }
    }
}

复杂度分析

这个实现最巧妙的地方在于它的摊还时间复杂度(Amortized Time Complexity)

  • 入队 add() :时间复杂度永远是 O(1)

  • 出队 poll() 和 查看队头 peek()

    • 最坏情况 :当 outStack 为空时,需要将 inStack 中的 n 个元素全部转移,此时的单次操作复杂度是 O(n)
    • 最好情况 :当 outStack 不为空时,直接操作,复杂度是 O(1)
    • 摊还分析 :我们可以看到,每个元素一生中最多只会被 pushinStack 一次,popinStack 一次,pushoutStack 一次,popoutStack 一次。总共最多4次操作。所以,对于一系列连续的操作来说,平均到每一次出队操作上的时间复杂度是摊还 O(1)

这个设计用一种巧妙的方式平衡了操作的开销,使得在宏观上,队列的性能依然非常高效。


常见的队列有哪些及应用场景?

面试官您好,队列(Queue)作为一种核心数据结构,在不同的应用场景下演化出了多种形态。我将它们分为单体应用内队列分布式系统队列两大类来介绍。

一、 单体应用内队列 (In-Process Queues)

这类队列运行在单个应用程序的内存中,主要用于解决线程间的协作和数据传递问题。

1. 普通队列 (FIFO Queues)
  • 特点 :最基础的队列,严格遵循先进先出(FIFO) 原则。
  • Java实现 :由 java.util.Queue 接口定义。
    • LinkedList:基于链表实现,在队头和队尾进行增删操作的效率很高。
    • ArrayDeque:基于动态数组实现,由于其优秀的缓存局部性,在大多数情况下性能优于 LinkedList
  • 应用场景
    • 广度优先搜索(BFS):在图或树的遍历中,利用队列来保证一层一层地按顺序访问节点。
    • 任务缓冲:比如打印机任务队列,用户提交的打印任务按顺序排队等待处理。
2. 阻塞队列 (Blocking Queues)
  • 特点 :这是Java并发编程的利器。它是一种线程安全的队列,并且带有阻塞 特性:
    • 当队列 时,尝试入队的生产者线程会被阻塞,直到队列有空位。
    • 当队列 时,尝试出队的消费者线程会被阻塞,直到队列有新元素。
  • Java实现 :由 java.util.concurrent.BlockingQueue 接口定义。
    • ArrayBlockingQueue:基于数组的有界阻塞队列,创建时必须指定容量,支持公平/非公平策略。
    • LinkedBlockingQueue:基于链表的阻塞队列,可以是有界的(容量默认为Integer.MAX_VALUE),吞吐量通常高于ArrayBlockingQueue
    • SynchronousQueue:一个不存储元素的"接头"队列,每个put操作必须等待一个take操作,反之亦然。非常适合传递性场景。
  • 应用场景
    • 线程池的任务队列 :这是最经典的应用。ThreadPoolExecutor 使用 BlockingQueue 来存放等待执行的任务,完美地协调了任务提交者和工作线程。
    • 生产者-消费者模型:任何需要解耦生产者和消费者、实现异步处理的场景,比如日志系统,业务线程是生产者,写日志的线程是消费者。
3. 优先队列 (Priority Queues)
  • 特点 :队列中的元素不再遵循FIFO,而是根据其优先级进行排序。每次出队的都是当前队列中优先级最高的元素。
  • Java实现java.util.PriorityQueue,其底层是基于二叉堆(Heap) 实现的。
  • 应用场景
    • 任务调度:操作系统根据任务的优先级来决定先执行哪个任务。
    • "Top K" 问题:在海量数据中找出最大或最小的K个元素。例如,用一个大小为K的小顶堆,就可以高效地找到Top K大的元素。
    • 网络协议中的优先级处理:比如QoS(服务质量)应用中,需要优先处理高优先级的网络包。
4. 双端队列 (Deques)
  • 特点 :队列的两端(队头和队尾)都既可以入队也可以出队,是一种更灵活的队列。
  • Java实现 :由 java.util.Deque 接口定义,ArrayDeque 是其首选实现。
  • 应用场景
    • 实现栈(Stack) :由于 java.util.Stack 类性能不佳,Deque 已成为官方推荐的实现栈的方式(push 对应 addFirstpop 对应 removeFirst)。
    • 工作窃取(Work-Stealing)算法 :在Java的 Fork/Join 框架中,每个线程都维护一个双端队列。线程从自己队列的头部获取任务,当自己队列为空时,可以从其他线程队列的尾部"窃取"一个任务来执行,以减少线程竞争,提高效率。

二、 分布式系统队列 (Distributed Queues)

这类队列通常作为独立的消息中间件(Message Queue, MQ)存在,用于解决跨进程、跨服务器的通信问题。

  • 特点 :独立于应用的服务,提供高可用、高可靠的消息传递,是分布式系统架构的核心组件。
  • 代表产品Kafka, RabbitMQ, RocketMQ
  • 应用场景
    • 系统解耦:服务的调用方(生产者)和被调用方(消费者)通过消息队列间接通信,任何一方的变更或宕机都不会直接影响对方。
    • 异步通信:对于一些耗时的操作,如发送邮件、生成报表等,用户请求可以先写入消息队列并立即返回,由后台服务异步地消费处理,极大提升了用户体验。
    • 流量削峰(削峰填谷):在秒杀、大促等场景下,瞬间的巨大流量可以先被积压在消息队列中,后端系统再按照自己的最大处理能力平稳地进行消费,防止系统被冲垮。

总结对比

队列类型 核心特性 典型应用场景
普通队列 先进先出 (FIFO) BFS、任务缓冲
阻塞队列 线程安全、生产者/消费者阻塞 线程池、生产者-消费者模型
优先队列 按优先级出队 任务调度、Top K 问题
双端队列 两端均可入队/出队 实现栈、工作窃取算法
分布式队列(MQ) 跨进程/跨服务器,高可用、高可靠 系统解耦、异步通信、流量削峰

平衡二叉树结构是怎么样的?

面试官您好,要理解平衡二叉树,我们首先需要知道它解决了什么问题

1. 为什么需要平衡二叉树?------ 普通二叉搜索树的缺陷

我们知道,普通二叉搜索树 (Binary Search Tree, BST) 的定义是:对于任意节点,其左子树上所有节点的值都小于它,右子树上所有节点的值都大于它。这个特性使得查找、插入、删除操作的平均时间复杂度可以达到 O(log n),效率很高。

但是,BST 有一个致命的缺陷 :它的性能严重依赖于树的形态

  • 理想情况:如果插入的数据是随机的,BST 可能会形成一棵比较"匀称"的树,其高度约等于 log n,此时性能最好。
  • 最坏情况 :如果我们插入的是一个有序序列 (比如 1, 2, 3, 4, 5),BST 就会退化成一条链表 。在这种情况下,树的高度等于节点数 n,所有操作的时间复杂度都会恶化到 O(n),失去了树形结构应有的优势。

平衡二叉树的诞生,就是为了解决普通二叉搜索树的这种"退化"问题。

2. 什么是平衡二叉树 (Balanced Binary Tree)?

平衡二叉树的本质,仍然是一棵二叉搜索树 ,它完全继承了BST的性质。但在此基础上,它增加了一个严格的"平衡"约束,以确保树永远不会变得"头重脚轻"或"一条腿长一条腿短"。

核心定义与特性:

  1. 它首先必须是一棵二叉搜索树
  2. 对于树中的任意一个节点 ,其左子树的高度右子树的高度 之差的绝对值不能超过1 。这个高度差,我们通常称之为"平衡因子"。
  3. 它的任意一个节点的左右子树必须是一棵平衡二叉树。

这个定义是递归 的,它保证了整棵树从上到下都是平衡的。通过维持这个平衡,平衡二叉树可以确保其高度始终保持在 O(log n) 的量级,从而保证了所有操作的性能始终稳定在 O(log n)

3. 如何维持平衡?------ 自平衡操作

平衡二叉树的神奇之处在于,它有一套自平衡(Self-Balancing) 的机制。当进行插入或删除操作,导致某个节点的平衡因子大于1(即树失衡)时,它会自动进行调整来恢复平衡。

这个调整的核心操作就是------旋转(Rotation)

  • 旋转 :是一种通过修改节点之间父子关系的局部操作,它可以在不破坏二叉搜索树性质的前提下,改变树的结构,降低树的高度。

旋转主要分为两种基本类型:

  • 左旋 (Left Rotation):将一个节点的右孩子"提拔"为新的父节点,原来的父节点"降级"为新父节点的左孩子。
  • 右旋 (Right Rotation):与左旋相反,将一个节点的左孩子"提拔"为新的父节点。

根据失衡的不同情况(比如,是插入到左子树的左边,还是左子树的右边),需要进行的旋转组合也不同,主要分为四种失衡类型:LL(左左)、RR(右右)、LR(左右)、RL(右左)。通过一次或两次旋转,就可以使失衡的子树重新恢复平衡。

4. 常见的平衡二叉树实现

  • AVL树 :这是最严格的平衡二叉树,它严格要求任何节点的平衡因子绝对值不能超过1。因此它的查找效率最高,但插入和删除时为了维持平衡,可能需要进行更多的旋转操作,维护成本较高。

  • 红黑树 (Red-Black Tree) :这是一种非严格的平衡二叉树。它通过引入"颜色"(红或黑)和五条简单的着色规则,来近似地维持树的平衡。它不追求绝对的平衡,只保证从根到最远叶子节点的路径长度,不超过到最近叶子节点路径长度的两倍。

    • 优点 :相比AVL树,红黑树在插入和删除时需要进行的调整操作(旋转和变色)更少,因此写操作的性能更好
    • 应用 :正因为这种在查找和写入性能上的良好平衡,红黑树在工程实践中应用得更为广泛 。比如,Java的 TreeMapTreeSet,以及Linux内核中的多种数据结构,都是用红黑树实现的。

总结一下:平衡二叉树通过引入严格的平衡约束和自平衡的旋转机制,确保了树的高度始终在 O(log n) 级别,从而解决了普通二叉搜索树可能退化成链表的性能问题,为高效的动态查找提供了可靠的性能保障。


红黑树是什么,跳表是什么?

面试官您好,红黑树和跳表都是非常优秀的数据结构,它们都实现了有序集合 的高效动态操作,提供了 O(log n) 时间复杂度的增、删、查性能。但它们实现这一目标的思路和底层结构完全不同,这导致了它们在实现复杂度、并发性能和适用场景上各有千秋。

红黑树 (Red-Black Tree)

1. 它是什么?

红黑树是一种近似平衡的二叉搜索树。它并不是追求像AVL树那样"绝对的平衡"(左右子树高度差不超过1),而是通过一套相对宽松的规则,来确保树不会过度倾斜。

2. 它是如何工作的?------ 五条核心规则

它在普通二叉搜索树的基础上,为每个节点增加了一个"颜色"(红色或黑色)属性,并强制要求整棵树必须始终满足以下五条规则:

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点必须是黑色的。
  3. 所有叶子节点(NIL节点,即空节点)都是黑色的。
  4. 关键规则1 :红色节点的子节点必须是黑色的。(杜绝了连续的红色节点
  5. 关键规则2 :从任意一个节点出发,到其所有后代叶子节点的路径上,黑色节点的数量必须相同

通过这五条规则,特别是后两条,红黑树巧妙地保证了最长路径(红黑相间的路径)不会超过最短路径(全是黑节点的路径)的两倍 。这就确保了树的高度始终保持在 O(log n) 级别,从而保证了性能。

当插入或删除节点破坏了这些规则时,红黑树会通过变色旋转(左旋、右旋)等局部操作,来重新恢复平衡。

3. 优缺点与应用

  • 优点
    • 性能稳定且高效,所有操作的最坏时间复杂度都是 O(log n)。
    • 是一种非常经典、经过充分验证的平衡树结构。
  • 缺点
    • 实现复杂:规则多,旋转和变色的逻辑判断比较复杂,手写和调试都很有挑战性。
    • 并发性能差:在多线程环境下,写操作(插入/删除)的调整范围可能很大(从叶子到根),导致锁的粒度很大,难以实现高效的并发控制。
  • 应用
    • JavaTreeMap, TreeSet, ConcurrentSkipListMap (在JDK 8之前)。
    • C++ STLmap, set
    • Linux内核:用它来管理内存区域、调度进程等。

跳表 (Skip List)

1. 它是什么?

跳表是一种基于有序链表 的、通过增加多级"快速通道"(索引) 来实现高效查找的数据结构。它的思想非常巧妙,有点像"空间换时间"。

2. 它是如何工作的?------ "给链表建高速公路"

  1. 底层 (Level 0) :首先,它有一个完整的、有序的普通链表,包含了所有的数据。
  2. 建立索引层 :在底层链表的基础上,它会随机地从一些节点中"提拔"出一部分,形成上一层的"快速通道"(索引层)。然后,再从这个索引层中,再提拔一部分形成更上一层的索引... 以此类推,直到最顶层只有少数几个节点。
  3. 查找过程 :查找一个元素时,我们从最高层的索引 开始,向右遍历。
    • 如果下一个节点的值比目标值大,或者到了这层的末尾,就通过一个"向下"的指针,降到下一层继续向右查找。
    • 这个过程就像坐火车,先坐最快的高铁(高层索引)到离目标最近的大站,然后换乘动车(中层索引),最后换乘地铁(底层链表),最终精准地找到目标。

由于每一层都是通过随机 的方式构建的(通常是抛硬币,决定一个节点是否要被提升到上一层),所以跳表在统计学上能保证其平均高度为 O(log n),从而实现了 O(log n) 的平均查找复杂度。插入和删除操作也类似,先找到位置,再更新各层的指针。

3. 优缺点与应用

  • 优点
    • 实现简单:相比红黑树,跳表的插入、删除、查找等逻辑都更直观、更容易理解和实现。代码量通常也更少。
    • 并发性能好:插入或删除一个节点,通常只需要修改其前后的局部指针,锁的粒度可以做得很小,因此更容易实现高效的并发跳表。
  • 缺点
    • 空间换时间:需要额外的内存来存储各级索引的指针,空间复杂度比红黑树要高一些。
  • 应用
    • Redis :它的有序集合(Sorted Set) 就是用跳表(结合哈希表)来实现的,充分利用了跳表的高效范围查询和简单实现。
    • LevelDB/RocksDB:这些存储引擎内部使用跳表作为内存中的数据结构(MemTable),因为它写操作快,并且天然有序,便于后续合并到磁盘。
    • JavaConcurrentSkipListMapConcurrentSkipListSet,它们是JDK中用于替代 TreeMap/Set 的高效线程安全实现。

总结对比

特性 红黑树 (Red-Black Tree) 跳表 (Skip List)
底层结构 树形结构 链表 + 多级索引
性能保证 严格的规则保证 (确定性) 随机化保证 (概率性)
实现复杂度 ,逻辑复杂,调试困难 ,逻辑清晰,易于实现
并发支持 ,写操作锁粒度大 ,写操作影响范围小,易于实现高并发
空间占用 相对较低 相对较高 (需要存储多层索引指针)
典型代表 C++ STL map, Java TreeMap Redis ZSET, Java ConcurrentSkipListMap

总的来说,红黑树是一种经典的、确定性的平衡数据结构,在单线程环境下非常优秀。而跳表则以其简单、高效、易于并发的特点,在现代多核、高并发的系统中,越来越受到青天睐。


红黑树和AVL树相比查询性能好还是插入性能好一些?

面试官您好,AVL树和红黑树都是非常优秀的自平衡二叉搜索树,它们都保证了操作的时间复杂度在 O(log n) 级别。但它们在平衡策略上的"严格"与"宽松"之别 ,导致了它们在查询性能插入/删除性能上各有侧重。

简单来说,结论是:

  • 查询性能AVL树 略优于 红黑树。
  • 插入/删除性能红黑树 明显优于 AVL树。

下面我来详细解释一下原因:

1. 查询性能对比:AVL树胜在"极致平衡"

  • AVL树 :它是一种高度平衡 的树。它严格要求任何节点的左右子树高度差的绝对值不能超过1。这个苛刻的条件使得AVL树在结构上尽可能地"矮"和"胖",其高度无限接近于理论最小值 log n。

    • 结果:更低的树高意味着更短的平均查找路径。因此,在纯粹的查询场景下,AVL树的性能是最优的。
  • 红黑树 :它是一种弱平衡 或者说"大致平衡"的树。它不直接关心高度差,而是通过一套颜色规则来保证最长路径(从根到最远叶子)的长度不超过最短路径的两倍

    • 结果:这导致红黑树的高度可能比同样节点数的AVL树要高一些。更高的树高自然意味着平均查找路径会稍长,所以查询性能会略逊于AVL树。

结论 :在查询性能上,AVL树 > 红黑树

2. 插入/删除性能对比:红黑树胜在"调整成本低"

插入和删除操作都包含两个阶段:查找调整 。它们的查找性能差异如上所述,关键在于调整阶段的开销。

  • AVL树

    • 调整方式 :只通过旋转来恢复平衡。
    • 调整成本 :由于其严格的平衡要求,一次插入或删除很可能导致从插入点到根节点的路径上多个节点 的平衡因子被破坏。因此,可能需要进行多次旋转,甚至是一路旋转到根节点,调整的成本相对较高。
  • 红黑树

    • 调整方式 :通过变色旋转两种手段来恢复平衡。
    • 调整成本 :红黑树的调整过程非常高效。大部分情况下,它可以通过少量的变色操作 就恢复平衡,因为变色不会改变树的结构。只有在少数情况下才需要进行旋转,并且最多只需要两次旋转就可以解决失衡问题。
    • 结果:相比AVL树,红黑树在插入和删除时需要进行的调整操作更少、更局部化,因此整体的写入性能要好得多。

结论 :在插入/删除性能上,红黑树 >> AVL树

总结与应用场景

特性/维度 AVL树 (高度平衡) 红黑树 (弱平衡)
平衡策略 严格:高度差 ≤ 1 宽松:最长路径 ≤ 2 * 最短路径
查询性能 更优 (树高更低) 较优 (树高可能稍高)
写入性能 较差 (调整开销大,可能多次旋转) 更优 (调整开销小,变色为主,最多2次旋转)
适用场景 读多写少的场景,如数据库索引 读写频繁的场景,需要兼顾查询和写入性能

正因为红黑树在查询和写入性能上取得了更好的平衡,使得它在工程实践中的应用远比AVL树广泛 。例如,Java的 TreeMapTreeSetC++ STL的 mapset ,以及Linux内核都选择了红黑树作为其核心的有序数据结构实现。而AVL树更多地出现在教科书和理论研究中。


B+树的特点是什么?

面试官您好,B+树是一种为磁盘等外部存储设备 量身定制的多路平衡查找树 。它的所有设计,最终都指向一个核心目标:尽可能地减少磁盘I/O次数,从而极大地提升在大数据量下的查询效率。

B+树的特点,可以从它的结构设计操作优势两个层面来理解。

1. 结构设计上的三大特点

a. 所有数据都只存在于叶子节点

  • 非叶子节点(索引节点) :它们只存储键(Key)的索引信息 ,不存储任何实际的数据(Value)。这些节点的作用就像一本书的"目录",它们的唯一任务就是引导查询,告诉我们应该去哪个子树继续查找。
  • 叶子节点(数据节点) :它们包含了所有的键 以及与之对应的实际数据。所有的查询,最终都必须"落到"叶子节点才能找到数据。
  • 带来的好处 :由于非叶子节点不存数据,所以它们非常"瘦小"。这意味着在同样大小的一个磁盘块(比如一页4KB)中,可以存放更多的键和指针 。这使得B+树的 "扇出"(fan-out)非常高 ,树也就变得更加"矮胖 "。更矮的树高,直接意味着从根节点到叶子节点的查询路径更短,需要进行的磁盘I/O次数就更少

b. 所有叶子节点构成一个有序链表

  • 所有的叶子节点,除了存储数据外,还会包含一个指向下一个叶子节点的指针。
  • 这样,所有的叶子节点就串联成了一个有序的双向链表(通常是双向的,便于正序和逆序遍历)。
  • 带来的好处 :这个设计对于范围查询(Range Query)全表遍历颠覆性的优化
    • 比如要查询 ID between 100 and 500 的所有数据,我们只需要先定位到 ID=100 所在的叶子节点,然后就可以沿着这个有序链表,一直向后遍历,直到 ID > 500 为止。这个过程完全是顺序的磁盘I/O,效率极高,避免了传统B树需要反复从中序遍历返回上层节点再下来的低效操作。

c. 多路平衡(M-way Tree)

  • B+树不再是二叉树,而是多叉树 。每个非叶子节点可以拥有多个子节点(从几十到上千个)。
  • 它通过一系列的分裂和合并操作,来保证从根节点到任意一个叶子节点的路径长度都是相同的
  • 带来的好处 :再次强调,多路 是为了提高扇出,而平衡是为了保证查询性能的稳定,确保任何查询的路径长度都一样短。

2. 操作上的优势

基于以上结构特点,B+树在数据库等场景中展现出巨大优势:

  • 单点查询效率稳定且高:由于树的高度极低(通常3-4层就能支持上亿条数据),任何一次单点查询,只需要进行极少数(3-4次)的磁盘I/O即可完成。
  • 范围查询和排序效率极高 :得益于叶子节点的有序链表结构,范围查询和ORDER BY操作变得非常高效。
  • 插入和删除效率稳定:通过节点的分裂和合并,B+树能动态地维持平衡,使得增删操作的时间复杂度也稳定在 O(log n) 级别。
  • 更适合磁盘存储 :它的设计充分利用了磁盘预读的空间局部性原理。当一个节点(通常对应一个磁盘页)被加载到内存时,它包含了大量的键和指针,这些数据很可能在接下来的查询中被用到,从而减少了后续的I/O请求。

总结

B+树通过非叶子节点只存索引数据全在叶子节点叶子节点形成有序链表 以及多路平衡 这几大核心设计,完美地适应了磁盘的读写特性。它以极矮的树高高效的范围查询能力,成为了关系型数据库(如MySQL的InnoDB)和文件系统索引实现的不二之选。


红黑树、B树和B+树发区别

面试官您好,红黑树、B树和B+树都是高效的自平衡查找树,但它们的设计目标和应用场景截然不同,这导致了它们在结构、性能和适用领域上存在巨大差异。

简单来说:

  • 红黑树 是为 内存 中的动态数据设计的"瑞士军刀"。
  • B树B+树 则是为 磁盘 等慢速I/O设备量身定制的"文件柜"。

下面我从几个核心维度来详细对比它们:

1. 应用场景与设计目标 (最根本的区别)

  • 红黑树

    • 场景 :主要用于内存中 的数据结构,比如Java的 TreeMap、C++ STL的 map、以及Linux内核中的多种数据管理。
    • 目标 :在内存中进行频繁的、动态的插入、删除和查找操作。因为内存访问速度极快,所以它的优化目标是减少CPU的计算和调整次数
  • B树 / B+树

    • 场景 :主要用于磁盘存储系统 ,如数据库索引(MySQL InnoDB)文件系统
    • 目标 :磁盘的I/O速度比内存慢几个数量级,是最大的性能瓶颈。因此,它们的核心设计目标是尽可能地减少磁盘I/O的次数

2. 结构类型与高度 (叉数)

  • 红黑树 :是严格的二叉树,每个节点最多只有两个子节点。其高度约为 O(log₂n),在内存中这已经足够高效。

  • B树 / B+树 :是多路查找树(M-way Tree) ,也叫"M叉树 "。每个节点可以拥有成百上千个子节点。

    • 优势 :这种"矮胖"的结构,使得树的高度被极大地压缩。一棵高度为3-4层的B+树,就可以存储上亿条数据。极低的树高意味着从根节点到叶子节点,只需要进行极少数次(3-4次)的磁盘I/O。

3. 数据存放位置

这是 B树B+树 之间最核心的区别。

  • 红黑树 :每个节点都同时存储键(Key)数据(Value/Data)

  • B树 :每个节点也都是同时存储键(Key)数据(Value/Data)。这意味着,一次查询有可能在到达叶子节点之前,就在一个非叶子节点上找到了数据并返回。

  • B+树:做了一个重要的区分:

    • 非叶子节点(索引节点)只存储键(Key),不存储任何数据。它们纯粹作为索引存在。
    • 叶子节点(数据节点) :存储了所有的键 以及与之对应的数据
    • 优势 :由于非叶子节点不存数据,它们变得非常"小"。一个磁盘页(如16KB)就可以容纳更多的键,使得树的 "扇出"(fan-out)更大,进一步降低了树的高度。

4. 查询性能 (单点查询 vs. 范围查询)

  • 单点查询

    • 对于这三者,单点查询的时间复杂度都是 O(log n)。
    • 但B树/B+树的 n 是以磁盘I/O次数来衡量的,其底数远大于2,所以实际性能远超红黑树。
    • B树的查询可能在非叶子节点就结束,而B+树必须查到叶子节点,所以在最好情况下,B树可能稍快一点。但在大多数情况下,由于B+树更矮,这点差异可以忽略不计。
  • 范围查询与遍历 :这是 B+树的杀手锏

    • 红黑树和B树 :要做范围查询,都需要进行复杂的中序遍历,可能需要在树的各层之间来回跳转,导致大量的随机磁盘I/O,性能很差。
    • B+树 :它的所有叶子节点 之间通过指针 形成了一个有序的双向链表 。当进行范围查询时,只需要先定位到范围的起始点,然后就可以沿着这个链表进行顺序遍历 ,这对应到磁盘上就是顺序I/O,效率极高。

总结对比表

特性/维度 红黑树 (Red-Black Tree) B树 (B-Tree) B+树 (B+ Tree)
应用场景 内存数据结构 (如 TreeMap) 数据库/文件系统 (较少用) 数据库索引 (如 MySQL InnoDB)
性能目标 减少CPU计算/调整次数 减少磁盘I/O次数 减少磁盘I/O次数 + 优化范围查询
结构类型 二叉树 多路查找树 (M叉) 多路查找树 (M叉)
数据存储 所有节点存 Key + Data 所有节点存 Key + Data 非叶子节点只存Key,叶子节点存Key+Data
范围查询 (需中序遍历,随机I/O) (需中序遍历,随机I/O) 极佳 (叶子节点构成有序链表,顺序I/O)
查询稳定性 稳定O(log n) 稳定O(log n),但查询可能在不同深度结束 最稳定,所有查询都必须到达叶子层,路径长度一致

简单总结

  • 如果你在内存中需要一个有序的动态集合,用红黑树
  • 如果你在为磁盘设计一个索引系统,并且需要极高的范围查询性能,B+树是毋庸置疑的最佳选择。这就是为什么它能成为关系型数据库索引的标准实现。

堆是什么?

面试官您好,堆(Heap)是一种基于完全二叉树 的、非常高效的数据结构。它的核心价值在于能够以 O(1) 的时间复杂度快速获取到集合中的最大值或最小值

我可以从两大核心属性底层实现核心操作典型应用场景这几个方面来详细阐述。

1. 堆的两大核心属性

一个合法的堆必须同时满足以下两个条件:

  • 结构属性:它必须是一棵完全二叉树(Complete Binary Tree)。

    • 这意味着树的每一层都是从左到右填满的,只有最后一层可能不满,并且最后一层的节点也都集中在左侧。这个结构属性非常重要,因为它使得我们可以用一个数组来高效地表示堆,而无需使用指针。
  • 堆序属性(Heap Property):

    • 对于树中的任意一个节点,其值都必须大于或等于 其所有子节点的值。我们称之为大顶堆(Max-Heap)。此时,堆顶元素永远是整个集合中的最大值。
    • 或者,其值都必须小于或等于 其所有子节点的值。我们称之为小顶堆(Min-Heap)。此时,堆顶元素永远是整个集合中的最小值。

2. 底层实现:数组

堆最巧妙的实现方式就是使用数组。由于它是完全二叉树,我们可以通过简单的数学计算来找到任意节点的父节点和子节点,无需任何指针:

  • 假设一个节点的索引是 i(从0开始)。
  • 它的父节点索引是 (i - 1) / 2
  • 它的左子节点索引是 2 * i + 1
  • 它的右子节点索引是 2 * i + 2

这种实现方式不仅节省了存储指针的额外空间 ,还因为内存是连续的,所以CPU缓存友好度非常高

3. 核心操作与时间复杂度

堆的核心操作在于,当插入或删除元素破坏了堆序属性后,它能通过高效的调整操作来恢复。

  • 获取最值 peek() :直接返回数组的第一个元素(索引0),时间复杂度为 O(1)

  • 插入元素 add() / push()

    1. 将新元素添加到数组的末尾(即完全二叉树的下一个空位)。
    2. 然后,让这个新元素不断地和它的父节点比较,如果它比父节点更"优先"(比如在大顶堆中它比父节点大),就和父节点交换位置。
    3. 这个过程一直持续到它不再比父节点更优先,或者它到达了堆顶。这个自下而上的调整过程我们称为 "上浮"(Sift-up / Bubble-up)
    • 由于树的高度是 O(log n),所以插入操作的时间复杂度为 O(log n)
  • 删除最值 poll() / pop()

    1. 将堆顶元素(数组第一个元素)与数组的最后一个元素交换位置。
    2. 移除数组的最后一个元素(即原来的堆顶)。
    3. 现在新的堆顶元素是原来树的最后一个节点,它很可能不满足堆序属性。
    4. 让这个新的堆顶元素不断地和它的子节点中更"优先"的那个进行比较和交换,直到它不再违反堆序属性,或者它成为了叶子节点。这个自上而下的调整过程我们称为 "下沉"(Sift-down / Bubble-down)
    • 这个操作的时间复杂度同样是 O(log n)

4. 典型应用场景

堆的这些特性,使它成为解决很多问题的利器。

  • 实现优先队列(Priority Queue) :这是堆最直接、最经典的应用。java.util.PriorityQueue 的底层就是用堆实现的。无论是任务调度、事件处理,只要涉及到需要根据优先级处理元素的场景,优先队列都是首选。

  • 求"Top K"问题:这是一个非常常见的面试题。比如,要在海量数据中找出最大的K个元素。

    • 解决方案 :我们可以维护一个大小为K的小顶堆。遍历数据,如果当前元素比堆顶元素大,就把堆顶元素替换掉,然后对堆进行调整。遍历完成后,堆里剩下的K个元素就是最大的K个。整个过程的时间复杂度是 O(n log k)。
  • 堆排序(Heap Sort):一种原地排序算法,时间复杂度稳定在 O(n log n)。

总结

特性/操作 描述 时间复杂度
底层实现 数组 (利用完全二叉树特性) -
获取最值 直接访问堆顶 (数组索引0) O(1)
插入元素 在末尾添加,然后"上浮"调整 O(log n)
删除最值 将末尾元素换到堆顶,然后"下沉"调整 O(log n)
核心应用 优先队列、Top K 问题、堆排序 -

总的来说,堆是一种看似简单,但功能强大且高效的数据结构,特别是在需要动态地、快速地找出集合中最值元素的场景下,它几乎是无可替代的选择。


前缀树是什么?有什么应用?

面试官您好,前缀树,也常被称为字典树Trie树 ,是一种非常特殊的树形数据结构。它不是用来存储任意类型数据的通用树,而是专门为高效地存储和检索字符串集合而设计的

1. 什么是前缀树?------ 空间换时间

它的核心思想是:利用字符串的公共前缀来节约存储空间和减少不必要的字符串比较,从而极大地提升查询效率

您可以把它想象成一本 "按前缀组织的英文字典"

  • 字典的根节点是空的,代表一切的开始。
  • 从根节点出发的每一条路径,都代表一个单词。
  • 路径上共享的节点,就代表这些单词共享的公共前缀

它的结构有几个鲜明特点:

  1. 根节点是空的,不代表任何字符。
  2. 从根节点到任意一个节点的路径,拼接起来就是该节点所代表的一个字符串前缀
  3. 如果一个节点被标记为"结束节点"(通常用一个布尔标志 isEnd),那么从根到该节点的路径就构成了一个完整的单词
  4. 一个节点的所有子节点 所代表的字符都是不同的。

举个例子 ,我们要存储 tea, ten, inn 这三个单词:

复制代码
       (root)
       /    \
      t      i
     /        \
    e          n
   / \          \
  a   n          n (isEnd=true)
 (isEnd=true) (isEnd=true)
  • teaten 共享了前缀 te,所以它们在前两层共享了路径 root -> t -> e
  • inn 与它们没有公共前缀,所以走了另一条分支 root -> i

2. 前缀树的核心操作

  • 插入 (Insert):从根节点开始,沿着字符串的字符逐层向下走。如果路径上的某个节点不存在,就创建一个。当字符串的所有字符都处理完毕后,将最后一个节点标记为"结束节点"。

  • 查找 (Search):和插入类似,从根节点开始沿着字符串的字符向下查找。如果中途路径断了,说明该字符串不存在。如果路径走完了,还要检查最后一个节点是否被标记为"结束节点",才能确定它是一个完整的单词,而不仅仅是一个前缀。

  • 前缀查询 (StartsWith) :逻辑和查找几乎一样,但只要路径能完整地走完,无论最后一个节点是否是"结束节点",都返回 true

3. 前缀树的优缺点

  • 优点

    • 查询效率极高 :插入和查询一个字符串的时间复杂度是 O(L) ,其中 L 是字符串的长度。这个效率与字典中总共有多少个单词是无关的,这是它相比哈希表等结构在特定场景下的巨大优势。
    • 天然支持前缀相关的操作:如前缀匹配、自动补全等。
  • 缺点

    • 空间消耗大:如果字符串集合中没有很多公共前缀,或者字符集很大(比如存储中文),前缀树会消耗大量的内存,因为每个节点都需要维护一个指向所有可能子节点的指针数组(或哈希表)。

4. 前缀树的应用场景

前缀树的应用场景都和它的核心特性------"高效处理字符串前缀"------密切相关。

  1. 搜索引擎的自动补全/输入提示

    • 这是前缀树最经典、最广泛的应用。当用户在搜索框输入一个前缀(比如 "jav")时,系统可以迅速在前缀树中定位到代表 "jav" 的那个节点,然后深度优先遍历该节点下的所有子树,就能找出所有以 "jav" 开头的搜索词(如 "java", "javascript" 等),并推荐给用户。
  2. IP路由表的最长前缀匹配

    • 在网络路由器中,需要根据数据包的目标IP地址,快速地在路由表中找到与之匹配的、前缀最长的路由规则。前缀树是实现这个功能非常高效的数据结构。
  3. 拼写检查与词频统计

    • 可以快速判断一个单词是否存在于词典中。
    • 可以在每个"结束节点"上存储该单词的词频信息,从而高效地进行词频统计。
  4. 敏感词过滤

    • 可以将所有敏感词构建成一棵前缀树。当要检查一段文本时,可以在树上进行匹配,高效地发现其中是否包含敏感词。

总的来说,当你的问题域涉及到大量的字符串 ,并且需要进行频繁的、与前缀相关的查询时,前缀树就是你应该首先考虑的高效解决方案。


LRU是什么?如何实现?

面试官您好,LRU(Least Recently Used)是一种非常经典的缓存淘汰策略 。它的核心思想是:当缓存空间不足时,优先淘汰掉那些最长时间没有被访问过的数据

这个策略基于一个普遍的假设,即局部性原理:如果一个数据最近被访问了,那么它在将来也很有可能被再次访问。反之,如果一个数据已经很久没被访问了,那么它在未来被访问的概率也很低。

1. LRU缓存需要满足的核心要求

一个高效的LRU缓存实现,必须能够快速地完成以下三个操作:

  1. 查找 (Get):能够快速地根据键(Key)查到值(Value)。
  2. 更新/插入 (Put):当一个数据被访问(无论是命中还是新增),都能快速地将其标记为"最近使用的"。
  3. 淘汰 (Evict):当缓存满了需要淘汰数据时,能够快速地找到并删除那个"最久未使用的"数据。

如果我们只用单一的数据结构,很难同时满足这三个要求。比如:

  • 哈希表:查找是 O(1),但无法得知哪个数据最久未使用。
  • 链表:可以把最近使用的放头部,最久未使用的放尾部,淘汰是 O(1),但查找是 O(n)。

因此,LRU的经典实现方案是------哈希表 + 双向链表

2. 实现原理:哈希表 + 双向链表

这个组合非常巧妙,它们各司其职,完美地满足了LRU的所有要求:

  • 哈希表 (HashMap)

    • 作用 :负责快速查找
    • 存储内容Key 是缓存的键,Value 则是指向双向链表中对应节点的引用(指针)
    • 效果 :通过哈希表,我们可以在 O(1) 的时间复杂度内,迅速判断一个数据是否存在于缓存中,并直接定位到它在链表中的位置。
  • 双向链表 (Doubly Linked List)

    • 作用 :负责维护所有缓存数据的访问顺序
    • 存储内容 :链表的每个节点中,除了存储 KeyValue,还必须有指向前一个节点(prev)和后一个节点(next)的指针。
    • 约定 :我们约定链表头部(Head)的节点是最近刚被使用 的,而链表尾部(Tail)的节点则是最久没有被使用的。
    • 效果
      • 由于是双向链表,我们可以在拿到一个节点的引用后,以 O(1) 的时间复杂度将它从链表的任意位置删除。
      • 我们也可以在 O(1) 的时间复杂度内,将一个节点添加到链表的头部。

3. 操作流程

假设我们有一个固定容量为 capacity 的LRU缓存。

a. 访问数据 (Get 操作)

  1. 通过 HashMap 查找 Key
  2. 如果 Key 不存在,返回 null
  3. 如果 Key 存在,说明缓存命中。此时:
    • HashMap 中获取到该 Key 对应的链表节点。
    • 将这个节点从它当前的位置移动到链表的头部(先删除,再头插)。
    • 返回节点中存储的 Value
    • 这个过程保证了"被访问的数据"成为了"最近使用的"。

b. 插入/更新数据 (Put 操作)

  1. 通过 HashMap 查找 Key
  2. 如果 Key 已存在
    • 更新该 Key 对应的 Value
    • 将该节点移动到链表的头部
  3. 如果 Key 不存在(新增数据)
    • 检查缓存是否已满 (size == capacity):
      • 如果已满,就需要淘汰数据
        • 找到链表的尾部节点(它就是最久未使用的)。
        • HashMap 中移除尾部节点的 Key
        • 从链表中删除该尾部节点。
      • 如果未满,则不需要淘汰。
    • 创建一个新的节点 ,包含新的 KeyValue
    • 将新节点插入到链表的头部
    • 将新的 Key 和新节点的引用存入 HashMap

4. Java 中的现成实现

在Java中,我们不需要手动去实现这么复杂的逻辑。java.util.LinkedHashMap 这个类,通过一个构造函数,就可以非常方便地实现一个LRU缓存。

java 复制代码
import java.util.LinkedHashMap;
import java.util.Map;

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    // 关键构造函数:
    // initialCapacity: 初始容量
    // loadFactor: 负载因子
    // accessOrder=true: 开启访问顺序模式,这正是LRU的关键!
    public LRUCache(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    // 重写这个方法,当put新元素导致map的size超过capacity时,
    // LinkedHashMap会自动移除最老的元素。
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

LinkedHashMap 内部正是通过哈希表和双向链表实现的,当 accessOrder 设置为 true 时,每次 getput 操作都会将被访问的元素移动到链表的尾部(这里是尾部代表最近使用),从而实现了LRU的语义。


布隆过滤器怎么设计?时间复杂度?

面试官您好,布隆过滤器(Bloom Filter)是一种非常巧妙的、基于概率的数据结构。它的核心价值在于,能够用极小的内存空间极高的效率 来判断一个元素 "是否可能存在" 于一个巨大的集合中。

1. 为什么需要布隆过滤器?------ 解决海量数据去重问题

如IP黑名单场景,当数据集非常庞大(比如上亿条),如果使用传统的 HashSetHashMap,会面临两个问题:

  1. 内存爆炸:存储1亿个IP地址,即使每个IP只占15个字节,也需要约1.5GB的内存,成本非常高。
  2. 性能瓶颈:当哈希表变得巨大时,哈希冲突会增加,性能可能会下降。

布隆过滤器就是为了解决这类问题而设计的。它不存储元素本身,只存储元素的"指纹",从而极大地压缩了内存。

2. 布隆过滤器的设计原理

它的设计主要包含两个核心部分:

a. 一个很长的二进制位数组 (Bit Array)

  • 这是一个非常大的、所有位都初始化为 0 的数组。比如,我们创建一个长度为 m 的位数组。

b. k 个独立的哈希函数 (Hash Functions)

  • 这些哈希函数需要满足独立性均匀分布 的特性,即它们能将任意输入均匀地散列到 0m-1 的范围内。常见的哈希函数有 MurmurHash, FNV Hash等。

3. 工作流程

i. 添加元素 (Add)

当我们要向布隆过滤器中添加一个元素时(比如一个恶意IP 1.2.3.4):

  1. 将这个元素分别输入到 k 个不同的哈希函数中。
  2. 我们会得到 k 个不同的哈希值
  3. 将这 k 个哈希值作为位数组的索引 ,把这些索引位置上的二进制位全部置为 1
  4. 这就完成了元素的"登记",我们只留下了它的 k 个"指纹",而没有存储IP本身。

ii. 查询元素 (Query / Might-Contain)

当我们需要判断一个新元素(比如IP 5.6.7.8)是否存在时:

  1. 同样地,将这个新元素输入到那 k 个哈希函数 中,得到 k 个哈希值(索引)。
  2. 然后,去检查位数组中k 个索引位置上的值
  3. 判断逻辑
    • 如果发现这 k 个位置中,有任何一个 位置的值是 0,那么我们就可以 100% 确定 ,这个元素绝对不存在 于集合中。因为如果它存在过,这 k 个位置必然都已经被置为 1 了。
    • 如果发现这 k 个位置的值全部都是 1 ,我们只能推断 ,这个元素 "可能存在"

4. 误判率(False Positive)与它的权衡

为什么是"可能存在"?因为一个位置被置为 1,可能是由多个不同元素的哈希碰撞导致的。当我们要查询的元素的所有哈希位恰好都被其他元素"踩过"了,就会发生误判------即,元素明明不在集合里,但布隆过滤器却说它在。

  • 这种"假阳性 "是布隆过滤器唯一的缺陷,但它永远不会有"假阴性"(即,把存在的元素误判为不存在)。
  • 误判率(fpp)是可以通过调整位数组大小 m哈希函数个数 k 来控制的。在给定预期元素数量 n 和期望的误判率 fpp 后,我们可以通过公式计算出最优的 mk
    • 位数组 m 越大,误判率越低。
    • 哈希函数 k 的选择有一个最优值,太多或太少都会增加误判率。

5. 时间与空间复杂度

  • 空间复杂度 :非常低,由位数组大小 m 决定。与存储的元素数量 n 没有直接的线性关系,而是对数关系。这就是它节省内存的关键。

  • 时间复杂度:极高。

    • 插入操作 :需要进行 k 次哈希计算和 k 次内存写操作,时间复杂度为 O(k)
    • 查询操作 :需要进行 k 次哈希计算和 k 次内存读操作,时间复杂度也是 O(k)
    • 由于 k 通常是一个很小的常数(比如10-20),所以我们可以认为其时间复杂度近似为 O(1)

6. 缺点与应用

  • 缺点

    1. 存在误判率。
    2. 无法删除元素。因为删除一个元素的哈希位,可能会影响到其他共享该位的元素。虽然有变体(如Counting Bloom Filter)支持删除,但实现更复杂,空间占用也更大。
  • 应用场景

    • 缓存穿透的防护:在Redis等缓存系统前置一个布隆过滤器。当一个请求查询一个不存在的数据时,布隆过滤器可以直接拦截掉绝大多数这类请求,避免它们穿透到后端数据库。
    • 海量数据去重:如爬虫系统对URL的去重,邮件系统的垃圾邮件地址过滤。
    • 推荐系统:过滤掉那些已经给用户推荐过的物品。
    • Google Chrome 用它来识别恶意网址。

总的来说,布隆过滤器是一种典型的 用"一定的误判率"换取"极高的空间和时间效率" 的数据结构,非常适合那些可以容忍少量误判,但对内存和性能要求极高的"存在性判断"场景。

参考小林 coding

相关推荐
天天摸鱼的java工程师31 分钟前
如何设计一个用户签到系统,支持连续签到统计?
java·后端
海海不掉头发1 小时前
【2025 年】软件体系结构考试试卷-期末考试
java·服务器·软件体系结构
智_永无止境1 小时前
集合的处理:JDK和Guava孰强孰弱?
java·jdk·集合·guava
matdodo1 小时前
【大数据】java API 进行集群间distCP 报错unresolvedAddressException
java·大数据·开发语言
Java技术小馆1 小时前
利用DeepWiki高效阅读项目源码
java·后端·面试
liujing102329292 小时前
Day05_数据结构大项目作业20250620
数据结构
int型码农2 小时前
数据结构第八章(六)-置换选择排序和最佳归并树
java·c语言·数据结构·算法·排序算法
@我漫长的孤独流浪2 小时前
数据结构----排序(3)
数据结构·c++·算法
小道仙972 小时前
gitlab对接,gitlabRestApi,gitlab4j-api
java·git·gitlab
oioihoii2 小时前
C++11 GC Interface:从入门到精通
java·jvm·c++