Beyond Basics: Efficiently Utilizing Arrays for Sorting, Searching, and Dynamic Memory Allocation

Posted on in programming

cover image for article

While arrays offer simple and efficient ways to store and access data, their true potential unfolds when you move beyond the basics. This article delves into advanced techniques for utilizing arrays in three key areas: sorting, searching, and dynamic memory allocation, with examples in Python, Rust, and Go.

Sorting Arrays:

Ordering elements is crucial for various tasks. Arrays offer diverse sorting algorithms, each with its own strengths and weaknesses.

1. Bubble Sort:

This simple algorithm repeatedly compares adjacent elements, swapping them if necessary until the array is sorted. While conceptually easy, it's inefficient for large arrays due to its quadratic time complexity (O(n^2)).

Python:

def bubble_sort(arr):
    for i in range(len(arr) - 1):
        for j in range(len(arr) - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]

unsorted_list = [64, 34, 25, 12, 22, 11, 90]
bubble_sort(unsorted_list)
print(unsorted_list)  # Output: [11, 12, 22, 25, 34, 64, 90]

Rust:

fn bubble_sort(arr: &mut [i32]) {
    for i in 0..arr.len() - 1 {
        for j in 0..arr.len() - i - 1 {
            if arr[j] > arr[j + 1] {
                arr.swap(j, j + 1);
            }
        }
    }
}

let mut unsorted_vec = vec![64, 34, 25, 12, 22, 11, 90];
bubble_sort(&mut unsorted_vec);
println!("{:?}", unsorted_vec);  // Output: [11, 12, 22, 25, 34, 64, 90]

Go:

func bubbleSort(arr []int) {
    for i := 0; i < len(arr)-1; i++ {
        for j := 0; j < len(arr)-i-1; j++ {
            if arr[j] > arr[j+1] {
                arr[j], arr[j+1] = arr[j+1], arr[j]
            }
        }
    }
}

unsortedSlice := []int{64, 34, 25, 12, 22, 11, 90}
bubbleSort(unsortedSlice)
fmt.Println(unsortedSlice)  // Output: [11 12 22 25 34 64 90]

2. Merge Sort:

This divide-and-conquer algorithm recursively splits the array into halves, sorts them independently, and then merges them back into a sorted array. It boasts a better time complexity of O(n log n), making it more efficient for larger datasets.

Python:

def merge_sort(arr):
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])

    return merge(left, right)

def merge(left, right):
    merged = []
    while left and right:
        if left[0] <= right[0]:
            merged.append(left.pop(0))
        else:
            merged.append(right.pop(0))
    merged += left + right
    return merged

unsorted_list = [64, 34, 25, 12, 22, 11, 90]
sorted_list = merge_sort(unsorted_list)
print(sorted_list)  # Output: [11, 12, 22, 25, 34, 64, 90]

Rust:

fn merge_sort(arr: &mut [i32]) {
    if arr.len() <= 1 {
        return;
    }

    let mid = arr.len() / 2;
    let mut left = arr[..mid].to_vec();
    let mut right = arr[mid..].to_vec();

    merge_sort(&mut left);
    merge_sort(&mut right);

    merge(arr, &mut left, &mut right);
}

fn merge(arr: &mut [i32], left: &mut [i32], right: &mut [i32]) {
    let mut i = 0;
    let mut j = 0;
    let mut k = 0;

    while i < left.len() && j < right.len() {
        if left[i] <= right[j] {
            arr[k] = left[i];
            i += 1;
        } else {
            arr[k] = right[j];
            j += 1;
        }
        k += 1;
    }

    while i < left.len() {
        arr[k] = left[i];
        i += 1;
        k += 1;
    }

    while j < right.len() {
        arr[k] = right[j];
        j += 1;
        k += 1;
    }
}

let mut unsorted_vec = vec![64, 34, 25, 12, 22, 11, 90];
merge_sort(&mut unsorted_vec);
println!("{:?}", unsorted_vec);  // Output: [11, 12, 22, 25, 34, 64, 90]

Go:

func mergeSort(arr []int) {
    if len(arr) <= 1 {
        return
    }

    mid := len(arr) / 2
    left := arr[:mid]
    right := arr[mid:]

    mergeSort(left)
    mergeSort(right)

    merge(arr, left, right)
}

