刷题必看

题目链接:https://www.nowcoder.com/exam/oj
配套详解:https://uploadfiles.nowcoder.com/bm/top101.html
image.png

专题1:链表

BM1 反转链表

方法1:迭代
单链表就是每个结点有一个值,还有一个next 指针指向下一个结点,尾结点指向特殊位置null。解决链表问题,就是顺藤摸瓜,可以从前往后依次处理。
要反转单链表,首先准备两个指针pre cur,分别指向前一个结点和当前结点,每次先保存当前结点的next 结点,然后修改当前结点的指向,再同步移动precur 指针,等到当前结点为空,此时pre 走到原链表的尾结点,而它此时又是反转后链表的头结点,返回它即可。

  1. public class Solution {
  2. // 方法1:迭代
  3. public ListNode ReverseList(ListNode head) {
  4. // 1. 处理参数
  5. if (head == null) {
  6. return null;
  7. }
  8. // 2. 准备两个指针,pre 指向上一个结点,cur 指向当前结点
  9. ListNode pre = null;
  10. ListNode cur = head;
  11. // 3. 从头结点往后遍历,顺藤摸瓜,依次处理
  12. while (cur != null) {
  13. // 4. 反转操作会使链表断开,所以先用变量记录下一个结点
  14. ListNode curNext = cur.next;
  15. // 5. 反转当前结点的next指向
  16. cur.next = pre;
  17. // 6. 同步移动两个指针,
  18. pre = cur;
  19. cur = curNext;
  20. }
  21. // 7. 返回反转后的头结点,即pre最后所指的结点
  22. return pre;
  23. }
  24. }

方法2:递归。
从迭代方法中可以看到,当从前往后遍历,反转一个节点的next 指向后,要进入下一个结点再次进行反转,相当于对规模小一级的子链表进行反转,这就是一个子问题,因此可以使用递归。

递归的模板:终止条件、返回值、本级处理。
终止条件:当前结点为空,或是尾结点,返回当前结点对象
返回值:本题把反转后的尾结点,通过各层调用向上返回。
本级处理:对当前结点的下一个进行处理,在本层调用中,把下一个结点的next 修改为指向当前结点,再把当前结点的next 指向null。

递归调用的执行过程示意图:要多想想整个执行流程,深刻理解递归。
image.png

  1. // 方法2:递归(从后往前反转)
  2. public class Solution {
  3. public ListNode ReverseList(ListNode head) {
  4. // 1. 递归的终止条件
  5. if (head == null || head.next == null) {
  6. // 终止时刻的返回值
  7. return head;
  8. }
  9. // 2. 递归调用:得到反转后的头结点
  10. ListNode newHead = ReverseList(head.next);
  11. // 3. 本级处理:反转当前结点的指向
  12. ListNode curNext = head.next;
  13. curNext.next = head;
  14. head.next = null;
  15. // 4. 中间层:把反转后的头结点层层向上返回
  16. return newHead;
  17. }
  18. }

方法3:尾递归。
题解里面有人解释:https://www.nowcoder.com/practice/75e878df47f24fdc9dc3e400ec6058ca?tpId=295&tqId=23286&ru=%2Fpractice%2F886370fe658f41b498d40fb34ae76ff9&qru=%2Fta%2Fformat-top101%2Fquestion-ranking&sourceUrl=%2Fexam%2Foj

这种递归往下传递的时候基本上没有逻辑处理,当往回反弹的时候才开始处理,也就是从链表的尾端往前开始处理的。我们还可以再来改一下,在链表递归的时候从前往后处理,处理完之后直接返回递归的结果,这就是所谓的尾递归,不过线上测试显示时间差不多。

  1. // 方法3:尾递归(从前往后,先反转处理再递归)
  2. public class Solution {
  3. public ListNode ReverseList(ListNode head) {
  4. return reverseFromHead(head, null);
  5. }
  6. private ListNode reverseFromHead(ListNode head, ListNode newHead) {
  7. // 1. 递归的终止条件:
  8. if (head == null) {
  9. return newHead;
  10. }
  11. // 2. 本级处理:记录下一个结点,并且修改当前结点的指向
  12. ListNode curNext = head.next;
  13. head.next = newHead;
  14. // 3. 处理后进行递归调用
  15. return reverseFromHead(curNext, head);
  16. }
  17. }

BM2 反转链表某段区间

描述
将一个 链表 m 位置到 n 位置之间的区间反转,要求时间复杂度 O(n),空间复杂度 O(1)

方法1:迭代-头插法
链表问题创建一个虚拟头结点,避免对头结点复杂的情况进行分类讨论,简化操作。

算法流程:
要找到第m个位置才能开始反转链表,而反转的部分就是从第m个位置到第n个位置。

  • step 1:在链表前加一个表头,后续返回时去掉就好了,因为如果要从链表头的位置反转,也很方便。
  • step 2:使用两个指针,cur 指向当前节点,pre 指向前序节点,也就是区间外,起到一个锚点的作用,用来每次对后面反转部分进行链接
  • step 3:依次遍历链表,到第m个的位置。
  • step 4:对于从m到n这些个位置的节点,依次修改当前结点指向,让它从指向后续结点temp,修改为指向隔一个的结点,然后把后序结点的指针修改为区间外pre 结点下一个结点,最后更新pre ,让pre 结点直接指向temp。
  • step 5:返回时去掉我们添加的表头。

image.png

2022.05.19 昨天看着参考解释,感觉很简单,今天早上吃完早饭,自己试着写却写不出来。比较考验动态想象能力,画图会清晰很多。
2022.06.01 写不出来反转的代码
2022.06.16. 再次看代码,依然很懵,结合画图看了两三遍才想明白。

  1. // 方法1:迭代法
  2. public class Solution {
  3. public ListNode reverseBetween(ListNode head, int m, int n) {
  4. ListNode dummy = new ListNode(-1);
  5. dummy.next = head;
  6. ListNode anchor = dummy;
  7. ListNode cur = head;
  8. for (int i = 1; i < m; i++) {
  9. anchor = cur;
  10. cur = cur.next;
  11. }
  12. for (int i = m; i < n; i++) {
  13. ListNode curNext = cur.next;
  14. cur.next = curNext.next;
  15. curNext.next = anchor.next;
  16. anchor.next = curNext;
  17. }
  18. return dummy.next;
  19. }
  20. }

方法2:递归
具体做法:
我们来看看另一种分析:如果m == 1,就相当于反转链表的前n 元素;如果 m != 1我们把 head 的索引视为 1,那么我们是想从第 m 个元素开始反转,如果把 head.next 的索引视为1,那相对于 head.next的反转的区间应该是从第 m - 1 个元素开始的,以此类推,反转区间的起点往后就是子问题,我们可以使用递归处理:

  • 终止条件:m == 1,就可以直接反转前n个元素。
  • 返回值: 将已经反转后的子问题头节点返回给上一级。
  • 本级任务: 递归地缩短区间,进入子问题反转。

而每次反转,如果n == 1,相当于只颠倒第一个节点,如果不是,则进入后续节点(子问题),因此反转过程也可以使用递归:

  • 终止条件: 当n == 1时,只反转当前头节点即可。
  • 返回值: 将子问题反转后的节点头返回。
  • 本级任务: 缩短n 进入子问题反转,并将当前节点接在反转后子问题的后面。

具体步骤如下:

  • step 1:按照第一个递归的思路缩短子问题找到反转区间的起点。
  • step 2:按照第二个递归的思路缩短终点的子问题,从第n 个位置开始反转,往上拼接。

这个流程图是指反转链表前3个结点:
image.png

  1. // 方法2:递归
  2. public class Solution {
  3. public ListNode reverseBetween(ListNode head, int m, int n) {
  4. // 1. 递归的终止条件:找到子区间的头结点
  5. if (m == 1) {
  6. return reverse(head, n);
  7. }
  8. // 2. 递归调用,得到区间反转后的头结点
  9. ListNode newHead = reverseBetween(head.next, m - 1, n - 1);
  10. // 3. 修改当前结点的next 指向,连接反转区间
  11. head.next = newHead;
  12. // 4. 返回这一段的头结点,此时 head--> newHead
  13. return head;
  14. }
  15. // 变量anchor 起到一个锚定的作用,它始终指向区间右侧外的结点
  16. ListNode anchor = null;
  17. public ListNode reverse(ListNode head, int n) {
  18. if (n == 1) {
  19. anchor = head.next;
  20. return head;
  21. }
  22. ListNode newHead = reverse(head.next, n - 1);
  23. ListNode curNext = head.next;
  24. curNext.next = head;
  25. head.next = anchor;
  26. return newHead;
  27. }
  28. }

BM3 链表k个结点为一组进行反转

方法1:递归。
这道题其实是BM1 和BM2 综合在一起的。
解题思路:首先,我们可以实现一个反转整个链表的方法,即问题BM1。
image.png

反转以head 为头结点的链表,其实就是反转[head, null)之间的结点,那么反转[start,end) 之间的结点,是不是只需要把null 改成 end?
image.png

具体做法:

  • step 1:现在我们想一想,如果拿到一个链表,想要像上述一样分组翻转应该做些什么?首先肯定是分段吧,至少我们要先分成一组一组,才能够在组内翻转。分组很容易,只要每次遍历k个元素,就是一组。
  • step 2:然后是组内翻转,翻转完了再连接起来。翻转即指定区间内的翻转,也很容易,可以参考链表指定区间内的翻转
  • step 3:最后是将反转后的分组连接,但是连接的时候遇到问题了:首先如果能够翻转,链表第一个元素一定是第一组,它翻转之后就跑到后面去了,而第一组的末尾元素才是新的链表首,我们要返回的也是这个元素,而原本的链表首要连接下一组翻转后的头部,即翻转前的尾部,如果不建立新的链表,看起来就会非常难。
  • step 4:如果我们从最后的一个组开始翻转,得到了最后一个组的链表首,是不是可以直接连在倒数第二个组翻转后的尾(即翻转前的头)后面,是不是看起来就容易多了。

怎样从后往前呢?我们这时候可以用到自上而下再自下而上的递归或者说栈。接下来我们说说为什么能用递归?如果这个链表有n 个分组可以翻转,我们首先对第一个分组翻转,那么是不是接下来将剩余个n - 1分组翻转后的结果接在第一组后面就行了,那这剩余的n - 1 组就是一个子问题。我们来看看递归的三段式模版:

  • 终止条件: 当进行到最后一个分组,即不足k 次遍历到链表尾(0次也算),就将剩余的部分直接返回。
  • 返回值: 每一级要返回的就是翻转后的这一分组的头,以及连接好它后面所有翻转好的分组链表。
  • 本级任务: 对于每个子问题,先遍历k次,找到该组结尾在哪里,然后从这一组开头遍历到结尾,依次翻转,结尾就可以作为下一个分组的开头,而先前指向开头的元素已经跑到了这一分组的最后,可以用它来连接它后面的子问题,即后面分组的头。

现在已经实现了反转链表某个区间的功能,那么只需要在遍历链表的过程中,将符合条件的区间进行反转,就可以解决问题了。思路说起来简单,但是代码中每次递归调用的流程轨迹很难想的透彻,一会儿懂,过一会儿又不明白了。

image.png

  1. public class Solution {
  2. public ListNode reverseKGroup(ListNode head, int k) {
  3. // 1. 找到要反转的区间[head, end)
  4. ListNode end = head;
  5. for (int i = 0; i < k; i++) {
  6. if (end == null) {
  7. return head;
  8. }
  9. end = end.next;
  10. }
  11. // 2. 对[head, end) 区间进行反转,返回反转后的头结点
  12. ListNode newHead = reverseBetween(head, end);
  13. // 3. 递归,依次连接下一段链表
  14. head.next = reverseKGroup(end, k);
  15. // 4. 向上返回当前区间的头结点
  16. return newHead;
  17. }
  18. // 方法1:迭代
  19. // 对区间[start, end) 的链表进行反转
  20. private ListNode reverseBetween(ListNode head, ListNode end) {
  21. ListNode pre = null;
  22. ListNode cur = head;
  23. while (cur != end) {
  24. ListNode curNext = cur.next;
  25. cur.next = pre;
  26. pre = cur;
  27. cur = curNext;
  28. }
  29. return pre;
  30. }
  31. // // 方法2:递归。
  32. // // 对区间[start, end) 的链表进行反转,返回反转后的头结点。即BM2问题
  33. // private ListNode reverseBetween(ListNode head, ListNode end) {
  34. // if (head.next == end) {
  35. // return head;
  36. // }
  37. //
  38. // ListNode curNext = head.next;
  39. //
  40. // ListNode newHead = reverseBetween(head.next, end);
  41. //
  42. // curNext.next = head;
  43. // head.next = null;
  44. //
  45. // return newHead;
  46. // }
  47. }

BM4 合并两个排序的链表

方法1:迭代
具体做法:
既然两个链表已经是排好序的,都是从小到大的顺序,那我们要将其组合,可以使用归并排序的思想:每次比较两个头部,从中取出最小的元素,然后依次往后。这样就能最快速地将最小的元素依次取出来排好序。

  • step 1:判断空链表的情况,只要有一个链表为空,那答案必定就是另一个链表了,就算另一个链表也为空。
  • step 2:新建一个空的表头后面连接两个链表排序后的结点。
  • step 3:遍历两个链表都不为空的情况,取较小值添加在新的链表后面,每次只把被添加的链表的指针后移。
  • step 4:遍历到最后肯定有一个链表还有剩余的结点,它们的值将大于前面所有的,直接连在新的链表后面即可。
  1. public class Solution {
  2. public ListNode Merge(ListNode list1, ListNode list2) {
  3. // 1. 为空直接返回另一个就行了
  4. if (list1 == null) {
  5. return list2;
  6. }
  7. if (list2 == null) {
  8. return list1;
  9. }
  10. // 2. 创建虚拟头结点和三个指针
  11. ListNode dummyHead = new ListNode(0);
  12. ListNode cur = dummyHead;
  13. ListNode p1 = list1;
  14. ListNode p2 = list2;
  15. // 3. 遍历两个链表,比较后进行合并
  16. while (p1 != null && p2 != null) {
  17. // 3.1 比较大小并修改指向关系
  18. if (p1.val <= p2.val) {
  19. cur.next = p1;
  20. p1 = p1.next;
  21. } else {
  22. cur.next = p2;
  23. p2 = p2.next;
  24. }
  25. cur = cur.next;
  26. }
  27. if (p1 != null) {
  28. cur.next = p1;
  29. } else {
  30. cur.next = p2;
  31. }
  32. return dummyHead.next;
  33. }
  34. }

方法2:递归
上述方法中,我们利用归并思想不断合并两个链表,每当我们添加完一个节点后,该节点指针后移,相当于这个链表剩余部分与另一个链表剩余部分合并,两个链表剩余部分合并就是原问题两个有序链表合并的子问题,因此也可以使用递归。

  • step 1:每次比较两个链表当前结点的值,然后取较小值的链表指针往后,另一个不变送入递归中。
  • step 2:递归回来的结果我们要加在当前较小值的结点后面,相当于不断在较小值后面添加结点。
  • step 3:递归的终止是两个链表为空。

    1. public class Solution {
    2. // 方法2:递归
    3. public ListNode Merge(ListNode list1, ListNode list2) {
    4. if (list1 == null) {
    5. return list2;
    6. }
    7. if (list2 == null) {
    8. return list1;
    9. }
    10. if (list1.val <= list2.val) {
    11. list1.next = Merge(list1.next, list2);
    12. return list1;
    13. } else {
    14. list2.next = Merge(list1, list2.next);
    15. return list2;
    16. }
    17. }
    18. }

BM5. 合并k个已排序的链表

题目的主要信息:

  • 给定k个排好序的升序链表
  • 将这k个链表合并成一个大的升序链表,并返回这个升序链表的头

方法一:归并排序思想(推荐使用)
具体做法:
如果是两个有序链表合并,我们可能会利用归并排序合并阶段的思想:准备双指针分别放在两个链表头,每次取出较小的一个元素加入新的大链表,将其指针后移,继续比较,这样我们出去的都是最小的元素,自然就完成了排序。
其实这道题我们也可以两两比较啊,只要遍历链表数组,取出开头的两个链表,按照上述思路合并,然后新链表再与后一个继续合并,如此循环,知道全部合并完成。但是,这样太浪费时间了。
既然都是归并排序的思想了,那我们可不可以直接归并的分治来做,而不是顺序遍历合并链表呢?答案是可以的!
归并排序是什么?简单来说就是将一个数组每次划分成等长的两部分,对两部分进行排序即是子问题。对子问题继续划分,直到子问题只有1个元素。还原的时候呢,将每个子问题和它相邻的另一个子问题利用上述双指针的方式,1个与1个合并成2个,2个与2个合并成4个,因为这每个单独的子问题合并好的都是有序的,直到合并成原本长度的数组。

  • step 1:对于这k个链表,就相当于上述合并阶段的k个子问题,需要两个合并,不断往上,最终合并成完整的一个链表。
  • step 2:从链表数组的首和尾开始,每次划分从中间开始划分,划分成两半。
  • step 3:将这两半子问题合并好了就成了两个有序链表,最后将这两个有序链表合并就成了,依据子问题递归处理。

  • 终止条件: 划分的时候直到左右区间相等或左边大于右边。

  • 返回值: 每级返回已经合并好的子问题链表。
  • 本级任务: 对半划分,将划分后的子问题合并成新的链表。

注意:这道题划分左右边界的时候,一个是mid,另一个是mid + 1。
还有这句代码“int mid = left + (( right - left ) >> 1);”,必须要加括号,因为Java中 +- 的优先级比>> 高,不加括号代码意思就变了,会出错的。

  1. public class Solution {
  2. // 方法1:归并排序思想
  3. public ListNode mergeKLists(ArrayList<ListNode> lists) {
  4. return divide(lists, 0, lists.size() - 1);
  5. }
  6. // 1. 分治,对问题进行划分,直到只有一个元素
  7. private ListNode divide(ArrayList<ListNode> lists, int left, int right) {
  8. if (left > right) {
  9. return null;
  10. } else if (left == right) {
  11. return lists.get(left);
  12. }
  13. int mid = left + ((right - left) >> 1);
  14. ListNode nodeLeft = divide(lists, left, mid);
  15. ListNode nodeRight = divide(lists, mid + 1, right);
  16. // 2. 合并。1-->2-->4-->8...
  17. return merge(nodeLeft, nodeRight);
  18. }
  19. // 合并两个有序链表
  20. private ListNode merge(ListNode list1, ListNode list2) {
  21. if (list1 == null) {
  22. return list2;
  23. }
  24. if (list2 == null) {
  25. return list1;
  26. }
  27. ListNode dummyHead = new ListNode(0);
  28. ListNode cur = dummyHead;
  29. while (list1 != null && list2 != null) {
  30. if (list1.val <= list2.val) {
  31. cur.next = list1;
  32. list1 = list1.next;
  33. } else {
  34. cur.next = list2;
  35. list2 = list2.next;
  36. }
  37. cur = cur.next;
  38. }
  39. if (list1 != null) {
  40. cur.next = list1;
  41. } else {
  42. cur.next = list2;
  43. }
  44. return dummyHead.next;
  45. }
  46. }

方法二:优先队列(扩展思路)
我们也可以准备k个指针,每次比较得出k个数字中的最小值。利用Java提供的PriorityQueue或者C++ssSLT提供的优先队列,它是一种参照堆排序的容器,容器中的元素是有序的,如果是小顶堆,顶部元素就是最小的,每次可以直接取出最小的元素,而每次插入的成本根据堆排序,就是O(log2^N) 。也就是每次该容器中有k个元素,我们可以直接拿出最小的元素,再插入下一个元素,相当于每次都是链表的k个指针在比较大小。

  • step 1:不管是Java 还是C++都需要重载比较方法,构造一个比较链表节点大小的小顶堆。
  • step 2:先遍历k 个链表头,将不是空节点的节点加入优先队列。
  • step 3:每次依次弹出优先队列中的最小元素,将其连接在合并后的链表后面,然后将这个节点在原本链表中的后一个节点(如果不为空的话)加入队列,类似上述双指针的过程。

PriorityQueue 是Java 提供的一个优先级队列类,其实就是堆结构,默认是小根堆。对Java 的基本数据类型,它已经定义好了排序规则,而对于其他类型,需要手动传入比较规则。这里 new PriorityQueue<>() 小括号中传入的参数是比较器,用流式编程定义了 ListNode 类比较排序的规则。

  1. public class Solution {
  2. public ListNode mergeKLists(ArrayList<ListNode> lists) {
  3. Queue<ListNode> heap = new PriorityQueue<>((o1, o2) -> o1.val - o2.val);
  4. for (int i = 0; i < lists.size(); i++) {
  5. if (lists.get(i) != null) {
  6. heap.add(lists.get(i));
  7. }
  8. }
  9. ListNode dummyHead = new ListNode(0);
  10. ListNode cur = dummyHead;
  11. while (!heap.isEmpty()) {
  12. ListNode temp = heap.poll();
  13. cur.next = temp;
  14. cur = cur.next;
  15. if (temp.next != null) {
  16. heap.add(temp.next);
  17. }
  18. }
  19. return dummyHead.next;
  20. }
  21. }

BM6 判断链表中是否有环

判断给定的链表中是否有环。如果有环则返回true,否则返回false。

方法1:快慢指针。
我们都知道链表不像二叉树,每个节点只有一个val 值和一个next 指针,也就是说一个节点只能有一个指针指向下一个节点,不能有两个指针,那这时我们就可以说一个性质:环形链表的环一定在末尾,末尾没有NULL了。进入环中后,没有任何一个节点可以指针指出环,它们只能在环内不断循环,因此环后面不可能还有一条尾巴。如果是普通线形链表末尾一定有NULL,那我们可以根据链表中是否有NULL判断是不是有环。
但是,环形链表遍历过程中会不断循环,线形链表遍历到NULL结束了,但是环形链表何时能结束呢?我们可以用一种双指针技巧,这也是处理环形链表常用的技巧:

  • step 1:设置快慢两个指针,初始都指向链表头。
  • step 2:遍历链表,快指针每次走两步,慢指针每次走一步。
  • step 3:如果快指针到了链表末尾,说明没有环,因为它每次走两步,所以要验证连续两步是否为NULL。
  • step 4:如果链表有环,那快慢双指针会在环内循环,因为快指针每次走两步,因此快指针会在环内追到慢指针,二者相遇就代表有环。

    1. public class Solution {
    2. public boolean hasCycle(ListNode head) {
    3. if (head == null) {
    4. return false;
    5. }
    6. ListNode fast = head;
    7. ListNode slow = head;
    8. // 必须判断fast 是否为空,不加这一句,当fast为空,访问它的next就会出现数组越界异常
    9. while (fast != null && fast.next != null) {
    10. fast = fast.next.next;
    11. slow = slow.next;
    12. if (slow == fast) {
    13. return true;
    14. }
    15. }
    16. return false;
    17. }
    18. }

BM7 链表中环的入口结点

直觉好像是这样的:当慢指针刚进环,在跑完一圈前,一定会和快指针相遇,可惜不会证明。
不严谨的证明:假设慢指针到达环的入口处,此时快指针在环内某一点,距离入口为x,当慢指针跑完一圈,因为快指针速度是慢指针的2 倍,所以快指针跑完了2 圈,比慢指针多跑了一圈,这一圈的长度肯定比x 大,所以在慢指针跑完一圈之前,快指针一定能追上它,在某个位置相遇。

image.png

  1. public class Solution {
  2. public ListNode EntryNodeOfLoop(ListNode pHead) {
  3. // 1. 判断是否有环
  4. ListNode slow = hasCycle(pHead);
  5. if (slow == null) {
  6. return null;
  7. }
  8. // 2. 如果有环,快指针回到头结点,会在环入口处相遇
  9. ListNode fast = pHead;
  10. while (fast != slow) {
  11. slow = slow.next;
  12. fast = fast.next;
  13. }
  14. return slow;
  15. }
  16. private ListNode hasCycle(ListNode head) {
  17. if (head == null) {
  18. return null;
  19. }
  20. ListNode fast = head;
  21. ListNode slow = head;
  22. while (fast != null && fast.next != null) {
  23. fast = fast.next.next;
  24. slow = slow.next;
  25. if (slow == fast) {
  26. return slow;
  27. }
  28. }
  29. return null;
  30. }
  31. }

BM8 链表中倒数第k个结点

方法1:快慢指针。
我们无法逆序遍历链表,就很难得到链表的倒数第k 个元素,那我们可以试试反过来考虑,如果当前我们处于倒数第k 的位置上,即距离链表尾的距离是k,那我们假设双指针指向这两个位置,二者同步向前移动,当前面个指针到了链表头的时候,两个指针之间的距离还是k 。虽然我们没有办法让指针逆向移动,但是我们刚刚这个思路却可以正向实施:

  • step 1:准备一个快指针,从链表头开始,在链表上先走k 步。
  • step 2:准备慢指针指向原始链表头,代表当前元素,则慢指针与快指针之间的距离一直都是k 。
  • step 3:快慢指针同步移动,当快指针到达链表尾部的时候,慢指针正好到了倒数k 个元素的位置。

    1. public class Solution {
    2. public ListNode FindKthToTail(ListNode pHead, int k) {
    3. if (pHead == null) {
    4. return null;
    5. }
    6. ListNode slow = pHead;
    7. ListNode fast = pHead;
    8. for (int i = 0; i < k; i++) {
    9. if (fast == null) {
    10. return null;
    11. }
    12. fast = fast.next;
    13. }
    14. while (fast != null) {
    15. slow = slow.next;
    16. fast = fast.next;
    17. }
    18. return slow;
    19. }
    20. }

BM9 删除链表的倒数第n个节点

方法1:双指针。
我们无法逆序遍历链表,就很难得到链表的倒数第n 个元素,那我们可以试试反过来考虑,如果当前我们处于倒数第n 的位置上,即距离链表尾的距离是n,那我们假设双指针指向这两个位置,二者同步向前移动,当前面个指针到了链表头的时候,两个指针之间的距离还是n。虽然我们没有办法让指针逆向移动,但是我们刚刚这个思路却可以正向实施:

  • step 1:给链表添加一个表头,处理删掉第一个元素时比较方便。
  • step 2:准备一个快指针,在链表上先走n步。
  • step 3:准备慢指针指向原始链表头,代表当前元素,前序节点指向添加的表头,这样两个指针之间相距就是一直都是n。
  • step 4:快慢指针同步移动,当快指针到达链表尾部的时候,慢指针正好到了倒数第n 个元素的位置。
  1. public class Solution {
  2. public ListNode removeNthFromEnd(ListNode head, int n) {
  3. if (head == null) {
  4. return null;
  5. }
  6. ListNode dummy = new ListNode(0);
  7. dummy.next = head;
  8. ListNode pre = dummy;
  9. ListNode slow = head;
  10. ListNode fast = head;
  11. for (int i = 0; i < n; i++) {
  12. if (fast == null) {
  13. return null;
  14. }
  15. fast = fast.next;
  16. }
  17. while (fast != null) {
  18. pre = slow;
  19. slow = slow.next;
  20. fast = fast.next;
  21. }
  22. pre.next = slow.next;
  23. return dummy.next;
  24. }
  25. }

BM10. 两个链表的第一个公共结点

方法一:长度比较法(推荐使用)
具体做法:
如果两个链表有公共节点,那么它们后半部分都是相同的,我们要找的也就是后半部分的第一个节点,只要遍历两个指针同时抵达第一个相同的节点,我们就找到了它。

  • step 1:单独的遍历两个链表,得到各自的长度。
  • step 2:求得两链表的长度差,其中较长的链表的指针从头先走步。
  • step 3:两链表指针同步向后遍历,遇到第一个相同的节点就是第一个公共结点;如果没有共同结点,那么两个指针都会指向最后的null,任意返回一个即可。

    1. // 方法1:长度比较法
    2. public class Solution {
    3. public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2) {
    4. int len1 = getLength(pHead1);
    5. int len2 = getLength(pHead2);
    6. if (len1 > len2) {
    7. int count = len1 - len2;
    8. while (count != 0) {
    9. pHead1 = pHead1.next;
    10. count--;
    11. }
    12. } else {
    13. int count = len2 - len1;
    14. while (count != 0) {
    15. pHead2 = pHead2.next;
    16. count--;
    17. }
    18. }
    19. while (pHead1 != null && pHead1 != pHead2) {
    20. pHead1 = pHead1.next;
    21. pHead2 = pHead2.next;
    22. }
    23. return pHead1;
    24. }
    25. private int getLength(ListNode head) {
    26. if (head == null) {
    27. return 0;
    28. }
    29. int len = 0;
    30. while (head != null) {
    31. len++;
    32. head = head.next;
    33. }
    34. return len;
    35. }
    36. }

方法2:交错走一遍。
具体做法:
由上种方法长度差的思路,不同上述一个指针先走另一个指针后走,仅需将两个链表连在一起,两个指针同步走。易知将两个链表连在一起长度都相等,对于遍历两个链表的两个指针,公共部分走的步数是一样的,非公共部分因都走了两个链表,因此也是相同的,所以绕了一圈,第一个相同的结点便是第一个公共结点。

  • step 1:判断链表情况,其中有一个为空,则不能有公共结点,返回null。
  • step 2:两个链表都从表头开始同步依次遍历。
  • step 3:不需要物理上将两个链表连在一起,仅需指针在一个链表的尾部时直接跳到另一个链表的头部。
  • step 4:根据上述说法,第一个相同的结点便是第一个公共结点。

要多写写代码,因为看着简单,有些细节却很容易出错!
要多写写代码,因为看着简单,有些细节却很容易出错!
要多写写代码,因为看着简单,有些细节却很容易出错!
image.png

  1. // 方法2:交错走一遍
  2. public class Solution {
  3. public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2) {
  4. if (pHead1 == null || pHead2 == null) {
  5. return null;
  6. }
  7. // 必须新建这两个指针变量,用来遍历
  8. ListNode p1 =pHead1;
  9. ListNode p2 =pHead2;
  10. while (p1 != p2) {
  11. p1 = p1 == null ? pHead2 : p1.next;
  12. p2 = p2 == null ? pHead1 : p2.next;
  13. }
  14. return p1;
  15. }
  16. }

BM11. 两个链表生成相加链表

方法:两次反转链表法(推荐使用)
具体做法:
既然链表每个节点表示数字的每一位,那相加的时候自然可以按照加法法则,从后往前依次相加。但是,链表是没有办法逆序访问的,这是我们要面对第一只拦路虎。解决它也很简单,既然从后往前不行,那从前往后总是可行的吧,将两个链表反转一下,即可得到个十百千……各个数字从前往后的排列,相加结果也是个位在前,怎么办?再次反转,结果不就正常了。

  • step 1:任意一个链表为空,返回另一个链表就行了,因为链表为空相当于0,0加任何数为0,包括另一个加数为0的情况。
  • step 2:相继反转两个待相加的链表,反转过程可以参考反转链表
  • step 3:设置返回链表的链表头,设置进位carry=0.
  • step 4:从头开始遍历两个链表,直到两个链表节点都为空且carry也不为1. 每次取出不为空的链表节点值,为空就设置为0,将两个数字与carry相加,然后查看是否进位,将进位后的结果(对10取模)加入新的链表节点,连接在返回链表后面,并继续往后遍历。
  • step 5:返回前将结果链表再反转回来。
  1. public class Solution {
  2. public ListNode addInList(ListNode head1, ListNode head2) {
  3. if (head1 == null) {
  4. return head2;
  5. }
  6. if (head2 == null) {
  7. return head1;
  8. }
  9. ListNode p1 = reverseList(head1);
  10. ListNode p2 = reverseList(head2);
  11. ListNode dummy = new ListNode(0);
  12. ListNode cur = dummy;
  13. int carry = 0;
  14. while (p1 != null || p2 != null || carry != 0) {
  15. int plus1 = p1 == null ? 0 : p1.val;
  16. int plus2 = p2 == null ? 0 : p2.val;
  17. int sum = plus1 + plus2 + carry;
  18. carry = sum / 10;
  19. int remainder = sum % 10;
  20. cur.next = new ListNode(remainder);
  21. cur = cur.next;
  22. if (p1 != null) {
  23. p1 = p1.next;
  24. }
  25. if (p2 != null) {
  26. p2 = p2.next;
  27. }
  28. }
  29. return reverseList(dummy.next);
  30. }
  31. private ListNode reverseList(ListNode head) {
  32. if (head == null) {
  33. return head;
  34. }
  35. ListNode pre = null;
  36. ListNode cur = head;
  37. while (cur != null) {
  38. ListNode curNext = cur.next;
  39. cur.next = pre;
  40. pre = cur;
  41. cur = curNext;
  42. }
  43. return pre;
  44. }
  45. }

BM12. 单链表的排序

方法一:归并排序(推荐使用)
具体做法:
前面我们做合并两个有序链表不是使用归并思想吗?说明在链表中归并排序也不是不可能使用,合并阶段可以参照前面这道题,两个链表逐渐取最小的元素就可以了,但是划分阶段呢?
常规数组的归并排序,是将数组从中间个元素开始划分,然后将划分后的子数组作为一个要排序的数组,再将排好序的两个子数组合并成一个完整的有序数组,因此采用的是递归。而链表中我们也可以用同样的方式,只需要找到中间个元素的前一个节点,将其断开,就可以将链表分成两个子链表,然后继续划分,直到最小,然后往上依次合并。

  • step 1:首先判断链表为空或者只有一个元素,直接就是有序的。
  • step 2:准备三个指针,快指针right每次走两步,慢指针mid每次走一步,前序指针left每次跟在mid前一个位置。三个指针遍历链表,当快指针到达链表尾部的时候,慢指针mid刚好走了链表的一半,正好是中间位置。
  • step 3:从left位置将链表断开,刚好分成两个子问题开始递归。

    • 终止条件: 当子链表划分到为空或者只剩一个节点时,不再继续划分,往上合并。
    • 返回值: 每次返回两个排好序且合并好的子链表。
    • 本级任务: 找到这个链表的中间节点,从前面断开,分为左右两个子链表,进入子问题排序。
  • step 4:将子问题得到的链表合并,参考合并两个有序链表

    1. public class Solution {
    2. public ListNode sortInList(ListNode head) {
    3. if (head == null || head.next == null) {
    4. return head;
    5. }
    6. ListNode left = head;
    7. ListNode mid = head.next;
    8. ListNode right = head.next.next;
    9. // 1. mid 走到链表中间,right走到null
    10. while (right != null && right.next != null) {
    11. left = left.next;
    12. mid = mid.next;
    13. right = right.next.next;
    14. }
    15. // 2. 断开链表
    16. left.next = null;
    17. // 3. 合并左右两个子链表
    18. return merge(sortInList(head), sortInList(mid));
    19. }
    20. private ListNode merge(ListNode head1, ListNode head2) {
    21. if (head1 == null) {
    22. return head2;
    23. }
    24. if (head2 == null) {
    25. return head1;
    26. }
    27. ListNode dummyHead = new ListNode(0);
    28. ListNode cur = dummyHead;
    29. while (head1 != null && head2 != null) {
    30. if (head1.val <= head2.val) {
    31. cur.next = head1;
    32. head1 = head1.next;
    33. } else {
    34. cur.next = head2;
    35. head2 = head2.next;
    36. }
    37. cur = cur.next;
    38. }
    39. if (head1 != null) {
    40. cur.next = head1;
    41. } else {
    42. cur.next = head2;
    43. }
    44. return dummyHead.next;
    45. }
    46. }

