enratingion's blog

By enratingion, history, 2 months ago, In English

Hello everyone,

Recently, I was reading about Genetic Algorithms (GA) used in AI training. It sparked an interesting thought: could we apply this concept to Competitive Programming to fish for an AC? I decided to put this to the test on a classic problem: CSES – Elevator Rides.

Problem Summary: There are n people who want to get to the top of a building using a single elevator. You are given the weight of each person and the maximum weight capacity of the elevator. What is the minimum number of elevator rides required? (n <= 16).

With n <= 16, this is obviously a standard Bitmask DP problem. However, just for the sake of experimentation, I decided to solve it using a Genetic Algorithm.

The Core Idea: Permutations as Individuals

Initially, we have n people, where the i-th person has a weight of a[i], and the elevator's capacity is x.

Suppose these n people line up for the elevator in a fixed order (i.e., a permutation P of length n). They enter the elevator strictly in this order. If the next person exceeds the weight limit, they wait for the next ride. We can easily calculate the number of rides for any permutation P using a simple greedy approach:

long long cur = 0, cnt = 1;

for (int i = 1; i <= n; i++) {
    if (cur + a[P[i]] > x) {
        cnt++;       // Overweight, start a new ride
        cur = a[P[i]];  
    } else {
        cur += a[P[i]]; // Add to current ride
    }
}
// 'cnt' is the required number of rides

Our goal is now simplified: Find a permutation P that minimizes cnt.
Generating all permutations is $$$O(n!)$$$ and will definitely result in TLE.

This is where the Genetic Algorithm comes in. Some permutations yield terrible arrangements, while others yield better ones. We can treat each permutation as an "individual" in a population. We will use a fitness function to evaluate how "good" an individual is, select the elite ones, and let them crossover to produce the next generation. We will also introduce random mutations.

Implementation Details

1. Mutation

This is straightforward. I defined a mutation rate X. A permutation P has an X probability of mutating. If it mutates, we randomly select two indices i and j, and simply swap(P[i], P[j]).

2. Crossover

There are many ways to combine two permutations. I used a relatively simple method (similar to Order Crossover):
Assume we want individuals A and B to produce a child C.

  • First, randomly select a subarray range [l, r].
  • Child C inherits the exact subarray [l, r] from parent A.
  • The remaining empty slots in C are filled sequentially using the genes from parent B (skipping elements that are already present in C).

Example:
Crossover A = [5, 4, 3, 2, 1] and B = [1, 2, 3, 4, 5] with range l = 1, r = 3.

  • First, C[1..3] = A[1..3], so C = [5, 4, 3, ?, ?].
  • We have 2 empty slots left. Iterating through B, the unused numbers are 1 and 2. We place them in the remaining slots.
  • The resulting child is C = [5, 4, 3, 1, 2].

3. Fitness Function

This is the most important part. The primary metric is, of course, the number of rides. However, many individuals will require the exact same number of rides, making this metric alone insufficient for natural selection.

To fix this, we need a secondary score: the square of the fill ratio for each elevator ride.
For example, let the capacity be 10. If an individual results in two rides with total weights 10 and 1, the fitness score component would be (10/10)^2 + (1/10)^2.

Why square it? Consider another individual that produces two rides of weights 5 and 6.
If we didn't square the ratios:
(10/10) + (1/10) = (5/10) + (6/10)
The algorithm would treat the [10, 1] arrangement and the [5, 6] arrangement as equally good. But intuitively, [10, 1] is much better because it perfectly packs one ride, leaving more flexible space for the rest. Squaring the ratios heavily rewards rides that are packed to maximum capacity.

Tuning the Parameters

