CS-A1120 Programming 2
Department of Computer Science
Aalto University
(Based on material by Petteri Kaski and Tommi Junttila)
Discuss in pairs https://presemo.aalto.fi/prog2
We want the amount of needed resources scale well when the input instance grows in size.
Instance = problem description ≅ input data
In this course we will (mostly) focus on time efficiency.
Different ways of measuring the running time of a program:
In the following we will focus on measuring CPU time.
System.nanoTime
gives Wall Clock timejava.lang.management
for CPU Time
getCPUTime
measureCpuTime
measureCpuTimeRepeated
import java.lang.management.{ ManagementFactory, ThreadMXBean }
val bean: ThreadMXBean = ManagementFactory.getThreadMXBean()
def getCpuTime: Long =
if bean.isCurrentThreadCpuTimeSupported() then
bean.getCurrentThreadCpuTime()
else
0L
Measure once, good enough if we think the operation will take more than about 0.1 s.
/** Define minimum positive time greater than 0.0*/
val minTime = 1e-9 // Needed as Windows gives 0.0 on small durations
/**
* Runs the argument function f and measures the user+system time spent in it in seconds.
* Accuracy depends on the system, preferably not used for runs taking less than 0.1 seconds.
* Returns a pair consisting of
* - the return value of the function call and
* - the time spent in executing the function.
*/
def measureCpuTime[T](f: => T): (T, Double) =
val start: Long = getCpuTime
val r = f
val end: Long = getCpuTime
val t: Double = minTime max (end - start) / 1e9
(r, t)
Measure repeatedly and calculate average. Necessary for functions which will complete fast.
/**
* The same as measureCpuTime but the function f is applied repeatedly
* until a cumulative threshold time use is reached (currently 0.1 seconds).
* The time returned is the cumulative time divided by the number of repetitions.
* Therefore, better accuracy is obtained for very small run-times.
* The function f should be side-effect free!
*/
def measureCpuTimeRepeated[T](f: => T): (T, Double) =
val start: Long = getCpuTime
var end = start
var runs = 0
var r: Option[T] = None
while end - start < 100000000L do
runs += 1
r = Some(f)
end = getCpuTime
val t = minTime max (end - start) / (runs * 1e9)
(r.get, t)
scala> val data =
for n <- Seq(10000000,20000000,
30000000,40000000)
yield measureCpuTimeRepeated {
// Add up n first positive numbers
(1 to n).foldLeft(0L)(_+_)
}
val data: Seq[(Long, Double)] = List(
(50000005000000,0.107851275),
(200000010000000,0.191804696),
(450000015000000,0.28604331),
(800000020000000,0.379793504))
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} \]
Addition:
\(C = A + B\)
Rule: \(c_{(i,j)} = a_{(i,j)} + b_{(i,j)}\)
Multiplication:
\(C = A B\)
Rule: \(c_{(i,j)} = \sum_{k=0}^{n-1} a_{(i,k)} \times b_{(k,j)}\)
Examples:
Represent matrix as Array:
/** 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)
/** With this 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
/** With this 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 ...
So, the two-dimensional \(3 \times 3\) matrix
is represented 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]\).
Matrix element \(\left(i,j\right)\) is on place \(i \times n + j\) in the array.
For example, matrix element \(a_{(1,2)}\) is on index \(1*3 + 2 = 5\) in entries
.
class Matrix //... as before
/** Returns a new matrix that is the sum of this and that */
def +(that: Matrix): Matrix =
val result = new Matrix(n)
for row <- 0 until n; column <- 0 until n do
result(row, column) = this(row, column) + that(row, column)
result
end +
Rule: \(c_{(i,j)} = a_{(i,j)} + b_{(i,j)}\)
class Matrix //... as before
/** Returns a new matrix that is the product of this and that */
def *(that: Matrix): Matrix =
val result = new Matrix(n)
for row <- 0 until n; column <- 0 until n do
var v = 0.0
for i <- 0 until n do
v += this(row, i) * that(i, column)
result(row, column) = v
end for
result
end *
Rule: \(c_{(i,j)} = \sum_{k=0}^{n-1} a_{(i,k)} \times b_{(k,j)}\)
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(n) = \mathcal{O}(g(n))\) if there exist constants \(c,n_0 \gt 0\) such that \(f(n) \leq {c g(n)}\) for all \(n \geq n_0\).
We can also define an upper bound (\(\Omega\)) and 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\left(n\right)\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 = new Matrix(n)
for row <- 0 until n; column <- 0 until n do
var v = 0.0
for i <- 0 until n do
v += this(row, i) * that(i, column)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do
var v = 0.0
for i <- 0 until n do
v += this(row, i) * that(i, column)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do// O(n^2)
var v = 0.0
for i <- 0 until n do
v += this(row, i) * that(i, column)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do// O(n^2)
var v = 0.0 // O(n^2)
for i <- 0 until n do
v += this(row, i) * that(i, column)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do// O(n^2)
var v = 0.0 // O(n^2)
for i <- 0 until n do // O(n^3)
v += this(row, i) * that(i, column)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do// O(n^2)
var v = 0.0 // O(n^2)
for i <- 0 until n do // O(n^3)
v += this(row, i) * that(i, column) // O(n^3)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do// O(n^2)
var v = 0.0 // O(n^2)
for i <- 0 until n do // O(n^3)
v += this(row, i) * that(i, column) // O(n^3)
result(row, column) = 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 = new Matrix(n) // O(n^2)
for row <- 0 until n; column <- 0 until n do// O(n^2)
var v = 0.0 // O(n^2)
for i <- 0 until n do // O(n^3)
v += this(row, i) * that(i, column) // O(n^3)
result(row, column) = v // O(n^2)
end for
result // O(1)
end *
Almost 30-fold increase! - But still \(\mathcal{O}(n^3)\)
(Code optimisation of constant factors means less readability - only optimise when necessary)
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 itself.19
in (1,4,4,7,7,8,11,19,21,23,24,30)
k
in sequence s
s
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 s
s
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)\)