方法二:转化为数组排序(扩展思路)
具体做法:
链表最难受的就是不能按照下标访问,只能逐个遍历,那像排序中常规的快速排序、堆排序都不能用了,只能用依次遍历的冒泡排序、选择排序这些。但是这些复杂度的排序方法太费时间了,我们可以将其转化成数组后再排序。

  • step 1:遍历链表,将节点值加入数组。
  • step 2:Java或者C++内置的排序函数对数组进行排序。
  • step 3:依次遍历数组和链表,按照位置将链表中的节点值修改为排序后的数组值。 ```java import java.util.*;

// 方法2:转换成数组排序 public class Solution { public ListNode sortInList(ListNode head) { if (head == null) { return head; }

  1. List<Integer> list = new ArrayList<>();
  2. ListNode cur = head;
  3. while (cur != null) {
  4. list.add(cur.val);
  5. cur = cur.next;
  6. }
  7. Collections.sort(list);
  8. cur = head;
  9. for (int i = 0; i < list.size(); i++) {
  10. cur.val = list.get(i);
  11. cur = cur.next;
  12. }
  13. return head;
  14. }

}

  1. <a name="OSH2P"></a>
  2. #### BM13. [判断一个链表是否为回文结构](https://www.nowcoder.com/practice/3fed228444e740c8be66232ce8b87c2f?tpId=295&sfm=html&channel=nowcoder)
  3. **方法1:栈**<br />逆序访问,我们可以借助先进先出的栈:根据链表顺序入栈的元素,越在前面的就越在栈底,越在后面的就越在栈顶,因此后续从栈中弹出的元素,依次就是链表的逆序。也可以用ArrayList 随机访问数组这些类似的结构。
  4. - step 1:遍历链表,将链表元素依次加入栈中。
  5. - step 2:依次从栈中弹出元素值,和链表的顺序遍历比较,如果都是一一比较相同的值,那正好就是回文,否则就不是。
  6. ```java
  7. import java.util.*;
  8. public class Solution {
  9. // 方法1:栈
  10. public boolean isPail(ListNode head) {
  11. ListNode cur = head;
  12. Deque<Integer> stack = new ArrayDeque<>();
  13. while (cur != null) {
  14. stack.push(cur.val);
  15. cur = cur.next;
  16. }
  17. cur = head;
  18. while (!stack.isEmpty()) {
  19. // 比较是否相同
  20. if (cur.val != stack.pop()) {
  21. return false;
  22. }
  23. cur = cur.next;
  24. }
  25. return true;
  26. }
  27. }

方法2:双指针中点反转法

  • step 1:慢指针每次走一个节点,快指针每次走两个节点,快指针到达链表尾的时候,慢指针刚好到了链表中点,链表结点个数为奇数时,慢指针刚好到中间的那个结点;个数为偶数时,慢指针会到右半段的开头位置。
  • step 2:从中点的位置,开始往后将后半段链表反转。
  • step 3:按照方法四的思路,左右双指针,左指针从链表头往后遍历,右指针从链表尾往反转后的前遍历,依次比较遇到的值。
  1. public class Solution {
  2. // 方法2:双指针反转链表法
  3. public boolean isPail(ListNode head) {
  4. if (head == null || head.next == null) {
  5. return true;
  6. }
  7. ListNode slow = head;
  8. ListNode fast = head;
  9. while (fast != null && fast.next != null) {
  10. slow = slow.next;
  11. fast = fast.next.next;
  12. }
  13. slow = reverse(slow);
  14. fast = head;
  15. while (slow != null) {
  16. if (slow.val != fast.val) {
  17. return false;
  18. }
  19. slow = slow.next;
  20. fast = fast.next;
  21. }
  22. return true;
  23. }
  24. private ListNode reverse(ListNode head) {
  25. if (head == null) {
  26. return null;
  27. }
  28. ListNode pre = null;
  29. ListNode cur = head;
  30. while (cur != null) {
  31. ListNode pNext = cur.next;
  32. cur.next = pre;
  33. pre = cur;
  34. cur = pNext;
  35. }
  36. return pre;
  37. }
  38. }

BM14. 链表的奇偶重排

方法1:双指针。

  • step 1:判断空链表的情况,如果链表为空,不用重排。
  • step 2:使用双指针odd和even分别遍历奇数节点和偶数节点,并给偶数节点链表一个头。
  • step 3:上述过程,每次遍历两个节点,且even在后面,因此每轮循环用even检查后两个元素是否为NULL,如果不为再进入循环进行上述连接过程。
  • step 4:将偶数节点头接在奇数最后一个节点后,再返回头部。
  1. public class Solution {
  2. public ListNode oddEvenList(ListNode head) {
  3. if (head == null) {
  4. return head;
  5. }
  6. // 1. 双指针,分别指向奇偶位置
  7. ListNode odd = head;
  8. ListNode even = head.next;
  9. ListNode evenHead = head.next;
  10. // 2. 依次修改链接,并移动指针
  11. while (even != null && even.next != null) {
  12. // odd 连接偶数的下一个,再移动
  13. odd.next = even.next;
  14. odd = odd.next;
  15. even.next = odd.next;
  16. even = even.next;
  17. }
  18. // 3. 偶数整体连接在奇数后面
  19. odd.next = evenHead;
  20. return head;
  21. }
  22. }

BM15. 删除有序链表中重复的元素-I

方法:遍历删除(推荐使用)
具体做法:
既然相同的元素只留下一个,我们留下哪一个最好呢?当然是遇到的第一个元素了!因为第一个元素直接就与前面的链表节点连接好了,前面就不用管了,只需要跳过后面重复的元素,连接第一个不重复的元素就可以了,在链表中连接后面的元素总比连接前面的元素更方便嘛,因为不能逆序访问。

  • step 1:判断链表是否为空链表,空链表不处理直接返回。
  • step 2:使用一个指针遍历链表,如果指针当前节点与下一个节点的值相同,我们就跳过下一个节点,当前节点直接连接下个节点的后一位。
  • step 3:如果当前节点与下一个节点值不同,移动指针,继续往后遍历。
  • step 4:循环过程中每次用到了两个节点值,要检查连续两个节点是否为空。
  1. public class Solution {
  2. public ListNode deleteDuplicates(ListNode head) {
  3. if (head == null) {
  4. return head;
  5. }
  6. ListNode cur = head;
  7. while (cur != null && cur.next != null) {
  8. if (cur.val == cur.next.val) {
  9. cur.next = cur.next.next;
  10. } else {
  11. cur = cur.next;
  12. }
  13. }
  14. return head;
  15. }
  16. }

BM16. 删除有序链表中重复的元素-II

给出一个升序排序的链表,删除链表中的所有重复出现的元素,只保留原链表中只出现一次的元素。

方法一:直接比较删除(推荐使用)
具体做法:
这是一个升序链表,重复的节点都连在一起,我们就可以很轻易地比较到重复的节点,然后去删除。

  • step 1:给链表前加上表头,方便可能的话删除第一个节点。
  • step 2:遍历链表,每次比较相邻两个节点,如果遇到了两个相邻结点相同,则新开内循环将这一段所有的相同都遍历过去。
  • step 3:在step 2中这一连串相同的节点前的节点直接连上后续第一个不相同值的节点。
  • step 4:返回时去掉添加的表头。
  1. // 1. 直接比较删除
  2. public class Solution {
  3. public ListNode deleteDuplicates(ListNode head) {
  4. if (head == null) {
  5. return null;
  6. }
  7. ListNode dummy = new ListNode(0);
  8. dummy.next = head;
  9. ListNode cur = dummy;
  10. while (cur.next != null && cur.next.next != null) {
  11. if (cur.next.val == cur.next.next.val) {
  12. int aimVal = cur.next.val;
  13. while (cur.next != null && cur.next.val == aimVal) {
  14. cur.next = cur.next.next;
  15. }
  16. } else {
  17. cur = cur.next;
  18. }
  19. }
  20. return dummy.next;
  21. }
  22. }

方法二:哈希表(扩展思路)
具体做法:
这道题幸运的是链表有序,我们可以直接与旁边的元素比较,然后删除重复。那我们扩展一点,万一遇到的链表无序呢?我们这里给出一种通用的解法,有序无序都可以使用,即使用哈希表来统计是否重复。

  • step 1:遍历一次链表用哈希表记录每个结点值出现的次数。
  • step 2:在链表前加一个结点值为0的表头,方便可能的话删除表头元素。
  • step 3:再次遍历该链表,对于每个结点值检查哈希表中的计数,只留下计数为1的,其他情况都删除。
  • step 4:返回时去掉增加的表头。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:哈希表
  4. public ListNode deleteDuplicates(ListNode head) {
  5. if (head == null) {
  6. return null;
  7. }
  8. // 1. 创建哈希表
  9. Map<Integer, Integer> map = new HashMap<>();
  10. ListNode cur = head;
  11. // 2. 遍历链表,在哈希表中存储结点的值、和该值出现的次数
  12. while (cur != null) {
  13. if (map.containsKey(cur.val)) {
  14. int count = map.get(cur.val);
  15. map.put(cur.val, count + 1);
  16. } else {
  17. map.put(cur.val, 1);
  18. }
  19. cur = cur.next;
  20. }
  21. // 3. 再次遍历链表,只留下次数为1
  22. ListNode dummyHead = new ListNode(0);
  23. dummyHead.next = head;
  24. cur = dummyHead;
  25. while (cur.next != null) {
  26. if (map.get(cur.next.val) != 1) {
  27. cur.next = cur.next.next;
  28. } else {
  29. cur = cur.next;
  30. }
  31. }
  32. return dummyHead.next;
  33. }
  34. }

专题2:二分查找和排序

BM17. 二分查找-I

既然数组是升序且无重复的,可以使用二分法。

  • step 1:从数组首尾开始,每次取中点值。
  • step 2:如果中间值等于目标即找到了,可返回下标,如果中点值大于目标,说明中点以后的都大于目标,因此目标在中点左半区间,如果中点值小于目标,则相反。
  • step 3:根据比较进入对应的区间,直到区间左右端相遇,意味着没有找到。

二分查找,求解mid 的时候,可以用(right - left) / 2,或者是>> 1 右移运算符。

  1. import java.util.*;
  2. public class Solution {
  3. public int search(int[] nums, int target) {
  4. int left = 0;
  5. int right = nums.length - 1;
  6. while (left <= right) {
  7. int mid = left + ((right - left) >> 1);
  8. if (nums[mid] < target) {
  9. left = mid + 1;
  10. } else if (nums[mid] > target) {
  11. right = mid - 1;
  12. } else {
  13. return mid;
  14. }
  15. }
  16. return -1;
  17. }
  18. }

BM18. 二维数组中的查找

方法1:二分法。
既然矩阵里面的元素是有序且无重复的,我们可以好好利用一下。首先看四个角,左上与右下必定为最小值与最大值,而左下与右上就有规律了:左下元素大于它上方的元素,小于它右方的元素,右上元素与之相反。我们可以在查找时使用二分法

  • step 1:首先获取矩阵的两个边长,判断特殊情况。
  • step 2:首先以左下角为起点,若是它小于目标元素,则往右移动去找大的,若是他大于目标元素,则往上移动去找小的。
  • step 3:若是移动到了矩阵边界也没找到,说明矩阵中不存在目标值。

    1. public class Solution {
    2. public boolean Find(int target, int[][] array) {
    3. if (array == null || array.length == 0) {
    4. return false;
    5. }
    6. int row = array.length - 1;
    7. int col = 0;
    8. while (row >= 0 && col < array[0].length) {
    9. if (array[row][col] < target) {
    10. col++;
    11. } else if (array[row][col] > target) {
    12. row--;
    13. } else {
    14. return true;
    15. }
    16. }
    17. return false;
    18. }
    19. }

BM19. 寻找峰值

方法1:二分查找
具体做法:
因为数组边界看成最小值,因此只要不断地往高处走,一定会有波峰,最大值两边一定比它小。那可以考虑二分查找

  • step 1:二分查找首先从数组首尾开始,每次取中间值,直到首尾相遇。
  • step 2:如果中间值的元素大于它右边的元素,说明往右是向下,我们不一定会遇到波峰,但是那就往左收缩区间。
  • step 3:如果中间值大于右边的元素,说明此时往右是向上,向上一定能有波峰,那我们往右收缩区间。
  • step 4:最后区间收尾相遇的点一定就是波峰。

数组边界看成最小值,因此只要不断地往高处走,一定会有波峰.

  1. public class Solution {
  2. public int findPeakElement(int[] nums) {
  3. int left = 0;
  4. int right = nums.length - 1;
  5. while (left < right) {
  6. int mid = left + (right - left) / 2;
  7. if (nums[mid] > nums[mid + 1]) {
  8. right = mid;
  9. } else {
  10. // 往上走,一定能找到波峰
  11. left = mid + 1;
  12. }
  13. }
  14. return right;
  15. }
  16. }

BM20. 数组中的逆序对

方法一:归并排序(推荐使用)
具体做法:
因为我们在归并排序过程中会将数组划分成最小为2个元素的子数组,然后依次比较子数组的长度,这里我们也可以用相同的方法统计逆序对。我们主要有三个阶段:

  • step 1: 划分阶段:将待划分区间从中点划分成两部分;
  • step 2: 排序阶段:使用归并排序递归地处理子序列,同时统计逆序对;
  • step 3: 合并阶段:将排好序的子序列合并,同时累加逆序对。

因为在归并排序中,右边大于左边时,它大于了左边的所有子序列,基于这个性质我们可以不用每次加1来统计,减少运算次数。

归并排序的典型应用。实际上是一个分治问题,不断将数组一分为二,直到数组中只有两个元素,然后对这一对进行merge,此时就是统计逆序对的最佳时机。

牛客网面试必刷TOP101 - 图10

  1. // 方法1:归并排序
  2. public class Solution {
  3. public int InversePairs(int[] array) {
  4. if (array.length < 2) {
  5. return 0;
  6. }
  7. int count = divide(array, 0, array.length - 1);
  8. return count % 1000000007;
  9. }
  10. // 1. 分治
  11. public int divide(int[] arr, int left, int right) {
  12. if (left == right) {
  13. return 0;
  14. }
  15. int mid = left + ((right - left) >> 1);
  16. // 得到左右两半边、和合并以后的逆序对个数
  17. int leftCount = divide(arr, left, mid);
  18. int rightCount = divide(arr, mid + 1, right);
  19. int mergeCount = merge(arr, left, mid, right);
  20. return leftCount + rightCount + mergeCount;
  21. }
  22. // 2. 合并
  23. public static int merge(int[] arr, int left, int mid, int right) {
  24. if (left > right) {
  25. return 0;
  26. }
  27. // 1. 辅助数组,依次比较两个有序数组,把元素按顺序填进去
  28. int[] help = new int[right - left + 1];
  29. int count = 0;
  30. // 2. 三个指针,分别指向三个数组
  31. int p1 = left;
  32. int p2 = mid + 1;
  33. int index = 0;
  34. // 3. 对两个有序数组进行合并,并统计逆序对
  35. while (p1 <= mid && p2 <= right) {
  36. if (arr[p1] <= arr[p2]) {
  37. help[index++] = arr[p1++];
  38. } else {
  39. help[index++] = arr[p2++];
  40. // 计算左边数组有几个大于右边的
  41. count += mid - p1 + 1;
  42. }
  43. }
  44. // 4. 添加剩余的部分
  45. while (p1 <= mid) {
  46. help[index++] = arr[p1++];
  47. }
  48. while (p2 <= right) {
  49. help[index++] = arr[p2++];
  50. }
  51. // 5. 把排好序的数组覆盖到原数组
  52. for (int i = 0; i < help.length; i++) {
  53. arr[left + i] = help[i];
  54. }
  55. // 6. 返回
  56. return count % 1000000007;
  57. }
  58. }

BM21. 旋转数组的最小数字

题目的主要信息:
有一个长度为 n 的非降序数组,把一个数组最开始的若干个元素“平移”到数组的末尾,变成一个旋转数组
找到这个旋转数组的最小值

方法一:二分法(推荐使用)
具体做法:
因为旋转数组将原本有序的数组分成了两部分有序的数组,因为在原始有序数组中,最小的元素一定是在首位,旋转后无序的点就是最小的数字。我们可以将旋转前的前半段命名为A,旋转后的前半段命名为B,旋转数组即将AB变成了BA。
可以依旧利用二分的思想,分情况讨论:

  • step 1:双指针指向旋转后数组的首尾,作为区间端点。
  • step 2:若是区间中点值大于区间右界值,则最小的数字一定在中点右边。
  • step 3:若是区间中点值等于区间右界值,则是不容易分辨最小数字在哪半个区间,比如[1,1,1,0,1],应该逐个缩减右界;
  • step 4:若是区间中点值小于区间右界值,则最小的数字一定在中点左边。
  • step 5:通过调整区间最后即可锁定最小值所在。

解题思路:排序数组首先考虑二分法。旋转数组将原本有序数组的前面部分截取,挪到了后面,即前面部分的元素都大于等于后面最大的。设置三个指针,left、mid、right,当arr[mid] > arr[right],说明mid 位置的元素本身就在这儿,没有截取挪动过,此时就可以缩小排查范围,旋转点在[mid+1, right] 区间内。

  1. public class Solution {
  2. public int minNumberInRotateArray(int[] array) {
  3. if (array == null || array.length == 0) {
  4. return -1;
  5. }
  6. int left = 0;
  7. int right = array.length - 1;
  8. while (left < right) {
  9. int mid = left + (right - left) / 2;
  10. if (array[mid] < array[right]) {
  11. right = mid;
  12. } else if (array[mid] > array[right]) {
  13. // 说明最小数字肯定在mid 的右边
  14. left = mid + 1;
  15. } else {
  16. // 无法判断,right 范围缩小1
  17. right--;
  18. }
  19. }
  20. return array[left];
  21. }
  22. }

BM22. 比较版本号

方法一:遍历直接截取(推荐使用)
具体做法:
既然是比较两个字符串每个点之间的数字是否相同,就直接遍历字符串比较,但是数字前导零不便于我们比较,因为我们不知道后面会出现多少前导零,因此应该将点之间的部分转化为数字再比较才方便。

  • step 1:利用两个指针表示字符串的下标,分别遍历两个字符串。
  • step 2:每次截取点之前的数字字符组成数字,即在遇到一个点之前,直接取数字,加在前面数字乘10的后面。(因为int会溢出,这里采用long记录数字)
  • step 3:然后比较两个数字大小,根据大小关系返回1或者-1,如果全部比较完都无法比较出大小关系,则返回0.
  1. public class Solution {
  2. public int compare(String version1, String version2) {
  3. int n1 = version1.length();
  4. int n2 = version2.length();
  5. int i = 0;
  6. int j = 0;
  7. while (i < n1 || j < n2) {
  8. // 1. 以点为分隔,计算出两个版本号
  9. long num1 = 0;
  10. while (i < n1 && version1.charAt(i) != '.') {
  11. num1 = num1 * 10 + (version1.charAt(i) - '0');
  12. i++;
  13. }
  14. i++; // 跳过符号.
  15. long num2 = 0;
  16. while (j < n2 && version2.charAt(j) != '.') {
  17. num2 = num2 * 10 + (version2.charAt(j) - '0');
  18. j++;
  19. }
  20. j++;
  21. // 2. 比较两个版本号大小
  22. if (num1 > num2) {
  23. return 1;
  24. }
  25. if (num1 < num2) {
  26. return -1;
  27. }
  28. }
  29. return 0;
  30. }
  31. }

专题3:二叉树

BM23. 二叉树的前序遍历

方法1:递归。
具体做法:
什么是二叉树的前序遍历?简单来说就是“根左右”,展开来说就是对于一颗二叉树优先访问其根节点,然后访问它的左子树,等左子树全部访问完了再访问其右子树,而对于子树也按照之前的访问方式,直到到达叶子节点。
从上述前序遍历的解释中我们不难发现,它存在递归的子问题:每次访问一个节点之后,它的左子树是一个要前序遍历的子问题,它的右子树同样是一个要前序遍历的子问题。那我们可以用递归处理:

  • 终止条件: 当子问题到达叶子节点后,后一个不管左右都是空,因此遇到空节点就返回。
  • 返回值: 每次处理完子问题后,就是将子问题访问过的元素返回,依次存入了数组中。
  • 本级任务: 每个子问题优先访问这棵子树的根节点,然后递归进入左子树和右子树。

因此处理的时候,过程就是:

  • step 1:准备数组用来记录遍历到的节点值,Java可以用List,C++可以直接用vector。
  • step 2:从根节点开始进入递归,遇到空节点就返回,否则将该节点值加入数组。
  • step 3:依次进入左右子树进行递归。

要多想想二叉树的遍历序,其实每个存在的结点都会被访问三次,空结点会访问一次,而二叉树的三种遍历顺序,其实是对根节点处理时机不同而形成的。
在当前方法中,root 是本级的根节点,先处理它本身,然后再去它的左孩子结点,处理完会返回当之前执行的位置,再去访问它的右孩子,所以宏观上来看,就是根—左—右,这样的处理顺序。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:递归
  4. public int[] preorderTraversal(TreeNode root) {
  5. List<Integer> list = new ArrayList<>();
  6. // 递归,结果存储在list中
  7. preorder(root, list);
  8. // 手动把list 转数组
  9. int[] res = new int[list.size()];
  10. for (int i = 0; i < list.size(); i++) {
  11. res[i] = list.get(i);
  12. }
  13. return res;
  14. }
  15. private void preorder(TreeNode root, List<Integer> list) {
  16. // 1. 递归的终止条件
  17. if (root == null) {
  18. return;
  19. }
  20. // 2. 递归的本级处理:把本级的根节点添加到list
  21. list.add(root.val);
  22. // 3. 调用递归,处理子问题
  23. preorder(root.left, list);
  24. preorder(root.right, list);
  25. }
  26. }

方法2:非递归
具体做法:
我们都知道递归,是不断调用自己,计算内部实现递归的时候,是将之前的父问题存储在栈中,先去计算子问题,等到子问题返回给父问题后再从栈中将父问题弹出,继续运算父问题。因此能够递归解决的问题,我们似乎也可以用栈来试一试。
根据前序遍历“根左右”的顺序,首先要遍历肯定是根节点,然后先遍历左子树,再遍历右子树。递归中我们是先进入左子节点作为子问题,等左子树结束,再进入右子节点作为子问题,这里我们同样可以这样做,它无非相当于把父问题挂进了栈中,等子问题结束再从栈中弹出父问题,从父问题进入右子树,我们这里已经访问过了父问题,不妨直接将右子节点挂入栈中,然后下一轮先访问左子节点。要怎么优先访问左子节点呢?同样将它挂入栈中,依据栈的后进先出原则,下一轮循环必然它要先出来,如此循环,原先父问题的右子节点被不断推入栈深处,只有左子树全部访问完毕,才会弹出继续访问。

  • step 1:优先判断树是否为空,空树不遍历。
  • step 2:准备辅助栈,首先记录根节点。
  • step 3:每次从栈中弹出一个元素,进行访问,然后验证该节点的左右子节点是否存在,存的话的加入栈中,优先加入右节点。

注意,这里返回new int[0],不知道是该题目的要求,还是Java 本身的一种机制。Java 的Stack 类已经废弃了,用ArrayDeque 或 LinkedList 类代替。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:非递归
  4. public int[] preorderTraversal(TreeNode root) {
  5. if (root == null) {
  6. return new int[0]; // 返回一个长度为0的数组
  7. }
  8. // 1. 准备一个栈结构,根节点入栈
  9. List<Integer> list = new ArrayList<>();
  10. Deque<TreeNode> stack = new ArrayDeque<>();
  11. stack.push(root);
  12. // 2. 栈不为空时,弹出栈顶结点,把该结点的右孩子、左孩子压入栈中
  13. while (!stack.isEmpty()) {
  14. TreeNode node = stack.pop();
  15. list.add(node.val);
  16. if (node.right != null) {
  17. stack.push(node.right);
  18. }
  19. if (node.left != null) {
  20. stack.push(node.left);
  21. }
  22. }
  23. int[] res = new int[list.size()];
  24. for (int i = 0; i < list.size(); i++) {
  25. res[i] = list.get(i);
  26. }
  27. return res;
  28. }
  29. }

BM24. 二叉树的中序遍历

方法1:递归。
具体做法:
什么是二叉树的中序遍历,简单来说就是“左根右”,展开来说就是对于一棵二叉树,我们优先访问它的左子树,等到左子树全部节点都访问完毕,再访问根节点,最后访问右子树。同时访问子树的时候,顺序也与访问整棵树相同。
从上述对于中序遍历的解释中,我们不难发现它存在递归的子问题,根节点的左右子树访问方式与原本的树相同,可以看成一颗树进行中序遍历,因此可以用递归处理:

  • 终止条件: 当子问题到达叶子节点后,后一个不管左右都是空,因此遇到空节点就返回。
  • 返回值: 每次处理完子问题后,就是将子问题访问过的元素返回,依次存入了数组中。
  • 本级任务: 每个子问题优先访问左子树的子问题,等到左子树的结果返回后,再访问自己的根节点,然后进入右子树。

因此处理的时候,过程就是:

  • step 1:准备数组用来记录遍历到的节点值,Java可以用List,C++可以直接用vector。
  • step 2:从根节点开始进入递归,遇到空节点就返回,否则优先进入左子树进行递归访问。
  • step 3:左子树访问完毕再回到根节点访问。
  • step 4:最后进入根节点的右子树进行递归。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:递归版
  4. public int[] inorderTraversal(TreeNode root) {
  5. ArrayList<Integer> list = new ArrayList<>();
  6. inorder(root, list);
  7. int[] res = new int[list.size()];
  8. for (int i = 0; i < list.size(); i++) {
  9. res[i] = list.get(i);
  10. }
  11. return res;
  12. }
  13. private void inorder(TreeNode root, ArrayList<Integer> list) {
  14. if (root == null) {
  15. return;
  16. }
  17. inorder(root.left, list);
  18. list.add(root.val);
  19. inorder(root.right, list);
  20. }
  21. }

方法2:非递归版本
前序遍历类似,我们利用栈来代替递归。如果一棵二叉树,对于每个根节点都优先访问左子树,那结果是什么?从根节点开始不断往左,第一个被访问的肯定是最左边的节点,然后访问该节点的右子树,最后向上回到父问题。因为每次访问最左的元素不止对一整棵二叉树成立,而是对所有子问题都成立,因此循环的时候自然最开始都是遍历到最左,然后访问,然后再进入右子树,我们可以用栈来实现回归父问题。

  • step 1:优先判断树是否为空,空树不遍历。
  • step 2:准备辅助栈,当二叉树节点为空了且栈中没有节点了,我们就停止访问。
  • step 3:从根节点开始,每次优先进入每棵的子树的最左边一个节点,我们将其不断加入栈中,用来保存父问题。
  • step 4:到达最左后,可以开始访问,如果它还有右节点,则将右边也加入栈中,之后右子树的访问也是优先到最左。
  1. import java.util.*;
  2. // 方法2:非递归版
  3. public class Solution {
  4. public int[] inorderTraversal(TreeNode root) {
  5. if (root == null) {
  6. return new int[0];
  7. }
  8. List<Integer> list = new ArrayList<>();
  9. Deque<TreeNode> stack = new ArrayDeque<>();
  10. while (root != null || !stack.isEmpty()) {
  11. while (root != null) {
  12. stack.push(root);
  13. root = root.left;
  14. }
  15. TreeNode node = stack.pop();
  16. list.add(node.val);
  17. if (node.right != null) {
  18. root = node.right;
  19. }
  20. }
  21. int[] res = new int[list.size()];
  22. for (int i = 0; i < list.size(); i++) {
  23. res[i] = list.get(i);
  24. }
  25. return res;
  26. }
  27. }

BM25. 二叉树的后序遍历

方法1:递归。
具体做法:
什么是二叉树的后续遍历,简单来说就是“左右根”,展开来说就是优先访问根节点的左子树的全部节点,然后再访问根节点的右子树的全部节点,最后再访问根节点。对于每棵子树的访问也按照这个逻辑,因此叫做“左右根”的顺序。
从上述后序遍历的解释中我们不难发现,它存在递归的子问题:对每个子树的访问,可以看成对于上一级树的子问题。那我们可以用递归处理:

  • 终止条件: 当子问题到达叶子节点后,后一个不管左右都是空,因此遇到空节点就返回。
  • 返回值: 每次处理完子问题后,就是将子问题访问过的元素返回,依次存入了数组中。
  • 本级任务: 对于每个子问题,优先进入左子树的子问题,访问完了再进入右子树的子问题,最后回到父问题访问根节点。

因此处理的时候,过程就是:

  • step 1:准备数组用来记录遍历到的节点值,Java可以用List,C++可以直接用vector。
  • step 2:从根节点开始进入递归,遇到空节点就返回,否则优先进入左子树进行递归访问。
  • step 3:左子树访问完毕再进入根节点的右子树递归访问。
  • step 4:最后回到根节点,访问该节点。 ```java import java.util.*;

public class Solution { public int[] postorderTraversal(TreeNode root) { ArrayList list = new ArrayList<>();

  1. postorder(root, list);
  2. int[] res = new int[list.size()];
  3. for (int i = 0; i < list.size(); i++) {
  4. res[i] = list.get(i);
  5. }
  6. return res;
  7. }
  8. private void postorder(TreeNode root, ArrayList<Integer> list) {
  9. if (root == null) {
  10. return;
  11. }
  12. postorder(root.left, list);
  13. postorder(root.right, list);
  14. list.add(root.val);
  15. }

}

  1. **方法2:非递归--一个栈**<br />既然二叉树的前序遍历和中序遍历都可以使用栈来代替递归,那后序遍历是否也可以呢?答案是可以的,但是会复杂一点。<br />根据后序遍历“左右中”的顺序,那么后序遍历也与[中序遍历](https://blog.nowcoder.net/n/87532f1cc7824296b1635f1e13a02a88)类似,要先找到每棵子树的最左端节点,然后我们就要访问该节点了嘛?不不不,如果它还有一个右节点呢?根据“左右根”的原则,我们还要访问它的右子树,最后才能访问当前的根节点,要怎么访问根节点呢?<br />我们都知道从栈中弹出根节点,那么它的左节点一定已经被访问过了,因为左节点是子问题,访问完了才回到父问题,所以我们还必须要确保右边也已经被访问过了。如果右边为空,那肯定不用去了,如果右边不为空,那我们肯定优先进入右边,此时再将根节点加入栈中,等待右边的子树结束。不过,当右边被访问了,又回到了根,我们的根怎么知道右边被访问了呢?用一个前序指针**pre **标记一下,每个根节点只对它的右节点需要标记,而每个右节点自己本身就是一个根节点,因此每次访问根节点的时候,我们可以用**pre **标记该节点,回到上一个根节点时,检查一下,如果pre 确实是它的右子节点,那正好,刚刚已经访问过了,现在可以安心访问这个根节点了。
  2. 算法流程:
  3. - step 1:开辟一个辅助栈,用于记录要访问的子节点,创建一个前序指针pre
  4. - step 2:从根节点开始,每次优先进入每棵子树的最左边一个节点,我们将其不断加入栈中,用来保存父问题。
  5. - step 3:弹出一个栈顶元素,看成该子树的根,判断这个根结点有没有右孩子或者有没有被访问过,如果没有右孩子结点或者被访问过了,那就访问这个根结点本身,并把pre 赋值为这个根节点。
  6. - step 4:如果没有被访问,那这个根必须入栈,进入右子树继续访问,只有右子树结束了回到这里才能继续访问根。
  7. ```java
  8. import java.util.*;
  9. public class Solution {
  10. // 方法2:非递归,一个栈
  11. // 挺难的,代码大概懂了,但写不出来
  12. public int[] postorderTraversal(TreeNode root) {
  13. List<Integer> list = new ArrayList<>();
  14. Deque<TreeNode> stack = new ArrayDeque<>();
  15. TreeNode pre = null;
  16. while (root != null || !stack.isEmpty()) {
  17. // 1. 处理左孩子
  18. while (root != null) {
  19. stack.push(root);
  20. root = root.left;
  21. }
  22. // 2. 处理当前根节点
  23. TreeNode cur = stack.pop();
  24. // 右孩子为空,或者右孩子已经访问过了
  25. if (cur.right == null || cur.right == pre) {
  26. list.add(cur.val);
  27. pre = cur;
  28. } else {
  29. // 3. 处理当前结点的右孩子
  30. stack.push(cur);
  31. root = cur.right;
  32. }
  33. }
  34. int[] res = new int[list.size()];
  35. for (int i = 0; i < list.size(); i++) {
  36. res[i] = list.get(i);
  37. }
  38. return res;
  39. }
  40. }

方法3:非递归—两个栈
按照类似先序遍历的流程遍历,不过是先把当前根节点的左孩子压栈,遍历顺序就是“头右左”,再用另一个栈存储结果,依次出栈就得到“左右头”的顺序了,具体看代码。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法3:非递归,两个栈
  4. public int[] postorderTraversal(TreeNode root) {
  5. if (root == null) {
  6. return new int[0];
  7. }
  8. Deque<TreeNode> stack1 = new ArrayDeque<>();
  9. Deque<TreeNode> stack2 = new ArrayDeque<>();
  10. stack1.push(root);
  11. while (!stack1.isEmpty()) {
  12. TreeNode node = stack1.pop();
  13. stack2.push(node); // 把stack1 每次弹出的元素再压入stack2
  14. if (node.left != null) {
  15. stack1.push(node.left);
  16. }
  17. if (node.right != null) {
  18. stack1.push(node.right);
  19. }
  20. }
  21. int[] arr = new int[stack2.size()];
  22. int index = 0;
  23. while (!stack2.isEmpty()) {
  24. arr[index++] = stack2.pop().val;
  25. }
  26. return arr;
  27. }
  28. }

BM26. 求二叉树的层序遍历

方法1:队列
二叉树的层次遍历就是按照从上到下每行,然后每行中从左到右依次遍历,得到的二叉树的元素值。对于层次遍历,我们通常会使用队列来辅助:因为队列是一种先进先出的数据结构,我们依照它的性质,如果从左到右访问完一行节点,并在访问的时候依次把它们的子节点加入队列,那么它们的子节点也是从左到右的次序,且排在本行节点的后面,因此队列中出现的顺序正好是层次遍历。
那我们解决这道题目的思路就有了:

  • step 1:首先判断二叉树是否为空,空树没有遍历结果。
  • step 2:建立辅助队列,根节点首先进入队列。不管层次怎么访问,根节点一定是第一个,那它肯定排在队伍的最前面。
  • step 3:每次进入一层,统计队列中元素的个数。因为每当访问完一层,下一层作为这一层的子节点,一定都加入队列,而再下一层还没有加入,因此此时队列中的元素个数就是这一层的元素个数。
  • step 4:每次遍历这一层这么多的节点数,将其依次从队列中弹出,然后加入这一行的一维数组中,如果它们有子节点,依次加入队列排队等待访问。
  • step 5:访问完这一层的元素后,将这个一维数组加入二维数组中,再访问下一层。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:队列
  4. public ArrayList<ArrayList<Integer>> levelOrder(TreeNode root) {
  5. ArrayList<ArrayList<Integer>> res = new ArrayList<>();
  6. if (root == null) {
  7. return res;
  8. }
  9. Queue<TreeNode> queue = new ArrayDeque<>();
  10. queue.add(root);
  11. while (!queue.isEmpty()) {
  12. ArrayList<Integer> layer = new ArrayList<>();
  13. int size = queue.size();
  14. for (int i = 0; i < size; i++) {
  15. TreeNode node = queue.poll();
  16. layer.add(node.val);
  17. if (node.left != null) {
  18. queue.add(node.left);
  19. }
  20. if (node.right != null) {
  21. queue.add(node.right);
  22. }
  23. }
  24. res.add(layer);
  25. }
  26. return res;
  27. }
  28. }

