11 min read

Knapsack Problem

Dynamic programming and the Knapsack problem in optimizing logistics for efficient, priority-aware package delivery.
A SparkFleet mini truck efficiently delivering packages

Exploring Real-World Problems through Algorithms

This is going to be an unconventional journey through the world of algorithms and their practical applications in software development. Unlike traditional, sequential learning paths, this series of articles takes a different route. Here, we explore various common problems, each acting as a window into the world of algorithmic solutions. These problems are not just hypothetical scenarios; they are real challenges I have encountered in my career, offering a unique opportunity to delve into practical problem-solving.

The aim is not to create a structured educational series but to present a tapestry of real-world issues and their resolutions through the lens of different algorithms. This approach provides a hands-on learning experience, demonstrating the versatility and necessity of algorithmic thinking in everyday software development.

Introducing SparkFleet

In our first exploration, we turn our attention to SparkFleet, an innovative startup changing the face of delivery services. SparkFleet's business model is built around the use of small electric trucks, designed for efficient and environmentally friendly package delivery. This modern approach to logistics addresses the growing concerns of urban transport and carbon emissions and presents unique challenges in optimizing delivery operations.

Why Electric Mini Trucks?

SparkFleet's choice of electric mini trucks is a testament to their commitment to sustainable logistics. These vehicles offer the perfect blend of maneuverability for city streets and reduced environmental impact. However, their smaller size poses a significant challenge in maximizing delivery efficiency; how to best load these trucks with packages of varying sizes to complete a fixed route effectively?

The Challenge

This is where our journey into algorithms begins. The challenge SparkFleet faces is reminiscent of the Knapsack problem, a classic conundrum in optimization. How can we utilize algorithmic strategies to ensure that SparkFleet's trucks are loaded in the most space-efficient manner, accommodating the greatest number of packages without compromising on their fixed routes? This real-world application serves as a perfect backdrop to explore the practicality and ingenuity of algorithms in modern business solutions.

Optimizing Package Delivery

In the fast-paced world of logistics and delivery services, efficiency is king. SparkFleet, with its fleet of small electric trucks, faces a unique challenge in this realm. The primary objective is to maximize the number of deliveries within a fixed route, all while contending with the limited space available in these compact vehicles. This scenario presents an intricate puzzle of spatial optimization, one that is emblematic of the Knapsack problem, albeit with a modern twist.

Package Sizes and Constraints
At the heart of SparkFleet's challenge are the packages, varying not just in weight but more crucially in size. Our focus is on four distinct package sizes:

  • Size A: Max dimensions 25 × 30 × 15 cm
  • Size B: Max dimensions 35 × 40 × 25 cm
  • Size C: Max dimensions 45 × 50 × 35 cm

Given these dimensions, SparkFleet's electric trucks, with a capacity of 2.5 cubic meters, can accommodate approximately 222 Size A packages, 71 Size B packages, and 31 Size C packages. This distribution highlights the efficient use of space within the trucks, adapting to the varying dimensions of packages typically encountered in delivery scenarios, where a mix of small to large items is common.

Volume and Priority Optimization:
In SparkFleet's logistics model, volume and priority of deliveries are key considerations. While the weight limit is rarely a constraint, allowing a focus on volume optimization, the priority of each package is also a critical factor. This dual focus ensures that both space efficiency and urgency of deliveries are addressed.

Optimization Strategy for Delivery
The strategy involves selecting an optimal combination of packages for each delivery run, balancing both volume and priority. Given a pool of deliveries at the start of a time window, the algorithm determines the most efficient packing arrangement. This approach ensures urgent deliveries are prioritized without compromising the overall volume of packages, thus maximizing the efficiency of each truck's capacity.

Application of the Knapsack Problem with Priority
This scenario is akin to the classic Knapsack problem, where each truck represents a 'knapsack' with a fixed volume 'capacity,' and each package type is an 'item' with a specific 'size' and 'priority'. The challenge is to pack the 'knapsack' (truck) in such a way that maximizes the 'value' (number and priority of deliveries) within the 'weight' (volume) constraints of the truck.

Implementing the Knapsack Algorithm in Go

In addressing SparkFleet's challenge of optimizing package delivery, we turn to a classic algorithm in computer science: the Knapsack problem. This section will guide you through implementing a variant of this algorithm in Go, specifically tailored to efficiently load packages of various sizes into SparkFleet's electric trucks.

