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

今天开始进行算法模板的复习和整理。授人以鱼,不如授人以渔。在本讲,我的目的是教会你如何做知识的整理和模板的整理,而不是直接给你一些现成的东西,让你去死记硬背。无论是思维导图,还是代码模板,你自己整理一遍的收获会更大。

今天我们主要介绍两种方法:

  • 通过思维导图将学过的知识添加到你的知识树中;
  • 将刷过的题目整理成代码模板,放到你的代码模板库中。

排序

我们在学习排序的时候,主要讨论了合并模板和快速排序两种排序,现在就可以利用下面这个思维导图进行快速复习。

23 | 算法模板:如何让高频算法考点秒变默写题? - 图1

合并的技巧

对于合并排序来说,我觉得最重要的是掌握下面这段合并的小技巧:

  1. int i = b;
  2. int j = m;
  3. int to = b;
  4. while (i < m || j < e) {
  5. if (j >= e || i < m && a[i] <= a[j]) {
  6. t[to++] = a[i++];
  7. } else {
  8. t[to++] = a[j++];
  9. }
  10. }

代码:Java/C++/Python

可以看到 while 语句中 if 语句的写法(上述代码第 11 行)用了最简洁的代码处理了各种边界条件!

三路切分

《08 | 排序:如何利用合并与快排的小技巧,解决算法难题?》介绍快速排序时,三路切分也是一个高频出现的知识点,所以我们需要掌握这个代码模板,如下所示:

  1. void swap(int[] A, int i, int j) {
  2. int t = A[i];
  3. A[i] = A[j];
  4. A[j] = t;
  5. }
  6. int threeSplit(int[] A, int b, int e) {
  7. if (b >= e) {
  8. return 0;
  9. }
  10. final int m = b + ((e - b) >> 1);
  11. final int x = A[m];
  12. int l = b;
  13. int i = b;
  14. int r = e - 1;
  15. while (i <= r) {
  16. if (A[i] < x) {
  17. swap(A, l++, i++);
  18. } else if (A[i] == x) {
  19. i++;
  20. } else {
  21. swap(A, r--, i);
  22. }
  23. }
  24. if (i - l == 1)
  25. return A[l];
  26. if (((l - b) & 0x01) == 1) {
  27. return threeSplit(A, b, l);
  28. }
  29. return threeSplit(A, i, e);
  30. }

代码:Java/C++/Python

二分

我们将二分的知识点整理如下:

23 | 算法模板:如何让高频算法考点秒变默写题? - 图2

当已知一个题目可以用二分进行破解时,就可以使用 lowerBound 和 upperBound 这两个标准的二分的模板了。

此外,我们还需要记住这两个模板的使用的条件:

  • lowerBound 用于查找有序数组中最左边出现的元素(闭)。
  • upperBound 用于查找有序数组中最右边出现的元素(开)。

注意,我们在条件部分加了 “开闭” 两个字!其含义如下:

当给定数组 A[] = {1,2,2,2,3},运行 lowerBound(A, 2) 返回下标 1,而运行 upperBound(A,2) 却返回下标 4,但此时 A[4] = 3。

因此,我们还需要注意,lowerBound 与 upperBound 返回值组成的区间是一个左闭右开的区间,如下所示:

  1. [lowerBound(A,2), upperBound(A,2)) = [1, 4)

一定要记住这里的左闭右开原则

lowerBound

其中 lowerBound 的代码模板如下:

  1. int lowerBound(long[] A, int n, long target) {
  2. int l = 0, r = n;
  3. while (l < r) {
  4. final int m = l + ((r - l) >> 1);
  5. if (A[m] < target) {
  6. l = m + 1;
  7. } else {
  8. r = m;
  9. }
  10. }
  11. return l;
  12. }

代码:Java/C++/Python

upperBound

upperBound 的模板代码如下:

  1. int upperBound(long[] A, int n, long target) {
  2. int l = 0, r = n;
  3. while (l < r) {
  4. final int m = l + ((r - l) >> 1);
  5. if (A[m] <= target) {
  6. l = m + 1;
  7. } else {
  8. r = m;
  9. }
  10. }
  11. return l;
  12. }

代码:Java/C++/Python

其实我们只需要记忆 lowerBound 模板就够了,因为两个模板之间的差异只有一处:

lowerBound:A[m] < target
upperBound:A[m] <= target

这里和你分享一个我的记忆方法。当 A[m] <= target 的时候,标志着 m 还可以往右移动一段距离,因为 upperBound 找到的一般都在更右边的位置,因此带有 “A[m] <= target” 的是 upperBound。

