Topics › Heaps › Hard Problems

Design Twitter

Medium TUF ID 565 A2Z

Problem

Design a tiny version of Twitter that supports four operations:

Constraints: user IDs up to 500, tweet IDs up to 10^4, at most 3 · 10^4 calls in total. Following is one-directional. “Most recent” means the order in which postTweet was called. Tweet IDs are not increasing, so they cannot be used as timestamps.

Example 1

postTweet(1, 5)
getNewsFeed(1)      -> [5]
follow(1, 2)
postTweet(2, 6)
getNewsFeed(1)      -> [6, 5]     // 6 was posted after 5
unfollow(1, 2)
getNewsFeed(1)      -> [5]        // user 2's tweets disappear from the feed

Example 2

postTweet(1, 10), postTweet(2, 20), follow(1, 2)
getNewsFeed(1)      -> [20, 10]
getNewsFeed(2)      -> [20]       // user 2 does not follow user 1

All three designs implement the same interface, so one test script drives all of them:

interface Twitter {
    void postTweet(int userId, int tweetId);
    List<Integer> getNewsFeed(int userId);
    void follow(int followerId, int followeeId);
    void unfollow(int followerId, int followeeId);
}
static final int FEED_SIZE = 10;

Some decisions are shared by every version:

Approach 1: Brute force, one global log

Intuition. Append every tweet, as {userId, tweetId}, to one global list. The list is in posting order, so reading it backwards visits tweets from newest to oldest. To build a feed, walk backwards and keep each tweet whose author is the user or someone they follow. Stop after 10 matches.

Algorithm.

  1. postTweet: append {userId, tweetId} to log.
  2. getNewsFeed(u): for i from the last index down to 0, while the feed has fewer than 10 items, add log[i].tweetId if log[i].userId is u or is in u’s follow set.
  3. follow / unfollow: add to or remove from the follower’s set (creating it on first use).
static class GlobalLogTwitter implements Twitter {
    private final List<int[]> log = new ArrayList<>();                      // {userId, tweetId}, oldest first
    private final Map<Integer, Set<Integer>> following = new HashMap<>();

    public void postTweet(int userId, int tweetId) {
        log.add(new int[]{userId, tweetId});
    }

    public List<Integer> getNewsFeed(int userId) {
        Set<Integer> followees = following.getOrDefault(userId, Set.of());
        List<Integer> feed = new ArrayList<>();
        for (int i = log.size() - 1; i >= 0 && feed.size() < FEED_SIZE; i--) {
            int[] t = log.get(i);
            if (t[0] == userId || followees.contains(t[0])) feed.add(t[1]);
        }
        return feed;
    }

    public void follow(int followerId, int followeeId) {
        if (followerId != followeeId) following.computeIfAbsent(followerId, k -> new HashSet<>()).add(followeeId);
    }

    public void unfollow(int followerId, int followeeId) {
        Set<Integer> s = following.get(followerId);
        if (s != null) s.remove(followeeId);
    }
}

Complexity. postTweet, follow and unfollow take O(1). getNewsFeed takes O(T) in the worst case, where T is the total number of tweets ever posted. If a user follows nobody active, the scan reads the whole history to find nothing. Space O(T + E) for the log and the E follow edges.

The problem is that the feed’s cost depends on everybody’s tweets, while the answer depends only on the user’s own small circle.

Approach 2: Better, per-user lists and a sort

Intuition. Store tweets per author instead: userId -> list of {time, tweetId}, oldest first. A feed then only touches its sources, meaning the user plus everyone they follow.

One more observation keeps the work small. Only the newest 10 tweets of any single source can make the feed, because if a source’s 11th-newest tweet were in the top 10, that source’s 10 newer tweets would be too, which is 11 tweets. So collect at most the last 10 tweets of each source, sort the candidates by time (newest first), and keep the first 10.

Algorithm.

  1. postTweet: append {clock++, tweetId} to the author’s list.
  2. getNewsFeed(u): build sources as a copy of u’s follow set plus u. For each source, add its last (up to) 10 tweets to candidates. Sort by time, descending, and return the first 10 tweet IDs.
  3. follow / unfollow: as before.
static class SortFeedTwitter implements Twitter {
    private int clock = 0;
    private final Map<Integer, List<int[]>> tweets = new HashMap<>();      // userId -> {time, tweetId}, oldest first
    private final Map<Integer, Set<Integer>> following = new HashMap<>();

    public void postTweet(int userId, int tweetId) {
        tweets.computeIfAbsent(userId, k -> new ArrayList<>()).add(new int[]{clock++, tweetId});
    }

    public List<Integer> getNewsFeed(int userId) {
        Set<Integer> sources = new HashSet<>(following.getOrDefault(userId, Set.of()));
        sources.add(userId);
        List<int[]> candidates = new ArrayList<>();
        for (int u : sources) {
            List<int[]> list = tweets.getOrDefault(u, List.of());
            for (int i = Math.max(0, list.size() - FEED_SIZE); i < list.size(); i++) candidates.add(list.get(i));
        }
        candidates.sort((a, b) -> Integer.compare(b[0], a[0]));            // newest first
        List<Integer> feed = new ArrayList<>();
        for (int i = 0; i < candidates.size() && i < FEED_SIZE; i++) feed.add(candidates.get(i)[1]);
        return feed;
    }

