8  Network Optimization

Chapter 4 – Part II: Core Optimization Techniques

Author

Troy Altus

Published

April 14, 2026

8.1 Networks Are Everywhere

A network is a set of nodes connected by edges. That simple structure models an enormous range of real-world systems:

  • Road and rail networks — nodes are intersections, edges are roads
  • Supply chains — nodes are factories, warehouses, and retailers; edges are shipping lanes
  • Telecommunications — nodes are routers, edges are fibre links
  • Social networks — nodes are people, edges are relationships
  • Project schedules — nodes are tasks, edges are dependencies

Network Optimization exploits the structure of these problems to solve them far more efficiently than general-purpose LP or IP methods. The two key tools are graph theory (representing structure) and network flow (optimising over that structure).

Python’s networkx library provides the data structures; pulp provides the optimisation. Together they handle problems from shortest paths to minimum cost flows.


8.2 Graph Fundamentals

A graph \(G = (V, E)\) consists of:

  • \(V\) — a set of vertices (nodes)
  • \(E\) — a set of edges (arcs), where each edge connects two vertices

Directed graphs (digraphs) have edges with a direction — from a tail node to a head node. Flow on an arc moves in one direction only.

Undirected graphs have edges with no direction — you can traverse them either way.

Key properties:

Term Definition
Degree Number of edges incident to a node
Path Sequence of edges connecting two nodes without repeating nodes
Cycle A path that starts and ends at the same node
Connected Every pair of nodes has at least one path between them
Tree A connected graph with no cycles (\(\|E\| = \|V\| - 1\))
Spanning tree A tree that includes all nodes of the graph
Code
import networkx as nx
import matplotlib.pyplot as plt

G = nx.DiGraph()
edges = [
    ("A", "B", 4), ("A", "C", 2), ("B", "C", 5),
    ("B", "D", 10), ("C", "E", 3), ("D", "F", 11),
    ("E", "D", 4), ("E", "F", 7), ("F", "G", 2)
]
G.add_weighted_edges_from(edges)

pos = nx.spring_layout(G, seed=42)
weights = nx.get_edge_attributes(G, "weight")

fig, ax = plt.subplots(figsize=(7, 4))
nx.draw_networkx(G, pos, ax=ax, node_color="#6366f1", node_size=600,
                 font_color="white", font_size=10, arrows=True,
                 arrowsize=15, edge_color="#94a3b8", width=1.5)
nx.draw_networkx_edge_labels(G, pos, edge_labels=weights, font_size=8, ax=ax)
ax.set_title("Directed Weighted Graph")
ax.axis("off")
plt.tight_layout()
plt.show()
Figure 8.1: A directed weighted graph

8.3 Shortest Path

Problem: Find the minimum-cost path from a source node \(s\) to a destination node \(t\) in a weighted graph.

Applications: GPS routing, network packet delivery, project critical path.

8.3.1 Dijkstra’s Algorithm

For graphs with non-negative edge weights, Dijkstra’s algorithm finds shortest paths from a single source in \(O((V + E) \log V)\) time. It maintains a priority queue of nodes ordered by their current shortest distance from the source, greedily expanding the closest unvisited node at each step.

Code
import networkx as nx

G = nx.DiGraph()
edges = [
    ("A", "B", 4), ("A", "C", 2), ("B", "C", 5),
    ("B", "D", 10), ("C", "E", 3), ("D", "F", 11),
    ("E", "D", 4), ("E", "F", 7), ("F", "G", 2)
]
G.add_weighted_edges_from(edges)

path   = nx.dijkstra_path(G, "A", "G", weight="weight")
length = nx.dijkstra_path_length(G, "A", "G", weight="weight")

print(f"Shortest path : {' → '.join(path)}")
print(f"Total cost    : {length}")
Shortest path : A → C → E → F → G
Total cost    : 14

8.3.2 Visualising the Shortest Path

Code
import matplotlib.pyplot as plt

pos = {"A": (0,1), "B": (1,2), "C": (1,0), "D": (2,2),
       "E": (2,0), "F": (3,1), "G": (4,1)}

path_edges = list(zip(path[:-1], path[1:]))
non_path   = [(u, v) for u, v, _ in edges if (u, v) not in path_edges]
weights    = nx.get_edge_attributes(G, "weight")

