CS-A1120 Programming 2
Department of Computer Science
Aalto University
Discuss and let us know https://presemo.aalto.fi/prog2
We want the amount of resources necessary to complete a task to scale well when the input instance grows in size.
Different ways of measuring the running time of a program:
In the following we will focus on measuring CPU time.
// Import from Java import java.lang.management.{ManagementFactory, ThreadMXBean} // Set-up val bean: ThreadMXBean = ManagementFactory .getThreadMXBean() // Get time, or 0 if functionality not supported def getCpuTime: Long = if bean.isCurrentThreadCpuTimeSupported() then bean.getCurrentThreadCpuTime() else 0L
def measureCpuTime[T](f: => T): (T, Double) = val start: Long = getCpuTime val r = f val end: Long = getCpuTime val t: Double = (end - start) / 1e9 (r, t)
measureCpuTime:
getCPUTimef is call-by-name)getCPUTime
// Our summation of values from 1 to n def f(m: Long): Long = var i = 1L var s = 0L while i <= m do s = s + i i = i + 1 s // Time for all n in this sequence val ns = Seq(1000000000L, 2000000000L, 3000000000L, 4000000000L) // Perform measurement for each n in ns val fData = ns.map(n => measureCpuTime( f(n) ) )
After running the above measurement in a Scala REPL on a laptop we have the following values in fData (word-wrapped for readability):
scala> fData val res: Seq[(Long, Double)] = List( (500000000500000000,0.23691685), (2000000001000000000,0.469175766), (4500000001500000000,0.787165003), (8000000002000000000,1.017464763))
These can be plotted to give us a hint of how running time depends on \(n\).
In our case \(n \times n\) square matrices, e.g. \[ A = \begin{pmatrix} a_{(0,0)} & a_{(0,1)} & \cdots & a_{(0,n-1)}\\ a_{(1,0)} & a_{(1,1)} & \cdots & a_{(1,n-1)}\\ \vdots & \vdots & \ddots & \vdots\\ a_{(n-1,0)} & a_{(n-1,1)} & \cdots & a_{(n-1,n-1)} \end{pmatrix} \]
\(C = A + B\)
Per element:
\(c_{(i,j)} = a_{(i,j)} + b_{(i,j)}\)
For example:
\(C = A B\)
Per element:
\(c_{(i,j)} = \sum_{k=0}^{n-1} a_{(i,k)} \times b_{(k,j)}\)
For example:
/** Basic square matrix class.*/ class Matrix(val n: Int): require(n > 0, "The dimension n must be positive") protected[Matrix] val entries = new Array[Double](n * n) /** We can access elements by writing M(i,j)*/ def apply(row: Int, column: Int) = require(0 <= row && row < n) require(0 <= column && column < n) entries(row * n + column) end apply /** We can set elements by writing M(i,j) = v */ def update(row: Int, column: Int, value: Double): Unit = require(0 <= row && row < n) require(0 <= column && column < n) entries(row * n + column) = value end update //... More methods to come on subsequent slides...
val entries = new Array[Double](n * n)
is represented sequentially as the entries array \(\left[a_{(0,0)} , a_{(0,1)} , a_{(0,2)}, a_{(1,0)} , a_{(1,1)} , a_{(1,2)}, a_{(2,0)} , a_{(2,1)} , a_{(2,2)}\right]\).
entries.
class Matrix //... as before /** Returns a new matrix that is the sum of this and that */ def +(that: Matrix): Matrix = val result = Matrix(n) // This is a double for loop: for i <- 0 until n; j <- 0 until n do result(i, j) = this(i, j) + that(i, j) result end +
for i <- 0 until n; j <- 0 until n do is shorthand for
for i <- 0 until n do for j <- 0 until n do
class Matrix //... as before /** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) for i <- 0 until n; j <- 0 until n do var v = 0.0 for k <- 0 until n do v += this(i, k) * that(k, j) result(i, j) = v end for result end *
Using measureCpuTimeRepeated, for matrix size \(n = 100, 200, \ldots, 1600\).
The shape of the curves will stay the same!
Definition (Big-O) ( "grows at most as fast as" ):
For two (positive, real-valued) functions,\(f\) and \(g\), defined over non-negative integers \(n\) we write \(f = \mathcal{O}(g)\) if there exist constants \(c,n_0 \gt 0\) such that \(f(n) \leq {c g(n)}\) for all \(n \geq n_0\).
https://presemo.aalto.fi/prog2
Hint: Only degree matters for polynomial functions
We can also define an asymptotic lower bound (\(\Omega\)) and asymptotic equality (\(\Theta\)) for scaling as
Definition (\(\Omega\) - "grows at least as fast as" ):
\( f\left(n\right) = \Omega\left(g\left(n\right)\right)\), if and only if \(g\left(n\right) = \mathcal{O}\left(f\left(n\right)\right)\)
Definition (\(\Theta\) - "grows equally fast" ):
\(f\left(n\right) = \Theta\left(g\left(n\right)\right)\), if and only if \(f\left(n\right) = \mathcal{O}\left(g\left(n\right)\right)\) and \(f\left(n\right) = \Omega\left(g\left(n\right)\right)\)
The running time for a function/method/program is \(\mathcal{O}\left(f\right)\) if and only if for all inputs of size \(n\) the running time is \(\mathcal{O}\left(f\left(n\right)\right)\) time units.
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) for i <- 0 until n; j <- 0 until n do var v = 0.0 for k <- 0 until n do v += this(i, k) * that(k, j) result(i, j) = v end for result end *
We have to look at each statement and ask ourselves how many constant time instructions it takes.
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do var v = 0.0 for k <- 0 until n do v += this(i, k) * that(k, j) result(i, j) = v end for result end *
A Matrix contains \(n^2\) numbers - each needs to be initialised.
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do // O(n^2) var v = 0.0 for k <- 0 until n do v += this(i, k) * that(k, j) result(i, j) = v end for result end *
The for loop goes through all \(n^2\) elements.
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do // O(n^2) var v = 0.0 // O(n^2) for k <- 0 until n do v += this(i, k) * that(k, j) result(i, j) = v end for result end *
Assignment is \(\mathcal{O}(1)\), but applied \(\mathcal{O}(n^2)\) times due to loop.
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do // O(n^2) var v = 0.0 // O(n^2) for k <- 0 until n do // O(n^3) v += this(i, k) * that(k, j) result(i, j) = v end for result end *
Loop by itself is \(\mathcal{O}(n)\), but inside \(\mathcal{O}(n^2)\) loop, so \(\mathcal{O}(n^3)\).
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do // O(n^2) var v = 0.0 // O(n^2) for k <- 0 until n do // O(n^3) v += this(i, k) * that(k, j) // O(n^3) result(i, j) = v end for result end *
Several constant time operations inside loop. Important: Why are accessing values constant in this case?
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do // O(n^2) var v = 0.0 // O(n^2) for k <- 0 until n do // O(n^3) v += this(i, k) * that(k, j) // O(n^3) result(i, j) = v // O(n^2) end for result end *
Performed \(\mathcal{O}(n^2)\) times. Again - assumes assignment of value to element is \(\mathcal{O}(1)\).
/** Returns a new matrix that is the product of this and that */ def *(that: Matrix): Matrix = val result = Matrix(n) // O(n^2) for i <- 0 until n; j <- 0 until n do // O(n^2) var v = 0.0 // O(n^2) for k <- 0 until n do // O(n^3) v += this(i, k) * that(k, j) // O(n^3) result(i, j) = v // O(n^2) end for result // O(1) end *
Matrix implementation if we had used a ListBuffer instead of an Array for the member value entries?)
Almost 30-fold increase between for and while+transpose! (But still \(\mathcal{O}(n^3)\)…)
19 in (4,24,7,11,4,7,21,23,8,19,1,30)def linearSearch[T](s: IndexedSeq[T], k: T): Boolean = var i = 0 while(i < s.length) do if(s(i) == k) then return true // found k at position i i = i + 1 end while false // no k in sequence end linearSearch
s(i)) are constant time, linear search is \(\mathcal{O}(n)\).n from the repeated search times the n from the search itself19 in (1,4,4,7,7,8,11,19,21,23,24,30)k in sequence ss is already sorted in ascending orders is empty the element cannot be found. Stop.m be the middle (rounded down) element of s:
k = m, we are done. Stop.k < m, then the key can only appear in the first half of the sequence
s onlyk > m, then the key can only appear in the second half of the sequence
s only
k in sequence ss is already sorted in ascending orders is empty the element cannot be found. Stop.m be the middle (rounded down) element of s:
k = m, we are done. Stop.k < m, then the key can only appear in the first half of the sequence
s onlyk > m, then the key can only appear in the second half of the sequence
s only
def binarySearch[T](s: IndexedSeq[T], k: T)(using Ordering[T]) : Boolean = import math.Ordered.orderingToOrdered // To use the given Ordering //require(s.sliding(2).forall(p => p(0) <= p(1)), "s should be sorted") def inner(start: Int, end: Int): Int = if !(start < end) then start else val mid = (start + end) / 2 val cmp = k compare s(mid) if cmp == 0 then mid // k == s(mid) else if cmp < 0 then inner(start, mid-1) // k < s(mid) else inner(mid+1, end) // k > s(mid) end if end inner if s.length == 0 then false else s(inner(0, s.length-1)) == k end binarySearch
=, <, >) and access takes constant timesorted method) work in time \(\mathcal{O}(n \log n)\)
sorted method works in \(\mathcal{O}(n \log n)\)