LeetCode 355 design-twitter
题目
355.设计推特
设计一个简化版的推特(Twitter),可以让用户实现发送推文,关注/取消关注其他用户,能够看见关注人(包括自己)的最近十条推文。你的设计需要支持以下的几个功能:
postTweet(userId, tweetId): 创建一条新的推文
getNewsFeed(userId): 检索最近的十条推文。每个推文都必须是由此用户关注的人或者是用户自己发出的。推文必须按照时间顺序由最近的开始排序。
follow(followerId, followeeId): 关注一个用户
unfollow(followerId, followeeId): 取消关注一个用户
全文目录
- 设计思路
1.1 数据结构设计
1.2 业务逻辑的处理
1.3 其他代码 - 测试验证
2.1 硬编码测试用例
2.1.1 运行推特
2.1.2 使用用例测试推特
2.2 从文件中加载测试用例
2.3 数据结构转换的代码 - 完整题解代码
- 完整的启动推特和测试推特代码
4.1 启动推特
4.2 加载用例
4.3 测试推特附录
[TOC]
04.15 更新说明
之前采用ArrayList<TwitterUser>
结构存储用户,采用LinkedList<Integer>
存储用户的关注列表和粉丝列表
这种结构有缺陷:
每次查找用户的时间复杂度为O(N), N为总用户数;
在拉取推文信息流时,判断每个推文是否符合要求,都需要遍历"我关注的人"列表,时间复杂度为O(m), M为我关注的人数
本次相应地改为HashMap<Integer, TwitterUser>
和HashSet<Integer>
,以降低这部分时间消耗
1. 设计思路
1.1 数据结构设计
- 自定义数据结构,用户表达推文
public class Tweet {
private Integer id;
private Integer userId;
// private Date postTime; // 实际上可以用整数替代,而本解法中,推文按照时间顺序排序,也并不需要记录发文时间
// 省略构造函数与getter/setter
}
-
自定义数据结构,用于表达用户的关注、被关注关系
其中,一个列表记录当前用户关注的人ID;一个列表记录 当前用户被关注人的ID
*注意,用户之间的关注关系会频繁变动,需尽量保证查找和删除的时间复杂度最低,所以会采用 HashSet结构
public class TwitterUser {
private Integer id;
private Set<Integer> fans; // 关注我的人
private Set<Integer> follower; // 我关注的人
// 省略构造函数与getter/setter
}
-
存储全部推文、用户关系
为了便于取时间最近的10条推文,我们考虑将推文按发文时间存储,所以使用链表结构
而进程需要查找用户,所以用Map结构,用户ID作为key
public List<Tweet> allTweets; // 所有推文
public Map<Integer, TwitterUser> allUsers; // 所有用户
/** Initialize your data structure here. */
public Twitter() {
allTweets = new LinkedList<>();
allUsers = new HashMap<>(); // 所有用户
}
1.2 业务逻辑的处理
-
用户注册
由于本题目本身的缺陷,并没有注册用户的入口
在尝试提交结果的过程中,我发现,所有的用户都存在,换而言之,只要用户不存在,那就添加到用户列表
public void signUpIfNotExisted(int userId) {
if(allUsers.containsKey(userId)) return;
Set<Integer> fans = new HashSet<>();
Set<Integer> follower = new HashSet<>();
allUsers.put(userId, new TwitterUser(userId, fans, follower));
}
-
用户发推文
为了便于去除最近发布的10条推文,我们可以在存储推文时,就按时间顺序存储
public void postTweet(int userId, int tweetId) {
signUpIfNotExisted(userId);
Tweet tweet = new Tweet(tweetId, userId);
allTweets.add(0, tweet);
}
-
关注他人
按照我们设计的数据结构,需要更新 当前用户的“我关注的人”列表,以及 被关注人的“关注我的人”列表
注意:不能关注自己
public void follow(int followerId, int followeeId) { // 用户followerId关注了用户followeeId.
if(followerId==followeeId) return;
signUpIfNotExisted(followerId, followeeId);
TwitterUser user = allUsers.get(followerId); // 当前用户
TwitterUser userFollowed = allUsers.get(followeeId); // 被关注的人
// 更新 followerId"我关注的人"列表
HashSet<Integer> userFollow = (HashSet<Integer>) user.getFollower();
userFollow.add(followeeId);
user.setFollower(userFollow);
// 更新 followeeId"关注我的人"列表
HashSet<Integer> fans = (HashSet<Integer>) userFollowed.getFans();
fans.add(followerId);
userFollowed.setFans(fans);
}
-
取消关注
按照我们设计的数据结构,需要更新 当前用户的“我关注的人”列表,以及 被关注人的“关注我的人”列表
注意:不能取消关注自己
public void unfollow(int followerId, int followeeId) {
if(followerId==followeeId) return;
signUpIfNotExisted(followerId, followeeId);
TwitterUser user = allUsers.get(followerId); // 当前用户
TwitterUser userFollowed = allUsers.get(followeeId); // 被关注的人
// 更新 followerId"我关注的人"列表
HashSet<Integer> userFollow = (HashSet<Integer>) user.getFollower();
userFollow.remove(followeeId);
user.setFollower(userFollow);
// 更新 followeeId"关注我的人"列表
HashSet<Integer> fans = (HashSet<Integer>) userFollowed.getFans();
fans.remove(followerId);
userFollowed.setFans(fans);
}
-
获取信息流
按照题目要求,信息流中,最多只能有10条推文,且按时间倒序,且来自自己或关注人所发推文
由于我们的推文已经按照时间倒序存储,可以很便捷地从前往后遍历,取出符合要求的10条推文即可。
具体的要求就是:推文的发布者ID是当前用户ID,或者是当前用户关注任意人的ID
public List<Integer> getNewsFeed(int userId) {
signUpIfNotExisted(userId);
TwitterUser user = allUsers.get(userId);
HashSet<Integer> hisFollowerId = (HashSet<Integer>) user.getFollower();
List<Integer> allTweetIdInFeed = new ArrayList<>(10); // 信息流中的推文ID
for(Tweet tweet: allTweets){
Integer tweetUserId = tweet.getUserId();
if(hisFollowerId.contains(tweetUserId) || tweetUserId==userId){
allTweetIdInFeed.add(tweet.getId());
}
if(allTweetIdInFeed.size()>=10) return allTweetIdInFeed;
}
return allTweetIdInFeed;
}
1.3 其他代码
04.15更新:先前的版本,以列表结构存储用户信息,所以需要写函数查询、删除用户,现在已经不需要
// 根据ID查找用户
public TwitterUser findByUserId(List<TwitterUser> allUsers, Integer userId){
for(TwitterUser user: allUsers){
if(user.getId().equals(userId)) return user;
}
return null;
}
public boolean removeUserById(List<Integer> userIds, Integer userId){
for(Integer id: userIds){
if(id.equals(userId)) return userIds.remove(id);
// if(id==userId) return userIds.remove((Integer)id);
// if(id==userId) return userIds.remove(new Integer(id));
}
return false;
}
2. 测试验证
由于本题的设计缺陷,并未提供输入输出描述。所以,最开始,我的测试代码是这样的:
@Test
public void testCase1() {
Twitter twitter = new Twitter();
// 用户1发送了一条新推文 (用户id = 1, 推文id = 5).
twitter.postTweet(1, 5);
Assert.assertEquals(1, twitter.allTweets.size());
Assert.assertEquals(1, twitter.allTweets.get(0).getUserId());
Assert.assertEquals(5, twitter.allTweets.get(0).getId());
// 用户1的获取推文应当返回一个列表,其中包含一个id为5的推文.
List<Integer> newsFeed1 = twitter.getNewsFeed(1);
List<Integer> newsFeed1ans = Arrays.asList(new Integer[] {5});
Assert.assertEquals(newsFeed1ans, newsFeed1);
// 用户1关注了用户2.
twitter.follow(1, 2);
TwitterUser user = twitter.findByUserId(twitter.allUsers, 1);
TwitterUser userFollowed = twitter.findByUserId(twitter.allUsers, 2);
Assert.assertNotNull(user);
Assert.assertTrue(user.getFollower().contains(2));
Assert.assertNotNull(userFollowed);
Assert.assertTrue(userFollowed.getFans().contains(1));
// 用户2发送了一个新推文 (推文id = 6).
twitter.postTweet(2, 6);
Assert.assertEquals(2, twitter.allTweets.size());
Assert.assertEquals(2, twitter.allTweets.get(0).getUserId());
Assert.assertEquals(6, twitter.allTweets.get(0).getId());
// 用户1的获取推文应当返回一个列表,其中包含两个推文,id分别为 -> [6, 5].
// 推文id6应当在推文id5之前,因为它是在5之后发送的.
List<Integer> newsFeed2 = twitter.getNewsFeed(1);
List<Integer> newsFeed2ans = Arrays.asList(new Integer[] {6, 5});
Assert.assertEquals(newsFeed2ans, newsFeed2);
// 用户1取消关注了用户2.
twitter.unfollow(1, 2);
user = twitter.findByUserId(twitter.allUsers, 1);
userFollowed = twitter.findByUserId(twitter.allUsers, 2);
Assert.assertFalse(user.getFollower().contains(2));
Assert.assertFalse(userFollowed.getFans().contains(1));
// 用户1的获取推文应当返回一个列表,其中包含一个id为5的推文.
// 因为用户1已经不再关注用户2.
List<Integer> newsFeed3 = twitter.getNewsFeed(1);
List<Integer> newsFeed3ans = Arrays.asList(new Integer[] {5});
Assert.assertEquals(newsFeed3ans, newsFeed3);
}
2.1 硬编码测试用例
2.1.1 运行推特
提交一次报错之后,在保存详情中,我看到了输入输出结构:
输入
["Twitter","postTweet","getNewsFeed","follow","postTweet","getNewsFeed","unfollow","getNewsFeed"]
[[],[1,5],[1],[1,2],[2,6],[1],[1,2],[1]]
输出
[null,null,[5],null,null,[6,5],null,[5]]
于是才想到,可以用 switch-case 的方式,来根据指令运行推特,代码大意如下:
switch (op) {
case "Twitter":
twitter = new Twitter(); // 调用对应操作
outputs.add(null); // 记录该操作的输出
break;
case "postTweet":
twitter.postTweet(params[0], params[1]);
outputs.add(null);
break;
case "getNewsFeed":
List<Integer> newsFeed = twitter.getNewsFeed(params[0]);
outputs.add(newsFeed);
break;
case "follow":
twitter.follow(params[0], params[1]);
outputs.add(null);
break;
case "unfollow":
twitter.unfollow(params[0], params[1]);
outputs.add(null);
break;
}
2.1.2 使用用例测试推特
那么对应的单元测试可以写为:
@Test
public void run1() {
DesignTwitter ins = new DesignTwitter();
// 输入:操作指令
String[] operations1 = new String[] {"Twitter", "postTweet", "getNewsFeed", "follow", "postTweet", "getNewsFeed", "unfollow", "getNewsFeed"};
// 输入:各个函数的入参
Integer[][] values1 = new Integer[][] {{}, {1, 5}, {1}, {1, 2}, {2, 6}, {1}, {1, 2}, {1}};
List<List<Integer>> valuesList1 = convert2DArrayTo2DList(values1); // 二位数组转成 元素为列表的列表
// 输出用例
Integer[][] outputsAns1 = new Integer[][] {null, null, {5}, null, null, {6, 5}, null, {5}};
List<List<Integer>> outputsAnsList1 = convert2DArrayTo2DList(outputsAns1);
List<List<Integer>> outputsList1 = ins.run(operations1, values1); // 为了便于断言时比较,返回值设计为List
Assert.assertEquals(outputsAnsList1, outputsList1);
}
2.2 从文件中加载测试用例
但是,在基本确定代码之后,我仅通过一部分用例,而在一个非常大的用例上,出现空指针异常。
由于该用例非常大,不便甚至不能在代码中硬编码用例,只能通过输入获得。
于是设计了下面的代码从文件中加载用例
- 自定义类,用于表达输入测试用例
class InputTestCase {
public String[] operations;
public Integer[][] values;
public TestCase(String[] operations, Integer[][] values) {
this.operations = operations;
this.values = values;
}
}
-
读取文件
测试用例的输入分为两行,第一行是操作名,第二行是对应操作的入参值
public class TestCaseLoader {
// 加载输入用例
public InputTestCase loadInput(String filepath) throws IOException {
File testfile = new File(filepath);
FileReader fileReader = new FileReader(testfile.getCanonicalPath());
BufferedReader reader = new BufferedReader(fileReader);
String line1 = reader.readLine();
String[] operations = parseStringArray(line1);
String line2 = reader.readLine();
Integer[][] values = parse2DIntegerArray(line2);
return new InputTestCase(operations, values);
}
// 加载输出用例
public Integer[][] loadOutput(String filepath) throws IOException {
File testfile = new File(filepath);
FileReader fileReader = new FileReader(testfile.getCanonicalPath());
BufferedReader reader = new BufferedReader(fileReader);
String line = reader.readLine();
Integer[][] value = parse2DIntegerArray(line);
return value;
}
// 输入形式为:字符串数组
String[] parseStringArray(String line){
JSONArray jsonArray1 = JSONArray.parseArray(line);
String[] strArray = jsonArray1.toArray(new String[]{});
return strArray;
}
// 输入形式为:二维整数数组
Integer[][] parse2DIntegerArray(String line){
JSONArray jsonArray2 = JSONArray.parseArray(line);
JSONArray[] jsonArray2Tmp = jsonArray2.toArray(new JSONArray[]{});
Integer[][] values = new Integer[jsonArray2Tmp.length][];
for(int i=0; i< jsonArray2Tmp.length; i++){
JSONArray intArrTmp = jsonArray2Tmp[i];
if(intArrTmp==null) {
values[i] = null;
}else {
values[i] = intArrTmp.toArray(new Integer[] {});
}
}
return values;
}
}
- 从文件中加载用例的单元测试写法
@Test
public void run5() throws IOException {
TestCaseLoader loader = new TestCaseLoader();
String inputFile = "src/main/resource/design_twitter/62405019_input.txt"; // 输入用例的路径
String outputFile = "src/main/resource/design_twitter/62405019_output.txt";
InputTestCase case1 = loader.loadInput(inputFile);
Integer[][] outputsAns1 = loader.loadOutput(outputFile);
DesignTwitter ins = new DesignTwitter();
String[] operations1 = case1.operations;
Integer[][] values1 = case1.values;
List<List<Integer>> outputsAnsList1 = convert2DArrayTo2DList(outputsAns1);
List<List<Integer>> outputsList1 = ins.run(operations1, values1);
Assert.assertEquals(outputsAnsList1, outputsList1);
}
2.3 数据结构转换的代码
// 二维数组转二维列表
List<List<Integer>> convert2DArrayTo2DList(Integer[][] array2d) {
List<List<Integer>> list2d = new ArrayList<>();
for (int i = 0; i < array2d.length; i++) {
List<Integer> columnList = new ArrayList<>();
if (array2d[i] == null) {
list2d.add(null);
continue;
}
for (int j = 0; j < array2d[i].length; j++) {
columnList.add(array2d[i][j]);
}
list2d.add(columnList);
}
return list2d;
}
3. 完整题解代码
class Twitter {
public List<Tweet> allTweets; // 所有推文
public Map<Integer, TwitterUser> allUsers; // 所有用户
/** Initialize your data structure here. */
public Twitter() {
allTweets = new LinkedList<>();
allUsers = new HashMap<>(); // 所有用户
}
/** Compose a new tweet. */
public void postTweet(int userId, int tweetId) {
signUpIfNotExisted(userId);
Tweet tweet = new Tweet(tweetId, userId);
allTweets.add(0, tweet);
}
/** Retrieve the 10 most recent tweet ids in the user's news feed. Each item in the news feed must be posted by users who the user followed or by the user herself. Tweets must be ordered from most recent to least recent. */
public List<Integer> getNewsFeed(int userId) {
signUpIfNotExisted(userId);
TwitterUser user = allUsers.get(userId);
HashSet<Integer> hisFollowerId = (HashSet<Integer>) user.getFollower();
List<Integer> allTweetIdInFeed = new ArrayList<>(10); // 信息流中的推文ID
for(Tweet tweet: allTweets){
Integer tweetUserId = tweet.getUserId();
if(hisFollowerId.contains(tweetUserId) || tweetUserId==userId){
allTweetIdInFeed.add(tweet.getId());
}
if(allTweetIdInFeed.size()>=10) return allTweetIdInFeed;
}
return allTweetIdInFeed;
}
/** Follower follows a followee. If the operation is invalid, it should be a no-op. */
public void follow(int followerId, int followeeId) { // 用户followerId关注了用户followeeId.
if(followerId==followeeId) return;
signUpIfNotExisted(followerId, followeeId);
TwitterUser user = allUsers.get(followerId); // 当前用户
TwitterUser userFollowed = allUsers.get(followeeId); // 被关注的人
// 更新 followerId"我关注的人"列表
HashSet<Integer> userFollow = (HashSet<Integer>) user.getFollower();
userFollow.add(followeeId);
user.setFollower(userFollow);
// 更新 followeeId"关注我的人"列表
HashSet<Integer> fans = (HashSet<Integer>) userFollowed.getFans();
fans.add(followerId);
userFollowed.setFans(fans);
}
/** Follower unfollows a followee. If the operation is invalid, it should be a no-op. */
public void unfollow(int followerId, int followeeId) {
if(followerId==followeeId) return;
signUpIfNotExisted(followerId, followeeId);
TwitterUser user = allUsers.get(followerId); // 当前用户
TwitterUser userFollowed = allUsers.get(followeeId); // 被关注的人
// 更新 followerId"我关注的人"列表
HashSet<Integer> userFollow = (HashSet<Integer>) user.getFollower();
userFollow.remove(followeeId);
user.setFollower(userFollow);
// 更新 followeeId"关注我的人"列表
HashSet<Integer> fans = (HashSet<Integer>) userFollowed.getFans();
fans.remove(followerId);
userFollowed.setFans(fans);
}
public void signUpIfNotExisted(int userId) {
if(allUsers.containsKey(userId)) return;
Set<Integer> fans = new HashSet<>();
Set<Integer> follower = new HashSet<>();
allUsers.put(userId, new TwitterUser(userId, fans, follower));
}
public void signUpIfNotExisted(int... userIdList) {
for(int userId: userIdList) {
if (allUsers.containsKey(userId))
continue;
Set<Integer> fans = new HashSet<>();
Set<Integer> follower = new HashSet<>();
allUsers.put(userId, new TwitterUser(userId, fans, follower));
}
}
}
/* 自定义用户类 */
class TwitterUser {
private Integer id;
private Set<Integer> fans; // 关注我的人
private Set<Integer> follower; // 我关注的人
public TwitterUser(Integer id, Set<Integer> fans, Set<Integer> follower) {
this.id = id;
this.fans = fans;
this.follower = follower;
}
public Integer getId() {
return id;
}
public Set<Integer> getFans() {
return fans;
}
public Set<Integer> getFollower() {
return follower;
}
public void setId(Integer id) {
this.id = id;
}
public void setFans(Set<Integer> fans) {
this.fans = fans;
}
public void setFollower(Set<Integer> follower) {
this.follower = follower;
}
}
/* 自定义 推文结构 */
class Tweet {
private Integer id;
private Integer userId;
public Tweet(Integer id, Integer userId) {
this.id = id;
this.userId = userId;
}
public void setId(Integer id) {
this.id = id;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getId() {
return id;
}
public Integer getUserId() {
return userId;
}
}
4. 完整的启动推特和测试推特代码
4.1 启动推特
public class TwitterStarter {
Twitter twitter;
List<List<Integer>> run(String[] operations, Integer[][] values){
List<List<Integer>> outputs = new ArrayList<>();
for(int i=0; i< operations.length; i++){
String op = operations[i];
Integer[] params =values[i];
switch (op) {
case "Twitter":
twitter = new Twitter();
outputs.add(null);
break;
case "postTweet":
twitter.postTweet(params[0], params[1]);
outputs.add(null);
break;
case "getNewsFeed":
List<Integer> newsFeed = twitter.getNewsFeed(params[0]);
outputs.add(newsFeed);
break;
case "follow":
twitter.follow(params[0], params[1]);
outputs.add(null);
break;
case "unfollow":
twitter.unfollow(params[0], params[1]);
outputs.add(null);
break;
}
}
return outputs;
}
}
4.2 加载用例
public class TestCaseLoader {
public InputTestCase loadInput(String filepath) throws IOException {
File testfile = new File(filepath);
FileReader fileReader = new FileReader(testfile.getCanonicalPath());
BufferedReader reader = new BufferedReader(fileReader);
String line1 = reader.readLine();
String[] operations = parseStringArray(line1);
String line2 = reader.readLine();
Integer[][] values = parse2DIntegerArray(line2);
return new InputTestCase(operations, values);
}
public Integer[][] loadOutput(String filepath) throws IOException {
File testfile = new File(filepath);
FileReader fileReader = new FileReader(testfile.getCanonicalPath());
BufferedReader reader = new BufferedReader(fileReader);
String line = reader.readLine();
Integer[][] value = parse2DIntegerArray(line);
return value;
}
String[] parseStringArray(String line){
JSONArray jsonArray1 = JSONArray.parseArray(line);
String[] strArray = jsonArray1.toArray(new String[]{});
return strArray;
}
Integer[][] parse2DIntegerArray(String line){
JSONArray jsonArray2 = JSONArray.parseArray(line);
JSONArray[] jsonArray2Tmp = jsonArray2.toArray(new JSONArray[]{});
Integer[][] values = new Integer[jsonArray2Tmp.length][];
for(int i=0; i< jsonArray2Tmp.length; i++){
JSONArray intArrTmp = jsonArray2Tmp[i];
if(intArrTmp==null) {
values[i] = null;
}else {
values[i] = intArrTmp.toArray(new Integer[] {});
}
}
return values;
}
}
class InputTestCase {
public String[] operations;
public Integer[][] values;
public InputTestCase(String[] operations, Integer[][] values) {
this.operations = operations;
this.values = values;
}
}
4.3 测试推特
import org.junit.*;
public class TwitterStarterTest {
@Test
public void run5() throws IOException {
TestCaseLoader loader = new TestCaseLoader();
String inputFile = "src/main/resource/design_twitter/62405019_input.txt";
String outputFile = "src/main/resource/design_twitter/62405019_output.txt";
InputTestCase case1 = loader.loadInput(inputFile);
Integer[][] outputsAns1 = loader.loadOutput(outputFile);
DesignTwitter ins = new DesignTwitter();
String[] operations1 = case1.operations;
Integer[][] values1 = case1.values;
List<List<Integer>> outputsAnsList1 = convert2DArrayTo2DList(outputsAns1);
List<List<Integer>> outputsList1 = ins.run(operations1, values1);
Assert.assertEquals(outputsAnsList1, outputsList1);
}
// 二维数组转二维列表
List<List<Integer>> convert2DArrayTo2DList(Integer[][] array2d) {
List<List<Integer>> list2d = new ArrayList<>();
for (int i = 0; i < array2d.length; i++) {
List<Integer> columnList = new ArrayList<>();
if (array2d[i] == null) {
list2d.add(null);
continue;
}
for (int j = 0; j < array2d[i].length; j++) {
// columnList.add(j, array2d[i][j]);
columnList.add(array2d[i][j]);
}
// list2d.add(i, columnList);
list2d.add(columnList);
}
return list2d;
}
}
附录
- 部分测试用例
输入:https://leetcode-cn.com/submissions/detail/62405019/testcase/
输出:用上文中给的单元测试运行一下前面的输入用例,即可得到
-
为什么会出现空指针异常
查找用户的函数,需要比较用户ID,开始是用
==
,在上面这个用例时,有一对取消关注(134,128)会报空指针异常,位置如下:public void unfollow(int followerId, int followeeId) { if(followerId==followeeId) return; signUpIfNotExisted(followerId, followeeId); TwitterUser user = findByUserId(allUsers, followerId); // 当前用户 TwitterUser userFollowed = findByUserId(allUsers, followeeId); // 被关注的人 List<Integer> userFollow = user.getFollower(); // 空指针异常发生位置
由于
signUpIfNotExisted
的存在,显然不可能有"用户不存在"的问题,原因显然是findByUserId
返回值为null
。这才发现,同一个数值可能有多个Integer对象 -
从Integer列表中,根据元素值移除该元素
List
对象有两个重载方法:
删除指定位置的元素:list.remove(int index);
删除指定值的元素:
list.remove(Object obj);
但是,当列表为Integer列表时,JVM可能会疑惑,需要注意。