AlgoInsight

Free lesson · Python Basic to Advance

NumPy Arrays

Hard10 min readpython

NumPy Arrays

What NumPy Is

NumPy is the foundational library for numerical computing in Python. Its central data structure is the ndarray - a multidimensional array of homogeneous values backed by a contiguous block of memory. Operations on ndarrays are vectorized: a single function call applies element-wise across the whole array, with the inner loop running in C rather than Python. The result is performance that approaches native compiled code while keeping the convenience of Python.

import numpy as np

# Create an array
a = np.array([1, 2, 3, 4, 5])
print(a)            # [1 2 3 4 5]
print(a.dtype)      # int64
print(a.shape)      # (5,)

# Vectorized operation - no explicit loop
b = a * 2
print(b)            # [ 2  4  6  8 10]

# Operate element-wise between arrays
c = a + b
print(c)            # [ 3  6  9 12 15]
This code creates a NumPy array and performs vectorized operations. Multiplying by two scales every element. Adding two arrays adds them element-wise. No explicit for loop - the work happens in a fast C loop under the hood. This vectorization is what makes NumPy fast for numerical work.

Install with pip install numpy. Convention: import numpy as np. The alias is universal - nearly all NumPy code uses it.

Why NumPy

NumPy earns its place because:

A simple comparison:

import numpy as np

# Python list - slow
py_list = list(range(1_000_000))
result = [x * 2 for x in py_list]    # tens of milliseconds

# NumPy array - fast
np_array = np.arange(1_000_000)
result = np_array * 2                 # ~10x faster, less memory
This code contrasts a list and an array for the same operation. The list comprehension runs in Python, looping element by element. The array operation runs vectorized in C. For a million elements, the speed difference is roughly tenfold. For larger data or more complex operations, the gap widens further.

For numerical work, that gap is the difference between code that runs in seconds and code that runs in hours.

Creating Arrays

Several ways to construct arrays:

import numpy as np

# From a Python list
a = np.array([1, 2, 3])

# Multidimensional from nested lists
m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.shape)      # (2, 3)

# Zeros, ones, empty
zeros = np.zeros((3, 4))
ones = np.ones((2, 2))
empty = np.empty(5)    # uninitialized; values are arbitrary

# Range-like
r = np.arange(10)           # 0..9
r2 = np.arange(0, 1, 0.1)   # 0, 0.1, 0.2, ..., 0.9
linspace = np.linspace(0, 1, 11)    # 11 evenly spaced points from 0 to 1

# Identity matrix
i = np.eye(3)

# Filled with a value
full = np.full((2, 3), 7)
This code shows array constructors. Array converts a list or nested list. Zeros, ones, and full create arrays of a fixed value. Arange is like Python's range but returns an array. Linspace is preferable for evenly spaced floating-point sequences - it specifies the count rather than the step, avoiding float-precision surprises. Eye creates an identity matrix.

The most commonly used: np.array from existing data, np.zeros and np.ones for placeholders, np.arange and np.linspace for sequences.

Data Types

Every ndarray has a single dtype - every element has the same type:

import numpy as np

# Default for integer literals
a = np.array([1, 2, 3])
print(a.dtype)       # int64 (on most systems)

# Default for floats
b = np.array([1.0, 2.0])
print(b.dtype)       # float64

# Explicit dtype
c = np.array([1, 2, 3], dtype=np.float32)
print(c.dtype)       # float32

d = np.array([1, 2, 3], dtype=np.int8)    # tiny integers
print(d.dtype)       # int8

# Common dtypes
# int8, int16, int32, int64
# uint8, uint16, uint32, uint64
# float16, float32, float64
# complex64, complex128
# bool
This code shows dtypes. Each array stores elements of one type. Default integer is int64 on most systems; default float is float64. Smaller dtypes save memory - useful for large arrays where the range fits. Choosing the right dtype affects memory usage and sometimes speed.

For large arrays, using float32 instead of float64 halves memory. For categorical-like integer data, int8 or int16 can be much smaller than the default. The trade-off is range and precision.

Shape, Size, and Dimensions

Every array has a shape that describes its dimensions:

import numpy as np

# 1-D
a = np.array([1, 2, 3, 4])
print(a.shape)      # (4,)
print(a.ndim)       # 1
print(a.size)       # 4

# 2-D
m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.shape)      # (2, 3) - 2 rows, 3 columns
print(m.ndim)       # 2
print(m.size)       # 6

# 3-D
t = np.zeros((2, 3, 4))
print(t.shape)      # (2, 3, 4)
print(t.ndim)       # 3
print(t.size)       # 24
This code shows array shapes. The shape is a tuple of dimension sizes. Ndim is the number of dimensions. Size is the total element count - the product of the shape's components. Two-D arrays are the most common in data work - they represent tables, matrices, and images. Three-D and higher appear in scientific computing and machine learning.

