If you evaluate the expression b = f1() then you are binding the name b with the list instance that was created inside f1() by the call to range().
Python is all references. The list object is created and a reference to the object is returned from the function, and then the reference is bound to the name b.
Your function f2() makes a generator that will first create a list instance with 10000 integers, and will bind the private variable name a with a reference to that list instance. Then, as you pull values from the iterator, each slicing operation in the loop will create a new list instance that will be yielded up. Once the loop completes and the last list instance has been yielded, the generator will be cleaned up, and at that time the list a will no longer be in use and will be garbage collected. (In CPython the garbage collection is based on reference counting and will work pretty promptly for this case. For other versions of Python such as Jython or PyPy, the garbage collection is much less predictable.)
I'm not a NumPy expert, but my understanding is that "views" (including slices) of array instances should take up very little memory. They don't make a copy of the original data. If you change f2() to build a numpy.array() instance with numpy.arange() and then yield up slices of it, I predict your program will use less memory. The current implementation of f2() creates and destroys 10 list instances, the slices of the list a; the NumPy array slices should avoid that.
I just tested the above:
import numpy as np
a = np.arange(100)
b = a[0:3]
b[0] = 99
assert a[0] == b[0]
In this example, b is a "view" into the array a. It doesn't allocate a new list or array, as proven by mutating the array by assigning to b[0]. The value at a[0] changes as well, because b is just another view of the same array.
(Anyone who is a NumPy expert, please point out if I have made any mistakes here. Thank you.)