2.4.3. Questions
2.4.3.1. Smallest Range Covering Elements from K Lists
You have k lists of sorted integers in non-decreasing order. Find the smallest range that includes at least one number from each of the k lists.
Example Input: nums = [[4,10,15,24,26],[0,9,12,20],[5,18,22,30]] Output: [20,24]
This problem can be solved by K-Way Merge. k-way merge algorithms or multiway merges are a specific type of sequence merge algorithms that specialize in taking in k sorted lists and merging them into a single sorted list. These merge algorithms generally refer to merge algorithms that take in a number of sorted lists greater than two. Two-way merges are also referred to as binary merges.
1vector<int> smallestRange(vector<vector<int>>& nums) {
2 using PVI = pair<vector<int>::iterator, vector<int>::iterator>;
3 struct comp { // comparator for min-heap
4 bool operator()(const PVI& p1, const PVI& p2) {
5 return *p1.first > *p2.first;
6 }
7 };
8 priority_queue<PVI, vector<PVI>, comp> pq;
9 int hi = INT_MIN;
10 for (auto &row : nums) {
11 hi = max(hi, row[0]);
12 pq.push({row.begin(), row.end()});
13 }
14 vector<int> ans = {*pq.top().first, hi};
15 while (1) {
16 auto p = pq.top(); pq.pop();
17 ++p.first;
18 if (p.first == p.second) break;
19 pq.push(p);
20 int lo = *pq.top().first;
21 hi = max(hi, *p.first);
22 if (hi - lo < ans[1] - ans[0])
23 ans = {lo, hi};
24 }
25 return ans;
26}
Refer to Section 2.3.1.3 for K-way merge of linked lists.
2.4.3.2. Top K Frequent Elements
Given a non-empty array of integers, return the k most frequent elements.
For example,
Given [1,1,1,2,2,3] and k = 2, return [1,2].
Note:
You may assume k is always valid, 1 ≤ k ≤ number of unique elements.
Your algorithm’s time complexity must be better than O(NlogN), where n is the array’s size.
2.4.3.3. Remove Smallest Peaks in Order
You are given a list of unique integers nums with length of N. Return a new list created by repeatedly removing the smallest peak from nums and appending to a resulting list. A number nums[i] is called a peak if:
nums[i] > nums[i + 1] if i = 0
nums[i - 1] < nums[i] if i = N - 1
nums[i - 1] < nums[i] > nums[i + 1]
(Constraints: 0 ≤ N ≤ 100,000 where N is the length of nums)
Otherwise, if a list has one element, that element is considered to be a peak.
Example:
Input: nums = [3, 5, 1, 4, 2]
Output: [4, 2, 5, 3, 1]
Explanation:
We remove 4 and get [3, 5, 1, 2]
We remove 2 and get [3, 5, 1]
We remove 5 and get [3, 1]
We remove 3 and get [1]
We remove 1 and get []
Other examples:
Input : [3, 5, 1, 4, 2]
Output : [4, 2, 5, 3, 1]
Input : [3]
Output : [3]
Input : [1, 2, 3, 4, 5]
Output : [5, 4, 3, 2, 1]
Input : [5, 4, 3, 2, 1]
Output : [5, 4, 3, 2, 1]
It is easy to find the \(O(N^2)\) solution. To reduce the time complexity, we can use priority_queue. To be more efficient on removing item, we need to construct a doubly linked list
.
The node definition is:
1struct Node { // a min heap node
2 Node *pre = nullptr, *next = nullptr;
3 int key = -1;
4 Node(int key_ = -1) { key = key_; }
5};
6struct comp{ // a min heap comparator
7 bool operator()(const Node* a, const Node* b){return a->key > b->key;}
8};
The algorithm goes like this:
In first for loop, create a doubly linked list and push all peaks into a priority queue.
In the second for loop, pop the minimum heap and remove it from the linked list and add its predecessor or successor if one of them is peak.
1struct RemoveSmallestPeaksInOrder {
2 Node head, tail; // dummy nodes
3 priority_queue<Node*, vector<Node*>, comp> pq; // min heap
4
5 RemoveSmallestPeaksInOrder() { head.next = &tail, tail.pre = &head; }
6 bool is_peak_vec(int idx, const vector<int>& nums) { // O(1)
7 if (idx == 0) return nums[idx] > nums[idx + 1];
8 if (idx == nums.size() - 1) return nums[idx] > nums[idx - 1];
9 return nums[idx] > nums[idx - 1] and nums[idx] > nums[idx + 1];
10 }
11 void remove_dl_node(Node* node) { // O(1)
12 auto pre = node->pre, next = node->next;
13 pre->next = next, next->pre = pre;
14 delete node;
15 }
16 bool is_peak_dl(Node* node) { // is peak in doubly linked list
17 auto pre = node->pre, next = node->next;
18 return (node->key > pre->key and node->key > next->key);
19 }
20 void create_dl(Node* node) { // create a double linked list
21 auto beforeTail = tail.pre;
22 beforeTail->next = node;
23 node->pre = beforeTail;
24 node->next = &tail;
25 tail.pre = node;
26 }
27 vector<int> run(vector<int> nums) { // O(NLogN)
28 int L = nums.size();
29 if (L == 1) return nums;
30 for (int i = 0; i < L; i++) { // push all peaks O(N)
31 Node* node = new Node(nums[i]);
32 create_dl(node); // create doubly linked list
33 if (is_peak_vec(i, nums)) pq.push(node);
34 }
35 vector<int> ans(L);
36 for (int i = 0; i < L; i++) { // O(N)
37 auto min_peak = pq.top();
38 pq.pop();
39 ans[i] = min_peak->key;
40 auto pre = min_peak->pre, next = min_peak->next;
41 remove_dl_node(min_peak);
42 if (pre != &head and is_peak_dl(pre)) pq.push(pre); // O(LogN)
43 if (next != &tail and is_peak_dl(next)) pq.push(next);
44 }
45 return ans;
46 }
47};
2.4.3.4. Minimize Max Distance to Gas Station
On a horizontal number line, we have gas stations at positions stations[0], stations[1], … stations[N-1], where N is the number of the stations. Now, you need to add K more gas stations so that D, the maximum distance between adjacent gas stations, is minimized. Return the smallest possible value of D.
stations = [0, 1, 2, 3, 5]
s[0]---------s[1]---------s[2]----------s[3]------------------------s[4] D
K=2: 0 -----x---- 1 ---------- 2 ----------- 3 ------------x------------ 5 -----> 1
K=3: 0 -----x---- 1 ---------- 2 ----------- 3 ------x----------x------- 5 -----> 1
K=5: 0 -----x---- 1 -----x---- 2 -----x----- 3 ------x----------x------- 5 -----> 2/3
function signature: double minmaxGasDist(vector<int> v, int k);
1#include <gtest/gtest.h>
2#include <sein.hpp>
3
4namespace ns_gas_station {
5struct interval {
6double total, avg;
7int n = 1;
8
9explicit interval(double d) : total(d), n(1), avg(d) {}
10};
11
12struct comp {
13bool operator()(const interval &x, const interval &y) { return x.avg < y.avg; }
14};
15
16double minmaxGasDist(vector<int> v, int k) { // TC: O(N*LogN+k*LogN) -> O(N+K*LogN)
17 priority_queue<interval, vector<interval>, comp> pq;
18 for (int i = 0; i < (int) v.size() - 1; ++i) { // N
19 double ds = (double) (v[i + 1]) - v[i];
20 pq.emplace(ds);
21 }
22 while (k--) {
23 auto tp = pq.top();
24 pq.pop();
25 tp.avg = tp.total / ++tp.n;
26 pq.push(tp);
27 }
28 return pq.top().avg;
29}
30
31double minmaxGasDist_binary_search(vector<int> v, int K) {
32 adjacent_difference(v.begin(), v.end(), v.begin());
33 v.erase(v.begin());
34 int mx = *max_element(v.begin(), v.end());
35 double h = 0, t = (double) mx;
36 while (t - h > 1e-9) { // O(?)
37 double m = h + (t - h) / 2.0;
38 long long required = 0;
39 for (int i: v) // O(N)
40 required += ceil(i / m) - 1;
41 if (required > K) {
42 h = m;
43 } else {
44 t = m;
45 }
46 }
47 return t;
48}
49}
50
51using namespace ns_gas_station;
52TEST(_ns_gas_station, a) {
53 EXPECT_EQ(minmaxGasDist({0, 1, 2, 3, 5}, 1), 1);
54 EXPECT_EQ(minmaxGasDist({0, 1, 2, 3, 5}, 2), 1);
55 EXPECT_EQ(minmaxGasDist({0, 1, 2, 3, 5}, 3), 1);
56 EXPECT_EQ(minmaxGasDist({0, 1, 2, 3, 5}, 4), 1);
57 EXPECT_DOUBLE_EQ(minmaxGasDist({0, 1, 2, 3, 5}, 5), 0.66666666666666663);
58}
2.4.3.5. Customer Total Waiting Time
Calculate the total waiting time for a customer C to speak to an agent given N agents, M customers, and T[] time for an agent to serve a customer. T[i] represents the amount of time it takes for an agent i to serve one customer. One agent can serve one customer at a time. All N agents can serve concurrently. The customer chooses the agent with the lowest wait time.
N = 2, M = 2, T = [4, 5]
First customer chooses agent 1. Second customer chooses agent 2. Customer C will wait 4 minutes.
N = 2, M = 4, T = [4, 5]
First customer chooses agent 1. Second customer chooses agent 2. Third customer chooses agent 1. Forth customer chooses agent 2. Customer C will wait 8 minutes.
A minheap is ued to solve this problem.
1#include <gtest/gtest.h>
2#include <sein.hpp>
3
4namespace ns_total_waiting_time {
5int total_waiting_time(vector<int> agents, int customers){
6 if(agents.size()>customers) return 0;
7 priority_queue<pair<int,int>,vector<pair<int,int>>,greater<>> pq;
8 for (int i=0; i<agents.size(); i++)
9 pq.emplace(0, agents[0]);
10 for (int i=0; i<customers; i++){
11 auto [tot, unit] = pq.top(); pq.pop();
12 pq.emplace(tot+unit, unit);
13 }
14 return pq.top().first;
15}
16}
17
18using namespace ns_total_waiting_time;
19TEST(_total_waiting_time, a) {
20 EXPECT_EQ(total_waiting_time({4,5},2), 4);
21 EXPECT_EQ(total_waiting_time({4,5},4), 8);
22}
2.4.3.6. Beam Search
You are developing a game where players navigate a grid-based map to find treasure. Obstacles block certain cells, and each cell has a cost to move through. We define our cost function as below:
int cost_function(const vector<Pos> &path, const VVI &grid) {
int cost = path.size(); // Base cost on length
for (auto pos: path) {
cost += grid[pos.first][pos.second]; // Add obstacle cost
}
return cost;
}
For the below grid, from the top-left cell to bottom-right cell, the top-2 most efficient paths are as below:
{0, 0, 1, 0, 0} {x, 0, 1, 0, 0} {x, 0, 1, 0, 0}
{0, 1, 0, 1, 0} {x, 1, 0, 1, 0} {x, 1, 0, 1, 0}
{0, 0, 0, 0, 1} ==> {x, x, x, x, 1} and {x, x, x, x, 1}
{1, 0, 1, 0, 0} {1, 0, 1, x, x} {1, 0, 1, x, x}
{0, 0, 0, 0, 0} {0, 0, 0, 0, x} {0, 0, 0, 0, x}
input grid top-2 most efficient paths indicated by x
Implement a function using beam search to find the top-K most efficient paths from a starting point to the treasure location.
// Assuming VVI is defined somewhere as:
// using VVI = std::vector<std::vector<int>>;
using Pos = std::pair<int, int>; // row, column definition
/**
* Finds the top-K most efficient paths using beam search.
*
* @param grid A 2D vector representing the map, where grid[i][j] can be:
* 0 (empty), 1 (obstacle), or a positive integer indicating cost.
* @param start A pair (row, col) representing the starting position.
* @param goal A pair (row, col) representing the treasure location.
* @param k The number of top paths to return.
* @param beam_width The beam width for the beam search.
* @return A vector of the top-K paths, where each path is a vector of (row, col) pairs.
*/
std::vector<std::vector<Pos>> find_top_paths(const VVI& grid, const Pos& start,
const Pos& goal, int k, int beam_width) {
// Implementation here
}
We can use priority queue to implement beam search, but before implementing the solution, there are two questions:
Should we use vistied_set like in many other graph traversal solutions(eg. BFS, DFS, Dijkstra)?
Should we reuse cost value saved in priority queue?
One feasible implementation would be as below:
1 vector<vector<Pos>> find_top_paths(const VVI &grid, const Pos &start, const Pos &goal, int k, int beam_width) {
2 int R = grid.size(), C = grid[0].size();
3 auto get_neighbors = [&](const Pos &pos) {
4 vector<Pos> result;
5 for (auto dir: directions) {
6 int nr = pos.first + dir.first;
7 int nc = pos.second + dir.second;
8 if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != 1) {
9 result.push_back({nr, nc});
10 }
11 }
12 return result;
13 };
14 // Heuristic (Manhattan distance for simplicity)
15 auto heuristic = [&](const Pos &pos) {
16 return abs(pos.first - goal.first) + abs(pos.second - goal.second);
17 };
18 vector<vector<Pos>> result_paths; // Store the top-K paths
19 priority_queue<pair<int, vector<Pos>>, vector<pair<int, vector<Pos>>>, greater<>> candidates;
20 candidates.push({-cost_function({start}, grid), {start}});
21 // Beam Search
22 while (!candidates.empty()) {
23 vector<pair<int, vector<Pos>>> current_candidates;
24 while (!candidates.empty() && current_candidates.size() < beam_width) {
25 current_candidates.push_back(candidates.top()), candidates.pop();
26 }
27 for (auto &[ignored_cost, path]: current_candidates) {
28 Pos last_pos = path.back();
29 if (last_pos == goal) {
30 result_paths.push_back(path);
31 if (result_paths.size() == k) return result_paths;
32 continue;
33 }
34 for (const auto &neighbor: get_neighbors(last_pos)) {
35 if (find(path.begin(), path.end(), neighbor) != path.end()) continue; // Skip cycles
36 vector<Pos> new_path = path;
37 new_path.push_back(neighbor);
38 int cost = cost_function(new_path, grid) + heuristic(neighbor);
39 candidates.push({cost, new_path});
40 }
41 }
42 }
43 return result_paths;
44 }
Note
No Visited Set
Beam Search Nature: Beam search, by its nature, keeps a narrow focus on a set of the most promising candidates (paths) at each step. Unlike breadth-first or depth-first searches, which might exhaustively explore all paths and thus benefit more directly from a visited set to prevent re-exploration, beam search’s limited “beam width” inherently restricts its exploration scope. However, this doesn’t mean revisiting nodes is always avoided; beam search can revisit nodes if doing so is part of paths within the current best candidates.
Path Uniqueness: Each path in the priority queue is unique, and extending a path with new neighbors naturally avoids immediate cycles. But since the algorithm aims to find multiple top paths (k paths), completely blocking revisited nodes could prematurely prune potentially optimal or diverse paths that share initial segments but diverge later.
Dynamic Programming vs. Beam Search: In dynamic programming approaches (like Dijkstra’s algorithm), marking nodes as visited is crucial because the goal is to find the single best path to each node based on cumulative cost. In contrast, beam search in this context seeks multiple high-quality paths, potentially with shared segments, making a global visited set less applicable.
Visited Set Reconsideration: For specific cases or larger grids, reintroducing some form of visited state management (potentially at a granular level, e.g., per path or with nuanced conditions) might optimize performance by avoiding redundant explorations.
Use of ignored_cost
Priority Queue Elements: The priority queue stores pairs of integers (cost) and paths (vectors of positions). In the context where the priority queue is polled (candidates.pop()), the cost (ignored_cost) has already served its purpose for sorting within the priority queue. The focus shifts to examining and extending the path associated with that cost.
Cost Recalculation: When a path is extended, its new cost is calculated based on the entire path plus the heuristic for the next step. This recalculation makes the previous cost (the one that was used for priority queue ordering) obsolete for paths still in contention, hence it can be “ignored” or not used directly in further computations within that iteration of expanding paths.
Simplification and Focus on Path Extension: The use of ignored_cost simplifies the code by not distracting with cost management once paths are being extended. It clarifies that the decision-making process at that point is focused on path exploration rather than cost comparison.