Any time we place something like [2, 3, 4] or "hello" or 123456123 in our program, the corresponding object is placed into memory. For example, the following:
a = "hello"
Places the string "hello" in memory, and assigned the address where "hello" was stored to a. We can find that address using id(a).
Consider the following:
a = "CSC180"
b = "CSC180"
print(id(a))
print(id(b))
a and b refer to the same address. That's because Python saw that they refer to the same string, and decided to save space by not placing "CSC180" in two separate memory slots.
Since strings are immutable (i.e., their content cannot be changed), there is no danger here, even though a and b are aliases of each other. Since we cannot change the contents of a (we can, of course, make a refer to a new string), there is no danger that changing the contents of a will also change the contents of b.
Python is not always smart enough to save memory space that way
d = "CSC"
e = "180"
f = d + e #f == "CSC180"
print(id(a))
print(id(b))
print(id(f))
Even though the we could theoretically figure out that f can have the same id as a and b, this was not done, and there are now two copies of the string "CSC180" in memory.
What about integers? Python (or rather CPython, the version of Python we are using) always knows to find integers between -5 and 256, but may place larger (and smaller) integers in many places in memory.
Here is a fun thing to do: let's display the id's of integers between -10 and 299:
#Cannot do this with for i in range(-10, 300) directly because
#python keeps recreating the integers and putting them in
#different memory locations
nums = list(range(-10, 300))
for i in nums:
print(i, id(i), id(i+1)-id(i))
As you can see, -5..255 are placed in sequence in memory (with addresses differing by 32 from each other.) This is done when Python starts. Other integers are placed as they are needed in free spaces.
Unlike strings and integers, lists are mutable -- their contents can be changed. Consider:
L1 = [1, 2, 3]
L2 = [1, 2, 3]
We cannot have both L1 and L2 stored in the same address, because that would mean that modifying the contents of L1 (e.g. using L1[0] = 5, but not using L1 = [5, 2, 3], which is different) would also modify the contents of L2.
Indeed, we see
print(id(L1))
print(id(L2))
To remind you, here is what would happen if the addresses of L1 and L2 had been the same:
L1 = [1, 2, 3]
L2 = L1 #id(L1)==id(L2) now, since L1 and L2 are aliases
L2[0] = 5
print(L1)
print(L2)
Both L1 and L2 were changed when we went L2[0] = 5!