字节跳动的算法面试题是什么难度?(第二弹)

因为 lucifer 我是一个小前端, 最近也在准备写一个《前端如何搞定算法面试》的专栏,所以最近没少看各大公司的面试题。都说字节跳动算法题比较难,我就先拿 ta 下手,作了几套 。此次咱们就拿一套 字节跳动2017秋招编程题汇总来看下字节的算法笔试题的难度几何。地址:https://www.nowcoder.com/test...前端

这套题一共 11 道题, 三道编程题, 八道问答题。本次给你们带来的就是这三道编程题。更多精彩内容,请期待个人搞定算法面试专栏。node

其中有一道题《异或》我没有经过全部的测试用例, 小伙伴能够找找茬,第一个找到并在公众号力扣加加留言的小伙伴奖励现金红包 10 元。git

1. 头条校招

题目描述

头条的 2017 校招开始了!为了此次校招,咱们组织了一个规模宏大的出题团队,每一个出题人都出了一些有趣的题目,而咱们如今想把这些题目组合成若干场考试出来,在选题以前,咱们对题目进行了盲审,并定出了每道题的难度系统。一场考试包含 3 道开放性题目,假设他们的难度从小到大分别为 a,b,c,咱们但愿这 3 道题能知足下列条件:
a<=b<=c
b-a<=10
c-b<=10
全部出题人一共出了 n 道开放性题目。如今咱们想把这 n 道题分布到若干场考试中(1 场或多场,每道题都必须使用且只能用一次),然而因为上述条件的限制,可能有一些考试无法凑够 3 道题,所以出题人就须要多出一些适当难度的题目来让每场考试都达到要求,然而咱们出题已经出得很累了,你能计算出咱们最少还须要再出几道题吗?

输入描述:
输入的第一行包含一个整数 n,表示目前已经出好的题目数量。

第二行给出每道题目的难度系数 d1,d2,...,dn。

数据范围

对于 30%的数据,1 ≤ n,di ≤ 5;

对于 100%的数据,1 ≤ n ≤ 10^5,1 ≤ di ≤ 100。

在样例中,一种可行的方案是添加 2 个难度分别为 20 和 50 的题目,这样能够组合成两场考试:(20 20 23)和(35,40,50)。

输出描述:
输出只包括一行,即所求的答案。
示例 1
输入
4
20 35 23 40
输出
2

思路

这道题看起来很复杂, 你须要考虑不少的状况。,属于那种没有技术含量,可是考验编程能力的题目,须要思惟足够严密。这种模拟的题目,就是题目让我干什么我干什么。 相似以前写的囚徒房间问题,约瑟夫环也是模拟,只不过模拟以后须要你剪枝优化。github

这道题的状况其实不少, 咱们须要考虑每一套题中的难度状况, 而不须要考虑不一样套题的难度状况。题目要求咱们知足:a<=b<=c b-a<=10 c-b<=10,也就是题目难度从小到大排序以后,相邻的难度不能大于 10 。面试

所以咱们的思路就是先排序,以后从小到大遍历,若是知足相邻的难度不大于 10 ,则继续。若是不知足, 咱们就只能让字节的老师出一道题使得知足条件。算法

因为只须要比较同一套题目的难度,所以个人想法就是比较同一套题目的第二个和第一个,以及第三个和第二个的 diff编程

  • 若是 diff 小于 10,什么都不作,继续。
  • 若是 diff 大于 10,咱们必须补充题目。

这里有几个点须要注意。数组

对于第二题来讲:数据结构

  • 好比 1 30 40 这样的难度。 我能够在 1,30 之间加一个 21,这样 1,21,30 就能够组成一一套。
  • 好比 1 50 60 这样的难度。 我能够在 1,50 之间加 21, 41 才能够组成一套,自身(50)是不管如何都没办法组到这套题中的。

不难看出, 第二道题的临界点是 diff = 20 。 小于等于 20 均可以将自身组到套题,增长一道便可,不然须要增长两个,而且自身不能组到当前套题。框架

对于第三题来讲:

  • 好比 1 20 40。 我能够在 20,40 之间加一个 30,这样 1,20,30 就能够组成一一套,自身(40)是没法组到这套题的。
  • 好比 1 20 60。 也是同样的,我能够在 20,60 之间加一个 30,自身(60)一样是没办法组到这套题中的。

不难看出, 第三道题的临界点是 diff = 10 。 小于等于 10 均可以将自身组到套题,不然须要增长一个,而且自身不能组到当前套题。

这就是全部的状况了。