The shape (rows, columns) convention matches matrix math. For images, (height, width, channels) is typical.

Reshaping

Arrays can be reshaped without copying data, as long as the total size matches:

import numpy as np

a = np.arange(12)
print(a.shape)      # (12,)

# Reshape to 2-D
m = a.reshape(3, 4)
print(m.shape)      # (3, 4)
print(m)
# [[ 0  1  2  3]
#  [ 4  5  6  7]
#  [ 8  9 10 11]]

# -1 means "infer this dimension"
m2 = a.reshape(2, -1)    # 2 rows, columns inferred
print(m2.shape)     # (2, 6)

# Flatten back to 1-D
flat = m.flatten()
print(flat.shape)   # (12,)

# Transpose
t = m.T
print(t.shape)      # (4, 3)
This code reshapes arrays. Reshape returns a view of the same data with a new shape - fast, no copy. Minus one infers a dimension from the total size. Flatten returns a 1-D copy. The dot T attribute transposes - rows become columns. Reshape and transpose are the most common shape operations in data work.

Reshape doesn't copy data (unless the new shape isn't compatible with the memory layout). For huge arrays, this matters.

Indexing - Basics

NumPy supports rich indexing schemes:

import numpy as np

a = np.array([10, 20, 30, 40, 50])

# Single element
print(a[0])         # 10
print(a[-1])        # 50

# Slicing - returns a view, not a copy
print(a[1:4])       # [20 30 40]
print(a[::2])       # [10 30 50] - every other element

# Modifying a slice modifies the original
a[1:4] = 0
print(a)            # [10  0  0  0 50]
This code uses basic indexing. Integer indices select single elements. Slices return views - modifying the slice modifies the underlying array. This differs from Python lists, where slicing copies. The view behavior is faster but easier to misuse: an unintended modification to a slice changes the original.

The slice-as-view distinction matters. For independent data, use .copy().

Indexing - Multidimensional

For 2-D and higher arrays:

import numpy as np

m = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

# Single element - (row, col)
print(m[0, 0])      # 1
print(m[1, 2])      # 6

# Row
print(m[0])         # [1 2 3]
print(m[0, :])      # same - [1 2 3]

# Column
print(m[:, 0])      # [1 4 7]
print(m[:, 1])      # [2 5 8]

# Sub-matrix
print(m[0:2, 1:3])  # [[2 3] [5 6]]
This code shows 2-D indexing. Commas separate dimensions inside the brackets. A single integer or slice per dimension. Colons select all elements in a dimension. The result of a 2-D indexing operation may be a scalar, a 1-D slice, or a 2-D submatrix depending on what's selected.

The comma-separated form (m[0, 1]) is preferred over chained brackets (m[0][1]) - it's faster and matches the mathematical notation.

Boolean Indexing

Filter arrays using boolean masks:

import numpy as np

a = np.array([1, 2, 3, 4, 5, 6])

# Comparison produces a boolean array
mask = a > 3
print(mask)         # [False False False  True  True  True]

# Boolean indexing - select where mask is True
print(a[mask])      # [4 5 6]

# Combine conditions
mask = (a > 2) & (a < 5)
print(a[mask])      # [3 4]

# Assignment via boolean mask
a[a > 3] = 0
print(a)            # [1 2 3 0 0 0]
This code uses boolean indexing. Comparison operators produce boolean arrays of the same shape. Using a boolean mask as an index selects only the True positions. Combining conditions uses ampersand and pipe - not the Python and or or, which don't work element-wise. Boolean masks are one of NumPy's most useful features for filtering numerical data.

For element-wise logical operations, use &, |, and ~ - not and, or, not. The latter are short-circuiting Python operators and don't work on arrays.

Fancy Indexing

Select arbitrary elements by index array:

import numpy as np

a = np.array([10, 20, 30, 40, 50])

# Pick specific indices
indices = np.array([0, 2, 4])
print(a[indices])   # [10 30 50]

# Indices can repeat
print(a[[0, 0, 1, 1]])    # [10 10 20 20]

# For 2-D arrays
m = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
rows = np.array([0, 2])
cols = np.array([1, 2])
print(m[rows, cols])    # [2 9] - m[0,1] and m[2,2]
This code uses fancy indexing - an array of indices instead of slices. Useful for picking specific elements without writing a loop. For 2-D arrays, providing two index arrays selects pairs of coordinates. Fancy indexing returns a copy, not a view - unlike slices.

Useful Array Methods

A few methods that come up often:

import numpy as np

a = np.array([3, 1, 4, 1, 5, 9, 2, 6])

# Aggregations
print(a.sum())          # 31
print(a.mean())         # 3.875
print(a.min(), a.max()) # 1 9
print(a.std())          # standard deviation

