Time Complexity
Time complexity describes the amount of time needed to run an algorithm, in terms of the number of elementary operations performed. We use the Big-O Notation (e.g., $O(N)$, $O(N^2)$, $O(N \log N)$) to express time complexity.
We assume elementary operations (e.g., addition/multiplication) have a time complexity of $O(1)$ (constant time).
a = 1; b = 2; c = a + b; // This operation is O(1) time
Our program's runtime is usually dependent on some input, e.g., number of students / array length.
For example, the following algorithm creates a vector with $N$ elements, where $N$ is given in the input. It performs vector assignment, an $O(1)$ operation, for a total of $N$ times. So we say the time complexity of this algorithm is $O(N)$ (linear time).
vector<int> v(n); for (int i = 0; i < n; i++) { // The for-loop runs in O(N) time v[i] = i; }
Another example: we want to calculate the sum of $i \times j$ for each $1 \le i, j \le N$.
int s = 0; for (int i = 0; i < n; i++) { // This nested for-loop runs in O(N^2) time for (int j = 0; j < n; j++) { s += i * j; } }
It is possible that our program's runtime depends on multiple inputs. If we modify the last algorithm to calculate the sum of $i \times j$ for each $1 \le i \le N$ and $1 \le j \le M$, then:
int s = 0; for (int i = 0; i < n; i++) { // This nested for-loop runs in O(NM) time for (int j = 0; j < m; j++) { s += i * j; } }
Rules with Big-O Notation
We ignore coefficients and constants in Big-O notation. For example:
int s = 0; for (int i = 1; i <= 2 * n + 5; i++) s += i;
Even though the loop runs for $2N + 5$ iterations, we still say it is $O(N)$. This is because we only care about the order of magnitude of $N$ involved.
Another example:
int s = 0; for (int i = 0; i < n; i++) s++; // O(N) for (int i = 0; i < n * n; i++) s++; // O(N^2)
The two for-loops run in $O(N)$ and $O(N^2)$ respectively. Overall, they run in $O(N + N^2) = O(N^2)$. We only preserve the larger term because $N^2 \gg N$ as $N$ becomes large.
Vector
Insertion and deletion are $O(N)$, but push_back and pop_back are $O(1)$. Checking existence of an element via linear search is $O(N)$.
Set
Set is a data structure that allows for faster insertion and deletion (in $O(\log N)$ time where $N$ is the size of the set).
- Set is implemented as a binary search tree (BST), so it does not support random access (but vector does). Random access means being able to access the element in the $k$-th position in $O(1)$ time.
- Elements in a set are unique. To allow for duplicates, use a multiset instead.
- Iteration yields the elements in ascending order.
set<int> s; // #include <set> s.insert(1); // insert, O(log N) s.insert(2); s.insert(3); s.erase(2); // erase, O(log N) if (s.find(3) != s.end()) { // check existence, O(log N) cout << "exist\n"; } // alternatively use s.count(3) for (int i : s) { // iteration, O(N) cout << i << "\n"; }
Map
The implementation of map is largely similar to set,
so the time complexity of each operation is the same.
The difference is that map creates a mapping between keys and values,
which can function as a lookup table.
When we create a map<string, int>
,
we are specifying that the keys are strings and the values are integers.
We assign a key-value pair using m["apple"] = 3
and retrieve the value using the key by m["apple"]
.
Iteration through a map also follows the ascending order of keys.
map<string, int> m; // #include <map> m["apple"] = 3; // insert, O(log N). "apple" is the key and 3 is the value m["banana"] = 4; m["pear"] = 5; cout << m["apple"] << "\n"; // access, O(log N) m.erase("banana"); // erase, O(log N) if (m.find("apple") != m.end()) { // check existence, O(log N) cout << "found\n"; } for (auto [k, v] : m) { // iteration, O(N) cout << k << " " << v << "\n"; }
If we try to access $m[k]$ but $k$ is not already a key in the map $m$,
$m[k]$ gets assigned with a "default value" corresponding to the type.
In the context of the preceding code, accessing m["grapes"]
would assign
m["grapes"] = 0
and return 0
.
Authored by s16f22