func merge(arr []int, left []int, right []int) {
    i, j, k := 0, 0, 0

    for i < len(left) && j < len(right) {
        if left[i] <= right[j] {
            arr[k] = left[i]
            i++
        } else {
            arr[k] = right[j]
            j++
        }
        k++
    }

    for i < len(left) {
        arr[k] = left[i]
        i++
        k++
    }

    for j < len(right) {
        arr[k] = right[j]
        j++
        k++
    }
}

unsortedSlice := []int{64, 34, 25, 12, 22, 11, 90}
mergeSort(unsortedSlice)
fmt.Println(unsortedSlice)  // Output: [11 12 22 25 34 64 90]

3. Quick Sort:

This randomized algorithm selects a pivot element, partitions the array based on it, and sorts the sub-arrays recursively. Its average time complexity is O(n log n), but it can have worse-case performance in specific scenarios.

Python:

def partition(arr, low, high):
    pivot = arr[high]
    i = (low - 1)  # index of smaller element
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

def quick_sort(arr, low, high):
    if low < high:
        pi = partition(arr, low, high)
        quick_sort(arr, low, pi - 1)
        quick_sort(arr, pi + 1, high)

unsorted_list = [64, 34, 25, 12, 22, 11, 90]
quick_sort(unsorted_list, 0, len(unsorted_list) - 1)
print(unsorted_list)  # Output: [11, 12, 22, 25, 34, 64, 90]

Rust:

fn partition(arr: &mut [i32], low: usize, high: usize) -> usize {
    let pivot = arr[high];
    let mut i = low - 1;
    for j in low..high {
        if arr[j] <= pivot {
            i += 1;
            arr.swap(i, j);
        }
    }
    arr.swap(i + 1, high);
    i + 1
}

fn quick_sort(arr: &mut [i32], low: usize, high: usize) {
    if low < high {
        let pi = partition(arr, low, high);
        quick_sort(arr, low, pi - 1);
        quick_sort(arr, pi + 1, high);
    }
}

let mut unsorted_vec = vec![64, 34, 25, 12, 22, 11, 90];
quick_sort(&mut unsorted_vec, 0, unsorted_vec.len() - 1);
println!("{:?}", unsorted_vec);  // Output: [11, 12, 22, 25, 34, 64, 90]

Go:

func partition(arr []int, low int, high int) int {
    pivot := arr[high]
    i := low - 1
    for j := low; j < high; j++ {
        if arr[j] <= pivot {
            i++
            arr[i], arr[j] = arr[j], arr[i]
        }
    }
    arr[i+1], arr[high] = arr[high], arr[i+1]
    return i + 1
}

func quickSort(arr []int, low int, high int) {
    if low < high {
        pi := partition(arr, low, high)
        quickSort(arr, low, pi-1)
        quickSort(arr, pi+1, high)
    }
}

unsortedSlice := []int{64, 34, 25, 12, 22, 11, 90}
quickSort(unsortedSlice, 0, len(unsortedSlice)-1)
fmt.Println(unsortedSlice)  // Output: [11 12 22 25 34 64 90]

Searching Arrays:

Finding specific elements within an array is another crucial task. Two common approaches are:

1. Linear Search:

This simple method iterates through each element, comparing it to the target value. While straightforward, it has a linear time complexity (O(n)) and can be slow for large arrays.

Python:

def linear_search(arr, x):
    for i in range(len(arr)):
        if arr[i] == x:
            return i  # Return the index of the found element
    return -1  # Return -1 if the element is not found

my_array = [12, 34, 56, 78, 90]
result = linear_search(my_array, 56)
print(result)  # Output: 2

Rust:

fn linear_search(arr: &[i32], x: i32) -> Option<usize> {
    for (i, value) in arr.iter().enumerate() {
        if value == &x {
            return Some(i);
        }
    }
    None
}

let my_vec = vec![12, 34, 56, 78, 90];
match linear_search(&my_vec, 56) {
    Some(i) => println!("Element found at index {}", i),
    None => println!("Element not found"),
}

Go:

func linearSearch(arr []int, x int) int {
    for i := 0; i < len(arr); i++ {
        if arr[i] == x {
            return i  // Return the index of the found element
        }
    }
    return -1  // Return -1 if the element is not found
}

mySlice := []int{12, 34, 56, 78, 90}
result := linearSearch(mySlice, 56)
fmt.Println(result)  // Output: 2

If you need faster searching for larger sorted arrays, consider binary search instead.

2. Binary Search:

This method leverages the sorted nature of the array, repeatedly dividing the search space in half until the target element is found. Its time complexity is logarithmic (O(log n)), making it significantly faster for large sorted arrays.

Python:

def binary_search(arr, x):
    low = 0
    high = len(arr) - 1
    mid = 0

    while low <= high:
        mid = (low + high) // 2

        # Check if x is present at mid
        if arr[mid] == x:
            return mid

        # If x is greater, ignore left half
        elif arr[mid] < x:
            low = mid + 1

        # If x is smaller, ignore right half
        else:
            high = mid - 1

    # If we reach here, then the element was not present
    return -1

unsorted_list = [12, 34, 56, 78, 90]
sorted_list = sorted(unsorted_list)  # Sort the list first!
position = binary_search(sorted_list, 56)
print("Element is present at index", position)  # Output: Element is present at index 2

Rust:

fn binary_search(arr: &[i32], x: i32) -> Option<usize> {
    let mut low = 0;
    let mut high = arr.len() - 1;
    let mut mid: usize;

    while low <= high {
        mid = (low + high) / 2;

        if arr[mid] == x {
            return Some(mid);
        } else if arr[mid] < x {
            low = mid + 1;
        } else {
            high = mid - 1;
        }
    }

    None
}

let unsorted_vec = vec![12, 34, 56, 78, 90];
let sorted_vec = unsorted_vec.clone().into_iter().sorted().collect::<Vec<_>>();
match binary_search(&sorted_vec, 56) {
    Some(position) => println!("Element is present at index {}", position),
    None => println!("Element not found"),
}

Go:

func binarySearch(arr []int, x int) int {
    low := 0
    high := len(arr) - 1
    mid := 0

    for low <= high {
        mid = (low + high) / 2

        if arr[mid] == x {
            return mid
        } else if arr[mid] < x {
            low = mid + 1
        } else {
            high = mid - 1
        }
    }

    return -1
}

unsortedSlice := []int{12, 34, 56, 78, 90}
sortedSlice := append([]int{}, unsortedSlice...)
sort.Ints(sortedSlice)
position := binarySearch(sortedSlice, 56)
fmt.Println("Element is present at index", position)  // Output: Element is present at index 2

Dynamic Memory Allocation:

Arrays in most languages have a fixed size at creation. However, dynamic memory allocation allows you to grow or shrink the array size as needed during runtime. Here's an example in Python, Rust, and Go:

Python:

import array

# Create an array with initial size 5
my_array = array.array('i', [1, 2, 3, 4, 5])

# Append a new element
my_array.append(6)

# Print the updated array
print(my_array)  # Output: array('i', [1, 2, 3, 4, 5, 6])

Rust:

use std::mem;

fn main() {
    let mut my_vec = vec![1, 2, 3, 4, 5];

    // Before pushing
    println!("Capacity before: {}", my_vec.capacity());

    // Append a new element
    my_vec.push(6);

    // After pushing
    println!("Capacity after: {}", my_vec.capacity());

    // Check if reallocation happened
    if my_vec.capacity() != my_vec.len() {
        println!("Reallocation occurred!");
    } else {
        println!("No reallocation happened.");
    }
}

Go:

package main

import (
    "fmt"
)

func main() {
    mySlice := []int{1, 2, 3, 4, 5}

    // Before appending
    fmt.Println("Capacity before:", cap(mySlice))

    // Append a new element
    mySlice = append(mySlice, 6)

    // After appending
    fmt.Println("Capacity after:", cap(mySlice))

    // Check if reallocation happened
    if cap(mySlice) != len(mySlice) {
        fmt.Println("Reallocation occurred!")
    } else {
        fmt.Println("No reallocation happened.")
    }
}

As you can see, dynamic memory allocation allows for flexible array manipulation during program execution. However, it's essential to consider trade-offs:

  • Performance: Dynamic allocation might involve memory management overhead compared to fixed-size arrays.
  • Memory fragmentation: Repeated allocations and deallocations can fragment memory, reducing efficiency.

Conclusion:

Arrays offer a powerful and versatile data structure for storing and manipulating elements. By understanding their strengths, limitations, and advanced techniques like sorting, searching, and dynamic memory allocation, you can leverage them effectively in various programming scenarios. Remember to carefully select algorithms and approaches based on your specific needs and data characteristics.

Part 5 of the Data Structures series

My Bookshelf

Reading Now

Other Stuff