With the logic set, I started submitting and tweaking the hyperparameters:

  • Attempt 1: Population = 100, Generations = 500, Mutation Rate = 0.1.
    Result: Not bad for a first try, but Got WA on 4 tests.
  • Attempt 2: I realized that instead of hardcoding the number of generations, I should just let it run until it's about to hit the Time Limit. I set it to loop continuously until 0.9s, increased Population to 200, and Mutation Rate to 0.15.
    Result: Improvement, WA on 3 tests.
  • Attempt 3: Increased Mutation Rate to 0.4 and Population to 500.
    Result: Only 2 WAs left.
  • Attempt 4: I noticed the evaluation and crossover functions are extremely fast. If the population is too small, running too many generations just leads to premature convergence (getting stuck in local optima). I drastically increased the Population to 5000, keeping the 0.9s time limit.
    Result: Accepted!

Conclusion

This was a fun experiment applying randomized heuristics to CP. Obviously, I do not recommend relying on this in strict offline contests where you have no feedback and a single WA can cost you dearly. It took quite a bit of parameter tuning to squeeze out an AC here.

However, when constraints are suddenly bumped up and exact solutions like DP Bitmask TLE, randomized heuristic searches like GA can get some partial points!

The AC code

  • Vote: I like it
  • +84
  • Vote: I do not like it

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

Auto comment: topic has been updated by enratingion (previous revision, new revision, compare).

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

Thats cool, would not have expected such an approach to work

»
2 months ago, hide # |
 
Vote: I like it +13 Vote: I do not like it

Is this kind like Simulated Annealing?

  • »
    »
    2 months ago, hide # ^ |
     
    Vote: I like it +28 Vote: I do not like it

    Not exactly the same, but they're related ideas. The main difference is that SA works with a single solution that it tweaks step by step (moving to a "neighbour" and sometimes accepting worse solutions to escape local optima), while GA keeps a whole population of solutions and combines them through crossover plus mutation.

    In practice, for CP I'd usually reach for SA first since it's simpler to code: you only need to define a neighbour move and a cost function, without designing a crossover operator (which, as you can see in the post, is often the trickiest part of GA). GA shines more when the search space is large and population diversity helps avoid getting stuck.

    Both work best when the input is small and the answer is "close" to a random solution, so you can keep fixing it with small moves until it converges.

»
2 months ago, hide # |
Rev. 2  
Vote: I like it +3 Vote: I do not like it

As someone who is a biotech major and a cp enjoyer, I love this..... its just so brilliant. Never thought something like this which I studied in the bioinformatics class can have such vast applications.

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

So cool

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

very interesting concept, if there are optimization problems in a div 2/3 for a, the time constraints so easily passible (np passing???) that perhaps this might work in real contest, if n is like 10> and if complexity is something around 2^n or n! this works

»
2 months ago, hide # |
 
Vote: I like it +4 Vote: I do not like it

a gray blog that isn't slop???

Ratism aside, I think this method really shines in constructive problems where the solution space is relatively small (about 2.4e18 here) and low-entropy, as in similar solutions provide similar results. very interesting but very limited in scope.

Would be OP in problems with partial scoring instead of pass/fail.

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

As a person professionally doing research in genetic and other evolutionary algorithms, I should say that in ICPC-like settings it almost never makes sense using them — unless you really know what you are doing.

In the training camps, we had some solutions accepted for certain geometry problems — that required finding a certain point at a maximum distance from something very complicated — using a real-valued evolution strategy, which is another algorithm from the family, that is capable of following a poorly defined gradient quite efficiently. This required a bit of tuning for the mutation operator, but passed the tests surprisingly quickly.

Compared to simulated annealing, Lin-Kernighan and other (1+1)-like stuff (that is, one parent and one offspring), populations that are common to genetic algorithms in particular typically only make sense if you can design a recombination operator (e.g. crossover) that does something meaningful, otherwise clever restart techniques usually are more efficient. As an example of how a meaningful crossover can be designed, one may check this paper: maybe not an easiest read, but it's open access, and Section 2.2/Fig.2 should be good enough for understanding when and how properly designed genetic algorithms perform extremely well.

»
3 weeks ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

interesting!