大家好,我们继续字节跳动的招聘笔试真题系列。
今天选择的题目是字节跳动2018年校招算法方向的笔试第一题,题目同样来源牛客网。这道题算是至今做过的笔试题里面难度比较大的了。我个人觉得在LeetCode应该可以排到Hard,所以如果做不出来或者是觉得很难是正常的。
当我们面临难题的时候,做出来其实并不是第一要义,重要的是我们从当中学到东西有所收获就足够了。好了,废话不多说了,我们来看题目吧。
题目链接:https://www.nowcoder.com/test/question/f0ed5df0a373456a8c9b5a64e6374961?pid=8537228&tid=40710686
题意
时间限制:C/C++ 3秒,其他语言6秒
空间限制:C/C++ 256M,其他语言512M
为了不断优化推荐效果,今日头条每天要存储和处理海量数据。假设有这样一种场景:我们对用户按照它们的注册时间先后来标号,对于一类文章,每个用户都有不同的喜好值,我们会想知道某一段时间内注册的用户(标号相连的一批用户)中,有多少用户对这类文章喜好值为k。因为一些特殊的原因,不会出现一个查询的用户区间完全覆盖另一个查询的用户区间(不存在L1<=L2<=R2<=R1)。
输入描述:
输入: 第1行为n代表用户的个数 第2行为n个整数,第i个代表用户标号为i的用户对某类文章的喜好度 第3行为一个正整数q代表查询的组数 第4行到第(3+q)行,每行包含3个整数l,r,k代表一组查询,即标号为l<=i<=r的用户中对这类文章喜好值为k的用户的个数。 数据范围n <= 300000,q<=300000 k是整型
输出描述:
输出:一共q行,每行一个整数代表喜好值为k的用户的个数
输入例子1:
51 2 3 3 531 2 12 4 53 5 3
输出例子1:
102
例子说明1:
样例解释:有5个用户,喜好值为分别为1、2、3、3、5,第一组询问对于标号[1,2]的用户喜好值为1的用户的个数是1第二组询问对于标号[2,4]的用户喜好值为5的用户的个数是0第三组询问对于标号[3,5]的用户喜好值为3的用户的个数是2
解法
我们来分析一下问题,这道题的难点很明确,明摆着就两点。第一点是查询的区间很多,并且区间的长度也很长,都是十万这个量级,如果使用暴力求解的方法显然一定会超时。算法可以通过的极限中的极限是1e4,1e4也需要保证常数比较小才行,否则一定会超时。
既然如此那有什么别的方法吗?
线段树
当然是有的,如果大家对于算法比较敏感的话,对于这种区间查询的问题很容易想到线段树。线段树可以在的时间内对某一个区间完成查询以及更新操作,这样对于1e6这个量级的区间以及查找就可以胜任了。
这道题一旦想到线段树之后,你会发现其实就是一道裸题。我们很容易可以想到,对于每一个区间我们都可以维护一个map,用来存储每一个数字出现的次数。当我们查询的区间包含若干个区间的时候,我们只需要把这些区间对应的map全部合并在一起即可。对于这道题而言,我们并不存在更新操作,只需要完成建树以及查找即可。
如果小伙伴们对于线段树这个数据结构不熟悉的,可以访问下方传送门回顾一下:
原创 | ACMer不得不会的线段树,究竟是种怎样的数据结构?
想到线段树就没什么可说的了,基本操作,直接来看代码即可。这里我偷懒没用C++,因为对C++结构体有些忘了,但逻辑是一样的。
from collections import Counterclass Node: def __init__(self, left=None, right=None, ct=None): self.left = left self.right = right self.ct = ctclass ST: def __init__(self, arr): self.l = 0 self.r = len(arr) # 建树 self.root = self.build(arr, 0, self.r) def build(self, arr, l, r): # 区间长度等于1,则节点中只有一个元素 if l + 1 >= r: return Node(None, None, Counter([arr[l]])) else: # 否则递归建树,并且合并左右子树区间的Counter m = (l + r) // 2 lchild = self.build(arr, l, m) rchild = self.build(arr, m, r) return Node(lchild, rchild, lchild.ct + rchild.ct) def query(self, ll, rr, k): return self._query(self.root, self.l, self.r, ll, rr, k) def _query(self, nd, l, r, ll, rr, k): # l,r表示当前节点nd维护的区间,ll和rr表示查询的区间 # 如果两个区间没有交集,返回0 if rr <= l or ll >= r: return 0 m = (l + r) // 2 # 如果查询区间包含维护区间,那么直接返回结果 if ll <= l and rr >= r: return nd.ct[k] if k in nd.ct else 0 # 如果查询区间在维护区间左侧,则往左子树递归 elif rr <= m: return self._query(nd.left, l, m, ll, rr, k) # 如果查询区间在维护区间右侧,则往右 elif ll >= m: return self._query(nd.right, m, r, ll, rr, k) # 如果横跨左右两边,则拆分 else: return self._query(nd.left, l, m, ll, m, k) + self._query(nd.right, m, r, m, rr, k)if __name__ == "__main__": n = int(input()) vals = input().split(' ') nums = [int(v) for v in vals if v != ''] st = ST(nums) q = int(input()) for i in range(q): l, r, k = list(map(int, input().split(' '))) print(st.query(l-1, r, k))
可能会有一些同学会说,笔试题当中出一个线段树的裸题这合适吗?会不会有些不公平呢?毕竟线段树是大学算法课程里都不会讲的。
其实不会,因为这道题除了线段树之外,还有其他的解法。并且这个解法并不是很难,大家都可以想得到。
二分位置
首先,我们可以做一个简单的聚合,把相等的k的下标聚合在一起。然后我们把这些下标进行从小到大排序,这样我们只需要通过k查找到对应的下标组成的数组,然后在数组当中使用二分法查找在[l, r]区间内的数量就是答案了。
我们进一步思考会发现其实我们连排序都没有必要,因为我们只要按顺序读入,获得的下标就是天然有序的。所以我们只需要按顺序读入然后插入就可以了,这里使用一个map<int, vector>就可以搞定,几乎没有难度,唯一的难点在二分。
但是不要小看这里的二分,它并不简单。这里我们使用二分是为了求解区间当中元素的数量,假设我们希望查询的范围是[l,r ]。那么我们需要找到第一个大于等于l的位置,以及第一个大于r的位置。由于我们需要用两个位置相减来计算元素的个数,所以我们也需要考虑非法情况。比如不存在大于等于l的元素,或者不存在大于r的元素的时候。比如当数组当中所有元素都大于l的时候,我们不能返回数组的最后一个元素,而需要返回数组的最后一个元素再往后一个位置,不然的话计算数量的时候就会漏掉一个。
老实讲这种既需要查询位置又需要考虑边界情况的二分法还是比较麻烦的,我在刚入门的时候在这里受了不少苦。这里我分别实现了带个函数用来求解区间的左侧和右侧,分别叫做find_left以及find_right。
int find_left(vector<int> vt, int v) { // 我们维护左开右闭的区间,但需要考虑非法情况 // r设置成数组长度,防止出现所有元素都小于v的情况 int l = -1, r = vt.size(); while (l + 1 < r) { if (vt[mid] < v) { l = mid; }else { r = mid; } } return r;}int find_right(vector<int> vt, int v) { // 同理,维护左开右闭区间 // 为了防止所有元素小于等于v,所以r设置成数组长度 int l = -1, r = vt.size(); while (l + 1 < r) { if (vt[mid] <= v) { l = mid; }else { r = mid; } } return r;}
老实讲,这里我在写二分的时候也花了一些时间调试,因为一开始没有考虑到非法的情况。所以如果是新手来写的话,这里卡上一段时间是正常的,也是几乎不可避免的。
对于这样的情况,我们也有办法可以取巧绕开。在C++的STL当中为我们提供了现成的二分算法,lower_bound和upper_bound。和这里的find_left和find_right的功能完全一样,它传入三个参数,分别是数组的起始位置,数组的结束位置以及需要查询的元素。它返回的对应位置的地址,有了地址之后我们既可以直接拿到这个数,也可以获取它的下标。
如果使用lower_bound和upper_bound函数的话,那么整个过程只会需要两行。
int left = lower_bound(mp[k].begin(), mp[k].end(), l-1) - mp[k].begin();int right = upper_bound(mp[k].begin(), mp[k].end(), r-1) - mp[k].begin();
最后,附上完整的代码:
#include <iostream>#include <cstdio>#include <cstring>#include <queue>#include <vector>#include <cmath>#include <cstdlib>#include <string>#include <map>#include <set>#include <algorithm>#include "time.h"#include <functional>#define rep(i,a,b) for (int i=a;i<b;i++)#define Rep(i,a,b) for (int i=a;i>b;i--)#define foreach(e,x) for (__typeof(x.begin()) e=x.begin();e!=x.end();e++)#define mid ((l+r)>>1)#define lson (k<<1)#define rson (k<<1|1)#define MEM(a,x) memset(a,x,sizeof a)#define L ch[r][0]#define R ch[r][1]#define pii pair<int, int>#define LL long long using namespace std;const int N=500005;const long long Mod=99997867;int n, q;int find_left(vector<int> vt, int v) { int l = -1, r = vt.size(); while (l + 1 < r) { if (vt[mid] < v) { l = mid; }else { r = mid; } } return r;}int find_right(vector<int> vt, int v) { int l = -1, r = vt.size(); while (l + 1 < r) { if (vt[mid] <= v) { l = mid; }else { r = mid; } } return r;}int main() { scanf("%d", &n); map<int, vector<int> > mp; rep(i, 0, n) { int x; scanf("%d", &x); if (mp.count(x) == 0) { vector<int> vt; mp[x] = vt; } mp[x].push_back(i); } scanf("%d", &q); rep(i, 0, q) { int l, r, k; scanf("%d %d %d", &l, &r, &k); if (mp.count(k) == 0) { puts("0"); continue; } // int left = lower_bound(mp[k].begin(), mp[k].end(), l-1) - mp[k].begin(); // int right = upper_bound(mp[k].begin(), mp[k].end(), r-1) - mp[k].begin(); int left = find_left(mp[k], l-1); int right = find_right(mp[k], r-1); printf("%d\n", right - left); } return 0;}
结尾
这道题的两种解法,无论是线段树硬刚还是通过二分曲线救国都不是非常简单,要么有丰富的代码和数据结构的能力,可以在笔试的时候写出线段树来,要么对于二分法很熟悉,可以写出这种进阶的用法。这基本上都是专业acm选手的水平了,所以从这点也看得出来,字节跳动对于算法的要求还是比较高的,非常看重候选人的算法和数据结构的编码能力。
但话说回来,对于真正的acm选手而言,这道题的难度并不算很大,赛场上几乎可以看成是80%都能做出来的签到题。所以大家也不要被它吓到,多做这样的练习非常有助于能力的成长。
今天的文章就到这里,衷心祝愿大家每天都有所收获。如果还喜欢今天的内容的话,请来一个三连支持吧~(点赞、在看、转发)