---
jupytext:
  formats: md:myst
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
kernelspec:
  display_name: Python 3 (ipykernel)
  language: python
  name: python3
---

# Loops (while and for)

:::{admonition} Education objectives
- blocks `while` and `for`
- keywords `in`, `break` and `continue`
- built-in functions `range` and `enumerate`
:::

## Loops with the keyword `while`

```{code-cell}
i = 0
while i < 4:
    i += 1
print("i =", i)
```

```{code-cell}
i = 0
while i < 4:
    i += 1
    print("i =", i)
```

```{exercise}
---
label: exercise-average
---
- Edit a script with Spyder that calculates the average of a set of numbers. For example `numbers = [67, 12, 2, 9, 23, 5]`

  - using the functions `sum` and `len`
  - manually (without `sum`), using the keyword `while`
  - check that the 2 methods give the same results with

  `assert avg0 == avg1`

- Run the script

  - in Spyder,
  - in a IPython session opened from another terminal,
  - with the command `python`.

```

```{solution-start} exercise-average
---
class: dropdown
---
```

```{code-cell}
numbers = [67, 12, 2, 9, 23, 5]

avg0 = sum(numbers) / len(numbers)

tmp = 0
i = 0
while i < len(numbers):
    tmp += numbers[i]
    i = i + 1
avg1 = tmp / len(numbers)

assert avg0 == avg1
```

```{solution-end}
```

### Simulating `do` `while stop_condition` construction

```python
while True:
    if stop_condition:
        break
    # content of the do-while loop
```

## Loops with the keyword `for`

```{code-cell}
values = range(5)
for i in values:
    print("i =", i)
```

The build-in function `range()` is very useful for loops. It creates a range object,
which is an iterable, immutable sequence of numbers.

```{code-cell}
# syntax is range(start, stop, step)
range(0, 4, 1)
```

(default start is 0, default step is 1)

```{code-cell}
range(4)
```

```{code-cell}
list(range(1, 8, 2))
```

`range()` is memory efficient because it does not store all the numbers in memory.

```{code-cell}
range(1_000_000_000_000_000)
```

:::{warning}
Do not try `list(range(1_000_000_000_000_000))` at home! Can you estimate the order of
magnitude of memory it would use?
:::

It is common to use `range()` directly in for loops:

```{code-cell}
for idx in range(4):
    print(idx, end=", ")
```

`for` loops are of course not limited to integers:

```{code-cell}
groceries = ["Carrots", "Cabbage", "Milk", "Onions", "Pepper"]

print("Groceries list")
for grocery in groceries:
    print("-", grocery)
```

The built-in function `enumerate` is very useful to access indices

```{code-cell}
print("My top 5 groceries:")
for index, grocery in enumerate(groceries):
    print(f"{index}. {grocery}")
```

## Loops: keywords `continue` and `break`

- `continue`: passes the block in the loop and continues the loop.

```{code-cell}
for x in range(1, 8):
    if x == 5:
        continue
    print(x, end=", ")
```

- `break`: stop the loop.

```{code-cell}
for x in range(1, 8):
    if x == 5:
        break
    print(x, end=", ")
```

```{exercise}
---
label: exercise-for-enumerate
---
- Extend your script with another method (using a `for` loop) to compute the average.

- In IPython, try to understand how the function `enumerate` works. Use it in your script.

```

```{solution-start} exercise-for-enumerate
---
class: dropdown
---
```

```{code-cell}
l = [67, 12, 2, 9, 23, 5]
# simple implementation with sum and len
avg0 = sum(l) / len(l)

# now with for and without sum
avg2 = 0
for e in l:
    avg2 += e
avg2 /= len(l)

# now with for and enumerate, but without sum and len
avg3 = 0
for i, e in enumerate(l):
    avg3 += e
avg3 /= i + 1

# and now let's check:
assert avg2 == avg0
```

```{solution-end}
```

```{exercise-start}
---
label: ex1
---
```

We build a list:

```{code-cell}
from random import randint, shuffle

n = 20
i_removed = randint(0, n - 1)
print("integer remove from the list:", i_removed)
numbers = list(range(n))
numbers.remove(i_removed)
shuffle(numbers)
print(f"shuffled list:\n  {numbers}")
```

One element has been removed:

- Find this element (given that you can change the ordering of `numbers`).
- Find this element (given that you cannot change the ordering of `numbers`).

```{exercise-end}
```

```{solution-start} ex1
---
class: dropdown
---
```

```{code-cell}
# we can change ordering, let's sort
print(numbers)
l_sorted = sorted(numbers)
print(l_sorted)
missing = None
for idx, elem in enumerate(l_sorted):
    if elem != idx:
        missing = idx
        break
if missing is None:
    missing = len(numbers)

print(f"{missing = }")
assert missing == i_removed
```

```{code-cell}
# we cannot sort -> higher complexity
for elem in range(len(numbers) + 1):
    if elem not in numbers:
        break
missing = elem

print(f"{missing = }")
assert missing == i_removed
```

```{code-cell}
# another solution
actual_sum = sum(numbers)
len_numbers = len(numbers)
original_sum = (len_numbers + 1) * (len_numbers) // 2
missing = original_sum - actual_sum

print(f"{missing = }")
assert missing == i_removed
```

```{code-cell}
# yet another solution:
availables = [0] * n
for number in numbers:
    availables[number] = 1

# now the removed element is the index of the only 0 element
missing = availables.index(0)
assert missing == i_removed
```

```{solution-end}
```

## `list`: list comprehension

They are iterable so they are often used to make loops. We have already seen how to use
the keyword `for`. For example to build a new list (side note: `x**2` computes `x^2`):

```{code-cell}
l0 = [1, 4, 10]
l1 = []
for number in l0:
    l1.append(number**2)

print(l1)
```

There is a more readable (and slightly more efficient) method to do such things, the
**list comprehension**:

```{code-cell}
l1 = [number**2 for number in l0]
print(l1)
```

```{code-cell}
# list comprehension with a condition
[s for s in ["a", "bbb", "e"] if len(s) == 1]
```

```{code-cell}
# lists comprehensions can be cascaded
[(x, y) for x in [1, 2] for y in ["a", "b"]]
```

```{exercise} advanced
---
label: exercise-extract-patterns
---
- Write a function `extract_patterns(text, n=3)` extracting the list of patterns
  of size `n=3` from a long string (e.g. if `text = "basically"`, patterns would
  be the list `['bas', 'asi', 'sic', ..., 'lly']`). Use list comprehension, range,
  slicing. Use a sliding window.

- You can apply your function to a long "ipsum lorem" string (ask to your favorite
  web search engine).

```

```{solution-start} exercise-extract-patterns
---
class: dropdown
---
```

```{code-cell}
text = "basically"


def extract_patterns(text, n=3):
    pat = [text[i : i + n] for i in range(len(text) - n + 1)]
    return pat


print("patterns=", extract_patterns(text))
print("patterns=", extract_patterns(text, n=5))
```

```{solution-end}
```
