配套:《字节Data北京全栈岗面试准备》(SQL 题、前四道算法、后端八股)。本手册收录新面经里的题目,以及同类型、同难度的高频题。 所有代码都用 Java 实际运行过,结果正确。 招聘方的提醒:可能不考原题,但类型和难度差不多。一定要写出来,不能只说思路。
面经里给候选人打的是四个维度:思维沟通、项目经验、基础知识、编程能力。通过的那位,编程能力的评价是“算法题能写出来,思路也可以”。写出来是底线,思路好是加分。
ListNode dummy = new ListNode(0); dummy.next = head; 能消掉“删除的是头节点”这类特殊情况,大部分链表题都能少写一半判断。看懂答案不等于会写。每道题都按这个流程练: 1. 先自己想 10 分钟,写不出就看答案; 2. 看懂后合上答案,在白纸或纯文本编辑器里从头默写,不开代码补全、不用 AI; 3. 写错了再看一眼,再合上重写,直到一次写对; 4. 第二天再默写一遍,还能一次写对才算掌握。
思路:快慢指针。快指针先走 n 步,然后两个指针一起走;快指针走到最后一个节点时,慢指针正好停在要删除节点的前一个。用虚拟头节点处理“删除的是头节点”的情况。
复杂度:一次遍历,时间 O(L),空间 O(1)。
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0); // 虚拟头节点:删除头节点时不用特殊处理
dummy.next = head;
ListNode fast = dummy, slow = dummy;
for (int i = 0; i < n; i++) { // 快指针先走 n 步
if (fast.next == null) throw new IllegalArgumentException("n 大于链表长度");
fast = fast.next;
}
while (fast.next != null) { // 一起走,直到快指针到最后一个节点
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next; // 慢指针的下一个就是要删的节点
return dummy.next; // 不能返回 head:head 可能已经被删掉了
}已验证:[1,2,3,4,5] 删倒数第 2 个得 [1,2,3,5];[1] 删倒数第 1 个得 [];[1,2] 删倒数第 1 个得 [1];[1,2] 删倒数第 2 个得 [2](删的是头节点)。
易错点:返回值要用 dummy.next,不能用 head,因为头节点可能被删了。
思路:用虚拟头节点,走到第 n−1 个节点(要删节点的前一个),把它的 next 跳过一个。
public ListNode removeNth(ListNode head, int n) {
if (n <= 0) throw new IllegalArgumentException("n 必须大于 0");
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode prev = dummy;
for (int i = 1; i < n; i++) { // 走到第 n-1 个节点
if (prev.next == null) throw new IllegalArgumentException("n 大于链表长度");
prev = prev.next;
}
if (prev.next == null) throw new IllegalArgumentException("n 大于链表长度");
prev.next = prev.next.next; // 跳过第 n 个节点
return dummy.next;
}已验证:1→3→4→8 删第 2 个得 1→4→8;删第 1 个得 3→4→8;删第 4 个得 1→3→4;n 越界会报错。
思路:每次先往后数 k 个节点,不够 k 个就停止(剩下的保持原样);够 k 个就把这一段原地反转,再接回原链表。
需要记住四个指针: - groupPrev:这一组的前一个节点(第一组就是虚拟头节点) - kth:这一组的第 k 个节点(反转后它变成这一组的头) - groupNext:下一组的第一个节点 - 反转时把 prev 初始化为 groupNext,反转完这一组的尾巴自然就接上了下一组
public ListNode reverseKGroup(ListNode head, int k) {
if (head == null || k <= 1) return head;
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode groupPrev = dummy; // 当前组的前一个节点
while (true) {
// 1. 找到当前组的第 k 个节点,不够 k 个就结束
ListNode kth = groupPrev;
for (int i = 0; i < k && kth != null; i++) kth = kth.next;
if (kth == null) break;
ListNode groupNext = kth.next; // 下一组的第一个节点
// 2. 反转当前组。prev 从 groupNext 开始,反转后组尾直接连上下一组
ListNode prev = groupNext, cur = groupPrev.next;
while (cur != groupNext) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
// 3. 接回原链表:原来的第一个节点现在是组尾,作为下一组的 groupPrev
ListNode oldFirst = groupPrev.next;
groupPrev.next = kth; // 组头变成原来的第 k 个
groupPrev = oldFirst;
}
return dummy.next;
}已验证:[1,2,3,4,5] k=2 得 [2,1,4,3,5];k=3 得 [3,2,1,4,5];k=1 不变;[1,2,3,4] k=4 得 [4,3,2,1];[1,2,3,4,5,6] k=3 得 [3,2,1,6,5,4]。
画图讲解(面试时边画边讲):以 1→2→3→4→5、k=2 为例。第一组:groupPrev 是虚拟头,kth 是 2,groupNext 是 3。反转后是 2→1→3,虚拟头指向 2,groupPrev 移到 1。第二组同理得到 4→3→5。第三组只剩 5,不够 2 个,停止。
复杂度:每个节点被访问常数次,时间 O(n),空间 O(1)。
如果实在写不出来的保底方案:把整个链表的值读进数组,每 k 个一段在数组里反转,再写回链表。但题目要求不能只改值、必须交换节点,所以这只能作为“先写个能跑的版本”,写完要说明“这个版本改的是值,题目要求交换节点,我再改成原地反转”。
思路: - 简单解法:把值放进数组,用双指针从两头往中间比较。时间 O(n),空间 O(n)。先写这个保底。 - 最优解(空间 O(1),加分):①快慢指针找中点;②反转后半段;③前半段和反转后的后半段逐个比较;④把后半段再反转回来,恢复原链表。
public boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) return true;
// 1. 找中点:快指针每次两步,慢指针每次一步;结束时 slow 停在前半段的最后一个节点
ListNode slow = head, fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. 反转后半段
ListNode secondHead = reverse(slow.next);
// 3. 比较。以后半段为准:奇数长度时中间节点归前半段,不参与比较
ListNode p1 = head, p2 = secondHead;
boolean ok = true;
while (p2 != null) {
if (p1.val != p2.val) { ok = false; break; }
p1 = p1.next;
p2 = p2.next;
}
// 4. 恢复原链表(加分点:不破坏调用方的数据)
slow.next = reverse(secondHead);
return ok;
}
private ListNode reverse(ListNode head) {
ListNode prev = null, cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}已验证:[1,2,2,1] 为 true,而且判断完链表恢复成 [1,2,2,1];[1,2] 为 false;[1,2,3,2,1] 为 true;单个节点和空链表为 true;[1,2,3] 为 false。
隐藏考点:奇数长度的中间节点怎么处理;判断完要恢复链表;多线程同时读这个链表时,临时修改会有问题,这种情况就用数组解法。
思路:用栈。遇到左括号,就把对应的右括号压栈;遇到右括号,就弹出栈顶比较,不一样或者栈是空的就返回 false。最后栈必须为空。
public boolean isValid(String s) {
if (s == null || s.length() % 2 == 1) return false; // 长度为奇数一定无效,提前返回
Deque<Character> stack = new ArrayDeque<>(); // 用 ArrayDeque,不用过时的 Stack 类
for (char c : s.toCharArray()) {
if (c == '(') stack.push(')'); // 压入期望出现的右括号,比较时更简单
else if (c == '[') stack.push(']');
else if (c == '{') stack.push('}');
else if (stack.isEmpty() || stack.pop() != c) return false;
}
return stack.isEmpty(); // 还有没闭合的左括号也是无效
}已验证:()、()[]{}、([])、{[]} 为 true;(]、([)]、(、)( 为 false。
复杂度:时间 O(n),空间 O(n)。
隐藏考点:右括号多出来时栈是空的,要先判断再弹出;最后要检查栈为空;长度为奇数可以提前返回;Java 里推荐用 ArrayDeque 而不是 Stack(Stack 是同步的,性能差)。
思路:纵向扫描。以第一个字符串为基准,逐个位置检查所有字符串在这个位置的字符是否相同;某个字符串到头了或者字符不同,就返回到当前位置为止的前缀。
public String longestCommonPrefix(String[] strs) {
if (strs == null || strs.length == 0) return "";
String first = strs[0];
for (int i = 0; i < first.length(); i++) {
char c = first.charAt(i);
for (int j = 1; j < strs.length; j++) {
// 其他字符串到头了,或者这个位置的字符不同
if (i >= strs[j].length() || strs[j].charAt(i) != c) {
return first.substring(0, i);
}
}
}
return first; // 第一个字符串整体就是公共前缀
}已验证:[“flower”,“flow”,“flight”] 得 “fl”;[“dog”,“racecar”,“car”] 得 "“;只有一个字符串时返回它本身;含空字符串时返回”“;["abc","abcd"] 得”abc"。
复杂度:时间 O(所有字符串的字符总数),最坏情况每个字符都比较一次;空间 O(1)。
追问:「另一种思路?」把字符串排序后,只需要比较第一个和最后一个字符串的公共前缀(字典序最远的两个)。但排序是 O(n log n),不一定更快。
见《全栈岗面试准备》§3.4,这里给数组版本(字符都在 ASCII 范围内时更快):
public int lengthOfLongestSubstring(String s) {
int[] last = new int[128]; // 每个字符最后一次出现的位置
Arrays.fill(last, -1);
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (last[c] >= left) left = last[c] + 1; // 重复字符在窗口内,左边界跳过它
last[c] = right;
best = Math.max(best, right - left + 1);
}
return best;
}已验证:abcabcbb 为 3,bbbbb 为 1,abba 为 2,空字符串为 0。
SELECT email AS Email
FROM Person
WHERE email IS NOT NULL -- 空值不算重复
GROUP BY email
HAVING COUNT(*) > 1;另一种写法(自连接,面试官可能让你写第二种):
SELECT DISTINCT p1.email AS Email
FROM Person p1
JOIN Person p2 ON p1.email = p2.email AND p1.id <> p2.id;两种写法的对比:GROUP BY 只扫一遍表,效率高,推荐;自连接在数据量大时开销大,但思路直观。
隐藏考点(实测发现的,主动讲加分): 1. 空值:GROUP BY 会把所有空邮箱归为一组,如果有两条空邮箱,结果里会出现一个空值,所以要加 WHERE email IS NOT NULL。 2. 大小写和空格:MySQL 常用的排序规则默认不区分大小写,A@b.com 和 a@b.com 会被当成同一个邮箱;业务上如果要区分,就要注意排序规则;反过来,如果要把前后带空格的也算重复,可以用 LOWER(TRIM(email)) 分组。 3. 追问「删除重复的邮箱,只保留 id 最小的一条」:
DELETE p1 FROM Person p1
JOIN Person p2 ON p1.email = p2.email AND p1.id > p2.id;| 优先级 | 题目 | 核心思路(一句话) |
|---|---|---|
| ★★★ | 反转链表 | 三个指针:prev、cur、next |
| ★★★ | 合并两个有序链表 | 虚拟头节点,每次接上较小的那个 |
| ★★★ | 环形链表(判断是否有环) | 快慢指针,相遇就有环 |
| ★★★ | 链表的中间节点 | 快慢指针,快的走两步慢的走一步 |
| ★★★ | 两数之和 | 哈希表存“值到下标”,查 target 减当前值 |
| ★★ | 相交链表 | 两个指针走完自己的链表就换到对方的链表,相遇点就是交点 |
| ★★ | 反转链表的第 m 到 n 个节点 | 虚拟头节点,找到第 m−1 个,后面逐个头插 |
| ★★ | 删除排序链表中的重复元素 | 当前值等于下一个值就跳过下一个 |
| ★★ | LRU 缓存 | 哈希表加双向链表;或者直接用 Java 的 LinkedHashMap |
| ★★ | 最小栈 | 再用一个栈同步记录当前最小值 |
| ★ | 字母异位词分组 | 排序后的字符串作为哈希表的 key |
| ★ | 二分查找 | 注意 mid = left + (right - left) / 2 防溢出,以及循环条件 |
top(CPU 和内存)、free -h(内存)、df -h(磁盘)、tail -f(实时看日志)、grep、less、ps -ef、netstat 或 ss(端口和连接)、jstack、jmap(Java 排查)。面经里通过的候选人讲了一个很典型的接口性能调优故事:数据大盘接口 7 到 8 秒,用 Arthas 定位到慢 SQL,用 EXPLAIN 分析后优化索引、冗余字段,再加 Redis 缓存。这种故事面试官很爱听。请从你自己的经历里找一个真实的性能优化案例(比如美团查询接口的限流和批量查询、阿里的某个慢查询),按“现象 → 定位工具 → 原因 → 方案 → 效果”整理好。一定要用真实经历,不要照搬别人的。
| 天 | 题目 | 要求 |
|---|---|---|
| 1 | 链表 4 道:删倒数第 n 个、删正数第 n 个、K 个一组翻转、回文链表 | 每道默写 3 遍;K 个一组至少写 5 遍 |
| 2 | 括号、最长公共前缀、无重复子串、重复邮箱 SQL;上一份文档的 4 道算法 | 每道默写 3 遍 |
| 3 | 第 4 节的五道三星题 | 每道限时 15 分钟,写完自己用例子走一遍 |
| 4 | 第 1、2 天的题全部重新默写一遍;八股快速过一遍 | 每道一次写对,否则当天再写 |
| 面试前一晚 | 链表 4 道再写一遍 | 保持手感 |
最后一句:面试时不管多紧张,先写出一个能跑的版本。写出来了,哪怕不是最优解也能及格;只讲思路,就是零分。
由 HiveGPT 托管 · 举报