方法二:递归(扩展思路)
具体做法:
既然二叉树的前序、中序、后序遍历都可以轻松用递归实现,树型结构本来就是递归喜欢的形式,那我们的层次遍历是不是也可以尝试用递归来试试呢?按行遍历的关键是每一行的深度对应了它输出在二维数组中的深度,即深度可以与二维数组的下标对应,那我们递归的时候记录深度就可以了啊。

  • step 1:首先判断二叉树是否为空,空树没有遍历结果。
  • step 2:使用递归进行层次遍历输出,每次递归记录当前二叉树的深度,每当遍历到一个节点,如果为空直接返回。
  • step 3:如果遍历的节点不为空,输出二维数组中一维数组的个数(即代表了输出的行数)小于深度,说明这个节点应该是新的一层,我们在二维数组中增加一个一维数组,然后再加入结点。
  • step 4:如果不是step 3的情况说明这个深度我们已经有了数组,直接根据深度作为下标取出数组,将元素加在最后就可以了。
  • step 5:处理完这个节点,再依次递归进入左右节点,同时深度增加。因为我们进入递归的时候是先左后右,那么遍历的时候也是先左后右,正好是层次遍历的顺序。

再来看看这个递归过程中三段式:

  • 终止条件: 遍历到了空节点,就不再继续,返回。
  • 返回值: 将加入的输出数组中的结果往上返回。
  • 本级任务: 处理按照上述思路处理非空节点,并进入该节点的子节点作为子问题。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:递归
  4. public ArrayList<ArrayList<Integer>> levelOrder(TreeNode root) {
  5. ArrayList<ArrayList<Integer>> res = new ArrayList<>();
  6. if (root == null) {
  7. return res;
  8. }
  9. traverse(root, res, 1);
  10. return res;
  11. }
  12. private void traverse(TreeNode root, ArrayList<ArrayList<Integer>> res, int depth) {
  13. // 1. 递归终止条件
  14. if (root == null) {
  15. return;
  16. }
  17. // 2. 本级处理。
  18. // 2.1 会在结果集中为二叉树的每一层创建一个数组
  19. if (res.size() < depth) {
  20. ArrayList<Integer> layer = new ArrayList<>();
  21. layer.add(root.val);
  22. res.add(layer);
  23. } else {
  24. // 2.2 从结果集中获得当前这层的数组,往里面添加它的平行结点
  25. ArrayList<Integer> layer = res.get(depth - 1);
  26. layer.add(root.val);
  27. }
  28. // 3. 递归。深度加一
  29. traverse(root.left, res, depth + 1);
  30. traverse(root.right, res, depth + 1);
  31. }
  32. }

BM27. 按之字形顺序打印二叉树

方法1:队列加反转方向
具体做法:
按照层次遍历按层打印二叉树的方式,每层分开打印,然后对于每一层利用flag 标记,第一层为false,之后每到一层取反一次,如果该层的flag 为true,则记录的数组整个反转即可。
但是难点在于如何每层分开存储,从哪里知晓分开的时机?在层次遍历的时候,我们通常会借助队列(queue),事实上,队列中的值大有玄机,让我们一起来看看:当根节点进入队列时,队列长度为1,第一层结点数也为1;若是根节点有两个子节点,push进队列后,队列长度为2,第二层结点数也为2;若是根节点一个子节点,push进队列后,队列长度为为1,第二层结点数也为1。由此,我们可知,每层的结点数等于进入该层时队列长度,因为刚进入该层时,这一层每个结点都会push进队列,而上一层的结点都出去了。

  • step 1:首先判断二叉树是否为空,空树没有打印结果。
  • step 2:建立辅助队列,根节点首先进入队列。不管层次怎么访问,根节点一定是第一个,那它肯定排在队伍的最前面,初始化flag 变量。
  • step 3:每次进入一层,统计队列中元素的个数,更改flag 变量的值。因为每当访问完一层,下一层作为这一层的子节点,一定都加入队列,而再下一层还没有加入,因此此时队列中的元素个数就是这一层的元素个数。
  • step 4:每次遍历这一层这么多的节点数,将其依次从队列中弹出,然后加入这一行的一维数组中,如果它们有子节点,依次加入队列排队等待访问。
  • step 5:访问完这一层的元素后,根据flag 变量决定将这个一维数组直接加入二维数组中还是反转后再加入,然后再访问下一层。

  • ```java import java.util.*;

public class Solution { // 方法1:队列 public ArrayList> Print(TreeNode pRoot) { ArrayList> res = new ArrayList<>(); if (pRoot == null) { return res; }

  1. Queue<TreeNode> queue = new ArrayDeque<>();
  2. queue.add(pRoot);
  3. boolean flag = false;
  4. while (!queue.isEmpty()) {
  5. flag = !flag;
  6. int size = queue.size();
  7. ArrayList<Integer> layer = new ArrayList<>();
  8. for (int i = 0; i < size; i++) {
  9. TreeNode node = queue.poll();
  10. layer.add(node.val);
  11. if (node.left != null) {
  12. queue.add(node.left);
  13. }
  14. if (node.right != null) {
  15. queue.add(node.right);
  16. }
  17. }
  18. if (!flag) {
  19. Collections.reverse(layer);
  20. }
  21. res.add(layer);
  22. }
  23. return res;
  24. }

}

  1. **方法2:两个栈**<br />方法一用到了反转函数,反转我们能想到什么?肯定是先进后出的栈!我们可以利用两个栈遍历这棵二叉树,第一个栈s1从根结点开始记录第一层,然后依次遍历两个栈,遍历第一个栈时遇到的子节点依次加入第二个栈s2中,即是第二层,而遍历第二个栈s2的时候因为是先进后出,因此就是逆序的,再将第二个栈s2的子节点依次加入第一个栈s1中,于是原本的逆序在第一个栈s1中又变回了正序,如果反复交替直到两个栈都空为止。
  2. - step 1:首先判断二叉树是否为空,空树没有打印结果。
  3. - step 2:建立两个辅助栈,每次依次访问第一个栈s1与第二个栈s2,根节点先进入s1.
  4. - step 3:依据依次访问的次序,s1必定记录的是奇数层,访问节点后,将它的子节点(如果有)依据先左后右的顺序加入s2,这样s2在访问的时候根据栈的先进后出原理就是右节点先访问,正好是偶数层需要的从右到左访问次序。偶数层则正好相反,要将子节点(如果有)依据先右后左的顺序加入s1,这样在s1访问的时候根据栈的先进后出原理就是左节点先访问,正好是奇数层需要的从左到右访问次序。
  5. - step 4:每次访问完一层,即一个栈为空,则将一维数组加入二维数组中,并清空以便下一层用来记录。
  6. ```java
  7. import java.util.*;
  8. public class Solution {
  9. // 方法2:两个栈
  10. public ArrayList<ArrayList<Integer>> Print(TreeNode pRoot) {
  11. ArrayList<ArrayList<Integer>> res = new ArrayList<>();
  12. if (pRoot == null) {
  13. return res;
  14. }
  15. Deque<TreeNode> stack1 = new ArrayDeque<>();
  16. Deque<TreeNode> stack2 = new ArrayDeque<>();
  17. stack1.push(pRoot);
  18. while (!stack1.isEmpty() || !stack2.isEmpty()) {
  19. ArrayList<Integer> layer = new ArrayList<>();
  20. while (!stack1.isEmpty()) {
  21. TreeNode node = stack1.pop();
  22. layer.add(node.val);
  23. if (node.left != null) {
  24. stack2.push(node.left);
  25. }
  26. if (node.right != null) {
  27. stack2.push(node.right);
  28. }
  29. }
  30. if (!layer.isEmpty()) {
  31. res.add(new ArrayList<>(layer));
  32. }
  33. layer.clear();
  34. while (!stack2.isEmpty()) {
  35. TreeNode node = stack2.pop();
  36. layer.add(node.val);
  37. if (node.right != null) {
  38. stack1.push(node.right);
  39. }
  40. if (node.left != null) {
  41. stack1.push(node.left);
  42. }
  43. }
  44. if (!layer.isEmpty()) {
  45. res.add(new ArrayList<>(layer));
  46. }
  47. layer.clear();
  48. }
  49. return res;
  50. }
  51. }

BM28. 二叉树的最大深度

方法一:递归(推荐使用)
具体做法:
最大深度是所有叶子节点的深度的最大值,深度是指树的根节点到任一叶子节点路径上节点的数量,因此从根节点每次往下一层深度就会加1。因此二叉树的深度就等于根结点这个1层加上左子树和右子树深度的最大值。而每个子树我们都可以看成一个根节点,继续用上述方法求的深度,于是我们可以对这个问题划为子问题,利用递归来解决:

  • step 1: 终止条件: 当进入叶子节点后,再进入子节点,即为空,没有深度可言,返回0.
  • step 2: 返回值: 每一级按照上述公式,返回两边子树深度的最大值加上本级的深度,即加1.
  • step 3:本级任务: 每一级的任务就是进入左右子树,求左右子树的深度。
  1. public class Solution {
  2. public int maxDepth(TreeNode root) {
  3. if (root == null) {
  4. return 0;
  5. }
  6. int left = maxDepth(root.left);
  7. int right = maxDepth(root.right);
  8. return Math.max(left, right) + 1;
  9. }
  10. }

方法2:层次遍历。
具体做法:
既然是统计二叉树的最大深度,除了根据路径到达从根节点到达最远的叶子节点以外,我们还可以分层统计。对于一棵二叉树而言,必然是一层一层的,那一层就是一个深度,有的层可能会很多节点,有的层如根节点或者最远的叶子节点,只有一个节点,但是不管多少个节点,它们都是一层。因此我们可以使用层次遍历,二叉树的层次遍历就是从上到下按层遍历,每层从左到右,我们只要每层统计层数即是深度。

  • step 1:既然是层次遍历,我们遍历完一层要怎么进入下一层,可以用队列记录这一层节点的子节点。
  • step 2:在刚刚进入某一层的时候,队列中的元素个数就是当前层的节点数,遍历的时候,每层开始统计该层结点的个数size ,然后经过size 次循环,精准进入下一层。
  • step 3:遍历完一层就可以节点深度就可以加1,
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:层次遍历
  4. public int maxDepth(TreeNode root) {
  5. if (root == null) {
  6. return 0;
  7. }
  8. Queue<TreeNode> queue = new ArrayDeque<>();
  9. queue.add(root);
  10. int depth = 0;
  11. while (!queue.isEmpty()) {
  12. depth++;
  13. int size = queue.size();
  14. for (int i = 0; i < size; i++) {
  15. TreeNode node = queue.poll();
  16. if (node.left != null) {
  17. queue.add(node.left);
  18. }
  19. if (node.right != null) {
  20. queue.add(node.right);
  21. }
  22. }
  23. }
  24. return depth;
  25. }
  26. }

BM29. 二叉树中和为某一值的路径(一)

方法1:递归。
具体做法:
既然是检查从根到叶子有没有一条等于目标值的路径,那肯定需要从根节点遍历到叶子,我们可以在根节点每次往下一层的时候,将sum减去节点值,最后检查是否完整等于0. 而遍历的方法我们可以选取二叉树常用的先序遍历,因为每次进入一个子节点,更新sum值以后,相当于对子树查找有没有等于新目标值的路径,因此这就是子问题,递归的三段式为:

  • 终止条件: 每当遇到节点为空,意味着过了叶子节点,返回。每当检查到某个节点没有子节点,它就是叶子节点,此时sum减去叶子节点值刚好为0,说明找到了路径。
  • 返回值: 将子问题中是否有符合新目标值的路径层层往上返回。
  • 本级任务: 每一级需要检查是否到了叶子节点,如果没有则递归地进入子节点,同时更新sum值减掉本层的节点值。

整个过程如下:

  • step 1:每次检查遍历到的节点是否为空节点,空节点就没有路径。
  • step 2:再检查遍历到是否为叶子节点,且当前sum值等于节点值,说明可以刚好找到。
  • step 3:检查左右子节点是否可以有完成路径的,如果任意一条路径可以都返回true,因此这里选用两个子节点递归的或。
  1. public class Solution {
  2. // 方法1:递归
  3. public boolean hasPathSum(TreeNode root, int sum) {
  4. // 1. 递归的终止条件
  5. if (root == null) {
  6. return false;
  7. }
  8. // 终止条件2:叶子结点且等于路径和,返回true
  9. if (root.left == null && root.right == null && sum - root.val == 0) {
  10. return true;
  11. }
  12. // 2. 进入左右孩子结点
  13. boolean left = hasPathSum(root.left, sum - root.val);
  14. boolean right = hasPathSum(root.right, sum - root.val);
  15. return left || right;
  16. }
  17. }

方法2:非递归
在二叉树中能够用递归解决的问题,很多时候我们也可以用非递归来解决。这里遍历过程也可以使用栈辅助,进行DFS 遍历,检查往下的路径中是否有等于sum 的路径和。
注意,这里仅是DFS,而不是先序遍历,左右节点的顺序没有关系,因为每次往下都是单独添加某个节点的值相加然后继续往下,因此左右节点谁先遍历不管用。

  • step 1:首先检查空节点,空树没有路径。
  • step 2:使用两个栈同步遍历,一个栈记录节点,辅助深度优先搜索,另一个栈跟随记录到该节点为止的路径和(C++中可以在一个栈中嵌套pair实现)。根节点及根节点值先进栈。
  • step 3:遍历的时候每次弹出两个栈中的内容,判断是否是叶子节点且路径和是否等于目标值。
  • step 4:没有到叶子节点就将左右子节点(如果有)加入栈中,并跟随加入路径和。
  • step 5:如果遍历结束也没有找到路径和,则该二叉树中没有。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:非递归
  4. public boolean hasPathSum(TreeNode root, int sum) {
  5. if (root == null) {
  6. return false;
  7. }
  8. Deque<TreeNode> stack1 = new ArrayDeque<>();
  9. Deque<Integer> stack2 = new ArrayDeque<>();
  10. stack1.push(root);
  11. stack2.push(root.val);
  12. while (!stack1.isEmpty()) {
  13. TreeNode node = stack1.pop();
  14. int curSum = stack2.pop();
  15. if (node.left == null && node.right == null && curSum == sum) {
  16. return true;
  17. }
  18. if (node.left != null) {
  19. stack1.push(node.left);
  20. stack2.push(node.left.val + curSum);
  21. }
  22. if (node.right != null) {
  23. stack1.push(node.right);
  24. stack2.push(node.right.val + curSum);
  25. }
  26. }
  27. return false;
  28. }
  29. }

BM30. 二叉搜索树转化为双向链表

方法1:递归版
二叉搜索树的每个节点值大于它的左子节点,且大于全部左子树的节点值,小于它右子节点,且小于全部右子树的节点值。因此最左端的元素一定最小,最右端的元素一定最大,符合“左中右”的特性,因此二叉搜索树的中序遍历就是一个递增序列,我们只要对它中序遍历就可以组装称为递增双向链表。

  • step 1:创建两个指针,一个指向题目中要求的链表头(head),一个指向当前遍历的前一结点(pre)。
  • step 2:首先递归到最左,初始化head与pre。
  • step 3:然后处理中间根节点,依次连接pre与当前结点,连接后更新pre为当前节点。
  • step 4:最后递归进入右子树,继续处理。
  • step 5:递归出口即是节点为空则返回。

2022.06.18 能看懂代码,但写不出来啊,关于找到头结点和建立两个结点联系的那段比较难
2022.07.04 还是写不出来代码啊。

  1. public class Solution {
  2. // 方法1:递归中序遍历
  3. public TreeNode pre = null;
  4. public TreeNode head = null;
  5. public TreeNode Convert(TreeNode pRootOfTree) {
  6. if (pRootOfTree == null) {
  7. return null;
  8. }
  9. // 1. 递归处理左子树
  10. Convert(pRootOfTree.left);
  11. // 2. 处理根结点
  12. if (pre == null) {
  13. // 这里只会执行一次,找到双向链表的头结点head
  14. pre = pRootOfTree;
  15. head = pRootOfTree;
  16. } else {
  17. // 给两个结点建立链接关系,再挪动位置
  18. pre.right = pRootOfTree;
  19. pRootOfTree.left = pre;
  20. pre = pRootOfTree;
  21. }
  22. // 3. 右子树
  23. Convert(pRootOfTree.right);
  24. return head;
  25. }
  26. }

方法2:非递归版
具体做法:
二叉树中序遍历除了递归方法,我们还可以尝试非递归解法,与常规的非递归中序遍历几乎相同,只是增加了连接节点。

  • step 1:创建两个指针,一个指向题目中要求的链表头(head),一个指向当前遍历的前一结点(pre),创建一个布尔型变量,标记是否是第一次到最左,因为第一次到最左就是链表头。
  • step 2:判断空树不能连接。
  • step 3:初始化一个栈辅助中序遍历。
  • step 4:依次将父节点加入栈中,直接进入二叉树最左端。
  • step 5:第一次进入最左,初始化head与pre,然后进入它的根节点开始连接。
  • step 6:最后将右子树加入栈中,栈中依次就弹出“左中右”的节点顺序,直到栈为空
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:非递归
  4. public TreeNode Convert(TreeNode pRootOfTree) {
  5. if (pRootOfTree == null) {
  6. return null;
  7. }
  8. Deque<TreeNode> stack = new ArrayDeque<>();
  9. TreeNode pre = null;
  10. TreeNode head = null;
  11. while (pRootOfTree != null || !stack.isEmpty()) {
  12. while (pRootOfTree != null) {
  13. stack.push(pRootOfTree);
  14. pRootOfTree = pRootOfTree.left;
  15. }
  16. TreeNode node = stack.pop();
  17. if (pre == null) {
  18. pre = node;
  19. head = node;
  20. } else {
  21. pre.right = node;
  22. node.left = pre;
  23. pre = node;
  24. }
  25. pRootOfTree = node.right;
  26. }
  27. return head;
  28. }
  29. }

BM31. 对称的二叉树

方法一:递归(推荐使用)
具体做法:
前序遍历的时候我们采用的是“根左右”的遍历次序,如果这棵二叉树是对称的,即相应的左右节点交换位置完全没有问题,那我们是不是可以尝试“根右左”遍历,按照轴对称图像的性质,这两种次序的遍历结果应该是一样的。不同的方式遍历两次,将结果拿出来比较看起来是一种可行的方法,但也仅仅可行,太过于麻烦。我们不如在遍历的过程就结果比较了。依据前序递归的次序,我们先访问根节点,然后递归地进入左子节点和右子节点,如果要采用“根右左”的顺序,那就应该是递归地进入右子节点,然后进入左子节点。我们可以准备两个指针啊,遍历的时候“根左右”走左边的时候“根右左”走右边,“根左右”走右边的时候“根右左”走左边,这样刚好可以同步遍历比较。

  • step 1: 终止条件: 当进入子问题的两个节点都为空,说明都到了叶子节点,且是同步的,因此结束本次子问题,返回true;当进入子问题的两个节点只有一个为空,或是元素值不相等,说明这里的对称不匹配,同样结束本次子问题,返回false。
  • step 2:返回值: 每一级将子问题是否匹配的结果往上传递。
  • step 3:本级任务: 每个子问题,需要按照上述思路,“根左右”走左边的时候“根右左”走右边,“根左右”走右边的时候“根右左”走左边,一起进入子问题,需要两边都是匹配才能对称。
  1. public class Solution {
  2. // 方法1:递归
  3. boolean isSymmetrical(TreeNode pRoot) {
  4. return recursion(pRoot, pRoot);
  5. }
  6. boolean recursion(TreeNode root1, TreeNode root2) {
  7. if (root1 == null && root2 == null) {
  8. return true;
  9. }
  10. if (root1 == null || root2 == null) {
  11. return false;
  12. }
  13. if (root1.val != root2.val) {
  14. return false;
  15. }
  16. return recursion(root1.left, root2.right) && recursion(root1.right, root2.left);
  17. }
  18. }

方法2:层次遍历
除了递归以外,我们还可以观察,对称的二叉树每一层都是回文的情况,即两边相互对应相等,有节点值的对应节点值,没有节点的连空节点都是对应着的呢。那我们从左往右遍历一层(包括空节点),和从右往左遍历一层(包括空节点),是不是就是得到一样的结果了。(注:必须包含空节点,因为空节点乱插入会导致不同,如题干第二个图所示)。
这时候二叉树每一层的遍历,我就需要用到了层次遍历。层次遍历从左往右经过第一层后,怎么进入第二层?我们可以借助队列——一个先进先出的容器,在遍历第一层的时候,将第一层节点的左右节点都加入到队列中,因为加入队列的顺序是遍历的顺序且先左后右,也就导致了我从队列出来的时候也是下一层的先左后右,正好一一对应。更巧的是,如果我们要从右到左遍历一层,加入队列后也是先右后左,简直完美对应!
而且我们不需要两个层次遍历都完整地遍历二叉树,只需要一半就行了,从左往右遍历左子树,从右往左遍历右子树,各自遍历一半相互比对,因为遍历到另一半都已经检查过了。

  • step 1:首先判断链表是否为空,空链表直接就是对称。
  • step 2:准备两个队列,分别作为从左往右层次遍历和从右往左层次遍历的辅助容器,初始第一个队列加入左节点,第二个队列加入右节点。
  • step 3:循环中每次从队列分别取出一个节点,如果都为空,暂时可以说是对称的,进入下一轮检查;如果某一个为空或是两个节点值不同,那必定不对称。其他情况暂时对称,可以依次从左往右加入子节点到第一个队列,从右往左加入子节点到第二个队列。(这里包括空节点)
  • step 4:遍历结束也没有检查到不匹配,说明就是对称的。

这道题要添加null 元素到容器中,所以要注意一下了。Queue 接口和它的实现类ArrayDeque 中的add 方法不允许添加null ,但是另一个实现类 LinkedList 允许添加空元素,所以这道题选择使用 LinkedList 。
image.png

image.png

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:层次遍历-两个队列
  4. boolean isSymmetrical(TreeNode pRoot) {
  5. if (pRoot == null) {
  6. return true;
  7. }
  8. Queue<TreeNode> queue1 = new LinkedList<>();
  9. Queue<TreeNode> queue2 = new LinkedList<>();
  10. queue1.add(pRoot.left);
  11. queue2.add(pRoot.right);
  12. while (!queue1.isEmpty() && !queue2.isEmpty()) {
  13. TreeNode leftChild = queue1.poll();
  14. TreeNode rightChild = queue2.poll();
  15. if (leftChild == null && rightChild == null) {
  16. continue;
  17. }
  18. if (leftChild == null || rightChild == null) {
  19. return false;
  20. }
  21. if (leftChild.val != rightChild.val) {
  22. return false;
  23. }
  24. queue1.add(leftChild.left);
  25. queue1.add(leftChild.right);
  26. // 从右往左加入队列
  27. queue2.add(rightChild.right);
  28. queue2.add(rightChild.left);
  29. }
  30. return true;
  31. }
  32. }

BM32. 合并二叉树

方法1:递归
要将一棵二叉树的节点与另一棵二叉树相加合并,肯定需要遍历两棵二叉树,那我们可以考虑同步遍历两棵二叉树,这样就可以将每次遍历到的值相加在一起。遍历的方式有多种,这里推荐前序递归遍历。

  • step 1:首先判断t1与t2是否为空,若为则用另一个代替,若都为空,返回的值也是空。
  • step 2:然后依据前序遍历的特点,优先访问根节点,将两个根点的值相加创建到新树中。
  • step 3:两棵树再依次同步进入左子树和右子树。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:前序遍历
  4. public TreeNode mergeTrees(TreeNode t1, TreeNode t2) {
  5. if (t1 == null) {
  6. return t2;
  7. }
  8. if (t2 == null) {
  9. return t1;
  10. }
  11. TreeNode root = new TreeNode(t1.val + t2.val);
  12. root.left = mergeTrees(t1.left, t2.left);
  13. root.right = mergeTrees(t1.right, t2.right);
  14. return root;
  15. }
  16. }

方法2:非递归
除了递归的遍历以外,非递归的层次遍历,也可以实现两棵树同步遍历节点相加,重点是两棵树从根节点开始每个节点是同步走的。

  • step 1:首先判断t1与t2是否为空,若为则用另一个代替,若都为空,返回的值也是空。
  • step 2:使用三个辅助队列,第一个队列q用于暂存合并后的二叉树的层次遍历结点,第二个队列q1用于暂存t1的层次遍历结点,第三个队列q2用于暂存t2的层次遍历结点。
  • step 3:两棵树同步层次遍历,先将根节点加入队列中,同时根节点优先合并。
  • step 4:每次从队列分别弹出一个元素,判断分别二者的左右子结点是否存在,若是都存在,则相加合并,若是只存在一个则连接该存在的结点,若是都不存在则连接null。
  1. import java.util.*;
  2. public class Solution {
  3. public TreeNode mergeTrees (TreeNode t1, TreeNode t2) {
  4. //若只有一个节点返回另一个,两个都为null自然返回null
  5. if (t1 == null)
  6. return t2;
  7. if (t2 == null)
  8. return t1;
  9. TreeNode head = new TreeNode(t1.val + t2.val); //合并根节点
  10. Queue<TreeNode> q = new LinkedList<TreeNode>(); //连接后的树的层次遍历节点
  11. Queue<TreeNode> q1 = new LinkedList<TreeNode>(); //分别存两棵树的层次遍历节点
  12. Queue<TreeNode> q2 = new LinkedList<TreeNode>();
  13. q.offer(head);
  14. q1.offer(t1);
  15. q2.offer(t2);
  16. while (!q1.isEmpty() && !q2.isEmpty()) {
  17. TreeNode node = q.poll();
  18. TreeNode node1 = q1.poll();
  19. TreeNode node2 = q2.poll();
  20. TreeNode left1 = node1.left;
  21. TreeNode left2 = node2.left;
  22. TreeNode right1 = node1.right;
  23. TreeNode right2 = node2.right;
  24. if(left1 != null || left2 != null){
  25. if(left1 != null && left2 != null){ //两个左节点都存在
  26. TreeNode left = new TreeNode(left1.val + left2.val);
  27. node.left = left;
  28. q.offer(left); //新节点入队列
  29. q1.offer(left1);
  30. q2.offer(left2);
  31. }else if(left1 != null) //只连接一个节点
  32. node.left = left1;
  33. else
  34. node.left = left2;
  35. }
  36. if(right1 != null || right2 != null){
  37. if(right1 != null && right2 != null) { //两个右节点都存在
  38. TreeNode right = new TreeNode(right1.val + right2.val);
  39. node.right = right;
  40. q.offer(right); //新节点入队列
  41. q1.offer(right1);
  42. q2.offer(right2);
  43. }else if(right1 != null) //只连接一个节点
  44. node.right = right1;
  45. else
  46. node.right = right2;
  47. }
  48. }
  49. return head;
  50. }
  51. }

BM33. 二叉树镜像反转

方法1:递归
具体做法:
因为我们需要将二叉树镜像,意味着每个左右子树都会交换位置,如果我们从上到下对遍历到的节点交换位置,但是它们后面的节点无法跟着他们一起被交换,因此我们可以考虑自底向上对每两个相对位置的节点交换位置,这样往上各个子树也会被交换位置。
自底向上的遍历方式,我们可以采用后序遍历的方法:

  • step 1:先深度最左端的节点,遇到空树返回。
  • step 2:然后进入子树的最右端。
  • step 3:再返回到父问题,交换父问题两个节点的值。
  1. public class Solution {
  2. public TreeNode Mirror(TreeNode pRoot) {
  3. if (pRoot == null) {
  4. return null;
  5. }
  6. // 后序遍历,自底向上,
  7. // 1. 一直递归到底层结点,得到它的左右孩子
  8. TreeNode leftChild = Mirror(pRoot.left);
  9. TreeNode rightChild = Mirror(pRoot.right);
  10. // 2. 交换左右孩子
  11. pRoot.left = rightChild;
  12. pRoot.right = leftChild;
  13. // 3. 返回根节点
  14. return pRoot;
  15. }
  16. }

方法2:非递归
二叉树中能够用递归的,我们大多也可以用栈来实现。栈的访问是一种自顶向下的访问,因此我们需要在左右子结点入栈后直接交换,然后再访问后续栈中内容。

  • step 1:优先检查空树的情况。
  • step 2:使用栈辅助遍历二叉树,根节点先进栈。
  • step 3:遍历过程中每次弹出栈中一个元素,然后该节点左右节点分别入栈,同时我们交换二者的值,因为子节点已经入栈了再交换,就不怕后续不能交换。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:非递归
  4. public TreeNode Mirror(TreeNode pRoot) {
  5. if (pRoot == null) {
  6. return null;
  7. }
  8. ArrayDeque<TreeNode> stack = new ArrayDeque<>();
  9. stack.push(pRoot);
  10. while (!stack.isEmpty()) {
  11. TreeNode node = stack.pop();
  12. if (node.left != null) {
  13. stack.push(node.left);
  14. }
  15. if (node.right != null) {
  16. stack.push(node.right);
  17. }
  18. TreeNode temp = node.left;
  19. node.left = node.right;
  20. node.right = temp;
  21. }
  22. return pRoot;
  23. }
  24. }

BM34. 判断是不是二叉搜索树

方法一:递归(推荐使用))
具体做法:
还记得二叉搜索树与双向链表这个里面使用的中序遍历吗?既然是判断是否是二叉搜索树,那我们可以继续使用中序遍历。只要之前的节点是二叉树搜索树,那么如果当前的节点小于上一个节点值那么就可以向下判断。只不过在过程中我们要求反退出。比如一个链表1->2->3->4 只要for 循环遍历如果中间有不是递增的直接返回false 即可。

  • step 1:首先递归到最左,初始化maxLeft 与pre 。
  • step 2:然后往后遍历整棵树,依次连接pre与当前结点,并更新pre。
  • step 3:左子树如果不是二叉搜索树返回false。
  • step 4:判断当前节点是不是小于前置节点,更新前置节点。
  • step 5:最后由右子树的后面节点决定。
  1. public class Solution {
  2. // 方法1:递归
  3. int preVal = Integer.MIN_VALUE;
  4. public boolean isValidBST(TreeNode root) {
  5. if (root == null) {
  6. return false;
  7. }
  8. boolean validLeft = isValidBST(root.left);
  9. if (!validLeft) {
  10. return false;
  11. }
  12. if (root.val < preVal) {
  13. return false;
  14. }
  15. preVal = root.val;
  16. boolean validRight = isValidBST(root.right);
  17. return validRight;
  18. }
  19. }

方法2:非递归
具体做法:
我们也可以利用栈来代替递归。如果一棵二叉树,对于每个根节点都优先访问左子树,那结果是什么?从根节点开始不断往左,第一个被访问的肯定是最左边的节点,然后访问该节点的右子树,最后向上回到父问题。因为每次访问最左的元素不止对一整棵二叉树成立,而是对所有子问题都成立,因此循环的时候自然最开始都是遍历到最左,然后访问,然后再进入右子树,我们可以用栈来实现回归父问题。

  • step 1:优先判断树是否为空,空树不遍历。
  • step 2:准备一个数组记录中序遍历的结果。
  • step 3:准备辅助栈,当二叉树节点为空了且栈中没有节点了,我们就停止访问。
  • step 4:从根节点开始,每次优先进入每棵的子树的最左边一个节点,我们将其不断加入栈中,用来保存父问题。
  • step 5:到达最左后,可以开始访问,如果它还有右节点,则将右边也加入栈中,之后右子树的访问也是优先到最左。
  • step 6:遍历数组,依次比较相邻两个元素是否为递增序。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:非递归。中序遍历的同时存储元素
  4. public boolean isValidBST(TreeNode root) {
  5. Deque<TreeNode> stack = new ArrayDeque<>();
  6. ArrayList<Integer> list = new ArrayList<>();
  7. while (root != null || !stack.isEmpty()) {
  8. // 1. 左孩子。一直往左走
  9. while (root != null) {
  10. stack.push(root);
  11. root = root.left;
  12. }
  13. // 2. 当前根节点
  14. TreeNode node = stack.pop();
  15. list.add(node.val);
  16. // 3. 去当前根节点的右孩子
  17. root = node.right;
  18. }
  19. // 4. 遍历list,检查是否是递增的
  20. for (int i = 1; i < list.size(); i++) {
  21. if (list.get(i - 1) > list.get(i)) {
  22. return false;
  23. }
  24. }
  25. return true;
  26. }
  27. }

BM35. 判断是不是完全二叉树

方法1:层次遍历
具体做法:
对完全二叉树最重要的定义就是叶子结点只能出现在最下层和次下层,所以我们想到可以使用层序遍历,只有次下层和最下层才有叶子节点,其他层出现叶子节点就意味着不是完全二叉树。

  • step 1:先判断空树一定是完全二叉树。
  • step 2:初始化一个队列辅助层次遍历,将根节点加入。
  • step 3:逐渐从队列中弹出元素访问节点,如果遇到某个节点为空,进行标记,若是后续还有访问,则说明提前出线了叶子节点。
  • step 4:否则,继续加入左右子节点进入队列排队,等待访问。

LinkedList 允许添加空元素。

  1. import java.util.*;
  2. public class Solution {
  3. public boolean isCompleteTree(TreeNode root) {
  4. if (root == null) {
  5. return true;
  6. }
  7. LinkedList<TreeNode> queue = new LinkedList<>();
  8. queue.add(root);
  9. boolean appeared = false;
  10. while (!queue.isEmpty()) {
  11. TreeNode node = queue.poll();
  12. if (node == null) {
  13. appeared = true;
  14. continue;
  15. }
  16. if (appeared) {
  17. return false;
  18. }
  19. queue.add(node.left);
  20. queue.add(node.right);
  21. }
  22. return true;
  23. }
  24. }

BM36. 判断是不是平衡二叉树

方法1:自顶向下
具体做法:
平衡二叉树任意节点两边的子树深度相差绝对值不会超过1,且每个子树都满足这个条件,那我们可以对每个节点找到两边的深度以后,判断是否两边相差绝对值超过1,然后因为每个子树都要满足这个条件,我们还需要遍历二叉树每个节点当成一棵子树进行判断,而对于每个每个节点判断后,其子节点就是子问题,因此可以用递归。

  • step 1:第一个函数递归遍历二叉树所有结点。
  • step 2:对于每个节点判断,调用第二个函数获取子树深度。
  • step 3:第二个函数递归获取子树深度,只需要不断往子节点深度遍历,累加左右深度的较大值。
  • step 4:根据深度判断该节点下的子树是否为平衡二叉树。
  1. public class Solution {
  2. // 方法1:自顶向下
  3. public boolean IsBalanced_Solution(TreeNode root) {
  4. if (root == null) {
  5. return true;
  6. }
  7. // 1. 得到根节点的左右子树的高度,判断是否平衡
  8. int leftDepth = getDepth(root.left);
  9. int rightDepth = getDepth(root.right);
  10. if (Math.abs(leftDepth - rightDepth) > 1) {
  11. return false;
  12. }
  13. // 2. 得到左右子树是否平衡
  14. boolean isBanlancedLeft = IsBalanced_Solution(root.left);
  15. boolean isBanlancedRight = IsBalanced_Solution(root.left);
  16. return isBanlancedLeft && isBanlancedRight;
  17. }
  18. private int getDepth(TreeNode root) {
  19. if (root == null) {
  20. return 0;
  21. }
  22. int leftDepth = getDepth(root.left);
  23. int rightDepth = getDepth(root.right);
  24. return Math.max(leftDepth, rightDepth) + 1;
  25. }
  26. }

