概述 数据结构的储存方式 数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储) 。
这句话怎么理解,不是还有散列表、栈、队列、堆、树、图等等各种数据结构吗?
我们分析问题,一定要有递归的思想,自顶向下,从抽象到具体。你上来就列出这么多,那些都属于「上层建筑」,而数组和链表才是「结构基础」。因为那些多样化的数据结构,究其源头,都是在链表或者数组上的特殊操作,API 不同而已。
比如说「队列」、「栈」这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针。
「图」的两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵。
「散列表」就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。
「树」,用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题。
了解 Redis 数据库的朋友可能也知道,Redis 提供列表、字符串、集合等等几种常用数据结构,但是对于每种数据结构,底层的存储方式都至少有两种,以便于根据存储数据的实际情况使用合适的存储方式。
综上,数据结构种类很多,甚至你也可以发明自己的数据结构,但是底层存储无非数组或者链表,二者的优缺点如下 :
数组 由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。
链表 因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。
数据结构的基本操作 对于任何数据结构,其基本操作无非遍历 + 访问,再具体一点就是:增删查改。
数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改 。话说这不就是数据结构的使命么?
如何遍历 + 访问?我们仍然从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。
线性就是 for/while 迭代为代表,非线性就是递归为代表。再具体一步,无非以下几种框架:
数组遍历框架,典型的线性迭代结构:
1 2 3 4 5 void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代访问 arr[i] } }
链表遍历框架,兼具迭代和递归结构:
1 2 3 /* 基本的单链表节点 */class ListNode { int val; ListNode next;} void traverse(ListNode head) { for (ListNode p = head; p != null; p = p.next) { // 迭代访问 p.val }} void traverse(ListNode head) { // 递归访问 head.val traverse(head.next);}
二叉树遍历框架,典型的非线性递归遍历结构:
1 2 /* 基本的二叉树节点 */class TreeNode { int val; TreeNode left, right;} void traverse(TreeNode root) { traverse(root.left); traverse(root.right);}
你看二叉树的递归遍历方式和链表的递归遍历方式,相似不?再看看二叉树结构和单链表结构,相似不?如果再多几条叉,N 叉树你会不会遍历?
二叉树框架可以扩展为 N 叉树的遍历框架:
1 2 /* 基本的 N 叉树节点 */class TreeNode { int val; TreeNode[] children;} void traverse(TreeNode root) { for (TreeNode child : root.children) traverse(child);}
N 叉树的遍历又可以扩展为图的遍历,因为图就是好几 N 叉棵树的结合体。你说图是可能出现环的?这个很好办,用个布尔数组 visited 做标记就行了,这里就不写代码了。
所谓框架,就是套路。不管增删查改,这些代码都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了,下面会具体举例 。
链表问题 1 2 3 4 5 6 public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } }
什么叫反转单链表的一部分呢,就是给你一个索引区间,让你把单链表中这部分元素反转,其他部分不变:
注意这里的索引是从 1 开始的 。迭代的思路大概是:先用一个 for 循环找到第 m
个位置,然后再用一个 for 循环将 m
和 n
之间的元素反转。但是我们的递归解法不用一个 for 循环,纯递归实现反转。
迭代实现思路看起来虽然简单,但是细节问题很多的,反而不容易写对。相反,递归实现就很简洁优美,下面就由浅入深,先从反转整个单链表说起。
递归反转整个链表 1 2 3 4 5 6 7 ListNode reverse (ListNode head) { if (head.next == null ) return head; ListNode last = reverse(head.next); head.next.next = head; head.next = null ; return last; }
对于递归算法,最重要的就是明确递归函数的定义 。具体来说,我们的 reverse
函数定义是这样的:
输入一个节点 head
,将「以 head
为起点」的链表反转,并返回反转之后的头结点 。
明白了函数的定义,在来看这个问题。比如说我们想反转这个链表:
那么输入 reverse(head)
后,会在这里进行递归:
1 ListNode last = reverse(head.next);
根据刚才的函数定义,来弄清楚这段代码会产生什么结果:
并且根据函数定义,reverse
函数会返回反转之后的头结点,我们用变量 last
接收了。
现在再来看下面的代码:
接下来:
1 2 head.next = null ; return last;
反转前n个节点 这次我们实现一个这样的函数:
1 2 ListNode reverseN (ListNode head, int n)
比如说对于下图链表,执行 reverseN(head, 3)
:
解决思路和反转整个链表差不多,只要稍加修改即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ListNode successor = null ; ListNode reverseN (ListNode head, int n) { if (n == 1 ) { successor = head.next; return head; } ListNode last = reverseN(head.next, n - 1 ); head.next.next = head; head.next = successor; return last; }
具体的区别:
1、base case 变为 n == 1
,反转一个元素,就是它本身,同时要记录后驱节点 。
2、刚才我们直接把 head.next
设置为 null,因为整个链表反转后原来的 head
变成了整个链表的最后一个节点。但现在 head
节点在递归反转之后不一定是最后一个节点了,所以要记录后驱 successor
(第 n + 1 个节点),反转之后将 head
连接上。
反转链表的一部分 现在解决我们最开始提出的问题,给一个索引区间 [m,n]
(索引从 1 开始),仅仅反转区间中的链表元素。
1 ListNode reverseBetween (ListNode head, int m, int n)
首先,如果 m == 1
,就相当于反转链表开头的 n
个元素嘛,也就是我们刚才实现的功能:
1 2 3 4 5 6 7 8 ListNode reverseBetween (ListNode head, int m, int n) { if (m == 1 ) { return reverseN(head, n); } }
如果 m != 1
怎么办?如果我们把 head
的索引视为 1,那么我们是想从第 m
个元素开始反转对吧;如果把 head.next
的索引视为 1 呢?那么相对于 head.next
,反转的区间应该是从第 m - 1
个元素开始的;那么对于 head.next.next
呢……
区别于迭代思想,这就是递归思想,所以我们可以完成代码:
1 2 3 4 5 6 7 8 9 ListNode reverseBetween (ListNode head, int m, int n) { if (m == 1 ) { return reverseN(head, n); } head.next = reverseBetween(head.next, m - 1 , n - 1 ); return head; }
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class Solution { ListNode successor = null ; public ListNode reverseBetween (ListNode head, int m, int n) { if (m == 1 ) { return reverseN(head, n); } head.next = reverseBetween(head.next, m - 1 , n - 1 ); return head; } private ListNode reverseN (ListNode head, int n) { if (n == 1 ) { successor = head.next; return head; } ListNode last = reverseN(head.next,n - 1 ); head.next.next = head; head.next = successor; return last; } }
问题分析 首先,前文学习数据结构的框架思维 提到过,链表是一种兼具递归和迭代性质的数据结构,认真思考一下可以发现这个问题具有递归性质 。
什么叫递归性质?直接上图理解,比如说我们对这个链表调用 reverseKGroup(head, 2)
,即以 2 个节点为一组反转链表:
如果我设法把前 2 个节点反转,那么后面的那些节点怎么处理?后面的这些节点也是一条链表,而且规模(长度)比原来这条链表小,这就叫子问题 。
我们可以直接递归调用 reverseKGroup(cur, 2)
,因为子问题和原问题的结构完全相同,这就是所谓的递归性质。
发现了递归性质,就可以得到大致的算法流程:
1、先反转以 head
开头的 k
个元素 。
2、将第 k + 1
个元素作为 head
递归调用 reverseKGroup
函数 。
3、将上述两个过程的结果连接起来 。
整体思路就是这样了,最后一点值得注意的是,递归函数都有个 base case,对于这个问题是什么呢?
题目说了,如果最后的元素不足 k
个,就保持不变。这就是 base case,待会会在代码里体现。
代码实现 首先,我们要实现一个 reverse
函数反转一个区间之内的元素。在此之前我们再简化一下,给定链表头结点,如何反转整个链表?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ListNode reverse (ListNode a) { ListNode pre, cur, nxt; pre = null ; cur = a; nxt = a; while (cur != null ) { nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } return pre; }
这次使用迭代思路来实现的,借助动画理解应该很容易。
「反转以 a
为头结点的链表」其实就是「反转 a
到 null 之间的结点」,那么如果让你「反转 a
到 b
之间的结点」,你会不会?
只要更改函数签名,并把上面的代码中 null
改成 b
即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ListNode reverse (ListNode a, ListNode b) { ListNode pre, cur, nxt; pre = null ; cur = a; nxt = a; while (cur != b) { nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } return pre; }
现在我们迭代实现了反转部分链表的功能,接下来就按照之前的逻辑编写 reverseKGroup
函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 class Solution { public ListNode reverseKGroup (ListNode head, int k) { if (head == null ) { return null ; } ListNode a, b; a = head; b = head; for (int i = 0 ; i < k; i++) { if (b == null ) { return head; } b = b.next; } ListNode newHead = reverse(a, b); a.next = reverseKGroup(b, k); return newHead; } private ListNode reverse (ListNode a, ListNode b) { ListNode pre, cur, nxt; pre = null ; cur = a; nxt = a; while (cur != b) { nxt = cur.next; cur.next = pre; pre = cur; cur = nxt; } return pre; } }
关键是前面的步骤:
翻转 连接
解释一下 for
循环之后的几句代码,注意 reverse
函数是反转区间 [a, b)
,所以情形是这样的:
递归部分就不展开了,整个函数递归完成之后就是这个结果,完全符合题意:
判断回文单链表 输入一个单链表的头结点,判断这个链表中的数字是不是回文:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 boolean isPalindrome (ListNode head) ;输入: 1 ->2 ->null 输出: false 输入: 1 ->2 ->2 ->1 ->null 输出: true
这道题的关键在于,单链表无法倒着遍历,无法使用双指针技巧。那么最简单的办法就是,把原始链表反转存入一条新的链表,然后比较这两条链表是否相同。关于如何反转链表,可以参见前文「递归操作链表」。
其实,借助二叉树后序遍历的思路,不需要显式反转原始链表也可以倒序遍历链表 ,下面来具体聊聊。
对于二叉树的几种遍历方式,我们再熟悉不过了:
1 2 3 4 5 6 7 void traverse (TreeNode root) { traverse(root.left); traverse(root.right); }
在「学习数据结构的框架思维」中说过,链表兼具递归结构,树结构不过是链表的衍生。那么,链表其实也可以有前序遍历和后序遍历 :
1 2 3 4 5 void traverse (ListNode head) { traverse(head.next); }
这个框架有什么指导意义呢?如果我想正序打印链表中的val
值,可以在前序遍历位置写代码;反之,如果想倒序遍历链表,就可以在后序遍历位置操作:
1 2 3 4 5 6 7 void traverse (ListNode head) { if (head == null ) return ; traverse(head.next); print(head.val); }
说到这了,其实可以稍作修改,模仿双指针实现回文判断的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ListNode left; boolean isPalindrome (ListNode head) { left = head; return traverse(head); } boolean traverse (ListNode right) { if (right == null ) return true ; boolean res = traverse(right.next); res = res && (right.val == left.val); left = left.next; return res; }
这么做的核心逻辑是什么呢?实际上就是把链表节点放入一个栈,然后再拿出来,这时候元素顺序就是反的 ,只不过我们利用的是递归函数的堆栈而已。
优化空间复杂度 更好的思路是这样的:
1、先通过「双指针技巧」中的快慢指针来找到链表的中点 :
1 2 3 4 5 6 7 ListNode slow, fast; slow = fast = head; while (fast != null && fast.next != null ) { slow = slow.next; fast = fast.next.next; }
2、如果fast
指针没有指向null
,说明链表长度为奇数,slow
还要再前进一步 :
1 2 if (fast != null ) slow = slow.next;
3、从slow
开始反转后面的链表,现在就可以开始比较回文串了 :
1 2 3 4 5 6 7 8 9 10 ListNode left = head; ListNode right = reverse(slow); while (right != null ) { if (left.val != right.val) return false ; left = left.next; right = right.next; } return true ;
至此,把上面 3 段代码合在一起就高效地解决这个问题了,其中reverse
函数很容易实现:
1 2 3 4 5 6 7 8 9 10 ListNode reverse (ListNode head) { ListNode pre = null , cur = head; while (cur != null ) { ListNode next = cur.next; cur.next = pre; pre = cur; cur = next; } return pre; }
算法总体的时间复杂度 O(N),空间复杂度 O(1),已经是最优的了。
我知道肯定有读者会问:这种解法虽然高效,但破坏了输入链表的原始结构,能不能避免这个瑕疵呢?
其实这个问题很好解决,关键在于得到p, q
这两个指针位置:
这样,只要在函数 return 之前加一段代码即可恢复原先链表顺序:
总结 首先,寻找回文串是从中间向两端扩展,判断回文串是从两端向中间收缩。对于单链表,无法直接倒序遍历,可以造一条新的反转链表,可以利用链表的后序遍历,也可以用栈结构倒序处理单链表。
具体到回文链表的判断问题,由于回文的特殊性,可以不完全反转链表,而是仅仅反转部分链表,将空间复杂度降到 O(1)。
二叉树 1 2 3 4 5 6 7 8 void traverse (TreeNode root) { traverse(root.left) traverse(root.right) }
第一期 二叉树的重要性 快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后续遍历
快速排序的逻辑是,若要对 nums[lo..hi]
进行排序,我们先找一个分界点 p
,通过交换元素使得 nums[lo..p-1]
都小于等于 nums[p]
,且 nums[p+1..hi]
都大于 nums[p]
,然后递归地去 nums[lo..p-1]
和 nums[p+1..hi]
中寻找新的分界点,最后整个数组就被排序了。
快速排序的代码框架如下:
1 2 3 4 5 6 7 8 9 void sort (int [] nums, int lo, int hi) { int p = partition(nums, lo, hi); sort(nums, lo, p - 1 ); sort(nums, p + 1 , hi); }
先构造分界点,然后去左右子数组构造分界点,你看这不就是一个二叉树的前序遍历吗?
再说说归并排序的逻辑,若要对 nums[lo..hi]
进行排序,我们先对 nums[lo..mid]
排序,再对 nums[mid+1..hi]
排序,最后把这两个有序的子数组合并,整个数组就排好序了。
归并排序的代码框架如下:
1 2 3 4 5 6 7 8 9 10 void sort (int [] nums, int lo, int hi) { int mid = (lo + hi) / 2 ; sort(nums, lo, mid); sort(nums, mid + 1 , hi); merge(nums, lo, mid, hi); }
先对左右子数组排序,然后合并(类似合并有序链表的逻辑),你看这是不是二叉树的后序遍历框架?另外,这不就是传说中的分治算法嘛,不过如此呀。
递归算法 写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要跳入递归的细节 。
怎么理解呢,我们用一个具体的例子来说,比如说让你计算一棵二叉树共有几个节点:
1 2 3 4 5 6 7 int count (TreeNode root) { if (root == null ) return 0 ; return 1 + count(root.left) + count(root.right); }
这个问题非常简单,大家应该都会写这段代码,root
本身就是一个节点,加上左右子树的节点数就是以 root
为根的树的节点总数。
左右子树的节点数怎么算?其实就是计算根为 root.left
和 root.right
两棵树的节点数呗,按照定义,递归调用 count
函数即可算出来。
写树相关的算法,简单说就是,先搞清楚当前 root
节点该做什么,然后根据函数定义递归调用子节点 ,递归调用会让孩子节点做相同的事情。
翻转二叉树 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution { public TreeNode invertTree (TreeNode root) { if (root == null ) { return null ; } TreeNode tmp = root.left; root.left = root.right; root.right = tmp; invertTree(root.left); invertTree(root.right); return root; } }
关键思路在于我们发现翻转整棵树就是交换每个节点的左右子节点,于是我们把交换左右子节点的代码放在了前序遍历的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Solution { public Node connect (Node root) { if (root == null ) { return null ; } connectTwoNode(root.left,root.right); return root; } private void connectTwoNode (Node node1, Node node2) { if (node1 == null || node2 == null ) { return ; } node1.next = node2; connectTwoNode(node1.left, node1.right); connectTwoNode(node2.left, node2.right); connectTwoNode(node1.right, node2.left); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution { public void flatten (TreeNode root) { if (root == null ) { return ; } flatten(root.left); flatten(root.right); TreeNode right = root.right; root.right = root.left; root.left = null ; TreeNode p = root; while (p.right != null ) { p = p.right; } p.right = right; } }
第二期 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 class Solution { public TreeNode constructMaximumBinaryTree (int [] nums) { return build(nums, 0 , nums.length - 1 ); } private TreeNode build (int [] nums, int start, int end) { if (end < start) { return null ; } int index = 0 ; int maxValue = Integer.MIN_VALUE; for (int i = start; i <= end; i++) { if (nums[i] > maxValue) { maxValue = nums[i]; index = i; } } TreeNode root = new TreeNode(maxValue); root.left = build(nums, start, index - 1 ); root.right = build(nums, index, end); return root; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class Solution { public TreeNode buildTree (int [] preorder, int [] inorder) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0 ; i < inorder.length; i++) { map.put(inorder[i], i); } return build(preorder, 0 , preorder.length - 1 , inorder, 0 , inorder.length - 1 ,map); } private TreeNode build (int [] preorder, int preStart, int preEnd, int [] inorder, int inStart, int inEnd, Map<Integer, Integer> map) { if (preEnd < preStart) { return null ; } int rootValue = preorder[preStart]; TreeNode root = new TreeNode(rootValue); int rootIndex = map.get(rootValue); int leftNodes = rootIndex - inStart, rightNodes = inEnd - rootIndex; root.left = build(preorder, preStart + 1 , preStart + leftNodes, inorder, inStart, rootIndex - 1 , map); root.right = build(preorder, preStart + leftNodes + 1 , preEnd, inorder, rootIndex + 1 , inEnd, map); return root; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { public TreeNode buildTree (int [] inorder, int [] postorder) { int n = inorder.length; Map<Integer, Integer> map = new HashMap<>(); for (int i = 0 ; i < n; i++) { map.put(inorder[i], i); } return build(inorder, 0 , n - 1 , postorder, 0 , n - 1 , map); } TreeNode build (int [] inorder, int inStart, int inEnd, int [] postorder, int postStart, int postEnd, Map<Integer, Integer> map) { if (postStart > postEnd) { return null ; } int rootValue = postorder[postEnd]; int rootIndex = map.get(rootValue); TreeNode root = new TreeNode(rootValue); int leftNodes = rootIndex - inStart, rightNodes = inEnd - rootIndex; root.left = build(inorder, inStart, rootIndex - 1 , postorder, postStart, postStart + leftNodes - 1 , map); root.right = build(inorder, rootIndex + 1 , inEnd, postorder, postStart + leftNodes, postEnd - 1 , map); return root; } }
第三期 如何判断我们应该用前序还是中序还是后序遍历的框架 ?
根据题意,思考一个二叉树节点需要做什么,到底用什么遍历顺序就清楚了 。
题目解释:
输入是一棵二叉树的根节点root
,返回的是一个列表,里面装着若干个二叉树节点,这些节点对应的子树在原二叉树中是存在重复的。
比如输入如下的二叉树:
首先,节点 4 本身可以作为一棵子树,且二叉树中有多个节点 4:
类似的,还存在两棵以 2 为根的重复子树:
那么,我们返回的List
中就应该有两个TreeNode
,值分别为 4 和 2(具体是哪个节点都无所谓)。
这题咋做呢?还是老套路,先思考,对于某一个节点,它应该做什么 。
比如说,你站在图中这个节点 2 上:
如果你想知道以自己为根的子树是不是重复的,是否应该被加入结果列表中,你需要知道什么信息?
你需要知道以下两点 :
1、以我为根的这棵二叉树(子树)长啥样 ?
2、以其他节点为根的子树都长啥样 ?
先来思考,我如何才能知道以自己为根的二叉树长啥样 ?
其实看到这个问题,就可以判断本题要使用「后序遍历」框架来解决:
1 2 3 4 5 void traverse (TreeNode root) { traverse(root.left); traverse(root.right); }
为什么?很简单呀,我要知道以自己为根的子树长啥样,是不是得先知道我的左右子树长啥样,再加上自己,就构成了整棵子树的样子?
如果你还绕不过来,我再来举个非常简单的例子:计算一棵二叉树有多少个节点。这个代码应该会写吧:
1 2 3 4 5 6 7 8 9 10 11 12 int count(TreeNode root) { if (root == null) { return 0; } // 先算出左右子树有多少节点 int left = count(root.left); int right = count(root.right); /* 后序遍历代码位置 */ // 加上自己,就是整棵二叉树的节点数 int res = left + right + 1; return res; }
怎么描述一棵二叉树的模样呢?
二叉树的前序/中序/后序遍历结果可以描述二叉树的结构。
所以,我们可以通过拼接字符串的方式把二叉树序列化,看下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 String traverse (TreeNode root) { if (root == null ) { return "#" ; } String left = traverse(root.left); String right = traverse(root.right); String subTree = left + "," + right + "," + root.val; return subTree; }
现在我们解决第二个问题,我知道了自己长啥样,怎么知道别人长啥样 ?这样我才能知道有没有其他子树跟我重复对吧。
这很简单呀,我们借助一个外部数据结构,让每个节点把自己子树的序列化结果存进去,这样,对于每个节点,不就可以知道有没有其他节点的子树和自己重复了么?
初步思路可以使用HashSet
记录子树,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 记录所有子树 HashSet<String> memo = new HashSet<>(); // 记录重复的子树根节点 LinkedList<TreeNode> res = new LinkedList<>(); String traverse(TreeNode root) { if (root == null) { return "#"; } String left = traverse(root.left); String right = traverse(root.right); String subTree = left + "," + right+ "," + root.val; if (memo.contains(subTree)) { // 有人和我重复,把自己加入结果列表 res.add(root); } else { // 暂时没人跟我重复,把自己加入集合 memo.add(subTree); } return subTree; }
但是呢,这有个问题,如果出现多棵重复的子树,结果集res
中必然出现重复,而题目要求不希望出现重复。
为了解决这个问题,可以把HashSet
升级成HashMap
,额外记录每棵子树的出现次数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // 记录所有子树以及出现的次数 HashMap<String, Integer> memo = new HashMap<>(); // 记录重复的子树根节点 LinkedList<TreeNode> res = new LinkedList<>(); /* 主函数 */ List<TreeNode> findDuplicateSubtrees(TreeNode root) { traverse(root); return res; } /* 辅助函数 */ String traverse(TreeNode root) { if (root == null) { return "#"; } String left = traverse(root.left); String right = traverse(root.right); String subTree = left + "," + right+ "," + root.val; int freq = memo.getOrDefault(subTree, 0); // 多次重复也只会被加入结果集一次 if (freq == 1) { res.add(root); } // 给子树对应的出现次数加一 memo.put(subTree, freq + 1); return subTree; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 class Solution { private HashMap<String, Integer> memo; private List<TreeNode> res; public List<TreeNode> findDuplicateSubtrees (TreeNode root) { memo = new HashMap<>(); res = new ArrayList<>(); traverse(root); return res; } private String traverse (TreeNode root) { if (root == null ) { return "#" ; } String left = traverse(root.left); String right = traverse(root.right); String subTree = left + "," + right + "," + root.val; int frep = memo.getOrDefault(subTree, 0 ); if (frep == 1 ) { res.add(root); } memo.put(subTree, frep + 1 ); return subTree; } }
二叉搜索树第一期 寻找第k小的元素
这个需求很常见吧,一个直接的思路就是升序排序,然后找第k
个元素呗。BST 的中序遍历其实就是升序排序的结果,找第k
个元素肯定不是什么难事。
按照这个思路,可以直接写出代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 int kthSmallest (TreeNode root, int k) { traverse(root, k); return res; } int res = 0 ;int rank = 0 ;void traverse (TreeNode root, int k) { if (root == null ) { return ; } traverse(root.left, k); rank++; if (k == rank) { res = root.val; return ; } traverse(root.right, k); }
这道题就做完了,不过呢,还是要多说几句,因为这个解法并不是最高效的解法,而是仅仅适用于这道题。
如果按照我们刚才说的方法,利用「BST 中序遍历就是升序排序结果」这个性质,每次寻找第k
小的元素都要中序遍历一次,最坏的时间复杂度是O(N)
,N
是 BST 的节点个数。
要知道 BST 性质是非常牛逼的,像红黑树这种改良的自平衡 BST,增删查改都是O(logN)
的复杂度,让你算一个第k
小元素,时间复杂度竟然要O(N)
,有点低效了。
所以说,计算第k
小元素,最好的算法肯定也是对数级别的复杂度,不过这个依赖于 BST 节点记录的信息有多少。
我们想一下 BST 的操作为什么这么高效?就拿搜索某一个元素来说,BST 能够在对数时间找到该元素的根本原因还是在 BST 的定义里,左子树小右子树大嘛,所以每个节点都可以通过对比自身的值判断去左子树还是右子树搜索目标值,从而避免了全树遍历,达到对数级复杂度。
那么回到这个问题,想找到第k
小的元素,或者说找到排名为k
的元素,如果想达到对数级复杂度,关键也在于每个节点得知道他自己排第几。
比如说你让我查找排名为k
的元素,当前节点知道自己排名第m
,那么我可以比较m
和k
的大小:
1、如果m == k
,显然就是找到了第k
个元素,返回当前节点就行了。
2、如果k < m
,那说明排名第k
的元素在左子树,所以可以去左子树搜索第k
个元素。
3、如果k > m
,那说明排名第k
的元素在右子树,所以可以去右子树搜索第k - m - 1
个元素。
这样就可以将时间复杂度降到O(logN)
了。
那么,如何让每一个节点知道自己的排名呢?
这就是我们之前说的,需要在二叉树节点中维护额外信息。每个节点需要记录,以自己为根的这棵二叉树有多少个节点 。
也就是说,我们TreeNode
中的字段应该如下:
1 2 3 4 5 6 7 class TreeNode { int val; int size; TreeNode left; TreeNode right; }
有了size
字段,外加 BST 节点左小右大的性质,对于每个节点node
就可以通过node.left
推导出node
的排名,从而做到我们刚才说到的对数级算法。
当然,size
字段需要在增删元素的时候需要被正确维护,力扣提供的TreeNode
是没有size
这个字段的,所以我们这道题就只能利用 BST 中序遍历的特性实现了,但是我们上面说到的优化思路是 BST 的常见操作,还是有必要理解的。
题目应该不难理解,比如图中的节点 5,转化成累加树的话,比 5 大的节点有 6,7,8,加上 5 本身,所以累加树上这个节点的值应该是 5+6+7+8=26。
我们需要把 BST 转化成累加树,函数签名如下:
1 TreeNode convertBST(TreeNode root)
按照二叉树的通用思路,需要思考每个节点应该做什么,但是这道题上很难想到什么思路。
BST 的每个节点左小右大,这似乎是一个有用的信息,既然累加和是计算大于等于当前值的所有元素之和,那么每个节点都去计算右子树的和,不就行了吗?
这是不行的。对于一个节点来说,确实右子树都是比它大的元素,但问题是它的父节点也可能是比它大的元素呀?这个没法确定的,我们又没有触达父节点的指针,所以二叉树的通用思路在这里用不了。
其实,正确的解法很简单,还是利用 BST 的中序遍历特性 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution { public int sum = 0 ; public TreeNode convertBST (TreeNode root) { traverse(root); return root; } private void traverse (TreeNode root) { if (root == null ) { return ; } traverse(root.right); sum += root.val; root.val = sum; traverse(root.left); } }
核心还是 BST 的中序遍历特性,只不过我们修改了递归顺序,降序遍历 BST 的元素值,从而契合题目累加树的要求。
BST 相关的问题,要么利用 BST 左小右大的特性提升算法效率,要么利用中序遍历的特性满足题目的要求,也就这么些事儿吧。
二叉搜索树第二期 这里是有坑的哦,我们按照刚才的思路,每个节点自己要做的事不就是比较自己和左右孩子吗?看起来应该这样写代码:
1 2 3 4 5 6 7 8 9 10 boolean isValidBST(TreeNode root) { if (root == null) return true; if (root.left != null && root.val <= root.left.val) return false; if (root.right != null && root.val >= root.right.val) return false; return isValidBST(root.left) && isValidBST(root.right); }
但是这个算法出现了错误,BST 的每个节点应该要小于右边子树的所有节点,下面这个二叉树显然不是 BST,因为节点 10 的右子树中有一个节点 6,但是我们的算法会把它判定为合法 BST:
出现问题的原因在于,对于每一个节点 root
** ,代码值检查了它的左右孩子节点是否符合左小右大的原则;但是根据 BST 的定义,**root
的整个左子树都要小于 root.val
** ,整个右子树都要大于 root.val
**。
问题是,对于某一个节点 root
,他只能管得了自己的左右子节点,怎么把 root
的约束传递给左右子树呢?
请看正确的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 boolean isValidBST(TreeNode root) { return isValidBST(root, null, null); } /* 限定以 root 为根的子树节点必须满足 max.val > root.val > min.val */ boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) { // base case if (root == null) return true; // 若 root.val 不符合 max 和 min 的限制,说明不是合法 BST if (min != null && root.val <= min.val) return false; if (max != null && root.val >= max.val) return false; // 限定左子树的最大值是 root.val,右子树的最小值是 root.val return isValidBST(root.left, min, root) && isValidBST(root.right, root, max); }
我们通过使用辅助函数,增加函数参数列表,在参数中携带额外信息,将这种约束传递给子树的所有节点,这也是二叉树算法的一个小技巧吧 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution { public TreeNode searchBST (TreeNode root, int val) { if (root == null ){ return null ; } if (root.val == val){ return root; } if (val < root.val){ return searchBST(root.left, val); } if (val > root.val){ return searchBST(root.right, val); } return null ; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Solution { public TreeNode insertIntoBST (TreeNode root, int val) { if (root == null ) { return new TreeNode(val); } if (root.val > val) { root.left = insertIntoBST(root.left, val); } if (root.val < val) { root.right = insertIntoBST(root.right, val); } return root; } }
这个问题稍微复杂,跟插入操作类似,先「找」再「改」,先把框架写出来再说:
1 2 3 4 5 6 7 8 9 10 11 12 TreeNode deleteNode(TreeNode root, int key) { if (root.val == key) { // 找到啦,进行删除 } else if (root.val > key) { // 去左子树找 root.left = deleteNode(root.left, key); } else if (root.val < key) { // 去右子树找 root.right = deleteNode(root.right, key); } return root; }
找到目标节点了,比方说是节点 A
,如何删除这个节点,这是难点。因为删除节点的同时不能破坏 BST 的性质。有三种情况,用图片来说明。
情况 1 :A
恰好是末端节点,两个子节点都为空,那么它可以当场去世了。
图片来自 LeetCode
1 2 if (root.left == null && root.right == null) return null;
情况 2 :A
只有一个非空子节点,那么它要让这个孩子接替自己的位置。
图片来自 LeetCode
1 2 3 // 排除了情况 1 之后 if (root.left == null) return root.right; if (root.right == null) return root.left;
情况 3 :A
有两个子节点,麻烦了,为了不破坏 BST 的性质,A
必须找到左子树中最大的那个节点,或者右子树中最小的那个节点来接替自己。我们以第二种方式讲解。
图片来自 LeetCode
1 2 3 4 5 6 7 8 if (root.left != null && root.right != null) { // 找到右子树的最小节点 TreeNode minNode = getMin(root.right); // 把 root 改成 minNode root.val = minNode.val; // 转而去删除 minNode root.right = deleteNode(root.right, minNode.val); }
三种情况分析完毕,填入框架,简化一下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 TreeNode deleteNode(TreeNode root, int key) { if (root == null) return null; if (root.val == key) { // 这两个 if 把情况 1 和 2 都正确处理了 if (root.left == null) return root.right; if (root.right == null) return root.left; // 处理情况 3 TreeNode minNode = getMin(root.right); root.val = minNode.val; root.right = deleteNode(root.right, minNode.val); } else if (root.val > key) { root.left = deleteNode(root.left, key); } else if (root.val < key) { root.right = deleteNode(root.right, key); } return root; } TreeNode getMin(TreeNode node) { // BST 最左边的就是最小的 while (node.left != null) node = node.left; return node; }
删除操作就完成了。注意一下,这个删除操作并不完美,因为我们一般不会通过 root.val = minNode.val
修改节点内部的值来交换节点,而是通过一系列略微复杂的链表操作交换 root
和 minNode
两个节点。
二叉搜索树第三期 base case,显然当lo > hi
闭区间[lo, hi]
肯定是个空区间,也就对应着空节点 null,虽然是空节点,但是也是一种情况,所以要返回 1 而不能返回 0。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Solution { int [][] memo; public int numTrees (int n) { memo = new int [n + 1 ][n + 1 ]; return count(1 , n); } public int count (int left, int right) { if (left > right) { return 1 ; } if (memo[left][right] != 0 ) { return memo[left][right]; } int res = 0 ; for (int i = left; i <= right; i++) { int l = count(left, i - 1 ); int r = count(i + 1 , right); res += l * r; } memo[left][right] = res; return res; } }
不同的二叉树2 1、穷举root
节点的所有可能。
2、递归构造出左右子树的所有合法 BST。
3、给root
节点穷举所有左右子树的组合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 class Solution { public List<TreeNode> generateTrees (int n) { if (n == 0 ) { return new ArrayList<>(); } return build(1 , n); } private List<TreeNode> build (int l, int r) { List<TreeNode> res = new ArrayList<>(); if (l > r) { res.add(null ); return res; } for (int i = l; i <= r; i++) { List<TreeNode> leftTree = build(l, i - 1 ); List<TreeNode> rightTree = build(i + 1 , r); for (TreeNode left : leftTree) { for (TreeNode right : rightTree) { TreeNode root = new TreeNode(i); root.left = left; root.right = right; res.add(root); } } } return res; } }
二叉树序列化 前序遍历 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public class Codec { String SEP = "," ; String NULL = "NULL" ; public String serialize (TreeNode root) { StringBuilder sb = new StringBuilder(); serialize(root,sb); return sb.toString(); } private void serialize (TreeNode root, StringBuilder sb) { if (root == null ) { sb.append(NULL).append(SEP); return ; } sb.append(root.val).append(SEP); serialize(root.left, sb); serialize(root.right, sb); } public TreeNode deserialize (String data) { List<String> nodes = new ArrayList<>(); for (String s : data.split(SEP)) { nodes.add(s); } return deserialize(nodes); } private TreeNode deserialize (List<String> nodes) { if (nodes.isEmpty()) { return null ; } String first = nodes.remove(0 ); if (first.equals(NULL)) { return null ; } TreeNode root = new TreeNode(Integer.parseInt(first)); root.left = deserialize(nodes); root.right = deserialize(nodes); return root; } }
后序遍历 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public class Codec { String SEP = "," ; String NULL = "NULL" ; public String serialize (TreeNode root) { StringBuilder sb = new StringBuilder(); serialize(root,sb); return sb.toString(); } private void serialize (TreeNode root, StringBuilder sb) { if (root == null ) { sb.append(NULL).append(SEP); return ; } serialize(root.left, sb); serialize(root.right, sb); sb.append(root.val).append(SEP); } public TreeNode deserialize (String data) { List<String> nodes = new ArrayList<>(); for (String s : data.split(SEP)) { nodes.add(s); } return deserialize(nodes); } private TreeNode deserialize (List<String> nodes) { if (nodes.isEmpty()) { return null ; } String first = nodes.remove(nodes.size() - 1 ); if (first.equals(NULL)) { return null ; } TreeNode root = new TreeNode(Integer.parseInt(first)); root.right = deserialize(nodes); root.left = deserialize(nodes); return root; } }
层次遍历 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 public class Codec { String SEP = "," ; String NULL = "NULL" ; public String serialize (TreeNode root) { if (root == null ) { return "" ; } StringBuilder sb = new StringBuilder(); Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { TreeNode cur = queue.poll(); if (cur == null ) { sb.append(NULL).append(SEP); } else { sb.append(cur.val).append(SEP); queue.offer(cur.left); queue.offer(cur.right); } } return sb.toString(); } public TreeNode deserialize (String data) { if (data.isEmpty()){ return null ; } String[] nodes = data.split(SEP); TreeNode root = new TreeNode(Integer.parseInt(nodes[0 ])); Queue<TreeNode> q = new LinkedList<>(); q.offer(root); int i = 1 ; while (i < nodes.length) { TreeNode parent = q.poll(); String left = nodes[i++]; if (!left.equals(NULL)){ parent.left = new TreeNode(Integer.parseInt(left)); q.offer(parent.left); } else { parent.left = null ; } String right = nodes[i++]; if (!right.equals(NULL)) { parent.right = new TreeNode(Integer.parseInt(right)); q.offer(parent.right); } else { parent.right = null ; } } return root; } }
首先,现在有一种数据结构NestedInteger
,这个结构中存的数据可能是一个Integer
整数,也可能是一个NestedInteger
列表 。注意,这个列表里面装着的是NestedInteger
,也就是说这个列表中的每一个元素可能是个整数,可能又是个列表,这样无限递归嵌套下去……
NestedInteger
有如下 API:
1 2 3 4 5 6 7 8 9 10 public class NestedInteger { // 如果其中存的是一个整数,则返回 true,否则返回 false public boolean isInteger(); // 如果其中存的是一个整数,则返回这个整数,否则返回 null public Integer getInteger(); // 如果其中存的是一个列表,则返回这个列表,否则返回 null public List<NestedInteger> getList(); }
我们的算法会被输入一个NestedInteger
列表,我们需要做的就是写一个迭代器类,将这个带有嵌套结构NestedInteger
的列表「拍平」:
1 2 3 4 5 6 7 8 9 10 public class NestedIterator implements Iterator<Integer> { // 构造器输入一个 NestedInteger 列表 public NestedIterator(List<NestedInteger> nestedList) {} // 返回下一个整数 public Integer next() {} // 是否还有下一个整数? public boolean hasNext() {} }
我们写的这个类会被这样调用,先调用hasNext
方法,后调用next
方法 :
1 2 3 NestedIterator i = new NestedIterator(nestedList); while (i.hasNext()) print(i.next());
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class NestedInteger { private Integer val; private List<NestedInteger> list; public NestedInteger (Integer val) { this .val = val; this .list = null ; } public NestedInteger (List<NestedInteger> list) { this .list = list; this .val = null ; } public boolean isInteger () { return val != null ; } public Integer getInteger () { return this .val; } public List<NestedInteger> getList () { return this .list; } }
第一种解法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public class NestedIterator implements Iterator <Integer > { private Iterator<Integer> it; public NestedIterator (List<NestedInteger> nestedList) { List<Integer> result = new LinkedList<>(); for (NestedInteger node : nestedList) { traverse(node, result); } this .it = result.iterator(); } @Override public Integer next () { return it.next(); } @Override public boolean hasNext () { return it.hasNext(); } private void traverse (NestedInteger root, List<Integer> result) { if (root.isInteger()) { result.add(root.getInteger()); return ; } for (NestedInteger child: root.getList()) { traverse(child, result); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public class NestedIterator implements Iterator <Integer > { private LinkedList<NestedInteger> list; public NestedIterator (List<NestedInteger> nestedList) { list = new LinkedList<>(nestedList); } @Override public Integer next () { return list.remove(0 ).getInteger(); } @Override public boolean hasNext () { while (!list.isEmpty() && !list.get(0 ).isInteger()){ List<NestedInteger> first = list.remove(0 ).getList(); for (int i = first.size() - 1 ; i >= 0 ; i--) { list.addFirst(first.get(i)); } } return !list.isEmpty(); } }
最近公共祖先 情况 1,如果p
和q
都在以root
为根的树中,函数返回的即使p
和q
的最近公共祖先节点。
情况 2,那如果p
和q
都不在以root
为根的树中怎么办呢?函数理所当然地返回null
呗。
情况 3,那如果p
和q
只有一个存在于root
为根的树中呢?函数就会返回那个节点。
base case:如果root
为空,肯定得返回null
。如果root
本身就是p
或者q
,比如说root
就是p
节点吧,如果q
存在于以root
为根的树中,显然root
就是最近公共祖先;即使q
不存在于以root
为根的树中,按照情况 3 的定义,也应该返回root
节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class Solution { public TreeNode lowestCommonAncestor (TreeNode root, TreeNode p, TreeNode q) { if (root == null || root == p || root == q) { return root; } TreeNode left = lowestCommonAncestor(root.left, p, q); TreeNode right = lowestCommonAncestor(root.right, p, q); if (left != null && right != null ) { return root; } if (left == null && right == null ) { return null ; } return left == null ? right : left; } }
计算二叉树的点数 最普通的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Solution { public int countNodes (TreeNode root) { if (root == null ) { return 0 ; } return 1 + countNodes(root.left) + countNodes(root.right); } }
一棵满 二叉树,节点总数就和树的高度呈指数关系:
1 2 3 4 5 6 7 8 9 10 public int countNodes (TreeNode root) { int h = 0 ; while (root != null ) { root = root.left; h++; } return (int )Math.pow(2 , h) - 1 ; }
完全 二叉树比普通二叉树特殊,但又没有满二叉树那么特殊,计算它的节点总数,可以说是普通二叉树和完全二叉树的结合版
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public int countNodes (TreeNode root) { TreeNode l = root, r = root; int hl = 0 , hr = 0 ; while (l != null ) { l = l.left; hl++; } while (r != null ) { r = r.right; hr++; } if (hl == hr) { return (int )Math.pow(2 , hl) - 1 ; } return 1 + countNodes(root.left) + countNodes(root.right); }
设计数据结构 Union-Find 算法 问题介绍 简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
Union-Find 算法主要需要实现这两个 API:
1 2 3 4 5 6 7 8 class UF { public void union (int p, int q) ; public boolean connected (int p, int q) ; public int count () ; }
这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:
1、自反性:节点p
和p
是连通的。
2、对称性:如果节点p
和q
连通,那么q
和p
也连通。
3、传递性:如果节点p
和q
连通,q
和r
连通,那么p
和r
也连通。
比如说之前那幅图,0~9 任意两个不同 的点都不连通,调用connected
都会返回 false,连通分量为 10 个。
如果现在调用union(0, 1)
,那么 0 和 1 被连通,连通分量降为 9 个。
再调用union(1, 2)
,这时 0,1,2 都被连通,调用connected(0, 2)
也会返回 true,连通分量变为 8 个。
基本思路 注意我刚才把「模型」和具体的「数据结构」分开说,这么做是有原因的。因为我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class UF { private int count; private int [] parent; public UF (int n) { this .count = n; parent = new int [n]; for (int i = 0 ; i < n; i++) parent[i] = i; } }
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public void union (int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return ; parent[rootP] = rootQ; count--; } private int find (int x) { while (parent[x] != x) x = parent[x]; return x; } public int count () { return count; }
这样,如果节点** p
和 q
**连通的话,它们一定拥有相同的根节点 :
1 2 3 4 5 public boolean connected(int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; }
平衡性优化 1 2 3 4 5 6 7 8 9 public void union (int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return ; parent[rootP] = rootQ; count--;
我们一开始就是简单粗暴的把p
所在的树接到q
所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:
长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些 。解决方法是额外使用一个size
数组,记录每棵树包含的节点数,我们不妨称为「重量」:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class UF { private int count; private int [] parent; private int [] size; public UF (int n) { this .count = n; parent = new int [n]; size = new int [n]; for (int i = 0 ; i < n; i++) { parent[i] = i; size[i] = 1 ; } } }
比如说size[3] = 5
表示,以节点3
为根的那棵树,总共有5
个节点。这样我们可以修改一下union
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public void union (int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return ; if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; }
这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在logN
这个数量级,极大提升执行效率。
此时,find
,union
,connected
的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。
路径压缩 这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?
这样find
就能以 O(1) 的时间找到某一节点的根节点,相应的,connected
和union
复杂度都下降为 O(1)。
要做到这一点,非常简单,只需要在find
中加一行代码:
1 2 3 4 5 6 7 8 private int find (int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
这个操作有点匪夷所思,看个 GIF 就明白它的作用了(为清晰起见,这棵树比较极端):
总结 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 class UF { private int count; private int [] parent; private int [] size; public UF (int n) { this .count = n; parent = new int [n]; size = new int [n]; for (int i = 0 ; i < n; i++) { parent[i] = i; size[i] = 1 ; } } public void union (int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return ; if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; } public boolean connected (int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; } private int find (int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; } public int count () { return count; } }
Union-Find算法应用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 class UF { private int count; private int [] parent; private int [] size; public UF (int n) { this .count = n; parent = new int [n]; size = new int [n]; for (int i = 0 ; i < n; i++) { parent[i] = i; size[i] = 1 ; } } public void union (int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return ; if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; } public boolean connected (int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; } private int find (int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; } public int count () { return count; } }
被围绕的区域 必须是四面被围的 O
才能被换成 X
,也就是说边角上的 O
一定不会被围,进一步,与边角上的 O
相连的 O
也不会被 X
围四面,也不会被替换。
把不需要变换的O连通
Union-Find 底层用的是一维数组,构造函数需要传入这个数组的大小,而题目给的是一个二维棋盘二维坐标 (x,y)
可以转换成 x * n + y
这个数(m
是棋盘的行数,n
是棋盘的列数)。这是将二维坐标映射到一维的常用技巧 。
虚拟的 dummy
节点占据索引 m * n
好了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 void solve (char [][] board) { if (board.length == 0 ) return ; int m = board.length; int n = board[0 ].length; UF uf = new UF(m * n + 1 ); int dummy = m * n; for (int i = 0 ; i < m; i++) { if (board[i][0 ] == 'O' ) uf.union(i * n, dummy); if (board[i][n - 1 ] == 'O' ) uf.union(i * n + n - 1 , dummy); } for (int j = 0 ; j < n; j++) { if (board[0 ][j] == 'O' ) uf.union(j, dummy); if (board[m - 1 ][j] == 'O' ) uf.union(n * (m - 1 ) + j, dummy); } int [][] d = new int [][]{{1 ,0 }, {0 ,1 }, {0 ,-1 }, {-1 ,0 }}; for (int i = 1 ; i < m - 1 ; i++) for (int j = 1 ; j < n - 1 ; j++) if (board[i][j] == 'O' ) for (int k = 0 ; k < 4 ; k++) { int x = i + d[k][0 ]; int y = j + d[k][1 ]; if (board[x][y] == 'O' ) uf.union(x * n + y, i * n + j); } for (int i = 1 ; i < m - 1 ; i++) for (int j = 1 ; j < n - 1 ; j++) if (!uf.connected(dummy, i * n + j)) board[i][j] = 'X' ; }
将 equations
中的算式根据 ==
和 !=
分成两部分,先处理 ==
算式,使得他们通过相等关系各自勾结成门派;然后处理 !=
算式,检查不等关系是否破坏了相等关系的连通性 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 class Solution { public boolean equationsPossible (String[] equations) { UnionFinde uf = new UnionFinde(26 ); for (String equation : equations) { if (equation.charAt(1 ) == '=' ) { char x = equation.charAt(0 ); char y = equation.charAt(3 ); uf.union(x - 'a' , y - 'a' ); } } for (String equation : equations) { if (equation.charAt(1 ) == '!' ) { char x = equation.charAt(0 ); char y = equation.charAt(3 ); if (uf.isConnected(x - 'a' , y - 'a' )) { return false ; } } } return true ; } } class UnionFinde { int count; int [] parent; int [] size; public UnionFinde (int n) { count = n; parent = new int [n]; size = new int [n]; for (int i = 0 ; i < n; i++) { parent[i] = i; size[i] = 1 ; } } public int find (int x) { while (x != parent[x]) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; } public void union (int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) { return ; } if (size[rootX] > size[rootY]) { parent[rootY] = rootX; size[rootX] += size[rootY]; } else { parent[rootX] = rootY; size[rootY] += size[rootX]; } } public boolean isConnected (int x, int y) { return find(x) == find(y); } public int count () { return count; } }
LRU 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 class LRUCache { private HashMap<Integer, Node> map; private DoubleList cache; private int cap; public LRUCache (int capacity) { this .cap = capacity; map = new HashMap<>(); cache = new DoubleList(); } private void makeRecently (int key) { Node x = map.get(key); cache.remove(x); cache.addLast(x); } private void addRecently (int key, int val) { Node x = new Node(key, val); cache.addLast(x); map.put(key, x); } private void deleteKey (int key) { Node x = map.get(key); cache.remove(x); map.remove(x); } private void removeLeastRecently () { Node deletedNode = cache.removeFirst(); int deleteKey = deletedNode.key; map.remove(deleteKey); } public int get (int key) { if (!map.containsKey(key)) { return -1 ; } makeRecently(key); return map.get(key).val; } public void put (int key, int value) { if (map.containsKey(key)) { deleteKey(key); addRecently(key, value); } else { if (cap == cache.size()) { removeLeastRecently(); } addRecently(key, value); } } } class Node { public int key, val; public Node next, pre; public Node (int k, int v) { this .key = k; this .val = v; } } class DoubleList { private Node head, tail; private int size; public DoubleList () { head = new Node(0 , 0 ); tail = new Node(0 , 0 ); head.next = tail; tail.pre = head; size = 0 ; } public void addLast (Node x) { x.pre = tail.pre; x.next = tail; tail.pre.next = x; tail.pre = x; size++; } public void remove (Node x) { x.pre.next = x.next; x.next.pre = x.pre; size--; } public Node removeFirst () { if (head.next == tail) { return null ; } Node first = head.next; remove(first); return first; } public int size () { return this .size; } }
LFU LRU 算法的淘汰策略是 Least Recently Used,也就是每次淘汰那些最久没被使用的数据;而 LFU 算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次数最少的数据。
3、这个需求应该是 LFU 算法的核心,所以我们分开说。
3.1、首先,肯定是需要 freq
到 key
的映射,用来找到 freq
最小的 key
。
3.2、将 freq
最小的 key
删除,那你就得快速得到当前所有 key
最小的 freq
是多少。想要时间复杂度 O(1) 的话,肯定不能遍历一遍去找,那就用一个变量 minFreq
来记录当前最小的 freq
吧。
3.3、可能有多个 key
拥有相同的 freq
,所以 freq
对 key
是一对多的关系 ,即一个 freq
对应一个 key
的列表。
3.4、希望 freq
对应的 key
的列表是存在时序 的,便于快速查找并删除最旧的 key
。
3.5、希望能够快速删除 key
列表中的任何一个 key
,因为如果频次为 freq
的某个 key
被访问,那么它的频次就会变成 freq+1
,就应该从 freq
对应的 key
列表中删除,加到 freq+1
对应的 key
的列表中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 class LFUCache { HashMap<Integer, Integer> keyToVal; HashMap<Integer, Integer> keyToFreq; HashMap<Integer, LinkedHashSet<Integer>> freqToKeys; int minFreq; int cap; public LFUCache (int capacity) { keyToVal = new HashMap<>(); keyToFreq = new HashMap<>(); freqToKeys = new HashMap<>(); this .cap = capacity; this .minFreq = 0 ; } public int get (int key) { if (!keyToVal.containsKey(key)) { return -1 ; } increaseFreq(key); return keyToVal.get(key); } public void put (int key, int value) { if (this .cap <= 0 ) { return ; } if (keyToVal.containsKey(key)) { keyToVal.put(key, value); increaseFreq(key); return ; } if (this .cap <= keyToVal.size()) { removeMinFreqKey(); } keyToVal.put(key, value); keyToFreq.put(key, 1 ); freqToKeys.putIfAbsent(1 , new LinkedHashSet<>()); freqToKeys.get(1 ).add(key); this .minFreq = 1 ; } private void removeMinFreqKey () { LinkedHashSet<Integer> keyList = freqToKeys.get(this .minFreq); int deletedKey = keyList.iterator().next(); keyList.remove(deletedKey); if (keyList.isEmpty()) { freqToKeys.remove(this .minFreq); } keyToVal.remove(deletedKey); keyToFreq.remove(deletedKey); } private void increaseFreq (int key) { int freq = keyToFreq.get(key); keyToFreq.put(key, freq + 1 ); freqToKeys.get(freq).remove(key); freqToKeys.putIfAbsent(freq + 1 , new LinkedHashSet<>()); freqToKeys.get(freq + 1 ).add(key); if (freqToKeys.get(freq).isEmpty()) { freqToKeys.remove(freq); if (this .minFreq == freq) { this .minFreq++; } } } }
单调栈 单调栈实际上就是栈,只是利用了一些巧妙的逻辑,使得每次新元素入栈后,栈内的元素都保持有序(单调递增或单调递减)。
听起来有点像堆(heap)?不是的,单调栈用途不太广泛,只处理一种典型的问题,叫做 Next Greater Element。
用hashmap记录下一个比当前元素大的,再在num1中去找。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution { public int [] nextGreaterElement(int [] nums1, int [] nums2) { int [] res = new int [nums1.length]; Stack<Integer> stack = new Stack<>(); HashMap<Integer, Integer> hashMap = new HashMap<>(); for (int num : nums2) { while (!stack.isEmpty() && stack.peek() < num) { hashMap.put(stack.pop(), num); } stack.push(num); } for (int i = 0 ; i < nums1.length; i++) { res[i] = hashMap.getOrDefault(nums1[i], -1 ); } return res; } }
数组扩大两倍也可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Solution { public int [] nextGreaterElements(int [] nums) { int n = nums.length; int [] res = new int [n]; Stack<Integer> stack = new Stack<>(); for (int i = 2 * n - 1 ; i >= 0 ; i--) { while (!stack.isEmpty() && stack.peek() <= nums[i % n]) { stack.pop(); } res[i % n] = stack.isEmpty() ? -1 : stack.peek(); stack.push(nums[i % n]); } return res; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution { public int [] dailyTemperatures(int [] T) { int [] res = new int [T.length]; Stack<Integer> stack = new Stack<>(); for (int i = T.length - 1 ; i >= 0 ; i--) { while (!stack.isEmpty() && T[stack.peek()] <= T[i]) { stack.pop(); } res[i] = stack.isEmpty() ? 0 : (stack.peek() - i); stack.push(i); } return res; } }
单调队列结构解决滑动窗户口 队列中的元素全都是单调递增(或递减)的 。
「单调栈」主要解决 Next Great Number 一类算法问题,而「单调队列」这个数据结构可以解决滑动窗口相关的问题
在一堆数字中,已知最值为 A
,如果给这堆数添加一个数 B
,那么比较一下 A
和 B
就可以立即算出新的最值;但如果减少一个数,就不能直接得到最值了,因为如果减少的这个数恰好是 A
,就需要遍历所有数重新找新的最值 。
一个「单调队列」的操作:
1 2 3 4 5 6 7 8 class MonotonicQueue { void push (int n) ; int max () ; void pop (int n) ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 class Solution { public int [] maxSlidingWindow(int [] nums, int k) { MonotonicQueue window = new MonotonicQueue(); List<Integer> res = new ArrayList<>(); for (int i = 0 ; i < nums.length; i++) { if (i < k - 1 ) { window.push(nums[i]); } else { window.push(nums[i]); res.add(window.max()); window.pop(nums[i - k + 1 ]); } } int [] arr = new int [res.size()]; for (int i = 0 ; i < res.size(); i++) { arr[i] = res.get(i); } return arr; } private class MonotonicQueue { private LinkedList<Integer> q = new LinkedList<>(); public void push (int n) { while (!q.isEmpty() && q.getLast() < n) { q.pollLast(); } q.addLast(n); } public int max () { return q.getFirst(); } public void pop (int n) { if (n == q.getFirst()) { q.pollFirst(); } } } }
二叉堆实现优先级队列 二叉堆(Binary Heap)没什么神秘,性质比二叉搜索树 BST 还简单。其主要操作就两个,sink
(下沉)和 swim
(上浮),用以维护二叉堆的性质。其主要应用有两个,首先是一种排序方法「堆排序」,第二是一种很有用的数据结构「优先级队列」。
预览 二叉堆其实就是一种特殊的二叉树(完全二叉树),只不过存储在数组里。一般的链表二叉树,我们操作节点的指针,而在数组里,我们把数组索引作为指针:
1 2 3 4 5 6 7 8 9 10 11 12 int parent (int root) { return root / 2 ; } int left (int root) { return root * 2 ; } int right (int root) { return root * 2 + 1 ; }
二叉堆还分为最大堆和最小堆。最大堆的性质是:每个节点都大于等于它的两个子节点。 类似的,最小堆的性质是:每个节点都小于等于它的子节点。
优先队列 优先级队列这种数据结构有一个很有用的功能,你插入或者删除元素的时候,元素会自动排序,这底层的原理就是二叉堆的操作。
数据结构的功能无非增删查该,优先级队列有两个主要 API,分别是 insert
插入一个元素和 delMax
删除最大元素(如果底层用最小堆,那么就是 delMin
)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public class MaxPQ <Key extends Comparable <Key >> { private Key[] pq; private int N = 0 ; public MaxPQ (int cap) { pq = (Key[]) new Comparable[cap + 1 ]; } public Key max () { return pq[1 ]; } public void insert (Key e) {...} public Key delMax () {...} private void swim (int k) {...} private void sink (int k) {...} private void exch (int i, int j) { Key temp = pq[i]; pq[i] = pq[j]; pq[j] = temp; } private boolean less (int i, int j) { return pq[i].compareTo(pq[j]) < 0 ; } }
空出来的四个方法是二叉堆和优先级队列的奥妙所在,下面用图文来逐个理解。
swim和sink 对于最大堆,会破坏堆性质的有有两种情况:
如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉 。
如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮 。
最终我们的操作只会在堆底和堆顶进行(等会讲原因),显然堆底的「错位」元素需要上浮,堆顶的「错位」元素需要下沉。
上浮的代码实现:
1 2 3 4 5 6 7 8 9 private void swim (int k) { while (k > 1 && less(parent(k), k)) { exch(parent(k), k); k = parent(k); } }
下沉的代码实现:
下沉比上浮略微复杂一点,因为上浮某个节点 A,只需要 A 和其父节点比较大小即可;但是下沉某个节点 A,需要 A 和其两个子节点 比较大小,如果 A 不是最大的就需要调整位置,要把较大的那个子节点和 A 交换。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private void sink (int k) { while (left(k) <= N) { int older = left(k); if (right(k) <= N && less(older, right(k))) older = right(k); if (less(older, k)) break ; exch(k, older); k = older; } }
delMax和insert insert
方法先把要插入的元素添加到堆底的最后,然后让其上浮到正确位置。
1 2 3 4 5 6 7 public void insert (Key e) { N++; pq[N] = e; swim(N); }
delMax
方法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A,最后让 B 下沉到正确位置。
1 2 3 4 5 6 7 8 9 10 11 public Key delMax () { Key max = pq[1 ]; exch(1 , N); pq[N] = null ; N--; sink(1 ); return max; }
队列和栈互相实现 队列是一种先进先出的数据结构,栈是一种先进后出的数据结构,形象一点就是这样:
栈实现队列
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 class MyQueue { private Stack<Integer> s1, s2; public MyQueue () { s1 = new Stack<>(); s2 = new Stack<>(); } public void push (int x) { s1.push(x); } public int pop () { peek(); return s2.pop(); } public int peek () { if (s2.isEmpty()) { while (!s1.isEmpty()) { s2.push(s1.pop()); } } return s2.peek(); } public boolean empty () { return s2.isEmpty() && s1.isEmpty(); } }
队列实现栈 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 class MyStack { private Queue<Integer> q; private int top_elem; public MyStack () { q = new LinkedList<>(); top_elem = 0 ; } public void push (int x) { q.offer(x); top_elem = x; } public int pop () { int size = q.size(); while (size > 2 ) { q.offer(q.poll()); size--; } top_elem = q.peek(); q.offer(q.poll()); return q.poll(); } public int top () { return top_elem; } public boolean empty () { return q.isEmpty(); } }
数组 二分查找 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class Solution { public int minEatingSpeed (int [] piles, int H) { int left = 1 , right = getMax(piles); while (left < right) { int mid = left + (right - left) / 2 ; if (canFinish(piles, mid, H)) { right = mid; } else { left = mid + 1 ; } } return left; } private boolean canFinish (int [] piles, int speed, int H) { int time = 0 ; for (int pile : piles) { time += timeOf(pile, speed); } return time <= H; } private int timeOf (int pile, int speed) { return (pile / speed) + ((pile % speed) > 0 ? 1 : 0 ); } private int getMax (int [] piles) { int max = 0 ; for (int pile : piles) { max = Math.max(max, pile); } return max; } }
首先确定 cap
的最小值和最大值分别为 max(weights)
和 sum(weights)
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 class Solution { public int shipWithinDays (int [] weights, int D) { int left = getMax(weights); int right = getSum(weights); while (left < right) { int mid = left + (right - left) / 2 ; if (canFinish(weights, D, mid)) { right = mid; } else { left = mid + 1 ; } } return left; } private int getSum (int [] weights) { int sum = 0 ; for (int weight : weights) { sum += weight; } return sum; } private int getMax (int [] piles) { int max = 0 ; for (int n : piles) max = Math.max(n, max); return max; } private boolean canFinish (int [] weights, int D, int cap) { int i = 0 ; for (int day = 0 ; day < D; day++) { int maxCap = cap; while ((maxCap -= weights[i]) >= 0 ) { i++; if (i == weights.length) { return true ; } } } return false ; } }
双指针 双指针技巧再分为两类,一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。
快慢指针 判断链表中是否有环
如果链表中不含环,那么这个指针最终会遇到空指针 null
表示链表到头了,这还好说,可以判断该链表不含环:
1 2 3 4 5 boolean hasCycle (ListNode head) { while (head != null ) head = head.next; return false ; }
但是如果链表中含有环,那么这个指针就会陷入死循环,因为环形数组中没有 null
指针作为尾部节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 public class Solution { public boolean hasCycle (ListNode head) { ListNode fast = head, slow = head; while (fast != null && fast.next != null ) { fast = fast.next.next; slow = slow.next; if (fast == slow) { return true ; } } return false ; } }
已知链表中含有环,返回这个环的起始位置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public class Solution { public ListNode detectCycle (ListNode head) { ListNode fast, slow; fast = slow = head; while (fast != null && fast.next != null ) { fast = fast.next.next; slow = slow.next; if (fast == slow) { break ; } } if (fast == null || fast.next == null ) { return null ; } slow = head; while (slow != fast) { slow = slow.next; fast = fast.next; } return slow; } }
当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。这是为什么呢?
第一次相遇时,假设慢指针 slow
走了 k
步,那么快指针 fast
一定走了 2k
步:
fast
一定比 slow
多走了 k
步,这多走的 k
步其实就是 fast
指针在环里转圈圈,所以 k
的值就是环长度的「整数倍」 。
设相遇点距环的起点的距离为 m
,那么环的起点距头结点 head
的距离为 k - m
,也就是说如果从 head
前进 k - m
步就能到达环起点。
巧的是,如果从相遇点继续前进 k - m
步,也恰好到达环起点。你甭管 fast
在环里到底转了几圈,反正走 k
步可以到相遇点,那走 k - m
步一定就是走到环起点了:
让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
1 2 3 4 5 6 7 8 9 10 11 class Solution { public ListNode middleNode (ListNode head) { ListNode fast, slow; fast = slow = head; while (fast != null && fast.next != null ) { fast = fast.next.next; slow = slow.next; } return slow; } }
注意一些特殊情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution { public ListNode removeNthFromEnd (ListNode head, int n) { ListNode slow, fast; slow = fast = head; for (int i = 0 ; i < n; i++) { fast = fast.next; } if (fast == null ) { return head.next; } while (fast != null && fast.next != null ) { fast = fast.next; slow = slow.next; } slow.next = slow.next.next; return head; } }
左右指针 二分查找 1 2 3 4 5 6 7 8 9 10 11 12 13 14 int binarySearch (int [] nums, int target) { int left = 0 ; int right = nums.length - 1 ; while (left <= right) { int mid = (right + left) / 2 ; if (nums[mid] == target) return mid; else if (nums[mid] < target) left = mid + 1 ; else if (nums[mid] > target) right = mid - 1 ; } return -1 ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution { public int [] twoSum(int [] numbers, int target) { int left = 0 , right = numbers.length - 1 ; while (left < right) { int sum = numbers[left] + numbers[right]; if (sum == target) { return new int []{left + 1 , right + 1 }; } else if (sum < target) { left++; } else { right--; } } return new int []{-1 , -1 }; } }
1 2 3 4 5 6 7 8 9 10 11 12 class Solution { public void reverseString (char [] s) { int left = 0 , right = s.length - 1 ; while (left < right) { char temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } } }