字節跳動的算法面試題是什麼難度?(第二彈)

因爲 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 啦。

關注公衆號力扣加加,努力用清晰直白的語言還原解題思路,而且有大量圖解,手把手教你識別套路,高效刷題。

相關文章
相關標籤/搜索