fig, ax = plt.subplots(figsize=(8, 4))
nx.draw_networkx_nodes(G, pos, node_color="#6366f1", node_size=600, ax=ax)
nx.draw_networkx_labels(G, pos, font_color="white", font_size=10, ax=ax)
nx.draw_networkx_edges(G, pos, edgelist=non_path, edge_color="#cbd5e1",
                       arrows=True, arrowsize=15, ax=ax)
nx.draw_networkx_edges(G, pos, edgelist=path_edges, edge_color="#ef4444",
                       width=2.5, arrows=True, arrowsize=18, ax=ax)
nx.draw_networkx_edge_labels(G, pos, edge_labels=weights, font_size=8, ax=ax)
ax.set_title(f"Shortest Path A → G (cost = {length})")
ax.axis("off")
plt.tight_layout()
plt.show()
Figure 8.2: Shortest path from A to G highlighted in red

8.3.3 Bellman-Ford for Negative Weights

Dijkstra fails on graphs with negative edge weights. Bellman-Ford handles them in \(O(VE)\) time and also detects negative cycles (cycles where the total weight is negative — the “shortest path” would be infinitely short by cycling forever).

Code
G_neg = nx.DiGraph()
G_neg.add_weighted_edges_from([
    ("S", "A", 6), ("S", "B", 7), ("A", "B", 8), ("A", "C", -4),
    ("B", "C", -3), ("B", "D", 9), ("C", "D", 7), ("D", "A", 2)
])

try:
    path_bf   = nx.bellman_ford_path(G_neg, "S", "D", weight="weight")
    length_bf = nx.bellman_ford_path_length(G_neg, "S", "D", weight="weight")
    print(f"Bellman-Ford path   : {' → '.join(path_bf)}")
    print(f"Total cost          : {length_bf}")
except nx.exception.NetworkXUnbounded:
    print("Negative cycle detected — shortest path is undefined")
Bellman-Ford path   : S → A → C → D
Total cost          : 9

8.4 Minimum Spanning Tree

Problem: Connect all nodes in an undirected graph with the minimum total edge weight, using exactly \(|V| - 1\) edges (a tree).

Applications: Network cable layout, cluster analysis, approximation algorithms for TSP.

Two classic algorithms:

  • Kruskal’s — sort edges by weight; add the cheapest edge that does not create a cycle
  • Prim’s — grow a tree from a seed node by repeatedly adding the cheapest edge connecting the tree to a new node

Both run in \(O(E \log E)\) for typical implementations.

Code
import networkx as nx
import matplotlib.pyplot as plt

G_und = nx.Graph()
G_und.add_weighted_edges_from([
    (1,2,4),(1,3,8),(2,3,11),(2,4,8),(3,5,7),(4,5,2),
    (4,6,4),(4,7,9),(5,7,14),(6,7,10),(6,8,2),(7,8,1)
])

mst      = nx.minimum_spanning_tree(G_und, weight="weight")
mst_cost = sum(d["weight"] for _, _, d in mst.edges(data=True))

pos       = nx.spring_layout(G_und, seed=0)
mst_edges = list(mst.edges())
non_mst   = [(u, v) for u, v in G_und.edges() if (u,v) not in mst_edges and (v,u) not in mst_edges]
all_weights = nx.get_edge_attributes(G_und, "weight")

fig, ax = plt.subplots(figsize=(7, 5))
nx.draw_networkx_nodes(G_und, pos, node_color="#6366f1", node_size=500, ax=ax)
nx.draw_networkx_labels(G_und, pos, font_color="white", font_size=10, ax=ax)
nx.draw_networkx_edges(G_und, pos, edgelist=non_mst, edge_color="#cbd5e1", ax=ax)
nx.draw_networkx_edges(G_und, pos, edgelist=mst_edges,
                       edge_color="#ef4444", width=2.5, ax=ax)
nx.draw_networkx_edge_labels(G_und, pos, edge_labels=all_weights, font_size=8, ax=ax)
ax.set_title(f"Minimum Spanning Tree (total cost = {mst_cost})")
ax.axis("off")
plt.tight_layout()
plt.show()
Figure 8.3: Minimum Spanning Tree highlighted in red