方法2:自底向上。
上述方法中一个函数算深度,一个函数遍历所有结点,用了两个递归,做了很多不必要的运算,这就是自顶向下的弊端。那我们可以考虑自底向上,在底部计算深度的同时,判断该子树是否为平衡二叉树,将是或否平衡与深度信息往上传就行

  • step 1:先判断空树,直接为平衡二叉树。
  • step 2:递归进行变计算深度边判断是否平衡二叉树。
  • step 3:递归计算当前节点左右子树的高度差,然后比较深度。
  • step 4:每次递归都将深度结果往上传,就能做到边判断边计算深度了。

这里是根据返回值确定是否平衡和深度这两个信息的。如果返回值为-1,说明不平衡;返回值为0,说明上次访问的为空;返回值为正整数,说明子树是平衡的,并且子树高度为返回值。
这道题的终止条件也有点特殊,分布在方法开头和调用递归之后。

  1. public class Solution {
  2. // 方法2:自底向上
  3. // 每次传递当前是否是平衡和深度这两个信息
  4. public boolean IsBalanced_Solution(TreeNode root) {
  5. if (root == null) {
  6. return true;
  7. }
  8. int res = getDepth(root);
  9. // 高度为-1,不平衡,false;
  10. return res != -1;
  11. }
  12. private int getDepth(TreeNode root) {
  13. // 1. 为空,向上返回子树高度0
  14. if (root == null) {
  15. return 0;
  16. }
  17. // 2. 访问左子树,如果左子树高度为负数,说明不平衡,也向上返回-1
  18. int leftDepth = getDepth(root.left);
  19. if (leftDepth < 0) {
  20. return -1;
  21. }
  22. // 3. 访问右子树,如果右子树高度为负数,说明不平衡,也向上返回-1
  23. int rightDepth = getDepth(root.right);
  24. if (rightDepth < 0) {
  25. return -1;
  26. }
  27. // 4. 如果当前结点的左右子树高度差超过1,说明不平衡,也向上返回-1
  28. // 否则说明是平衡的,向上返回包含自身结点的高度
  29. int curDepth = 0;
  30. if (Math.abs(leftDepth - rightDepth) > 1) {
  31. curDepth = -1;
  32. } else {
  33. curDepth = Math.max(leftDepth, rightDepth) + 1;
  34. }
  35. return curDepth;
  36. }
  37. }

BM37. 二叉搜索树中两个结点的最近公共祖先

方法1:两次遍历
具体做法:
二叉搜索树没有相同值的节点,因此分别从根节点往下遍历可以轻松找到值为p、q的结点,再逐一对比路径中的值,就可以找到最近的公共祖先。

  • step 1:根据二叉搜索树的性质,从根节点开始查找目标节点,当前节点比目标小则进入右子树,当前节点比目标大则进入左子树,直到找到目标节点。这个过程成用数组记录遇到的元素。
  • step 2:分别在搜索二叉树中找到p和q两个点,并记录各自的路径为数组。
  • step 3:同时遍历两个数组,比较元素值,最后一个相等的元素就是最近的公共祖先。
  1. import java.util.*;
  2. public class Solution {
  3. public int lowestCommonAncestor(TreeNode root, int p, int q) {
  4. ArrayList<Integer> pathP = getPath(root, p);
  5. ArrayList<Integer> pathQ = getPath(root, q);
  6. int res = 0;
  7. // 这两个结点的路径,都是从根节点开始,找到第一个不相同的结点,前一个就是最近公共祖先结点
  8. for (int i = 0; i < pathP.size() && i < pathQ.size(); i++) {
  9. int curP = pathP.get(i);
  10. int curQ = pathQ.get(i);
  11. if (curP == curQ) {
  12. res = curP;
  13. } else {
  14. break;
  15. }
  16. }
  17. return res;
  18. }
  19. // 找到二叉搜索树从根节点到目标值的路径
  20. public ArrayList<Integer> getPath(TreeNode root, int target) {
  21. ArrayList<Integer> path = new ArrayList<>();
  22. TreeNode cur = root;
  23. while (cur.val != target) {
  24. path.add(cur.val);
  25. if (cur.val < target) {
  26. cur = cur.right;
  27. } else {
  28. cur = cur.left;
  29. }
  30. }
  31. path.add(cur.val);
  32. return path;
  33. }
  34. }

方法2:一次遍历
具体做法:
我们也可以利用二叉搜索树的性质:对于某一个节点若是p 与q 都小于等于这个这个节点值,说明p、q都在这个节点的左子树,而最近的公共祖先也一定在这个节点的左子树;若是p与q都大于等于这个节点,说明p、q都在这个节点的右子树,而最近的公共祖先也一定在这个节点的右子树。而若是对于某个节点,p与q的值一个大于等于节点值,一个小于等于节点值,说明它们分布在该节点的两边,而这个节点就是最近的公共祖先,因此从上到下的其他祖先都将这个两个节点放到同一子树,只有最近公共祖先会将它们放入不同的子树,每次进入一个子树又回到刚刚的问题,因此可以使用递归。

  • step 1:首先检查空节点,空树没有公共祖先。
  • step 2:对于某个节点,比较与p、q的大小,若p、q在该节点两边说明这就是最近公共祖先。
  • step 3:如果p、q都在该节点的左边,则递归进入左子树。
  • step 4:如果p、q都在该节点的右边,则递归进入右子树。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:一次遍历
  4. public int lowestCommonAncestor(TreeNode root, int p, int q) {
  5. if (root == null) {
  6. return -1;
  7. }
  8. if ((p <= root.val && q >= root.val) || (p >= root.val && q <= root.val)) {
  9. return root.val;
  10. } else if (p <= root.val && q <= root.val) {
  11. return lowestCommonAncestor(root.left, p, q);
  12. } else {
  13. return lowestCommonAncestor(root.right, p, q);
  14. }
  15. }
  16. }

BM38. 二叉树中两个结点的最近公共祖先

方法1:路径比较法
具体做法:
既然要找到二叉树中两个节点的最近公共祖先,那我们可以考虑先找到两个节点全部祖先,然后依次比较的出谁是最近的祖先。

  • step 1:利用DFS 求得根节点到两个目标节点的路径:每次选择二叉树的一棵子树往下找,同时路径数组增加这个遍历的节点值,一旦遍历到了叶子节点也没有,则回溯到父节点,寻找其他路径,回溯时要去掉数组中刚刚加入的元素。
  • step 2:然后遍历两条路径数组,依次比较元素值。
  • step 3:找到两条路径第一个不相同的节点即是最近公共祖先。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:路径比较法
  4. public boolean find = false;
  5. public int lowestCommonAncestor(TreeNode root, int o1, int o2) {
  6. ArrayList<Integer> path1 = new ArrayList<>();
  7. ArrayList<Integer> path2 = new ArrayList<>();
  8. dfs(root, path1, o1);
  9. find = false;
  10. dfs(root, path2, o2);
  11. int res = 0;
  12. for (int i = 0; i < Math.min(path1.size(), path2.size()); i++) {
  13. int value1 = path1.get(i);
  14. int value2 = path2.get(i);
  15. if (value1 == value2) {
  16. res = value1;
  17. } else {
  18. break;
  19. }
  20. }
  21. return res;
  22. }
  23. private void dfs(TreeNode root, ArrayList<Integer> path, int target) {
  24. if (find || root == null) {
  25. return;
  26. }
  27. if (root.val == target) {
  28. path.add(root.val);
  29. find = true;
  30. return;
  31. }
  32. path.add(root.val);
  33. dfs(root.left, path, target);
  34. dfs(root.right, path, target);
  35. if (find) {
  36. return;
  37. }
  38. path.remove(path.size() - 1);
  39. }
  40. }

方法2:递归
具体做法:
我们可以从根节点开始思想几种情况:

  • step 1:如果o1和o2中的任一个和root匹配,那么root就是最近公共祖先。
  • step 2:如果都不匹配,则分别递归左、右子树。
  • step 3:如果有一个节点出现在左子树,并且另一个节点出现在右子树,则root就是最近公共祖先.
  • step 4:如果两个节点都出现在左子树,则说明最低公共祖先在左子树中,否则在右子树。 ```java import java.util.*;

public class Solution {

  1. // 方法2:递归
  2. public int lowestCommonAncestor(TreeNode root, int o1, int o2) {
  3. if (root == null) {
  4. return -1;
  5. }
  6. // 1. 根节点的值等于其中一个,那根节点就是最近的公共祖先
  7. if (root.val == o1 || root.val == o2) {
  8. return root.val;
  9. }
  10. // 2. 去左右子树寻找
  11. int left = lowestCommonAncestor(root.left, o1, o2);
  12. int right = lowestCommonAncestor(root.right, o1, o2);
  13. // 左子树没找到,则在右子树中,反之亦然
  14. if (left == -1) {
  15. return right;
  16. }
  17. if (right == -1) {
  18. return left;
  19. }
  20. // 3. 左右子树都没找到,那就是当前结点
  21. return root.val;
  22. }

}

  1. <a name="FooaW"></a>
  2. #### BM39. [序列化二叉树](https://www.nowcoder.com/practice/cf7e25aa97c04cc1a68c8f040e71fb84?tpId=295&sfm=html&channel=nowcoder)
  3. **方法1:前序遍历**<br />**具体做法:**<br />反序列化即按照前序遍历的思路,遍历二叉树每个节点,并将节点值存储在字符串中,我们用‘#’表示空节点,用‘!'表示节点与节点之间的分割。序列化即根据给定的字符串,将二叉树重建,因为字符串中的顺序是前序遍历,因此我们重建的时候也是前序遍历。
  4. - step 1:优先处理序列化,SerializeFunction函数负责先序递归,遇到空节点在字符串中添加‘#’,遇到非空节点,添加相应节点数字和‘!’,然后依次进入左子树,右子树。
  5. - step 2:创建全局变量index表示序列中的下标(C++中直接指针完成)。
  6. - step 3:DeserializeFunction函数负责先序递归构建树,遇到‘#’则是空节点,遇到数字则根据感叹号分割,将字符串转换为数字后加入创建的节点中。然后依次创建左子树、右子树。
  7. ```java
  8. public class Solution {
  9. // 1. 序列化
  10. public String Serialize(TreeNode root) {
  11. if (root == null) {
  12. return "#";
  13. }
  14. StringBuilder res = new StringBuilder();
  15. serializeHelper(root, res);
  16. return res.toString();
  17. }
  18. private void serializeHelper(TreeNode root, StringBuilder str) {
  19. if (root == null) {
  20. str.append('#');
  21. return;
  22. }
  23. str.append(root.val).append('!');
  24. serializeHelper(root.left, str);
  25. serializeHelper(root.right, str);
  26. }
  27. // 2. 活化
  28. public int index = 0;
  29. public TreeNode Deserialize(String str) {
  30. if (str == "#") {
  31. return null;
  32. }
  33. TreeNode res = deserializeHelper(str);
  34. return res;
  35. }
  36. private TreeNode deserializeHelper(String str) {
  37. if (str.charAt(index) == '#') {
  38. index++;
  39. return null;
  40. }
  41. int val = 0;
  42. while (str.charAt(index) != '!' && index != str.length()) {
  43. val = val * 10 + ((str.charAt(index)) - '0');
  44. index++;
  45. }
  46. TreeNode root = new TreeNode(val);
  47. if (index == str.length()) {
  48. return root;
  49. } else {
  50. index++;
  51. }
  52. root.left = deserializeHelper(str);
  53. root.right = deserializeHelper(str);
  54. return root;
  55. }
  56. }

BM40. 重建二叉树

方法1:递归
具体做法:
对于二叉树的前序遍历,我们知道序列的第一个元素必定是根节点的值,因为序列没有重复的元素,因此中序遍历中可以找到相同的这个元素,而我们又知道中序遍历中根节点将二叉树分成了左右子树两个部分,如下图所示:
牛客网面试必刷TOP101 - 图13

我们可以发现,数字1是根节点,并将二叉树分成了(247)和(3568)两棵子树,而子树的的根也是相应前序序列的首位,比如左子树的根是数字2,右子树的根是数字3,这样我们就可以利用前序遍历序列找子树的根节点,利用中序遍历序列区分每个子树的节点数。

  • step 1:先根据前序遍历第一个点建立根节点。
  • step 2:然后遍历中序遍历找到根节点在数组中的位置。
  • step 2:再按照子树的节点数将两个遍历的序列分割成子数组,将子数组送入函数建立子树。
  • step 4:直到子树的序列长度为0,结束递归。
  1. import java.util.*;
  2. public class Solution {
  3. public TreeNode reConstructBinaryTree(int[] pre, int[] vin) {
  4. int m = pre.length;
  5. int n = vin.length;
  6. if (m == 0 || n == 0) {
  7. return null;
  8. }
  9. // 找到二叉树根节点的值,创建根节点
  10. TreeNode root = new TreeNode(pre[0]);
  11. for (int i = 0; i < vin.length; i++) {
  12. if (pre[0] == vin[i]) {
  13. // 2. 创建左右孩子结点
  14. root.left = reConstructBinaryTree(Arrays.copyOfRange(pre, 1, i + 1), Arrays.copyOfRange(vin, 0, i));
  15. root.right = reConstructBinaryTree(Arrays.copyOfRange(pre, i + 1, pre.length), Arrays.copyOfRange(vin, i + 1, vin.length));
  16. break;
  17. }
  18. }
  19. return root;
  20. }
  21. }

方法2:栈
具体做法:
除了递归,我们也可以用类似非递归前序遍历的方式建立二叉树。

  • step 1:首先前序遍历第一个元素依然是根节点,并建立栈辅助遍历。
  • step 2:然后我们就开始判断,在前序遍历中相邻的两个数字必定是只有两种情况:要么后一个是前一个的左节点;要么是前一个的右节点或者其祖先的右节点。
  • step 3:我们可以同时顺序遍历pre和vin两个序列,判断是否是左节点,如果是左节点则不断向左深入,用栈记录祖先,如果不是需要弹出栈回到相应的祖先,然后进入右子树,整个过程类似非递归前序遍历。

image.png

2022.06.19 这个非递归版本代码好厉害啊,多看几遍!多看几遍!多看几遍!
2022.07.05 这个代码好难,今天看半天理解都有点困难呢。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:非递归
  4. // 2022.06.19 这个代码很神奇,刚好能运转正确,目前能理解,但写不出来
  5. public static TreeNode reConstructBinaryTree(int[] pre, int[] vin) {
  6. int m = pre.length;
  7. int n = vin.length;
  8. if (m == 0 || n == 0) {
  9. return null;
  10. }
  11. // 1. 创建整棵树的根节点
  12. Deque<TreeNode> stack = new ArrayDeque<>();
  13. TreeNode root = new TreeNode(pre[0]);
  14. TreeNode cur = root;
  15. for (int i = 1, j = 0; i < n; i++) {
  16. if (cur.val != vin[j]) {
  17. // 2. 要么旁边这个是它的左孩子
  18. cur.left = new TreeNode(pre[i]);
  19. stack.push(cur);
  20. cur = cur.left;
  21. } else {
  22. // 3. 如果相等,来到中序遍历的头结点的下一位,比较
  23. j++;
  24. while (!stack.isEmpty() && stack.peek().val == vin[j]) {
  25. cur = stack.pop();
  26. j++;
  27. }
  28. // 4. 要么旁边这个是它的右孩子
  29. cur.right = new TreeNode(pre[i]); // pre[i] 此时刚好是右孩子
  30. cur = cur.right;
  31. }
  32. }
  33. return root;
  34. }
  35. }

BM41. 输出二叉树的右视图

方法一:递归建树+深度优先搜索(推荐使用)
具体做法:
可以发现解这道题,我们有两个步骤:

  1. 建树
  2. 打印右视图

首先建树方面,前序遍历是根左右的顺序,中序遍历是左根右的顺序,因为节点值互不相同,我们可以根据在前序遍历中找到根节点(每个子树部分第一个就是),再在中序遍历中找到对应的值,从其左右分割开,左边就是该树的左子树,右边就是该树的右子树,于是将问题划分为了子问题。
而打印右视图即找到二叉树每层最右边的节点元素,我们可以采取dfs(深度优先搜索)遍历树,根据记录的深度找到最右值。

  • step 1:首先检查两个遍历序列的大小,若是为0,则空树不用打印。
  • step 2:建树函数根据上述说,每次利用前序遍历第一个元素就是根节点,在中序遍历中找到它将二叉树划分为左右子树,利用l1 r1 l2 r2分别记录子树部分在数组中分别对应的下标,并将子树的数组部分送入函数进行递归。
  • step 3:dfs打印右视图时,使用哈希表存储每个深度对应的最右边节点,初始化两个栈辅助遍历,第一个栈记录dfs时的节点,第二个栈记录遍历到的深度,根节点先入栈。
  • step 4:对于每个访问的节点,每次左子节点先进栈,右子节点再进栈,这样访问完一层后,因为栈的先进后出原理,每次都是右边被优先访问,因此我们在哈希表该层没有元素时,添加第一个该层遇到的元素就是最右边的节点。
  • step 5:使用一个变量逐层维护深度最大值,最后遍历每个深度,从哈希表中读出每个深度的最右边节点加入数组中。
  1. import java.util.*;
  2. public class Solution {
  3. public int[] solve(int[] xianxu, int[] zhongxu) {
  4. if (xianxu.length == 0) {
  5. return new int[0];
  6. }
  7. TreeNode root = buildTree(xianxu, 0, xianxu.length - 1, zhongxu, 0, zhongxu.length - 1);
  8. ArrayList<Integer> list = rightSideView(root);
  9. int[] res = new int[list.size()];
  10. for (int i = 0; i < list.size(); i++) {
  11. res[i] = list.get(i);
  12. }
  13. return res;
  14. }
  15. // 1. 重建二叉树
  16. private TreeNode buildTree(int[] pre, int left1, int right1, int[] in, int left2, int right2) {
  17. if (left1 > right1 || left2 > right2) {
  18. return null;
  19. }
  20. TreeNode root = new TreeNode(pre[left1]);
  21. int rootIndex = 0;
  22. for (int i = left2; i <= right2; i++) {
  23. if (in[i] == pre[left1]) {
  24. rootIndex = i;
  25. break;
  26. }
  27. }
  28. int leftSize = rootIndex - left2;
  29. int rightSize = right2 - rootIndex;
  30. root.left = buildTree(pre, left1 + 1, left1 + leftSize, in, left2, left2 + leftSize - 1);
  31. root.right = buildTree(pre, right1 - rightSize + 1, right1, in, rootIndex + 1, right2);
  32. return root;
  33. }
  34. // 2. DFS 寻找右视图结点
  35. private ArrayList<Integer> rightSideView(TreeNode root) {
  36. HashMap<Integer, Integer> hashMap = new HashMap<>();
  37. int count = 0;
  38. LinkedList<TreeNode> stack1 = new LinkedList<>();
  39. LinkedList<Integer> stack2 = new LinkedList<>();
  40. stack1.push(root);
  41. stack2.push(1);
  42. while (!stack1.isEmpty()) {
  43. TreeNode node = stack1.pop();
  44. int curDepth = stack2.pop();
  45. if (node != null) {
  46. count = Math.max(count, curDepth);
  47. if (!hashMap.containsKey(curDepth)) {
  48. hashMap.put(curDepth, node.val);
  49. }
  50. stack1.push(node.left);
  51. stack1.push(node.right);
  52. stack2.push(curDepth + 1);
  53. stack2.push(curDepth + 1);
  54. }
  55. }
  56. ArrayList<Integer> list = new ArrayList<>();
  57. for (int i = 0; i < count; i++) {
  58. list.add(hashMap.get(i + 1));
  59. }
  60. return list;
  61. }
  62. }

专题4:堆、栈和队列结构

BM42. 用两个栈实现队列

具体做法:
元素进栈以后,只能优先弹出末尾元素,但是队列每次弹出的却是最先进去的元素,如果能够将栈中元素全部取出来,才能访问到最前面的元素,此时,另一个栈就起作用了。

  • step 1:push操作就正常push到第一个栈末尾。
  • step 2:pop操作时,优先将第一个栈的元素弹出,并依次进入第二个栈中。
  • step 3:第一个栈中最后取出的元素也就是最后进入第二个栈的元素就是队列首部元素,要弹出,此时在第二个栈中可以直接弹出。
  • step 4:再将第二个中保存的内容,依次弹出,依次进入第一个栈中,这样第一个栈中虽然取出了最里面的元素,但是顺序并没有变。
  1. import java.util.*;
  2. public class Solution {
  3. Stack<Integer> stack1 = new Stack<>();
  4. Stack<Integer> stack2 = new Stack<>();
  5. // 1. push
  6. public void push(int value) {
  7. stack1.push(value);
  8. }
  9. // 2. pop
  10. public int pop() {
  11. while (!stack1.isEmpty()) {
  12. int val = stack1.pop();
  13. stack2.push(val);
  14. }
  15. int topValue = stack2.pop();
  16. while (!stack2.isEmpty()) {
  17. int val = stack2.pop();
  18. stack1.push(val);
  19. }
  20. return topValue;
  21. }
  22. }

BM43. 包含min函数的栈

方法1:双栈法
具体做法:
我们都知道栈结构的push、pop、top操作都是O(1) 的代价,但是min函数做不到,于是想到在push的时候就将最小值记录下来,由于栈先进后出的特殊性,只能同样用栈来记录最小值。

  • step 1:使用一个栈记录进入栈的元素,正常进行push、pop、top操作。
  • step 2:使用另一个栈记录每次push进入的最小值。
  • step 3:每次push元素的时候与第二个栈的栈顶元素比较,若是较小,则进入第二个栈,若是较大,则第二个栈的栈顶元素再次入栈,因为即便加了一个元素,它依然是最小值。于是,每次访问最小值即访问第二个栈的栈顶。
  1. import java.util.*;
  2. public class Solution {
  3. Stack<Integer> stack1 = new Stack<>();
  4. Stack<Integer> minStack = new Stack<>();
  5. public void push(int node) {
  6. stack1.push(node);
  7. // 每次同步新增一个当前最小的值
  8. if (minStack.isEmpty() || minStack.peek() > node) {
  9. minStack.push(node);
  10. } else {
  11. minStack.push(minStack.peek());
  12. }
  13. }
  14. public void pop() {
  15. stack1.pop();
  16. minStack.pop();
  17. }
  18. public int top() {
  19. return stack1.pop();
  20. }
  21. public int min() {
  22. return minStack.peek();
  23. }
  24. }

BM44. 判断是否有效括号序列

方法1:栈
具体做法:
括号的匹配规则应该符合先进后出原理:最外层的括号即最早出现的左括号,也对应最晚出现的右括号,即先进后出,因此使用同样先进后出的栈。

  • step 1:创建辅助栈,遍历字符串。
  • step 2:每次遇到小括号的左括号、中括号的左括号、大括号的左括号,就将其对应的呦括号加入栈中,期待在后续遇到。
  • step 3:如果没有遇到左括号但是栈为空,说明直接遇到了右括号,不合法。
  • step 4:其他情况下,如果遇到右括号,刚好会与栈顶元素相同,弹出栈顶元素继续遍历。
  • step 5:理论上,只要括号是匹配的,栈中元素最后是为空的,因此检查栈是否为空即可最后判断是否合法。
  1. import java.util.*;
  2. public class Solution {
  3. public boolean isValid(String s) {
  4. Stack<Character> stack = new Stack<>();
  5. for (int i = 0; i < s.length(); i++) {
  6. if (s.charAt(i) == '(') {
  7. stack.push(')');
  8. } else if (s.charAt(i) == '[') {
  9. stack.push(']');
  10. } else if (s.charAt(i) == '{') {
  11. stack.push('}');
  12. } else if (stack.isEmpty() || stack.pop() != s.charAt(i)) {
  13. // 栈为空但还后续还有字符;当前字符和栈顶元素不一样
  14. return false;
  15. }
  16. }
  17. // 如果一一匹配,到最后栈不可能还有元素吧
  18. return stack.isEmpty();
  19. }
  20. }

BM45. 滑动窗口的最大值

方法1:单调队列
具体做法:
我们都知道,若是一个数字A进入窗口后,若是比窗口内其他数字都大,那么这个数字之前的数字都没用了,因为它们必定会比A早离开窗口,在A离开之前都争不过A,所以A在进入时要依次从后排除掉前面的小值。而因为窗口符合先进先出的原理,因此可以考虑双向队列。

  • step 1:维护一个双向队列,用来存储数列的下标。
  • step 2:首先检查窗口大小与数组大小。
  • step 3:先遍历第一个窗口,如果即将进入队列的下标的值大于队列后方的值,依次将小于的值拿出来去掉,再加入,保证队列是递增序。
  • step 4:遍历后续窗口,每次取出队首就是最大值,如果某个下标已经过了窗口,则从队列前方将其弹出。
  • step 5:对于之后的窗口,重复step 3,直到数组结束。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:单调递增队列
  4. public static ArrayList<Integer> maxInWindows(int[] num, int size) {
  5. ArrayList<Integer> list = new ArrayList<>();
  6. if (size < 0 || size > num.length) {
  7. return list;
  8. }
  9. Deque<Integer> deque = new ArrayDeque<>();
  10. for (int i = 0; i < size; i++) {
  11. while (!deque.isEmpty() && num[deque.peekLast()] < num[i]) {
  12. deque.pollLast();
  13. }
  14. deque.add(i);
  15. }
  16. for (int i = size; i < num.length; i++) {
  17. list.add(num[deque.peekFirst()]);
  18. while (!deque.isEmpty() && deque.peekFirst() < (i - size + 1)) {
  19. deque.pollFirst();
  20. }
  21. while (!deque.isEmpty() && num[deque.peekLast()] < num[i]) {
  22. deque.pollLast();
  23. }
  24. deque.add(i);
  25. }
  26. list.add(num[deque.pollFirst()]);
  27. return list;
  28. }
  29. }

同样的方法,少写了几行代码。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:单调队列-优化版
  4. public static ArrayList<Integer> maxInWindows(int[] num, int size) {
  5. ArrayList<Integer> list = new ArrayList<>();
  6. if (size < 0 || size > num.length) {
  7. return list;
  8. }
  9. Deque<Integer> deque = new ArrayDeque<>();
  10. for (int i = 0; i < num.length; i++) {
  11. while (!deque.isEmpty() && deque.peekFirst() < i - size + 1) {
  12. deque.pollFirst();
  13. }
  14. while (!deque.isEmpty() && num[deque.peekLast()] < num[i]) {
  15. deque.pollLast();
  16. }
  17. deque.add(i);
  18. if (i >= size - 1) {
  19. list.add(num[deque.peekFirst()]);
  20. }
  21. }
  22. return list;
  23. }
  24. }

方法2:暴力搜索
运行超时。

  1. public class Solution {
  2. // 方法2:暴力搜索
  3. public static ArrayList<Integer> maxInWindows(int[] num, int size) {
  4. ArrayList<Integer> list = new ArrayList<>();
  5. if (size < 0 || size > num.length) {
  6. return list;
  7. }
  8. for (int i = 0; i < num.length - size + 1; i++) {
  9. int maxValue = Integer.MIN_VALUE;
  10. for (int j = i; j < i + size; j++) {
  11. if (maxValue < num[j]) {
  12. maxValue = num[j];
  13. }
  14. }
  15. list.add(maxValue);
  16. }
  17. return list;
  18. }
  19. }

方法3:下标记录法

  1. import java.util.*;
  2. public class Solution {
  3. // 方法3:下标记录法
  4. public static ArrayList<Integer> maxInWindows(int[] num, int size) {
  5. ArrayList<Integer> list = new ArrayList<>();
  6. if (size <= 0 || size > num.length) {
  7. return list;
  8. }
  9. int maxValue = num[0];
  10. int maxIndex = 0;
  11. for (int i = 0; i < size; i++) {
  12. if (maxValue < num[i]) {
  13. maxValue = num[i];
  14. maxIndex = i;
  15. }
  16. }
  17. list.add(maxValue);
  18. for (int i = size; i < num.length; i++) {
  19. if (maxIndex < i - size + 1) {
  20. maxValue = num[i - size + 1];
  21. for (int j = i - size + 1; j <= i; j++) {
  22. if (maxValue < num[j]) {
  23. maxValue = num[j];
  24. maxIndex = j;
  25. }
  26. }
  27. } else {
  28. if (maxValue < num[i]) {
  29. maxValue = num[i];
  30. maxIndex = i;
  31. }
  32. }
  33. list.add(maxValue);
  34. }
  35. return list;
  36. }
  37. }

方法4:滑动窗口
使用两个变量模拟滑动窗口,

  1. import java.util.*;
  2. public class Solution {
  3. // 方法4:滑动窗口
  4. public static ArrayList<Integer> maxInWindows(int[] num, int size) {
  5. ArrayList<Integer> list = new ArrayList<>();
  6. if (size <= 0 || size > num.length) {
  7. return list;
  8. }
  9. int maxIndex = -1;
  10. int maxValue = num[0];
  11. int left = 0;
  12. int right = size - 1;
  13. while (right < num.length) {
  14. if (maxIndex >= left && maxIndex <= right) {
  15. if (maxValue < num[right]) {
  16. maxValue = num[right];
  17. maxIndex = right;
  18. }
  19. } else {
  20. maxValue = num[left];
  21. maxIndex = left;
  22. for (int i = left; i <= right; i++) {
  23. if (maxValue < num[i]) {
  24. maxValue = num[i];
  25. maxIndex = i;
  26. }
  27. }
  28. }
  29. list.add(maxValue);
  30. left++;
  31. right++;
  32. }
  33. return list;
  34. }
  35. }

BM46. 最小的K个数

方法1:堆排序
具体做法:
我们只需要用一个数据结构一直维持k个最小的元素就可以了,而优先队列(大根堆)正好可以满足这个条件。

  • step 1:利用input数组中前k个元素,构建一个大小为k的大顶堆,堆顶为这k 个元素的最大值。
  • step 2:对于后续的元素,依次比较其与堆顶的大小,若是比堆顶小,则堆顶弹出,再将新数加入堆中,直至数组结束,保证堆顶中的k个最小。
  • step 3:最后将堆顶依次弹出即是最小的k个数。 ```java import java.util.*;

public class Solution {

  1. // 方法1:堆排序
  2. public ArrayList<Integer> GetLeastNumbers_Solution(int[] input, int k) {
  3. ArrayList<Integer> res = new ArrayList<>();
  4. if (input.length == 0 || k == 0) {
  5. return res;
  6. }
  7. // 1. 创建大根堆,并初始化添加k 个元素
  8. Queue<Integer> maxHeap = new PriorityQueue<>((o1, o2) -> o2 - o1);
  9. for (int i = 0; i < k; i++) {
  10. maxHeap.offer(input[i]);
  11. }
  12. // 2. 遍历数组,每个元素都逐一和堆顶比较,再添加到大根堆
  13. for (int i = k; i < input.length; i++) {
  14. if (maxHeap.peek() > input[i]) {
  15. maxHeap.poll();
  16. maxHeap.offer(input[i]);
  17. }
  18. }
  19. // 3. 堆中存储的就是最小的k 个元素
  20. for (int i = 0; i < k; i++) {
  21. res.add(maxHeap.poll());
  22. }
  23. return res;
  24. }

}

  1. **方法2:一般排序**<br />其实还有一种更简单的思路,只要对整个数组进行了一次排序,那最小的k个元素不就手到擒来了嘛。
  2. - step 1:优先判断k0或者输入数组长度为0的特殊情况。
  3. - step 2:使用sort函数对整个数组排序。
  4. - step 3:遍历排序后的数组前k个元素即可获取最小的k个。
  5. ```java
  6. import java.util.*;
  7. public class Solution {
  8. public ArrayList<Integer> GetLeastNumbers_Solution(int [] input, int k) {
  9. ArrayList<Integer> res = new ArrayList<Integer>();
  10. if(k == 0 || input.length == 0) //排除特殊情况
  11. return res;
  12. Arrays.sort(input); //排序
  13. for(int i = 0; i < k; i++){ //因为k<=input.length,取前k小
  14. res.add(input[i]);
  15. }
  16. return res;
  17. }
  18. }

BM47. 寻找第K大

方法1:快排+二分法
具体做法:
快速排序:每次移动,可以找到一个标杆元素,然后将大于它的移到左边,小于它的移到右边。然后分别对左边和右边进行排序,不断划分左右子段,直到整个数组有序。放到这道题中,如果标杆元素左边刚好有K-1个比它大的,那么该元素就是第K大,如果它左边的元素比K - 1多,说明第K大在其左边,直接二分,不用管标杆元素右边,同理如果它左边的元素比K-1少,那第K大在其右边,左边不用管。

  • step 1:进行一次快排,大元素在左,小元素在右,得到的中轴p点。
  • step 2:如果 p - low + 1 = k ,那么p点就是第K大。
  • step 3:如果 p - low + 1 > k,则第k大的元素在左半段,更新high = p - 1,执行step 1。
  • step 4:如果 p - low + 1 < k,则第k大的元素在右半段,更新low = p + 1, 且 k = k - (p - low + 1),排除掉前面部分更大的元素,再执行step 1.

代码超时,不想调试了。

  1. public class Solution {
  2. public int findKth(int[] a, int n, int K) {
  3. return quickSort(a, 0, a.length - 1, K);
  4. }
  5. private int quickSort(int[] arr, int left, int right, int k) {
  6. int p = partition(arr, left, right);
  7. if (p == arr.length - k) {
  8. return arr[p];
  9. } else if (p < arr.length - k) {
  10. return quickSort(arr, p + 1, right, k);
  11. } else {
  12. return quickSort(arr, left, p - 1, k);
  13. }
  14. }
  15. private int partition(int[] arr, int left, int right) {
  16. int pivot = arr[left];
  17. while (left < right) {
  18. while (left < right && arr[right] >= pivot) {
  19. right--;
  20. }
  21. arr[left] = arr[right];
  22. while (left < right && arr[left] <= pivot) {
  23. left++;
  24. }
  25. arr[right] = arr[left];
  26. }
  27. arr[left] = pivot;
  28. return left;
  29. }
  30. }

BM48. 数据流中的中位数

