理解算法的时间复杂度

翻译:疯狂的技术宅原文:https://www.freecodecamp.org/... 未经允许严禁转载 在计算机科学中,算法分析是非常关键的部分。找到解决问题的最有效算法非常重要。可能会有许多算法能够解决问题,但这里的挑战是选择最有效的算法。现在关键是假如我们有一套不同的算法,应该如何识别最有效的算法呢?在这里算法的空间和时间复杂度的概念出现了。空间和时间复杂度是算法的测量尺度。我们根据它们的空间(内存量)和时间复杂度(操作次数)来对算法进行比较。 算法在执行时使用的计算机内存总量是该算法的空间复杂度(为了使本文更简短一些我们不会讨论空间复杂度)。因此,时间复杂度是算法为完成其任务而执行的操作次数(考虑到每个操作花费相同的时间)。在时间复杂度方面,以较少的操作次数执行任务的算法被认为是有效的算法。但是空间和时间复杂性也受操作系统、硬件等因素的影响,不过现在不考虑它们。 我们将通过解决一个特定问题的例子来帮你理解时间复杂度, 这个问题是搜索。我们必须在数组中查找一个元素(在这个问题中,假设数组已经按升序排序)。为了解决这个问题,我们有两种算法: 线性搜索二分搜索假设数组包含十个元素,要求我们在数组中找到数字 10。 const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];const search_digit = 10;线性搜索算法会将数组的每个元素与 search_digit 进行比较,当它在数组中找到 search_digit 时,会返回 true。现在让我们计算它执行的操作次数。这里的答案是10(因为它比较了数组的每个元素)。因此线性搜索使用十个操作来查找给定元素(这是使用线性搜索算法时对此数组的最大操作数,这也被称为最坏情况。通常线性搜索在最坏的情况下会进行 n 次操作(其中 n 是数组的大小)。 让我们来看看同样情况下的二分搜索算法。 通过此图可以轻松理解二分搜索: 如果要在这个问题上应用此逻辑,那么首先我们将 search_digit 与数组的中间元素进行比较,即 5。现在,因为 5 小于 10,那么我们将开始在大于 5 的数组元素中寻找 search_digit ,不断执行相同的操作直到我们找到所需的元素 10 为止。 现在试着计算使用二分搜索找到所需的元素进行的操作次数:大约需要四次操作。这是二分搜索的最坏情况。这表明,执行的操作数和数组的总大小之间存在对数关系。 操作次数 = log(10) = 4(约) 我们可以将此结果推广到二分搜索:对于大小为 n 的数组,二分搜索执行的操作数为:log(n) Big O表示法在上面的陈述中,我们看到对于大小为 n 的数组,线性搜索将执行 n 次操作来完成查找,而二分搜索执行 log(n) 次操作(两者都是最糟糕的情况)。我们可以将其表示为图形(x轴:元素数量,y轴:操作次数)。 ...

June 13, 2019 · 1 min · jiezi

轻松搞定时间复杂度

