First of all: this has nothing to do with "Big O". Big O has nothing to do with complexity, algorithms, programming, computer science, etc. Big O simply compares growth rates of functions. It doesn't care what the functions describe, or whether they even describe anything at all.
What you are asking about, is simply figuring out the cost (runtime cost, memory cost, whatever …) of a program. And the way you do that is exactly the same in functional languages as it is in imperative languages:
- define what you want to measure (runtime, memory, operations, …)
- define your machine model (Universal Turing Machine, Multi-Tape Turing Machine, Random Access Machine, λ-calculus, …)
- define your cost model
- identify your primitives (stores, loads, comparisons, swaps, …)
- count them
- Done!
Note that all of those are important. For example, as we all know, sorting is O(n log n), right? Wrong! Comparison-based sorting in a Random Access Machine Model takes O(n log n) comparisons and swaps. But there are non-comparison based sorts such as radix sort and counting sort, which only need O(n) comparisons and swaps. And there are machine models where you are only allowed to swap two adjacent elements, but not two arbitrary elements, and in that case, it can be proven, that you cannot do better than O(n2).
For example, for search algorithms, we are often interested in counting the number of comparisons. Well, it doesn't matter whether your code is pure or not, even the purest of the pure functional languages needs to do comparisons to find something. Finding something in an unsorted array takes n/2 comparisons on average, n comparisons in the worst-case, and you count those comparisons the same way regardless of whether we are talking about a functional or an imperative language.
For sorting algorithms, we are usually interested in the number of comparisons and swaps. This is where it gets interesting, because obviously in a purely functional language with a purely functional data structure, you cannot do in-situ swaps, you will always produce a new data structure with the two elements swapped. But! The same thing is true for an imperative language trying to sort a purely functional data structure.
So, in this case, the difference is not about the languages but more about the data structures. Or, you could say, we have two different algorithms, one using an immutable data structure, and one using a mutable data structure. And for the immutable data structure, maybe counting "swaps" makes no sense and you need to count something different.
When we want to be very generic, we often count "primitive operations" of some theoretical machine, such as loads, stores, pointer dereferences, and integer add and subtract. For λ-calculus, there are similar ideas, by counting the number of reductions.
In your specific example, what we are interested in, is "number of executions of the transformation operation". And that is certainly something that we can compute for both the imperative and the functional versions. In the imperative version, the "transformation operation" is the loop body. In the functional version, it is the function passed as the first argument to map
.
In both cases, we don't even need Big O at all, we don't need to estimate the growth rate, because we can count exactly how often it gets executed: it gets executed exactly prev.Count
times, in both cases.
However, note that your two examples aren't exactly equivalent.
Take, for example, the following C♯ code:
var result = prev.Select(el => el * 2);
// alternatively:
var result = from el in prev select el * 2;
or, in ECMAScript:
const result = prev.map(el => el * 2);
That is much more comparable to the code in your functional example, but written in an imperative language.
The different implementations of map
in imperative and functional style might look something like this (all examples in ECMAScript):
// imperative style
Array.prototype.imperativeMap = function (fn) {
const res = [];
for (let el of this) res.push(fn(el));
return res;
};
// naive functional style in O(n) call stack space
Array.prototype.naiveFunctionalMap = function (fn) {
const [first, ...rest] = this;
return this.length === 0 ? [] : [fn(first)].concat(rest.map(fn));
};
// tail-recursive functional style in O(1) call stack space
Array.prototype.tailRecursiveFunctionalMap = function (fn) {
const mapTailrec = (ary, acc) =>
ary.length === 0 ?
acc :
mapTailrec(ary.slice(0, -1), acc.concat([fn(ary[ary.length-1])]));
return mapTailrec(this, []).reverse();
};
As you can see, in all cases, there are things you can count. You just need to decide what you want to count. Some things that make sense to count: number of invocations of the callback function (n in all three cases), depth of the call stack (O(1) in case #1 and #3, O(n) in case #2), size of intermediate data structures (O(n) in all three cases).
For more information, possibly more than you ever wanted to know, you could start at this Lambda the Ultimate post titled Cost semantics for functional languages, which lists several papers on, well, cost semantics for functional languages and additionally has a lively discussion in the comments section, both of the papers listed and the topic at large.