The Art of Making Programming Problems

Image
Hello! Today we’ll explore the wonderful world of creating problems for programming contests. This article could be used as a sort of guide if you’re interested in writing problems yourself, but it’s intended for anyone interested in the process or philosophy behind making problems.   The scope of the article is quite broad, and in its four parts, we will discuss (1) why to make programming problems, (2) how to invent problems, (3) how to plan a contest, and (4) how to prepare problems. Do feel free to skip around if any of these topics tickle your fancy, or if you’re up for it, I invite you to read the article through from top to bottom. A lot of the thoughts presented here are based on my own modest experience with problem setting, mainly for university contests, informatics olympiads and miscellaneous educational purposes. Keep in mind that nothing I say is meant to prescribe a correct way to do things, just to describe what I’ve personally found effective for the work I’ve done. So

Fracturing Search

Have you ever had that insatiable urge to find the kth smallest minimum spanning subtree which of a weighted graph? (I know I have) Well look no further! This post will an exploration of fracturing search and its applications.

(No prerequisite knowledge it required to understand the concepts, although it will be helpful graph theory terminology and an algorithm that solves the standard minimum spanning tree problem.)

An example - finding the kth smallest value in a heap


Fracturing search is a method that partitions your search space, and explores it in a way to get the "kth best" item. Let me illustrate with an example:

Lets consider a heap, which is a binary tree where the value of a parent is always strictly smaller than the value of any child (this property is known as the heap invariant). For example, 1 < 2 and 1 < 7, because (1) is the parent of (2) and (7).


We want to find the kth smallest value. Now is a good time to pause and think if you want to try it yourself.



Due to our heap invariant, the 1st smallest value is always the root. More generally, the smallest value of a tree is always the root node. I shall depict this idea in blue from now on. (In practice you would implement this as a function that takes a subtree and returns the smallest value - the root).


Hence we can say that the 2nd smallest value is the smallest value of the left subtree or the smallest value of the right subtree, namely 2 or 7. It is clear that the 2nd smallest value is 2.


Then the 3rd smallest value is the smallest value of the remaining subtrees, namely 10, 8 or 7. Hence we conclude the the 3rd smallest value is 7.



This process looks like "fracturing" our tree into smaller trees, hence the name Fracturing Search.

Solution - finding the kth smallest value in a heap


#include <bits/stdc++.h>
using namespace std;


int N, K, heap[1000005];

struct Subtree {
	// every subtree can be uniquely represented its root node
	// note that the minimum node is the root node, with a value of `heap[root]`
	int root;
	
	// given another subtree return which one is minimum
	// this is useful for priority queue shenanigans
	bool operator<(Subtree other) const {
		return heap[root] > heap[other.root];
	}

	// a partition of a tree returns the left subtree and right subtree
	vector<Subtree> getPartition() {
		vector<Subtree> output;
		// left child
		if (2 * root <= N) output.push_back({2 * root});
		// right child
		if (2 * root + 1 <= N) output.push_back({2 * root + 1});
		return output;
	}
};


signed main() {
	// N = total number of nodes in the tree
	cin >> N;

	// K = we want the kth minimum node
	cin >> K;
	assert(1 <= K && K <= N);

	// read in the tree as a standard heap structure
	//  - 1 is the root
	//  - 2*node is the left child of nod
	//  - 2*node+1 is the right child of node
	for (int i = 1; i <= N; ++i) {
		cin >> heap[i];
	}

	priority_queue<Subtree> pq;
	// initially searching the whole tree, i.e. the tree which has 1 as the root
	pq.push({1});

	for (int ithBest = 1; ithBest < K; ++ithBest) {
		Subtree curr = pq.top();
		pq.pop();
		for (Subtree partition : curr.getPartition()) {
			pq.push(partition);
		}
	}
	int ans = pq.top().root;
	printf("The %dth smallest node is %d, with a value of %d\n", K, ans, heap[ans]);
}

The illustrated example is equivalent to the input

6 3
1 2 7 10 8 9


This algorithm can also be applied to most problems which gives you many states, each which a particular value, and asks for the state with the kth smallest value. (given that you can easily find the minimum of a subset of states, and that you can partition a subset of states efficiently).

Try it yourself - kth smallest spanning tree

Try the kth smallest spanning tree problem, here's some starter code.

int main() {
    // STATEMENT:
    // Given an undirected weighted graph, find the total costs of the minimum 
    // spanning subtree to the kth minimum spanning subtree

    // CONTRAINTS:
    // N, M, K <= 2000
    // K <= number of possible spanning trees
    // 1 <= a_i, b_i <= N
    // 0 <= w_i
    // \sum_i w_i <= INT_MAX

    // INPUT FORMAT:
    // N M K
    // a_1 b_1 w_1
    // ...
    // a_M b_M w_M
    cin.tie(0); ios::sync_with_stdio(0);
    
    cin >> N >> M >> K;
    for (int i = 0; i < M; ++i) cin >> edges[i].second.first >> edges[i].second.second >> edges[i].first;
    sort(edges, edges + M);

    fracturingSearch();
    //bruteForce();
}

Solutions - kth smallest spanning tree



And here's my solution in the better language (eww Java 🤢🤮)
#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
#include <assert.h>
using namespace std;

int N, M, K;
pair<int, pair<int, int>> edges[2005];
int parents[2005];

//////////////////////////////////////////////////////////////////////
void clearDSU() {
    for (int node = 1; node <= N; ++node) parents[node] = node;
}

int getParent(int node) {
    if (parents[node] == node) return node;
    return parents[node] = getParent(parents[node]);
}