关注公众号「前端小苑」,阅读 时间复杂度详解全文。 通过学习本文,你可以掌握以下三点内容: 为什么需要时间复杂度时间复杂度怎么表示怎样分析一段代码的时间复杂度相信认真阅读过本文,面对一些常见的算法复杂度分析,一定会游刃有余,轻松搞定。文章中举的例子,也尽量去贴近常见场景,难度递增。 复杂度是用来衡量代码执行效率的指标,直白讲代码的执行效率就是一段代码执行所需要的时间。 那么,有人会问了,代码执行所需要的时间,我执行一下,看看用了多少时间不就可以了?还要时间复杂度干啥? 一、为什么需要时间复杂度实际开发时,我们希望自己的代码是最优的,但总不能把所有的实现的方式都写出来,再跑一遍代码做比较,这样不太实际,所以需要一个指标可以衡量算法的效率。而且,直接跑代码看执行时间会有一些局限性: 1.测试结果受当前运行环境影响同样的代码在不同硬件的机器上进行测试,得到的测试结果明显会不同。有时候同样是a,b两段代码,在x设备上,a执行的速度比b快,但到了y设备上,可能会完全相反的结果。 2.测试结果受测试数据的影响不同的测试数据可能会带来不同的结果,比如我们采用顺序查找的方法查找数组中的一个元素,如果这个元素刚好在数组的第一位,执行一次代码就能找到,而如果要找的元素位于数组最后,就要遍历完整个数组才能得到结果。 于是乎,我们需要一个不受硬件,宿主环境和数据集影响的指标来衡量算法的执行效率,它就是算法的复杂度分析。 二、时间复杂度怎么表示我们知道了为什么需要时间复杂度,那要怎么来表示它呢?下面通过一个简单例子,来说明一下 大O时间复杂度表示法。 首先看第一个例子: function factorial(){ let i = 0 // 执行了1次 let re = 1 // 执行了1次 for(;i < n; i++ ){ // 执行了n次 re*= n // 执行了n次 } return re // 执行了1次}上面代码是求n的阶乘(n!)的方法,即n×...×3×2×1 我们粗略的计算一下这段代码的执行时间。 首先,为了方便计算,假设执行每行代码的执行时间是相同的。 在这里,假设每行代码执行一次的时间设为t,代码的总执行时间为T(n)。 代码的第2行,第3行执行了一次,需要的时间是t + t = 2t; 代码的第4行,第5行都执行了n次,所以用时(n + n)t = 2n*t; 代码的第7行,只执行了一次,需要时间 t。 所以执行这段代码的总时间是: T(n) = 2t + 2nt + t = (2n + 3)t我们以n为x轴,T(n)为y轴,绘制出坐标图如下: ...

April 29, 2019 · 2 min · jiezi

Leetcode116-填充同一层的兄弟节点2

题目给定一个完美二叉树,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下: struct Node { int val; Node *left; Node *right; Node *next;}填充它的每个 next 指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next 指针设置为 NULL。 初始状态下,所有 next 指针都被设置为 NULL。 示例: 输入:{"$id":"1","left":{"$id":"2","left":{"$id":"3","left":null,"next":null,"right":null,"val":4},"next":null,"right":{"$id":"4","left":null,"next":null,"right":null,"val":5},"val":2},"next":null,"right":{"$id":"5","left":{"$id":"6","left":null,"next":null,"right":null,"val":6},"next":null,"right":{"$id":"7","left":null,"next":null,"right":null,"val":7},"val":3},"val":1}输出:{"$id":"1","left":{"$id":"2","left":{"$id":"3","left":null,"next":{"$id":"4","left":null,"next":{"$id":"5","left":null,"next":{"$id":"6","left":null,"next":null,"right":null,"val":7},"right":null,"val":6},"right":null,"val":5},"right":null,"val":4},"next":{"$id":"7","left":{"$ref":"5"},"next":null,"right":{"$ref":"6"},"val":3},"right":{"$ref":"4"},"val":2},"next":null,"right":{"$ref":"7"},"val":1}解释:给定二叉树如图 A 所示,你的函数应该填充它的每个 next 指针,以指向其下一个右侧节点,如图 B 所示。题解方法一: 层序遍历使用层序遍历,遍历的时候把同层的节点连接起来; class Solution { public Node connect(Node root) { if (root == null) return null; Queue<Node> queue = new LinkedList<>(); queue.add(root); while (!queue.isEmpty()) { int size = queue.size(); Node current = null; while (size > 0) { Node node = queue.poll(); if (node.right != null) queue.add(node.right); if (node.left != null) queue.add(node.left); node.next = current; current = node; size--; } } return root; }}方法二:递归递归的时候我们通常就分解为递归子问题和递归结束条件。 ...

April 24, 2019 · 2 min · jiezi

【Leetcode】116. 填充同一层的兄弟节点

题目给定一个完美二叉树,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下: struct Node { int val; Node *left; Node *right; Node *next;}填充它的每个 next 指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next 指针设置为 NULL。 初始状态下,所有 next 指针都被设置为 NULL。 示例: 输入:{"$id":"1","left":{"$id":"2","left":{"$id":"3","left":null,"next":null,"right":null,"val":4},"next":null,"right":{"$id":"4","left":null,"next":null,"right":null,"val":5},"val":2},"next":null,"right":{"$id":"5","left":{"$id":"6","left":null,"next":null,"right":null,"val":6},"next":null,"right":{"$id":"7","left":null,"next":null,"right":null,"val":7},"val":3},"val":1}输出:{"$id":"1","left":{"$id":"2","left":{"$id":"3","left":null,"next":{"$id":"4","left":null,"next":{"$id":"5","left":null,"next":{"$id":"6","left":null,"next":null,"right":null,"val":7},"right":null,"val":6},"right":null,"val":5},"right":null,"val":4},"next":{"$id":"7","left":{"$ref":"5"},"next":null,"right":{"$ref":"6"},"val":3},"right":{"$ref":"4"},"val":2},"next":null,"right":{"$ref":"7"},"val":1}解释:给定二叉树如图 A 所示,你的函数应该填充它的每个 next 指针,以指向其下一个右侧节点,如图 B 所示。题解方法一: 层序遍历使用层序遍历,遍历的时候把同层的节点连接起来; class Solution { public Node connect(Node root) { if (root == null) return null; Queue<Node> queue = new LinkedList<>(); queue.add(root); while (!queue.isEmpty()) { int size = queue.size(); Node current = null; while (size > 0) { Node node = queue.poll(); if (node.right != null) queue.add(node.right); if (node.left != null) queue.add(node.left); node.next = current; current = node; size--; } } return root; }}方法二:递归递归的时候我们通常就分解为递归子问题和递归结束条件。 ...

April 21, 2019 · 2 min · jiezi

【Leetcode】124. 二叉树中的最大路径和

题目给定一个非空二叉树,返回其最大路径和。本题中,路径被定义为一条从树中任意节点出发,达到任意节点的序列。该路径至少包含一个节点,且不一定经过根节点。示例 1:输入: [1,2,3] 1 / \ 2 3输出: 6示例 2:输入: [-10,9,20,null,null,15,7] -10 / \ 9 20 / \ 15 7输出: 42题解这道题虽然是标记成hard难度的题目,但是我觉得主要是因为需要处理的细节可能需要仔细考虑,倒不是因为题目本身比较复杂。我们很容易想到用递归去解这道题。递归子问题:左子树的路径和 + 右子树的路径和 + 当前结点的数字class Solution { private int maxSum; public int maxPathSum(TreeNode root) { maxSum = Integer.MIN_VALUE; helper(root); return maxSum; } private int helper(TreeNode root) { if (root == null) return 0; int leftSum = Math.max(helper(root.left), 0); // 和0比较,要么要这个分支,要么不要这个分支 int rightSum = Math.max(helper(root.right), 0); // 当前节点路径下最大值和全局最大值做比较 maxSum = Math.max(leftSum + rightSum + root.val, maxSum); // 返回的是左右子树中最大的 + 当前结点的值作为这棵树的路径。因为必须要走根节点。 return Math.max(leftSum, rightSum) + root.val; }}考虑以下极端情况: -2 / \ -1 -3手撕代码QQ群:805423079, 群密码:1024 ...

April 9, 2019 · 1 min · jiezi

社招面试总结——算法题篇

面试结果总结下最近的面试:头条后端:3面技术面挂蚂蚁支付宝营销-机器学习平台开发: 技术面通过,年后被通知只有P7的hc蚂蚁中台-机器学习平台开发: 技术面通过, 被蚂蚁HR挂掉(脉脉上好多人遇到这种情况,一个是今年大环境不好,另一个,面试尽量不要赶上阿里财年年底,这算是一点tips吧)快手后端: 拿到offer百度后端: 拿到offer最终拒了百度,去快手了, 一心想去阿里, 个人有点阿里情节吧,缘分差点。总结下最近的面试情况, 由于面了20多面, 就按照题型分类给大家一个总结。推荐大家每年都要抽出时间去面一下,不一定跳槽,但是需要知道自己的不足,一定要你的工龄匹配上你的能力。比如就我个人来说,通过面试我知道数据库的知识不是很懂,再加上由于所在组对数据库接触较少,这就是短板,作为一个后端工程师对数据库说不太了解是很可耻的,在选择offer的时候就可以适当有偏向性。下面分专题把最近的面试总结和大家总结一下。过分简单的就不说了,比如打印一个图形啥的, 还有的我不太记得清了,比如快手一面好像是一道动态规划的题目。当然,可能有的解决方法不是很好,大家可以在手撕代码群里讨论。最后一篇我再谈一下一些面试的技巧啥的。麻烦大家点赞转发支持下~股票买卖(头条)Leetcode 上有三题股票买卖,面试的时候只考了两题,分别是easy 和medium的难度Leetcode 121. 买卖股票的最佳时机给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。注意你不能在买入股票前卖出股票。示例 1:输入: [7,1,5,3,6,4]输出: 5解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。 注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。示例 2:输入: [7,6,4,3,1]输出: 0解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。题解纪录两个状态, 一个是最大利润, 另一个是遍历过的子序列的最小值。知道之前的最小值我们就可以算出当前天可能的最大利润是多少class Solution { public int maxProfit(int[] prices) { // 7,1,5,3,6,4 int maxProfit = 0; int minNum = Integer.MAX_VALUE; for (int i = 0; i < prices.length; i++) { if (Integer.MAX_VALUE != minNum && prices[i] - minNum > maxProfit) { maxProfit = prices[i] - minNum; } if (prices[i] < minNum) { minNum = prices[i]; } } return maxProfit; }}Leetcode 122. 买卖股票的最佳时机 II这次改成股票可以买卖多次, 但是你必须要在出售股票之前把持有的股票卖掉。示例 1:输入: [7,1,5,3,6,4]输出: 7解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。 随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。示例 2:输入: [1,2,3,4,5]输出: 4解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。 注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。 因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。示例 3:输入: [7,6,4,3,1]输出: 0解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。题解由于可以无限次买入和卖出。我们都知道炒股想挣钱当然是低价买入高价抛出,那么这里我们只需要从第二天开始,如果当前价格比之前价格高,则把差值加入利润中,因为我们可以昨天买入,今日卖出,若明日价更高的话,还可以今日买入,明日再抛出。以此类推,遍历完整个数组后即可求得最大利润。class Solution { public int maxProfit(int[] prices) { // 7,1,5,3,6,4 int maxProfit = 0; for (int i = 0; i < prices.length; i++) { if (i != 0 && prices[i] - prices[i-1] > 0) { maxProfit += prices[i] - prices[i-1]; } } return maxProfit; }}LRU cache (头条、蚂蚁)这道题目是头条的高频题目,甚至我怀疑,头条这个面试题是题库里面的必考题。看脉脉也是好多人遇到。第一次我写的时候没写好,可能由于这个挂了。转自知乎:https://zhuanlan.zhihu.com/p/…题目运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?LRUCache cache = new LRUCache( 2 /* 缓存容量 / );cache.put(1, 1);cache.put(2, 2);cache.get(1); // 返回 1cache.put(3, 3); // 该操作会使得密钥 2 作废cache.get(2); // 返回 -1 (未找到)cache.put(4, 4); // 该操作会使得密钥 1 作废cache.get(1); // 返回 -1 (未找到)cache.get(3); // 返回 3cache.get(4); // 返回 4题解这道题在今日头条、快手或者硅谷的公司中是比较常见的,代码要写的还蛮多的,难度也是hard级别。最重要的是LRU 这个策略怎么去实现,很容易想到用一个链表去实现最近使用的放在链表的最前面。比如get一个元素,相当于被使用过了,这个时候它需要放到最前面,再返回值,set同理。那如何把一个链表的中间元素,快速的放到链表的开头呢?很自然的我们想到了双端链表。基于 HashMap 和 双向链表实现 LRU 的整体的设计思路是,可以使用 HashMap 存储 key,这样可以做到 save 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。LRU 存储是基于双向链表实现的,下面的图演示了它的原理。其中 head 代表双向链表的表头,tail 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。下面展示了,预设大小是 3 的,LRU存储的在存储和访问过程中的变化。为了简化图复杂度,图中没有展示 HashMap部分的变化,仅仅演示了上图 LRU 双向链表的变化。我们对这个LRU缓存的操作序列如下:save(“key1”, 7)save(“key2”, 0)save(“key3”, 1)save(“key4”, 2)get(“key2”)save(“key5”, 3)get(“key2”)save(“key6”, 4)相应的 LRU 双向链表部分变化如下:总结一下核心操作的步骤:save(key, value),首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。get(key),通过 HashMap 找到 LRU 链表节点,因为根据LRU 原理,这个节点是最新访问的,所以要把节点插入到队头,然后返回缓存的值。 private static class DLinkedNode { int key; int value; DLinkedNode pre; DLinkedNode post; } /* * 总是在头节点中插入新节点. / private void addNode(DLinkedNode node) { node.pre = head; node.post = head.post; head.post.pre = node; head.post = node; } /* * 摘除一个节点. / private void removeNode(DLinkedNode node) { DLinkedNode pre = node.pre; DLinkedNode post = node.post; pre.post = post; post.pre = pre; } /* * 摘除一个节点,并且将它移动到开头 / private void moveToHead(DLinkedNode node) { this.removeNode(node); this.addNode(node); } /* * 弹出最尾巴节点 / private DLinkedNode popTail() { DLinkedNode res = tail.pre; this.removeNode(res); return res; } private HashMap<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>(); private int count; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.count = 0; this.capacity = capacity; head = new DLinkedNode(); head.pre = null; tail = new DLinkedNode(); tail.post = null; head.post = tail; tail.pre = head; } public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; // cache里面没有 } // cache 命中,挪到开头 this.moveToHead(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node == null) { DLinkedNode newNode = new DLinkedNode(); newNode.key = key; newNode.value = value; this.cache.put(key, newNode); this.addNode(newNode); ++count; if (count > capacity) { // 最后一个节点弹出 DLinkedNode tail = this.popTail(); this.cache.remove(tail.key); count–; } } else { // cache命中,更新cache. node.value = value; this.moveToHead(node); } } 二叉树层次遍历(头条)这个题目之前也讲过,Leetcode 102题。题目给定一个二叉树,返回其按层次遍历的节点值。 (即逐层地,从左到右访问所有节点)。例如:给定二叉树: [3,9,20,null,null,15,7], 3 / \ 9 20 / \ 15 7返回其层次遍历结果:[ [3], [9,20], [15,7]]题解我们数据结构的书上教的层序遍历,就是利用一个队列,不断的把左子树和右子树入队。但是这个题目还要要求按照层输出。所以关键的问题是: 如何确定是在同一层的。我们很自然的想到:如果在入队之前,把上一层所有的节点出队,那么出队的这些节点就是上一层的列表。由于队列是先进先出的数据结构,所以这个列表是从左到右的。/* * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } /class Solution { public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> res = new LinkedList<>(); if (root == null) { return res; } LinkedList<TreeNode> queue = new LinkedList<>(); queue.add(root); while (!queue.isEmpty()) { int size = queue.size(); List<Integer> currentRes = new LinkedList<>(); // 当前队列的大小就是上一层的节点个数, 依次出队 while (size > 0) { TreeNode current = queue.poll(); if (current == null) { continue; } currentRes.add(current.val); // 左子树和右子树入队. if (current.left != null) { queue.add(current.left); } if (current.right != null) { queue.add(current.right); } size–; } res.add(currentRes); } return res; }}这道题可不可以用非递归来解呢?递归的子问题:遍历当前节点, 对于当前层, 遍历左子树的下一层层,遍历右子树的下一层递归结束条件: 当前层,当前子树节点是null/* * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode(int x) { val = x; } * } /class Solution { public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> res = new LinkedList<>(); if (root == null) { return res; } levelOrderHelper(res, root, 0); return res; } /* * @param depth 二叉树的深度 */ private void levelOrderHelper(List<List<Integer>> res, TreeNode root, int depth) { if (root == null) { return; } if (res.size() <= depth) { // 当前层的第一个节点,需要new 一个list来存当前层. res.add(new LinkedList<>()); } // depth 层,把当前节点加入 res.get(depth).add(root.val); // 递归的遍历下一层. levelOrderHelper(res, root.left, depth + 1); levelOrderHelper(res, root.right, depth + 1); }}二叉树转链表(快手)这是Leetcode 104题。给定一个二叉树,原地将它展开为链表。例如,给定二叉树 1 / \ 2 5 / \ \3 4 6将其展开为:1 \ 2 \ 3 \ 4 \ 5 \ 6这道题目的关键是转换的时候递归的时候记住是先转换右子树,再转换左子树。所以需要记录一下右子树转换完之后链表的头结点在哪里。注意没有新定义一个next指针,而是直接将right 当做next指针,那么Left指针我们赋值成null就可以了。class Solution { private TreeNode prev = null; public void flatten(TreeNode root) { if (root == null) return; flatten(root.right); // 先转换右子树 flatten(root.left); root.right = prev; // 右子树指向链表的头 root.left = null; // 把左子树置空 prev = root; // 当前结点为链表头 }}用递归解法,用一个stack 记录节点,右子树先入栈,左子树后入栈。class Solution { public void flatten(TreeNode root) { if (root == null) return; Stack<TreeNode> stack = new Stack<TreeNode>(); stack.push(root); while (!stack.isEmpty()) { TreeNode current = stack.pop(); if (current.right != null) stack.push(current.right); if (current.left != null) stack.push(current.left); if (!stack.isEmpty()) current.right = stack.peek(); current.left = null; } }}二叉树寻找最近公共父节点(快手)Leetcode 236 二叉树的最近公共父亲节点题解从根节点开始遍历,如果node1和node2中的任一个和root匹配,那么root就是最低公共祖先。 如果都不匹配,则分别递归左、右子树,如果有一个 节点出现在左子树,并且另一个节点出现在右子树,则root就是最低公共祖先. 如果两个节点都出现在左子树,则说明最低公共祖先在左子树中,否则在右子树。public class Solution { public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { //发现目标节点则通过返回值标记该子树发现了某个目标结点 if(root == null || root == p || root == q) return root; //查看左子树中是否有目标结点,没有为null TreeNode left = lowestCommonAncestor(root.left, p, q); //查看右子树是否有目标节点,没有为null TreeNode right = lowestCommonAncestor(root.right, p, q); //都不为空,说明做右子树都有目标结点,则公共祖先就是本身 if(left!=null&&right!=null) return root; //如果发现了目标节点,则继续向上标记为该目标节点 return left == null ? right : left; } }数据流求中位数(蚂蚁)面了蚂蚁中台的团队,二面面试官根据汇报层级推测应该是P9级别及以上,在美国面我,面试风格偏硅谷那边。题目是hard难度的,leetcode 295题。这道题目是Leetcode的hard难度的原题。给定一个数据流,求数据流的中位数,求中位数或者topK的问题我们通常都会想用堆来解决。但是面试官又进一步加大了难度,他要求内存使用很小,没有磁盘,但是压榨空间的同时可以忍受一定时间的损耗。且面试官不仅要求说出思路,要写出完整可经过大数据检测的production code。先不考虑内存不考虑内存的方式就是Leetcode 论坛上的题解。基本思想是建立两个堆。左边是大根堆,右边是小根堆。如果是奇数的时候,大根堆的堆顶是中位数。例如:[1,2,3,4,5]大根堆建立如下: 3 / \ 1 2小根堆建立如下: 4 / 5 偶数的时候则是最大堆和最小堆顶的平均数。例如: [1, 2, 3, 4]大根堆建立如下: 2 / 1 小根堆建立如下: 3 / 4 然后再维护一个奇数偶数的状态即可求中位数。public class MedianStream { private PriorityQueue<Integer> leftHeap = new PriorityQueue<>(5, Collections.reverseOrder()); private PriorityQueue<Integer> rightHeap = new PriorityQueue<>(5); private boolean even = true; public double getMedian() { if (even) { return (leftHeap.peek() + rightHeap.peek()) / 2.0; } else { return leftHeap.peek(); } } public void addNum(int num) { if (even) { rightHeap.offer(num); int rightMin = rightHeap.poll(); leftHeap.offer(rightMin); } else { leftHeap.offer(num); int leftMax = leftHeap.poll(); rightHeap.offer(leftMax); } System.out.println(leftHeap); System.out.println(rightHeap); // 奇偶变换. even = !even; }}压榨内存但是这样做的问题就是可能内存会爆掉。如果你的流无限大,那么意味着这些数据都要存在内存中,堆必须要能够建无限大。如果内存必须很小的方式,用时间换空间。流是可以重复去读的, 用时间换空间;可以用分治的思想,先读一遍流,把流中的数据个数分桶;分桶之后遍历桶就可以得到中位数落在哪个桶里面,这样就把问题的范围缩小了。public class Median { private static int BUCKET_SIZE = 1000; private int left = 0; private int right = Integer.MAX_VALUE; // 流这里用int[] 代替 public double findMedian(int[] nums) { // 第一遍读取stream 将问题复杂度转化为内存可接受的量级. int[] bucket = new int[BUCKET_SIZE]; int step = (right - left) / BUCKET_SIZE; boolean even = true; int sumCount = 0; for (int i = 0; i < nums.length; i++) { int index = nums[i] / step; bucket[index] = bucket[index] + 1; sumCount++; even = !even; } // 如果是偶数,那么就需要计算第topK 个数 // 如果是奇数, 那么需要计算第 topK和topK+1的个数. int topK = even ? sumCount / 2 : sumCount / 2 + 1; int index = 0; int indexBucketCount = 0; for (index = 0; index < bucket.length; index++) { indexBucketCount = bucket[index]; if (indexBucketCount >= topK) { // 当前bucket 就是中位数的bucket. break; } topK -= indexBucketCount; } // 划分到这里其实转化为一个topK的问题, 再读一遍流. if (even && indexBucketCount == topK) { left = index * step; right = (index + 2) * step; return helperEven(nums, topK); // 偶数的时候, 恰好划分到在左右两个子段中. // 左右两段中 [topIndex-K + (topIndex-K + 1)] / 2. } else if (even) { left = index * step; right = (index + 1) * step; return helperEven(nums, topK); // 左边 [topIndex-K + (topIndex-K + 1)] / 2 } else { left = index * step; right = (index + 1) * step; return helperOdd(nums, topK); // 奇数, 左边topIndex-K } }}这里边界条件我们处理好之后,关键还是helperOdd 和 helperEven这两个函数怎么去求topK的问题. 我们还是转化为一个topK的问题,那么求top-K和top(K+1)的问题到这里我们是不是可以用堆来解决了呢? 答案是不能,考虑极端情况。中位数的重复次数非常多eg:[100,100,100,100,100…] (1000亿个100)你的划分恰好落到这个桶里面,内存同样会爆掉。再用时间换空间假如我们的划分bucket大小是10000,那么最大的时候区间就是20000。(对应上面的偶数且落到两个分桶的情况)那么既然划分到某一个bucket了,我们直接用数数字的方式来求topK 就可以了,用堆的方式也可以,更高效一点,其实这里问题规模已经划分到很小了,两种方法都可以。我们选用TreeMap这种数据结构计数。然后分奇数偶数去求解。 private double helperEven(int[] nums, int topK) { TreeMap<Integer, Integer> map = new TreeMap<>(); for (int i = 0; i < nums.length; i++) { if (nums[i] >= left && nums[i] <= right) { if (!map.containsKey(nums[i])) { map.put(nums[i], 1); } else { map.put(nums[i], map.get(nums[i]) + 1); } } } int count = 0; int kNum = Integer.MIN_VALUE; int kNextNum = 0; for (Map.Entry<Integer, Integer> entry : map.entrySet()) { int currentCountIndex = entry.getValue(); if (kNum != Integer.MIN_VALUE) { kNextNum = entry.getKey(); break; } if (count + currentCountIndex == topK) { kNum = entry.getKey(); } else if (count + currentCountIndex > topK) { kNum = entry.getKey(); kNextNum = entry.getKey(); break; } else { count += currentCountIndex; } } return (kNum + kNextNum) / 2.0; } private double helperOdd(int[] nums, int topK) { TreeMap<Integer, Integer> map = new TreeMap<>(); for (int i = 0; i < nums.length; i++) { if (nums[i] >= left && nums[i] <= right) { if (!map.containsKey(nums[i])) { map.put(nums[i], 1); } else { map.put(nums[i], map.get(nums[i]) + 1); } } } int count = 0; int kNum = Integer.MIN_VALUE; for (Map.Entry<Integer, Integer> entry : map.entrySet()) { int currentCountIndex = entry.getValue(); if (currentCountIndex + count >= topK) { kNum = entry.getKey(); break; } else { count += currentCountIndex; } } return kNum; }至此,我觉得算是一个比较好的解决方案,leetcode社区没有相关的解答和探索,欢迎大家交流。热门阅读技术文章汇总【Leetcode】101. 对称二叉树【Leetcode】100. 相同的树【Leetcode】98. 验证二叉搜索树 ...

April 9, 2019 · 8 min · jiezi

【Leetcode】106. 从中序与后序遍历序列构造二叉树2

题目根据一棵树的中序遍历与后序遍历构造二叉树。注意:你可以假设树中没有重复的元素。例如,给出中序遍历 inorder = [9,3,15,20,7]后序遍历 postorder = [9,15,7,20,3]返回如下的二叉树: 3 / \ 9 20 / \ 15 7题解根据前序和中序可以构造一颗二叉树,根据中序和后续也可以构建一颗二叉树。反正必须要有中序才能构建,因为没有中序,你没办法确定树的形状。比如先序和后序是不能构建唯一的一颗二叉树的。例如:先序为:[1, 2]后序为:[2, 1]可以构建如下 1 / 2 1 \ 2 这个面试官也会问的。回到这个题目。那回到这个题目, 其实思路比较好想到,就是如何划分子问题,然后递归的构建左子树和右子树。inorder = [9,3,15,20,7]postorder = [9,15,7,20,3]因为后序后遍历根节点,后续最后一个节点为整棵树的根节点,可以确定根节点为3;再根据中序得到:leftInOrder = [9]RightInOrder = [15, 20 ,7]又由于中序和先序的数组大小应该相同的,所以,LeftPostOrder = [9]RightPostOrder = [15, 7, 20]至此,划分为子问题:leftInOrder = [9]LeftPostOrder = [9]构建左子树。RightPreOrder = [20, 15, 7]RightPostOrder = [15, 7, 20]构建右子树。class Solution { public TreeNode buildTree(int[] inorder, int[] postorder) { return helper(inorder, postorder, postorder.length - 1, 0, inorder.length - 1); } public TreeNode helper(int[] inorder, int[] postorder, int postEnd, int inStart, int inEnd) { if (inStart > inEnd) { return null; } int currentVal = postorder[postEnd]; TreeNode current = new TreeNode(currentVal); int inIndex = 0; for (int i = inStart; i <= inEnd; i++) { if (inorder[i] == currentVal) { inIndex = i; } } TreeNode left = helper(inorder, postorder, postEnd - (inEnd- inIndex) - 1, inStart, inIndex - 1); TreeNode right = helper(inorder, postorder, postEnd - 1, inIndex + 1, inEnd); current.left = left; current.right = right; return current; }}热门阅读技术文章汇总【Leetcode】103. 二叉树的锯齿形层次遍历【Leetcode】102. 二叉树的层次遍历【Leetcode】101. 对称二叉树【Leetcode】100. 相同的树【Leetcode】98. 验证二叉搜索树手撕代码QQ群:805423079, 群密码:1024 ...

March 6, 2019 · 1 min · jiezi

【算法专栏】-- 谈谈时间复杂度

不管是 Android 代码还是数据结构的设计,都涉及到算法的问题,其中时间复杂度是一个Core,这篇文章我们就一起聊聊时间复杂度的原理!1、算法效率虽然随着计算机硬件的迭代更新,运算处理的性能越来越强,但实际上,它也需要根据输入数据的大小和算法效率来消耗一定的处理器资源。要想编写出能高效运行的程序,我们就需要考虑到 “算法的效率”。衡量算法的“好坏”和“效率”主要由以下两个指标(复杂度)来评估: ✨ 时间复杂度(运行时间):评估执行程序所需的时间,可以估算出程序对处理器的使用程度。(本篇博文我们重点探讨时间复杂度) ✨ 空间复杂度(占用空间):评估执行程序所需的存储空间,可以估算出程序对计算机内存的使用程度。2、算法事例我们通过几个场景引出时间复杂度的概念,以及常见的几种时间复杂度,最后再总结比较它们的优劣!场景一生活场景: 你买了一箱“牛栏山二锅头”(16瓶),2天喝一瓶,全部喝完需要几天?很简单的算术问题,2 ✖ 16 = 32 天,那如果一箱有 n 瓶,则需要 2 ✖ n = 2n 天,如果我们用一个函数来表达这个相对时间,可以记作 T(n) = 2n 。代码场景: for(int i = 0; i < n; i++){ // 执行次数是线性的 System.out.println(“喝一瓶酒”); System.out.println(“等待一天”); }场景二生活场景: 你又买了一箱“牛栏山二锅头”(16瓶),决定换个法子喝,5天为一个周期,喝剩下酒的一半,于是第一次喝 8 瓶,第二次喝 4 瓶,那么喝到最后一瓶需要几天?这个问题其实也很简单,16/2 = 8,8/2 = 4,4/2 = 2,2/2 = 1(还剩一瓶),这不就是对数函数吗?以 2 为底数,16为真数,得到的对数就是我们需要的答案!我们可以简写为:5log16,如果一箱有 n 瓶,则需要 5logn 天,如果我们用一个函数来表达这个相对时间,可以记作 T(n) = 5logn 。代码场景: for(int i = 1; i < n; i = 2){ System.out.println(“喝一瓶酒”); System.out.println(“等待一天”); System.out.println(“等待一天”); System.out.println(“等待一天”); System.out.println(“等待一天”);场景三生活场景: 酒喝多了,买了一瓶枸杞,3天喝一瓶,请问喝完枸杞要几天?是的,你没听错,我只是问你喝完枸杞要多久?答案很简单:3天!如果我们用一个函数来表达这个相对时间,可以记作:T(n) = 3 。代码场景:void drink(int n){ System.out.println(“喝一瓶枸杞”); System.out.println(“等待一天”); System.out.println(“等待一天”);场景四生活场景: 酒瘾难戒,又买了一箱好酒(6瓶),但是又不能多喝,于是第一瓶喝了1天,第二瓶喝了2天,第三瓶喝了3天,这样下去全部喝完需要几天?不用我说,其实这就是一个 1 + 2 + 3 … + 6 的算术问题,我们知道有个公式:6(6+1)/2 = 21 天,那如果有 n 瓶,就需要 n(n+1)/2 天,如果我们用一个函数来表达这个相对时间,可以记作 T(n) = n²/2 + n/2 。代码场景:void drink(int n){ for(int i = 0; i < n; i++){ for(int j = 0; j < i; j++){ System.out.println(“等待一天”); } System.out.println(“喝一瓶酒”); }}3、渐进时间复杂度有了基本操作执行次数的函数 T(n),是否就可以分析和比较一段代码的运行时间了呢?还是有一定的困难。比如算法 A 的相对时间是 T(n) = 100n ,算法 B 的相对时间是 T(n) = 5n² ,这两个到底谁的运行时间更长一些?这就要看 n 的取值了!所以,这时候有了 “渐进时间复杂度”(asymptotic time complectiy)的概念。我们看看官方的定义:若存在函数 f(n),使得当 n 趋近于无穷大时,T(n) / f(n) 的极限值为不等于零的常数,则称 f(n) 是 T(n) 的同数量级函数。记作 T(n)= O(f(n)),称 O(f(n)) 为算法的 “渐进时间复杂度”,简称 “时间复杂度”。渐进时间复杂度用大写 O 来表示,所以也被称为 “大O表示法”。4、推导原则如何推导出时间复杂度呢?有如下几个原则: ✨ 1、如果运行时间是常数量级,用常数 1 表示; ✨ 2、只保留时间函数中的最高阶项; ✨ 3、如果最高阶项存在,则省去最高阶项前面的系数。5、事例再分析场景一相对时间:T(n) = 2n ,根据推导原则三:最高阶数为 2n ,省去系数 2 ,转换后的时间复杂度为:T(n) = O(n) 。场景二相对时间:T(n) = 5logn ,根据推导原则三:最高阶数为 5logn ,省去系数 5 ,转换后的时间复杂度为:T(n) = O(logn) 。场景三相对时间:T(n) = 3 ,根据推导原则一:只有常数量级 ,用常数 1 表示 ,转换后的时间复杂度为:T(n) = O(1) 。场景四相对时间:T(n) = n²/2 + n/2 ,根据推导原则二:最高阶数为 n²/2 ,省去系数 0.5 ,转换后的时间复杂度为:T(n) = O(n²) 。这四种时间复杂度究竟谁用时更长,谁节省时间呢?O(1) < O(logn) < O(n) < O(n²)6、其他常见复杂度除了常数阶、线性阶、平方阶、对数阶,还有如下时间复杂度:f(n)时间复杂度阶nlognO(nlogn)nlogn 阶n³O(n³)立方阶2O(2)指数阶n!O(n!)阶乘阶(√n)O(√n)平方根阶7、复杂度比较nlogn√nnlognn²2n!5221025321201033301001024362880050572502500约10^15约3.010^6410061060010000约10^30约9.310^157100093190001000 000约10^300约4.010^2567从上表可以看出,O(n)、O(logn)、O(√n)、O(nlogn) 随着 n 的增加,复杂度提升不大,因此这些复杂度属于效率比较高的算法,反观 O(2) 和 O(n!) 当 n 增加到 50 时,复杂度就突破十位数了,这种效率极差的复杂度最好不要出现在程序中,因此在动手编程时要评估所写算法的最坏情况的复杂度。这些时间复杂度究竟谁用时更长,谁节省时间呢?O(1) < O(logn) < O(√n) < O(n) < O(nlogn) < O(n²) < O(n³) < O(2) < O(n!)8、再举一例????【疑问】????:现在计算机硬件性能越来越强,算法真的体验那么明显吗?算法时间复杂度真的需要那么重视吗?我相信你肯定存在这样的疑问,虽然我们知道算法这个东西是很重要的,但是我们平常可能接触不多,很多时候计算机的性能已经能满足我们的需求,但是我还是要举个例子让你更直观的看到不同算法之间的巨大差异! ???? 算法 A 的相对时间规模是 T(n) = 100n,时间复杂度是 O(n),算法 A 运行在老旧电脑上。 ???? 算法 B 的相对时间规模是 T(n) = 5n²,时间复杂度是 O(n²),算法 B 运行在某台超级计算机上,运行速度是老旧电脑的 100 倍。当随着 n 的增大,我们通过表格看看 T(n) 的变化:nT(n) = 100n ✖ 100T(n) = 5n²11000055500001251010 0000500100100 00005000010001000 0000500 000020002000 00002000 0000100001 0000 00005 0000 000010000010 0000 0000500 0000 00001000000100 0000 000050000 0000 0000从表格中可以看出,当 n 的值很小的时候,算法 A 的运行用时要远大于算法 B;当 n 的值达到 1000 左右,算法 A 和算法 B 的运行时间已经接近;当 n 的值达到 2000 左右,算法 A 和 算法 B 的运行时间一致;当 n 的值越来越大,达到十万、百万时,算法 A 的优势开始显现,算法 B 则越来越慢,差距越来越明显。这就是不同时间复杂度带来的差距,即便你的计算机很牛X!参考博客一套图 搞懂“时间复杂度” ...

February 15, 2019 · 2 min · jiezi

从上往下打印二叉树

题目从上往下打印出二叉树的每个节点,同层节点从左至右打印。题解import java.util.ArrayList;import java.util.Queue;import java.util.LinkedList;/**public class TreeNode { int val = 0; TreeNode left = null; TreeNode right = null; public TreeNode(int val) { this.val = val; }}*/public class Solution { public ArrayList<Integer> PrintFromTopToBottom(TreeNode root) { ArrayList<Integer> result = new ArrayList<>(); if (root == null) { return result; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while(!queue.isEmpty()) { TreeNode current = queue.poll(); TreeNode left = current.left; TreeNode right = current.right; if (left != null) { queue.offer(left); } if (right != null) { queue.offer(right); } result.add(current.val); } return result; }} ...

December 17, 2018 · 1 min · jiezi