有的同窗比较好奇,我是怎么思考的。 我是怎么保障不重不漏的。

实际上,这道题就是一个决策树, 我画个决策树出来你就明白了。

图中红色边框表示自身能够组成套题的一部分, 我也用文字进行了说明。#2 表明第二题, #3 表明第三题。

从图中能够看出, 我已经考虑了全部状况。若是你可以像我同样画出这个决策图,我想你也不会漏的。固然个人解法并不必定是最优的,不过确实是一个很是好用,具备普适性的思惟框架。

须要特别注意的是,因为须要凑整, 所以你须要使得题目的总数是 3 的倍数向上取整。

代码

n = int(input())
nums = list(map(int, input().split()))
cnt = 0
cur = 1
nums.sort()
for i in range(1, n):
    if cur == 3:
        cur = 1
        continue
    diff = nums[i] - nums[i - 1]
    if diff <= 10:
        cur += 1
    if 10 < diff <= 20:
        if cur == 1:
            cur = 3
        if cur == 2:
            cur = 1
        cnt += 1
    if diff > 20:
        if cur == 1:
            cnt += 2
        if cur == 2:
            cnt += 1
        cur = 1
print(cnt + 3 - cur)

复杂度分析

  • 时间复杂度:因为使用了排序, 所以时间复杂度为 $O(NlogN)$。(假设使用了基于比较的排序)
  • 空间复杂度:$O(1)$

2. 异或

题目描述

给定整数 m 以及 n 各数字 A1,A2,..An,将数列 A 中全部元素两两异或,共能获得 n(n-1)/2 个结果,请求出这些结果中大于 m 的有多少个。

输入描述:
第一行包含两个整数 n,m.

第二行给出 n 个整数 A1,A2,...,An。

数据范围

对于 30%的数据,1 <= n, m <= 1000

对于 100%的数据,1 <= n, m, Ai <= 10^5

输出描述:
输出仅包括一行,即所求的答案

输入例子 1:
3 10
6 5 10

输出例子 1:
2

前置知识

  • 异或运算的性质
  • 如何高效比较两个数的大小(从高位到低位)

首先普及一下前置知识。 第一个是异或运算:

异或的性质:两个数字异或的结果 a^b 是将 a 和 b 的二进制每一位进行运算,得出的数字。 运算的逻辑是若是同一位的数字相同则为 0,不一样则为 1

异或的规律:

  1. 任何数和自己异或则为 0
  2. 任何数和 0 异或是自己
  3. 异或运算知足交换律,即: a ^ b ^ c = a ^ c ^ b

同时建议你们去看下我总结的几道位运算的经典题目。 位运算系列

其次要知道一个常识, 即比较两个数的大小, 咱们是从高位到低位比较,这样才比较高效。

好比:

123
456
1234

这三个数比较大小, 为了方便咱们先补 0 ,使得你们的位数保持一致。

0123
0456
1234

先比较第一位,1 比较 0 大, 所以 1234 最大。再比较第二位, 4 比 1 大, 所以 456 大于 123,后面位不须要比较了。这其实就是剪枝的思想。

有了这两个前提,咱们来试下暴力法解决这道题。

思路

暴力法就是枚举 $N^2 / 2$ 中组合, 让其两两按位异或,将获得的结果和 m 进行比较, 若是比 m 大, 则计数器 + 1, 最后返回计数器的值便可。

暴力的方法就如同题目描述的那样, 复杂度为 $N^2$。 必定过不了全部的测试用例, 不过你们实在没有好的解法的状况能够兜底。无论是牛客笔试仍是实际的面试都是可行的。

接下来,让咱们来分析一下暴力为何低效,以及如何选取数据结构和算法可以使得这个过程变得高效。 记住这句话, 几乎全部的优化都是基于这种思惟产生的,除非你开启了上帝模式,直接看了答案。 只不过等你熟悉了以后,这个思惟过程会很是短, 以致于变成条件反射, 你感受不到有这个过程, 这就是有了题感。

其实我刚才说的第二个前置知识就是咱们优化的关键之一。

我举个例子, 好比 3 和 5 按位异或。

3 的二进制是 011, 5 的二进制是 101,

011
101

按照我前面讲的异或知识, 不可贵出其异或结构就是 110。

上面我进行了三次异或:

  1. 第一次是最高位的 0 和 1 的异或, 结果为 1。
  2. 第二次是次高位的 1 和 0 的异或, 结果为 1。
  3. 第三次是最低位的 1 和 1 的异或, 结果为 0。

