面试编程题精选
开篇:算法面试不只是刷题,更是考察工程思维
面试中的编程题,尤其是手写代码类题目,考察的远不止"你背没背过答案"。面试官真正想看到的,是你面对一个问题时的分析思路:你怎么拆解问题、怎么选择数据结构、怎么处理边界条件,以及你写出来的代码是不是具备工程可读性。
本文精选了面试中最高频的三类编程题:多线程协调、数据结构手写实现、常见编程小题。每道题先讲思路,再给完整可运行的代码,帮你建立"拿到题目先想清楚再动手"的习惯。
一、多线程编程题
多线程题是 Java 面试的经典保留节目。它们的核心考点不是多复杂的并发模型,而是线程间通信的几种基本手段你是否熟练掌握。面试官通常期望你至少能给出两到三种不同的实现方式。
1.1 交替打印:两个线程输出 1A2B3C
这道题的本质是两个线程轮流执行。一个线程打印数字、一个线程打印字母,它们之间需要一个"令牌"机制来协调顺序。
思路很直接:用一个共享的布尔标志位控制"该谁打印了"。打印完之后翻转标志位,并通知对方。实现手段至少有四种:wait/notify、Lock+Condition、Semaphore、yield 自旋。下面逐一展示。
方式一:wait/notify
最经典的 Java 线程通信方式。两个线程共享同一把锁,通过 wait() 让出锁并等待,通过 notify() 唤醒对方。
public class PrintingWithWaitNotify {
private static final Object lock = new Object();
private static boolean printNumber = true;
public static void main(String[] args) {
Thread numberThread = new Thread(new NumberPrinter());
Thread letterThread = new Thread(new LetterPrinter());
numberThread.start();
letterThread.start();
}
static class NumberPrinter implements Runnable {
@Override
public void run() {
synchronized (lock) {
try {
for (int i = 1; i <= 3; i++) {
while (!printNumber) {
lock.wait();
}
System.out.print(i);
printNumber = false;
lock.notify();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class LetterPrinter implements Runnable {
@Override
public void run() {
synchronized (lock) {
try {
for (char c = 'A'; c <= 'C'; c++) {
while (printNumber) {
lock.wait();
}
System.out.print(c);
printNumber = true;
lock.notify();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}方式二:ReentrantLock + Condition
与 wait/notify 的思想相同,但 Condition 提供了更灵活的等待/唤醒控制。在需要多个等待队列的场景下(比如后面的三线程交替打印),Condition 的优势更加明显。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class PrintingWithReentrant {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
Thread thread1 = new Thread(new PrintNumbers(lock, condition));
Thread thread2 = new Thread(new PrintLetters(lock, condition));
thread1.start();
thread2.start();
}
}
class PrintNumbers implements Runnable {
private Lock lock;
private Condition condition;
public PrintNumbers(Lock lock, Condition condition) {
this.lock = lock;
this.condition = condition;
}
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
lock.lock();
try {
System.out.print(i);
condition.signal();
if (i < 3) {
condition.await();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
}
class PrintLetters implements Runnable {
private Lock lock;
private Condition condition;
public PrintLetters(Lock lock, Condition condition) {
this.lock = lock;
this.condition = condition;
}
@Override
public void run() {
for (char letter = 'A'; letter <= 'C'; letter++) {
lock.lock();
try {
System.out.print(letter);
condition.signal();
if (letter < 'C') {
condition.await();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
}方式三:Semaphore
信号量天然适合"你做完了轮到我"的场景。给数字线程一个初始许可为 1 的信号量,给字母线程一个初始许可为 0 的信号量。数字线程打印后释放字母的许可,字母线程打印后释放数字的许可,形成完美的交替。
import java.util.concurrent.Semaphore;
public class PrintingWithSemaphore {
private static final Semaphore semaphoreNumber = new Semaphore(1);
private static final Semaphore semaphoreLetter = new Semaphore(0);
public static void main(String[] args) {
Thread numberThread = new Thread(new NumberPrinter());
Thread letterThread = new Thread(new LetterPrinter());
numberThread.start();
letterThread.start();
}
static class NumberPrinter implements Runnable {
@Override
public void run() {
try {
for (int i = 1; i <= 3; i++) {
semaphoreNumber.acquire();
System.out.print(i);
semaphoreLetter.release();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
static class LetterPrinter implements Runnable {
@Override
public void run() {
try {
for (char c = 'A'; c <= 'C'; c++) {
semaphoreLetter.acquire();
System.out.print(c);
semaphoreNumber.release();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}方式四:volatile + yield 自旋
最"轻量"的方式,不用锁、不用同步工具,靠 volatile 保证可见性,靠 Thread.yield() 避免空转浪费 CPU。但这种方式在生产环境中不推荐,因为自旋会持续消耗 CPU 资源。面试时提一嘴,表明你知道这个选项就行。
public class PrintingWithYield {
private static volatile boolean printNumber = true;
public static void main(String[] args) {
Thread numberThread = new Thread(new NumberPrinter());
Thread letterThread = new Thread(new LetterPrinter());
numberThread.start();
letterThread.start();
}
static class NumberPrinter implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
while (!printNumber) {
Thread.yield();
}
System.out.print(i);
printNumber = false;
Thread.yield();
}
}
}
static class LetterPrinter implements Runnable {
@Override
public void run() {
for (char c = 'A'; c <= 'C'; c++) {
while (printNumber) {
Thread.yield();
}
System.out.print(c);
printNumber = true;
Thread.yield();
}
}
}
}1.2 线程编排:a 先执行,bcd 并发,最后 e
这道题考察的是多线程之间的执行顺序控制,即:a -> (b, c, d 并发) -> e。
思路是用两个 CountDownLatch:第一个 latchA(计数 1)让 b/c/d 等待 a 完成;第二个 latchBCD(计数 3)让 e 等待 b/c/d 全部完成。CountDownLatch 是"一次性门栓",非常适合这种"等所有人到齐再继续"的场景。
import java.util.concurrent.CountDownLatch;
public class ThreadOrderControl {
public static void main(String[] args) {
CountDownLatch latchA = new CountDownLatch(1);
CountDownLatch latchBCD = new CountDownLatch(3);
Thread a = new Thread(() -> {
System.out.println("A start");
System.out.println("A done");
latchA.countDown();
});
Runnable bcdTask = () -> {
try {
latchA.await();
String name = Thread.currentThread().getName();
System.out.println(name + " start");
System.out.println(name + " done");
latchBCD.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread b = new Thread(bcdTask, "B");
Thread c = new Thread(bcdTask, "C");
Thread d = new Thread(bcdTask, "D");
Thread e = new Thread(() -> {
try {
latchBCD.await();
System.out.println("E start");
System.out.println("E done");
} catch (InterruptedException ex) {
ex.printStackTrace();
}
});
a.start();
b.start();
c.start();
d.start();
e.start();
}
}1.3 并发调用:任一成功即返回,全部失败才失败
实际业务中很常见的场景:并发校验多个黑名单,只要有一个命中就立刻返回结果,不用等其余请求。
核心工具是 CompletionService。它包装了 ExecutorService,最大的特点是先完成的任务先返回。我们提交所有任务后,在循环中不断 take() 获取最先完成的结果,一旦拿到 true 就立刻返回,无需等待剩余任务。
ScheduledExecutorService executor = new ScheduledThreadPoolExecutor(20,
new BasicThreadFactory.Builder()
.namingPattern("multi-black-list-decision-%d").build());
CompletionService<Boolean> completionService =
new ExecutorCompletionService<>(executor);
// 提交所有校验任务
for (String blackListName : blackListNames) {
completionService.submit(() ->
getData(new BlackListDecisionObject(obj, blackListName)) != null);
}
try {
int tasks = blackListNames.size();
while (tasks > 0) {
Future<Boolean> future = completionService.take();
boolean result = future.get();
// 只要有一个命中,直接返回
if (result) {
return true;
}
tasks--;
}
// 全部校验完毕,没有命中
return false;
} catch (InterruptedException | ExecutionException e) {
return false;
}1.4 线程池中设置超时:任务跑 10 秒,1 秒就要抛异常
两种常用做法。
方式一:Future.get(timeout) —— 最直接的方式。提交任务拿到 Future 后,调用 get(1, TimeUnit.SECONDS),超时会抛 TimeoutException,然后主动 cancel 任务。
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<String> task = () -> {
Thread.sleep(10000); // 模拟耗时任务
return "Task completed";
};
Future<String> future = executor.submit(task);
try {
String result = future.get(1, TimeUnit.SECONDS);
System.out.println(result);
} catch (TimeoutException e) {
System.out.println("Task timed out!");
future.cancel(true);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}方式二:ScheduledExecutorService —— 用延迟任务来"倒计时取消"。提交主任务后,再 schedule 一个 1 秒后执行的取消任务。
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
Future<String> future = executor.submit(() -> {
Thread.sleep(10000);
return "Task completed";
});
// 1 秒后检查并取消
executor.schedule(() -> {
if (!future.isDone()) {
System.out.println("Task timed out");
future.cancel(true);
}
}, 1, TimeUnit.SECONDS);
try {
String result = future.get();
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}二、数据结构手写实现
面试中被要求"手写一个 XXX"时,考察的不是你能不能一字不差地背出来,而是你对这个数据结构的设计意图和核心操作是否真正理解。
2.1 用栈实现队列
思路很巧妙:两个栈倒腾一下,顺序就反过来了。
准备两个栈 stackIn(入队栈)和 stackOut(出队栈)。入队时直接压入 stackIn。出队时先看 stackOut 是否有元素,有就直接弹;没有就把 stackIn 的元素全部倒入 stackOut,再弹。这样先入队的元素会被倒到 stackOut 的栈顶,实现了先进先出。
均摊时间复杂度是 O(1):每个元素最多被倒腾一次。
import java.util.Stack;
public class MyQueue<T> {
private Stack<T> stackIn;
private Stack<T> stackOut;
public MyQueue() {
stackIn = new Stack<>();
stackOut = new Stack<>();
}
public void enqueue(T element) {
stackIn.push(element);
}
public T dequeue() {
if (stackOut.isEmpty()) {
while (!stackIn.isEmpty()) {
stackOut.push(stackIn.pop());
}
}
return stackOut.pop();
}
public boolean isEmpty() {
return stackIn.isEmpty() && stackOut.isEmpty();
}
}2.2 用队列实现栈
反过来用队列模拟栈。核心思路:pop 时把队列里除了最后一个元素以外的全部转移到临时队列,最后一个元素就是"栈顶"。然后交换两个队列的引用。
这个实现的 push 是 O(1),但 pop 和 peek 是 O(n),因为每次都要搬移。
import java.util.LinkedList;
import java.util.Queue;
public class MyStack<T> {
private Queue<T> queue;
private Queue<T> tempQueue;
public MyStack() {
queue = new LinkedList<>();
tempQueue = new LinkedList<>();
}
public void push(T element) {
queue.offer(element);
}
public T pop() {
if (isEmpty()) {
throw new RuntimeException("Stack is empty");
}
while (queue.size() > 1) {
tempQueue.offer(queue.poll());
}
T element = queue.poll();
Queue<T> temp = queue;
queue = tempQueue;
tempQueue = temp;
return element;
}
public T peek() {
if (isEmpty()) {
throw new RuntimeException("Stack is empty");
}
while (queue.size() > 1) {
tempQueue.offer(queue.poll());
}
T element = queue.poll();
tempQueue.offer(element); // peek 不移除,放回去
Queue<T> temp = queue;
queue = tempQueue;
tempQueue = temp;
return element;
}
public boolean isEmpty() {
return queue.isEmpty();
}
}2.3 LRU 缓存
LRU(Least Recently Used)是面试中出现频率极高的题目。核心思想:最近被访问的数据最有价值,最久没被访问的优先淘汰。
需要两个能力:O(1) 查找(HashMap)和 O(1) 维护访问顺序(双向链表)。Java 的 LinkedHashMap 恰好同时具备这两个能力。
实现一:继承 LinkedHashMap(最简洁)
LinkedHashMap 构造函数的第三个参数 accessOrder=true 表示按访问顺序排列(最近访问的在尾部)。只需重写 removeEldestEntry 方法,当容量超出时返回 true,LinkedHashMap 会自动删除链表头部(最久未访问)的元素。
import java.util.*;
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}实现二:HashMap + LinkedList(手动维护顺序)
面试官可能会要求不用 LinkedHashMap,这时候就得自己维护一个链表来记录访问顺序。
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
public class LRUCache<K, V> {
private final int capacity;
private final Map<K, V> cache;
private final LinkedList<K> keyList;
public LRUCache(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>(capacity);
this.keyList = new LinkedList<>();
}
public synchronized void put(K key, V value) {
if (cache.containsKey(key)) {
keyList.remove(key);
}
while (cache.size() >= capacity) {
K oldestKey = keyList.removeFirst();
cache.remove(oldestKey);
}
cache.put(key, value);
keyList.addLast(key);
}
public synchronized V get(K key) {
if (cache.containsKey(key)) {
keyList.remove(key);
keyList.addLast(key);
return cache.get(key);
}
return null;
}
}2.4 手写 HashMap
面试中经常要求手写一个简易版 HashMap,考察的核心知识点是:数组 + 链表的结构、哈希函数、扩容机制。不需要实现红黑树,但必须体现 put/get 的完整逻辑。
public class SimpleHashMap<K, V> {
// 链表节点
static class Node<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
private Node<K, V>[] table;
private int size;
private int capacity;
private static final int DEFAULT_CAPACITY = 16;
private static final float LOAD_FACTOR = 0.75f;
@SuppressWarnings("unchecked")
public SimpleHashMap() {
this.capacity = DEFAULT_CAPACITY;
this.table = new Node[capacity];
this.size = 0;
}
// 哈希函数:高16位异或低16位,减少碰撞
private int hash(K key) {
if (key == null) return 0;
int h = key.hashCode();
return h ^ (h >>> 16);
}
// 根据哈希值定位数组下标(用位运算代替取模,要求 capacity 是 2 的幂)
private int index(int hash) {
return hash & (capacity - 1);
}
public V put(K key, V value) {
int h = hash(key);
int i = index(h);
// 遍历链表,如果 key 已存在则覆盖
for (Node<K, V> node = table[i]; node != null; node = node.next) {
if (node.hash == h && (node.key == key
|| (key != null && key.equals(node.key)))) {
V oldVal = node.value;
node.value = value;
return oldVal;
}
}
// key 不存在,头插法插入新节点
table[i] = new Node<>(h, key, value, table[i]);
size++;
// 超过负载因子就扩容
if (size > capacity * LOAD_FACTOR) {
resize();
}
return null;
}
public V get(K key) {
int h = hash(key);
int i = index(h);
for (Node<K, V> node = table[i]; node != null; node = node.next) {
if (node.hash == h && (node.key == key
|| (key != null && key.equals(node.key)))) {
return node.value;
}
}
return null;
}
// 扩容:容量翻倍,所有元素重新散列
@SuppressWarnings("unchecked")
private void resize() {
int newCapacity = capacity << 1;
Node<K, V>[] newTable = new Node[newCapacity];
for (int i = 0; i < capacity; i++) {
Node<K, V> node = table[i];
while (node != null) {
Node<K, V> next = node.next;
int idx = node.hash & (newCapacity - 1);
node.next = newTable[idx];
newTable[idx] = node;
node = next;
}
}
table = newTable;
capacity = newCapacity;
}
public int size() { return size; }
}面试要点速查:
- 数组下标计算:
hash & (capacity - 1)等价于hash % capacity,前提是 capacity 是 2 的幂。位运算比取模快。 - 哈希函数:
h ^ (h >>> 16)让高位也参与运算,减少只靠低位决定下标时的碰撞概率。这就是 JDK HashMap 的扰动函数。 - 扩容时机:
size > capacity * loadFactor时触发。默认负载因子 0.75 是时间和空间的折中——太小浪费空间,太大链表变长查找变慢。 - 扩容过程:容量翻倍,所有节点重新计算下标并迁移。JDK 8 优化为只看新增的那一位 bit 是 0 还是 1 来判断节点是留在原位还是移到
原位 + 旧容量的位置,避免重新计算哈希。 - 线程不安全:多线程并发 put 可能导致链表成环(JDK 7 头插法)或数据覆盖。生产环境用
ConcurrentHashMap。
2.5 实现字符串的 equals 方法
这道题不是考你背 String 源码,而是考你写代码时有没有 fail-fast 的意识。很多人上来就 for 循环逐个字符比较,但其实有很多情况可以提前短路:
- 引用相同 -> 直接 true
- null 判断 -> 直接 false
- 长度不同 -> 直接 false
- hashCode 不同 -> 直接 false(hashCode 相同不代表相等,但不同一定不等)
- 逐字符比较 -> 有不同则 false
- 全部通过 -> true
public boolean myEquals(String other) {
if (this == other) return true;
if (other == null) return false;
if (this.length() != other.length()) return false;
if (this.hashCode() != other.hashCode()) return false;
for (int i = 0; i < this.length(); i++) {
if (this.charAt(i) != other.charAt(i)) return false;
}
return true;
}三、常见编程题解题套路
3.1 判断 101-200 之间的质数
质数判断的关键优化:只需检查到平方根。如果 n 不是质数,它必定有一个因子小于或等于 sqrt(n)。比如 36 的平方根是 6,它的所有因子对 (1,36), (2,18), (3,12), (4,9), (6,6) 中,每一对至少有一个不超过 6。
public class PrimeNumbers {
public static void main(String[] args) {
int count = 0;
for (int i = 101; i <= 200; i++) {
if (isPrime(i)) {
System.out.println(i);
count++;
}
}
System.out.println("Total: " + count);
}
public static boolean isPrime(int n) {
if (n <= 1) return false;
for (int i = 2; i <= Math.sqrt(n); i++) {
if (n % i == 0) return false;
}
return true;
}
}3.2 O(n) 找数组中最小差值
题目要求时间复杂度必须为 O(n),这就排除了常规排序(O(n log n))。思路是桶排序:值域宽度为 1 的桶,把每个元素放进对应的桶,然后扫描相邻非空桶的最小差值。
import java.util.Arrays;
public class MinDiff {
public static int findMinDiff(int[] arr) {
int n = arr.length;
if (n < 2) return -1;
int minVal = Integer.MAX_VALUE, maxVal = Integer.MIN_VALUE;
for (int v : arr) {
minVal = Math.min(minVal, v);
maxVal = Math.max(maxVal, v);
}
int bucketCount = maxVal - minVal + 1;
int[][] buckets = new int[bucketCount][n];
int[] bucketSizes = new int[bucketCount];
for (int v : arr) {
int index = v - minVal;
buckets[index][bucketSizes[index]++] = v;
}
for (int i = 0; i < bucketCount; i++) {
if (bucketSizes[i] > 0) {
Arrays.sort(buckets[i], 0, bucketSizes[i]);
}
}
int minDiff = Integer.MAX_VALUE;
int prevMax = buckets[0][0];
for (int i = 1; i < bucketCount; i++) {
if (bucketSizes[i] == 0) continue;
int currMin = buckets[i][0];
minDiff = Math.min(minDiff, currMin - prevMax);
prevMax = buckets[i][bucketSizes[i] - 1];
}
return minDiff;
}
}3.3 BST 中找第 k 小的元素
二叉搜索树有一个非常重要的性质:中序遍历的结果是有序的。所以只需要做一次中序遍历,边遍历边计数,数到第 k 个就是答案。
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int x) { val = x; }
}
public class Solution {
private int count = 0;
private int result = Integer.MIN_VALUE;
public int kthSmallest(TreeNode root, int k) {
inOrderTraverse(root, k);
return result;
}
private void inOrderTraverse(TreeNode node, int k) {
if (node == null) return;
inOrderTraverse(node.left, k);
count++;
if (count == k) {
result = node.val;
return;
}
inOrderTraverse(node.right, k);
}
}3.4 写出堆溢出、栈溢出、元空间溢出的代码
这道题考的是你对 JVM 内存模型的理解。三个区域,三种溢出方式:
堆溢出 —— 死循环创建对象且保持引用,GC 无法回收:
import java.util.ArrayList;
import java.util.List;
public class HeapOverflow {
public static void main(String[] args) {
List<Object> objects = new ArrayList<>();
while (true) {
objects.add(new Object());
}
}
}栈溢出 —— 无终止条件的递归,调用栈无限增长:
public class StackOverflow {
public static void main(String[] args) {
recursiveMethod(1);
}
private static void recursiveMethod(int i) {
recursiveMethod(i);
}
}元空间溢出 —— 动态生成大量类,类元数据撑爆元空间(需要 javassist 库):
import javassist.ClassPool;
public class MetaspaceOverflow {
public static void main(String[] args) throws Exception {
ClassPool classPool = ClassPool.getDefault();
for (int i = 0; ; i++) {
classPool.makeClass("Class" + i).toClass();
}
}
}小结
回顾这三类题目,它们考察的核心能力各不相同:
- 多线程题:考的是你对 Java 并发工具箱的熟练程度,以及你是否理解线程协调的本质(共享状态 + 通知机制)。
- 数据结构实现:考的是你对底层原理的理解深度。LRU 不只是"最近最少使用"五个字,它背后是 HashMap + 双向链表的精妙配合。
- 编程小题:考的是你的工程素养。质数判断只检查到平方根,equals 方法先做 fail-fast,这些细节决定了你写出来的代码是"能跑"还是"写得好"。
面试时最重要的一点:先说思路,再写代码。面试官给你一道题,不要闷头就写,先用一两句话把你的解题思路说清楚。即使代码有小瑕疵,只要思路对了,面试官通常不会卡你。