8.5 Maximum Flow

Problem: Given a directed graph with edge capacities, find the maximum amount of flow that can be sent from a source \(s\) to a sink \(t\) without exceeding any edge capacity.

Applications: Pipeline capacity, internet bandwidth allocation, bipartite matching.

8.5.1 The Max-Flow Min-Cut Theorem

The maximum flow from \(s\) to \(t\) equals the minimum cut — the minimum total capacity of edges that, if removed, would disconnect \(s\) from \(t\).

This duality is one of the most elegant results in combinatorial optimisation. It says that the bottleneck in a network is always a cut — a set of edges that, collectively, limits all paths from source to sink.

Code
import networkx as nx

G_flow = nx.DiGraph()
G_flow.add_edge("s", "a", capacity=15)
G_flow.add_edge("s", "b", capacity=4)
G_flow.add_edge("a", "b", capacity=12)
G_flow.add_edge("a", "c", capacity=10)
G_flow.add_edge("b", "d", capacity=10)
G_flow.add_edge("c", "d", capacity=9)
G_flow.add_edge("c", "t", capacity=15)
G_flow.add_edge("d", "t", capacity=10)

flow_value, flow_dict = nx.maximum_flow(G_flow, "s", "t")

print(f"Maximum flow: {flow_value}")
print("\nFlow on each edge:")
for u in flow_dict:
    for v, f in flow_dict[u].items():
        if f > 0:
            cap = G_flow[u][v]["capacity"]
            print(f"  {u}{v}: {f} / {cap}")
Maximum flow: 19

Flow on each edge:
  s → a: 15 / 15
  s → b: 4 / 4
  a → b: 5 / 12
  a → c: 10 / 10
  b → d: 9 / 10
  c → t: 10 / 15
  d → t: 9 / 10

8.5.2 Visualising Flow

Code
import matplotlib.pyplot as plt

pos = {"s": (0,1), "a": (1,2), "b": (1,0), "c": (2,2), "d": (2,0), "t": (3,1)}

edge_labels = {
    (u, v): f"{flow_dict[u].get(v, 0)}/{G_flow[u][v]['capacity']}"
    for u, v in G_flow.edges()
}
edge_colors = [
    "#ef4444" if flow_dict[u].get(v, 0) == G_flow[u][v]["capacity"] else "#94a3b8"
    for u, v in G_flow.edges()
]

fig, ax = plt.subplots(figsize=(7, 4))
nx.draw_networkx(G_flow, pos, ax=ax, node_color="#6366f1", node_size=600,
                 font_color="white", font_size=10, arrows=True,
                 arrowsize=15, edge_color=edge_colors, width=2)
nx.draw_networkx_edge_labels(G_flow, pos, edge_labels=edge_labels,
                              font_size=8, ax=ax)
ax.set_title(f"Max Flow = {flow_value}  (red = saturated edges)")
ax.axis("off")
plt.tight_layout()
plt.show()
Figure 8.4: Maximum flow network — edge labels show flow/capacity

Saturated edges (flow = capacity, shown in red) form the minimum cut — these are the bottlenecks limiting total throughput.


8.6 Minimum Cost Flow

Minimum Cost Flow (MCF) is the most general network flow problem. It subsumes shortest path, max flow, transportation, and assignment problems as special cases.

Formulation: Each arc \((i,j)\) has a capacity \(u_{ij}\), a lower bound \(l_{ij}\), and a unit cost \(c_{ij}\). Each node has a supply \(b_i\) (positive if it generates flow, negative if it consumes flow, zero if it is a transshipment node).

\[\text{Minimize} \quad \sum_{(i,j) \in E} c_{ij} x_{ij}\]

\[\text{s.t.} \quad \sum_{j:(i,j)\in E} x_{ij} - \sum_{j:(j,i)\in E} x_{ji} = b_i \quad \forall i \in V \quad \text{(flow balance)}\]

\[l_{ij} \leq x_{ij} \leq u_{ij} \quad \forall (i,j) \in E\]

The flow balance constraint says: flow out minus flow in equals supply at each node. Supply nodes (\(b_i > 0\)) generate flow; demand nodes (\(b_i < 0\)) absorb it.

