Does pass by reference and pass by value cause a change in time complexity of a program in C++?

Viewed 1244

In Java I never really had to worry about thinking if the argument is being passed by reference because it is not possible. In C++ though both ways to pass arguments either by value or reference are possible, would either way passing the argument have an effect on the time complexity analysis through big O notation? How would one determine the difference or change when the arguments are being passed by reference or value when calculating the big O notation?I have been finding some people say yes while others say no, is there a clear answer to this?

2 Answers

There is no a yes-no answer for this question, since sometimes converting one to the other may not work.

For explaining what happen, I take two simple examples for two directions.

From pass-by-reference to pass-by-value

In this direction, the answer is yes. e.g.

bool binary_search(std::vector<int> &arr, int key)
{
    return std::binary_search(arr.begin(),arr.end(),key);
}

This is a binary search function in a vector, and the complexity is O(log n) where n is arr.size().

But if we modify it to pass-by-value like:

bool binary_search(std::vector<int> arr, int key)
{
    return std::binary_search(arr.begin(),arr.end(),key);
}

The complexity become O(n) since the function should copy the arr.

From pass-by-value to pass-by-reference

In this direction, the conversion may not work. e.g.

std::vector<bool> batch_search(std::vector<int> arr, std::vector<int> keys)
{
    std::sort(arr.begin(), arr.end());
    std::vector<bool> res;
    for(auto key:keys)
    {
        res.push_back(std::binary_search(arr.begin(),arr.end(),key));
    }
    return res;
}

This is a batch search function. The function frist sort a copy of the vector, and search for each key. The complexity is O(n log n + m log n) where m is keys.size().

As you can see, the function sort the arr before using it. So directly converting arr from pass-by-reference to pass-by-value will not work.

One way to convert to pass-by-reference is like:

std::vector<bool> batch_search(std::vector<int> &arr, std::vector<int> keys)
{
    std::vector<int> copy_arr(arr);
    std::sort(copy_arr.begin(), copy_arr.end());
    std::vector<bool> res;
    for(auto key:keys)
    {
        res.push_back(std::binary_search(copy_arr.begin(),copy_arr.end(),key));
    }
    return res;
}

Just copy it, and sort the copy since what you need is the valus of the vector. Or in some sense, it's implement the pass-by-value using pass-by-referance.

Or an other way is like:

std::vector<bool> batch_search(std::vector<int> &arr, std::vector<int> keys)
{
    std::vector<bool> res;
    for(auto key:keys)
    {
        res.push_back(std::find(arr.begin(),arr.end(),key)!=arr.end());
    }
    return res;
}

This way change the algorithm to avoid sort, and the complexity is O(n*m).

But both the two approachs does not just convert the param, but rewrite the batch_search. When discussing pass-by-reference and pass-by-value, it seems some function implement by pass-by-reference cannot be directly converted to pass-by-value.

Is there a difference in the overall time complexity of an algorithm in C++ when using pass by value versus pass by reference?

This depends on how technical you want to get. The actual time complexity of the algorithm itself does not change. If a said sorting or searching algorithm is expressed as O(n) or O(log n) then that algorithm will always have that property.

As for the structure of your code in C++ that can vary on a wide variety of things. Such as your current hardware, Motherboard and CPU combo, the type of cache you have, how many cores and threads it has, your ram, etc... The operating system you are using, what kind of background processes are running. Your current compiler, what kind of optimizations you are using, are you multithreading or writing parallel programming source code, are you utilizing MMX registers, etc... There are a lot of factors that come into play.

Will your execution time change? Slightly, but almost insignificantly. Here's a demo application of sorting a vector that has a random size between [50k,100k] elements where their values range from [1,10k].

The output will vary each time you run this as a different size vector will be generated, but this is to demonstrate the same algorithm being performed both ways and to show the minimal time difference between the two: I write the values of both vectors before and after sorting to a file just to verify that they are the same before being sorted and that they were actually sorted. I only display the time executions to the console.

#include <algorithm>
#include <exception>
#include <chrono>
#include <iostream>
#include <iomanip>
#include <fstream>
#include <sstream>
#include <random>

template<class Resolution = std::chrono::milliseconds>
class ExecutionTimer {
public:
    using Clock = std::conditional_t < std::chrono::high_resolution_clock::is_steady,
        std::chrono::high_resolution_clock,
        std::chrono::steady_clock>;
private:
    const Clock::time_point mStart = Clock::now();
public:
    ExecutionTimer() = default;

    ~ExecutionTimer() {
        const auto end = Clock::now();
        std::ostringstream strStream;
        strStream << "Destructor Elapsed: "
            << std::chrono::duration_cast<Resolution>(end - mStart).count()
            << std::endl;
        std::cout << strStream.str() << std::endl;
    }

    inline void stop() {
        const auto end = Clock::now();
        std::ostringstream strStream;
        strStream << "Stop Elapsed: "
            << std::chrono::duration_cast<Resolution>(end - mStart).count()
            << std::endl;
        std::cout << strStream.str() << std::endl;
    }
};

std::vector<uint32_t> sortVectorByValue(std::vector<uint32_t> values) {
    std::vector<uint32_t> temp{ values };
    std::sort(temp.begin(), temp.end());
    return temp;
}

void sortVectorByReference(std::vector<uint32_t>& values) {
    std::sort(values.begin(), values.end());
}