Understanding the Problem in Algorithmic Terms

Before we explore the algorithmic solution, it's essential to understand SparkFleet's challenge within the framework of a modified Knapsack problem:

  • The Truck as a Knapsack: Here, our 'knapsack' is the truck's cargo space, limited in volume.
  • Packages as Items with Priority: Each package type represents an 'item,' characterized by its specific volume and delivery priority. This adds layer of complexity to the selection process.
  • Objective: The goal is to maximize the number of packages (items) in each truck (knapsack) while prioritizing urgent deliveries, all without exceeding the truck's volume capacity.

This approach not only focuses on the efficient utilization of space but also on meeting the time-sensitive requirements of certain deliveries, ensuring a balance between volume optimization and delivery priority."

Structuring the Data in Go

The first step is to define the data structures representing packages and the truck:

type Package struct {
    Width, Height, Depth, Weight, Priority int
}

func (p Package) Volume() int {
    return p.Width * p.Height * p.Depth
}

// Package sizes with weight
var small = Package{Width: 25, Height: 30, Depth: 15, Weight: 10_000, Priority: 2}
var medium = Package{Width: 35, Height: 40, Depth: 25, Weight: 15_000, Priority: 1} 
var large = Package{Width: 45, Height: 50, Depth: 35, Weight: 20_000, Priority: 1} 

:

Dynamic Programming Approach

We'll use dynamic programming, an efficient method for solving optimization problems like Knapsack.

Dynamic programming is a method used in computer science and mathematics to solve complex problems by breaking them down into simpler subproblems. It is particularly effective for optimization problems where the solution involves making a series of choices to achieve the best outcome. The key principle of dynamic programming is to store the results of these subproblems, often in a table, to avoid redundant calculations. This approach ensures efficiency, especially in problems where the same subproblem occurs multiple times. Dynamic programming is thus ideal for situations where an optimal solution is composed of optimal solutions to its subparts, leading to significant time and resource savings compared to brute-force methods.

func MaxPackages(packages []Package, truckVol int) int {
	dp := make([][2]int, truckVol+1)
    
	for i := range dp {
		dp[i][0] = math.MaxInt32 
	}
    
	dp[0] = [2]int{0, 0}

	for _, pkg := range packages {
		pkgVol := pkg.Volume()

		for j := truckVol; j >= pkgVol; j-- {
			if dp[j-pkgVol][1]+1 > dp[j][1] &&
				dp[j-pkgVol][0]+pkg.Priority < dp[j][0]+10 {
				dp[j][0] = dp[j-pkgVol][0] + pkg.Priority
				dp[j][1] = dp[j-pkgVol][1] + 1
			}
		}
	}

	return dp[truckVol][1]
}

The MaxPackages function is a dynamic programming solution to a variation of the Knapsack problem, tailored for logistics optimization. It aims to maximize the number of packages loaded into a delivery truck while considering the priority of each package. The function takes two parameters: a slice of Package structs and an integer representing the truck's volume capacity (truckVol).

The Knapsack problem is a classic example of a combinatorial optimization problem, commonly addressed using dynamic programming. It involves a scenario where you have a set of items, each with a weight and a value, and a knapsack with a limited weight capacity. The goal is to determine the most valuable combination of items to include in the knapsack without exceeding its weight limit. This problem models many real-world scenarios where resources are limited and choices must be made to maximize a particular value. In the context of logistics, for example, the 'items' can be packages with volumes and priorities, and the 'knapsack' is the cargo space of a delivery truck. The challenge is to maximize the number or priority of packages while adhering to the volume constraints of the truck.

Dynamic Programming Table Initialization

We begin by initializing a two-dimensional slice (dp). Each element of this slice is a pair (array of two integers), where the first integer represents the cumulative priority sum of packages, and the second integer represents the count of packages.

The priority sum is initialized to math.MaxInt32, a large number, to represent an initially high (worst-case) priority sum.

Base Case

The base case (dp[0]) is set to [2]int{0, 0}, indicating that no packages are loaded, resulting in a priority sum of zero.

Iterating Over Packages

The function iterates over each package. For each package, it calculates the package volume (pkgVol).

It then iterates backward through the dp slice from the truck's volume down to the package volume. This reverse iteration ensures that each package is considered for every possible volume up to the truck's capacity.

Updating the DP Table

For each volume, the function checks if adding the current package improves the total count of packages without excessively worsening the priority sum.