Code
import pulp

# Three suppliers (S1, S2, S3), three customers (C1, C2, C3), two warehouses (W1, W2)
# Cost per unit on each arc, capacity, supply/demand

nodes = ["S1", "S2", "S3", "W1", "W2", "C1", "C2", "C3"]

supply = {"S1": 120, "S2": 80,  "S3": 100,
          "W1": 0,   "W2": 0,
          "C1": -90, "C2": -110, "C3": -100}

arcs = {
    ("S1", "W1"): (8,  120), ("S1", "W2"): (6,  120),
    ("S2", "W1"): (5,  80),  ("S2", "W2"): (9,  80),
    ("S3", "W1"): (7,  100), ("S3", "W2"): (4,  100),
    ("W1", "C1"): (3,  200), ("W1", "C2"): (5,  200), ("W1", "C3"): (6,  200),
    ("W2", "C1"): (7,  200), ("W2", "C2"): (2,  200), ("W2", "C3"): (4,  200),
}  # (cost, capacity)

model = pulp.LpProblem("Min_Cost_Flow", pulp.LpMinimize)

x = {(i, j): pulp.LpVariable(f"x_{i}_{j}", lowBound=0, upBound=cap)
     for (i, j), (_, cap) in arcs.items()}

# Objective
model += pulp.lpSum(cost * x[i, j] for (i, j), (cost, _) in arcs.items())

# Flow balance
for node in nodes:
    out_flow = pulp.lpSum(x[i, j] for (i, j) in arcs if i == node)
    in_flow  = pulp.lpSum(x[i, j] for (i, j) in arcs if j == node)
    model += out_flow - in_flow == supply[node], f"Balance_{node}"

model.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"Status      : {pulp.LpStatus[model.status]}")
print(f"Total cost  : ${pulp.value(model.objective):,.0f}\n")
print("Flow routing:")
for (i, j), var in x.items():
    flow = pulp.value(var)
    if flow and flow > 0.01:
        print(f"  {i}{j}: {flow:.0f} units")
Status      : Optimal
Total cost  : $2,430

Flow routing:
  S1 → W1: 10 units
  S1 → W2: 110 units
  S2 → W1: 80 units
  S3 → W2: 100 units
  W1 → C1: 90 units
  W2 → C2: 110 units
  W2 → C3: 100 units

MCF is solvable in polynomial time using the network simplex algorithm — a specialised version of the Simplex Method that exploits the tree structure of network flow bases. It is typically orders of magnitude faster than general LP for network problems.


8.7 The Transportation Problem

The transportation problem is a special case of MCF with a bipartite structure: \(m\) supply nodes and \(n\) demand nodes, all connected directly (no intermediate transshipment nodes).

It models the classic question: how to ship goods from warehouses to customers at minimum cost, given supply and demand constraints?

Code
import pulp
import numpy as np
import matplotlib.pyplot as plt

# 3 suppliers, 4 customers
supply   = [300, 400, 500]
demand   = [250, 350, 400, 200]
cost_mat = np.array([
    [2, 3, 1, 5],
    [7, 3, 4, 2],
    [6, 1, 3, 4],
])

n_sup = len(supply)
n_dem = len(demand)

model = pulp.LpProblem("Transportation", pulp.LpMinimize)

x = [[pulp.LpVariable(f"x_{i}_{j}", lowBound=0)
      for j in range(n_dem)] for i in range(n_sup)]

# Objective
model += pulp.lpSum(cost_mat[i, j] * x[i][j]
                    for i in range(n_sup) for j in range(n_dem))

# Supply constraints
for i in range(n_sup):
    model += pulp.lpSum(x[i][j] for j in range(n_dem)) <= supply[i], f"Supply_{i}"

# Demand constraints
for j in range(n_dem):
    model += pulp.lpSum(x[i][j] for i in range(n_sup)) == demand[j], f"Demand_{j}"

model.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"Status     : {pulp.LpStatus[model.status]}")
print(f"Total cost : ${pulp.value(model.objective):,.0f}\n")

print("Shipment plan (units):")
header = "         " + "  ".join(f"Cust {j+1:1d}" for j in range(n_dem))
print(header)
for i in range(n_sup):
    row = f"Supplier {i+1}: " + "  ".join(
        f"{pulp.value(x[i][j]):6.0f}" for j in range(n_dem)
    )
    print(row)
