加餐 课后练习题详解 专栏虽已结束,但学习不应停止。我看到很多同学依然还在这里学习,一部分同学积极地在留言区和大家分享学习总结和练习题答案。

我几乎在每个课时的结尾都留下了一道练习题,目的是帮助你检测和巩固本课时的重点内容,抑或是引出后续课时中的内容。在我处理留言的过程中,发现很多同学想要练习题详细解答过程以及答案,所以就有了今天的这一篇加餐内容,希望对你有所帮助。

接下来我会给出每个课时练习题的解题思路和答案,如果你没有找到对应的练习题答案,那么请你在正课中查找。

01 | 复杂度:如何衡量程序运行的效率?

【问题】 评估一下,如下的代码片段,时间复杂度是多少? for (i = 0; i < n; i++) { for (j = 0; j < n; j++) { for (k = 0; k < n; k++) { } for (m = 0; m < n; m++) { } } }

【解析】 在上面的代码中:

  • 第 3~5 行和 6~8 行,显然是一个 O(n) 复杂度的循环。这两个循环是顺序结构,因此合在一起的复杂度是 O(n) + O(n) = O(2n) = O(n)。
  • 第 2~9 行是一个 for 循环,它的时间复杂度是 O(n)。这个 for 循环内部嵌套了 O(n) 复杂度的代码,因此合在一起就是 O(n ² ) 的时间复杂度。
  • 在代码的最外部,第 1~10 行又是一个 O(n) 复杂度的循环,内部嵌套了 O(n ² ) 的时间复杂度的代码。因此合在一起就是 O(n ³ ) 的时间复杂度。

02 | 数据结构:将“昂贵”的时间复杂度转换成“廉价”的空间复杂度

【问题】 在下面这段代码中,如果要降低代码的执行时间,第 4 行代码需要做哪些改动呢?如果做出改动后,是否降低了时间复杂度呢? public void s2_2() { int count = 0; for (int i = 0; i <= (100 / 7); i++) { for (int j = 0; j <= (100 / 3); j++) { if ((100-i/7-j/3 >= 0)&&((100-i/7-j/3) % 2 == 0)) { count += 1; } } } System.out.println(count); }

【解析】 代码的第 4 行可以改为:

