Monday, 30 April 2018

Memoization :

Memoization is a technique for remembering the results that incur huge costs to running time to the algorithm.
Memoization in python is generally carried out using dictionary(hash) data type.
Memoization technique is simpler than it sounds.

A brief idea using example:
Let's take example of factorial function : 

'Native way : '
 
def fact(n):
    if n in [0, 1] : return 1
    else : return n*fact(n-1)

This is what we use generally for computing factorial.
Let's compute factorial for first 5000 natural numbers and calculate time:

'Native way : '
from time import time
def fact(n):
    if n in [0, 1] : return 1
    else : return n*fact(n-1)
start_time = time()
for i in range(5000):
    fact(i)
end_time = time()
print("Time required :", end_time-start_time)

Output :
Time required : 12.179325103759766

This computation took 12 secs in my OS!!
This may be lesser for you, but it is still a big time.
This is because, at every integer n, it  recursed for n-1 times.

Memoization does a smart move here.
Memoization says that, when you know that the result that you got will be used later on, why dont you store it??

Hence, in program, we will store value of computed value at each step.

memory = {}  # a dictionary storing n : fact(n)
def fact(n):
    try : return memory[n]
    except:
        "compute"
        if n in [0, 1] : return 1
        else :
            memory[n] = n*fact(n-1)
            return memory[n]

In this function, we have used try except:
try checks if key n is present in our memory dictionary.
If it is not present, it is computed, stored and returned.

Let's test it for its time complexity :
'Memoized '
from time import time
memory = {}  # a dictionary storing n : fact(n)
def fact(n):
    try : return memory[n]
    except:
        "compute"
        if n in [0, 1] : return 1
        else :
            memory[n] = n*fact(n-1)
            return memory[n]
start_time = time()
for i in range(5000):
    fact(i)
end_time = time()
print("Time required :", end_time-start_time)

OUTPUT:
Time required : 0.014145374298095703

see the fraction of difference!!
Memoized version is almost 860 times better than naive factorial function.

More Native functions memoized:

1) Fibonacci series:
memory = {}
def fib(n):
    try : return memory[n]
    except:
        if n <= 1 : return n
        else : return fib(n-1)+fib(n-2)

List Comprehension : 

 List comprehension is used to create an iterable which follows a particular fashion.

List comprehensions are a syntactic sugar for creating a list(iterable).

>>>"Expected output : [0, 0, 0, 0, 0]"
>>> 'Native way : '
>>> list = []
>>> for i in range(5):
...     list.append(0)
...
>>> list
[0, 0, 0, 0, 0]

"Using list comprehension : "
>>> list = [0 for i in range(5)]
>>> list
[0, 0, 0, 0, 0]
Isn't it fun and easy to read and write??

Let's understand it.

list = [0 for i in range(5)]
Here, there is a loop counter i which takes value from 0, 4 using the range function and foreverey value of the loop counter, 
0 is appended to the list.

Another example:
Creating [0, 1, 2, 3, 4, 5] : 
In previous example, we had appended 0 for each value of loop counter, 
Now, we will append the loop counter value:

>>> list = [i for i in range(5)]
>>> list
[0, 1, 2, 3, 4]
 Note : this can be achieved using list(range(5)), but for example i did this in that way.


just like normal for loop, we can iterate over an iterable, same can be done in list comprehension.
For example : 
1)
a = [1, 4, 7, 2]
b = [i+1 for i in a]

>>> a = [1, 4, 7, 2]
>>> b = [i+1 for i in a]
>>> b
[2, 5, 8, 3]

2)
 >>> names = ['math', 'pyqt', 'numpy', 'matplotlib', 'keras', 'tensorflow']
>>> length = [len(name) for name in names]
>>> length
[4, 4, 5, 10, 5, 10]

3)
>>> numbers = [2, 5, 1, 9]
>>> squares = [n*n for n in numbers]
>>> squares
[4, 25, 1, 81]

Easy, correct??

You may have a doubt that, There are some conditions where the list elements are not following same output which is dependent on conditions.
List comprehension supports if else (any boolean) conditions.

