Recommend top-K movies from similarity graph
Company: Google
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: medium
Interview Round: Onsite
## Movie Recommendation: Top K
You are building a simple movie recommendation feature.
### Input
- A set of movies `0..(M-1)`.
- An **undirected** similarity graph `adj`, where `adj[i]` lists movies similar to movie `i`.
- An array `rating[i]` (or `score[i]`) for each movie.
- A starting movie `start` that the user liked.
- A set `watched` of movies the user has already watched.
- An integer `K`.
### Output
Return a list of up to `K` **recommended movie IDs** that the user has **not watched**.
### Ranking rules
1. Prefer movies with **smaller graph distance** (fewer similarity hops) from `start`.
2. If distances tie, prefer **higher `rating`**.
3. If still tied, break ties by **smaller movie ID**.
### Notes / constraints
- The graph can be large; avoid revisiting nodes.
- If fewer than `K` unwatched movies are reachable, return all reachable unwatched movies.
### Follow-up (production thinking)
What potential issues might arise when deploying this in production (e.g., caching, concurrency, hot keys, stale data), and how would you mitigate them?
Quick Answer: This question evaluates understanding of graph traversal and shortest-path distance concepts along with multi-criteria ranking (distance, rating, ID) and efficient exploration of large undirected graphs to avoid revisiting nodes.
Part 1: Recommend top-K movies from similarity graph
Recommend up to **`k`** movies for a user, given a similarity graph between movies and each movie's rating.
## Problem
You are building a movie recommender. Movies form an **undirected similarity graph**: movie `i` is directly similar to every movie listed in `adj[i]`. Each movie also has a numeric rating.
Starting from a movie `start` that the user liked, return the list of recommended movie IDs (most relevant first), choosing them by graph distance, then rating, then ID.
## Function signature
```python
def solution(adj, rating, start, watched, k):
```
## Inputs
- **`adj`** — adjacency list. `adj[i]` is a list of movie IDs directly connected to movie `i`. There are `n = len(adj)` movies, with IDs `0 .. n - 1`.
- **`rating`** — list of length `n`; `rating[i]` is the rating of movie `i`.
- **`start`** — ID of the movie the user liked (the starting node).
- **`watched`** — list of movie IDs the user has already watched.
- **`k`** — the maximum number of recommendations to return.
## Output
Return a **list of up to `k` movie IDs** in recommendation order.
## Eligibility
A movie may be recommended only if **all** of the following hold:
- it is **reachable** from `start` in the graph, **and**
- it is **not** in `watched`, **and**
- it is **not** the `start` movie itself.
## Ranking
Order all eligible movies by these tie-breakers, in priority order:
1. **Smaller graph distance** from `start` comes first (distance = number of edges on the shortest path).
2. If distances tie, **higher rating** comes first.
3. If both distance and rating tie, **smaller movie ID** comes first.
If fewer than `k` eligible movies are reachable, return **all** of them in this order.
## Examples
**Example 1**
```
adj = [[1, 2], [0, 3], [0, 3, 4], [1, 2], [2]]
rating = [5, 7, 6, 9, 8]
start = 0
watched = [0, 2]
k = 3
=> [1, 3, 4]
```
Movie `2` is excluded (in `watched`) and `0` is the start. Movie `1` is at distance 1; movies `3` and `4` are at distance 2 (so `1` comes before them). Among the distance-2 movies, `3` (rating 9) ranks above `4` (rating 8).
**Example 2**
```
adj = [[1, 2], [0, 3], [0, 4], [1], [2]]
rating = [1, 8, 8, 7, 9]
start = 0
watched = [0]
k = 4
=> [1, 2, 4, 3]
```
Movies `1` and `2` are at distance 1 with equal ratings (8 and 8), so the smaller ID `1` comes first. Movies `3` and `4` are at distance 2; `4` (rating 9) outranks `3` (rating 7).
## Notes and edge cases
- If `k <= 0`, or if `start` is outside the range `[0, n - 1]`, return an empty list.
- The graph is undirected, but the input may contain **repeated/duplicate edges**; make sure you do not revisit nodes (avoid infinite loops).
- `watched` may or may not contain `start`; either way, `start` must never be recommended.
## Constraints
- `1 <= len(adj) == len(rating) <= 100000`
- `0 <= start < len(adj)`
- `0 <= k <= len(adj)`
- Each neighbor in `adj[i]` is a valid movie ID in the range `[0, len(adj) - 1]`
Constraints
- 1 <= len(adj) == len(rating) <= 100000
- 0 <= start < len(adj)
- 0 <= k <= len(adj)
- Each neighbor in adj[i] is a valid movie ID in the range [0, len(adj) - 1]
- The graph is undirected, but the input may still contain repeated edges; do not revisit nodes infinitely
- watched may or may not contain start, but start must never be recommended
Examples
Input: ([[1, 2], [0, 3], [0, 3, 4], [1, 2], [2]], [5, 7, 6, 9, 8], 0, [0, 2], 3)
Expected Output: [1, 3, 4]
Explanation: Movie 1 is distance 1 and unwatched. Movies 3 and 4 are distance 2. Among distance-2 movies, rating 9 beats rating 8.
Input: ([[1, 2], [0, 3], [0, 4], [1], [2]], [1, 8, 8, 7, 9], 0, [0], 4)
Expected Output: [1, 2, 4, 3]
Explanation: Movies 1 and 2 are both distance 1 with equal rating, so smaller ID 1 comes first. At distance 2, movie 4 has higher rating than movie 3.
Input: ([[1], [0], []], [3, 4, 5], 0, [0], 5)
Expected Output: [1]
Explanation: Only movie 1 is reachable and unwatched. Movie 2 is disconnected.
Input: ([[]], [10], 0, [], 1)
Expected Output: []
Explanation: Edge case: there is only the start movie, and the start movie itself cannot be recommended.
Hints
- Use BFS because every edge has the same cost, so BFS gives the shortest hop distance.
- Process one BFS layer at a time. All movies found in the same layer have the same distance, so you only need to sort that layer by rating and movie ID.
Part 2: Classify stale and hot cache keys for movie recommendations
Classify every starting movie in a recommendation cache as **stale**, **hot**, both, or neither, and return one action per movie.
## Background
A production recommendation service caches results keyed by a **starting movie ID**. Each cached entry was computed from a list of **dependency movie IDs** that influenced that recommendation. If any of those dependency movies has changed, the cached result is **stale** and must be recomputed.
A starting movie is **hot** if it appears frequently in recent requests, and hot keys should be protected with mitigations such as request coalescing or per-key throttling.
## Task
Implement:
```python
def solution(requests, cache_entries, updated_movies, hot_threshold):
```
### Parameters
- **`requests`** — a list of integer movie IDs representing recent requests. A movie may appear multiple times.
- **`cache_entries`** — a dict mapping each cached **starting movie ID** to a list of its **dependency movie IDs**. A dependency list may be empty.
- **`updated_movies`** — a list of integer movie IDs that have changed.
- **`hot_threshold`** — an integer.
### Which movies to classify
Produce an action for **every** starting movie that appears in **either**:
- the `requests` list, **or**
- the keys of `cache_entries`.
(A movie may appear in only one of these. A movie present in `requests` but absent from `cache_entries` simply has no dependencies.)
### Classification rules
For each such movie, determine two independent flags:
- **stale** — `True` if **at least one** of the movie's dependency IDs (from its `cache_entries` list) is in `updated_movies`. A movie with **no cache entry** or an **empty** dependency list is **not** stale.
- **hot** — `True` if the movie's number of occurrences in `requests` is **greater than or equal to** `hot_threshold`. A movie not present in `requests` has a count of `0`.
Map the two flags to an action:
| stale | hot | action |
|-------|-----|--------|
| yes | yes | `recompute_and_protect` |
| yes | no | `recompute` |
| no | yes | `protect_hot_key` |
| no | no | `ok` |
## Output
Return a list of strings, one per classified movie, each formatted as:
```
"movie_id:action"
```
The list must be **sorted by `movie_id` in ascending order**.
## Notes
- Movie IDs are non-negative integers.
- If no movie qualifies (both `requests` and `cache_entries` are empty), return an empty list.
### Example
```
requests = [1, 2, 1, 3, 1, 2]
cache_entries = {1: [1, 4, 5], 2: [2, 7], 4: [8]}
updated_movies = [5, 7]
hot_threshold = 3
-> ["1:recompute_and_protect", "2:recompute", "3:ok", "4:ok"]
```
- Movie `1`: appears 3 times (≥ 3 → hot); dependency `5` is in `updated_movies` (stale) → `recompute_and_protect`.
- Movie `2`: appears 2 times (< 3 → not hot); dependency `7` is updated (stale) → `recompute`.
- Movie `3`: appears once (not hot) and has no cache entry (not stale) → `ok`.
- Movie `4`: never requested (not hot); dependency `8` is not updated (not stale) → `ok`.
Constraints
- 0 <= len(requests) <= 200000
- 0 <= sum(len(v) for v in cache_entries.values()) <= 200000
- 1 <= hot_threshold <= 1000000000
- Movie IDs are non-negative integers
- cache_entries may contain keys with empty dependency lists
Examples
Input: ([1, 2, 1, 3, 1, 2], {1: [1, 4, 5], 2: [2, 7], 4: [8]}, [5, 7], 3)
Expected Output: ["1:recompute_and_protect", "2:recompute", "3:ok", "4:ok"]
Explanation: Key 1 is requested 3 times and depends on updated movie 5, so it is both hot and stale. Key 2 is stale only. Key 3 is requested but not hot. Key 4 is cached but unaffected.
Input: ([5, 5, 5, 6], {2: [1], 5: [9], 6: [10]}, [11], 2)
Expected Output: ["2:ok", "5:protect_hot_key", "6:ok"]
Explanation: No cache entry is stale because updated movie 11 is not in any dependency list. Key 5 is hot because it appears 3 times, which is at least 2.
Input: ([], {1: [2, 3], 2: [], 3: [4]}, [3, 4], 1)
Expected Output: ["1:recompute", "2:ok", "3:recompute"]
Explanation: There are no requests, so nothing is hot. Keys 1 and 3 are stale because their dependency lists contain updated movies.
Input: ([], {}, [], 2)
Expected Output: []
Explanation: Edge case: no requests and no cached keys, so there is nothing to classify.
Hints
- First count how many times each movie ID appears in requests to find hot keys.
- Convert updated_movies to a set so you can test whether a cache entry is stale by scanning its dependency list once.