    public void follow(int followerId, int followeeId) {
        if (followerId != followeeId) following.computeIfAbsent(followerId, k -> new HashSet<>()).add(followeeId);
    }

    public void unfollow(int followerId, int followeeId) {
        Set<Integer> s = following.get(followerId);
        if (s != null) s.remove(followeeId);
    }
}

Complexity. getNewsFeed is O(F log F) where F is the number of sources: there are at most 10F candidates to sort. The other operations are O(1). Space O(T + E).

This no longer depends on the total number of tweets. It still sorts up to 10F items to keep only 10, so most of the sorting work is thrown away.

Approach 3: Optimal, k-way merge with a max-heap

Intuition. Each source’s list is already sorted by time, because tweets are appended in posting order. The feed is the first 10 items of the merge of F sorted lists, which is exactly the Merge K Sorted Lists pattern. We can stop as soon as 10 items have come out.

Put each source’s newest tweet into a max-heap ordered by time. The heap top is the newest tweet that has not been taken yet across all sources. Pop it into the feed, then push the next older tweet of the same author, because that tweet is the only new candidate the pop has made eligible. Repeat until the feed has 10 items or the heap is empty.

A heap entry needs to know where it came from, so it stores {time, tweetId, userId, indexInThatUsersList}.

Algorithm.

  1. postTweet: append {clock++, tweetId} to the author’s list.
  2. getNewsFeed(u): build sources (a copy of the follow set, plus u). For each source with tweets, push its last tweet with its index.
  3. While the heap is non-empty and the feed has fewer than 10 items: pop the top, append its tweetId, and if index - 1 >= 0, push that author’s tweet at index - 1.
  4. follow / unfollow: as before.
static class HeapMergeTwitter implements Twitter {
    private int clock = 0;
    private final Map<Integer, List<int[]>> tweets = new HashMap<>();      // userId -> {time, tweetId}, oldest first
    private final Map<Integer, Set<Integer>> following = new HashMap<>();

    public void postTweet(int userId, int tweetId) {
        tweets.computeIfAbsent(userId, k -> new ArrayList<>()).add(new int[]{clock++, tweetId});
    }

    public List<Integer> getNewsFeed(int userId) {
        Set<Integer> sources = new HashSet<>(following.getOrDefault(userId, Set.of()));
        sources.add(userId);
        // heap entry {time, tweetId, userId, indexInThatUsersList}; newest tweet on top
        PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(b[0], a[0]));
        for (int u : sources) {
            List<int[]> list = tweets.get(u);
            if (list == null || list.isEmpty()) continue;
            int last = list.size() - 1;
            pq.add(new int[]{list.get(last)[0], list.get(last)[1], u, last});
        }
        List<Integer> feed = new ArrayList<>();
        while (!pq.isEmpty() && feed.size() < FEED_SIZE) {
            int[] top = pq.poll();
            feed.add(top[1]);
            int prev = top[3] - 1;                                         // next older tweet of the same user
            if (prev >= 0) {
                int[] older = tweets.get(top[2]).get(prev);
                pq.add(new int[]{older[0], older[1], top[2], prev});
            }
        }
        return feed;
    }

    public void follow(int followerId, int followeeId) {
        if (followerId != followeeId) following.computeIfAbsent(followerId, k -> new HashSet<>()).add(followeeId);
    }

    public void unfollow(int followerId, int followeeId) {
        Set<Integer> s = following.get(followerId);
        if (s != null) s.remove(followeeId);
    }
}

Dry run. Posts in order: (1, 101) at time 0, (2, 201) at 1, (3, 301) at 2, (2, 202) at 3, (1, 102) at 4. User 1 follows users 2 and 3. Heap entries are written time:tweetId.

step popped pushed (same author, older) heap afterwards feed
init   newest of users 1, 2, 3 4:102, 3:202, 2:301  
1 4:102 0:101 3:202, 2:301, 0:101 102
2 3:202 1:201 2:301, 1:201, 0:101 102, 202
3 2:301 none (user 3 has no older tweet) 1:201, 0:101 102, 202, 301
4 1:201 none 0:101 …, 201
5 0:101 none empty 102, 202, 301, 201, 101

Complexity. getNewsFeed is O(F log F) to seed the heap, plus at most 10 pops and 10 pushes at O(log F) each. postTweet, follow and unfollow are O(1). Space O(T + E) overall, plus O(F) for the heap during a feed request.

The seeding step could be made O(F) with a bottom-up heapify, but for at most 500 users the difference does not matter. What matters is that the work after seeding is bounded by the feed size, 10, and not by how many tweets exist.

Edge cases and pitfalls