数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育
在这个模块,我把常见的 “套路” 题,帮你总结成手写代码时应该准备的各种代码模板。还会把自己压箱底的独家代码模板分享给你,利用它,我多次在 10 分钟以内拿下了算法面试。
今天我先带你把数据结构部分做一个归纳和整理,方便你考前复习和平日积累。可以想象一下,如果在准备面试期间,你已经刷了很多题,那么在临近面试时还可以做些什么呢?
- 把所有写过的代码再看一遍?
- 把前面 20 讲的内容从头到尾再复习一遍?
- 还是继续刷题?
在我个人看来,以上这些方法都不可取,此时最行之有效的方法是将学过的知识尽可能地压缩、再压缩,最后形成模板。整理模板,有以下几个好处。
- 组合:其实大部分面试题都是一些算法模块的组合,并不需要我们真正去发明一个算法。
- 速度:面试写题时速度更快,一些常用的功能性代码可以直接粘贴过去,不用在打字和调试上浪费时间。
- 重点:可以在有限的时间里重点关注整理好的代码模板,告别 “大海捞针” 式的复习。
其实面试中考察的那些高频知识点,就像一块块 “积木”,而面试求解过程就像 “搭积木的游戏”。高效利用代码模版的技巧,能够帮助你在面试时写出更高效和 0 Bug 的代码。
说明:一些扩展知识点,我会通过练习题的形式给出来。
栈
在《01 | 栈:从简单栈到单调栈,解决经典栈问题》中,我们将栈的知识总结在了下面这张知识导图中。
简单栈的性质
后面我们又在《20 | 5 种解法,如何利用常量空间求解最长有效括号长度?》的 “特点 4” 中,介绍了另一个栈的重点性质——括号匹配时栈的性质。我们可以用如下代码模板展示这个性质:
int longestValidParentheses(String s) {
final int N = s == null ? 0 : s.length();
if (N <= 1) {
return 0;
}
Stack<Integer> st = new Stack<>();
int ans = 0;
int start = 0;
for (int i = 0; i < N; i++) {
final char c = s.charAt(i);
if (c == ')') {
if (st.isEmpty()) {
start = i + 1;
} else {
st.pop();
final int base =
st.isEmpty() ? start : st.peek() + 1;
ans = Math.max(ans, i - base + 1);
}
} else {
st.push(i);
}
}
return ans;
}
}
栈的模拟主要是使用其他数据结构来模拟栈的 push/pop 操作,主要涉及 3 个经典的题目,即下面的练习题 1、练习题 2 以及练习题 3。
练习题 1:请使用两个队列实现栈的 push/pop/empty/size 四种操作。
练习题 2:输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列 {1,2,3,4,5} 是某栈的压栈序列,序列 {4,5,3,2,1} 是该压栈序列对应的一个弹出序列,但 {4,3,5,1,2} 就不可能是该压栈序列的弹出序列。
练习题 3:定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的 min 函数在该栈中,调用 min、push 及 pop 的时间复杂度都是 O(1)。
单调栈
单调栈中经常还会用来解决这类题目:数组中元素右边第一个比元素自身小的元素的位置。
int[] findRightSmall(int[] A) {
int[] ans = new int[A.length];
Stack<Integer> t = new Stack();
for (int i = 0; i < A.length; i++) {
final int x = A[i];
while (!t.empty() && A[t.peek()] > x) {
ans[t.peek()] = i;
t.pop();
}
t.push(i);
}
while (!t.empty()) {
ans[t.peek()] = -1;
t.pop();
}
return ans;
}
还有 3 类问题与上面这道题目类似,一般而言,深入理解其中一个模板即可。
- 数组中元素右边第一个比我大的元素的位置
- 数组中元素左边离我最近且比我小的元素的位置
- 数组中元素左边离我最近且比我大的元素的位置
单调栈的性质
我们将单调栈的性质总结为以下两点,更详细的介绍你可以回到《16 | 如何利用 DP 与单调队列寻找最大矩形?》进行复习。
- 当单调递增栈中存放数组下标 i, j, k 时,其中 (i, k] 中的元素 > A[j];
- 当单调递增栈中存放数组下标 i, j,并且当 A[k] 入栈,会把栈顶元素 A[j]“削” 出栈时,其中 (j, k) 元素 > A[j]。
我们曾经用到单调栈性质的模板代码求解最大矩形,如下所示:
int largestRectangleArea(int[] A) {
final int N = A == null ? 0 : A.length;
int top = 0;
int[] s = new int[N];
int ans = 0;
for (int i = 0; i <= N; i++) {
final int x = i == N ? -1 : A[i];
while (top > 0 && A[s[top - 1]] > x) {
final int height = A[s[--top]];
final int rightPos = i;
final int leftPos = top > 0 ? s[top - 1] : -1;
final int width = rightPos - leftPos - 1;
final int area = height * width;
ans = Math.max(ans, area);
}
s[top++] = i;
}
return ans;
}
队列
关于队列的知识点,我们同样总结在了一张思维导图中,如下所示:
队列的数据结构知识点一般有 5 个:
- FIFO 队列
- 循环队列(模板)
- 单调队列(模板)
- 堆(模板)
- 优先级队列
不过一般而言,需要重点掌握的数据结构的模板只有 3 个,即循环队列、单调队列以及堆。
循环队列
首先我们看一下使用数组来实现循环队列的写法,代码如下:
class MyCircularQueue {
private int used = 0;
private int front = 0;
private int rear = 0;
private int capacity = 0;
private int[] a = null;
public MyCircularQueue(int k) {
capacity = k;
a = new int[capacity];
}
public boolean enQueue(int value) {
if (isFull()) {
return false;
}
a[rear] = value;
rear = (rear + 1) % capacity;
used++;
return true;
}
public boolean deQueue() {
if (isEmpty()) {
return false;
}
int ret = a[front];
front = (front + 1) % capacity;
used--;
return true;
}
public int Front() {
if (isEmpty()) {
return -1;
}
return a[front];
}
public int Rear() {
if (isEmpty()) {
return -1;
}
int tail = (rear - 1 + capacity) % capacity;
return a[tail];
}
public boolean isEmpty() {
return used == 0;
}
public boolean isFull() {
return used == capacity;
}
}
单调队列
接下来,我们看一下单调队列的实现代码。单调队列有两种,即递增队列和递减队列。由于这两种队列的代码模版非常类似,因此只需要记住其中一个就可以了,递减队列代码如下:
class Solution {
private ArrayDeque<Integer> Q = new ArrayDeque<Integer>();
private void push(int val) {
while (!Q.isEmpty() && Q.getLast() < val) {
Q.removeLast();
}
Q.addLast(val);
}
private void pop(int val) {
if (!Q.isEmpty() && Q.getFirst() == val) {
Q.removeFirst();
}
}
此外,单调队列还可以使用 “<元素值,下标> 同时入队和出队” 的方法来实现。这两种实现本质上没有太大的区别。你可以根据你对单调队列理解程度选择一种作为做通用模板。
堆
由于堆往往用来实现优先级队列,因此,这里我也整理好了堆的实现的代码:
class Heap {
private int[] a = null;
private int n = 0;
public void sink(int i) {
int j = 0;
int t = a[i];
while ((j = (i << 1) + 1) < n) {
if (j < n - 1 && a[j] < a[j + 1]) {
j++;
}
if (a[j] > t) {
a[i] = a[j];
i = j;
} else {
break;
}
}
a[i] = t;
}
public void swim(int i) {
int t = a[i];
int par = 0;
while (i > 0 && (par = (i - 1) >> 1) != i) {
if (a[par] < t) {
a[i] = a[par];
i = par;
} else {
break;
}
}
a[i] = t;
}
public void push(int v) {
a[n++] = v;
swim(n - 1);
}
public int pop() {
int ret = a[0];
a[0] = a[--n];
sink(0);
return ret;
}
public int size() {
return n;
}
}
链表
要想解决链表题,我们首先需要掌几种最基本的操作,如下图所示:
不知道你是否还记得,我在《04 | 链表:如何利用 “假头、新链表、双指针” 解决链表题?(上)》中,将这几种操作整理成了一个代码模板。其核心思想就是链表的 “第一斧”:假头。如下所示:
class MyLinkedList {
class ListNode {
public int val = 0;
public ListNode next = null;
public ListNode() {}
public ListNode(int x) {
val = x;
}
}
private ListNode dummy = new ListNode();
private ListNode tail = dummy;
private int length = 0;
public MyLinkedList() {
}
private ListNode getPreNode(int index) {
ListNode front = dummy.next;
ListNode back = dummy;
for (int i = 0; i < index; i++) {
back = front;
front = front.next;
}
return back;
}
public int get(int index) {
if (index < 0 || index >= length) {
return -1;
}
return getPreNode(index).next.val;
}
public void addAtHead(int val) {
ListNode p = new ListNode(val);
p.next = dummy.next;
dummy.next = p;
if (tail == dummy) {
tail = p;
}
length++;
}
public void addAtTail(int val) {
tail.next = new ListNode(val);
tail = tail.next;
length++;
}
public void addAtIndex(int index, int val) {
if (index > length) {
return;
} else if (index == length) {
addAtTail(val);
return;
} else if (index <= 0) {
addAtHead(val);
return;
}
ListNode pre = getPreNode(index);
ListNode p = new ListNode(val);
p.next = pre.next;
pre.next = p;
length++;
}
public void deleteAtIndex(int index) {
if (index < 0 || index >= length) {
return;
}
ListNode pre = getPreNode(index);
if (tail == pre.next) {
tail = pre;
}
length--;
pre.next = pre.next.next;
}
}
此外,关于链表,我们还需要掌握另外的 “两板斧”。这里我已经将知识点整理如下:
树
在《06 | 树:如何深度运用树的遍历?》中,我们深入探讨了三种遍历,并且发现只要我们掌握这三种遍历的模板代码,就能够轻松解决二叉树问题。
在这一讲中,我们需要熟练掌握三种遍历的代码模板有 6 个:
- 前序遍历的递归实现与栈的实现
- 中序遍历的递归实现与栈的实现
- 后序遍历的递归实现与栈的实现
下面我们分别整理一下。
前序遍历
采用递归的前序遍历的代码如下(解析在注释里):
void preOrder(TreeNode root, List<Integer> ans) {
if (root != null) {
ans.add(root.val);
preOrder(root.left, ans);
preOrder(root.right, ans);
}
}
使用栈来实现的前序遍历的代码如下(解析在注释里):
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
Stack<TreeNode> s = new Stack<>();
List<Integer> ans = new ArrayList<>();
while (root != null || !s.empty()) {
while (root != null) {
s.push(root);
ans.add(root.val);
root = root.left;
}
root = s.peek();
s.pop();
root = root.right;
}
return ans;
}
}
中序遍历
采用递归的中序遍历代码如下(解析在注释里):
void preOrder(TreeNode root, List<Integer> ans) {
if (root != null) {
preOrder(root.left, ans);
ans.add(root.val);
preOrder(root.right, ans);
}
}
采用非递归的中序代码(解析在注释里):
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
Stack<TreeNode> s = new Stack<>();
List<Integer> ans = new ArrayList<>();
while (root != null || !s.empty()) {
while (root != null) {
s.push(root);
root = root.left;
}
root = s.peek();
s.pop();
ans.add(root.val);
root = root.right;
}
return ans;
}
}
后序遍历
采用递归实现的后序遍历代码模板如下(解析在注释里):
void postOrder(TreeNode root, List<Integer> ans) {
if (root != null) {
postOrder(root.left, ans);
postOrder(root.right, ans);
ans.add(root.val);
}
}
采用非递归的后序遍历代码如下(解析在注释里):
class Solution {
public List<Integer> postorderTraversal(TreeNode t) {
List<Integer> ans = new ArrayList<>();
TreeNode pre = null;
Stack<TreeNode> s = new Stack<>();
while (!s.isEmpty() || t != null) {
while (t != null) {
s.push(t);
t = t.left;
}
t = s.peek();
if (t.right == null || t.right == pre) {
ans.add(t.val);
s.pop();
pre = t;
t = null;
} else {
t = t.right;
}
}
return ans;
}
}
【面试建议】在面试的时候,大部分情况都应该优先写递归的代码,除非面试官特别要求你必须使用 “非递归” 来实现。主要有以下几点原因:
- 递归代码更加简单,因此不容易出错;
- 不要为了 “炫技” 展示 “非递归” 代码;
- 如果我们要进行二叉树上的搜索、DP、二分等情况的时候,“非递归” 的代码往往会增加代码的复杂度,面试的时候不容易完全写对。
并查集
虽然并查集的代码模板只有一个,但是涉及的知识点却不少,这里我们将重点的内容浓缩在下面这张图里:
最后,我们给出并查集的代码模板如下(解析在注释里):
class UF {
int[] F = null;
int count = 0;
int[] Cnt = null;
void Init(int n)
{
F = new int[n];
Cnt = new int[n];
for (int i = 0; i < n; i++) {
F[i] = i;
Cnt[i] = 1;
}
count = n;
}
int Find(int x)
{
if (x == F[x]) {
return x;
}
F[x] = Find(F[x]);
return F[x];
}
void Union(int x, int y)
{
int xpar = Find(x);
int ypar = Find(y);
if (xpar != ypar) {
F[xpar] = ypar;
Cnt[ypar] += Cnt[xpar];
count--;
}
}
int Size(int i) {return Cnt[Find(i); }
}
总结
在这一讲中,我们通过整理代码模板,将 “第 01 讲” 到“第 07 讲” 学习的所有知识点都整理好了。这样你复习起来是不是压力要小很多呢。下面我再和你分享两个代码模板的“小秘密”。
模板代码要精练
其实在整理模板的时候,要尽量将代码压缩得越短越好(指的并不是不换行),代码压缩得短,有以下好处:
- 如果是自己熟悉的代码,在需要记忆的情况下,越短越好记;
- 较短的代码可以更精练,一眼看上去没有那么大的心理压力。
比如就我自己而言,在复习并查集的代码时,就经常使用下面这段更短的代码:
int Find(int x) { return x == F[x] ? x : F[x] = Find(F[x]); }
void Union(int x, int y) { F[find(x)] = find(y); }
自己整理可复用的代码模版
和你分享一下自己整理的模板的好处。主要是基于以下两点原因。
1. 变量的命名要有规律,而这些规律都是自己平时约定使用的,当你复习代码时会更熟练,比如:
1)返回值一律设置为 ans 或者 ret;
2)遍历下标设置为 i,j,k;
3)长度变量设置为 len。
2. 同一个算法往往有很多种写法,自己的写法会更熟悉,而且可以不断迭代和复用。
所以,本讲的练习题,就是希望你能把 “第 01 讲” 到“第 07 讲”刷过的题的代码整理成模板。当临近面试,你只需要对着思维导图和代码模板过一下思路就可以了。
这一讲我们就介绍到这里。下一讲介绍《23 | 算法模板:如何让高频算法考点秒变默写题?》,让我们继续前进。