数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育

在这个模块,我把常见的 “套路” 题,帮你总结成手写代码时应该准备的各种代码模板。还会把自己压箱底的独家代码模板分享给你,利用它,我多次在 10 分钟以内拿下了算法面试。

今天我先带你把数据结构部分做一个归纳和整理,方便你考前复习和平日积累。可以想象一下,如果在准备面试期间,你已经刷了很多题,那么在临近面试时还可以做些什么呢?

  • 把所有写过的代码再看一遍?
  • 把前面 20 讲的内容从头到尾再复习一遍?
  • 还是继续刷题?

在我个人看来,以上这些方法都不可取,此时最行之有效的方法是将学过的知识尽可能地压缩、再压缩,最后形成模板。整理模板,有以下几个好处。

  • 组合:其实大部分面试题都是一些算法模块的组合,并不需要我们真正去发明一个算法。
  • 速度:面试写题时速度更快,一些常用的功能性代码可以直接粘贴过去,不用在打字和调试上浪费时间。
  • 重点:可以在有限的时间里重点关注整理好的代码模板,告别 “大海捞针” 式的复习。

其实面试中考察的那些高频知识点,就像一块块 “积木”,而面试求解过程就像 “搭积木的游戏”。高效利用代码模版的技巧,能够帮助你在面试时写出更高效和 0 Bug 的代码。

说明:一些扩展知识点,我会通过练习题的形式给出来。

《01 | 栈:从简单栈到单调栈,解决经典栈问题》中,我们将栈的知识总结在了下面这张知识导图中。

22 | 数据结构模板:如何让解题变成搭积木? - 图1

简单栈的性质

后面我们又在《20 | 5 种解法,如何利用常量空间求解最长有效括号长度?》的 “特点 4” 中,介绍了另一个栈的重点性质——括号匹配时栈的性质。我们可以用如下代码模板展示这个性质:

  1. int longestValidParentheses(String s) {
  2. final int N = s == null ? 0 : s.length();
  3. if (N <= 1) {
  4. return 0;
  5. }
  6. Stack<Integer> st = new Stack<>();
  7. int ans = 0;
  8. int start = 0;
  9. for (int i = 0; i < N; i++) {
  10. final char c = s.charAt(i);
  11. if (c == ')') {
  12. if (st.isEmpty()) {
  13. start = i + 1;
  14. } else {
  15. st.pop();
  16. final int base =
  17. st.isEmpty() ? start : st.peek() + 1;
  18. ans = Math.max(ans, i - base + 1);
  19. }
  20. } else {
  21. st.push(i);
  22. }
  23. }
  24. return ans;
  25. }
  26. }

代码:Java/C++/Python

栈的模拟主要是使用其他数据结构来模拟栈的 push/pop 操作,主要涉及 3 个经典的题目,即下面的练习题 1、练习题 2 以及练习题 3。

练习题 1:请使用两个队列实现栈的 push/pop/empty/size 四种操作。

代码:Java/C++/Python

练习题 2:输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列 {1,2,3,4,5} 是某栈的压栈序列,序列 {4,5,3,2,1} 是该压栈序列对应的一个弹出序列,但 {4,3,5,1,2} 就不可能是该压栈序列的弹出序列。

代码:Java/C++/Python

练习题 3:定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的 min 函数在该栈中,调用 min、push 及 pop 的时间复杂度都是 O(1)。

代码:Java/C++/Python

单调栈

单调栈中经常还会用来解决这类题目:数组中元素右边第一个比元素自身小的元素的位置。

  1. int[] findRightSmall(int[] A) {
  2. int[] ans = new int[A.length];
  3. Stack<Integer> t = new Stack();
  4. for (int i = 0; i < A.length; i++) {
  5. final int x = A[i];
  6. while (!t.empty() && A[t.peek()] > x) {
  7. ans[t.peek()] = i;
  8. t.pop();
  9. }
  10. t.push(i);
  11. }
  12. while (!t.empty()) {
  13. ans[t.peek()] = -1;
  14. t.pop();
  15. }
  16. return ans;
  17. }

代码:Java/C++/Python

还有 3 类问题与上面这道题目类似,一般而言,深入理解其中一个模板即可。

  • 数组中元素右边第一个比我大的元素的位置
  • 数组中元素左边离我最近且比我小的元素的位置
  • 数组中元素左边离我最近且比我大的元素的位置

单调栈的性质

