This project is an implementation of a genetic algorithm in C. The application was initially based on this article by Geeks for Geeks, but has now been developed and changed enough to be more than a mere port from their C++ implementation into C. The subsequent paragraph however is straight from the above mentioned article.
Genetic Algorithms(GAs) are adaptive heuristic search algorithms that belong to the larger part of evolutionary algorithms. Genetic algorithms are based on the ideas of natural selection and genetics. These are intelligent exploitation of random search provided with historical data to direct the search into the region of better performance in solution space. They are commonly used to generate high-quality solutions for optimization problems and search problems.
Genetic algorithms simulate the process of natural selection which means those species who can adapt to changes in their environment are able to survive and reproduce and go to next generation. In simple words, they simulate “survival of the fittest” among individual of consecutive generation for solving a problem. Each generation consist of a population of individuals and each individual represents a point in search space and possible solution. Each individual is represented as a string of character/integer/float/bits. This string is analogous to the Chromosome.
This image explains my implementation of the algorithm. The code specific documentation is hosted on the project's page.
A singular gene, is represented as a character.
typedef char gene_t;A genome being a collection of genes, is represented as this structure. It contains the pointer to an array of genes, the quantity of those genes being the length of the genome, and a placeholder to store the fitness of that genome for ease of access.
typedef struct genome_t
{
gene_t * genes;
uint16_t length;
int fitness;
} genome_t;The application expects a string argument that it takes as its target, and the
number of offspring to generate. The string must only contain the characters
present in the gene pool, which is is defined in
genetic_algorithm_utils.h.
#define GENE_POOL "!@#$^&*()_-=+,.;:'/\\\"{}[]<>? 1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"After initializing the target genome, two new parent genomes, and n number of
offspring genomes are initialized. Their genes are selected randomly from the gene
pool. This is done in the genome_init() function.
genome_t parents[2];
parents[0] = genome_init(target.length);
parents[1] = genome_init(target.length);
genome_t offsprings[offspring_count];
for (uint16_t i = 0U; i < offspring_count; i++)
{
offsprings[i] = genome_init(target.length);
}The algorithm starts by mating the two parent genomes to create all the offspring genomes.
for (uint16_t i = 0U; i < offspring_count; i++)
{
genomes_mate(&target, &parents[0], &parents[1], &offsprings[i]);
}Mating involves two steps.
- Joining the parents over a random crossover point
- Performing a slight mutation
Take these two parents, of size 7:
+-----------+---+---+---+---+---+---+---+
| index | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|-----------|---|---|---|---|---|---|---|
| parent 1 | a | b | c | d | e | f | g |
| parent 2 | h | i | j | k | l | m | n |
+-----------+---+---+---+---+---+---+---+
If the random crossover point is 3, the resulting offspring will look like this
+-----------+---+---+---+---+---+---+---+
| index | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|-----------|---|---|---|---|---|---|---|
| parent 1 | a | b | c | d | | | |
| parent 2 | | | | | l | m | n |
|-----------|---|---|---|---|---|---|---|
| offspring | a | b | c | d | l | m | n |
+-----------+---+---+---+---+---+---+---+
However to prevent parent 1 from always contributing to the first n genes and
parent 2 to the remaining n-length, their sequence is randomly (should be 50/50)
selected so with the same crossover point at 3, this can also happen
+-----------+---+---+---+---+---+---+---+
| index | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|-----------|---|---|---|---|---|---|---|
| parent 1 | | | | | e | f | g |
| parent 2 | h | i | j | k | | | |
|-----------|---|---|---|---|---|---|---|
| offspring | h | i | j | k | e | f | g |
+-----------+---+---+---+---+---+---+---+
After joining, a mutation can (at a 50/50 chance) be performed at a random index on the offspring. This is done to prevent the evolution from being stuck in a local maxima.
+-----------+---+---+---+---+---+---+---+
| index | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|-----------|---|---|---|---|---|---|---|
| offspring | a | b | c | d | l | m | n |
| mutation | | X | | | | | |
|-----------|---|---|---|---|---|---|---|
| mutated | a | X | c | d | l | m | n |
+-----------+---+---+---+---+---+---+---+
The fitness of a genome is being calculated based on these parameters.
How many genes in this are also present in the target, regardless of the order.How many genes in this are NOT present in the target.- How many genes match the target exactly in order.
Naturally, a heavy bias is given to genes that match with the target in order.
Ideally a genome that EXACTLY matches the target should have a fitness score of 0 and the less it matches, the more negative its fitness should become.
Currently the fitness calculation is anything but optimized, but I want to keep it
that way for ease of understanding and debugging. It is done inside the genome_mate()
function when an offspring is created.
p_offspring->fitness = genome_calculate_fitness(p_target->genes, p_offspring->genes, length);After creating an n number of offspring, they are sorted by ascending fitness.
This is done using the genomes_sort_by_fitness() function which performs a bubble
sort.
The two fittest offspring are then declared as the new parents, by copying them to
the parent genomes using the genome_copy() function which performs a deep copy
to preserve the references of the original parents.
genome_copy(&parents[0], &offsprings[0]);
genome_copy(&parents[1], &offsprings[1]);As the algorithm iterates, the genomes closer to the target keep getting selected to be parents and the resulting offspring keep getting closer to the target with slight mutations every time to help them converge.
Convergence is achieved when the healthiest parent has a fitness score of 0, i.e., it matches the target exactly. There is also an upper bound to number of iterations in case the algorithm never converges.
if ((parents[0].fitness == 0) || (iterations == UINT64_MAX))
{
(void)printf("\rConvergence Achieved!\n");
break;
}Some observations that can be made about this algorithm:
- Bigger the target, the more iterations it takes to converge
- If a target is too big, it fails to converge (within reasonable limits)
- Larger the offspring count, the less iterations it takes to converge
- Larger the offspring count, the longer a single iteration lasts
- If the offspring count is too small, it fails to converge
It is also noticeable that in some cases having a smaller offspring count leads to convergence quicker due to more iterations, than having a large offspring count leading to less in number, but more time consuming iterations.
genetic_algorithm> ./build/genetic_algorithm.exe "Hi, my name is Usman. :)" 1000
(-23) : "BkGd0 +gh4,KSpX2lTuJfIO"
(-21) : "BkGd0 +gh4,KS UKf_j?nEH"
(-20) : "BkGd0 +gh4,KS Usf_j?nEH"
(-19) : "B,Gd0 +gh4,KS Usf_j?nEH"
(-17) : "B, d0 +gh4,KS Usm_j?nEH"
(-16) : "B, d0 +gh4 KS Usm_j?nEH"
(-16) : "B, d0 +gh4 KS Usm_j?nEH"
(-15) : "B, m0 +gh4 KS Usm_j?nEH"
(-14) : "B, m0 +gh4 KS Usmaj?nEH"
(-13) : "B, m0 +gh4 KS Usmaj.nEH"
(-12) : "B, m0 +ah4 KY Usmaj.nEH"
(-11) : "B, m0 +ah4 Ks Usmaj.nEH"
(-10) : "B, m0 nah4 Ks Usmaj.nEH"
( -9) : "HB, m0 nah4 Ks Usmaj.nEH"
( -8) : "HB, m0 nahe Ks Usmaj.nEH"
( -7) : "HB, m4 nahe is Usmaj.nEH"
( -7) : "HB, m0 nahe is Usmaj.nBH"
( -6) : "Hi, m0 nahe is Usma'.nEH"
( -5) : "Hi, my nahe is Usma'.nEH"
( -4) : "Hi, my nahe is Usma'.n:H"
( -3) : "Hi, my nahe is Usma'. :H"
( -2) : "Hi, my nahe is Usman. :H"
( -1) : "Hi, my name is Usman. :H"
( -1) : "Hi, my name is Usman. :H"
( -1) : "Hi, my name is Usman. :H"
( 0) : "Hi, my name is Usman. :)"
Convergence Achieved!
Iterations: 25
Time taken: 56 milliseconds
Iteration time: 2.240000 msec per iterFollowing tools are required to compile
In the repository directory, use CMake and Ninja to build
cmake -GNinja -Bbuild
ninja -C buildTo make a Debug build without optimizations,
cmake -GNinja -Bbuild -DCMAKE_BUILD_TYPE=Debug
ninja -C buildTo make a Test build with unit tests and coverage data, first
initialize the c_asserts submodule.
git submodule update --init --recursiveAnd then perform the build
cmake -GNinja -Bbuild -DCMAKE_BUILD_TYPE=Test
ninja -C buildTo run the application
./build/genetic_algorithm.exe "Your test string" 1000The source files (excluding supporting files) are organized like this:
app_initcontains the parser for CLI args passed to the application.c_assertsis a submodule that provides a basic testing frameworkgenetic_algorithm_utilscontains all the functions used for operations in the algorithmtestcontains a test application which covers genetic_algorithm_utilsmain.ccontains the actual algorithm