方法1:插入排序法
具体做法:
传统的寻找中位数的方法便是排序之后,取中间值或者中间两位的平均即可,但是因为数组在不断增长, 每增长一位便排一次,很浪费时间,于是可以考虑在增加数据的同时将其有序化,这个过程就让我们想到了插入排序:遍历后续数组每个元素时将它插入前面排好序的部分的相应位置。

  • step 1:用一数组存储输入的数据流。
  • step 2:Insert函数在插入的同时,遍历之前存储在数组中的数据,按照递增顺序依次插入,如此一来,加入的数据流便是有序的。
  • step 3:GetMedian函数可以根据下标直接访问中位数,分为数组为奇数个元素和偶数个元素两种情况。记得需要类型转换为double。 ```java import java.util.*;

public class Solution {

  1. private ArrayList<Integer> list = new ArrayList<>();
  2. public void Insert(Integer num) {
  3. if (list.isEmpty()) {
  4. list.add(num);
  5. } else {
  6. // list容器中已有数据,需要插入排序
  7. int i = 0;
  8. for (; i < list.size(); i++) {
  9. if (num <= list.get(i)) {
  10. break;
  11. }
  12. }
  13. list.add(i, num); // 把当前数字插入到list相应位置
  14. }
  15. }
  16. public Double GetMedian() {
  17. int size = list.size();
  18. if (size % 2 == 1) { // 奇数个数字
  19. return (double) list.get(size / 2);
  20. } else { // 偶数个数字
  21. double right = list.get(size / 2);
  22. double left = list.get(size / 2 - 1);
  23. return (right + left) / 2;
  24. }
  25. }

}

  1. **方法二:堆排序(扩展思路)**<br />**具体做法:**<br />除了插入排序,我们换种思路,因为插入排序每次要遍历整个已经有的数组,很浪费时间,有没有什么可以找到插入位置时能够更方便。这是我们想到了堆排序,因此可以使用优先队列。
  2. - step 1:中位数为一个数列的中间两个或一个,也即中位数将数列分成了较小的部分和较大的部分。
  3. - step 2:因此我们可以维护两个堆,分别是大顶堆min,用于存储较小的值,其中顶部最大;小顶堆max,用于存储较小的值,其中顶部最小,则中位数只会在两个堆的堆顶出现。
  4. - step 3:我们可以约定奇数个元素时取大顶堆的顶部值,偶数个元素时取两堆顶的平均值,则可以发现两个堆的数据长度要么是相等的,要么奇数时大顶堆会多一个。
  5. - step 4:每次输入的数据流先进入大顶堆排序,然后将小顶堆的最大值弹入大顶堆中,完成整个的排序。
  6. - step 5:但是因为大顶堆的数据不可能会比小顶堆少一个,因此需要再比较二者的长度,若是小顶堆长度小于大顶堆,需要从大顶堆中弹出最小值到大顶堆中进行平衡。
  7. ```java
  8. import java.util.*;
  9. public class Solution {
  10. // 小顶堆,元素数值都比大顶堆大
  11. private PriorityQueue<Integer> minHeap = new PriorityQueue<>();
  12. // 大顶堆,元素数值较小
  13. private PriorityQueue<Integer> maxHeap = new PriorityQueue<>((o1, o2) -> o2.compareTo(o1));
  14. // 维护两个堆,取两个堆顶部即与中位数相关
  15. public void Insert(Integer num) {
  16. maxHeap.offer(num);
  17. minHeap.offer(maxHeap.poll());
  18. // 平衡两个堆的数量
  19. if (maxHeap.size() < minHeap.size()) {
  20. maxHeap.offer(minHeap.poll());
  21. }
  22. }
  23. public Double GetMedian() {
  24. if (maxHeap.size() > minHeap.size()) { // 奇数个
  25. return (double) maxHeap.peek();
  26. } else {
  27. return (double) (maxHeap.peek() + minHeap.peek()) / 2; //偶数个
  28. }
  29. }
  30. }

BM49. 表达式求值

方法1:栈
具体做法:
对于上述两个要求,我们要考虑的是两点,一是处理运算优先级的问题,二是处理括号的问题。

  • 优先级处理我们可以借助栈,当遇到符号的时候如果是+,正常入栈,如果是-,则将其相反数入栈,如果是*,则将栈中内容弹出与后一个元素相乘再入栈,最后将栈中所有元素相加即可。
  • 括号的处理我们可以借助递归,将括号内的运算视为一个子问题,由此来简化。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:栈
  4. public static int solve(String s) {
  5. // 1. 准备工作:建立栈,字符串首尾去空格
  6. s = s.trim();
  7. Deque<Integer> stack = new ArrayDeque<>();
  8. int number = 0;
  9. char sign = '+';
  10. char[] arr = s.toCharArray();
  11. // 2. 遍历字符串
  12. for (int i = 0; i < arr.length; i++) {
  13. if (arr[i] == ' ') {
  14. continue;
  15. }
  16. // 3. 当前字符如果是数字
  17. if (Character.isDigit(arr[i])) {
  18. number = number * 10 + (arr[i] - '0');
  19. }
  20. // 4. 当前字符如果是左括号
  21. if (arr[i] == '(') {
  22. int j = i + 1;
  23. int counterPartition = 1;
  24. while (counterPartition > 0) {
  25. if (arr[j] == '(') {
  26. counterPartition++;
  27. }
  28. if (arr[j] == ')') {
  29. counterPartition--;
  30. }
  31. j++;
  32. }
  33. // 递归处理子串
  34. number = solve(s.substring(i + 1, j - 1));
  35. i = j - 1;
  36. }
  37. // 5. 处理符号
  38. if (!Character.isDigit(arr[i]) || i == arr.length - 1) {
  39. if (sign == '+') {
  40. stack.push(number);
  41. } else if (sign == '-') {
  42. stack.push(-1 * number);
  43. } else if (sign == '*') {
  44. stack.push(stack.pop() * number);
  45. } else if (sign == '/') {
  46. stack.push(stack.pop() / number);
  47. }
  48. number = 0;
  49. sign = arr[i];
  50. }
  51. }
  52. int ans = 0;
  53. while (!stack.isEmpty()) {
  54. ans += stack.pop();
  55. }
  56. return ans;
  57. }
  58. }

专题5:哈希表

BM50. 两数之和

方法1:哈希表
具体做法:
我们能想到最直观的解法,可能就是两层遍历,将数组所有的二元组合枚举一遍,看看是否是和为目标值,但是这样太费时间了,既然加法这么复杂,我们是不是可以尝试一下减法:对于数组中出现的一个数a,如果目标值减去a的值已经出现过了,那这不就是我们要找的一对元组吗?这种时候,快速找到已经出现过的某个值,可以考虑使用哈希表。

  • step 1:构建一个哈希表,其中key 值为遍历数组过程中出现过的值,value 值为其相应的下标,因为我们最终要返回的是下标。
  • step 2:遍历数组每个元素,如果目标值减去该元素的结果在哈希表中存在,说明我们先前遍历的时候它出现过,根据记录的下标,就可以得到结果。
  • step 3:如果相减后的结果没有在哈希表中,说明先前遍历的元素中没有它对应的另一个值,那我们将它加入哈希表,等待后续它匹配的那个值出现即可。
  • step 4:需要注意最后的结果是下标值加1.

只需要使用一次哈希表,当得到一个补充数,首先在哈希表中查找是否存在,存在的话就刚好得到一对;如果哈希表中不存在,就把它存到哈希表中。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:一次哈希
  4. public int[] twoSum(int[] numbers, int target) {
  5. HashMap<Integer, Integer> map = new HashMap<>();
  6. int[] res = new int[0];
  7. for (int i = 0; i < numbers.length; i++) {
  8. int complement = target - numbers[i];
  9. // 假设有complement 和 numbers[i] 组成一对,
  10. // 首先在哈希表中查找complement 是否出现过,如果出现过,就说明找到了,返回两个元素的索引
  11. // 如果哈希表中没出现过,那就把当前元素和它的索引存到哈希表中,
  12. if (map.containsKey(complement)) {
  13. return new int[]{map.get(complement) + 1, i + 1};
  14. } else {
  15. map.put(numbers[i], i);
  16. }
  17. }
  18. return res;
  19. }
  20. }

BM51. 数组中出现次数超过一半的数字

方法:哈希表(推荐使用)
具体做法:
首先我们分析一下,数组某个元素出现次数超过了数组长度的一半,那它肯定出现最多,而且只要超过了一半,其他数字不可能超过一半了,必定是它
如果给定的数组是有序的,那我们在连续的相同数字中找到出现次数最多即可,但是题目没有要求有序,一种方法是对数组排序后解决,但是时间复杂度就上去了。那我们可以考虑遍历一次数组统计各个元素出现的次数,找到出现次数大于数组长度一半的那个数字。

  • step 1:创建一个哈希表,统计数组元素各自出现了多少次,即key 值为数组元素,value 值为其出现次数。
  • step 2:遍历数组,每遇到一个元素就把哈希表中相应key 值的value 值加1,用来统计出现次数。
  • step 3:本来可以统计完了之后统一遍历哈希表找到频次大于数组长度一半的key 值,但是根据我们上面加粗的点,只要它出现超过了一半,不管后面还有没有,必定就是这个元素了,因此每次统计后,我们都可以检查value 值是否大于数组长度的一半,如果大于则找到了。
  1. import java.util.*;
  2. public class Solution {
  3. public int MoreThanHalfNum_Solution(int[] array) {
  4. HashMap<Integer, Integer> hashmap = new HashMap<>();
  5. for (int i = 0; i < array.length; i++) {
  6. // 如果哈希表中存在,就把出现次数再加1;否则把它第一次添加到哈希表
  7. if (hashmap.containsKey(array[i])) {
  8. int count = hashmap.get(array[i]);
  9. hashmap.put(array[i], count + 1);
  10. } else {
  11. hashmap.put(array[i], 1);
  12. }
  13. if (hashmap.get(array[i]) > array.length / 2) {
  14. return array[i];
  15. }
  16. }
  17. return 0;
  18. }
  19. }

使用JDK8的新语法,getOrDefault 方法。

  1. import java.util.*;
  2. public class Solution {
  3. public int MoreThanHalfNum_Solution(int[] array) {
  4. HashMap<Integer, Integer> hashMap = new HashMap<>();
  5. for (int i = 0; i < array.length; i++) {
  6. int key = array[i];
  7. int value = hashMap.getOrDefault(key, 0);
  8. hashMap.put(key, value + 1);
  9. if (hashMap.get(array[i]) > array.length / 2) {
  10. return array[i];
  11. }
  12. }
  13. return 0;
  14. }
  15. }

BM52. 数组中只出现一次的两个数字

方法1:哈希表
具体做法:
既然有两个数字只出现了一次,我们就统计每个数字的出现次数。

  • step 1:遍历数组,用哈希表统计每个数字出现的频率。
  • step 2:然后再遍历一次数组,对比哈希表,找到出现频率为1的两个数字。
  • step 3:最后整理次序输出。 ```java import java.util.*;

public class Solution { public int[] FindNumsAppearOnce(int[] array) { HashMap map = new HashMap<>(); ArrayList list = new ArrayList<>();

  1. // 1. 哈希表统计每个数字和它出现的次数关系
  2. for (int i = 0; i < array.length; i++) {
  3. if (!map.containsKey(array[i])) {
  4. map.put(array[i], 1);
  5. } else {
  6. map.put(array[i], map.get(array[i]) + 1);
  7. }
  8. }
  9. // 2. 找到只出现1次的两个数字
  10. for (int i = 0; i < array.length; i++) {
  11. if (map.get(array[i]) == 1) {
  12. list.add(array[i]);
  13. }
  14. }
  15. // 3. 排序,生成一个数组返回
  16. if (list.get(0) < list.get(1)) {
  17. return new int[]{list.get(0), list.get(1)};
  18. } else {
  19. return new int[]{list.get(1), list.get(0)};
  20. }
  21. }

}

  1. **方法2:异或运算**<br />**具体做法:**<br />异或运算满足交换率,且相同的数字作异或会被抵消掉,比如:,且任何数字与0异或还是原数字,放到这个题目里面所有数字异或运算就会得到,也即得到了两个只出现一次的数字的异或和。<br />但是我们是要将其分开得到结果的,可以考虑将数组分成两部分,一部分为,另一部分为的样式,怎么划分才能让ab完全分开,而另外的也能刚好成对在一个组呢?这是我们需要考虑的问题。
  2. - step 1:遍历整个数组,将每个元素逐个异或运算,得到。
  3. - step 2:我们可以考虑位运算:的结果中如果二进制第一位是1,则说明ab的第一位二进制不相同,否则则是相同的,从结果二进制的最高位开始遍历,总能找到二进制位为1的情况,因为两个数字不相同,我们就以这一位是否为1来划分上述的两个数组,相同的数字自然会被划分到另一边,而ab也会刚好被分开。
  4. - step 3:遍历数组对分开的数组单独作异或连算。
  5. - step 4:最后整理次序输出。
  6. ```java
  7. import java.util.*;
  8. public class Solution {
  9. // 方法2:异或运算
  10. public int[] FindNumsAppearOnce(int[] array) {
  11. int num1 = 0;
  12. int num2 = 0;
  13. int xor = 0;
  14. // 1. 求出所有数字的异或结果
  15. for (int i = 0; i < array.length; i++) {
  16. xor ^= array[i];
  17. }
  18. // 2. 提取出xor 最右侧的1,用这个数字做筛选器,
  19. int onlyOne = xor & (~xor + 1);
  20. // 3. 把数组中的数字分成两部分,两个只出现一次的数字会被分开放到这两部分
  21. for (int i = 0; i < array.length; i++) {
  22. if ((onlyOne & array[i]) == 0) {
  23. num1 ^= array[i];
  24. } else {
  25. num2 ^= array[i];
  26. }
  27. }
  28. // 4. 排序,返回结果数组
  29. int[] arr = new int[]{num1, num2};
  30. if (arr[0] > arr[1]) {
  31. int temp = arr[0];
  32. arr[0] = arr[1];
  33. arr[1] = temp;
  34. }
  35. return arr;
  36. }
  37. }

BM53. 缺失的第一个正整数

方法1:哈希表
具体做法:
n个长度的数组,没有重复,则如果数组填满了1~n,那么缺失n+1,如果数组填不满1~n,那么缺失的就是1~n中的数字。对于这种快速查询某个元素是否出现过的问题,还是可以使用哈希表。

  • step 1:使用unordered_map构建一个哈希表,用于记录数组中出现的数字。
  • step 2:从1开始,遍历到n,查询哈希表中是否有这个数字,如果没有,说明它就是数组缺失的第一个正整数,即找到。
  • step 3:如果遍历到最后都在哈希表中出现过了,那缺失的就是n+1.
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:哈希表
  4. public int minNumberDisappeared(int[] nums) {
  5. HashMap<Integer, Integer> hashMap = new HashMap<>();
  6. for (int i = 0; i < nums.length; i++) {
  7. hashMap.put(nums[i], i);
  8. }
  9. int res = 1;
  10. while (hashMap.containsKey(res)) {
  11. res++;
  12. }
  13. return res;
  14. }
  15. }

方法2:原地哈希
前面提到了数组要么缺失1~n中的某个数字,要么缺失n+1。

  • step 1:我们可以先遍历数组将所有的负数都修改成n+1。
  • step 2:然后再遍历数组,每当遇到一个元素绝对值不超过n时,则表示这个元素是1~n中出现的元素,我们可以将这个数值对应的下标里的元素改成负数,相当于每个出现过的正整数的下标都指向一个负数,这就是类似哈希表的实现原理的操作
  • step 3:最后遍历数组的时候碰到的第一个非负数的下标就是没有出现的第一个正整数,因为它在之前的过程中没有被修改,说明它这个下标的正整数没有出现过。

2022.07.07 这个代码太绕了,暂时不用掌握。注意第2步中,一定要用Math.abs 方法,因为前面可能改动过当前元素的值了。
1657201315425.jpg

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:原地哈希
  4. // 有点绕,暂时不研究
  5. public int minNumberDisappeared(int[] nums) {
  6. int len = nums.length;
  7. // 1. 值为负数和0 的,全部修改为一个固定的数
  8. for (int i = 0; i < len; i++) {
  9. if (nums[i] <= 0) {
  10. nums[i] = len + 1;
  11. }
  12. }
  13. // 2. 原数组创建哈希关系
  14. for (int i = 0; i < len; i++) {
  15. if (Math.abs(nums[i]) <= len) {
  16. int ref = Math.abs(nums[i]) - 1;
  17. nums[ref] = -1 * Math.abs(nums[ref]);
  18. }
  19. }
  20. // 3. 找到第一个正数,返回下标加一
  21. for (int i = 0; i < len; i++) {
  22. if (nums[i] > 0) {
  23. return i + 1;
  24. }
  25. }
  26. return len + 1;
  27. }
  28. }

BM54. 三数之和

方法1:双指针
具体做法:

  • step 1:排除边界特殊情况。
  • step 2:既然三元组内部要求非降序排列,那我们先得把这个无序的数组搞有序了,使用sort函数优先对其排序。
  • step 3:得到有序数组后,遍历该数组,对于每个遍历到的元素假设它是三元组中最小的一个,那么另外两个一定在后面。
  • step 4:需要三个数相加为0,则另外两个数相加应该为上述第一个数的相反数,我们可以利用双指针在剩余的子数组中找有没有这样的数对。双指针指向剩余子数组的首尾,如果二者相加为目标值,那么可以记录,而且二者中间的数字相加可能还会有;如果二者相加大于目标值,说明右指针太大了,那就将其左移缩小,相反如果二者相加小于目标值,说明左指针太小了,将其右移扩大,直到两指针相遇,剩余子数组找完了。

注:对于三个数字都要判断是否相邻有重复的情况,要去重。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:双指针
  4. public ArrayList<ArrayList<Integer>> threeSum(int[] num) {
  5. ArrayList<ArrayList<Integer>> res = new ArrayList<>();
  6. int len = num.length;
  7. if (len < 3) {
  8. return res;
  9. }
  10. // 1. 先排序
  11. Arrays.sort(num);
  12. for (int i = 0; i < len - 2; i++) {
  13. // 2. 跳过重复的
  14. if (i != 0 && num[i] == num[i - 1]) {
  15. continue;
  16. }
  17. // 3. 两个指针,目标和
  18. int left = i + 1;
  19. int right = len - 1;
  20. int target = -num[i];
  21. // 4. 遍历有序数组
  22. while (left < right) {
  23. // 5. 找到一组三数之和为0
  24. if (num[left] + num[right] == target) {
  25. // 6. 组装并添加到结果集
  26. ArrayList<Integer> temp = new ArrayList<>();
  27. temp.add(num[i]);
  28. temp.add(num[left]);
  29. temp.add(num[right]);
  30. res.add(temp);
  31. // 7. 跳过重复元素
  32. while (left + 1 < right && num[left] == num[left + 1]) {
  33. left++;
  34. }
  35. while (right - 1 > left && num[right] == num[right - 1]) {
  36. right--;
  37. }
  38. left++;
  39. right--;
  40. } else if (num[left] + num[right] < target) {
  41. left++;
  42. } else {
  43. right--;
  44. }
  45. }
  46. }
  47. return res;
  48. }
  49. }

专题6:回溯法

BM55. 没有重复项数字的全排列

方法1:回溯法
这道题目就是很典型的回溯类题目。
回溯其实也是暴力解法,但是又一些题目可以通过剪枝对算法进行优化,这道题目要找出所有的排列,其实还是比较简单的。
算法的思路主要就是:选择与撤销
例如:1开头的有,[1,2,3],接着3撤销,2撤销,然后选择3,再选择2,就有了[1,3,2]。
整体用一个图来观看整个过程
牛客网面试必刷TOP101 - 图16

递归版1:

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:回溯法-递归版1
  4. public static ArrayList<ArrayList<Integer>> permute(int[] num) {
  5. ArrayList<Integer> temp = new ArrayList<>();
  6. ArrayList<ArrayList<Integer>> list = new ArrayList<>();
  7. if (num == null) {
  8. return list;
  9. }
  10. boolean[] visited = new boolean[num.length];
  11. traceback(num, visited, temp, list);
  12. return list;
  13. }
  14. private static void traceback(int[] arr, boolean[] visited, ArrayList<Integer> temp, ArrayList<ArrayList<Integer>> list) {
  15. if (temp.size() == arr.length) {
  16. list.add(new ArrayList<>(temp));
  17. return;
  18. }
  19. for (int i = 0; i < arr.length; i++) {
  20. if (visited[i]) {
  21. continue;
  22. }
  23. visited[i] = true;
  24. temp.add(arr[i]);
  25. traceback(arr, visited, temp, list);
  26. visited[i] = false;
  27. temp.remove(temp.size() - 1);
  28. }
  29. }
  30. }

递归版2:

  • step 1:如何保证每个元素能和从自己开始后的每个元素都交换位置,这种时候我们可以考虑递归。为什么可以使用递归?我们可以看数组[1,2,3,4],如果遍历经过一个元素2以后,那就相当于我们确定了数组到该元素为止的前半部分,前半部分1和2的位置都不用变了,只需要对3,4进行排列,这对于后半部分而言同样是一个全排列,同样要对从每个元素开始往后交换位置,因此后面部分就是一个子问题。那我们考虑递归的几个条件:
    • 终止条件: 要交换位置的下标到了数组末尾,没有可交换的了,那这就构成了一个排列情况,可以加入输出数组。
    • 返回值: 每一级的子问题应该把什么东西传递给父问题呢,这个题中我们是交换数组元素位置,前面已经确定好位置的元素就是我们返还给父问题的结果,后续递归下去会逐渐把整个数组位置都确定,形成一种排列情况。
    • 本级任务: 每一级需要做的就是遍历从它开始的后续元素,每一级就与它交换一次位置。
  • step 2:如果只是使用递归,我们会发现,上例中的1与3交换位置以后,如果2再与4交换位置的时候,我们只能得到3412这种排列,无法得到1432这种情况。这是因为遍历的时候1与3交换位置在2与4交换位置前面,递归过程中就默认将后者看成了前者的子问题,但是其实我们1与3没有交换位置的情况都没有结束,相当于上述图示中只进行了第一个分支。因此我们用到了回溯。处理完1与3交换位置的子问题以后,我们再将其交换回原来的情况,相当于上述图示回到了父节点,那后续完整的情况交换我们也能得到。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:回溯法-递归版
  4. public static ArrayList<ArrayList<Integer>> permute(int[] num) {
  5. ArrayList<ArrayList<Integer>> list = new ArrayList<>();
  6. ArrayList<Integer> temp = new ArrayList<>();
  7. for (int i = 0; i < num.length; i++) {
  8. temp.add(num[i]);
  9. }
  10. traceback(temp, list, 0);
  11. return list;
  12. }
  13. private static void traceback(ArrayList<Integer> temp, ArrayList<ArrayList<Integer>> list, int index) {
  14. if (index == temp.size() - 1) {
  15. list.add(temp);
  16. return;
  17. }
  18. for (int i = index; i < temp.size(); i++) {
  19. swap(temp, i, index);
  20. traceback(temp, list, index + 1);
  21. swap(temp, i, index);
  22. }
  23. }
  24. private static void swap(ArrayList<Integer> list, int i, int j) {
  25. int tmp = list.get(i);
  26. list.set(i, list.get(j));
  27. list.set(j, tmp);
  28. }
  29. }

方法2:回溯法-非递归版
变量太多,有点看不懂。
2022.06.19,这个回溯法非递归版本的代码看着头晕,根本看不懂。。。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:回溯法-非递归版
  4. // 2022.06.19 这个代码太复杂了,看不懂
  5. public static ArrayList<ArrayList<Integer>> permute(int[] num) {
  6. ArrayList<ArrayList<Integer>> res = new ArrayList<>();
  7. res.add(new ArrayList<>());
  8. for (int i = 0; i < num.length; i++) {
  9. ArrayList<ArrayList<Integer>> tmp = new ArrayList<>();
  10. for (ArrayList<Integer> list : res) {
  11. for (int j = 0; j < list.size() + 1; j++) {
  12. list.add(j, num[i]);
  13. tmp.add(new ArrayList<>(list));
  14. list.remove(j);
  15. }
  16. }
  17. res = new ArrayList<>(tmp);
  18. }
  19. return res;
  20. }
  21. }

BM56. 有重复项数字的全排列

方法1:回溯法
具体做法:
这道题类似没有重复项数字的全排列,但是因为交换位置可能会出现重复交换的情况,出现的结果需要去重,因此不便于使用交换位置的方法。

  • step 1:我们就使用临时变量去组装一个排列的情况:每当我们选取一个数组元素以后,就确定了其位置,相当于对数组中剩下的元素进行全排列添加在该元素后面,给剩余部分进行全排列就是一个子问题,因此可以使用递归
    • 终止条件: 临时数组中选取了n个元素,已经形成了一种排列情况了,可以将其加入输出数组中。
    • 返回值: 每一次给上一层返回的就是本层级在临时数组中添加的元素,递归到末尾的时候就能添加全部元素。
    • 本级任务: 每一级都需要选择一个元素加入到临时数组末尾(遍历数组选择)。首先已经加入的元素不能再次加入了,因此我们需要使用额外的vis数组用于记录哪些位置的数字被加入了。同时为了去除重复元素的影响,如果当前的元素num[i]与同一层的前一个元素num[i-1]相同且num[i-1]已经用过了,也不需要将其纳入。
  • step 2:回溯的思想也与没有重复项数字的全排列类似,对于数组[1,2,2,3],如果事先在临时数组中加入了1,后续子问题只能是[2,2,3]的全排列接在1后面,对于2开头的分支达不到,因此也需要回溯:将临时数组刚刚加入的数字pop掉,同时vis修改为没有加入,这样才能正常进入别的分支。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:回溯法
  4. public static ArrayList<ArrayList<Integer>> permuteUnique(int[] num) {
  5. ArrayList<ArrayList<Integer>> list = new ArrayList<>();
  6. ArrayList<Integer> temp = new ArrayList<>();
  7. boolean[] visited = new boolean[num.length];
  8. Arrays.sort(num);
  9. traceback(num, visited, temp, list);
  10. return list;
  11. }
  12. private static void traceback(int[] arr, boolean[] visited, ArrayList<Integer> list, ArrayList<ArrayList<Integer>> res) {
  13. if (list.size() == arr.length) {
  14. res.add(new ArrayList<>(list));
  15. return;
  16. }
  17. for (int i = 0; i < arr.length; i++) {
  18. if (visited[i]) {
  19. continue;
  20. }
  21. if (i > 0 && arr[i - 1] == arr[i] && !visited[i - 1]) {
  22. continue;
  23. }
  24. visited[i] = true;
  25. list.add(arr[i]);
  26. traceback(arr, visited, list, res);
  27. visited[i] = false;
  28. list.remove(list.size() - 1);
  29. }
  30. }
  31. }

BM57. 岛屿数量

给一个01矩阵,1代表是陆地,0代表海洋, 如果两个1相邻,那么这两个1属于同一个岛。我们只考虑上下左右为相邻。
岛屿: 相邻陆地可以组成一个岛屿(相邻:上下左右) 判断岛屿个数。

方法1:DFS
具体做法:
矩阵中多处聚集着1,要想统计1聚集的堆数而不重复统计,那我们可以考虑每次找到一堆相邻的1,就将其全部改成0,而将所有相邻的1改成0的步骤又可以使用深度优先搜索(dfs),因此具体做法如下:

  • step 1:优先判断空矩阵等情况。
  • step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
  • step 3:使用dfs将遍历矩阵遇到的1以及相邻的1全部置为0。

至于dfs具体怎么操作,我们接着看。当我们遇到矩阵的某个元素为1时,首先将其置为了0,然后查看与它相邻的上下左右四个方向,如果这四个方向相邻元素为1,则进入该元素,进入该元素之后我们发现又回到了刚刚的子问题,又是把这一片相邻区域的1全部置为0,因此可以用递归实现。

  • 终止条件: 进入某个元素修改其值为0后,遍历四个方向发现周围都没有1,那就不用继续递归,返回即可,或者递归到矩阵边界也同样可以结束。
  • 返回值: 每一级的子问题就是把修改后的矩阵返回,因为其是函数引用,也不用管。
  • 本级任务: 对于每一级任务就是将该位置的元素置为0,然后查询与之相邻的四个方向,看看能不能进入子问题。

遍历数组中的每一个值,如果是1就说明是岛屿,然后把它置为0或者其他的字符都可以,只要不是1就行,然后再遍历他的上下左右4个邻接位置。如果是1,说明这两个岛屿是连着的,只能算是一个岛屿,我们还要把它置为0,然后再以它为中心遍历他的上下左右4个位置……;如果是0,就说明不是岛屿,就不在往他的上下左右4个位置遍历了。
这里和回溯不同的是:设置了以后不会恢复原状。当设置(i,j)位置为0时,会使邻接的整片海岛都会被设置为0,就像整个海岛下沉到海底一样。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:DFS
  4. public int solve(char[][] grid) {
  5. if (grid == null || grid.length == 0) {
  6. return 0;
  7. }
  8. int m = grid.length;
  9. int n = grid[0].length;
  10. int count = 0;
  11. for (int i = 0; i < m; i++) {
  12. for (int j = 0; j < n; j++) {
  13. if (grid[i][j] == '1') {
  14. count++;
  15. dfs(grid, i, j);
  16. }
  17. }
  18. }
  19. return count;
  20. }
  21. private void dfs(char[][] arr, int row, int col) {
  22. if (row < 0 || row >= arr.length || col < 0 || col >= arr[0].length) {
  23. return;
  24. }
  25. if (arr[row][col] == '0') {
  26. return;
  27. }
  28. arr[row][col] = '0';
  29. dfs(arr, row, col + 1);
  30. dfs(arr, row + 1, col);
  31. dfs(arr, row, col - 1);
  32. dfs(arr, row - 1, col);
  33. }
  34. }

方法2:BFS
DFS就是沿着一条路径一直走下去,当遇到终止条件的时候才会返回,而BFS就是先把当前位置附近的访问一遍,以当前位置为中心画一个圈,然后再把圈放大继续访问,就像水滴在水面,泛起一圈涟漪。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:BFS
  4. public int solve(char[][] grid) {
  5. if (grid == null || grid.length == 0) {
  6. return 0;
  7. }
  8. int m = grid.length;
  9. int n = grid[0].length;
  10. int count = 0;
  11. for (int i = 0; i < m; i++) {
  12. for (int j = 0; j < n; j++) {
  13. if (grid[i][j] == '1') {
  14. count++;
  15. bfs(grid, i, j);
  16. }
  17. }
  18. }
  19. return count;
  20. }
  21. private void bfs(char[][] arr, int i, int j) {
  22. int m = arr.length;
  23. int n = arr[0].length;
  24. LinkedList<Integer> queue1 = new LinkedList<>();
  25. LinkedList<Integer> queue2 = new LinkedList<>();
  26. queue1.offer(i);
  27. queue2.offer(j);
  28. arr[i][j] = '0';
  29. while (!queue1.isEmpty()) {
  30. int row = queue1.poll();
  31. int col = queue2.poll();
  32. if (col + 1 < n && arr[row][col + 1] == '1') {
  33. queue1.offer(row);
  34. queue2.offer(col + 1);
  35. arr[row][col + 1] = '0';
  36. }
  37. if (row + 1 < m && arr[row + 1][col] == '1') {
  38. queue1.offer(row + 1);
  39. queue2.offer(col);
  40. arr[row + 1][col] = '0';
  41. }
  42. if (col - 1 >= 0 && arr[row][col - 1] == '1') {
  43. queue1.offer(row);
  44. queue2.offer(col - 1);
  45. arr[row][col - 1] = '0';
  46. }
  47. if (row - 1 >= 0 && arr[row - 1][col] == '1') {
  48. queue1.offer(row - 1);
  49. queue2.offer(col);
  50. arr[row - 1][col] = '0';
  51. }
  52. }
  53. }
  54. }

BM58. 字符串的排列

方法1:回溯
都是求元素的全排列,字符串与数组没有区别,一个是数字全排列,一个是字符全排列,因此大致思路与有重复数字的全排列类似,只是这道题输出顺序没有要求。但是为了便于去掉重复情况,我们还是应该参照这道数组全排列,优先按照字典序排序,因此排序后重复的字符就会相邻,后续递归找起来也很方便。

算法流程:

  • step 1:依据数组的全排列,使用临时变量去组装一个排列的情况:每当我们选取一个字符以后,就确定了其位置,相当于对字符串中剩下的元素进行全排列添加在该元素后面,给剩余部分进行全排列就是一个子问题,因此可以使用递归
    • 终止条件: 临时字符串中选取了n个元素,已经形成了一种排列情况了,可以将其加入结果集中。
    • 返回值: 每一层给上一层返回的就是本层级在临时字符串中添加的元素,递归到末尾的时候就能添加全部元素。
    • 本级任务: 每一级都需要选择一个元素加入到临时字符串末尾(遍历原字符串选择)。首先已经加入的元素不能再次加入了,因此我们需要使用额外的vis数组用于记录哪些位置的字符被加入了。同时为了去除重复元素的影响,如果当前的元素str[i]与同一层的前一个元素str[i-1]相同且str[i-1]已经用过了,也不需要将其纳入。
  • step 2:递归过程也需要回溯,比如说对于字符串“abbc”,如果事先在临时字符串中加入了a,后续子问题只能是”bbc”的全排列接在a后面,对于b开头的分支达不到,因此也需要回溯:将临时字符串刚刚加入的字符去掉,同时vis修改为没有加入,这样才能正常进入别的分支。

有点搞笑了,6月份还能运行通过,7月8号却提示运行时间过长。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:回溯法
  4. public ArrayList<String> Permutation(String str) {
  5. ArrayList<String> res = new ArrayList<>();
  6. if (str == null || str.length() == 0) {
  7. return res;
  8. }
  9. char[] arr = str.toCharArray();
  10. boolean[] visited = new boolean[arr.length];
  11. StringBuilder sb = new StringBuilder();
  12. Arrays.sort(arr);
  13. traceback(arr, visited, sb, res);
  14. return res;
  15. }
  16. private void traceback(char[] arr, boolean[] visited, StringBuilder sb, ArrayList<String> res) {
  17. if (sb.length() == arr.length) {
  18. res.add(new String(sb));
  19. return;
  20. }
  21. for (int i = 0; i < arr.length; i++) {
  22. if (visited[i]) {
  23. continue;
  24. }
  25. if (i - 1 >= 0 && arr[i - 1] == arr[i] && !visited[i - 1]) {
  26. continue;
  27. }
  28. visited[i] = true;
  29. sb.append(arr[i]);
  30. traceback(arr, visited, sb, res);
  31. visited[i] = false;
  32. sb.deleteCharAt(sb.length() - 1);
  33. }
  34. }
  35. }

BM59. N皇后问题

N 皇后问题是指在 n * n 的棋盘上要摆 n 个皇后,
要求:任何两个皇后不同行,不同列也不在同一条斜线上,
求给一个整数 n ,返回 n 皇后的摆法数。

方法1:递归
具体做法:
n个皇后,不同行不同列,那么肯定棋盘每行都会有一个皇后,每列都会有一个皇后。

  • step 1:对于第一行,皇后可能出现在该行的任意一列,我们用一个数组pos记录皇后出现的位置,下标为行号,元素值为列号。
  • step 2:如果皇后出现在第一列,那么第一行的皇后位置就确定了,相当于在剩余的n-1行中找n-1个皇后的位置,这就是一个子问题,因此使用递归。
    • 终止条件: 当最后一行都被选择了位置,说明n个皇后位置齐了,增加一种方案数返回。
    • 返回值: 每一级要将选中的位置及方案数返回。
    • 本级任务: 每一级其实就是在该行选择一列作为该行皇后的位置,遍历所有的列选择一个符合条件的位置加入数组,然后进入下一级。
  • step 3:每个子问题检查是否符合条件,我们可以对比所有已经记录的行,对其记录的列号查看与当前行列号的关系:即是否同行、同列或是同一对角线。