for (int j = 0; j <= (100-7/*i) / 3; j++) {

代码改造完成后,时间复杂度并没有变小。但由于减少了一些不必要的计算量,程序的执行时间变少了。

03 | 增删查:掌握数据处理的基本操作,以不变应万变

【问题】 对于一个包含 5 个元素的数组,如果要把这个数组元素的顺序翻转过来。你可以试着分析该过程需要对数据进行哪些操作?

【解析】 假设原数组 a = {1,2,3,4,5},现在要更改为 a = {5,4,3,2,1}。要想得到新的数组,就要找到 “1” 和 “5”,再分别把它们赋值给对方。因此,这里主要会产生大量的基于索引位置的查找动作。

04 | 如何完成线性表结构下的增删查?

【问题】 给定一个包含 n 个元素的链表,现在要求每 k 个节点一组进行翻转,打印翻转后的链表结果。例如,链表为 1 -> 2 -> 3 -> 4 -> 5 -> 6,k = 3,则打印 321654。

【解析】 我们给出一些提示。利用链表翻转的算法,这个问题应该很简单。利用 3 个指针,prev、curr、next,执行链表翻转,每次得到了 k 个翻转的结点就执行打印。

05 | 栈:后进先出的线性表,如何实现增删查?

【问题】 给定一个包含 n 个元素的链表,现在要求每 k 个节点一组进行翻转,打印翻转后的链表结果。例如,链表为 1 -> 2 -> 3 -> 4 -> 5 -> 6,k = 3,则打印 321654。要求用栈来实现。

【解析】 我们用栈来实现它,就很简单了。你可以牢牢记住,只要涉及翻转动作的题目,都是使用栈来解决的强烈信号

具体的操作如下,设置一个栈,不断将队列数据入栈,并且实时记录栈的大小。当栈的大小达到 k 的时候,全部出栈。我们给出核心代码: while (tmp != null && count < k) { stack.push(tmp.value); tmp = tmp.next; count++; } while (!stack.isEmpty()) { System.out.print(stack.pop()); }

07 | 数组:如何实现基于索引的查找?

详细分析和答案,请翻阅 18 课时例题 1

08 | 字符串:如何正确回答面试中高频考察的字符串匹配算法?

详细分析和解题步骤,请参考 17 课时例题 1。

10 | 哈希表:如何利用好高效率查找的“利器”?

详细分析和答案,请翻阅 15 课时例题 1

11 | 递归:如何利用递归求解汉诺塔问题?

详细分析和答案,请翻阅 16 课时例题 1

12 | 分治:如何利用分治法完成数据查找?

【问题】 在一个有序数组中,查找出第一个大于 9 的数字,假设一定存在。例如,arr = { -1, 3, 3, 7, 10, 14, 14 };则返回 10。

【解析】 在这里提醒一下,带查找的目标数字具备这样的性质:

  • 第一,它比 9 大;
  • 第二,它前面的数字(除非它是第一个数字),比 9 小。

因此,当我们作出向左走或向右走的决策时,必须满足这两个条件。代码如下: public static void main(String[] args) { int targetNumb = 9; // 目标有序数组 int[] arr = { -1, 3, 3, 7, 10, 14, 14 }; int middle = 0; int low = 0; int high = arr.length - 1; while (low <= high) { middle = (high + low) / 2; if (arr[middle] > targetNumb && (middle == 0 || arr[middle - 1] <= targetNumb)) { System.out.println(“第一个比 “ + targetNumb + “ 大的数字是 “ + arr[middle]); break; } else if (arr[middle] > targetNumb) { // 说明该数在low~middle之间 high = middle - 1; } else { // 说明该数在middle~high之间 low = middle + 1; } } }

14 | 动态规划:如何通过最优子结构,完成复杂问题求解?

详细分析和答案,请翻阅 16 课时例题 3

15 | 定位问题才能更好地解决问题:开发前的复杂度分析与技术选型

【问题】 下面的代码采用了两个 for 循环去实现 two sums。那么,能否只使用一个 for 循环完成呢? private static int[] twoSum(int[] arr, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < arr.length; i++) { map.put(arr[i], i); } for (int i = 0; i < arr.length; i++) { int complement = target - arr[i]; if (map.containsKey(complement) && map.get(complement) != i) { return new int[] { map.get(complement), i }; } } return null; }

【解析】 原代码中,第 3 和第 6 行的 for 循环合并后,就需要把 map 的新增、查找合在一起执行。则代码如下:

private static int[] twoSum(int[] arr, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < arr.length; i++) { int complement = target - arr[i]; if (map.containsKey(complement) && map.get(complement) != i) { return new int[] { map.get(complement), i }; } else{ map.put(arr[i], i); } } return null; }

16 | 真题案例(一):算法思维训练

【问题】 如果现在是个线上实时交互的系统。客户端输入 x,服务端返回斐波那契数列中的第 x 位。那么,这个问题使用上面的解法是否可行。

【解析】 这里给你一个小提示,既然我这么问,答案显然是不可行的。如果不可行,原因是什么呢?我们又该如何解决?

注意,题目中给出的是一个实时系统。当用户提交了 x,如果在几秒内没有得到系统响应,用户就会卸载 App 啦。

一个实时系统,必须想方设法在 O(1) 时间复杂度内返回结果。因此,一个可行的方式是,在系统上线之前,把输入 x 在 1~100 的结果预先就计算完,并且保存在数组里。当收到 1~100 范围内输入时,O(1) 时间内就可以返回。如果不在这个范围,则需要计算。计算之后的结果返回给用户,并在数组中进行保存。以方便后续同样输入时,能在 O(1) 时间内可以返回。

17 | 真题案例(二):数据结构训练

【问题】 对于树的层次遍历,我们再拓展一下。如果要打印的不是层次,而是蛇形遍历,又该如何实现呢?蛇形遍历就是 s 形遍历,即奇数层从左到右,偶数层从右到左。

【解析】 这里要对数据的顺序进行逆序处理,直观上,你需要立马想到栈。毕竟只有栈是后进先出的结构,是能快速实现逆序的。具体而言,需要建立两个栈 s1 和 s2。进栈的顺序是,s1 先右后左,s2 先左后右。两个栈交替出栈的结果就是 s 形遍历,代码如下: public ArrayList<ArrayList> Print(TreeNodes pRoot) { // 先右后左 Stack s1 = new Stack(); // 先左后右 Stack s2 = new Stack(); ArrayList<ArrayList> list = new ArrayList<ArrayList>(); list.add(pRoot.val); s1.push(pRoot); while (s1.isEmpty() || s2.isEmpty()) { if (s1.isEmpty() && s2.isEmpty()) { break; } if (s2.isEmpty()) { while (!s1.isEmpty()) { if (s1.peek().right != null) { list.add(s1.peek().right.val); s2.push(s1.peek().right); } if (s1.peek().left != null) { list.add(s1.peek().left.val); s2.push(s1.peek().left); } s1.pop(); } } else { while (!s2.isEmpty()) { if (s2.peek().left != null) { list.add(s2.peek().left.val); s1.push(s2.peek().left); } if (s2.peek().right != null) { list.add(s2.peek().right.val); s1.push(s2.peek().right); } s2.pop(); } } } return list; }

18 | 真题案例(三): 力扣真题训练

【问题】 给定一个链表,删除链表的倒数第 n 个节点。例如,给定一个链表: 1 -> 2 -> 3 -> 4 -> 5, 和 n = 2。当删除了倒数第二个节点后,链表变为 1 -> 2 -> 3 -> 5。你可以假设,给定的 n 是有效的。额外要求就是,要在一趟扫描中实现,即时间复杂度是 O(n)。这里给你一个提示,可以采用快慢指针的方法。

【解析】 定义快慢指针,slow 和 fast 并同时指向 header。然后,让 fast 指针先走 n 步。接着,让二者保持同样的速度,一起往前走。最后,fast 指针先到达终点,并指向了 null。此时,slow 指针的结果就是倒数第 n 个结点。比较简单,我们就不给代码了。

19 | 真题案例(四):大厂真题实战演练

【问题】 小明从小就喜欢数学,喜欢在笔记里记录很多表达式。他觉得现在的表达式写法很麻烦,为了提高运算符优先级,不得不添加很多括号。如果不小心漏了一个右括号的话,就差之毫厘,谬之千里了。因此他改用前缀表达式,例如把

(2 + 3) /* 4 写成

/* + 2 3 4 ,这样就能避免使用括号了。这样的表达式虽然书写简单,但计算却不够直观。请你写一个程序帮他计算这些前缀表达式。

【解析】 在这个题目中,输入就是前缀表达式,输出就是计算的结果。你可以假设除法为整除,即 “5/3 = 1”。例如,输入字符串为 + 2 3,输出 5;输入字符串为 /* + 2 2 3,输出为 12;输入字符串为 /* 2 + 2 3,输出为 10。

假设输入字符串为 /* 2 + 2 3,即 2/(2+3)。第一个字符为运算符号 /,它将对两个数字进行乘法。如果后面紧接着的字符不全是数字字符,那就需要暂存下来,先计算后面的算式。一旦后面的计算完成,就需要接着从后往前去继续计算。因为从后往前是一种逆序动作,我们能够很自然地想到可以用栈的数据结构进行存储。代码如下: public static void main(String[] args) { Stack stack = new Stack(); String s = “/* + 2 2 3”; String attr[] = s.split(“ “); for (int i = attr.length - 1; i >= 0; i–) { if (!(attr[i].equals(“+”) || attr[i].equals(“-“) || attr[i].equals(“/”) || attr[i].equals(“/”))) { stack.push(Integer.parseInt(attr[i])); } else { int a = (int) stack.pop();// 出栈 int b = (int) stack.pop();// 出栈 int result = Cal(a, b, attr[i]); // 调用函数计算结果值 stack.push(result); // 结果进栈 } } int ans = (int) stack.pop(); System.out.print(ans); } public static int Cal(int a, int b, String s) { switch (s) { case “+”: return a + b; case “-“: return a - b; case “/”: return a /* b; case “/”: return a / b; } return 0; }

以上这些练习题你做得怎么样呢?还能回忆起来每道题是源自哪个算法知识点或哪个课时吗?

你可以把课后习题和课程中的案例都当作一个小项目,自己动手实践,即使有些题目你还不能写出完整的代码,那也可以尝试写出解题思路,从看不明白到能够理解,再到能联想到用什么数据结构和算法去解决什么样的问题,这是一个循序渐进的过程,切勿着急。

通过留言可以看出,你们都在认真地学习这门课程,也正因如此,我才愿意付出更多的时间优化这个已经完结的专栏。所以,请你不要犹豫,尽管畅所欲言,在留言区留下你的思考,也欢迎你积极地提问,更欢迎你为专栏提出建议,这样我才能更直接地看到你们的想法和收获。也许你的一条留言,就是下一篇加餐的主题。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e9%87%8d%e5%ad%a6%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84%e4%b8%8e%e7%ae%97%e6%b3%95-%e5%ae%8c/%e5%8a%a0%e9%a4%90%20%20%e8%af%be%e5%90%8e%e7%bb%83%e4%b9%a0%e9%a2%98%e8%af%a6%e8%a7%a3.md