了解哪些数据结构?
面试官您好,我了解并使用过多种数据结构。在我的理解中,数据结构可以分为几个大的类别,每一类都有其独特的优势和适用场景。
1. 线性结构 (Linear Structures)
这类结构的特点是数据元素之间存在一对一的线性关系,像一条线一样。
-
数组 (Array):
- 特点 :它是一块连续的内存空间 ,通过索引来访问元素,所以随机访问速度极快,时间复杂度是 O(1)。
- 缺点:插入和删除元素比较慢,因为需要移动后续所有元素,平均时间复杂度是 O(n)。
- Java实现 :
java.util.ArrayList
的底层就是动态数组。 - 应用场景 :适合读多写少,并且需要频繁按索引查找元素的场景。
-
链表 (Linked List):
- 特点 :由一系列节点组成,内存空间不要求连续。每个节点除了存储数据,还存有指向下一个(或上一个)节点的指针。
- 优点 :插入和删除元素非常快,只需要修改相邻节点的指针即可,时间复杂度是 O(1)。
- 缺点 :随机访问很慢,必须从头节点开始遍历,时间复杂度是 O(n)。
- Java实现 :
java.util.LinkedList
。它同时实现了List
和Deque
接口,既可以当列表用,也可以当栈或队列用。 - 应用场景 :适合写多读少,需要频繁进行插入和删除操作的场景。
-
栈 (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.HashMap
、Hashtable
(线程安全但已不推荐)、ConcurrentHashMap
(分段锁/CAS实现的高效线程安全哈希表)。 - 应用场景:几乎无处不在,如缓存系统、配置信息存储、需要快速通过Key查找Value的任何场景。
3. 树形结构 (Tree Structures)
这类结构是分层的,元素之间是一对多的关系。
-
二叉搜索树 (Binary Search Tree, BST):
- 特点:左子节点的值小于父节点,右子节点的值大于父节点。
- 优点:使得查找、插入、删除的平均时间复杂度都为 O(log n)。
- 缺点:在极端情况下可能退化成链表,时间复杂度降为 O(n)。
-
平衡二叉搜索树 (Balanced BST):
- 特点 :为了解决BST的退化问题,通过自平衡操作(如旋转),确保树的高度大致保持在 log n 级别。
- Java实现 :
java.util.TreeMap
和java.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
接口定义,常用实现类有LinkedList
和ArrayDeque
。 - 栈 :虽然有
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) 特性。
实现思路
我们需要准备两个栈:
- 一个输入栈 (inStack) :专门负责处理所有入队 (enqueue) 的操作。
- 一个输出栈 (outStack) :专门负责处理所有出队 (dequeue) 和查看队头 (peek) 的操作。
核心规则:
-
入队
add(element)
:非常简单,直接将元素push
到inStack
中。 -
出队
poll()
:这是最关键的一步。- 首先,检查
outStack
是否为空。 - 如果
outStack
不为空 ,说明里面还有之前"倒腾"过来的、顺序正确的元素,直接pop
出栈顶元素即可。 - 如果
outStack
为空 ,就必须进行一次 "倒水" 操作:将inStack
中的所有元素,一个一个地pop
出来,然后push
到outStack
中。这个过程完成后,inStack
会变空,而outStack
中的元素顺序就和它们最初入队的顺序完全一致了(先进的元素现在位于栈顶)。然后再从outStack
中pop
出栈顶元素。 - 如果两个栈都为空,说明队列为空,返回
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)。 - 摊还分析 :我们可以看到,每个元素一生中最多只会被
push
进inStack
一次,pop
出inStack
一次,push
进outStack
一次,pop
出outStack
一次。总共最多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
对应addFirst
,pop
对应removeFirst
)。 - 工作窃取(Work-Stealing)算法 :在Java的
Fork/Join
框架中,每个线程都维护一个双端队列。线程从自己队列的头部获取任务,当自己队列为空时,可以从其他线程队列的尾部"窃取"一个任务来执行,以减少线程竞争,提高效率。
- 实现栈(Stack) :由于
二、 分布式系统队列 (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 。这个高度差,我们通常称之为"平衡因子"。
- 它的任意一个节点的左右子树 也必须是一棵平衡二叉树。
这个定义是递归 的,它保证了整棵树从上到下都是平衡的。通过维持这个平衡,平衡二叉树可以确保其高度始终保持在 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的
TreeMap
和TreeSet
,以及Linux内核中的多种数据结构,都是用红黑树实现的。
总结一下:平衡二叉树通过引入严格的平衡约束和自平衡的旋转机制,确保了树的高度始终在 O(log n) 级别,从而解决了普通二叉搜索树可能退化成链表的性能问题,为高效的动态查找提供了可靠的性能保障。
红黑树是什么,跳表是什么?
面试官您好,红黑树和跳表都是非常优秀的数据结构,它们都实现了有序集合 的高效动态操作,提供了 O(log n) 时间复杂度的增、删、查性能。但它们实现这一目标的思路和底层结构完全不同,这导致了它们在实现复杂度、并发性能和适用场景上各有千秋。
红黑树 (Red-Black Tree)
1. 它是什么?
红黑树是一种近似平衡的二叉搜索树。它并不是追求像AVL树那样"绝对的平衡"(左右子树高度差不超过1),而是通过一套相对宽松的规则,来确保树不会过度倾斜。
2. 它是如何工作的?------ 五条核心规则
它在普通二叉搜索树的基础上,为每个节点增加了一个"颜色"(红色或黑色)属性,并强制要求整棵树必须始终满足以下五条规则:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色的。
- 所有叶子节点(NIL节点,即空节点)都是黑色的。
- 关键规则1 :红色节点的子节点必须是黑色的。(杜绝了连续的红色节点)
- 关键规则2 :从任意一个节点出发,到其所有后代叶子节点的路径上,黑色节点的数量必须相同。
通过这五条规则,特别是后两条,红黑树巧妙地保证了最长路径(红黑相间的路径)不会超过最短路径(全是黑节点的路径)的两倍 。这就确保了树的高度始终保持在 O(log n) 级别,从而保证了性能。
当插入或删除节点破坏了这些规则时,红黑树会通过变色 和旋转(左旋、右旋)等局部操作,来重新恢复平衡。
3. 优缺点与应用
- 优点 :
- 性能稳定且高效,所有操作的最坏时间复杂度都是 O(log n)。
- 是一种非常经典、经过充分验证的平衡树结构。
- 缺点 :
- 实现复杂:规则多,旋转和变色的逻辑判断比较复杂,手写和调试都很有挑战性。
- 并发性能差:在多线程环境下,写操作(插入/删除)的调整范围可能很大(从叶子到根),导致锁的粒度很大,难以实现高效的并发控制。
- 应用 :
- Java :
TreeMap
,TreeSet
,ConcurrentSkipListMap
(在JDK 8之前)。 - C++ STL :
map
,set
。 - Linux内核:用它来管理内存区域、调度进程等。
- Java :
跳表 (Skip List)
1. 它是什么?
跳表是一种基于有序链表 的、通过增加多级"快速通道"(索引) 来实现高效查找的数据结构。它的思想非常巧妙,有点像"空间换时间"。
2. 它是如何工作的?------ "给链表建高速公路"
- 底层 (Level 0) :首先,它有一个完整的、有序的普通链表,包含了所有的数据。
- 建立索引层 :在底层链表的基础上,它会随机地从一些节点中"提拔"出一部分,形成上一层的"快速通道"(索引层)。然后,再从这个索引层中,再提拔一部分形成更上一层的索引... 以此类推,直到最顶层只有少数几个节点。
- 查找过程 :查找一个元素时,我们从最高层的索引 开始,向右遍历。
- 如果下一个节点的值比目标值大,或者到了这层的末尾,就通过一个"向下"的指针,降到下一层继续向右查找。
- 这个过程就像坐火车,先坐最快的高铁(高层索引)到离目标最近的大站,然后换乘动车(中层索引),最后换乘地铁(底层链表),最终精准地找到目标。
由于每一层都是通过随机 的方式构建的(通常是抛硬币,决定一个节点是否要被提升到上一层),所以跳表在统计学上能保证其平均高度为 O(log n),从而实现了 O(log n) 的平均查找复杂度。插入和删除操作也类似,先找到位置,再更新各层的指针。
3. 优缺点与应用
- 优点 :
- 实现简单:相比红黑树,跳表的插入、删除、查找等逻辑都更直观、更容易理解和实现。代码量通常也更少。
- 并发性能好:插入或删除一个节点,通常只需要修改其前后的局部指针,锁的粒度可以做得很小,因此更容易实现高效的并发跳表。
- 缺点 :
- 空间换时间:需要额外的内存来存储各级索引的指针,空间复杂度比红黑树要高一些。
- 应用 :
- Redis :它的有序集合(Sorted Set) 就是用跳表(结合哈希表)来实现的,充分利用了跳表的高效范围查询和简单实现。
- LevelDB/RocksDB:这些存储引擎内部使用跳表作为内存中的数据结构(MemTable),因为它写操作快,并且天然有序,便于后续合并到磁盘。
- Java :
ConcurrentSkipListMap
和ConcurrentSkipListSet
,它们是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的 TreeMap
和 TreeSet
,C++ STL的 map
和 set
,以及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的计算和调整次数。
- 场景 :主要用于内存中 的数据结构,比如Java的
-
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()
:- 将新元素添加到数组的末尾(即完全二叉树的下一个空位)。
- 然后,让这个新元素不断地和它的父节点比较,如果它比父节点更"优先"(比如在大顶堆中它比父节点大),就和父节点交换位置。
- 这个过程一直持续到它不再比父节点更优先,或者它到达了堆顶。这个自下而上的调整过程我们称为 "上浮"(Sift-up / Bubble-up)。
- 由于树的高度是 O(log n),所以插入操作的时间复杂度为 O(log n)。
-
删除最值
poll()
/pop()
:- 将堆顶元素(数组第一个元素)与数组的最后一个元素交换位置。
- 移除数组的最后一个元素(即原来的堆顶)。
- 现在新的堆顶元素是原来树的最后一个节点,它很可能不满足堆序属性。
- 让这个新的堆顶元素不断地和它的子节点中更"优先"的那个进行比较和交换,直到它不再违反堆序属性,或者它成为了叶子节点。这个自上而下的调整过程我们称为 "下沉"(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. 什么是前缀树?------ 空间换时间
它的核心思想是:利用字符串的公共前缀来节约存储空间和减少不必要的字符串比较,从而极大地提升查询效率。
您可以把它想象成一本 "按前缀组织的英文字典":
- 字典的根节点是空的,代表一切的开始。
- 从根节点出发的每一条路径,都代表一个单词。
- 路径上共享的节点,就代表这些单词共享的公共前缀。
它的结构有几个鲜明特点:
- 根节点是空的,不代表任何字符。
- 从根节点到任意一个节点的路径,拼接起来就是该节点所代表的一个字符串前缀。
- 如果一个节点被标记为"结束节点"(通常用一个布尔标志
isEnd
),那么从根到该节点的路径就构成了一个完整的单词。 - 一个节点的所有子节点 所代表的字符都是不同的。
举个例子 ,我们要存储 tea
, ten
, inn
这三个单词:
(root)
/ \
t i
/ \
e n
/ \ \
a n n (isEnd=true)
(isEnd=true) (isEnd=true)
tea
和ten
共享了前缀te
,所以它们在前两层共享了路径root -> t -> e
。inn
与它们没有公共前缀,所以走了另一条分支root -> i
。
2. 前缀树的核心操作
-
插入 (Insert):从根节点开始,沿着字符串的字符逐层向下走。如果路径上的某个节点不存在,就创建一个。当字符串的所有字符都处理完毕后,将最后一个节点标记为"结束节点"。
-
查找 (Search):和插入类似,从根节点开始沿着字符串的字符向下查找。如果中途路径断了,说明该字符串不存在。如果路径走完了,还要检查最后一个节点是否被标记为"结束节点",才能确定它是一个完整的单词,而不仅仅是一个前缀。
-
前缀查询 (StartsWith) :逻辑和查找几乎一样,但只要路径能完整地走完,无论最后一个节点是否是"结束节点",都返回
true
。
3. 前缀树的优缺点
-
优点:
- 查询效率极高 :插入和查询一个字符串的时间复杂度是 O(L) ,其中 L 是字符串的长度。这个效率与字典中总共有多少个单词是无关的,这是它相比哈希表等结构在特定场景下的巨大优势。
- 天然支持前缀相关的操作:如前缀匹配、自动补全等。
-
缺点:
- 空间消耗大:如果字符串集合中没有很多公共前缀,或者字符集很大(比如存储中文),前缀树会消耗大量的内存,因为每个节点都需要维护一个指向所有可能子节点的指针数组(或哈希表)。
4. 前缀树的应用场景
前缀树的应用场景都和它的核心特性------"高效处理字符串前缀"------密切相关。
-
搜索引擎的自动补全/输入提示:
- 这是前缀树最经典、最广泛的应用。当用户在搜索框输入一个前缀(比如 "jav")时,系统可以迅速在前缀树中定位到代表 "jav" 的那个节点,然后深度优先遍历该节点下的所有子树,就能找出所有以 "jav" 开头的搜索词(如 "java", "javascript" 等),并推荐给用户。
-
IP路由表的最长前缀匹配:
- 在网络路由器中,需要根据数据包的目标IP地址,快速地在路由表中找到与之匹配的、前缀最长的路由规则。前缀树是实现这个功能非常高效的数据结构。
-
拼写检查与词频统计:
- 可以快速判断一个单词是否存在于词典中。
- 可以在每个"结束节点"上存储该单词的词频信息,从而高效地进行词频统计。
-
敏感词过滤:
- 可以将所有敏感词构建成一棵前缀树。当要检查一段文本时,可以在树上进行匹配,高效地发现其中是否包含敏感词。
总的来说,当你的问题域涉及到大量的字符串 ,并且需要进行频繁的、与前缀相关的查询时,前缀树就是你应该首先考虑的高效解决方案。
LRU是什么?如何实现?
面试官您好,LRU(Least Recently Used)是一种非常经典的缓存淘汰策略 。它的核心思想是:当缓存空间不足时,优先淘汰掉那些最长时间没有被访问过的数据。
这个策略基于一个普遍的假设,即局部性原理:如果一个数据最近被访问了,那么它在将来也很有可能被再次访问。反之,如果一个数据已经很久没被访问了,那么它在未来被访问的概率也很低。
1. LRU缓存需要满足的核心要求
一个高效的LRU缓存实现,必须能够快速地完成以下三个操作:
- 查找 (Get):能够快速地根据键(Key)查到值(Value)。
- 更新/插入 (Put):当一个数据被访问(无论是命中还是新增),都能快速地将其标记为"最近使用的"。
- 淘汰 (Evict):当缓存满了需要淘汰数据时,能够快速地找到并删除那个"最久未使用的"数据。
如果我们只用单一的数据结构,很难同时满足这三个要求。比如:
- 用哈希表:查找是 O(1),但无法得知哪个数据最久未使用。
- 用链表:可以把最近使用的放头部,最久未使用的放尾部,淘汰是 O(1),但查找是 O(n)。
因此,LRU的经典实现方案是------哈希表 + 双向链表。
2. 实现原理:哈希表 + 双向链表
这个组合非常巧妙,它们各司其职,完美地满足了LRU的所有要求:
-
哈希表 (HashMap):
- 作用 :负责快速查找。
- 存储内容 :
Key
是缓存的键,Value
则是指向双向链表中对应节点的引用(指针)。 - 效果 :通过哈希表,我们可以在 O(1) 的时间复杂度内,迅速判断一个数据是否存在于缓存中,并直接定位到它在链表中的位置。
-
双向链表 (Doubly Linked List):
- 作用 :负责维护所有缓存数据的访问顺序。
- 存储内容 :链表的每个节点中,除了存储
Key
和Value
,还必须有指向前一个节点(prev
)和后一个节点(next
)的指针。 - 约定 :我们约定链表头部(Head)的节点是最近刚被使用 的,而链表尾部(Tail)的节点则是最久没有被使用的。
- 效果 :
- 由于是双向链表,我们可以在拿到一个节点的引用后,以 O(1) 的时间复杂度将它从链表的任意位置删除。
- 我们也可以在 O(1) 的时间复杂度内,将一个节点添加到链表的头部。
3. 操作流程
假设我们有一个固定容量为 capacity
的LRU缓存。
a. 访问数据 (Get 操作)
- 通过
HashMap
查找Key
。 - 如果
Key
不存在,返回null
。 - 如果
Key
存在,说明缓存命中。此时:- 从
HashMap
中获取到该Key
对应的链表节点。 - 将这个节点从它当前的位置移动到链表的头部(先删除,再头插)。
- 返回节点中存储的
Value
。 - 这个过程保证了"被访问的数据"成为了"最近使用的"。
- 从
b. 插入/更新数据 (Put 操作)
- 通过
HashMap
查找Key
。 - 如果
Key
已存在 :- 更新该
Key
对应的Value
。 - 将该节点移动到链表的头部。
- 更新该
- 如果
Key
不存在(新增数据) :- 检查缓存是否已满 (
size == capacity
):- 如果已满,就需要淘汰数据 :
- 找到链表的尾部节点(它就是最久未使用的)。
- 从
HashMap
中移除尾部节点的Key
。 - 从链表中删除该尾部节点。
- 如果未满,则不需要淘汰。
- 如果已满,就需要淘汰数据 :
- 创建一个新的节点 ,包含新的
Key
和Value
。 - 将新节点插入到链表的头部。
- 将新的
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
时,每次 get
或 put
操作都会将被访问的元素移动到链表的尾部(这里是尾部代表最近使用),从而实现了LRU的语义。
布隆过滤器怎么设计?时间复杂度?
面试官您好,布隆过滤器(Bloom Filter)是一种非常巧妙的、基于概率的数据结构。它的核心价值在于,能够用极小的内存空间 和极高的效率 来判断一个元素 "是否可能存在" 于一个巨大的集合中。
1. 为什么需要布隆过滤器?------ 解决海量数据去重问题
如IP黑名单场景,当数据集非常庞大(比如上亿条),如果使用传统的 HashSet
或 HashMap
,会面临两个问题:
- 内存爆炸:存储1亿个IP地址,即使每个IP只占15个字节,也需要约1.5GB的内存,成本非常高。
- 性能瓶颈:当哈希表变得巨大时,哈希冲突会增加,性能可能会下降。
布隆过滤器就是为了解决这类问题而设计的。它不存储元素本身,只存储元素的"指纹",从而极大地压缩了内存。
2. 布隆过滤器的设计原理
它的设计主要包含两个核心部分:
a. 一个很长的二进制位数组 (Bit Array)
- 这是一个非常大的、所有位都初始化为
0
的数组。比如,我们创建一个长度为m
的位数组。
b. k 个独立的哈希函数 (Hash Functions)
- 这些哈希函数需要满足独立性 和均匀分布 的特性,即它们能将任意输入均匀地散列到
0
到m-1
的范围内。常见的哈希函数有 MurmurHash, FNV Hash等。
3. 工作流程
i. 添加元素 (Add)
当我们要向布隆过滤器中添加一个元素时(比如一个恶意IP 1.2.3.4
):
- 将这个元素分别输入到
k
个不同的哈希函数中。 - 我们会得到
k
个不同的哈希值。 - 将这
k
个哈希值作为位数组的索引 ,把这些索引位置上的二进制位全部置为1
。 - 这就完成了元素的"登记",我们只留下了它的
k
个"指纹",而没有存储IP本身。
ii. 查询元素 (Query / Might-Contain)
当我们需要判断一个新元素(比如IP 5.6.7.8
)是否存在时:
- 同样地,将这个新元素输入到那
k
个哈希函数 中,得到k
个哈希值(索引)。 - 然后,去检查位数组中这
k
个索引位置上的值。 - 判断逻辑 :
- 如果发现这
k
个位置中,有任何一个 位置的值是0
,那么我们就可以 100% 确定 ,这个元素绝对不存在 于集合中。因为如果它存在过,这k
个位置必然都已经被置为1
了。 - 如果发现这
k
个位置的值全部都是1
,我们只能推断 ,这个元素 "可能存在"。
- 如果发现这
4. 误判率(False Positive)与它的权衡
为什么是"可能存在"?因为一个位置被置为 1
,可能是由多个不同元素的哈希碰撞导致的。当我们要查询的元素的所有哈希位恰好都被其他元素"踩过"了,就会发生误判------即,元素明明不在集合里,但布隆过滤器却说它在。
- 这种"假阳性 "是布隆过滤器唯一的缺陷,但它永远不会有"假阴性"(即,把存在的元素误判为不存在)。
- 误判率(
fpp
)是可以通过调整位数组大小m
和 哈希函数个数k
来控制的。在给定预期元素数量n
和期望的误判率fpp
后,我们可以通过公式计算出最优的m
和k
。- 位数组
m
越大,误判率越低。 - 哈希函数
k
的选择有一个最优值,太多或太少都会增加误判率。
- 位数组
5. 时间与空间复杂度
-
空间复杂度 :非常低,由位数组大小
m
决定。与存储的元素数量n
没有直接的线性关系,而是对数关系。这就是它节省内存的关键。 -
时间复杂度:极高。
- 插入操作 :需要进行
k
次哈希计算和k
次内存写操作,时间复杂度为 O(k)。 - 查询操作 :需要进行
k
次哈希计算和k
次内存读操作,时间复杂度也是 O(k)。 - 由于
k
通常是一个很小的常数(比如10-20),所以我们可以认为其时间复杂度近似为 O(1)。
- 插入操作 :需要进行
6. 缺点与应用
-
缺点:
- 存在误判率。
- 无法删除元素。因为删除一个元素的哈希位,可能会影响到其他共享该位的元素。虽然有变体(如Counting Bloom Filter)支持删除,但实现更复杂,空间占用也更大。
-
应用场景:
- 缓存穿透的防护:在Redis等缓存系统前置一个布隆过滤器。当一个请求查询一个不存在的数据时,布隆过滤器可以直接拦截掉绝大多数这类请求,避免它们穿透到后端数据库。
- 海量数据去重:如爬虫系统对URL的去重,邮件系统的垃圾邮件地址过滤。
- 推荐系统:过滤掉那些已经给用户推荐过的物品。
- Google Chrome 用它来识别恶意网址。
总的来说,布隆过滤器是一种典型的 用"一定的误判率"换取"极高的空间和时间效率" 的数据结构,非常适合那些可以容忍少量误判,但对内存和性能要求极高的"存在性判断"场景。
参考小林 coding