那如何 m 是 1 呢? 咱们有必要进行三次异或么? 实际上进行第一次异或的时候已经知道了必定比 m(m 是 1) 大。由于第一次异或的结构致使其最高位为 1,也就是说其最小也不过是 100,也就是 4,必定是大于 1 的。这就是剪枝, 这就是算法优化的关键。

看出我一步一步的思惟过程了么?全部的算法优化都须要通过相似的过程。

所以个人算法就是从高位开始两两异或,而且异或的结果和 m 对应的二进制位比较大小。

  • 若是比 m 对应的二进制位大或者小,咱们提早退出便可。
  • 若是相等,咱们继续往低位移动重复这个过程。

这虽然已经剪枝了,可是极端状况下,性能仍是不好。好比:

m: 1111
a: 1010
b: 0101

a,b 表示两个数,咱们比较到最后才发现,其异或的值和 m 相等。所以极端状况,算法效率没有获得改进。

这里我想到了一点,就是若是一个数 a 的前缀和另一个数 b 的前缀是同样的,那么 c 和 a 或者 c 和 b 的异或的结构前缀部分必定也是同样的。好比:

a: 111000
b: 111101
c: 101011

a 和 b 有共同的前缀 111,c 和 a 异或过了,当再次和 b 异或的时候,实际上前三位是没有必要进行的,这也是重复的部分。这就是算法能够优化的部分, 这就是剪枝。

分析算法,找到算法的瓶颈部分,而后选取合适的数据结构和算法来优化到。 这句话很重要, 请务必记住。

在这里,咱们用的就是剪枝技术,关于剪枝,91 天学算法也有详细的介绍。

回到前面讲到的算法瓶颈, 多个数是有共同前缀的, 前缀部分就是咱们浪费的运算次数, 说到前缀你们应该能够想到前缀树。若是不熟悉前缀树的话,看下个人这个前缀树专题,里面的题所有手写一遍就差很少了。

所以一种想法就是创建一个前缀树, 树的根就是最高的位。 因为题目要求异或, 咱们知道异或是二进制的位运算, 所以这棵树要存二进制才比较好。

反手看了一眼数据范围:m, n<=10^5 。 10^5 = 2 ^ x,咱们的目标是求出 知足条件的 x 的 ceil(向上取整),所以 x 应该是 17。

树的每个节点存储的是:n 个数中,从根节点到当前节点造成的前缀有多少个是同样的,即多少个数的前缀是同样的。这样能够剪枝,提早退出的时候,就直接取出来用了。好比异或的结果是 1, m 当前二进制位是 0 ,那么这个前缀有 10 个,我都不须要比较了, 计数器直接 + 10 。

我用 17 直接复杂度太高,目前仅仅经过了 70 % - 80 % 测试用例, 但愿你们能够帮我找找毛病,我猜想是语言的锅。

代码

class TreeNode:
    def __init__(self):
        self.cnt = 1
        self.children = [None] * 2
def solve(num, i, cur):
    if cur == None or i == -1: return 0
    bit = (num >> i) & 1
    mbit = (m >> i) & 1
    if bit == 0 and mbit == 0:
        return (cur.children[1].cnt if cur.children[1] else 0) + solve(num, i - 1, cur.children[0])
    if bit == 1 and mbit == 0:
        return (cur.children[0].cnt if cur.children[0] else 0) + solve(num, i - 1, cur.children[1])
    if bit == 0 and mbit == 1:
        return solve(num, i - 1, cur.children[1])
    if bit == 1 and mbit == 1:
        return solve(num, i - 1, cur.children[0])

def preprocess(nums, root):
    for num in nums:
        cur = root
        for i in range(16, -1, -1):
            bit = (num >> i) & 1
            if cur.children[bit]:
                cur.children[bit].cnt += 1
            else:
                cur.children[bit] = TreeNode()
            cur = cur.children[bit]

n, m = map(int, input().split())
nums = list(map(int, input().split()))
root = TreeNode()
preprocess(nums, root)
ans = 0
for num in nums:
    ans += solve(num, 16, root)