这个代码和左神的一样,比一般的方法要简洁,但是不容易理解,目前也是理解了大概而已。

  1. public class Solution {
  2. public static int Nqueen(int n) {
  3. // 相当于哈希表:数组下标表示行号,数组元素表示列号,记录皇后位置
  4. int[] pos = new int[n];
  5. int res = recursion(n, 0, pos);
  6. return res;
  7. }
  8. public static int recursion(int n, int row, int[] pos) {
  9. // 1. 终止条件
  10. if (row == n) { // 全部行都选择了位置
  11. return 1;
  12. }
  13. int res = 0;
  14. // 2. 遍历所有列,开始尝试摆放
  15. // 第row 行的皇后,放在第i 列
  16. for (int col = 0; col < n; col++) {
  17. if (isValid(pos, row, col)) { // 3. 检查(row,col)位置是否符合条件
  18. pos[row] = col; // 4. 记录皇后位置 (row,col)
  19. res += recursion(n, row + 1, pos); // 5. 递归继续查找
  20. }
  21. }
  22. return res;
  23. }
  24. // 判断皇后是否符合条件
  25. public static boolean isValid(int[] pos, int row, int col) {
  26. for (int i = 0; i < row; i++) { // 遍历记录表pos数组
  27. // 不能同行、同列、同一斜线
  28. if (i == row || pos[i] == col || Math.abs(row - i) == Math.abs(col - pos[i])) {
  29. return false;
  30. }
  31. }
  32. return true;
  33. }
  34. }

BM60. 括号生成

方法1:递归
具体做法:
相当于一共n个左括号和n个右括号,可以给我们使用,我们需要依次组装这些括号。

  • step 1:如果使用了一个左括号以后,那么还剩下n-1个左括号和n个右括号,也是将这些括号连接成一个字符串,就相当于是原问题的子问题,因此我们使用递归。
  • step 2:但是这样递归不能保证括号一定合法,我们需要保证左括号出现的次数比右括号多时我们再使用右括号就一定能保证括号合法了,因此每次需要检查左括号和右括号的使用次数。
    • 终止条件: 左右括号都使用了n个,将结果加入数组。
    • 返回值: 每一级向上一级返回后续组装后的字符串,即子问题中搭配出来的括号序列。
    • 本级任务: 每一级就是保证左括号还有剩余的情况下,使用一次左括号进入子问题,或者右括号还有剩余且右括号使用次数少于左括号的情况下使用一次右括号进入子问题。

这里有个问题:为什么把str 单独提取出来赋值后,代码无法通过。

  1. import java.util.*;
  2. public class Solution {
  3. public ArrayList<String> generateParenthesis(int n) {
  4. ArrayList<String> res = new ArrayList<>();
  5. recursion(n, 0, 0, "", res);
  6. return res;
  7. }
  8. private void recursion(int n, int left, int right, String str, ArrayList<String> res) {
  9. // 1. 终止条件
  10. if (left == n && right == n) {
  11. res.add(str);
  12. return;
  13. }
  14. // 2. 本级处理。使用一次左括号,或者使用一次右括号(在右括号数小于左括号的情况下,这样才能形成正确的括号对)
  15. if (left < n) {
  16. // 没想明白,这样写就会报错,代码不通过
  17. // str = str + "(";
  18. // recursion(n, left + 1, right, str, res);
  19. recursion(n, left + 1, right, str + "(", res);
  20. }
  21. if (right < n && right < left) {
  22. recursion(n, left, right + 1, str + ")", res);
  23. }
  24. }
  25. }

BM61. 矩阵最长递增路径

方法1:DFS深度优先搜索
具体做法:

  • step 1:既然是查找最长的递增路径长度,那我们首先要找到这个路径的起点,起点不好直接找到,就从上到下从左到右遍历矩阵的每个元素。
  • step 2:然后以每个元素都可以作为起点查找它能到达的最长递增路径,然后维护一个最大值。
  • step 3:如何查找以某个点为起点的最长递增路径呢?我们可以考虑递归,因此我们查找递增路径的时候,每次选中路径一个点,然后找到与该点相邻的递增位置,相当于进入这个相邻的点,继续查找递增路径,这就是递归的子问题。因此递归过程如下:
    • 终止条件: 进入路径最后一个点后,四个方向要么是矩阵边界,要么没有递增的位置,路径不能再增长,返回上一级。
    • 返回值: 每次返回的就是本级之后的子问题中查找到的路径长度加上本级的长度。
    • 本级任务: 每次进入一级子问题,先初始化后续路径长度为0,然后遍历四个方向(可以用数组表示,下标对数组元素的加减表示去往四个方向),进入符合不是边界且在递增的邻近位置作为子问题,查找子问题中的递增路径长度。因为有四个方向,所以最多有四种递增路径情况,因此要维护当级子问题的最大值。
  • step 4:使用一个dp数组记录i,j处的单元格拥有的最长递增路径,这样在递归过程中如果访问到就不需要重复访问。

感觉这DFS熟悉了以后比二叉树那些简单很多,写代码也没有那么多细节需要考虑,链表和二叉树的代码对细节要求太苛刻了。
这道题和岛屿数量很像,都需要遍历整个数组,以数组当前遍历到的元素为起点,开始进行DFS,当到达边界或者满足题目条件的时候返回。
2022.06.08 今天再看题目,完全没有思路,哎,好难呀。

  1. public class Solution {
  2. public int solve(int[][] matrix) {
  3. int max = 0;
  4. // 依次访问二维数组每个当前元素[i,j],得到以该位置为起点的最长递增路径,选择所有点的最长路径中最长的
  5. for (int i = 0; i < matrix.length; i++) {
  6. for (int j = 0; j < matrix[i].length; j++) {
  7. int res = dfs(matrix, i, j, -1);
  8. max = Math.max(max, res);
  9. }
  10. }
  11. return max;
  12. }
  13. // 从数组的(i,j)位置开始搜索,返回最长路径的长度
  14. private int dfs(int[][] matrix, int i, int j, int pre) {
  15. // 1. 终止条件。边界条件剪枝
  16. // 这几个判断可以分开加到后面每个dfs外层,
  17. // 如果像这样归纳在一起,那就必须放在第二个basecase 之前,不然matrix[i][j] 就会出现数组越界错误
  18. if (i < 0 || i >= matrix.length || j < 0 || j >= matrix[0].length) {
  19. return 0;
  20. }
  21. // 2. 终止条件。当前元素小于上一个元素,返回
  22. if (matrix[i][j] <= pre) {
  23. return 0;
  24. }
  25. // 3. DFS。向四个方向探索,刷新最大值
  26. int len = 0;
  27. int max = 0;
  28. len = dfs(matrix, i - 1, j, matrix[i][j]);
  29. max = Math.max(max, len);
  30. len = dfs(matrix, i, j + 1, matrix[i][j]);
  31. max = Math.max(max, len);
  32. len = dfs(matrix, i + 1, j, matrix[i][j]);
  33. max = Math.max(max, len);
  34. len = dfs(matrix, i, j - 1, matrix[i][j]);
  35. max = Math.max(max, len);
  36. // 4. 返回值加一
  37. return max + 1;
  38. }
  39. }

方法2:DFS + 缓存表

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:DFS + 缓存表
  4. private int[][] paths;
  5. public int solve(int[][] matrix) {
  6. int maxLen = 0;
  7. if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
  8. return maxLen;
  9. }
  10. paths = new int[matrix.length][matrix[0].length];
  11. for (int i = 0; i < matrix.length; ++i) {
  12. for (int j = 0; j < matrix[0].length; ++j) {
  13. int len = searchMaxPath(matrix, i, j, -1);
  14. maxLen = Math.max(len, maxLen);
  15. }
  16. }
  17. return maxLen;
  18. }
  19. private int searchMaxPath(int[][] matrix, int row, int col, int pre) {
  20. if (row < 0 || col < 0 || row >= matrix.length || col >= matrix.length) {
  21. return 0;
  22. }
  23. if (matrix[row][col] <= pre) {
  24. return 0;
  25. }
  26. // 首先尝试从缓存表中读取,如果有记录就取出并返回,没有才继续下面的代码
  27. if (paths[row][col] > 0) {
  28. return paths[row][col];
  29. }
  30. int maxLen = 0;
  31. int len = 0;
  32. len = searchMaxPath(matrix, row - 1, col, matrix[row][col]);
  33. maxLen = Math.max(len, maxLen);
  34. len = searchMaxPath(matrix, row, col + 1, matrix[row][col]);
  35. maxLen = Math.max(len, maxLen);
  36. len = searchMaxPath(matrix, row + 1, col, matrix[row][col]);
  37. maxLen = Math.max(len, maxLen);
  38. len = searchMaxPath(matrix, row, col - 1, matrix[row][col]);
  39. maxLen = Math.max(len, maxLen);
  40. paths[row][col] = maxLen + 1;
  41. return paths[row][col];
  42. }
  43. }

方法3:BFS 广度优先搜索
具体做法:

  • step 1:我们可以将矩阵看成是一个有向图,计算每个结点(单元格)所对应的出度(符合边界条件且递增)。对于作为边界条件的单元格,它的值比所有的相邻单元格的值都要大,因此作为边界条件的单元格的出度都为0。利用一个二维矩阵记录每个单元格的出度
  • step 2:利用拓扑排序的思想,从所有出度为0的单元格开始进行广度优先搜索,借助队列来广度优先搜索,队列中每次加入出度为0的点,即路径最远点,每次从A点到B点,便将A点出度减一。
  • step 3:每次搜索都会遍历当前层的所有单元格,更新其余单元格的出度,并将出度变为0的单元格加入下一层搜索。
  • step 4:当搜索结束时,搜索的总层数即为矩阵中的最长递增路径的长度。
  1. import java.util.*;
  2. public class Solution {
  3. // 记录四个方向
  4. private int[][] dirs = new int[][]{{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
  5. private int n, m;
  6. public int solve(int[][] matrix) {
  7. if (matrix.length == 0 || matrix[0].length == 0) {
  8. return 0;
  9. }
  10. int res = 0;
  11. n = matrix.length;
  12. m = matrix[0].length;
  13. int[][] outdegrees = new int[m + 1][n + 1]; // i,j处的单元格拥有的最长递增路径
  14. for (int i = 0; i < n; i++) {
  15. for (int j = 0; j < m; j++) {
  16. for (int k = 0; k < 4; k++) {
  17. int nexti = i + dirs[k][0];
  18. int nextj = j + dirs[k][1];
  19. if (nexti >= 0 && nexti < n && nextj >= 0 && nextj < m && matrix[nexti][nextj] > matrix[i][j]) {
  20. outdegrees[i][j]++; // 符合条件,记录出度
  21. }
  22. }
  23. }
  24. }
  25. Queue<Integer> q1 = new LinkedList<>(); // 辅助队列
  26. Queue<Integer> q2 = new LinkedList<>();
  27. for (int i = 0; i < n; i++) {
  28. for (int j = 0; j < m; j++) {
  29. if (outdegrees[i][j] == 0) {
  30. q1.offer(i); // 找到出度为0的入队列
  31. q2.offer(j);
  32. }
  33. }
  34. }
  35. while (!q1.isEmpty()) {
  36. res++;
  37. int size = q1.size();
  38. for (int x = 0; x < size; x++) {
  39. int i = q1.poll();
  40. int j = q2.poll();
  41. for (int k = 0; k < 4; k++) { // 四个方向
  42. int nexti = i + dirs[k][0];
  43. int nextj = j + dirs[k][1];
  44. // 逆向搜索,所以下一步是小于
  45. if (nexti >= 0 && nexti < n && nextj >= 0 && nextj < m && matrix[nexti][nextj] < matrix[i][j]) {
  46. outdegrees[nexti][nextj]--; // 符合条件,出度递减
  47. if (outdegrees[nexti][nextj] == 0) {
  48. q1.offer(nexti);
  49. q2.offer(nextj);
  50. }
  51. }
  52. }
  53. }
  54. }
  55. return res;
  56. }
  57. }

专题7:动态规划

BM62. 斐波那契数列

方法1:递归
斐波那契数列是: 1, 1, 2, 3,5, 8, 13,为了计算前面多了一个第0位,值是0.

  1. public int Fibonacci(int n) {
  2. if (n <= 1) {
  3. return n;
  4. }
  5. return Fibonacci(n - 1) + Fibonacci(n - 2);
  6. }
  7. }

方法2:动态规划。
既然是数列,我们就把它放入数组中来解决。

  • step 1:创建一个长度为n + 1的数组,因为只有n + 1才能有下标第n 项,我们用它记录前n 项斐波那契数列。
  • step 2:根据公式,初始化第0项和第1项(题目中是第1项和第2项,本质上一样的)。
  • step 3:遍历数组,依照公式某一项等于前两项之和,将数组后续元素补齐,即可得到 fib[n]
  1. public class Solution {
  2. public int Fibonacci(int n) {
  3. // 1. 第0项是0,第1项是1
  4. if (n <= 1) {
  5. return n;
  6. }
  7. int[] fib = new int[n + 1];
  8. fib[0] = 0;
  9. fib[1] = 1;
  10. for (int i = 2; i <= n; i++) {
  11. fib[i] = fib[i - 1] + fib[i - 2];
  12. }
  13. return fib[n];
  14. }
  15. }

方法3:动态规划空间优化
从递归公式F(n) = F(n-1) + F(n-2) ,可以看出,当前的结果只与它前面两个的状态有关,跟后面其他任何元素都没有关系,所以我们可以只存储最近的两个数,这样相比数组,就节约了很多空间。

  1. public class Solution {
  2. public int Fibonacci(int n) {
  3. // 按照从0开始:0, 1, 1, 2, 3
  4. if (n <= 1) {
  5. return n;
  6. }
  7. int res = 0;
  8. int first = 0;
  9. int second = 1;
  10. for (int i = 2; i <= n; i++) {
  11. res = first + second;
  12. first = second;
  13. second = res;
  14. }
  15. return res;
  16. }
  17. }

动态规划持续优化:减少了一个变量,稍微有点绕。

  1. public class Solution {
  2. public int Fibonacci(int n) {
  3. if (n <= 1) {
  4. return n;
  5. }
  6. int first = 0;
  7. int second = 1;
  8. for (int i = 2; i <= n; i++) {
  9. second = second + first;
  10. first = second - first;
  11. }
  12. return second;
  13. }
  14. }

BM63. 跳台阶

一只青蛙一次可以跳1阶或2阶,直到跳到第n阶,也可以看成这只青蛙从n 阶往下跳,到0 阶,按照原路返回的话,两种方法事实上可以的跳法是一样的——即怎么来的,怎么回去!
当青蛙在第n阶往下跳,它可以选择跳1阶到n-1,也可以选择跳2阶到n-2,即它后续的跳法变成了f(n-1) + f(n-2) ,这就变成了斐波那契数列。因此可以按照斐波那契数列的做法来做:即输入n,输出第n个斐波那契数列的值,初始化0阶有1种,1阶有1种。

方法1:递归
根据公式:f(n) = f(n - 1) + f(n - 2),而f(n - 1)与 f(n - 2) 又可以作为子问题继续计算,因此可以使用递归。

  • step 1:低于2项的数列,直接返回1。
  • step 2:对于当前n,递归调用函数计算两个子问题相加。
  1. public class Solution {
  2. // 方法1:递归
  3. public int jumpFloor(int target) {
  4. if (target <= 1) {
  5. return 1;
  6. }
  7. return jumpFloor(target - 1) + jumpFloor(target - 2);
  8. }
  9. }

方法2:递归加缓存表。
递归虽然简单,但是重复计算了很大一部分,而且f(n) = f(n - 1) + f(n - 2) ,f(n) 的值只与它前面两个有关系,可以利用数组记录每个 f(n),需要的时候直接从数组里面取值即可,这里数组就起到了缓存表的作用。

  • step 1:使用dp数组记录前面的数列值。
  • step 2:函数中低于2项的数列,直接返回1。
  • step 3:对于当前n,如果dp 数组中存在则直接使用,否则递归调用函数计算两个子问题相加。

牛客网面试必刷TOP101 - 图17

  1. public class Solution {
  2. // 方法2:递归加缓存表
  3. // int 范围内只有50个斐波那契数
  4. int[] dp = new int[50];
  5. public int jumpFloor(int target) {
  6. if (target <= 1) {
  7. return 1;
  8. }
  9. // 从缓存表中取数据
  10. if (dp[target] != 0) {
  11. return dp[target];
  12. }
  13. // 往缓存表中填数据,同时也是返回值给上一层
  14. return dp[target] = jumpFloor(target - 1) + jumpFloor(target - 2);
  15. }
  16. }

方法3:动态规划
具体做法:
既然与斐波那契数列相同,我们就把它放入数组中来解决。

  • step 1:创建一个长度为n + 1 的数组,这样才能有下标第n 项,我们用它记录前n项斐波那契数列。
  • step 2:根据公式,初始化第0项和第1项。
  • step 3:遍历数组,依照公式某一项等于前两项之和,将数组后续元素补齐,即可得到fib(n) 。
  1. // 方法3:动态规划
  2. public int jumpFloor(int target) {
  3. // 1. 创建dp数组,初始化前两个数据
  4. int[] dp = new int[target + 1];
  5. dp[0] = 1;
  6. dp[1] = 1;
  7. // 2. 依次对数组进行填充
  8. for (int i = 2; i <= target; i++) {
  9. dp[i] = dp[i - 1] + dp[i - 2];
  10. }
  11. // 3. 返回最后的结果
  12. return dp[target];
  13. }
  14. }

BM64. 最小花费爬楼梯

方法:动态规划(推荐使用)
具体做法:

  • step 1:可以用一个数组记录每次爬到第i 阶楼梯的最小花费,然后每增加一级台阶就转移一次状态,最终得到结果。
  • step 2:(初始状态) 因为可以直接从第0级或是第1级台阶开始,因此这两级的花费都直接为0.
  • step 3:(状态转移) 每次到一个台阶,只有两种情况,要么是它前一级台阶向上一步,要么是它前两级的台阶向上两步,因为在前面的台阶花费我们都得到了,因此每次更新最小值即可,转移方程为:dp[i] = min(dp[i - 1] + cost[i - 1] , dp[i - 2] + cost[i - 2])

这道题解法有点理解不了,动态规划这东西,摸不清。
2022.06.09 dp[i] 表示爬过第i 阶楼梯需要的最小花费
2022.06.20 有点理解动态规划了,dp[i]就表示爬到第i 个阶梯所用的最小花费,每次i 更新,花费也会动态更新,每次都是最优解。

  1. public class Solution {
  2. public int minCostClimbingStairs(int[] cost) {
  3. // 1. dp[i] 表示爬过第i阶楼梯需要的最小花费
  4. int[] dp = new int[cost.length + 1];
  5. for (int i = 2; i <= cost.length; i++) {
  6. int p1 = dp[i - 1] + cost[i - 1];
  7. int p2 = dp[i - 2] + cost[i - 2];
  8. dp[i] = Math.min(p1, p2);
  9. }
  10. return dp[cost.length];
  11. }
  12. }

BM65. 最长公共子序列(二)

方法1:动态规划
具体做法:

  • step 1:优先检查特殊情况。
  • step 2:我们以dp[i][j] 表示在s1中以i 结尾,s2中以j 结尾的字符串的最长公共子序列长度。
  • step 3:若是i与j 位置的字符相等,则该问题可以变成1 + dp[i][j] ,即最长公共子序列长度加1,若是不相等,则换成两个子问题:dp[i][j - 1] 或者dp[i - 1][j] ,由此用递归即可以解决。
  • step 4:但是递归的复杂度过高,重复计算了太多部分,因此可以用动态规划,从前往后加,由此形成一个表,表从1开始往后相加。因最后要返回该序列,所以在构造表的同时要以另一个二维矩阵记录打印方向。
  • step 5:计算答案时使用递归。

代码

另外一版代码:
参考博客:https://blog.csdn.net/hrn1216/article/details/51534607
首先根据递归公式,填充整个dp表
牛客网面试必刷TOP101 - 图18

假设有两个字符串,Str1 为“ACDEFGGH” ,Str2 为 “CEGDHFGHB“,他们的最长公共子序列就是图中标记为绿色的“CEGGH”或“CDFGH”,这两个结果就是根据dp表从后往前推理出来的:
dp[8][9] = 5,且Str1[8] != Str2[9],所以倒推回去,dp[8][9] 的值来源于dp[8][8]的值 (因为dp[8][8] > dp[7][9])。
dp[8][8] = 5, 且Str1[8] = Str2[8], 所以倒推回去,dp[8][8]的值来源于 dp[7][7]。
以此类推,如果遇到 Str1[i] != Str2[j] ,且dp[i - 1][j] = c[i][j - 1] 这种存在分支的情况,这里请都选择一个方向,(之后遇到这样的情况,也选择相同的方向),所以可能存在多个相同长度的公共子序列。

公共子序列1:
1654760722478.jpg

公共子序列2:
1654760818156.jpg

  1. public class Solution {
  2. // 方法1:动态规划
  3. public static String LCS(String s1, String s2) {
  4. int len1 = s1.length();
  5. int len2 = s2.length();
  6. if (len1 == 0 || len2 == 0) {
  7. return "-1";
  8. }
  9. int[][] dp = new int[len1 + 1][len2 + 1];
  10. for (int i = 1; i < len1 + 1; i++) {
  11. for (int j = 1; j < len2 + 1; j++) {
  12. if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
  13. dp[i][j] = dp[i - 1][j - 1] + 1;
  14. } else {
  15. dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
  16. }
  17. }
  18. }
  19. StringBuilder sb = new StringBuilder();
  20. int row = len1;
  21. int col = len2;
  22. while (row != 0 && col != 0) {
  23. if (s1.charAt(row - 1) == s2.charAt(col - 1)) {
  24. sb.append(s1.charAt(row - 1));
  25. row--;
  26. col--;
  27. } else {
  28. if (dp[row - 1][col] > dp[row][col - 1]) {
  29. row--;
  30. } else {
  31. col--;
  32. }
  33. }
  34. }
  35. if (sb.length() == 0) {
  36. return "-1";
  37. }
  38. return sb.reverse().toString();
  39. }
  40. }

BM66. 最长公共子串

这道题和上面的不一样,公共子串要求是连续的,而公共子序列不是连续的。

方法1:暴力搜索。
同时遍历两个字符串,以当前位置为起点比较两个字符串,如果字符相同就记录下来,直到有不相同字符截止,然后检查更新最长公共字符串。

  1. public class Solution {
  2. // 方法1:暴力搜索
  3. public static String LCS(String str1, String str2) {
  4. int maxLen = 0;
  5. StringBuilder sb = new StringBuilder();
  6. for (int i = 0; i < str1.length(); i++) {
  7. for (int j = 0; j < str2.length(); j++) {
  8. int len = 0;
  9. int p1 = i;
  10. int p2 = j;
  11. StringBuilder temp = new StringBuilder();
  12. while (p1 < str1.length() && p2 < str2.length() && str1.charAt(p1) == str2.charAt(p2)) {
  13. temp.append(str1.charAt(p1));
  14. len++;
  15. p1++;
  16. p2++;
  17. }
  18. if (maxLen < len) {
  19. maxLen = len;
  20. sb = temp;
  21. }
  22. }
  23. }
  24. return sb.toString();
  25. }
  26. }

方法2:动态规划
动态规划继承自方法一,在枚举的基础上用动态规划来改进。

  • step 1:我们可以用dp[i][j] 表示在str1中以第i 个字符结尾、在str2中以第j 个字符结尾时的公共子串长度,
  • step 2:遍历两个字符串填充dp数组,转移方程为:如果遍历到的该位两个字符相等,则此时长度等于两个前一位长度+1,dp[i][j] = dp[i - 1][j - 1] + 1,如果遍历到该位时两个字符不相等,则置为0,即 dp[i][j] = 0,因为这是子串,必须是连续的,断开要重新开始。
  • step 3:每次更新dp[i][j] 后,我们维护最大值,并更新该子串结束位置。
  • step 4:最后根据最大值结束位置,即可截取出子串。
  1. public class Solution {
  2. // 方法2:动态规划
  3. public static String LCS(String str1, String str2) {
  4. int m = str1.length();
  5. int n = str2.length();
  6. // 1. 创建dp数组
  7. // dp[i][j] 表示以str1第i个字符结尾,同时在str2中第j 个字符结尾时的公共子串长度
  8. int[][] dp = new int[m + 1][n + 1];
  9. int maxLen = 0;
  10. int end = 0;
  11. // 2. 填充dp数组
  12. for (int i = 1; i <= m; i++) {
  13. for (int j = 1; j <= n; j++) {
  14. // 3. 从头开始比较两个字符串,
  15. // 如果当前两个字符相同,长度就在上一个基础上加1
  16. // 如果不相同,公共子串就断开了,dp[i][j] = 0
  17. if (str1.charAt(i - 1) == str2.charAt(j - 1)) {
  18. dp[i][j] = dp[i - 1][j - 1] + 1;
  19. } else {
  20. dp[i][j] = 0;
  21. }
  22. // 4. 更新记录最大的长度
  23. if (dp[i][j] > maxLen) {
  24. maxLen = dp[i][j];
  25. end = i - 1;
  26. }
  27. }
  28. }
  29. // 5. 截取最长公共子串,[end - maxLen + 1, end + 1)
  30. return str1.substring(end - maxLen + 1, end + 1);
  31. }
  32. }

BM67. 求二维数组路径数

方法1:递归
具体做法:
首先我们在左上角第一个格子的时候,有两种行走方式:如果向右走,相当于后面在一个m (n - 1) 的矩阵中查找从左上角到右下角的不同路径数;而如果向下走,相当于后面在一个 (m - 1) n 的矩阵中查找从左上角到右下角不同的路径数。而这两个矩阵都是矩阵的子问题,因此可以使用递归。

  • step 1:(终止条件) 当矩阵n 减少到1 的时候,很明显只能往下走,没有别的选择了,只有1条路径;同理m 减少到1时也是如此。因此此时返回数量为1.
  • step 2:(返回值) 对于每一级都将其两个子问题返回的结果相加返回给上一级。
  • step 3:(本级任务) 每一级都有向下或者向右两种路径选择,分别进入相应分支的子问题。
  1. public class Solution {
  2. public int uniquePaths(int m, int n) {
  3. if (m == 1 || n == 1) {
  4. return 1;
  5. }
  6. return uniquePaths(m, n - 1) + uniquePaths(m - 1, n);
  7. }
  8. }

方法2:动态规划
具体做法:
如果我们此时就在右下角的格子,那么能够到达该格子的路径只能是它的上方和它的左方两个格子,因此从左上角到右下角的路径数应该是从左上角到它的左边格子和上边格子的路径数之和,因此可以动态规划。

  • step 1:用dp[i][j] 表示大小为i * j 的矩阵的路径数量,下标从1 开始。
  • step 2:(初始条件) 当i 或者j 为1的时候,代表矩阵只有一行或者一列,因此只有一种路径。
  • step 3:(转移方程) 每个格子的路径数只会来自它左边的格子数和上边的格子数,因此状态转移为 dp[i][j] = dp[i-1][j] + dp[i][j - 1]
  1. public class Solution {
  2. // 方法2:动态规划
  3. public int uniquePaths(int m, int n) {
  4. // 1. 创建一个dp数组,长宽是m+1,n+1
  5. // dp[i][j]表示大小为i * j的矩阵的路径数量,下标从1开始,0索引的在此处没有实际意义
  6. int[][] dp = new int[m + 1][n + 1];
  7. for (int i = 1; i <= m; i++) {
  8. for (int j = 1; j <= n; j++) {
  9. // 2. 初始化部分数据。i或j为1时,表示矩阵只有一行或一列,因此只有一种路径
  10. if (i == 1 || j == 1) {
  11. dp[i][j] = 1;
  12. continue;
  13. }
  14. // 3. 递归公式。来到当前路径只有两种途径,从上面或者左边
  15. dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
  16. }
  17. }
  18. return dp[m][n];
  19. }
  20. }

方法3:排列组合
image.png

BM68. 矩阵的最小路径和

方法1:动态规划
具体做法:
最朴素的解法莫过于枚举所有的路径,然后求和,找出其中最大值。但是像这种有状态值可以转移的问题,我们可以尝试用动态规划

  • step 1:构造一个同样大小的二维辅助数组,其中dp[i][j] 表示以(i, j) 位置为终点的最短路径和。初始化左上角元素, dp[0][0] = matrix[0][0]
  • step 2:初始化第一行和第一列。第一行与第一列,只能分别向右或向下,没有第二种选择,因此第一行只能由其左边的累加,第一列只能由其上面的累加。
  • step 3:边缘状态构造好以后,遍历矩阵,补全dp 数组中每个位置的值:如果当前的位置是(i,j),上一步要么是(i - 1, j)往下,要么就是(i, j - 1)往右,那么取其中较小值与当前位置的值相加就是到当前位置的最小路径和,因此状态转移公式为 dp[i][j] = min (dp[i-1][j], dp[i][j - 1]) + matrix[i][j]
  • step 4:dp[m - 1][n - 1] 的值就是到右下角的最短路径和。

状态转移结果如图所示:首先初始化左上角、第一行和第一列的元素,然后根据状态转移公式,填充其他元素。
1655737105279.jpg

  1. public class Solution {
  2. public static int minPathSum(int[][] matrix) {
  3. if (matrix == null || matrix.length == 0) {
  4. return -1;
  5. }
  6. // 1. 创建dp数组,
  7. // dp[i][j] 表示以当前i、j 位置为终点的最短路径长度
  8. int m = matrix.length;
  9. int n = matrix[0].length;
  10. int[][] dp = new int[m][n];
  11. // 2. 初始化边缘状态:左上角、第一列和第一行
  12. dp[0][0] = matrix[0][0];
  13. for (int i = 1; i < m; i++) {
  14. dp[i][0] = dp[i - 1][0] + matrix[i][0];
  15. }
  16. for (int i = 1; i < n; i++) {
  17. dp[0][i] = dp[0][i - 1] + matrix[0][i];
  18. }
  19. // 3. 填充中间位置
  20. for (int i = 1; i < m; i++) {
  21. for (int j = 1; j < n; j++) {
  22. int minVal = Math.min(dp[i - 1][j], dp[i][j - 1]);
  23. dp[i][j] = matrix[i][j] + minVal;
  24. }
  25. }
  26. // 4. 返回右下角的元素,就是题目所求的最小路径和
  27. return dp[m - 1][n - 1];
  28. }
  29. }

BM69. 把数字翻译成字符串的译码组合数

方法1:动态规划
具体做法:

  • step 1:用辅助数组dp表示前i 个数的译码方法有多少种。
  • step 2:对于一个数,我们可以直接译码它,也可以将其与前面的1或者2组合起来译码:如果直接译码,则;dp[i] = dp[i - 1];如果组合译码,则 dp[i] = dp[i - 2]
  • step 3:对于只有一种译码方式的,选上种dp[i - 1] 即可,对于满足两种译码方式(10,20不能)则是 dp[i - 1] + dp[i - 2]
  • step 4:依次相加,最后的 dp[length] 即为所求答案。

2022.07.10 这道题感觉还是有点难啊,能理解,但写不出来

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:动态规划
  4. public int solve(String nums) {
  5. // 1. 特殊情况: 0
  6. if (nums.equals("0")) {
  7. return 0;
  8. }
  9. // 2. 特殊情况: 10 、20
  10. if (nums == "10" || nums == "20") {
  11. return 1;
  12. }
  13. // 3. 特殊情况:前缀非1或2,无法译码
  14. for (int i = 1; i < nums.length(); i++) {
  15. if (nums.charAt(i) == '0') {
  16. if (nums.charAt(i - 1) != '1' && nums.charAt(i - 1) != '2') {
  17. return 0;
  18. }
  19. }
  20. }
  21. // 4. 创建dp数组,初始化
  22. int[] dp = new int[nums.length() + 1];
  23. Arrays.fill(dp, 1);
  24. // 5. 遍历数组
  25. for (int i = 2; i <= nums.length(); i++) {
  26. // 6. 在11-19、21-26 之间,两种都合法,可能性是它们两个之和
  27. if ((nums.charAt(i - 2) == '1' && nums.charAt(i - 1) != '0')
  28. || (nums.charAt(i - 2) == '2' && nums.charAt(i - 1) > '0' && nums.charAt(i - 1) < '7')) {
  29. dp[i] = dp[i - 1] + dp[i - 2];
  30. } else {
  31. dp[i] = dp[i - 1];
  32. }
  33. }
  34. return dp[nums.length()];
  35. }
  36. }

BM70. 兑换零钱(一)

给定数组arr,arr中所有的值都为正整数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个aim,代表要找的钱数,求组成aim的最少货币数。
如果无解,请返回-1

方法1:动态规划。
这类涉及状态转移的题目,可以考虑动态规划。

  • step 1:可以用dp[i] 表示要凑出i 元钱需要最小的货币张数。
  • step 2:一开始都设置为最大值。因为货币最小值是1元,即可以用aim 个一元货币,达到aim 元这个目标值
  • step 3:初始化dp[0] = 0
  • step 4:后续遍历dp数组,从1元到aim 元,枚举每种面值的货币可能组成的情况,取每次的最小值即可,转移方程为dp[i] = min(dp[i], dp[i - arr[j]] +1)
  • step 5:最后比较的值是否超过aim,如果超过说明无解,否则返回即可。

2022.06.11 看到题目又没有思路了,还是没有理解本质。按照题意,货币面值最小值是1元,所以初始化DP数组时值为aim + 1,dp[0] 表示0元需要0 张货币。dp数组的边界构造好之后,开始推理dp数组其他元素,对于每个钱的值aim,都要枚举每种面值的货币,要么是dp[i],要么是使用一张当前货币,总钱数要减去当前的值,一句话说不清楚,理解就行了。
2022.07.10 写不出来哦,dp数组怎么写,它的含义是什么,都想不出来。代码中的p1是初始化的值,p2是使用当前货币后的总张数。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:动态规划
  4. public static int minMoney(int[] arr, int aim) {
  5. if (aim < 1) {
  6. return 0;
  7. }
  8. // 1. 创建dp 数组,并初始化所有数据和边缘状态
  9. // dp[i] 表示凑齐 i元,最少需要几张钞票
  10. int[] dp = new int[aim + 1];
  11. Arrays.fill(dp, aim + 1);
  12. dp[0] = 0;
  13. // 2. 遍历dp数组,并修改元素的值
  14. for (int i = 1; i < dp.length; i++) {
  15. // 3. 枚举每种面值的货币
  16. for (int j = 0; j < arr.length; j++) {
  17. // 填充dp数组,如果当前钞票的面值小于等于要凑的钱才行
  18. if (arr[j] <= i) {
  19. int p1 = dp[i];
  20. int p2 = dp[i - arr[j]] + 1;
  21. dp[i] = Math.min(p1, p2);
  22. }
  23. }
  24. }
  25. return dp[aim] > aim ? -1 : dp[aim];
  26. }
  27. }

BM71. 最长上升子序列(一)

给定一个长度为 n 的数组 arr,求它的最长严格上升子序列的长度。
所谓子序列,指一个数组删掉一些数(也可以不删)之后,形成的新数组。例如 [1,5,3,7,3] 数组,其子序列有:[1,3,3]、[7] 等。但 [1,6]、[1,3,5] 则不是它的子序列。
我们定义一个序列是 严格上升

方法1:动态规划。
具体做法:
要找到最长的递增子序列长度,常用方法是动态规划。

  • step 1:用dp[i] 表示到索引 i 结尾时,最长上升子序列的长度。第一层遍历得到n个长度的子串,第二层遍历该子串求相应到元素结尾时的最长递增序列长度,期间维护最大值。
  • step 2:(初始条件) 不管如何只要数组不为空,最长递增子序列至少有1个,因此可以初始化dp数组全部为1.
  • step 3:(转移方程) 对于每一个到结尾的子串,如果遍历过程中遇到元素j 小于结尾,说明以该元素结尾的子序列加上子串末尾元素也是严格递增的,因此转移方程为 dp[i] = dp[j] + 1