void writeVectorToFile(std::ofstream& file, std::vector<uint32_t>& values) {
    int count = 0;
    for (auto& v : values) {
        if (count % 15 == 0)
            file << '\n';
        file << std::setw(5) << v << ' ';
        count++;
    }
    file << "\n\n";
}

int main() {
    try {
        std::random_device rd;
        std::mt19937 gen{ rd() };

        std::uniform_int_distribution<uint32_t> distSize(50000, 100000);
        std::uniform_int_distribution<uint32_t> distRange(1, 10000);
        
        std::vector<uint32_t> values;
        values.resize(distSize(gen));

        for (auto& v : values)
            v = distRange(gen);

        std::ofstream file;
        file.open("random numbers.txt");
        file << "values:\n";
        writeVectorToFile(file, values);

        std::vector<uint32_t> values2{ values };
        file << "values2:\n";
        writeVectorToFile(file, values2);

        // Using a local block scope to cause the Execution Timer to call its destructor
        {
            std::cout << "Evaluated Execution Time for Pass By Value of Sorting " << values.size() << " Elements:\n";
            ExecutionTimer timer;            
            values = sortVectorByValue(values);
            timer.stop();
        }

        {
            std::cout << "Evaluated Execution Time for Pass By Reference of Sorting " << values2.size() << " Elements:\n";
            ExecutionTimer timer;
            sortVectorByReference(values2);
            timer.stop();
        }

        file << "values1:\n";
        writeVectorToFile(file, values);
        file << "values2:\n";
        writeVectorToFile(file, values2);

        file.close();
    
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

I have an Intel Core 2 Quad Extreme 3.0Ghz with 8GB Ram running Windows 7 64bit. I'm using Visual Studio 2017 with C++17. I'm compiling everything in x64 mode.

Here are some of my random outputs in Debug Mode:

Trial 1

Evaluated Execution Time for Pass By Value of Sorting 93347 Elements:
Stop Elapsed: 247

Destructor Elapsed: 247

Evaluated Execution Time for Pass By Reference of Sorting 93347 Elements:
Stop Elapsed: 247

Destructor Elapsed: 247

Trial 2

Evaluated Execution Time for Pass By Value of Sorting 58782 Elements:
Stop Elapsed: 174

Destructor Elapsed: 174

Evaluated Execution Time for Pass By Reference of Sorting 58782 Elements:
Stop Elapsed: 172

Destructor Elapsed: 172

Trial 3

Evaluated Execution Time for Pass By Value of Sorting 67137 Elements:
Stop Elapsed: 194

Destructor Elapsed: 194

Evaluated Execution Time for Pass By Reference of Sorting 67137 Elements:
Stop Elapsed: 191

Destructor Elapsed: 191

Here are some trials in release mode with optimizations set to /O2

Trial 1

Evaluated Execution Time for Pass By Value of Sorting 61078 Elements:
Stop Elapsed: 4

Destructor Elapsed: 5

Evaluated Execution Time for Pass By Reference of Sorting 61078 Elements:
Stop Elapsed: 4

Destructor Elapsed: 4

Trial 2

Evaluated Execution Time for Pass By Value of Sorting 87909 Elements:
Stop Elapsed: 6

Destructor Elapsed: 6

Evaluated Execution Time for Pass By Reference of Sorting 87909 Elements:
Stop Elapsed: 6

Destructor Elapsed: 6

Trial 3

Evaluated Execution Time for Pass By Value of Sorting 93007 Elements:
Stop Elapsed: 7

Destructor Elapsed: 8

Evaluated Execution Time for Pass By Reference of Sorting 93007 Elements:
Stop Elapsed: 9

Destructor Elapsed: 9

All times are measured in milliseconds. It is safe to say that with the std::sort algorithm there is very minimal difference in passing by value as by reference. As you can see from the output above, even the initialization of values to the temp vector and the returning of the copy within the by-value version has very little overhead compared to its by-reference counterpart.

Yes, there is a little more work to do, but the leading factor in complexity is the term of the polynomial of the highest order. Performing a copy may not always be that expensive, especially with the optimization tricks that modern compilers will use, and how modern CPU's can utilize their cache as well as their vectorized memory registers...

I think you should be more concerned with choosing the right algorithm for the right problem and designing appropriate data structures to have proper memory alignment for better cache-hit performance than worrying about the minor semantics of pass by value versus pass by reference. Sometimes you will want to pass by value and others you will want to pass by reference. It's more of a matter of knowing when to use which feature based on the context of the problem at hand.

If a function needs a value from outside but doesn't change that outside value and later parts of your code don't require it to be updated, then pass by value... If a function requires a state of value but will change it and you will need to use it later after the function call, then pass by reference.

Also when passing containers of large size, passing by reference is normally the preferred choice... the demo that I showed only had up to 100k elements. What if a container had over 3 billion? Would you want to have to copy all 3 billion elements? Probably not. So when you have extremely large containers, it's better to pass by reference if you need to modify the contents of that container. If you only need to reference it to perform calculations for other variables within the scope of that local function, then pass by const reference.

Overall, does it change the time complexity of the algorithm? I'd say no it doesn't! Why?

Because O(n^2 + 10n) is still considered just O(n^2) and O(n + 10000) is still considered just O(n), etc.

Now on the other hand, if the object being copied is complex and requires a bunch of resources, dynamic memory allocations, etc... then yes this can change the complexity of the algorithm, but for anything that is considered RAII that is default constructible, trivially destructible, and even movable, then no it doesn't!

Related