- Using `int` for indexing is wrong. Use `size_t`.

- An addition in `int q = (p + r) / 2;` may overflow. Consider

        int q = p + (r - p)/2;

- A single most important advantage of merge sort is stability: element compare equal retain their original order. In your implementation

        if(left_sub[i] < right_sub[j])

  stability is lost: of the two equals the one from the right is merged first.  It doesn't really matter for integers, but still

        if(left_sub[i] <= right_sub[j])

  is a correct way.

- As the partitions get smaller and smaller, a recursion overhead of quick sort becomes more and more expensive. Do not recurse into small partitions; have a final pass of an insertion sort instead, like in:

      void quick_sort_helper(int* arr, int low, int high) {
        if (low + CUTOFF < high) {
          int partition_index = partition(arr, low, high); // partition the array and get the partition index
          quick_sort_helper(arr, low, partition_index - 1); // recursively sort the left subarray
          quick_sort_helper(arr, partition_index + 1, high); // recursively sort the right subarray
        }
      }

      void quick_sort(int* arr, int size) {
        quick_sort_helper(arr, 0, size - 1);
        insertion_sort(arr, 0, size - 1);
      }

  Selection of a cutoff value is an interesting question. Insertion sorting an arbitrary array has a quadratic time complexity; however we are guaranteed that by the time `insertion_sort` is called, the array is _almost sorted_. Every element is within a `CUTOFF` of its final destination. The time complexity becomes `O(n * CUTOFF)`. Since we are striving to obtain an `O(N * log n)` complexity, `log n` is a natural choice.

  That said, most implementation use a cutoff constant like 16 or 32.