1657459274263.jpg

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:动态规划
  4. public static int LIS(int[] arr) {
  5. if (arr == null || arr.length == 0) {
  6. return 0;
  7. }
  8. // 1. 创建dp数组,初始化数组
  9. // dp[i] 表示以索引i 结尾时,最长递增子序列的长度
  10. int[] dp = new int[arr.length];
  11. Arrays.fill(dp, 1);
  12. int maxLen = 0;
  13. // 2. 遍历并填充数组
  14. for (int i = 1; i < arr.length; i++) {
  15. // 3. 遍历前面所有的
  16. for (int j = 0; j < i; j++) {
  17. // 如果当前元素大于前面某个元素,且dp值小,那就更新dp值
  18. if (arr[i] > arr[j] && dp[i] < dp[j] + 1) {
  19. dp[i] = dp[j] + 1;
  20. maxLen = Math.max(maxLen, dp[i]);
  21. }
  22. }
  23. }
  24. return maxLen;
  25. }
  26. }

BM72. 连续子数组的最大和

方法一:动态规划(推荐使用)
具体做法:

  • step 1:可以用dp数组表示以下标i 为终点的最大连续子数组和
  • step 2:每次遇到一个新的数组元素,连续的子数组要么加上它变得更大,要么它本身就更大,因此状态转移为 dp[i] = max(dp[i - 1] + arr[i], arr[i])
  • step 3:遍历数组,每次只要比较取最大值即可。
  1. public class Solution {
  2. // 方法1:动态规划
  3. public int FindGreatestSumOfSubArray(int[] array) {
  4. if (array == null || array.length == 0) {
  5. return 0;
  6. }
  7. // dp[i] 表示截止到索引i 为止的最大连续子数组的和
  8. int[] dp = new int[array.length];
  9. dp[0] = array[0];
  10. int maxSum = array[0];
  11. for (int i = 1; i < dp.length; i++) {
  12. int p1 = dp[i - 1] + array[i];
  13. int p2 = array[i];
  14. dp[i] = Math.max(p1, p2);
  15. maxSum = Math.max(maxSum, dp[i]);
  16. }
  17. return maxSum;
  18. }
  19. }

方法2:动态规划的空间优化
具体做法:
我们注意到方法一的动态规划在状态转移的时候只用到了前一个索引 dp[i - 1] 的信息,没有使用数组其他索引位置的的信息。

  • step 1:我们可以使用两个变量迭代来代替数组
  • step 2:状态转移的时候更新变量cur,该轮循环结束的再更新pre 为cur ,即可做到每次迭代都是上一轮的dp。
  • step 3:遍历数组,每次只要比较取最大值即可。
  1. public class Solution {
  2. // 方法2:动态规划空间优化
  3. public int FindGreatestSumOfSubArray(int[] array) {
  4. int pre = array[0];
  5. int maxSum = pre;
  6. for (int i = 1; i < array.length; i++) {
  7. int cur = Math.max(pre + array[i], array[i]);
  8. maxSum = Math.max(maxSum, cur);
  9. pre = cur;
  10. }
  11. return maxSum;
  12. }
  13. }

BM73. 最长回文子串

方法一:中心扩展法(推荐使用)
具体做法:
遍历每个字符,以该字符为中心(分奇数长度和偶数长度两种情况),不断向两边扩展,如果两边都是相同的就是回文,不断扩大到最大长度即是以这个字符为中心的最长回文子串,我们比较以每个字符为中心的最长回文子串,取最大值即可。

  1. public class Solution {
  2. // 方法1:中心点扩展法
  3. public int getLongestPalindrome(String A) {
  4. int maxLen = 1;
  5. // 1. 以每个点为中心,尝试向两边扩展
  6. for (int i = 0; i < A.length() - 1; i++) {
  7. // 2. 分别尝试以奇数和偶数回文向两边扩展,得到较大值,并更新最大值
  8. int odd = extendEdge(A, i, i);
  9. int even = extendEdge(A, i, i + 1);
  10. int curMax = Math.max(odd, even);
  11. maxLen = Math.max(maxLen, curMax);
  12. }
  13. return maxLen;
  14. }
  15. // 以这两个位置向两边扩展,得到回文子串的长度
  16. public int extendEdge(String s, int start, int end) {
  17. while (start >= 0 && end < s.length() && s.charAt(start) == s.charAt(end)) {
  18. start--;
  19. end++;
  20. }
  21. return end - start - 1;
  22. }
  23. }

方法2:manacher算法(扩展思路)
具体做法:
方法一讨论了两种情况,子串长度为奇数和偶数的情况,但其实我们可以对字符串添加不属于里面的特殊字符,来让所有的回文串都变成奇数形式。同时上述中心扩展法有很多重复计算,manacher就可以优化。

  • step 1:我们用R 表示目前已知的最长回文子串的最右一位的后一位,用C 表示当前的最长回文子串的中心点。
  • step 2:对于给定的 i 我们找一个和它关于C 对称的位置 iMirror,也就是 C - iMirror == i - C
  • step 3:i 和 j 的最长回文子串在C 的回文串范围内的部分应该是一模一样的,但是在外面的部分就无法保证了,当然,最好的情况是i j 的回文子串范围都很小,这样就保证了它们的回文子串一定一模一样,对于超出的部分我们也没有办法, 只能手动使用中心扩展。
  • step 4:最后答案计算的时候需要考虑使用预处理,长度被加了一倍,于是结果是 max(mp[i] - 1)。

manacher算法讲解:https://leetcode.wang/leetCode-5-Longest-Palindromic-Substring.html
1655802675033.jpg

  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:manacher 算法
  4. public int getLongestPalindrome(String A) {
  5. int res = manacher(A);
  6. return res;
  7. }
  8. public static int manacher(String str) {
  9. // 1. 在原字符串基础上添加特殊符号,构造奇数长度的数组
  10. // 原字符串:ababc
  11. // 构造结果:#a#b#a#b#c#
  12. char[] arr = new char[str.length() * 2 + 1];
  13. int index = 0;
  14. for (int i = 0; i < arr.length; i++) {
  15. // 偶数和1做&运算,结果是0,添加#;
  16. // 奇数和1做&运算,结果是1,复制字符
  17. if ((i & 1) == 0) {
  18. arr[i] = '#';
  19. } else {
  20. arr[i] = str.charAt(index);
  21. index++;
  22. }
  23. }
  24. // 2. 创建一个回文串窗口
  25. int C = -1; // 回文串中心的索引
  26. int R = -1; // 回文串右侧边界(不包含)
  27. int[] p = new int[arr.length];
  28. int maxLen = Integer.MIN_VALUE;
  29. for (int i = 0; i < arr.length; i++) {
  30. int iMirror = 2 * C - i;
  31. // 3. 判断当前字符是否在前面某个回文串覆盖范围内
  32. if (i < R) {
  33. p[i] = Math.min(p[iMirror], R - i);
  34. } else {
  35. p[i] = 1;
  36. }
  37. // 4. 中心扩展法
  38. while (i + p[i] < arr.length && i - p[i] > -1) {
  39. if (arr[i + p[i]] == arr[i - p[i]]) {
  40. p[i]++;
  41. } else {
  42. break;
  43. }
  44. }
  45. // 5. 判断是否需要更新R
  46. if (i + p[i] > R) {
  47. C = i;
  48. R = i + p[i];
  49. }
  50. // 6. 更新最大值。p[i]-1 才是回文串的长度
  51. maxLen = Math.max(maxLen, p[i] - 1);
  52. }
  53. return maxLen;
  54. }
  55. }

BM74. 数字字符串转化成IP地址

方法一:枚举(推荐使用)
具体做法:
对于IP字符串,如果只有数字,则相当于需要我们将IP地址的三个点插入字符串中,而第一个点的位置只能在第一个字符、第二个字符、第三个字符之后,而第二个点只能在第一个点后1-3个位置之内,第三个点只能在第二个点后1-3个位置之内,且要求第三个点后的数字数量不能超过3,因为IP地址每位最多3位数字。

  • step 1:依次枚举这三个点的位置。
  • step 2:然后截取出四段数字。
  • step 3:比较截取出来的数字,不能大于255,且除了0以外不能有前导0,然后才能组装成IP地址加入答案中。 ```java import java.util.*;

public class Solution {

  1. // 方法1:枚举
  2. public static ArrayList<String> restoreIpAddresses(String s) {
  3. ArrayList<String> res = new ArrayList<>();
  4. int len = s.length();
  5. // 1. 遍历IP的点可能的位置
  6. for (int i = 1; i < 4 && i < len - 2; i++) {
  7. for (int j = i + 1; j < i + 4 && j < len - 1; j++) {
  8. for (int k = j + 1; k < j + 4 && k < len; k++) {
  9. // 最后一段剩余数字不能超过3个
  10. if (len - k >= 4) {
  11. continue;
  12. }
  13. // 2. 截取出各段ip
  14. String a = s.substring(0, i);
  15. String b = s.substring(i, j);
  16. String c = s.substring(j, k);
  17. String d = s.substring(k);
  18. // 3. 对ip合法性进行校验,1:长度不超过255;2:排除前导0
  19. if (Integer.parseInt(a) > 255 || Integer.parseInt(b) > 255
  20. || Integer.parseInt(c) > 255 || Integer.parseInt(d) > 255) {
  21. continue;
  22. }
  23. if ((a.length() != 1 && a.charAt(0) == '0') || (b.length() != 1 && b.charAt(0) == '0')
  24. || (c.length() != 1 && c.charAt(0) == '0') || (d.length() != 1 && d.charAt(0) == '0')) {
  25. continue;
  26. }
  27. // 4. 组装ip地址
  28. String temp = a + "." + b + "." + c + "." + d;
  29. res.add(temp);
  30. }
  31. }
  32. }
  33. return res;
  34. }

}

  1. **方法二:回溯(扩展思路)**<br />**具体做法:**<br />我们也可以使用递归和回溯将点插入数字中。
  2. - step 1:使用step 记录分割出的数字个数,index 记录递归的下标,结束递归是指step 已经为4,且下标到达字符串末尾。
  3. - step 2:在主体递归中,每次加入一个字符当数字,最多可以加入三个数字,剩余字符串进入递归构造下一个数字。
  4. - step 3:然后要检查每次的数字是否合法(不超过255且没有前导0).
  5. - step 4:合法IP需要将其连接,同时递归完这一轮后需要回溯。
  6. ![](https://cdn.nlark.com/yuque/0/2022/png/25686846/1655808596547-e402975b-bd5a-4a43-b09a-a5416a73dff5.png#clientId=ueecba146-e9b9-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u6e7558ab&margin=%5Bobject%20Object%5D&originHeight=668&originWidth=1180&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=stroke&taskId=uaa979846-1d02-4d9e-be8c-366a64a9f68&title=)
  7. ```java
  8. import java.util.*;
  9. public class Solution {
  10. // 方法2:回溯法
  11. public static ArrayList<String> restoreIpAddresses(String s) {
  12. ArrayList<String> res = new ArrayList<>();
  13. StringBuilder ip = new StringBuilder();
  14. backtrack(s, 1, 0, ip, res);
  15. return res;
  16. }
  17. // step 表示第几个数字;index 表示字符串的下标
  18. private static void backtrack(String str, int step, int index, StringBuilder ip, ArrayList<String> res) {
  19. // 1. 递归的终止条件
  20. if (step == 5 && index == str.length()) {
  21. res.add(ip.toString());
  22. return;
  23. }
  24. // 以下是本级处理
  25. // 变量cur存储第step个数字
  26. StringBuilder cur = new StringBuilder();
  27. // 遍历字符串,拼接第step个数字,有两个约束条件
  28. for (int i = index; i < index + 3 && i < str.length(); i++) {
  29. // 2. 得到本层级字符串和转换成的数字
  30. cur.append(str.charAt(i));
  31. int curNum = Integer.parseInt(cur.toString());
  32. // 3. 递归前数据备份
  33. StringBuilder backup = new StringBuilder(ip);
  34. // 4. 拼接数字
  35. // 合法数字条件:小于等于255,并且不能有前导0
  36. if (curNum <= 255 && (cur.length() == 1 || cur.charAt(0) != '0')) {
  37. // 前三个数后面要加.,第四个数不加
  38. if (step - 4 != 0) {
  39. ip.append(cur).append(".");
  40. } else {
  41. ip.append(cur);
  42. }
  43. // 5. 递归。
  44. backtrack(str, step + 1, i + 1, ip, res);
  45. // 6. 回溯。递归后从备份中恢复数据
  46. ip = backup;
  47. }
  48. }
  49. }
  50. }

BM75. 编辑距离(一)

方法1:动态规划(推荐使用)
具体做法:
把第一个字符串变成第二个字符串,我们需要逐个将第一个字符串的子串最少操作下变成第二个字符串,这就涉及了第一个字符串增加长度,状态转移,那可以考虑动态规划。用dp[i][j] 表示从两个字符串首部各自到str1[i] str2[j] 为止的子串需要的编辑距离,那很明显dp[str1.length][str2.length] 就是我们要求的编辑距离。(下标从1开始)

  • step 1:初始条件: 假设第二个字符串为空,那很明显第一个字符串子串每增加一个字符,编辑距离就加1,这步操作是删除;同理,假设第一个字符串为空,那第二个字符串每增加一个字符,编剧距离就加1,这步操作是添加。
  • step 2:状态转移: 状态转移肯定是将dp矩阵填满,那就遍历第一个字符串的每个长度,对应第二个字符串的每个长度。如果遍历到str1[i]和 str2[j] 的位置,这两个字符相同,这多出来的字符就不用操作,操作次数与两个子串的前一个相同,因此有 dp[i][j] = dp[i - 1][j - 1]; 如果这两个字符不相同,那么这两个字符需要编辑,此时有三种情况,

添加操作代价是:dp[i][j - 1] + 1,就是说str1的前i 个字符,已经编辑成str2的前 j-1 个字符了,str2又添加了一位j,那str1也要添加一个相同的字符与之匹配。
删除操作代价:dp[i - 1][j] + 1,str1的前i - 1 个字符,已经编辑成 str2 的前j 个字符了,此时str1还多了一个i 位置的字符,那就需要把它删除。
替换代价:dp[i - 1][j - 1] + 1。选择这三种情况代价最小的。

  1. public class Solution {
  2. public static int editDistance(String str1, String str2) {
  3. int len1 = str1.length();
  4. int len2 = str2.length();
  5. // 1. 创建dp数组
  6. // dp[i][j] 表示str1的[0, i] 变成 str2 的[0, j] 需要的编辑距离
  7. int[][] dp = new int[len1 + 1][len2 + 1];
  8. // 2. 初始化数组,构造两个边缘状态
  9. for (int i = 1; i <= len1; i++) {
  10. dp[i][0] = dp[i - 1][0] + 1;
  11. }
  12. for (int i = 1; i <= len2; i++) {
  13. dp[0][i] = dp[0][i - 1] + 1;
  14. }
  15. // 3. 根据状态转移公式,填充整个dp数组
  16. for (int i = 1; i <= len1; i++) {
  17. for (int j = 1; j <= len2; j++) {
  18. // 4. 如果当前字符相同,无需编辑,操作代价和前面一样
  19. if (str1.charAt(i - 1) == str2.charAt(j - 1)) {
  20. dp[i][j] = dp[i - 1][j - 1];
  21. } else {
  22. // 5. 如果两个字符不同,则需要编辑,有三种情况:插入、删除、替换,选择代价最小的
  23. int insertCost = dp[i][j - 1] + 1; // 添加操作
  24. int deleteCost = dp[i - 1][j] + 1; // 删除操作
  25. int replaceCost = dp[i - 1][j - 1] + 1; // 替换操作
  26. dp[i][j] = Math.min(insertCost, Math.min(deleteCost, replaceCost));
  27. }
  28. }
  29. }
  30. // 6. 返回数组最后一个元素,就是要求的答案
  31. return dp[len1][len2];
  32. }
  33. }

BM76. 正则表达式匹配

题目主要信息:

  • 一个正常字符串str,可能为空,只包含小写字母
  • 一个模式串pattern,可能为空,只包含小写字母和‘*’与‘.’
  • 模式中的字符’.’表示任意一个字符,而’*’表示它前面的字符可以出现任意次(包含0次)
  • 求str与pattern是否能完全匹配

方法:动态规划(推荐使用)
具体做法:
如果是只有小写字母,那么直接比较字符是否相同即可匹配,如果再多一个’.’,可以用它匹配任意字符,只要对应str中的元素不为空就行了。但是多了’*’字符,它的情况有多种,涉及状态转移,因此我们用动态规划。

  • step 1:设dp[i][j] 表示str前i 个字符和pattern 前j 个字符是否匹配。(需要注意这里的i,j是长度,比对应的字符串下标要多1)
  • step 2: (初始条件) 首先,毋庸置疑,两个空串是直接匹配,因此 dp[0][0] = true 。然后我们假设str字符串为空,那么pattern要怎么才能匹配空串呢?答案是利用’‘字符出现0次的特性。遍历pattern字符串,如果遇到’‘意味着它前面的字符可以出现0次,要想匹配空串也只能出现0,那就相当于考虑再前一个字符是否能匹配,因此 dp[0][i] = dp[0][i - 2]
  • step 3: (状态转移) 然后分别遍历str与pattern的每个长度,开始寻找状态转移。首先考虑字符不为’‘的简单情况,只要遍历到的两个字符相等,或是pattern串中为’.’即可匹配,因此最后一位匹配,即查看二者各自前一位是否能完成匹配,即 dp[i][j] = dp[i - 1][j - 1] 。然后考虑’‘出现的情况:
    1. pattern[j - 2] == ‘.’ || pattern[j - 2] == str[i - 1]:即pattern前一位能够多匹配一位,可以用’*’让它多出现一次或是不出现,因此有转移方程
    2. 不满足上述条件,只能不匹配,让前一个字符出现0次,.

2022.6.13 这道题看着就感觉麻烦

  1. public class Solution {
  2. // 正则表达式匹配,一点都不懂的
  3. public boolean match(String str, String pattern) {
  4. int len1 = str.length();
  5. int len2 = pattern.length();
  6. // 1. dp[i][j]表示str 前i个字符和pattern 前j 个字符是否匹配
  7. boolean[][] dp = new boolean[len1 + 1][len2 + 1];
  8. // 2. 遍历
  9. for (int i = 0; i <= len1; i++) {
  10. for (int j = 0; j <= len2; j++) {
  11. // 空正则的情况
  12. if (j == 0) {
  13. dp[i][j] = (i == 0 ? true : false);
  14. } else {
  15. // 非空的情况,星号、点号、字符
  16. if (pattern.charAt(j - 1) != '*') {
  17. // 当前字符不为*,用. 去匹配或者字符直接相同
  18. if (i > 0 &&
  19. (str.charAt(i - 1) == pattern.charAt(j - 1) || pattern.charAt(j - 1) == '.')) {
  20. dp[i][j] = dp[i - 1][j - 1];
  21. }
  22. } else {
  23. // 碰到*
  24. if (j >= 2) {
  25. dp[i][j] |= dp[i][j - 2];
  26. }
  27. // 如果前一位为. 或者前一位可以与这个数字匹配
  28. if (i >= 1 && j >= 2 &&
  29. (str.charAt(i - 1) == pattern.charAt(j - 2) || pattern.charAt(j - 2) == '.')) {
  30. dp[i][j] |= dp[i - 1][j];
  31. }
  32. }
  33. }
  34. }
  35. }
  36. return dp[len1][len2];
  37. }
  38. }

BM77. 最长的括号子串

方法1:栈
具体做法:
因为括号需要一一匹配,而且先来的左括号,只能匹配后面的右括号,因此可以考虑使用栈的先进后出功能,使括号匹配。

  • step 1:可以使用栈来记录左括号下标。
  • step 2:遍历字符串,左括号入栈,每次遇到右括号则弹出左括号的下标。
  • step 3:然后长度则更新为当前下标与栈顶下标的距离。
  • step 4:遇到不符合的括号,可能会使栈为空,因此需要使用start记录上一次结束的位置,这样用当前下标减去start即可获取长度,即得到子串。
  • step 5:循环中最后维护子串长度最大值。

2022.06.13 代入了子后,算法流程大概懂了,但写不出来,要区分好几个不同的条件下怎么处理,有点繁琐和难记忆。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:栈
  4. public int longestValidParentheses(String s) {
  5. // 1. 栈存储左括号的下标
  6. int res = 0;
  7. int start = -1; // 记录边界
  8. Deque<Integer> stack = new ArrayDeque<>();
  9. // 2. 遍历字符串
  10. for (int i = 0; i < s.length(); i++) {
  11. // 3. 遇到左括号就入栈
  12. if (s.charAt(i) == '(') {
  13. stack.push(i);
  14. } else {
  15. // 4. 遇到右括号
  16. // 栈为空,更新start
  17. if (stack.isEmpty()) {
  18. start = i;
  19. } else {
  20. // 5. 栈不为空,弹出与当前右括号匹配的那个左括号
  21. stack.pop();
  22. // 6. 栈不为空,说明右括号不够,减去栈顶位置就是长度
  23. if (!stack.isEmpty()) {
  24. res = Math.max(res, i - stack.peek());
  25. } else {
  26. // 7. 如果栈为空了,说明左右括号匹配完了,减去上一次结束位置,就是长度了
  27. res = Math.max(res, i - start);
  28. }
  29. }
  30. }
  31. }
  32. return res;
  33. }
  34. }

方法2:动态规划
具体做法:
像这种子串长度的题,一般都涉及状态转移,可以用动态规划的方式。

  • step 1:用dp[i] 表示以下标为i 的字符为结束点的最长合法括号长度。
  • step 2:很明显知道左括号不能做结尾,因此但是左括号都是 dp[i] = 0
  • step 3:我们遍历字符串,因为第一位不管是左括号还是右括号都是0,所以dp[0] = 0,因此跳过,后续只查看右括号的情况,右括号有两种情况:

情况1:右括号前面就是左括号,那么合法长度只需要增加2 即可,和i - 2 之前的结果没有关系。状态转移公式:dp[i] = (i >= 2 ? dp[i - 2] : 0 ) + 2
image.png

情况2:当前右括号前面还是右括号,与它匹配的左括号在前面合法序列之前,因此用它的下标i 减去前一个合法序列的长度,即可得到最前面匹配的括号,状态转移公式为:
dp[i] = dp[i - dp[i - 1] - 2] + dp[i - 1] + 2,
dp[i - dp[i - 1] - 2], 是当前位置匹配的左括号再往前一个位置的值;
dp[i - 1],是前一个位置的值;
2,是当前这一对括号的长度。

image.png

2022.06.22 挺繁琐的,大概理解了,但写不出来。
2022.07.11 能理解,但写不出来,两个情况对应的代码挺复杂的。

  1. public class Solution {
  2. // 方法2:动态规划
  3. public int longestValidParentheses(String s) {
  4. int res = 0;
  5. if (s == null || s.length() == 0) {
  6. return res;
  7. }
  8. // 1. 创建dp数组
  9. // dp[i] 表示到下标i 为止的最长合法括号长度
  10. // dp[0] = 0;左括号所在的值都等于0
  11. int[] dp = new int[s.length()];
  12. dp[0] = 0;
  13. // 2. 遍历字符串
  14. for (int i = 1; i < s.length(); i++) {
  15. // 3. 当前是右括号
  16. if (s.charAt(i) == ')') {
  17. // 情况1:前一位是左括号
  18. if (s.charAt(i - 1) == '(') {
  19. dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
  20. // 情况2:前一位不是左括号,且对应位置是左括号
  21. } else if (i - dp[i - 1] > 0 && s.charAt(i - dp[i - 1] - 1) == '(') {
  22. dp[i] = (i - dp[i - 1] > 1 ? dp[i - dp[i - 1] - 2] : 0) + dp[i - 1] + 2;
  23. }
  24. }
  25. res = Math.max(res, dp[i]);
  26. }
  27. return res;
  28. }
  29. }

BM78. 打家劫舍(一)

方法1:动态规划(推荐使用)
具体做法:
或许有人认为利用贪心思想,偷取最多人家的钱就可以了,要么偶数家要么奇数家全部的钱,但是有时候会为了偷取更多的钱,或许可能会连续放弃两家不偷,因此这种方案行不通,我们依旧考虑动态规划。

  • step 1:用dp[i] 表示长度为i 的数组,最多能偷取到多少钱,只要每次转移状态逐渐累加就可以得到整个数组能偷取的钱。
  • step 2:(初始状态) 如果数组长度为1,只有一家人,肯定是把这家人偷了,收益最大,因此dp[1] = nums[0]
  • step 3:(状态转移) 每次对于一个人家,我们选择偷他或者不偷他,如果我们选择偷那么前一家必定不能偷,因此累加的上上级的最多收益,同理如果选择不偷他,那我们最多可以累加上一级的收益。因此转移方程为 dp[i] = Math.max(dp[i - 1], nums[i - 1] + dp[i - 2])。这里的i 在dp 中为数组长度,在nums 中 i-1 为当前元素的下标。


1655876736642.jpg

  1. public class Solution {
  2. public int rob(int[] nums) {
  3. // dp[i] 表示长度为i 的数组,最多能偷取的钱
  4. int[] dp = new int[nums.length + 1];
  5. dp[0] = 0;
  6. dp[1] = nums[0];
  7. for (int i = 2; i < dp.length; i++) {
  8. // 对于索引i-1 位置,要么偷,要么不偷
  9. int selected = nums[i - 1] + dp[i - 2];
  10. int notSelected = dp[i - 1];
  11. dp[i] = Math.max(selected, notSelected);
  12. }
  13. return dp[dp.length - 1];
  14. }
  15. }

BM79. 打家劫舍(二)

方法1:动态规划
具体做法:
这道题与打家劫舍(一)比较类似,区别在于这道题是环形,第一家和最后一家是相邻的,既然如此,在原先的方案中第一家和最后一家不能同时取到。

  • step 1:使用原先的方案是:用dp[i] 表示长度为i 的数组,最多能偷取到多少钱,只要每次转移状态逐渐累加就可以得到整个数组能偷取的钱。
  • step 2:(初始状态) 如果数组长度为1,只有一家人,肯定是把这家人偷了,收益最大,因此 dp[1] = nums[0]
  • step 3:(状态转移) 每次对于一个人家,我们选择偷他或者不偷他,如果我们选择偷那么前一家必定不能偷,因此累加的上上级的最多收益,同理如果选择不偷他,那我们最多可以累加上一级的收益。因此转移方程为 dp[i] = Math.max(dp[i - 1], nums[i - 1] + dp[i - 2]) 。这里的i 在dp中为数组长度。
  • step 4:此时第一家与最后一家不能同时取到,那么我们可以分成两种情况讨论:
    • 情况1:偷第一家的钱,不偷最后一家的钱。初始状态与状态转移不变,只是遍历的时候数组最后一位不去遍历。
    • 情况2:偷最后一家的请,不偷第一家的钱。初始状态就设定了 dp[1] = 0,第一家就不要了,然后遍历的时候也会遍历到数组最后一位。
  • step 5:最后取两种情况的较大值即可。
  1. import java.util.*;
  2. public class Solution {
  3. public int rob(int[] nums) {
  4. // dp[i] 表示长度为i的数组,可以偷取的最大钱数
  5. int[] dp = new int[nums.length + 1];
  6. // 方案1:偷第一家,不偷最后一家
  7. dp[1] = nums[0];
  8. for (int i = 2; i < dp.length; i++) {
  9. int selected = nums[i - 1] + dp[i - 2];
  10. int notSelected = dp[i - 1];
  11. dp[i] = Math.max(selected, notSelected);
  12. }
  13. // 不偷最后一家,所以是减2
  14. int ret1 = dp[dp.length - 2];
  15. // 2. 方案2:不偷第一家,偷最后一家
  16. Arrays.fill(dp, 0);
  17. dp[1] = 0;
  18. for (int i = 2; i < dp.length; i++) {
  19. int selected = nums[i - 1] + dp[i - 2];
  20. int notSelected = dp[i - 1];
  21. dp[i] = Math.max(selected, notSelected);
  22. }
  23. int ret2 = dp[dp.length - 1];
  24. return Math.max(ret1, ret2);
  25. }
  26. }

BM80. 买卖股票的最好时机(一)