Status     : Optimal
Total cost : $2,550

Shipment plan (units):
         Cust 1  Cust 2  Cust 3  Cust 4
Supplier 1:    250       0      50       0
Supplier 2:      0       0     200     200
Supplier 3:      0     350     150       0

8.8 Project Scheduling: CPM

The Critical Path Method (CPM) applies shortest/longest path algorithms to project scheduling. Activities are represented as edges (or nodes), with durations as weights. The critical path is the longest path through the network — it determines the minimum project duration.

Any delay on the critical path delays the entire project. Activities not on the critical path have float (slack) — they can be delayed without affecting the project deadline.

Code
import networkx as nx
import matplotlib.pyplot as plt

# Activities: (predecessor, successor, duration)
activities = [
    ("START", "A", 0), ("START", "B", 0), ("START", "C", 0),
    ("A", "D", 3), ("A", "E", 3),
    ("B", "E", 5), ("B", "F", 5),
    ("C", "G", 2),
    ("D", "END", 4), ("E", "END", 6), ("F", "END", 2), ("G", "END", 7)
]

G = nx.DiGraph()
for pred, succ, dur in activities:
    G.add_edge(pred, succ, duration=dur)

# Longest path = critical path
critical_length = nx.dag_longest_path_length(G, weight="duration")
critical_path   = nx.dag_longest_path(G, weight="duration")

print(f"Project duration : {critical_length} days")
print(f"Critical path    : {' → '.join(critical_path)}")

# Early start times via topological sort
topo_order  = list(nx.topological_sort(G))
early_start = {node: 0 for node in G.nodes()}
for node in topo_order:
    for succ in G.successors(node):
        dur = G[node][succ]["duration"]
        early_start[succ] = max(early_start[succ], early_start[node] + dur)

print("\nEarly start times:")
for node, es in sorted(early_start.items(), key=lambda x: x[1]):
    print(f"  {node:6s}: day {es}")
Project duration : 11 days
Critical path    : START → B → E → END

Early start times:
  START : day 0
  A     : day 0
  B     : day 0
  C     : day 0
  G     : day 2
  D     : day 3
  E     : day 5
  F     : day 5
  END   : day 11

The critical path tells a project manager exactly where to focus resources. Crashing (accelerating) a non-critical activity wastes money; crashing a critical activity directly reduces project duration.


8.9 Choosing the Right Algorithm

Problem Algorithm Complexity
Single-source shortest path (non-negative weights) Dijkstra \(O((V+E)\log V)\)
Single-source shortest path (negative weights) Bellman-Ford \(O(VE)\)
All-pairs shortest path Floyd-Warshall \(O(V^3)\)
Minimum spanning tree Kruskal / Prim \(O(E \log E)\)
Maximum flow Ford-Fulkerson / Push-relabel \(O(VE^2)\)
Minimum cost flow Network Simplex \(O(VE \log V \log(VC))\)
Transportation Simplex (network form) Fast in practice
Bipartite matching Hungarian algorithm \(O(V^3)\)

For most practical problems, networkx and pulp provide sufficient performance. For large-scale production systems (millions of nodes), Google OR-Tools’ network flow solver or Gurobi’s built-in network simplex are preferred.


8.10 Chapter Summary

  • Network Optimization models systems as graphs (nodes and edges) and finds optimal routes, flows, or spanning structures
  • Shortest path (Dijkstra, Bellman-Ford) solves routing and scheduling problems; the critical path in project management is the longest-path analogue
  • Minimum spanning tree (Kruskal, Prim) connects all nodes at minimum total edge cost — used in network design and cluster analysis
  • Maximum flow (Ford-Fulkerson) finds the throughput limit of a network; by the Max-Flow Min-Cut theorem, this equals the minimum cut capacity
  • Minimum cost flow is the most general network model, subsuming transportation, assignment, and shortest path as special cases
  • Python’s networkx provides graph data structures and classical algorithms; pulp solves formulated network problems as LP/MIP
  • Network algorithms exploit problem structure to achieve dramatically better performance than general LP solvers on the same problems