我们将单调栈的性质总结为以下两点,更详细的介绍你可以回到《16 | 如何利用 DP 与单调队列寻找最大矩形?》进行复习。

  • 当单调递增栈中存放数组下标 i, j, k 时,其中 (i, k] 中的元素 > A[j];
  • 当单调递增栈中存放数组下标 i, j,并且当 A[k] 入栈,会把栈顶元素 A[j]“削” 出栈时,其中 (j, k) 元素 > A[j]。

我们曾经用到单调栈性质的模板代码求解最大矩形,如下所示:

  1. int largestRectangleArea(int[] A) {
  2. final int N = A == null ? 0 : A.length;
  3. int top = 0;
  4. int[] s = new int[N];
  5. int ans = 0;
  6. for (int i = 0; i <= N; i++) {
  7. final int x = i == N ? -1 : A[i];
  8. while (top > 0 && A[s[top - 1]] > x) {
  9. final int height = A[s[--top]];
  10. final int rightPos = i;
  11. final int leftPos = top > 0 ? s[top - 1] : -1;
  12. final int width = rightPos - leftPos - 1;
  13. final int area = height * width;
  14. ans = Math.max(ans, area);
  15. }
  16. s[top++] = i;
  17. }
  18. return ans;
  19. }

代码:Java/C++/Python

队列

关于队列的知识点,我们同样总结在了一张思维导图中,如下所示:

22 | 数据结构模板:如何让解题变成搭积木? - 图2

队列的数据结构知识点一般有 5 个:

  • FIFO 队列
  • 循环队列(模板)
  • 单调队列(模板)
  • 堆(模板)
  • 优先级队列

不过一般而言,需要重点掌握的数据结构的模板只有 3 个,即循环队列、单调队列以及堆。

循环队列

首先我们看一下使用数组来实现循环队列的写法,代码如下:

  1. class MyCircularQueue {
  2. private int used = 0;
  3. private int front = 0;
  4. private int rear = 0;
  5. private int capacity = 0;
  6. private int[] a = null;
  7. public MyCircularQueue(int k) {
  8. capacity = k;
  9. a = new int[capacity];
  10. }
  11. public boolean enQueue(int value) {
  12. if (isFull()) {
  13. return false;
  14. }
  15. a[rear] = value;
  16. rear = (rear + 1) % capacity;
  17. used++;
  18. return true;
  19. }
  20. public boolean deQueue() {
  21. if (isEmpty()) {
  22. return false;
  23. }
  24. int ret = a[front];
  25. front = (front + 1) % capacity;
  26. used--;
  27. return true;
  28. }
  29. public int Front() {
  30. if (isEmpty()) {
  31. return -1;
  32. }
  33. return a[front];
  34. }
  35. public int Rear() {
  36. if (isEmpty()) {
  37. return -1;
  38. }
  39. int tail = (rear - 1 + capacity) % capacity;
  40. return a[tail];
  41. }
  42. public boolean isEmpty() {
  43. return used == 0;
  44. }
  45. public boolean isFull() {
  46. return used == capacity;
  47. }
  48. }

代码:Java/C++/Python

单调队列