方法1:动态规划
具体做法:
对于每天有到此为止的最大收益和是否持股两个状态,因此我们可以用动态规划。

  • step 1:用 dp[i][0] 表示第i 天不持股到该天为止的最大收益,dp[i][1] 表示第i 天持股,到该天为止的最大收益。
  • step 2:(初始状态) 第一天不持股,则总收益为0,dp[0][0] = 0;第一天持股,则总收益为买股票的花费,此时为负数,dp[0][1] = - prices[0]
  • step 3:(状态转移) 对于之后的每一天,如果当天不持股,有可能是前面的若干天中卖掉了或是还没买,因此到此为止的总收益和前一天相同,也有可能是当天才卖掉,我们选择较大的状态 dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
  • step 4:如果当天持股,有可能是前面若干天中买了股票,当天还没卖,因此收益与前一天相同,也有可能是当天买入,此时收益为负的股价,同样是选取最大值:dp[i][1] = max(dp[i - 1][1], -prices[i)

    1. public class Solution {
    2. public int maxProfit(int[] prices) {
    3. int n = prices.length;
    4. // dp[i][0] 表示某一天不持股,到该天为止的最大收益
    5. // dp[i][1] 表示某一天持股,到该天为止的最大收益
    6. int[][] dp = new int[n][2];
    7. dp[0][0] = 0; // 第一天不持股,总收益为0
    8. dp[0][1] = -prices[0]; // 第一天持股,总收益为-prices[0],即买股票花的钱
    9. for (int i = 1; i < n; i++) {
    10. dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
    11. dp[i][1] = Math.max(dp[i - 1][1], -prices[i]);
    12. }
    13. return dp[n - 1][0];
    14. }
    15. }

方法2:贪心
具体做法:
如果我们在某一天卖出了股票,那么要想收益最高,一定是它前面价格最低的那天买入的股票才可以。因此我们可以利用贪心思想解决,每次都将每日收入与最低价格相减维护最大值。

  • step 1:首先排除数组为空的特殊情况。
  • step 2:将第一天看成价格最低,后续遍历的时候遇到价格更低则更新价格最低,每次都比较最大收益与当日价格减去价格最低的值,选取最大值作为最大收益。
  1. import java.util.*;
  2. public class Solution {
  3. // 方法2:贪心
  4. public int maxProfit(int[] prices) {
  5. int res = 0;
  6. if (prices.length == 0) {
  7. return res;
  8. }
  9. int minPrice = prices[0];
  10. for (int i = 1; i < prices.length; i++) {
  11. minPrice = Math.min(minPrice, prices[i]);
  12. res = Math.max(res, prices[i] - minPrice);
  13. }
  14. return res;
  15. }
  16. }

BM81. 买卖股票的最好时机(二)

具体做法:
这道题与买卖股票的最好时机(一)的区别在于可以多次买入卖出。但是对于每天还是有到此为止的最大收益和是否持股两个状态,因此我们照样可以用动态规划。

  • step 1:用dp[i][0] 表示第i 天不持股到该天为止的最大收益,dp[i][1] 表示第i 天持股,到该天为止的最大收益。
  • step 2:(初始状态) 第一天不持股,则总收益为0,dp[0][0] = 0;第一天持股,则总收益为买股票的花费,此时为负数,dp[0][1] = -prices[0]
  • step 3:(状态转移) 对于之后的每一天,如果当天不持股,有可能是前面的若干天中卖掉了或是还没买,因此到此为止的总收益和前一天相同,也有可能是当天卖掉股票,我们选择较大的状态: dp[i][0] = max( dp[i - 1][0], dp[i - 1][1] + prices[i] )
  • step4:如果当天持股,可能是前几天买入的还没卖,因此收益与前一天相同,也有可能是当天买入,减去买入的花费,同样是选取最大值 :dp[i][1] = max ( dp[i - 1][1], dp[i - 1][0] - prices[i] )
  1. public class Solution {
  2. public int maxProfit(int[] prices) {
  3. int len = prices.length;
  4. // dp[i][0]表示某一天不持股到该天为止的最大收益,dp[i][1]表示某天持股,到该天为止的最大收益
  5. int[][] dp = new int[len][2];
  6. dp[0][0] = 0;
  7. dp[0][1] = -prices[0];
  8. for (int i = 1; i < len; i++) {
  9. dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
  10. dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
  11. }
  12. return dp[len - 1][0];
  13. }
  14. }

方法2:贪心算法
具体做法:
其实我们要想获取最大收益,只需要在低价买入高价卖出就可以了,因为可以买卖多次。利用贪心思想:只要一段区间内价格是递增的,那么这段区间的差值就是我们可以有的收益。

  • step 1:遍历数组,只要数组后一个比前一个更大,就可以有收益。
  • step 2:将收益累加。

    1. public class Solution {
    2. public int maxProfit(int[] prices) {
    3. int res = 0;
    4. for (int i = 1; i < prices.length; i++) {
    5. if (prices[i - 1] < prices[i]) {
    6. res += prices[i] - prices[i - 1];
    7. }
    8. }
    9. return res;
    10. }
    11. }

BM82. 买卖股票的最好时机(三)

具体做法:
这道题与买卖股票的最好时机(一)的区别在于最多可以买入卖出2次,那实际上相当于它的状态多了几个,对于每天有到此为止的最大收益和持股情况两个状态,持股情况有了5种变化,我们用:

  • dp[i][0] 表示到第i 天为止没有买过股票的最大收益
  • dp[i][1] 表示到第i天为止买过一次股票还没有卖出的最大收益
  • dp[i][2] 表示到第i天为止买过一次也卖出过一次股票的最大收益
  • dp[i][3] 表示到第i天为止买过两次只卖出过一次股票的最大收益
  • dp[i][4] 表示到第i天为止买过两次同时也买出过两次股票的最大收益

于是使用动态规划,有了如下的状态转移:

  • step 1:(初始状态) 与上述提到的题类似,第0天有买入了和没有买两种状态:dp[0][0] = 0 dp[0][1] = - prices[0]
  • step 2:状态转移: 对于后续的每一天,如果当天还是状态0,则与前一天相同,没有区别;
  • step 3:如果当天状态为1,可能是之前买过了或者当天才第一次买入,选取较大值:dp[i][1] = max( dp[i-1][1], dp[i - 1][0] - prices[i] )
  • step 4:如果当天状态是2,那必须是在1的状态下(已经买入了一次)当天卖出第一次,或者早在之前就卖出只是还没买入第二次,选取较大值:dp[i][2] = max( dp[i - 1][2], dp[i - 1][1] + prices[i] )
  • step 5:如果当天状态是3,那必须是在2的状态下(已经卖出了第一次)当天买入了第二次,或者早在之前就买入了第二次,只是还没卖出,选取较大值:dp[i[[3] = max( dp[i - 1][3] , dp[i - 1][2] - prices[i] );
  • step 6:如果当天是状态4,那必须是在3的状态下(已经买入了第二次)当天再卖出第二次,或者早在之前就卖出了第二次,选取较大值:dp[i][4] = max( dp[i - 1][4], dp[i - 1][3] + prices[i] )
  • step 7:最后我们还要从0、第一次卖出、第二次卖出中选取最大值,因为有可能没有收益,也有可能只交易一次收益最大。
  1. import java.util.*;
  2. public class Solution {
  3. public int maxProfit(int[] prices) {
  4. int len = prices.length;
  5. int[][] dp = new int[len][5];
  6. Arrays.fill(dp[0], -10000);
  7. dp[0][0] = 0;
  8. dp[0][1] = -prices[0];
  9. for (int i = 1; i < len; i++) {
  10. dp[i][0] = dp[i - 1][0];
  11. dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
  12. dp[i][2] = Math.max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
  13. dp[i][3] = Math.max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
  14. dp[i][4] = Math.max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
  15. }
  16. return Math.max(dp[len - 1][2], Math.max(0, dp[len - 1][4]));
  17. }
  18. }

专题8:字符串

BM83. 字符串变形

对于一个长度为 n 字符串,我们需要对它做一些变形。
首先这个字符串中包含着一些空格,就像”Hello World”一样,然后我们要做的是把这个字符串中由空格隔开的单词反序,同时反转每个字符的大小写。
比如”Hello World”变形后就变成了”wORLD hELLO”。

方法1:两次反转
具体做法:

  • step 1:第一次反转整个字符串,这样基本的单词逆序就有了,但是每个单词的字符也是逆的。
  • step 2:第二次遍历字符串的同时反转每个单词。

    1. public class Solution {
    2. public String trans(String s, int n) {
    3. // 1. 转成字符数组,并先改变大小写
    4. char[] chars = s.toCharArray();
    5. reverseCaps(chars);
    6. // 2. 找到每个单词,并反转
    7. int start = 0;
    8. for (int i = 0; i < chars.length; i++) {
    9. if (chars[i] == ' ') {
    10. // 每个单词的起始位置是[start, i - 1]
    11. reverse(chars, start, i - 1);
    12. start = i + 1;
    13. }
    14. }
    15. // 3. 单独反转最后一个单词
    16. reverse(chars, start, n - 1);
    17. // 4. 反转整个数组
    18. reverse(chars, 0, n - 1);
    19. // 5. 返回
    20. return new String(chars);
    21. }
    22. // 改变字母大小写
    23. public static void reverseCaps(char[] arr) {
    24. for (int i = 0; i < arr.length; i++) {
    25. if (arr[i] >= 'a' && arr[i] <= 'z') {
    26. arr[i] -= 32;
    27. } else if (arr[i] >= 'A' && arr[i] <= 'Z') {
    28. arr[i] += 32;
    29. }
    30. }
    31. }
    32. // 反转数组某一段
    33. public static void reverse(char[] arr, int left, int right) {
    34. while (left < right) {
    35. char temp = arr[left];
    36. arr[left] = arr[right];
    37. arr[right] = temp;
    38. left++;
    39. right--;
    40. }
    41. }
    42. }

BM84. 最长公共前缀

方法1:纵向扫描,逐个对比
具体做法:
既然是公共前缀,那我们可以从第一个字符开始,逐位比较,找到最长公共子串。

  • step 1:处理数组为空的特殊情况。
  • step 2:因为最长公共前缀的长度不会超过任何一个字符串的长度,因此我们逐位就以第一个字符串为标杆,遍历第一个字符串的所有位置,取出字符。
  • step 3:遍历数组中后续字符串,依次比较其他字符串中相应位置是否为刚刚取出的字符,如果是,循环继续,继续查找,如果不是或者长度不足,说明从第i位开始不同,前面的都是公共前缀。
  • step 4:如果遍历结束都相同,最长公共前缀最多为第一个字符串。
  1. public class Solution {
  2. public String longestCommonPrefix(String[] strs) {
  3. if (strs == null || strs.length == 0) {
  4. return "";
  5. }
  6. // 1. 以第一个字符串为基准,进行逐一比较
  7. String str1 = strs[0];
  8. for (int i = 0; i < str1.length(); i++) {
  9. char c = str1.charAt(i);
  10. // 2. 遍历其他每个字符串
  11. for (int j = 1; j < strs.length; j++) {
  12. // 3. 如果某个字符串到达末尾,或者字符不相同
  13. if (i == strs[j].length() || c != strs[j].charAt(i)) {
  14. return str1.substring(0, i);
  15. }
  16. }
  17. }
  18. return str1;
  19. }
  20. }

BM85. 验证IP地址

方法1:分割比较法
我们可以先对IP字符串进行分割,然后依次判断每个分割是否符合要求。
具体做法:

  • step 1:写一个split 函数(或者java内置)。
  • step 2:遍历IP 字符串,遇到 “.“ 或者: 将其分开储存在一个数组中。
  • step 3:遍历数组,判断其中每个字符串是否符合上述要求。
  1. public class Solution {
  2. // 方法1:分割比较法
  3. public String solve(String IP) {
  4. if (isIPv4(IP)) {
  5. return "IPv4";
  6. } else if (isIPv6(IP)) {
  7. return "IPv6";
  8. }
  9. return "Neither";
  10. }
  11. private boolean isIPv4(String ip) {
  12. // 没有. ,说明不正确
  13. if (ip.indexOf('.') == -1) {
  14. return false;
  15. }
  16. // 1. 以. 作为分隔符,把字符串分割成数组
  17. String[] arr = ip.split("\\.");
  18. // IPv4必定为4组
  19. if (arr.length != 4) {
  20. return false;
  21. }
  22. // 2. 遍历这四个部分
  23. for (int i = 0; i < arr.length; i++) {
  24. // 3. 校验合法性
  25. // 有一个数组长度为0,说明是两个.. 相连
  26. if (arr[i].length() == 0) {
  27. return false;
  28. }
  29. if (arr[i].length() > 3) {
  30. return false;
  31. }
  32. // 非法IP:开头为0,长度大于1
  33. if (arr[i].charAt(0) == '0' && arr[i].length() != 1) {
  34. return false;
  35. }
  36. // 4. 得到当前这部分的数字
  37. int num = 0;
  38. for (int j = 0; j < arr[i].length(); j++) {
  39. char c = arr[i].charAt(j);
  40. if (c < '0' || c > '9') {
  41. return false;
  42. }
  43. num = num * 10 + (int) (c - '0');
  44. }
  45. if (num < 0 || num > 255) {
  46. return false;
  47. }
  48. }
  49. return true;
  50. }
  51. boolean isIPv6(String IP) {
  52. if (IP.indexOf(':') == -1) {
  53. return false;
  54. }
  55. String[] arr = IP.split(":", -1);
  56. if (arr.length != 8) { // IPv6必定为8组
  57. return false;
  58. }
  59. for (int i = 0; i < arr.length; i++) {
  60. if (arr[i].length() == 0 || arr[i].length() > 4) { // 每个分割不能缺省,不能超过4位
  61. return false;
  62. }
  63. for (int j = 0; j < arr[i].length(); j++) {
  64. // 不能出现a-fA-F以外的大小写字符
  65. char c = arr[i].charAt(j);
  66. boolean expr = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
  67. if (!expr) {
  68. return false;
  69. }
  70. }
  71. }
  72. return true;
  73. }
  74. }

方法2:正则表达式
具体做法:

  • stpe 1:IP地址是有规律可言的,我们可以直接用正则表达式来匹配。
  1. import java.util.regex.Pattern;
  2. public class Solution {
  3. // 方法2:正则表达式
  4. public String solve(String IP) {
  5. // 正则表达式限制0-255 且没有前缀0 四组齐全
  6. String ipv4 = "(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
  7. Pattern ipv4_pattern = Pattern.compile(ipv4);
  8. //正则表达式限制出现8组,0-9a-fA-F的数,个数必须是1-4个
  9. String ipv6 = "([0-9a-fA-F]{1,4}\\:){7}[0-9a-fA-F]{1,4}";
  10. Pattern ipv6_pattern = Pattern.compile(ipv6);
  11. // 调用正则匹配函数
  12. if (ipv4_pattern.matcher(IP).matches()) {
  13. return "IPv4";
  14. } else if (ipv6_pattern.matcher(IP).matches()) {
  15. return "IPv6";
  16. } else {
  17. return "Neither";
  18. }
  19. }
  20. }

BM86. 大数加法

方法1:模拟法
具体思路:
大整数相加,就可以按照整数相加的方式,从个位开始,逐渐往上累加,换到字符串中就是从两个字符串的末尾开始相加。

  • step 1:若是其中一个字符串为空,直接返回另一个,不用加了。
  • step 2:交换两个字符串的位置,我们是s为较长的字符串,t为较短的字符串,结果也记录在较长的字符串中。
  • step 3:从后往前遍历字符串s,每次取出字符转数字,加上进位制,将下标转换为字符串t中从后往前相应的下标,如果下标为非负数则还需要加上字符串t中相应字符转化的数字。
  • step 4:整型除法取进位,取模算法去掉十位,将计算后的结果放入较长数组对应位置。
  • step 5:如果遍历结束,进位值还有,则需要直接在字符串s前增加一个字符‘1’。

这里要注意:int 类型强转为char类型这样写:char c = (char) (num + ‘0’)

  1. public class Solution {
  2. public static String solve(String s, String t) {
  3. if (s.length() == 0) {
  4. return t;
  5. }
  6. if (t.length() == 0) {
  7. return s;
  8. }
  9. if (s.length() < t.length()) {
  10. String temp = s;
  11. s = t;
  12. t = temp;
  13. }
  14. int carry = 0;
  15. char[] arr = new char[s.length()];
  16. for (int i = s.length() - 1; i >= 0; i--) {
  17. int sum = s.charAt(i) - '0' + carry;
  18. int j = i - s.length() + t.length();
  19. if (j >= 0) {
  20. sum += t.charAt(j) - '0';
  21. }
  22. carry = sum / 10;
  23. int remainder = sum % 10;
  24. arr[i] = (char) (remainder + '0');
  25. // arr[i] = (char) (remainder);
  26. }
  27. String str = String.valueOf(arr);
  28. if (carry == 1) {
  29. str = '1' + str;
  30. }
  31. return str;
  32. }
  33. }

专题9:双指针

BM87. 合并两个有序的数组

方法1:合并排序
具体思路:
既然是两个已经排好序的数组,如果可以用新的辅助数组,那很容易我们可以借助归并排序的思想,将排好序的两个子数组合并到一起。但是这道题要求我们在数组A上面添加,那因为数组A后半部分相当于为空,则我们可以考虑逆向使用归并排序思想,从较大的开始排。

  • step 1:使用三个指针,i 指向数组A 的最大元素,j 指向数组B 的最大元素,k 指向数组A 空间的结尾处。
  • step 2:从两个数组最大的元素开始遍历,直到某一个结束,每次取出较大的一个值放入数组A空间的最后,然后指针一次往前。
  • step 3:如果数组B先遍历结束,数组A前半部分已经存在了,不用管;但是如果数组A先遍历结束,则需要把数组B,剩余的前半部分依次逆序加入数组A前半部分,类似归并排序最后的步骤。
  1. public class Solution {
  2. public void merge(int A[], int m, int B[], int n) {
  3. // 1. 三个指针,分别指向尾部
  4. int p1 = m - 1;
  5. int p2 = n - 1;
  6. int index = m + n - 1;
  7. // 2. 从后往前遍历,比较后复制
  8. while (p1 >= 0 && p2 >= 0) {
  9. if (A[p1] > B[p2]) {
  10. A[index--] = A[p1--];
  11. } else {
  12. A[index--] = B[p2--];
  13. }
  14. }
  15. // 3. 如果数组A 先遍历完了,则复制数组B;如果数组B 先遍历完了,不需要处理
  16. if (p1 < 0) {
  17. while (p2 >= 0) {
  18. A[index--] = B[p2--];
  19. }
  20. }
  21. }
  22. }

BM88. 判断是否为回文字符串

方法1:首尾比较法
具体做法:

  • step 1:两个指针,一个在字符串首,一个在字符串尾。
  • step 2:在首的指针往后走,在尾的指针往前走,依次比较路过的两个字符是否相等,直到两指针在中间相遇。(我们这里用下标代替指针
  1. public class Solution {
  2. public boolean judge(String str) {
  3. // 1. 准备两个指针,指向字符串首尾
  4. int left = 0;
  5. int right = str.length() - 1;
  6. // 2. 依次对比首尾两个字符
  7. while (left < right) {
  8. if (str.charAt(left) != str.charAt(right)) {
  9. return false;
  10. }
  11. left++;
  12. right--;
  13. }
  14. return true;
  15. }
  16. }

BM89. 合并区间

方法:排序比较(推荐使用)
具体思路:

  • step 1:既然要求重叠后的区间按照起点位置升序排列,我们就将所有区间按照起点位置先进行排序。使用sort函数进行排序,重载比较方式为比较interval结构的start变量。
  • step 2:排序后的第一个区间一定是起点值最小的区间,我们将其计入返回数组res,然后遍历后续区间。
  • step 3:后续遍历过程中,如果遇到起点值小于res中最后一个区间的末尾值的情况,那一定是重叠,取二者最大末尾值更新res中最后一个区间即可;如果遇到起点值大于res中最后一个区间的末尾值的情况,那一定没有重叠,后续也不会有这个末尾的重叠区间了,因为后面的起点只会更大,因此可以将它加入res。
  1. import java.util.*;
  2. public class Solution {
  3. public ArrayList<Interval> merge(ArrayList<Interval> intervals) {
  4. // 1. 创建一个有序表
  5. ArrayList<Interval> res = new ArrayList<>();
  6. if (intervals.size() == 0) {
  7. return res;
  8. }
  9. // 2. 排序
  10. Collections.sort(intervals, ((o1, o2) -> o1.start - o2.start));
  11. // 3. 添加第一个区间
  12. res.add(intervals.get(0));
  13. int count = 0;
  14. for (int i = 1; i < intervals.size(); i++) {
  15. Interval cur = intervals.get(i);
  16. Interval tail = res.get(count);
  17. // 4. 遍历其他元素,如果当前区间的开头大于结果中最后一个的结尾,说明两者无交集,直接添加到结果有序表中
  18. if (cur.start > tail.end) {
  19. res.add(cur);
  20. count++;
  21. } else {
  22. // 5. 如果有重叠,先移除结果集尾部元素,再创建一个新的区间,修正尾部
  23. res.remove(count);
  24. Interval temp = new Interval(tail.start, cur.end);
  25. if (cur.end < tail.end) {
  26. temp.end = tail.end;
  27. }
  28. res.add(temp);
  29. }
  30. }
  31. return res;
  32. }
  33. }

BM90. 最小覆盖子串

方法:滑动窗口加哈希表
具体思路:

  • step 1:字符串仅包含大小写字母,则字符集是已知且有限的,那这种情况下我们可以考虑使用哈希表——只需要维护一个哈希表,里面是字符串T 的字符为key值,初始化时当字符在T中出现一次则value值减1,后续如果找到就可以将其加回来。
  • step 2:依次遍历字符串S ,如果匹配则将哈希表中的相应的字符加1。
  • step 3:在遍历过程中维护一个窗口,如果哈希表中所有元素都大于0,意味着已经找全了,则窗口收缩向右移动,找最小的窗口,如果不满足这个条件则窗口右移继续匹配。窗口移动的时候需要更新最小窗口,以取得最短子串。
  • step 4:如果匹配到最后,窗口left(初始为-1)也没有右移,说明没有找到,返回空串即可。
  • step 5:最后使用字符串截取函数,截取刚刚记录下的窗口即可得到符合条件的最短子串。

因此,这道题中使用哈希表的一个重要条件是字符集是确定的。

  1. import java.util.*;
  2. public class Solution {
  3. public static String minWindow(String S, String T) {
  4. // 1. 创建数组。相当于哈希表:记录主串中各字符出现次数
  5. int[] hash = new int[128];
  6. // 2. 初始化哈希表。遍历模式串,把元素的值设置为该字符出现次数的负数
  7. for (int i = 0; i < T.length(); i++) {
  8. hash[T.charAt(i)] -= 1;
  9. }
  10. // 3. 创建一个左右边界会改变的滑动窗口,初识左右边界都为0
  11. int left = 0;
  12. int right = 0;
  13. int start = -1;
  14. int end = -1; // 记录左右区间
  15. int count = S.length() + 1;
  16. // 4. 遍历主串
  17. for (; right < S.length(); right++) {
  18. char c = S.charAt(right);
  19. hash[c]++;
  20. // 5. 数组元素值都大于等于0,说明模式串被覆盖了
  21. while (check(hash)) {
  22. // 5.1 更新最小窗口的大小
  23. if (count > right - left + 1) { // 与已有的结果长度比较,取最优解
  24. count = right - left + 1;
  25. start = left;
  26. end = right;
  27. }
  28. // 5.2 使窗口左边界收缩,则原来左边界上的字符出现次数就减一,left 指针加一
  29. c = S.charAt(left);
  30. hash[c]--; // 要缩小窗口左边界,左边界所在的字符出现次数减一
  31. left++; // 调整窗口左边界
  32. }
  33. }
  34. // 6. start 一直等于原始值-1,说明主串没有完全覆盖模式串
  35. if (start == -1) {
  36. return "";
  37. }
  38. // 7. 返回完全覆盖的最小的子串
  39. return S.substring(start, end + 1);
  40. }
  41. // 数组元素都>= 0,返回true
  42. public static boolean check(int[] hash) {
  43. for (int i = 0; i < hash.length; i++) {
  44. if (hash[i] < 0) {
  45. return false;
  46. }
  47. }
  48. return true;
  49. }
  50. }

BM91. 反转字符串

方法一:双指针交换(推荐使用)
具体做法:
字符串反转即逆序,前后顺序是反的,那既然这样我们就将前后的顺序依次对称交换。

  • step 1:准备两个指针,从字符串一首一尾同时出发。
  • step 2:每次交换二者指向的字符,直到二者相遇,这样刚好可以将字符串首尾交换,完成反转。
  1. public class Solution {
  2. public String solve(String str) {
  3. // 1. 字符串转数组,创建两个指针
  4. char[] chars = str.toCharArray();
  5. int left = 0;
  6. int right = chars.length - 1;
  7. // 2. 交换两个指针所在的元素,同时往中间靠
  8. while (left < right) {
  9. char c = chars[left];
  10. chars[left] = chars[right];
  11. chars[right] = c;
  12. left++;
  13. right--;
  14. }
  15. // 3. String 构造方法中传入数组做参数
  16. return new String(chars);
  17. }
  18. }

BM92. 最长无重复子数组

方法:滑动窗口(推荐使用)
具体做法:
既然要找一段连续子数组内不重复的长度,我们可以使用滑动窗口,窗口内都是不重复的,然后窗口右界不断向右滑,如果窗口内出现了重复数组,说明新加入的元素与之前的重复了,此时只需要窗口左界也向右收缩就可以保证窗口内都是不重复的。

  • step 1:使用HashMap 构建一个哈希表,用于统计数组元素在窗口中出现的次数。
  • step 2:窗口左右界都从数组头部开始,每次窗口优先右移右界,并统计进入窗口的元素的出现频率。
  • step 3:一旦右界元素出现频率大于1,就需要右移左界直到窗口内不再重复,将左边的元素移除窗口的时候同时需要将它在哈希表中的频率减1,保证哈希表中的频率都是窗口内的频率。
  • step 4:每轮循环,维护窗口长度最大值。
  1. import java.util.*;
  2. public class Solution {
  3. public int maxLength(int[] arr) {
  4. // 1. 模拟滑动窗口,它的左右边界
  5. int left = 0;
  6. int right = 0;
  7. int maxLen = 0;
  8. // 2. 哈希表,用来存储滑动窗口的元素,和它出现的次数
  9. HashMap<Integer, Integer> hashMap = new HashMap<>();
  10. while (right < arr.length) {
  11. int rightVal = arr[right];
  12. // 3. 出现最新元素,更新哈希表
  13. if (hashMap.containsKey(rightVal)) {
  14. int val = hashMap.get(rightVal);
  15. hashMap.put(rightVal, val + 1);
  16. } else {
  17. hashMap.put(rightVal, 1);
  18. }
  19. // 4. 调整左边界,直到窗口中无重复元素
  20. while (hashMap.get(rightVal) > 1) {
  21. int leftVal = arr[left];
  22. hashMap.put(leftVal, hashMap.get(leftVal) - 1);
  23. left++;
  24. }
  25. // 5. 更新最大值,窗口向右移动
  26. maxLen = Math.max(maxLen, right - left + 1);
  27. right++;
  28. }
  29. return maxLen;
  30. }
  31. }

BM93. 盛水最多的容器

方法:贪心法(建议使用)
具体做法:
这道题类似接雨水问题,还是利用了水桶的短板原理,较短的一边控制最大水量,因此直接用较短边长乘底部两边距离就可以得到当前情况下的容积。但是要怎么找最大值呢?可以用双指针+贪心思想

  • step 1:优先排除不能形成容器的特殊情况。
  • step 2:初始化双指针指向数组首尾,每次利用上述公式计算当前的容积,维护一个最大容积作为返回值。
  • step 3:我们都知道容积与最短边长和底边长有关,双指针向中间靠的情况下,底边长会缩短,因此还想要有更大容积只能是增加最短变长,因此每次指针移动就移动较短的一边,因为贪心思想下较长的一边比较短的一边更可能出现更大容积。
  1. public class Solution {
  2. public int maxArea(int[] height) {
  3. if (height.length < 2) {
  4. return 0;
  5. }
  6. // 1. 左右双指针
  7. int res = 0;
  8. int left = 0;
  9. int right = height.length - 1;
  10. // 2. 两个指针同步往中间移动
  11. while (left < right) {
  12. // 3. 计算容器的宽度、高度和容量,维护最大值
  13. int h = Math.min(height[left], height[right]);
  14. int w = right - left;
  15. int capacity = h * w;
  16. res = Math.max(res, capacity);
  17. // 4. 舍弃边界中较短的
  18. if (height[left] < height[right]) {
  19. left++;
  20. } else {
  21. right--;
  22. }
  23. }
  24. return res;
  25. }
  26. }

BM94. 接雨水问题

方法:双指针(推荐使用)
具体做法:
我们都知道水桶的短板问题,控制水桶水量的是最短的一条板子。这道题也是类似,我们可以将整个图看成一个水桶,两边就是水桶的板,中间比较低的部分就是水桶的底,由较短的边控制水桶的最高水量。但是水桶中可能出现更高的边,比如上图第四列,它比水桶边还要高,那这种情况下它是不是将一个水桶分割成了两个水桶,而中间的那条边就是两个水桶的边。
有了这个思想,解决这道题就容易了,因为我们这里的水桶有两个边,因此可以考虑使用双指针往中间靠。

  • step 1:检查数组是否为空的特殊情况
  • step 2:准备双指针,分别指向数组首尾元素,代表最初的两个边界
  • step 3:指针往中间遍历,遇到更低柱子就是底,用较短的边界减去底就是这一列的接水量,遇到更高的柱子就是新的边界,更新边界大小。
  1. public class Solution {
  2. public long maxWater(int[] arr) {
  3. if (arr.length == 0) {
  4. return 0;
  5. }
  6. // 1. 创建左右双指针
  7. int left = 0;
  8. int right = arr.length - 1;
  9. int maxL = 0;
  10. int maxR = 0;
  11. long res = 0;
  12. // 2. 同步往中间遍历
  13. while (left < right) {
  14. // 3. 维护左右边界最大高度
  15. maxL = Math.max(maxL, arr[left]);
  16. maxR = Math.max(maxR, arr[right]);
  17. // 4. 计算这一列容纳的水量
  18. if (maxL < maxR) {
  19. res += maxL - arr[left++];
  20. } else {
  21. res += maxR - arr[right--];
  22. }
  23. }
  24. return res;
  25. }
  26. }

专题10. 贪心算法

BM95. 分糖果问题

方法:贪心算法(推荐使用)
具体思路:
要想分出最少的糖果,利用贪心思想,肯定是相邻位置没有增加的情况下大家都分到1,相邻位置有增加的情况下,分到糖果数加1就好。什么情况下会增加糖果,相邻位置有得分差异,可能是递增可能是递减,如果是递增的话,糖果依次加1,如果是递减糖果依次减1?这不符合最小,必须从1开始加才是最小,那我们可以反向加1.

  • step 1:使用一个辅助数组记录每个位置的孩子分到的糖果,全部初始化为1.
  • step 2:从左到右遍历数组,如果右边元素比相邻左边元素大,意味着在递增,糖果数就是前一个加1,否则保持1不变。
  • step 3:从右到左遍历数组,如果左边元素比相邻右边元素大, 意味着在原数组中是递减部分,如果左边在上一轮中分到的糖果数更小,则更新为右边的糖果数+1,否则保持不变。
  • step 4:将辅助数组中的元素累加求和。
  1. public class Solution {
  2. public int candy(int[] arr) {
  3. int n = arr.length;
  4. if (n <= 1) {
  5. return n;
  6. }
  7. // 1. 创建辅助数组,全部初始化为1
  8. int[] p = new int[n];
  9. for (int i = 0; i < p.length; i++) {
  10. p[i] = 1;
  11. }
  12. // 2. 从左到右遍历数组,更新辅助数组
  13. for (int i = 1; i < arr.length; i++) {
  14. if (arr[i] > arr[i - 1]) {
  15. p[i] = p[i - 1] + 1;
  16. }
  17. }
  18. int res = p[arr.length - 1];
  19. // 3. 从右往左,再次遍历原数组,根据相邻元素大小关系更新辅助数组
  20. for (int i = arr.length - 2; i >= 0; i--) {
  21. if (arr[i] > arr[i + 1] && p[i] <= p[i + 1]) {
  22. p[i] = p[i + 1] + 1;
  23. }
  24. // 顺便累加总的糖果数
  25. res += p[i];
  26. }
  27. return res;
  28. }
  29. }

BM96. 主持人调度

方法一:排序+遍历比较(推荐使用)
具体做法:

  • step 1: 利用辅助数组单独各个活动开始的时间和结束时间,然后分别进行排序。
  • step 2: 遍历个活动,如果某个活动开始的时间大于之前活动结束的时候,当前主持人就够了,活动结束时间往后一位;
  • step 3: 若是出现之前活动结束时间晚于当前活动开始时间的,需要增加主持人。

2022.07.12 不太理解算法的原理。

  1. import java.util.*;
  2. public class Solution {
  3. // 方法1:排序加遍历
  4. public int minmumNumberOfHost(int n, int[][] startEnd) {
  5. // 1. 把开始和结束时间分成两个数组,并初始化数组的元素
  6. int[] start = new int[n];
  7. int[] end = new int[n];
  8. for (int i = 0; i < n; i++) {
  9. start[i] = startEnd[i][0];
  10. end[i] = startEnd[i][1];
  11. }
  12. // 2. 对两个数组进行排序
  13. Arrays.sort(start);
  14. Arrays.sort(end);
  15. int res = 0;
  16. int j = 0;
  17. for (int i = 0; i < n; i++) {
  18. // 3. 如果新开始的节目大于上一轮结束的时间,就不用新增主持人
  19. if (start[i] >= end[j]) {
  20. j++;
  21. } else {
  22. res++;
  23. }
  24. }
  25. return res;
  26. }
  27. }

专题11:模拟

BM97. 旋转数组

方法:三次翻转(推荐使用)
具体思路:
循环右移相当于从第m 个位置开始,左右两部分视作整体翻转。即abcdefg右移3位efgabcd可以看成AB翻转成BA(这里小写字母看成数组元素,大写字母看成整体)。既然是翻转我们就可以用到reverse函数。

  • step 1:因为m 可能大于n ,因此需要对n 取余,因为每次长度为n 的旋转数组相当于没有变化。
  • step 2:第一次将整个数组翻转,得到数组的逆序,它已经满足了右移的整体出现在了左边。
  • step 3:第二次就将左边的m 个元素单独翻转,因为它虽然移到了左边,但是逆序了。
  • step 4:第三次就将右边的n-m 个元素单独翻转,因此这部分也逆序了。

image.png

  1. import java.util.*;
  2. public class Solution {
  3. public int[] solve(int n, int m, int[] a) {
  4. // 1. 题目中是循环右移,所以m可能大于数组长度n
  5. m = m % n;
  6. reverse(a, 0, n - 1); // 2. 反转整个数组
  7. reverse(a, 0, m - 1); // 3. 反转前面m 个元素
  8. reverse(a, m, n - 1); // 4. 反转后半部分元素
  9. return a;
  10. }
  11. private void reverse(int[] arr, int start, int end) {
  12. while (start < end) {
  13. int temp = arr[start];
  14. arr[start] = arr[end];
  15. arr[end] = temp;
  16. start++;
  17. end--;
  18. }
  19. }
  20. }

BM98. 螺旋矩阵

方法1:顺时针打印
具体思路:
这道题就是一个简单的模拟,我们想象有一个矩阵,从第一个元素开始,往右到底后再往下到底后再往左到底后再往上,结束这一圈,进入下一圈螺旋。

  • step 1:首先排除特殊情况,即矩阵为空的情况。
  • step 2:设置矩阵的四个边界值,开始准备螺旋遍历矩阵,遍历的截止点是左右边界或者上下边界重合。
  • step 3:首先对最上面一排从左到右进行遍历输出,到达最右边后第一排就输出完了,上边界相应就往下一行,要判断上下边界是否相遇相交。
  • step 4:然后输出到了右边,正好就对最右边一列从上到下输出,到底后最右边一列已经输出完了,右边界就相应往左一列,要判断左右边界是否相遇相交。
  • step 5:然后对最下面一排从右到左进行遍历输出,到达最左边后最下一排就输出完了,下边界相应就往上一行,要判断上下边界是否相遇相交。
  • step 6:然后输出到了左边,正好就对最左边一列从下到上输出,到顶后最左边一列已经输出完了,左边界就相应往右一列,要判断左右边界是否相遇相交。
  • step 7:重复上述3-6步骤直到循环结束。

注意:每遍历一条边后就判断一次边界。

  1. import java.util.*;
  2. public class Solution {
  3. public ArrayList<Integer> spiralOrder(int[][] matrix) {
  4. // 1. 进行基本的参数判断
  5. ArrayList<Integer> res = new ArrayList<>();
  6. if (matrix.length == 0) {
  7. return res;
  8. }
  9. // 2. 四个边界
  10. int top = 0;
  11. int bottom = matrix.length - 1;
  12. int left = 0;
  13. int right = matrix[0].length - 1;
  14. while (left <= right && top <= bottom) {
  15. // 3. 顺时针遍历,注意每次要判断边界条件,不然会有意外情况
  16. for (int i = left; i <= right; i++) {
  17. res.add(matrix[top][i]);
  18. }
  19. top++;
  20. if (top > bottom) {
  21. break;
  22. }
  23. for (int i = top; i <= bottom; i++) {
  24. res.add(matrix[i][right]);
  25. }
  26. right--;
  27. if (left > right) {
  28. break;
  29. }
  30. for (int i = right; i >= left; i--) {
  31. res.add(matrix[bottom][i]);
  32. }
  33. bottom--;
  34. if (top > bottom) {
  35. break;
  36. }
  37. for (int i = bottom; i >= top; i--) {
  38. res.add(matrix[i][left]);
  39. }
  40. left++;
  41. if (left > right) {
  42. break;
  43. }
  44. }
  45. return res;
  46. }
  47. }

BM99. 顺时针旋转矩阵

方法1:转置加反转

  1. public class Solution {
  2. public int[][] rotateMatrix(int[][] mat, int n) {
  3. int len = mat.length;
  4. // 1. 矩阵转置
  5. for (int i = 0; i < len; i++) {
  6. for (int j = 0; j < i; j++) {
  7. int temp = mat[i][j];
  8. mat[i][j] = mat[j][i];
  9. mat[j][i] = temp;
  10. }
  11. }
  12. // 2. 反转矩阵的每一行
  13. for (int i = 0; i < len; i++) {
  14. for (int j = 0; j < len / 2; j++) {
  15. int temp = mat[i][j];
  16. mat[i][j] = mat[i][len - 1 - j];
  17. mat[i][len - 1 - j] = temp;
  18. }
  19. }
  20. return mat;
  21. }
  22. }

BM100. 设计LRU缓存结构

方法:构建双向链表(推荐使用)

插入与访问值都是O(1),没有任何一种数据结构可以做到。于是我们可以想到数据结构的组合。访问O(1)很容易想到了哈希表,插入O(1)有很多,但是如果访问到了再插入,且超出长度要在之内删除,我们可以想到用链表。因为要在O(1) 之内删除最不常访问的,所以是双向链表。于是我们的方法就是哈希表+双向链表。
具体做法:

  • step 1:用哈希表存储链表结点和key值,能够做到O(1) 访问链表任意结点
  • step 2:每次调用函数后将该结点放到链表最前方表示权重最大,最常访问,每次删除链表最后一个结点。
  • step 3:要实现这个操作,我们需要的是有头结点和尾结点的双向链表。 ```java import java.util.*;

public class Solution {

  1. // 内部类
  2. class Node {
  3. int key, val;
  4. Node prex, next;
  5. Node() {
  6. }
  7. Node(int key, int val) {
  8. this.key = key;
  9. this.val = val;
  10. }
  11. }
  12. Map<Integer, Node> cache = new HashMap<>();
  13. Node head = new Node();
  14. Node tail = new Node();
  15. int size;
  16. int capacity;
  17. // 有参构造方法
  18. public Solution(int capacity) {
  19. this.capacity = capacity;
  20. size = 0;
  21. head.next = tail;
  22. tail.prex = head;
  23. }
  24. public int get(int key) {
  25. // write code here
  26. Node node = cache.get(key);
  27. if (node == null) {
  28. return -1;
  29. }
  30. moveToHead(node);
  31. return node.val;
  32. }
  33. public void set(int key, int value) {
  34. // write code here
  35. Node node = cache.get(key);
  36. if (node != null) {
  37. node.val = value;
  38. moveToHead(node);
  39. } else {
  40. Node newNode = new Node(key, value);
  41. cache.put(key, newNode);
  42. addToHead(newNode);
  43. size++;
  44. if (size > capacity) {
  45. Node removeNode = removeTail();
  46. cache.remove(removeNode.key);
  47. size--;
  48. }
  49. }
  50. }
  51. private void addToHead(Node node) {
  52. node.next = head.next;
  53. node.prex = head;
  54. head.next.prex = node;
  55. head.next = node;
  56. }
  57. private void remove(Node node) {
  58. node.prex.next = node.next;
  59. node.next.prex = node.prex;
  60. }
  61. private void moveToHead(Node node) {
  62. remove(node);
  63. addToHead(node);
  64. }
  65. private Node removeTail() {
  66. Node temp = tail.prex;
  67. remove(temp);
  68. return temp;
  69. }

}

/**

  • Your Solution object will be instantiated and called as such:
  • Solution solution = new Solution(capacity);
  • int output = solution.get(key);
  • solution.set(key,value); */ ```

BM101. 设计LFU缓存结构

方法1:双哈希表
具体做法:

  • step 1: 需要建立一个双向量表及两个哈希表,链表结点存储频率、key及value。第一个哈希表建立链表与频率的映射,旨在能O(1)找到最小频率;第二个哈希表建立键值key到第一个哈希表的映射,旨在能找到key对应的那组数据。
  • step 2: 对于get函数,直接访问哈希表即可,但是访问后要更新频率;
  • step 3: 对于set函数,需要容量未满,则直接加入,若是满了则通过第一个哈希表剔出频率最低的结点,最后要更新结点频率为1。