features the most complex project architecture in the core curriculum of 42 so far, as well as the following topics:
- an introduction to the topic of computational complexity
- stacks (the abstract notion)
- sorting values
- the learning and the subsequent unlearning of the sorting efficiency terminology
- sorting operations limitations
It also features 'getting next line' in the bonus part, which I hear might be a convenient segway into the pipex project.
The following operations all count as one step each, including the dual ones (ss, rr, rrr). One must sort a list of numbers using only these, but with some relevant sidenotes regarding the topic of computational complexity. In the table below, the symbol _
signifies where a moved element had previously been.
Beginning state | Operation applied | final state |
---|---|---|
a: 01234 b: 56789 |
sa (swap a) |
a: 10234 b: 56789 |
a: 01234 b: 56789 |
sb (swap b) |
a: 01234 b: 65789 |
a: 01234 b: 56789 |
ss (swap a + swap b) |
a: 10234 b: 65789 |
a: 01234 b: 56789 |
pa (push to a) |
a: 501234 b: _6789 |
a: 01234 b: 56789 |
pb (push to b) |
a: _1234 b: 056789 |
a: 01234 b: 56789 |
ra (rotate a) |
a: _12340 b: 56789 |
a: 01234 b: 56789 |
rb (rotate b) |
a: 01234 b: _67895 |
a: 01234 b: 56789 |
rr (ra + rb) |
a: _12340 b: _67895 |
a: 01234 b: 56789 |
rra (reverse rotate a) |
a: 40123_ b: 56789 |
a: 01234 b: 56789 |
rrb (reverse rotate b) |
a: 01234 b: 95678_ |
a: 01234 b: 56789 |
rrr (revrot a + revrot b) |
a: 40123_ b: 95678_ |
There is a discrepancy between what the assignment PDF describes and what the evaluation sheet evaluates, which initiates a journey of learning and subsequently ignoring the general terminology of computational complexity. The assignment says:
This project will make you sort data on a stack, with a limited set of instructions, using the lowest possible number of actions.
The word 'actions' does not actually refer to the elementary operations like the condition checks or any "pre-sorting" initialization steps on a copy of the list, but rather to the number of the authorized sorting actions only. Which frees the programmer to perform countless checks, but obfuscates the considerations of efficiency. Because ultimately, the evaluation sheet rates efficiency only according to the number of 'steps' performed. As an example for a list of 100 unsorted numbers:
- Less than 700 'steps': max score
- Just below 1500 'steps': min score
- Over 1500 'steps': fail
Whether and how many parameters to keep track of or calculate dynamically will depend on how we approach the topic of algorithm efficiency i.e. computational complexity. But again, this project is not evaluated based on the elementary operations in the traditional sense of computational complexity. They are not accounted for at all. On a broad range of possible parameters, we could consider:
- The
largest number
/ thesmallest number
- The
previous largest number
/ thenext smallest number
- The
indexes
(values 0 2 8 11 13 being indexed as 0 1 2 3 4) - The
how many values smaller/greater than
thecurrent number
- The
easy to reach positions
such aspos_stk_a[0]
,pos_stk_a[1]
, ... ,pos_stk_a[z - 1]
,pos_stk_a[z]
/ - The
steps cost
for sorting a given number - The
chunk size
if we divided the list into segments during sorting (relevant to a recursive approach)
One might find themself asking whether applying other, well-known and straight-forward sorting algorithms on a copy of the raw list initially would be fair game. Why would you want to do that? E.g. to know that -1 and 8 are in fact consequent values in an ultimately sorted list, where the elements are {-42, -1, 8, 9, 22}. Personally I think it doesn't really matter whether you do it, because any computational strain of iterating over the stacks looking for the thenextnumber
might turn out to be unnoticeable in real time, or the iteration might be optimized to perform other usefull services as well. However, after several discussions with peers, I decided to justify my slapping on of a bubble sort, thus creating an array of upward indexed values, by adhering to reducing the overall computational complexity, which ultimately is the project's topic, even if distantly.
push_swap: the "sort the smallest value permanently" approach
---arr_raw //malloc//
---handle_input //check for errors, get size//---|
| ---arr_ind //malloc//
|
main(argc, argv) -----| ---init_stk(a) //as linked list; malloc//
| |
| |
---go_sorting-----|
| *while ---conds_if_val_in_a --> do op, print, return
| (i < size) |
--- find_n_move---|
|
---conds_if_val_in_b --> do op, print, return
* one by one, the next 'smallest' value is permanently sent to the bottom of stack A
graph TD;
main-->handle_input;
main-->go_midpointing;
handle_input-->arr_raw;
handle_input-->arr_ind;
go_midpointing-->init_stkA;
go_midpointing-->pb_all_check;
pb_all_check-->pb_all_engine;
pb_all_check-->pb_all_check;
go_midpointing-->flip_b;
flip_b-->flip_a;
flip_b-->flip_b;
flip_a-->flip_b;
flip_a-->flip_a;
Notes on 'midpoint sort' flow:
- handle_input
- parse the command line arguments
- malloc for the two value arrays (raw and "pre-sorted" (indexed))
- error handling
- indexing values by size (yup, pre-sorting)
- init_stkA
- malloc for the linked list and assign the values from the arr_raw; the arr_ind is used for sortedness checks later on
- pb_all_check (a recursive function)
- pb_all_engine:
pb
half of A (all values below a midpoint) - call pb_all_check again with a new (higher value) midpoint
- pb_all_engine:
- flip_b <-> flip_a two recursive functions calling themselves and eachother until base case, which ultimately will produce a sorted stack A
- Note: they continously halve the sizes of each respective 'chunk' which they evaluate and bounce around via
pb
andpa
- Note: they continously halve the sizes of each respective 'chunk' which they evaluate and bounce around via
I first (1) used my own algorithm, then (2) tried to rewrite it by adding a pivot point and utilizing ss
, rr
and rrr
a lot, but ultimately (3) implemented a recursive algorithm which the author has baptised Midpoint sort.
I first devised my own sorting algorithm, which was ultimately passing the tests (barely). I was happy with it. Until I discovered the number-of-steps limitation for 500 numbers, which was 11500. My algorithm was taking 31000 steps to sort that many numbers. Here is how it worked.
sketch of the pa + ra algorithm
We use ra
to first send smallest and then keep sending larger and larger values to the bottom of the stack a, ensuring that ra
== sorted
- Version 2.a.3 of my original algorithm:
int smallest
,int next
,step count
. Determine these, then store them in a struct.- You have smallest, now look at stacks. [
smallest
happens to be within reach]- Formalization of "if [
smallest
happens to be within reach]"- if
smallest
== a[0], thenra
- else if
smallest
== a[1]- if
next
== a[0], thensa
+ra
- else
pb
+ra
- if
- else if
smallest
== a[z], then0
- else if
smallest
== a[z - 1] && step == 0, thenrra
- else if
smallest
== b[0], thenpa
+ra
- else if
smallest
== b[1]- if
next
== b[0], thensb
+pa
+ra
- else
rb
+pa
+ra
- if
- else if
smallest
== b[z], thenrrb
+pa
+ra
- else if
smallest
== b[z - 1], thenrrb
+rrb
+pa
+ra
- else if
smallest
in a, thenpb
- else if
smallest
in b, thenrb
orrrb
- add weigh: if b[pos] > size(b) / 2, then
rrb
untilsmallest
is atop b - add weigh: if b[pos] <= size(b) / 2, then
rb
untilsmallest
is atop b
- add weigh: if b[pos] > size(b) / 2, then
- if
- Formalization of "if [
- Calculate new
smallest
, calculate newnext
. Double while loop. If (number being tested) !>unsorted[i++]
thensmallest = (number being tested)
, else test next number from the array. rm it fromunsorted[]
. - You have smallest, now look at stacks. Atop a is not smallest, abottom a is smallest, atop b is nothing, abottom b is nothing.
pb
the 5 => a: _2431 b: 5
An example of how it works: 15243 => ra
pb
ra
sa
ra
ra
pa
ra
=> 12345
This algorithm was meaning well and was going to maximize the utilization of ss
, rr
and rrr
but I realized it would end up having similar shortcomings as my first one and was still not ready to accept that I needed to utilize extensive structs or that I needed to triple the number of conditions. So I opted for accepting that I needed recursion instead.
I never completed a sketch of this algorithm and it's for the best. I don't find the number of conditions triple the size of (a) a very appealing notion.
There seem to be similarities between 'Quick sort' and 'Midpoint sort'. However at this point the intertwining of multiple recursive functions seemed inevitable and I'd decided I needed guidance for that, so I've made my choice and relied on this description of the algorithm by the author himself, which had the benefit of relating to the project directly. I find the 'Midpoint sort' to be very impressive conceptually. Unfortunately however, a lack of examples in the video had me struggling with the implementation quite a bit. But I've somehow tamed the dual recursion. I don't think I can competently fully explain the midpoint algorithm, and to that purpose, I'd recommend you to watch the video. And if you're then interested in implementing this algorithm and find that the video isn't telling you everything, perhaps this GitHub page can be of assistance.
- First, (almost) everything is sent from A to B in a recursive function that functionally only ever returns into itself, so that's easy to deal with.
- After this, the bouncing and flipping above and below respective midpoints begins, which will ultimately produce a sorted stack A.
- 'bouncing' refers to the
pa
andpb
taking place - 'flipping' refers to the controlled and tracked combination of
rx
andrrx
each time a value is inappropriate topa
/pb
because it is not higher/lower than midpoint. So if say we're in the process ofpa
-ing, butpa
is not appropriate for the value currently at the top of stack B, we 'tuck' it underneath the stack usingrb
and take note that we've done so. Later, werrb
as many times as we'verb
-ed.
- 'bouncing' refers to the
The concept of chunk sizes is crucial to minimizing the number of sorting operations, while symultaneously entirely omitting the need for evaluating the state of the entire stacks after each operation performed. Meaning no extensive structs or iterating over the entire chunks after each step are needed, which I think is quite elegant. However, I'm not gonna lie, I've spent 3 whole days tweaking the order of the recursive function calls and the exact chunksize
arguments passed to each one of them, and I'm still not sure that I confidently understand why I was unable to deal with those chunk size' calculations in a more succint and more legible way.
Besides keeping track of chunk sizes, which we can easily dynamically calculate the midpoint from, another crucial thing to dynamically calculate and keep track of is the size of the rest of chunk. Having to pass this restsize
to something is in fact the bottom-line reason why the functions flip_a and flip_b are not only calling each other, but also themselves. And it makes the manipulating of the function calls inside each recursive case and passing the correct integer values to them as arguments a challenge.
This function isn't part of 'flipping and bouncing' the stacks or sending chunk halves back and forth between them, it's only in charge of sending (almost) all the numbers to stack B as a first step, but also of relaying the chunk sizes that will be relevant to the 'flipping and bouncing' subsequently.
Next, pb_all_check returns when there's only two values left in stk_a, and then flip_b is called for the first time, where mid
is equal to 1 or 2 and is passed as an argument to flip_b to relay the chunk size
to be considered in there.
pb_all_check
void pb_all_check(int *arr_ind, int size, t_list **stk_a, t_list **stk_b)
{
int mid = size / 2;
int rest = size - mid;
// Base case:
if (size <= 2)
// Recursive case:
pb_all_engine(arr_ind, mid, stk_a, stk_b); // non-recursive, just sends all values below midpoint up to 'mid' times
pb_all_check(&arr_ind[mid], rest, stk_a, stk_b); // recursive
flip_b(arr_ind, mid, stk_a, stk_b); // recursive; here 'mid' will translate to the chunk size inside the function
}
The flipping-and-bouncing begins. By design, the first time this function is called, the chunksz
will be 1 or 2. And each subsequent time it is called from pb_all_check, that number will increase.
flip_b
void flip_b(int *arr_ind, int chunksz, t_list **stk_a, t_list **stk_b)
{
mid = chunksz / 2;
if (chunksz % 2 == 0)
restsz = chunksz - mid + 1;
else
restsz = chunksz - mid;
// Base case:
if (chunksz <= 2)
// Recursive case:
pa_abovemid(&arr_ind[mid], chunksz - mid - 1, stk_a, stk_b); // non-recursive; this is where the 'flipping' happens
flip_a(&arr_ind[mid + 1], chunksz - mid - 1, stk_a, stk_b); // recursive
flip_b(arr_ind, restsz, stk_a, stk_b); // recursive; here 'restsz' will translate to the chunk size inside the function
// pa_abovemid and its counterpart pa_belowmid are non-recursive. They just `pa` and `pb`
// a number of elements above and below a given midpoint respectively.
}
Compare the recursive cases of flip_b and flip_a. Note that:
- flip_b is called from within flip_b to deal with the 'rest', i.e. the chunk values above and including the 'current' B pivot point
- flip_a is called from within flip_a to deal with the 'rest', i.e. the chunk values above and including the 'current' A pivot point
- also, somewhat surprisingly, notice the order in which flip_a and flip_b are called from within both functions - it's the same order
flip_a
void flip_a(int *arr_ind, int chunksz, t_list **stk_a, t_list **stk_b)
{
mid = chunksz / 2;
restsz = chunksz - mid;
// Base case:
if (chunksz <= 2)
// Recursive case:
pb_belowmid(&arr_ind[mid], mid, stk_a, stk_b); // non-recursive; this is where the 'flipping' happens
flip_a(&arr_ind[mid], restsz, stk_a, stk_b); // recursive
flip_b(arr_ind, mid, stk_a, stk_b); // recursive; here 'mid' will translate to the chunk size inside the function
}
This project encourages us to use libft. So it was high time I'd given thought to how I can add ft_printf and get_next_line to my libft. Since these two functions extend beyond the scope of Norminette-conform single .c files, I didn't want anything called utilities.c floating around my libft folder or listed in my libft's $(SRC), so instead I kept both ft_printf and get_next_line in their own subfolders, gave them appropriate Makefiles of their own, and had my libft Makefile 'make -C respectivesubfolders' and then extract the .o files into the libft folder, archiving the single-*.c-file libft functions together with the ft_printf and get_next_line functions as such:
NAME := libft.a
LIBFTPRINTFD := ./ft_printf
GNLD := ./get_next_line
$(NAME): $(OBJ)
make -C $(LIBFTPRINTFD) all
make -C $(GNLD) all
find $(LIBFTPRINTFD) -name "*.o" -exec cp {} . \;
find $(GNLD) -name "*.o" -exec cp {} . \;
ar rcs $(NAME) *.o