2

I was trying out different sorting algorithms. I was interested to really see the difference. I tried it on an array of 10 integers and everything works nicely, but of course, the run time is negligible on small amounts of data. So I tried it on 100,000 integers, and thats where I started to notice which ones are faster than the others.

I believe quicksort is known to be faster than mergesort so I was wondering what the problem in my code was. Referenced from ( https://www.youtube.com/watch?v=COk73cpQbFQ&list=PL2_aWCzGMAwKedT2KfDMB9YA5DgASZb3U&index=7 ) sorting algorithm series.

void quickSort(int num[], int start, int end) {
    if (start < end) {
        printf("\n.");
        int partitionIndex;
        partitionIndex = randomizedPartition(num, start, end);
        quickSort(num, 0, partitionIndex - 1);
        quickSort(num, partitionIndex + 1, end);
    }
}

int partition(int num[], int start, int end) {
    int partitionIndex = start;
    int pivot = end;
    int i;
    for (i = start; i < end; i++) {
        if (num[i] <= num[pivot]) {
            swap(num, i, partitionIndex);
            partitionIndex++;
        }
    }
    swap(num, partitionIndex, pivot);
    return partitionIndex;
}

int randomizedPartition(int num[], int start, int end) {
    int partitionIndex;
    int pivot = (rand() % (end - start + 1)) + start;
    swap(num, pivot, end);
    partitionIndex = partition(num, start, end);
    return partitionIndex;
}

the code above runs forever on an array of 100,000 integers while mergeSort runs for 18 seconds on my computer.

chqrlie
  • 131,814
  • 10
  • 121
  • 189
krom
  • 31
  • 5
  • 6
    I think you have issue with this line: `quickSort(num, 0, partitionIndex-1);`. It seems to me, you should replace `0` with `start`. – sardok Mar 08 '21 at 14:41
  • 5
    "Forever" sounds like a bug. test your code! – Paul Ogilvie Mar 08 '21 at 14:42
  • Be sure that your quicksort implementation is correct. If you have a bug, then measurements are meaningless. – Code-Apprentice Mar 08 '21 at 14:43
  • @sardok i understand that start would be 0 anyways right? since the left side would be partitioned next. – krom Mar 08 '21 at 14:48
  • 4
    @krom, imagine an array with 100 numbers. Let's say the *right* partition starts at 50. Now that partition will again split into two partitions. Where do you think that nested *left* partition has to start? – trincot Mar 08 '21 at 15:00
  • @pjs @trincot right! thanks for clarifying! ill try again with `0` replaced with `start`! – krom Mar 08 '21 at 15:07
  • update: now it crashes lol. but i understand now that i should replace `0` with `start`. Thank you for that. I'll have to further analyze what's wrong with the code. All the help is a learning opportunity and appreciated, thank you! – krom Mar 08 '21 at 15:15
  • What does your `swap` implementation look like? – Julian Mar 08 '21 at 15:34
  • Almost a duplicate of this: https://stackoverflow.com/questions/66531479/maximum-recursion-depth-exceeded-stack-overflow-exception. Your code is likely inefficient because of the recursion, get rid of it. – Lundin Mar 08 '21 at 15:35
  • (Though obviously a merge sort also implemented with recursion won't perform any better either. The choice of algorithm is secondary when you do something like forcing the compiler to make a 10,000 calls deep call stack...) – Lundin Mar 08 '21 at 15:38
  • @Lundin recursion cannot be entirely avoided with quicksort and mergesort. It's not really a concern, either; modern compilers will optimize one half of the recursion into a loop (which is as much recursion as can be avoided). – Julian Mar 08 '21 at 15:46
  • Oh, and 10000 calls deep is highly unlikely with a well-balanced divide-and-conquer. The binary logarithm of 1 billion is still only 30. – Julian Mar 08 '21 at 15:48
  • @Julian Nonsense, recursion can always be avoided. Don't confuse mathematical recursion for programmed recursion. Long as you can trace the "previous" instance somehow - there is nothing _forcing_ you to do that through the process call stack. If you follow the link I gave there's an iterative quick sort algorithm with no recursion, which will run in circles laughing and pointing fingers at the recursive version. – Lundin Mar 09 '21 at 09:34
  • @Lundin fair enough, recursive function calls can be avoided *if you implement your own stack*. That's not something I would recommend to anyone, however, since it means avoiding a language feature meant for that purpose just as a micro-optimization. I'm also very skeptical that the use of function calls would explain a dramatic slowdown; as I wrote before, the stack doesn't actually get that deep when quicksort is implemented well. – Julian Mar 09 '21 at 20:48
  • @Julian Or instead of a stack you can have a "previous" pointer either as part as the data or as a separate entity. You don't need to shovel the actual data around at all. Also, recursion as a language feature was added to C because it exists in assembler, where in turn it mostly exists by accident. As why function calls would sour performance, it's not just the function call overhead, but the missed possibility to optimize the code. No loop optimization or unrolling, no branch optimization, no possibility to inline parameters or know how the alias etc. – Lundin Mar 10 '21 at 07:44

3 Answers3

4

@sardok was right to point out a bug in your code. This is the main explanation for the speed difference. However:

I believe quicksort is known to be faster than mergesort

This is a bit of an oversimplification. Quicksort and mergesort are both optimal within their own domain: non-stable, in-place comparison sorts versus stable, copying comparison sorts, respectively.

For typical real-world data, you may very well find that quicksort performs a bit faster than mergesort, but there are definitey also datasets for which mergesort will perform better. In particular, when all keys are unique and the order of the data is fully randomized, you may find that a well-implemented mergesort is faster than a well-implemented quicksort.

I predict that when you have fixed the recursion bug, your quicksort implementation will still be a bit slower than your mergesort implementation. The reason for this is that you are generating a random number in order to select the pivot; this is an expensive operation. Using any single element from the array as the pivot (whether randomly selected or not) is also known to be suboptimal; you'll get better performance by selecting the median of the first, middle and last elements of the array (so-called median-of-three quicksort).

Furthermore, both quicksort and mergesort (but especially quicksort) are actually rather inefficient for small arrays. You'll get much better performance if you switch to insertionsort below array sizes of about 30 elements (the optimal threshold depends on the hardware and software platform, but 30 is a good ballpark threshold).

Finally, your partition algorithm can be made faster by passing the pivot as a separate value, without swapping it to the end of the array first, and by iterating from both ends:

int partition(int num[], int start, int end, int pivotValue) {
    while (start < end) {
        while (num[start] < pivotValue) ++start;
        while (num[end] > pivotValue) --end;
        swap(num, start++, end--);
    }
    return start;
}

This is faster because it avoids swapping elements that are already at the correct side of the array. Something to keep in mind, however, is that the originally selected pivot element is not necessarily at start by the end of the function; so after partitioning in this way, the element at the partitionIndex is not sorted yet. In other words, the right half of the array needs to recurse with quicksort(num, partitionIndex, end) instead of quicksort(num, partitionIndex + 1, end).

You can find much enlightenment about sorting algorithms by reading the original C++ STL implementation by Alex Stepanov.

Julian
  • 4,176
  • 19
  • 40
  • thats a lot to take in but all so very educational! Thank you for explaining! Ill get on analyzing what you said and trying out your suggestions. About the randomized partitioning, the youtube video i watched said it actually prevents the worst case time complexity of O(n^2). Sadly, my current code still crashes so I can’t compare it with what you suggested. Ill come back to it tomorrow morning! Thanks everyone – krom Mar 08 '21 at 16:00
  • 2
    Mergesort really shone back in the bad old days when memory was at a premium. In the mid-1970's I was a "power user" who had access to 64K of memory, while normal users were assigned 32K partitions for their work. Most sorting was done by spooling the data between tapes. These days most sorting can easily be done in-memory. – pjs Mar 08 '21 at 16:03
  • @krom random pivot selection protects against data that are specifically constructed in order to cause quadratic running time, but you can still be unlucky and hit quadratic running time by accident. The only way to completely prevent that is to keep track of the recursion depth and to switch to a different sorting algorithm when it crosses a limit. This is called introsort. So in production you'll always use introsort, and you never need a random number generator. – Julian Mar 08 '21 at 16:21
  • 1
    update: I did some changes following what @Julian said. First, I get the `median` of the `first`, `middle`, and `last` elements. I swapped the `median` to the `last`. That becomes the `pivot`. Then I passed the `pivotValue` to the partition function. I first tried partitioning with my original implementation (the one where I don't avoid those that are already sorted) and it finished sorting in 10.85 seconds. Then I tried @Julian's suggestion. It finished sorting in 10.82 seconds. That finally satisfied my curiosity. QuickSort was indeed faster than mergeSort for my data set. – krom Mar 09 '21 at 05:55
  • 1
    Furthermore, I tried doing insertionSort when the subarrays are less than or equal to 30. It finished after 17 seconds. On subarrays less than or equal to 20, It finished after 16 seconds. In the quickSort function, after checking if start is less than end, I then check if `end-start` is less than or equal to 30 or 20. If so, I use insertionSort, if not, I qucikSort. I saw a pdf online discussing improvements on quickSort (https://www.cs.cornell.edu/courses/JavaAndDS/files/sort3Quicksort3.pdf). I might try to follow this too. – krom Mar 09 '21 at 06:53
2

Although your particular implementation has other problems discussed by other answers, it's useful to understand an important principle when judging sort performance.

Simple metrics of sorting-algorithm performance simply count the number of comparisons and swaps, on the assumption that all comparisons have equal cost, and all swaps have equal cost. Many real-world systems, however, are designed to optimize the performance of certain common sequences of memory operations; while it used to be that there might be a 2:1 or 3:1 difference in cost between accessing a favored or non-favored location, that difference has increased to be 100:1 or possibly more. Thus, sorting algorithms which access things in the optimal order may outperform those that don't, even if they end up performing more comparisons or swaps.

supercat
  • 77,689
  • 9
  • 166
  • 211
  • Thank you for your insight. I understand now thanks to everyone's help. I'm now curious as to how to further improve on these algorithms but finding out takes time and patience. For now, I've successfully ran and compared mergeSort and quickSort thanks to @Julian 's and `sardok` 's help and have concluded that Quick Sort does run faster than merge Sort on my particular data set. Thank you everyone! – krom Mar 09 '21 at 06:57
1

Everyone has helped very much but I would like to include the corrections I made:

First, the primary bug was that I used 0 instead of start so I replaced that. Next, I used `median-of-three'. Then, for my partitioning, thanks to @Julian, I ended up with this line of code:

int partition3(int num[], int start, int end, int pivotValue){
   int left = start;
   int right = end - 1;
   while(left <= right){
       while(num[left] <= pivotValue && left <= right) ++left;
       while(num[right] >= pivotValue && left <= right) --right;
       if(left < right) swap(num, left++, right--);
   }
   swap(num, left, end);
   return left;

}

And with that, My problem was not only solved but I was also able to improve my quickSort algorithm. Here are some references I used and am currently reading:

  1. cs.cornell.edu/courses/JavaAndDS/files/sort3Quicksort3.pdf
  2. https://www.cs.bham.ac.uk/~jxb/DSA/dsa.pdf

Thanks!

krom
  • 31
  • 5