Case 1) Only if condition is needed.
Structure:
[operation for something in something  if condition]
>>> numbers = [3, 2, 1, 4, 5, 7, 6]
>>> even = [n for n in numbers if n%2==0]
>>> even
[2, 4, 6]

Case 2) Both if and else condition is needed.
Structure:
[cto if cond else fco for iteratioon]
cond :  condition to be checked
cto : condition true operation
fco : condition false operation.

Example:
>>> numbers = [3, 2, 1, 4, 5, 7, 6]
>>> even = [1 if n%2 == 0 else 0 for n in numbers]
>>> even
[0, 1, 0, 1, 0, 0, 1]



Nested List comprehension : 

It is generally used in representing graphs and multi-dimensional datatypes.
for example:

1)
0 0 0 0
0 0 0 0
0 0 0 0
0 0 0 0
 can be created using:
 >>> [[0 for i in range(4)] for i in range(4)]
[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]

2)
0 1 2 3
0 1 2 3
0 1 2 3
0 1 2 3
>>> [[i for i in range(4)] for i in range(4)]
[[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]]

3)
Problem : Create a 2d list whose (i ,j)th element is (i+j)

Soln:

0 1 2 3 
1 2 3 4
2 3 4 5
3 4 5 6
>>> [[i+j for i in range(4)] for j in range(4)]
[[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]]

4)
In Engineering / related courses, we generally come across situations for initializing with some conditions like:
(i, j)th element   =    0            if i = j;
                                 (i+j)!     otherwise

Soln:
0   1  2     6
1   0  6     24
2   6  0     120
6  24 120 0
>>> [[factorial(i+j) if i !=j else 0 for i in range(4)] for j in range(4)]
[[0, 1, 2, 6], [1, 0, 6, 24], [2, 6, 0, 120], [6, 24, 120, 0]]
Note : List comprehensions are not only used to create a list. ;)

A generator object, set, dictionary ....... any iterable,
except for tuples.

>>> (i for i in range(5))
<generator object <genexpr> at 0x7f654145a4c0>

This is what happens when you try to create a tuple.
Although, type casting does the task.


Sets : 

>>> {2*i for i in range(7)}
{0, 2, 4, 6, 8, 10, 12}

Dictionary:

>>> cubes = {i : i*i*i  for i in range(7)}
>>> cubes
{0: 0, 1: 1, 2: 8, 3: 27, 4: 64, 5: 125, 6: 216}

Splat Operator:

'*' is the splat operator in python
spalt operator is also known as scatter operator.
Splat operator is used to unpack a packed data type like list.

Generally, splat operator is used in methods (function) which can have any number of elements as input.

For example:
average method:

>>> def average(*elements):
...     s = 0
...     for e in elements:
...         s += e
...     print("Average :", s/len(elements))
...
>>> average(1,2,3)
Average : 2.0
>>> average(1,2,3,6,8,0)
Average : 3.3333333333333335


Note : Spalts are also used in retrieving elements from zipped objects.

For example:
>>> zip_object = zip(['one', 'two', 'three'], [1,2,3])
>>> zip_object
<zip object at 0x7f654145ce88>
>>> unzipped_list = [*zip_object]
>>> unzipped_list
[('one', 1), ('two', 2), ('three', 3)]

In Python3,
splat operator has more use in unpacking and assigning.

>>> a, *b, c = range(10)
>>> print(a, b, c)
0 [1, 2, 3, 4, 5, 6, 7, 8] 9

This property of unpacking is useful for handling multiple number of return parameters where all of them may/not be used.

Also in some revisions, user is also unaware of number of return parameters. In that case, splat is very useful.

>>> def unpredictable(n):
...     '''returns any number of random nuumbers upto n'''
...     return [random(1, n) for i in range(random(1, n))]
...
>>> a, b, *d = unpredictable(10)
>>> a
1
>>> b
6
>>> d
[3, 3, 5, 4, 7, 9]


Memoization : Memoization is a technique for remembering the results that incur huge costs to running time to the algorithm. Memoizati...