The condition dp[j-pkgVol][1]+1 > dp[j][1] && dp[j-pkgVol][0]+pkg.Priority < dp[j][0]+10 ensures that a package is added only if it increases the total count and does not significantly worsen the priority sum.

If these conditions are met, the DP table is updated to reflect the new count and priority sum for that volume.

Finally, we return the count of packages for the full volume of the truck (dp[truckVol][1]), representing the maximum number of packages that can be loaded while optimizing for priority.

Upgrading for Detailed Package Distribution

We're taking a step forward in refining our algorithm for SparkFleet's logistics. The goal now is to extract more meaningful data from our delivery operations. Instead of simply calculating the total number of packages for each truck, we're upgrading the code to detail the quantity of each package type in the delivery. This enhancement will provide a clearer picture of our load distribution, enabling us to optimize delivery routes and manage logistics more effectively. Let's explore how this improved algorithm offers a deeper insight into our delivery strategy, balancing volume with the specifics of each package type.

type PackageSize struct {
	Width, Height, Depth int
}

func (ps PackageSize) Volume() int {
	return ps.Width * ps.Height * ps.Depth
}

type Package struct {
	ID       int
	Size     PackageSize
	Weight   int
	Priority int
	Type     string
}

type PackageInfo struct {
	ID     int
	Type   string
	Volume int
}

// Predefined package sizes
var (
	SizeA = PackageSize{Width: 25, Height: 30, Depth: 15}
	SizeB = PackageSize{Width: 35, Height: 40, Depth: 25}
	SizeC = PackageSize{Width: 45, Height: 50, Depth: 35}
)


Previously, the Package struct was a flat structure with fields for dimensions (Width, Height, Depth), Weight, and Priority. The new approach introduces the PackageSize struct, which encapsulates the dimensional attributes.

The PackageSize struct is a new addition, specifically designed to manage package dimensions. The associated Volume method, now part of PackageSize, offers a focused and reusable way to calculate package volume, a shift from the previous direct method of the Package struct.

The Package struct has been expanded to include a Size field and a Type string field. The Size field's introduction streamlines dimension management and the Type field adds a new dimension for categorizing packages.

The introduction of the PackageInfo struct offers a simplified view of package data, primarily for reporting or analysis purposes.

func MaxPackages(packages []Package, truckVol int) []PackageInfo {
	dp := make([][2]int, truckVol+1)
	lastAdded := make([]int, truckVol+1)

	for i := range dp {
		dp[i][0] = math.MaxInt32
		lastAdded[i] = -1
	}
	dp[0] = [2]int{0, 0}

	for i, pkg := range packages {
		pkgVol := pkg.Size.Volume()

		for j := truckVol; j >= pkgVol; j-- {
			if dp[j-pkgVol][1]+1 > dp[j][1] && dp[j-pkgVol][0]+pkg.Priority < dp[j][0]+10 {
				dp[j][0] = dp[j-pkgVol][0] + pkg.Priority
				dp[j][1] = dp[j-pkgVol][1] + 1
				lastAdded[j] = i
			}
		}
	}

	var selected []PackageInfo
	for vol := truckVol; vol > 0; {
		if lastAdded[vol] == -1 {
			break
		}

		pkgIndex := lastAdded[vol]
		pkg := packages[pkgIndex]

		selected = append([]PackageInfo{{
			ID:     pkg.ID,
			Type:   pkg.Type,
			Volume: pkg.Size.Volume(),
		}}, selected...)

		vol -= packages[pkgIndex].Size.Volume()
	}

	return selected
}

The new code modifies the MaxPackages function to return a slice of PackageInfo instead of just an integer count of packages. This change provides more granular information about the specific packages chosen for the delivery, including their ID, type, and volume.

Also, a new slice, lastAdded, has been introduced. This slice tracks the index of the last package added to the truck at each volume level. This allows for backtracking to determine which packages have been selected.

Finally, the function now reconstructs the list of selected packages at the end of the computation. It iterates backward through the lastAdded slice to accumulate the PackageInfo of each package included in the optimal solution. This step lets us identify not just the number but also the specifics of each package that fits within the truck's volume capacity.

func main() {
	fmt.Println("Knapsack Problem")

	testPackages := sampleList()
	truckVolume := 2500000 // 2.5 cubic meters in cubic cm

	selectedPackages := MaxPackages(testPackages, truckVolume)

	Summarize(selectedPackages)
}