# Sorted - returns a copy
print(np.sort(a))       # [1 1 2 3 4 5 6 9]

# Argsort - indices that would sort the array
print(np.argsort(a))    # [1 3 6 0 2 4 7 5]

# Unique values
print(np.unique(a))     # [1 2 3 4 5 6 9]

# Cumulative
print(np.cumsum(a))     # [ 3  4  8  9 14 23 25 31]
This code shows common methods. Sum, mean, min, max, std are the basic aggregations. Sort returns a sorted copy; argsort returns indices. Unique gives the unique values. Cumsum computes the running total. These methods work on 1-D arrays directly, and on multidimensional arrays with an axis argument - Topic 27.2 covers this.

For multidimensional arrays, most aggregations accept an axis argument to operate along one direction.

Copies vs Views

A common source of confusion:

import numpy as np

a = np.array([1, 2, 3, 4, 5])

# Slice - view, shares data
b = a[1:4]
b[0] = 99
print(a)            # [ 1 99  3  4  5] - original changed!

# Fancy indexing - copy
c = a[[1, 2, 3]]
c[0] = 0
print(a)            # original unchanged

# Explicit copy
d = a[1:4].copy()
d[0] = 0
print(a)            # unchanged
This code shows the copy versus view distinction. Slices return views - modifying them modifies the original. Fancy indexing returns copies. When in doubt, call dot copy explicitly. For functions that should not mutate their inputs, document the contract clearly or call copy at the boundary.

The rule of thumb: basic slicing is a view; advanced (boolean or fancy) indexing is a copy. When in doubt, use .copy().

Memory Layout

NumPy arrays are stored as contiguous blocks of memory. Two layouts:

import numpy as np

m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.flags["C_CONTIGUOUS"])  # True
print(m.flags["F_CONTIGUOUS"])  # False

# Force Fortran order
mf = np.asfortranarray(m)
print(mf.flags["F_CONTIGUOUS"]) # True
This code inspects memory layout. C-order matches C and most other languages - rows are stored sequentially. Fortran-order - used by some linear algebra libraries - stores columns sequentially. For most application code, the default is correct. Some scientific libraries are faster with one layout or the other.

For application code, defaults are usually right. For performance-critical interop with libraries (BLAS, LAPACK), layout sometimes matters.

Performance

NumPy's performance comes from:

Typical speedups over pure Python:

For numerical code, NumPy is usually the right starting point. For even better performance on specific workloads, libraries like numba, cython, or cupy (GPU) extend the same patterns.

Interview Relevance

Interview: NumPy basics are essential Python knowledge for any numerical or data role:

  • The ndarray as a typed, multi-dimensional, contiguous container
  • Vectorized operations vs Python loops
  • Array creation: np.array, np.arange, np.linspace, np.zeros/ones
  • dtype and how to choose it
  • Shape, reshape, T transpose
  • Indexing: basic, slicing (returns views), boolean masks, fancy indexing
  • Element-wise vs whole-array operations
  • Copies vs views - the slice-modifies-original surprise

A common interview moment: a candidate writes a Python for loop over an array. The right answer is the vectorized form.

Another moment: explaining why a[a > 3] = 0 works. The answer involves boolean masks as indices.

Module 30 covers Python interview preparation.

Common Pitfalls

Warning: Mixing and/or with arrays. Use &, |, ~ for element-wise logic. Wrap conditions in parentheses - operator precedence is different from Python's.

Modifying a slice and surprising the original. Slices are views. Use .copy() for independent data.

Treating an ndarray like a list. len(a) returns the first dimension size, not the total element count. Use .size.

Mixed dtypes. Arrays are homogeneous. Mixing int and str upcasts to object and loses NumPy's speed.

Forgetting axis arguments. a.sum() for a 2-D array sums everything. Often you want a.sum(axis=0) or a.sum(axis=1).

Ignoring memory usage. float64 for a billion-element array is 8 GB. Choose dtypes appropriate to the data.

Building arrays incrementally with np.append. Each append copies the entire array. Build a list and convert once at the end.

Try It

Exercise: Practice with NumPy:

  1. Create a 1-D array of 1,000,000 integers. Double each element with both a Python loop and a NumPy operation. Compare timings.
  2. Build a 4×4 matrix. Practice slicing - rows, columns, sub-matrices.
  3. Use boolean indexing to find all elements greater than the mean of an array.
  4. Reshape a 12-element array into a 3×4 matrix, then transpose to 4×3.
  5. Demonstrate the slice-as-view behavior: modify a slice and observe the original changing. Then use .copy() to break the link.
  6. Compare memory usage for the same data stored as int8 vs int64. Use a.nbytes.

Back to Python Basic to Advance