题目
标题和出处
标题:设计推特
出处:355. 设计推特
难度
7 级
题目描述
要求
设计一个简化版的推特,可以让用户实现发送推文,关注/取消关注其他用户,能够看见关注人(包括自己)的最近 10 \texttt{10} 10 条推文。
实现 Twitter \texttt{Twitter} Twitter 类:
- Twitter() \texttt{Twitter()} Twitter() 初始化简易版推特对象。
- void postTweet(int userId, int tweetId) \texttt{void postTweet(int userId, int tweetId)} void postTweet(int userId, int tweetId) 根据给定的 tweetId \texttt{tweetId} tweetId 和 userId \texttt{userId} userId 创建一条新推文。每次调用此函数都会使用一个不同的 tweetId \texttt{tweetId} tweetId。
- List<Integer> getNewsFeed(int userId) \texttt{List<Integer> getNewsFeed(int userId)} List<Integer> getNewsFeed(int userId) 检索当前用户新闻推送中最近 10 \texttt{10} 10 条推文的 ID 。新闻推送中的每一项都必须是由用户关注的人或者是用户自己发布的推文。推文必须按照时间顺序由最近到最远排序。
- void follow(int followerId, int followeeId) \texttt{void follow(int followerId, int followeeId)} void follow(int followerId, int followeeId) ID 为 followerId \texttt{followerId} followerId 的用户开始关注 ID 为 followeeId \texttt{followeeId} followeeId 的用户。
- void unfollow(int followerId, int followeeId) \texttt{void unfollow(int followerId, int followeeId)} void unfollow(int followerId, int followeeId) ID 为 followerId \texttt{followerId} followerId 的用户不再关注 ID 为 followeeId \texttt{followeeId} followeeId 的用户。
示例
示例 1:
输入:
["Twitter",
"postTweet",
"getNewsFeed",
"follow",
"postTweet",
"getNewsFeed",
"unfollow",
"getNewsFeed"]
\texttt{["Twitter", "postTweet", "getNewsFeed", "follow", "postTweet", "getNewsFeed", "unfollow", "getNewsFeed"]}
["Twitter", "postTweet", "getNewsFeed", "follow", "postTweet", "getNewsFeed", "unfollow", "getNewsFeed"]
[[],
[1,
5],
[1],
[1,
2],
[2,
6],
[1],
[1,
2],
[1]]
\texttt{[[], [1, 5], [1], [1, 2], [2, 6], [1], [1, 2], [1]]}
[[], [1, 5], [1], [1, 2], [2, 6], [1], [1, 2], [1]]
输出:
[null,
null,
[5],
null,
null,
[6,
5],
null,
[5]]
\texttt{[null, null, [5], null, null, [6, 5], null, [5]]}
[null, null, [5], null, null, [6, 5], null, [5]]
解释:
Twitter
twitter
=
new
Twitter();
\texttt{Twitter twitter = new Twitter();}
Twitter twitter = new Twitter();
twitter.postTweet(1,
5);
\texttt{twitter.postTweet(1, 5);}
twitter.postTweet(1, 5); // 用户
1
\texttt{1}
1 发送了一条新推文(
id
=
5
\texttt{id = 5}
id = 5)。
twitter.getNewsFeed(1);
\texttt{twitter.getNewsFeed(1);}
twitter.getNewsFeed(1); // 用户
1
\texttt{1}
1 的获取推文应当返回一个列表,其中包含
1
\texttt{1}
1 条推文
[5]
\texttt{[5]}
[5]。
twitter.follow(1,
2);
\texttt{twitter.follow(1, 2);}
twitter.follow(1, 2); // 用户
1
\texttt{1}
1 关注了用户
2
\texttt{2}
2。
twitter.postTweet(2,
6);
\texttt{twitter.postTweet(2, 6);}
twitter.postTweet(2, 6); // 用户
2
\texttt{2}
2 发送了一条新推文(
id
=
6
\texttt{id = 6}
id = 6)。
twitter.getNewsFeed(1);
\texttt{twitter.getNewsFeed(1);}
twitter.getNewsFeed(1); // 用户
1
\texttt{1}
1 的获取推文应当返回一个列表,其中包含
2
\texttt{2}
2 条推文
[6,
5]
\texttt{[6, 5]}
[6, 5]。推文
id
=
6
\texttt{id = 6}
id = 6 应当在推文
id
=
5
\texttt{id = 5}
id = 5 之前,因为它是在推文
id
=
5
\texttt{id = 5}
id = 5 之后发送的。
twitter.unfollow(1,
2);
\texttt{twitter.unfollow(1, 2);}
twitter.unfollow(1, 2); // 用户
1
\texttt{1}
1 取消关注了用户
2
\texttt{2}
2。
twitter.getNewsFeed(1);
\texttt{twitter.getNewsFeed(1);}
twitter.getNewsFeed(1); // 用户
1
\texttt{1}
1 获取推文应当返回一个列表,其中包含
1
\texttt{1}
1 条推文
[5]
\texttt{[5]}
[5],因为用户
1
\texttt{1}
1 已经不再关注用户
2
\texttt{2}
2。
数据范围
- 1 ≤ userId, followerId, followeeId ≤ 500 \texttt{1} \le \texttt{userId, followerId, followeeId} \le \texttt{500} 1≤userId, followerId, followeeId≤500
- 0 ≤ tweetId ≤ 10 4 \texttt{0} \le \texttt{tweetId} \le \texttt{10}^\texttt{4} 0≤tweetId≤104
- 所有推特的 ID 都互不相同
- postTweet \texttt{postTweet} postTweet、 getNewsFeed \texttt{getNewsFeed} getNewsFeed、 follow \texttt{follow} follow 和 unfollow \texttt{unfollow} unfollow 方法最多调用 3 × 10 4 \texttt{3} \times \texttt{10}^\texttt{4} 3×104 次
前言
这道题要求设计一个简化版的推特,需要支持以下功能:
- 创建新推文;
- 检索最近 10 10 10 条推文,并按照时间顺序由最近到最远排序;
- 关注用户;
- 取消关注用户。
功能 3 和功能 4 的实现较为简单,只需要维护一个关注用户的哈希表,对每个用户使用一个哈希集合存储该用户关注的用户集合。如果 ID 为 followerId \textit{followerId} followerId 的用户关注 ID 为 followeeId \textit{followeeId} followeeId 的用户,则在 followerId \textit{followerId} followerId 对应的哈希集合中加入 followeeId \textit{followeeId} followeeId;如果取消关注,则在对应的哈希集合中将被关注者移除。
功能 1 和功能 2 的实现较为复杂。功能 1 需要对每个用户维护一个按时间倒序排序的推文列表,功能 2 需要得到当前用户和该用户关注的所有用户的推文列表并检索这些推文中的最近 10 10 10 条推文。为了实现检索推文功能,可以对每个用户维护一个链表,链表中的每个结点存储一条推文的信息,结点按照时间倒序排序,检索推文时可以使用「合并K个升序链表」的做法,得到最近 10 10 10 条推文。
存储推文信息的链表可以使用单向链表或者双向链表。
解法一
思路和算法
使用单向链表存储推文信息,则每个用户对应的链表的头结点为该用户最新创建的推文(如果用户没有创建过推文则链表为空)。链表中的每个结点需要包含以下信息:推文 ID、创建时间和上一条推文的结点。
推特类中需要维护以下信息:
- 推文时间,记录当前时间,每次发布推文时更新推文时间然后创建新推文;
- 用户推文哈希表,记录每个用户对应的推文列表;
- 关注用户哈希表,记录每个用户关注的用户集合。
构造方法中,将推文时间初始化为 0 0 0,将两个哈希表初始化。
对于 postTweet \textit{postTweet} postTweet 操作,将推文时间加 1 1 1,在用户推文哈希表中得到 userId \textit{userId} userId 对应的推文列表,根据 tweetId \textit{tweetId} tweetId 和推文时间创建新推文对应的结点,添加到推文列表的头部。
对于 getNewsFeed \textit{getNewsFeed} getNewsFeed 操作,得到 userId \textit{userId} userId 及其关注的全部用户的推文列表,使用优先队列存储每个用户的非空推文列表,优先队列存储结点,队首结点为创建时间最大的推文对应的结点。每次从优先队列中取出剩余结点中的创建时间最大的结点,将该结点对应的推文 ID 添加到结果中,然后判断该结点的上一条推文的结点是否为空,如果不为空则加入优先队列。重复该操作直到结果中有 10 10 10 个推文 ID,或者优先队列变为空,然后返回结果。
对于 follow \textit{follow} follow 操作,在关注用户哈希表中得到 followerId \textit{followerId} followerId 对应的哈希集合,将 followeeId \textit{followeeId} followeeId 加入到该哈希集合中。
对于 unfollow \textit{unfollow} unfollow 操作,在关注用户哈希表中得到 followerId \textit{followerId} followerId 对应的哈希集合,将 followeeId \textit{followeeId} followeeId 从该哈希集合中移除。
代码
class Twitter {
private class Node {
private int tweetId;
private int time;
private Node prev;
public Node(int tweetId, int time, Node prev) {
this.tweetId = tweetId;
this.time = time;
this.prev = prev;
}
public int getTweetId() {
return tweetId;
}
public int getTime() {
return time;
}
public Node getPrev() {
return prev;
}
}
private static final int MAX_RECENT = 10;
private int time = 0;
private Map<Integer, Node> userTweetsMap;
private Map<Integer, Set<Integer>> followeeMap;
public Twitter() {
time = 0;
userTweetsMap = new HashMap<Integer, Node>();
followeeMap = new HashMap<Integer, Set<Integer>>();
}
public void postTweet(int userId, int tweetId) {
time++;
Node prev = userTweetsMap.get(userId);
Node curr = new Node(tweetId, time, prev);
userTweetsMap.put(userId, curr);
}
public List<Integer> getNewsFeed(int userId) {
List<Integer> mostRecent = new ArrayList<Integer>();
PriorityQueue<Node> pq = new PriorityQueue<Node>((a, b) -> b.getTime() - a.getTime());
if (userTweetsMap.containsKey(userId)) {
pq.offer(userTweetsMap.get(userId));
}
Set<Integer> followees = followeeMap.getOrDefault(userId, new HashSet<Integer>());
for (int followee : followees) {
Node node = userTweetsMap.get(followee);
if (node != null) {
pq.offer(node);
}
}
for (int i = 0; i < MAX_RECENT && !pq.isEmpty(); i++) {
Node node = pq.poll();
mostRecent.add(node.getTweetId());
Node prev = node.getPrev();
if (prev != null) {
pq.offer(prev);
}
}
return mostRecent;
}
public void follow(int followerId, int followeeId) {
followeeMap.putIfAbsent(followerId, new HashSet<Integer>());
followeeMap.get(followerId).add(followeeId);
}
public void unfollow(int followerId, int followeeId) {
followeeMap.putIfAbsent(followerId, new HashSet<Integer>());
followeeMap.get(followerId).remove(followeeId);
}
}
复杂度分析
-
时间复杂度:构造方法的时间复杂度是 O ( 1 ) O(1) O(1),创建推文、关注用户和取消关注用户操作的时间复杂度都是 O ( 1 ) O(1) O(1),检索推文操作的时间复杂度是 O ( u log u ) O(u \log u) O(ulogu),其中 u u u 是用户数量。
创建推文操作需要从哈希表中得到用户对应的推文列表,然后在链表头部添加结点,时间复杂度是 O ( 1 ) O(1) O(1)。
关注用户和取消关注用户操作需要从哈希表中得到用户对应的哈希集合,然后在哈希集合中添加或移除元素,时间复杂度是 O ( 1 ) O(1) O(1)。
检索推文操作需要将用户和关注的每个用户对应的推文列表,需要在优先队列中执行 u u u 次添加元素和 10 10 10 次移除元素操作,每次操作的时间复杂度是 O ( log u ) O(\log u) O(logu),因此总时间复杂度是 O ( u log u ) O(u \log u) O(ulogu)。 -
空间复杂度: O ( u 2 + t ) O(u^2 + t) O(u2+t),其中 u u u 是用户数量, t t t 是推文数量。需要使用两个哈希表记录每个用户的推文列表和每个用户关注的用户集合,用户推文哈希表需要 O ( u + t ) O(u + t) O(u+t) 的空间,关注用户哈希表需要 O ( u 2 ) O(u^2) O(u2) 的空间(最坏情况下,每个用户都关注全部用户),因此空间复杂度是 O ( u + t + u 2 ) = O ( u 2 + t ) O(u + t + u^2) = O(u^2 + t) O(u+t+u2)=O(u2+t)。
解法二
思路和算法
解法一需要在每个用户的推文列表中存储该用户创建的全部推文。由于每次检索推文只需要返回当前用户及其关注的全部用户创建的所有推文中的最近 10 10 10 条推文,因此不需要存储全部推文,对于每个用户,最多只需要存储最近的 10 10 10 条推文,从而优化空间。为了实现空间优化,如果一个用户的推文列表的长度大于 10 10 10,则需要将该用户最早创建的推文从推文列表中删除,直到推文列表的长度减少到 10 10 10。
由于单向链表无法快速定位到尾结点,因此需要使用双向链表存储每个推文信息。和单向链表的结点相比,双向链表的结点需要多记录一项信息:下一条推文的结点。
为了便于操作,双向链表需要维护伪头结点、伪尾结点和链表的长度,链表的实际头结点为伪头结点的上一条推文的结点,链表的实际尾结点为伪尾结点的下一条推文的结点(只有当链表不为空时才存在实际头结点和实际尾结点)。初始时,伪头结点和伪尾结点相邻。链表的长度为链表的结点数,不包括伪头结点和伪尾结点,初始时链表的长度为 0 0 0。
对于 postTweet \textit{postTweet} postTweet 操作,在双向列表的伪头结点的上一条推文处添加新推文对应的结点,如果添加结点之后链表的长度大于 10 10 10,则将双向链表的伪尾结点的下一条推文处的结点删除,确保每个用户的推文列表的长度不超过 10 10 10。实现方法可以参考「设计链表」的双向链表解法。
对于 getNewsFeed \textit{getNewsFeed} getNewsFeed 操作,其做法和解法一相同,但是由于使用双向链表,因此具体实现有所不同。
其余操作和解法一相同。
代码
class Twitter {
private class DoublyLinkedList {
private Node pseudoHead;
private Node pseudoTail;
private int size;
public DoublyLinkedList() {
pseudoHead = new Node();
pseudoTail = new Node();
pseudoHead.setPrev(pseudoTail);
pseudoTail.setNext(pseudoHead);
size = 0;
}
public void addNode(Node node) {
Node prevNode = pseudoHead.getPrev();
node.setPrev(prevNode);
prevNode.setNext(node);
pseudoHead.setPrev(node);
node.setNext(pseudoHead);
size++;
if (size > MAX_RECENT) {
Node removeNode = pseudoTail.getNext();
pseudoTail.setNext(removeNode.getNext());
removeNode.getNext().setPrev(pseudoTail);
size--;
}
}
public Node getHead() {
return pseudoHead.getPrev();
}
public int getSize() {
return size;
}
}
private class Node {
private int tweetId;
private int time;
private Node prev;
private Node next;
public Node() {
this(-1, -1);
}
public Node(int tweetId, int time) {
this.tweetId = tweetId;
this.time = time;
}
public int getTweetId() {
return tweetId;
}
public int getTime() {
return time;
}
public Node getPrev() {
return prev;
}
public void setPrev(Node prev) {
this.prev = prev;
}
public Node getNext() {
return next;
}
public void setNext(Node next) {
this.next = next;
}
}
private static final int MAX_RECENT = 10;
private int time = 0;
private Map<Integer, DoublyLinkedList> userTweetsMap;
private Map<Integer, Set<Integer>> followeeMap;
public Twitter() {
time = 0;
userTweetsMap = new HashMap<Integer, DoublyLinkedList>();
followeeMap = new HashMap<Integer, Set<Integer>>();
}
public void postTweet(int userId, int tweetId) {
time++;
userTweetsMap.putIfAbsent(userId, new DoublyLinkedList());
userTweetsMap.get(userId).addNode(new Node(tweetId, time));
}
public List<Integer> getNewsFeed(int userId) {
List<Integer> mostRecent = new ArrayList<Integer>();
PriorityQueue<Node> pq = new PriorityQueue<Node>((a, b) -> b.getTime() - a.getTime());
if (userTweetsMap.containsKey(userId)) {
userTweetsMap.putIfAbsent(userId, new DoublyLinkedList());
DoublyLinkedList list = userTweetsMap.get(userId);
if (list.getSize() > 0) {
Node node = list.getHead();
pq.offer(node);
}
}
Set<Integer> followees = followeeMap.getOrDefault(userId, new HashSet<Integer>());
for (int followee : followees) {
userTweetsMap.putIfAbsent(followee, new DoublyLinkedList());
DoublyLinkedList list = userTweetsMap.get(followee);
if (list.getSize() > 0) {
Node node = list.getHead();
pq.offer(node);
}
}
for (int i = 0; i < MAX_RECENT && !pq.isEmpty(); i++) {
Node node = pq.poll();
mostRecent.add(node.getTweetId());
Node prev = node.getPrev();
if (prev.getTweetId() >= 0) {
pq.offer(prev);
}
}
return mostRecent;
}
public void follow(int followerId, int followeeId) {
followeeMap.putIfAbsent(followerId, new HashSet<Integer>());
followeeMap.get(followerId).add(followeeId);
}
public void unfollow(int followerId, int followeeId) {
followeeMap.putIfAbsent(followerId, new HashSet<Integer>());
followeeMap.get(followerId).remove(followeeId);
}
}
复杂度分析
-
时间复杂度:构造方法的时间复杂度是 O ( 1 ) O(1) O(1),创建推文、关注用户和取消关注用户操作的时间复杂度都是 O ( 1 ) O(1) O(1),检索推文操作的时间复杂度是 O ( u log u ) O(u \log u) O(ulogu),其中 u u u 是用户数量。
创建推文操作需要从哈希表中得到用户对应的推文列表,然后在链表头部添加结点,时间复杂度是 O ( 1 ) O(1) O(1)。
关注用户和取消关注用户操作需要从哈希表中得到用户对应的哈希集合,然后在哈希集合中添加或移除元素,时间复杂度是 O ( 1 ) O(1) O(1)。
检索推文操作需要将用户和关注的每个用户对应的推文列表,需要在优先队列中执行 u u u 次添加元素和 10 10 10 次移除元素操作,每次操作的时间复杂度是 O ( log u ) O(\log u) O(logu),因此总时间复杂度是 O ( u log u ) O(u \log u) O(ulogu)。 -
空间复杂度: O ( u 2 + M × u ) O(u^2 + M \times u) O(u2+M×u),其中 u u u 是用户数量, M M M 是检索的最大推文数量, M = 10 M = 10 M=10。需要使用两个哈希表记录每个用户的推文列表和每个用户关注的用户集合,由于每个用户最多需要记录 M M M 条推文,因此用户推文哈希表需要 O ( u + M × u ) O(u + M \times u) O(u+M×u) 的空间,关注用户哈希表需要 O ( u 2 ) O(u^2) O(u2) 的空间(最坏情况下,每个用户都关注全部用户),因此空间复杂度是 O ( u + M × u + u 2 ) = O ( u 2 + M × u ) O(u + M \times u + u^2) = O(u^2 + M \times u) O(u+M×u+u2)=O(u2+M×u)。