双指针

双指针的知识我们总结如下:

23 | 算法模板:如何让高频算法考点秒变默写题? - 图3

需要熟练掌握的代码模板,一共有 3 个。

最长区间

求最长区间的代码模板长成这样:

  1. int maxLength(int[] A) {
  2. int N = A.length;
  3. int left = -1;
  4. int ans = 0;
  5. for (int i = 0; i < N; i++) {
  6. while (check((left, i]))) {
  7. ++left;
  8. }
  9. ans = max(ans, i - left);
  10. }
  11. return ans;
  12. }

注意:在解题的时候,需要根据具体的题目条件,在模板的基础上写一点状态更新的代码,也就是要替换掉代码模板中的 “TODO” 部分。

定长区间

其实定长区间就是固定窗口大小的滑动窗口算法,这里我整理好了一个通用的模板,如下所示:

  1. int fixedLength(int[] A, int windowSize) {
  2. final int N = A == null ? 0 : A.length;
  3. int left = -1;
  4. for (int i = 0; i < N; i++) {
  5. if (i - left < windowSize) {
  6. continue;
  7. }
  8. left++;
  9. }
  10. return ans;

注意:这个代码模板也是需要根据具体的题目条件完成 “TODO” 的部分。不同的题目,状态更新的代码可能会稍有不同。

最短区间

最短区间的代码模板如下:

  1. int minimalRange(int[] A) {
  2. final int N = A == null ? 0 : A.length;
  3. int left = -1;
  4. int ans = A.length + 1;
  5. for (int i = 0; i < N; i++) {
  6. while (区间超出/满足条件) {
  7. ans = Math.min(ans, i - left);
  8. }
  9. }
  10. return ans;
  11. }

注意:状态更新的代码同样需要根据题目的条件完成 “TODO” 的部分。

贪心

虽然说贪心算法是一种思想,不过还是有一些代码模板需要你掌握。比如区间不重复的最大数目模板,如下所示:

  1. int nonOverlapIntervals(int[][] A) {
  2. final int N = A == null ? 0 : A.length;
  3. Arrays.sort(A, new Comparator<int[]>() {
  4. public int compare(int[] a, int[] b) {
  5. return a[1] == b[1] ? 0 : (a[1] < b[1] ? -1 : 1);
  6. }
  7. });
  8. int maxEnd = Integer.MIN_VALUE;
  9. int ans = 0;
  10. for (int i = 0; i < N; i++) {
  11. final int start = A[i][0];
  12. if (maxEnd <= start) {
  13. maxEnd = A[i][1];
  14. ans++;
  15. }
  16. }
  17. return ans;
  18. }

代码:Java/C++/Python

此外,在《11 | 贪心:这种思想,没有模板,如何才能掌握它?》中我们介绍过 “青蛙跳” 问题, 实际上该解法还可以解决一系列题目,比如“第 11 讲” 的练习题 5,练习题 6 等。因此,我把这个算法模板叫作青蛙跳模板

  1. boolean canJump(int[] A) {
  2. final int N = A == null ? 0 : A.length;
  3. int preScanedPos = -1;
  4. int curCoveredRange = 0;
  5. while (curCoveredRange < N - 1) {
  6. int oldCoveredRange = curCoveredRange;
  7. for (int i = preScanedPos + 1; i <= oldCoveredRange; i++) {
  8. if (i + A[i] > curCoveredRange) {
  9. curCoveredRange = i + A[i];
  10. }
  11. }
  12. if (oldCoveredRange == curCoveredRange) {
  13. return false;
  14. }
  15. preScanedPos = oldCoveredRange;
  16. }
  17. return true;
  18. }

代码:Java/C++/Python

回溯

关于回溯,我经常从两个方向思考:

  • 只看第 i 个人怎么选;
  • “有借有还”。

关于这两点,希望你看了下面的这个思维导图还能够想起来。

23 | 算法模板:如何让高频算法考点秒变默写题? - 图4

回溯的代码模板只有一个,如下所示:

  1. void backtrace(int[] A,
  2. int i,
  3. Box s,
  4. answer) {
  5. final int N = A == null ? 0 : A.length;
  6. if (状态满足要求) {
  7. answer.add(s);
  8. }
  9. if ([i, ...., 后面)的人都没有任何选项了) {
  10. return;
  11. }
  12. for 宝石 in {第i个人当前所有宝石选项} {
  13. s.push(宝石);
  14. backtrace(A, i + 1, s, answer);
  15. s.pop();
  16. }
  17. }

DFS 与 BFS

DFS 与 BFS 的知识点我压缩在了一张思维导图里:

23 | 算法模板:如何让高频算法考点秒变默写题? - 图5

关于代码模板,你只需要掌握两个。

DFS

通常而言,DFS 算法会用到两个模板,一个用于遍历,另一个用于收集符合条件的解。其中用于遍历的代码模板如下:

  1. boolean vis[N];
  2. void DFS(int start) {
  3. if start == end {
  4. success = true
  5. return
  6. }
  7. for opt in getOptions(start) {
  8. if (vis[opt]) continue;
  9. vis[opt] = true;
  10. dfs(opt);
  11. if success {
  12. return;
  13. }
  14. }
  15. }

用于收集符合条件的解的代码模板如下:

  1. void dfs(A,
  2. int start,
  3. vis,
  4. path,
  5. answer) {
  6. final int N = A == null ? 0 : A.length;
  7. if (状态满足要求) {
  8. if (s better_than ans) {
  9. ans = s
  10. }
  11. return;
  12. }
  13. for next in {start点的后继点} {
  14. if !vis[next] {
  15. path.append(next);
  16. vis[next] = true;
  17. dfs(A, next, vis, path, answer);
  18. path.pop();
  19. vis[next] = false;
  20. }
  21. }
  22. }

BFS

巧合的是,BFS 也有两种写法,一种是使用队列,另一种是使用 “两段击”。其中使用队列的代码写法如下:

  1. bfs(s) {
  2. q = new queue()
  3. q.push(s), visited[s] = true
  4. while (!q.empty()) {
  5. u = q.pop()
  6. for next in getNext(u) {
  7. if (!visited[next]) {
  8. q.push(next)
  9. visited[next] = true
  10. }
  11. }
  12. }
  13. }

使用 “两段击” 的代码写法如下:

  1. bfs(s) {
  2. cur = {s};
  3. visited[s] = true;
  4. while (!cur.empty()) {
  5. next = [];
  6. for c in cur {
  7. for next in getNext(c) {
  8. if (!visited[next]) {
  9. next.append(next);
  10. visited[next] = true;
  11. }
  12. }
  13. }
  14. cur = next;
  15. }
  16. }

动态规划

动态规划其实并没有太多的模板可以套,你需要通过实战练习不停地提高自己的应对能力。掌握动态规划的重点在于 6 步分析法,如下图所示:

23 | 算法模板:如何让高频算法考点秒变默写题? - 图6

这里我们重点看一下需要你熟练掌握的 KMP 算法模板(你还记得求 next 数组时的动态规划吗?),如下所示:

  1. int[] buildNext(String sub) {
  2. final int N = sub == null ? 0 : sub.length();
  3. int[] next = new int[N + 1];
  4. int i = 0;
  5. int j = -1;
  6. next[0] = -1;
  7. while (i < N) {
  8. if (j == -1 || sub.charAt(i) == sub.charAt(j)) {
  9. i++;
  10. j++;
  11. if (i < sub.length() && j < sub.length() &&
  12. sub.charAt(i) == sub.charAt(j)) {
  13. next[i] = next[j];
  14. } else {
  15. next[i] = j;
  16. }
  17. } else {
  18. j = next[j];
  19. }
  20. }
  21. return next;
  22. }
  23. int KMP(String main, String sub) {
  24. int i = 0;
  25. int j = 0;
  26. final int alen = main.length();
  27. final int blen = sub.length();
  28. int[] next = buildNext(sub);
  29. while (i < alen && j < blen) {
  30. if (-1 == j || main.charAt(i) == sub.charAt(j)) {
  31. i++;
  32. j++;
  33. } else {
  34. j = next[j];
  35. }
  36. }
  37. return j == blen ? i - blen : -1;
  38. }

代码:Java/C++/Python

总结

整理好算法的代码模板之后,最后我再强调一点,也是新手往往容易犯错的地方。

不要试图把刷过的所有题都放到代码模板中,因为你复习的时候容易没有重点。另外,人的记忆能力是有限的,如果你强记一段代码,可能效果并不好。更重要的是勤于将知识进行整理、归纳以及压缩。然后将它们打包成知识库中的 “积木”,在需要的时候直接拿来用,而不是再从 0 到 1 推导。

算法模板就整理到这里了,接下来,我将和你聊聊我的大厂面试经历,谈谈我对算法学习的看法,让我们继续前进。