Problem overview
The class Twitter supports four operations: postTweet(userId, tweetId), follow(followerId, followeeId), unfollow(followerId, followeeId) and getNewsFeed(userId). A user's news feed is the ten most recent tweets posted by the user and by everyone the user follows, newest first. A feed with fewer than ten tweets returns them all, and an empty feed returns an empty array.
Following goes one way only. Following someone you already follow, following yourself and unfollowing someone you don't follow all change nothing, and your own tweets are always in your feed.
Here is the first example. User 1 posts tweet 5, and the feed is [5]. User 1 follows user 2, who posts tweet 6, and the feed becomes [6, 5], because 6 is newer. After unfollow(1, 2) the feed is [5] again. The statement asks for O(1) time in postTweet, follow and unfollow, and O(F log F) in getNewsFeed, where F is the number of people the user follows.
Approach: a clock, one list per user, and a merge
The simple design stores every tweet in one big array. Then getNewsFeed filters it for the user's people and takes the last ten. That is easy, but each feed request walks through every tweet ever posted, so the cost grows with the whole site instead of with the user.
A better design splits the data into three parts:
- a global clock
timethat counts every tweet posted. Tweet ids say nothing about age. In the second example, tweet 106 is newer than tweet 201, so each tweet is stored with its time; tweets, a dictionary from a user id to that user's tweets as(time, tweetId)pairs. Times only grow, so each list is already sorted from oldest to newest, and the newest tweet is at the end;following, a dictionary from a user id to theSetof ids that user follows. A set ignores a repeated follow, and adds and removes inO(1).
With that, postTweet, follow and unfollow are one dictionary update each.
The feed is a merge of sorted lists. Each person's tweets are sorted by time, so the newest tweet of the feed is the newest of the people's last tweets. Put those last tweets, one per person, in a max-heap ordered by time. Take the top: it is the newest tweet in the feed. Then put that person's previous tweet into the heap, since it is now their newest tweet not shown yet. Repeat until there are ten tweets or the heap is empty.
The heap never holds more than one entry per person, so there are at most F + 1 entries (the user plus the people followed). Building it and taking up to ten tweets out costs O(F log F) overall, whatever the number of tweets posted.
Step by step
- Declare the clock
timeas avarset to 0. - Declare
tweetsas an empty[Int: [(Int, Int)]]. A user gets a list only when they first post. - Declare
followingas an empty[Int: Set<Int>]. - In
postTweet, add 1 totimeso every tweet gets its own, larger time. - Append
(time, tweetId)totweets[userId, default: []]. Thedefault: []gives a new user an empty array to append to. - In
getNewsFeed, startusersas an array holdinguserId, because your own tweets are always in your feed. - Append the followees with
append(contentsOf:)whenfollowing[userId]exists.follownever stores a user following themselves, so nobody appears twice. - Create the heap
newestas aMaxHeap<[Int]>with the comparison{ $0[0] < $1[0] }. The lesson providesMaxHeap, which takes a closure that says when one element is smaller than another. Each entry is[time, user, index]: the tweet's time, its author, and its position in that author's array. It must be avar. - Loop over
users. - Skip people who never posted with
if let userTweets = tweets[user]. - Compute
index, the last position:userTweets.count - 1. - Insert the entry
[userTweets[index].0, user, index]. Only one tweet per person goes in at first. - Declare
feedas an empty[Int]. - Loop while
newestisn't empty andfeed.count < 10. - Extract the top entry into
top. - Read the user and the index from
top[1]andtop[2]. - Append the tweet's id,
tweets[user]![index].1, tofeed. - If
index > 0, insert that person's previous tweet withindex - 1. It takes their place in the heap. - Return
feed. - In
follow, return at once iffollowerId == followeeId. - Insert
followeeIdintofollowing[followerId, default: []]. - In
unfollow, removefolloweeIdfromfollowing[followerId]using?., which does nothing if the user follows nobody.
The solution in Swift
class Twitter {
var time = 0
var tweets = [Int: [(Int, Int)]]()
var following = [Int: Set<Int>]()
func postTweet(_ userId: Int, _ tweetId: Int) {
time += 1
tweets[userId, default: []].append((time, tweetId))
}
func getNewsFeed(_ userId: Int) -> [Int] {
var users = [userId]
if let followees = following[userId] {
users.append(contentsOf: followees)
}
var newest = MaxHeap<[Int]>({ $0[0] < $1[0] })
for user in users {
if let userTweets = tweets[user] {
let index = userTweets.count - 1
newest.insert([userTweets[index].0, user, index])
}
}
var feed = [Int]()
while !newest.isEmpty && feed.count < 10 {
let top = newest.extract()
let user = top[1], index = top[2]
feed.append(tweets[user]![index].1)
if index > 0 {
newest.insert([tweets[user]![index - 1].0, user, index - 1])
}
}
return feed
}
func follow(_ followerId: Int, _ followeeId: Int) {
if followerId == followeeId {
return
}
following[followerId, default: []].insert(followeeId)
}
func unfollow(_ followerId: Int, _ followeeId: Int) {
following[followerId]?.remove(followeeId)
}
}
The Swift details worth a closer look:
default:in a subscript.tweets[userId, default: []].append(...)reads the array, or an empty one if the key is missing, appends to it, and stores the result back. A plaintweets[userId]returns an optional and could not be appended to directly.- Tuples as values.
(Int, Int)is a tuple..0is the time and.1is the tweet id. The heap holds plain[Int]entries and compares their first number, so no comparison of tuples is needed. - The heap's closure.
{ $0[0] < $1[0] }uses the shorthand argument names. Times are all different, so ties don't happen; the user and index only say where the tweet lives. if let.if let followees = following[userId]runs its block only when the dictionary holds a value for the key, and names it. Dictionary lookups always return an optional.!ontweets[user]!. It unwraps the optional and crashes if there is no value. Here it is safe: the entry came from a tweet of that user, so the array exists.- Optional chaining.
following[followerId]?.remove(followeeId)does nothing when there is no set.Set.removereturns the removed element ornil, and the line ignores it. Removing a value that isn't there is also harmless.
Complexity
Time. postTweet is one counter update and one append, O(1) amortized. follow and unfollow are one set insert or remove, O(1) on average. getNewsFeed inserts at most F + 1 entries and then does at most ten extracts and ten inserts, each on a heap of at most F + 1 entries, so it takes O(F log F).
Space. O(T + E) for the stored data, where T is the number of tweets and E the number of follow relations, plus O(F) for one feed's heap.
Edge cases and common mistakes
- Using the tweet id to order tweets. Ids are arbitrary. With user 1 posting 101 to 106 and user 2 posting 201 to 206 in turns, the feed of user 1 is
[206, 106, 205, 105, 204, 104, 203, 103, 202, 102], ordered by when the tweets were posted. The clock stores 101 at time 1 and 201 at time 2. - More than ten tweets. The same example has twelve tweets in user 1's feed, and only the ten newest are returned. User 2 doesn't follow user 1, so their feed is
[206, 205, 204, 203, 202, 201]. - An empty feed. If nobody in the feed has posted,
getNewsFeedreturns[]. The loop overusersskips people without tweets, and thewhileloop never runs. - Following yourself. Without the early return in
follow, a user would be inuserstwice and every one of their tweets would appear twice. With it,follow(3, 3)followed bypostTweet(3, 7)gives the feed[7]. - Unfollowing someone you don't follow. It is a no-op. After
unfollow(3, 4)and thenfollow(3, 4), user 3 follows user 4 even before user 4 has posted. Once user 4 posts tweet 8, the feed of user 3 is[8, 7]. - Old tweets of a new followee. Following shows the followee's tweets from before, too. The lists are kept per user, so nothing needs copying when you follow.
- Following twice. A set stores the id once. With users 9 and 8 followed by user 1 (9 twice), the feed contains each tweet once and
following[1]holds two ids. - Collecting everything. Gathering all tweets of all followees and sorting them works for the examples, but takes time proportional to the number of tweets each time.
Testing
postTweet(1, 5), feed of 1 gives[5];follow(1, 2),postTweet(2, 6), feed of 1 gives[6, 5];unfollow(1, 2), feed of 1 gives[5].- On a new
Twitter,getNewsFeed(3)gives[]. - On a new
Twitter,postTweet(9, 40),postTweet(8, 30)andpostTweet(9, 10), then user 1 follows 9, 8 and 9 again: the feed of user 1 is[10, 30, 40].