接下来,我们看一下单调队列的实现代码。单调队列有两种,即递增队列和递减队列。由于这两种队列的代码模版非常类似,因此只需要记住其中一个就可以了,递减队列代码如下:

  1. class Solution {
  2. private ArrayDeque<Integer> Q = new ArrayDeque<Integer>();
  3. private void push(int val) {
  4. while (!Q.isEmpty() && Q.getLast() < val) {
  5. Q.removeLast();
  6. }
  7. Q.addLast(val);
  8. }
  9. private void pop(int val) {
  10. if (!Q.isEmpty() && Q.getFirst() == val) {
  11. Q.removeFirst();
  12. }
  13. }

代码:Java/C++/Python

此外,单调队列还可以使用 “<元素值,下标> 同时入队和出队” 的方法来实现。这两种实现本质上没有太大的区别。你可以根据你对单调队列理解程度选择一种作为做通用模板。

代码:Java/C++/Python

由于堆往往用来实现优先级队列,因此,这里我也整理好了堆的实现的代码:

  1. class Heap {
  2. private int[] a = null;
  3. private int n = 0;
  4. public void sink(int i) {
  5. int j = 0;
  6. int t = a[i];
  7. while ((j = (i << 1) + 1) < n) {
  8. if (j < n - 1 && a[j] < a[j + 1]) {
  9. j++;
  10. }
  11. if (a[j] > t) {
  12. a[i] = a[j];
  13. i = j;
  14. } else {
  15. break;
  16. }
  17. }
  18. a[i] = t;
  19. }
  20. public void swim(int i) {
  21. int t = a[i];
  22. int par = 0;
  23. while (i > 0 && (par = (i - 1) >> 1) != i) {
  24. if (a[par] < t) {
  25. a[i] = a[par];
  26. i = par;
  27. } else {
  28. break;
  29. }
  30. }
  31. a[i] = t;
  32. }
  33. public void push(int v) {
  34. a[n++] = v;
  35. swim(n - 1);
  36. }
  37. public int pop() {
  38. int ret = a[0];
  39. a[0] = a[--n];
  40. sink(0);
  41. return ret;
  42. }
  43. public int size() {
  44. return n;
  45. }
  46. }

链表

要想解决链表题,我们首先需要掌几种最基本的操作,如下图所示:

22 | 数据结构模板:如何让解题变成搭积木? - 图3

不知道你是否还记得,我在《04 | 链表:如何利用 “假头、新链表、双指针” 解决链表题?(上)》中,将这几种操作整理成了一个代码模板。其核心思想就是链表的 “第一斧”:假头。如下所示:

  1. class MyLinkedList {
  2. class ListNode {
  3. public int val = 0;
  4. public ListNode next = null;
  5. public ListNode() {}
  6. public ListNode(int x) {
  7. val = x;
  8. }
  9. }
  10. private ListNode dummy = new ListNode();
  11. private ListNode tail = dummy;
  12. private int length = 0;
  13. public MyLinkedList() {
  14. }
  15. private ListNode getPreNode(int index) {
  16. ListNode front = dummy.next;
  17. ListNode back = dummy;
  18. for (int i = 0; i < index; i++) {
  19. back = front;
  20. front = front.next;
  21. }
  22. return back;
  23. }
  24. public int get(int index) {
  25. if (index < 0 || index >= length) {
  26. return -1;
  27. }
  28. return getPreNode(index).next.val;
  29. }
  30. public void addAtHead(int val) {
  31. ListNode p = new ListNode(val);
  32. p.next = dummy.next;
  33. dummy.next = p;
  34. if (tail == dummy) {
  35. tail = p;
  36. }
  37. length++;
  38. }
  39. public void addAtTail(int val) {
  40. tail.next = new ListNode(val);
  41. tail = tail.next;
  42. length++;
  43. }
  44. public void addAtIndex(int index, int val) {
  45. if (index > length) {
  46. return;
  47. } else if (index == length) {
  48. addAtTail(val);
  49. return;
  50. } else if (index <= 0) {
  51. addAtHead(val);
  52. return;
  53. }
  54. ListNode pre = getPreNode(index);
  55. ListNode p = new ListNode(val);
  56. p.next = pre.next;
  57. pre.next = p;
  58. length++;
  59. }
  60. public void deleteAtIndex(int index) {
  61. if (index < 0 || index >= length) {
  62. return;
  63. }
  64. ListNode pre = getPreNode(index);
  65. if (tail == pre.next) {
  66. tail = pre;
  67. }
  68. length--;
  69. pre.next = pre.next.next;
  70. }
  71. }

此外,关于链表,我们还需要掌握另外的 “两板斧”。这里我已经将知识点整理如下:

22 | 数据结构模板:如何让解题变成搭积木? - 图4

《06 | 树:如何深度运用树的遍历?》中,我们深入探讨了三种遍历,并且发现只要我们掌握这三种遍历的模板代码,就能够轻松解决二叉树问题。

22 | 数据结构模板:如何让解题变成搭积木? - 图5

在这一讲中,我们需要熟练掌握三种遍历的代码模板有 6 个:

  • 前序遍历的递归实现与栈的实现
  • 中序遍历的递归实现与栈的实现
  • 后序遍历的递归实现与栈的实现

下面我们分别整理一下。

前序遍历

采用递归的前序遍历的代码如下(解析在注释里):

  1. void preOrder(TreeNode root, List<Integer> ans) {
  2. if (root != null) {
  3. ans.add(root.val);
  4. preOrder(root.left, ans);
  5. preOrder(root.right, ans);
  6. }
  7. }

代码:Java/C++/Python

使用栈来实现的前序遍历的代码如下(解析在注释里):

  1. class Solution {
  2. public List<Integer> preorderTraversal(TreeNode root) {
  3. Stack<TreeNode> s = new Stack<>();
  4. List<Integer> ans = new ArrayList<>();
  5. while (root != null || !s.empty()) {
  6. while (root != null) {
  7. s.push(root);
  8. ans.add(root.val);
  9. root = root.left;
  10. }
  11. root = s.peek();
  12. s.pop();
  13. root = root.right;
  14. }
  15. return ans;
  16. }
  17. }

代码:Java/C++/Python

中序遍历

采用递归的中序遍历代码如下(解析在注释里):

  1. void preOrder(TreeNode root, List<Integer> ans) {
  2. if (root != null) {
  3. preOrder(root.left, ans);
  4. ans.add(root.val);
  5. preOrder(root.right, ans);
  6. }
  7. }

代码:Java/C++/Python

采用非递归的中序代码(解析在注释里):

  1. class Solution {
  2. public List<Integer> inorderTraversal(TreeNode root) {
  3. Stack<TreeNode> s = new Stack<>();
  4. List<Integer> ans = new ArrayList<>();
  5. while (root != null || !s.empty()) {
  6. while (root != null) {
  7. s.push(root);
  8. root = root.left;
  9. }
  10. root = s.peek();
  11. s.pop();
  12. ans.add(root.val);
  13. root = root.right;
  14. }
  15. return ans;
  16. }
  17. }

代码:Java/C++/Python

后序遍历

采用递归实现的后序遍历代码模板如下(解析在注释里):

  1. void postOrder(TreeNode root, List<Integer> ans) {
  2. if (root != null) {
  3. postOrder(root.left, ans);
  4. postOrder(root.right, ans);
  5. ans.add(root.val);
  6. }
  7. }

代码:Java/C++/Python

采用非递归的后序遍历代码如下(解析在注释里):

  1. class Solution {
  2. public List<Integer> postorderTraversal(TreeNode t) {
  3. List<Integer> ans = new ArrayList<>();
  4. TreeNode pre = null;
  5. Stack<TreeNode> s = new Stack<>();
  6. while (!s.isEmpty() || t != null) {
  7. while (t != null) {
  8. s.push(t);
  9. t = t.left;
  10. }
  11. t = s.peek();
  12. if (t.right == null || t.right == pre) {
  13. ans.add(t.val);
  14. s.pop();
  15. pre = t;
  16. t = null;
  17. } else {
  18. t = t.right;
  19. }
  20. }
  21. return ans;
  22. }
  23. }

代码:Java/C++/Python

【面试建议】在面试的时候,大部分情况都应该优先写递归的代码,除非面试官特别要求你必须使用 “非递归” 来实现。主要有以下几点原因:

  • 递归代码更加简单,因此不容易出错;
  • 不要为了 “炫技” 展示 “非递归” 代码;
  • 如果我们要进行二叉树上的搜索、DP、二分等情况的时候,“非递归” 的代码往往会增加代码的复杂度,面试的时候不容易完全写对。

并查集

虽然并查集的代码模板只有一个,但是涉及的知识点却不少,这里我们将重点的内容浓缩在下面这张图里:

22 | 数据结构模板:如何让解题变成搭积木? - 图6

最后,我们给出并查集的代码模板如下(解析在注释里):

  1. class UF {
  2. int[] F = null;
  3. int count = 0;
  4. int[] Cnt = null;
  5. void Init(int n)
  6. {
  7. F = new int[n];
  8. Cnt = new int[n];
  9. for (int i = 0; i < n; i++) {
  10. F[i] = i;
  11. Cnt[i] = 1;
  12. }
  13. count = n;
  14. }
  15. int Find(int x)
  16. {
  17. if (x == F[x]) {
  18. return x;
  19. }
  20. F[x] = Find(F[x]);
  21. return F[x];
  22. }
  23. void Union(int x, int y)
  24. {
  25. int xpar = Find(x);
  26. int ypar = Find(y);
  27. if (xpar != ypar) {
  28. F[xpar] = ypar;
  29. Cnt[ypar] += Cnt[xpar];
  30. count--;
  31. }
  32. }
  33. int Size(int i) {return Cnt[Find(i); }
  34. }

代码:Java/C++/Python

总结

在这一讲中,我们通过整理代码模板,将 “第 01 讲” 到“第 07 讲” 学习的所有知识点都整理好了。这样你复习起来是不是压力要小很多呢。下面我再和你分享两个代码模板的“小秘密”。

模板代码要精练

其实在整理模板的时候,要尽量将代码压缩得越短越好(指的并不是不换行),代码压缩得短,有以下好处:

  • 如果是自己熟悉的代码,在需要记忆的情况下,越短越好记;
  • 较短的代码可以更精练,一眼看上去没有那么大的心理压力。

比如就我自己而言,在复习并查集的代码时,就经常使用下面这段更短的代码:

  1. int Find(int x) { return x == F[x] ? x : F[x] = Find(F[x]); }
  2. 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 | 算法模板:如何让高频算法考点秒变默写题?》,让我们继续前进。