bool doUnion(int a, int b) {
    int pA = getParent(a), pB = getParent(b);
    bool ans = (pA == pB);
    parents[pA] = pB;
    return ans;
}

bool doUnion(pair<int, int> edge) {
    return doUnion(edge.first, edge.second);
}

// find whether all nodes are in the same component
bool isMerged() {
    for (int node = 1; node <= N; ++node) {
        if (getParent(node) != getParent(1)) return false;
    }
    return true;
}

//////////////////////////////////////////////////////////////////////

struct State {
    int cost = 0;
    vector<int> choices;
    vector<bool> used;

    State(int M) {
        choices.resize(M);
        used.resize(M);
    }

    bool operator<(State o) const {
        return cost > o.cost;
    }
};
priority_queue<State> pq;

State initState() {
    State output(M);

    clearDSU();
    for (int i = 0; i < M; ++i) {
        if (!doUnion(edges[i].second)) {
            output.cost += edges[i].first;
            output.used[i] = true;
        }
    }
    return output;
}

State findNeighbour(vector<int> choices) {
    State output(M);
    output.choices = choices;

    clearDSU();
    for (int i = 0; i < M; ++i) {
        if (choices[i] == 1) {
            doUnion(edges[i].second);
            output.cost += edges[i].first;
            output.used[i] = true;
        }
    }
    for (int i = 0; i < M; ++i) {
        if (choices[i] == 0 && !doUnion(edges[i].second)) {
            output.cost += edges[i].first;
            output.used[i] = true;
        }
    }
    return output;
}

void fracturingSearch() {
    pq.push(initState());
    for (int rep = 0; rep < K; ++rep) {
        if (pq.empty()) {
            cout << -1 << "\n";
            continue;
        }

        State curr = pq.top();
        pq.pop();
        cout << curr.cost << "\n";

        for (int remIndex = 0; remIndex < M; ++remIndex) {
            // this edge is being used, but it could be swapped out
            if (curr.used[remIndex] && curr.choices[remIndex] == 0) {
                vector<int> newChoices = curr.choices;
                for (int i = 0; i < remIndex; ++i) {
                    if (curr.used[i]) newChoices[i] = 1;
                }
                newChoices[remIndex] = -1;

                State neighbour = findNeighbour(newChoices);
                if (isMerged()) pq.push(neighbour);
            }
        }
    }
}

void bruteForce() {
    vector<int> ans;
    for (int bits = 0; bits < (1 << M); ++bits) {
        int cost = 0;
        bool valid = true;

        clearDSU();
        for (int i = 0; i < M; ++i) {
            if (bits & (1 << i)) {
                if (doUnion(edges[i].second)) valid = false;
                cost += edges[i].first;
            }
        }
        if (valid && isMerged()) ans.push_back(cost);
    }

    sort(ans.begin(), ans.end());
    for (int i = 0; i < K; ++i) cout << (i < ans.size() ? ans[i] : -1) << "\n";
}



Bonus question!


And here's my solution (though I suggest you only look at this after trying the problem yourself):

/* N <= 2e3, K <= 2e3

fracturing search
O(NK log NK)
*/

#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
#include <assert.h>
using namespace std;
typedef long long ll;

ll N, K, D;
vector<ll> costs[2005];

struct State {
    ll numItems = 0, cost = 0;
    ll hasChoice;
    ll lastChoice;
    // choices[i] <= used[i]
    // day is discarded when used[d] = choices[d] = cost[d].size()
    // day has free choice when choices[d] = -1

    bool operator<(State o) const {
        if (numItems == o.numItems) return cost > o.cost;
        return numItems < o.numItems;
    }
};
priority_queue<State> pq;

State initState() {
    State output;
    output.numItems = D;
    for (ll d = 1; d <= D; ++d) output.cost += costs[d][0];
    output.hasChoice = 1;
    //output.used.resize(D + 1);

    return output;
}

void fracturingSearch() {
    pq.push(initState());

    for (ll rep = 0; rep < K - 1; ++rep) {
        State curr = pq.top();
        pq.pop();

        for (ll d = curr.hasChoice; d <= D; ++d) {
            //cerr << "d " << d << "\n";
            //cerr << "PQ size: " << pq.size() << "\n";
            // option 1: increment the cost
            for (ll itemI = 1; itemI < (ll)costs[d].size(); ++itemI) {
                State neighbour = curr;
                neighbour.cost += costs[d][itemI] - costs[d][0];
                neighbour.hasChoice = d + 1;
                //neighbour.used[d] = itemI;
                pq.push(neighbour);
            }

            // option 2: remove this day
            if (0 != (ll)costs[d].size()) {
                State neighbour = curr;
                neighbour.cost -= costs[d][0];
                neighbour.numItems--;
                neighbour.hasChoice = d + 1;
                //neighbour.used[d] = costs[d].size();
                pq.push(neighbour);
            }
        }
    }

    cout << pq.top().numItems << " " << pq.top().cost << "\n";
}

int main() {
    cin.tie(0); ios::sync_with_stdio(0);

    cin >> N >> K >> D;
    for (ll d, w, i = 0; i < N; ++i) {
        cin >> d >> w;
        costs[d].push_back(w);
    }
    for (ll d = 1; d <= D; ++d) sort(costs[d].begin(), costs[d].end());

    fracturingSearch();
}

Popular posts from this blog

Implementation tips for competitive programming

Tips for Competitive Programming

Sqrt linked lists

Interactive Tasks

Introduction to Dynamic Programming

Introduction to Competitive Programming

The Art of Making Programming Problems

Union-find Disjoint set