print(ans // 2)

复杂度分析

  • 时间复杂度:$O(N)$
  • 空间复杂度:$O(N)$

3. 字典序

题目描述

给定整数 n 和 m, 将 1 到 n 的这 n 个整数按字典序排列以后, 求其中的第 m 个数。
对于 n=11, m=4, 按字典序排列依次为 1, 10, 11, 2, 3, 4, 5, 6, 7, 8, 9, 所以第 4 个数是 2.
对于 n=200, m=25, 按字典序排列依次为 1 10 100 101 102 103 104 105 106 107 108 109 11 110 111 112 113 114 115 116 117 118 119 12 120 121 122 123 124 125 126 127 128 129 13 130 131 132 133 134 135 136 137 138 139 14 140 141 142 143 144 145 146 147 148 149 15 150 151 152 153 154 155 156 157 158 159 16 160 161 162 163 164 165 166 167 168 169 17 170 171 172 173 174 175 176 177 178 179 18 180 181 182 183 184 185 186 187 188 189 19 190 191 192 193 194 195 196 197 198 199 2 20 200 21 22 23 24 25 26 27 28 29 3 30 31 32 33 34 35 36 37 38 39 4 40 41 42 43 44 45 46 47 48 49 5 50 51 52 53 54 55 56 57 58 59 6 60 61 62 63 64 65 66 67 68 69 7 70 71 72 73 74 75 76 77 78 79 8 80 81 82 83 84 85 86 87 88 89 9 90 91 92 93 94 95 96 97 98 99 所以第 25 个数是 120…

输入描述:
输入仅包含两个整数 n 和 m。

数据范围:

对于 20%的数据, 1 <= m <= n <= 5 ;

对于 80%的数据, 1 <= m <= n <= 10^7 ;

对于 100%的数据, 1 <= m <= n <= 10^18.

输出描述:
输出仅包括一行, 即所求排列中的第 m 个数字.
示例 1
输入
11 4
输出
2

前置知识

  • 十叉树
  • 彻底十叉树
  • 计算彻底十叉树的节点个数
  • 字典树

思路

和上面题目思路同样, 先从暴力解法开始,尝试打开思路。

暴力兜底的思路是直接生成一个长度为 n 的数组, 排序,选第 m 个便可。代码:

n, m = map(int, input().split())

nums  = [str(i) for i in range(1, n + 1)]
print(sorted(nums)[m - 1])

复杂度分析

  • 时间复杂度:取决于排序算法, 不妨认为是 $O(NlogN)$
  • 空间复杂度: $O(N)$

这种算法能够 pass 50 % case。

上面算法低效的缘由是开辟了 N 的空间,并对整 N 个 元素进行了排序。

一种简单的优化方法是将排序换成堆,利用堆的特性求第 k 大的数, 这样时间复杂度能够减低到 $mlogN$。

咱们继续优化。实际上,你若是把字典序的排序结构画出来, 能够发现他本质就是一个十叉树,而且是一个彻底十叉树。

接下来,我带你继续分析。

如图, 红色表示根节点。节点表示一个十进制数, 树的路径存储真正的数字,好比图上的 100,109 等。 这不就是上面讲的前缀树么?

如图黄色部分, 表示字典序的顺序,注意箭头的方向。所以本质上,求字典序第 m 个数, 就是求这棵树的前序遍历的第 m 个节点。

所以一种优化思路就是构建一颗这样的树,而后去遍历。 构建的复杂度是 $O(N)$,遍历的复杂度是 $O(M)$。所以这种算法的复杂度能够达到 $O(max(m, n))$ ,因为 n >= m,所以就是 $O(N)$。

实际上, 这样的优化算法依然是没法 AC 所有测试用例的,会超内存限制。 所以咱们的思路只能是不使用 N 的空间去构造树。想一想也知道, 因为 N 最大可能为 10^18,一个数按照 4 字节来算, 那么这就有 400000000 字节,大约是 381 M,这是不能接受的。

上面提到这道题就是一个彻底十叉树的前序遍历,问题转化为求彻底十叉树的前序遍历的第 m 个数。

十叉树和二叉树没有本质不一样, 我在二叉树专题部分, 也提到了 N 叉树均可以用二叉树来表示。

对于一个节点来讲,第 m 个节点:

  • 要么就是它自己
  • 要么其孩子节点中
  • 要么在其兄弟节点
  • 要么在兄弟节点的孩子节点中

究竟在上面的四个部分的哪,取决于其孩子节点的个数。

  • count > m ,m 在其孩子节点中,咱们须要深刻到子节点。
  • count <= m ,m 不在自身和孩子节点, 咱们应该跳过全部孩子节点,直接到兄弟节点。

这本质就是一个递归的过程。

须要注意的是,咱们并不会真正的在树上走,所以上面提到的深刻到子节点, 以及 跳过全部孩子节点,直接到兄弟节点如何操做呢?

你仔细观察会发现: 若是当前节点的前缀是 x ,那么其第一个子节点(就是最小的子节点)是 x * 10,第二个就是 x * 10 + 1,以此类推。所以:

  • 深刻到子节点就是 x * 10。
  • 跳过全部孩子节点,直接到兄弟节点就是 x + 1。

ok,铺垫地差很少了。

接下来,咱们的重点是如何计算给定节点的孩子节点的个数

这个过程和彻底二叉树计算节点个数并没有二致,这个算法的时间复杂度应该是 $O(logN*logN)$。 若是不会的同窗,能够参考力扣原题: 222. 彻底二叉树的节点个数 ,这是一个难度为中等的题目。

所以这道题自己被划分为 hard,一点都不为过。

这里简单说下,计算给定节点的孩子节点的个数的思路, 个人 91 天学算法里出过这道题。

一种简单但非最优的思路是分别计算左右子树的深度。

  • 若是当前节点的左右子树高度相同,那么左子树是一个满二叉树,右子树是一个彻底二叉树。
  • 不然(左边的高度大于右边),那么左子树是一个彻底二叉树,右子树是一个满二叉树。

若是是满二叉树,当前节点数 是 2 ^ depth,而对于彻底二叉树,咱们继续递归便可。

class Solution:
    def countNodes(self, root):
        if not root:
            return 0
        ld = self.getDepth(root.left)
        rd = self.getDepth(root.right)
        if ld == rd:
            return 2 ** ld + self.countNodes(root.right)
        else:
            return 2 ** rd + self.countNodes(root.left)

    def getDepth(self, root):
        if not root:
            return 0
        return 1 + self.getDepth(root.left)

复杂度分析

  • 时间复杂度:$O(logN * log N)$
  • 空间复杂度:$O(logN)$

而这道题, 咱们能够更简单和高效。

好比咱们要计算 1 号节点的子节点个数。

  • 它的孩子节点个数是 。。。
  • 它的孙子节点个数是 。。。
  • 。。。

所有加起来便可。

它的孩子节点个数是 20 - 10 = 10 。 也就是它的右边的兄弟节点的第一个子节点 减去 它的第一个子节点

因为是彻底十叉树,而不是满十叉树 。所以你须要考虑边界状况,好比题目的 n 是 15。 那么 1 的子节点个数就不是 20 - 10 = 10 了, 而是 15 - 10 + 1 = 16。

其余也是相似的过程, 咱们只要:

  • Go deeper and do the same thing

或者:

  • Move to next neighbor and do the same thing

不断重复,直到 m 下降到 0 。

代码

def count(c1, c2, n):
    steps = 0
    while c1 <= n:
        steps += min(n + 1, c2) - c1
        c1 *= 10
        c2 *= 10
    return steps
def findKthNumber(n: int, k: int) -> int:
    cur = 1
    k = k - 1
    while k > 0:
        steps = count(cur, cur + 1, n)
        if steps <= k:
            cur += 1
            k -= steps
        else:
            cur *= 10
            k -= 1
    return cur
n, m = map(int, input().split())
print(findKthNumber(n, m))

复杂度分析

  • 时间复杂度:$O(logM * log N)$
  • 空间复杂度:$O(1)$

总结

其中三道算法题从难度上来讲,基本都是困难难度。从内容来看,基本都是力扣的换皮题,且都或多或少和树有关。若是你们一开始没有思路,建议你们先给出暴力的解法兜底,再画图或举简单例子打开思路。

我也刷了不少字节的题了,还有一些难度比较大的题。若是你第一次作,那么须要你思考比较久才能想出来。加上面试紧张,极可能作不出来。这个时候就更须要你冷静分析,先暴力打底,慢慢优化。有时候即便给不了最优解,让面试官看出你的思路也很重要。 好比小兔的棋盘 想出最优解难度就不低,不过你能够先暴力 DFS 解决,再 DP 优化会慢慢帮你打开思路。有时候面试官也会引导你,给你提示, 加上你刚才“发挥不错”,说不定一会儿就作出最优解了,这个我深有体会。

另外要提醒你们的是, 刷题要适量,不要贪多。要彻底理清一道题的前因后果。多问几个为何。 这道题暴力法怎么作?暴力法哪有问题?怎么优化?为何选了这个算法就能够优化?为何这种算法要用这种数据结构来实现?

更多题解能够访问个人 LeetCode 题解仓库:https://github.com/azl3979858... 。 目前已经 36K+ star 啦。

关注公众号力扣加加,努力用清晰直白的语言还原解题思路,而且有大量图解,手把手教你识别套路,高效刷题。

相关文章
相关标签/搜索