// ...

func Summarize(packages []PackageInfo) {
	totalCount := len(packages)
	typeCount := make(map[string]int)
	totalVolume := 0

	for _, pkg := range packages {
		typeCount[pkg.Type]++
		totalVolume += pkg.Volume
	}

	fmt.Printf("Type A: %d\n", typeCount["A"])
	fmt.Printf("Type B: %d\n", typeCount["B"])
	fmt.Printf("Type C: %d\n", typeCount["C"])
	fmt.Printf("Total : %d\n", totalCount)
	fmt.Printf("Volume: %d\n", totalVolume)
}

func sampleList() []Package {
	var packages []Package
	idCounter := 0

	rand.Seed(time.Now().UnixNano())

	for i := 0; i < 10; i++ {
		priorityA := rand.Intn(3) + 1
		priorityB := rand.Intn(3) + 1
		priorityC := rand.Intn(3) + 1

		packages = append(packages, Package{ID: idCounter, Type: "A", Size: SizeA, Priority: priorityA})
		idCounter++
		packages = append(packages, Package{ID: idCounter, Type: "B", Size: SizeB, Priority: priorityB})
		idCounter++
		packages = append(packages, Package{ID: idCounter, Type: "C", Size: SizeC, Priority: priorityC})
		idCounter++
	}

	return packages
}

The Summarize function, which is called after selecting the packages usingMaxPackages, now offers a comprehensive overview of the selected packages. It breaks down the total count of packages by their types (A, B, C) and calculates the total volume of these packages.

The main function remains largely the same, illustrating the process flow where a list of packages is created using sampleList, the optimal selection is made with MaxPackages, and the results are summarized using Summarize.

The sampleList function plays a crucial role in generating a test dataset of packages. It creates a randomized list of packages of types A, B, and C, each with varying priorities. This function simulates a realistic scenario where a diverse range of packages is available for selection, which is essential for testing the effectiveness of the MaxPackages function in different scenarios.

Running the Code

$ go run main.go
Knapsack Problem
Type A: 13
Type B: 11
Type C: 25
Total Qty : 49
Toal Volume: 2500000

Analysis of Complexity

Time

The Knapsack algorithm implemented using dynamic programming has a time complexity of O(nW), where 'n' is the number of package types and 'W' is the volume capacity of the truck. This means the time to calculate the optimal loading configuration increases with the number of package types and the truck's volume capacity.

Space

Similarly, the space complexity is O(nW) due to the storage requirements for the dynamic programming table. This can become significant in scenarios with a large variety of package types or large truck capacities.

Practical Implications

While the algorithm is efficient for a moderate number of packages and truck volume, its performance might degrade as these numbers grow, potentially slowing down the decision-making process in real-time operations. The computational resources required for running the algorithm, especially for a large fleet, must be balanced against the gains in delivery efficiency and operational costs.

Potential Challenges and Scalability

As SparkFleet expands, adding more package types or increasing truck capacities, the algorithm must scale efficiently. This might involve optimizing the existing algorithm or exploring alternative approaches if the computational load becomes a bottleneck.

The algorithm needs to be flexible to accommodate changing conditions like varying package sizes, emergency delivery requests, or last-minute route changes. Implementing the solution in a dynamic, real-time operational environment poses challenges. It requires the algorithm to be responsive and adaptable to immediate changes in the delivery schedule or route alterations.

Scalability Solutions

Techniques like memoization or iterative implementations can optimize resource usage. Combining the Knapsack algorithm with heuristic methods might offer better performance in large-scale or dynamic scenarios. Also, incorporating real-time traffic data, weather conditions, and other logistical variables can enhance the algorithm's applicability and responsiveness in practical scenarios.

Route Optimization and Efficiency

Having tackled the challenge of efficiently loading SparkFleet's electric trucks, an equally critical aspect of their operation comes into focus: route optimization. While this article has concentrated on maximizing the number of packages per truck, the efficiency of SparkFleet's service also heavily depends on how these packages are delivered, specifically, the routes taken and the order of deliveries.

The sequence in which deliveries are made can significantly impact the overall time and efficiency of SparkFleet's operation. Optimizing routes to minimize travel distance and time is not just a logistical necessity but a strategic imperative. Efficiently planned routes ensure timely deliveries and higher customer satisfaction, all while maintaining the sustainability ethos of using electric vehicles.

References