面试官您好,我了解并使用过多种数据结构。在我的理解中,数据结构可以分为几个大的类别,每一类都有其独特的优势和适用场景。
这类结构的特点是数据元素之间存在一对一的线性关系,像一条线一样。
数组 (Array):
java.util.ArrayList
的底层就是动态数组。链表 (Linked List):
java.util.LinkedList
。它同时实现了 List
和 Deque
接口,既可以当列表用,也可以当栈或队列用。栈 (Stack):
java.util.Stack
(线程安全但已不推荐),现在更推荐使用 java.util.Deque
接口,其实现类如 ArrayDeque
效率更高。队列 (Queue):
java.util.Queue
接口,常用实现有基于链表的 LinkedList
和基于数组的 ArrayDeque
。java.util.HashMap
、Hashtable
(线程安全但已不推荐)、ConcurrentHashMap
(分段锁/CAS实现的高效线程安全哈希表)。这类结构是分层的,元素之间是一对多的关系。
二叉搜索树 (Binary Search Tree, BST):
平衡二叉搜索树 (Balanced BST):
java.util.TreeMap
和 java.util.TreeSet
的底层就是一种自平衡的红黑树 (Red-Black Tree)。堆 (Heap):
java.util.PriorityQueue
(优先队列),其底层就是用堆实现的。字典树 (Trie / Prefix Tree):
数据结构 | 主要优点 | 主要缺点 | 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. 内存存储方式(根本区别)
2. 访问效率(读操作)
address = base_address + index * element_size
直接计算出任何一个元素的内存地址,从而实现O(1)时间复杂度的随机访问。3. 插入和删除效率(写操作)
数组:较低。
链表:极高。
4. 内存使用与CPU缓存友好度
数组:
链表:
特性/维度 | 数组 (Array) | 链表 (Linked List) |
---|---|---|
内存结构 | 连续存储 | 离散存储 |
随机访问 (读) | O(1),极快 | O(n),慢 |
插入/删除 (写) | O(n),慢 | O(1) (定位后),快 |
内存开销 | 可能有预分配浪费,但无指针开销 | 有额外的指针开销 |
缓存友好度 | 高,遍历速度快 | 低,遍历时缓存命中率低 |
适用场景 | 读多写少,需要频繁按索引访问的场景 | 写多读少,需要频繁插入删除的场景 |
Java实现 | ArrayList |
LinkedList |
在实际开发中,由于现代CPU的缓存机制对性能影响巨大,ArrayList
在绝大多数情况下的综合性能都优于 LinkedList
,即使是在涉及部分插入/删除的场景。只有在需要对列表的头部和尾部进行大量操作时,LinkedList
作为队列(Deque)的优势才能真正体现出来。
面试官您好,队列和栈都是非常重要的线性数据结构,它们最核心的区别在于数据进出的规则不同,这导致了它们的应用场景也截然不同。
可以把它们想象成两种不同的通道:
我从以下几个方面来详细对比它们:
1. 操作规则(核心区别)
队列:
栈:
2. 元素访问顺序
3. 应用场景
正是因为它们操作规则的不同,导致了它们在解决问题时扮演的角色完全不同。
队列的应用场景(强调“公平”和“顺序”):
栈的应用场景(强调“逆序”、“配对”和“现场保存”):
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 ) |
面试官您好,栈(Stack)是一种非常重要的、遵循后进先出(Last-In, First-Out, LIFO) 原则的线性数据结构。
您可以把栈想象成一个一摞盘子或者一个死胡同。所有的操作都只能在一端进行,这一端我们称之为栈顶(Top)。
这个“后进先出”的特性,决定了最后放进去的元素,总是最先被取出来。
核心操作包括:
push(item)
: 将一个元素压入栈顶。pop()
: 移除并返回栈顶的元素。peek()
: 查看栈顶的元素,但不移除它。isEmpty()
: 检查栈是否为空。size()
: 返回栈中元素的数量。栈的LIFO特性使它在计算机科学中应用极为广泛,特别是在需要保存和恢复现场或者处理逆序关系的场景:
在Java中,实现栈主要有以下几种方式:
Deque
接口在现代Java开发中,官方不再推荐使用古老的 java.util.Stack
类(因为它继承自 Vector
,有不必要的同步开销)。而是强烈推荐使用 Deque
(双端队列)接口及其实现类 ArrayDeque
来模拟栈的行为。
ArrayDeque
提供了标准的 push
, pop
, peek
方法,并且性能比 Stack
更好。
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
}
}
这是一种常见的面试题,用于考察对数据结构基本原理的理解。
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;
}
}
优缺点分析:
这种方式可以实现一个动态扩容的栈。
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) 特性。
我们需要准备两个栈:
核心规则:
入队 add(element)
:非常简单,直接将元素 push
到 inStack
中。
出队 poll()
:这是最关键的一步。
outStack
是否为空。outStack
不为空,说明里面还有之前“倒腾”过来的、顺序正确的元素,直接 pop
出栈顶元素即可。outStack
为空,就必须进行一次 “倒水” 操作:将 inStack
中的所有元素,一个一个地 pop
出来,然后 push
到 outStack
中。这个过程完成后,inStack
会变空,而 outStack
中的元素顺序就和它们最初入队的顺序完全一致了(先进的元素现在位于栈顶)。然后再从 outStack
中 pop
出栈顶元素。null
。查看队头 peek()
:逻辑和出队完全一样,只是最后一步不是 pop
,而是 peek
查看 outStack
的栈顶元素。
下面是一个具体的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)作为一种核心数据结构,在不同的应用场景下演化出了多种形态。我将它们分为单体应用内队列和分布式系统队列两大类来介绍。
这类队列运行在单个应用程序的内存中,主要用于解决线程间的协作和数据传递问题。
java.util.Queue
接口定义。
LinkedList
:基于链表实现,在队头和队尾进行增删操作的效率很高。ArrayDeque
:基于动态数组实现,由于其优秀的缓存局部性,在大多数情况下性能优于 LinkedList
。java.util.concurrent.BlockingQueue
接口定义。
ArrayBlockingQueue
:基于数组的有界阻塞队列,创建时必须指定容量,支持公平/非公平策略。LinkedBlockingQueue
:基于链表的阻塞队列,可以是有界的(容量默认为Integer.MAX_VALUE
),吞吐量通常高于ArrayBlockingQueue
。SynchronousQueue
:一个不存储元素的“接头”队列,每个put
操作必须等待一个take
操作,反之亦然。非常适合传递性场景。ThreadPoolExecutor
使用 BlockingQueue
来存放等待执行的任务,完美地协调了任务提交者和工作线程。java.util.PriorityQueue
,其底层是基于二叉堆(Heap) 实现的。java.util.Deque
接口定义,ArrayDeque
是其首选实现。java.util.Stack
类性能不佳,Deque
已成为官方推荐的实现栈的方式(push
对应 addFirst
,pop
对应 removeFirst
)。Fork/Join
框架中,每个线程都维护一个双端队列。线程从自己队列的头部获取任务,当自己队列为空时,可以从其他线程队列的尾部“窃取”一个任务来执行,以减少线程竞争,提高效率。这类队列通常作为独立的消息中间件(Message Queue, MQ)存在,用于解决跨进程、跨服务器的通信问题。
Kafka
, RabbitMQ
, RocketMQ
。队列类型 | 核心特性 | 典型应用场景 |
---|---|---|
普通队列 | 先进先出 (FIFO) | BFS、任务缓冲 |
阻塞队列 | 线程安全、生产者/消费者阻塞 | 线程池、生产者-消费者模型 |
优先队列 | 按优先级出队 | 任务调度、Top K 问题 |
双端队列 | 两端均可入队/出队 | 实现栈、工作窃取算法 |
分布式队列(MQ) | 跨进程/跨服务器,高可用、高可靠 | 系统解耦、异步通信、流量削峰 |
面试官您好,要理解平衡二叉树,我们首先需要知道它解决了什么问题。
我们知道,普通二叉搜索树 (Binary Search Tree, BST) 的定义是:对于任意节点,其左子树上所有节点的值都小于它,右子树上所有节点的值都大于它。这个特性使得查找、插入、删除操作的平均时间复杂度可以达到 O(log n),效率很高。
但是,BST 有一个致命的缺陷:它的性能严重依赖于树的形态。
1, 2, 3, 4, 5
),BST 就会退化成一条链表。在这种情况下,树的高度等于节点数 n,所有操作的时间复杂度都会恶化到 O(n),失去了树形结构应有的优势。平衡二叉树的诞生,就是为了解决普通二叉搜索树的这种“退化”问题。
平衡二叉树的本质,仍然是一棵二叉搜索树,它完全继承了BST的性质。但在此基础上,它增加了一个严格的“平衡”约束,以确保树永远不会变得“头重脚轻”或“一条腿长一条腿短”。
核心定义与特性:
这个定义是递归的,它保证了整棵树从上到下都是平衡的。通过维持这个平衡,平衡二叉树可以确保其高度始终保持在 O(log n) 的量级,从而保证了所有操作的性能始终稳定在 O(log n)。
平衡二叉树的神奇之处在于,它有一套自平衡(Self-Balancing) 的机制。当进行插入或删除操作,导致某个节点的平衡因子大于1(即树失衡)时,它会自动进行调整来恢复平衡。
这个调整的核心操作就是——旋转(Rotation)。
旋转主要分为两种基本类型:
根据失衡的不同情况(比如,是插入到左子树的左边,还是左子树的右边),需要进行的旋转组合也不同,主要分为四种失衡类型:LL(左左)、RR(右右)、LR(左右)、RL(右左)。通过一次或两次旋转,就可以使失衡的子树重新恢复平衡。
AVL树:这是最严格的平衡二叉树,它严格要求任何节点的平衡因子绝对值不能超过1。因此它的查找效率最高,但插入和删除时为了维持平衡,可能需要进行更多的旋转操作,维护成本较高。
红黑树 (Red-Black Tree):这是一种非严格的平衡二叉树。它通过引入“颜色”(红或黑)和五条简单的着色规则,来近似地维持树的平衡。它不追求绝对的平衡,只保证从根到最远叶子节点的路径长度,不超过到最近叶子节点路径长度的两倍。
TreeMap
和 TreeSet
,以及Linux内核中的多种数据结构,都是用红黑树实现的。总结一下:平衡二叉树通过引入严格的平衡约束和自平衡的旋转机制,确保了树的高度始终在 O(log n) 级别,从而解决了普通二叉搜索树可能退化成链表的性能问题,为高效的动态查找提供了可靠的性能保障。
面试官您好,红黑树和跳表都是非常优秀的数据结构,它们都实现了有序集合的高效动态操作,提供了 O(log n) 时间复杂度的增、删、查性能。但它们实现这一目标的思路和底层结构完全不同,这导致了它们在实现复杂度、并发性能和适用场景上各有千秋。
1. 它是什么?
红黑树是一种近似平衡的二叉搜索树。它并不是追求像AVL树那样“绝对的平衡”(左右子树高度差不超过1),而是通过一套相对宽松的规则,来确保树不会过度倾斜。
2. 它是如何工作的?—— 五条核心规则
它在普通二叉搜索树的基础上,为每个节点增加了一个“颜色”(红色或黑色)属性,并强制要求整棵树必须始终满足以下五条规则:
通过这五条规则,特别是后两条,红黑树巧妙地保证了最长路径(红黑相间的路径)不会超过最短路径(全是黑节点的路径)的两倍。这就确保了树的高度始终保持在 O(log n) 级别,从而保证了性能。
当插入或删除节点破坏了这些规则时,红黑树会通过变色和旋转(左旋、右旋)等局部操作,来重新恢复平衡。
3. 优缺点与应用
TreeMap
, TreeSet
, ConcurrentSkipListMap
(在JDK 8之前)。map
, set
。1. 它是什么?
跳表是一种基于有序链表的、通过增加多级“快速通道”(索引) 来实现高效查找的数据结构。它的思想非常巧妙,有点像“空间换时间”。
2. 它是如何工作的?—— “给链表建高速公路”
由于每一层都是通过随机的方式构建的(通常是抛硬币,决定一个节点是否要被提升到上一层),所以跳表在统计学上能保证其平均高度为 O(log n),从而实现了 O(log n) 的平均查找复杂度。插入和删除操作也类似,先找到位置,再更新各层的指针。
3. 优缺点与应用
ConcurrentSkipListMap
和 ConcurrentSkipListSet
,它们是JDK中用于替代 TreeMap/Set
的高效线程安全实现。特性 | 红黑树 (Red-Black Tree) | 跳表 (Skip List) |
---|---|---|
底层结构 | 树形结构 | 链表 + 多级索引 |
性能保证 | 严格的规则保证 (确定性) | 随机化保证 (概率性) |
实现复杂度 | 高,逻辑复杂,调试困难 | 低,逻辑清晰,易于实现 |
并发支持 | 差,写操作锁粒度大 | 好,写操作影响范围小,易于实现高并发 |
空间占用 | 相对较低 | 相对较高 (需要存储多层索引指针) |
典型代表 | C++ STL map , Java TreeMap |
Redis ZSET , Java ConcurrentSkipListMap |
总的来说,红黑树是一种经典的、确定性的平衡数据结构,在单线程环境下非常优秀。而跳表则以其简单、高效、易于并发的特点,在现代多核、高并发的系统中,越来越受到青天睐。
面试官您好,AVL树和红黑树都是非常优秀的自平衡二叉搜索树,它们都保证了操作的时间复杂度在 O(log n) 级别。但它们在平衡策略上的“严格”与“宽松”之别,导致了它们在查询性能和插入/删除性能上各有侧重。
简单来说,结论是:
下面我来详细解释一下原因:
AVL树:它是一种高度平衡的树。它严格要求任何节点的左右子树高度差的绝对值不能超过1。这个苛刻的条件使得AVL树在结构上尽可能地“矮”和“胖”,其高度无限接近于理论最小值 log n。
红黑树:它是一种弱平衡或者说“大致平衡”的树。它不直接关心高度差,而是通过一套颜色规则来保证最长路径(从根到最远叶子)的长度不超过最短路径的两倍。
结论:在查询性能上,AVL树 > 红黑树。
插入和删除操作都包含两个阶段:查找 和 调整。它们的查找性能差异如上所述,关键在于调整阶段的开销。
AVL树:
红黑树:
结论:在插入/删除性能上,红黑树 >> AVL树。
特性/维度 | AVL树 (高度平衡) | 红黑树 (弱平衡) |
---|---|---|
平衡策略 | 严格:高度差 ≤ 1 | 宽松:最长路径 ≤ 2 * 最短路径 |
查询性能 | 更优 (树高更低) | 较优 (树高可能稍高) |
写入性能 | 较差 (调整开销大,可能多次旋转) | 更优 (调整开销小,变色为主,最多2次旋转) |
适用场景 | 读多写少的场景,如数据库索引 | 读写频繁的场景,需要兼顾查询和写入性能 |
正因为红黑树在查询和写入性能上取得了更好的平衡,使得它在工程实践中的应用远比AVL树广泛。例如,Java的 TreeMap
和 TreeSet
,C++ STL的 map
和 set
,以及Linux内核都选择了红黑树作为其核心的有序数据结构实现。而AVL树更多地出现在教科书和理论研究中。
面试官您好,B+树是一种为磁盘等外部存储设备量身定制的多路平衡查找树。它的所有设计,最终都指向一个核心目标:尽可能地减少磁盘I/O次数,从而极大地提升在大数据量下的查询效率。
B+树的特点,可以从它的结构设计和操作优势两个层面来理解。
a. 所有数据都只存在于叶子节点
b. 所有叶子节点构成一个有序链表
ID between 100 and 500
的所有数据,我们只需要先定位到 ID=100
所在的叶子节点,然后就可以沿着这个有序链表,一直向后遍历,直到 ID > 500
为止。这个过程完全是顺序的磁盘I/O,效率极高,避免了传统B树需要反复从中序遍历返回上层节点再下来的低效操作。c. 多路平衡(M-way Tree)
基于以上结构特点,B+树在数据库等场景中展现出巨大优势:
ORDER BY
操作变得非常高效。总结:
B+树通过非叶子节点只存索引、数据全在叶子节点、叶子节点形成有序链表以及多路平衡这几大核心设计,完美地适应了磁盘的读写特性。它以极矮的树高和高效的范围查询能力,成为了关系型数据库(如MySQL的InnoDB)和文件系统索引实现的不二之选。
面试官您好,红黑树、B树和B+树都是高效的自平衡查找树,但它们的设计目标和应用场景截然不同,这导致了它们在结构、性能和适用领域上存在巨大差异。
简单来说:
下面我从几个核心维度来详细对比它们:
红黑树:
TreeMap
、C++ STL的 map
、以及Linux内核中的多种数据管理。B树 / B+树:
红黑树:是严格的二叉树,每个节点最多只有两个子节点。其高度约为 O(log₂n),在内存中这已经足够高效。
B树 / B+树:是多路查找树(M-way Tree),也叫“M叉树”。每个节点可以拥有成百上千个子节点。
这是 B树 和 B+树 之间最核心的区别。
红黑树:每个节点都同时存储键(Key)和数据(Value/Data)。
B树:每个节点也都是同时存储键(Key)和数据(Value/Data)。这意味着,一次查询有可能在到达叶子节点之前,就在一个非叶子节点上找到了数据并返回。
B+树:做了一个重要的区分:
单点查询:
范围查询与遍历:这是 B+树的杀手锏。
特性/维度 | 红黑树 (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),但查询可能在不同深度结束 | 最稳定,所有查询都必须到达叶子层,路径长度一致 |
简单总结:
面试官您好,堆(Heap)是一种基于完全二叉树的、非常高效的数据结构。它的核心价值在于能够以 O(1) 的时间复杂度快速获取到集合中的最大值或最小值。
我可以从两大核心属性、底层实现、核心操作和典型应用场景这几个方面来详细阐述。
一个合法的堆必须同时满足以下两个条件:
结构属性:它必须是一棵完全二叉树(Complete Binary Tree)。
堆序属性(Heap Property):
堆最巧妙的实现方式就是使用数组。由于它是完全二叉树,我们可以通过简单的数学计算来找到任意节点的父节点和子节点,无需任何指针:
i
(从0开始)。(i - 1) / 2
。2 * i + 1
。2 * i + 2
。这种实现方式不仅节省了存储指针的额外空间,还因为内存是连续的,所以CPU缓存友好度非常高。
堆的核心操作在于,当插入或删除元素破坏了堆序属性后,它能通过高效的调整操作来恢复。
获取最值 peek()
:直接返回数组的第一个元素(索引0),时间复杂度为 O(1)。
插入元素 add()
/ push()
:
删除最值 poll()
/ pop()
:
堆的这些特性,使它成为解决很多问题的利器。
实现优先队列(Priority Queue):这是堆最直接、最经典的应用。java.util.PriorityQueue
的底层就是用堆实现的。无论是任务调度、事件处理,只要涉及到需要根据优先级处理元素的场景,优先队列都是首选。
求“Top K”问题:这是一个非常常见的面试题。比如,要在海量数据中找出最大的K个元素。
堆排序(Heap Sort):一种原地排序算法,时间复杂度稳定在 O(n log n)。
特性/操作 | 描述 | 时间复杂度 |
---|---|---|
底层实现 | 数组 (利用完全二叉树特性) | - |
获取最值 | 直接访问堆顶 (数组索引0) | O(1) |
插入元素 | 在末尾添加,然后“上浮”调整 | O(log n) |
删除最值 | 将末尾元素换到堆顶,然后“下沉”调整 | O(log n) |
核心应用 | 优先队列、Top K 问题、堆排序 | - |
总的来说,堆是一种看似简单,但功能强大且高效的数据结构,特别是在需要动态地、快速地找出集合中最值元素的场景下,它几乎是无可替代的选择。
面试官您好,前缀树,也常被称为字典树或Trie树,是一种非常特殊的树形数据结构。它不是用来存储任意类型数据的通用树,而是专门为高效地存储和检索字符串集合而设计的。
它的核心思想是:利用字符串的公共前缀来节约存储空间和减少不必要的字符串比较,从而极大地提升查询效率。
您可以把它想象成一本 “按前缀组织的英文字典”:
它的结构有几个鲜明特点:
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
。插入 (Insert):从根节点开始,沿着字符串的字符逐层向下走。如果路径上的某个节点不存在,就创建一个。当字符串的所有字符都处理完毕后,将最后一个节点标记为“结束节点”。
查找 (Search):和插入类似,从根节点开始沿着字符串的字符向下查找。如果中途路径断了,说明该字符串不存在。如果路径走完了,还要检查最后一个节点是否被标记为“结束节点”,才能确定它是一个完整的单词,而不仅仅是一个前缀。
前缀查询 (StartsWith):逻辑和查找几乎一样,但只要路径能完整地走完,无论最后一个节点是否是“结束节点”,都返回 true
。
优点:
缺点:
前缀树的应用场景都和它的核心特性——“高效处理字符串前缀”——密切相关。
搜索引擎的自动补全/输入提示:
IP路由表的最长前缀匹配:
拼写检查与词频统计:
敏感词过滤:
总的来说,当你的问题域涉及到大量的字符串,并且需要进行频繁的、与前缀相关的查询时,前缀树就是你应该首先考虑的高效解决方案。
面试官您好,LRU(Least Recently Used)是一种非常经典的缓存淘汰策略。它的核心思想是:当缓存空间不足时,优先淘汰掉那些最长时间没有被访问过的数据。
这个策略基于一个普遍的假设,即局部性原理:如果一个数据最近被访问了,那么它在将来也很有可能被再次访问。反之,如果一个数据已经很久没被访问了,那么它在未来被访问的概率也很低。
一个高效的LRU缓存实现,必须能够快速地完成以下三个操作:
如果我们只用单一的数据结构,很难同时满足这三个要求。比如:
因此,LRU的经典实现方案是——哈希表 + 双向链表。
这个组合非常巧妙,它们各司其职,完美地满足了LRU的所有要求:
哈希表 (HashMap):
Key
是缓存的键,Value
则是指向双向链表中对应节点的引用(指针)。双向链表 (Doubly Linked List):
Key
和 Value
,还必须有指向前一个节点(prev
)和后一个节点(next
)的指针。假设我们有一个固定容量为 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
。在Java中,我们不需要手动去实现这么复杂的逻辑。java.util.LinkedHashMap
这个类,通过一个构造函数,就可以非常方便地实现一个LRU缓存。
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)是一种非常巧妙的、基于概率的数据结构。它的核心价值在于,能够用极小的内存空间和极高的效率来判断一个元素 “是否可能存在” 于一个巨大的集合中。
如IP黑名单场景,当数据集非常庞大(比如上亿条),如果使用传统的 HashSet
或 HashMap
,会面临两个问题:
布隆过滤器就是为了解决这类问题而设计的。它不存储元素本身,只存储元素的“指纹”,从而极大地压缩了内存。
它的设计主要包含两个核心部分:
a. 一个很长的二进制位数组 (Bit Array)
0
的数组。比如,我们创建一个长度为 m
的位数组。b. k 个独立的哈希函数 (Hash Functions)
0
到 m-1
的范围内。常见的哈希函数有 MurmurHash, FNV Hash等。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
,我们只能推断,这个元素 “可能存在”。为什么是“可能存在”?因为一个位置被置为 1
,可能是由多个不同元素的哈希碰撞导致的。当我们要查询的元素的所有哈希位恰好都被其他元素“踩过”了,就会发生误判——即,元素明明不在集合里,但布隆过滤器却说它在。
fpp
)是可以通过调整位数组大小 m
和 哈希函数个数 k
来控制的。在给定预期元素数量 n
和期望的误判率 fpp
后,我们可以通过公式计算出最优的 m
和 k
。
m
越大,误判率越低。k
的选择有一个最优值,太多或太少都会增加误判率。空间复杂度:非常低,由位数组大小 m
决定。与存储的元素数量 n
没有直接的线性关系,而是对数关系。这就是它节省内存的关键。
时间复杂度:极高。
k
次哈希计算和 k
次内存写操作,时间复杂度为 O(k)。k
次哈希计算和 k
次内存读操作,时间复杂度也是 O(k)。k
通常是一个很小的常数(比如10-20),所以我们可以认为其时间复杂度近似为 O(1)。缺点:
应用场景:
总的来说,布隆过滤器是一种典型的 用“一定的误判率”换取“极高的空间和时间效率” 的数据结构,非常适合那些可以容忍少量误判,但对内存和性能要求极高的“存在性判断”场景。
参考小林 coding