3

I'm working with QuickSort and I tested these 2 algorithms on LeetCode for this problem. Both algorithms work, but one seems to be significantly faster than the other one and I don't understand why.

This one exceeds time limit of LeetCode:

function quickSort(array) {
    quickSortHelper(array, 0, array.length - 1);
    return array;
}

function quickSortHelper(array, start, end) {
    // Edge case
    if(start >= end) return;
    // Always choose the pivot index at the beginning of the array.
    const pivot = start;
    // The left index is next to the pivot on the right.
    let left = start + 1;
    let right = end;

    while (right >= left) {
        if (array[left] > array[right] && array[right] < array[pivot])
            swap(left, right, array);
        if (array[left] < array[pivot]) left += 1;
        if (array[right] > array[pivot]) right -= 1;
    }

    swap(pivot, right, array);

    // Always sort the smaller sub-array first
    const leftIsSmaller = right - 1 - start < end - (right + 1);

    if (leftIsSmaller) {
        quickSort(array, start, right - 1);
        quickSort(array, right + 1, end);
    }
    else {
        quickSort(array, right + 1, end);
        quickSort(array, start, right - 1);
    }
}

function swap(a, b, array) {
    [array[a], array[b]] = [array[b], array[a]];
    return array;
}

Big O:

  • Worst: Time = O(n^2) because if the pivot keeps being swapped to the end of the unsorted part, then we have O(n - 1) ~ O(n) every time = O(n*n)
  • Best: Time = O(nlog(n)) when the pivot devides the array into 2 halves.
  • Average: Time = O(nlog(n))
  • Space = O(log(n)) because recursion in place with 2 sub-arrays and quicksort against the smaller sub-array first.

This one doesn't exceed LeetCode time limit and it's significantly faster than most of the submissions. The 2 while loops inside the big while loop (to me) seem to be slower but it's actually faster.

function quickSort(array) {
    sortHelper(array, 0, array.length - 1);
    return array;
}

function sortHelper(array, start, end) {
    if (start >= end) return;
    let i = start;
    let j = end;
    let base = array[i];

    while (i < j) {
        while (i < j && array[j] >= base) j -= 1; // This makes sense but why this while loop has to happen before the other while loop?
        array[i] = array[j]; // this line
        while (i < j && array[i] <= base) i += 1;
        array[j] = array[i]; // and this line. don't they make you lose access to the values in the array?
    }
    array[i] = base;
    sortHelper(array, start, i - 1);
    sortHelper(array, i + 1, end);
}

Why is that?

Viet
  • 6,513
  • 12
  • 42
  • 74
  • 1
    `swap` is making 2 heap objects in a loop while code B is just incrementing/decrementing numbers. That might hurt, but I'm not seeing any time complexity differences at a glance. Have you done any profiling? – ggorlen Dec 08 '19 at 15:49
  • If you rewrote `swap()` in the first one so that it did a simple swap with a temporary local variable, the performance might be closer. (The runtime might effectively do that anyway to implement the destructuring assignment; I'm not sure but I'd certainly experiment with it.) – Pointy Dec 08 '19 at 16:02
  • @ggorlen Thank you for your suggestion. I haven't done profiling yet but I'm looking into it. – Viet Dec 08 '19 at 16:05
  • @Pointy: i've rewritten the `swap()` with `let tmp = array[a]; array[a] = array[b]; array[b] = tmp;` but the algorithm still exceeds time limit. – Viet Dec 08 '19 at 16:06
  • Although time complexity remains the same, you could further speed up the constant time part of quicksort by reducing the inner while loops to a single conditional as shown in the [wiki pseudo code](https://en.wikipedia.org/wiki/Quicksort#Hoare_partition_scheme) . – rcgldr Dec 08 '19 at 20:04

1 Answers1

3

There is no difference in time complexity, but the first version often performs the same (or opposite) data comparisons more than once. This is not happening in the second version.

For example, imagine that for the following if, the first expression is true, and the second false, and this during several iterations:

 if (array[left] > array[right] && array[right] < array[pivot])

...then it really is a waste that this first expression is checked that many times, given that left does not change from one iteration to the next when this first expression is true.

Take for example this extreme example array: [1, 6, 3, 2, 5, 4]

The pivot is 1.

During all iterations the right index will decrease until it reaches the left side. The number of comparisons per iteration is 4, and so for the 5 iterations together it is 20.

Now look at the faster algorithm. There the number of comparisons for the same input is 5 for the first inner loop and 0 for the second inner loop (I only count data comparisons).

This is an extreme example (20 versus 5), but it happens to a less degree also with more random inputs.

trincot
  • 317,000
  • 35
  • 244
  • 286
  • Thank you @trincot for your answer. That makes sense. I don't quite understand the faster algorithm. If you don't mind explaining it a bit further: why in the big while loop, the while loop for `j -= 1` has to be first? and don't `array[i] = array[j];` and `array[j] = array[i];` make you lose access to the values in the array? – Viet Dec 08 '19 at 16:21
  • 1
    You can write to `array[i]` because you have a copy of the original value in `base`. After that you can write to `array[j]` because you have a copy of the original now in `array[i]`. Because you start with `base = array[i]`, you must first work towards the assignment to `array[i] =` by doing the first inner loop. – trincot Dec 08 '19 at 16:31
  • "Always sort the smaller sub-array first" is likely to mitigate the risk of stack overflow when sorting very large arrays... – Trentium Dec 08 '19 at 18:24